@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.
@@ -70,7 +70,7 @@ import {
70
70
  } from "@procedures/client/query";
71
71
  import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@procedures/client/browser";
72
72
  import { backoffDelay } from "@mark1russell7/service/backoff";
73
- import { MemoryQueueStore, idsOf, withoutId, type QueuedOperation, type QueueStore } from "./queue.js";
73
+ import { MemoryQueueStore, idsOf, singlesOf, withoutId, type QueuedOperation, type QueueStore } from "./queue.js";
74
74
  import type { SyncError, SyncStatus } from "./types.js";
75
75
  import type { RemoteChangeFeed, RemoteChangeHint, RemoteChanges } from "./feed.js";
76
76
  import { isSyncState, type SyncStateStore } from "./sync-state.js";
@@ -146,9 +146,13 @@ export interface HybridStorageOptions {
146
146
  *
147
147
  * @param local - Local version
148
148
  * @param remote - Remote version
149
+ * @param base - The remote value that the queued writes of the id changed (roadmap CLI-109). A
150
+ * three-way merge uses it. `null`: the remote had no item. The value is undefined when the
151
+ * storage does not know it. Examples are a storage with no `versionOf`, an operation of an
152
+ * older version, and a write after a restart, before a sync.
149
153
  * @returns Merged value
150
154
  */
151
- mergeFn?: <T>(local: T, remote: T) => T;
155
+ mergeFn?: <T>(local: T, remote: T, base: T | null | undefined) => T;
152
156
 
153
157
  /**
154
158
  * The key of a remote item, for the local cache. The default reads a string `id` field. Give
@@ -245,6 +249,14 @@ export interface HybridStorageOptions {
245
249
  */
246
250
  isRefusal?: (error: unknown) => boolean;
247
251
 
252
+ /**
253
+ * True when an error of the remote tells that a write is larger than its limit (roadmap CLI-108). A batch with this
254
+ * error, or with a refusal, goes again as one write for each id. So only the bad write leaves the queue. A write of one
255
+ * id with this error leaves the queue with the reason `too-large`, and the writes behind it go on. The default: the
256
+ * code `PAYLOAD_TOO_LARGE`.
257
+ */
258
+ isTooLarge?: (error: unknown) => boolean;
259
+
248
260
  /**
249
261
  * This hook gets each queued write that the remote refused (roadmap O67). The local copy of its
250
262
  * ids gets the remote values again, except for an id that a later queued write changes. An
@@ -252,6 +264,15 @@ export interface HybridStorageOptions {
252
264
  */
253
265
  onRefused?: (refusal: QueueRefusal) => void | Promise<void>;
254
266
 
267
+ /**
268
+ * This hook gets each queued write that leaves the queue with no send that the remote took
269
+ * (roadmap CLI-106). The storage gives one call for each id, on a conflict that the policy
270
+ * `remote` decides, on a delete of the remote and on a refusal. The call comes after the local
271
+ * copy changed, so the hook can write the value again. An error of the hook does not stop the
272
+ * sync. The sync waits for the hook.
273
+ */
274
+ onDropped?: (drop: DroppedWrite) => void | Promise<void>;
275
+
255
276
  /**
256
277
  * The storage of the local copy (roadmap R45). A durable storage, for example `SqliteStorage`
257
278
  * of `@procedures/sqlite`, keeps the local copy after a restart. Then a new storage in
@@ -582,6 +603,32 @@ export interface QueueRefusal {
582
603
  restored: boolean;
583
604
  }
584
605
 
606
+ /**
607
+ * A queued write that left the queue with no send that the remote took: the argument of the hook
608
+ * `onDropped` (roadmap CLI-106). The hook can get a write again after a crash, because the store
609
+ * of the queue gets the change after the hook.
610
+ */
611
+ export interface DroppedWrite {
612
+ /** The id of the write, or `"*"` for a refused clear. */
613
+ id: string;
614
+ /**
615
+ * The value of the write when it left the queue. Undefined for a queued delete and for a clear.
616
+ * The last queued write gives the value when an id has more than one.
617
+ */
618
+ value: unknown;
619
+ /**
620
+ * 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).
622
+ */
623
+ reason: "conflict" | "remote-delete" | "refused" | "too-large";
624
+ /** The sync that dropped the write. */
625
+ during: "pull" | "push";
626
+ /** The remote value that replaced the write, for the reason `conflict`. */
627
+ remote?: unknown;
628
+ /** The error of the remote, for the reasons `refused` and `too-large`. */
629
+ error?: unknown;
630
+ }
631
+
585
632
  /**
586
633
  * The remote did not take a write of the write-through mode. The write is in the local cache and
587
634
  * in the queue, and the storage sends it later (roadmap R2). `cause` is the error of the remote.
@@ -717,6 +764,9 @@ const defaultIsRefusal = (error: unknown): boolean => {
717
764
  return typeof code === "string" && REFUSAL_CODES.has(code);
718
765
  };
719
766
 
767
+ /** The default of `isTooLarge`: an error with the code `PAYLOAD_TOO_LARGE` (roadmap CLI-108). */
768
+ const defaultIsTooLarge = (error: unknown): boolean => (error as { code?: unknown } | null | undefined)?.code === "PAYLOAD_TOO_LARGE";
769
+
720
770
  /** True when a remote does not know a procedure: an older server without `changes`. */
721
771
  const isNotFound = (error: unknown): boolean =>
722
772
  typeof error === "object" && error !== null && (error as { code?: unknown }).code === "NOT_FOUND";
@@ -922,15 +972,20 @@ class BaseVersions extends Map<string, string | null> {
922
972
  /** True when the storage has a sync state store */
923
973
  tracked = false;
924
974
 
975
+ /** This function gets each change of a version, after the change. */
976
+ onChange: ((id: string, version: string | null | undefined) => void) | undefined;
977
+
925
978
  override set(id: string, version: string | null): this {
926
979
  super.set(id, version);
927
980
  if (this.tracked) this.changed.add(id);
981
+ this.onChange?.(id, version);
928
982
  return this;
929
983
  }
930
984
 
931
985
  override delete(id: string): boolean {
932
986
  const had = super.delete(id);
933
987
  if (had && this.tracked) this.changed.add(id);
988
+ this.onChange?.(id, undefined);
934
989
  return had;
935
990
  }
936
991
  }
@@ -977,7 +1032,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
977
1032
  private readonly queueStore: QueueStore;
978
1033
  private options: {
979
1034
  conflictResolution: ConflictResolution;
980
- mergeFn?: <T>(local: T, remote: T) => T;
1035
+ mergeFn?: <T>(local: T, remote: T, base: T | null | undefined) => T;
981
1036
  keyOf: (item: T) => string | undefined;
982
1037
  versionOf?: (item: T) => string | undefined;
983
1038
  sendBaseVersions: boolean;
@@ -988,7 +1043,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
988
1043
  offlineQueue: boolean;
989
1044
  isUnavailable: (error: unknown) => boolean;
990
1045
  isRefusal: (error: unknown) => boolean;
1046
+ isTooLarge: (error: unknown) => boolean;
991
1047
  onRefused?: (refusal: QueueRefusal) => void | Promise<void>;
1048
+ onDropped?: (drop: DroppedWrite) => void | Promise<void>;
992
1049
  };
993
1050
  private mode: HybridMode;
994
1051
  private offlineOps: QueuedOperation[] = [];
@@ -1044,6 +1101,13 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1044
1101
  private changesUnsupported = false;
1045
1102
  /** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
1046
1103
  private readonly bases = new BaseVersions();
1104
+ /**
1105
+ * With `versionOf`, the policy `merge` and `capture: "observe"`, this map holds the remote value at the known remote
1106
+ * version of each id (roadmap CLI-109). A captured change has no value before it, so its base value comes from here.
1107
+ * A write of the storage reads its base value from the local copy, and needs no map. A new version of an id drops
1108
+ * its value. A sync or a send gives the value of the new version.
1109
+ */
1110
+ private readonly baseValues = new Map<string, { version: string; value: T }>();
1047
1111
  /**
1048
1112
  * With `versionOf`: the version of each local item that the storage wrote or read. A pull does
1049
1113
  * not read an item whose version it knows. The version of an id that is not here is unknown.
@@ -1075,6 +1139,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1075
1139
  private resetPull = false;
1076
1140
  /** The stop of the capture of the local copy */
1077
1141
  private readonly stopCapture: (() => void) | undefined;
1142
+ /** True with `capture: "observe"`. */
1143
+ private readonly capturing: boolean;
1144
+ /** True when a queued write keeps its base value: with `versionOf` and the policy `merge` (roadmap CLI-109). */
1145
+ private readonly keepsBaseValues: boolean;
1078
1146
  /** The captured changes that came before the end of the load. Undefined after the load. */
1079
1147
  private captured: StorageChangeEvent<T>[] | undefined = [];
1080
1148
  /** With `versionOf`: the versions of the own writes that wait for their echo */
@@ -1105,6 +1173,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1105
1173
  cacheMisses: 0,
1106
1174
  syncCount: 0,
1107
1175
  conflicts: 0,
1176
+ dropped: 0,
1108
1177
  };
1109
1178
 
1110
1179
  constructor(
@@ -1131,7 +1200,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1131
1200
  offlineQueue: options.offlineQueue ?? true,
1132
1201
  isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
1133
1202
  isRefusal: options.isRefusal ?? defaultIsRefusal,
1203
+ isTooLarge: options.isTooLarge ?? defaultIsTooLarge,
1134
1204
  ...(options.onRefused !== undefined && { onRefused: options.onRefused }),
1205
+ ...(options.onDropped !== undefined && { onDropped: options.onDropped }),
1135
1206
  };
1136
1207
  this.statusListeners = new StorageListeners<SyncStatus>(options.onListenerError);
1137
1208
  this.queryPolicy = options.queryPolicy;
@@ -1146,6 +1217,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1146
1217
  this.aroundRemoteApply = options.aroundRemoteApply;
1147
1218
  this.stateStore = options.syncState;
1148
1219
  this.bases.tracked = this.stateStore !== undefined;
1220
+ this.bases.onChange = (id, version) => {
1221
+ if (this.baseValues.get(id)?.version !== version) this.baseValues.delete(id);
1222
+ };
1149
1223
  this.localGiven = options.local !== undefined;
1150
1224
 
1151
1225
  // The timers on the clock, with the backoff after a failure (roadmap CLI-13)
@@ -1181,6 +1255,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1181
1255
  : undefined;
1182
1256
  const capture: unknown = options.capture ?? "none";
1183
1257
  if (capture !== "none" && capture !== "observe") throw new TypeError(`Unknown capture ${JSON.stringify(capture)}: use none or observe`);
1258
+ this.capturing = capture === "observe";
1259
+ this.keepsBaseValues = this.options.conflictResolution === "merge" && this.options.versionOf !== undefined;
1184
1260
  if (capture === "observe") {
1185
1261
  if (!this.local.observe) throw new TypeError('capture: "observe" needs a local copy with observe()');
1186
1262
  // The capture starts before the load: a change of the application during the load waits for it
@@ -1330,6 +1406,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1330
1406
  const previous = this.offlineOps[index]!;
1331
1407
  if (previous.base) op.base = previous.base;
1332
1408
  else delete op.base;
1409
+ if (previous.baseValues) op.baseValues = previous.baseValues;
1410
+ else delete op.baseValues;
1333
1411
  this.offlineOps[index] = op;
1334
1412
  } else {
1335
1413
  this.offlineOps.push(op);
@@ -2042,6 +2120,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2042
2120
  this.ensureQueueRoom(op);
2043
2121
  this.attachBase(op);
2044
2122
  const undo = await this.snapshot(ids, origin);
2123
+ this.fillBaseValues(op, undo.before);
2045
2124
  const result = await applyLocal();
2046
2125
  this.noteOwnWrite(op);
2047
2126
  await this.enqueue(op, undo);
@@ -2082,6 +2161,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2082
2161
  // The status shows the queue and the connection. The caller gets PendingWriteError.
2083
2162
  this.isOnline = false;
2084
2163
  this.attachBase(op);
2164
+ this.fillBaseValues(op, undo.before);
2085
2165
  await this.enqueue(op, undo);
2086
2166
  this.scheduleDrain();
2087
2167
  throw new PendingWriteError(type, error);
@@ -2218,13 +2298,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2218
2298
  * The local values of the ids before a write, and a function that puts them back. It puts back
2219
2299
  * only the ids that no later write changed.
2220
2300
  */
2221
- private async snapshot(ids: string[] | "*", origin: string | undefined): Promise<() => Promise<void>> {
2301
+ private async snapshot(
2302
+ ids: string[] | "*",
2303
+ origin: string | undefined,
2304
+ ): Promise<(() => Promise<void>) & { readonly before: ReadonlyMap<string, T> }> {
2222
2305
  const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
2223
2306
  const touched = ids === "*" ? [...before.keys()] : ids;
2224
2307
  // The versions after the write: this write adds one to each id (clear adds to clearVersion)
2225
2308
  const versions = new Map(touched.map((id) => [id, (this.writeVersions.get(id) ?? 0) + (ids === "*" ? 0 : 1)]));
2226
2309
  const clearVersion = this.clearVersion + (ids === "*" ? 1 : 0);
2227
- return async () => {
2310
+ const undo = async (): Promise<void> => {
2228
2311
  if (this.clearVersion !== clearVersion) return;
2229
2312
  for (const id of touched) {
2230
2313
  if ((this.writeVersions.get(id) ?? 0) !== versions.get(id)) continue;
@@ -2233,6 +2316,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2233
2316
  else await this.localSet(id, value, origin);
2234
2317
  }
2235
2318
  };
2319
+ return Object.assign(undo, { before });
2236
2320
  }
2237
2321
 
2238
2322
  //
@@ -2405,6 +2489,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2405
2489
  cacheMisses: this.stats.cacheMisses,
2406
2490
  syncCount: this.stats.syncCount,
2407
2491
  conflicts: this.stats.conflicts,
2492
+ dropped: this.stats.dropped,
2408
2493
  pendingOps: this.offlineOps.length,
2409
2494
  isOnline: this.isOnline,
2410
2495
  mode: this.mode,
@@ -2752,21 +2837,24 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2752
2837
  if (base === undefined || base === remoteVersion) return false;
2753
2838
  if (knownVersion === remoteVersion) {
2754
2839
  this.bases.set(id, remoteVersion);
2840
+ this.noteBaseValue(id, remoteVersion, item);
2755
2841
  return false;
2756
2842
  }
2757
2843
  const localItem = await this.readLocal(id);
2758
2844
  if (changedLocally(id)) return false;
2759
2845
  if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
2760
2846
  this.bases.set(id, remoteVersion);
2847
+ this.noteBaseValue(id, remoteVersion, item);
2761
2848
  return false;
2762
2849
  }
2763
- return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
2850
+ return this.resolvePending(id, localItem, item, remoteVersion, conflicts, "pull");
2764
2851
  }
2765
2852
 
2766
2853
  if (knownVersion !== undefined) {
2767
2854
  // Only the remote changed: it is not a conflict. The version of the local item is known.
2768
2855
  if (knownVersion !== remoteVersion) await this.localSet(id, item, this.syncOrigin);
2769
2856
  this.bases.set(id, remoteVersion!);
2857
+ this.noteBaseValue(id, remoteVersion!, item);
2770
2858
  return false;
2771
2859
  }
2772
2860
 
@@ -2789,7 +2877,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2789
2877
  await this.localSet(id, resolved, this.syncOrigin);
2790
2878
  this.stats.conflicts++;
2791
2879
  }
2792
- if (remoteVersion !== undefined) this.bases.set(id, remoteVersion);
2880
+ if (remoteVersion !== undefined) {
2881
+ this.bases.set(id, remoteVersion);
2882
+ this.noteBaseValue(id, remoteVersion, item);
2883
+ }
2793
2884
  return false;
2794
2885
  }
2795
2886
 
@@ -2822,6 +2913,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2822
2913
  if (!this.dropQueued(id)) break;
2823
2914
  await this.localDelete(id, this.syncOrigin);
2824
2915
  this.bases.set(id, null);
2916
+ await this.reportDropped({ id, value: localItem, reason: "remote-delete", during: "pull" });
2825
2917
  return true;
2826
2918
  case "error":
2827
2919
  conflicts.push(id);
@@ -2844,6 +2936,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2844
2936
  remoteItem: T,
2845
2937
  remoteVersion: string,
2846
2938
  conflicts: string[],
2939
+ during: DroppedWrite["during"],
2847
2940
  ): Promise<boolean> {
2848
2941
  this.stats.conflicts++;
2849
2942
  switch (this.options.conflictResolution) {
@@ -2852,14 +2945,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2852
2945
  if (!this.dropQueued(id)) break;
2853
2946
  await this.localSet(id, remoteItem, this.syncOrigin);
2854
2947
  this.bases.set(id, remoteVersion);
2948
+ this.noteBaseValue(id, remoteVersion, remoteItem);
2949
+ await this.reportDropped({ id, value: localItem, reason: "conflict", during, remote: remoteItem });
2855
2950
  return true;
2856
2951
  case "merge": {
2857
2952
  if (localItem === undefined) break;
2858
- const merged = this.resolveConflict(localItem, remoteItem);
2953
+ const merged = this.resolveConflict(localItem, remoteItem, this.queuedBaseValue(id));
2859
2954
  if (!this.dropQueued(id)) break;
2860
2955
  await this.localSet(id, merged, this.syncOrigin);
2861
2956
  this.bases.set(id, remoteVersion);
2862
- this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
2957
+ this.noteBaseValue(id, remoteVersion, remoteItem);
2958
+ // The merged write changes the remote item of this conflict: that item is its base
2959
+ this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]], baseValues: [[id, remoteItem]] });
2863
2960
  return true;
2864
2961
  }
2865
2962
  case "error":
@@ -2873,6 +2970,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2873
2970
  return false;
2874
2971
  }
2875
2972
 
2973
+ /** This method counts a dropped write and gives it to the hook `onDropped`. An error of the hook does not stop the sync. */
2974
+ private async reportDropped(drop: DroppedWrite): Promise<void> {
2975
+ this.stats.dropped++;
2976
+ try {
2977
+ await this.options.onDropped?.(drop);
2978
+ } catch {
2979
+ // An error of the hook does not stop the sync
2980
+ }
2981
+ }
2982
+
2876
2983
  /**
2877
2984
  * This method removes an id from the queued operations. It gives false when a queued clear touches it: nothing
2878
2985
  * changes then.
@@ -2927,6 +3034,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2927
3034
  private async pushQueue(): Promise<void> {
2928
3035
  this.pushing = true;
2929
3036
  let sent = 0;
3037
+ // True when a batch went again as one write for each id: the queue store gets the new operations
3038
+ let split = false;
2930
3039
  // The operations whose conflicts this push resolved once (roadmap CLI-16)
2931
3040
  const resolved = new Set<QueuedOperation>();
2932
3041
  try {
@@ -2956,20 +3065,25 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2956
3065
  }
2957
3066
  continue;
2958
3067
  }
2959
- if (
2960
- conflicts === undefined &&
2961
- !(error instanceof QueueStoreError) &&
2962
- !this.options.isUnavailable(error) &&
2963
- this.options.isRefusal(error)
2964
- ) {
2965
- // The remote refused the write: it leaves the queue, and the push goes on (roadmap O67)
3068
+ const tooLarge = conflicts === undefined && !(error instanceof QueueStoreError) && this.options.isTooLarge(error);
3069
+ const refused = tooLarge || (conflicts === undefined && !(error instanceof QueueStoreError) && !this.options.isUnavailable(error) && this.options.isRefusal(error));
3070
+ if (refused) {
2966
3071
  this.isOnline = true;
3072
+ const singles = singlesOf(op);
3073
+ if (singles.length > 1 && this.offlineOps[0] === op) {
3074
+ // A batch goes again as one write for each id, so only the bad write leaves the queue (roadmap CLI-108)
3075
+ this.offlineOps.splice(0, 1, ...singles);
3076
+ split = true;
3077
+ this.storeDirty = true;
3078
+ continue;
3079
+ }
3080
+ // The remote refused the write: it leaves the queue, and the push goes on (roadmap O67)
2967
3081
  if (this.offlineOps[0] === op) {
2968
3082
  this.offlineOps.shift();
2969
3083
  sent++;
2970
3084
  this.storeDirty = true;
2971
3085
  }
2972
- await this.refuseQueued(op, error);
3086
+ await this.refuseQueued(op, error, tooLarge ? "too-large" : "refused");
2973
3087
  continue;
2974
3088
  }
2975
3089
  // This operation and the later ones stay queued
@@ -2987,7 +3101,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2987
3101
  this.isOnline = true;
2988
3102
  } finally {
2989
3103
  this.pushing = false;
2990
- if (sent > 0) {
3104
+ if (sent > 0 || split) {
2991
3105
  // The store keeps sent operations, and the status gets the failure. A later direct write
2992
3106
  // repairs the store first.
2993
3107
  await this.persistAll().catch(() => undefined);
@@ -3000,7 +3114,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3000
3114
  * gets the remote values, and the hook `onRefused` gets the refusal. A refused clear asks for a
3001
3115
  * full pull.
3002
3116
  */
3003
- private async refuseQueued(op: QueuedOperation, error: unknown): Promise<void> {
3117
+ private async refuseQueued(op: QueuedOperation, error: unknown, reason: "refused" | "too-large"): Promise<void> {
3004
3118
  const ids = idsOf(op);
3005
3119
  const all = ids.includes("*");
3006
3120
  let restored = false;
@@ -3014,6 +3128,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3014
3128
  } catch {
3015
3129
  // An error of the hook does not stop the queue
3016
3130
  }
3131
+ for (const id of all ? ["*"] : ids) {
3132
+ const written = this.writtenValue(op, id);
3133
+ await this.reportDropped({ id, value: written ? written.value : undefined, reason, during: "push", error });
3134
+ }
3017
3135
  }
3018
3136
 
3019
3137
  /**
@@ -3078,8 +3196,54 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3078
3196
  return;
3079
3197
  }
3080
3198
  const version = this.versionOfItem(item);
3081
- if (version === undefined) this.bases.delete(id);
3082
- else this.bases.set(id, version);
3199
+ if (version === undefined) {
3200
+ this.bases.delete(id);
3201
+ return;
3202
+ }
3203
+ this.bases.set(id, version);
3204
+ this.noteBaseValue(id, version, item);
3205
+ }
3206
+
3207
+ /** This method keeps the remote value of the remote version of an id, for a captured change (roadmap CLI-109). */
3208
+ private noteBaseValue(id: string, version: string, value: T): void {
3209
+ if (!this.keepsBaseValues || !this.capturing) return;
3210
+ if (this.bases.get(id) === version) this.baseValues.set(id, { version, value });
3211
+ }
3212
+
3213
+ /**
3214
+ * This method gives a write of the storage the base value of each id that has no queued write (roadmap CLI-109).
3215
+ * The value is the local item before the write, when that item has the base version. The local copy then holds the
3216
+ * remote value of a sync or a send.
3217
+ */
3218
+ private fillBaseValues(op: QueuedOperation, before: ReadonlyMap<string, T>): void {
3219
+ if (!this.keepsBaseValues || !op.base) return;
3220
+ const values = op.baseValues ? [...op.baseValues] : [];
3221
+ const known = new Set(values.map(([id]) => id));
3222
+ for (const [id, version] of op.base) {
3223
+ if (known.has(id) || version === null) continue;
3224
+ const value = before.get(id);
3225
+ if (value !== undefined && this.versionOfItem(value) === version) values.push([id, value]);
3226
+ }
3227
+ if (values.length > 0) op.baseValues = values;
3228
+ }
3229
+
3230
+ /** The remote value at a remote version of an id: `null` for no item, and undefined when the storage does not know it. */
3231
+ private baseValueAt(id: string, version: string | null | undefined): T | null | undefined {
3232
+ if (version === null) return null;
3233
+ const known = this.baseValues.get(id);
3234
+ return known !== undefined && known.version === version ? known.value : undefined;
3235
+ }
3236
+
3237
+ /**
3238
+ * The remote value that the queued writes of an id changed: the base value of the first queued operation of the id.
3239
+ * The value is undefined when that operation has none, or when it is a clear.
3240
+ */
3241
+ private queuedBaseValue(id: string): T | null | undefined {
3242
+ const op = this.offlineOps.find((queued) => {
3243
+ const ids = idsOf(queued);
3244
+ return ids.includes(id) || ids.includes("*");
3245
+ });
3246
+ return op?.baseValues?.find(([valueId]) => valueId === id)?.[1] as T | null | undefined;
3083
3247
  }
3084
3248
 
3085
3249
  /** This method records the remote versions after a write reached the remote. */
@@ -3113,12 +3277,47 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3113
3277
  private attachBase(op: QueuedOperation): void {
3114
3278
  if (!this.options.versionOf) return;
3115
3279
  const base: Array<[string, string | null]> = [];
3280
+ const values: Array<[string, unknown]> = [];
3116
3281
  for (const id of idsOf(op)) {
3117
3282
  if (id === "*") continue;
3118
3283
  const version = this.expectedBase(id);
3119
3284
  if (version !== undefined) base.push([id, version]);
3285
+ const value = this.expectedBaseValue(id, version);
3286
+ if (value !== undefined) values.push([id, value]);
3120
3287
  }
3121
3288
  if (base.length > 0) op.base = base;
3289
+ if (values.length > 0) op.baseValues = values;
3290
+ }
3291
+
3292
+ /**
3293
+ * This method gives the remote value at the version of `expectedBase`, for the policy `merge` (roadmap CLI-109). It
3294
+ * is the value of an older queued operation of the id. Without one, it is the kept value of the known remote version.
3295
+ */
3296
+ private expectedBaseValue(id: string, version: string | null | undefined): T | null | undefined {
3297
+ if (!this.keepsBaseValues || version === undefined) return undefined;
3298
+ for (let index = this.offlineOps.length - 1; index >= 0; index--) {
3299
+ const op = this.offlineOps[index]!;
3300
+ const data = op.data as { id?: string; value?: T; ids?: string[]; items?: Array<[string, T]> };
3301
+ switch (op.type) {
3302
+ case "set":
3303
+ if (data.id === id) return data.value as T;
3304
+ break;
3305
+ case "delete":
3306
+ if (data.id === id) return null;
3307
+ break;
3308
+ case "setBatch": {
3309
+ const item = (data.items ?? []).findLast(([itemId]) => itemId === id);
3310
+ if (item) return item[1];
3311
+ break;
3312
+ }
3313
+ case "deleteBatch":
3314
+ if ((data.ids ?? []).includes(id)) return null;
3315
+ break;
3316
+ case "clear":
3317
+ return null;
3318
+ }
3319
+ }
3320
+ return this.baseValueAt(id, version);
3122
3321
  }
3123
3322
 
3124
3323
  /** The remote version of an id after the queued operations, or undefined when it is not known. */
@@ -3158,7 +3357,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3158
3357
  /**
3159
3358
  * Resolve conflict between local and remote versions.
3160
3359
  */
3161
- private resolveConflict(local: T, remote: T): T {
3360
+ private resolveConflict(local: T, remote: T, base?: T | null): T {
3162
3361
  switch (this.options.conflictResolution) {
3163
3362
  case "local":
3164
3363
  return local;
@@ -3172,7 +3371,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3172
3371
  "Merge function required for conflict resolution strategy 'merge'"
3173
3372
  );
3174
3373
  }
3175
- return this.options.mergeFn(local, remote);
3374
+ return this.options.mergeFn(local, remote, base);
3176
3375
 
3177
3376
  case "error":
3178
3377
  throw new Error(
@@ -3204,6 +3403,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3204
3403
  // The new operation changes the same remote version as the operation that it replaces
3205
3404
  if (previous.base) op.base = previous.base;
3206
3405
  else delete op.base;
3406
+ if (previous.baseValues) op.baseValues = previous.baseValues;
3407
+ else delete op.baseValues;
3207
3408
  this.offlineOps[index] = op;
3208
3409
  rollback = () => {
3209
3410
  const at = this.offlineOps.indexOf(op);
@@ -3444,12 +3645,15 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3444
3645
  const echo = written !== null && sameJson(written.value, remoteItem);
3445
3646
  if (remoteVersion === undefined || echo) {
3446
3647
  if (remoteVersion === undefined) this.bases.delete(id);
3447
- else this.bases.set(id, remoteVersion);
3648
+ else {
3649
+ this.bases.set(id, remoteVersion);
3650
+ this.noteBaseValue(id, remoteVersion, remoteItem);
3651
+ }
3448
3652
  if (echo) echoes.add(id);
3449
3653
  continue;
3450
3654
  }
3451
3655
  const localItem = await this.readLocal(id);
3452
- if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids)) queueChanged = true;
3656
+ if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids, "push")) queueChanged = true;
3453
3657
  continue;
3454
3658
  }
3455
3659
  // The remote has no item: the echo of a delete, or a delete of another writer
@@ -3478,11 +3682,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3478
3682
  private async resolveRemoteDelete(id: string, conflicts: string[]): Promise<boolean> {
3479
3683
  this.stats.conflicts++;
3480
3684
  switch (this.options.conflictResolution) {
3481
- case "remote":
3685
+ case "remote": {
3686
+ const value = await this.readLocal(id);
3482
3687
  if (!this.dropQueued(id)) break;
3483
3688
  await this.localDelete(id, this.syncOrigin);
3484
3689
  this.bases.set(id, null);
3690
+ await this.reportDropped({ id, value, reason: "remote-delete", during: "push" });
3485
3691
  return true;
3692
+ }
3486
3693
  case "error":
3487
3694
  conflicts.push(id);
3488
3695
  return false;
@@ -50,6 +50,7 @@ export {
50
50
  RemoteApplyHookError,
51
51
  } from "./hybrid.js";
52
52
  export type {
53
+ DroppedWrite,
53
54
  HybridStorageOptions,
54
55
  HybridMode,
55
56
  LocalCopyStorage,
@@ -31,6 +31,12 @@ export interface QueuedOperation {
31
31
  * reads it.
32
32
  */
33
33
  base?: Array<[string, string | null]>;
34
+ /**
35
+ * The remote value at the version of `base`, for each id with a known value (roadmap CLI-109). `null`: the remote had
36
+ * no item. Only a storage with `versionOf` and the policy `merge` writes it, and `mergeFn` gets it as the base of a
37
+ * three-way merge. An operation from before this field loads, and its merge gets no base.
38
+ */
39
+ baseValues?: Array<[string, unknown]>;
34
40
  }
35
41
 
36
42
  /**
@@ -116,6 +122,27 @@ export function withoutId(op: QueuedOperation, id: string): QueuedOperation | un
116
122
  }
117
123
  }
118
124
 
125
+ /**
126
+ * The operations of one id each that a batch holds, in its order, with the base version of each id (roadmap CLI-108).
127
+ * A set, a delete and a clear give an array of the operation itself.
128
+ */
129
+ export function singlesOf(op: QueuedOperation): QueuedOperation[] {
130
+ const data = op.data as { ids?: string[]; items?: Array<[string, unknown]> };
131
+ const baseOf = (id: string): Pick<QueuedOperation, "base" | "baseValues"> => {
132
+ const base = op.base?.find(([baseId]) => baseId === id);
133
+ const value = op.baseValues?.find(([valueId]) => valueId === id);
134
+ return { ...(base !== undefined && { base: [base] }), ...(value !== undefined && { baseValues: [value] }) };
135
+ };
136
+ switch (op.type) {
137
+ case "setBatch":
138
+ return (data.items ?? []).map(([id, value]) => ({ type: "set", data: { id, value }, timestamp: op.timestamp, ...baseOf(id) }));
139
+ case "deleteBatch":
140
+ return (data.ids ?? []).map((id) => ({ type: "delete", data: { id }, timestamp: op.timestamp, ...baseOf(id) }));
141
+ default:
142
+ return [op];
143
+ }
144
+ }
145
+
119
146
  /** True when a value has the shape of a queued operation. A loaded file can hold anything. */
120
147
  export function isQueuedOperation(value: unknown): value is QueuedOperation {
121
148
  if (typeof value !== "object" || value === null) return false;