@procedures/store 0.48.2 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +41 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/hybrid.d.ts +49 -4
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +162 -20
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/queue.d.ts +11 -0
- package/dist/storage/queue.d.ts.map +1 -1
- package/dist/storage/queue.js +20 -0
- package/dist/storage/queue.js.map +1 -1
- package/package.json +4 -4
- package/src/storage/hybrid.ts +178 -27
- package/src/storage/queue.ts +27 -0
package/src/storage/hybrid.ts
CHANGED
|
@@ -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
|
-
/**
|
|
608
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
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
|
|
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)
|
|
3135
|
-
|
|
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
|
|
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
|
}
|
package/src/storage/queue.ts
CHANGED
|
@@ -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;
|