@procedures/store 0.14.1 → 0.15.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 +39 -0
- package/README.md +18 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/api.d.ts +20 -5
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +46 -6
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/hybrid.d.ts +64 -6
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +187 -19
- package/dist/storage/hybrid.js.map +1 -1
- package/package.json +4 -4
- package/src/storage/api.ts +59 -9
- package/src/storage/hybrid.ts +205 -21
package/dist/storage/hybrid.js
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* - The queue is durable through the `queueStore` that the application gives.
|
|
16
16
|
* - A sync asks for the changes since a version when the remote has `getChangesSince`.
|
|
17
17
|
* - With `versionOf`, a conflict is a change on both sides, and `conflictResolution` decides.
|
|
18
|
+
* - With `versionOf`, the push of a queued write sends its base version as `expectedVersion`
|
|
19
|
+
* (roadmap CLI-16). A `VERSION_CONFLICT` of the remote goes to `conflictResolution`, as a
|
|
20
|
+
* conflict of a pull does. The option `sendBaseVersions: false` stops it.
|
|
18
21
|
* - `pullInterval` keeps the local copy fresh.
|
|
19
22
|
* - Each write takes a copy of the value (`structuredClone`), for the local copy, the queue and the
|
|
20
23
|
* remote. The reads give the copies of the local copy, or of the remote in `remote-only` mode.
|
|
@@ -40,7 +43,7 @@
|
|
|
40
43
|
* remote delta and a full sync never run at the same time (Graph R18).
|
|
41
44
|
*/
|
|
42
45
|
import { InMemoryStorage, StorageListeners } from "@procedures/collections";
|
|
43
|
-
import { queryEntries, queryOptionsProblems, selectorProblems, } from "@procedures/client/query";
|
|
46
|
+
import { queryEntries, queryOptionsProblems, selectorProblems, versionConflictsOf, } from "@procedures/client/query";
|
|
44
47
|
import { optionalMs, realClock } from "@procedures/client/browser";
|
|
45
48
|
import { backoffDelay } from "@mark1russell7/service/backoff";
|
|
46
49
|
import { MemoryQueueStore, idsOf, withoutId } from "./queue.js";
|
|
@@ -233,6 +236,15 @@ function checkMode(mode) {
|
|
|
233
236
|
}
|
|
234
237
|
return mode;
|
|
235
238
|
}
|
|
239
|
+
/** True when two values have the same JSON. A value without JSON is never the same. */
|
|
240
|
+
function sameJson(a, b) {
|
|
241
|
+
try {
|
|
242
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
236
248
|
/** A change with an origin, when there is one. */
|
|
237
249
|
function withOrigin(change, origin) {
|
|
238
250
|
return origin === undefined ? change : { ...change, origin };
|
|
@@ -555,7 +567,10 @@ export class HybridStorage {
|
|
|
555
567
|
conflicts: 0,
|
|
556
568
|
};
|
|
557
569
|
constructor(remote, options = {}) {
|
|
558
|
-
|
|
570
|
+
// The local copy tests the preconditions of the callers (roadmap CLI-16): it gets `versionOf`
|
|
571
|
+
this.local =
|
|
572
|
+
options.local ??
|
|
573
|
+
new InMemoryStorage(options.versionOf !== undefined ? { versionOf: options.versionOf } : {});
|
|
559
574
|
this.remote = remote;
|
|
560
575
|
this.queueStore = options.queueStore ?? new MemoryQueueStore();
|
|
561
576
|
this.mode = checkMode(options.mode ?? options.writeStrategy ?? "write-through");
|
|
@@ -564,6 +579,7 @@ export class HybridStorage {
|
|
|
564
579
|
...(options.mergeFn !== undefined && { mergeFn: options.mergeFn }),
|
|
565
580
|
keyOf: options.keyOf ?? defaultKeyOf,
|
|
566
581
|
...(options.versionOf !== undefined && { versionOf: options.versionOf }),
|
|
582
|
+
sendBaseVersions: options.sendBaseVersions ?? true,
|
|
567
583
|
syncInterval: optionalMs(options.syncInterval, "syncInterval") ?? 5000,
|
|
568
584
|
pullInterval: optionalMs(options.pullInterval, "pullInterval") ?? 0,
|
|
569
585
|
syncOnInit: options.syncOnInit ?? true,
|
|
@@ -1303,21 +1319,32 @@ export class HybridStorage {
|
|
|
1303
1319
|
* This method sets an item. The storage takes a copy of the value, so a later change of the
|
|
1304
1320
|
* value does not change the local copy, the queue or the remote write. The origin of the options
|
|
1305
1321
|
* goes to the batches of `observe()`. The local copy and the remote get the origin of the storage.
|
|
1322
|
+
*
|
|
1323
|
+
* An `expectedVersion` (roadmap CLI-16) is a precondition on the item as the storage reads it.
|
|
1324
|
+
* The local copy tests it (the default local copy needs `versionOf`). In `write-through` mode, the
|
|
1325
|
+
* remote write also gets it. In `remote-only` mode, only the remote tests it.
|
|
1306
1326
|
*/
|
|
1307
1327
|
async set(id, written, options) {
|
|
1308
1328
|
const value = structuredClone(written);
|
|
1309
1329
|
const origin = options?.origin;
|
|
1330
|
+
const expected = this.expectedOf(options);
|
|
1310
1331
|
await this.write("set", { id, value }, [id], origin, async () => {
|
|
1311
1332
|
this.bump(id);
|
|
1312
|
-
await this.localSet(id, value, origin);
|
|
1313
|
-
}, () => this.remote.set(id, value, this.own));
|
|
1333
|
+
await this.localSet(id, value, origin, expected);
|
|
1334
|
+
}, () => this.remote.set(id, value, { ...this.own, ...expected }));
|
|
1314
1335
|
}
|
|
1336
|
+
/** This method deletes an item. An `expectedVersion` is a precondition, as for `set`. */
|
|
1315
1337
|
async delete(id, options) {
|
|
1316
1338
|
const origin = options?.origin;
|
|
1339
|
+
const expected = this.expectedOf(options);
|
|
1317
1340
|
return this.write("delete", { id }, [id], origin, async () => {
|
|
1318
1341
|
this.bump(id);
|
|
1319
|
-
return this.localDelete(id, origin);
|
|
1320
|
-
}, () => this.remote.delete(id, this.own));
|
|
1342
|
+
return this.localDelete(id, origin, expected);
|
|
1343
|
+
}, () => this.remote.delete(id, { ...this.own, ...expected }));
|
|
1344
|
+
}
|
|
1345
|
+
/** The precondition of the caller: the field `expectedVersion`, or no field. */
|
|
1346
|
+
expectedOf(options) {
|
|
1347
|
+
return options?.expectedVersion === undefined ? {} : { expectedVersion: options.expectedVersion };
|
|
1321
1348
|
}
|
|
1322
1349
|
async clear(options) {
|
|
1323
1350
|
const origin = options?.origin;
|
|
@@ -1329,15 +1356,19 @@ export class HybridStorage {
|
|
|
1329
1356
|
//
|
|
1330
1357
|
// ═══ Bulk Operations ═══
|
|
1331
1358
|
//
|
|
1332
|
-
/**
|
|
1359
|
+
/**
|
|
1360
|
+
* This method sets several items. The storage takes a copy of each value, as `set` does. The
|
|
1361
|
+
* `expectedVersions` are preconditions, as `expectedVersion` of `set` is.
|
|
1362
|
+
*/
|
|
1333
1363
|
async setBatch(written, options) {
|
|
1334
1364
|
const items = written.map(([id, value]) => [id, structuredClone(value)]);
|
|
1335
1365
|
const origin = options?.origin;
|
|
1366
|
+
const expected = options?.expectedVersions !== undefined && options.expectedVersions.length > 0 ? { expectedVersions: options.expectedVersions } : {};
|
|
1336
1367
|
await this.write("setBatch", { items }, items.map(([id]) => id), origin, async () => {
|
|
1337
1368
|
for (const [id] of items)
|
|
1338
1369
|
this.bump(id);
|
|
1339
|
-
await this.localSetBatch(items, origin);
|
|
1340
|
-
}, () => this.remote.setBatch(items, this.own));
|
|
1370
|
+
await this.localSetBatch(items, origin, expected);
|
|
1371
|
+
}, () => this.remote.setBatch(items, { ...this.own, ...expected }));
|
|
1341
1372
|
}
|
|
1342
1373
|
async deleteBatch(ids, options) {
|
|
1343
1374
|
const origin = options?.origin;
|
|
@@ -1942,19 +1973,21 @@ export class HybridStorage {
|
|
|
1942
1973
|
}
|
|
1943
1974
|
// The writes of the local copy have the origin of this storage, so the capture does not queue
|
|
1944
1975
|
// them. The observers get each change with the origin of the caller.
|
|
1945
|
-
|
|
1946
|
-
|
|
1976
|
+
// A precondition of a caller goes to the local copy, which tests it and writes in one step
|
|
1977
|
+
// (roadmap CLI-16).
|
|
1978
|
+
async localSet(id, value, origin, expected = {}) {
|
|
1979
|
+
await this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value, { ...this.own, ...expected }));
|
|
1947
1980
|
if (this.observers.size > 0)
|
|
1948
1981
|
this.emit([setChange(id, value, origin)]);
|
|
1949
1982
|
}
|
|
1950
|
-
async localDelete(id, origin) {
|
|
1951
|
-
const existed = await this.writeLocal(() => [[id, undefined]], () => this.local.delete(id, this.own));
|
|
1983
|
+
async localDelete(id, origin, expected = {}) {
|
|
1984
|
+
const existed = await this.writeLocal(() => [[id, undefined]], () => this.local.delete(id, { ...this.own, ...expected }));
|
|
1952
1985
|
if (existed)
|
|
1953
1986
|
this.emit([withOrigin({ type: "delete", id }, origin)]);
|
|
1954
1987
|
return existed;
|
|
1955
1988
|
}
|
|
1956
|
-
async localSetBatch(items, origin) {
|
|
1957
|
-
await this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items, this.own));
|
|
1989
|
+
async localSetBatch(items, origin, expected = {}) {
|
|
1990
|
+
await this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items, { ...this.own, ...expected }));
|
|
1958
1991
|
if (this.observers.size > 0)
|
|
1959
1992
|
this.emit(items.map(([id, value]) => setChange(id, value, origin)));
|
|
1960
1993
|
}
|
|
@@ -2282,6 +2315,8 @@ export class HybridStorage {
|
|
|
2282
2315
|
async pushQueue() {
|
|
2283
2316
|
this.pushing = true;
|
|
2284
2317
|
let sent = 0;
|
|
2318
|
+
// The operations whose conflicts this push resolved once (roadmap CLI-16)
|
|
2319
|
+
const resolved = new Set();
|
|
2285
2320
|
try {
|
|
2286
2321
|
while (this.offlineOps.length > 0) {
|
|
2287
2322
|
const op = this.offlineOps[0];
|
|
@@ -2295,8 +2330,17 @@ export class HybridStorage {
|
|
|
2295
2330
|
await this.executeOperation(op);
|
|
2296
2331
|
}
|
|
2297
2332
|
catch (error) {
|
|
2333
|
+
// The remote refused a base version: the conflict policy decides, then the push goes on
|
|
2334
|
+
const conflicts = this.sendsBaseVersions() ? versionConflictsOf(error) : undefined;
|
|
2335
|
+
if (conflicts !== undefined && conflicts.length > 0 && !resolved.has(op)) {
|
|
2336
|
+
resolved.add(op);
|
|
2337
|
+
this.isOnline = true;
|
|
2338
|
+
await this.resolvePushConflicts(op, conflicts);
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2298
2341
|
// This operation and the later ones stay queued
|
|
2299
|
-
|
|
2342
|
+
if (conflicts === undefined)
|
|
2343
|
+
this.isOnline = false;
|
|
2300
2344
|
throw error;
|
|
2301
2345
|
}
|
|
2302
2346
|
this.noteRemoteWrite(op);
|
|
@@ -2627,13 +2671,13 @@ export class HybridStorage {
|
|
|
2627
2671
|
const data = op.data;
|
|
2628
2672
|
switch (op.type) {
|
|
2629
2673
|
case "set":
|
|
2630
|
-
await this.remote.set(data.id, data.value, this.
|
|
2674
|
+
await this.remote.set(data.id, data.value, this.withBase(data.id));
|
|
2631
2675
|
break;
|
|
2632
2676
|
case "delete":
|
|
2633
|
-
await this.remote.delete(data.id, this.
|
|
2677
|
+
await this.remote.delete(data.id, this.withBase(data.id));
|
|
2634
2678
|
break;
|
|
2635
2679
|
case "setBatch":
|
|
2636
|
-
await this.remote.setBatch(data.items, this.
|
|
2680
|
+
await this.remote.setBatch(data.items, this.withBases(data.items));
|
|
2637
2681
|
break;
|
|
2638
2682
|
case "deleteBatch":
|
|
2639
2683
|
await this.remote.deleteBatch(data.ids, this.own);
|
|
@@ -2643,6 +2687,130 @@ export class HybridStorage {
|
|
|
2643
2687
|
break;
|
|
2644
2688
|
}
|
|
2645
2689
|
}
|
|
2690
|
+
//
|
|
2691
|
+
// ═══ The base versions as preconditions (roadmap CLI-16) ═══
|
|
2692
|
+
//
|
|
2693
|
+
/** True when a push sends the base versions of its operations as expected versions. */
|
|
2694
|
+
sendsBaseVersions() {
|
|
2695
|
+
return this.options.sendBaseVersions && this.options.versionOf !== undefined;
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* The options of the push of a set or a delete of one id: the base version of the id as its
|
|
2699
|
+
* expected version. The first queued operation of an id changes the remote version in `bases`.
|
|
2700
|
+
* An id without a known base version gets no precondition: the queued write wins, as before.
|
|
2701
|
+
*/
|
|
2702
|
+
withBase(id) {
|
|
2703
|
+
if (!this.sendsBaseVersions())
|
|
2704
|
+
return this.own;
|
|
2705
|
+
const base = this.bases.get(id);
|
|
2706
|
+
return base === undefined ? this.own : { ...this.own, expectedVersion: base };
|
|
2707
|
+
}
|
|
2708
|
+
/** The options of the push of a batch: the base version of each id that has one. */
|
|
2709
|
+
withBases(items) {
|
|
2710
|
+
if (!this.sendsBaseVersions())
|
|
2711
|
+
return this.own;
|
|
2712
|
+
const expectedVersions = [];
|
|
2713
|
+
for (const id of new Set(items.map(([id]) => id))) {
|
|
2714
|
+
const base = this.bases.get(id);
|
|
2715
|
+
if (base !== undefined)
|
|
2716
|
+
expectedVersions.push([id, base]);
|
|
2717
|
+
}
|
|
2718
|
+
return expectedVersions.length === 0 ? this.own : { ...this.own, expectedVersions };
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* The value that a queued operation writes for an id: the value of the last item of the id, or
|
|
2722
|
+
* `null` for a delete. Undefined when the operation does not write the id.
|
|
2723
|
+
*/
|
|
2724
|
+
writtenValue(op, id) {
|
|
2725
|
+
const data = op.data;
|
|
2726
|
+
switch (op.type) {
|
|
2727
|
+
case "set":
|
|
2728
|
+
return data.id === id ? { value: data.value } : undefined;
|
|
2729
|
+
case "setBatch": {
|
|
2730
|
+
const item = data.items.findLast(([itemId]) => itemId === id);
|
|
2731
|
+
return item ? { value: item[1] } : undefined;
|
|
2732
|
+
}
|
|
2733
|
+
case "delete":
|
|
2734
|
+
return data.id === id ? null : undefined;
|
|
2735
|
+
default:
|
|
2736
|
+
return undefined;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* This method applies the conflicts of a push that the remote refused (`VERSION_CONFLICT`). Each
|
|
2741
|
+
* conflict goes to `conflictResolution`, as a conflict of a pull does:
|
|
2742
|
+
*
|
|
2743
|
+
* - The remote item is the value of the operation itself: an earlier send of it reached the
|
|
2744
|
+
* remote, and its answer was lost. The base becomes the current version, and the push sends
|
|
2745
|
+
* the operation again.
|
|
2746
|
+
* - `remote`: the remote item replaces the local item, and the queued writes of the id go.
|
|
2747
|
+
* - `merge`: the merged item replaces the local item, and a new queued write sends it.
|
|
2748
|
+
* - `local`: the base becomes the current version, so the next send replaces the remote item.
|
|
2749
|
+
* - `error`: the queue keeps the operation, and the push throws `ConflictError` with the ids.
|
|
2750
|
+
*/
|
|
2751
|
+
async resolvePushConflicts(op, conflicts) {
|
|
2752
|
+
const ids = [];
|
|
2753
|
+
let queueChanged = false;
|
|
2754
|
+
for (const conflict of conflicts) {
|
|
2755
|
+
const { id } = conflict;
|
|
2756
|
+
const written = this.writtenValue(op, id);
|
|
2757
|
+
if (written === undefined)
|
|
2758
|
+
continue;
|
|
2759
|
+
if (conflict.found) {
|
|
2760
|
+
const remoteItem = conflict.value;
|
|
2761
|
+
const remoteVersion = conflict.version ?? this.versionOfItem(remoteItem);
|
|
2762
|
+
// The echo of this operation, or a remote item without a version: the queued write wins
|
|
2763
|
+
if (remoteVersion === undefined || (written !== null && sameJson(written.value, remoteItem))) {
|
|
2764
|
+
if (remoteVersion === undefined)
|
|
2765
|
+
this.bases.delete(id);
|
|
2766
|
+
else
|
|
2767
|
+
this.bases.set(id, remoteVersion);
|
|
2768
|
+
continue;
|
|
2769
|
+
}
|
|
2770
|
+
const localItem = await this.readLocal(id);
|
|
2771
|
+
if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids))
|
|
2772
|
+
queueChanged = true;
|
|
2773
|
+
continue;
|
|
2774
|
+
}
|
|
2775
|
+
// The remote has no item: the echo of a delete, or a delete of another writer
|
|
2776
|
+
if (written === null) {
|
|
2777
|
+
this.bases.set(id, null);
|
|
2778
|
+
continue;
|
|
2779
|
+
}
|
|
2780
|
+
if (await this.resolveRemoteDelete(id, ids))
|
|
2781
|
+
queueChanged = true;
|
|
2782
|
+
}
|
|
2783
|
+
if (queueChanged) {
|
|
2784
|
+
this.storeDirty = true;
|
|
2785
|
+
// The status gets the failure of the queue store. A later direct write repairs the store first.
|
|
2786
|
+
await this.persistAll().catch(() => undefined);
|
|
2787
|
+
}
|
|
2788
|
+
if (ids.length > 0)
|
|
2789
|
+
throw new ConflictError(ids);
|
|
2790
|
+
}
|
|
2791
|
+
/**
|
|
2792
|
+
* A queued write of an id that the remote deleted: a conflict, and the policy decides, as for
|
|
2793
|
+
* a delete of a pull. True when the queue changed.
|
|
2794
|
+
*/
|
|
2795
|
+
async resolveRemoteDelete(id, conflicts) {
|
|
2796
|
+
this.stats.conflicts++;
|
|
2797
|
+
switch (this.options.conflictResolution) {
|
|
2798
|
+
case "remote":
|
|
2799
|
+
if (!this.dropQueued(id))
|
|
2800
|
+
break;
|
|
2801
|
+
await this.localDelete(id, this.syncOrigin);
|
|
2802
|
+
this.bases.set(id, null);
|
|
2803
|
+
return true;
|
|
2804
|
+
case "error":
|
|
2805
|
+
conflicts.push(id);
|
|
2806
|
+
return false;
|
|
2807
|
+
default:
|
|
2808
|
+
// "local", and "merge" with one deleted side: the queued write wins
|
|
2809
|
+
break;
|
|
2810
|
+
}
|
|
2811
|
+
this.bases.set(id, null);
|
|
2812
|
+
return false;
|
|
2813
|
+
}
|
|
2646
2814
|
/**
|
|
2647
2815
|
* Get current online status.
|
|
2648
2816
|
*/
|