@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.
@@ -21,7 +21,7 @@ import {
21
21
  type SubscribeOptions,
22
22
  type Subscription,
23
23
  type SubscriptionError,
24
- } from "@procedures/client";
24
+ } from "@procedures/client/browser";
25
25
  import type { EventTransport } from "@procedures/client/events";
26
26
 
27
27
  /**
@@ -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.
@@ -42,23 +45,27 @@
42
45
 
43
46
  import type {
44
47
  CollectionStorage,
48
+ StorageBatchWriteOptions,
45
49
  StorageChangeBatch,
46
50
  StorageChangeEvent,
47
51
  StorageChanges,
52
+ StorageItemWriteOptions,
48
53
  StorageMetadata,
49
54
  StorageObserver,
50
55
  StorageWriteOptions,
56
+ VersionConflict,
51
57
  } from "@procedures/collections";
52
58
  import { InMemoryStorage, StorageListeners, type ListenerErrorHandler } from "@procedures/collections";
53
59
  import {
54
60
  queryEntries,
55
61
  queryOptionsProblems,
56
62
  selectorProblems,
63
+ versionConflictsOf,
57
64
  type StorageQueryOptions,
58
65
  type StorageQueryPolicy,
59
66
  type StorageSelector,
60
67
  } from "@procedures/client/query";
61
- import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@procedures/client";
68
+ import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@procedures/client/browser";
62
69
  import { backoffDelay } from "@mark1russell7/service/backoff";
63
70
  import { MemoryQueueStore, idsOf, withoutId, type QueuedOperation, type QueueStore } from "./queue.js";
64
71
  import type { SyncError, SyncStatus } from "./types.js";
@@ -147,6 +154,19 @@ export interface HybridStorageOptions {
147
154
  */
148
155
  versionOf?: (item: any) => string | undefined;
149
156
 
157
+ /**
158
+ * With `versionOf`: true sends the base version of each queued write to the remote as its
159
+ * `expectedVersion` (roadmap CLI-16). The remote then refuses a write over a change of another
160
+ * writer with `VERSION_CONFLICT`, and `conflictResolution` decides, as for a pull. The base
161
+ * version is the remote version of the id when the local write was queued. False: the push
162
+ * sends no precondition, and the queued write replaces the remote item, as before CLI-16.
163
+ *
164
+ * The remote must test the precondition: an `ApiStorage` of the core collection procedures over
165
+ * a storage with the same `versionOf`. A remote that cannot test it refuses each push.
166
+ * @default true
167
+ */
168
+ sendBaseVersions?: boolean;
169
+
150
170
  /**
151
171
  * Automatic sync interval (for write-back mode), for example `seconds(5)`.
152
172
  * Set to 0 to disable automatic sync. In write-through mode, it is also the delay before the
@@ -530,6 +550,15 @@ type PulledChanges<T> = Omit<StorageChanges<T>, "version"> & { version?: string
530
550
  /** The data of a queued operation of each type. */
531
551
  type OperationData<T> = { id: string; value: T; ids: string[]; items: Array<[string, T]> };
532
552
 
553
+ /** True when two values have the same JSON. A value without JSON is never the same. */
554
+ function sameJson(a: unknown, b: unknown): boolean {
555
+ try {
556
+ return JSON.stringify(a) === JSON.stringify(b);
557
+ } catch {
558
+ return false;
559
+ }
560
+ }
561
+
533
562
  /** A change with an origin, when there is one. */
534
563
  function withOrigin<T>(change: StorageChangeEvent<T>, origin: string | undefined): StorageChangeEvent<T> {
535
564
  return origin === undefined ? change : { ...change, origin };
@@ -752,6 +781,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
752
781
  mergeFn?: <T>(local: T, remote: T) => T;
753
782
  keyOf: (item: T) => string | undefined;
754
783
  versionOf?: (item: T) => string | undefined;
784
+ sendBaseVersions: boolean;
755
785
  syncInterval: number;
756
786
  pullInterval: number;
757
787
  syncOnInit: boolean;
@@ -874,7 +904,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
874
904
  remote: CollectionStorage<T>,
875
905
  options: HybridStorageOptions = {}
876
906
  ) {
877
- this.local = (options.local as LocalCopyStorage<T> | undefined) ?? new InMemoryStorage<T>();
907
+ // The local copy tests the preconditions of the callers (roadmap CLI-16): it gets `versionOf`
908
+ this.local =
909
+ (options.local as LocalCopyStorage<T> | undefined) ??
910
+ new InMemoryStorage<T>(options.versionOf !== undefined ? { versionOf: options.versionOf as (item: T) => string | undefined } : {});
878
911
  this.remote = remote;
879
912
  this.queueStore = options.queueStore ?? new MemoryQueueStore();
880
913
  this.mode = checkMode(options.mode ?? options.writeStrategy ?? "write-through");
@@ -883,6 +916,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
883
916
  ...(options.mergeFn !== undefined && { mergeFn: options.mergeFn }),
884
917
  keyOf: options.keyOf ?? defaultKeyOf,
885
918
  ...(options.versionOf !== undefined && { versionOf: options.versionOf }),
919
+ sendBaseVersions: options.sendBaseVersions ?? true,
886
920
  syncInterval: optionalMs(options.syncInterval, "syncInterval") ?? 5000,
887
921
  pullInterval: optionalMs(options.pullInterval, "pullInterval") ?? 0,
888
922
  syncOnInit: options.syncOnInit ?? true,
@@ -1603,22 +1637,34 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1603
1637
  * This method sets an item. The storage takes a copy of the value, so a later change of the
1604
1638
  * value does not change the local copy, the queue or the remote write. The origin of the options
1605
1639
  * goes to the batches of `observe()`. The local copy and the remote get the origin of the storage.
1640
+ *
1641
+ * An `expectedVersion` (roadmap CLI-16) is a precondition on the item as the storage reads it.
1642
+ * The local copy tests it (the default local copy needs `versionOf`). In `write-through` mode, the
1643
+ * remote write also gets it. In `remote-only` mode, only the remote tests it.
1606
1644
  */
1607
- async set(id: string, written: T, options?: StorageWriteOptions): Promise<void> {
1645
+ async set(id: string, written: T, options?: StorageItemWriteOptions): Promise<void> {
1608
1646
  const value = structuredClone(written);
1609
1647
  const origin = options?.origin;
1648
+ const expected = this.expectedOf(options);
1610
1649
  await this.write("set", { id, value }, [id], origin, async () => {
1611
1650
  this.bump(id);
1612
- await this.localSet(id, value, origin);
1613
- }, () => this.remote.set(id, value, this.own));
1651
+ await this.localSet(id, value, origin, expected);
1652
+ }, () => this.remote.set(id, value, { ...this.own, ...expected }));
1614
1653
  }
1615
1654
 
1616
- async delete(id: string, options?: StorageWriteOptions): Promise<boolean> {
1655
+ /** This method deletes an item. An `expectedVersion` is a precondition, as for `set`. */
1656
+ async delete(id: string, options?: StorageItemWriteOptions): Promise<boolean> {
1617
1657
  const origin = options?.origin;
1658
+ const expected = this.expectedOf(options);
1618
1659
  return this.write("delete", { id }, [id], origin, async () => {
1619
1660
  this.bump(id);
1620
- return this.localDelete(id, origin);
1621
- }, () => this.remote.delete(id, this.own));
1661
+ return this.localDelete(id, origin, expected);
1662
+ }, () => this.remote.delete(id, { ...this.own, ...expected }));
1663
+ }
1664
+
1665
+ /** The precondition of the caller: the field `expectedVersion`, or no field. */
1666
+ private expectedOf(options: StorageItemWriteOptions | undefined): { expectedVersion?: string | null } {
1667
+ return options?.expectedVersion === undefined ? {} : { expectedVersion: options.expectedVersion };
1622
1668
  }
1623
1669
 
1624
1670
  async clear(options?: StorageWriteOptions): Promise<void> {
@@ -1633,14 +1679,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1633
1679
  // ═══ Bulk Operations ═══
1634
1680
  //
1635
1681
 
1636
- /** This method sets several items. The storage takes a copy of each value, as `set` does. */
1637
- async setBatch(written: Array<[string, T]>, options?: StorageWriteOptions): Promise<void> {
1682
+ /**
1683
+ * This method sets several items. The storage takes a copy of each value, as `set` does. The
1684
+ * `expectedVersions` are preconditions, as `expectedVersion` of `set` is.
1685
+ */
1686
+ async setBatch(written: Array<[string, T]>, options?: StorageBatchWriteOptions): Promise<void> {
1638
1687
  const items = written.map(([id, value]): [string, T] => [id, structuredClone(value)]);
1639
1688
  const origin = options?.origin;
1689
+ const expected = options?.expectedVersions !== undefined && options.expectedVersions.length > 0 ? { expectedVersions: options.expectedVersions } : {};
1640
1690
  await this.write("setBatch", { items }, items.map(([id]) => id), origin, async () => {
1641
1691
  for (const [id] of items) this.bump(id);
1642
- await this.localSetBatch(items, origin);
1643
- }, () => this.remote.setBatch(items, this.own));
1692
+ await this.localSetBatch(items, origin, expected);
1693
+ }, () => this.remote.setBatch(items, { ...this.own, ...expected }));
1644
1694
  }
1645
1695
 
1646
1696
  async deleteBatch(ids: string[], options?: StorageWriteOptions): Promise<number> {
@@ -2248,19 +2298,22 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2248
2298
  // The writes of the local copy have the origin of this storage, so the capture does not queue
2249
2299
  // them. The observers get each change with the origin of the caller.
2250
2300
 
2251
- private async localSet(id: string, value: T, origin: string | undefined): Promise<void> {
2252
- await this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value, this.own));
2301
+ // A precondition of a caller goes to the local copy, which tests it and writes in one step
2302
+ // (roadmap CLI-16).
2303
+
2304
+ private async localSet(id: string, value: T, origin: string | undefined, expected: { expectedVersion?: string | null } = {}): Promise<void> {
2305
+ await this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value, { ...this.own, ...expected }));
2253
2306
  if (this.observers.size > 0) this.emit([setChange(id, value, origin)]);
2254
2307
  }
2255
2308
 
2256
- private async localDelete(id: string, origin: string | undefined): Promise<boolean> {
2257
- const existed = await this.writeLocal(() => [[id, undefined]], () => this.local.delete(id, this.own));
2309
+ private async localDelete(id: string, origin: string | undefined, expected: { expectedVersion?: string | null } = {}): Promise<boolean> {
2310
+ const existed = await this.writeLocal(() => [[id, undefined]], () => this.local.delete(id, { ...this.own, ...expected }));
2258
2311
  if (existed) this.emit([withOrigin<T>({ type: "delete", id }, origin)]);
2259
2312
  return existed;
2260
2313
  }
2261
2314
 
2262
- private async localSetBatch(items: Array<[string, T]>, origin: string | undefined): Promise<void> {
2263
- await this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items, this.own));
2315
+ private async localSetBatch(items: Array<[string, T]>, origin: string | undefined, expected: StorageBatchWriteOptions = {}): Promise<void> {
2316
+ await this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items, { ...this.own, ...expected }));
2264
2317
  if (this.observers.size > 0) this.emit(items.map(([id, value]) => setChange(id, value, origin)));
2265
2318
  }
2266
2319
 
@@ -2576,6 +2629,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2576
2629
  private async pushQueue(): Promise<void> {
2577
2630
  this.pushing = true;
2578
2631
  let sent = 0;
2632
+ // The operations whose conflicts this push resolved once (roadmap CLI-16)
2633
+ const resolved = new Set<QueuedOperation>();
2579
2634
  try {
2580
2635
  while (this.offlineOps.length > 0) {
2581
2636
  const op = this.offlineOps[0]!;
@@ -2588,8 +2643,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2588
2643
  this.echoSent(op);
2589
2644
  await this.executeOperation(op);
2590
2645
  } catch (error) {
2646
+ // The remote refused a base version: the conflict policy decides, then the push goes on
2647
+ const conflicts = this.sendsBaseVersions() ? versionConflictsOf(error) : undefined;
2648
+ if (conflicts !== undefined && conflicts.length > 0 && !resolved.has(op)) {
2649
+ resolved.add(op);
2650
+ this.isOnline = true;
2651
+ await this.resolvePushConflicts(op, conflicts);
2652
+ continue;
2653
+ }
2591
2654
  // This operation and the later ones stay queued
2592
- this.isOnline = false;
2655
+ if (conflicts === undefined) this.isOnline = false;
2593
2656
  throw error;
2594
2657
  }
2595
2658
  this.noteRemoteWrite(op);
@@ -2914,15 +2977,15 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2914
2977
 
2915
2978
  switch (op.type) {
2916
2979
  case "set":
2917
- await this.remote.set(data.id, data.value, this.own);
2980
+ await this.remote.set(data.id, data.value, this.withBase(data.id));
2918
2981
  break;
2919
2982
 
2920
2983
  case "delete":
2921
- await this.remote.delete(data.id, this.own);
2984
+ await this.remote.delete(data.id, this.withBase(data.id));
2922
2985
  break;
2923
2986
 
2924
2987
  case "setBatch":
2925
- await this.remote.setBatch(data.items, this.own);
2988
+ await this.remote.setBatch(data.items, this.withBases(data.items));
2926
2989
  break;
2927
2990
 
2928
2991
  case "deleteBatch":
@@ -2935,6 +2998,127 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2935
2998
  }
2936
2999
  }
2937
3000
 
3001
+ //
3002
+ // ═══ The base versions as preconditions (roadmap CLI-16) ═══
3003
+ //
3004
+
3005
+ /** True when a push sends the base versions of its operations as expected versions. */
3006
+ private sendsBaseVersions(): boolean {
3007
+ return this.options.sendBaseVersions && this.options.versionOf !== undefined;
3008
+ }
3009
+
3010
+ /**
3011
+ * The options of the push of a set or a delete of one id: the base version of the id as its
3012
+ * expected version. The first queued operation of an id changes the remote version in `bases`.
3013
+ * An id without a known base version gets no precondition: the queued write wins, as before.
3014
+ */
3015
+ private withBase(id: string): StorageItemWriteOptions {
3016
+ if (!this.sendsBaseVersions()) return this.own;
3017
+ const base = this.bases.get(id);
3018
+ return base === undefined ? this.own : { ...this.own, expectedVersion: base };
3019
+ }
3020
+
3021
+ /** The options of the push of a batch: the base version of each id that has one. */
3022
+ private withBases(items: Array<[string, T]>): StorageBatchWriteOptions {
3023
+ if (!this.sendsBaseVersions()) return this.own;
3024
+ const expectedVersions: Array<[string, string | null]> = [];
3025
+ for (const id of new Set(items.map(([id]) => id))) {
3026
+ const base = this.bases.get(id);
3027
+ if (base !== undefined) expectedVersions.push([id, base]);
3028
+ }
3029
+ return expectedVersions.length === 0 ? this.own : { ...this.own, expectedVersions };
3030
+ }
3031
+
3032
+ /**
3033
+ * The value that a queued operation writes for an id: the value of the last item of the id, or
3034
+ * `null` for a delete. Undefined when the operation does not write the id.
3035
+ */
3036
+ private writtenValue(op: QueuedOperation, id: string): { value: T } | null | undefined {
3037
+ const data = op.data as OperationData<T>;
3038
+ switch (op.type) {
3039
+ case "set":
3040
+ return data.id === id ? { value: data.value } : undefined;
3041
+ case "setBatch": {
3042
+ const item = data.items.findLast(([itemId]) => itemId === id);
3043
+ return item ? { value: item[1] } : undefined;
3044
+ }
3045
+ case "delete":
3046
+ return data.id === id ? null : undefined;
3047
+ default:
3048
+ return undefined;
3049
+ }
3050
+ }
3051
+
3052
+ /**
3053
+ * This method applies the conflicts of a push that the remote refused (`VERSION_CONFLICT`). Each
3054
+ * conflict goes to `conflictResolution`, as a conflict of a pull does:
3055
+ *
3056
+ * - The remote item is the value of the operation itself: an earlier send of it reached the
3057
+ * remote, and its answer was lost. The base becomes the current version, and the push sends
3058
+ * the operation again.
3059
+ * - `remote`: the remote item replaces the local item, and the queued writes of the id go.
3060
+ * - `merge`: the merged item replaces the local item, and a new queued write sends it.
3061
+ * - `local`: the base becomes the current version, so the next send replaces the remote item.
3062
+ * - `error`: the queue keeps the operation, and the push throws `ConflictError` with the ids.
3063
+ */
3064
+ private async resolvePushConflicts(op: QueuedOperation, conflicts: readonly VersionConflict[]): Promise<void> {
3065
+ const ids: string[] = [];
3066
+ let queueChanged = false;
3067
+ for (const conflict of conflicts) {
3068
+ const { id } = conflict;
3069
+ const written = this.writtenValue(op, id);
3070
+ if (written === undefined) continue;
3071
+ if (conflict.found) {
3072
+ const remoteItem = conflict.value as T;
3073
+ const remoteVersion = conflict.version ?? this.versionOfItem(remoteItem);
3074
+ // The echo of this operation, or a remote item without a version: the queued write wins
3075
+ if (remoteVersion === undefined || (written !== null && sameJson(written.value, remoteItem))) {
3076
+ if (remoteVersion === undefined) this.bases.delete(id);
3077
+ else this.bases.set(id, remoteVersion);
3078
+ continue;
3079
+ }
3080
+ const localItem = await this.readLocal(id);
3081
+ if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids)) queueChanged = true;
3082
+ continue;
3083
+ }
3084
+ // The remote has no item: the echo of a delete, or a delete of another writer
3085
+ if (written === null) {
3086
+ this.bases.set(id, null);
3087
+ continue;
3088
+ }
3089
+ if (await this.resolveRemoteDelete(id, ids)) queueChanged = true;
3090
+ }
3091
+ if (queueChanged) {
3092
+ this.storeDirty = true;
3093
+ // The status gets the failure of the queue store. A later direct write repairs the store first.
3094
+ await this.persistAll().catch(() => undefined);
3095
+ }
3096
+ if (ids.length > 0) throw new ConflictError(ids);
3097
+ }
3098
+
3099
+ /**
3100
+ * A queued write of an id that the remote deleted: a conflict, and the policy decides, as for
3101
+ * a delete of a pull. True when the queue changed.
3102
+ */
3103
+ private async resolveRemoteDelete(id: string, conflicts: string[]): Promise<boolean> {
3104
+ this.stats.conflicts++;
3105
+ switch (this.options.conflictResolution) {
3106
+ case "remote":
3107
+ if (!this.dropQueued(id)) break;
3108
+ await this.localDelete(id, this.syncOrigin);
3109
+ this.bases.set(id, null);
3110
+ return true;
3111
+ case "error":
3112
+ conflicts.push(id);
3113
+ return false;
3114
+ default:
3115
+ // "local", and "merge" with one deleted side: the queued write wins
3116
+ break;
3117
+ }
3118
+ this.bases.set(id, null);
3119
+ return false;
3120
+ }
3121
+
2938
3122
  /**
2939
3123
  * Get current online status.
2940
3124
  */