cry-synced-db-client 2.0.10 → 2.0.11

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
@@ -3231,6 +3231,17 @@ function stripServerManagedFromChanges(changes) {
3231
3231
 
3232
3232
  // src/db/sync/SyncEngine.ts
3233
3233
  var SUPRESS_DB_WARNINGS = true;
3234
+ function sameDirtyValue(a, b) {
3235
+ if (a === b) return true;
3236
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") {
3237
+ try {
3238
+ return JSON.stringify(a) === JSON.stringify(b);
3239
+ } catch (e) {
3240
+ return false;
3241
+ }
3242
+ }
3243
+ return false;
3244
+ }
3234
3245
  var SyncEngine = class {
3235
3246
  constructor(config) {
3236
3247
  var _a;
@@ -3592,6 +3603,72 @@ var SyncEngine = class {
3592
3603
  }
3593
3604
  return out;
3594
3605
  }
3606
+ /**
3607
+ * Clear dirty entries for server-ACKed ids WITHOUT losing edits authored
3608
+ * during the upload round-trip. SyncedDb.save() merges new diffs into the
3609
+ * SAME dirty entry the in-flight upload read from (addDirtyChange runs at
3610
+ * save()-time, not at flush-time), so a wholesale per-id delete destroys
3611
+ * the only upload tracking of any edit that landed between getDirty and
3612
+ * this clear — its content stays in the row (flushAllPendingChanges before
3613
+ * the re-fetch sees it) at the ACKed _rev, never re-uploads, and no
3614
+ * equal-rev echo ever repairs the server (mirror of the 2026-07-17
3615
+ * incident; regression test: test/ackClearsRoundTripDirty.test.ts).
3616
+ *
3617
+ * Re-add rule: keep exactly the paths that are NEW or CHANGED relative to
3618
+ * the upload-start snapshot (`dirtyAtUpload`). Everything unchanged is
3619
+ * dropped — it was either uploaded+ACKed, or deliberately NOT sent
3620
+ * (fixDotnetArrays stale-drop, preprocessDirtyItem transform); re-adding
3621
+ * the latter would bypass that protection AND rebase it onto the fresh
3622
+ * _rev so the staleness detection never fires again (regression witness:
3623
+ * test/concurrentArrayMerge.test.ts LEGACY case). Comparison is against
3624
+ * the upload-start snapshot, NOT against the sent payload: the payload
3625
+ * holds STRIPPED values (nested _ts/_rev/_csq removed) and possibly
3626
+ * translated keys, so it would false-positive every object/array path
3627
+ * into a re-add loop (regression witness: test/stuckDirtyDirectInject).
3628
+ *
3629
+ * Skipped ids: server-deleted (local row is gone — re-adding would
3630
+ * resurrect an upload) and mustRefresh (adoptMustRefresh re-adds its own
3631
+ * remainder from dirtyBeforeRefresh).
3632
+ */
3633
+ async clearAckedDirtyPreservingRoundTripEdits(collection, allSuccessIds, ackedEntities, deletedEntities, mustRefreshIds, dirtyAtUpload) {
3634
+ var _a;
3635
+ if (allSuccessIds.length === 0) return;
3636
+ const dirtyBeforeClear = await this.dexieDb.getDirtyChangesBatch(
3637
+ collection,
3638
+ allSuccessIds
3639
+ );
3640
+ await this.dexieDb.clearDirtyChangesBatch(collection, allSuccessIds);
3641
+ const deletedIds = /* @__PURE__ */ new Set();
3642
+ for (const e of deletedEntities) deletedIds.add(String(e._id));
3643
+ const ackedById = /* @__PURE__ */ new Map();
3644
+ for (const e of ackedEntities) ackedById.set(String(e._id), e);
3645
+ const reAdd = [];
3646
+ for (const [sid, entry] of dirtyBeforeClear) {
3647
+ if (deletedIds.has(sid) || mustRefreshIds.has(sid)) continue;
3648
+ const atUpload = dirtyAtUpload.get(sid);
3649
+ const remaining = {};
3650
+ for (const path of Object.keys(entry.changes)) {
3651
+ const value = entry.changes[path];
3652
+ if (atUpload && path in atUpload && sameDirtyValue(value, atUpload[path])) {
3653
+ continue;
3654
+ }
3655
+ remaining[path] = value;
3656
+ }
3657
+ if (Object.keys(remaining).length === 0) continue;
3658
+ const ack = ackedById.get(sid);
3659
+ reAdd.push({
3660
+ id: sid,
3661
+ changes: remaining,
3662
+ baseMeta: {
3663
+ _ts: (_a = ack == null ? void 0 : ack._ts) != null ? _a : entry.baseTs,
3664
+ _rev: typeof (ack == null ? void 0 : ack._rev) === "number" ? ack._rev : entry.baseRev
3665
+ }
3666
+ });
3667
+ }
3668
+ if (reAdd.length > 0) {
3669
+ await this.dexieDb.addDirtyChangesBatch(collection, reAdd);
3670
+ }
3671
+ }
3595
3672
  /**
3596
3673
  * Diagnostic guard for the ACK writeback path: after pending writes have
3597
3674
  * been flushed and the row re-read, every plain scalar top-level field of
@@ -3632,8 +3709,9 @@ var SyncEngine = class {
3632
3709
  * Upload dirty items for all collections.
3633
3710
  */
3634
3711
  async uploadDirtyItems(calledFrom) {
3635
- var _a, _b;
3712
+ var _b, _c, _d;
3636
3713
  const collectionBatches = [];
3714
+ const dirtyAtUploadByCollection = /* @__PURE__ */ new Map();
3637
3715
  for (const [collectionName] of this.collections)
3638
3716
  try {
3639
3717
  const dirtyChanges = await this.dexieDb.getDirty(collectionName);
@@ -3642,6 +3720,12 @@ var SyncEngine = class {
3642
3720
  for (const dirtyItem of dirtyChanges) {
3643
3721
  dirtyChangesMap.set(String(dirtyItem._id), dirtyItem);
3644
3722
  }
3723
+ const atUpload = /* @__PURE__ */ new Map();
3724
+ for (const [sid, dc] of dirtyChangesMap) {
3725
+ const _a = dc, { _id, _ts, _rev } = _a, changes = __objRest(_a, ["_id", "_ts", "_rev"]);
3726
+ atUpload.set(sid, changes);
3727
+ }
3728
+ dirtyAtUploadByCollection.set(collectionName, atUpload);
3645
3729
  const updates = [];
3646
3730
  const skipped = [];
3647
3731
  const ids = dirtyChanges.map((dc) => dc._id);
@@ -3829,7 +3913,7 @@ var SyncEngine = class {
3829
3913
  for (const b of batch) {
3830
3914
  const allIds = [];
3831
3915
  for (const u of b.batch.updates) allIds.push(u._id);
3832
- for (const d of b.batch.deletes) allIds.push((_a = d._id) != null ? _a : d);
3916
+ for (const d of b.batch.deletes) allIds.push((_b = d._id) != null ? _b : d);
3833
3917
  const newlyStuck = await this.dexieDb.incrementDirtyUploadAttempts(
3834
3918
  b.collection,
3835
3919
  allIds
@@ -3891,7 +3975,14 @@ var SyncEngine = class {
3891
3975
  ]) : /* @__PURE__ */ new Map();
3892
3976
  const uploadedByIdRefresh = mustRefreshIds.size > 0 ? this.uploadedSnapshotFor(collectionBatches, collection) : /* @__PURE__ */ new Map();
3893
3977
  if (allSuccessIds.length > 0) {
3894
- await this.dexieDb.clearDirtyChangesBatch(collection, allSuccessIds);
3978
+ await this.clearAckedDirtyPreservingRoundTripEdits(
3979
+ collection,
3980
+ allSuccessIds,
3981
+ inserted.concat(updated),
3982
+ deleted,
3983
+ mustRefreshIds,
3984
+ (_c = dirtyAtUploadByCollection.get(collection)) != null ? _c : /* @__PURE__ */ new Map()
3985
+ );
3895
3986
  }
3896
3987
  const sentIdsForCollection = /* @__PURE__ */ new Set();
3897
3988
  for (const batch of collectionBatches) {
@@ -3917,7 +4008,7 @@ var SyncEngine = class {
3917
4008
  await this.callOnDirtyItemStuck(collection, newlyStuck, calledFrom);
3918
4009
  }
3919
4010
  let collectionSentCount = 0;
3920
- const isWriteOnly = (_b = this.collections.get(collection)) == null ? void 0 : _b.writeOnly;
4011
+ const isWriteOnly = (_d = this.collections.get(collection)) == null ? void 0 : _d.writeOnly;
3921
4012
  const insertedAndUpdated = inserted.concat(updated);
3922
4013
  if (insertedAndUpdated.length > 0) {
3923
4014
  const idsToCheck = [];
@@ -4098,6 +4189,11 @@ var SyncEngine = class {
4098
4189
  for (const dirtyItem of dirtyItems) {
4099
4190
  dirtyChangesMap.set(String(dirtyItem._id), dirtyItem);
4100
4191
  }
4192
+ const dirtyAtUpload = /* @__PURE__ */ new Map();
4193
+ for (const [sid, dc] of dirtyChangesMap) {
4194
+ const _a = dc, { _id, _ts, _rev } = _a, changes = __objRest(_a, ["_id", "_ts", "_rev"]);
4195
+ dirtyAtUpload.set(sid, changes);
4196
+ }
4101
4197
  const updates = [];
4102
4198
  for (const fullItem of fullItems) {
4103
4199
  if (!fullItem) continue;
@@ -4115,7 +4211,7 @@ var SyncEngine = class {
4115
4211
  collection,
4116
4212
  batch: {
4117
4213
  updates: updates.map((item) => {
4118
- const _a = item.delta, { _ts, _rev } = _a, changes = __objRest(_a, ["_ts", "_rev"]);
4214
+ const _a2 = item.delta, { _ts, _rev } = _a2, changes = __objRest(_a2, ["_ts", "_rev"]);
4119
4215
  return {
4120
4216
  _id: item._id,
4121
4217
  _rev: typeof _rev === "number" ? _rev : 0,
@@ -4187,7 +4283,14 @@ var SyncEngine = class {
4187
4283
  const dirtyBeforeRefresh = refreshIds.length > 0 ? await this.dexieDb.getDirtyChangesBatch(collection, refreshIds) : /* @__PURE__ */ new Map();
4188
4284
  const uploadedByIdRefresh = refreshIds.length > 0 ? this.uploadedSnapshotFor(collectionBatches, collection) : /* @__PURE__ */ new Map();
4189
4285
  if (allSuccessIds.length > 0) {
4190
- await this.dexieDb.clearDirtyChangesBatch(collection, allSuccessIds);
4286
+ await this.clearAckedDirtyPreservingRoundTripEdits(
4287
+ collection,
4288
+ allSuccessIds,
4289
+ inserted.concat(updated),
4290
+ deleted,
4291
+ new Set(refreshIds),
4292
+ dirtyAtUpload
4293
+ );
4191
4294
  }
4192
4295
  const sentIdsForCollection = /* @__PURE__ */ new Set();
4193
4296
  for (const batch of collectionBatches) {
@@ -54,6 +54,34 @@ export declare class SyncEngine implements I_SyncEngine {
54
54
  * server-reconciled paths from local edits made during the round-trip.
55
55
  */
56
56
  private uploadedSnapshotFor;
57
+ /**
58
+ * Clear dirty entries for server-ACKed ids WITHOUT losing edits authored
59
+ * during the upload round-trip. SyncedDb.save() merges new diffs into the
60
+ * SAME dirty entry the in-flight upload read from (addDirtyChange runs at
61
+ * save()-time, not at flush-time), so a wholesale per-id delete destroys
62
+ * the only upload tracking of any edit that landed between getDirty and
63
+ * this clear — its content stays in the row (flushAllPendingChanges before
64
+ * the re-fetch sees it) at the ACKed _rev, never re-uploads, and no
65
+ * equal-rev echo ever repairs the server (mirror of the 2026-07-17
66
+ * incident; regression test: test/ackClearsRoundTripDirty.test.ts).
67
+ *
68
+ * Re-add rule: keep exactly the paths that are NEW or CHANGED relative to
69
+ * the upload-start snapshot (`dirtyAtUpload`). Everything unchanged is
70
+ * dropped — it was either uploaded+ACKed, or deliberately NOT sent
71
+ * (fixDotnetArrays stale-drop, preprocessDirtyItem transform); re-adding
72
+ * the latter would bypass that protection AND rebase it onto the fresh
73
+ * _rev so the staleness detection never fires again (regression witness:
74
+ * test/concurrentArrayMerge.test.ts LEGACY case). Comparison is against
75
+ * the upload-start snapshot, NOT against the sent payload: the payload
76
+ * holds STRIPPED values (nested _ts/_rev/_csq removed) and possibly
77
+ * translated keys, so it would false-positive every object/array path
78
+ * into a re-add loop (regression witness: test/stuckDirtyDirectInject).
79
+ *
80
+ * Skipped ids: server-deleted (local row is gone — re-adding would
81
+ * resurrect an upload) and mustRefresh (adoptMustRefresh re-adds its own
82
+ * remainder from dirtyBeforeRefresh).
83
+ */
84
+ private clearAckedDirtyPreservingRoundTripEdits;
57
85
  /**
58
86
  * Diagnostic guard for the ACK writeback path: after pending writes have
59
87
  * been flushed and the row re-read, every plain scalar top-level field of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "2.0.10",
3
+ "version": "2.0.11",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",