@procedures/store 0.37.0 → 0.39.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.
@@ -222,6 +222,24 @@ export interface HybridStorageOptions {
222
222
  */
223
223
  isUnavailable?: (error: unknown) => boolean;
224
224
 
225
+ /**
226
+ * True when an error of the remote refuses a queued write for good: a new send gets the same
227
+ * refusal (roadmap O67). Such a write leaves the queue, and the push goes on with the next write.
228
+ * An error for which `isUnavailable` gives true is never a refusal. Another error keeps the write
229
+ * at the head of the queue, and the next push sends it again. The default: the codes
230
+ * `FORBIDDEN`, `VALIDATION_ERROR`, `INVALID_REQUEST`, `NOT_EXPOSED` and `NOT_DECLARED`. A
231
+ * `TIMEOUT` or an `INTERNAL_ERROR` is not a refusal, because the remote can take a new send.
232
+ * `UNAUTHORIZED` is not a refusal by default, because a new token can let the write through.
233
+ */
234
+ isRefusal?: (error: unknown) => boolean;
235
+
236
+ /**
237
+ * This hook gets each queued write that the remote refused (roadmap O67). The local copy of its
238
+ * ids gets the remote values again, except for an id that a later queued write changes. An
239
+ * error of the hook does not stop the queue.
240
+ */
241
+ onRefused?: (refusal: QueueRefusal) => void | Promise<void>;
242
+
225
243
  /**
226
244
  * The storage of the local copy (roadmap R45). A durable storage, for example `SqliteStorage`
227
245
  * of `@procedures/sqlite`, keeps the local copy after a restart. Then a new storage in
@@ -533,6 +551,21 @@ class SyncTimer {
533
551
  }
534
552
  }
535
553
 
554
+ /** A queued write that the remote refused: the argument of the hook `onRefused` (roadmap O67). */
555
+ export interface QueueRefusal {
556
+ /** The write. It is not in the queue now. */
557
+ operation: QueuedOperation;
558
+ /** The ids of the write, or `"*"` for a clear. */
559
+ ids: string[] | "*";
560
+ /** The error of the remote. */
561
+ error: unknown;
562
+ /**
563
+ * True when the local copy got the remote values of the ids. A clear asks for a full pull, and
564
+ * gives false. A failed read of the remote also gives false.
565
+ */
566
+ restored: boolean;
567
+ }
568
+
536
569
  /**
537
570
  * The remote did not take a write of the write-through mode. The write is in the local cache and
538
571
  * in the queue, and the storage sends it later (roadmap R2). `cause` is the error of the remote.
@@ -631,6 +664,15 @@ const defaultIsUnavailable = (error: unknown): boolean => {
631
664
  return retryable === true || code === undefined;
632
665
  };
633
666
 
667
+ /** The codes of the protocol that refuse a queued write for good: a new send gets the same refusal (roadmap O67). */
668
+ const REFUSAL_CODES: ReadonlySet<string> = new Set(["FORBIDDEN", "VALIDATION_ERROR", "INVALID_REQUEST", "NOT_EXPOSED", "NOT_DECLARED"]);
669
+
670
+ /** The default of `isRefusal`: an error with a code of `REFUSAL_CODES`. */
671
+ const defaultIsRefusal = (error: unknown): boolean => {
672
+ const code = (error as { code?: unknown } | null | undefined)?.code;
673
+ return typeof code === "string" && REFUSAL_CODES.has(code);
674
+ };
675
+
634
676
  /** True when a remote does not know a procedure: an older server without `changes`. */
635
677
  const isNotFound = (error: unknown): boolean =>
636
678
  typeof error === "object" && error !== null && (error as { code?: unknown }).code === "NOT_FOUND";
@@ -891,6 +933,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
891
933
  maxOfflineOps: number;
892
934
  offlineQueue: boolean;
893
935
  isUnavailable: (error: unknown) => boolean;
936
+ isRefusal: (error: unknown) => boolean;
937
+ onRefused?: (refusal: QueueRefusal) => void | Promise<void>;
894
938
  };
895
939
  private mode: HybridMode;
896
940
  private offlineOps: QueuedOperation[] = [];
@@ -1032,6 +1076,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1032
1076
  maxOfflineOps: options.maxOfflineOps ?? 1000,
1033
1077
  offlineQueue: options.offlineQueue ?? true,
1034
1078
  isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
1079
+ isRefusal: options.isRefusal ?? defaultIsRefusal,
1080
+ ...(options.onRefused !== undefined && { onRefused: options.onRefused }),
1035
1081
  };
1036
1082
  this.statusListeners = new StorageListeners<SyncStatus>(options.onListenerError);
1037
1083
  this.queryPolicy = options.queryPolicy;
@@ -2846,6 +2892,22 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2846
2892
  await this.resolvePushConflicts(op, conflicts);
2847
2893
  continue;
2848
2894
  }
2895
+ if (
2896
+ conflicts === undefined &&
2897
+ !(error instanceof QueueStoreError) &&
2898
+ !this.options.isUnavailable(error) &&
2899
+ this.options.isRefusal(error)
2900
+ ) {
2901
+ // The remote refused the write: it leaves the queue, and the push goes on (roadmap O67)
2902
+ this.isOnline = true;
2903
+ if (this.offlineOps[0] === op) {
2904
+ this.offlineOps.shift();
2905
+ sent++;
2906
+ this.storeDirty = true;
2907
+ }
2908
+ await this.refuseQueued(op, error);
2909
+ continue;
2910
+ }
2849
2911
  // This operation and the later ones stay queued
2850
2912
  if (conflicts === undefined) this.isOnline = false;
2851
2913
  throw error;
@@ -2869,6 +2931,49 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2869
2931
  }
2870
2932
  }
2871
2933
 
2934
+ /**
2935
+ * This method ends a queued write that the remote refused (roadmap O67). The local copy of its ids
2936
+ * gets the remote values, and the hook `onRefused` gets the refusal. A refused clear asks for a
2937
+ * full pull.
2938
+ */
2939
+ private async refuseQueued(op: QueuedOperation, error: unknown): Promise<void> {
2940
+ const ids = idsOf(op);
2941
+ const all = ids.includes("*");
2942
+ let restored = false;
2943
+ if (all) {
2944
+ this.requestPull(undefined, true);
2945
+ } else if (this.mode !== "remote-only") {
2946
+ restored = await this.restoreRemote(ids.filter((id) => !this.hasPendingOp(id)));
2947
+ }
2948
+ try {
2949
+ await this.options.onRefused?.({ operation: op, ids: all ? "*" : ids, error, restored });
2950
+ } catch {
2951
+ // An error of the hook does not stop the queue
2952
+ }
2953
+ }
2954
+
2955
+ /**
2956
+ * This method writes the remote values of these ids into the local copy. It skips an id that a
2957
+ * queued write or an own write in flight changes. It gives false when the read of the remote fails.
2958
+ */
2959
+ private async restoreRemote(ids: string[]): Promise<boolean> {
2960
+ if (ids.length === 0) return true;
2961
+ let items: Map<string, T>;
2962
+ try {
2963
+ items = await this.remote.getBatch(ids);
2964
+ } catch {
2965
+ return false;
2966
+ }
2967
+ for (const id of ids) {
2968
+ if (this.hasPendingOp(id) || this.waitsForRemote(id)) continue;
2969
+ const item = items.get(id);
2970
+ if (item === undefined) await this.localDelete(id, this.syncOrigin);
2971
+ else await this.localSet(id, item, this.syncOrigin);
2972
+ this.noteBase(id, item);
2973
+ }
2974
+ return true;
2975
+ }
2976
+
2872
2977
  /** Run syncs one at a time. */
2873
2978
  private serialize<R>(run: () => Promise<R>): Promise<R> {
2874
2979
  const next = this.syncChain.then(run, run);
@@ -52,6 +52,7 @@ export type {
52
52
  HybridStorageOptions,
53
53
  HybridMode,
54
54
  LocalCopyStorage,
55
+ QueueRefusal,
55
56
  RemoteApplyCause,
56
57
  RemoteApplyContext,
57
58
  RemoteApplyHook,