@procedures/store 0.7.0 → 0.8.1
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 +77 -0
- package/README.md +98 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/indexeddb/sync-state-store.d.ts +40 -0
- package/dist/indexeddb/sync-state-store.d.ts.map +1 -0
- package/dist/indexeddb/sync-state-store.js +94 -0
- package/dist/indexeddb/sync-state-store.js.map +1 -0
- package/dist/indexeddb.d.ts +7 -2
- package/dist/indexeddb.d.ts.map +1 -1
- package/dist/indexeddb.js +6 -2
- package/dist/indexeddb.js.map +1 -1
- package/dist/storage/api.d.ts +94 -11
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +177 -24
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/feed.d.ts +133 -0
- package/dist/storage/feed.d.ts.map +1 -0
- package/dist/storage/feed.js +159 -0
- package/dist/storage/feed.js.map +1 -0
- package/dist/storage/file-queue.d.ts.map +1 -1
- package/dist/storage/file-queue.js +2 -1
- package/dist/storage/file-queue.js.map +1 -1
- package/dist/storage/hybrid.d.ts +415 -20
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +1405 -196
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/index.d.ts +6 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +6 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/sync-state.d.ts +56 -0
- package/dist/storage/sync-state.d.ts.map +1 -0
- package/dist/storage/sync-state.js +58 -0
- package/dist/storage/sync-state.js.map +1 -0
- package/dist/storage/synced-registry.d.ts +22 -4
- package/dist/storage/synced-registry.d.ts.map +1 -1
- package/dist/storage/synced-registry.js +63 -31
- package/dist/storage/synced-registry.js.map +1 -1
- package/dist/storage/types.d.ts +22 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/indexeddb/sync-state-store.ts +101 -0
- package/src/indexeddb.ts +7 -2
- package/src/storage/api.ts +240 -28
- package/src/storage/feed.ts +251 -0
- package/src/storage/file-queue.ts +2 -1
- package/src/storage/hybrid.ts +1526 -203
- package/src/storage/index.ts +17 -0
- package/src/storage/sync-state.ts +89 -0
- package/src/storage/synced-registry.ts +76 -31
- package/src/storage/types.ts +25 -1
package/dist/storage/hybrid.js
CHANGED
|
@@ -19,12 +19,124 @@
|
|
|
19
19
|
* - Each write takes a copy of the value (`structuredClone`), for the local copy, the queue and the
|
|
20
20
|
* remote. The reads give the copies of the local copy, or of the remote in `remote-only` mode.
|
|
21
21
|
* - A full sync reads the local ids with `getIds()`, so it does not copy each local item.
|
|
22
|
+
* - A pull reads a local item only when the result depends on it. With `versionOf`, the storage
|
|
23
|
+
* knows the version of each local item that it wrote or read.
|
|
24
|
+
* - The timers run on the clock of the options. After a failed push, pull or send of the queue,
|
|
25
|
+
* the next delay grows with `backoffDelay` of `@mark1russell7/service/backoff` (roadmap CLI-13).
|
|
26
|
+
* - `getStatus()` and `onStatus()` give the state of the sync and its errors. Before, the errors
|
|
27
|
+
* went to the console.
|
|
28
|
+
* - `query()` runs a selector on the local copy, or on the remote in `remote-only` mode.
|
|
22
29
|
* - After `close()`, each operation rejects, and a second `close()` does nothing.
|
|
30
|
+
*
|
|
31
|
+
* The sync engine of roadmap CLI-21 (decision D9: a SignalDB collection is one strategy of the local copy):
|
|
32
|
+
* - `observe()` gives the batches of the changes of the storage, with their origins.
|
|
33
|
+
* - `capture: "observe"` queues the changes that the application writes to the local copy itself.
|
|
34
|
+
* - `syncState` keeps the cursor and the base versions after a reload.
|
|
35
|
+
* - `feed` pushes the remote deltas. A delta whose `since` is the cursor applies without a read.
|
|
36
|
+
* - A pull never erases a delta that came during it, and the echo of an older own write never
|
|
37
|
+
* writes over a newer local write.
|
|
38
|
+
* - The syncs of one storage run one at a time, in their order: the deltas and the hints of the
|
|
39
|
+
* feed, the pulls, the pushes, the reads after an own write, and the mode switches. Thus a
|
|
40
|
+
* remote delta and a full sync never run at the same time (Graph R18).
|
|
23
41
|
*/
|
|
24
|
-
import { InMemoryStorage } from "@procedures/collections";
|
|
25
|
-
import {
|
|
42
|
+
import { InMemoryStorage, StorageListeners } from "@procedures/collections";
|
|
43
|
+
import { queryEntries, queryOptionsProblems, selectorProblems, } from "@procedures/client/query";
|
|
44
|
+
import { optionalMs, realClock } from "@procedures/client";
|
|
45
|
+
import { backoffDelay } from "@mark1russell7/service/backoff";
|
|
26
46
|
import { MemoryQueueStore, idsOf, withoutId } from "./queue.js";
|
|
47
|
+
import { isSyncState } from "./sync-state.js";
|
|
27
48
|
const MODES = new Set(["local-only", "write-through", "write-back", "remote-only"]);
|
|
49
|
+
/**
|
|
50
|
+
* A timer of a `HybridStorage` on a clock. It runs `work` after its interval. After a failed run,
|
|
51
|
+
* the next delay grows with `backoffDelay` (roadmap CLI-13). The timer does not keep a Node
|
|
52
|
+
* process alive.
|
|
53
|
+
*/
|
|
54
|
+
class SyncTimer {
|
|
55
|
+
options;
|
|
56
|
+
cancel;
|
|
57
|
+
active = false;
|
|
58
|
+
/** True while `work` runs */
|
|
59
|
+
running = false;
|
|
60
|
+
/** True when `start()` came during a run: the timer waits again after the run */
|
|
61
|
+
again = false;
|
|
62
|
+
failures = 0;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.options = options;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* This method starts the timer. A timer that waits does not start again. A start during a run
|
|
68
|
+
* makes the timer wait again after the run, also when it does not repeat.
|
|
69
|
+
*/
|
|
70
|
+
start() {
|
|
71
|
+
if (this.running) {
|
|
72
|
+
this.again = true;
|
|
73
|
+
if (this.active)
|
|
74
|
+
return;
|
|
75
|
+
this.active = true;
|
|
76
|
+
this.failures = 0;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (this.active)
|
|
80
|
+
return;
|
|
81
|
+
this.active = true;
|
|
82
|
+
this.failures = 0;
|
|
83
|
+
this.arm();
|
|
84
|
+
}
|
|
85
|
+
/** This method stops the timer. A run that is on its way ends, and no next run starts. */
|
|
86
|
+
stop() {
|
|
87
|
+
this.active = false;
|
|
88
|
+
this.again = false;
|
|
89
|
+
this.cancel?.();
|
|
90
|
+
this.cancel = undefined;
|
|
91
|
+
}
|
|
92
|
+
/** The delay of the next run: the interval, or the backoff after failures. */
|
|
93
|
+
delay() {
|
|
94
|
+
const { interval, factor, maxDelay, jitter, random } = this.options;
|
|
95
|
+
if (this.failures === 0)
|
|
96
|
+
return interval;
|
|
97
|
+
return backoffDelay({
|
|
98
|
+
base: interval,
|
|
99
|
+
factor,
|
|
100
|
+
cap: Math.max(maxDelay, interval),
|
|
101
|
+
jitter: jitter > 0 ? { kind: "subtract", fraction: jitter } : { kind: "none" },
|
|
102
|
+
}, this.failures + 1, random);
|
|
103
|
+
}
|
|
104
|
+
arm() {
|
|
105
|
+
const cancel = this.options.clock.after(this.delay(), () => {
|
|
106
|
+
this.cancel = undefined;
|
|
107
|
+
void this.run();
|
|
108
|
+
});
|
|
109
|
+
// The timer must not keep the process alive (deep dive DATA-9)
|
|
110
|
+
cancel.unref?.();
|
|
111
|
+
this.cancel = cancel;
|
|
112
|
+
}
|
|
113
|
+
async run() {
|
|
114
|
+
if (!this.active)
|
|
115
|
+
return;
|
|
116
|
+
this.running = true;
|
|
117
|
+
let failed = false;
|
|
118
|
+
try {
|
|
119
|
+
await this.options.work();
|
|
120
|
+
this.failures = 0;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// The work reports its error in the status of the storage
|
|
124
|
+
failed = true;
|
|
125
|
+
this.failures++;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
this.running = false;
|
|
129
|
+
}
|
|
130
|
+
if (!this.active)
|
|
131
|
+
return;
|
|
132
|
+
const again = this.again;
|
|
133
|
+
this.again = false;
|
|
134
|
+
if (failed || again || this.options.repeat)
|
|
135
|
+
this.arm();
|
|
136
|
+
else
|
|
137
|
+
this.active = false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
28
140
|
/**
|
|
29
141
|
* The remote did not take a write of the write-through mode. The write is in the local cache and
|
|
30
142
|
* in the queue, and the storage sends it later (roadmap R2). `cause` is the error of the remote.
|
|
@@ -92,6 +204,17 @@ export class ModeSwitchError extends Error {
|
|
|
92
204
|
this.to = to;
|
|
93
205
|
}
|
|
94
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* The sync state store failed (roadmap CLI-21). The storage goes on: after a failed load, the first
|
|
209
|
+
* pull reads all items, and after a failed write, the next write gives the whole state.
|
|
210
|
+
*/
|
|
211
|
+
export class SyncStateError extends Error {
|
|
212
|
+
name = "SyncStateError";
|
|
213
|
+
code = "SYNC_STATE";
|
|
214
|
+
constructor(action, cause) {
|
|
215
|
+
super(`The sync state store could not ${action === "load" ? "load the state" : "keep the state"}`, { cause });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
95
218
|
const defaultIsUnavailable = (error) => {
|
|
96
219
|
if (typeof error !== "object" || error === null)
|
|
97
220
|
return true;
|
|
@@ -110,6 +233,184 @@ function checkMode(mode) {
|
|
|
110
233
|
}
|
|
111
234
|
return mode;
|
|
112
235
|
}
|
|
236
|
+
/** A change with an origin, when there is one. */
|
|
237
|
+
function withOrigin(change, origin) {
|
|
238
|
+
return origin === undefined ? change : { ...change, origin };
|
|
239
|
+
}
|
|
240
|
+
/** A `set` change with a copy of the value: an observer cannot change the stored item. */
|
|
241
|
+
function setChange(id, value, origin) {
|
|
242
|
+
return withOrigin({ type: "set", id, value: structuredClone(value) }, origin);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The queued operations of a batch of changes of the local copy (roadmap CLI-21). The sets in a
|
|
246
|
+
* row go in one operation, and the deletes in a row too. Each value is a copy.
|
|
247
|
+
*/
|
|
248
|
+
function operationsOf(changes) {
|
|
249
|
+
const ops = [];
|
|
250
|
+
const timestamp = Date.now();
|
|
251
|
+
let sets = [];
|
|
252
|
+
let deletes = [];
|
|
253
|
+
const flush = () => {
|
|
254
|
+
if (sets.length === 1)
|
|
255
|
+
ops.push({ type: "set", data: { id: sets[0][0], value: sets[0][1] }, timestamp });
|
|
256
|
+
else if (sets.length > 1)
|
|
257
|
+
ops.push({ type: "setBatch", data: { items: sets }, timestamp });
|
|
258
|
+
if (deletes.length === 1)
|
|
259
|
+
ops.push({ type: "delete", data: { id: deletes[0] }, timestamp });
|
|
260
|
+
else if (deletes.length > 1)
|
|
261
|
+
ops.push({ type: "deleteBatch", data: { ids: deletes }, timestamp });
|
|
262
|
+
sets = [];
|
|
263
|
+
deletes = [];
|
|
264
|
+
};
|
|
265
|
+
for (const change of changes) {
|
|
266
|
+
if (change.type === "set") {
|
|
267
|
+
if (deletes.length > 0)
|
|
268
|
+
flush();
|
|
269
|
+
sets.push([change.id, structuredClone(change.value)]);
|
|
270
|
+
}
|
|
271
|
+
else if (change.type === "delete") {
|
|
272
|
+
if (sets.length > 0)
|
|
273
|
+
flush();
|
|
274
|
+
deletes.push(change.id);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
flush();
|
|
278
|
+
ops.push({ type: "clear", data: {}, timestamp });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
flush();
|
|
282
|
+
return ops;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The batch of a write of the `remote-only` mode. A delete gives a change only for an id that
|
|
286
|
+
* existed: `existing` has the ids of a `deleteBatch`, or tells if a `clear` found items.
|
|
287
|
+
*/
|
|
288
|
+
function remoteChangesOf(op, result, existing, origin) {
|
|
289
|
+
const data = op.data;
|
|
290
|
+
switch (op.type) {
|
|
291
|
+
case "set":
|
|
292
|
+
return [setChange(data.id, data.value, origin)];
|
|
293
|
+
case "setBatch":
|
|
294
|
+
return data.items.map(([id, value]) => setChange(id, value, origin));
|
|
295
|
+
case "delete":
|
|
296
|
+
return result === true ? [withOrigin({ type: "delete", id: data.id }, origin)] : [];
|
|
297
|
+
case "deleteBatch":
|
|
298
|
+
return [...new Set(data.ids)].filter((id) => existing instanceof Set && existing.has(id)).map((id) => withOrigin({ type: "delete", id }, origin));
|
|
299
|
+
case "clear":
|
|
300
|
+
return existing === true ? [withOrigin({ type: "clear" }, origin)] : [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The versions of the own writes of a `HybridStorage` that no remote change echoed yet (roadmap
|
|
305
|
+
* CLI-21, after the EchoLedger of Graph).
|
|
306
|
+
*
|
|
307
|
+
* - The ledger keeps the versions of each id in the order of the writes. It records a write when
|
|
308
|
+
* the local copy takes it, because a stale echo can come before the push.
|
|
309
|
+
* - A remote change with a version of the ledger is an echo: that version and each older version
|
|
310
|
+
* leave the ledger. When a newer version stays, the echo is stale: the newer echo comes later.
|
|
311
|
+
* - A remote change with another version is a write of another writer.
|
|
312
|
+
* - A version expires `window` milliseconds after its write, or after its send to the remote. Thus
|
|
313
|
+
* a write whose echo never comes does not hold an id long.
|
|
314
|
+
*/
|
|
315
|
+
class EchoLedger {
|
|
316
|
+
window;
|
|
317
|
+
now;
|
|
318
|
+
maxPerId;
|
|
319
|
+
maxIds;
|
|
320
|
+
/** The versions of each id, the oldest first. The ids are in the order of their last write. */
|
|
321
|
+
entries = new Map();
|
|
322
|
+
constructor(window, now, maxPerId = 20, maxIds = 10_000) {
|
|
323
|
+
this.window = window;
|
|
324
|
+
this.now = now;
|
|
325
|
+
this.maxPerId = maxPerId;
|
|
326
|
+
this.maxIds = maxIds;
|
|
327
|
+
}
|
|
328
|
+
/** This method records the version that an own write of an id gives. `null`: a delete. */
|
|
329
|
+
wrote(id, version) {
|
|
330
|
+
const now = this.now();
|
|
331
|
+
const list = this.live(id, now);
|
|
332
|
+
list.push({ version, at: now });
|
|
333
|
+
if (list.length > this.maxPerId)
|
|
334
|
+
list.shift();
|
|
335
|
+
// The id moves to the end: the ids stay in the order of their last write
|
|
336
|
+
this.entries.delete(id);
|
|
337
|
+
this.entries.set(id, list);
|
|
338
|
+
this.prune(now);
|
|
339
|
+
}
|
|
340
|
+
/** This method records a delete of each id of the ledger: an own clear. */
|
|
341
|
+
cleared() {
|
|
342
|
+
for (const id of [...this.entries.keys()])
|
|
343
|
+
this.wrote(id, null);
|
|
344
|
+
}
|
|
345
|
+
/** This method starts the time of the versions of an id again: its write goes to the remote. */
|
|
346
|
+
sent(id) {
|
|
347
|
+
const list = this.entries.get(id);
|
|
348
|
+
if (!list)
|
|
349
|
+
return;
|
|
350
|
+
const now = this.now();
|
|
351
|
+
for (const entry of list)
|
|
352
|
+
entry.at = now;
|
|
353
|
+
this.entries.delete(id);
|
|
354
|
+
this.entries.set(id, list);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* True when a remote version of an id is a stale echo: a version of the ledger, with a newer
|
|
358
|
+
* version after it. The echo takes its version and each older version out of the ledger.
|
|
359
|
+
*/
|
|
360
|
+
isStale(id, version) {
|
|
361
|
+
const list = this.live(id, this.now());
|
|
362
|
+
const at = list.findIndex((entry) => entry.version === version);
|
|
363
|
+
if (at < 0)
|
|
364
|
+
return false;
|
|
365
|
+
list.splice(0, at + 1);
|
|
366
|
+
if (list.length > 0)
|
|
367
|
+
return true;
|
|
368
|
+
this.entries.delete(id);
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
/** The versions of an id that did not expire. */
|
|
372
|
+
live(id, now) {
|
|
373
|
+
const list = this.entries.get(id);
|
|
374
|
+
if (!list)
|
|
375
|
+
return [];
|
|
376
|
+
const kept = list.filter((entry) => now - entry.at < this.window);
|
|
377
|
+
if (kept.length === 0)
|
|
378
|
+
this.entries.delete(id);
|
|
379
|
+
else
|
|
380
|
+
this.entries.set(id, kept);
|
|
381
|
+
return kept;
|
|
382
|
+
}
|
|
383
|
+
/** This method drops the ids whose versions expired, and the oldest ids above the limit. */
|
|
384
|
+
prune(now) {
|
|
385
|
+
for (const [id, list] of this.entries) {
|
|
386
|
+
if (this.entries.size <= this.maxIds && now - list[list.length - 1].at < this.window)
|
|
387
|
+
break;
|
|
388
|
+
this.entries.delete(id);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The base versions of a `HybridStorage`: a map that keeps the ids that changed since the last
|
|
394
|
+
* write of the sync state (roadmap CLI-21). Without a sync state store, it keeps no ids.
|
|
395
|
+
*/
|
|
396
|
+
class BaseVersions extends Map {
|
|
397
|
+
/** The ids that changed since the last write of the sync state */
|
|
398
|
+
changed = new Set();
|
|
399
|
+
/** True when the storage has a sync state store */
|
|
400
|
+
tracked = false;
|
|
401
|
+
set(id, version) {
|
|
402
|
+
super.set(id, version);
|
|
403
|
+
if (this.tracked)
|
|
404
|
+
this.changed.add(id);
|
|
405
|
+
return this;
|
|
406
|
+
}
|
|
407
|
+
delete(id) {
|
|
408
|
+
const had = super.delete(id);
|
|
409
|
+
if (had && this.tracked)
|
|
410
|
+
this.changed.add(id);
|
|
411
|
+
return had;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
113
414
|
/**
|
|
114
415
|
* Hybrid storage combining local cache with remote persistence.
|
|
115
416
|
*
|
|
@@ -151,14 +452,25 @@ export class HybridStorage {
|
|
|
151
452
|
storeDirty = false;
|
|
152
453
|
/** The calls of the queue store, one at a time */
|
|
153
454
|
storeChain = Promise.resolve();
|
|
154
|
-
/** The push timer of the write-back mode */
|
|
155
|
-
|
|
156
|
-
/** The periodic pull */
|
|
157
|
-
pullTimer
|
|
455
|
+
/** The push timer of the write-back mode, or undefined without `syncInterval` */
|
|
456
|
+
pushTimer;
|
|
457
|
+
/** The periodic pull, or undefined without `pullInterval` */
|
|
458
|
+
pullTimer;
|
|
158
459
|
/** True while pushQueue sends the queue: the first operation can be on its way */
|
|
159
460
|
pushing = false;
|
|
160
461
|
/** The timer that sends the queue of the write-through mode after a failure */
|
|
161
|
-
drainTimer
|
|
462
|
+
drainTimer;
|
|
463
|
+
/** The failures that a later success did not clear, the newest last */
|
|
464
|
+
failures = new Map();
|
|
465
|
+
/** The number of pushes and pulls that run */
|
|
466
|
+
syncs = 0;
|
|
467
|
+
/** The time of the last pull that succeeded (`Date.now()`) */
|
|
468
|
+
lastSync;
|
|
469
|
+
/** The listeners of `onStatus()`, and the status that they got last */
|
|
470
|
+
statusListeners;
|
|
471
|
+
lastStatus;
|
|
472
|
+
/** The query policy of a call without a policy */
|
|
473
|
+
queryPolicy;
|
|
162
474
|
/** The load of the queue. The writes wait for it. */
|
|
163
475
|
loaded;
|
|
164
476
|
/** The load and the first sync. The reads wait for it, so a read never sees an empty cache by mistake. */
|
|
@@ -185,7 +497,57 @@ export class HybridStorage {
|
|
|
185
497
|
/** True when the remote has `getChangesSince` but its server does not know `changes` */
|
|
186
498
|
changesUnsupported = false;
|
|
187
499
|
/** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
|
|
188
|
-
bases = new
|
|
500
|
+
bases = new BaseVersions();
|
|
501
|
+
/**
|
|
502
|
+
* With `versionOf`: the version of each local item that the storage wrote or read. A pull does
|
|
503
|
+
* not read an item whose version it knows. The version of an id that is not here is unknown.
|
|
504
|
+
*/
|
|
505
|
+
localVersions = new Map();
|
|
506
|
+
/** The number of the last local write of each id that runs: only that write records a version */
|
|
507
|
+
localWrites = new Map();
|
|
508
|
+
localWriteCount = 0;
|
|
509
|
+
/** The number of clears of the local copy: a clear cancels the records of the writes that run */
|
|
510
|
+
localClears = 0;
|
|
511
|
+
/** The observers of `observe()` */
|
|
512
|
+
observers;
|
|
513
|
+
/** The tag of the writes of this storage, and the write options that give it */
|
|
514
|
+
origin;
|
|
515
|
+
own;
|
|
516
|
+
/** The origin of the changes that the sync makes in the local copy */
|
|
517
|
+
syncOrigin;
|
|
518
|
+
/** The push feed, the stop of its subscription, and the number of the subscription */
|
|
519
|
+
feed;
|
|
520
|
+
feedStop;
|
|
521
|
+
feedToken = 0;
|
|
522
|
+
/** True while a pull that a gap asked for waits to start */
|
|
523
|
+
gapPull = false;
|
|
524
|
+
/** The stop of the capture of the local copy */
|
|
525
|
+
stopCapture;
|
|
526
|
+
/** The captured changes that came before the end of the load. Undefined after the load. */
|
|
527
|
+
captured = [];
|
|
528
|
+
/** With `versionOf`: the versions of the own writes that wait for their echo */
|
|
529
|
+
echoes;
|
|
530
|
+
/** The store of the sync state, and the state of its writes */
|
|
531
|
+
stateStore;
|
|
532
|
+
stateReset = true;
|
|
533
|
+
savedCursor;
|
|
534
|
+
stateSaving;
|
|
535
|
+
stateAgain = false;
|
|
536
|
+
/** True when the application gave the local copy */
|
|
537
|
+
localGiven;
|
|
538
|
+
/**
|
|
539
|
+
* The own writes of the `write-through` mode that wait for the remote, for each id: their number
|
|
540
|
+
* and the newest value (`undefined` for a delete). A pull does not write over these ids.
|
|
541
|
+
*/
|
|
542
|
+
inFlight = new Map();
|
|
543
|
+
/** The number of own clears that wait for the remote: each id waits */
|
|
544
|
+
inFlightClears = 0;
|
|
545
|
+
/** The remote values that a pull did not apply because an own write of the id waited: `undefined` for a delete */
|
|
546
|
+
deferred = new Map();
|
|
547
|
+
/** The number of the last local write, of the last write of each id, and of the last clear */
|
|
548
|
+
writeSeq = 0;
|
|
549
|
+
lastWrite = new Map();
|
|
550
|
+
lastClear = 0;
|
|
189
551
|
stats = {
|
|
190
552
|
cacheHits: 0,
|
|
191
553
|
cacheMisses: 0,
|
|
@@ -209,12 +571,533 @@ export class HybridStorage {
|
|
|
209
571
|
offlineQueue: options.offlineQueue ?? true,
|
|
210
572
|
isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
|
|
211
573
|
};
|
|
574
|
+
this.statusListeners = new StorageListeners(options.onListenerError);
|
|
575
|
+
this.queryPolicy = options.queryPolicy;
|
|
576
|
+
this.origin = options.origin ?? `hybrid-${Math.random().toString(36).slice(2, 10)}`;
|
|
577
|
+
this.own = { origin: this.origin };
|
|
578
|
+
this.syncOrigin = options.syncOrigin ?? "sync";
|
|
579
|
+
this.observers = new StorageListeners(options.onListenerError);
|
|
580
|
+
this.feed = options.feed;
|
|
581
|
+
this.stateStore = options.syncState;
|
|
582
|
+
this.bases.tracked = this.stateStore !== undefined;
|
|
583
|
+
this.localGiven = options.local !== undefined;
|
|
584
|
+
// The timers on the clock, with the backoff after a failure (roadmap CLI-13)
|
|
585
|
+
const factor = options.backoff?.factor ?? 2;
|
|
586
|
+
if (!(Number.isFinite(factor) && factor >= 1))
|
|
587
|
+
throw new RangeError(`backoff.factor must be a number of 1 or more, not ${factor}`);
|
|
588
|
+
const jitter = options.backoff?.jitter ?? 0.5;
|
|
589
|
+
if (!(jitter >= 0 && jitter <= 1))
|
|
590
|
+
throw new RangeError(`backoff.jitter must be from 0 to 1, not ${jitter}`);
|
|
591
|
+
const timing = {
|
|
592
|
+
clock: options.clock ?? realClock,
|
|
593
|
+
factor,
|
|
594
|
+
maxDelay: optionalMs(options.backoff?.maxDelay, "backoff.maxDelay") ?? 300_000,
|
|
595
|
+
jitter,
|
|
596
|
+
random: options.random ?? Math.random,
|
|
597
|
+
};
|
|
598
|
+
this.pushTimer =
|
|
599
|
+
this.options.syncInterval > 0
|
|
600
|
+
? new SyncTimer({ ...timing, interval: this.options.syncInterval, repeat: true, work: () => (this.closed ? Promise.resolve() : this.push()) })
|
|
601
|
+
: undefined;
|
|
602
|
+
this.pullTimer =
|
|
603
|
+
this.options.pullInterval > 0
|
|
604
|
+
? new SyncTimer({ ...timing, interval: this.options.pullInterval, repeat: true, work: () => (this.closed ? Promise.resolve() : this.pull()) })
|
|
605
|
+
: undefined;
|
|
606
|
+
this.drainTimer = new SyncTimer({
|
|
607
|
+
...timing,
|
|
608
|
+
interval: this.options.syncInterval > 0 ? this.options.syncInterval : 5000,
|
|
609
|
+
repeat: false,
|
|
610
|
+
work: () => (this.closed ? Promise.resolve() : this.push()),
|
|
611
|
+
});
|
|
612
|
+
// The echoes of the own writes: by version, or by the JSON of the value with a feed (roadmap CLI-21)
|
|
613
|
+
this.echoes = this.options.versionOf || this.feed
|
|
614
|
+
? new EchoLedger(optionalMs(options.echoWindow, "echoWindow") ?? 30_000, () => timing.clock.now())
|
|
615
|
+
: undefined;
|
|
616
|
+
const capture = options.capture ?? "none";
|
|
617
|
+
if (capture !== "none" && capture !== "observe")
|
|
618
|
+
throw new TypeError(`Unknown capture ${JSON.stringify(capture)}: use none or observe`);
|
|
619
|
+
if (capture === "observe") {
|
|
620
|
+
if (!this.local.observe)
|
|
621
|
+
throw new TypeError('capture: "observe" needs a local copy with observe()');
|
|
622
|
+
// The capture starts before the load: a change of the application during the load waits for it
|
|
623
|
+
this.stopCapture = this.local.observe((batch) => this.onLocalChanges(batch));
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
this.stopCapture = undefined;
|
|
627
|
+
}
|
|
212
628
|
// The queue first, then the first sync. The reads wait for both (roadmap R2, R28).
|
|
213
629
|
this.loaded = this.loadQueue();
|
|
214
630
|
this.ready = this.loaded.then(() => this.initialSync());
|
|
215
631
|
this.updateTimers();
|
|
216
632
|
}
|
|
217
633
|
//
|
|
634
|
+
// ═══ The status of the sync ═══
|
|
635
|
+
//
|
|
636
|
+
/**
|
|
637
|
+
* The state of the sync now (roadmap CLI-13).
|
|
638
|
+
*
|
|
639
|
+
* - `connected` is false after a remote call failed, until a remote call succeeds.
|
|
640
|
+
* - `pendingChanges` is the number of operations in the queue.
|
|
641
|
+
* - `syncing` is true while a push or a pull runs.
|
|
642
|
+
* - `lastSync` is the time (`Date.now()`) of the last pull that succeeded.
|
|
643
|
+
* - `error` is the newest failure of a load, a push, a pull or the queue store that a later
|
|
644
|
+
* success of the same work did not clear.
|
|
645
|
+
*/
|
|
646
|
+
getStatus() {
|
|
647
|
+
const error = [...this.failures.values()].at(-1);
|
|
648
|
+
return {
|
|
649
|
+
connected: this.isOnline,
|
|
650
|
+
lastSync: this.lastSync,
|
|
651
|
+
pendingChanges: this.offlineOps.length,
|
|
652
|
+
syncing: this.syncs > 0,
|
|
653
|
+
mode: this.mode,
|
|
654
|
+
...(error !== undefined && { error }),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* This method starts to send the status to a listener at each change: a failure, a sync that
|
|
659
|
+
* starts or ends, a change of the queue, of the connection or of the mode. A failure of a timer
|
|
660
|
+
* reaches the listeners here: the storage does not write to the console.
|
|
661
|
+
*
|
|
662
|
+
* @returns The function that stops the status. A second use of it does nothing.
|
|
663
|
+
*/
|
|
664
|
+
onStatus(listener) {
|
|
665
|
+
if (this.closed)
|
|
666
|
+
throw this.closedError();
|
|
667
|
+
return this.statusListeners.add(listener);
|
|
668
|
+
}
|
|
669
|
+
//
|
|
670
|
+
// ═══ The changes of the storage (roadmap CLI-21) ═══
|
|
671
|
+
//
|
|
672
|
+
/**
|
|
673
|
+
* This method starts to send the changes of the storage to an observer. Each write that changes
|
|
674
|
+
* the storage gives one batch, before the write resolves.
|
|
675
|
+
*
|
|
676
|
+
* - A write of this storage gives its batch with the origin of its options.
|
|
677
|
+
* - A pull, a delta of the feed, a fill after a read and a mode switch give their changes with
|
|
678
|
+
* `syncOrigin`.
|
|
679
|
+
* - With `capture: "observe"`, a write of the application to the local copy gives the batch of
|
|
680
|
+
* the local copy, with its origin.
|
|
681
|
+
* - In `remote-only` mode, the batches tell the writes of this storage to the remote.
|
|
682
|
+
*
|
|
683
|
+
* @returns The function that stops the batches. A second use of it does nothing.
|
|
684
|
+
* @throws Error after `close()`
|
|
685
|
+
*/
|
|
686
|
+
observe(observer) {
|
|
687
|
+
if (this.closed)
|
|
688
|
+
throw this.closedError();
|
|
689
|
+
return this.observers.add(observer);
|
|
690
|
+
}
|
|
691
|
+
/** This method sends a batch to the observers. */
|
|
692
|
+
emit(changes) {
|
|
693
|
+
if (changes.length > 0 && this.observers.size > 0)
|
|
694
|
+
this.observers.emit(changes);
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* This method takes a batch of the local copy. The changes whose origin is not the origin of this
|
|
698
|
+
* storage come from the application: the storage queues them, and its observers get them.
|
|
699
|
+
*/
|
|
700
|
+
onLocalChanges(batch) {
|
|
701
|
+
if (this.closed)
|
|
702
|
+
return;
|
|
703
|
+
const foreign = batch.filter((change) => change.origin !== this.origin);
|
|
704
|
+
if (foreign.length === 0)
|
|
705
|
+
return;
|
|
706
|
+
if (this.captured)
|
|
707
|
+
this.captured.push(...foreign);
|
|
708
|
+
else
|
|
709
|
+
this.captureChanges(foreign);
|
|
710
|
+
this.emit(foreign);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* This method queues the changes of the application to the local copy (roadmap CLI-21). The
|
|
714
|
+
* operations go in the queue at once, in the order of the changes. The queue store keeps them
|
|
715
|
+
* later. A failure of the store keeps them in memory, and the status shows it.
|
|
716
|
+
*/
|
|
717
|
+
captureChanges(changes) {
|
|
718
|
+
if (this.loadError || this.closed)
|
|
719
|
+
return;
|
|
720
|
+
for (const op of operationsOf(changes)) {
|
|
721
|
+
this.noteCaptured(op);
|
|
722
|
+
this.noteOwnWrite(op);
|
|
723
|
+
this.attachBase(op);
|
|
724
|
+
this.enqueueCaptured(op);
|
|
725
|
+
}
|
|
726
|
+
this.notify();
|
|
727
|
+
if (this.mode === "write-through" || this.mode === "remote-only") {
|
|
728
|
+
// A failed send waits for the timer of the queue
|
|
729
|
+
void this.push().catch(() => this.scheduleDrain());
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* This method records a captured change as a local write: a sync that runs keeps its value, and
|
|
734
|
+
* the version of the local item is known.
|
|
735
|
+
*/
|
|
736
|
+
noteCaptured(op) {
|
|
737
|
+
if (op.type === "clear") {
|
|
738
|
+
this.bumpClear();
|
|
739
|
+
this.localClears++;
|
|
740
|
+
this.localVersions.clear();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const data = op.data;
|
|
744
|
+
const sets = op.type === "set" ? [[data.id, data.value]] : op.type === "setBatch" ? data.items : [];
|
|
745
|
+
const deletes = op.type === "delete" ? [data.id] : op.type === "deleteBatch" ? data.ids : [];
|
|
746
|
+
for (const [id, value] of sets) {
|
|
747
|
+
this.bump(id);
|
|
748
|
+
// A local write of the storage that runs now makes the version unknown
|
|
749
|
+
const version = this.localWrites.has(id) ? undefined : this.recordedVersion(value);
|
|
750
|
+
this.localWrites.delete(id);
|
|
751
|
+
if (version === undefined)
|
|
752
|
+
this.localVersions.delete(id);
|
|
753
|
+
else
|
|
754
|
+
this.localVersions.set(id, version);
|
|
755
|
+
}
|
|
756
|
+
for (const id of deletes) {
|
|
757
|
+
this.bump(id);
|
|
758
|
+
this.localWrites.delete(id);
|
|
759
|
+
this.localVersions.delete(id);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/** This method puts a captured operation in the queue, and then in the queue store. */
|
|
763
|
+
enqueueCaptured(op) {
|
|
764
|
+
const index = this.replaceableIndex(op);
|
|
765
|
+
if (index >= 0) {
|
|
766
|
+
const previous = this.offlineOps[index];
|
|
767
|
+
if (previous.base)
|
|
768
|
+
op.base = previous.base;
|
|
769
|
+
else
|
|
770
|
+
delete op.base;
|
|
771
|
+
this.offlineOps[index] = op;
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
this.offlineOps.push(op);
|
|
775
|
+
}
|
|
776
|
+
this.unpersisted.add(op);
|
|
777
|
+
// The application made the change already: a failure of the store keeps the operation, and
|
|
778
|
+
// the next write of the store gives the whole queue
|
|
779
|
+
void this.persist(op, index < 0, () => undefined).catch(() => undefined);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* This method queues the changes that the application made during the load. The load wrote the
|
|
783
|
+
* loaded queue to the local copy, so the changes go to the local copy again, in order.
|
|
784
|
+
*/
|
|
785
|
+
async flushCaptured() {
|
|
786
|
+
const changes = [];
|
|
787
|
+
while (this.captured && this.captured.length > 0 && !this.loadError) {
|
|
788
|
+
const batch = this.captured.splice(0);
|
|
789
|
+
changes.push(...batch);
|
|
790
|
+
for (const op of operationsOf(batch))
|
|
791
|
+
await this.applyToLocal(op, this.syncOrigin);
|
|
792
|
+
}
|
|
793
|
+
this.captured = undefined;
|
|
794
|
+
if (changes.length > 0)
|
|
795
|
+
this.captureChanges(changes);
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* The mark of a value in the echo ledger: its version with `versionOf`, or else its JSON. A
|
|
799
|
+
* delete is `null` in the ledger: a tombstone, so an older echo does not bring the item back.
|
|
800
|
+
*/
|
|
801
|
+
echoPrint(value) {
|
|
802
|
+
if (this.options.versionOf)
|
|
803
|
+
return this.recordedVersion(value);
|
|
804
|
+
try {
|
|
805
|
+
return JSON.stringify(value);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/** This method records the versions of an own write, for its echo (roadmap CLI-21). */
|
|
812
|
+
noteOwnWrite(op) {
|
|
813
|
+
const echoes = this.echoes;
|
|
814
|
+
if (!echoes)
|
|
815
|
+
return;
|
|
816
|
+
const data = op.data;
|
|
817
|
+
const wrote = (id, value) => {
|
|
818
|
+
const print = this.echoPrint(value);
|
|
819
|
+
if (print !== undefined)
|
|
820
|
+
echoes.wrote(id, print);
|
|
821
|
+
};
|
|
822
|
+
switch (op.type) {
|
|
823
|
+
case "set":
|
|
824
|
+
wrote(data.id, data.value);
|
|
825
|
+
break;
|
|
826
|
+
case "setBatch":
|
|
827
|
+
for (const [id, value] of data.items)
|
|
828
|
+
wrote(id, value);
|
|
829
|
+
break;
|
|
830
|
+
case "delete":
|
|
831
|
+
echoes.wrote(data.id, null);
|
|
832
|
+
break;
|
|
833
|
+
case "deleteBatch":
|
|
834
|
+
for (const id of data.ids)
|
|
835
|
+
echoes.wrote(id, null);
|
|
836
|
+
break;
|
|
837
|
+
case "clear":
|
|
838
|
+
echoes.cleared();
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** This method starts the echo time of the ids of an operation again: it goes to the remote. */
|
|
843
|
+
echoSent(op) {
|
|
844
|
+
if (!this.echoes)
|
|
845
|
+
return;
|
|
846
|
+
for (const id of idsOf(op))
|
|
847
|
+
if (id !== "*")
|
|
848
|
+
this.echoes.sent(id);
|
|
849
|
+
}
|
|
850
|
+
//
|
|
851
|
+
// ═══ The push feed (roadmap CLI-21, CLI-15) ═══
|
|
852
|
+
//
|
|
853
|
+
/** This method subscribes to the feed in the modes with a local copy and a remote, and stops it in the others. */
|
|
854
|
+
updateFeed() {
|
|
855
|
+
const feed = this.feed;
|
|
856
|
+
if (!feed)
|
|
857
|
+
return;
|
|
858
|
+
const wanted = !this.closed && (this.mode === "write-through" || this.mode === "write-back");
|
|
859
|
+
if (wanted === (this.feedStop !== undefined))
|
|
860
|
+
return;
|
|
861
|
+
const token = ++this.feedToken;
|
|
862
|
+
if (!wanted) {
|
|
863
|
+
const stop = this.feedStop;
|
|
864
|
+
this.feedStop = undefined;
|
|
865
|
+
try {
|
|
866
|
+
stop();
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
// The subscription is gone
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const live = () => token === this.feedToken && !this.closed;
|
|
874
|
+
try {
|
|
875
|
+
const stop = feed.subscribe({
|
|
876
|
+
onChanges: (changes) => {
|
|
877
|
+
if (live())
|
|
878
|
+
this.onRemoteChanges(changes);
|
|
879
|
+
},
|
|
880
|
+
onHint: (hint) => {
|
|
881
|
+
if (live())
|
|
882
|
+
this.onRemoteHint(hint);
|
|
883
|
+
},
|
|
884
|
+
onGap: () => {
|
|
885
|
+
if (live())
|
|
886
|
+
this.requestPull();
|
|
887
|
+
},
|
|
888
|
+
onError: (error) => {
|
|
889
|
+
if (live())
|
|
890
|
+
this.fail("pull", error);
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
if (live())
|
|
894
|
+
this.feedStop = stop;
|
|
895
|
+
else
|
|
896
|
+
stop();
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
this.fail("pull", error);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* This method takes a delta of the feed. The delta applies after the syncs that run, so a pull
|
|
904
|
+
* that runs does not erase it. Each decision about it comes when it applies, at its turn in the
|
|
905
|
+
* chain of the syncs, from the local state of that time: the queue, the own writes that wait,
|
|
906
|
+
* the echo ledger and the versions. On arrival, the storage keeps only the count of the local
|
|
907
|
+
* writes: a local write after the arrival is newer than the delta, and it keeps its value.
|
|
908
|
+
*/
|
|
909
|
+
onRemoteChanges(changes) {
|
|
910
|
+
const mark = this.writeSeq;
|
|
911
|
+
void this.loaded
|
|
912
|
+
.then(() => this.serialize(() => this.track("pull", () => this.applyFeed(changes, mark))))
|
|
913
|
+
.catch(() => undefined);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* This method applies a delta of the feed: without a read when its `since` is the cursor. A
|
|
917
|
+
* delta of the version of the cursor changes nothing. Another delta is a gap: a pull.
|
|
918
|
+
*/
|
|
919
|
+
async applyFeed(changes, mark) {
|
|
920
|
+
if (this.loadError)
|
|
921
|
+
throw this.loadError;
|
|
922
|
+
if (this.closed || (this.mode !== "write-through" && this.mode !== "write-back"))
|
|
923
|
+
return;
|
|
924
|
+
if (changes.version === this.cursor)
|
|
925
|
+
return;
|
|
926
|
+
if (changes.since === undefined || changes.since !== this.cursor) {
|
|
927
|
+
await this.pullRemote(false);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
await this.applyChanges(changes, (id) => this.changedSince(mark, id));
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* This method takes a hint of the feed: the ids that changed, without their values (roadmap
|
|
934
|
+
* CLI-15). The hint applies after the syncs that run, against the cursor and the local state of
|
|
935
|
+
* that time, as a delta does.
|
|
936
|
+
*/
|
|
937
|
+
onRemoteHint(hint) {
|
|
938
|
+
const mark = this.writeSeq;
|
|
939
|
+
void this.loaded
|
|
940
|
+
.then(() => this.serialize(() => this.track("pull", () => this.applyHint(hint, mark))))
|
|
941
|
+
.catch(() => undefined);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* This method applies a hint. A hint of the version of the cursor changes nothing. A hint whose
|
|
945
|
+
* `since` is the cursor and that changed no value applies its deletes without a read. Each other
|
|
946
|
+
* hint asks for a pull: the values come through the checks of a read of the remote, and the
|
|
947
|
+
* hints that come while the pull waits join it.
|
|
948
|
+
*/
|
|
949
|
+
async applyHint(hint, mark) {
|
|
950
|
+
if (this.loadError)
|
|
951
|
+
throw this.loadError;
|
|
952
|
+
if (this.closed || (this.mode !== "write-through" && this.mode !== "write-back"))
|
|
953
|
+
return;
|
|
954
|
+
if (hint.version !== undefined && hint.version === this.cursor)
|
|
955
|
+
return;
|
|
956
|
+
if (hint.since !== undefined && hint.since === this.cursor && hint.version !== undefined && hint.changed.length === 0) {
|
|
957
|
+
await this.applyChanges({ version: hint.version, full: false, changed: [], deleted: [...hint.deleted] }, (id) => this.changedSince(mark, id));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
this.requestPull();
|
|
961
|
+
}
|
|
962
|
+
/** This method asks for a pull after the syncs that run. A request while a pull waits joins it. */
|
|
963
|
+
requestPull() {
|
|
964
|
+
if (this.gapPull)
|
|
965
|
+
return;
|
|
966
|
+
this.gapPull = true;
|
|
967
|
+
void this.loaded
|
|
968
|
+
.then(() => this.serialize(() => {
|
|
969
|
+
this.gapPull = false;
|
|
970
|
+
if (this.closed)
|
|
971
|
+
return Promise.resolve();
|
|
972
|
+
return this.track("pull", async () => {
|
|
973
|
+
if (this.loadError)
|
|
974
|
+
throw this.loadError;
|
|
975
|
+
if (this.mode === "write-through" || this.mode === "write-back")
|
|
976
|
+
await this.pullRemote(false);
|
|
977
|
+
});
|
|
978
|
+
}))
|
|
979
|
+
.catch(() => undefined);
|
|
980
|
+
}
|
|
981
|
+
//
|
|
982
|
+
// ═══ The sync state (roadmap CLI-21) ═══
|
|
983
|
+
//
|
|
984
|
+
/** This method loads the sync state: the bases, and the cursor that the load then examines. */
|
|
985
|
+
async loadState() {
|
|
986
|
+
if (!this.stateStore)
|
|
987
|
+
return undefined;
|
|
988
|
+
try {
|
|
989
|
+
const state = await this.stateStore.load();
|
|
990
|
+
if (state === undefined)
|
|
991
|
+
return undefined;
|
|
992
|
+
if (!isSyncState(state))
|
|
993
|
+
throw new TypeError("The store gave a value that is not a sync state");
|
|
994
|
+
for (const [id, version] of state.bases)
|
|
995
|
+
this.bases.set(id, version);
|
|
996
|
+
return state.cursor;
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
// The storage goes on without the state: the first pull reads all items
|
|
1000
|
+
this.fail("store", new SyncStateError("load", error));
|
|
1001
|
+
return undefined;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* This method writes the changes of the sync state to its store: the cursor and the bases that
|
|
1006
|
+
* changed. The writes run one at a time, and a change during a write goes in the next write.
|
|
1007
|
+
*/
|
|
1008
|
+
saveState() {
|
|
1009
|
+
if (!this.stateStore || this.loadError)
|
|
1010
|
+
return;
|
|
1011
|
+
if (!this.stateReset && this.bases.changed.size === 0 && this.cursor === this.savedCursor)
|
|
1012
|
+
return;
|
|
1013
|
+
if (this.stateSaving) {
|
|
1014
|
+
this.stateAgain = true;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
this.stateSaving = this.writeState().finally(() => {
|
|
1018
|
+
this.stateSaving = undefined;
|
|
1019
|
+
if (this.stateAgain) {
|
|
1020
|
+
this.stateAgain = false;
|
|
1021
|
+
this.saveState();
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
/** One write of the sync state. The first write, and the write after a failure, give the whole state. */
|
|
1026
|
+
async writeState() {
|
|
1027
|
+
const reset = this.stateReset;
|
|
1028
|
+
const ids = reset ? [...this.bases.keys()] : [...this.bases.changed];
|
|
1029
|
+
this.bases.changed.clear();
|
|
1030
|
+
this.stateReset = false;
|
|
1031
|
+
const cursor = this.cursor;
|
|
1032
|
+
const bases = [];
|
|
1033
|
+
const forgotten = [];
|
|
1034
|
+
for (const id of ids) {
|
|
1035
|
+
const version = this.bases.get(id);
|
|
1036
|
+
if (version === undefined)
|
|
1037
|
+
forgotten.push(id);
|
|
1038
|
+
else
|
|
1039
|
+
bases.push([id, version]);
|
|
1040
|
+
}
|
|
1041
|
+
try {
|
|
1042
|
+
await this.stateStore.write({ reset, cursor, bases, forgotten: reset ? [] : forgotten });
|
|
1043
|
+
this.savedCursor = cursor;
|
|
1044
|
+
this.succeed("store");
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
this.stateReset = true;
|
|
1048
|
+
this.fail("store", new SyncStateError("write", error));
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/** This method sends the status to the listeners when it changed. */
|
|
1052
|
+
notify() {
|
|
1053
|
+
if (this.statusListeners.size === 0)
|
|
1054
|
+
return;
|
|
1055
|
+
const status = this.getStatus();
|
|
1056
|
+
const last = this.lastStatus;
|
|
1057
|
+
if (last &&
|
|
1058
|
+
last.connected === status.connected &&
|
|
1059
|
+
last.lastSync === status.lastSync &&
|
|
1060
|
+
last.pendingChanges === status.pendingChanges &&
|
|
1061
|
+
last.syncing === status.syncing &&
|
|
1062
|
+
last.mode === status.mode &&
|
|
1063
|
+
last.error === status.error) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
this.lastStatus = status;
|
|
1067
|
+
this.statusListeners.emit(status);
|
|
1068
|
+
}
|
|
1069
|
+
/** This method keeps a failure for the status. The failure of an operation replaces its older one. */
|
|
1070
|
+
fail(operation, error) {
|
|
1071
|
+
this.failures.delete(operation);
|
|
1072
|
+
this.failures.set(operation, { operation, error });
|
|
1073
|
+
this.notify();
|
|
1074
|
+
}
|
|
1075
|
+
/** This method clears the failure of an operation after a success. */
|
|
1076
|
+
succeed(operation) {
|
|
1077
|
+
if (this.failures.delete(operation))
|
|
1078
|
+
this.notify();
|
|
1079
|
+
}
|
|
1080
|
+
/** A push or a pull: the status shows it while it runs, and keeps its failure. */
|
|
1081
|
+
async track(operation, work) {
|
|
1082
|
+
this.syncs++;
|
|
1083
|
+
this.notify();
|
|
1084
|
+
try {
|
|
1085
|
+
await work();
|
|
1086
|
+
this.succeed(operation);
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
// A storage whose load failed keeps the failure of the load
|
|
1090
|
+
if (error !== this.loadError)
|
|
1091
|
+
this.fail(operation, error);
|
|
1092
|
+
throw error;
|
|
1093
|
+
}
|
|
1094
|
+
finally {
|
|
1095
|
+
this.syncs--;
|
|
1096
|
+
this.notify();
|
|
1097
|
+
this.saveState();
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
//
|
|
218
1101
|
// ═══ Modes ═══
|
|
219
1102
|
//
|
|
220
1103
|
/** The mode of the storage now. */
|
|
@@ -277,58 +1160,40 @@ export class HybridStorage {
|
|
|
277
1160
|
}
|
|
278
1161
|
this.mode = target;
|
|
279
1162
|
this.updateTimers();
|
|
1163
|
+
this.notify();
|
|
1164
|
+
this.saveState();
|
|
280
1165
|
}
|
|
281
1166
|
/** This method drops the local copy. A read in flight does not fill it again. */
|
|
282
1167
|
async dropLocal() {
|
|
283
|
-
this.
|
|
1168
|
+
this.bumpClear();
|
|
284
1169
|
this.cursor = undefined;
|
|
285
|
-
await this.
|
|
1170
|
+
await this.localClear(this.syncOrigin);
|
|
286
1171
|
}
|
|
287
1172
|
/** This method makes the local copy from the queue only, before a full sync. */
|
|
288
1173
|
async rebuildLocal() {
|
|
289
1174
|
await this.dropLocal();
|
|
290
1175
|
for (const op of this.offlineOps)
|
|
291
|
-
await this.applyToLocal(op);
|
|
1176
|
+
await this.applyToLocal(op, this.syncOrigin);
|
|
292
1177
|
}
|
|
293
|
-
/**
|
|
1178
|
+
/**
|
|
1179
|
+
* This method starts and stops the timers for the mode. The push timer runs in `write-back`, the
|
|
1180
|
+
* pull timer in `write-through` and `write-back`, and the send of the queue after a failure only
|
|
1181
|
+
* in `write-through`. A failed run reports its error in the status, and the next delay grows.
|
|
1182
|
+
*/
|
|
294
1183
|
updateTimers() {
|
|
295
1184
|
if (this.closed)
|
|
296
1185
|
return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
else if (!push && this.syncTimer) {
|
|
310
|
-
clearInterval(this.syncTimer);
|
|
311
|
-
this.syncTimer = null;
|
|
312
|
-
}
|
|
313
|
-
const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
|
|
314
|
-
if (pull && !this.pullTimer) {
|
|
315
|
-
this.pullTimer = setInterval(() => {
|
|
316
|
-
if (this.closed)
|
|
317
|
-
return;
|
|
318
|
-
this.pull().catch((err) => {
|
|
319
|
-
console.error("Periodic pull failed:", err);
|
|
320
|
-
});
|
|
321
|
-
}, this.options.pullInterval);
|
|
322
|
-
this.pullTimer.unref?.();
|
|
323
|
-
}
|
|
324
|
-
else if (!pull && this.pullTimer) {
|
|
325
|
-
clearInterval(this.pullTimer);
|
|
326
|
-
this.pullTimer = null;
|
|
327
|
-
}
|
|
328
|
-
if (this.mode !== "write-through" && this.drainTimer) {
|
|
329
|
-
clearTimeout(this.drainTimer);
|
|
330
|
-
this.drainTimer = null;
|
|
331
|
-
}
|
|
1186
|
+
if (this.mode === "write-back")
|
|
1187
|
+
this.pushTimer?.start();
|
|
1188
|
+
else
|
|
1189
|
+
this.pushTimer?.stop();
|
|
1190
|
+
if (this.mode === "write-through" || this.mode === "write-back")
|
|
1191
|
+
this.pullTimer?.start();
|
|
1192
|
+
else
|
|
1193
|
+
this.pullTimer?.stop();
|
|
1194
|
+
if (this.mode !== "write-through")
|
|
1195
|
+
this.drainTimer.stop();
|
|
1196
|
+
this.updateFeed();
|
|
332
1197
|
}
|
|
333
1198
|
//
|
|
334
1199
|
// ═══ Read Operations ═══
|
|
@@ -349,17 +1214,18 @@ export class HybridStorage {
|
|
|
349
1214
|
return cached;
|
|
350
1215
|
}
|
|
351
1216
|
this.stats.cacheMisses++;
|
|
352
|
-
// A pending local delete means "no item": do not read it back from the remote
|
|
353
|
-
|
|
1217
|
+
// A pending local delete means "no item": do not read it back from the remote. An own delete
|
|
1218
|
+
// that waits for the remote too (roadmap CLI-21).
|
|
1219
|
+
if (this.mode === "local-only" || this.hasPendingOp(id) || this.waitsForRemote(id)) {
|
|
354
1220
|
return undefined;
|
|
355
1221
|
}
|
|
356
1222
|
const version = this.writeKey(id);
|
|
357
1223
|
try {
|
|
358
1224
|
const remote = await this.remote.get(id);
|
|
359
1225
|
this.isOnline = true;
|
|
360
|
-
if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
|
|
1226
|
+
if (this.writeKey(id) === version && !this.hasPendingOp(id) && !this.waitsForRemote(id)) {
|
|
361
1227
|
if (remote !== undefined)
|
|
362
|
-
await this.
|
|
1228
|
+
await this.localSet(id, remote, this.syncOrigin);
|
|
363
1229
|
this.noteBase(id, remote);
|
|
364
1230
|
}
|
|
365
1231
|
return remote;
|
|
@@ -388,6 +1254,24 @@ export class HybridStorage {
|
|
|
388
1254
|
async find(predicate) {
|
|
389
1255
|
return this.run(true, () => this.mode === "remote-only" ? this.remoteRead(() => this.remote.find(predicate)) : this.local.find(predicate));
|
|
390
1256
|
}
|
|
1257
|
+
/**
|
|
1258
|
+
* The entries whose items match the selector (roadmap CLI-14): from the local copy, or from the
|
|
1259
|
+
* remote in `remote-only` mode. A storage under it with `query()` runs the selector itself, for
|
|
1260
|
+
* example `ApiStorage` on its server. Else this method runs `queryEntries()` on its entries. The
|
|
1261
|
+
* policy of the call, or of the options, goes to the storage under it.
|
|
1262
|
+
*/
|
|
1263
|
+
async query(selector, options, policy) {
|
|
1264
|
+
return this.run(true, async () => {
|
|
1265
|
+
const limits = policy ?? this.queryPolicy;
|
|
1266
|
+
const problems = [...selectorProblems(selector, limits), ...queryOptionsProblems(options, limits)];
|
|
1267
|
+
if (problems.length > 0)
|
|
1268
|
+
throw new TypeError(`The query is not correct: ${problems.join(". ")}`);
|
|
1269
|
+
if (this.mode === "remote-only") {
|
|
1270
|
+
return this.remoteRead(async () => this.remote.query ? this.remote.query(selector, options, limits) : queryEntries(await this.remoteEntries(), selector, options, limits));
|
|
1271
|
+
}
|
|
1272
|
+
return this.local.query ? this.local.query(selector, options, limits) : queryEntries(await this.local.getEntries(), selector, options, limits);
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
391
1275
|
async has(id) {
|
|
392
1276
|
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.has(id)) : this.local.has(id)));
|
|
393
1277
|
}
|
|
@@ -417,45 +1301,51 @@ export class HybridStorage {
|
|
|
417
1301
|
//
|
|
418
1302
|
/**
|
|
419
1303
|
* This method sets an item. The storage takes a copy of the value, so a later change of the
|
|
420
|
-
* value does not change the local copy, the queue or the remote write.
|
|
1304
|
+
* value does not change the local copy, the queue or the remote write. The origin of the options
|
|
1305
|
+
* goes to the batches of `observe()`. The local copy and the remote get the origin of the storage.
|
|
421
1306
|
*/
|
|
422
|
-
async set(id, written) {
|
|
1307
|
+
async set(id, written, options) {
|
|
423
1308
|
const value = structuredClone(written);
|
|
424
|
-
|
|
1309
|
+
const origin = options?.origin;
|
|
1310
|
+
await this.write("set", { id, value }, [id], origin, async () => {
|
|
425
1311
|
this.bump(id);
|
|
426
|
-
await this.
|
|
427
|
-
}, () => this.remote.set(id, value));
|
|
1312
|
+
await this.localSet(id, value, origin);
|
|
1313
|
+
}, () => this.remote.set(id, value, this.own));
|
|
428
1314
|
}
|
|
429
|
-
async delete(id) {
|
|
430
|
-
|
|
1315
|
+
async delete(id, options) {
|
|
1316
|
+
const origin = options?.origin;
|
|
1317
|
+
return this.write("delete", { id }, [id], origin, async () => {
|
|
431
1318
|
this.bump(id);
|
|
432
|
-
return this.
|
|
433
|
-
}, () => this.remote.delete(id));
|
|
1319
|
+
return this.localDelete(id, origin);
|
|
1320
|
+
}, () => this.remote.delete(id, this.own));
|
|
434
1321
|
}
|
|
435
|
-
async clear() {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1322
|
+
async clear(options) {
|
|
1323
|
+
const origin = options?.origin;
|
|
1324
|
+
await this.write("clear", {}, "*", origin, async () => {
|
|
1325
|
+
this.bumpClear();
|
|
1326
|
+
await this.localClear(origin);
|
|
1327
|
+
}, () => this.remote.clear(this.own));
|
|
440
1328
|
}
|
|
441
1329
|
//
|
|
442
1330
|
// ═══ Bulk Operations ═══
|
|
443
1331
|
//
|
|
444
1332
|
/** This method sets several items. The storage takes a copy of each value, as `set` does. */
|
|
445
|
-
async setBatch(written) {
|
|
1333
|
+
async setBatch(written, options) {
|
|
446
1334
|
const items = written.map(([id, value]) => [id, structuredClone(value)]);
|
|
447
|
-
|
|
1335
|
+
const origin = options?.origin;
|
|
1336
|
+
await this.write("setBatch", { items }, items.map(([id]) => id), origin, async () => {
|
|
448
1337
|
for (const [id] of items)
|
|
449
1338
|
this.bump(id);
|
|
450
|
-
await this.
|
|
451
|
-
}, () => this.remote.setBatch(items));
|
|
1339
|
+
await this.localSetBatch(items, origin);
|
|
1340
|
+
}, () => this.remote.setBatch(items, this.own));
|
|
452
1341
|
}
|
|
453
|
-
async deleteBatch(ids) {
|
|
454
|
-
|
|
1342
|
+
async deleteBatch(ids, options) {
|
|
1343
|
+
const origin = options?.origin;
|
|
1344
|
+
return this.write("deleteBatch", { ids }, ids, origin, async () => {
|
|
455
1345
|
for (const id of ids)
|
|
456
1346
|
this.bump(id);
|
|
457
|
-
return this.
|
|
458
|
-
}, () => this.remote.deleteBatch(ids));
|
|
1347
|
+
return this.localDeleteBatch(ids, origin);
|
|
1348
|
+
}, () => this.remote.deleteBatch(ids, this.own));
|
|
459
1349
|
}
|
|
460
1350
|
/**
|
|
461
1351
|
* One write, in the mode of the storage. The result is the result of the local copy, or of the
|
|
@@ -474,14 +1364,18 @@ export class HybridStorage {
|
|
|
474
1364
|
* queue, a second try queued the operation again, and a full queue dropped the oldest operation
|
|
475
1365
|
* without a word (roadmap R2).
|
|
476
1366
|
*/
|
|
477
|
-
write(type, data, ids, applyLocal, applyRemote) {
|
|
1367
|
+
write(type, data, ids, origin, applyLocal, applyRemote) {
|
|
478
1368
|
return this.run(false, async () => {
|
|
479
1369
|
const op = { type, data, timestamp: Date.now() };
|
|
480
1370
|
if (this.mode === "remote-only") {
|
|
481
1371
|
const send = async () => {
|
|
482
1372
|
await this.ensureStoreClean();
|
|
1373
|
+
// The observers get a delete only for an id that existed
|
|
1374
|
+
const existing = this.observers.size > 0 ? await this.remoteExisting(op) : undefined;
|
|
483
1375
|
const result = await applyRemote();
|
|
484
1376
|
this.noteRemoteWrite(op);
|
|
1377
|
+
if (this.observers.size > 0)
|
|
1378
|
+
this.emit(remoteChangesOf(op, result, existing, origin));
|
|
485
1379
|
return result;
|
|
486
1380
|
};
|
|
487
1381
|
try {
|
|
@@ -503,53 +1397,188 @@ export class HybridStorage {
|
|
|
503
1397
|
if (this.mode === "write-back" || this.mode === "local-only") {
|
|
504
1398
|
this.ensureQueueRoom(op);
|
|
505
1399
|
this.attachBase(op);
|
|
506
|
-
const undo = await this.snapshot(ids);
|
|
1400
|
+
const undo = await this.snapshot(ids, origin);
|
|
507
1401
|
const result = await applyLocal();
|
|
1402
|
+
this.noteOwnWrite(op);
|
|
508
1403
|
await this.enqueue(op, undo);
|
|
509
1404
|
return result;
|
|
510
1405
|
}
|
|
511
|
-
const undo = await this.snapshot(ids);
|
|
512
|
-
|
|
1406
|
+
const undo = await this.snapshot(ids, origin);
|
|
1407
|
+
// Until the remote takes the write, or the queue or the undo replaces it, a pull does not
|
|
1408
|
+
// write over its local value (roadmap CLI-21)
|
|
1409
|
+
this.startFlight(op);
|
|
513
1410
|
try {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
1411
|
+
const result = await applyLocal();
|
|
1412
|
+
this.noteOwnWrite(op);
|
|
1413
|
+
try {
|
|
1414
|
+
if (this.offlineOps.length > 0) {
|
|
1415
|
+
// The queued operations are older: they reach the remote first, or the older value of a
|
|
1416
|
+
// queued write replaces this one later. A failed send keeps this write behind them.
|
|
1417
|
+
await this.serialize(async () => {
|
|
1418
|
+
await this.pushQueue();
|
|
1419
|
+
await this.ensureStoreClean();
|
|
1420
|
+
this.echoSent(op);
|
|
1421
|
+
await applyRemote();
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
else {
|
|
519
1425
|
await this.ensureStoreClean();
|
|
1426
|
+
this.echoSent(op);
|
|
520
1427
|
await applyRemote();
|
|
521
|
-
}
|
|
1428
|
+
}
|
|
1429
|
+
this.isOnline = true;
|
|
1430
|
+
this.noteRemoteWrite(op);
|
|
522
1431
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
1432
|
+
catch (error) {
|
|
1433
|
+
if (!(error instanceof QueueStoreError) &&
|
|
1434
|
+
this.options.offlineQueue &&
|
|
1435
|
+
this.options.isUnavailable(error) &&
|
|
1436
|
+
this.hasQueueRoom(op)) {
|
|
1437
|
+
// The status shows the queue and the connection. The caller gets PendingWriteError.
|
|
1438
|
+
this.isOnline = false;
|
|
1439
|
+
this.attachBase(op);
|
|
1440
|
+
await this.enqueue(op, undo);
|
|
1441
|
+
this.scheduleDrain();
|
|
1442
|
+
throw new PendingWriteError(type, error);
|
|
1443
|
+
}
|
|
1444
|
+
await undo();
|
|
1445
|
+
throw error;
|
|
526
1446
|
}
|
|
527
|
-
|
|
528
|
-
this.noteRemoteWrite(op);
|
|
1447
|
+
return result;
|
|
529
1448
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1449
|
+
finally {
|
|
1450
|
+
this.endFlight(op);
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
//
|
|
1455
|
+
// ═══ The own writes that wait for the remote (roadmap CLI-21) ═══
|
|
1456
|
+
//
|
|
1457
|
+
/** This method records an own write of the `write-through` mode that goes to the remote. */
|
|
1458
|
+
startFlight(op) {
|
|
1459
|
+
if (op.type === "clear") {
|
|
1460
|
+
this.inFlightClears++;
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const data = op.data;
|
|
1464
|
+
const values = op.type === "set" ? [[data.id, data.value]] :
|
|
1465
|
+
op.type === "setBatch" ? data.items :
|
|
1466
|
+
op.type === "delete" ? [[data.id, undefined]] :
|
|
1467
|
+
data.ids.map((id) => [id, undefined]);
|
|
1468
|
+
for (const [id, value] of values) {
|
|
1469
|
+
const flight = this.inFlight.get(id);
|
|
1470
|
+
if (flight) {
|
|
1471
|
+
flight.count++;
|
|
1472
|
+
flight.value = value;
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
this.inFlight.set(id, { count: 1, value });
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* This method ends an own write that went to the remote: the remote took it, or the queue or the
|
|
1481
|
+
* undo replaced it. The deferred remote values of the ids that no own write waits for now go
|
|
1482
|
+
* through a new read of the remote, after the syncs that run.
|
|
1483
|
+
*/
|
|
1484
|
+
endFlight(op) {
|
|
1485
|
+
if (op.type === "clear") {
|
|
1486
|
+
this.inFlightClears--;
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
for (const id of new Set(idsOf(op))) {
|
|
1490
|
+
const flight = this.inFlight.get(id);
|
|
1491
|
+
if (!flight)
|
|
1492
|
+
continue;
|
|
1493
|
+
if (--flight.count <= 0)
|
|
1494
|
+
this.inFlight.delete(id);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const ready = [...this.deferred.keys()].filter((id) => !this.waitsForRemote(id));
|
|
1498
|
+
if (ready.length === 0)
|
|
1499
|
+
return;
|
|
1500
|
+
void this.serialize(() => this.track("pull", () => this.applyDeferred(ready))).catch(() => undefined);
|
|
1501
|
+
}
|
|
1502
|
+
/** True when an own write of the id waits for the remote. */
|
|
1503
|
+
waitsForRemote(id) {
|
|
1504
|
+
return this.inFlightClears > 0 || this.inFlight.has(id);
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* True when a remote value of an id is the value of the newest own write that waits for the
|
|
1508
|
+
* remote: its echo. `undefined` is a delete. With `versionOf` the versions decide, else the JSON.
|
|
1509
|
+
*/
|
|
1510
|
+
isFlightValue(id, item) {
|
|
1511
|
+
const flight = this.inFlight.get(id);
|
|
1512
|
+
const value = flight ? flight.value : undefined;
|
|
1513
|
+
if (item === undefined || value === undefined)
|
|
1514
|
+
return item === undefined && value === undefined;
|
|
1515
|
+
if (this.options.versionOf) {
|
|
1516
|
+
const version = this.recordedVersion(item);
|
|
1517
|
+
return version !== undefined && version === this.recordedVersion(value);
|
|
1518
|
+
}
|
|
1519
|
+
return JSON.stringify(item) === JSON.stringify(value);
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* A pull keeps the local value of an id whose own write waits for the remote, and the storage
|
|
1523
|
+
* keeps the remote value for later. True when the pull defers the id. The echo of the write
|
|
1524
|
+
* itself is not deferred: it is the local value.
|
|
1525
|
+
*/
|
|
1526
|
+
deferIfInFlight(id, item) {
|
|
1527
|
+
if (!this.waitsForRemote(id) || this.isFlightValue(id, item))
|
|
1528
|
+
return false;
|
|
1529
|
+
this.deferred.set(id, { item });
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* This method applies the deferred remote values of ids whose own writes ended. The storage
|
|
1534
|
+
* reads each id from the remote again: the deferred value can be older than the write, or a
|
|
1535
|
+
* newer write of another writer. Then the rules of a pull decide. When the remote cannot answer,
|
|
1536
|
+
* the deferred value goes through the rules.
|
|
1537
|
+
*/
|
|
1538
|
+
async applyDeferred(ids) {
|
|
1539
|
+
if (this.loadError)
|
|
1540
|
+
throw this.loadError;
|
|
1541
|
+
if (this.closed)
|
|
1542
|
+
return;
|
|
1543
|
+
const conflicts = [];
|
|
1544
|
+
let queueChanged = false;
|
|
1545
|
+
for (const id of ids) {
|
|
1546
|
+
const deferred = this.deferred.get(id);
|
|
1547
|
+
// A new own write of the id waits: its end applies the value
|
|
1548
|
+
if (!deferred || this.waitsForRemote(id))
|
|
1549
|
+
continue;
|
|
1550
|
+
this.deferred.delete(id);
|
|
1551
|
+
const mark = this.writeSeq;
|
|
1552
|
+
let item = deferred.item;
|
|
1553
|
+
if (this.mode === "write-through" || this.mode === "write-back") {
|
|
1554
|
+
try {
|
|
1555
|
+
item = await this.remote.get(id);
|
|
1556
|
+
this.isOnline = true;
|
|
1557
|
+
}
|
|
1558
|
+
catch {
|
|
1559
|
+
// The deferred value is the newest value that the storage knows
|
|
535
1560
|
this.isOnline = false;
|
|
536
|
-
console.error(`Remote ${type} failed: the operation waits in the queue`);
|
|
537
|
-
this.attachBase(op);
|
|
538
|
-
await this.enqueue(op, undo);
|
|
539
|
-
this.scheduleDrain();
|
|
540
|
-
throw new PendingWriteError(type, error);
|
|
541
1561
|
}
|
|
542
|
-
await undo();
|
|
543
|
-
throw error;
|
|
544
1562
|
}
|
|
545
|
-
|
|
546
|
-
|
|
1563
|
+
const changedLocally = (other) => this.changedSince(mark, other);
|
|
1564
|
+
const changed = item === undefined
|
|
1565
|
+
? await this.pullDelete(id, changedLocally, conflicts)
|
|
1566
|
+
: await this.pullItem(id, item, changedLocally, conflicts);
|
|
1567
|
+
if (changed)
|
|
1568
|
+
queueChanged = true;
|
|
1569
|
+
}
|
|
1570
|
+
if (queueChanged) {
|
|
1571
|
+
this.storeDirty = true;
|
|
1572
|
+
await this.persistAll().catch(() => undefined);
|
|
1573
|
+
}
|
|
1574
|
+
if (conflicts.length > 0)
|
|
1575
|
+
throw new ConflictError(conflicts);
|
|
547
1576
|
}
|
|
548
1577
|
/**
|
|
549
1578
|
* The local values of the ids before a write, and a function that puts them back. It puts back
|
|
550
1579
|
* only the ids that no later write changed.
|
|
551
1580
|
*/
|
|
552
|
-
async snapshot(ids) {
|
|
1581
|
+
async snapshot(ids, origin) {
|
|
553
1582
|
const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
|
|
554
1583
|
const touched = ids === "*" ? [...before.keys()] : ids;
|
|
555
1584
|
// The versions after the write: this write adds one to each id (clear adds to clearVersion)
|
|
@@ -563,9 +1592,9 @@ export class HybridStorage {
|
|
|
563
1592
|
continue;
|
|
564
1593
|
const value = before.get(id);
|
|
565
1594
|
if (value === undefined)
|
|
566
|
-
await this.
|
|
1595
|
+
await this.localDelete(id, origin);
|
|
567
1596
|
else
|
|
568
|
-
await this.
|
|
1597
|
+
await this.localSet(id, value, origin);
|
|
569
1598
|
}
|
|
570
1599
|
};
|
|
571
1600
|
}
|
|
@@ -616,25 +1645,25 @@ export class HybridStorage {
|
|
|
616
1645
|
await this.syncToRemote();
|
|
617
1646
|
await this.syncFromRemote();
|
|
618
1647
|
}
|
|
619
|
-
/** The pull of `syncFromRemote()`, after the syncs that run. */
|
|
1648
|
+
/** The pull of `syncFromRemote()`, after the syncs that run. The status keeps its failure. */
|
|
620
1649
|
pull() {
|
|
621
|
-
return this.serialize(async () => {
|
|
1650
|
+
return this.serialize(() => this.track("pull", async () => {
|
|
622
1651
|
await this.loaded;
|
|
623
1652
|
if (this.loadError)
|
|
624
1653
|
throw this.loadError;
|
|
625
1654
|
if (this.mode === "write-through" || this.mode === "write-back")
|
|
626
1655
|
await this.pullRemote(false);
|
|
627
|
-
});
|
|
1656
|
+
}));
|
|
628
1657
|
}
|
|
629
|
-
/** The push of `syncToRemote()`, after the syncs that run. */
|
|
1658
|
+
/** The push of `syncToRemote()`, after the syncs that run. The status keeps its failure. */
|
|
630
1659
|
push() {
|
|
631
|
-
return this.serialize(async () => {
|
|
1660
|
+
return this.serialize(() => this.track("push", async () => {
|
|
632
1661
|
await this.loaded;
|
|
633
1662
|
if (this.loadError)
|
|
634
1663
|
throw this.loadError;
|
|
635
1664
|
if (this.mode !== "local-only")
|
|
636
1665
|
await this.pushQueue();
|
|
637
|
-
});
|
|
1666
|
+
}));
|
|
638
1667
|
}
|
|
639
1668
|
/** The error of an operation after `close()`. */
|
|
640
1669
|
closedError() {
|
|
@@ -662,38 +1691,39 @@ export class HybridStorage {
|
|
|
662
1691
|
}
|
|
663
1692
|
/** The work of `close()`. */
|
|
664
1693
|
async shutDown() {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
this.
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
this.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1694
|
+
this.pushTimer?.stop();
|
|
1695
|
+
this.pullTimer?.stop();
|
|
1696
|
+
this.drainTimer.stop();
|
|
1697
|
+
this.updateFeed();
|
|
1698
|
+
this.stopCapture?.();
|
|
1699
|
+
try {
|
|
1700
|
+
await this.loaded;
|
|
1701
|
+
// The operations and the switch that started before close() end first
|
|
1702
|
+
await this.gate;
|
|
1703
|
+
await this.idle();
|
|
1704
|
+
// After the syncs that run: send the pending operations. The queue store keeps the ones that
|
|
1705
|
+
// the remote did not take, and the status gets the failure.
|
|
1706
|
+
await this.serialize(async () => {
|
|
1707
|
+
if (this.loadError || this.offlineOps.length === 0 || this.mode === "local-only")
|
|
1708
|
+
return;
|
|
1709
|
+
await this.track("push", () => this.pushQueue()).catch(() => undefined);
|
|
1710
|
+
});
|
|
1711
|
+
await this.storeChain;
|
|
1712
|
+
// The last changes of the sync state reach its store
|
|
1713
|
+
this.saveState();
|
|
1714
|
+
while (this.stateSaving)
|
|
1715
|
+
await this.stateSaving;
|
|
1716
|
+
// Close remote storage (an ApiStorage leaves the caller's client open)
|
|
683
1717
|
try {
|
|
684
|
-
await this.
|
|
1718
|
+
await this.remote.close();
|
|
685
1719
|
}
|
|
686
|
-
|
|
687
|
-
|
|
1720
|
+
finally {
|
|
1721
|
+
await this.local.close();
|
|
688
1722
|
}
|
|
689
|
-
});
|
|
690
|
-
await this.storeChain;
|
|
691
|
-
// Close remote storage (an ApiStorage leaves the caller's client open)
|
|
692
|
-
try {
|
|
693
|
-
await this.remote.close();
|
|
694
1723
|
}
|
|
695
1724
|
finally {
|
|
696
|
-
|
|
1725
|
+
this.statusListeners.clear();
|
|
1726
|
+
this.observers.clear();
|
|
697
1727
|
}
|
|
698
1728
|
}
|
|
699
1729
|
/**
|
|
@@ -757,6 +1787,10 @@ export class HybridStorage {
|
|
|
757
1787
|
for (const resolve of this.idleWaiters.splice(0))
|
|
758
1788
|
resolve();
|
|
759
1789
|
}
|
|
1790
|
+
// A write changes the queue, and a remote call the connection
|
|
1791
|
+
this.notify();
|
|
1792
|
+
// A remote write or a read of the remote changes the bases
|
|
1793
|
+
this.saveState();
|
|
760
1794
|
}
|
|
761
1795
|
}
|
|
762
1796
|
/** A promise that resolves when no operation runs. */
|
|
@@ -767,13 +1801,17 @@ export class HybridStorage {
|
|
|
767
1801
|
}
|
|
768
1802
|
/** This method loads the queue, and puts its writes in the local copy. */
|
|
769
1803
|
async loadQueue() {
|
|
1804
|
+
// The sync state first: its bases are newer than the bases of the queued operations
|
|
1805
|
+
const cursor = await this.loadState();
|
|
770
1806
|
let ops;
|
|
771
1807
|
try {
|
|
772
1808
|
ops = await this.queueStore.load();
|
|
773
1809
|
}
|
|
774
1810
|
catch (error) {
|
|
1811
|
+
// The storage refuses each operation. The status shows the failure.
|
|
775
1812
|
this.loadError = new QueueStoreError("load", error);
|
|
776
|
-
|
|
1813
|
+
this.fail("load", this.loadError);
|
|
1814
|
+
this.captured = undefined;
|
|
777
1815
|
return;
|
|
778
1816
|
}
|
|
779
1817
|
this.offlineOps = ops.slice();
|
|
@@ -785,12 +1823,17 @@ export class HybridStorage {
|
|
|
785
1823
|
}
|
|
786
1824
|
// A durable local copy can hold the operation already: each operation gives the same
|
|
787
1825
|
// result two times
|
|
788
|
-
await this.applyToLocal(op);
|
|
1826
|
+
await this.applyToLocal(op, this.syncOrigin);
|
|
789
1827
|
}
|
|
1828
|
+
// The cursor belongs to a durable local copy with items. Else the first pull reads all items.
|
|
1829
|
+
if (cursor !== undefined && this.localGiven && (await this.local.size()) > 0)
|
|
1830
|
+
this.cursor = cursor;
|
|
1831
|
+
await this.flushCaptured();
|
|
790
1832
|
}
|
|
791
1833
|
catch (error) {
|
|
792
1834
|
this.loadError = new Error("The local copy could not take the loaded queue: the storage refuses each operation", { cause: error });
|
|
793
|
-
|
|
1835
|
+
this.fail("load", this.loadError);
|
|
1836
|
+
this.captured = undefined;
|
|
794
1837
|
}
|
|
795
1838
|
}
|
|
796
1839
|
/** The first sync: send the loaded queue, then read the remote. */
|
|
@@ -799,41 +1842,154 @@ export class HybridStorage {
|
|
|
799
1842
|
if (this.loadError || this.closed)
|
|
800
1843
|
return;
|
|
801
1844
|
if (this.options.syncOnInit && this.mode !== "local-only") {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
});
|
|
806
|
-
}
|
|
1845
|
+
// A failure goes to the status: the reads go on with the local copy
|
|
1846
|
+
if (this.offlineOps.length > 0)
|
|
1847
|
+
await this.push().catch(() => undefined);
|
|
807
1848
|
if (!this.closed && (this.mode === "write-through" || this.mode === "write-back")) {
|
|
808
|
-
await this.pull().catch((
|
|
809
|
-
console.error("Failed to sync on init:", err);
|
|
810
|
-
});
|
|
1849
|
+
await this.pull().catch(() => undefined);
|
|
811
1850
|
}
|
|
812
1851
|
}
|
|
813
1852
|
if (this.offlineOps.length > 0)
|
|
814
1853
|
this.scheduleDrain();
|
|
815
1854
|
}
|
|
816
|
-
/** This method applies a queued operation to the local copy. */
|
|
817
|
-
async applyToLocal(op) {
|
|
1855
|
+
/** This method applies a queued operation to the local copy. The observers get the origin. */
|
|
1856
|
+
async applyToLocal(op, origin) {
|
|
818
1857
|
const data = op.data;
|
|
819
1858
|
switch (op.type) {
|
|
820
1859
|
case "set":
|
|
821
|
-
await this.
|
|
1860
|
+
await this.localSet(data.id, data.value, origin);
|
|
822
1861
|
break;
|
|
823
1862
|
case "delete":
|
|
824
|
-
await this.
|
|
1863
|
+
await this.localDelete(data.id, origin);
|
|
825
1864
|
break;
|
|
826
1865
|
case "setBatch":
|
|
827
|
-
await this.
|
|
1866
|
+
await this.localSetBatch(data.items, origin);
|
|
828
1867
|
break;
|
|
829
1868
|
case "deleteBatch":
|
|
830
|
-
await this.
|
|
1869
|
+
await this.localDeleteBatch(data.ids, origin);
|
|
831
1870
|
break;
|
|
832
1871
|
case "clear":
|
|
833
|
-
await this.
|
|
1872
|
+
await this.localClear(origin);
|
|
834
1873
|
break;
|
|
835
1874
|
}
|
|
836
1875
|
}
|
|
1876
|
+
//
|
|
1877
|
+
// ═══ The writes of the local copy ═══
|
|
1878
|
+
//
|
|
1879
|
+
/**
|
|
1880
|
+
* This method writes to the local copy. With `versionOf`, it records the version of each item
|
|
1881
|
+
* that the write gives, so a pull does not read the item to know its version.
|
|
1882
|
+
*
|
|
1883
|
+
* - During the write, the versions of its ids are unknown. A failed write keeps them unknown.
|
|
1884
|
+
* - Only the last write of an id that started records its version, and a clear that started
|
|
1885
|
+
* after the write cancels the record.
|
|
1886
|
+
*/
|
|
1887
|
+
writeLocal(versionsOf, write) {
|
|
1888
|
+
return this.options.versionOf ? this.recordedWrite(versionsOf(), write) : write();
|
|
1889
|
+
}
|
|
1890
|
+
/** The write of `writeLocal` with `versionOf`. */
|
|
1891
|
+
async recordedWrite(versions, write) {
|
|
1892
|
+
const clears = this.localClears;
|
|
1893
|
+
const numbers = versions.map(([id]) => {
|
|
1894
|
+
this.localVersions.delete(id);
|
|
1895
|
+
const number = ++this.localWriteCount;
|
|
1896
|
+
this.localWrites.set(id, number);
|
|
1897
|
+
return number;
|
|
1898
|
+
});
|
|
1899
|
+
let written = false;
|
|
1900
|
+
try {
|
|
1901
|
+
const result = await write();
|
|
1902
|
+
written = true;
|
|
1903
|
+
return result;
|
|
1904
|
+
}
|
|
1905
|
+
finally {
|
|
1906
|
+
versions.forEach(([id, version], index) => {
|
|
1907
|
+
if (this.localWrites.get(id) !== numbers[index])
|
|
1908
|
+
return;
|
|
1909
|
+
this.localWrites.delete(id);
|
|
1910
|
+
if (written && version !== undefined && clears === this.localClears)
|
|
1911
|
+
this.localVersions.set(id, version);
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* This method reads a local item for a pull. With `versionOf`, it records the version of the item
|
|
1917
|
+
* when no local write started or ran during the read.
|
|
1918
|
+
*/
|
|
1919
|
+
async readLocal(id) {
|
|
1920
|
+
const writes = this.localWriteCount;
|
|
1921
|
+
const clears = this.localClears;
|
|
1922
|
+
const item = await this.local.get(id);
|
|
1923
|
+
if (item !== undefined &&
|
|
1924
|
+
this.options.versionOf &&
|
|
1925
|
+
writes === this.localWriteCount &&
|
|
1926
|
+
clears === this.localClears &&
|
|
1927
|
+
!this.localWrites.has(id)) {
|
|
1928
|
+
const version = this.recordedVersion(item);
|
|
1929
|
+
if (version !== undefined)
|
|
1930
|
+
this.localVersions.set(id, version);
|
|
1931
|
+
}
|
|
1932
|
+
return item;
|
|
1933
|
+
}
|
|
1934
|
+
/** The version of an item for the record of the local copy, or undefined when `versionOf` fails. */
|
|
1935
|
+
recordedVersion(item) {
|
|
1936
|
+
try {
|
|
1937
|
+
return this.versionOfItem(item);
|
|
1938
|
+
}
|
|
1939
|
+
catch {
|
|
1940
|
+
return undefined;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
// The writes of the local copy have the origin of this storage, so the capture does not queue
|
|
1944
|
+
// them. The observers get each change with the origin of the caller.
|
|
1945
|
+
async localSet(id, value, origin) {
|
|
1946
|
+
await this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value, this.own));
|
|
1947
|
+
if (this.observers.size > 0)
|
|
1948
|
+
this.emit([setChange(id, value, origin)]);
|
|
1949
|
+
}
|
|
1950
|
+
async localDelete(id, origin) {
|
|
1951
|
+
const existed = await this.writeLocal(() => [[id, undefined]], () => this.local.delete(id, this.own));
|
|
1952
|
+
if (existed)
|
|
1953
|
+
this.emit([withOrigin({ type: "delete", id }, origin)]);
|
|
1954
|
+
return existed;
|
|
1955
|
+
}
|
|
1956
|
+
async localSetBatch(items, origin) {
|
|
1957
|
+
await this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items, this.own));
|
|
1958
|
+
if (this.observers.size > 0)
|
|
1959
|
+
this.emit(items.map(([id, value]) => setChange(id, value, origin)));
|
|
1960
|
+
}
|
|
1961
|
+
async localDeleteBatch(ids, origin) {
|
|
1962
|
+
// The observers get a delete for each id that existed
|
|
1963
|
+
const before = this.observers.size > 0 ? await this.local.getBatch(ids) : undefined;
|
|
1964
|
+
const count = await this.writeLocal(() => ids.map((id) => [id, undefined]), () => this.local.deleteBatch(ids, this.own));
|
|
1965
|
+
if (before && count > 0)
|
|
1966
|
+
this.emit([...new Set(ids)].filter((id) => before.has(id)).map((id) => withOrigin({ type: "delete", id }, origin)));
|
|
1967
|
+
return count;
|
|
1968
|
+
}
|
|
1969
|
+
localClear(origin) {
|
|
1970
|
+
this.localClears++;
|
|
1971
|
+
this.localVersions.clear();
|
|
1972
|
+
if (this.observers.size === 0)
|
|
1973
|
+
return this.local.clear(this.own);
|
|
1974
|
+
return this.observedClear(origin);
|
|
1975
|
+
}
|
|
1976
|
+
/** A clear of the local copy with observers: they get the change when the copy had items. */
|
|
1977
|
+
async observedClear(origin) {
|
|
1978
|
+
const had = (await this.local.size()) > 0;
|
|
1979
|
+
await this.local.clear(this.own);
|
|
1980
|
+
if (had)
|
|
1981
|
+
this.emit([withOrigin({ type: "clear" }, origin)]);
|
|
1982
|
+
}
|
|
1983
|
+
/** The ids that a delete of the `remote-only` mode finds, for its batch. */
|
|
1984
|
+
async remoteExisting(op) {
|
|
1985
|
+
if (op.type === "deleteBatch") {
|
|
1986
|
+
const ids = [...new Set(op.data.ids)];
|
|
1987
|
+
return new Set((await this.remote.getBatch(ids)).keys());
|
|
1988
|
+
}
|
|
1989
|
+
if (op.type === "clear")
|
|
1990
|
+
return (await this.remote.size()) > 0;
|
|
1991
|
+
return undefined;
|
|
1992
|
+
}
|
|
837
1993
|
/**
|
|
838
1994
|
* One pull. `full`: ask for all items (after the local copy was dropped).
|
|
839
1995
|
*
|
|
@@ -842,10 +1998,8 @@ export class HybridStorage {
|
|
|
842
1998
|
* comes again at the next pull.
|
|
843
1999
|
*/
|
|
844
2000
|
async pullRemote(full) {
|
|
845
|
-
|
|
846
|
-
const
|
|
847
|
-
// True when a local write changed the id after the sync started
|
|
848
|
-
const changedLocally = (id) => this.clearVersion !== clearVersion || (this.writeVersions.get(id) ?? 0) !== (versions.get(id) ?? 0);
|
|
2001
|
+
// A local write after this mark keeps its value: the remote value is older
|
|
2002
|
+
const mark = this.writeSeq;
|
|
849
2003
|
let changes;
|
|
850
2004
|
try {
|
|
851
2005
|
changes = await this.fetchChanges(full);
|
|
@@ -855,6 +2009,14 @@ export class HybridStorage {
|
|
|
855
2009
|
this.isOnline = false;
|
|
856
2010
|
throw error;
|
|
857
2011
|
}
|
|
2012
|
+
await this.applyChanges(changes, (id) => this.changedSince(mark, id));
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* This method applies remote changes to the local copy: the changes of a pull, or a delta of
|
|
2016
|
+
* the feed. `changedLocally` tells the ids that a local write changed after the changes left the
|
|
2017
|
+
* remote: they keep their local values. The cursor moves only without conflicts.
|
|
2018
|
+
*/
|
|
2019
|
+
async applyChanges(changes, changedLocally) {
|
|
858
2020
|
const conflicts = [];
|
|
859
2021
|
let queueChanged = false;
|
|
860
2022
|
const remoteIds = new Set();
|
|
@@ -872,9 +2034,8 @@ export class HybridStorage {
|
|
|
872
2034
|
}
|
|
873
2035
|
if (queueChanged) {
|
|
874
2036
|
this.storeDirty = true;
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
});
|
|
2037
|
+
// The status gets the failure of the queue store. A later direct write repairs the store first.
|
|
2038
|
+
await this.persistAll().catch(() => undefined);
|
|
878
2039
|
}
|
|
879
2040
|
this.stats.syncCount++;
|
|
880
2041
|
this.isOnline = true;
|
|
@@ -882,6 +2043,7 @@ export class HybridStorage {
|
|
|
882
2043
|
throw new ConflictError(conflicts);
|
|
883
2044
|
if (changes.version !== undefined)
|
|
884
2045
|
this.cursor = changes.version;
|
|
2046
|
+
this.lastSync = Date.now();
|
|
885
2047
|
}
|
|
886
2048
|
/**
|
|
887
2049
|
* The remote changes: since the cursor from `getChangesSince` when the remote has it, or else
|
|
@@ -901,14 +2063,29 @@ export class HybridStorage {
|
|
|
901
2063
|
}
|
|
902
2064
|
return { full: true, changed: await this.remoteEntries(), deleted: [] };
|
|
903
2065
|
}
|
|
904
|
-
/**
|
|
2066
|
+
/**
|
|
2067
|
+
* This method applies one remote item. It gives true when the queue changed.
|
|
2068
|
+
*
|
|
2069
|
+
* The pull reads the local item only when the result depends on it (roadmap R28):
|
|
2070
|
+
* - An id with a queued write: only when the remote version changed after the last known remote
|
|
2071
|
+
* version. Without versions, the queued write wins, and the pull reads nothing.
|
|
2072
|
+
* - An id without a queued write: not when the storage knows the version of the local item.
|
|
2073
|
+
* Without versions, the pull compares the JSON of the two items, so it reads the local item.
|
|
2074
|
+
* Before, the pull read each local item.
|
|
2075
|
+
*/
|
|
905
2076
|
async pullItem(id, item, changedLocally, conflicts) {
|
|
906
|
-
if (changedLocally(id))
|
|
907
|
-
return false;
|
|
908
|
-
const localItem = await this.local.get(id);
|
|
909
2077
|
if (changedLocally(id))
|
|
910
2078
|
return false;
|
|
911
2079
|
const remoteVersion = this.versionOfItem(item);
|
|
2080
|
+
// The echo of an older own write: a newer own write of the id, or its delete (a tombstone),
|
|
2081
|
+
// waits for its echo (roadmap CLI-21)
|
|
2082
|
+
const print = this.echoes ? this.echoPrint(item) : undefined;
|
|
2083
|
+
if (print !== undefined && this.echoes?.isStale(id, print))
|
|
2084
|
+
return false;
|
|
2085
|
+
// An own write of the id waits for the remote: the remote value can be older (roadmap CLI-21)
|
|
2086
|
+
if (this.deferIfInFlight(id, item))
|
|
2087
|
+
return false;
|
|
2088
|
+
const knownVersion = remoteVersion === undefined ? undefined : this.localVersions.get(id);
|
|
912
2089
|
if (this.hasPendingOp(id)) {
|
|
913
2090
|
// Without versions, the queued write wins, as before
|
|
914
2091
|
if (remoteVersion === undefined)
|
|
@@ -918,20 +2095,37 @@ export class HybridStorage {
|
|
|
918
2095
|
const base = this.bases.get(id);
|
|
919
2096
|
if (base === undefined || base === remoteVersion)
|
|
920
2097
|
return false;
|
|
2098
|
+
if (knownVersion === remoteVersion) {
|
|
2099
|
+
this.bases.set(id, remoteVersion);
|
|
2100
|
+
return false;
|
|
2101
|
+
}
|
|
2102
|
+
const localItem = await this.readLocal(id);
|
|
2103
|
+
if (changedLocally(id))
|
|
2104
|
+
return false;
|
|
921
2105
|
if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
|
|
922
2106
|
this.bases.set(id, remoteVersion);
|
|
923
2107
|
return false;
|
|
924
2108
|
}
|
|
925
2109
|
return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
|
|
926
2110
|
}
|
|
2111
|
+
if (knownVersion !== undefined) {
|
|
2112
|
+
// Only the remote changed: it is not a conflict. The version of the local item is known.
|
|
2113
|
+
if (knownVersion !== remoteVersion)
|
|
2114
|
+
await this.localSet(id, item, this.syncOrigin);
|
|
2115
|
+
this.bases.set(id, remoteVersion);
|
|
2116
|
+
return false;
|
|
2117
|
+
}
|
|
2118
|
+
const localItem = await this.readLocal(id);
|
|
2119
|
+
if (changedLocally(id))
|
|
2120
|
+
return false;
|
|
927
2121
|
const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
|
|
928
2122
|
if (localItem === undefined) {
|
|
929
|
-
await this.
|
|
2123
|
+
await this.localSet(id, item, this.syncOrigin);
|
|
930
2124
|
}
|
|
931
2125
|
else if (remoteVersion !== undefined && localVersion !== undefined) {
|
|
932
2126
|
// Only the remote changed: it is not a conflict
|
|
933
2127
|
if (localVersion !== remoteVersion)
|
|
934
|
-
await this.
|
|
2128
|
+
await this.localSet(id, item, this.syncOrigin);
|
|
935
2129
|
}
|
|
936
2130
|
else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
|
|
937
2131
|
// Conflict - resolve according to strategy
|
|
@@ -941,7 +2135,7 @@ export class HybridStorage {
|
|
|
941
2135
|
return false;
|
|
942
2136
|
}
|
|
943
2137
|
const resolved = this.resolveConflict(localItem, item);
|
|
944
|
-
await this.
|
|
2138
|
+
await this.localSet(id, resolved, this.syncOrigin);
|
|
945
2139
|
this.stats.conflicts++;
|
|
946
2140
|
}
|
|
947
2141
|
if (remoteVersion !== undefined)
|
|
@@ -952,8 +2146,14 @@ export class HybridStorage {
|
|
|
952
2146
|
async pullDelete(id, changedLocally, conflicts) {
|
|
953
2147
|
if (changedLocally(id))
|
|
954
2148
|
return false;
|
|
2149
|
+
// The echo of an older own delete: a newer own write of the id waits for its echo (roadmap CLI-21)
|
|
2150
|
+
if (this.echoes?.isStale(id, null))
|
|
2151
|
+
return false;
|
|
2152
|
+
// An own write of the id waits for the remote: the remote delete can be older (roadmap CLI-21)
|
|
2153
|
+
if (this.deferIfInFlight(id, undefined))
|
|
2154
|
+
return false;
|
|
955
2155
|
if (!this.hasPendingOp(id)) {
|
|
956
|
-
await this.
|
|
2156
|
+
await this.localDelete(id, this.syncOrigin);
|
|
957
2157
|
if (this.options.versionOf)
|
|
958
2158
|
this.bases.set(id, null);
|
|
959
2159
|
return false;
|
|
@@ -963,7 +2163,7 @@ export class HybridStorage {
|
|
|
963
2163
|
const base = this.bases.get(id);
|
|
964
2164
|
if (!this.options.versionOf || base === undefined || base === null)
|
|
965
2165
|
return false;
|
|
966
|
-
const localItem = await this.
|
|
2166
|
+
const localItem = await this.readLocal(id);
|
|
967
2167
|
if (changedLocally(id))
|
|
968
2168
|
return false;
|
|
969
2169
|
if (localItem === undefined) {
|
|
@@ -976,7 +2176,7 @@ export class HybridStorage {
|
|
|
976
2176
|
case "remote":
|
|
977
2177
|
if (!this.dropQueued(id))
|
|
978
2178
|
break;
|
|
979
|
-
await this.
|
|
2179
|
+
await this.localDelete(id, this.syncOrigin);
|
|
980
2180
|
this.bases.set(id, null);
|
|
981
2181
|
return true;
|
|
982
2182
|
case "error":
|
|
@@ -1000,7 +2200,7 @@ export class HybridStorage {
|
|
|
1000
2200
|
// The remote value replaces the queued write. A queued clear cannot lose one id.
|
|
1001
2201
|
if (!this.dropQueued(id))
|
|
1002
2202
|
break;
|
|
1003
|
-
await this.
|
|
2203
|
+
await this.localSet(id, remoteItem, this.syncOrigin);
|
|
1004
2204
|
this.bases.set(id, remoteVersion);
|
|
1005
2205
|
return true;
|
|
1006
2206
|
case "merge": {
|
|
@@ -1009,7 +2209,7 @@ export class HybridStorage {
|
|
|
1009
2209
|
const merged = this.resolveConflict(localItem, remoteItem);
|
|
1010
2210
|
if (!this.dropQueued(id))
|
|
1011
2211
|
break;
|
|
1012
|
-
await this.
|
|
2212
|
+
await this.localSet(id, merged, this.syncOrigin);
|
|
1013
2213
|
this.bases.set(id, remoteVersion);
|
|
1014
2214
|
this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
|
|
1015
2215
|
return true;
|
|
@@ -1091,6 +2291,7 @@ export class HybridStorage {
|
|
|
1091
2291
|
continue;
|
|
1092
2292
|
}
|
|
1093
2293
|
try {
|
|
2294
|
+
this.echoSent(op);
|
|
1094
2295
|
await this.executeOperation(op);
|
|
1095
2296
|
}
|
|
1096
2297
|
catch (error) {
|
|
@@ -1111,10 +2312,9 @@ export class HybridStorage {
|
|
|
1111
2312
|
finally {
|
|
1112
2313
|
this.pushing = false;
|
|
1113
2314
|
if (sent > 0) {
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
});
|
|
2315
|
+
// The store keeps sent operations, and the status gets the failure. A later direct write
|
|
2316
|
+
// repairs the store first.
|
|
2317
|
+
await this.persistAll().catch(() => undefined);
|
|
1118
2318
|
}
|
|
1119
2319
|
}
|
|
1120
2320
|
}
|
|
@@ -1126,6 +2326,16 @@ export class HybridStorage {
|
|
|
1126
2326
|
}
|
|
1127
2327
|
bump(id) {
|
|
1128
2328
|
this.writeVersions.set(id, (this.writeVersions.get(id) ?? 0) + 1);
|
|
2329
|
+
this.lastWrite.set(id, ++this.writeSeq);
|
|
2330
|
+
}
|
|
2331
|
+
/** A local clear: each id changed. */
|
|
2332
|
+
bumpClear() {
|
|
2333
|
+
this.clearVersion++;
|
|
2334
|
+
this.lastClear = ++this.writeSeq;
|
|
2335
|
+
}
|
|
2336
|
+
/** True when a local write changed the id after the mark, a value of `writeSeq`. */
|
|
2337
|
+
changedSince(mark, id) {
|
|
2338
|
+
return this.lastClear > mark || (this.lastWrite.get(id) ?? 0) > mark;
|
|
1129
2339
|
}
|
|
1130
2340
|
/** The count of local writes of an id: it changes with each write and each clear. */
|
|
1131
2341
|
writeKey(id) {
|
|
@@ -1326,12 +2536,15 @@ export class HybridStorage {
|
|
|
1326
2536
|
this.unpersisted.delete(op);
|
|
1327
2537
|
this.storeDirty = false;
|
|
1328
2538
|
}
|
|
2539
|
+
this.succeed("store");
|
|
1329
2540
|
}
|
|
1330
2541
|
catch (error) {
|
|
1331
2542
|
this.storeDirty = true;
|
|
1332
2543
|
this.unpersisted.delete(op);
|
|
1333
2544
|
rollback();
|
|
1334
|
-
|
|
2545
|
+
const failure = new QueueStoreError("write", error);
|
|
2546
|
+
this.fail("store", failure);
|
|
2547
|
+
throw failure;
|
|
1335
2548
|
}
|
|
1336
2549
|
});
|
|
1337
2550
|
this.storeChain = run.catch(() => undefined);
|
|
@@ -1351,10 +2564,13 @@ export class HybridStorage {
|
|
|
1351
2564
|
for (const kept of snapshot)
|
|
1352
2565
|
this.unpersisted.delete(kept);
|
|
1353
2566
|
this.storeDirty = false;
|
|
2567
|
+
this.succeed("store");
|
|
1354
2568
|
}
|
|
1355
2569
|
catch (error) {
|
|
1356
2570
|
this.storeDirty = true;
|
|
1357
|
-
|
|
2571
|
+
const failure = new QueueStoreError("write", error);
|
|
2572
|
+
this.fail("store", failure);
|
|
2573
|
+
throw failure;
|
|
1358
2574
|
}
|
|
1359
2575
|
});
|
|
1360
2576
|
this.storeChain = run.catch(() => undefined);
|
|
@@ -1397,19 +2613,12 @@ export class HybridStorage {
|
|
|
1397
2613
|
}
|
|
1398
2614
|
/**
|
|
1399
2615
|
* In write-through mode, a timer sends the queue after a failure, and again after each failed
|
|
1400
|
-
* send, until the queue is empty.
|
|
2616
|
+
* send, until the queue is empty. The delay after a failed send grows with the backoff.
|
|
1401
2617
|
*/
|
|
1402
2618
|
scheduleDrain() {
|
|
1403
|
-
if (this.
|
|
2619
|
+
if (this.closed || this.mode !== "write-through")
|
|
1404
2620
|
return;
|
|
1405
|
-
|
|
1406
|
-
this.drainTimer = setTimeout(() => {
|
|
1407
|
-
this.drainTimer = null;
|
|
1408
|
-
if (this.closed)
|
|
1409
|
-
return;
|
|
1410
|
-
this.push().catch(() => this.scheduleDrain());
|
|
1411
|
-
}, delay);
|
|
1412
|
-
this.drainTimer.unref?.();
|
|
2621
|
+
this.drainTimer.start();
|
|
1413
2622
|
}
|
|
1414
2623
|
/**
|
|
1415
2624
|
* Execute a queued operation against remote storage.
|
|
@@ -1418,19 +2627,19 @@ export class HybridStorage {
|
|
|
1418
2627
|
const data = op.data;
|
|
1419
2628
|
switch (op.type) {
|
|
1420
2629
|
case "set":
|
|
1421
|
-
await this.remote.set(data.id, data.value);
|
|
2630
|
+
await this.remote.set(data.id, data.value, this.own);
|
|
1422
2631
|
break;
|
|
1423
2632
|
case "delete":
|
|
1424
|
-
await this.remote.delete(data.id);
|
|
2633
|
+
await this.remote.delete(data.id, this.own);
|
|
1425
2634
|
break;
|
|
1426
2635
|
case "setBatch":
|
|
1427
|
-
await this.remote.setBatch(data.items);
|
|
2636
|
+
await this.remote.setBatch(data.items, this.own);
|
|
1428
2637
|
break;
|
|
1429
2638
|
case "deleteBatch":
|
|
1430
|
-
await this.remote.deleteBatch(data.ids);
|
|
2639
|
+
await this.remote.deleteBatch(data.ids, this.own);
|
|
1431
2640
|
break;
|
|
1432
2641
|
case "clear":
|
|
1433
|
-
await this.remote.clear();
|
|
2642
|
+
await this.remote.clear(this.own);
|
|
1434
2643
|
break;
|
|
1435
2644
|
}
|
|
1436
2645
|
}
|