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 +121 -33
- package/dist/index.js +281 -35
- package/dist/src/db/SyncedDb.d.ts +2 -0
- package/dist/src/db/types/managers.d.ts +1 -0
- package/dist/src/types/I_SyncedDb.d.ts +79 -0
- 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,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
|
-
|
|
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:
|
|
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>;
|