cry-synced-db-client 0.1.155 → 0.1.157

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,76 @@ 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
+ const topLevelArrays = /* @__PURE__ */ new Set();
2728
+ for (const [k, v] of Object.entries(changes)) {
2729
+ if (Array.isArray(v)) topLevelArrays.add(k);
2730
+ }
2731
+ const isStale = typeof serverRev === "number" && typeof baseRev === "number" && serverRev > baseRev;
2732
+ const cleaned = {};
2733
+ for (const [key, value] of Object.entries(changes)) {
2734
+ if (hasArrayIndexPath(key)) {
2735
+ const arrayField = key.split(".")[0];
2736
+ if (arrayField && topLevelArrays.has(arrayField)) continue;
2737
+ if (isStale) continue;
2738
+ }
2739
+ cleaned[key] = value;
2740
+ }
2741
+ return cleaned;
2742
+ }
2743
+
2744
+ // src/utils/translateBracketPaths.ts
2745
+ function translateBracketPathsToIndex(changes, entity) {
2746
+ const result = {};
2747
+ for (const [key, value] of Object.entries(changes)) {
2748
+ const translated = translateKey(key, entity);
2749
+ if (translated !== null) {
2750
+ result[translated] = value;
2751
+ }
2752
+ }
2753
+ return result;
2754
+ }
2755
+ function translateKey(key, entity) {
2756
+ const parts = tokenizePath(key);
2757
+ const out = [];
2758
+ let cursor = entity;
2759
+ for (let i = 0; i < parts.length; i++) {
2760
+ const part = parts[i];
2761
+ if (part.startsWith("[") && part.endsWith("]")) {
2762
+ const idStr = part.slice(1, -1);
2763
+ if (!Array.isArray(cursor)) return null;
2764
+ const idx = cursor.findIndex(
2765
+ (item) => item && String(item._id) === idStr
2766
+ );
2767
+ if (idx < 0) return null;
2768
+ out.push(String(idx));
2769
+ cursor = cursor[idx];
2770
+ continue;
2771
+ }
2772
+ out.push(part);
2773
+ if (cursor === null || cursor === void 0) {
2774
+ for (let j = i + 1; j < parts.length; j++) {
2775
+ const p = parts[j];
2776
+ if (p.startsWith("[") && p.endsWith("]")) return null;
2777
+ out.push(p);
2778
+ }
2779
+ return out.join(".");
2780
+ }
2781
+ if (/^\d+$/.test(part) && Array.isArray(cursor)) {
2782
+ cursor = cursor[Number(part)];
2783
+ } else if (typeof cursor === "object") {
2784
+ cursor = cursor[part];
2785
+ } else {
2786
+ cursor = void 0;
2787
+ }
2788
+ }
2789
+ return out.join(".");
2790
+ }
2791
+
2621
2792
  // src/db/sync/SyncEngine.ts
2622
2793
  var _SyncEngine = class _SyncEngine {
2623
2794
  constructor(config) {
@@ -2821,6 +2992,7 @@ var _SyncEngine = class _SyncEngine {
2821
2992
  dirtyChangesMap.set(String(dirtyItem._id), dirtyItem);
2822
2993
  }
2823
2994
  const updates = [];
2995
+ const skipped = [];
2824
2996
  const ids = dirtyChanges.map((dc) => dc._id);
2825
2997
  const fullItems = await this.dexieDb.getByIds(collectionName, ids);
2826
2998
  for (let i = 0; i < fullItems.length; i++) {
@@ -2829,7 +3001,10 @@ var _SyncEngine = class _SyncEngine {
2829
3001
  if (fullItem) {
2830
3002
  const delta = dirtyChangesMap.get(String(fullItem._id));
2831
3003
  if (delta) {
2832
- updates.push({ _id: fullItem._id, delta });
3004
+ const currentServerRev = typeof fullItem._rev === "number" ? fullItem._rev : void 0;
3005
+ updates.push({ _id: fullItem._id, delta, currentServerRev, fullItem });
3006
+ } else {
3007
+ skipped.push({ _id: String(fullItem._id), reason: "no-delta-for-fullitem" });
2833
3008
  }
2834
3009
  } else if (id != null) {
2835
3010
  const delta = dirtyChangesMap.get(String(id));
@@ -2837,23 +3012,69 @@ var _SyncEngine = class _SyncEngine {
2837
3012
  const reconstructed = __spreadProps(__spreadValues({}, delta), { _id: id });
2838
3013
  await this.dexieDb.save(collectionName, id, reconstructed);
2839
3014
  updates.push({ _id: id, delta });
3015
+ } else {
3016
+ skipped.push({ _id: String(id), reason: "no-delta-for-orphan" });
2840
3017
  }
3018
+ } else {
3019
+ skipped.push({ _id: "<null>", reason: "no-fullitem-no-id" });
2841
3020
  }
2842
3021
  }
2843
3022
  if (updates.length === 0) {
2844
3023
  console.warn(
2845
- `uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items`
3024
+ `uploadDirtyItems: ${collectionName} has ${dirtyChanges.length} dirty entries but 0 resolvable items`,
3025
+ skipped
2846
3026
  );
3027
+ if (this.callbacks.onUploadSkip) {
3028
+ try {
3029
+ this.callbacks.onUploadSkip({
3030
+ collection: collectionName,
3031
+ reason: "no-resolvable-items",
3032
+ dirtyCount: dirtyChanges.length,
3033
+ skippedIds: skipped.slice(0, 20).map((s) => s._id),
3034
+ skipReasons: skipped.slice(0, 20),
3035
+ calledFrom,
3036
+ timestamp: /* @__PURE__ */ new Date()
3037
+ });
3038
+ } catch (err) {
3039
+ console.error("onUploadSkip callback failed:", err);
3040
+ }
3041
+ }
2847
3042
  continue;
2848
3043
  }
3044
+ if (skipped.length > 0 && this.callbacks.onUploadSkip) {
3045
+ try {
3046
+ this.callbacks.onUploadSkip({
3047
+ collection: collectionName,
3048
+ reason: "no-resolvable-items",
3049
+ dirtyCount: dirtyChanges.length,
3050
+ skippedIds: skipped.slice(0, 20).map((s) => s._id),
3051
+ skipReasons: skipped.slice(0, 20),
3052
+ calledFrom,
3053
+ timestamp: /* @__PURE__ */ new Date()
3054
+ });
3055
+ } catch (err) {
3056
+ console.error("onUploadSkip callback failed:", err);
3057
+ }
3058
+ }
2849
3059
  collectionBatches.push([{
2850
3060
  collection: collectionName,
2851
3061
  batch: {
2852
3062
  updates: updates.map((item) => {
2853
- const _a2 = item.delta, { _ts, _rev } = _a2, changes = __objRest(_a2, ["_ts", "_rev"]);
3063
+ const _a2 = item.delta, { _ts, _rev: dirtyBaseRev } = _a2, changes = __objRest(_a2, ["_ts", "_rev"]);
3064
+ const stripped = {};
3065
+ for (const [k, v] of Object.entries(changes)) {
3066
+ if (k.startsWith("_ts.") || k.startsWith("_rev.") || k.startsWith("_csq.")) continue;
3067
+ stripped[k] = v;
3068
+ }
3069
+ const fixed = fixDotnetArrays(
3070
+ stripped,
3071
+ item.currentServerRev,
3072
+ typeof dirtyBaseRev === "number" ? dirtyBaseRev : void 0
3073
+ );
3074
+ const cleanedChanges = translateBracketPathsToIndex(fixed, item.fullItem);
2854
3075
  return {
2855
3076
  _id: item._id,
2856
- update: changes
3077
+ update: cleanedChanges
2857
3078
  };
2858
3079
  }),
2859
3080
  deletes: []
@@ -3752,6 +3973,8 @@ var _SyncedDb = class _SyncedDb {
3752
3973
  this.onWakeSync = config.onWakeSync;
3753
3974
  this.onEvictionStart = config.onEvictionStart;
3754
3975
  this.onEviction = config.onEviction;
3976
+ this.onSaveIdMismatch = config.onSaveIdMismatch;
3977
+ this.onUploadSkip = config.onUploadSkip;
3755
3978
  this.evictStaleRecordsEveryHrs = (_e = config.evictStaleRecordsEveryHrs) != null ? _e : 0;
3756
3979
  this.scopeExitLookbehindMs = (_f = config.scopeExitLookbehindMs) != null ? _f : 0;
3757
3980
  this.evictOnWake = (_g = config.evictOnWake) != null ? _g : false;
@@ -3888,7 +4111,8 @@ var _SyncedDb = class _SyncedDb {
3888
4111
  onServerWriteRequest: config.onServerWriteRequest,
3889
4112
  onServerWriteResult: config.onServerWriteResult,
3890
4113
  onFindNewerManyCall: config.onFindNewerManyCall,
3891
- onFindNewerManyResult: config.onFindNewerManyResult
4114
+ onFindNewerManyResult: config.onFindNewerManyResult,
4115
+ onUploadSkip: config.onUploadSkip
3892
4116
  },
3893
4117
  deps: {
3894
4118
  getSyncMetaCache: () => this.syncMetaCache,
@@ -4499,6 +4723,28 @@ var _SyncedDb = class _SyncedDb {
4499
4723
  );
4500
4724
  delete update._id;
4501
4725
  }
4726
+ if ("_id" in update && update._id && String(update._id) !== String(id)) {
4727
+ const updateKeys = Object.keys(update);
4728
+ const stack = (() => {
4729
+ try {
4730
+ return new Error().stack;
4731
+ } catch (e) {
4732
+ return void 0;
4733
+ }
4734
+ })();
4735
+ console.error(
4736
+ `SyncedDb.save("${collection}", "${String(id)}"): update._id (${JSON.stringify(update._id)}) does NOT match id (${JSON.stringify(String(id))}). Stripped from update to prevent stuck-dirty bug. The caller likely passed a stale this._id while building update from a freshly-generated record. Data keys: [${updateKeys.join(", ")}]`
4737
+ );
4738
+ this.safeCallback(this.onSaveIdMismatch, {
4739
+ collection,
4740
+ id: String(id),
4741
+ updateId: String(update._id),
4742
+ updateKeys,
4743
+ stack,
4744
+ timestamp: /* @__PURE__ */ new Date()
4745
+ });
4746
+ delete update._id;
4747
+ }
4502
4748
  update = _SyncedDb.stringifyObjectIds(update);
4503
4749
  const existing = await this.dexieDb.getById(collection, id);
4504
4750
  if (!existing && !((_a = this.collections.get(collection)) == null ? void 0 : _a.writeOnly)) {
@@ -52,6 +52,8 @@ export declare class SyncedDb implements I_SyncedDb {
52
52
  private readonly onWakeSync?;
53
53
  private readonly onEvictionStart?;
54
54
  private readonly onEviction?;
55
+ private readonly onSaveIdMismatch?;
56
+ private readonly onUploadSkip?;
55
57
  private readonly evictStaleRecordsEveryHrs;
56
58
  private readonly scopeExitLookbehindMs;
57
59
  private readonly evictOnWake;
@@ -261,6 +261,7 @@ export interface SyncEngineCallbacks {
261
261
  onServerWriteResult?: (info: ServerWriteResultInfo) => void;
262
262
  onFindNewerManyCall?: (info: FindNewerManyCallInfo) => void;
263
263
  onFindNewerManyResult?: (info: FindNewerManyResultInfo) => void;
264
+ onUploadSkip?: (info: import("../../types/I_SyncedDb").UploadSkipInfo) => void;
264
265
  }
265
266
  export interface SyncEngineDeps {
266
267
  getSyncMetaCache: () => Map<string, SyncMeta>;
@@ -35,6 +35,62 @@ export interface InfrastructureErrorInfo {
35
35
  /** Timestamp when error occurred */
36
36
  timestamp: Date;
37
37
  }
38
+ /**
39
+ * Payload za upload-skip callback. Sproži se, ko `uploadDirtyItems` najde
40
+ * dirty zapise v Dexie `_dirty_changes`, ampak iz njih ne more zgraditi
41
+ * resolve-able update batch (npr. fullItem manjka in `id` je null/undefined,
42
+ * ali `dirtyChangesMap.get` vrne undefined zaradi key mismatch-a).
43
+ *
44
+ * Production incident 2026-05-08 (etv/prvak/studenci/tackakp racuni stuck):
45
+ * 14 dirty zapisov nikoli ne pride na server, brez napake na rdb2/errors,
46
+ * `lastDirtySyncAt: null` (sentCount=0). Ta callback razkrije za katere
47
+ * konkretne `_id`-je se to dogaja.
48
+ */
49
+ export interface UploadSkipInfo {
50
+ /** Collection kjer je skip nastal */
51
+ collection: string;
52
+ /** Razlog za skip */
53
+ reason: "no-resolvable-items" | "no-batches";
54
+ /** Število dirty zapisov v Dexie pred skip-om */
55
+ dirtyCount: number;
56
+ /** Konkretni _id-ji, ki so šli skozi skip pot (do 20) */
57
+ skippedIds: string[];
58
+ /** Razlog za vsak skipped _id (do 20) */
59
+ skipReasons: {
60
+ _id: string;
61
+ reason: "no-fullitem-no-id" | "no-delta-for-fullitem" | "no-delta-for-orphan";
62
+ }[];
63
+ /** calledFrom iz sync flow-a */
64
+ calledFrom?: string;
65
+ /** Timestamp */
66
+ timestamp: Date;
67
+ }
68
+ /**
69
+ * Payload za save() id-mismatch callback. Sproži se, ko caller pokliče
70
+ * `save(collection, id, update)` z `update._id` ki se NE ujema z `id`.
71
+ *
72
+ * Brez popravka bi addDirtyChange shranil entry.id = id, entry.changes._id =
73
+ * update._id; pri uploadu bi server dobil update._id, vrnil ack pod update._id,
74
+ * client bi poskušal clear pod id → ne ujema → dirty stuck forever.
75
+ *
76
+ * Library v save() avtomatsko strip-a `_id` iz update preden pokliče
77
+ * addDirtyChange. Callback je samo za telemetry/syslog/alerting — caller bug
78
+ * mora biti popravljen v aplikaciji.
79
+ */
80
+ export interface SaveIdMismatchInfo {
81
+ /** Collection name */
82
+ collection: string;
83
+ /** id parameter passed to save() */
84
+ id: string;
85
+ /** _id field present in update object (mismatched) */
86
+ updateId: string;
87
+ /** Top-level keys of the update object (no values, no PII) */
88
+ updateKeys: string[];
89
+ /** Stack trace at the call site */
90
+ stack?: string;
91
+ /** Timestamp when mismatch detected */
92
+ timestamp: Date;
93
+ }
38
94
  /**
39
95
  * Callback payload for server write requests (before sending)
40
96
  */
@@ -462,6 +518,29 @@ export interface SyncedDbConfig {
462
518
  * Client code can use this to show warnings, log telemetry, or adjust behavior.
463
519
  */
464
520
  onInfrastructureError?: (info: InfrastructureErrorInfo) => void;
521
+ /**
522
+ * Callback when `uploadDirtyItems` skips dirty entries (silent skip without
523
+ * sending to server). Used to diagnose stuck-dirty pattern where dirty
524
+ * exists but `sentCount === 0` (lastDirtySyncAt stays null).
525
+ *
526
+ * Production incident 2026-05-08 — 14 stuck racuni/protokoli on prod.
527
+ * Server has no record, no rdb2 error, dirty stays forever. This callback
528
+ * reveals which dirty entries hit which skip path.
529
+ */
530
+ onUploadSkip?: (info: UploadSkipInfo) => void;
531
+ /**
532
+ * Callback when `save(collection, id, update)` is called with `update._id`
533
+ * that does NOT match `id`. Library auto-strips `_id` from update to prevent
534
+ * the stuck-dirty bug, but the caller bug should be fixed in the app code.
535
+ *
536
+ * Use this callback for telemetry/syslog/alerting — the mismatch is silent
537
+ * data corruption (dirty stays forever) without observability.
538
+ *
539
+ * Production incident 2026-05-08 (zvitorepka, 3 stuck protokoli) was caused
540
+ * by `pages/protokol.vue` calling `save("protokoli", this._id, this.protokol)`
541
+ * where `this._id` and `this.protokol._id` had drifted apart.
542
+ */
543
+ onSaveIdMismatch?: (info: SaveIdMismatchInfo) => void;
465
544
  /**
466
545
  * Enable in-memory object metadata feature.
467
546
  * When true, collections with hasMetadata=true will have their metadata callbacks invoked
@@ -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.157",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",