@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.
- package/CHANGELOG.md +37 -0
- package/README.md +43 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/hybrid.d.ts +78 -1
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +187 -23
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.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 +233 -26
- package/src/storage/index.ts +1 -0
- 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
|
|
@@ -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(
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
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)
|
|
3082
|
-
|
|
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
|
|
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;
|
package/src/storage/index.ts
CHANGED
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;
|