cry-synced-db-client 0.1.150 → 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
@@ -69,16 +69,13 @@ function matchesQuery(item, query) {
69
69
  function matchesCondition(item, key, condition) {
70
70
  const value = getNestedValue(item, key);
71
71
  if (condition === null || typeof condition !== "object") {
72
- return value === condition;
72
+ return scalarMatches(value, condition);
73
73
  }
74
74
  if (condition instanceof Date) {
75
- if (value instanceof Date) {
76
- return value.getTime() === condition.getTime();
77
- }
78
- return false;
75
+ return scalarMatches(value, condition);
79
76
  }
80
77
  if (Array.isArray(condition)) {
81
- return condition.includes(value);
78
+ return condition.some((c) => scalarMatches(value, c));
82
79
  }
83
80
  for (const [op, opValue] of Object.entries(condition)) {
84
81
  if (!matchesOperator(value, op, opValue)) {
@@ -90,29 +87,32 @@ function matchesCondition(item, key, condition) {
90
87
  function matchesOperator(value, operator, operand) {
91
88
  switch (operator) {
92
89
  case "$eq":
93
- return equals(value, operand);
90
+ return scalarMatches(value, operand);
94
91
  case "$ne":
95
- return !equals(value, operand);
92
+ return !scalarMatches(value, operand);
96
93
  case "$gt":
97
- return value > operand;
94
+ return compareScalarOrArray(value, (v) => v > operand);
98
95
  case "$gte":
99
- return value >= operand;
96
+ return compareScalarOrArray(value, (v) => v >= operand);
100
97
  case "$lt":
101
- return value < operand;
98
+ return compareScalarOrArray(value, (v) => v < operand);
102
99
  case "$lte":
103
- return value <= operand;
100
+ return compareScalarOrArray(value, (v) => v <= operand);
104
101
  case "$in":
105
102
  if (!Array.isArray(operand)) return false;
106
- return operand.some((item) => equals(value, item));
103
+ return operand.some((item) => scalarMatches(value, item));
107
104
  case "$nin":
108
105
  if (!Array.isArray(operand)) return true;
109
- return !operand.some((item) => equals(value, item));
106
+ return !operand.some((item) => scalarMatches(value, item));
110
107
  case "$exists":
111
108
  return operand ? value !== void 0 : value === void 0;
112
109
  case "$regex": {
113
- if (typeof value !== "string") return false;
114
110
  const regex = operand instanceof RegExp ? operand : new RegExp(operand);
115
- return regex.test(value);
111
+ if (typeof value === "string") return regex.test(value);
112
+ if (Array.isArray(value)) {
113
+ return value.some((v) => typeof v === "string" && regex.test(v));
114
+ }
115
+ return false;
116
116
  }
117
117
  case "$elemMatch":
118
118
  if (!Array.isArray(value)) return false;
@@ -152,6 +152,19 @@ function getNestedValue(obj, path) {
152
152
  }
153
153
  return current;
154
154
  }
155
+ function scalarMatches(value, scalar) {
156
+ if (equals(value, scalar)) return true;
157
+ if (Array.isArray(value)) {
158
+ return value.some((elem) => equals(elem, scalar));
159
+ }
160
+ return false;
161
+ }
162
+ function compareScalarOrArray(value, predicate) {
163
+ if (Array.isArray(value)) {
164
+ return value.some((v) => predicate(v));
165
+ }
166
+ return predicate(value);
167
+ }
155
168
  function equals(a, b) {
156
169
  var _a, _b;
157
170
  if (a === b) return true;
@@ -260,6 +273,183 @@ function applyQueryOpts(items, opts) {
260
273
  return result;
261
274
  }
262
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
+
263
453
  // src/db/managers/InMemManager.ts
264
454
  var InMemManager = class {
265
455
  constructor(config) {
@@ -1290,7 +1480,7 @@ var CustomTransformerRegistry = class {
1290
1480
  var getType = (payload) => Object.prototype.toString.call(payload).slice(8, -1);
1291
1481
  var isUndefined = (payload) => typeof payload === "undefined";
1292
1482
  var isNull = (payload) => payload === null;
1293
- var isPlainObject = (payload) => {
1483
+ var isPlainObject2 = (payload) => {
1294
1484
  if (typeof payload !== "object" || payload === null)
1295
1485
  return false;
1296
1486
  if (payload === Object.prototype)
@@ -1299,7 +1489,7 @@ var isPlainObject = (payload) => {
1299
1489
  return true;
1300
1490
  return Object.getPrototypeOf(payload) === Object.prototype;
1301
1491
  };
1302
- var isEmptyObject = (payload) => isPlainObject(payload) && Object.keys(payload).length === 0;
1492
+ var isEmptyObject = (payload) => isPlainObject2(payload) && Object.keys(payload).length === 0;
1303
1493
  var isArray = (payload) => Array.isArray(payload);
1304
1494
  var isString = (payload) => typeof payload === "string";
1305
1495
  var isNumber = (payload) => typeof payload === "number" && !isNaN(payload);
@@ -1612,7 +1802,7 @@ var setDeep = (object, path, mapper) => {
1612
1802
  if (isArray(parent)) {
1613
1803
  const index = +key;
1614
1804
  parent = parent[index];
1615
- } else if (isPlainObject(parent)) {
1805
+ } else if (isPlainObject2(parent)) {
1616
1806
  parent = parent[key];
1617
1807
  } else if (isSet(parent)) {
1618
1808
  const row = +key;
@@ -1638,7 +1828,7 @@ var setDeep = (object, path, mapper) => {
1638
1828
  const lastKey = path[path.length - 1];
1639
1829
  if (isArray(parent)) {
1640
1830
  parent[+lastKey] = mapper(parent[+lastKey]);
1641
- } else if (isPlainObject(parent)) {
1831
+ } else if (isPlainObject2(parent)) {
1642
1832
  parent[lastKey] = mapper(parent[lastKey]);
1643
1833
  }
1644
1834
  if (isSet(parent)) {
@@ -1723,7 +1913,7 @@ function applyReferentialEqualityAnnotations(plain, annotations, version) {
1723
1913
  }
1724
1914
  return plain;
1725
1915
  }
1726
- 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);
1727
1917
  function addIdentity(object, path, identities) {
1728
1918
  const existingSet = identities.get(object);
1729
1919
  if (existingSet) {
@@ -1801,7 +1991,7 @@ var walker = (object, identities, superJson, dedupe, path = [], objectsInThisPat
1801
1991
  transformedValue[index] = recursiveResult.transformedValue;
1802
1992
  if (isArray(recursiveResult.annotations)) {
1803
1993
  innerAnnotations[escapeKey(index)] = recursiveResult.annotations;
1804
- } else if (isPlainObject(recursiveResult.annotations)) {
1994
+ } else if (isPlainObject2(recursiveResult.annotations)) {
1805
1995
  forEach(recursiveResult.annotations, (tree, key) => {
1806
1996
  innerAnnotations[escapeKey(index) + "." + key] = tree;
1807
1997
  });
@@ -1831,7 +2021,7 @@ function isArray2(payload) {
1831
2021
  }
1832
2022
 
1833
2023
  // node_modules/is-what/dist/isPlainObject.js
1834
- function isPlainObject2(payload) {
2024
+ function isPlainObject3(payload) {
1835
2025
  if (getType2(payload) !== "Object")
1836
2026
  return false;
1837
2027
  const prototype = Object.getPrototypeOf(payload);
@@ -1856,7 +2046,7 @@ function copy(target2, options = {}) {
1856
2046
  if (isArray2(target2)) {
1857
2047
  return target2.map((item) => copy(item, options));
1858
2048
  }
1859
- if (!isPlainObject2(target2)) {
2049
+ if (!isPlainObject3(target2)) {
1860
2050
  return target2;
1861
2051
  }
1862
2052
  const props = Object.getOwnPropertyNames(target2);
@@ -2371,7 +2561,7 @@ function mergeObjects(local, external) {
2371
2561
  }
2372
2562
  if (Array.isArray(localValue) && Array.isArray(externalValue)) {
2373
2563
  result[key] = mergeArrays(localValue, externalValue);
2374
- } else if (isPlainObject3(localValue) && isPlainObject3(externalValue)) {
2564
+ } else if (isPlainObject4(localValue) && isPlainObject4(externalValue)) {
2375
2565
  result[key] = mergeObjects(localValue, externalValue);
2376
2566
  } else if (serverWinsOnConflict) {
2377
2567
  result[key] = externalValue;
@@ -2390,7 +2580,7 @@ function mergeArrays(local, external) {
2390
2580
  for (const item of external) set2.add(item);
2391
2581
  return Array.from(set2);
2392
2582
  }
2393
- if (isPlainObject3(firstLocal) || isPlainObject3(firstExternal)) {
2583
+ if (isPlainObject4(firstLocal) || isPlainObject4(firstExternal)) {
2394
2584
  return mergeObjectArrays(local, external);
2395
2585
  }
2396
2586
  const set = new Set(local);
@@ -2424,7 +2614,7 @@ function mergeObjectArrays(local, external) {
2424
2614
  }
2425
2615
  return result;
2426
2616
  }
2427
- function isPlainObject3(value) {
2617
+ function isPlainObject4(value) {
2428
2618
  return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
2429
2619
  }
2430
2620
 
@@ -3536,7 +3726,7 @@ var _SyncedDb = class _SyncedDb {
3536
3726
  this.syncOnlyCollections = null;
3537
3727
  // Sync metadata cache
3538
3728
  this.syncMetaCache = /* @__PURE__ */ new Map();
3539
- 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;
3540
3730
  this.tenant = config.tenant;
3541
3731
  this.dexieDb = config.dexieDb;
3542
3732
  this.inMemDb = config.inMemDb;
@@ -3563,13 +3753,15 @@ var _SyncedDb = class _SyncedDb {
3563
3753
  this.onEvictionStart = config.onEvictionStart;
3564
3754
  this.onEviction = config.onEviction;
3565
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;
3566
3758
  for (const col of config.collections) {
3567
3759
  this.collections.set(col.name, col);
3568
3760
  }
3569
3761
  this.inMemManager = new InMemManager({
3570
3762
  inMemDb: this.inMemDb,
3571
3763
  collections: this.collections,
3572
- useObjectMetadata: (_f = config.useObjectMetadata) != null ? _f : false
3764
+ useObjectMetadata: (_h = config.useObjectMetadata) != null ? _h : false
3573
3765
  });
3574
3766
  this.leaderElection = new LeaderElectionManager({
3575
3767
  tenant: this.tenant,
@@ -3604,7 +3796,7 @@ var _SyncedDb = class _SyncedDb {
3604
3796
  tenant: this.tenant,
3605
3797
  instanceId: this.syncedDbInstanceId,
3606
3798
  windowId,
3607
- debounceMs: (_g = config.crossTabSyncDebounceMs) != null ? _g : 100,
3799
+ debounceMs: (_i = config.crossTabSyncDebounceMs) != null ? _i : 100,
3608
3800
  callbacks: {
3609
3801
  onCrossTabSync: config.onCrossTabSync,
3610
3802
  onInfrastructureError: config.onInfrastructureError ? (type, message, error) => {
@@ -3630,8 +3822,8 @@ var _SyncedDb = class _SyncedDb {
3630
3822
  });
3631
3823
  this.connectionManager = new ConnectionManager({
3632
3824
  restInterface: this.restInterface,
3633
- restTimeoutMs: (_h = config.restTimeoutMs) != null ? _h : 9e4,
3634
- syncTimeoutMs: (_i = config.syncTimeoutMs) != null ? _i : 12e4,
3825
+ restTimeoutMs: (_j = config.restTimeoutMs) != null ? _j : 9e4,
3826
+ syncTimeoutMs: (_k = config.syncTimeoutMs) != null ? _k : 12e4,
3635
3827
  autoSyncIntervalMs: config.autoSyncIntervalMs,
3636
3828
  onlineRetryIntervalMs: config.onlineRetryIntervalMs,
3637
3829
  callbacks: {
@@ -3658,8 +3850,8 @@ var _SyncedDb = class _SyncedDb {
3658
3850
  });
3659
3851
  this.pendingChanges = new PendingChangesManager({
3660
3852
  tenant: this.tenant,
3661
- debounceDexieWritesMs: (_j = config.debounceDexieWritesMs) != null ? _j : 500,
3662
- debounceRestWritesMs: (_k = config.debounceRestWritesMs) != null ? _k : 100,
3853
+ debounceDexieWritesMs: (_l = config.debounceDexieWritesMs) != null ? _l : 500,
3854
+ debounceRestWritesMs: (_m = config.debounceRestWritesMs) != null ? _m : 100,
3663
3855
  callbacks: {
3664
3856
  onDexieWriteRequest: config.onDexieWriteRequest,
3665
3857
  onDexieWriteResult: config.onDexieWriteResult,
@@ -3736,8 +3928,8 @@ var _SyncedDb = class _SyncedDb {
3736
3928
  });
3737
3929
  if (config.wakeSyncEnabled) {
3738
3930
  this.wakeSync = new WakeSyncManager({
3739
- gapThresholdMs: (_l = config.wakeSyncGapThresholdMs) != null ? _l : 1e4,
3740
- debounceMs: (_m = config.wakeSyncDebounceMs) != null ? _m : 2e3,
3931
+ gapThresholdMs: (_n = config.wakeSyncGapThresholdMs) != null ? _n : 1e4,
3932
+ debounceMs: (_o = config.wakeSyncDebounceMs) != null ? _o : 2e3,
3741
3933
  callbacks: {
3742
3934
  onWakeSync: config.onWakeSync
3743
3935
  },
@@ -3752,7 +3944,7 @@ var _SyncedDb = class _SyncedDb {
3752
3944
  }
3753
3945
  if (config.networkStatusEnabled) {
3754
3946
  this.networkStatus = new NetworkStatusManager({
3755
- debounceMs: (_n = config.networkStatusDebounceMs) != null ? _n : 100,
3947
+ debounceMs: (_p = config.networkStatusDebounceMs) != null ? _p : 100,
3756
3948
  callbacks: {
3757
3949
  onBrowserNetworkChange: config.onBrowserNetworkChange,
3758
3950
  onBrowserOnline: config.onBrowserOnline,
@@ -4314,10 +4506,12 @@ var _SyncedDb = class _SyncedDb {
4314
4506
  `SyncedDb.save: Object ${String(id)} not found in ${collection}, creating new`
4315
4507
  );
4316
4508
  }
4509
+ const fullChanges = __spreadProps(__spreadValues({}, update), { _lastUpdaterId: this.updaterId });
4510
+ const diff = computeDiff(existing, fullChanges);
4317
4511
  await this.dexieDb.addDirtyChange(
4318
4512
  collection,
4319
4513
  id,
4320
- __spreadProps(__spreadValues({}, update), { _lastUpdaterId: this.updaterId }),
4514
+ diff,
4321
4515
  { _ts: existing == null ? void 0 : existing._ts, _rev: existing == null ? void 0 : existing._rev }
4322
4516
  );
4323
4517
  const newData = __spreadProps(__spreadValues({}, update), {
@@ -4521,7 +4715,7 @@ var _SyncedDb = class _SyncedDb {
4521
4715
  this.crossTabSync.startServerSync();
4522
4716
  let evictionPlan;
4523
4717
  let evictionServerFailed = false;
4524
- if (await this._isAutoEvictionDue()) {
4718
+ if (await this._isAutoEvictionDue(calledFrom)) {
4525
4719
  try {
4526
4720
  evictionPlan = await this._collectScopeExitPlan("auto");
4527
4721
  } catch (err) {
@@ -4837,11 +5031,12 @@ var _SyncedDb = class _SyncedDb {
4837
5031
  let serverEvictedCount = 0;
4838
5032
  if (serverAssisted && serverCandidateIds.length > 0) {
4839
5033
  let scopeExitTimestamp;
4840
- const lookbehindMs = opts == null ? void 0 : opts.outOfWindowLookbehindMs;
4841
- 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) {
4842
5037
  scopeExitTimestamp = Math.max(
4843
5038
  0,
4844
- Math.floor((Date.now() - lookbehindMs) / 1e3)
5039
+ Math.floor((Date.now() - effectiveLookbehindMs) / 1e3)
4845
5040
  );
4846
5041
  } else {
4847
5042
  scopeExitTimestamp = (_d = (_c = this.syncMetaCache.get(collection)) == null ? void 0 : _c.lastSyncTs) != null ? _d : 0;
@@ -4997,7 +5192,7 @@ var _SyncedDb = class _SyncedDb {
4997
5192
  * callback is a reliable lifecycle marker.
4998
5193
  */
4999
5194
  async _collectScopeExitPlan(trigger) {
5000
- var _a, _b, _c;
5195
+ var _a;
5001
5196
  const startTime = Date.now();
5002
5197
  const eligible = [];
5003
5198
  for (const [name, config] of this.collections) {
@@ -5047,7 +5242,7 @@ var _SyncedDb = class _SyncedDb {
5047
5242
  collection: name,
5048
5243
  config,
5049
5244
  query,
5050
- timestamp: (_c = (_b = this.syncMetaCache.get(name)) == null ? void 0 : _b.lastSyncTs) != null ? _c : 0,
5245
+ timestamp: this._scopeExitTimestamp(name),
5051
5246
  evictIds,
5052
5247
  localEvictedCount: evictIds.length,
5053
5248
  serverEvictedCount: 0,
@@ -5155,14 +5350,44 @@ var _SyncedDb = class _SyncedDb {
5155
5350
  this.safeCallback(this.onEviction, info);
5156
5351
  return info;
5157
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
+ }
5158
5374
  /**
5159
5375
  * Whether auto-eviction is due to run on the next sync. Mirrors the
5160
5376
  * gating logic of the old `maybeAutoEvict` (interval check + persisted
5161
5377
  * `__lastEviction` cursor) but split out so `sync()` can pre-compute
5162
5378
  * the eviction plan BEFORE issuing the streamed `findNewerMany` —
5163
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.
5164
5386
  */
5165
- async _isAutoEvictionDue() {
5387
+ async _isAutoEvictionDue(calledFrom) {
5388
+ if (this.evictOnWake && (calledFrom == null ? void 0 : calledFrom.startsWith("wake-sync:"))) {
5389
+ return true;
5390
+ }
5166
5391
  if (this.evictStaleRecordsEveryHrs <= 0) return false;
5167
5392
  const intervalMs = this.evictStaleRecordsEveryHrs * 36e5;
5168
5393
  if (!this._lastEvictionDate) {
@@ -5643,7 +5868,7 @@ var DexieDb = class extends Dexie {
5643
5868
  const existing = await this.dirtyChanges.get([collection, stringId]);
5644
5869
  const now = Date.now();
5645
5870
  if (existing) {
5646
- Object.assign(existing.changes, changes);
5871
+ mergeDirtyChanges(existing.changes, changes);
5647
5872
  existing.updatedAt = now;
5648
5873
  await this.dirtyChanges.put(existing);
5649
5874
  } else {
@@ -5671,7 +5896,7 @@ var DexieDb = class extends Dexie {
5671
5896
  const stringId = this.idToString(changeItem.id);
5672
5897
  const existing = existingEntries[i];
5673
5898
  if (existing) {
5674
- Object.assign(existing.changes, changeItem.changes);
5899
+ mergeDirtyChanges(existing.changes, changeItem.changes);
5675
5900
  existing.updatedAt = now;
5676
5901
  toWrite.push(existing);
5677
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;
@@ -5,6 +5,10 @@ import type { DbEntity } from "../types/DbEntity";
5
5
  * Podpira polje-operatorje: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin,
6
6
  * $exists, $regex, $elemMatch, $size, $all — in logične operatorje
7
7
  * $and, $or, $nor na top-levelu.
8
+ *
9
+ * Skalarni operandi se ujemajo tudi z array-polji (MongoDB semantika):
10
+ * `{ a: "ok" }` matcha tako `{ a: "ok" }` kot `{ a: ["ok", "more"] }`.
11
+ * Velja za neposredno enakost, $eq, $ne, $in, $nin, $gt/$gte/$lt/$lte in $regex.
8
12
  */
9
13
  export declare function matchesQuery<T extends DbEntity>(item: T, query: QuerySpec<T>): boolean;
10
14
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.150",
3
+ "version": "0.1.155",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",