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 +121 -33
- package/dist/index.js +213 -33
- package/dist/src/utils/computeDiff.d.ts +9 -17
- package/dist/src/utils/fixDotnetArrays.d.ts +29 -0
- package/dist/src/utils/translateBracketPaths.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,49 +1,137 @@
|
|
|
1
1
|
# Versions
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.156
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
79
|
+
## 0.1.155
|
|
22
80
|
|
|
23
|
-
|
|
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`
|
|
89
|
+
### `scopeExitLookbehindMs?: number` (default `0`)
|
|
26
90
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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`
|
|
35
|
-
cross-device close-out scenario
|
|
36
|
-
|
|
37
|
-
`lastSyncTs` past
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
405
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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>;
|