@procedures/store 0.2.3 → 0.3.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 +54 -0
- package/README.md +109 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/indexeddb/idb.d.ts +100 -0
- package/dist/indexeddb/idb.d.ts.map +1 -0
- package/dist/indexeddb/idb.js +137 -0
- package/dist/indexeddb/idb.js.map +1 -0
- package/dist/indexeddb/program-store.d.ts +42 -0
- package/dist/indexeddb/program-store.d.ts.map +1 -0
- package/dist/indexeddb/program-store.js +71 -0
- package/dist/indexeddb/program-store.js.map +1 -0
- package/dist/indexeddb/queue-store.d.ts +38 -0
- package/dist/indexeddb/queue-store.d.ts.map +1 -0
- package/dist/indexeddb/queue-store.js +73 -0
- package/dist/indexeddb/queue-store.js.map +1 -0
- package/dist/indexeddb.d.ts +19 -0
- package/dist/indexeddb.d.ts.map +1 -0
- package/dist/indexeddb.js +17 -0
- package/dist/indexeddb.js.map +1 -0
- package/dist/program-store/file.d.ts +5 -0
- package/dist/program-store/file.d.ts.map +1 -1
- package/dist/program-store/file.js +5 -2
- package/dist/program-store/file.js.map +1 -1
- package/dist/storage/api.d.ts +18 -7
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +34 -3
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/factory.d.ts +13 -2
- package/dist/storage/factory.d.ts.map +1 -1
- package/dist/storage/factory.js +12 -4
- package/dist/storage/factory.js.map +1 -1
- package/dist/storage/file-queue.d.ts +32 -0
- package/dist/storage/file-queue.d.ts.map +1 -0
- package/dist/storage/file-queue.js +101 -0
- package/dist/storage/file-queue.js.map +1 -0
- package/dist/storage/hybrid.d.ts +355 -42
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +1093 -250
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/index.d.ts +4 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +5 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/queue.d.ts +67 -0
- package/dist/storage/queue.d.ts.map +1 -0
- package/dist/storage/queue.js +85 -0
- package/dist/storage/queue.js.map +1 -0
- package/dist/storage/synced-registry.d.ts +18 -2
- package/dist/storage/synced-registry.d.ts.map +1 -1
- package/dist/storage/synced-registry.js +37 -3
- package/dist/storage/synced-registry.js.map +1 -1
- package/dist/storage/types.d.ts +5 -2
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +16 -6
- package/src/indexeddb/idb.ts +190 -0
- package/src/indexeddb/program-store.ts +80 -0
- package/src/indexeddb/queue-store.ts +80 -0
- package/src/indexeddb.ts +19 -0
- package/src/program-store/file.ts +5 -2
- package/src/storage/api.ts +50 -10
- package/src/storage/factory.ts +21 -4
- package/src/storage/file-queue.ts +103 -0
- package/src/storage/hybrid.ts +1142 -274
- package/src/storage/index.ts +15 -2
- package/src/storage/queue.ts +128 -0
- package/src/storage/synced-registry.ts +49 -4
- package/src/storage/types.ts +6 -2
package/src/storage/hybrid.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hybrid Storage Implementation
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* A local copy (an `InMemoryStorage`, or the `local` storage that the application gives) in
|
|
5
|
+
* front of a remote `CollectionStorage` (often an `ApiStorage`). The storage has four modes, and
|
|
6
|
+
* `setMode()` changes the mode while the storage operates (roadmap R28):
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
8
|
+
* | Mode | Reads | Writes | Remote calls |
|
|
9
|
+
* |---|---|---|---|
|
|
10
|
+
* | `local-only` | the local copy | the local copy and the queue | none |
|
|
11
|
+
* | `write-through` | the local copy, the remote on a miss | the local copy, then the remote | yes |
|
|
12
|
+
* | `write-back` | the local copy, the remote on a miss | the local copy and the queue, a timer sends it | yes |
|
|
13
|
+
* | `remote-only` | the remote | the remote | yes, and no local copy |
|
|
14
|
+
*
|
|
15
|
+
* - The queue is durable through the `queueStore` that the application gives.
|
|
16
|
+
* - A sync asks for the changes since a version when the remote has `getChangesSince`.
|
|
17
|
+
* - With `versionOf`, a conflict is a change on both sides, and `conflictResolution` decides.
|
|
18
|
+
* - `pullInterval` keeps the local copy fresh.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
|
-
import type { CollectionStorage, StorageMetadata } from "@procedures/collections";
|
|
21
|
+
import type { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/collections";
|
|
16
22
|
import { InMemoryStorage } from "@procedures/collections";
|
|
23
|
+
import { optionalMs, type Duration } from "@procedures/client";
|
|
24
|
+
import { MemoryQueueStore, idsOf, withoutId, type QueuedOperation, type QueueStore } from "./queue.js";
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
27
|
* Conflict resolution strategy.
|
|
@@ -22,19 +30,37 @@ export type ConflictResolution = "local" | "remote" | "merge" | "error";
|
|
|
22
30
|
|
|
23
31
|
/**
|
|
24
32
|
* Write strategy for hybrid storage.
|
|
33
|
+
* @deprecated Use `HybridMode`: the strategies are two of its modes.
|
|
25
34
|
*/
|
|
26
35
|
export type WriteStrategy = "write-through" | "write-back";
|
|
27
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The mode of a `HybridStorage` (roadmap R28).
|
|
39
|
+
*
|
|
40
|
+
* - `local-only`: the storage makes no remote call. Each write goes to the local copy and to the
|
|
41
|
+
* queue, so a later switch to a remote mode sends it.
|
|
42
|
+
* - `write-through`: each write goes to the local copy, then to the remote.
|
|
43
|
+
* - `write-back`: each write goes to the local copy and to the queue. A timer sends the queue.
|
|
44
|
+
* - `remote-only`: each read and each write goes to the remote. The storage keeps no local copy.
|
|
45
|
+
*/
|
|
46
|
+
export type HybridMode = "local-only" | "write-through" | "write-back" | "remote-only";
|
|
47
|
+
|
|
48
|
+
const MODES: ReadonlySet<string> = new Set<HybridMode>(["local-only", "write-through", "write-back", "remote-only"]);
|
|
49
|
+
|
|
28
50
|
/**
|
|
29
51
|
* Hybrid storage configuration options.
|
|
30
52
|
*/
|
|
31
53
|
export interface HybridStorageOptions {
|
|
32
54
|
/**
|
|
33
|
-
*
|
|
34
|
-
* - write-through: Write to both local and remote immediately
|
|
35
|
-
* - write-back: Write to local immediately, sync to remote later
|
|
55
|
+
* The first mode. `setMode()` changes it later.
|
|
36
56
|
* @default "write-through"
|
|
37
57
|
*/
|
|
58
|
+
mode?: HybridMode;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The first mode, with its old name. `mode` comes first when both are given.
|
|
62
|
+
* @deprecated Use `mode`.
|
|
63
|
+
*/
|
|
38
64
|
writeStrategy?: WriteStrategy;
|
|
39
65
|
|
|
40
66
|
/**
|
|
@@ -42,14 +68,20 @@ export interface HybridStorageOptions {
|
|
|
42
68
|
* - local: Local changes win
|
|
43
69
|
* - remote: Remote changes win
|
|
44
70
|
* - merge: Custom merge function (requires mergeFn)
|
|
45
|
-
* - error:
|
|
71
|
+
* - error: The sync throws `ConflictError` with the ids, and changes nothing for them
|
|
72
|
+
*
|
|
73
|
+
* With `versionOf`, a conflict is a queued local write of an id whose remote version changed
|
|
74
|
+
* after the last version that the storage saw. A queued write of an id without a known remote
|
|
75
|
+
* version wins. Without `versionOf`, a conflict is a local value that differs from the remote
|
|
76
|
+
* value, with no queued write: a queued write always wins.
|
|
46
77
|
* @default "remote"
|
|
47
78
|
*/
|
|
48
79
|
conflictResolution?: ConflictResolution;
|
|
49
80
|
|
|
50
81
|
/**
|
|
51
82
|
* Custom merge function for conflict resolution.
|
|
52
|
-
* Only used when conflictResolution is "merge".
|
|
83
|
+
* Only used when conflictResolution is "merge". A conflict where one side deleted the item
|
|
84
|
+
* keeps the local side.
|
|
53
85
|
*
|
|
54
86
|
* @param local - Local version
|
|
55
87
|
* @param remote - Remote version
|
|
@@ -64,62 +96,174 @@ export interface HybridStorageOptions {
|
|
|
64
96
|
keyOf?: (item: any) => string | undefined;
|
|
65
97
|
|
|
66
98
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
99
|
+
* The version of an item, for example its ETag or a revision field (roadmap R28). The sync
|
|
100
|
+
* compares the versions, not the JSON of the values. A remote change of an id with a queued
|
|
101
|
+
* local write is a conflict.
|
|
102
|
+
*/
|
|
103
|
+
versionOf?: (item: any) => string | undefined;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Automatic sync interval (for write-back mode), for example `seconds(5)`.
|
|
107
|
+
* Set to 0 to disable automatic sync. In write-through mode, it is also the delay before the
|
|
108
|
+
* storage sends the queue after a failure (5 seconds when it is 0).
|
|
109
|
+
* @default 5 seconds
|
|
110
|
+
*/
|
|
111
|
+
syncInterval?: Duration;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The interval of the periodic pull, for example `minutes(1)` (roadmap R28). A pull reads the
|
|
115
|
+
* remote changes into the local copy in the modes `write-through` and `write-back`.
|
|
116
|
+
* @default no periodic pull
|
|
70
117
|
*/
|
|
71
|
-
|
|
118
|
+
pullInterval?: Duration;
|
|
72
119
|
|
|
73
120
|
/**
|
|
74
|
-
* Initialize by syncing from remote on construction
|
|
121
|
+
* Initialize by syncing from remote on construction: send the loaded queue, then read the
|
|
122
|
+
* remote. The mode `local-only` makes no remote call.
|
|
75
123
|
* @default true
|
|
76
124
|
*/
|
|
77
125
|
syncOnInit?: boolean;
|
|
78
126
|
|
|
79
127
|
/**
|
|
80
|
-
* Maximum offline operations to queue.
|
|
128
|
+
* Maximum offline operations to queue. A full queue refuses the next write.
|
|
81
129
|
* @default 1000
|
|
82
130
|
*/
|
|
83
131
|
maxOfflineOps?: number;
|
|
84
132
|
|
|
85
133
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
134
|
+
* The queue of the write-through mode.
|
|
135
|
+
* In write-through mode, a write that the remote cannot take stays in the local cache and the
|
|
136
|
+
* queue, and the write throws `PendingWriteError`. A timer sends the queue later.
|
|
137
|
+
* Without the queue, the write is undone in the local cache and the write throws.
|
|
138
|
+
* The modes `write-back` and `local-only` always use the queue.
|
|
88
139
|
* @default true
|
|
89
140
|
*/
|
|
90
141
|
offlineQueue?: boolean;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The store that keeps the queue (roadmap R28). A new storage with the same store loads the
|
|
145
|
+
* queue again and sends it. `FileQueueStore` of `@procedures/store/file-queue` keeps it in a
|
|
146
|
+
* file.
|
|
147
|
+
* @default a new `MemoryQueueStore`
|
|
148
|
+
*/
|
|
149
|
+
queueStore?: QueueStore;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* True when an error of the remote means "not available now", so the write can wait in the
|
|
153
|
+
* queue. Another error (a refusal of the remote) undoes the write. The default: an error with
|
|
154
|
+
* `retryable: true`, or an error without a `code`.
|
|
155
|
+
*/
|
|
156
|
+
isUnavailable?: (error: unknown) => boolean;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The storage of the local copy (roadmap R45). A durable storage, for example `SqliteStorage`
|
|
160
|
+
* of `@procedures/sqlite`, keeps the local copy after a restart. Then a new storage in
|
|
161
|
+
* `local-only` mode reads the items of the last run. Give it with a durable `queueStore`.
|
|
162
|
+
* `close()` closes it.
|
|
163
|
+
* @default a new `InMemoryStorage`
|
|
164
|
+
*/
|
|
165
|
+
local?: LocalCopyStorage<any>;
|
|
91
166
|
}
|
|
92
167
|
|
|
93
168
|
/**
|
|
94
|
-
*
|
|
169
|
+
* The remote did not take a write of the write-through mode. The write is in the local cache and
|
|
170
|
+
* in the queue, and the storage sends it later (roadmap R2). `cause` is the error of the remote.
|
|
95
171
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
172
|
+
export class PendingWriteError extends Error {
|
|
173
|
+
override readonly name = "PendingWriteError";
|
|
174
|
+
readonly code = "WRITE_PENDING";
|
|
175
|
+
readonly pending = true;
|
|
176
|
+
constructor(operation: string, cause: unknown) {
|
|
177
|
+
super(`The remote did not take the ${operation}: it waits in the queue`, { cause });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The queue of operations is full, so the storage refuses the write (roadmap R2). */
|
|
182
|
+
export class QueueFullError extends Error {
|
|
183
|
+
override readonly name = "QueueFullError";
|
|
184
|
+
readonly code = "QUEUE_FULL";
|
|
185
|
+
constructor(max: number) {
|
|
186
|
+
super(`The queue of operations is full (${max} operations): the storage refuses the write`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The remote gave items without a key, so a sync cannot know which local items to keep. */
|
|
191
|
+
export class MissingKeysError extends Error {
|
|
192
|
+
override readonly name = "MissingKeysError";
|
|
193
|
+
readonly code = "MISSING_KEYS";
|
|
194
|
+
constructor(count: number) {
|
|
195
|
+
super(`${count} remote items have no key. Give the option keyOf, or a remote storage with getEntries(). The sync changed nothing.`);
|
|
196
|
+
}
|
|
100
197
|
}
|
|
101
198
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
case "deleteBatch":
|
|
112
|
-
return data.ids ?? [];
|
|
113
|
-
case "clear":
|
|
114
|
-
return ["*"];
|
|
199
|
+
/**
|
|
200
|
+
* The queue store failed (roadmap R28). A write that the store did not keep is undone and not
|
|
201
|
+
* acknowledged. A storage whose store cannot load refuses each operation.
|
|
202
|
+
*/
|
|
203
|
+
export class QueueStoreError extends Error {
|
|
204
|
+
override readonly name = "QueueStoreError";
|
|
205
|
+
readonly code = "QUEUE_STORE";
|
|
206
|
+
constructor(action: "load" | "write", cause: unknown) {
|
|
207
|
+
super(`The queue store could not ${action === "load" ? "load the queue" : "keep the queue"}`, { cause });
|
|
115
208
|
}
|
|
116
209
|
}
|
|
117
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The sync found conflicts and `conflictResolution` is "error" (roadmap R28). The local values and
|
|
213
|
+
* their queued writes stay. `ids` are the ids of the conflicts.
|
|
214
|
+
*/
|
|
215
|
+
export class ConflictError extends Error {
|
|
216
|
+
override readonly name = "ConflictError";
|
|
217
|
+
readonly code = "SYNC_CONFLICT";
|
|
218
|
+
constructor(readonly ids: string[]) {
|
|
219
|
+
super(`Conflict detected between local and remote versions of ${ids.length} items: ${ids.slice(0, 10).join(", ")}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `setMode()` could not change the mode (roadmap R28). The mode did not change. `cause` is the
|
|
225
|
+
* error of the flush or of the sync.
|
|
226
|
+
*/
|
|
227
|
+
export class ModeSwitchError extends Error {
|
|
228
|
+
override readonly name = "ModeSwitchError";
|
|
229
|
+
readonly code = "MODE_SWITCH_FAILED";
|
|
230
|
+
constructor(readonly from: HybridMode, readonly to: HybridMode, cause: unknown) {
|
|
231
|
+
super(`The storage could not change from ${from} to ${to}: the mode is still ${from}`, { cause });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const defaultIsUnavailable = (error: unknown): boolean => {
|
|
236
|
+
if (typeof error !== "object" || error === null) return true;
|
|
237
|
+
const { retryable, code } = error as { retryable?: unknown; code?: unknown };
|
|
238
|
+
return retryable === true || code === undefined;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** True when a remote does not know a procedure: an older server without `changes`. */
|
|
242
|
+
const isNotFound = (error: unknown): boolean =>
|
|
243
|
+
typeof error === "object" && error !== null && (error as { code?: unknown }).code === "NOT_FOUND";
|
|
244
|
+
|
|
118
245
|
const defaultKeyOf = (item: unknown): string | undefined => {
|
|
119
246
|
const id = (item as { id?: unknown } | null)?.id;
|
|
120
247
|
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
121
248
|
};
|
|
122
249
|
|
|
250
|
+
function checkMode(mode: unknown): HybridMode {
|
|
251
|
+
if (typeof mode !== "string" || !MODES.has(mode)) {
|
|
252
|
+
throw new TypeError(`Unknown mode ${JSON.stringify(mode)}: use local-only, write-through, write-back or remote-only`);
|
|
253
|
+
}
|
|
254
|
+
return mode as HybridMode;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The changes that a pull applies: from `getChangesSince`, or all entries. */
|
|
258
|
+
type PulledChanges<T> = Omit<StorageChanges<T>, "version"> & { version?: string };
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A storage for the local copy of a `HybridStorage` (roadmap R45): a collection storage that gives
|
|
262
|
+
* its entries. A durable one, for example `SqliteStorage` of `@procedures/sqlite`, keeps the local
|
|
263
|
+
* copy after a restart.
|
|
264
|
+
*/
|
|
265
|
+
export type LocalCopyStorage<T> = CollectionStorage<T> & { getEntries(): Promise<Array<[string, T]>> };
|
|
266
|
+
|
|
123
267
|
/**
|
|
124
268
|
* Hybrid storage combining local cache with remote persistence.
|
|
125
269
|
*
|
|
@@ -131,9 +275,9 @@ const defaultKeyOf = (item: unknown): string | undefined => {
|
|
|
131
275
|
*
|
|
132
276
|
* const remote = new ApiStorage(client, { collection: "users" });
|
|
133
277
|
* const hybrid = new HybridStorage(remote, {
|
|
134
|
-
*
|
|
278
|
+
* mode: "write-through",
|
|
135
279
|
* conflictResolution: "remote",
|
|
136
|
-
* syncInterval:
|
|
280
|
+
* syncInterval: seconds(5)
|
|
137
281
|
* });
|
|
138
282
|
*
|
|
139
283
|
* // Fast local reads
|
|
@@ -141,29 +285,69 @@ const defaultKeyOf = (item: unknown): string | undefined => {
|
|
|
141
285
|
*
|
|
142
286
|
* // Writes go to both local and remote
|
|
143
287
|
* await hybrid.set("123", { name: "John" });
|
|
288
|
+
*
|
|
289
|
+
* // Offline: no remote call. The writes wait in the queue.
|
|
290
|
+
* await hybrid.setMode("local-only");
|
|
291
|
+
* // Online again: the switch sends the queue, then syncs the local copy
|
|
292
|
+
* await hybrid.setMode("write-through");
|
|
144
293
|
* ```
|
|
145
294
|
*/
|
|
146
295
|
export class HybridStorage<T> implements CollectionStorage<T> {
|
|
147
|
-
private local:
|
|
148
|
-
private remote: CollectionStorage<T>;
|
|
296
|
+
private readonly local: LocalCopyStorage<T>;
|
|
297
|
+
private readonly remote: CollectionStorage<T>;
|
|
298
|
+
private readonly queueStore: QueueStore;
|
|
149
299
|
private options: {
|
|
150
|
-
writeStrategy: WriteStrategy;
|
|
151
300
|
conflictResolution: ConflictResolution;
|
|
152
301
|
mergeFn?: <T>(local: T, remote: T) => T;
|
|
153
302
|
keyOf: (item: T) => string | undefined;
|
|
303
|
+
versionOf?: (item: T) => string | undefined;
|
|
154
304
|
syncInterval: number;
|
|
305
|
+
pullInterval: number;
|
|
155
306
|
syncOnInit: boolean;
|
|
156
307
|
maxOfflineOps: number;
|
|
157
308
|
offlineQueue: boolean;
|
|
309
|
+
isUnavailable: (error: unknown) => boolean;
|
|
158
310
|
};
|
|
159
|
-
private
|
|
311
|
+
private mode: HybridMode;
|
|
312
|
+
private offlineOps: QueuedOperation[] = [];
|
|
313
|
+
/** The operations in `offlineOps` that the queue store does not have yet */
|
|
314
|
+
private readonly unpersisted = new Set<QueuedOperation>();
|
|
315
|
+
/** True when the queue store can differ from `offlineOps`: a failed write, or sent operations */
|
|
316
|
+
private storeDirty = false;
|
|
317
|
+
/** The calls of the queue store, one at a time */
|
|
318
|
+
private storeChain: Promise<unknown> = Promise.resolve();
|
|
319
|
+
/** The push timer of the write-back mode */
|
|
160
320
|
private syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
321
|
+
/** The periodic pull */
|
|
322
|
+
private pullTimer: ReturnType<typeof setInterval> | null = null;
|
|
323
|
+
/** True while pushQueue sends the queue: the first operation can be on its way */
|
|
324
|
+
private pushing = false;
|
|
325
|
+
/** The timer that sends the queue of the write-through mode after a failure */
|
|
326
|
+
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
327
|
+
/** The load of the queue. The writes wait for it. */
|
|
328
|
+
private readonly loaded: Promise<void>;
|
|
329
|
+
/** The load and the first sync. The reads wait for it, so a read never sees an empty cache by mistake. */
|
|
330
|
+
private readonly ready: Promise<void>;
|
|
331
|
+
/** The error of the load (of the queue store, or of the local copy): the storage refuses each operation */
|
|
332
|
+
private loadError: Error | undefined;
|
|
333
|
+
/** A mode switch that runs. The operations wait for it. */
|
|
334
|
+
private gate: Promise<void> = Promise.resolve();
|
|
335
|
+
/** The operations that run, and the switches that wait for them to end */
|
|
336
|
+
private active = 0;
|
|
337
|
+
private idleWaiters: Array<() => void> = [];
|
|
161
338
|
private isOnline = true;
|
|
339
|
+
private closed = false;
|
|
162
340
|
/** A counter of local writes for each id, so a sync does not restore an older remote value */
|
|
163
341
|
private readonly writeVersions = new Map<string, number>();
|
|
164
342
|
private clearVersion = 0;
|
|
165
343
|
/** The running sync: syncs run one at a time */
|
|
166
344
|
private syncChain: Promise<unknown> = Promise.resolve();
|
|
345
|
+
/** The remote version of the last delta pull */
|
|
346
|
+
private cursor: string | undefined;
|
|
347
|
+
/** True when the remote has `getChangesSince` but its server does not know `changes` */
|
|
348
|
+
private changesUnsupported = false;
|
|
349
|
+
/** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
|
|
350
|
+
private readonly bases = new Map<string, string | null>();
|
|
167
351
|
private stats = {
|
|
168
352
|
cacheHits: 0,
|
|
169
353
|
cacheMisses: 0,
|
|
@@ -175,32 +359,135 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
175
359
|
remote: CollectionStorage<T>,
|
|
176
360
|
options: HybridStorageOptions = {}
|
|
177
361
|
) {
|
|
178
|
-
this.local = new InMemoryStorage<T>();
|
|
362
|
+
this.local = (options.local as LocalCopyStorage<T> | undefined) ?? new InMemoryStorage<T>();
|
|
179
363
|
this.remote = remote;
|
|
364
|
+
this.queueStore = options.queueStore ?? new MemoryQueueStore();
|
|
365
|
+
this.mode = checkMode(options.mode ?? options.writeStrategy ?? "write-through");
|
|
180
366
|
this.options = {
|
|
181
|
-
writeStrategy: options.writeStrategy ?? "write-through",
|
|
182
367
|
conflictResolution: options.conflictResolution ?? "remote",
|
|
183
368
|
...(options.mergeFn !== undefined && { mergeFn: options.mergeFn }),
|
|
184
369
|
keyOf: options.keyOf ?? defaultKeyOf,
|
|
185
|
-
|
|
370
|
+
...(options.versionOf !== undefined && { versionOf: options.versionOf }),
|
|
371
|
+
syncInterval: optionalMs(options.syncInterval, "syncInterval") ?? 5000,
|
|
372
|
+
pullInterval: optionalMs(options.pullInterval, "pullInterval") ?? 0,
|
|
186
373
|
syncOnInit: options.syncOnInit ?? true,
|
|
187
374
|
maxOfflineOps: options.maxOfflineOps ?? 1000,
|
|
188
375
|
offlineQueue: options.offlineQueue ?? true,
|
|
376
|
+
isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
|
|
189
377
|
};
|
|
190
378
|
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
379
|
+
// The queue first, then the first sync. The reads wait for both (roadmap R2, R28).
|
|
380
|
+
this.loaded = this.loadQueue();
|
|
381
|
+
this.ready = this.loaded.then(() => this.initialSync());
|
|
382
|
+
this.updateTimers();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
//
|
|
386
|
+
// ═══ Modes ═══
|
|
387
|
+
//
|
|
388
|
+
|
|
389
|
+
/** The mode of the storage now. */
|
|
390
|
+
getMode(): HybridMode {
|
|
391
|
+
return this.mode;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* This method changes the mode while the storage operates (roadmap R28).
|
|
396
|
+
*
|
|
397
|
+
* - The switch waits for the operations that run. The operations that start later wait for
|
|
398
|
+
* the switch. Switches and syncs run one at a time.
|
|
399
|
+
* - A switch to a remote mode sends the queue first. A switch to `local-only` makes no remote
|
|
400
|
+
* call: the queue stays until the next remote mode.
|
|
401
|
+
* - A switch to `remote-only` drops the local copy, after the queue reached the remote.
|
|
402
|
+
* - A switch from `local-only` or `remote-only` to a mode with a local copy syncs the local
|
|
403
|
+
* copy. The reads wait for the sync.
|
|
404
|
+
* - When the flush or the sync fails, the mode does not change, and the switch throws
|
|
405
|
+
* `ModeSwitchError`.
|
|
406
|
+
*/
|
|
407
|
+
async setMode(mode: HybridMode): Promise<void> {
|
|
408
|
+
const target = checkMode(mode);
|
|
409
|
+
const previous = this.gate;
|
|
410
|
+
let release!: () => void;
|
|
411
|
+
this.gate = new Promise<void>((resolve) => (release = resolve));
|
|
412
|
+
try {
|
|
413
|
+
await previous;
|
|
414
|
+
await this.loaded;
|
|
415
|
+
if (this.loadError) throw this.loadError;
|
|
416
|
+
await this.idle();
|
|
417
|
+
await this.serialize(() => this.switchTo(target));
|
|
418
|
+
} finally {
|
|
419
|
+
release();
|
|
196
420
|
}
|
|
421
|
+
}
|
|
197
422
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
423
|
+
private async switchTo(target: HybridMode): Promise<void> {
|
|
424
|
+
const from = this.mode;
|
|
425
|
+
if (target === from) return;
|
|
426
|
+
try {
|
|
427
|
+
if (target !== "local-only") {
|
|
428
|
+
// The queue reaches the remote before the mode changes
|
|
429
|
+
await this.pushQueue();
|
|
430
|
+
await this.ensureStoreClean();
|
|
431
|
+
}
|
|
432
|
+
if (target === "remote-only") {
|
|
433
|
+
await this.dropLocal();
|
|
434
|
+
} else if (from === "remote-only" || from === "local-only") {
|
|
435
|
+
if (from === "remote-only") await this.rebuildLocal();
|
|
436
|
+
await this.pullRemote(from === "remote-only");
|
|
437
|
+
}
|
|
438
|
+
} catch (error) {
|
|
439
|
+
throw new ModeSwitchError(from, target, error);
|
|
440
|
+
}
|
|
441
|
+
this.mode = target;
|
|
442
|
+
this.updateTimers();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** This method drops the local copy. A read in flight does not fill it again. */
|
|
446
|
+
private async dropLocal(): Promise<void> {
|
|
447
|
+
this.clearVersion++;
|
|
448
|
+
this.cursor = undefined;
|
|
449
|
+
await this.local.clear();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** This method makes the local copy from the queue only, before a full sync. */
|
|
453
|
+
private async rebuildLocal(): Promise<void> {
|
|
454
|
+
await this.dropLocal();
|
|
455
|
+
for (const op of this.offlineOps) await this.applyToLocal(op);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** This method starts and stops the timers for the mode. */
|
|
459
|
+
private updateTimers(): void {
|
|
460
|
+
if (this.closed) return;
|
|
461
|
+
const push = this.mode === "write-back" && this.options.syncInterval > 0;
|
|
462
|
+
if (push && !this.syncTimer) {
|
|
463
|
+
this.syncTimer = setInterval(() => {
|
|
464
|
+
this.syncToRemote().catch((err) => {
|
|
465
|
+
console.error("Periodic sync failed:", err);
|
|
466
|
+
});
|
|
467
|
+
}, this.options.syncInterval);
|
|
468
|
+
// The timer must not keep the process alive (deep dive DATA-9)
|
|
469
|
+
(this.syncTimer as { unref?: () => void }).unref?.();
|
|
470
|
+
} else if (!push && this.syncTimer) {
|
|
471
|
+
clearInterval(this.syncTimer);
|
|
472
|
+
this.syncTimer = null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
|
|
476
|
+
if (pull && !this.pullTimer) {
|
|
477
|
+
this.pullTimer = setInterval(() => {
|
|
478
|
+
this.syncFromRemote().catch((err) => {
|
|
479
|
+
console.error("Periodic pull failed:", err);
|
|
480
|
+
});
|
|
481
|
+
}, this.options.pullInterval);
|
|
482
|
+
(this.pullTimer as { unref?: () => void }).unref?.();
|
|
483
|
+
} else if (!pull && this.pullTimer) {
|
|
484
|
+
clearInterval(this.pullTimer);
|
|
485
|
+
this.pullTimer = null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (this.mode !== "write-through" && this.drainTimer) {
|
|
489
|
+
clearTimeout(this.drainTimer);
|
|
490
|
+
this.drainTimer = null;
|
|
204
491
|
}
|
|
205
492
|
}
|
|
206
493
|
|
|
@@ -210,51 +497,80 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
210
497
|
|
|
211
498
|
/**
|
|
212
499
|
* The local item, or else the remote item (which the cache then keeps). When the remote
|
|
213
|
-
* cannot answer, the storage is offline and the result is undefined.
|
|
500
|
+
* cannot answer, the storage is offline and the result is undefined. In `local-only` mode, a
|
|
501
|
+
* miss is undefined. In `remote-only` mode, the remote answers, and its error passes through.
|
|
214
502
|
*/
|
|
215
503
|
async get(id: string): Promise<T | undefined> {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.
|
|
220
|
-
|
|
221
|
-
|
|
504
|
+
return this.run(true, async () => {
|
|
505
|
+
if (this.mode === "remote-only") return this.remoteRead(() => this.remote.get(id));
|
|
506
|
+
// Try local cache first
|
|
507
|
+
const cached = await this.local.get(id);
|
|
508
|
+
if (cached !== undefined) {
|
|
509
|
+
this.stats.cacheHits++;
|
|
510
|
+
return cached;
|
|
511
|
+
}
|
|
222
512
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
const version = this.versionOf(id);
|
|
229
|
-
try {
|
|
230
|
-
const remote = await this.remote.get(id);
|
|
231
|
-
this.isOnline = true;
|
|
232
|
-
if (remote !== undefined && this.versionOf(id) === version) {
|
|
233
|
-
await this.local.set(id, remote);
|
|
513
|
+
this.stats.cacheMisses++;
|
|
514
|
+
// A pending local delete means "no item": do not read it back from the remote
|
|
515
|
+
if (this.mode === "local-only" || this.hasPendingOp(id)) {
|
|
516
|
+
return undefined;
|
|
234
517
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
518
|
+
const version = this.writeKey(id);
|
|
519
|
+
try {
|
|
520
|
+
const remote = await this.remote.get(id);
|
|
521
|
+
this.isOnline = true;
|
|
522
|
+
if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
|
|
523
|
+
if (remote !== undefined) await this.local.set(id, remote);
|
|
524
|
+
this.noteBase(id, remote);
|
|
525
|
+
}
|
|
526
|
+
return remote;
|
|
527
|
+
} catch {
|
|
528
|
+
this.isOnline = false;
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
});
|
|
240
532
|
}
|
|
241
533
|
|
|
242
534
|
async getAll(): Promise<T[]> {
|
|
243
|
-
|
|
244
|
-
|
|
535
|
+
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.getAll()) : this.local.getAll()));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Every item with its id. */
|
|
539
|
+
async getEntries(): Promise<Array<[string, T]>> {
|
|
540
|
+
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remoteEntries()) : this.local.getEntries()));
|
|
245
541
|
}
|
|
246
542
|
|
|
247
543
|
async find(predicate: (item: T) => boolean): Promise<T[]> {
|
|
248
|
-
|
|
249
|
-
|
|
544
|
+
return this.run(true, () =>
|
|
545
|
+
this.mode === "remote-only" ? this.remoteRead(() => this.remote.find(predicate)) : this.local.find(predicate),
|
|
546
|
+
);
|
|
250
547
|
}
|
|
251
548
|
|
|
252
549
|
async has(id: string): Promise<boolean> {
|
|
253
|
-
return this.local.has(id);
|
|
550
|
+
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.has(id)) : this.local.has(id)));
|
|
254
551
|
}
|
|
255
552
|
|
|
256
553
|
async size(): Promise<number> {
|
|
257
|
-
return this.local.size();
|
|
554
|
+
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.size()) : this.local.size()));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async getBatch(ids: string[]): Promise<Map<string, T>> {
|
|
558
|
+
return this.run(true, () =>
|
|
559
|
+
this.mode === "remote-only" ? this.remoteRead(() => this.remote.getBatch(ids)) : this.local.getBatch(ids),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** A read of the remote-only mode. A queue that a restart loaded reaches the remote first. */
|
|
564
|
+
private async remoteRead<R>(read: () => Promise<R>): Promise<R> {
|
|
565
|
+
if (this.offlineOps.length > 0) await this.serialize(() => this.pushQueue());
|
|
566
|
+
try {
|
|
567
|
+
const result = await read();
|
|
568
|
+
this.isOnline = true;
|
|
569
|
+
return result;
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (!(error instanceof MissingKeysError)) this.isOnline = false;
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
258
574
|
}
|
|
259
575
|
|
|
260
576
|
//
|
|
@@ -262,68 +578,24 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
262
578
|
//
|
|
263
579
|
|
|
264
580
|
async set(id: string, value: T): Promise<void> {
|
|
265
|
-
this.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (this.options.writeStrategy === "write-through") {
|
|
270
|
-
// Write-through: sync to remote immediately
|
|
271
|
-
try {
|
|
272
|
-
await this.remote.set(id, value);
|
|
273
|
-
} catch (error) {
|
|
274
|
-
this.handleRemoteFailure("set", { id, value });
|
|
275
|
-
throw error;
|
|
276
|
-
}
|
|
277
|
-
} else {
|
|
278
|
-
// Write-back: queue for later sync
|
|
279
|
-
this.queueOperation({
|
|
280
|
-
type: "set",
|
|
281
|
-
data: { id, value },
|
|
282
|
-
timestamp: Date.now(),
|
|
283
|
-
});
|
|
284
|
-
}
|
|
581
|
+
await this.write("set", { id, value }, [id], async () => {
|
|
582
|
+
this.bump(id);
|
|
583
|
+
await this.local.set(id, value);
|
|
584
|
+
}, () => this.remote.set(id, value));
|
|
285
585
|
}
|
|
286
586
|
|
|
287
587
|
async delete(id: string): Promise<boolean> {
|
|
288
|
-
this.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
try {
|
|
293
|
-
await this.remote.delete(id);
|
|
294
|
-
} catch (error) {
|
|
295
|
-
this.handleRemoteFailure("delete", { id });
|
|
296
|
-
throw error;
|
|
297
|
-
}
|
|
298
|
-
} else {
|
|
299
|
-
this.queueOperation({
|
|
300
|
-
type: "delete",
|
|
301
|
-
data: { id },
|
|
302
|
-
timestamp: Date.now(),
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return existed;
|
|
588
|
+
return this.write("delete", { id }, [id], async () => {
|
|
589
|
+
this.bump(id);
|
|
590
|
+
return this.local.delete(id);
|
|
591
|
+
}, () => this.remote.delete(id));
|
|
307
592
|
}
|
|
308
593
|
|
|
309
594
|
async clear(): Promise<void> {
|
|
310
|
-
this.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
await this.remote.clear();
|
|
316
|
-
} catch (error) {
|
|
317
|
-
this.handleRemoteFailure("clear", {});
|
|
318
|
-
throw error;
|
|
319
|
-
}
|
|
320
|
-
} else {
|
|
321
|
-
this.queueOperation({
|
|
322
|
-
type: "clear",
|
|
323
|
-
data: {},
|
|
324
|
-
timestamp: Date.now(),
|
|
325
|
-
});
|
|
326
|
-
}
|
|
595
|
+
await this.write("clear", {}, "*", async () => {
|
|
596
|
+
this.clearVersion++;
|
|
597
|
+
await this.local.clear();
|
|
598
|
+
}, () => this.remote.clear());
|
|
327
599
|
}
|
|
328
600
|
|
|
329
601
|
//
|
|
@@ -331,49 +603,134 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
331
603
|
//
|
|
332
604
|
|
|
333
605
|
async setBatch(items: Array<[string, T]>): Promise<void> {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
await this.remote.setBatch(items);
|
|
340
|
-
} catch (error) {
|
|
341
|
-
this.handleRemoteFailure("setBatch", { items });
|
|
342
|
-
throw error;
|
|
343
|
-
}
|
|
344
|
-
} else {
|
|
345
|
-
this.queueOperation({
|
|
346
|
-
type: "setBatch",
|
|
347
|
-
data: { items },
|
|
348
|
-
timestamp: Date.now(),
|
|
349
|
-
});
|
|
350
|
-
}
|
|
606
|
+
await this.write("setBatch", { items }, items.map(([id]) => id), async () => {
|
|
607
|
+
for (const [id] of items) this.bump(id);
|
|
608
|
+
await this.local.setBatch(items);
|
|
609
|
+
}, () => this.remote.setBatch(items));
|
|
351
610
|
}
|
|
352
611
|
|
|
353
612
|
async deleteBatch(ids: string[]): Promise<number> {
|
|
354
|
-
|
|
355
|
-
|
|
613
|
+
return this.write("deleteBatch", { ids }, ids, async () => {
|
|
614
|
+
for (const id of ids) this.bump(id);
|
|
615
|
+
return this.local.deleteBatch(ids);
|
|
616
|
+
}, () => this.remote.deleteBatch(ids));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* One write, in the mode of the storage. The result is the result of the local copy, or of the
|
|
621
|
+
* remote in `remote-only` mode.
|
|
622
|
+
*
|
|
623
|
+
* - `remote-only`: the remote only. Its error passes through.
|
|
624
|
+
* - `write-back` and `local-only`: the local copy and the queue. A full queue refuses the write
|
|
625
|
+
* before the local change. The queue store keeps the operation before the write ends.
|
|
626
|
+
* - `write-through`, the remote refuses the write: the local change is undone, and the error
|
|
627
|
+
* of the remote passes through.
|
|
628
|
+
* - `write-through`, the remote is not available: with the queue, the write waits in it, a timer
|
|
629
|
+
* sends it later, and the write throws `PendingWriteError`. Without the queue, or with a full
|
|
630
|
+
* queue, the local change is undone.
|
|
631
|
+
*
|
|
632
|
+
* Before, a failed write-through stayed in the local cache and in the queue, no timer sent the
|
|
633
|
+
* queue, a second try queued the operation again, and a full queue dropped the oldest operation
|
|
634
|
+
* without a word (roadmap R2).
|
|
635
|
+
*/
|
|
636
|
+
private write<R>(
|
|
637
|
+
type: QueuedOperation["type"],
|
|
638
|
+
data: unknown,
|
|
639
|
+
ids: string[] | "*",
|
|
640
|
+
applyLocal: () => Promise<R>,
|
|
641
|
+
applyRemote: () => Promise<R>,
|
|
642
|
+
): Promise<R> {
|
|
643
|
+
return this.run(false, async () => {
|
|
644
|
+
const op: QueuedOperation = { type, data, timestamp: Date.now() };
|
|
645
|
+
|
|
646
|
+
if (this.mode === "remote-only") {
|
|
647
|
+
const send = async (): Promise<R> => {
|
|
648
|
+
await this.ensureStoreClean();
|
|
649
|
+
const result = await applyRemote();
|
|
650
|
+
this.noteRemoteWrite(op);
|
|
651
|
+
return result;
|
|
652
|
+
};
|
|
653
|
+
try {
|
|
654
|
+
const result = this.offlineOps.length > 0
|
|
655
|
+
? await this.serialize(async () => {
|
|
656
|
+
await this.pushQueue();
|
|
657
|
+
return send();
|
|
658
|
+
})
|
|
659
|
+
: await send();
|
|
660
|
+
this.isOnline = true;
|
|
661
|
+
return result;
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (!(error instanceof QueueStoreError) && this.options.isUnavailable(error)) this.isOnline = false;
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (this.mode === "write-back" || this.mode === "local-only") {
|
|
669
|
+
this.ensureQueueRoom(op);
|
|
670
|
+
this.attachBase(op);
|
|
671
|
+
const undo = await this.snapshot(ids);
|
|
672
|
+
const result = await applyLocal();
|
|
673
|
+
await this.enqueue(op, undo);
|
|
674
|
+
return result;
|
|
675
|
+
}
|
|
356
676
|
|
|
357
|
-
|
|
677
|
+
const undo = await this.snapshot(ids);
|
|
678
|
+
const result = await applyLocal();
|
|
358
679
|
try {
|
|
359
|
-
|
|
680
|
+
if (this.offlineOps.length > 0) {
|
|
681
|
+
// The queued operations are older: they reach the remote first, or the older value of a
|
|
682
|
+
// queued write replaces this one later. A failed send keeps this write behind them.
|
|
683
|
+
await this.serialize(async () => {
|
|
684
|
+
await this.pushQueue();
|
|
685
|
+
await this.ensureStoreClean();
|
|
686
|
+
await applyRemote();
|
|
687
|
+
});
|
|
688
|
+
} else {
|
|
689
|
+
await this.ensureStoreClean();
|
|
690
|
+
await applyRemote();
|
|
691
|
+
}
|
|
692
|
+
this.isOnline = true;
|
|
693
|
+
this.noteRemoteWrite(op);
|
|
360
694
|
} catch (error) {
|
|
361
|
-
|
|
695
|
+
if (
|
|
696
|
+
!(error instanceof QueueStoreError) &&
|
|
697
|
+
this.options.offlineQueue &&
|
|
698
|
+
this.options.isUnavailable(error) &&
|
|
699
|
+
this.hasQueueRoom(op)
|
|
700
|
+
) {
|
|
701
|
+
this.isOnline = false;
|
|
702
|
+
console.error(`Remote ${type} failed: the operation waits in the queue`);
|
|
703
|
+
this.attachBase(op);
|
|
704
|
+
await this.enqueue(op, undo);
|
|
705
|
+
this.scheduleDrain();
|
|
706
|
+
throw new PendingWriteError(type, error);
|
|
707
|
+
}
|
|
708
|
+
await undo();
|
|
362
709
|
throw error;
|
|
363
710
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
type: "deleteBatch",
|
|
367
|
-
data: { ids },
|
|
368
|
-
timestamp: Date.now(),
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return deleted;
|
|
711
|
+
return result;
|
|
712
|
+
});
|
|
373
713
|
}
|
|
374
714
|
|
|
375
|
-
|
|
376
|
-
|
|
715
|
+
/**
|
|
716
|
+
* The local values of the ids before a write, and a function that puts them back. It puts back
|
|
717
|
+
* only the ids that no later write changed.
|
|
718
|
+
*/
|
|
719
|
+
private async snapshot(ids: string[] | "*"): Promise<() => Promise<void>> {
|
|
720
|
+
const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
|
|
721
|
+
const touched = ids === "*" ? [...before.keys()] : ids;
|
|
722
|
+
// The versions after the write: this write adds one to each id (clear adds to clearVersion)
|
|
723
|
+
const versions = new Map(touched.map((id) => [id, (this.writeVersions.get(id) ?? 0) + (ids === "*" ? 0 : 1)]));
|
|
724
|
+
const clearVersion = this.clearVersion + (ids === "*" ? 1 : 0);
|
|
725
|
+
return async () => {
|
|
726
|
+
if (this.clearVersion !== clearVersion) return;
|
|
727
|
+
for (const id of touched) {
|
|
728
|
+
if ((this.writeVersions.get(id) ?? 0) !== versions.get(id)) continue;
|
|
729
|
+
const value = before.get(id);
|
|
730
|
+
if (value === undefined) await this.local.delete(id);
|
|
731
|
+
else await this.local.set(id, value);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
377
734
|
}
|
|
378
735
|
|
|
379
736
|
//
|
|
@@ -381,27 +738,47 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
381
738
|
//
|
|
382
739
|
|
|
383
740
|
/**
|
|
384
|
-
* Sync
|
|
385
|
-
*
|
|
741
|
+
* Sync from the remote to the local copy, in the modes `write-through` and `write-back`. The
|
|
742
|
+
* other modes have no local copy to sync (`remote-only`) or make no remote call (`local-only`):
|
|
743
|
+
* the call does nothing.
|
|
386
744
|
*
|
|
387
|
-
* -
|
|
745
|
+
* - The sync asks for the changes since the version of the last sync when the remote has
|
|
746
|
+
* `getChangesSince`. Else it reads all entries.
|
|
747
|
+
* - Each remote item goes into the cache under its id.
|
|
388
748
|
* - A local item that the remote does not have is removed, unless a queued local operation
|
|
389
|
-
* changes it
|
|
749
|
+
* changes it.
|
|
390
750
|
* - An id that a local write changed during the sync keeps the local value: the remote value
|
|
391
751
|
* is older.
|
|
392
752
|
* - Syncs run one at a time.
|
|
393
753
|
*/
|
|
394
754
|
syncFromRemote(): Promise<void> {
|
|
395
|
-
return this.serialize(() =>
|
|
755
|
+
return this.serialize(async () => {
|
|
756
|
+
await this.loaded;
|
|
757
|
+
if (this.loadError) throw this.loadError;
|
|
758
|
+
if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
|
|
759
|
+
});
|
|
396
760
|
}
|
|
397
761
|
|
|
398
762
|
/**
|
|
399
763
|
* Sync all pending offline operations to remote.
|
|
400
764
|
* Processes queued operations in order. When one fails, it and the later operations stay in
|
|
401
|
-
* the queue: the operations that reached the remote are not sent again.
|
|
765
|
+
* the queue: the operations that reached the remote are not sent again. In `local-only` mode,
|
|
766
|
+
* the call does nothing.
|
|
402
767
|
*/
|
|
403
768
|
syncToRemote(): Promise<void> {
|
|
404
|
-
return this.serialize(() =>
|
|
769
|
+
return this.serialize(async () => {
|
|
770
|
+
await this.loaded;
|
|
771
|
+
if (this.loadError) throw this.loadError;
|
|
772
|
+
if (this.mode !== "local-only") await this.pushQueue();
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* This method sends the queue to the remote (roadmap R28). `ProcedureServer.useStorage` uses it. In
|
|
778
|
+
* `local-only` mode, it sends nothing: the queue waits for a remote mode.
|
|
779
|
+
*/
|
|
780
|
+
flush(): Promise<void> {
|
|
781
|
+
return this.syncToRemote();
|
|
405
782
|
}
|
|
406
783
|
|
|
407
784
|
/**
|
|
@@ -416,24 +793,40 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
416
793
|
// ═══ Lifecycle & Metadata ═══
|
|
417
794
|
//
|
|
418
795
|
|
|
796
|
+
/**
|
|
797
|
+
* This method stops the timers, sends the queue (not in `local-only` mode), and closes the remote and the
|
|
798
|
+
* local copy. The queue store keeps the operations that the remote did not take. A durable local
|
|
799
|
+
* copy keeps its items.
|
|
800
|
+
*/
|
|
419
801
|
async close(): Promise<void> {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
clearInterval(
|
|
423
|
-
|
|
802
|
+
this.closed = true;
|
|
803
|
+
for (const timer of [this.syncTimer, this.pullTimer]) {
|
|
804
|
+
if (timer) clearInterval(timer);
|
|
805
|
+
}
|
|
806
|
+
this.syncTimer = null;
|
|
807
|
+
this.pullTimer = null;
|
|
808
|
+
if (this.drainTimer) {
|
|
809
|
+
clearTimeout(this.drainTimer);
|
|
810
|
+
this.drainTimer = null;
|
|
424
811
|
}
|
|
425
812
|
|
|
813
|
+
await this.loaded;
|
|
426
814
|
// Sync pending operations before closing
|
|
427
|
-
if (this.offlineOps.length > 0) {
|
|
815
|
+
if (this.offlineOps.length > 0 && this.mode !== "local-only") {
|
|
428
816
|
try {
|
|
429
817
|
await this.syncToRemote();
|
|
430
818
|
} catch (error) {
|
|
431
819
|
console.error("Failed to sync before close:", error);
|
|
432
820
|
}
|
|
433
821
|
}
|
|
822
|
+
await this.storeChain;
|
|
434
823
|
|
|
435
824
|
// Close remote storage (an ApiStorage leaves the caller's client open)
|
|
436
|
-
|
|
825
|
+
try {
|
|
826
|
+
await this.remote.close();
|
|
827
|
+
} finally {
|
|
828
|
+
await this.local.close();
|
|
829
|
+
}
|
|
437
830
|
}
|
|
438
831
|
|
|
439
832
|
async getMetadata(): Promise<StorageMetadata> {
|
|
@@ -452,7 +845,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
452
845
|
conflicts: this.stats.conflicts,
|
|
453
846
|
pendingOps: this.offlineOps.length,
|
|
454
847
|
isOnline: this.isOnline,
|
|
455
|
-
|
|
848
|
+
mode: this.mode,
|
|
849
|
+
writeStrategy: this.mode,
|
|
456
850
|
conflictResolution: this.options.conflictResolution,
|
|
457
851
|
},
|
|
458
852
|
};
|
|
@@ -462,93 +856,465 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
462
856
|
// ═══ Internal Helpers ═══
|
|
463
857
|
//
|
|
464
858
|
|
|
465
|
-
|
|
859
|
+
/**
|
|
860
|
+
* This method starts a public operation: after the load of the queue (a write) or the first sync (a read),
|
|
861
|
+
* and never during a mode switch.
|
|
862
|
+
*/
|
|
863
|
+
private async run<R>(read: boolean, work: () => Promise<R>): Promise<R> {
|
|
864
|
+
await (read ? this.ready : this.loaded);
|
|
865
|
+
for (;;) {
|
|
866
|
+
const gate = this.gate;
|
|
867
|
+
await gate;
|
|
868
|
+
// No new switch started while this operation waited
|
|
869
|
+
if (this.gate === gate) break;
|
|
870
|
+
}
|
|
871
|
+
if (this.loadError) throw this.loadError;
|
|
872
|
+
this.active++;
|
|
873
|
+
try {
|
|
874
|
+
return await work();
|
|
875
|
+
} finally {
|
|
876
|
+
if (--this.active === 0) {
|
|
877
|
+
for (const resolve of this.idleWaiters.splice(0)) resolve();
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** A promise that resolves when no operation runs. */
|
|
883
|
+
private idle(): Promise<void> {
|
|
884
|
+
if (this.active === 0) return Promise.resolve();
|
|
885
|
+
return new Promise<void>((resolve) => this.idleWaiters.push(resolve));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** This method loads the queue, and puts its writes in the local copy. */
|
|
889
|
+
private async loadQueue(): Promise<void> {
|
|
890
|
+
let ops: QueuedOperation[];
|
|
891
|
+
try {
|
|
892
|
+
ops = await this.queueStore.load();
|
|
893
|
+
} catch (error) {
|
|
894
|
+
this.loadError = new QueueStoreError("load", error);
|
|
895
|
+
console.error("The queue store could not load the queue: the storage refuses each operation", error);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
this.offlineOps = ops.slice();
|
|
899
|
+
try {
|
|
900
|
+
for (const op of this.offlineOps) {
|
|
901
|
+
for (const [id, version] of op.base ?? []) {
|
|
902
|
+
if (!this.bases.has(id)) this.bases.set(id, version);
|
|
903
|
+
}
|
|
904
|
+
// A durable local copy can hold the operation already: each operation gives the same
|
|
905
|
+
// result two times
|
|
906
|
+
await this.applyToLocal(op);
|
|
907
|
+
}
|
|
908
|
+
} catch (error) {
|
|
909
|
+
this.loadError = new Error("The local copy could not take the loaded queue: the storage refuses each operation", { cause: error });
|
|
910
|
+
console.error(this.loadError.message, error);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** The first sync: send the loaded queue, then read the remote. */
|
|
915
|
+
private async initialSync(): Promise<void> {
|
|
916
|
+
if (this.loadError) return;
|
|
917
|
+
if (this.options.syncOnInit && this.mode !== "local-only") {
|
|
918
|
+
if (this.offlineOps.length > 0) {
|
|
919
|
+
await this.syncToRemote().catch((err) => {
|
|
920
|
+
console.error("Failed to send the queue on init:", err);
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (this.mode === "write-through" || this.mode === "write-back") {
|
|
924
|
+
await this.syncFromRemote().catch((err) => {
|
|
925
|
+
console.error("Failed to sync on init:", err);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (this.offlineOps.length > 0) this.scheduleDrain();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** This method applies a queued operation to the local copy. */
|
|
933
|
+
private async applyToLocal(op: QueuedOperation): Promise<void> {
|
|
934
|
+
const data = op.data as { id: string; value: T; ids: string[]; items: Array<[string, T]> };
|
|
935
|
+
switch (op.type) {
|
|
936
|
+
case "set":
|
|
937
|
+
await this.local.set(data.id, data.value);
|
|
938
|
+
break;
|
|
939
|
+
case "delete":
|
|
940
|
+
await this.local.delete(data.id);
|
|
941
|
+
break;
|
|
942
|
+
case "setBatch":
|
|
943
|
+
await this.local.setBatch(data.items);
|
|
944
|
+
break;
|
|
945
|
+
case "deleteBatch":
|
|
946
|
+
await this.local.deleteBatch(data.ids);
|
|
947
|
+
break;
|
|
948
|
+
case "clear":
|
|
949
|
+
await this.local.clear();
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* One pull. `full`: ask for all items (after the local copy was dropped).
|
|
956
|
+
*
|
|
957
|
+
* - The versions of the local writes at the start: a write during the pull keeps its value.
|
|
958
|
+
* - The cursor moves only after a pull without conflicts, so a conflict of the "error" policy
|
|
959
|
+
* comes again at the next pull.
|
|
960
|
+
*/
|
|
961
|
+
private async pullRemote(full: boolean): Promise<void> {
|
|
466
962
|
const versions = new Map(this.writeVersions);
|
|
467
963
|
const clearVersion = this.clearVersion;
|
|
468
964
|
// True when a local write changed the id after the sync started
|
|
469
965
|
const changedLocally = (id: string): boolean =>
|
|
470
966
|
this.clearVersion !== clearVersion || (this.writeVersions.get(id) ?? 0) !== (versions.get(id) ?? 0);
|
|
471
967
|
|
|
472
|
-
let
|
|
968
|
+
let changes: PulledChanges<T>;
|
|
473
969
|
try {
|
|
474
|
-
|
|
970
|
+
changes = await this.fetchChanges(full);
|
|
475
971
|
} catch (error) {
|
|
476
|
-
this.isOnline = false;
|
|
972
|
+
if (!(error instanceof MissingKeysError)) this.isOnline = false;
|
|
477
973
|
throw error;
|
|
478
974
|
}
|
|
479
975
|
|
|
976
|
+
const conflicts: string[] = [];
|
|
977
|
+
let queueChanged = false;
|
|
480
978
|
const remoteIds = new Set<string>();
|
|
481
|
-
for (const item of
|
|
482
|
-
const id = this.options.keyOf(item);
|
|
483
|
-
if (!id) {
|
|
484
|
-
console.warn("Item has no key, skipping:", item);
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
979
|
+
for (const [id, item] of changes.changed) {
|
|
487
980
|
remoteIds.add(id);
|
|
488
|
-
if (
|
|
489
|
-
|
|
981
|
+
if (await this.pullItem(id, item, changedLocally, conflicts)) queueChanged = true;
|
|
982
|
+
}
|
|
983
|
+
// The remote deleted these items
|
|
984
|
+
const gone = changes.full
|
|
985
|
+
? (await this.local.getEntries()).map(([id]) => id).filter((id) => !remoteIds.has(id))
|
|
986
|
+
: changes.deleted;
|
|
987
|
+
for (const id of gone) {
|
|
988
|
+
if (await this.pullDelete(id, changedLocally, conflicts)) queueChanged = true;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (queueChanged) {
|
|
992
|
+
this.storeDirty = true;
|
|
993
|
+
await this.persistAll().catch((error) => {
|
|
994
|
+
console.error("The queue store could not keep the queue after the sync:", error);
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
this.stats.syncCount++;
|
|
998
|
+
this.isOnline = true;
|
|
999
|
+
if (conflicts.length > 0) throw new ConflictError(conflicts);
|
|
1000
|
+
if (changes.version !== undefined) this.cursor = changes.version;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* The remote changes: since the cursor from `getChangesSince` when the remote has it, or else
|
|
1005
|
+
* all entries.
|
|
1006
|
+
*/
|
|
1007
|
+
private async fetchChanges(full: boolean): Promise<PulledChanges<T>> {
|
|
1008
|
+
if (this.remote.getChangesSince && !this.changesUnsupported) {
|
|
1009
|
+
try {
|
|
1010
|
+
return await this.remote.getChangesSince(full ? undefined : this.cursor);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (!isNotFound(error)) throw error;
|
|
1013
|
+
// A server without the procedure `changes`: read all entries from now on
|
|
1014
|
+
this.changesUnsupported = true;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return { full: true, changed: await this.remoteEntries(), deleted: [] };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** This method applies one remote item. It gives true when the queue changed. */
|
|
1021
|
+
private async pullItem(
|
|
1022
|
+
id: string,
|
|
1023
|
+
item: T,
|
|
1024
|
+
changedLocally: (id: string) => boolean,
|
|
1025
|
+
conflicts: string[],
|
|
1026
|
+
): Promise<boolean> {
|
|
1027
|
+
if (changedLocally(id)) return false;
|
|
1028
|
+
const localItem = await this.local.get(id);
|
|
1029
|
+
if (changedLocally(id)) return false;
|
|
1030
|
+
const remoteVersion = this.versionOfItem(item);
|
|
1031
|
+
|
|
1032
|
+
if (this.hasPendingOp(id)) {
|
|
1033
|
+
// Without versions, the queued write wins, as before
|
|
1034
|
+
if (remoteVersion === undefined) return false;
|
|
1035
|
+
// The remote did not change since the last sync, or the storage never saw a remote version
|
|
1036
|
+
// of the id: the queued write wins
|
|
1037
|
+
const base = this.bases.get(id);
|
|
1038
|
+
if (base === undefined || base === remoteVersion) return false;
|
|
1039
|
+
if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
|
|
1040
|
+
this.bases.set(id, remoteVersion);
|
|
1041
|
+
return false;
|
|
490
1042
|
}
|
|
1043
|
+
return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
|
|
1044
|
+
}
|
|
491
1045
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1046
|
+
const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
|
|
1047
|
+
if (localItem === undefined) {
|
|
1048
|
+
await this.local.set(id, item);
|
|
1049
|
+
} else if (remoteVersion !== undefined && localVersion !== undefined) {
|
|
1050
|
+
// Only the remote changed: it is not a conflict
|
|
1051
|
+
if (localVersion !== remoteVersion) await this.local.set(id, item);
|
|
1052
|
+
} else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
|
|
1053
|
+
// Conflict - resolve according to strategy
|
|
1054
|
+
if (this.options.conflictResolution === "error") {
|
|
500
1055
|
this.stats.conflicts++;
|
|
1056
|
+
conflicts.push(id);
|
|
1057
|
+
return false;
|
|
501
1058
|
}
|
|
1059
|
+
const resolved = this.resolveConflict(localItem, item);
|
|
1060
|
+
await this.local.set(id, resolved);
|
|
1061
|
+
this.stats.conflicts++;
|
|
502
1062
|
}
|
|
1063
|
+
if (remoteVersion !== undefined) this.bases.set(id, remoteVersion);
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
503
1066
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1067
|
+
/** This method applies one remote delete. It gives true when the queue changed. */
|
|
1068
|
+
private async pullDelete(id: string, changedLocally: (id: string) => boolean, conflicts: string[]): Promise<boolean> {
|
|
1069
|
+
if (changedLocally(id)) return false;
|
|
1070
|
+
if (!this.hasPendingOp(id)) {
|
|
1071
|
+
await this.local.delete(id);
|
|
1072
|
+
if (this.options.versionOf) this.bases.set(id, null);
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
// Without versions, the queued write wins, as before. With versions, the remote deleted the
|
|
1076
|
+
// item only if the last sync saw a version of it.
|
|
1077
|
+
const base = this.bases.get(id);
|
|
1078
|
+
if (!this.options.versionOf || base === undefined || base === null) return false;
|
|
1079
|
+
const localItem = await this.local.get(id);
|
|
1080
|
+
if (changedLocally(id)) return false;
|
|
1081
|
+
if (localItem === undefined) {
|
|
1082
|
+
// Both sides deleted the item
|
|
1083
|
+
this.bases.set(id, null);
|
|
1084
|
+
return false;
|
|
1085
|
+
}
|
|
1086
|
+
this.stats.conflicts++;
|
|
1087
|
+
switch (this.options.conflictResolution) {
|
|
1088
|
+
case "remote":
|
|
1089
|
+
if (!this.dropQueued(id)) break;
|
|
507
1090
|
await this.local.delete(id);
|
|
1091
|
+
this.bases.set(id, null);
|
|
1092
|
+
return true;
|
|
1093
|
+
case "error":
|
|
1094
|
+
conflicts.push(id);
|
|
1095
|
+
return false;
|
|
1096
|
+
default:
|
|
1097
|
+
// "local", and "merge" with one deleted side: the queued write wins
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
this.bases.set(id, null);
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* A queued local write of an id whose remote version changed: a conflict, and the policy
|
|
1106
|
+
* decides. True when the queue changed.
|
|
1107
|
+
*/
|
|
1108
|
+
private async resolvePending(
|
|
1109
|
+
id: string,
|
|
1110
|
+
localItem: T | undefined,
|
|
1111
|
+
remoteItem: T,
|
|
1112
|
+
remoteVersion: string,
|
|
1113
|
+
conflicts: string[],
|
|
1114
|
+
): Promise<boolean> {
|
|
1115
|
+
this.stats.conflicts++;
|
|
1116
|
+
switch (this.options.conflictResolution) {
|
|
1117
|
+
case "remote":
|
|
1118
|
+
// The remote value replaces the queued write. A queued clear cannot lose one id.
|
|
1119
|
+
if (!this.dropQueued(id)) break;
|
|
1120
|
+
await this.local.set(id, remoteItem);
|
|
1121
|
+
this.bases.set(id, remoteVersion);
|
|
1122
|
+
return true;
|
|
1123
|
+
case "merge": {
|
|
1124
|
+
if (localItem === undefined) break;
|
|
1125
|
+
const merged = this.resolveConflict(localItem, remoteItem);
|
|
1126
|
+
if (!this.dropQueued(id)) break;
|
|
1127
|
+
await this.local.set(id, merged);
|
|
1128
|
+
this.bases.set(id, remoteVersion);
|
|
1129
|
+
this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
|
|
1130
|
+
return true;
|
|
508
1131
|
}
|
|
1132
|
+
case "error":
|
|
1133
|
+
conflicts.push(id);
|
|
1134
|
+
return false;
|
|
1135
|
+
default:
|
|
1136
|
+
break;
|
|
509
1137
|
}
|
|
1138
|
+
// "local": the queued write wins, and it replaces this remote version
|
|
1139
|
+
this.bases.set(id, remoteVersion);
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
510
1142
|
|
|
511
|
-
|
|
512
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* This method removes an id from the queued operations. It gives false when a queued clear touches it: nothing
|
|
1145
|
+
* changes then.
|
|
1146
|
+
*/
|
|
1147
|
+
private dropQueued(id: string): boolean {
|
|
1148
|
+
if (this.offlineOps.some((op) => op.type === "clear")) return false;
|
|
1149
|
+
const next: QueuedOperation[] = [];
|
|
1150
|
+
for (const op of this.offlineOps) {
|
|
1151
|
+
const kept = withoutId(op, id);
|
|
1152
|
+
if (kept) next.push(kept);
|
|
1153
|
+
}
|
|
1154
|
+
this.offlineOps = next;
|
|
1155
|
+
return true;
|
|
513
1156
|
}
|
|
514
1157
|
|
|
1158
|
+
/**
|
|
1159
|
+
* The remote items with their ids: from `getEntries()` when the remote has it, or else from
|
|
1160
|
+
* `getAll()` and `keyOf`. An item without a key stops the sync before any change. Before, the
|
|
1161
|
+
* sync skipped such items and then deleted each local item that the remote list did not have,
|
|
1162
|
+
* so the remote list without ids emptied the cache (roadmap R2).
|
|
1163
|
+
*/
|
|
1164
|
+
private async remoteEntries(): Promise<Array<[string, T]>> {
|
|
1165
|
+
if (this.remote.getEntries) return this.remote.getEntries();
|
|
1166
|
+
const items = await this.remote.getAll();
|
|
1167
|
+
const entries: Array<[string, T]> = [];
|
|
1168
|
+
let missing = 0;
|
|
1169
|
+
for (const item of items) {
|
|
1170
|
+
const id = this.options.keyOf(item);
|
|
1171
|
+
if (id) entries.push([id, item]);
|
|
1172
|
+
else missing++;
|
|
1173
|
+
}
|
|
1174
|
+
if (missing > 0) throw new MissingKeysError(missing);
|
|
1175
|
+
return entries;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* This method sends the queue. The operations that reached the remote leave the queue. The queue store
|
|
1180
|
+
* gets the change at the end.
|
|
1181
|
+
*/
|
|
515
1182
|
private async pushQueue(): Promise<void> {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1183
|
+
this.pushing = true;
|
|
1184
|
+
let sent = 0;
|
|
1185
|
+
try {
|
|
1186
|
+
while (this.offlineOps.length > 0) {
|
|
1187
|
+
const op = this.offlineOps[0]!;
|
|
1188
|
+
if (this.unpersisted.has(op)) {
|
|
1189
|
+
// The write of this operation waits for the queue store
|
|
1190
|
+
await this.storeChain;
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
try {
|
|
1194
|
+
await this.executeOperation(op);
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
// This operation and the later ones stay queued
|
|
1197
|
+
this.isOnline = false;
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
this.noteRemoteWrite(op);
|
|
1201
|
+
// Remove the operation only after it reached the remote
|
|
1202
|
+
if (this.offlineOps[0] === op) {
|
|
1203
|
+
this.offlineOps.shift();
|
|
1204
|
+
sent++;
|
|
1205
|
+
this.storeDirty = true;
|
|
1206
|
+
}
|
|
524
1207
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1208
|
+
this.isOnline = true;
|
|
1209
|
+
} finally {
|
|
1210
|
+
this.pushing = false;
|
|
1211
|
+
if (sent > 0) {
|
|
1212
|
+
await this.persistAll().catch((error) => {
|
|
1213
|
+
// The store keeps sent operations. A later direct write repairs it first.
|
|
1214
|
+
console.error("The queue store could not remove the sent operations:", error);
|
|
1215
|
+
});
|
|
528
1216
|
}
|
|
529
1217
|
}
|
|
530
|
-
this.isOnline = true;
|
|
531
1218
|
}
|
|
532
1219
|
|
|
533
1220
|
/** Run syncs one at a time. */
|
|
534
|
-
private serialize(run: () => Promise<
|
|
1221
|
+
private serialize<R>(run: () => Promise<R>): Promise<R> {
|
|
535
1222
|
const next = this.syncChain.then(run, run);
|
|
536
1223
|
this.syncChain = next.catch(() => undefined);
|
|
537
1224
|
return next;
|
|
538
1225
|
}
|
|
539
1226
|
|
|
540
|
-
private async localIds(): Promise<string[]> {
|
|
541
|
-
return [...this.local.keys()];
|
|
542
|
-
}
|
|
543
|
-
|
|
544
1227
|
private bump(id: string): void {
|
|
545
1228
|
this.writeVersions.set(id, (this.writeVersions.get(id) ?? 0) + 1);
|
|
546
1229
|
}
|
|
547
1230
|
|
|
548
|
-
|
|
1231
|
+
/** The count of local writes of an id: it changes with each write and each clear. */
|
|
1232
|
+
private writeKey(id: string): string {
|
|
549
1233
|
return `${this.clearVersion}:${this.writeVersions.get(id) ?? 0}`;
|
|
550
1234
|
}
|
|
551
1235
|
|
|
1236
|
+
private versionOfItem(item: T): string | undefined {
|
|
1237
|
+
return this.options.versionOf?.(item);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** This method records the remote version of an id that the storage read. */
|
|
1241
|
+
private noteBase(id: string, item: T | undefined): void {
|
|
1242
|
+
if (!this.options.versionOf) return;
|
|
1243
|
+
if (item === undefined) {
|
|
1244
|
+
this.bases.set(id, null);
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const version = this.versionOfItem(item);
|
|
1248
|
+
if (version === undefined) this.bases.delete(id);
|
|
1249
|
+
else this.bases.set(id, version);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** This method records the remote versions after a write reached the remote. */
|
|
1253
|
+
private noteRemoteWrite(op: QueuedOperation): void {
|
|
1254
|
+
if (!this.options.versionOf) return;
|
|
1255
|
+
const data = op.data as { id: string; value: T; ids: string[]; items: Array<[string, T]> };
|
|
1256
|
+
switch (op.type) {
|
|
1257
|
+
case "set":
|
|
1258
|
+
this.noteBase(data.id, data.value);
|
|
1259
|
+
break;
|
|
1260
|
+
case "delete":
|
|
1261
|
+
this.bases.set(data.id, null);
|
|
1262
|
+
break;
|
|
1263
|
+
case "setBatch":
|
|
1264
|
+
for (const [id, value] of data.items) this.noteBase(id, value);
|
|
1265
|
+
break;
|
|
1266
|
+
case "deleteBatch":
|
|
1267
|
+
for (const id of data.ids) this.bases.set(id, null);
|
|
1268
|
+
break;
|
|
1269
|
+
case "clear":
|
|
1270
|
+
for (const id of this.bases.keys()) this.bases.set(id, null);
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* This method puts in the operation the remote version that it changes, for each id: the version that an
|
|
1277
|
+
* older queued operation of the id writes, or else the known remote version. A storage that
|
|
1278
|
+
* loads the queue after a restart takes the version of the first operation of each id.
|
|
1279
|
+
*/
|
|
1280
|
+
private attachBase(op: QueuedOperation): void {
|
|
1281
|
+
if (!this.options.versionOf) return;
|
|
1282
|
+
const base: Array<[string, string | null]> = [];
|
|
1283
|
+
for (const id of idsOf(op)) {
|
|
1284
|
+
if (id === "*") continue;
|
|
1285
|
+
const version = this.expectedBase(id);
|
|
1286
|
+
if (version !== undefined) base.push([id, version]);
|
|
1287
|
+
}
|
|
1288
|
+
if (base.length > 0) op.base = base;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** The remote version of an id after the queued operations, or undefined when it is not known. */
|
|
1292
|
+
private expectedBase(id: string): string | null | undefined {
|
|
1293
|
+
for (let index = this.offlineOps.length - 1; index >= 0; index--) {
|
|
1294
|
+
const op = this.offlineOps[index]!;
|
|
1295
|
+
const data = op.data as { id?: string; value?: T; ids?: string[]; items?: Array<[string, T]> };
|
|
1296
|
+
switch (op.type) {
|
|
1297
|
+
case "set":
|
|
1298
|
+
if (data.id === id) return this.versionOfItem(data.value as T);
|
|
1299
|
+
break;
|
|
1300
|
+
case "delete":
|
|
1301
|
+
if (data.id === id) return null;
|
|
1302
|
+
break;
|
|
1303
|
+
case "setBatch": {
|
|
1304
|
+
const item = (data.items ?? []).findLast(([itemId]) => itemId === id);
|
|
1305
|
+
if (item) return this.versionOfItem(item[1]);
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
case "deleteBatch":
|
|
1309
|
+
if ((data.ids ?? []).includes(id)) return null;
|
|
1310
|
+
break;
|
|
1311
|
+
case "clear":
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return this.bases.get(id);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
552
1318
|
private hasPendingOp(id: string): boolean {
|
|
553
1319
|
return this.offlineOps.some((op) => {
|
|
554
1320
|
const ids = idsOf(op);
|
|
@@ -585,26 +1351,158 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
585
1351
|
}
|
|
586
1352
|
}
|
|
587
1353
|
|
|
1354
|
+
//
|
|
1355
|
+
// ═══ The queue and its store ═══
|
|
1356
|
+
//
|
|
1357
|
+
|
|
588
1358
|
/**
|
|
589
|
-
*
|
|
1359
|
+
* This method puts an operation in the queue, then in the queue store. The write ends only after the store
|
|
1360
|
+
* kept it. When the store fails, the operation leaves the queue, the local change is undone,
|
|
1361
|
+
* and the write throws `QueueStoreError`.
|
|
1362
|
+
*
|
|
1363
|
+
* A set or a delete of one id replaces the queued set or delete of that id, when no later
|
|
1364
|
+
* operation touches the id: a second try of a write does not queue it two times.
|
|
590
1365
|
*/
|
|
591
|
-
private
|
|
592
|
-
|
|
593
|
-
|
|
1366
|
+
private async enqueue(op: QueuedOperation, undo: () => Promise<void>): Promise<void> {
|
|
1367
|
+
const index = this.replaceableIndex(op);
|
|
1368
|
+
let rollback: () => void;
|
|
1369
|
+
if (index >= 0) {
|
|
1370
|
+
const previous = this.offlineOps[index]!;
|
|
1371
|
+
// The new operation changes the same remote version as the operation that it replaces
|
|
1372
|
+
if (previous.base) op.base = previous.base;
|
|
1373
|
+
else delete op.base;
|
|
1374
|
+
this.offlineOps[index] = op;
|
|
1375
|
+
rollback = () => {
|
|
1376
|
+
const at = this.offlineOps.indexOf(op);
|
|
1377
|
+
if (at >= 0) this.offlineOps[at] = previous;
|
|
1378
|
+
};
|
|
1379
|
+
} else {
|
|
1380
|
+
try {
|
|
1381
|
+
this.ensureQueueRoom(op);
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
await undo();
|
|
1384
|
+
throw error;
|
|
1385
|
+
}
|
|
1386
|
+
this.offlineOps.push(op);
|
|
1387
|
+
rollback = () => {
|
|
1388
|
+
const at = this.offlineOps.indexOf(op);
|
|
1389
|
+
if (at >= 0) this.offlineOps.splice(at, 1);
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
this.unpersisted.add(op);
|
|
1393
|
+
try {
|
|
1394
|
+
await this.persist(op, index < 0, rollback);
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
await undo();
|
|
1397
|
+
throw error;
|
|
594
1398
|
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* This method writes one operation to the queue store: an append when the store holds each operation
|
|
1403
|
+
* before it, or else the whole queue. On a failure, `rollback` removes the operation from the
|
|
1404
|
+
* queue before the next call of the store.
|
|
1405
|
+
*/
|
|
1406
|
+
private persist(op: QueuedOperation, appended: boolean, rollback: () => void): Promise<void> {
|
|
1407
|
+
const run = this.storeChain.then(async () => {
|
|
1408
|
+
// An earlier write of the whole queue kept it already
|
|
1409
|
+
if (!this.unpersisted.has(op)) return;
|
|
1410
|
+
const at = this.offlineOps.indexOf(op);
|
|
1411
|
+
const canAppend =
|
|
1412
|
+
appended && !this.storeDirty && at >= 0 && this.offlineOps.slice(0, at).every((other) => !this.unpersisted.has(other));
|
|
1413
|
+
const snapshot = this.offlineOps.slice();
|
|
1414
|
+
try {
|
|
1415
|
+
if (canAppend) {
|
|
1416
|
+
await this.queueStore.append(op);
|
|
1417
|
+
this.unpersisted.delete(op);
|
|
1418
|
+
} else {
|
|
1419
|
+
await this.queueStore.replace(snapshot);
|
|
1420
|
+
for (const kept of snapshot) this.unpersisted.delete(kept);
|
|
1421
|
+
this.unpersisted.delete(op);
|
|
1422
|
+
this.storeDirty = false;
|
|
1423
|
+
}
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
this.storeDirty = true;
|
|
1426
|
+
this.unpersisted.delete(op);
|
|
1427
|
+
rollback();
|
|
1428
|
+
throw new QueueStoreError("write", error);
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
this.storeChain = run.catch(() => undefined);
|
|
1432
|
+
return run;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** This method writes the whole queue to the queue store when the store can differ from the queue. */
|
|
1436
|
+
private persistAll(): Promise<void> {
|
|
1437
|
+
const run = this.storeChain.then(async () => {
|
|
1438
|
+
if (!this.storeDirty && this.unpersisted.size === 0) return;
|
|
1439
|
+
const snapshot = this.offlineOps.slice();
|
|
1440
|
+
try {
|
|
1441
|
+
if (snapshot.length === 0) await this.queueStore.clear();
|
|
1442
|
+
else await this.queueStore.replace(snapshot);
|
|
1443
|
+
for (const kept of snapshot) this.unpersisted.delete(kept);
|
|
1444
|
+
this.storeDirty = false;
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
this.storeDirty = true;
|
|
1447
|
+
throw new QueueStoreError("write", error);
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
this.storeChain = run.catch(() => undefined);
|
|
1451
|
+
return run;
|
|
1452
|
+
}
|
|
595
1453
|
|
|
596
|
-
|
|
1454
|
+
/**
|
|
1455
|
+
* Before a write goes to the remote directly, the queue store must not hold a sent operation:
|
|
1456
|
+
* after a restart, it would replace this newer write.
|
|
1457
|
+
*/
|
|
1458
|
+
private async ensureStoreClean(): Promise<void> {
|
|
1459
|
+
if (this.storeDirty) await this.persistAll();
|
|
1460
|
+
}
|
|
597
1461
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1462
|
+
/** The index of the queued operation that `op` replaces, or -1. */
|
|
1463
|
+
private replaceableIndex(op: QueuedOperation): number {
|
|
1464
|
+
if (op.type !== "set" && op.type !== "delete") return -1;
|
|
1465
|
+
const [id] = idsOf(op);
|
|
1466
|
+
if (id === undefined) return -1;
|
|
1467
|
+
for (let index = this.offlineOps.length - 1; index >= 0; index--) {
|
|
1468
|
+
const queued = this.offlineOps[index]!;
|
|
1469
|
+
const ids = idsOf(queued);
|
|
1470
|
+
if (!ids.includes(id) && !ids.includes("*")) continue;
|
|
1471
|
+
// pushQueue can be sending the first operation now: do not change it
|
|
1472
|
+
const sending = index === 0 && this.pushing;
|
|
1473
|
+
return !sending && (queued.type === "set" || queued.type === "delete") ? index : -1;
|
|
601
1474
|
}
|
|
1475
|
+
return -1;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** True when the queue can take the operation. */
|
|
1479
|
+
private hasQueueRoom(op: QueuedOperation): boolean {
|
|
1480
|
+
return this.replaceableIndex(op) >= 0 || this.offlineOps.length < this.options.maxOfflineOps;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** A full queue refuses the write. Before, it dropped the oldest operation (roadmap R2). */
|
|
1484
|
+
private ensureQueueRoom(op: QueuedOperation): void {
|
|
1485
|
+
if (!this.hasQueueRoom(op)) throw new QueueFullError(this.options.maxOfflineOps);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* In write-through mode, a timer sends the queue after a failure, and again after each failed
|
|
1490
|
+
* send, until the queue is empty.
|
|
1491
|
+
*/
|
|
1492
|
+
private scheduleDrain(): void {
|
|
1493
|
+
if (this.drainTimer || this.closed || this.mode !== "write-through") return;
|
|
1494
|
+
const delay = this.options.syncInterval > 0 ? this.options.syncInterval : 5000;
|
|
1495
|
+
this.drainTimer = setTimeout(() => {
|
|
1496
|
+
this.drainTimer = null;
|
|
1497
|
+
this.syncToRemote().catch(() => this.scheduleDrain());
|
|
1498
|
+
}, delay);
|
|
1499
|
+
(this.drainTimer as { unref?: () => void }).unref?.();
|
|
602
1500
|
}
|
|
603
1501
|
|
|
604
1502
|
/**
|
|
605
1503
|
* Execute a queued operation against remote storage.
|
|
606
1504
|
*/
|
|
607
|
-
private async executeOperation(op:
|
|
1505
|
+
private async executeOperation(op: QueuedOperation): Promise<void> {
|
|
608
1506
|
const data = op.data as any;
|
|
609
1507
|
|
|
610
1508
|
switch (op.type) {
|
|
@@ -630,36 +1528,6 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
630
1528
|
}
|
|
631
1529
|
}
|
|
632
1530
|
|
|
633
|
-
/**
|
|
634
|
-
* Handle remote operation failure.
|
|
635
|
-
*/
|
|
636
|
-
private handleRemoteFailure(operation: OfflineOperation["type"], data: unknown): void {
|
|
637
|
-
console.error(`Remote ${operation} failed, marking offline`);
|
|
638
|
-
this.isOnline = false;
|
|
639
|
-
|
|
640
|
-
// Queue operation if offline queue enabled
|
|
641
|
-
if (this.options.offlineQueue) {
|
|
642
|
-
this.queueOperation({
|
|
643
|
-
type: operation,
|
|
644
|
-
data,
|
|
645
|
-
timestamp: Date.now(),
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Start periodic background sync.
|
|
652
|
-
*/
|
|
653
|
-
private startPeriodicSync(): void {
|
|
654
|
-
this.syncTimer = setInterval(() => {
|
|
655
|
-
this.syncToRemote().catch((err) => {
|
|
656
|
-
console.error("Periodic sync failed:", err);
|
|
657
|
-
});
|
|
658
|
-
}, this.options.syncInterval);
|
|
659
|
-
// The timer must not keep the process alive (deep dive DATA-9)
|
|
660
|
-
(this.syncTimer as { unref?: () => void }).unref?.();
|
|
661
|
-
}
|
|
662
|
-
|
|
663
1531
|
/**
|
|
664
1532
|
* Get current online status.
|
|
665
1533
|
*/
|