cry-synced-db-client 0.1.151 → 0.1.155

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,50 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.154
4
+
5
+ ### `evictOnWake` config
6
+
7
+ New optional field on `SyncedDbConfig`. When set to `true`, scope-exit
8
+ eviction runs on every wake event (`sync()` whose `calledFrom` starts
9
+ with `'wake-sync:'`), bypassing the `evictStaleRecordsEveryHrs` interval
10
+ gate. The eviction is bundled into the wake sync's `findNewerManyStream`
11
+ call — single RTT, same shape as existing 8h auto-eviction.
12
+
13
+ Use with `scopeExitLookbehindMs` to ensure scope-exit detection covers
14
+ records mutated within the lookbehind window. Together they guarantee
15
+ cross-device close-outs become visible on the very next wake instead of
16
+ waiting up to `evictStaleRecordsEveryHrs`.
17
+
18
+ `evictStaleRecordsEveryHrs` continues to gate non-wake syncs (interval
19
+ auto-sync ticks, manual `sync()` calls without `'wake-sync:*'` prefix).
20
+
21
+ Default: `false`.
22
+
23
+ ## 0.1.153
24
+
25
+ ### `scopeExitLookbehindMs` config
26
+
27
+ New optional field on `SyncedDbConfig`. When set (> 0), server-assisted
28
+ scope-exit detection (auto-eviction bundled with `sync()`, plus the batch
29
+ `evictOutOfScopeRecordsAll` path) uses `_ts > now - scopeExitLookbehindMs`
30
+ instead of `_ts > lastSyncTs` for the `findNewerMany` spec timestamp.
31
+
32
+ Why: with the legacy `lastSyncTs` cursor, the scope-exit query
33
+ `{$nor:[positiveQuery], _id:{$in:chunk}, _ts > lastSyncTs}` excluded any
34
+ record whose server-side `_ts` predates the device's `lastSyncTs`. Typical
35
+ cross-device close-out scenario (device A closes a record while device B
36
+ is offline; B comes back online and syncs many newer records, advancing
37
+ `lastSyncTs` past the close-out's `_ts`) → server returned 0 scope-exit
38
+ candidates forever. Per-call override via
39
+ `evictOutOfScopeRecords({outOfWindowLookbehindMs})` is unchanged and now
40
+ also falls back to `scopeExitLookbehindMs` when no per-call value is given.
41
+
42
+ Default: `0` (legacy behavior). Recommended for multi-device deployments:
43
+ `3 * 24 * 3600 * 1000` (3 days). Combine with periodic
44
+ `refreshDatabaseFromServer()` (e.g. when `lastInitialSync()` exceeds the
45
+ same threshold) to also cover scope-exit events older than the lookbehind
46
+ window.
47
+
3
48
  ## Unreleased
4
49
 
5
50
  ### `SyncSource` flag in `I_InMemDb.saveMany` / `deleteManyByIds`
package/dist/index.js CHANGED
@@ -273,6 +273,183 @@ function applyQueryOpts(items, opts) {
273
273
  return result;
274
274
  }
275
275
 
276
+ // src/utils/computeDiff.ts
277
+ function isObjectIdLike(v) {
278
+ return !!(v && typeof v === "object" && (v._bsontype === "ObjectId" || v._bsontype === "ObjectID") && typeof v.toString === "function");
279
+ }
280
+ function isPlainObject(v) {
281
+ if (v === null || typeof v !== "object") return false;
282
+ if (Array.isArray(v)) return false;
283
+ if (v instanceof Date) return false;
284
+ if (isObjectIdLike(v)) return false;
285
+ const proto = Object.getPrototypeOf(v);
286
+ return proto === Object.prototype || proto === null;
287
+ }
288
+ function deepEquals(a, b) {
289
+ if (a === b) return true;
290
+ if (a === null || b === null) return false;
291
+ if (a === void 0 || b === void 0) return false;
292
+ if (a instanceof Date && b instanceof Date) {
293
+ return a.getTime() === b.getTime();
294
+ }
295
+ if (isObjectIdLike(a) && isObjectIdLike(b)) {
296
+ return String(a) === String(b);
297
+ }
298
+ if (typeof a !== typeof b) return false;
299
+ if (typeof a !== "object") return false;
300
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
301
+ if (Array.isArray(a)) {
302
+ if (a.length !== b.length) return false;
303
+ for (let i = 0; i < a.length; i++) {
304
+ if (!deepEquals(a[i], b[i])) return false;
305
+ }
306
+ return true;
307
+ }
308
+ const ak = Object.keys(a);
309
+ const bk = Object.keys(b);
310
+ if (ak.length !== bk.length) return false;
311
+ for (const k of ak) {
312
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
313
+ if (!deepEquals(a[k], b[k])) return false;
314
+ }
315
+ return true;
316
+ }
317
+ function allElementsHaveId(arr) {
318
+ if (arr.length === 0) return false;
319
+ for (const e of arr) {
320
+ if (!e || typeof e !== "object") return false;
321
+ if (e._id == null) return false;
322
+ }
323
+ return true;
324
+ }
325
+ function sameIdSequence(a, b) {
326
+ if (a.length !== b.length) return false;
327
+ for (let i = 0; i < a.length; i++) {
328
+ if (String(a[i]._id) !== String(b[i]._id)) return false;
329
+ }
330
+ return true;
331
+ }
332
+ function computeArrayDiff(existingArr, updateArr, basePath, diff) {
333
+ if (existingArr.length === 0) {
334
+ if (updateArr.length > 0) diff[basePath] = updateArr;
335
+ return;
336
+ }
337
+ if (!allElementsHaveId(existingArr) || !allElementsHaveId(updateArr)) {
338
+ if (!deepEquals(existingArr, updateArr)) {
339
+ diff[basePath] = updateArr;
340
+ }
341
+ return;
342
+ }
343
+ if (!sameIdSequence(existingArr, updateArr)) {
344
+ diff[basePath] = updateArr;
345
+ return;
346
+ }
347
+ for (let i = 0; i < updateArr.length; i++) {
348
+ computeDiffInto(
349
+ existingArr[i],
350
+ updateArr[i],
351
+ `${basePath}.${i}`,
352
+ diff
353
+ );
354
+ }
355
+ }
356
+ function computeDiffInto(existing, update, basePath, diff) {
357
+ if (deepEquals(existing, update)) return;
358
+ if (update === null || update === void 0 || typeof update !== "object" || update instanceof Date || isObjectIdLike(update)) {
359
+ diff[basePath] = update;
360
+ return;
361
+ }
362
+ if (existing === null || existing === void 0 || typeof existing !== "object" || existing instanceof Date || isObjectIdLike(existing)) {
363
+ diff[basePath] = update;
364
+ return;
365
+ }
366
+ if (Array.isArray(update) !== Array.isArray(existing)) {
367
+ diff[basePath] = update;
368
+ return;
369
+ }
370
+ if (Array.isArray(update)) {
371
+ computeArrayDiff(existing, update, basePath, diff);
372
+ return;
373
+ }
374
+ if (!isPlainObject(update) || !isPlainObject(existing)) {
375
+ diff[basePath] = update;
376
+ return;
377
+ }
378
+ for (const key of Object.keys(update)) {
379
+ const childPath = basePath ? `${basePath}.${key}` : key;
380
+ computeDiffInto(existing[key], update[key], childPath, diff);
381
+ }
382
+ }
383
+ function computeDiff(existing, update) {
384
+ const diff = {};
385
+ if (!update || typeof update !== "object") return diff;
386
+ if (existing === null || existing === void 0) {
387
+ return __spreadValues({}, update);
388
+ }
389
+ for (const key of Object.keys(update)) {
390
+ computeDiffInto(existing[key], update[key], key, diff);
391
+ }
392
+ return diff;
393
+ }
394
+ function isDescendantOrEqual(path, candidate) {
395
+ if (path === candidate) return true;
396
+ return path.startsWith(candidate + ".");
397
+ }
398
+ function setByPath(target2, path, value) {
399
+ if (target2 === null || target2 === void 0) return false;
400
+ const parts = path.split(".");
401
+ let current = target2;
402
+ for (let i = 0; i < parts.length - 1; i++) {
403
+ const part = parts[i];
404
+ const idx = /^\d+$/.test(part) ? Number(part) : null;
405
+ let next;
406
+ if (Array.isArray(current) && idx !== null) {
407
+ next = current[idx];
408
+ } else if (typeof current === "object") {
409
+ next = current[part];
410
+ } else {
411
+ return false;
412
+ }
413
+ if (next === null || next === void 0) return false;
414
+ current = next;
415
+ }
416
+ const last = parts[parts.length - 1];
417
+ const lastIdx = /^\d+$/.test(last) ? Number(last) : null;
418
+ if (Array.isArray(current) && lastIdx !== null) {
419
+ current[lastIdx] = value;
420
+ } else if (typeof current === "object") {
421
+ current[last] = value;
422
+ } else {
423
+ return false;
424
+ }
425
+ return true;
426
+ }
427
+ function mergeDirtyPath(accumulated, newPath, newValue) {
428
+ for (const existingKey of Object.keys(accumulated)) {
429
+ if (existingKey === newPath) continue;
430
+ if (isDescendantOrEqual(newPath, existingKey)) {
431
+ const relativePath = newPath.substring(existingKey.length + 1);
432
+ const ok = setByPath(accumulated[existingKey], relativePath, newValue);
433
+ if (ok) return;
434
+ break;
435
+ }
436
+ }
437
+ const toDelete = [];
438
+ for (const existingKey of Object.keys(accumulated)) {
439
+ if (existingKey === newPath) continue;
440
+ if (isDescendantOrEqual(existingKey, newPath)) {
441
+ toDelete.push(existingKey);
442
+ }
443
+ }
444
+ for (const k of toDelete) delete accumulated[k];
445
+ accumulated[newPath] = newValue;
446
+ }
447
+ function mergeDirtyChanges(accumulated, newChanges) {
448
+ for (const path of Object.keys(newChanges)) {
449
+ mergeDirtyPath(accumulated, path, newChanges[path]);
450
+ }
451
+ }
452
+
276
453
  // src/db/managers/InMemManager.ts
277
454
  var InMemManager = class {
278
455
  constructor(config) {
@@ -1303,7 +1480,7 @@ var CustomTransformerRegistry = class {
1303
1480
  var getType = (payload) => Object.prototype.toString.call(payload).slice(8, -1);
1304
1481
  var isUndefined = (payload) => typeof payload === "undefined";
1305
1482
  var isNull = (payload) => payload === null;
1306
- var isPlainObject = (payload) => {
1483
+ var isPlainObject2 = (payload) => {
1307
1484
  if (typeof payload !== "object" || payload === null)
1308
1485
  return false;
1309
1486
  if (payload === Object.prototype)
@@ -1312,7 +1489,7 @@ var isPlainObject = (payload) => {
1312
1489
  return true;
1313
1490
  return Object.getPrototypeOf(payload) === Object.prototype;
1314
1491
  };
1315
- var isEmptyObject = (payload) => isPlainObject(payload) && Object.keys(payload).length === 0;
1492
+ var isEmptyObject = (payload) => isPlainObject2(payload) && Object.keys(payload).length === 0;
1316
1493
  var isArray = (payload) => Array.isArray(payload);
1317
1494
  var isString = (payload) => typeof payload === "string";
1318
1495
  var isNumber = (payload) => typeof payload === "number" && !isNaN(payload);
@@ -1625,7 +1802,7 @@ var setDeep = (object, path, mapper) => {
1625
1802
  if (isArray(parent)) {
1626
1803
  const index = +key;
1627
1804
  parent = parent[index];
1628
- } else if (isPlainObject(parent)) {
1805
+ } else if (isPlainObject2(parent)) {
1629
1806
  parent = parent[key];
1630
1807
  } else if (isSet(parent)) {
1631
1808
  const row = +key;
@@ -1651,7 +1828,7 @@ var setDeep = (object, path, mapper) => {
1651
1828
  const lastKey = path[path.length - 1];
1652
1829
  if (isArray(parent)) {
1653
1830
  parent[+lastKey] = mapper(parent[+lastKey]);
1654
- } else if (isPlainObject(parent)) {
1831
+ } else if (isPlainObject2(parent)) {
1655
1832
  parent[lastKey] = mapper(parent[lastKey]);
1656
1833
  }
1657
1834
  if (isSet(parent)) {
@@ -1736,7 +1913,7 @@ function applyReferentialEqualityAnnotations(plain, annotations, version) {
1736
1913
  }
1737
1914
  return plain;
1738
1915
  }
1739
- var isDeep = (object, superJson) => isPlainObject(object) || isArray(object) || isMap(object) || isSet(object) || isError(object) || isInstanceOfRegisteredClass(object, superJson);
1916
+ var isDeep = (object, superJson) => isPlainObject2(object) || isArray(object) || isMap(object) || isSet(object) || isError(object) || isInstanceOfRegisteredClass(object, superJson);
1740
1917
  function addIdentity(object, path, identities) {
1741
1918
  const existingSet = identities.get(object);
1742
1919
  if (existingSet) {
@@ -1814,7 +1991,7 @@ var walker = (object, identities, superJson, dedupe, path = [], objectsInThisPat
1814
1991
  transformedValue[index] = recursiveResult.transformedValue;
1815
1992
  if (isArray(recursiveResult.annotations)) {
1816
1993
  innerAnnotations[escapeKey(index)] = recursiveResult.annotations;
1817
- } else if (isPlainObject(recursiveResult.annotations)) {
1994
+ } else if (isPlainObject2(recursiveResult.annotations)) {
1818
1995
  forEach(recursiveResult.annotations, (tree, key) => {
1819
1996
  innerAnnotations[escapeKey(index) + "." + key] = tree;
1820
1997
  });
@@ -1844,7 +2021,7 @@ function isArray2(payload) {
1844
2021
  }
1845
2022
 
1846
2023
  // node_modules/is-what/dist/isPlainObject.js
1847
- function isPlainObject2(payload) {
2024
+ function isPlainObject3(payload) {
1848
2025
  if (getType2(payload) !== "Object")
1849
2026
  return false;
1850
2027
  const prototype = Object.getPrototypeOf(payload);
@@ -1869,7 +2046,7 @@ function copy(target2, options = {}) {
1869
2046
  if (isArray2(target2)) {
1870
2047
  return target2.map((item) => copy(item, options));
1871
2048
  }
1872
- if (!isPlainObject2(target2)) {
2049
+ if (!isPlainObject3(target2)) {
1873
2050
  return target2;
1874
2051
  }
1875
2052
  const props = Object.getOwnPropertyNames(target2);
@@ -2384,7 +2561,7 @@ function mergeObjects(local, external) {
2384
2561
  }
2385
2562
  if (Array.isArray(localValue) && Array.isArray(externalValue)) {
2386
2563
  result[key] = mergeArrays(localValue, externalValue);
2387
- } else if (isPlainObject3(localValue) && isPlainObject3(externalValue)) {
2564
+ } else if (isPlainObject4(localValue) && isPlainObject4(externalValue)) {
2388
2565
  result[key] = mergeObjects(localValue, externalValue);
2389
2566
  } else if (serverWinsOnConflict) {
2390
2567
  result[key] = externalValue;
@@ -2403,7 +2580,7 @@ function mergeArrays(local, external) {
2403
2580
  for (const item of external) set2.add(item);
2404
2581
  return Array.from(set2);
2405
2582
  }
2406
- if (isPlainObject3(firstLocal) || isPlainObject3(firstExternal)) {
2583
+ if (isPlainObject4(firstLocal) || isPlainObject4(firstExternal)) {
2407
2584
  return mergeObjectArrays(local, external);
2408
2585
  }
2409
2586
  const set = new Set(local);
@@ -2437,7 +2614,7 @@ function mergeObjectArrays(local, external) {
2437
2614
  }
2438
2615
  return result;
2439
2616
  }
2440
- function isPlainObject3(value) {
2617
+ function isPlainObject4(value) {
2441
2618
  return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
2442
2619
  }
2443
2620
 
@@ -3549,7 +3726,7 @@ var _SyncedDb = class _SyncedDb {
3549
3726
  this.syncOnlyCollections = null;
3550
3727
  // Sync metadata cache
3551
3728
  this.syncMetaCache = /* @__PURE__ */ new Map();
3552
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n;
3729
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p;
3553
3730
  this.tenant = config.tenant;
3554
3731
  this.dexieDb = config.dexieDb;
3555
3732
  this.inMemDb = config.inMemDb;
@@ -3576,13 +3753,15 @@ var _SyncedDb = class _SyncedDb {
3576
3753
  this.onEvictionStart = config.onEvictionStart;
3577
3754
  this.onEviction = config.onEviction;
3578
3755
  this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
3756
+ this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
3757
+ this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
3579
3758
  for (const col of config.collections) {
3580
3759
  this.collections.set(col.name, col);
3581
3760
  }
3582
3761
  this.inMemManager = new InMemManager({
3583
3762
  inMemDb: this.inMemDb,
3584
3763
  collections: this.collections,
3585
- useObjectMetadata: (_f = config.useObjectMetadata) != null ? _f : false
3764
+ useObjectMetadata: (_h = config.useObjectMetadata) != null ? _h : false
3586
3765
  });
3587
3766
  this.leaderElection = new LeaderElectionManager({
3588
3767
  tenant: this.tenant,
@@ -3617,7 +3796,7 @@ var _SyncedDb = class _SyncedDb {
3617
3796
  tenant: this.tenant,
3618
3797
  instanceId: this.syncedDbInstanceId,
3619
3798
  windowId,
3620
- debounceMs: (_g = config.crossTabSyncDebounceMs) != null ? _g : 100,
3799
+ debounceMs: (_i = config.crossTabSyncDebounceMs) != null ? _i : 100,
3621
3800
  callbacks: {
3622
3801
  onCrossTabSync: config.onCrossTabSync,
3623
3802
  onInfrastructureError: config.onInfrastructureError ? (type, message, error) => {
@@ -3643,8 +3822,8 @@ var _SyncedDb = class _SyncedDb {
3643
3822
  });
3644
3823
  this.connectionManager = new ConnectionManager({
3645
3824
  restInterface: this.restInterface,
3646
- restTimeoutMs: (_h = config.restTimeoutMs) != null ? _h : 9e4,
3647
- syncTimeoutMs: (_i = config.syncTimeoutMs) != null ? _i : 12e4,
3825
+ restTimeoutMs: (_j = config.restTimeoutMs) != null ? _j : 9e4,
3826
+ syncTimeoutMs: (_k = config.syncTimeoutMs) != null ? _k : 12e4,
3648
3827
  autoSyncIntervalMs: config.autoSyncIntervalMs,
3649
3828
  onlineRetryIntervalMs: config.onlineRetryIntervalMs,
3650
3829
  callbacks: {
@@ -3671,8 +3850,8 @@ var _SyncedDb = class _SyncedDb {
3671
3850
  });
3672
3851
  this.pendingChanges = new PendingChangesManager({
3673
3852
  tenant: this.tenant,
3674
- debounceDexieWritesMs: (_j = config.debounceDexieWritesMs) != null ? _j : 500,
3675
- debounceRestWritesMs: (_k = config.debounceRestWritesMs) != null ? _k : 100,
3853
+ debounceDexieWritesMs: (_l = config.debounceDexieWritesMs) != null ? _l : 500,
3854
+ debounceRestWritesMs: (_m = config.debounceRestWritesMs) != null ? _m : 100,
3676
3855
  callbacks: {
3677
3856
  onDexieWriteRequest: config.onDexieWriteRequest,
3678
3857
  onDexieWriteResult: config.onDexieWriteResult,
@@ -3749,8 +3928,8 @@ var _SyncedDb = class _SyncedDb {
3749
3928
  });
3750
3929
  if (config.wakeSyncEnabled) {
3751
3930
  this.wakeSync = new WakeSyncManager({
3752
- gapThresholdMs: (_l = config.wakeSyncGapThresholdMs) != null ? _l : 1e4,
3753
- debounceMs: (_m = config.wakeSyncDebounceMs) != null ? _m : 2e3,
3931
+ gapThresholdMs: (_n = config.wakeSyncGapThresholdMs) != null ? _n : 1e4,
3932
+ debounceMs: (_o = config.wakeSyncDebounceMs) != null ? _o : 2e3,
3754
3933
  callbacks: {
3755
3934
  onWakeSync: config.onWakeSync
3756
3935
  },
@@ -3765,7 +3944,7 @@ var _SyncedDb = class _SyncedDb {
3765
3944
  }
3766
3945
  if (config.networkStatusEnabled) {
3767
3946
  this.networkStatus = new NetworkStatusManager({
3768
- debounceMs: (_n = config.networkStatusDebounceMs) != null ? _n : 100,
3947
+ debounceMs: (_p = config.networkStatusDebounceMs) != null ? _p : 100,
3769
3948
  callbacks: {
3770
3949
  onBrowserNetworkChange: config.onBrowserNetworkChange,
3771
3950
  onBrowserOnline: config.onBrowserOnline,
@@ -4327,10 +4506,12 @@ var _SyncedDb = class _SyncedDb {
4327
4506
  `SyncedDb.save: Object ${String(id)} not found in ${collection}, creating new`
4328
4507
  );
4329
4508
  }
4509
+ const fullChanges = __spreadProps(__spreadValues({}, update), { _lastUpdaterId: this.updaterId });
4510
+ const diff = computeDiff(existing, fullChanges);
4330
4511
  await this.dexieDb.addDirtyChange(
4331
4512
  collection,
4332
4513
  id,
4333
- __spreadProps(__spreadValues({}, update), { _lastUpdaterId: this.updaterId }),
4514
+ diff,
4334
4515
  { _ts: existing == null ? void 0 : existing._ts, _rev: existing == null ? void 0 : existing._rev }
4335
4516
  );
4336
4517
  const newData = __spreadProps(__spreadValues({}, update), {
@@ -4534,7 +4715,7 @@ var _SyncedDb = class _SyncedDb {
4534
4715
  this.crossTabSync.startServerSync();
4535
4716
  let evictionPlan;
4536
4717
  let evictionServerFailed = false;
4537
- if (await this._isAutoEvictionDue()) {
4718
+ if (await this._isAutoEvictionDue(calledFrom)) {
4538
4719
  try {
4539
4720
  evictionPlan = await this._collectScopeExitPlan("auto");
4540
4721
  } catch (err) {
@@ -4850,11 +5031,12 @@ var _SyncedDb = class _SyncedDb {
4850
5031
  let serverEvictedCount = 0;
4851
5032
  if (serverAssisted && serverCandidateIds.length > 0) {
4852
5033
  let scopeExitTimestamp;
4853
- const lookbehindMs = opts == null ? void 0 : opts.outOfWindowLookbehindMs;
4854
- if (lookbehindMs != null && lookbehindMs > 0) {
5034
+ const callerLookbehindMs = opts == null ? void 0 : opts.outOfWindowLookbehindMs;
5035
+ const effectiveLookbehindMs = callerLookbehindMs != null && callerLookbehindMs > 0 ? callerLookbehindMs : this.scopeExitLookbehindMs;
5036
+ if (effectiveLookbehindMs > 0) {
4855
5037
  scopeExitTimestamp = Math.max(
4856
5038
  0,
4857
- Math.floor((Date.now() - lookbehindMs) / 1e3)
5039
+ Math.floor((Date.now() - effectiveLookbehindMs) / 1e3)
4858
5040
  );
4859
5041
  } else {
4860
5042
  scopeExitTimestamp = (_d = (_c = this.syncMetaCache.get(collection)) == null ? void 0 : _c.lastSyncTs) != null ? _d : 0;
@@ -5010,7 +5192,7 @@ var _SyncedDb = class _SyncedDb {
5010
5192
  * callback is a reliable lifecycle marker.
5011
5193
  */
5012
5194
  async _collectScopeExitPlan(trigger) {
5013
- var _a, _b, _c;
5195
+ var _a;
5014
5196
  const startTime = Date.now();
5015
5197
  const eligible = [];
5016
5198
  for (const [name, config] of this.collections) {
@@ -5060,7 +5242,7 @@ var _SyncedDb = class _SyncedDb {
5060
5242
  collection: name,
5061
5243
  config,
5062
5244
  query,
5063
- timestamp: (_c = (_b = this.syncMetaCache.get(name)) == null ? void 0 : _b.lastSyncTs) != null ? _c : 0,
5245
+ timestamp: this._scopeExitTimestamp(name),
5064
5246
  evictIds,
5065
5247
  localEvictedCount: evictIds.length,
5066
5248
  serverEvictedCount: 0,
@@ -5168,14 +5350,44 @@ var _SyncedDb = class _SyncedDb {
5168
5350
  this.safeCallback(this.onEviction, info);
5169
5351
  return info;
5170
5352
  }
5353
+ /**
5354
+ * Compute the `timestamp` value used in server-assisted scope-exit
5355
+ * `findNewerMany` specs for a given collection. Honors the global
5356
+ * `scopeExitLookbehindMs` config: when > 0, the scope-exit query
5357
+ * uses `_ts > now - lookbehindMs`; otherwise falls back to the
5358
+ * collection's lastSyncTs cursor (legacy behavior).
5359
+ *
5360
+ * Per-call override (`evictOutOfScopeRecords({outOfWindowLookbehindMs})`)
5361
+ * is computed at the call site — this helper covers the auto-eviction
5362
+ * path and `evictOutOfScopeRecordsAll`.
5363
+ */
5364
+ _scopeExitTimestamp(collection) {
5365
+ var _a, _b;
5366
+ if (this.scopeExitLookbehindMs > 0) {
5367
+ return Math.max(
5368
+ 0,
5369
+ Math.floor((Date.now() - this.scopeExitLookbehindMs) / 1e3)
5370
+ );
5371
+ }
5372
+ return (_b = (_a = this.syncMetaCache.get(collection)) == null ? void 0 : _a.lastSyncTs) != null ? _b : 0;
5373
+ }
5171
5374
  /**
5172
5375
  * Whether auto-eviction is due to run on the next sync. Mirrors the
5173
5376
  * gating logic of the old `maybeAutoEvict` (interval check + persisted
5174
5377
  * `__lastEviction` cursor) but split out so `sync()` can pre-compute
5175
5378
  * the eviction plan BEFORE issuing the streamed `findNewerMany` —
5176
5379
  * letting scope-exit specs ride along on the same call.
5380
+ *
5381
+ * `calledFrom` is honored when `evictOnWake` is true: any sync whose
5382
+ * `calledFrom` starts with `'wake-sync:'` forces eviction regardless
5383
+ * of the `evictStaleRecordsEveryHrs` interval gate. This ensures
5384
+ * cross-device scope-exit records become visible on the very next
5385
+ * wake instead of waiting up to the configured interval.
5177
5386
  */
5178
- async _isAutoEvictionDue() {
5387
+ async _isAutoEvictionDue(calledFrom) {
5388
+ if (this.evictOnWake && (calledFrom == null ? void 0 : calledFrom.startsWith("wake-sync:"))) {
5389
+ return true;
5390
+ }
5179
5391
  if (this.evictStaleRecordsEveryHrs <= 0) return false;
5180
5392
  const intervalMs = this.evictStaleRecordsEveryHrs * 36e5;
5181
5393
  if (!this._lastEvictionDate) {
@@ -5656,7 +5868,7 @@ var DexieDb = class extends Dexie {
5656
5868
  const existing = await this.dirtyChanges.get([collection, stringId]);
5657
5869
  const now = Date.now();
5658
5870
  if (existing) {
5659
- Object.assign(existing.changes, changes);
5871
+ mergeDirtyChanges(existing.changes, changes);
5660
5872
  existing.updatedAt = now;
5661
5873
  await this.dirtyChanges.put(existing);
5662
5874
  } else {
@@ -5684,7 +5896,7 @@ var DexieDb = class extends Dexie {
5684
5896
  const stringId = this.idToString(changeItem.id);
5685
5897
  const existing = existingEntries[i];
5686
5898
  if (existing) {
5687
- Object.assign(existing.changes, changeItem.changes);
5899
+ mergeDirtyChanges(existing.changes, changeItem.changes);
5688
5900
  existing.updatedAt = now;
5689
5901
  toWrite.push(existing);
5690
5902
  } else {
@@ -53,6 +53,8 @@ export declare class SyncedDb implements I_SyncedDb {
53
53
  private readonly onEvictionStart?;
54
54
  private readonly onEviction?;
55
55
  private readonly evictStaleRecordsEveryHrs;
56
+ private readonly scopeExitLookbehindMs;
57
+ private readonly evictOnWake;
56
58
  private _lastEvictionDate?;
57
59
  constructor(config: SyncedDbConfig);
58
60
  getInstanceId(): string;
@@ -295,12 +297,30 @@ export declare class SyncedDb implements I_SyncedDb {
295
297
  * info and still fires the callback so consumers see a lifecycle event.
296
298
  */
297
299
  private _applyScopeExitPlan;
300
+ /**
301
+ * Compute the `timestamp` value used in server-assisted scope-exit
302
+ * `findNewerMany` specs for a given collection. Honors the global
303
+ * `scopeExitLookbehindMs` config: when > 0, the scope-exit query
304
+ * uses `_ts > now - lookbehindMs`; otherwise falls back to the
305
+ * collection's lastSyncTs cursor (legacy behavior).
306
+ *
307
+ * Per-call override (`evictOutOfScopeRecords({outOfWindowLookbehindMs})`)
308
+ * is computed at the call site — this helper covers the auto-eviction
309
+ * path and `evictOutOfScopeRecordsAll`.
310
+ */
311
+ private _scopeExitTimestamp;
298
312
  /**
299
313
  * Whether auto-eviction is due to run on the next sync. Mirrors the
300
314
  * gating logic of the old `maybeAutoEvict` (interval check + persisted
301
315
  * `__lastEviction` cursor) but split out so `sync()` can pre-compute
302
316
  * the eviction plan BEFORE issuing the streamed `findNewerMany` —
303
317
  * letting scope-exit specs ride along on the same call.
318
+ *
319
+ * `calledFrom` is honored when `evictOnWake` is true: any sync whose
320
+ * `calledFrom` starts with `'wake-sync:'` forces eviction regardless
321
+ * of the `evictStaleRecordsEveryHrs` interval gate. This ensures
322
+ * cross-device scope-exit records become visible on the very next
323
+ * wake instead of waiting up to the configured interval.
304
324
  */
305
325
  private _isAutoEvictionDue;
306
326
  /**
@@ -550,6 +550,39 @@ export interface SyncedDbConfig {
550
550
  * Default: 0 (disabled).
551
551
  */
552
552
  evictStaleRecordsEveryHrs?: number;
553
+ /**
554
+ * Default lookbehind window (ms) for server-assisted scope-exit detection
555
+ * in auto-eviction (`sync()` bundled) and `evictOutOfScopeRecordsAll`.
556
+ * When set (> 0), the scope-exit query uses `_ts > now - lookbehindMs`
557
+ * instead of `_ts > lastSyncTs`. This catches records whose server-side
558
+ * `_ts` predates `lastSyncTs` but still fail `$nor [positiveQuery]`
559
+ * server-side (typical cross-device close-out scenario where the local
560
+ * client was offline at the moment of mutation and findNewer's positive
561
+ * query never re-shipped the post-mutation row).
562
+ *
563
+ * Per-call override remains via `evictOutOfScopeRecords({outOfWindowLookbehindMs})`.
564
+ *
565
+ * Default: 0 (= use lastSyncTs cursor — current behavior).
566
+ * Recommended for multi-device deployments: 3 * 24 * 3600 * 1000 (3 days).
567
+ */
568
+ scopeExitLookbehindMs?: number;
569
+ /**
570
+ * Force scope-exit eviction on every wake event (`calledFrom` starts
571
+ * with `'wake-sync:'`), bypassing the `evictStaleRecordsEveryHrs`
572
+ * interval gate. Eviction is bundled into the wake sync's
573
+ * `findNewerManyStream` call (single RTT, same as positive sync).
574
+ *
575
+ * Combine with `scopeExitLookbehindMs` so the scope-exit query covers
576
+ * records mutated within the lookbehind window — guarantees that
577
+ * cross-device close-outs become visible to the local cache on the
578
+ * very next wake instead of waiting up to `evictStaleRecordsEveryHrs`.
579
+ *
580
+ * `evictStaleRecordsEveryHrs` still gates non-wake syncs (interval
581
+ * sync, manual `sync()` triggers without `'wake-sync:*'` prefix).
582
+ *
583
+ * Default: false.
584
+ */
585
+ evictOnWake?: boolean;
553
586
  /**
554
587
  * Callback fired before each eviction run begins, after the eligible
555
588
  * collection list has been resolved. Useful as a debug breadcrumb to
@@ -0,0 +1,76 @@
1
+ /**
2
+ * computeDiff — produces minimal MongoDB-style dot-notation paths
3
+ * representing the actual differences between `existing` and `update`.
4
+ *
5
+ * PURPOSE: Solves the "stale array upload" bug where a save() call
6
+ * with a full nested array captures unchanged fields into the dirty
7
+ * change. When the device later uploads, those unchanged fields
8
+ * overwrite concurrent edits made by other devices.
9
+ *
10
+ * INVARIANT: applyDiff(existing, computeDiff(existing, update)) deep-equals
11
+ * the merged result of `existing` ⊔ `update` (where update wins on conflicts,
12
+ * and missing keys in update preserve existing values).
13
+ *
14
+ * STRATEGY:
15
+ * - Primitives, Date, ObjectId: emit single path if value differs
16
+ * - Plain objects: recurse, accumulating paths
17
+ * - Arrays of objects with _id (same composition + order): element-wise diff
18
+ * - Arrays with composition or order changes: full replace at array path
19
+ * - Arrays of primitives or mixed types: full replace
20
+ *
21
+ * MongoDB native supports dot-notation in $set, so output paths can be
22
+ * passed directly to the server without protocol changes.
23
+ */
24
+ /** Deep equality for values that may include Date, ObjectId, plain objects, arrays. */
25
+ export declare function deepEquals(a: any, b: any): boolean;
26
+ /**
27
+ * Compute the minimal set of MongoDB dot-notation $set paths
28
+ * that transform `existing` into `update` (only for keys present in `update`).
29
+ *
30
+ * @returns Record where keys are dot-notation paths and values are new values.
31
+ * Empty object means no changes.
32
+ *
33
+ * @example
34
+ * existing = { koraki: [{ _id: "k1", diag: "old", ter: "x" }] }
35
+ * update = { koraki: [{ _id: "k1", diag: "new", ter: "x" }] }
36
+ * → { "koraki.0.diag": "new" }
37
+ *
38
+ * @example
39
+ * existing = { koraki: [{ _id: "k1" }] }
40
+ * update = { koraki: [{ _id: "k1" }, { _id: "k2", diag: "x" }] }
41
+ * → { "koraki": [{ _id: "k1" }, { _id: "k2", diag: "x" }] } // composition change
42
+ */
43
+ export declare function computeDiff(existing: Record<string, any> | null | undefined, update: Record<string, any>): Record<string, any>;
44
+ /**
45
+ * Detect whether a `changes` object uses dot-notation paths (new format)
46
+ * or top-level keys with full nested values (legacy format).
47
+ *
48
+ * Used to decide whether smart path-based merging applies, or fall back
49
+ * to legacy Object.assign accumulation.
50
+ */
51
+ export declare function hasDotNotationPaths(changes: Record<string, any>): boolean;
52
+ /**
53
+ * Smart-merge a single (path, value) entry into an accumulated dirty changes
54
+ * object. Resolves three relationships between the new path and existing keys:
55
+ *
56
+ * 1. New path is a DESCENDANT of an existing key (e.g. existing has "koraki",
57
+ * new is "koraki.0.diag"): mutate the value inside the existing parent
58
+ * rather than adding a conflicting child path.
59
+ *
60
+ * 2. New path is an ANCESTOR of existing keys (e.g. existing has "koraki.0.diag",
61
+ * new is "koraki" with full array): remove the now-redundant descendants and
62
+ * set the parent path. The new full value supersedes any field-level deltas.
63
+ *
64
+ * 3. New path is ORTHOGONAL to existing keys: simple set (Object.assign-equivalent).
65
+ *
66
+ * Used by DexieDb.addDirtyChange to accumulate changes across multiple save()
67
+ * calls without producing MongoDB-conflicting payloads (parent + child paths
68
+ * in same $set yields nondeterministic results).
69
+ */
70
+ export declare function mergeDirtyPath(accumulated: Record<string, any>, newPath: string, newValue: any): void;
71
+ /**
72
+ * Bulk-merge an entire changes object into the accumulator using path semantics.
73
+ * Order matters: paths are processed sequentially, so later entries can supersede
74
+ * earlier ones. This is acceptable because mergeDirtyPath is idempotent.
75
+ */
76
+ export declare function mergeDirtyChanges(accumulated: Record<string, any>, newChanges: Record<string, any>): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.151",
3
+ "version": "0.1.155",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",