@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.
@@ -49,7 +49,7 @@ import { InMemoryStorage, StorageListeners } from "@procedures/collections";
49
49
  import { queryEntries, queryOptionsProblems, selectorProblems, versionConflictsOf, } from "@procedures/client/query";
50
50
  import { optionalMs, realClock } from "@procedures/client/browser";
51
51
  import { backoffDelay } from "@mark1russell7/service/backoff";
52
- import { MemoryQueueStore, idsOf, withoutId } from "./queue.js";
52
+ import { MemoryQueueStore, idsOf, singlesOf, withoutId } from "./queue.js";
53
53
  import { isSyncState } from "./sync-state.js";
54
54
  const MODES = new Set(["local-only", "write-through", "write-back", "remote-only", "read-only"]);
55
55
  /** True for the modes that keep a local copy and read the remote changes into it. */
@@ -283,6 +283,8 @@ const defaultIsRefusal = (error) => {
283
283
  const code = error?.code;
284
284
  return typeof code === "string" && REFUSAL_CODES.has(code);
285
285
  };
286
+ /** The default of `isTooLarge`: an error with the code `PAYLOAD_TOO_LARGE` (roadmap CLI-108). */
287
+ const defaultIsTooLarge = (error) => error?.code === "PAYLOAD_TOO_LARGE";
286
288
  /** True when a remote does not know a procedure: an older server without `changes`. */
287
289
  const isNotFound = (error) => typeof error === "object" && error !== null && error.code === "NOT_FOUND";
288
290
  const defaultKeyOf = (item) => {
@@ -469,16 +471,20 @@ class BaseVersions extends Map {
469
471
  changed = new Set();
470
472
  /** True when the storage has a sync state store */
471
473
  tracked = false;
474
+ /** This function gets each change of a version, after the change. */
475
+ onChange;
472
476
  set(id, version) {
473
477
  super.set(id, version);
474
478
  if (this.tracked)
475
479
  this.changed.add(id);
480
+ this.onChange?.(id, version);
476
481
  return this;
477
482
  }
478
483
  delete(id) {
479
484
  const had = super.delete(id);
480
485
  if (had && this.tracked)
481
486
  this.changed.add(id);
487
+ this.onChange?.(id, undefined);
482
488
  return had;
483
489
  }
484
490
  }
@@ -569,6 +575,13 @@ export class HybridStorage {
569
575
  changesUnsupported = false;
570
576
  /** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
571
577
  bases = new BaseVersions();
578
+ /**
579
+ * With `versionOf`, the policy `merge` and `capture: "observe"`, this map holds the remote value at the known remote
580
+ * version of each id (roadmap CLI-109). A captured change has no value before it, so its base value comes from here.
581
+ * A write of the storage reads its base value from the local copy, and needs no map. A new version of an id drops
582
+ * its value. A sync or a send gives the value of the new version.
583
+ */
584
+ baseValues = new Map();
572
585
  /**
573
586
  * With `versionOf`: the version of each local item that the storage wrote or read. A pull does
574
587
  * not read an item whose version it knows. The version of an id that is not here is unknown.
@@ -600,6 +613,10 @@ export class HybridStorage {
600
613
  resetPull = false;
601
614
  /** The stop of the capture of the local copy */
602
615
  stopCapture;
616
+ /** True with `capture: "observe"`. */
617
+ capturing;
618
+ /** True when a queued write keeps its base value: with `versionOf` and the policy `merge` (roadmap CLI-109). */
619
+ keepsBaseValues;
603
620
  /** The captured changes that came before the end of the load. Undefined after the load. */
604
621
  captured = [];
605
622
  /** With `versionOf`: the versions of the own writes that wait for their echo */
@@ -630,6 +647,7 @@ export class HybridStorage {
630
647
  cacheMisses: 0,
631
648
  syncCount: 0,
632
649
  conflicts: 0,
650
+ dropped: 0,
633
651
  };
634
652
  constructor(remote, options = {}) {
635
653
  // The local copy tests the preconditions of the callers (roadmap CLI-16): it gets `versionOf`
@@ -652,7 +670,9 @@ export class HybridStorage {
652
670
  offlineQueue: options.offlineQueue ?? true,
653
671
  isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
654
672
  isRefusal: options.isRefusal ?? defaultIsRefusal,
673
+ isTooLarge: options.isTooLarge ?? defaultIsTooLarge,
655
674
  ...(options.onRefused !== undefined && { onRefused: options.onRefused }),
675
+ ...(options.onDropped !== undefined && { onDropped: options.onDropped }),
656
676
  };
657
677
  this.statusListeners = new StorageListeners(options.onListenerError);
658
678
  this.queryPolicy = options.queryPolicy;
@@ -667,6 +687,10 @@ export class HybridStorage {
667
687
  this.aroundRemoteApply = options.aroundRemoteApply;
668
688
  this.stateStore = options.syncState;
669
689
  this.bases.tracked = this.stateStore !== undefined;
690
+ this.bases.onChange = (id, version) => {
691
+ if (this.baseValues.get(id)?.version !== version)
692
+ this.baseValues.delete(id);
693
+ };
670
694
  this.localGiven = options.local !== undefined;
671
695
  // The timers on the clock, with the backoff after a failure (roadmap CLI-13)
672
696
  const factor = options.backoff?.factor ?? 2;
@@ -703,6 +727,8 @@ export class HybridStorage {
703
727
  const capture = options.capture ?? "none";
704
728
  if (capture !== "none" && capture !== "observe")
705
729
  throw new TypeError(`Unknown capture ${JSON.stringify(capture)}: use none or observe`);
730
+ this.capturing = capture === "observe";
731
+ this.keepsBaseValues = this.options.conflictResolution === "merge" && this.options.versionOf !== undefined;
706
732
  if (capture === "observe") {
707
733
  if (!this.local.observe)
708
734
  throw new TypeError('capture: "observe" needs a local copy with observe()');
@@ -855,6 +881,10 @@ export class HybridStorage {
855
881
  op.base = previous.base;
856
882
  else
857
883
  delete op.base;
884
+ if (previous.baseValues)
885
+ op.baseValues = previous.baseValues;
886
+ else
887
+ delete op.baseValues;
858
888
  this.offlineOps[index] = op;
859
889
  }
860
890
  else {
@@ -1579,6 +1609,7 @@ export class HybridStorage {
1579
1609
  this.ensureQueueRoom(op);
1580
1610
  this.attachBase(op);
1581
1611
  const undo = await this.snapshot(ids, origin);
1612
+ this.fillBaseValues(op, undo.before);
1582
1613
  const result = await applyLocal();
1583
1614
  this.noteOwnWrite(op);
1584
1615
  await this.enqueue(op, undo);
@@ -1618,6 +1649,7 @@ export class HybridStorage {
1618
1649
  // The status shows the queue and the connection. The caller gets PendingWriteError.
1619
1650
  this.isOnline = false;
1620
1651
  this.attachBase(op);
1652
+ this.fillBaseValues(op, undo.before);
1621
1653
  await this.enqueue(op, undo);
1622
1654
  this.scheduleDrain();
1623
1655
  throw new PendingWriteError(type, error);
@@ -1765,7 +1797,7 @@ export class HybridStorage {
1765
1797
  // The versions after the write: this write adds one to each id (clear adds to clearVersion)
1766
1798
  const versions = new Map(touched.map((id) => [id, (this.writeVersions.get(id) ?? 0) + (ids === "*" ? 0 : 1)]));
1767
1799
  const clearVersion = this.clearVersion + (ids === "*" ? 1 : 0);
1768
- return async () => {
1800
+ const undo = async () => {
1769
1801
  if (this.clearVersion !== clearVersion)
1770
1802
  return;
1771
1803
  for (const id of touched) {
@@ -1778,6 +1810,7 @@ export class HybridStorage {
1778
1810
  await this.localSet(id, value, origin);
1779
1811
  }
1780
1812
  };
1813
+ return Object.assign(undo, { before });
1781
1814
  }
1782
1815
  //
1783
1816
  // ═══ Sync Operations ═══
@@ -1942,6 +1975,7 @@ export class HybridStorage {
1942
1975
  cacheMisses: this.stats.cacheMisses,
1943
1976
  syncCount: this.stats.syncCount,
1944
1977
  conflicts: this.stats.conflicts,
1978
+ dropped: this.stats.dropped,
1945
1979
  pendingOps: this.offlineOps.length,
1946
1980
  isOnline: this.isOnline,
1947
1981
  mode: this.mode,
@@ -2294,6 +2328,7 @@ export class HybridStorage {
2294
2328
  return false;
2295
2329
  if (knownVersion === remoteVersion) {
2296
2330
  this.bases.set(id, remoteVersion);
2331
+ this.noteBaseValue(id, remoteVersion, item);
2297
2332
  return false;
2298
2333
  }
2299
2334
  const localItem = await this.readLocal(id);
@@ -2301,15 +2336,17 @@ export class HybridStorage {
2301
2336
  return false;
2302
2337
  if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
2303
2338
  this.bases.set(id, remoteVersion);
2339
+ this.noteBaseValue(id, remoteVersion, item);
2304
2340
  return false;
2305
2341
  }
2306
- return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
2342
+ return this.resolvePending(id, localItem, item, remoteVersion, conflicts, "pull");
2307
2343
  }
2308
2344
  if (knownVersion !== undefined) {
2309
2345
  // Only the remote changed: it is not a conflict. The version of the local item is known.
2310
2346
  if (knownVersion !== remoteVersion)
2311
2347
  await this.localSet(id, item, this.syncOrigin);
2312
2348
  this.bases.set(id, remoteVersion);
2349
+ this.noteBaseValue(id, remoteVersion, item);
2313
2350
  return false;
2314
2351
  }
2315
2352
  const localItem = await this.readLocal(id);
@@ -2335,8 +2372,10 @@ export class HybridStorage {
2335
2372
  await this.localSet(id, resolved, this.syncOrigin);
2336
2373
  this.stats.conflicts++;
2337
2374
  }
2338
- if (remoteVersion !== undefined)
2375
+ if (remoteVersion !== undefined) {
2339
2376
  this.bases.set(id, remoteVersion);
2377
+ this.noteBaseValue(id, remoteVersion, item);
2378
+ }
2340
2379
  return false;
2341
2380
  }
2342
2381
  /** This method applies one remote delete. It gives true when the queue changed. */
@@ -2375,6 +2414,7 @@ export class HybridStorage {
2375
2414
  break;
2376
2415
  await this.localDelete(id, this.syncOrigin);
2377
2416
  this.bases.set(id, null);
2417
+ await this.reportDropped({ id, value: localItem, reason: "remote-delete", during: "pull" });
2378
2418
  return true;
2379
2419
  case "error":
2380
2420
  conflicts.push(id);
@@ -2390,7 +2430,7 @@ export class HybridStorage {
2390
2430
  * A queued local write of an id whose remote version changed: a conflict, and the policy
2391
2431
  * decides. True when the queue changed.
2392
2432
  */
2393
- async resolvePending(id, localItem, remoteItem, remoteVersion, conflicts) {
2433
+ async resolvePending(id, localItem, remoteItem, remoteVersion, conflicts, during) {
2394
2434
  this.stats.conflicts++;
2395
2435
  switch (this.options.conflictResolution) {
2396
2436
  case "remote":
@@ -2399,16 +2439,20 @@ export class HybridStorage {
2399
2439
  break;
2400
2440
  await this.localSet(id, remoteItem, this.syncOrigin);
2401
2441
  this.bases.set(id, remoteVersion);
2442
+ this.noteBaseValue(id, remoteVersion, remoteItem);
2443
+ await this.reportDropped({ id, value: localItem, reason: "conflict", during, remote: remoteItem });
2402
2444
  return true;
2403
2445
  case "merge": {
2404
2446
  if (localItem === undefined)
2405
2447
  break;
2406
- const merged = this.resolveConflict(localItem, remoteItem);
2448
+ const merged = this.resolveConflict(localItem, remoteItem, this.queuedBaseValue(id));
2407
2449
  if (!this.dropQueued(id))
2408
2450
  break;
2409
2451
  await this.localSet(id, merged, this.syncOrigin);
2410
2452
  this.bases.set(id, remoteVersion);
2411
- this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
2453
+ this.noteBaseValue(id, remoteVersion, remoteItem);
2454
+ // The merged write changes the remote item of this conflict: that item is its base
2455
+ this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]], baseValues: [[id, remoteItem]] });
2412
2456
  return true;
2413
2457
  }
2414
2458
  case "error":
@@ -2421,6 +2465,16 @@ export class HybridStorage {
2421
2465
  this.bases.set(id, remoteVersion);
2422
2466
  return false;
2423
2467
  }
2468
+ /** This method counts a dropped write and gives it to the hook `onDropped`. An error of the hook does not stop the sync. */
2469
+ async reportDropped(drop) {
2470
+ this.stats.dropped++;
2471
+ try {
2472
+ await this.options.onDropped?.(drop);
2473
+ }
2474
+ catch {
2475
+ // An error of the hook does not stop the sync
2476
+ }
2477
+ }
2424
2478
  /**
2425
2479
  * This method removes an id from the queued operations. It gives false when a queued clear touches it: nothing
2426
2480
  * changes then.
@@ -2479,6 +2533,8 @@ export class HybridStorage {
2479
2533
  async pushQueue() {
2480
2534
  this.pushing = true;
2481
2535
  let sent = 0;
2536
+ // True when a batch went again as one write for each id: the queue store gets the new operations
2537
+ let split = false;
2482
2538
  // The operations whose conflicts this push resolved once (roadmap CLI-16)
2483
2539
  const resolved = new Set();
2484
2540
  try {
@@ -2509,18 +2565,25 @@ export class HybridStorage {
2509
2565
  }
2510
2566
  continue;
2511
2567
  }
2512
- if (conflicts === undefined &&
2513
- !(error instanceof QueueStoreError) &&
2514
- !this.options.isUnavailable(error) &&
2515
- this.options.isRefusal(error)) {
2516
- // The remote refused the write: it leaves the queue, and the push goes on (roadmap O67)
2568
+ const tooLarge = conflicts === undefined && !(error instanceof QueueStoreError) && this.options.isTooLarge(error);
2569
+ const refused = tooLarge || (conflicts === undefined && !(error instanceof QueueStoreError) && !this.options.isUnavailable(error) && this.options.isRefusal(error));
2570
+ if (refused) {
2517
2571
  this.isOnline = true;
2572
+ const singles = singlesOf(op);
2573
+ if (singles.length > 1 && this.offlineOps[0] === op) {
2574
+ // A batch goes again as one write for each id, so only the bad write leaves the queue (roadmap CLI-108)
2575
+ this.offlineOps.splice(0, 1, ...singles);
2576
+ split = true;
2577
+ this.storeDirty = true;
2578
+ continue;
2579
+ }
2580
+ // The remote refused the write: it leaves the queue, and the push goes on (roadmap O67)
2518
2581
  if (this.offlineOps[0] === op) {
2519
2582
  this.offlineOps.shift();
2520
2583
  sent++;
2521
2584
  this.storeDirty = true;
2522
2585
  }
2523
- await this.refuseQueued(op, error);
2586
+ await this.refuseQueued(op, error, tooLarge ? "too-large" : "refused");
2524
2587
  continue;
2525
2588
  }
2526
2589
  // This operation and the later ones stay queued
@@ -2540,7 +2603,7 @@ export class HybridStorage {
2540
2603
  }
2541
2604
  finally {
2542
2605
  this.pushing = false;
2543
- if (sent > 0) {
2606
+ if (sent > 0 || split) {
2544
2607
  // The store keeps sent operations, and the status gets the failure. A later direct write
2545
2608
  // repairs the store first.
2546
2609
  await this.persistAll().catch(() => undefined);
@@ -2552,7 +2615,7 @@ export class HybridStorage {
2552
2615
  * gets the remote values, and the hook `onRefused` gets the refusal. A refused clear asks for a
2553
2616
  * full pull.
2554
2617
  */
2555
- async refuseQueued(op, error) {
2618
+ async refuseQueued(op, error, reason) {
2556
2619
  const ids = idsOf(op);
2557
2620
  const all = ids.includes("*");
2558
2621
  let restored = false;
@@ -2568,6 +2631,10 @@ export class HybridStorage {
2568
2631
  catch {
2569
2632
  // An error of the hook does not stop the queue
2570
2633
  }
2634
+ for (const id of all ? ["*"] : ids) {
2635
+ const written = this.writtenValue(op, id);
2636
+ await this.reportDropped({ id, value: written ? written.value : undefined, reason, during: "push", error });
2637
+ }
2571
2638
  }
2572
2639
  /**
2573
2640
  * This method writes the remote values of these ids into the local copy. It skips an id that a
@@ -2630,10 +2697,57 @@ export class HybridStorage {
2630
2697
  return;
2631
2698
  }
2632
2699
  const version = this.versionOfItem(item);
2633
- if (version === undefined)
2700
+ if (version === undefined) {
2634
2701
  this.bases.delete(id);
2635
- else
2636
- this.bases.set(id, version);
2702
+ return;
2703
+ }
2704
+ this.bases.set(id, version);
2705
+ this.noteBaseValue(id, version, item);
2706
+ }
2707
+ /** This method keeps the remote value of the remote version of an id, for a captured change (roadmap CLI-109). */
2708
+ noteBaseValue(id, version, value) {
2709
+ if (!this.keepsBaseValues || !this.capturing)
2710
+ return;
2711
+ if (this.bases.get(id) === version)
2712
+ this.baseValues.set(id, { version, value });
2713
+ }
2714
+ /**
2715
+ * This method gives a write of the storage the base value of each id that has no queued write (roadmap CLI-109).
2716
+ * The value is the local item before the write, when that item has the base version. The local copy then holds the
2717
+ * remote value of a sync or a send.
2718
+ */
2719
+ fillBaseValues(op, before) {
2720
+ if (!this.keepsBaseValues || !op.base)
2721
+ return;
2722
+ const values = op.baseValues ? [...op.baseValues] : [];
2723
+ const known = new Set(values.map(([id]) => id));
2724
+ for (const [id, version] of op.base) {
2725
+ if (known.has(id) || version === null)
2726
+ continue;
2727
+ const value = before.get(id);
2728
+ if (value !== undefined && this.versionOfItem(value) === version)
2729
+ values.push([id, value]);
2730
+ }
2731
+ if (values.length > 0)
2732
+ op.baseValues = values;
2733
+ }
2734
+ /** The remote value at a remote version of an id: `null` for no item, and undefined when the storage does not know it. */
2735
+ baseValueAt(id, version) {
2736
+ if (version === null)
2737
+ return null;
2738
+ const known = this.baseValues.get(id);
2739
+ return known !== undefined && known.version === version ? known.value : undefined;
2740
+ }
2741
+ /**
2742
+ * The remote value that the queued writes of an id changed: the base value of the first queued operation of the id.
2743
+ * The value is undefined when that operation has none, or when it is a clear.
2744
+ */
2745
+ queuedBaseValue(id) {
2746
+ const op = this.offlineOps.find((queued) => {
2747
+ const ids = idsOf(queued);
2748
+ return ids.includes(id) || ids.includes("*");
2749
+ });
2750
+ return op?.baseValues?.find(([valueId]) => valueId === id)?.[1];
2637
2751
  }
2638
2752
  /** This method records the remote versions after a write reached the remote. */
2639
2753
  noteRemoteWrite(op) {
@@ -2670,15 +2784,56 @@ export class HybridStorage {
2670
2784
  if (!this.options.versionOf)
2671
2785
  return;
2672
2786
  const base = [];
2787
+ const values = [];
2673
2788
  for (const id of idsOf(op)) {
2674
2789
  if (id === "*")
2675
2790
  continue;
2676
2791
  const version = this.expectedBase(id);
2677
2792
  if (version !== undefined)
2678
2793
  base.push([id, version]);
2794
+ const value = this.expectedBaseValue(id, version);
2795
+ if (value !== undefined)
2796
+ values.push([id, value]);
2679
2797
  }
2680
2798
  if (base.length > 0)
2681
2799
  op.base = base;
2800
+ if (values.length > 0)
2801
+ op.baseValues = values;
2802
+ }
2803
+ /**
2804
+ * This method gives the remote value at the version of `expectedBase`, for the policy `merge` (roadmap CLI-109). It
2805
+ * is the value of an older queued operation of the id. Without one, it is the kept value of the known remote version.
2806
+ */
2807
+ expectedBaseValue(id, version) {
2808
+ if (!this.keepsBaseValues || version === undefined)
2809
+ return undefined;
2810
+ for (let index = this.offlineOps.length - 1; index >= 0; index--) {
2811
+ const op = this.offlineOps[index];
2812
+ const data = op.data;
2813
+ switch (op.type) {
2814
+ case "set":
2815
+ if (data.id === id)
2816
+ return data.value;
2817
+ break;
2818
+ case "delete":
2819
+ if (data.id === id)
2820
+ return null;
2821
+ break;
2822
+ case "setBatch": {
2823
+ const item = (data.items ?? []).findLast(([itemId]) => itemId === id);
2824
+ if (item)
2825
+ return item[1];
2826
+ break;
2827
+ }
2828
+ case "deleteBatch":
2829
+ if ((data.ids ?? []).includes(id))
2830
+ return null;
2831
+ break;
2832
+ case "clear":
2833
+ return null;
2834
+ }
2835
+ }
2836
+ return this.baseValueAt(id, version);
2682
2837
  }
2683
2838
  /** The remote version of an id after the queued operations, or undefined when it is not known. */
2684
2839
  expectedBase(id) {
@@ -2719,7 +2874,7 @@ export class HybridStorage {
2719
2874
  /**
2720
2875
  * Resolve conflict between local and remote versions.
2721
2876
  */
2722
- resolveConflict(local, remote) {
2877
+ resolveConflict(local, remote, base) {
2723
2878
  switch (this.options.conflictResolution) {
2724
2879
  case "local":
2725
2880
  return local;
@@ -2729,7 +2884,7 @@ export class HybridStorage {
2729
2884
  if (!this.options.mergeFn) {
2730
2885
  throw new Error("Merge function required for conflict resolution strategy 'merge'");
2731
2886
  }
2732
- return this.options.mergeFn(local, remote);
2887
+ return this.options.mergeFn(local, remote, base);
2733
2888
  case "error":
2734
2889
  throw new Error(`Conflict detected between local and remote versions`);
2735
2890
  default:
@@ -2757,6 +2912,10 @@ export class HybridStorage {
2757
2912
  op.base = previous.base;
2758
2913
  else
2759
2914
  delete op.base;
2915
+ if (previous.baseValues)
2916
+ op.baseValues = previous.baseValues;
2917
+ else
2918
+ delete op.baseValues;
2760
2919
  this.offlineOps[index] = op;
2761
2920
  rollback = () => {
2762
2921
  const at = this.offlineOps.indexOf(op);
@@ -3002,14 +3161,16 @@ export class HybridStorage {
3002
3161
  if (remoteVersion === undefined || echo) {
3003
3162
  if (remoteVersion === undefined)
3004
3163
  this.bases.delete(id);
3005
- else
3164
+ else {
3006
3165
  this.bases.set(id, remoteVersion);
3166
+ this.noteBaseValue(id, remoteVersion, remoteItem);
3167
+ }
3007
3168
  if (echo)
3008
3169
  echoes.add(id);
3009
3170
  continue;
3010
3171
  }
3011
3172
  const localItem = await this.readLocal(id);
3012
- if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids))
3173
+ if (await this.resolvePending(id, localItem, remoteItem, remoteVersion, ids, "push"))
3013
3174
  queueChanged = true;
3014
3175
  continue;
3015
3176
  }
@@ -3040,12 +3201,15 @@ export class HybridStorage {
3040
3201
  async resolveRemoteDelete(id, conflicts) {
3041
3202
  this.stats.conflicts++;
3042
3203
  switch (this.options.conflictResolution) {
3043
- case "remote":
3204
+ case "remote": {
3205
+ const value = await this.readLocal(id);
3044
3206
  if (!this.dropQueued(id))
3045
3207
  break;
3046
3208
  await this.localDelete(id, this.syncOrigin);
3047
3209
  this.bases.set(id, null);
3210
+ await this.reportDropped({ id, value, reason: "remote-delete", during: "push" });
3048
3211
  return true;
3212
+ }
3049
3213
  case "error":
3050
3214
  conflicts.push(id);
3051
3215
  return false;