cry-synced-db-client 0.1.151 → 0.1.156

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,138 @@
1
1
  # Versions
2
2
 
3
+ ## 0.1.156
4
+
5
+ Three related fixes targeting **dirty-payload metadata leak** and
6
+ **concurrent array merge corruption** observed in production
7
+ (tenant *prvak*, obravnave with stale offline edits silently dropping
8
+ field updates from a second device).
9
+
10
+ ### Bracket-by-_id array path notation in `computeDiff`
11
+
12
+ Array element paths emitted by `computeDiff` now use bracket notation
13
+ keyed by the element's `_id`:
14
+
15
+ ```diff
16
+ - "koraki.0.diagnostika" // position-based — corrupts on reorder
17
+ + "koraki[<korakId>].diagnostika" // identity-based — survives reorder
18
+ ```
19
+
20
+ Why: with position-based dot notation, if device B reordered or
21
+ inserted/removed earlier elements while device A was offline, A's
22
+ upload of `koraki.0.field` would mongo `$set` the wrong element on
23
+ the server.
24
+
25
+ `tokenizePath()` is now exported and accepts both `.` and `[…]`
26
+ delimiters. `setByPath` and `isDescendantOrEqual` updated to handle
27
+ bracket segments.
28
+
29
+ ### `translateBracketPathsToIndex` (upload-time)
30
+
31
+ `SyncEngine.uploadDirtyItems` translates bracket paths to mongo
32
+ `$set`-compatible index paths just before upload, using the current
33
+ local entity (post-sync, post-merge) as the source of truth for
34
+ `_id → index` mapping. Paths whose `_id` cannot be resolved (concurrent
35
+ deletion on the server) are dropped — preferring partial loss over
36
+ silently writing to the wrong index.
37
+
38
+ ### Server-managed metadata excluded from dirty payloads
39
+
40
+ `computeDiff` skips `_ts`, `_rev`, `_csq` at the top level. As a
41
+ defense-in-depth, `SyncEngine.uploadDirtyItems` strips any path
42
+ beginning with `_ts.`, `_rev.`, `_csq.` before upload (mitigates legacy
43
+ dirty rows written by older library versions).
44
+
45
+ Why: the BSON `Timestamp` shape `{t, i}` was being recursed into by
46
+ `computeDiff`, emitting paths like `_ts.t` / `_ts.i`. These conflicted
47
+ with mongo's `$currentDate` operator on the server — silent rejection
48
+ left items dirty forever and blocked subsequent uploads.
49
+
50
+ ### `mergeObjectArrays` — server composition is authoritative
51
+
52
+ When parent `_rev` says the server is newer (`parentServerWins=true`):
53
+
54
+ 1. **Drop local-only elements**. Per directive: *"če ima server višji
55
+ `_rev` in nima več elementa z nekim `_id`, client ne sme zopet
56
+ dodati tega zapisa v array"*. The server has authoritatively
57
+ removed the element; the client must not resurrect it.
58
+ 2. **Server's value wins on primitive conflicts inside surviving
59
+ sub-elements** (Option B — propagate `_rev` for *comparison only*,
60
+ never written into sub-objects).
61
+
62
+ When local `_rev >= external _rev`, behavior is unchanged: union
63
+ semantics preserve local-only elements (server is older / equal).
64
+
65
+ ### `mergeObjectArrays` — fail-fast on missing `_id`
66
+
67
+ Per directive: array elements MUST carry `_id`. If any element on
68
+ either side lacks `_id`, log `console.error` for each offender and
69
+ fall back to **whole-array replace by higher `_rev`** (parent's verdict
70
+ chooses local vs external). Prevents silent merge corruption when
71
+ schemas regress.
72
+
73
+ ### `fixDotnetArrays` (legacy mitigation)
74
+
75
+ Temporary upload-time scrubber dropping legacy position-based array
76
+ paths (`field.<digit>(.…)?`) when `serverRev > baseRev`. Marked for
77
+ removal after ~2026-05-15 once all clients have re-synced.
78
+
79
+ ## 0.1.155
80
+
81
+ Two new `SyncedDbConfig` fields targeting **cross-device scope-exit**
82
+ detection: situations where one device modifies a record so it no longer
83
+ matches the positive `syncConfig.query` (e.g. status `'odprta'` →
84
+ `'zaključena'` on a `{status: {$ne: 'zaključena'}}` query) while another
85
+ device is offline at that moment. The filtered delta feed never re-ships
86
+ the post-mutation row, so the second device's local cache otherwise
87
+ goes stale forever.
88
+
89
+ ### `scopeExitLookbehindMs?: number` (default `0`)
90
+
91
+ Default lookbehind window (ms) for server-assisted scope-exit detection
92
+ in auto-eviction (`sync()` bundled) and `evictOutOfScopeRecordsAll`.
93
+ When set (> 0), the scope-exit `findNewerMany` spec uses
94
+ `_ts > now - scopeExitLookbehindMs` instead of `_ts > lastSyncTs`.
95
+
96
+ Why: with the legacy `lastSyncTs` cursor, the scope-exit query
97
+ `{$nor:[positiveQuery], _id:{$in:chunk}, _ts > lastSyncTs}` excluded any
98
+ record whose server-side `_ts` predated the device's `lastSyncTs`.
99
+ Typical cross-device close-out scenario: device A closes a record at T0,
100
+ device B (offline at T0) reconnects at T1 and syncs many newer records,
101
+ advancing `lastSyncTs` past T0 → server returned 0 scope-exit candidates
102
+ forever even though the closed record was a clear scope-exit.
103
+
104
+ Per-call override remains via
105
+ `evictOutOfScopeRecords({outOfWindowLookbehindMs})` — now also falls
106
+ back to global `scopeExitLookbehindMs` when no per-call value is given.
107
+
108
+ Recommended for multi-device deployments: `3 * 24 * 3600 * 1000` (3 days).
109
+ Combine with periodic `refreshDatabaseFromServer()` (e.g. when
110
+ `lastInitialSync()` exceeds the same threshold) to cover drift older
111
+ than the lookbehind window.
112
+
113
+ ### `evictOnWake?: boolean` (default `false`)
114
+
115
+ When `true`, scope-exit eviction runs on every wake event (`sync()`
116
+ whose `calledFrom` starts with `'wake-sync:'`), bypassing the
117
+ `evictStaleRecordsEveryHrs` interval gate. The eviction is bundled into
118
+ the wake sync's `findNewerManyStream` call — single RTT, same shape as
119
+ existing 8h auto-eviction (`evictStaleRecordsEveryHrs` continues to gate
120
+ non-wake syncs: interval auto-sync ticks, manual `sync()` calls without
121
+ `'wake-sync:*'` prefix).
122
+
123
+ Combine with `scopeExitLookbehindMs` to guarantee that any cross-device
124
+ close-out within the lookbehind window becomes visible on the very next
125
+ wake.
126
+
127
+ ### Implementation
128
+
129
+ - `SyncedDb.ts` `_isAutoEvictionDue(calledFrom?)` — wake-sync trigger
130
+ bypasses interval gate when `evictOnWake = true`.
131
+ - New private `_scopeExitTimestamp(collection)` — single source of truth
132
+ for the scope-exit query timestamp; honors `scopeExitLookbehindMs`.
133
+ - `_collectScopeExitPlan` and single-collection `evictOutOfScopeRecords`
134
+ both route through the new helper.
135
+
3
136
  ## Unreleased
4
137
 
5
138
  ### `SyncSource` flag in `I_InMemDb.saveMany` / `deleteManyByIds`
package/dist/index.js CHANGED
@@ -273,6 +273,241 @@ 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
+ const elementId = String(updateArr[i]._id);
349
+ computeDiffInto(
350
+ existingArr[i],
351
+ updateArr[i],
352
+ `${basePath}[${elementId}]`,
353
+ diff
354
+ );
355
+ }
356
+ }
357
+ function computeDiffInto(existing, update, basePath, diff) {
358
+ if (deepEquals(existing, update)) return;
359
+ if (update === null || update === void 0 || typeof update !== "object" || update instanceof Date || isObjectIdLike(update)) {
360
+ diff[basePath] = update;
361
+ return;
362
+ }
363
+ if (existing === null || existing === void 0 || typeof existing !== "object" || existing instanceof Date || isObjectIdLike(existing)) {
364
+ diff[basePath] = update;
365
+ return;
366
+ }
367
+ if (Array.isArray(update) !== Array.isArray(existing)) {
368
+ diff[basePath] = update;
369
+ return;
370
+ }
371
+ if (Array.isArray(update)) {
372
+ computeArrayDiff(existing, update, basePath, diff);
373
+ return;
374
+ }
375
+ if (!isPlainObject(update) || !isPlainObject(existing)) {
376
+ diff[basePath] = update;
377
+ return;
378
+ }
379
+ for (const key of Object.keys(update)) {
380
+ const childPath = basePath ? `${basePath}.${key}` : key;
381
+ computeDiffInto(existing[key], update[key], childPath, diff);
382
+ }
383
+ }
384
+ var SERVER_MANAGED_METADATA_KEYS = /* @__PURE__ */ new Set(["_ts", "_rev", "_csq"]);
385
+ function computeDiff(existing, update) {
386
+ const diff = {};
387
+ if (!update || typeof update !== "object") return diff;
388
+ if (existing === null || existing === void 0) {
389
+ const cleaned = {};
390
+ for (const k of Object.keys(update)) {
391
+ if (SERVER_MANAGED_METADATA_KEYS.has(k)) continue;
392
+ cleaned[k] = update[k];
393
+ }
394
+ return cleaned;
395
+ }
396
+ for (const key of Object.keys(update)) {
397
+ if (SERVER_MANAGED_METADATA_KEYS.has(key)) continue;
398
+ computeDiffInto(existing[key], update[key], key, diff);
399
+ }
400
+ return diff;
401
+ }
402
+ function isDescendantOrEqual(path, candidate) {
403
+ if (path === candidate) return true;
404
+ if (!path.startsWith(candidate)) return false;
405
+ const next = path[candidate.length];
406
+ return next === "." || next === "[";
407
+ }
408
+ function tokenizePath(path) {
409
+ const out = [];
410
+ let buf = "";
411
+ for (let i = 0; i < path.length; i++) {
412
+ const ch = path[i];
413
+ if (ch === ".") {
414
+ if (buf) {
415
+ out.push(buf);
416
+ buf = "";
417
+ }
418
+ } else if (ch === "[") {
419
+ if (buf) {
420
+ out.push(buf);
421
+ buf = "";
422
+ }
423
+ const close = path.indexOf("]", i);
424
+ if (close < 0) {
425
+ buf += ch;
426
+ continue;
427
+ }
428
+ out.push(path.substring(i, close + 1));
429
+ i = close;
430
+ } else {
431
+ buf += ch;
432
+ }
433
+ }
434
+ if (buf) out.push(buf);
435
+ return out;
436
+ }
437
+ function setByPath(target2, path, value) {
438
+ if (target2 === null || target2 === void 0) return false;
439
+ const parts = tokenizePath(path);
440
+ let current = target2;
441
+ for (let i = 0; i < parts.length - 1; i++) {
442
+ const part = parts[i];
443
+ const next = navigateSegment(current, part);
444
+ if (next === void 0) return false;
445
+ current = next;
446
+ }
447
+ const last = parts[parts.length - 1];
448
+ return setSegment(current, last, value);
449
+ }
450
+ function navigateSegment(current, part) {
451
+ if (current === null || current === void 0) return void 0;
452
+ if (part.startsWith("[") && part.endsWith("]")) {
453
+ const idStr = part.slice(1, -1);
454
+ if (!Array.isArray(current)) return void 0;
455
+ return current.find((item) => item && String(item._id) === idStr);
456
+ }
457
+ if (/^\d+$/.test(part) && Array.isArray(current)) {
458
+ return current[Number(part)];
459
+ }
460
+ if (typeof current === "object") {
461
+ return current[part];
462
+ }
463
+ return void 0;
464
+ }
465
+ function setSegment(current, part, value) {
466
+ if (current === null || current === void 0) return false;
467
+ if (part.startsWith("[") && part.endsWith("]")) {
468
+ const idStr = part.slice(1, -1);
469
+ if (!Array.isArray(current)) return false;
470
+ const idx = current.findIndex((item) => item && String(item._id) === idStr);
471
+ if (idx < 0) return false;
472
+ current[idx] = value;
473
+ return true;
474
+ }
475
+ if (/^\d+$/.test(part) && Array.isArray(current)) {
476
+ current[Number(part)] = value;
477
+ return true;
478
+ }
479
+ if (typeof current === "object") {
480
+ current[part] = value;
481
+ return true;
482
+ }
483
+ return false;
484
+ }
485
+ function mergeDirtyPath(accumulated, newPath, newValue) {
486
+ for (const existingKey of Object.keys(accumulated)) {
487
+ if (existingKey === newPath) continue;
488
+ if (isDescendantOrEqual(newPath, existingKey)) {
489
+ const relativePath = newPath.substring(existingKey.length + 1);
490
+ const ok = setByPath(accumulated[existingKey], relativePath, newValue);
491
+ if (ok) return;
492
+ break;
493
+ }
494
+ }
495
+ const toDelete = [];
496
+ for (const existingKey of Object.keys(accumulated)) {
497
+ if (existingKey === newPath) continue;
498
+ if (isDescendantOrEqual(existingKey, newPath)) {
499
+ toDelete.push(existingKey);
500
+ }
501
+ }
502
+ for (const k of toDelete) delete accumulated[k];
503
+ accumulated[newPath] = newValue;
504
+ }
505
+ function mergeDirtyChanges(accumulated, newChanges) {
506
+ for (const path of Object.keys(newChanges)) {
507
+ mergeDirtyPath(accumulated, path, newChanges[path]);
508
+ }
509
+ }
510
+
276
511
  // src/db/managers/InMemManager.ts
277
512
  var InMemManager = class {
278
513
  constructor(config) {
@@ -1303,7 +1538,7 @@ var CustomTransformerRegistry = class {
1303
1538
  var getType = (payload) => Object.prototype.toString.call(payload).slice(8, -1);
1304
1539
  var isUndefined = (payload) => typeof payload === "undefined";
1305
1540
  var isNull = (payload) => payload === null;
1306
- var isPlainObject = (payload) => {
1541
+ var isPlainObject2 = (payload) => {
1307
1542
  if (typeof payload !== "object" || payload === null)
1308
1543
  return false;
1309
1544
  if (payload === Object.prototype)
@@ -1312,7 +1547,7 @@ var isPlainObject = (payload) => {
1312
1547
  return true;
1313
1548
  return Object.getPrototypeOf(payload) === Object.prototype;
1314
1549
  };
1315
- var isEmptyObject = (payload) => isPlainObject(payload) && Object.keys(payload).length === 0;
1550
+ var isEmptyObject = (payload) => isPlainObject2(payload) && Object.keys(payload).length === 0;
1316
1551
  var isArray = (payload) => Array.isArray(payload);
1317
1552
  var isString = (payload) => typeof payload === "string";
1318
1553
  var isNumber = (payload) => typeof payload === "number" && !isNaN(payload);
@@ -1625,7 +1860,7 @@ var setDeep = (object, path, mapper) => {
1625
1860
  if (isArray(parent)) {
1626
1861
  const index = +key;
1627
1862
  parent = parent[index];
1628
- } else if (isPlainObject(parent)) {
1863
+ } else if (isPlainObject2(parent)) {
1629
1864
  parent = parent[key];
1630
1865
  } else if (isSet(parent)) {
1631
1866
  const row = +key;
@@ -1651,7 +1886,7 @@ var setDeep = (object, path, mapper) => {
1651
1886
  const lastKey = path[path.length - 1];
1652
1887
  if (isArray(parent)) {
1653
1888
  parent[+lastKey] = mapper(parent[+lastKey]);
1654
- } else if (isPlainObject(parent)) {
1889
+ } else if (isPlainObject2(parent)) {
1655
1890
  parent[lastKey] = mapper(parent[lastKey]);
1656
1891
  }
1657
1892
  if (isSet(parent)) {
@@ -1736,7 +1971,7 @@ function applyReferentialEqualityAnnotations(plain, annotations, version) {
1736
1971
  }
1737
1972
  return plain;
1738
1973
  }
1739
- var isDeep = (object, superJson) => isPlainObject(object) || isArray(object) || isMap(object) || isSet(object) || isError(object) || isInstanceOfRegisteredClass(object, superJson);
1974
+ var isDeep = (object, superJson) => isPlainObject2(object) || isArray(object) || isMap(object) || isSet(object) || isError(object) || isInstanceOfRegisteredClass(object, superJson);
1740
1975
  function addIdentity(object, path, identities) {
1741
1976
  const existingSet = identities.get(object);
1742
1977
  if (existingSet) {
@@ -1814,7 +2049,7 @@ var walker = (object, identities, superJson, dedupe, path = [], objectsInThisPat
1814
2049
  transformedValue[index] = recursiveResult.transformedValue;
1815
2050
  if (isArray(recursiveResult.annotations)) {
1816
2051
  innerAnnotations[escapeKey(index)] = recursiveResult.annotations;
1817
- } else if (isPlainObject(recursiveResult.annotations)) {
2052
+ } else if (isPlainObject2(recursiveResult.annotations)) {
1818
2053
  forEach(recursiveResult.annotations, (tree, key) => {
1819
2054
  innerAnnotations[escapeKey(index) + "." + key] = tree;
1820
2055
  });
@@ -1844,7 +2079,7 @@ function isArray2(payload) {
1844
2079
  }
1845
2080
 
1846
2081
  // node_modules/is-what/dist/isPlainObject.js
1847
- function isPlainObject2(payload) {
2082
+ function isPlainObject3(payload) {
1848
2083
  if (getType2(payload) !== "Object")
1849
2084
  return false;
1850
2085
  const prototype = Object.getPrototypeOf(payload);
@@ -1869,7 +2104,7 @@ function copy(target2, options = {}) {
1869
2104
  if (isArray2(target2)) {
1870
2105
  return target2.map((item) => copy(item, options));
1871
2106
  }
1872
- if (!isPlainObject2(target2)) {
2107
+ if (!isPlainObject3(target2)) {
1873
2108
  return target2;
1874
2109
  }
1875
2110
  const props = Object.getOwnPropertyNames(target2);
@@ -2366,9 +2601,10 @@ function resolveConflict(local, external) {
2366
2601
  }
2367
2602
  return mergeObjects(local, external);
2368
2603
  }
2369
- function mergeObjects(local, external) {
2604
+ function mergeObjects(local, external, parentServerWins = false) {
2370
2605
  const result = __spreadValues({}, local);
2371
- const serverWinsOnConflict = typeof local._rev === "number" && typeof external._rev === "number" && external._rev > local._rev;
2606
+ const ownServerWins = typeof local._rev === "number" && typeof external._rev === "number" && external._rev > local._rev;
2607
+ const serverWinsOnConflict = parentServerWins || ownServerWins;
2372
2608
  for (const key of Object.keys(external)) {
2373
2609
  if (key === "_id" || key === "_dirty") {
2374
2610
  continue;
@@ -2383,16 +2619,16 @@ function mergeObjects(local, external) {
2383
2619
  continue;
2384
2620
  }
2385
2621
  if (Array.isArray(localValue) && Array.isArray(externalValue)) {
2386
- result[key] = mergeArrays(localValue, externalValue);
2387
- } else if (isPlainObject3(localValue) && isPlainObject3(externalValue)) {
2388
- result[key] = mergeObjects(localValue, externalValue);
2622
+ result[key] = mergeArrays(localValue, externalValue, serverWinsOnConflict);
2623
+ } else if (isPlainObject4(localValue) && isPlainObject4(externalValue)) {
2624
+ result[key] = mergeObjects(localValue, externalValue, serverWinsOnConflict);
2389
2625
  } else if (serverWinsOnConflict) {
2390
2626
  result[key] = externalValue;
2391
2627
  }
2392
2628
  }
2393
2629
  return result;
2394
2630
  }
2395
- function mergeArrays(local, external) {
2631
+ function mergeArrays(local, external, parentServerWins = false) {
2396
2632
  if (local.length === 0) {
2397
2633
  return external.slice();
2398
2634
  }
@@ -2403,14 +2639,56 @@ function mergeArrays(local, external) {
2403
2639
  for (const item of external) set2.add(item);
2404
2640
  return Array.from(set2);
2405
2641
  }
2406
- if (isPlainObject3(firstLocal) || isPlainObject3(firstExternal)) {
2407
- return mergeObjectArrays(local, external);
2642
+ if (isPlainObject4(firstLocal) || isPlainObject4(firstExternal)) {
2643
+ return mergeObjectArrays(local, external, parentServerWins);
2408
2644
  }
2409
2645
  const set = new Set(local);
2410
2646
  for (const item of external) set.add(item);
2411
2647
  return Array.from(set);
2412
2648
  }
2413
- function mergeObjectArrays(local, external) {
2649
+ function mergeObjectArrays(local, external, parentServerWins = false) {
2650
+ const objectItemsMissingId = [];
2651
+ for (const item of local) {
2652
+ if (item && typeof item === "object" && item._id == null) {
2653
+ objectItemsMissingId.push(item);
2654
+ }
2655
+ }
2656
+ for (const item of external) {
2657
+ if (item && typeof item === "object" && item._id == null) {
2658
+ objectItemsMissingId.push(item);
2659
+ }
2660
+ }
2661
+ if (objectItemsMissingId.length > 0) {
2662
+ for (const item of objectItemsMissingId) {
2663
+ console.error(
2664
+ "[mergeObjectArrays] array element without _id \u2014 falling back to whole-array replace by higher _rev:",
2665
+ item
2666
+ );
2667
+ }
2668
+ return parentServerWins ? external.slice() : local.slice();
2669
+ }
2670
+ if (parentServerWins) {
2671
+ const result2 = [];
2672
+ for (const extItem of external) {
2673
+ if (!extItem || typeof extItem !== "object") {
2674
+ result2.push(extItem);
2675
+ continue;
2676
+ }
2677
+ if ("_id" in extItem) {
2678
+ const localIndex = local.findIndex(
2679
+ (l) => l && typeof l === "object" && "_id" in l && String(l._id) === String(extItem._id)
2680
+ );
2681
+ if (localIndex >= 0) {
2682
+ result2.push(mergeObjects(local[localIndex], extItem, parentServerWins));
2683
+ } else {
2684
+ result2.push(extItem);
2685
+ }
2686
+ } else {
2687
+ result2.push(extItem);
2688
+ }
2689
+ }
2690
+ return result2;
2691
+ }
2414
2692
  const result = local.slice();
2415
2693
  const localIds = /* @__PURE__ */ new Map();
2416
2694
  for (let i = 0; i < local.length; i++) {
@@ -2427,7 +2705,7 @@ function mergeObjectArrays(local, external) {
2427
2705
  if ("_id" in extItem) {
2428
2706
  const localIndex = localIds.get(String(extItem._id));
2429
2707
  if (localIndex !== void 0) {
2430
- result[localIndex] = mergeObjects(result[localIndex], extItem);
2708
+ result[localIndex] = mergeObjects(result[localIndex], extItem, parentServerWins);
2431
2709
  } else {
2432
2710
  result.push(extItem);
2433
2711
  }
@@ -2437,10 +2715,77 @@ function mergeObjectArrays(local, external) {
2437
2715
  }
2438
2716
  return result;
2439
2717
  }
2440
- function isPlainObject3(value) {
2718
+ function isPlainObject4(value) {
2441
2719
  return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
2442
2720
  }
2443
2721
 
2722
+ // src/utils/fixDotnetArrays.ts
2723
+ function hasArrayIndexPath(key) {
2724
+ return /\.\d+(\.|$)/.test(key);
2725
+ }
2726
+ function fixDotnetArrays(changes, serverRev, baseRev) {
2727
+ if (typeof serverRev !== "number" || typeof baseRev !== "number") {
2728
+ return changes;
2729
+ }
2730
+ if (serverRev <= baseRev) {
2731
+ return changes;
2732
+ }
2733
+ const cleaned = {};
2734
+ for (const [key, value] of Object.entries(changes)) {
2735
+ if (hasArrayIndexPath(key)) continue;
2736
+ cleaned[key] = value;
2737
+ }
2738
+ return cleaned;
2739
+ }
2740
+
2741
+ // src/utils/translateBracketPaths.ts
2742
+ function translateBracketPathsToIndex(changes, entity) {
2743
+ const result = {};
2744
+ for (const [key, value] of Object.entries(changes)) {
2745
+ const translated = translateKey(key, entity);
2746
+ if (translated !== null) {
2747
+ result[translated] = value;
2748
+ }
2749
+ }
2750
+ return result;
2751
+ }
2752
+ function translateKey(key, entity) {
2753
+ const parts = tokenizePath(key);
2754
+ const out = [];
2755
+ let cursor = entity;
2756
+ for (let i = 0; i < parts.length; i++) {
2757
+ const part = parts[i];
2758
+ if (part.startsWith("[") && part.endsWith("]")) {
2759
+ const idStr = part.slice(1, -1);
2760
+ if (!Array.isArray(cursor)) return null;
2761
+ const idx = cursor.findIndex(
2762
+ (item) => item && String(item._id) === idStr
2763
+ );
2764
+ if (idx < 0) return null;
2765
+ out.push(String(idx));
2766
+ cursor = cursor[idx];
2767
+ continue;
2768
+ }
2769
+ out.push(part);
2770
+ if (cursor === null || cursor === void 0) {
2771
+ for (let j = i + 1; j < parts.length; j++) {
2772
+ const p = parts[j];
2773
+ if (p.startsWith("[") && p.endsWith("]")) return null;
2774
+ out.push(p);
2775
+ }
2776
+ return out.join(".");
2777
+ }
2778
+ if (/^\d+$/.test(part) && Array.isArray(cursor)) {
2779
+ cursor = cursor[Number(part)];
2780
+ } else if (typeof cursor === "object") {
2781
+ cursor = cursor[part];
2782
+ } else {
2783
+ cursor = void 0;
2784
+ }
2785
+ }
2786
+ return out.join(".");
2787
+ }
2788
+
2444
2789
  // src/db/sync/SyncEngine.ts
2445
2790
  var _SyncEngine = class _SyncEngine {
2446
2791
  constructor(config) {
@@ -2652,7 +2997,8 @@ var _SyncEngine = class _SyncEngine {
2652
2997
  if (fullItem) {
2653
2998
  const delta = dirtyChangesMap.get(String(fullItem._id));
2654
2999
  if (delta) {
2655
- updates.push({ _id: fullItem._id, delta });
3000
+ const currentServerRev = typeof fullItem._rev === "number" ? fullItem._rev : void 0;
3001
+ updates.push({ _id: fullItem._id, delta, currentServerRev, fullItem });
2656
3002
  }
2657
3003
  } else if (id != null) {
2658
3004
  const delta = dirtyChangesMap.get(String(id));
@@ -2673,10 +3019,21 @@ var _SyncEngine = class _SyncEngine {
2673
3019
  collection: collectionName,
2674
3020
  batch: {
2675
3021
  updates: updates.map((item) => {
2676
- const _a2 = item.delta, { _ts, _rev } = _a2, changes = __objRest(_a2, ["_ts", "_rev"]);
3022
+ const _a2 = item.delta, { _ts, _rev: dirtyBaseRev } = _a2, changes = __objRest(_a2, ["_ts", "_rev"]);
3023
+ const stripped = {};
3024
+ for (const [k, v] of Object.entries(changes)) {
3025
+ if (k.startsWith("_ts.") || k.startsWith("_rev.") || k.startsWith("_csq.")) continue;
3026
+ stripped[k] = v;
3027
+ }
3028
+ const fixed = fixDotnetArrays(
3029
+ stripped,
3030
+ item.currentServerRev,
3031
+ typeof dirtyBaseRev === "number" ? dirtyBaseRev : void 0
3032
+ );
3033
+ const cleanedChanges = translateBracketPathsToIndex(fixed, item.fullItem);
2677
3034
  return {
2678
3035
  _id: item._id,
2679
- update: changes
3036
+ update: cleanedChanges
2680
3037
  };
2681
3038
  }),
2682
3039
  deletes: []
@@ -3549,7 +3906,7 @@ var _SyncedDb = class _SyncedDb {
3549
3906
  this.syncOnlyCollections = null;
3550
3907
  // Sync metadata cache
3551
3908
  this.syncMetaCache = /* @__PURE__ */ new Map();
3552
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n;
3909
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p;
3553
3910
  this.tenant = config.tenant;
3554
3911
  this.dexieDb = config.dexieDb;
3555
3912
  this.inMemDb = config.inMemDb;
@@ -3576,13 +3933,15 @@ var _SyncedDb = class _SyncedDb {
3576
3933
  this.onEvictionStart = config.onEvictionStart;
3577
3934
  this.onEviction = config.onEviction;
3578
3935
  this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
3936
+ this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
3937
+ this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
3579
3938
  for (const col of config.collections) {
3580
3939
  this.collections.set(col.name, col);
3581
3940
  }
3582
3941
  this.inMemManager = new InMemManager({
3583
3942
  inMemDb: this.inMemDb,
3584
3943
  collections: this.collections,
3585
- useObjectMetadata: (_f = config.useObjectMetadata) != null ? _f : false
3944
+ useObjectMetadata: (_h = config.useObjectMetadata) != null ? _h : false
3586
3945
  });
3587
3946
  this.leaderElection = new LeaderElectionManager({
3588
3947
  tenant: this.tenant,
@@ -3617,7 +3976,7 @@ var _SyncedDb = class _SyncedDb {
3617
3976
  tenant: this.tenant,
3618
3977
  instanceId: this.syncedDbInstanceId,
3619
3978
  windowId,
3620
- debounceMs: (_g = config.crossTabSyncDebounceMs) != null ? _g : 100,
3979
+ debounceMs: (_i = config.crossTabSyncDebounceMs) != null ? _i : 100,
3621
3980
  callbacks: {
3622
3981
  onCrossTabSync: config.onCrossTabSync,
3623
3982
  onInfrastructureError: config.onInfrastructureError ? (type, message, error) => {
@@ -3643,8 +4002,8 @@ var _SyncedDb = class _SyncedDb {
3643
4002
  });
3644
4003
  this.connectionManager = new ConnectionManager({
3645
4004
  restInterface: this.restInterface,
3646
- restTimeoutMs: (_h = config.restTimeoutMs) != null ? _h : 9e4,
3647
- syncTimeoutMs: (_i = config.syncTimeoutMs) != null ? _i : 12e4,
4005
+ restTimeoutMs: (_j = config.restTimeoutMs) != null ? _j : 9e4,
4006
+ syncTimeoutMs: (_k = config.syncTimeoutMs) != null ? _k : 12e4,
3648
4007
  autoSyncIntervalMs: config.autoSyncIntervalMs,
3649
4008
  onlineRetryIntervalMs: config.onlineRetryIntervalMs,
3650
4009
  callbacks: {
@@ -3671,8 +4030,8 @@ var _SyncedDb = class _SyncedDb {
3671
4030
  });
3672
4031
  this.pendingChanges = new PendingChangesManager({
3673
4032
  tenant: this.tenant,
3674
- debounceDexieWritesMs: (_j = config.debounceDexieWritesMs) != null ? _j : 500,
3675
- debounceRestWritesMs: (_k = config.debounceRestWritesMs) != null ? _k : 100,
4033
+ debounceDexieWritesMs: (_l = config.debounceDexieWritesMs) != null ? _l : 500,
4034
+ debounceRestWritesMs: (_m = config.debounceRestWritesMs) != null ? _m : 100,
3676
4035
  callbacks: {
3677
4036
  onDexieWriteRequest: config.onDexieWriteRequest,
3678
4037
  onDexieWriteResult: config.onDexieWriteResult,
@@ -3749,8 +4108,8 @@ var _SyncedDb = class _SyncedDb {
3749
4108
  });
3750
4109
  if (config.wakeSyncEnabled) {
3751
4110
  this.wakeSync = new WakeSyncManager({
3752
- gapThresholdMs: (_l = config.wakeSyncGapThresholdMs) != null ? _l : 1e4,
3753
- debounceMs: (_m = config.wakeSyncDebounceMs) != null ? _m : 2e3,
4111
+ gapThresholdMs: (_n = config.wakeSyncGapThresholdMs) != null ? _n : 1e4,
4112
+ debounceMs: (_o = config.wakeSyncDebounceMs) != null ? _o : 2e3,
3754
4113
  callbacks: {
3755
4114
  onWakeSync: config.onWakeSync
3756
4115
  },
@@ -3765,7 +4124,7 @@ var _SyncedDb = class _SyncedDb {
3765
4124
  }
3766
4125
  if (config.networkStatusEnabled) {
3767
4126
  this.networkStatus = new NetworkStatusManager({
3768
- debounceMs: (_n = config.networkStatusDebounceMs) != null ? _n : 100,
4127
+ debounceMs: (_p = config.networkStatusDebounceMs) != null ? _p : 100,
3769
4128
  callbacks: {
3770
4129
  onBrowserNetworkChange: config.onBrowserNetworkChange,
3771
4130
  onBrowserOnline: config.onBrowserOnline,
@@ -4327,10 +4686,12 @@ var _SyncedDb = class _SyncedDb {
4327
4686
  `SyncedDb.save: Object ${String(id)} not found in ${collection}, creating new`
4328
4687
  );
4329
4688
  }
4689
+ const fullChanges = __spreadProps(__spreadValues({}, update), { _lastUpdaterId: this.updaterId });
4690
+ const diff = computeDiff(existing, fullChanges);
4330
4691
  await this.dexieDb.addDirtyChange(
4331
4692
  collection,
4332
4693
  id,
4333
- __spreadProps(__spreadValues({}, update), { _lastUpdaterId: this.updaterId }),
4694
+ diff,
4334
4695
  { _ts: existing == null ? void 0 : existing._ts, _rev: existing == null ? void 0 : existing._rev }
4335
4696
  );
4336
4697
  const newData = __spreadProps(__spreadValues({}, update), {
@@ -4534,7 +4895,7 @@ var _SyncedDb = class _SyncedDb {
4534
4895
  this.crossTabSync.startServerSync();
4535
4896
  let evictionPlan;
4536
4897
  let evictionServerFailed = false;
4537
- if (await this._isAutoEvictionDue()) {
4898
+ if (await this._isAutoEvictionDue(calledFrom)) {
4538
4899
  try {
4539
4900
  evictionPlan = await this._collectScopeExitPlan("auto");
4540
4901
  } catch (err) {
@@ -4850,11 +5211,12 @@ var _SyncedDb = class _SyncedDb {
4850
5211
  let serverEvictedCount = 0;
4851
5212
  if (serverAssisted && serverCandidateIds.length > 0) {
4852
5213
  let scopeExitTimestamp;
4853
- const lookbehindMs = opts == null ? void 0 : opts.outOfWindowLookbehindMs;
4854
- if (lookbehindMs != null && lookbehindMs > 0) {
5214
+ const callerLookbehindMs = opts == null ? void 0 : opts.outOfWindowLookbehindMs;
5215
+ const effectiveLookbehindMs = callerLookbehindMs != null && callerLookbehindMs > 0 ? callerLookbehindMs : this.scopeExitLookbehindMs;
5216
+ if (effectiveLookbehindMs > 0) {
4855
5217
  scopeExitTimestamp = Math.max(
4856
5218
  0,
4857
- Math.floor((Date.now() - lookbehindMs) / 1e3)
5219
+ Math.floor((Date.now() - effectiveLookbehindMs) / 1e3)
4858
5220
  );
4859
5221
  } else {
4860
5222
  scopeExitTimestamp = (_d = (_c = this.syncMetaCache.get(collection)) == null ? void 0 : _c.lastSyncTs) != null ? _d : 0;
@@ -5010,7 +5372,7 @@ var _SyncedDb = class _SyncedDb {
5010
5372
  * callback is a reliable lifecycle marker.
5011
5373
  */
5012
5374
  async _collectScopeExitPlan(trigger) {
5013
- var _a, _b, _c;
5375
+ var _a;
5014
5376
  const startTime = Date.now();
5015
5377
  const eligible = [];
5016
5378
  for (const [name, config] of this.collections) {
@@ -5060,7 +5422,7 @@ var _SyncedDb = class _SyncedDb {
5060
5422
  collection: name,
5061
5423
  config,
5062
5424
  query,
5063
- timestamp: (_c = (_b = this.syncMetaCache.get(name)) == null ? void 0 : _b.lastSyncTs) != null ? _c : 0,
5425
+ timestamp: this._scopeExitTimestamp(name),
5064
5426
  evictIds,
5065
5427
  localEvictedCount: evictIds.length,
5066
5428
  serverEvictedCount: 0,
@@ -5168,14 +5530,44 @@ var _SyncedDb = class _SyncedDb {
5168
5530
  this.safeCallback(this.onEviction, info);
5169
5531
  return info;
5170
5532
  }
5533
+ /**
5534
+ * Compute the `timestamp` value used in server-assisted scope-exit
5535
+ * `findNewerMany` specs for a given collection. Honors the global
5536
+ * `scopeExitLookbehindMs` config: when > 0, the scope-exit query
5537
+ * uses `_ts > now - lookbehindMs`; otherwise falls back to the
5538
+ * collection's lastSyncTs cursor (legacy behavior).
5539
+ *
5540
+ * Per-call override (`evictOutOfScopeRecords({outOfWindowLookbehindMs})`)
5541
+ * is computed at the call site — this helper covers the auto-eviction
5542
+ * path and `evictOutOfScopeRecordsAll`.
5543
+ */
5544
+ _scopeExitTimestamp(collection) {
5545
+ var _a, _b;
5546
+ if (this.scopeExitLookbehindMs > 0) {
5547
+ return Math.max(
5548
+ 0,
5549
+ Math.floor((Date.now() - this.scopeExitLookbehindMs) / 1e3)
5550
+ );
5551
+ }
5552
+ return (_b = (_a = this.syncMetaCache.get(collection)) == null ? void 0 : _a.lastSyncTs) != null ? _b : 0;
5553
+ }
5171
5554
  /**
5172
5555
  * Whether auto-eviction is due to run on the next sync. Mirrors the
5173
5556
  * gating logic of the old `maybeAutoEvict` (interval check + persisted
5174
5557
  * `__lastEviction` cursor) but split out so `sync()` can pre-compute
5175
5558
  * the eviction plan BEFORE issuing the streamed `findNewerMany` —
5176
5559
  * letting scope-exit specs ride along on the same call.
5560
+ *
5561
+ * `calledFrom` is honored when `evictOnWake` is true: any sync whose
5562
+ * `calledFrom` starts with `'wake-sync:'` forces eviction regardless
5563
+ * of the `evictStaleRecordsEveryHrs` interval gate. This ensures
5564
+ * cross-device scope-exit records become visible on the very next
5565
+ * wake instead of waiting up to the configured interval.
5177
5566
  */
5178
- async _isAutoEvictionDue() {
5567
+ async _isAutoEvictionDue(calledFrom) {
5568
+ if (this.evictOnWake && (calledFrom == null ? void 0 : calledFrom.startsWith("wake-sync:"))) {
5569
+ return true;
5570
+ }
5179
5571
  if (this.evictStaleRecordsEveryHrs <= 0) return false;
5180
5572
  const intervalMs = this.evictStaleRecordsEveryHrs * 36e5;
5181
5573
  if (!this._lastEvictionDate) {
@@ -5656,7 +6048,7 @@ var DexieDb = class extends Dexie {
5656
6048
  const existing = await this.dirtyChanges.get([collection, stringId]);
5657
6049
  const now = Date.now();
5658
6050
  if (existing) {
5659
- Object.assign(existing.changes, changes);
6051
+ mergeDirtyChanges(existing.changes, changes);
5660
6052
  existing.updatedAt = now;
5661
6053
  await this.dirtyChanges.put(existing);
5662
6054
  } else {
@@ -5684,7 +6076,7 @@ var DexieDb = class extends Dexie {
5684
6076
  const stringId = this.idToString(changeItem.id);
5685
6077
  const existing = existingEntries[i];
5686
6078
  if (existing) {
5687
- Object.assign(existing.changes, changeItem.changes);
6079
+ mergeDirtyChanges(existing.changes, changeItem.changes);
5688
6080
  existing.updatedAt = now;
5689
6081
  toWrite.push(existing);
5690
6082
  } 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,68 @@
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
+ export declare function computeDiff(existing: Record<string, any> | null | undefined, update: Record<string, any>): Record<string, any>;
27
+ /**
28
+ * Detect whether a `changes` object uses dot-notation paths (new format)
29
+ * or top-level keys with full nested values (legacy format).
30
+ *
31
+ * Used to decide whether smart path-based merging applies, or fall back
32
+ * to legacy Object.assign accumulation.
33
+ */
34
+ export declare function hasDotNotationPaths(changes: Record<string, any>): boolean;
35
+ /**
36
+ * Tokenize a dot-notation path that may contain bracket-id segments.
37
+ *
38
+ * "postavke[A].kolicina" → ["postavke", "[A]", "kolicina"]
39
+ * "koraki.0.diag" → ["koraki", "0", "diag"]
40
+ * "stranka.gsm" → ["stranka", "gsm"]
41
+ * "arr[A].sub[B].field" → ["arr", "[A]", "sub", "[B]", "field"]
42
+ */
43
+ export declare function tokenizePath(path: string): string[];
44
+ /**
45
+ * Smart-merge a single (path, value) entry into an accumulated dirty changes
46
+ * object. Resolves three relationships between the new path and existing keys:
47
+ *
48
+ * 1. New path is a DESCENDANT of an existing key (e.g. existing has "koraki",
49
+ * new is "koraki.0.diag"): mutate the value inside the existing parent
50
+ * rather than adding a conflicting child path.
51
+ *
52
+ * 2. New path is an ANCESTOR of existing keys (e.g. existing has "koraki.0.diag",
53
+ * new is "koraki" with full array): remove the now-redundant descendants and
54
+ * set the parent path. The new full value supersedes any field-level deltas.
55
+ *
56
+ * 3. New path is ORTHOGONAL to existing keys: simple set (Object.assign-equivalent).
57
+ *
58
+ * Used by DexieDb.addDirtyChange to accumulate changes across multiple save()
59
+ * calls without producing MongoDB-conflicting payloads (parent + child paths
60
+ * in same $set yields nondeterministic results).
61
+ */
62
+ export declare function mergeDirtyPath(accumulated: Record<string, any>, newPath: string, newValue: any): void;
63
+ /**
64
+ * Bulk-merge an entire changes object into the accumulator using path semantics.
65
+ * Order matters: paths are processed sequentially, so later entries can supersede
66
+ * earlier ones. This is acceptable because mergeDirtyPath is idempotent.
67
+ */
68
+ export declare function mergeDirtyChanges(accumulated: Record<string, any>, newChanges: Record<string, any>): void;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Detects whether a dirty change key contains an array index segment,
3
+ * e.g. `postavke.0.a`, `koraki.5.diag`, `arr.10`. Returns `true` if the
4
+ * key has any segment matching `^\d+$` between dots (or trailing).
5
+ *
6
+ * Examples:
7
+ * "stranka.gsm" → false (nested object navigation)
8
+ * "postavke.0.a" → true (array index 0)
9
+ * "koraki.5.diag.text" → true (array index 5)
10
+ * "koraki.5" → true (whole element at index 5)
11
+ * "koraki" → false (top-level, full array)
12
+ * "_ts.t" → false (no digit segment)
13
+ * "arr.10" → true (multi-digit index)
14
+ */
15
+ export declare function hasArrayIndexPath(key: string): boolean;
16
+ /**
17
+ * TEMPORARY mitigation: when server has advanced past client's base _rev
18
+ * (i.e., the dirty was emitted against a stale snapshot of the record),
19
+ * drop dirty keys that contain array-index dot-notation. Their position
20
+ * may no longer correspond to the element the client intended to modify.
21
+ *
22
+ * @param changes - dirty payload to be uploaded (top-level _ts/_rev already stripped)
23
+ * @param serverRev - current server _rev for this record (from local Dexie main
24
+ * AFTER the download phase brought it up to date)
25
+ * @param baseRev - dirty entry's `baseRev` (the _rev when client emitted the diff)
26
+ * @returns cleaned `changes` with array-index paths dropped if `serverRev > baseRev`,
27
+ * otherwise the input unchanged.
28
+ */
29
+ export declare function fixDotnetArrays(changes: Record<string, any>, serverRev: number | undefined, baseRev: number | undefined): Record<string, any>;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Translate every key in `changes` from bracket notation to mongo dot
3
+ * notation, resolving `[<_id>]` against the corresponding sub-array of
4
+ * `entity`. Drops keys whose `_id` cannot be resolved (= element was
5
+ * removed by another writer).
6
+ */
7
+ export declare function translateBracketPathsToIndex(changes: Record<string, any>, entity: any): Record<string, any>;
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.156",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",