cry-synced-db-client 0.1.29 → 0.1.31

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.
@@ -39,9 +39,8 @@ export declare class DexieDb extends Dexie implements I_DexieDb {
39
39
  deleteSyncMeta(collection: string): Promise<void>;
40
40
  getTenant(): string;
41
41
  getInstanceId(): string;
42
- getNewerThan<T extends LocalDbEntity>(collection: string, timestamp: any): Promise<T[]>;
42
+ getNewerThan<T extends LocalDbEntity>(collection: string, rev: number): Promise<T[]>;
43
43
  onMetaUpdated(callback: (payload: MetaUpdateBroadcast) => void): () => void;
44
- private normalizeTimestamp;
45
44
  /** Close the broadcast channel when done */
46
45
  closeBroadcastChannel(): void;
47
46
  }
@@ -1,5 +1,5 @@
1
1
  import type { I_ServerUpdateNotifier, ServerUpdateCallback, ServerUpdateNotifierCallbacks } from "../types/I_ServerUpdateNotifier";
2
- import type { PublishRevsPayload } from "../types/PublishRevsPayload";
2
+ import type { PublishDataPayload } from "../types/PublishRevsPayload";
3
3
  export interface Ebus2ProxyServerUpdateNotifierConfig {
4
4
  /** WebSocket URL (e.g., "ws://localhost:3033/ws") */
5
5
  wsUrl: string;
@@ -24,7 +24,7 @@ export interface Ebus2ProxyServerUpdateNotifierConfig {
24
24
  /** Called when WebSocket reconnects */
25
25
  onWsReconnect?: (attempt: number) => void;
26
26
  /** Called when a notification is received */
27
- onWsNotification?: (payload: PublishRevsPayload) => void;
27
+ onWsNotification?: (payload: PublishDataPayload) => void;
28
28
  }
29
29
  export declare class Ebus2ProxyServerUpdateNotifier implements I_ServerUpdateNotifier {
30
30
  private wsUrl;
@@ -60,6 +60,8 @@ 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;
63
65
  /** Unsubscribe function for cross-tab meta updates */
64
66
  private unsubscribeMetaUpdates?;
65
67
  /** Whether this instance is the leader tab */
@@ -199,5 +201,19 @@ export declare class SyncedDb implements I_SyncedDb {
199
201
  private handleServerUpdate;
200
202
  private handleServerItemUpdate;
201
203
  private getNewFieldsFromServer;
204
+ /**
205
+ * Merge local dirty item with server delta.
206
+ *
207
+ * Delta updates only contain fields that were ACTUALLY changed on the server.
208
+ * We apply ALL delta fields because:
209
+ * - Deltas represent specific server-side changes
210
+ * - Local dirty status indicates the object has changes, but not WHICH fields
211
+ * - Server delta fields are by definition different from what client had
212
+ * - Local dirty changes will be sent on next sync and can overwrite if needed
213
+ *
214
+ * This approach ensures real-time updates are reflected immediately while
215
+ * local dirty changes are preserved in Dexie for sync.
216
+ */
217
+ private mergeLocalWithDelta;
202
218
  private handleServerItemDelete;
203
219
  }
package/dist/index.js CHANGED
@@ -306,6 +306,7 @@ class SyncedDb {
306
306
  updaterId;
307
307
  syncedDbInstanceId;
308
308
  syncMetaCache = new Map;
309
+ lastSeenRevCache = new Map;
309
310
  unsubscribeMetaUpdates;
310
311
  isLeader = false;
311
312
  leaderLockAbortController;
@@ -1528,51 +1529,45 @@ class SyncedDb {
1528
1529
  }
1529
1530
  }
1530
1531
  async handleCrossTabMetaUpdate(payload) {
1531
- if (payload.instanceId === this.syncedDbInstanceId) {
1532
+ if (payload.instanceId === this.dexieDb.getInstanceId()) {
1532
1533
  return;
1533
1534
  }
1534
1535
  const collection = payload.collection;
1535
1536
  if (!this.collections.has(collection)) {
1536
1537
  return;
1537
1538
  }
1538
- const cachedMeta = this.syncMetaCache.get(collection);
1539
- const cachedTs = this.normalizeTimestamp(cachedMeta?.lastSyncTs);
1540
- const receivedTs = this.normalizeTimestamp(payload.lastSyncTs);
1541
- if (receivedTs > cachedTs) {
1542
- try {
1543
- const newMeta = await this.dexieDb.getSyncMeta(collection);
1544
- if (newMeta) {
1545
- this.syncMetaCache.set(collection, newMeta);
1546
- }
1547
- const newerItems = await this.dexieDb.getNewerThan(collection, cachedTs);
1548
- let newItemsCount = 0;
1549
- for (const item of newerItems) {
1550
- if (item._deleted) {
1551
- this.inMemDb.deleteOne(collection, item._id);
1552
- } else {
1553
- this.inMemDb.save(collection, item._id, item);
1554
- }
1555
- newItemsCount++;
1539
+ const lastSeenRev = this.lastSeenRevCache.get(collection) ?? 0;
1540
+ try {
1541
+ const newMeta = await this.dexieDb.getSyncMeta(collection);
1542
+ if (newMeta) {
1543
+ this.syncMetaCache.set(collection, newMeta);
1544
+ }
1545
+ const newerItems = await this.dexieDb.getNewerThan(collection, lastSeenRev);
1546
+ let newItemsCount = 0;
1547
+ let maxRev = lastSeenRev;
1548
+ for (const item of newerItems) {
1549
+ if (item._rev !== undefined && item._rev > maxRev) {
1550
+ maxRev = item._rev;
1556
1551
  }
1557
- if (this.onExternalSync) {
1558
- try {
1559
- this.onExternalSync(collection, newItemsCount);
1560
- } catch (err) {
1561
- console.error("onExternalSync callback failed:", err);
1562
- }
1552
+ if (item._deleted) {
1553
+ this.inMemDb.deleteOne(collection, item._id);
1554
+ } else {
1555
+ this.inMemDb.save(collection, item._id, this.stripLocalFields(item));
1563
1556
  }
1564
- } catch (err) {
1565
- console.error(`Error handling cross-tab meta update for ${collection}:`, err);
1557
+ newItemsCount++;
1558
+ }
1559
+ if (maxRev > lastSeenRev) {
1560
+ this.lastSeenRevCache.set(collection, maxRev);
1566
1561
  }
1567
- } else if (receivedTs < cachedTs) {
1568
- console.warn(`Cross-tab sync: Received stale metadata for ${collection}. ` + `In-memory: ${cachedTs}, Received: ${receivedTs}`);
1569
- if (this.onStaleMetaConflict) {
1562
+ if (this.onExternalSync) {
1570
1563
  try {
1571
- this.onStaleMetaConflict(collection, cachedMeta?.lastSyncTs, payload.lastSyncTs);
1564
+ this.onExternalSync(collection, newItemsCount);
1572
1565
  } catch (err) {
1573
- console.error("onStaleMetaConflict callback failed:", err);
1566
+ console.error("onExternalSync callback failed:", err);
1574
1567
  }
1575
1568
  }
1569
+ } catch (err) {
1570
+ console.error(`Error handling cross-tab meta update for ${collection}:`, err);
1576
1571
  }
1577
1572
  }
1578
1573
  async handleServerUpdate(payload) {
@@ -1585,22 +1580,8 @@ class SyncedDb {
1585
1580
  if (!this.collections.has(collectionName))
1586
1581
  return;
1587
1582
  switch (payload.operation) {
1588
- case "insert":
1589
- case "update": {
1590
- if (this.onFindNewerCall) {
1591
- try {
1592
- this.onFindNewerCall({
1593
- collection: collectionName,
1594
- fromTimestamp: 0,
1595
- query: { _id: payload._id },
1596
- timestamp: new Date
1597
- });
1598
- } catch (err) {
1599
- console.error("onFindNewerCall callback failed:", err);
1600
- }
1601
- }
1602
- const items = await this.restInterface.findNewer(collectionName, 0, { _id: payload._id });
1603
- const serverItem = items[0];
1583
+ case "insert": {
1584
+ const serverItem = payload.data;
1604
1585
  if (serverItem) {
1605
1586
  await this.handleServerItemUpdate(collectionName, serverItem);
1606
1587
  if (serverItem._ts) {
@@ -1609,18 +1590,61 @@ class SyncedDb {
1609
1590
  }
1610
1591
  break;
1611
1592
  }
1612
- case "delete": {
1613
- await this.handleServerItemDelete(collectionName, payload._id);
1614
- await this.broadcastMetaUpdate(collectionName, Date.now());
1593
+ case "update": {
1594
+ const deltaData = payload.data;
1595
+ if (deltaData && deltaData._id) {
1596
+ const localItem = await this.dexieDb.getById(collectionName, deltaData._id);
1597
+ if (localItem) {
1598
+ await this.handleServerItemUpdate(collectionName, deltaData);
1599
+ if (deltaData._ts) {
1600
+ await this.broadcastMetaUpdate(collectionName, deltaData._ts);
1601
+ }
1602
+ } else {
1603
+ const items = await this.restInterface.findNewer(collectionName, 0, { _id: deltaData._id });
1604
+ const fullItem = items[0];
1605
+ if (fullItem) {
1606
+ await this.handleServerItemUpdate(collectionName, fullItem);
1607
+ if (fullItem._ts) {
1608
+ await this.broadcastMetaUpdate(collectionName, fullItem._ts);
1609
+ }
1610
+ }
1611
+ }
1612
+ }
1615
1613
  break;
1616
1614
  }
1617
- case "updateMany":
1618
- case "deleteMany": {
1619
- await this.syncSingleCollection(collectionName);
1615
+ case "delete": {
1616
+ const deleteData = payload.data;
1617
+ await this.handleServerItemDelete(collectionName, deleteData._id);
1618
+ await this.broadcastMetaUpdate(collectionName, Date.now());
1620
1619
  break;
1621
1620
  }
1622
1621
  case "batch": {
1623
- await this.syncSingleCollection(collectionName);
1622
+ for (const item of payload.data) {
1623
+ if (item.operation === "insert") {
1624
+ const serverItem = item.doc;
1625
+ if (serverItem) {
1626
+ await this.handleServerItemUpdate(collectionName, serverItem);
1627
+ }
1628
+ } else if (item.operation === "update") {
1629
+ const deltaData = item.doc;
1630
+ if (deltaData && deltaData._id) {
1631
+ const localItem = await this.dexieDb.getById(collectionName, deltaData._id);
1632
+ if (localItem) {
1633
+ await this.handleServerItemUpdate(collectionName, deltaData);
1634
+ } else {
1635
+ const items = await this.restInterface.findNewer(collectionName, 0, { _id: deltaData._id });
1636
+ const fullItem = items[0];
1637
+ if (fullItem) {
1638
+ await this.handleServerItemUpdate(collectionName, fullItem);
1639
+ }
1640
+ }
1641
+ }
1642
+ } else if (item.operation === "delete") {
1643
+ const deleteData = item.doc;
1644
+ await this.handleServerItemDelete(collectionName, deleteData._id);
1645
+ }
1646
+ }
1647
+ await this.broadcastMetaUpdate(collectionName, Date.now());
1624
1648
  break;
1625
1649
  }
1626
1650
  }
@@ -1633,10 +1657,9 @@ class SyncedDb {
1633
1657
  if (hasPendingChanges) {
1634
1658
  const currentMemItem = this.inMemDb.getById(collectionName, serverItem._id);
1635
1659
  if (currentMemItem) {
1636
- const config = this.collections.get(collectionName);
1637
- const resolved = this.resolveCollectionConflict(collectionName, config, currentMemItem, serverItem, "serverUpdate_pendingChanges");
1638
- if (!resolved._deleted) {
1639
- this.inMemDb.save(collectionName, serverItem._id, this.stripLocalFields(resolved));
1660
+ const merged = this.mergeLocalWithDelta(currentMemItem, serverItem);
1661
+ if (!merged._deleted) {
1662
+ this.inMemDb.save(collectionName, serverItem._id, this.stripLocalFields(merged));
1640
1663
  }
1641
1664
  pendingChange.data = {
1642
1665
  ...pendingChange.data,
@@ -1655,14 +1678,13 @@ class SyncedDb {
1655
1678
  return;
1656
1679
  }
1657
1680
  if (localItem._dirty) {
1658
- const config = this.collections.get(collectionName);
1659
- const resolved = this.resolveCollectionConflict(collectionName, config, localItem, serverItem, "serverUpdate_dirtyLocal");
1681
+ const merged = this.mergeLocalWithDelta(localItem, serverItem);
1660
1682
  await this.dexieDb.save(collectionName, serverItem._id, {
1661
- ...resolved,
1683
+ ...merged,
1662
1684
  _dirty: true
1663
1685
  });
1664
- if (!resolved._deleted) {
1665
- this.inMemDb.save(collectionName, serverItem._id, this.stripLocalFields(resolved));
1686
+ if (!merged._deleted) {
1687
+ this.inMemDb.save(collectionName, serverItem._id, this.stripLocalFields(merged));
1666
1688
  }
1667
1689
  } else {
1668
1690
  await this.dexieDb.save(collectionName, serverItem._id, {
@@ -1694,6 +1716,16 @@ class SyncedDb {
1694
1716
  }
1695
1717
  return newFields;
1696
1718
  }
1719
+ mergeLocalWithDelta(local, delta) {
1720
+ const result = { ...local };
1721
+ for (const key of Object.keys(delta)) {
1722
+ if (key === "_id" || key === "_dirty" || key === "_localChangedAt") {
1723
+ continue;
1724
+ }
1725
+ result[key] = delta[key];
1726
+ }
1727
+ return result;
1728
+ }
1697
1729
  async handleServerItemDelete(collectionName, id) {
1698
1730
  const localItem = await this.dexieDb.getById(collectionName, id);
1699
1731
  if (!localItem)
@@ -1731,7 +1763,7 @@ class DexieDb extends Dexie {
1731
1763
  schema[SYNC_META_TABLE] = "[tenant+collection]";
1732
1764
  for (const config of collectionConfigs) {
1733
1765
  const additionalIndexes = config.indexes || [];
1734
- const indexes = ["_id", "_dirty", "_ts", ...additionalIndexes.map(String)];
1766
+ const indexes = ["_id", "_dirty", "_rev", ...additionalIndexes.map(String)];
1735
1767
  schema[config.name] = indexes.join(", ");
1736
1768
  }
1737
1769
  this.version(1).stores(schema);
@@ -1893,10 +1925,9 @@ class DexieDb extends Dexie {
1893
1925
  getInstanceId() {
1894
1926
  return this.instanceId;
1895
1927
  }
1896
- async getNewerThan(collection, timestamp) {
1928
+ async getNewerThan(collection, rev) {
1897
1929
  const table = this.getTable(collection);
1898
- const normalizedTs = this.normalizeTimestamp(timestamp);
1899
- return await table.where("_ts").above(normalizedTs).toArray();
1930
+ return await table.where("_rev").above(rev).toArray();
1900
1931
  }
1901
1932
  onMetaUpdated(callback) {
1902
1933
  this.metaUpdateCallbacks.add(callback);
@@ -1904,19 +1935,6 @@ class DexieDb extends Dexie {
1904
1935
  this.metaUpdateCallbacks.delete(callback);
1905
1936
  };
1906
1937
  }
1907
- normalizeTimestamp(ts) {
1908
- if (ts === null || ts === undefined)
1909
- return 0;
1910
- if (typeof ts === "number")
1911
- return ts;
1912
- if (ts instanceof Date)
1913
- return ts.getTime();
1914
- if (typeof ts.toNumber === "function")
1915
- return ts.toNumber();
1916
- if (typeof ts.valueOf === "function")
1917
- return ts.valueOf();
1918
- return 0;
1919
- }
1920
1938
  closeBroadcastChannel() {
1921
1939
  if (this.debounceTimer) {
1922
1940
  clearTimeout(this.debounceTimer);
@@ -4409,7 +4427,7 @@ class Ebus2ProxyServerUpdateNotifier {
4409
4427
  this.reconnectAttempt = 0;
4410
4428
  this.currentReconnectDelay = this.reconnectDelayMs;
4411
4429
  for (const collection of this.collections) {
4412
- const channel = `dbrev/${this.dbName}/${collection}`;
4430
+ const channel = `db/${this.dbName}/${collection}`;
4413
4431
  this.sendSubscribe(channel);
4414
4432
  }
4415
4433
  this.startPingInterval();
@@ -4469,7 +4487,7 @@ class Ebus2ProxyServerUpdateNotifier {
4469
4487
  }
4470
4488
  }
4471
4489
  handleChannelMessage(message) {
4472
- if (!message.channel.startsWith("dbrev/")) {
4490
+ if (!message.channel.startsWith("db/")) {
4473
4491
  return;
4474
4492
  }
4475
4493
  const payload = message.data;
@@ -61,8 +61,8 @@ export interface I_DexieDb {
61
61
  deleteSyncMeta(collection: string): Promise<void>;
62
62
  /** Vrne tenant */
63
63
  getTenant(): string;
64
- /** Get records newer than timestamp (uses _ts index - server timestamp) */
65
- getNewerThan<T extends LocalDbEntity>(collection: string, timestamp: any): Promise<T[]>;
64
+ /** Get records newer than revision (uses _rev index) */
65
+ getNewerThan<T extends LocalDbEntity>(collection: string, rev: number): Promise<T[]>;
66
66
  /** Subscribe to metadata updates from other tabs. Returns unsubscribe function. */
67
67
  onMetaUpdated(callback: (payload: MetaUpdateBroadcast) => void): () => void;
68
68
  /** Get the instance ID */
@@ -1,8 +1,9 @@
1
- import type { PublishRevsPayload } from "./PublishRevsPayload";
1
+ import type { PublishDataPayload } from "./PublishRevsPayload";
2
2
  /**
3
3
  * Callback za prejemanje server update notifikacij
4
+ * Payload now contains actual data, not just metadata
4
5
  */
5
- export type ServerUpdateCallback = (payload: PublishRevsPayload) => void;
6
+ export type ServerUpdateCallback = (payload: PublishDataPayload) => void;
6
7
  /**
7
8
  * Connection lifecycle callbacks for I_ServerUpdateNotifier
8
9
  */
@@ -139,8 +139,8 @@ export interface LocalstorageWriteResultInfo {
139
139
  * Callback payload for WebSocket notification received
140
140
  */
141
141
  export interface WsNotificationInfo {
142
- /** The notification payload */
143
- payload: import("./PublishRevsPayload").PublishRevsPayload;
142
+ /** The notification payload - contains actual data changes */
143
+ payload: import("./PublishRevsPayload").PublishDataPayload;
144
144
  /** Timestamp when notification was received */
145
145
  timestamp: Date;
146
146
  }
@@ -52,14 +52,44 @@ export type PublishRevsSpec = {
52
52
  payload: PublishRevsPayload;
53
53
  user?: string;
54
54
  };
55
+ /**
56
+ * Payload for data-carrying notifications (db/ channel)
57
+ * Unlike PublishRevsPayload which only contains metadata,
58
+ * this contains the actual data changes.
59
+ */
60
+ export type PublishDataPayloadBase = {
61
+ db: string;
62
+ collection: string;
63
+ };
64
+ export type PublishDataPayloadInsert = PublishDataPayloadBase & {
65
+ operation: 'insert';
66
+ data: any;
67
+ };
68
+ export type PublishDataPayloadUpdate = PublishDataPayloadBase & {
69
+ operation: 'update';
70
+ data: any;
71
+ };
72
+ export type PublishDataPayloadDelete = PublishDataPayloadBase & {
73
+ operation: 'delete';
74
+ data: {
75
+ _id: Id;
76
+ };
77
+ };
78
+ export type PublishDataPayloadBatch = PublishDataPayloadBase & {
79
+ operation: 'batch';
80
+ data: Array<{
81
+ operation: 'insert' | 'update' | 'delete';
82
+ doc: any;
83
+ }>;
84
+ };
85
+ /**
86
+ * Notifikacija serverja s podatki o spremembah
87
+ * Vsebuje dejanske podatke, ne samo metapodatke
88
+ */
89
+ export type PublishDataPayload = PublishDataPayloadInsert | PublishDataPayloadUpdate | PublishDataPayloadDelete | PublishDataPayloadBatch;
55
90
  export type PublishDataSpec = {
56
91
  channel: string;
57
- payload: {
58
- db: string;
59
- collection: string;
60
- operation: PublishableOperation;
61
- data: any;
62
- };
92
+ payload: PublishDataPayload;
63
93
  user?: string;
64
94
  };
65
95
  export type PublishSpec = PublishDataSpec | PublishRevsSpec;
@@ -1,5 +1,5 @@
1
1
  export type { Id, Entity, IdOrEntity, DbEntity, LocalDbEntity } from "./DbEntity";
2
- export type { PublishableOperation, PublishRevsPayloadInsert, PublishRevsPayloadUpdate, PublishRevsPayloadDelete, PublishRevsPayloadUpdateMany, PublishRevsPayloadDeleteMany, PublishRevsPayloadBatchItem, PublishRevsPayloadBatch, PublishRevsPayload, PublishRevsSpec, PublishDataSpec, PublishSpec, } from "./PublishRevsPayload";
2
+ export type { PublishableOperation, PublishRevsPayloadInsert, PublishRevsPayloadUpdate, PublishRevsPayloadDelete, PublishRevsPayloadUpdateMany, PublishRevsPayloadDeleteMany, PublishRevsPayloadBatchItem, PublishRevsPayloadBatch, PublishRevsPayload, PublishRevsSpec, PublishDataPayloadBase, PublishDataPayloadInsert, PublishDataPayloadUpdate, PublishDataPayloadDelete, PublishDataPayloadBatch, PublishDataPayload, PublishDataSpec, PublishSpec, } from "./PublishRevsPayload";
3
3
  export type { Obj, QuerySpec, Projection, QueryOpts, KeyOf, InsertKeyOf, InsertSpec, UpdateSpec, BatchSpec, UpsertOptions, GetNewerSpec, I_RestInterface as RestInterface, } from "./I_RestInterface";
4
4
  export type { I_InMemDb as InMemDb } from "./I_InMemDb";
5
5
  export type { I_DexieDb as DexieDb, SyncMeta } from "./I_DexieDb";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -27,7 +27,6 @@
27
27
  "devDependencies": {
28
28
  "@types/bun": "latest",
29
29
  "bson": "^7.0.0",
30
- "cry-db": "^2.4.15",
31
30
  "cry-ebus-proxy": "^1.0.3",
32
31
  "dexie": "^4.2.1",
33
32
  "fake-indexeddb": "^6.2.5",
@@ -35,6 +34,7 @@
35
34
  "vitest": "^4.0.17"
36
35
  },
37
36
  "dependencies": {
37
+ "cry-db": "^2.4.16",
38
38
  "cry-helpers": "^2.1.187",
39
39
  "msgpackr": "^1.11.8",
40
40
  "notepack": "^0.0.2",