@procedures/store 0.48.2 → 0.49.1

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
@@ -604,13 +616,16 @@ export interface DroppedWrite {
604
616
  * The last queued write gives the value when an id has more than one.
605
617
  */
606
618
  value: unknown;
607
- /** The cause: the policy took the remote value, the remote deleted the item, or the remote refused the write. */
608
- reason: "conflict" | "remote-delete" | "refused";
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";
609
624
  /** The sync that dropped the write. */
610
625
  during: "pull" | "push";
611
626
  /** The remote value that replaced the write, for the reason `conflict`. */
612
627
  remote?: unknown;
613
- /** The error of the remote, for the reason `refused`. */
628
+ /** The error of the remote, for the reasons `refused` and `too-large`. */
614
629
  error?: unknown;
615
630
  }
616
631
 
@@ -749,6 +764,9 @@ const defaultIsRefusal = (error: unknown): boolean => {
749
764
  return typeof code === "string" && REFUSAL_CODES.has(code);
750
765
  };
751
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
+
752
770
  /** True when a remote does not know a procedure: an older server without `changes`. */
753
771
  const isNotFound = (error: unknown): boolean =>
754
772
  typeof error === "object" && error !== null && (error as { code?: unknown }).code === "NOT_FOUND";
@@ -954,15 +972,20 @@ class BaseVersions extends Map<string, string | null> {
954
972
  /** True when the storage has a sync state store */
955
973
  tracked = false;
956
974
 
975
+ /** This function gets each change of a version, after the change. */
976
+ onChange: ((id: string, version: string | null | undefined) => void) | undefined;
977
+
957
978
  override set(id: string, version: string | null): this {
958
979
  super.set(id, version);
959
980
  if (this.tracked) this.changed.add(id);
981
+ this.onChange?.(id, version);
960
982
  return this;
961
983
  }
962
984
 
963
985
  override delete(id: string): boolean {
964
986
  const had = super.delete(id);
965
987
  if (had && this.tracked) this.changed.add(id);
988
+ this.onChange?.(id, undefined);
966
989
  return had;
967
990
  }
968
991
  }
@@ -1009,7 +1032,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1009
1032
  private readonly queueStore: QueueStore;
1010
1033
  private options: {
1011
1034
  conflictResolution: ConflictResolution;
1012
- mergeFn?: <T>(local: T, remote: T) => T;
1035
+ mergeFn?: <T>(local: T, remote: T, base: T | null | undefined) => T;
1013
1036
  keyOf: (item: T) => string | undefined;
1014
1037
  versionOf?: (item: T) => string | undefined;
1015
1038
  sendBaseVersions: boolean;
@@ -1020,6 +1043,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1020
1043
  offlineQueue: boolean;
1021
1044
  isUnavailable: (error: unknown) => boolean;
1022
1045
  isRefusal: (error: unknown) => boolean;
1046
+ isTooLarge: (error: unknown) => boolean;
1023
1047
  onRefused?: (refusal: QueueRefusal) => void | Promise<void>;
1024
1048
  onDropped?: (drop: DroppedWrite) => void | Promise<void>;
1025
1049
  };
@@ -1077,6 +1101,13 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1077
1101
  private changesUnsupported = false;
1078
1102
  /** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
1079
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 }>();
1080
1111
  /**
1081
1112
  * With `versionOf`: the version of each local item that the storage wrote or read. A pull does
1082
1113
  * not read an item whose version it knows. The version of an id that is not here is unknown.
@@ -1108,6 +1139,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1108
1139
  private resetPull = false;
1109
1140
  /** The stop of the capture of the local copy */
1110
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;
1111
1146
  /** The captured changes that came before the end of the load. Undefined after the load. */
1112
1147
  private captured: StorageChangeEvent<T>[] | undefined = [];
1113
1148
  /** With `versionOf`: the versions of the own writes that wait for their echo */
@@ -1165,6 +1200,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1165
1200
  offlineQueue: options.offlineQueue ?? true,
1166
1201
  isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
1167
1202
  isRefusal: options.isRefusal ?? defaultIsRefusal,
1203
+ isTooLarge: options.isTooLarge ?? defaultIsTooLarge,
1168
1204
  ...(options.onRefused !== undefined && { onRefused: options.onRefused }),
1169
1205
  ...(options.onDropped !== undefined && { onDropped: options.onDropped }),
1170
1206
  };
@@ -1181,6 +1217,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1181
1217
  this.aroundRemoteApply = options.aroundRemoteApply;
1182
1218
  this.stateStore = options.syncState;
1183
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
+ };
1184
1223
  this.localGiven = options.local !== undefined;
1185
1224
 
1186
1225
  // The timers on the clock, with the backoff after a failure (roadmap CLI-13)
@@ -1216,6 +1255,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1216
1255
  : undefined;
1217
1256
  const capture: unknown = options.capture ?? "none";
1218
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;
1219
1260
  if (capture === "observe") {
1220
1261
  if (!this.local.observe) throw new TypeError('capture: "observe" needs a local copy with observe()');
1221
1262
  // The capture starts before the load: a change of the application during the load waits for it
@@ -1365,6 +1406,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1365
1406
  const previous = this.offlineOps[index]!;
1366
1407
  if (previous.base) op.base = previous.base;
1367
1408
  else delete op.base;
1409
+ if (previous.baseValues) op.baseValues = previous.baseValues;
1410
+ else delete op.baseValues;
1368
1411
  this.offlineOps[index] = op;
1369
1412
  } else {
1370
1413
  this.offlineOps.push(op);
@@ -2077,6 +2120,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2077
2120
  this.ensureQueueRoom(op);
2078
2121
  this.attachBase(op);
2079
2122
  const undo = await this.snapshot(ids, origin);
2123
+ this.fillBaseValues(op, undo.before);
2080
2124
  const result = await applyLocal();
2081
2125
  this.noteOwnWrite(op);
2082
2126
  await this.enqueue(op, undo);
@@ -2117,6 +2161,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2117
2161
  // The status shows the queue and the connection. The caller gets PendingWriteError.
2118
2162
  this.isOnline = false;
2119
2163
  this.attachBase(op);
2164
+ this.fillBaseValues(op, undo.before);
2120
2165
  await this.enqueue(op, undo);
2121
2166
  this.scheduleDrain();
2122
2167
  throw new PendingWriteError(type, error);
@@ -2253,13 +2298,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2253
2298
  * The local values of the ids before a write, and a function that puts them back. It puts back
2254
2299
  * only the ids that no later write changed.
2255
2300
  */
2256
- 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> }> {
2257
2305
  const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
2258
2306
  const touched = ids === "*" ? [...before.keys()] : ids;
2259
2307
  // The versions after the write: this write adds one to each id (clear adds to clearVersion)
2260
2308
  const versions = new Map(touched.map((id) => [id, (this.writeVersions.get(id) ?? 0) + (ids === "*" ? 0 : 1)]));
2261
2309
  const clearVersion = this.clearVersion + (ids === "*" ? 1 : 0);
2262
- return async () => {
2310
+ const undo = async (): Promise<void> => {
2263
2311
  if (this.clearVersion !== clearVersion) return;
2264
2312
  for (const id of touched) {
2265
2313
  if ((this.writeVersions.get(id) ?? 0) !== versions.get(id)) continue;
@@ -2268,6 +2316,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2268
2316
  else await this.localSet(id, value, origin);
2269
2317
  }
2270
2318
  };
2319
+ return Object.assign(undo, { before });
2271
2320
  }
2272
2321
 
2273
2322
  //
@@ -2788,12 +2837,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2788
2837
  if (base === undefined || base === remoteVersion) return false;
2789
2838
  if (knownVersion === remoteVersion) {
2790
2839
  this.bases.set(id, remoteVersion);
2840
+ this.noteBaseValue(id, remoteVersion, item);
2791
2841
  return false;
2792
2842
  }
2793
2843
  const localItem = await this.readLocal(id);
2794
2844
  if (changedLocally(id)) return false;
2795
2845
  if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
2796
2846
  this.bases.set(id, remoteVersion);
2847
+ this.noteBaseValue(id, remoteVersion, item);
2797
2848
  return false;
2798
2849
  }
2799
2850
  return this.resolvePending(id, localItem, item, remoteVersion, conflicts, "pull");
@@ -2803,6 +2854,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2803
2854
  // Only the remote changed: it is not a conflict. The version of the local item is known.
2804
2855
  if (knownVersion !== remoteVersion) await this.localSet(id, item, this.syncOrigin);
2805
2856
  this.bases.set(id, remoteVersion!);
2857
+ this.noteBaseValue(id, remoteVersion!, item);
2806
2858
  return false;
2807
2859
  }
2808
2860
 
@@ -2825,7 +2877,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2825
2877
  await this.localSet(id, resolved, this.syncOrigin);
2826
2878
  this.stats.conflicts++;
2827
2879
  }
2828
- 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
+ }
2829
2884
  return false;
2830
2885
  }
2831
2886
 
@@ -2890,15 +2945,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2890
2945
  if (!this.dropQueued(id)) break;
2891
2946
  await this.localSet(id, remoteItem, this.syncOrigin);
2892
2947
  this.bases.set(id, remoteVersion);
2948
+ this.noteBaseValue(id, remoteVersion, remoteItem);
2893
2949
  await this.reportDropped({ id, value: localItem, reason: "conflict", during, remote: remoteItem });
2894
2950
  return true;
2895
2951
  case "merge": {
2896
2952
  if (localItem === undefined) break;
2897
- const merged = this.resolveConflict(localItem, remoteItem);
2953
+ const merged = this.resolveConflict(localItem, remoteItem, this.queuedBaseValue(id));
2898
2954
  if (!this.dropQueued(id)) break;
2899
2955
  await this.localSet(id, merged, this.syncOrigin);
2900
2956
  this.bases.set(id, remoteVersion);
2901
- 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]] });
2902
2960
  return true;
2903
2961
  }
2904
2962
  case "error":
@@ -2976,6 +3034,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2976
3034
  private async pushQueue(): Promise<void> {
2977
3035
  this.pushing = true;
2978
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;
2979
3039
  // The operations whose conflicts this push resolved once (roadmap CLI-16)
2980
3040
  const resolved = new Set<QueuedOperation>();
2981
3041
  try {
@@ -3005,20 +3065,25 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3005
3065
  }
3006
3066
  continue;
3007
3067
  }
3008
- if (
3009
- conflicts === undefined &&
3010
- !(error instanceof QueueStoreError) &&
3011
- !this.options.isUnavailable(error) &&
3012
- this.options.isRefusal(error)
3013
- ) {
3014
- // 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) {
3015
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)
3016
3081
  if (this.offlineOps[0] === op) {
3017
3082
  this.offlineOps.shift();
3018
3083
  sent++;
3019
3084
  this.storeDirty = true;
3020
3085
  }
3021
- await this.refuseQueued(op, error);
3086
+ await this.refuseQueued(op, error, tooLarge ? "too-large" : "refused");
3022
3087
  continue;
3023
3088
  }
3024
3089
  // This operation and the later ones stay queued
@@ -3036,7 +3101,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3036
3101
  this.isOnline = true;
3037
3102
  } finally {
3038
3103
  this.pushing = false;
3039
- if (sent > 0) {
3104
+ if (sent > 0 || split) {
3040
3105
  // The store keeps sent operations, and the status gets the failure. A later direct write
3041
3106
  // repairs the store first.
3042
3107
  await this.persistAll().catch(() => undefined);
@@ -3049,7 +3114,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3049
3114
  * gets the remote values, and the hook `onRefused` gets the refusal. A refused clear asks for a
3050
3115
  * full pull.
3051
3116
  */
3052
- private async refuseQueued(op: QueuedOperation, error: unknown): Promise<void> {
3117
+ private async refuseQueued(op: QueuedOperation, error: unknown, reason: "refused" | "too-large"): Promise<void> {
3053
3118
  const ids = idsOf(op);
3054
3119
  const all = ids.includes("*");
3055
3120
  let restored = false;
@@ -3065,7 +3130,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3065
3130
  }
3066
3131
  for (const id of all ? ["*"] : ids) {
3067
3132
  const written = this.writtenValue(op, id);
3068
- await this.reportDropped({ id, value: written ? written.value : undefined, reason: "refused", during: "push", error });
3133
+ await this.reportDropped({ id, value: written ? written.value : undefined, reason, during: "push", error });
3069
3134
  }
3070
3135
  }
3071
3136
 
@@ -3131,8 +3196,54 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3131
3196
  return;
3132
3197
  }
3133
3198
  const version = this.versionOfItem(item);
3134
- if (version === undefined) this.bases.delete(id);
3135
- 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;
3136
3247
  }
3137
3248
 
3138
3249
  /** This method records the remote versions after a write reached the remote. */
@@ -3166,12 +3277,47 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3166
3277
  private attachBase(op: QueuedOperation): void {
3167
3278
  if (!this.options.versionOf) return;
3168
3279
  const base: Array<[string, string | null]> = [];
3280
+ const values: Array<[string, unknown]> = [];
3169
3281
  for (const id of idsOf(op)) {
3170
3282
  if (id === "*") continue;
3171
3283
  const version = this.expectedBase(id);
3172
3284
  if (version !== undefined) base.push([id, version]);
3285
+ const value = this.expectedBaseValue(id, version);
3286
+ if (value !== undefined) values.push([id, value]);
3173
3287
  }
3174
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);
3175
3321
  }
3176
3322
 
3177
3323
  /** The remote version of an id after the queued operations, or undefined when it is not known. */
@@ -3211,7 +3357,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3211
3357
  /**
3212
3358
  * Resolve conflict between local and remote versions.
3213
3359
  */
3214
- private resolveConflict(local: T, remote: T): T {
3360
+ private resolveConflict(local: T, remote: T, base?: T | null): T {
3215
3361
  switch (this.options.conflictResolution) {
3216
3362
  case "local":
3217
3363
  return local;
@@ -3225,7 +3371,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3225
3371
  "Merge function required for conflict resolution strategy 'merge'"
3226
3372
  );
3227
3373
  }
3228
- return this.options.mergeFn(local, remote);
3374
+ return this.options.mergeFn(local, remote, base);
3229
3375
 
3230
3376
  case "error":
3231
3377
  throw new Error(
@@ -3257,6 +3403,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3257
3403
  // The new operation changes the same remote version as the operation that it replaces
3258
3404
  if (previous.base) op.base = previous.base;
3259
3405
  else delete op.base;
3406
+ if (previous.baseValues) op.baseValues = previous.baseValues;
3407
+ else delete op.baseValues;
3260
3408
  this.offlineOps[index] = op;
3261
3409
  rollback = () => {
3262
3410
  const at = this.offlineOps.indexOf(op);
@@ -3497,7 +3645,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
3497
3645
  const echo = written !== null && sameJson(written.value, remoteItem);
3498
3646
  if (remoteVersion === undefined || echo) {
3499
3647
  if (remoteVersion === undefined) this.bases.delete(id);
3500
- else this.bases.set(id, remoteVersion);
3648
+ else {
3649
+ this.bases.set(id, remoteVersion);
3650
+ this.noteBaseValue(id, remoteVersion, remoteItem);
3651
+ }
3501
3652
  if (echo) echoes.add(id);
3502
3653
  continue;
3503
3654
  }
@@ -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;