@procedures/store 0.14.0 → 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.
@@ -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,8 +43,8 @@
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";
44
- import { optionalMs, realClock } from "@procedures/client";
46
+ import { queryEntries, queryOptionsProblems, selectorProblems, versionConflictsOf, } from "@procedures/client/query";
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";
47
50
  import { isSyncState } from "./sync-state.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
- this.local = options.local ?? new InMemoryStorage();
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
- /** This method sets several items. The storage takes a copy of each value, as `set` does. */
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
- async localSet(id, value, origin) {
1946
- await this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value, this.own));
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
- this.isOnline = false;
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.own);
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.own);
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.own);
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
  */