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