cry-synced-db-client 0.1.219 → 2.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Versions
2
2
 
3
+ ## 2.0.0 (2026-07-10)
4
+
5
+ ### Dual-channel ebus support: `db/` + `dbbracketed/`
6
+
7
+ `Ebus2ProxyServerUpdateNotifier` now subscribes to **both** channels simultaneously:
8
+
9
+ | Channel | Payload | Use case |
10
+ |---|---|---|
11
+ | `db/` | Full document object (`operation: insert/update/delete`) | Legacy; full object replication |
12
+ | `dbbracketed/` | Diff (`bracketedData: { "field": value, "arr[id].sub": value }`) | Bandwidth-efficient; only changed fields |
13
+
14
+ Both channels carry `pub_ts` (publisher timestamp) at batch level. The client
15
+ uses `pub_ts` for cross-channel dedup — if the same batch arrives on both
16
+ channels, only the first one is processed.
17
+
18
+ **Server-side:** cry-db ≥ 2.5.x emits both channels for every change. The
19
+ diff is produced by `computeObjDiff` (same function the client uses for dirty
20
+ changes), so the format is identical end-to-end.
21
+
22
+ **Client-side (`ServerUpdateHandler`):**
23
+ - `_rev`-based stale/self-echo/gap detection shared across both channels
24
+ - `checkRev()` returns: `ignore` | `bump-meta` | `apply` | `fetch-full`
25
+ - For `dbbracketed/` diffs: `applyObjDiff` from cry-db applies the diff locally
26
+ - `undefined` values in diffs trigger `deleteByPath` (mongo `$unset` symmetric)
27
+ - On gap (`incomingRev > localRev + 1`): fetches full doc from server via
28
+ `findById` (we're online because WS is connected); if fetch fails, fires
29
+ `onEbusUpdateSkipped` and skips
30
+
31
+ **Why `insert()` instead of `save()` for bracketed diffs:**
32
+ Dexie's `update()` only touches fields present in the payload — it does NOT
33
+ delete fields. When `applyObjDiff` produces a diff with `{ field: undefined }`
34
+ (signaling deletion via `deleteByPath`), we must use `put()` (`insert()` in our
35
+ interface) to replace the entire row, ensuring deleted fields are actually
36
+ removed locally.
37
+
38
+ **Diagnostic callbacks:**
39
+ ```typescript
40
+ new SyncedDb({
41
+ onEbusNotificationReceived: (info) => {
42
+ // info: { payload, channel: "db" | "dbbracketed", collection, timestamp }
43
+ syslog.debug("ebus", info);
44
+ },
45
+ onEbusUpdateSkipped: (info) => {
46
+ // info: { collection, _id, incomingRev, localRev, reason, timestamp }
47
+ // reason: "fetch-failed" (gap detected but findById failed)
48
+ syslog.warn("ebus skip", info);
49
+ },
50
+ });
51
+ ```
52
+
53
+ ### Generic `_rev` check for all server notifications
54
+
55
+ Previously only self-echoed updates (same `_lastUpdaterId`) were handled.
56
+ Now every incoming item runs through `checkRev()`:
57
+ - `incomingRev <= localRev` → ignore (stale/duplicate)
58
+ - `incomingRev === localRev + 1 && incomingUpdaterId === local._lastUpdaterId`
59
+ → bump-meta (clear dirty, advance rev)
60
+ - `incomingRev === localRev + 1 && different updater` → apply (external update)
61
+ - `incomingRev > localRev + 1` → fetch-full (gap; online because WS is connected)
62
+
63
+ ### Backward compatibility
64
+
65
+ All existing public API is unchanged. Both `db/` and `dbbracketed/` channels
66
+ are handled transparently. Legacy servers (cry-db < 2.5.x) that only emit
67
+ `db/` work identically to before.
68
+
69
+ ---
70
+
3
71
  ## 0.1.216 (2026-06-25)
4
72
 
5
73
  ### `onSyncProgress` fires for every chunk (not just first per collection)
package/dist/index.js CHANGED
@@ -4444,6 +4444,8 @@ var SyncEngine = class {
4444
4444
  // src/db/sync/ServerUpdateHandler.ts
4445
4445
  var ServerUpdateHandler = class {
4446
4446
  constructor(config) {
4447
+ /** Per-collection last processed pub_ts for cross-channel dedup. */
4448
+ this.lastProcessedPubTs = /* @__PURE__ */ new Map();
4447
4449
  this.tenant = config.tenant;
4448
4450
  this.updaterId = config.updaterId;
4449
4451
  this.collections = config.collections;
@@ -4452,22 +4454,220 @@ var ServerUpdateHandler = class {
4452
4454
  this.callbacks = config.callbacks;
4453
4455
  this.deps = config.deps;
4454
4456
  }
4457
+ // ============================================================
4458
+ // Entry point
4459
+ // ============================================================
4455
4460
  /**
4456
4461
  * Handle incoming server update (WebSocket notification).
4462
+ * Accepts both db/ (full-object) and dbbracketed/ (diff) payloads.
4457
4463
  */
4458
4464
  async handleServerUpdate(payload) {
4465
+ this.callOnEbusNotificationReceived(payload);
4459
4466
  this.callOnWsNotification(payload);
4460
4467
  if (!this.deps.isLeader()) return;
4461
4468
  if (!this.deps.canReceiveServerUpdates()) return;
4462
4469
  const collectionName = payload.collection;
4463
4470
  if (!this.collections.has(collectionName)) return;
4464
4471
  if (!this.deps.isSyncAllowed(collectionName)) return;
4472
+ const pubTs = payload.pub_ts;
4473
+ if (this.wasAlreadyProcessed(collectionName, pubTs)) {
4474
+ return;
4475
+ }
4476
+ const isBracketed = this.isBracketedPayload(payload);
4477
+ const updatedIds = isBracketed ? await this.handleBracketedBatch(collectionName, payload) : await this.handleLegacyBatch(collectionName, payload);
4478
+ if (updatedIds.length > 0) {
4479
+ this.deps.broadcastUpdates({ [collectionName]: updatedIds });
4480
+ }
4481
+ }
4482
+ // ============================================================
4483
+ // pub_ts dedup
4484
+ // ============================================================
4485
+ /**
4486
+ * Check if this batch was already processed (duplicate or stale pub_ts).
4487
+ * Updates lastProcessedPubTs when a new pub_ts is seen.
4488
+ */
4489
+ wasAlreadyProcessed(collection, pubTs) {
4490
+ if (pubTs == null) return false;
4491
+ const last = this.lastProcessedPubTs.get(collection);
4492
+ if (last != null && pubTs <= last) {
4493
+ return true;
4494
+ }
4495
+ this.lastProcessedPubTs.set(collection, pubTs);
4496
+ return false;
4497
+ }
4498
+ isBracketedPayload(payload) {
4499
+ if (payload.operation !== "batch") return false;
4500
+ const batch = payload;
4501
+ return batch.data.length > 0 && batch.data[0].bracketedData !== void 0;
4502
+ }
4503
+ // ============================================================
4504
+ // Bracketed batch (dbbracketed/ channel)
4505
+ // ============================================================
4506
+ async handleBracketedBatch(collection, payload) {
4507
+ const updatedIds = [];
4508
+ for (const item of payload.data) {
4509
+ const processed = await this.processBracketedItem(collection, item);
4510
+ if (processed) updatedIds.push(String(item._id));
4511
+ }
4512
+ return updatedIds;
4513
+ }
4514
+ async processBracketedItem(collection, item) {
4515
+ var _a, _b, _c, _d, _e, _f;
4516
+ const localItem = await this.dexieDb.getById(collection, item._id);
4517
+ if (!localItem) {
4518
+ if (item.operation === "insert" && item.bracketedData) {
4519
+ await this.handleServerItemInsert(collection, item.bracketedData);
4520
+ return true;
4521
+ }
4522
+ const fullItem = await this.safeFindById(collection, item._id);
4523
+ if (fullItem) {
4524
+ await this.handleServerItemInsert(collection, fullItem);
4525
+ return true;
4526
+ }
4527
+ return false;
4528
+ }
4529
+ const revAction = this.checkRev(
4530
+ item._rev,
4531
+ item._ts,
4532
+ void 0,
4533
+ // bracketedData doesn't carry _lastUpdaterId
4534
+ localItem
4535
+ );
4536
+ switch (revAction) {
4537
+ case "ignore":
4538
+ return false;
4539
+ case "bump-meta": {
4540
+ await this.dexieDb.save(collection, item._id, {
4541
+ _rev: item._rev,
4542
+ _ts: item._ts
4543
+ });
4544
+ await this.dexieDb.clearDirtyChange(collection, item._id);
4545
+ return false;
4546
+ }
4547
+ case "fetch-full": {
4548
+ const fullItem = await this.safeFindById(collection, item._id);
4549
+ if (fullItem) {
4550
+ await this.dexieDb.insert(collection, fullItem);
4551
+ this.deps.writeToInMemBatch(
4552
+ collection,
4553
+ [this.stripLocalFields(fullItem)],
4554
+ "upsert",
4555
+ { source: "incremental" }
4556
+ );
4557
+ return true;
4558
+ }
4559
+ this.callOnEbusUpdateSkipped({
4560
+ collection,
4561
+ _id: item._id,
4562
+ incomingRev: (_a = item._rev) != null ? _a : 0,
4563
+ localRev: (_b = localItem._rev) != null ? _b : 0,
4564
+ reason: "fetch-failed"
4565
+ });
4566
+ return false;
4567
+ }
4568
+ case "apply":
4569
+ break;
4570
+ }
4571
+ const pendingChange = this.deps.getPendingChange(collection, item._id);
4572
+ if (pendingChange && item.operation === "update") {
4573
+ const diff = this.stripMetaFromDiff(item.bracketedData);
4574
+ const currentInMemState = Object.assign({}, localItem, pendingChange.data);
4575
+ const merged = applyObjDiff(currentInMemState, diff, item._id, "bracketed");
4576
+ merged._rev = (_c = item._rev) != null ? _c : localItem._rev;
4577
+ merged._ts = (_d = item._ts) != null ? _d : localItem._ts;
4578
+ if (!merged._deleted && !merged._archived) {
4579
+ this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", {
4580
+ source: "incremental"
4581
+ });
4582
+ }
4583
+ return true;
4584
+ }
4585
+ const dirtyChange = await this.dexieDb.getDirtyChange(collection, item._id);
4586
+ if (dirtyChange && item.operation === "update") {
4587
+ const diff = this.stripMetaFromDiff(item.bracketedData);
4588
+ const merged = applyObjDiff(localItem, diff, item._id, "bracketed");
4589
+ merged._rev = (_e = item._rev) != null ? _e : localItem._rev;
4590
+ merged._ts = (_f = item._ts) != null ? _f : localItem._ts;
4591
+ if (!merged._deleted && !merged._archived) {
4592
+ await this.dexieDb.insert(collection, merged);
4593
+ this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", {
4594
+ source: "incremental"
4595
+ });
4596
+ } else {
4597
+ await this.dexieDb.save(collection, item._id, { _deleted: /* @__PURE__ */ new Date() });
4598
+ this.deps.writeToInMemBatch(collection, [{ _id: item._id }], "delete", {
4599
+ source: "incremental"
4600
+ });
4601
+ }
4602
+ return true;
4603
+ }
4604
+ switch (item.operation) {
4605
+ case "insert": {
4606
+ await this.handleServerItemInsert(collection, item.bracketedData);
4607
+ return true;
4608
+ }
4609
+ case "update": {
4610
+ const merged = this.applyBracketedDiff(localItem, item);
4611
+ if (!merged._deleted && !merged._archived) {
4612
+ await this.dexieDb.insert(collection, merged);
4613
+ this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", {
4614
+ source: "incremental"
4615
+ });
4616
+ } else {
4617
+ await this.dexieDb.deleteOne(collection, item._id);
4618
+ this.deps.writeToInMemBatch(collection, [{ _id: item._id }], "delete", {
4619
+ source: "incremental"
4620
+ });
4621
+ }
4622
+ return true;
4623
+ }
4624
+ case "delete": {
4625
+ await this.handleServerItemDelete(collection, item._id);
4626
+ return true;
4627
+ }
4628
+ }
4629
+ return false;
4630
+ }
4631
+ /**
4632
+ * Apply a bracketed diff to a local item using cry-db's applyObjDiff.
4633
+ * Strips meta fields from the diff, then applies _rev/_ts from the item level.
4634
+ */
4635
+ applyBracketedDiff(localItem, item) {
4636
+ var _a, _b;
4637
+ const diff = this.stripMetaFromDiff(item.bracketedData);
4638
+ const merged = applyObjDiff(localItem, diff, item._id, "bracketed");
4639
+ merged._rev = (_a = item._rev) != null ? _a : localItem._rev;
4640
+ merged._ts = (_b = item._ts) != null ? _b : localItem._ts;
4641
+ return merged;
4642
+ }
4643
+ /**
4644
+ * Strip _id, _rev, _ts, _lastUpdaterId from a diff object before applyObjDiff.
4645
+ *
4646
+ * IMPORTANT: Uses for...in instead of Object.keys() because Object.keys()
4647
+ * skips properties whose value is undefined. Bracketed diffs may contain
4648
+ * {field: undefined} to signal field deletion (translated by applyObjDiff
4649
+ * to deleteByPath). Skipping such keys would silently ignore deletions.
4650
+ */
4651
+ stripMetaFromDiff(diff) {
4652
+ if (diff == null || typeof diff !== "object") return diff;
4653
+ const result = {};
4654
+ for (const key in diff) {
4655
+ if (!Object.prototype.hasOwnProperty.call(diff, key)) continue;
4656
+ if (key === "_id" || key === "_rev" || key === "_ts" || key === "_lastUpdaterId") continue;
4657
+ result[key] = diff[key];
4658
+ }
4659
+ return result;
4660
+ }
4661
+ // ============================================================
4662
+ // Legacy batch (db/ channel)
4663
+ // ============================================================
4664
+ async handleLegacyBatch(collection, payload) {
4465
4665
  const updatedIds = [];
4466
4666
  switch (payload.operation) {
4467
4667
  case "insert": {
4468
4668
  const serverItem = payload.data;
4469
4669
  if (serverItem) {
4470
- await this.handleServerItemInsert(collectionName, serverItem);
4670
+ await this.handleServerItemInsert(collection, serverItem);
4471
4671
  updatedIds.push(String(serverItem._id));
4472
4672
  }
4473
4673
  break;
@@ -4475,20 +4675,14 @@ var ServerUpdateHandler = class {
4475
4675
  case "update": {
4476
4676
  const deltaData = payload.data;
4477
4677
  if (deltaData && deltaData._id) {
4478
- const localItem = await this.dexieDb.getById(
4479
- collectionName,
4480
- deltaData._id
4481
- );
4678
+ const localItem = await this.dexieDb.getById(collection, deltaData._id);
4482
4679
  if (localItem) {
4483
- await this.handleServerItemUpdate(collectionName, localItem, deltaData);
4680
+ await this.handleServerItemUpdate(collection, localItem, deltaData);
4484
4681
  updatedIds.push(String(deltaData._id));
4485
4682
  } else {
4486
- const fullItem = await this.restInterface.findById(
4487
- collectionName,
4488
- deltaData._id
4489
- );
4683
+ const fullItem = await this.safeFindById(collection, deltaData._id);
4490
4684
  if (fullItem) {
4491
- await this.handleServerItemInsert(collectionName, fullItem);
4685
+ await this.handleServerItemInsert(collection, fullItem);
4492
4686
  updatedIds.push(String(fullItem._id));
4493
4687
  }
4494
4688
  }
@@ -4497,7 +4691,7 @@ var ServerUpdateHandler = class {
4497
4691
  }
4498
4692
  case "delete": {
4499
4693
  const deleteData = payload.data;
4500
- await this.handleServerItemDelete(collectionName, deleteData._id);
4694
+ await this.handleServerItemDelete(collection, deleteData._id);
4501
4695
  updatedIds.push(String(deleteData._id));
4502
4696
  break;
4503
4697
  }
@@ -4516,9 +4710,7 @@ var ServerUpdateHandler = class {
4516
4710
  }
4517
4711
  if (inserts.length > 0) {
4518
4712
  await Promise.all(
4519
- inserts.map(
4520
- (serverItem) => this.handleServerItemInsert(collectionName, serverItem)
4521
- )
4713
+ inserts.map((serverItem) => this.handleServerItemInsert(collection, serverItem))
4522
4714
  );
4523
4715
  for (const serverItem of inserts) {
4524
4716
  updatedIds.push(String(serverItem._id));
@@ -4526,7 +4718,7 @@ var ServerUpdateHandler = class {
4526
4718
  }
4527
4719
  if (updates.length > 0) {
4528
4720
  const updateIds = updates.map((u) => u._id);
4529
- const localItems = await this.dexieDb.getByIds(collectionName, updateIds);
4721
+ const localItems = await this.dexieDb.getByIds(collection, updateIds);
4530
4722
  const missingIds = [];
4531
4723
  const updatePromises = [];
4532
4724
  for (let i = 0; i < updates.length; i++) {
@@ -4534,7 +4726,7 @@ var ServerUpdateHandler = class {
4534
4726
  const localItem = localItems[i];
4535
4727
  if (localItem) {
4536
4728
  updatePromises.push(
4537
- this.handleServerItemUpdate(collectionName, localItem, deltaData)
4729
+ this.handleServerItemUpdate(collection, localItem, deltaData)
4538
4730
  );
4539
4731
  updatedIds.push(String(deltaData._id));
4540
4732
  } else {
@@ -4544,15 +4736,13 @@ var ServerUpdateHandler = class {
4544
4736
  if (updatePromises.length > 0) await Promise.all(updatePromises);
4545
4737
  if (missingIds.length > 0) {
4546
4738
  const fullItems = await this.restInterface.findByIds(
4547
- collectionName,
4739
+ collection,
4548
4740
  missingIds
4549
4741
  );
4550
4742
  const insertPromises = [];
4551
4743
  for (const fullItem of fullItems) {
4552
4744
  if (!fullItem) continue;
4553
- insertPromises.push(
4554
- this.handleServerItemInsert(collectionName, fullItem)
4555
- );
4745
+ insertPromises.push(this.handleServerItemInsert(collection, fullItem));
4556
4746
  updatedIds.push(String(fullItem._id));
4557
4747
  }
4558
4748
  if (insertPromises.length > 0) await Promise.all(insertPromises);
@@ -4560,7 +4750,7 @@ var ServerUpdateHandler = class {
4560
4750
  }
4561
4751
  if (deletes.length > 0) {
4562
4752
  await Promise.all(
4563
- deletes.map((d) => this.handleServerItemDelete(collectionName, d._id))
4753
+ deletes.map((d) => this.handleServerItemDelete(collection, d._id))
4564
4754
  );
4565
4755
  for (const deleteData of deletes) {
4566
4756
  updatedIds.push(String(deleteData._id));
@@ -4569,10 +4759,53 @@ var ServerUpdateHandler = class {
4569
4759
  break;
4570
4760
  }
4571
4761
  }
4572
- if (updatedIds.length > 0) {
4573
- this.deps.broadcastUpdates({ [collectionName]: updatedIds });
4762
+ return updatedIds;
4763
+ }
4764
+ // ============================================================
4765
+ // Rev-check (shared across both channels)
4766
+ // ============================================================
4767
+ /**
4768
+ * Determine action based on incoming vs local revision.
4769
+ *
4770
+ * | incomingRev | Action | Reason |
4771
+ * |-------------|-------------|-------------------------------------------|
4772
+ * | ≤ localRev | ignore | Stale / duplicate / self-echo after WB |
4773
+ * | = local+1 | bump-meta | Self-echo before WB (only if updaterId matches) |
4774
+ * | = local+1 | apply | External update (updaterId mismatch) |
4775
+ * | > local+1 | fetch-full | Gap — missed intermediate updates; fetch |
4776
+ * | | full doc since we're online (WS connected).|
4777
+ * | undefined | apply | Legacy server / no rev tracking |
4778
+ */
4779
+ checkRev(incomingRev, incomingTs, incomingUpdaterId, localItem) {
4780
+ const localRev = localItem._rev;
4781
+ if (typeof incomingRev !== "number" || typeof localRev !== "number") {
4782
+ return "apply";
4783
+ }
4784
+ if (incomingRev <= localRev) {
4785
+ return "ignore";
4786
+ }
4787
+ if (incomingRev === localRev + 1) {
4788
+ const updaterId = incomingUpdaterId != null ? incomingUpdaterId : localItem._lastUpdaterId;
4789
+ if (updaterId === this.updaterId) {
4790
+ return "bump-meta";
4791
+ }
4792
+ return "apply";
4793
+ }
4794
+ return "fetch-full";
4795
+ }
4796
+ // ============================================================
4797
+ // Safe fetch helper
4798
+ // ============================================================
4799
+ async safeFindById(collection, id) {
4800
+ try {
4801
+ return await this.restInterface.findById(collection, id);
4802
+ } catch (e) {
4803
+ return null;
4574
4804
  }
4575
4805
  }
4806
+ // ============================================================
4807
+ // Per-item handlers (unchanged logic, now called from both branches)
4808
+ // ============================================================
4576
4809
  /**
4577
4810
  * Handle server item insert.
4578
4811
  */
@@ -4596,17 +4829,27 @@ var ServerUpdateHandler = class {
4596
4829
  await this.dexieDb.clearDirtyChange(collection, serverItem._id);
4597
4830
  }
4598
4831
  if (!serverItem._deleted && !serverItem._archived) {
4599
- this.deps.writeToInMemBatch(collection, [this.stripLocalFields(serverItem)], "upsert", { source: "incremental" });
4832
+ this.deps.writeToInMemBatch(
4833
+ collection,
4834
+ [this.stripLocalFields(serverItem)],
4835
+ "upsert",
4836
+ { source: "incremental" }
4837
+ );
4600
4838
  }
4601
4839
  } else {
4602
4840
  await this.dexieDb.insert(collection, serverItem);
4603
4841
  if (!serverItem._deleted && !serverItem._archived) {
4604
- this.deps.writeToInMemBatch(collection, [this.stripLocalFields(serverItem)], "upsert", { source: "incremental" });
4842
+ this.deps.writeToInMemBatch(
4843
+ collection,
4844
+ [this.stripLocalFields(serverItem)],
4845
+ "upsert",
4846
+ { source: "incremental" }
4847
+ );
4605
4848
  }
4606
4849
  }
4607
4850
  }
4608
4851
  /**
4609
- * Handle server item update (delta).
4852
+ * Handle server item update (delta) — db/ channel only.
4610
4853
  */
4611
4854
  async handleServerItemUpdate(collection, localItem, serverDelta) {
4612
4855
  const serverRev = serverDelta._rev;
@@ -4631,7 +4874,12 @@ var ServerUpdateHandler = class {
4631
4874
  const currentInMemState = Object.assign({}, localItem, pendingChange.data);
4632
4875
  const merged = this.mergeLocalWithDelta(currentInMemState, serverDelta);
4633
4876
  if (!merged._deleted && !merged._archived) {
4634
- this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", { source: "incremental" });
4877
+ this.deps.writeToInMemBatch(
4878
+ collection,
4879
+ [this.stripLocalFields(merged)],
4880
+ "upsert",
4881
+ { source: "incremental" }
4882
+ );
4635
4883
  }
4636
4884
  return;
4637
4885
  }
@@ -4643,16 +4891,31 @@ var ServerUpdateHandler = class {
4643
4891
  await this.dexieDb.save(collection, serverDelta._id, merged);
4644
4892
  }
4645
4893
  if (!merged._deleted && !merged._archived) {
4646
- this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", { source: "incremental" });
4894
+ this.deps.writeToInMemBatch(
4895
+ collection,
4896
+ [this.stripLocalFields(merged)],
4897
+ "upsert",
4898
+ { source: "incremental" }
4899
+ );
4647
4900
  }
4648
4901
  } else {
4649
4902
  if (!metaChanged) return;
4650
4903
  const merged = this.mergeLocalWithDelta(localItem, serverDelta);
4651
4904
  await this.dexieDb.save(collection, serverDelta._id, merged);
4652
4905
  if (!merged._deleted && !merged._archived) {
4653
- this.deps.writeToInMemBatch(collection, [this.stripLocalFields(merged)], "upsert", { source: "incremental" });
4906
+ this.deps.writeToInMemBatch(
4907
+ collection,
4908
+ [this.stripLocalFields(merged)],
4909
+ "upsert",
4910
+ { source: "incremental" }
4911
+ );
4654
4912
  } else {
4655
- this.deps.writeToInMemBatch(collection, [{ _id: serverDelta._id }], "delete", { source: "incremental" });
4913
+ this.deps.writeToInMemBatch(
4914
+ collection,
4915
+ [{ _id: serverDelta._id }],
4916
+ "delete",
4917
+ { source: "incremental" }
4918
+ );
4656
4919
  }
4657
4920
  }
4658
4921
  }
@@ -4668,7 +4931,12 @@ var ServerUpdateHandler = class {
4668
4931
  } else {
4669
4932
  await this.dexieDb.deleteOne(collection, id);
4670
4933
  }
4671
- this.deps.writeToInMemBatch(collection, [{ _id: id }], "delete", { source: "incremental" });
4934
+ this.deps.writeToInMemBatch(
4935
+ collection,
4936
+ [{ _id: id }],
4937
+ "delete",
4938
+ { source: "incremental" }
4939
+ );
4672
4940
  }
4673
4941
  // ============================================================
4674
4942
  // Private Helpers
@@ -4717,6 +4985,36 @@ var ServerUpdateHandler = class {
4717
4985
  }
4718
4986
  }
4719
4987
  }
4988
+ callOnEbusNotificationReceived(payload) {
4989
+ if (this.callbacks.onEbusNotificationReceived) {
4990
+ try {
4991
+ const channel = this.isBracketedPayload(payload) ? "dbbracketed" : "db";
4992
+ this.callbacks.onEbusNotificationReceived({
4993
+ payload,
4994
+ channel,
4995
+ collection: payload.collection,
4996
+ timestamp: /* @__PURE__ */ new Date()
4997
+ });
4998
+ } catch (err) {
4999
+ console.error(
5000
+ `[ServerUpdateHandler] onEbusNotificationReceived callback failed: ${err}`,
5001
+ err
5002
+ );
5003
+ }
5004
+ }
5005
+ }
5006
+ callOnEbusUpdateSkipped(info) {
5007
+ if (this.callbacks.onEbusUpdateSkipped) {
5008
+ try {
5009
+ const fullInfo = __spreadProps(__spreadValues({}, info), {
5010
+ timestamp: /* @__PURE__ */ new Date()
5011
+ });
5012
+ this.callbacks.onEbusUpdateSkipped(fullInfo);
5013
+ } catch (err) {
5014
+ console.error(`[ServerUpdateHandler] onEbusUpdateSkipped callback failed: ${err}`, err);
5015
+ }
5016
+ }
5017
+ }
4720
5018
  };
4721
5019
 
4722
5020
  // src/db/managers/WakeSyncManager.ts
@@ -5125,7 +5423,9 @@ var _SyncedDb = class _SyncedDb {
5125
5423
  dexieDb: this.dexieDb,
5126
5424
  restInterface: this.restInterface,
5127
5425
  callbacks: {
5128
- onWsNotification: config.onWsNotification
5426
+ onWsNotification: config.onWsNotification,
5427
+ onEbusNotificationReceived: config.onEbusNotificationReceived,
5428
+ onEbusUpdateSkipped: config.onEbusUpdateSkipped
5129
5429
  },
5130
5430
  deps: {
5131
5431
  isLeader: () => this.leaderElection.isLeader(),
@@ -11289,6 +11589,7 @@ var Ebus2ProxyServerUpdateNotifier = class {
11289
11589
  this.reconnectAttempt = 0;
11290
11590
  this.currentReconnectDelay = this.reconnectDelayMs;
11291
11591
  this.sendSubscribe(`db/${this.dbName}`);
11592
+ this.sendSubscribe(`dbbracketed/${this.dbName}`);
11292
11593
  if (this.subscribeServices) {
11293
11594
  this.sendSubscribe("ebus/services");
11294
11595
  }
@@ -11431,8 +11732,12 @@ var Ebus2ProxyServerUpdateNotifier = class {
11431
11732
  }
11432
11733
  return;
11433
11734
  }
11434
- const prefix = `db/${this.dbName}/`;
11435
- if (!message.channel.startsWith(prefix)) {
11735
+ let prefix;
11736
+ if (message.channel.startsWith(`db/${this.dbName}/`)) {
11737
+ prefix = `db/${this.dbName}/`;
11738
+ } else if (message.channel.startsWith(`dbbracketed/${this.dbName}/`)) {
11739
+ prefix = `dbbracketed/${this.dbName}/`;
11740
+ } else {
11436
11741
  return;
11437
11742
  }
11438
11743
  const collection = message.channel.slice(prefix.length);
@@ -3,13 +3,14 @@
3
3
  *
4
4
  * Handles:
5
5
  * - Routing updates by operation type
6
- * - Loopback detection
7
- * - Delta merging with local data
6
+ * - pub_ts dedup across db/ and dbbracketed/ channels
7
+ * - _rev-based stale/self-echo/gap detection
8
+ * - Delta merging with local data (mergeLocalWithDelta for db/, applyObjDiff for dbbracketed/)
8
9
  * - Conflict management with pending changes
9
10
  * - Broadcasting updates to other tabs
10
11
  */
11
12
  import type { Id, LocalDbEntity } from "../../types/DbEntity";
12
- import type { PublishDataPayload } from "../../types/PublishRevsPayload";
13
+ import type { PublishDataPayload, PublishBracketedPayloadBatch } from "../../types/PublishRevsPayload";
13
14
  import type { I_ServerUpdateHandler, ServerUpdateHandlerConfig } from "../types/managers";
14
15
  export declare class ServerUpdateHandler implements I_ServerUpdateHandler {
15
16
  private readonly tenant;
@@ -19,17 +20,57 @@ export declare class ServerUpdateHandler implements I_ServerUpdateHandler {
19
20
  private readonly restInterface;
20
21
  private readonly callbacks;
21
22
  private readonly deps;
23
+ /** Per-collection last processed pub_ts for cross-channel dedup. */
24
+ private lastProcessedPubTs;
22
25
  constructor(config: ServerUpdateHandlerConfig);
23
26
  /**
24
27
  * Handle incoming server update (WebSocket notification).
28
+ * Accepts both db/ (full-object) and dbbracketed/ (diff) payloads.
25
29
  */
26
- handleServerUpdate(payload: PublishDataPayload): Promise<void>;
30
+ handleServerUpdate(payload: PublishDataPayload | PublishBracketedPayloadBatch): Promise<void>;
31
+ /**
32
+ * Check if this batch was already processed (duplicate or stale pub_ts).
33
+ * Updates lastProcessedPubTs when a new pub_ts is seen.
34
+ */
35
+ private wasAlreadyProcessed;
36
+ private isBracketedPayload;
37
+ private handleBracketedBatch;
38
+ private processBracketedItem;
39
+ /**
40
+ * Apply a bracketed diff to a local item using cry-db's applyObjDiff.
41
+ * Strips meta fields from the diff, then applies _rev/_ts from the item level.
42
+ */
43
+ private applyBracketedDiff;
44
+ /**
45
+ * Strip _id, _rev, _ts, _lastUpdaterId from a diff object before applyObjDiff.
46
+ *
47
+ * IMPORTANT: Uses for...in instead of Object.keys() because Object.keys()
48
+ * skips properties whose value is undefined. Bracketed diffs may contain
49
+ * {field: undefined} to signal field deletion (translated by applyObjDiff
50
+ * to deleteByPath). Skipping such keys would silently ignore deletions.
51
+ */
52
+ private stripMetaFromDiff;
53
+ private handleLegacyBatch;
54
+ /**
55
+ * Determine action based on incoming vs local revision.
56
+ *
57
+ * | incomingRev | Action | Reason |
58
+ * |-------------|-------------|-------------------------------------------|
59
+ * | ≤ localRev | ignore | Stale / duplicate / self-echo after WB |
60
+ * | = local+1 | bump-meta | Self-echo before WB (only if updaterId matches) |
61
+ * | = local+1 | apply | External update (updaterId mismatch) |
62
+ * | > local+1 | fetch-full | Gap — missed intermediate updates; fetch |
63
+ * | | full doc since we're online (WS connected).|
64
+ * | undefined | apply | Legacy server / no rev tracking |
65
+ */
66
+ private checkRev;
67
+ private safeFindById;
27
68
  /**
28
69
  * Handle server item insert.
29
70
  */
30
71
  handleServerItemInsert(collection: string, serverItem: LocalDbEntity): Promise<void>;
31
72
  /**
32
- * Handle server item update (delta).
73
+ * Handle server item update (delta) — db/ channel only.
33
74
  */
34
75
  handleServerItemUpdate(collection: string, localItem: LocalDbEntity, serverDelta: LocalDbEntity): Promise<void>;
35
76
  /**
@@ -42,4 +83,6 @@ export declare class ServerUpdateHandler implements I_ServerUpdateHandler {
42
83
  private mergeLocalWithDelta;
43
84
  private getNewFieldsFromServer;
44
85
  private callOnWsNotification;
86
+ private callOnEbusNotificationReceived;
87
+ private callOnEbusUpdateSkipped;
45
88
  }
@@ -7,7 +7,7 @@ import type { I_DexieDb, SyncMeta, MetaUpdateBroadcast } from "../../types/I_Dex
7
7
  import type { I_InMemDb, SyncSource } from "../../types/I_InMemDb";
8
8
  import type { I_RestInterface } from "../../types/I_RestInterface";
9
9
  import type { PublishDataPayload } from "../../types/PublishRevsPayload";
10
- import type { CollectionConfig, SyncInfo, ConflictResolutionReport, ServerWriteRequestInfo, ServerWriteResultInfo, ServerSyncWriteInfo, FindNewerManyCallInfo, FindNewerManyResultInfo, DexieWriteRequestInfo, DexieWriteResultInfo, LocalstorageWriteResultInfo, WsNotificationInfo, CrossTabSyncInfo } from "../../types/I_SyncedDb";
10
+ import type { CollectionConfig, SyncInfo, ConflictResolutionReport, ServerWriteRequestInfo, ServerWriteResultInfo, ServerSyncWriteInfo, FindNewerManyCallInfo, FindNewerManyResultInfo, DexieWriteRequestInfo, DexieWriteResultInfo, LocalstorageWriteResultInfo, WsNotificationInfo, EbusNotificationReceivedInfo, EbusUpdateSkippedInfo, CrossTabSyncInfo } from "../../types/I_SyncedDb";
11
11
  import type { PendingChange, UploadResult } from "./internal";
12
12
  export interface LeaderElectionCallbacks {
13
13
  onBecameLeader?: () => void;
@@ -351,6 +351,10 @@ export interface I_SyncEngine {
351
351
  }
352
352
  export interface ServerUpdateHandlerCallbacks {
353
353
  onWsNotification?: (info: WsNotificationInfo) => void;
354
+ /** Fired for every incoming ebus notification BEFORE any processing. */
355
+ onEbusNotificationReceived?: (info: EbusNotificationReceivedInfo) => void;
356
+ /** Fired when a gap notification is skipped because the fetch failed. */
357
+ onEbusUpdateSkipped?: (info: EbusUpdateSkippedInfo) => void;
354
358
  }
355
359
  export interface ServerUpdateHandlerDeps {
356
360
  isLeader: () => boolean;
@@ -373,8 +377,8 @@ export interface ServerUpdateHandlerConfig {
373
377
  deps: ServerUpdateHandlerDeps;
374
378
  }
375
379
  export interface I_ServerUpdateHandler {
376
- /** Handle incoming server update. */
377
- handleServerUpdate(payload: PublishDataPayload): Promise<void>;
380
+ /** Handle incoming server update (db/ or dbbracketed/ channel). */
381
+ handleServerUpdate(payload: PublishDataPayload | import("../../types/PublishRevsPayload").PublishBracketedPayloadBatch): Promise<void>;
378
382
  }
379
383
  /** Type of event that triggered wake sync */
380
384
  export type WakeSyncTrigger = "pageshow" | "focus" | "visibilitychange";
@@ -341,6 +341,32 @@ export interface WsNotificationInfo {
341
341
  /** Timestamp when notification was received */
342
342
  timestamp: Date;
343
343
  }
344
+ /** Info passed to onEbusNotificationReceived — fires for every incoming ebus notification before processing. */
345
+ export interface EbusNotificationReceivedInfo {
346
+ /** The raw notification payload exactly as received from ebus-proxy */
347
+ payload: import("./PublishRevsPayload").PublishDataPayload | import("./PublishRevsPayload").PublishBracketedPayloadBatch;
348
+ /** Which channel the notification arrived on */
349
+ channel: "db" | "dbbracketed";
350
+ /** Collection name */
351
+ collection: string;
352
+ /** Timestamp when notification was received */
353
+ timestamp: Date;
354
+ }
355
+ /** Info passed to onEbusUpdateSkipped — fires when a notification is skipped because the gap fetch failed. */
356
+ export interface EbusUpdateSkippedInfo {
357
+ /** Collection name */
358
+ collection: string;
359
+ /** Document _id */
360
+ _id: Id;
361
+ /** Incoming _rev that caused the gap detection */
362
+ incomingRev: number;
363
+ /** Local _rev at the time of skip */
364
+ localRev: number;
365
+ /** Reason for skipping */
366
+ reason: "fetch-failed";
367
+ /** Timestamp when skip occurred */
368
+ timestamp: Date;
369
+ }
344
370
  /**
345
371
  * Source of the conflict - where the conflict was detected
346
372
  */
@@ -756,6 +782,19 @@ export interface SyncedDbConfig {
756
782
  onWsReconnect?: (attempt: number) => void;
757
783
  /** Callback when a WebSocket notification is received */
758
784
  onWsNotification?: (info: WsNotificationInfo) => void;
785
+ /**
786
+ * Diagnostic callback fired for EVERY incoming ebus notification BEFORE
787
+ * any processing (dedup, rev-check, apply). Useful for telemetry/debugging.
788
+ * Receives the raw payload + channel info.
789
+ */
790
+ onEbusNotificationReceived?: (info: EbusNotificationReceivedInfo) => void;
791
+ /**
792
+ * Callback fired when a server update is skipped because the local document
793
+ * is too far behind (gap: incoming._rev > local._rev + 1) and the fetch
794
+ * for the full document failed (network error, timeout, offline).
795
+ * The document stays unchanged; next sync will catch up.
796
+ */
797
+ onEbusUpdateSkipped?: (info: EbusUpdateSkippedInfo) => void;
759
798
  /** Callback when online status changes (after ping success/failure in tryGoOnline) */
760
799
  onOnlineStatusChange?: (online: boolean) => void;
761
800
  /** Debounce interval for cross-tab sync broadcasts in ms (default: 100) */
@@ -82,6 +82,7 @@ export type PublishDataPayloadBatchItem = {
82
82
  export type PublishDataPayloadBatch = PublishDataPayloadBase & {
83
83
  operation: 'batch';
84
84
  data: PublishDataPayloadBatchItem[];
85
+ pub_ts?: number;
85
86
  };
86
87
  /**
87
88
  * Notifikacija serverja s podatki o spremembah
@@ -93,5 +94,25 @@ export type PublishDataSpec = {
93
94
  payload: PublishDataPayload;
94
95
  user?: string;
95
96
  };
97
+ /**
98
+ * Payload for bracketed diff notifications (dbbracketed/ channel).
99
+ * Contains the client diff (bracketedData) instead of full parent arrays.
100
+ * _rev and _ts are at item level, outside bracketedData.
101
+ */
102
+ export type PublishBracketedPayloadBatchItem<T = any> = {
103
+ operation: 'insert' | 'update' | 'delete';
104
+ _id: Id;
105
+ _ts: Timestamp | {
106
+ t: number;
107
+ i: number;
108
+ };
109
+ _rev?: number;
110
+ bracketedData: T;
111
+ };
112
+ export type PublishBracketedPayloadBatch<T = any> = PublishDataPayloadBase & {
113
+ operation: 'batch';
114
+ data: PublishBracketedPayloadBatchItem<T>[];
115
+ pub_ts?: number;
116
+ };
96
117
  export type PublishSpec = PublishDataSpec | PublishRevsSpec;
97
118
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.219",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",