@procedures/store 0.50.0 → 0.52.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.
@@ -150,9 +150,11 @@ export interface HybridStorageOptions {
150
150
  * three-way merge uses it. `null`: the remote had no item. The value is undefined when the
151
151
  * storage does not know it. Examples are a storage with no `versionOf`, an operation of an
152
152
  * older version, and a write after a restart, before a sync.
153
+ * @param context - `context.overlap(paths)` reports the paths where the merge kept only one side.
154
+ * The hook `onDropped` gets one call with the reason `overlap` for each call.
153
155
  * @returns Merged value
154
156
  */
155
- mergeFn?: <T>(local: T, remote: T, base: T | null | undefined) => T;
157
+ mergeFn?: <T>(local: T, remote: T, base: T | null | undefined, context: MergeContext) => T;
156
158
 
157
159
  /**
158
160
  * The key of a remote item, for the local cache. The default reads a string `id` field. Give
@@ -270,6 +272,9 @@ export interface HybridStorageOptions {
270
272
  * `remote` decides, on a delete of the remote and on a refusal. The call comes after the local
271
273
  * copy changed, so the hook can write the value again. An error of the hook does not stop the
272
274
  * sync. The sync waits for the hook.
275
+ *
276
+ * The hook also gets each overlap that `mergeFn` reports, with the reason `overlap`. The merged write of an overlap
277
+ * stays in the queue.
273
278
  */
274
279
  onDropped?: (drop: DroppedWrite) => void | Promise<void>;
275
280
 
@@ -603,6 +608,15 @@ export interface QueueRefusal {
603
608
  restored: boolean;
604
609
  }
605
610
 
611
+ /** The fourth argument of `mergeFn` (roadmap CLI-109). */
612
+ export interface MergeContext {
613
+ /**
614
+ * This method reports the paths where the merge kept only one side, for example `["title"]`. Each call during the
615
+ * merge gives one call of `onDropped` with the reason `overlap`. A call after the end of the merge changes nothing.
616
+ */
617
+ overlap(paths: readonly string[]): void;
618
+ }
619
+
606
620
  /**
607
621
  * A queued write that left the queue with no send that the remote took: the argument of the hook
608
622
  * `onDropped` (roadmap CLI-106). The hook can get a write again after a crash, because the store
@@ -618,13 +632,21 @@ export interface DroppedWrite {
618
632
  value: unknown;
619
633
  /**
620
634
  * The cause: the policy took the remote value, the remote deleted the item, or the remote refused the write. The
621
- * cause `too-large` tells that the write was larger than the limit of the remote (roadmap CLI-108).
635
+ * cause `too-large` tells that the write was larger than the limit of the remote (roadmap CLI-108). The cause
636
+ * `overlap` tells that `mergeFn` kept only one side of some paths (roadmap CLI-109). Its merged write stays in the
637
+ * queue.
622
638
  */
623
- reason: "conflict" | "remote-delete" | "refused" | "too-large";
639
+ reason: "conflict" | "remote-delete" | "refused" | "too-large" | "overlap";
624
640
  /** The sync that dropped the write. */
625
641
  during: "pull" | "push";
626
- /** The remote value that replaced the write, for the reason `conflict`. */
642
+ /** The remote value that replaced the write, for the reason `conflict`. The remote value of the merge, for `overlap`. */
627
643
  remote?: unknown;
644
+ /** The base value of the merge, for the reason `overlap`. This value is undefined when the storage does not know it. */
645
+ base?: unknown;
646
+ /** The value that `mergeFn` gave, for the reason `overlap`. */
647
+ merged?: unknown;
648
+ /** The paths that `mergeFn` reported, for the reason `overlap`. */
649
+ paths?: string[];
628
650
  /** The error of the remote, for the reasons `refused` and `too-large`. */
629
651
  error?: unknown;
630
652
  }
@@ -1032,7 +1054,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1032
1054
  private readonly queueStore: QueueStore;
1033
1055
  private options: {
1034
1056
  conflictResolution: ConflictResolution;
1035
- mergeFn?: <T>(local: T, remote: T, base: T | null | undefined) => T;
1057
+ mergeFn?: <T>(local: T, remote: T, base: T | null | undefined, context: MergeContext) => T;
1036
1058
  keyOf: (item: T) => string | undefined;
1037
1059
  versionOf?: (item: T) => string | undefined;
1038
1060
  sendBaseVersions: boolean;
@@ -1174,6 +1196,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1174
1196
  syncCount: 0,
1175
1197
  conflicts: 0,
1176
1198
  dropped: 0,
1199
+ overlaps: 0,
1177
1200
  };
1178
1201
 
1179
1202
  constructor(
@@ -2490,6 +2513,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2490
2513
  syncCount: this.stats.syncCount,
2491
2514
  conflicts: this.stats.conflicts,
2492
2515
  dropped: this.stats.dropped,
2516
+ overlaps: this.stats.overlaps,
2493
2517
  pendingOps: this.offlineOps.length,
2494
2518
  isOnline: this.isOnline,
2495
2519
  mode: this.mode,
@@ -2873,9 +2897,11 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2873
2897
  conflicts.push(id);
2874
2898
  return false;
2875
2899
  }
2876
- const resolved = this.resolveConflict(localItem, item);
2900
+ const overlaps: string[][] = [];
2901
+ const resolved = this.resolveConflict(localItem, item, undefined, overlaps);
2877
2902
  await this.localSet(id, resolved, this.syncOrigin);
2878
2903
  this.stats.conflicts++;
2904
+ await this.reportOverlaps({ id, during: "pull", local: localItem, remote: item, base: undefined, merged: resolved }, overlaps);
2879
2905
  }
2880
2906
  if (remoteVersion !== undefined) {
2881
2907
  this.bases.set(id, remoteVersion);
@@ -2950,13 +2976,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2950
2976
  return true;
2951
2977
  case "merge": {
2952
2978
  if (localItem === undefined) break;
2953
- const merged = this.resolveConflict(localItem, remoteItem, this.queuedBaseValue(id));
2979
+ const base = this.queuedBaseValue(id);
2980
+ const overlaps: string[][] = [];
2981
+ const merged = this.resolveConflict(localItem, remoteItem, base, overlaps);
2954
2982
  if (!this.dropQueued(id)) break;
2955
2983
  await this.localSet(id, merged, this.syncOrigin);
2956
2984
  this.bases.set(id, remoteVersion);
2957
2985
  this.noteBaseValue(id, remoteVersion, remoteItem);
2958
2986
  // The merged write changes the remote item of this conflict: that item is its base
2959
2987
  this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]], baseValues: [[id, remoteItem]] });
2988
+ await this.reportOverlaps({ id, during, local: localItem, remote: remoteItem, base, merged }, overlaps);
2960
2989
  return true;
2961
2990
  }
2962
2991
  case "error":
@@ -2980,6 +3009,33 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2980
3009
  }
2981
3010
  }
2982
3011
 
3012
+ /**
3013
+ * This method gives each overlap of a merge to the hook `onDropped` (roadmap CLI-109). It counts the overlaps, not the
3014
+ * dropped writes: the merged write stays in the queue. An error of the hook does not stop the sync.
3015
+ */
3016
+ private async reportOverlaps(
3017
+ merge: { id: string; during: DroppedWrite["during"]; local: T; remote: T; base: T | null | undefined; merged: T },
3018
+ overlaps: readonly string[][],
3019
+ ): Promise<void> {
3020
+ for (const paths of overlaps) {
3021
+ this.stats.overlaps++;
3022
+ try {
3023
+ await this.options.onDropped?.({
3024
+ id: merge.id,
3025
+ value: merge.local,
3026
+ reason: "overlap",
3027
+ during: merge.during,
3028
+ remote: merge.remote,
3029
+ base: merge.base,
3030
+ merged: merge.merged,
3031
+ paths,
3032
+ });
3033
+ } catch {
3034
+ // An error of the hook does not stop the sync
3035
+ }
3036
+ }
3037
+ }
3038
+
2983
3039
  /**
2984
3040
  * This method removes an id from the queued operations. It gives false when a queued clear touches it: nothing
2985
3041
  * changes then.
@@ -3357,7 +3413,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3357
3413
  /**
3358
3414
  * Resolve conflict between local and remote versions.
3359
3415
  */
3360
- private resolveConflict(local: T, remote: T, base?: T | null): T {
3416
+ private resolveConflict(local: T, remote: T, base?: T | null, overlaps?: string[][]): T {
3361
3417
  switch (this.options.conflictResolution) {
3362
3418
  case "local":
3363
3419
  return local;
@@ -3371,7 +3427,19 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3371
3427
  "Merge function required for conflict resolution strategy 'merge'"
3372
3428
  );
3373
3429
  }
3374
- return this.options.mergeFn(local, remote, base);
3430
+ {
3431
+ let open = true;
3432
+ const context: MergeContext = {
3433
+ overlap: (paths) => {
3434
+ if (open) overlaps?.push([...paths]);
3435
+ },
3436
+ };
3437
+ try {
3438
+ return this.options.mergeFn(local, remote, base, context);
3439
+ } finally {
3440
+ open = false;
3441
+ }
3442
+ }
3375
3443
 
3376
3444
  case "error":
3377
3445
  throw new Error(
@@ -51,6 +51,7 @@ export {
51
51
  } from "./hybrid.js";
52
52
  export type {
53
53
  DroppedWrite,
54
+ MergeContext,
54
55
  HybridStorageOptions,
55
56
  HybridMode,
56
57
  LocalCopyStorage,