cry-synced-db-client 0.1.155 → 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,49 +1,137 @@
1
1
  # Versions
2
2
 
3
- ## 0.1.154
3
+ ## 0.1.156
4
4
 
5
- ### `evictOnWake` config
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).
6
9
 
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.
10
+ ### Bracket-by-_id array path notation in `computeDiff`
12
11
 
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`.
12
+ Array element paths emitted by `computeDiff` now use bracket notation
13
+ keyed by the element's `_id`:
17
14
 
18
- `evictStaleRecordsEveryHrs` continues to gate non-wake syncs (interval
19
- auto-sync ticks, manual `sync()` calls without `'wake-sync:*'` prefix).
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.
20
78
 
21
- Default: `false`.
79
+ ## 0.1.155
22
80
 
23
- ## 0.1.153
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.
24
88
 
25
- ### `scopeExitLookbehindMs` config
89
+ ### `scopeExitLookbehindMs?: number` (default `0`)
26
90
 
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.
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`.
31
95
 
32
96
  Why: with the legacy `lastSyncTs` cursor, the scope-exit query
33
97
  `{$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.
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.
47
135
 
48
136
  ## Unreleased
49
137
 
package/dist/index.js CHANGED
@@ -345,10 +345,11 @@ function computeArrayDiff(existingArr, updateArr, basePath, diff) {
345
345
  return;
346
346
  }
347
347
  for (let i = 0; i < updateArr.length; i++) {
348
+ const elementId = String(updateArr[i]._id);
348
349
  computeDiffInto(
349
350
  existingArr[i],
350
351
  updateArr[i],
351
- `${basePath}.${i}`,
352
+ `${basePath}[${elementId}]`,
352
353
  diff
353
354
  );
354
355
  }
@@ -380,49 +381,106 @@ function computeDiffInto(existing, update, basePath, diff) {
380
381
  computeDiffInto(existing[key], update[key], childPath, diff);
381
382
  }
382
383
  }
384
+ var SERVER_MANAGED_METADATA_KEYS = /* @__PURE__ */ new Set(["_ts", "_rev", "_csq"]);
383
385
  function computeDiff(existing, update) {
384
386
  const diff = {};
385
387
  if (!update || typeof update !== "object") return diff;
386
388
  if (existing === null || existing === void 0) {
387
- return __spreadValues({}, update);
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;
388
395
  }
389
396
  for (const key of Object.keys(update)) {
397
+ if (SERVER_MANAGED_METADATA_KEYS.has(key)) continue;
390
398
  computeDiffInto(existing[key], update[key], key, diff);
391
399
  }
392
400
  return diff;
393
401
  }
394
402
  function isDescendantOrEqual(path, candidate) {
395
403
  if (path === candidate) return true;
396
- return path.startsWith(candidate + ".");
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;
397
436
  }
398
437
  function setByPath(target2, path, value) {
399
438
  if (target2 === null || target2 === void 0) return false;
400
- const parts = path.split(".");
439
+ const parts = tokenizePath(path);
401
440
  let current = target2;
402
441
  for (let i = 0; i < parts.length - 1; i++) {
403
442
  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;
443
+ const next = navigateSegment(current, part);
444
+ if (next === void 0) return false;
414
445
  current = next;
415
446
  }
416
447
  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;
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);
424
456
  }
425
- return true;
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;
426
484
  }
427
485
  function mergeDirtyPath(accumulated, newPath, newValue) {
428
486
  for (const existingKey of Object.keys(accumulated)) {
@@ -2543,9 +2601,10 @@ function resolveConflict(local, external) {
2543
2601
  }
2544
2602
  return mergeObjects(local, external);
2545
2603
  }
2546
- function mergeObjects(local, external) {
2604
+ function mergeObjects(local, external, parentServerWins = false) {
2547
2605
  const result = __spreadValues({}, local);
2548
- 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;
2549
2608
  for (const key of Object.keys(external)) {
2550
2609
  if (key === "_id" || key === "_dirty") {
2551
2610
  continue;
@@ -2560,16 +2619,16 @@ function mergeObjects(local, external) {
2560
2619
  continue;
2561
2620
  }
2562
2621
  if (Array.isArray(localValue) && Array.isArray(externalValue)) {
2563
- result[key] = mergeArrays(localValue, externalValue);
2622
+ result[key] = mergeArrays(localValue, externalValue, serverWinsOnConflict);
2564
2623
  } else if (isPlainObject4(localValue) && isPlainObject4(externalValue)) {
2565
- result[key] = mergeObjects(localValue, externalValue);
2624
+ result[key] = mergeObjects(localValue, externalValue, serverWinsOnConflict);
2566
2625
  } else if (serverWinsOnConflict) {
2567
2626
  result[key] = externalValue;
2568
2627
  }
2569
2628
  }
2570
2629
  return result;
2571
2630
  }
2572
- function mergeArrays(local, external) {
2631
+ function mergeArrays(local, external, parentServerWins = false) {
2573
2632
  if (local.length === 0) {
2574
2633
  return external.slice();
2575
2634
  }
@@ -2581,13 +2640,55 @@ function mergeArrays(local, external) {
2581
2640
  return Array.from(set2);
2582
2641
  }
2583
2642
  if (isPlainObject4(firstLocal) || isPlainObject4(firstExternal)) {
2584
- return mergeObjectArrays(local, external);
2643
+ return mergeObjectArrays(local, external, parentServerWins);
2585
2644
  }
2586
2645
  const set = new Set(local);
2587
2646
  for (const item of external) set.add(item);
2588
2647
  return Array.from(set);
2589
2648
  }
2590
- 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
+ }
2591
2692
  const result = local.slice();
2592
2693
  const localIds = /* @__PURE__ */ new Map();
2593
2694
  for (let i = 0; i < local.length; i++) {
@@ -2604,7 +2705,7 @@ function mergeObjectArrays(local, external) {
2604
2705
  if ("_id" in extItem) {
2605
2706
  const localIndex = localIds.get(String(extItem._id));
2606
2707
  if (localIndex !== void 0) {
2607
- result[localIndex] = mergeObjects(result[localIndex], extItem);
2708
+ result[localIndex] = mergeObjects(result[localIndex], extItem, parentServerWins);
2608
2709
  } else {
2609
2710
  result.push(extItem);
2610
2711
  }
@@ -2618,6 +2719,73 @@ function isPlainObject4(value) {
2618
2719
  return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
2619
2720
  }
2620
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
+
2621
2789
  // src/db/sync/SyncEngine.ts
2622
2790
  var _SyncEngine = class _SyncEngine {
2623
2791
  constructor(config) {
@@ -2829,7 +2997,8 @@ var _SyncEngine = class _SyncEngine {
2829
2997
  if (fullItem) {
2830
2998
  const delta = dirtyChangesMap.get(String(fullItem._id));
2831
2999
  if (delta) {
2832
- 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 });
2833
3002
  }
2834
3003
  } else if (id != null) {
2835
3004
  const delta = dirtyChangesMap.get(String(id));
@@ -2850,10 +3019,21 @@ var _SyncEngine = class _SyncEngine {
2850
3019
  collection: collectionName,
2851
3020
  batch: {
2852
3021
  updates: updates.map((item) => {
2853
- 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);
2854
3034
  return {
2855
3035
  _id: item._id,
2856
- update: changes
3036
+ update: cleanedChanges
2857
3037
  };
2858
3038
  }),
2859
3039
  deletes: []
@@ -23,23 +23,6 @@
23
23
  */
24
24
  /** Deep equality for values that may include Date, ObjectId, plain objects, arrays. */
25
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
26
  export declare function computeDiff(existing: Record<string, any> | null | undefined, update: Record<string, any>): Record<string, any>;
44
27
  /**
45
28
  * Detect whether a `changes` object uses dot-notation paths (new format)
@@ -49,6 +32,15 @@ export declare function computeDiff(existing: Record<string, any> | null | undef
49
32
  * to legacy Object.assign accumulation.
50
33
  */
51
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[];
52
44
  /**
53
45
  * Smart-merge a single (path, value) entry into an accumulated dirty changes
54
46
  * object. Resolves three relationships between the new path and existing keys:
@@ -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.155",
3
+ "version": "0.1.156",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",