@procedures/store 0.48.1 → 0.49.0
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 +37 -0
- package/README.md +43 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/hybrid.d.ts +78 -1
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +187 -23
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/queue.d.ts +11 -0
- package/dist/storage/queue.d.ts.map +1 -1
- package/dist/storage/queue.js +20 -0
- package/dist/storage/queue.js.map +1 -1
- package/package.json +4 -4
- package/src/storage/hybrid.ts +233 -26
- package/src/storage/index.ts +1 -0
- package/src/storage/queue.ts +27 -0
package/dist/storage/hybrid.js
CHANGED
|
@@ -49,7 +49,7 @@ import { InMemoryStorage, StorageListeners } from "@procedures/collections";
|
|
|
49
49
|
import { queryEntries, queryOptionsProblems, selectorProblems, versionConflictsOf, } from "@procedures/client/query";
|
|
50
50
|
import { optionalMs, realClock } from "@procedures/client/browser";
|
|
51
51
|
import { backoffDelay } from "@mark1russell7/service/backoff";
|
|
52
|
-
import { MemoryQueueStore, idsOf, withoutId } from "./queue.js";
|
|
52
|
+
import { MemoryQueueStore, idsOf, singlesOf, withoutId } from "./queue.js";
|
|
53
53
|
import { isSyncState } from "./sync-state.js";
|
|
54
54
|
const MODES = new Set(["local-only", "write-through", "write-back", "remote-only", "read-only"]);
|
|
55
55
|
/** True for the modes that keep a local copy and read the remote changes into it. */
|
|
@@ -283,6 +283,8 @@ const defaultIsRefusal = (error) => {
|
|
|
283
283
|
const code = error?.code;
|
|
284
284
|
return typeof code === "string" && REFUSAL_CODES.has(code);
|
|
285
285
|
};
|
|
286
|
+
/** The default of `isTooLarge`: an error with the code `PAYLOAD_TOO_LARGE` (roadmap CLI-108). */
|
|
287
|
+
const defaultIsTooLarge = (error) => error?.code === "PAYLOAD_TOO_LARGE";
|
|
286
288
|
/** True when a remote does not know a procedure: an older server without `changes`. */
|
|
287
289
|
const isNotFound = (error) => typeof error === "object" && error !== null && error.code === "NOT_FOUND";
|
|
288
290
|
const defaultKeyOf = (item) => {
|
|
@@ -469,16 +471,20 @@ class BaseVersions extends Map {
|
|
|
469
471
|
changed = new Set();
|
|
470
472
|
/** True when the storage has a sync state store */
|
|
471
473
|
tracked = false;
|
|
474
|
+
/** This function gets each change of a version, after the change. */
|
|
475
|
+
onChange;
|
|
472
476
|
set(id, version) {
|
|
473
477
|
super.set(id, version);
|
|
474
478
|
if (this.tracked)
|
|
475
479
|
this.changed.add(id);
|
|
480
|
+
this.onChange?.(id, version);
|
|
476
481
|
return this;
|
|
477
482
|
}
|
|
478
483
|
delete(id) {
|
|
479
484
|
const had = super.delete(id);
|
|
480
485
|
if (had && this.tracked)
|
|
481
486
|
this.changed.add(id);
|
|
487
|
+
this.onChange?.(id, undefined);
|
|
482
488
|
return had;
|
|
483
489
|
}
|
|
484
490
|
}
|
|
@@ -569,6 +575,13 @@ export class HybridStorage {
|
|
|
569
575
|
changesUnsupported = false;
|
|
570
576
|
/** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
|
|
571
577
|
bases = new BaseVersions();
|
|
578
|
+
/**
|
|
579
|
+
* With `versionOf`, the policy `merge` and `capture: "observe"`, this map holds the remote value at the known remote
|
|
580
|
+
* version of each id (roadmap CLI-109). A captured change has no value before it, so its base value comes from here.
|
|
581
|
+
* A write of the storage reads its base value from the local copy, and needs no map. A new version of an id drops
|
|
582
|
+
* its value. A sync or a send gives the value of the new version.
|
|
583
|
+
*/
|
|
584
|
+
baseValues = new Map();
|
|
572
585
|
/**
|
|
573
586
|
* With `versionOf`: the version of each local item that the storage wrote or read. A pull does
|
|
574
587
|
* not read an item whose version it knows. The version of an id that is not here is unknown.
|
|
@@ -600,6 +613,10 @@ export class HybridStorage {
|
|
|
600
613
|
resetPull = false;
|
|
601
614
|
/** The stop of the capture of the local copy */
|
|
602
615
|
stopCapture;
|
|
616
|
+
/** True with `capture: "observe"`. */
|
|
617
|
+
capturing;
|
|
618
|
+
/** True when a queued write keeps its base value: with `versionOf` and the policy `merge` (roadmap CLI-109). */
|
|
619
|
+
keepsBaseValues;
|
|
603
620
|
/** The captured changes that came before the end of the load. Undefined after the load. */
|
|
604
621
|
captured = [];
|
|
605
622
|
/** With `versionOf`: the versions of the own writes that wait for their echo */
|
|
@@ -630,6 +647,7 @@ export class HybridStorage {
|
|
|
630
647
|
cacheMisses: 0,
|
|
631
648
|
syncCount: 0,
|
|
632
649
|
conflicts: 0,
|
|
650
|
+
dropped: 0,
|
|
633
651
|
};
|
|
634
652
|
constructor(remote, options = {}) {
|
|
635
653
|
// The local copy tests the preconditions of the callers (roadmap CLI-16): it gets `versionOf`
|
|
@@ -652,7 +670,9 @@ export class HybridStorage {
|
|
|
652
670
|
offlineQueue: options.offlineQueue ?? true,
|
|
653
671
|
isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
|
|
654
672
|
isRefusal: options.isRefusal ?? defaultIsRefusal,
|
|
673
|
+
isTooLarge: options.isTooLarge ?? defaultIsTooLarge,
|
|
655
674
|
...(options.onRefused !== undefined && { onRefused: options.onRefused }),
|
|
675
|
+
...(options.onDropped !== undefined && { onDropped: options.onDropped }),
|
|
656
676
|
};
|
|
657
677
|
this.statusListeners = new StorageListeners(options.onListenerError);
|
|
658
678
|
this.queryPolicy = options.queryPolicy;
|
|
@@ -667,6 +687,10 @@ export class HybridStorage {
|
|
|
667
687
|
this.aroundRemoteApply = options.aroundRemoteApply;
|
|
668
688
|
this.stateStore = options.syncState;
|
|
669
689
|
this.bases.tracked = this.stateStore !== undefined;
|
|
690
|
+
this.bases.onChange = (id, version) => {
|
|
691
|
+
if (this.baseValues.get(id)?.version !== version)
|
|
692
|
+
this.baseValues.delete(id);
|
|
693
|
+
};
|
|
670
694
|
this.localGiven = options.local !== undefined;
|
|
671
695
|
// The timers on the clock, with the backoff after a failure (roadmap CLI-13)
|
|
672
696
|
const factor = options.backoff?.factor ?? 2;
|
|
@@ -703,6 +727,8 @@ export class HybridStorage {
|
|
|
703
727
|
const capture = options.capture ?? "none";
|
|
704
728
|
if (capture !== "none" && capture !== "observe")
|
|
705
729
|
throw new TypeError(`Unknown capture ${JSON.stringify(capture)}: use none or observe`);
|
|
730
|
+
this.capturing = capture === "observe";
|
|
731
|
+
this.keepsBaseValues = this.options.conflictResolution === "merge" && this.options.versionOf !== undefined;
|
|
706
732
|
if (capture === "observe") {
|
|
707
733
|
if (!this.local.observe)
|
|
708
734
|
throw new TypeError('capture: "observe" needs a local copy with observe()');
|
|
@@ -855,6 +881,10 @@ export class HybridStorage {
|
|
|
855
881
|
op.base = previous.base;
|
|
856
882
|
else
|
|
857
883
|
delete op.base;
|
|
884
|
+
if (previous.baseValues)
|
|
885
|
+
op.baseValues = previous.baseValues;
|
|
886
|
+
else
|
|
887
|
+
delete op.baseValues;
|
|
858
888
|
this.offlineOps[index] = op;
|
|
859
889
|
}
|
|
860
890
|
else {
|
|
@@ -1579,6 +1609,7 @@ export class HybridStorage {
|
|
|
1579
1609
|
this.ensureQueueRoom(op);
|
|
1580
1610
|
this.attachBase(op);
|
|
1581
1611
|
const undo = await this.snapshot(ids, origin);
|
|
1612
|
+
this.fillBaseValues(op, undo.before);
|
|
1582
1613
|
const result = await applyLocal();
|
|
1583
1614
|
this.noteOwnWrite(op);
|
|
1584
1615
|
await this.enqueue(op, undo);
|
|
@@ -1618,6 +1649,7 @@ export class HybridStorage {
|
|
|
1618
1649
|
// The status shows the queue and the connection. The caller gets PendingWriteError.
|
|
1619
1650
|
this.isOnline = false;
|
|
1620
1651
|
this.attachBase(op);
|
|
1652
|
+
this.fillBaseValues(op, undo.before);
|
|
1621
1653
|
await this.enqueue(op, undo);
|
|
1622
1654
|
this.scheduleDrain();
|
|
1623
1655
|
throw new PendingWriteError(type, error);
|
|
@@ -1765,7 +1797,7 @@ export class HybridStorage {
|
|
|
1765
1797
|
// The versions after the write: this write adds one to each id (clear adds to clearVersion)
|
|
1766
1798
|
const versions = new Map(touched.map((id) => [id, (this.writeVersions.get(id) ?? 0) + (ids === "*" ? 0 : 1)]));
|
|
1767
1799
|
const clearVersion = this.clearVersion + (ids === "*" ? 1 : 0);
|
|
1768
|
-
|
|
1800
|
+
const undo = async () => {
|
|
1769
1801
|
if (this.clearVersion !== clearVersion)
|
|
1770
1802
|
return;
|
|
1771
1803
|
for (const id of touched) {
|
|
@@ -1778,6 +1810,7 @@ export class HybridStorage {
|
|
|
1778
1810
|
await this.localSet(id, value, origin);
|
|
1779
1811
|
}
|
|
1780
1812
|
};
|
|
1813
|
+
return Object.assign(undo, { before });
|
|
1781
1814
|
}
|
|
1782
1815
|
//
|
|
1783
1816
|
// ═══ Sync Operations ═══
|
|
@@ -1942,6 +1975,7 @@ export class HybridStorage {
|
|
|
1942
1975
|
cacheMisses: this.stats.cacheMisses,
|
|
1943
1976
|
syncCount: this.stats.syncCount,
|
|
1944
1977
|
conflicts: this.stats.conflicts,
|
|
1978
|
+
dropped: this.stats.dropped,
|
|
1945
1979
|
pendingOps: this.offlineOps.length,
|
|
1946
1980
|
isOnline: this.isOnline,
|
|
1947
1981
|
mode: this.mode,
|
|
@@ -2294,6 +2328,7 @@ export class HybridStorage {
|
|
|
2294
2328
|
return false;
|
|
2295
2329
|
if (knownVersion === remoteVersion) {
|
|
2296
2330
|
this.bases.set(id, remoteVersion);
|
|
2331
|
+
this.noteBaseValue(id, remoteVersion, item);
|
|
2297
2332
|
return false;
|
|
2298
2333
|
}
|
|
2299
2334
|
const localItem = await this.readLocal(id);
|
|
@@ -2301,15 +2336,17 @@ export class HybridStorage {
|
|
|
2301
2336
|
return false;
|
|
2302
2337
|
if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
|
|
2303
2338
|
this.bases.set(id, remoteVersion);
|
|
2339
|
+
this.noteBaseValue(id, remoteVersion, item);
|
|
2304
2340
|
return false;
|
|
2305
2341
|
}
|
|
2306
|
-
return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
|
|
2342
|
+
return this.resolvePending(id, localItem, item, remoteVersion, conflicts, "pull");
|
|
2307
2343
|
}
|
|
2308
2344
|
if (knownVersion !== undefined) {
|
|
2309
2345
|
// Only the remote changed: it is not a conflict. The version of the local item is known.
|
|
2310
2346
|
if (knownVersion !== remoteVersion)
|
|
2311
2347
|
await this.localSet(id, item, this.syncOrigin);
|
|
2312
2348
|
this.bases.set(id, remoteVersion);
|
|
2349
|
+
this.noteBaseValue(id, remoteVersion, item);
|
|
2313
2350
|
return false;
|
|
2314
2351
|
}
|
|
2315
2352
|
const localItem = await this.readLocal(id);
|
|
@@ -2335,8 +2372,10 @@ export class HybridStorage {
|
|
|
2335
2372
|
await this.localSet(id, resolved, this.syncOrigin);
|
|
2336
2373
|
this.stats.conflicts++;
|
|
2337
2374
|
}
|
|
2338
|
-
if (remoteVersion !== undefined)
|
|
2375
|
+
if (remoteVersion !== undefined) {
|
|
2339
2376
|
this.bases.set(id, remoteVersion);
|
|
2377
|
+
this.noteBaseValue(id, remoteVersion, item);
|
|
2378
|
+
}
|
|
2340
2379
|
return false;
|
|
2341
2380
|
}
|
|
2342
2381
|
/** This method applies one remote delete. It gives true when the queue changed. */
|
|
@@ -2375,6 +2414,7 @@ export class HybridStorage {
|
|
|
2375
2414
|
break;
|
|
2376
2415
|
await this.localDelete(id, this.syncOrigin);
|
|
2377
2416
|
this.bases.set(id, null);
|
|
2417
|
+
await this.reportDropped({ id, value: localItem, reason: "remote-delete", during: "pull" });
|
|
2378
2418
|
return true;
|
|
2379
2419
|
case "error":
|
|
2380
2420
|
conflicts.push(id);
|
|
@@ -2390,7 +2430,7 @@ export class HybridStorage {
|
|
|
2390
2430
|
* A queued local write of an id whose remote version changed: a conflict, and the policy
|
|
2391
2431
|
* decides. True when the queue changed.
|
|
2392
2432
|
*/
|
|
2393
|
-
async resolvePending(id, localItem, remoteItem, remoteVersion, conflicts) {
|
|
2433
|
+
async resolvePending(id, localItem, remoteItem, remoteVersion, conflicts, during) {
|
|
2394
2434
|
this.stats.conflicts++;
|
|
2395
2435
|
switch (this.options.conflictResolution) {
|
|
2396
2436
|
case "remote":
|
|
@@ -2399,16 +2439,20 @@ export class HybridStorage {
|
|
|
2399
2439
|
break;
|
|
2400
2440
|
await this.localSet(id, remoteItem, this.syncOrigin);
|
|
2401
2441
|
this.bases.set(id, remoteVersion);
|
|
2442
|
+
this.noteBaseValue(id, remoteVersion, remoteItem);
|
|
2443
|
+
await this.reportDropped({ id, value: localItem, reason: "conflict", during, remote: remoteItem });
|
|
2402
2444
|
return true;
|
|
2403
2445
|
case "merge": {
|
|
2404
2446
|
if (localItem === undefined)
|
|
2405
2447
|
break;
|
|
2406
|
-
const merged = this.resolveConflict(localItem, remoteItem);
|
|
2448
|
+
const merged = this.resolveConflict(localItem, remoteItem, this.queuedBaseValue(id));
|
|
2407
2449
|
if (!this.dropQueued(id))
|
|
2408
2450
|
break;
|
|
2409
2451
|
await this.localSet(id, merged, this.syncOrigin);
|
|
2410
2452
|
this.bases.set(id, remoteVersion);
|
|
2411
|
-
this.
|
|
2453
|
+
this.noteBaseValue(id, remoteVersion, remoteItem);
|
|
2454
|
+
// The merged write changes the remote item of this conflict: that item is its base
|
|
2455
|
+
this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]], baseValues: [[id, remoteItem]] });
|
|
2412
2456
|
return true;
|
|
2413
2457
|
}
|
|
2414
2458
|
case "error":
|
|
@@ -2421,6 +2465,16 @@ export class HybridStorage {
|
|
|
2421
2465
|
this.bases.set(id, remoteVersion);
|
|
2422
2466
|
return false;
|
|
2423
2467
|
}
|
|
2468
|
+
/** This method counts a dropped write and gives it to the hook `onDropped`. An error of the hook does not stop the sync. */
|
|
2469
|
+
async reportDropped(drop) {
|
|
2470
|
+
this.stats.dropped++;
|
|
2471
|
+
try {
|
|
2472
|
+
await this.options.onDropped?.(drop);
|
|
2473
|
+
}
|
|
2474
|
+
catch {
|
|
2475
|
+
// An error of the hook does not stop the sync
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2424
2478
|
/**
|
|
2425
2479
|
* This method removes an id from the queued operations. It gives false when a queued clear touches it: nothing
|
|
2426
2480
|
* changes then.
|
|
@@ -2479,6 +2533,8 @@ export class HybridStorage {
|
|
|
2479
2533
|
async pushQueue() {
|
|
2480
2534
|
this.pushing = true;
|
|
2481
2535
|
let sent = 0;
|
|
2536
|
+
// True when a batch went again as one write for each id: the queue store gets the new operations
|
|
2537
|
+
let split = false;
|
|
2482
2538
|
// The operations whose conflicts this push resolved once (roadmap CLI-16)
|
|
2483
2539
|
const resolved = new Set();
|
|
2484
2540
|
try {
|
|
@@ -2509,18 +2565,25 @@ export class HybridStorage {
|
|
|
2509
2565
|
}
|
|
2510
2566
|
continue;
|
|
2511
2567
|
}
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
this.options.isRefusal(error)) {
|
|
2516
|
-
// The remote refused the write: it leaves the queue, and the push goes on (roadmap O67)
|
|
2568
|
+
const tooLarge = conflicts === undefined && !(error instanceof QueueStoreError) && this.options.isTooLarge(error);
|
|
2569
|
+
const refused = tooLarge || (conflicts === undefined && !(error instanceof QueueStoreError) && !this.options.isUnavailable(error) && this.options.isRefusal(error));
|
|
2570
|
+
if (refused) {
|
|
2517
2571
|
this.isOnline = true;
|
|
2572
|
+
const singles = singlesOf(op);
|
|
2573
|
+
if (singles.length > 1 && this.offlineOps[0] === op) {
|
|
2574
|
+
// A batch goes again as one write for each id, so only the bad write leaves the queue (roadmap CLI-108)
|
|
2575
|
+
this.offlineOps.splice(0, 1, ...singles);
|
|
2576
|
+
split = true;
|
|
2577
|
+
this.storeDirty = true;
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
// The remote refused the write: it leaves the queue, and the push goes on (roadmap O67)
|
|
2518
2581
|
if (this.offlineOps[0] === op) {
|
|
2519
2582
|
this.offlineOps.shift();
|
|
2520
2583
|
sent++;
|
|
2521
2584
|
this.storeDirty = true;
|
|
2522
2585
|
}
|
|
2523
|
-
await this.refuseQueued(op, error);
|
|
2586
|
+
await this.refuseQueued(op, error, tooLarge ? "too-large" : "refused");
|
|
2524
2587
|
continue;
|
|
2525
2588
|
}
|
|
2526
2589
|
// This operation and the later ones stay queued
|
|
@@ -2540,7 +2603,7 @@ export class HybridStorage {
|
|
|
2540
2603
|
}
|
|
2541
2604
|
finally {
|
|
2542
2605
|
this.pushing = false;
|
|
2543
|
-
if (sent > 0) {
|
|
2606
|
+
if (sent > 0 || split) {
|
|
2544
2607
|
// The store keeps sent operations, and the status gets the failure. A later direct write
|
|
2545
2608
|
// repairs the store first.
|
|
2546
2609
|
await this.persistAll().catch(() => undefined);
|
|
@@ -2552,7 +2615,7 @@ export class HybridStorage {
|
|
|
2552
2615
|
* gets the remote values, and the hook `onRefused` gets the refusal. A refused clear asks for a
|
|
2553
2616
|
* full pull.
|
|
2554
2617
|
*/
|
|
2555
|
-
async refuseQueued(op, error) {
|
|
2618
|
+
async refuseQueued(op, error, reason) {
|
|
2556
2619
|
const ids = idsOf(op);
|
|
2557
2620
|
const all = ids.includes("*");
|
|
2558
2621
|
let restored = false;
|
|
@@ -2568,6 +2631,10 @@ export class HybridStorage {
|
|
|
2568
2631
|
catch {
|
|
2569
2632
|
// An error of the hook does not stop the queue
|
|
2570
2633
|
}
|
|
2634
|
+
for (const id of all ? ["*"] : ids) {
|
|
2635
|
+
const written = this.writtenValue(op, id);
|
|
2636
|
+
await this.reportDropped({ id, value: written ? written.value : undefined, reason, during: "push", error });
|
|
2637
|
+
}
|
|
2571
2638
|
}
|
|
2572
2639
|
/**
|
|
2573
2640
|
* This method writes the remote values of these ids into the local copy. It skips an id that a
|
|
@@ -2630,10 +2697,57 @@ export class HybridStorage {
|
|
|
2630
2697
|
return;
|
|
2631
2698
|
}
|
|
2632
2699
|
const version = this.versionOfItem(item);
|
|
2633
|
-
if (version === undefined)
|
|
2700
|
+
if (version === undefined) {
|
|
2634
2701
|
this.bases.delete(id);
|
|
2635
|
-
|
|
2636
|
-
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
this.bases.set(id, version);
|
|
2705
|
+
this.noteBaseValue(id, version, item);
|
|
2706
|
+
}
|
|
2707
|
+
/** This method keeps the remote value of the remote version of an id, for a captured change (roadmap CLI-109). */
|
|
2708
|
+
noteBaseValue(id, version, value) {
|
|
2709
|
+
if (!this.keepsBaseValues || !this.capturing)
|
|
2710
|
+
return;
|
|
2711
|
+
if (this.bases.get(id) === version)
|
|
2712
|
+
this.baseValues.set(id, { version, value });
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* This method gives a write of the storage the base value of each id that has no queued write (roadmap CLI-109).
|
|
2716
|
+
* The value is the local item before the write, when that item has the base version. The local copy then holds the
|
|
2717
|
+
* remote value of a sync or a send.
|
|
2718
|
+
*/
|
|
2719
|
+
fillBaseValues(op, before) {
|
|
2720
|
+
if (!this.keepsBaseValues || !op.base)
|
|
2721
|
+
return;
|
|
2722
|
+
const values = op.baseValues ? [...op.baseValues] : [];
|
|
2723
|
+
const known = new Set(values.map(([id]) => id));
|
|
2724
|
+
for (const [id, version] of op.base) {
|
|
2725
|
+
if (known.has(id) || version === null)
|
|
2726
|
+
continue;
|
|
2727
|
+
const value = before.get(id);
|
|
2728
|
+
if (value !== undefined && this.versionOfItem(value) === version)
|
|
2729
|
+
values.push([id, value]);
|
|
2730
|
+
}
|
|
2731
|
+
if (values.length > 0)
|
|
2732
|
+
op.baseValues = values;
|
|
2733
|
+
}
|
|
2734
|
+
/** The remote value at a remote version of an id: `null` for no item, and undefined when the storage does not know it. */
|
|
2735
|
+
baseValueAt(id, version) {
|
|
2736
|
+
if (version === null)
|
|
2737
|
+
return null;
|
|
2738
|
+
const known = this.baseValues.get(id);
|
|
2739
|
+
return known !== undefined && known.version === version ? known.value : undefined;
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* The remote value that the queued writes of an id changed: the base value of the first queued operation of the id.
|
|
2743
|
+
* The value is undefined when that operation has none, or when it is a clear.
|
|
2744
|
+
*/
|
|
2745
|
+
queuedBaseValue(id) {
|
|
2746
|
+
const op = this.offlineOps.find((queued) => {
|
|
2747
|
+
const ids = idsOf(queued);
|
|
2748
|
+
return ids.includes(id) || ids.includes("*");
|
|
2749
|
+
});
|
|
2750
|
+
return op?.baseValues?.find(([valueId]) => valueId === id)?.[1];
|
|
2637
2751
|
}
|
|
2638
2752
|
/** This method records the remote versions after a write reached the remote. */
|
|
2639
2753
|
noteRemoteWrite(op) {
|
|
@@ -2670,15 +2784,56 @@ export class HybridStorage {
|
|
|
2670
2784
|
if (!this.options.versionOf)
|
|
2671
2785
|
return;
|
|
2672
2786
|
const base = [];
|
|
2787
|
+
const values = [];
|
|
2673
2788
|
for (const id of idsOf(op)) {
|
|
2674
2789
|
if (id === "*")
|
|
2675
2790
|
continue;
|
|
2676
2791
|
const version = this.expectedBase(id);
|
|
2677
2792
|
if (version !== undefined)
|
|
2678
2793
|
base.push([id, version]);
|
|
2794
|
+
const value = this.expectedBaseValue(id, version);
|
|
2795
|
+
if (value !== undefined)
|
|
2796
|
+
values.push([id, value]);
|
|
2679
2797
|
}
|
|
2680
2798
|
if (base.length > 0)
|
|
2681
2799
|
op.base = base;
|
|
2800
|
+
if (values.length > 0)
|
|
2801
|
+
op.baseValues = values;
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* This method gives the remote value at the version of `expectedBase`, for the policy `merge` (roadmap CLI-109). It
|
|
2805
|
+
* is the value of an older queued operation of the id. Without one, it is the kept value of the known remote version.
|
|
2806
|
+
*/
|
|
2807
|
+
expectedBaseValue(id, version) {
|
|
2808
|
+
if (!this.keepsBaseValues || version === undefined)
|
|
2809
|
+
return undefined;
|
|
2810
|
+
for (let index = this.offlineOps.length - 1; index >= 0; index--) {
|
|
2811
|
+
const op = this.offlineOps[index];
|
|
2812
|
+
const data = op.data;
|
|
2813
|
+
switch (op.type) {
|
|
2814
|
+
case "set":
|
|
2815
|
+
if (data.id === id)
|
|
2816
|
+
return data.value;
|
|
2817
|
+
break;
|
|
2818
|
+
case "delete":
|
|
2819
|
+
if (data.id === id)
|
|
2820
|
+
return null;
|
|
2821
|
+
break;
|
|
2822
|
+
case "setBatch": {
|
|
2823
|
+
const item = (data.items ?? []).findLast(([itemId]) => itemId === id);
|
|
2824
|
+
if (item)
|
|
2825
|
+
return item[1];
|
|
2826
|
+
break;
|
|
2827
|
+
}
|
|
2828
|
+
case "deleteBatch":
|
|
2829
|
+
if ((data.ids ?? []).includes(id))
|
|
2830
|
+
return null;
|
|
2831
|
+
break;
|
|
2832
|
+
case "clear":
|
|
2833
|
+
return null;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
return this.baseValueAt(id, version);
|
|
2682
2837
|
}
|
|
2683
2838
|
/** The remote version of an id after the queued operations, or undefined when it is not known. */
|
|
2684
2839
|
expectedBase(id) {
|
|
@@ -2719,7 +2874,7 @@ export class HybridStorage {
|
|
|
2719
2874
|
/**
|
|
2720
2875
|
* Resolve conflict between local and remote versions.
|
|
2721
2876
|
*/
|
|
2722
|
-
resolveConflict(local, remote) {
|
|
2877
|
+
resolveConflict(local, remote, base) {
|
|
2723
2878
|
switch (this.options.conflictResolution) {
|
|
2724
2879
|
case "local":
|
|
2725
2880
|
return local;
|
|
@@ -2729,7 +2884,7 @@ export class HybridStorage {
|
|
|
2729
2884
|
if (!this.options.mergeFn) {
|
|
2730
2885
|
throw new Error("Merge function required for conflict resolution strategy 'merge'");
|
|
2731
2886
|
}
|
|
2732
|
-
return this.options.mergeFn(local, remote);
|
|
2887
|
+
return this.options.mergeFn(local, remote, base);
|
|
2733
2888
|
case "error":
|
|
2734
2889
|
throw new Error(`Conflict detected between local and remote versions`);
|
|
2735
2890
|
default:
|
|
@@ -2757,6 +2912,10 @@ export class HybridStorage {
|
|
|
2757
2912
|
op.base = previous.base;
|
|
2758
2913
|
else
|
|
2759
2914
|
delete op.base;
|
|
2915
|
+
if (previous.baseValues)
|
|
2916
|
+
op.baseValues = previous.baseValues;
|
|
2917
|
+
else
|
|
2918
|
+
delete op.baseValues;
|
|
2760
2919
|
this.offlineOps[index] = op;
|
|
2761
2920
|
rollback = () => {
|
|
2762
2921
|
const at = this.offlineOps.indexOf(op);
|
|
@@ -3002,14 +3161,16 @@ export class HybridStorage {
|
|
|
3002
3161
|
if (remoteVersion === undefined || echo) {
|
|
3003
3162
|
if (remoteVersion === undefined)
|
|
3004
3163
|
this.bases.delete(id);
|
|
3005
|
-
else
|
|
3164
|
+
else {
|
|
3006
3165
|
this.bases.set(id, remoteVersion);
|
|
3166
|
+
this.noteBaseValue(id, remoteVersion, remoteItem);
|
|
3167
|
+
}
|
|
3007
3168
|
if (echo)
|
|
3008
3169
|
echoes.add(id);
|
|
3009
3170
|
continue;
|
|
3010
3171
|
}
|
|
3011
3172
|
const localItem = await this.readLocal(id);
|
|
3012
|
-
if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids))
|
|
3173
|
+
if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids, "push"))
|
|
3013
3174
|
queueChanged = true;
|
|
3014
3175
|
continue;
|
|
3015
3176
|
}
|
|
@@ -3040,12 +3201,15 @@ export class HybridStorage {
|
|
|
3040
3201
|
async resolveRemoteDelete(id, conflicts) {
|
|
3041
3202
|
this.stats.conflicts++;
|
|
3042
3203
|
switch (this.options.conflictResolution) {
|
|
3043
|
-
case "remote":
|
|
3204
|
+
case "remote": {
|
|
3205
|
+
const value = await this.readLocal(id);
|
|
3044
3206
|
if (!this.dropQueued(id))
|
|
3045
3207
|
break;
|
|
3046
3208
|
await this.localDelete(id, this.syncOrigin);
|
|
3047
3209
|
this.bases.set(id, null);
|
|
3210
|
+
await this.reportDropped({ id, value, reason: "remote-delete", during: "push" });
|
|
3048
3211
|
return true;
|
|
3212
|
+
}
|
|
3049
3213
|
case "error":
|
|
3050
3214
|
conflicts.push(id);
|
|
3051
3215
|
return false;
|