@procedures/store 0.8.0 → 0.9.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 +62 -0
- package/README.md +88 -0
- 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 +84 -11
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +148 -25
- 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/hybrid.d.ts +367 -20
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +1295 -206
- 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 +210 -29
- package/src/storage/feed.ts +251 -0
- package/src/storage/hybrid.ts +1404 -211
- 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
|
@@ -21,12 +21,122 @@
|
|
|
21
21
|
* - A full sync reads the local ids with `getIds()`, so it does not copy each local item.
|
|
22
22
|
* - A pull reads a local item only when the result depends on it. With `versionOf`, the storage
|
|
23
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.
|
|
24
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).
|
|
25
41
|
*/
|
|
26
|
-
import { InMemoryStorage } from "@procedures/collections";
|
|
27
|
-
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";
|
|
28
46
|
import { MemoryQueueStore, idsOf, withoutId } from "./queue.js";
|
|
47
|
+
import { isSyncState } from "./sync-state.js";
|
|
29
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
|
+
}
|
|
30
140
|
/**
|
|
31
141
|
* The remote did not take a write of the write-through mode. The write is in the local cache and
|
|
32
142
|
* in the queue, and the storage sends it later (roadmap R2). `cause` is the error of the remote.
|
|
@@ -94,6 +204,17 @@ export class ModeSwitchError extends Error {
|
|
|
94
204
|
this.to = to;
|
|
95
205
|
}
|
|
96
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
|
+
}
|
|
97
218
|
const defaultIsUnavailable = (error) => {
|
|
98
219
|
if (typeof error !== "object" || error === null)
|
|
99
220
|
return true;
|
|
@@ -112,6 +233,184 @@ function checkMode(mode) {
|
|
|
112
233
|
}
|
|
113
234
|
return mode;
|
|
114
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
|
+
}
|
|
115
414
|
/**
|
|
116
415
|
* Hybrid storage combining local cache with remote persistence.
|
|
117
416
|
*
|
|
@@ -153,14 +452,25 @@ export class HybridStorage {
|
|
|
153
452
|
storeDirty = false;
|
|
154
453
|
/** The calls of the queue store, one at a time */
|
|
155
454
|
storeChain = Promise.resolve();
|
|
156
|
-
/** The push timer of the write-back mode */
|
|
157
|
-
|
|
158
|
-
/** The periodic pull */
|
|
159
|
-
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;
|
|
160
459
|
/** True while pushQueue sends the queue: the first operation can be on its way */
|
|
161
460
|
pushing = false;
|
|
162
461
|
/** The timer that sends the queue of the write-through mode after a failure */
|
|
163
|
-
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;
|
|
164
474
|
/** The load of the queue. The writes wait for it. */
|
|
165
475
|
loaded;
|
|
166
476
|
/** The load and the first sync. The reads wait for it, so a read never sees an empty cache by mistake. */
|
|
@@ -187,7 +497,7 @@ export class HybridStorage {
|
|
|
187
497
|
/** True when the remote has `getChangesSince` but its server does not know `changes` */
|
|
188
498
|
changesUnsupported = false;
|
|
189
499
|
/** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
|
|
190
|
-
bases = new
|
|
500
|
+
bases = new BaseVersions();
|
|
191
501
|
/**
|
|
192
502
|
* With `versionOf`: the version of each local item that the storage wrote or read. A pull does
|
|
193
503
|
* not read an item whose version it knows. The version of an id that is not here is unknown.
|
|
@@ -198,6 +508,46 @@ export class HybridStorage {
|
|
|
198
508
|
localWriteCount = 0;
|
|
199
509
|
/** The number of clears of the local copy: a clear cancels the records of the writes that run */
|
|
200
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;
|
|
201
551
|
stats = {
|
|
202
552
|
cacheHits: 0,
|
|
203
553
|
cacheMisses: 0,
|
|
@@ -221,12 +571,533 @@ export class HybridStorage {
|
|
|
221
571
|
offlineQueue: options.offlineQueue ?? true,
|
|
222
572
|
isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
|
|
223
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
|
+
}
|
|
224
628
|
// The queue first, then the first sync. The reads wait for both (roadmap R2, R28).
|
|
225
629
|
this.loaded = this.loadQueue();
|
|
226
630
|
this.ready = this.loaded.then(() => this.initialSync());
|
|
227
631
|
this.updateTimers();
|
|
228
632
|
}
|
|
229
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
|
+
//
|
|
230
1101
|
// ═══ Modes ═══
|
|
231
1102
|
//
|
|
232
1103
|
/** The mode of the storage now. */
|
|
@@ -289,58 +1160,40 @@ export class HybridStorage {
|
|
|
289
1160
|
}
|
|
290
1161
|
this.mode = target;
|
|
291
1162
|
this.updateTimers();
|
|
1163
|
+
this.notify();
|
|
1164
|
+
this.saveState();
|
|
292
1165
|
}
|
|
293
1166
|
/** This method drops the local copy. A read in flight does not fill it again. */
|
|
294
1167
|
async dropLocal() {
|
|
295
|
-
this.
|
|
1168
|
+
this.bumpClear();
|
|
296
1169
|
this.cursor = undefined;
|
|
297
|
-
await this.localClear();
|
|
1170
|
+
await this.localClear(this.syncOrigin);
|
|
298
1171
|
}
|
|
299
1172
|
/** This method makes the local copy from the queue only, before a full sync. */
|
|
300
1173
|
async rebuildLocal() {
|
|
301
1174
|
await this.dropLocal();
|
|
302
1175
|
for (const op of this.offlineOps)
|
|
303
|
-
await this.applyToLocal(op);
|
|
1176
|
+
await this.applyToLocal(op, this.syncOrigin);
|
|
304
1177
|
}
|
|
305
|
-
/**
|
|
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
|
+
*/
|
|
306
1183
|
updateTimers() {
|
|
307
1184
|
if (this.closed)
|
|
308
1185
|
return;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
else if (!push && this.syncTimer) {
|
|
322
|
-
clearInterval(this.syncTimer);
|
|
323
|
-
this.syncTimer = null;
|
|
324
|
-
}
|
|
325
|
-
const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
|
|
326
|
-
if (pull && !this.pullTimer) {
|
|
327
|
-
this.pullTimer = setInterval(() => {
|
|
328
|
-
if (this.closed)
|
|
329
|
-
return;
|
|
330
|
-
this.pull().catch((err) => {
|
|
331
|
-
console.error("Periodic pull failed:", err);
|
|
332
|
-
});
|
|
333
|
-
}, this.options.pullInterval);
|
|
334
|
-
this.pullTimer.unref?.();
|
|
335
|
-
}
|
|
336
|
-
else if (!pull && this.pullTimer) {
|
|
337
|
-
clearInterval(this.pullTimer);
|
|
338
|
-
this.pullTimer = null;
|
|
339
|
-
}
|
|
340
|
-
if (this.mode !== "write-through" && this.drainTimer) {
|
|
341
|
-
clearTimeout(this.drainTimer);
|
|
342
|
-
this.drainTimer = null;
|
|
343
|
-
}
|
|
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();
|
|
344
1197
|
}
|
|
345
1198
|
//
|
|
346
1199
|
// ═══ Read Operations ═══
|
|
@@ -361,17 +1214,18 @@ export class HybridStorage {
|
|
|
361
1214
|
return cached;
|
|
362
1215
|
}
|
|
363
1216
|
this.stats.cacheMisses++;
|
|
364
|
-
// A pending local delete means "no item": do not read it back from the remote
|
|
365
|
-
|
|
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)) {
|
|
366
1220
|
return undefined;
|
|
367
1221
|
}
|
|
368
1222
|
const version = this.writeKey(id);
|
|
369
1223
|
try {
|
|
370
1224
|
const remote = await this.remote.get(id);
|
|
371
1225
|
this.isOnline = true;
|
|
372
|
-
if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
|
|
1226
|
+
if (this.writeKey(id) === version && !this.hasPendingOp(id) && !this.waitsForRemote(id)) {
|
|
373
1227
|
if (remote !== undefined)
|
|
374
|
-
await this.localSet(id, remote);
|
|
1228
|
+
await this.localSet(id, remote, this.syncOrigin);
|
|
375
1229
|
this.noteBase(id, remote);
|
|
376
1230
|
}
|
|
377
1231
|
return remote;
|
|
@@ -400,6 +1254,24 @@ export class HybridStorage {
|
|
|
400
1254
|
async find(predicate) {
|
|
401
1255
|
return this.run(true, () => this.mode === "remote-only" ? this.remoteRead(() => this.remote.find(predicate)) : this.local.find(predicate));
|
|
402
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
|
+
}
|
|
403
1275
|
async has(id) {
|
|
404
1276
|
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.has(id)) : this.local.has(id)));
|
|
405
1277
|
}
|
|
@@ -429,45 +1301,51 @@ export class HybridStorage {
|
|
|
429
1301
|
//
|
|
430
1302
|
/**
|
|
431
1303
|
* This method sets an item. The storage takes a copy of the value, so a later change of the
|
|
432
|
-
* 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.
|
|
433
1306
|
*/
|
|
434
|
-
async set(id, written) {
|
|
1307
|
+
async set(id, written, options) {
|
|
435
1308
|
const value = structuredClone(written);
|
|
436
|
-
|
|
1309
|
+
const origin = options?.origin;
|
|
1310
|
+
await this.write("set", { id, value }, [id], origin, async () => {
|
|
437
1311
|
this.bump(id);
|
|
438
|
-
await this.localSet(id, value);
|
|
439
|
-
}, () => this.remote.set(id, value));
|
|
1312
|
+
await this.localSet(id, value, origin);
|
|
1313
|
+
}, () => this.remote.set(id, value, this.own));
|
|
440
1314
|
}
|
|
441
|
-
async delete(id) {
|
|
442
|
-
|
|
1315
|
+
async delete(id, options) {
|
|
1316
|
+
const origin = options?.origin;
|
|
1317
|
+
return this.write("delete", { id }, [id], origin, async () => {
|
|
443
1318
|
this.bump(id);
|
|
444
|
-
return this.localDelete(id);
|
|
445
|
-
}, () => this.remote.delete(id));
|
|
1319
|
+
return this.localDelete(id, origin);
|
|
1320
|
+
}, () => this.remote.delete(id, this.own));
|
|
446
1321
|
}
|
|
447
|
-
async clear() {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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));
|
|
452
1328
|
}
|
|
453
1329
|
//
|
|
454
1330
|
// ═══ Bulk Operations ═══
|
|
455
1331
|
//
|
|
456
1332
|
/** This method sets several items. The storage takes a copy of each value, as `set` does. */
|
|
457
|
-
async setBatch(written) {
|
|
1333
|
+
async setBatch(written, options) {
|
|
458
1334
|
const items = written.map(([id, value]) => [id, structuredClone(value)]);
|
|
459
|
-
|
|
1335
|
+
const origin = options?.origin;
|
|
1336
|
+
await this.write("setBatch", { items }, items.map(([id]) => id), origin, async () => {
|
|
460
1337
|
for (const [id] of items)
|
|
461
1338
|
this.bump(id);
|
|
462
|
-
await this.localSetBatch(items);
|
|
463
|
-
}, () => this.remote.setBatch(items));
|
|
1339
|
+
await this.localSetBatch(items, origin);
|
|
1340
|
+
}, () => this.remote.setBatch(items, this.own));
|
|
464
1341
|
}
|
|
465
|
-
async deleteBatch(ids) {
|
|
466
|
-
|
|
1342
|
+
async deleteBatch(ids, options) {
|
|
1343
|
+
const origin = options?.origin;
|
|
1344
|
+
return this.write("deleteBatch", { ids }, ids, origin, async () => {
|
|
467
1345
|
for (const id of ids)
|
|
468
1346
|
this.bump(id);
|
|
469
|
-
return this.localDeleteBatch(ids);
|
|
470
|
-
}, () => this.remote.deleteBatch(ids));
|
|
1347
|
+
return this.localDeleteBatch(ids, origin);
|
|
1348
|
+
}, () => this.remote.deleteBatch(ids, this.own));
|
|
471
1349
|
}
|
|
472
1350
|
/**
|
|
473
1351
|
* One write, in the mode of the storage. The result is the result of the local copy, or of the
|
|
@@ -486,14 +1364,18 @@ export class HybridStorage {
|
|
|
486
1364
|
* queue, a second try queued the operation again, and a full queue dropped the oldest operation
|
|
487
1365
|
* without a word (roadmap R2).
|
|
488
1366
|
*/
|
|
489
|
-
write(type, data, ids, applyLocal, applyRemote) {
|
|
1367
|
+
write(type, data, ids, origin, applyLocal, applyRemote) {
|
|
490
1368
|
return this.run(false, async () => {
|
|
491
1369
|
const op = { type, data, timestamp: Date.now() };
|
|
492
1370
|
if (this.mode === "remote-only") {
|
|
493
1371
|
const send = async () => {
|
|
494
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;
|
|
495
1375
|
const result = await applyRemote();
|
|
496
1376
|
this.noteRemoteWrite(op);
|
|
1377
|
+
if (this.observers.size > 0)
|
|
1378
|
+
this.emit(remoteChangesOf(op, result, existing, origin));
|
|
497
1379
|
return result;
|
|
498
1380
|
};
|
|
499
1381
|
try {
|
|
@@ -515,53 +1397,188 @@ export class HybridStorage {
|
|
|
515
1397
|
if (this.mode === "write-back" || this.mode === "local-only") {
|
|
516
1398
|
this.ensureQueueRoom(op);
|
|
517
1399
|
this.attachBase(op);
|
|
518
|
-
const undo = await this.snapshot(ids);
|
|
1400
|
+
const undo = await this.snapshot(ids, origin);
|
|
519
1401
|
const result = await applyLocal();
|
|
1402
|
+
this.noteOwnWrite(op);
|
|
520
1403
|
await this.enqueue(op, undo);
|
|
521
1404
|
return result;
|
|
522
1405
|
}
|
|
523
|
-
const undo = await this.snapshot(ids);
|
|
524
|
-
|
|
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);
|
|
525
1410
|
try {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 {
|
|
531
1425
|
await this.ensureStoreClean();
|
|
1426
|
+
this.echoSent(op);
|
|
532
1427
|
await applyRemote();
|
|
533
|
-
}
|
|
1428
|
+
}
|
|
1429
|
+
this.isOnline = true;
|
|
1430
|
+
this.noteRemoteWrite(op);
|
|
534
1431
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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;
|
|
538
1446
|
}
|
|
539
|
-
|
|
540
|
-
this.noteRemoteWrite(op);
|
|
1447
|
+
return result;
|
|
541
1448
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
547
1560
|
this.isOnline = false;
|
|
548
|
-
console.error(`Remote ${type} failed: the operation waits in the queue`);
|
|
549
|
-
this.attachBase(op);
|
|
550
|
-
await this.enqueue(op, undo);
|
|
551
|
-
this.scheduleDrain();
|
|
552
|
-
throw new PendingWriteError(type, error);
|
|
553
1561
|
}
|
|
554
|
-
await undo();
|
|
555
|
-
throw error;
|
|
556
1562
|
}
|
|
557
|
-
|
|
558
|
-
|
|
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);
|
|
559
1576
|
}
|
|
560
1577
|
/**
|
|
561
1578
|
* The local values of the ids before a write, and a function that puts them back. It puts back
|
|
562
1579
|
* only the ids that no later write changed.
|
|
563
1580
|
*/
|
|
564
|
-
async snapshot(ids) {
|
|
1581
|
+
async snapshot(ids, origin) {
|
|
565
1582
|
const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
|
|
566
1583
|
const touched = ids === "*" ? [...before.keys()] : ids;
|
|
567
1584
|
// The versions after the write: this write adds one to each id (clear adds to clearVersion)
|
|
@@ -575,9 +1592,9 @@ export class HybridStorage {
|
|
|
575
1592
|
continue;
|
|
576
1593
|
const value = before.get(id);
|
|
577
1594
|
if (value === undefined)
|
|
578
|
-
await this.localDelete(id);
|
|
1595
|
+
await this.localDelete(id, origin);
|
|
579
1596
|
else
|
|
580
|
-
await this.localSet(id, value);
|
|
1597
|
+
await this.localSet(id, value, origin);
|
|
581
1598
|
}
|
|
582
1599
|
};
|
|
583
1600
|
}
|
|
@@ -628,25 +1645,25 @@ export class HybridStorage {
|
|
|
628
1645
|
await this.syncToRemote();
|
|
629
1646
|
await this.syncFromRemote();
|
|
630
1647
|
}
|
|
631
|
-
/** The pull of `syncFromRemote()`, after the syncs that run. */
|
|
1648
|
+
/** The pull of `syncFromRemote()`, after the syncs that run. The status keeps its failure. */
|
|
632
1649
|
pull() {
|
|
633
|
-
return this.serialize(async () => {
|
|
1650
|
+
return this.serialize(() => this.track("pull", async () => {
|
|
634
1651
|
await this.loaded;
|
|
635
1652
|
if (this.loadError)
|
|
636
1653
|
throw this.loadError;
|
|
637
1654
|
if (this.mode === "write-through" || this.mode === "write-back")
|
|
638
1655
|
await this.pullRemote(false);
|
|
639
|
-
});
|
|
1656
|
+
}));
|
|
640
1657
|
}
|
|
641
|
-
/** The push of `syncToRemote()`, after the syncs that run. */
|
|
1658
|
+
/** The push of `syncToRemote()`, after the syncs that run. The status keeps its failure. */
|
|
642
1659
|
push() {
|
|
643
|
-
return this.serialize(async () => {
|
|
1660
|
+
return this.serialize(() => this.track("push", async () => {
|
|
644
1661
|
await this.loaded;
|
|
645
1662
|
if (this.loadError)
|
|
646
1663
|
throw this.loadError;
|
|
647
1664
|
if (this.mode !== "local-only")
|
|
648
1665
|
await this.pushQueue();
|
|
649
|
-
});
|
|
1666
|
+
}));
|
|
650
1667
|
}
|
|
651
1668
|
/** The error of an operation after `close()`. */
|
|
652
1669
|
closedError() {
|
|
@@ -674,38 +1691,39 @@ export class HybridStorage {
|
|
|
674
1691
|
}
|
|
675
1692
|
/** The work of `close()`. */
|
|
676
1693
|
async shutDown() {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
this.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
this.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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)
|
|
695
1717
|
try {
|
|
696
|
-
await this.
|
|
1718
|
+
await this.remote.close();
|
|
697
1719
|
}
|
|
698
|
-
|
|
699
|
-
|
|
1720
|
+
finally {
|
|
1721
|
+
await this.local.close();
|
|
700
1722
|
}
|
|
701
|
-
});
|
|
702
|
-
await this.storeChain;
|
|
703
|
-
// Close remote storage (an ApiStorage leaves the caller's client open)
|
|
704
|
-
try {
|
|
705
|
-
await this.remote.close();
|
|
706
1723
|
}
|
|
707
1724
|
finally {
|
|
708
|
-
|
|
1725
|
+
this.statusListeners.clear();
|
|
1726
|
+
this.observers.clear();
|
|
709
1727
|
}
|
|
710
1728
|
}
|
|
711
1729
|
/**
|
|
@@ -769,6 +1787,10 @@ export class HybridStorage {
|
|
|
769
1787
|
for (const resolve of this.idleWaiters.splice(0))
|
|
770
1788
|
resolve();
|
|
771
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();
|
|
772
1794
|
}
|
|
773
1795
|
}
|
|
774
1796
|
/** A promise that resolves when no operation runs. */
|
|
@@ -779,13 +1801,17 @@ export class HybridStorage {
|
|
|
779
1801
|
}
|
|
780
1802
|
/** This method loads the queue, and puts its writes in the local copy. */
|
|
781
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();
|
|
782
1806
|
let ops;
|
|
783
1807
|
try {
|
|
784
1808
|
ops = await this.queueStore.load();
|
|
785
1809
|
}
|
|
786
1810
|
catch (error) {
|
|
1811
|
+
// The storage refuses each operation. The status shows the failure.
|
|
787
1812
|
this.loadError = new QueueStoreError("load", error);
|
|
788
|
-
|
|
1813
|
+
this.fail("load", this.loadError);
|
|
1814
|
+
this.captured = undefined;
|
|
789
1815
|
return;
|
|
790
1816
|
}
|
|
791
1817
|
this.offlineOps = ops.slice();
|
|
@@ -797,12 +1823,17 @@ export class HybridStorage {
|
|
|
797
1823
|
}
|
|
798
1824
|
// A durable local copy can hold the operation already: each operation gives the same
|
|
799
1825
|
// result two times
|
|
800
|
-
await this.applyToLocal(op);
|
|
1826
|
+
await this.applyToLocal(op, this.syncOrigin);
|
|
801
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();
|
|
802
1832
|
}
|
|
803
1833
|
catch (error) {
|
|
804
1834
|
this.loadError = new Error("The local copy could not take the loaded queue: the storage refuses each operation", { cause: error });
|
|
805
|
-
|
|
1835
|
+
this.fail("load", this.loadError);
|
|
1836
|
+
this.captured = undefined;
|
|
806
1837
|
}
|
|
807
1838
|
}
|
|
808
1839
|
/** The first sync: send the loaded queue, then read the remote. */
|
|
@@ -811,38 +1842,34 @@ export class HybridStorage {
|
|
|
811
1842
|
if (this.loadError || this.closed)
|
|
812
1843
|
return;
|
|
813
1844
|
if (this.options.syncOnInit && this.mode !== "local-only") {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
});
|
|
818
|
-
}
|
|
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);
|
|
819
1848
|
if (!this.closed && (this.mode === "write-through" || this.mode === "write-back")) {
|
|
820
|
-
await this.pull().catch((
|
|
821
|
-
console.error("Failed to sync on init:", err);
|
|
822
|
-
});
|
|
1849
|
+
await this.pull().catch(() => undefined);
|
|
823
1850
|
}
|
|
824
1851
|
}
|
|
825
1852
|
if (this.offlineOps.length > 0)
|
|
826
1853
|
this.scheduleDrain();
|
|
827
1854
|
}
|
|
828
|
-
/** This method applies a queued operation to the local copy. */
|
|
829
|
-
async applyToLocal(op) {
|
|
1855
|
+
/** This method applies a queued operation to the local copy. The observers get the origin. */
|
|
1856
|
+
async applyToLocal(op, origin) {
|
|
830
1857
|
const data = op.data;
|
|
831
1858
|
switch (op.type) {
|
|
832
1859
|
case "set":
|
|
833
|
-
await this.localSet(data.id, data.value);
|
|
1860
|
+
await this.localSet(data.id, data.value, origin);
|
|
834
1861
|
break;
|
|
835
1862
|
case "delete":
|
|
836
|
-
await this.localDelete(data.id);
|
|
1863
|
+
await this.localDelete(data.id, origin);
|
|
837
1864
|
break;
|
|
838
1865
|
case "setBatch":
|
|
839
|
-
await this.localSetBatch(data.items);
|
|
1866
|
+
await this.localSetBatch(data.items, origin);
|
|
840
1867
|
break;
|
|
841
1868
|
case "deleteBatch":
|
|
842
|
-
await this.localDeleteBatch(data.ids);
|
|
1869
|
+
await this.localDeleteBatch(data.ids, origin);
|
|
843
1870
|
break;
|
|
844
1871
|
case "clear":
|
|
845
|
-
await this.localClear();
|
|
1872
|
+
await this.localClear(origin);
|
|
846
1873
|
break;
|
|
847
1874
|
}
|
|
848
1875
|
}
|
|
@@ -913,22 +1940,55 @@ export class HybridStorage {
|
|
|
913
1940
|
return undefined;
|
|
914
1941
|
}
|
|
915
1942
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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) {
|
|
929
1970
|
this.localClears++;
|
|
930
1971
|
this.localVersions.clear();
|
|
931
|
-
|
|
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;
|
|
932
1992
|
}
|
|
933
1993
|
/**
|
|
934
1994
|
* One pull. `full`: ask for all items (after the local copy was dropped).
|
|
@@ -938,10 +1998,8 @@ export class HybridStorage {
|
|
|
938
1998
|
* comes again at the next pull.
|
|
939
1999
|
*/
|
|
940
2000
|
async pullRemote(full) {
|
|
941
|
-
|
|
942
|
-
const
|
|
943
|
-
// True when a local write changed the id after the sync started
|
|
944
|
-
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;
|
|
945
2003
|
let changes;
|
|
946
2004
|
try {
|
|
947
2005
|
changes = await this.fetchChanges(full);
|
|
@@ -951,6 +2009,14 @@ export class HybridStorage {
|
|
|
951
2009
|
this.isOnline = false;
|
|
952
2010
|
throw error;
|
|
953
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) {
|
|
954
2020
|
const conflicts = [];
|
|
955
2021
|
let queueChanged = false;
|
|
956
2022
|
const remoteIds = new Set();
|
|
@@ -968,9 +2034,8 @@ export class HybridStorage {
|
|
|
968
2034
|
}
|
|
969
2035
|
if (queueChanged) {
|
|
970
2036
|
this.storeDirty = true;
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
});
|
|
2037
|
+
// The status gets the failure of the queue store. A later direct write repairs the store first.
|
|
2038
|
+
await this.persistAll().catch(() => undefined);
|
|
974
2039
|
}
|
|
975
2040
|
this.stats.syncCount++;
|
|
976
2041
|
this.isOnline = true;
|
|
@@ -978,6 +2043,7 @@ export class HybridStorage {
|
|
|
978
2043
|
throw new ConflictError(conflicts);
|
|
979
2044
|
if (changes.version !== undefined)
|
|
980
2045
|
this.cursor = changes.version;
|
|
2046
|
+
this.lastSync = Date.now();
|
|
981
2047
|
}
|
|
982
2048
|
/**
|
|
983
2049
|
* The remote changes: since the cursor from `getChangesSince` when the remote has it, or else
|
|
@@ -1011,6 +2077,14 @@ export class HybridStorage {
|
|
|
1011
2077
|
if (changedLocally(id))
|
|
1012
2078
|
return false;
|
|
1013
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;
|
|
1014
2088
|
const knownVersion = remoteVersion === undefined ? undefined : this.localVersions.get(id);
|
|
1015
2089
|
if (this.hasPendingOp(id)) {
|
|
1016
2090
|
// Without versions, the queued write wins, as before
|
|
@@ -1037,7 +2111,7 @@ export class HybridStorage {
|
|
|
1037
2111
|
if (knownVersion !== undefined) {
|
|
1038
2112
|
// Only the remote changed: it is not a conflict. The version of the local item is known.
|
|
1039
2113
|
if (knownVersion !== remoteVersion)
|
|
1040
|
-
await this.localSet(id, item);
|
|
2114
|
+
await this.localSet(id, item, this.syncOrigin);
|
|
1041
2115
|
this.bases.set(id, remoteVersion);
|
|
1042
2116
|
return false;
|
|
1043
2117
|
}
|
|
@@ -1046,12 +2120,12 @@ export class HybridStorage {
|
|
|
1046
2120
|
return false;
|
|
1047
2121
|
const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
|
|
1048
2122
|
if (localItem === undefined) {
|
|
1049
|
-
await this.localSet(id, item);
|
|
2123
|
+
await this.localSet(id, item, this.syncOrigin);
|
|
1050
2124
|
}
|
|
1051
2125
|
else if (remoteVersion !== undefined && localVersion !== undefined) {
|
|
1052
2126
|
// Only the remote changed: it is not a conflict
|
|
1053
2127
|
if (localVersion !== remoteVersion)
|
|
1054
|
-
await this.localSet(id, item);
|
|
2128
|
+
await this.localSet(id, item, this.syncOrigin);
|
|
1055
2129
|
}
|
|
1056
2130
|
else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
|
|
1057
2131
|
// Conflict - resolve according to strategy
|
|
@@ -1061,7 +2135,7 @@ export class HybridStorage {
|
|
|
1061
2135
|
return false;
|
|
1062
2136
|
}
|
|
1063
2137
|
const resolved = this.resolveConflict(localItem, item);
|
|
1064
|
-
await this.localSet(id, resolved);
|
|
2138
|
+
await this.localSet(id, resolved, this.syncOrigin);
|
|
1065
2139
|
this.stats.conflicts++;
|
|
1066
2140
|
}
|
|
1067
2141
|
if (remoteVersion !== undefined)
|
|
@@ -1072,8 +2146,14 @@ export class HybridStorage {
|
|
|
1072
2146
|
async pullDelete(id, changedLocally, conflicts) {
|
|
1073
2147
|
if (changedLocally(id))
|
|
1074
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;
|
|
1075
2155
|
if (!this.hasPendingOp(id)) {
|
|
1076
|
-
await this.localDelete(id);
|
|
2156
|
+
await this.localDelete(id, this.syncOrigin);
|
|
1077
2157
|
if (this.options.versionOf)
|
|
1078
2158
|
this.bases.set(id, null);
|
|
1079
2159
|
return false;
|
|
@@ -1096,7 +2176,7 @@ export class HybridStorage {
|
|
|
1096
2176
|
case "remote":
|
|
1097
2177
|
if (!this.dropQueued(id))
|
|
1098
2178
|
break;
|
|
1099
|
-
await this.localDelete(id);
|
|
2179
|
+
await this.localDelete(id, this.syncOrigin);
|
|
1100
2180
|
this.bases.set(id, null);
|
|
1101
2181
|
return true;
|
|
1102
2182
|
case "error":
|
|
@@ -1120,7 +2200,7 @@ export class HybridStorage {
|
|
|
1120
2200
|
// The remote value replaces the queued write. A queued clear cannot lose one id.
|
|
1121
2201
|
if (!this.dropQueued(id))
|
|
1122
2202
|
break;
|
|
1123
|
-
await this.localSet(id, remoteItem);
|
|
2203
|
+
await this.localSet(id, remoteItem, this.syncOrigin);
|
|
1124
2204
|
this.bases.set(id, remoteVersion);
|
|
1125
2205
|
return true;
|
|
1126
2206
|
case "merge": {
|
|
@@ -1129,7 +2209,7 @@ export class HybridStorage {
|
|
|
1129
2209
|
const merged = this.resolveConflict(localItem, remoteItem);
|
|
1130
2210
|
if (!this.dropQueued(id))
|
|
1131
2211
|
break;
|
|
1132
|
-
await this.localSet(id, merged);
|
|
2212
|
+
await this.localSet(id, merged, this.syncOrigin);
|
|
1133
2213
|
this.bases.set(id, remoteVersion);
|
|
1134
2214
|
this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
|
|
1135
2215
|
return true;
|
|
@@ -1211,6 +2291,7 @@ export class HybridStorage {
|
|
|
1211
2291
|
continue;
|
|
1212
2292
|
}
|
|
1213
2293
|
try {
|
|
2294
|
+
this.echoSent(op);
|
|
1214
2295
|
await this.executeOperation(op);
|
|
1215
2296
|
}
|
|
1216
2297
|
catch (error) {
|
|
@@ -1231,10 +2312,9 @@ export class HybridStorage {
|
|
|
1231
2312
|
finally {
|
|
1232
2313
|
this.pushing = false;
|
|
1233
2314
|
if (sent > 0) {
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
});
|
|
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);
|
|
1238
2318
|
}
|
|
1239
2319
|
}
|
|
1240
2320
|
}
|
|
@@ -1246,6 +2326,16 @@ export class HybridStorage {
|
|
|
1246
2326
|
}
|
|
1247
2327
|
bump(id) {
|
|
1248
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;
|
|
1249
2339
|
}
|
|
1250
2340
|
/** The count of local writes of an id: it changes with each write and each clear. */
|
|
1251
2341
|
writeKey(id) {
|
|
@@ -1446,12 +2536,15 @@ export class HybridStorage {
|
|
|
1446
2536
|
this.unpersisted.delete(op);
|
|
1447
2537
|
this.storeDirty = false;
|
|
1448
2538
|
}
|
|
2539
|
+
this.succeed("store");
|
|
1449
2540
|
}
|
|
1450
2541
|
catch (error) {
|
|
1451
2542
|
this.storeDirty = true;
|
|
1452
2543
|
this.unpersisted.delete(op);
|
|
1453
2544
|
rollback();
|
|
1454
|
-
|
|
2545
|
+
const failure = new QueueStoreError("write", error);
|
|
2546
|
+
this.fail("store", failure);
|
|
2547
|
+
throw failure;
|
|
1455
2548
|
}
|
|
1456
2549
|
});
|
|
1457
2550
|
this.storeChain = run.catch(() => undefined);
|
|
@@ -1471,10 +2564,13 @@ export class HybridStorage {
|
|
|
1471
2564
|
for (const kept of snapshot)
|
|
1472
2565
|
this.unpersisted.delete(kept);
|
|
1473
2566
|
this.storeDirty = false;
|
|
2567
|
+
this.succeed("store");
|
|
1474
2568
|
}
|
|
1475
2569
|
catch (error) {
|
|
1476
2570
|
this.storeDirty = true;
|
|
1477
|
-
|
|
2571
|
+
const failure = new QueueStoreError("write", error);
|
|
2572
|
+
this.fail("store", failure);
|
|
2573
|
+
throw failure;
|
|
1478
2574
|
}
|
|
1479
2575
|
});
|
|
1480
2576
|
this.storeChain = run.catch(() => undefined);
|
|
@@ -1517,19 +2613,12 @@ export class HybridStorage {
|
|
|
1517
2613
|
}
|
|
1518
2614
|
/**
|
|
1519
2615
|
* In write-through mode, a timer sends the queue after a failure, and again after each failed
|
|
1520
|
-
* send, until the queue is empty.
|
|
2616
|
+
* send, until the queue is empty. The delay after a failed send grows with the backoff.
|
|
1521
2617
|
*/
|
|
1522
2618
|
scheduleDrain() {
|
|
1523
|
-
if (this.
|
|
2619
|
+
if (this.closed || this.mode !== "write-through")
|
|
1524
2620
|
return;
|
|
1525
|
-
|
|
1526
|
-
this.drainTimer = setTimeout(() => {
|
|
1527
|
-
this.drainTimer = null;
|
|
1528
|
-
if (this.closed)
|
|
1529
|
-
return;
|
|
1530
|
-
this.push().catch(() => this.scheduleDrain());
|
|
1531
|
-
}, delay);
|
|
1532
|
-
this.drainTimer.unref?.();
|
|
2621
|
+
this.drainTimer.start();
|
|
1533
2622
|
}
|
|
1534
2623
|
/**
|
|
1535
2624
|
* Execute a queued operation against remote storage.
|
|
@@ -1538,19 +2627,19 @@ export class HybridStorage {
|
|
|
1538
2627
|
const data = op.data;
|
|
1539
2628
|
switch (op.type) {
|
|
1540
2629
|
case "set":
|
|
1541
|
-
await this.remote.set(data.id, data.value);
|
|
2630
|
+
await this.remote.set(data.id, data.value, this.own);
|
|
1542
2631
|
break;
|
|
1543
2632
|
case "delete":
|
|
1544
|
-
await this.remote.delete(data.id);
|
|
2633
|
+
await this.remote.delete(data.id, this.own);
|
|
1545
2634
|
break;
|
|
1546
2635
|
case "setBatch":
|
|
1547
|
-
await this.remote.setBatch(data.items);
|
|
2636
|
+
await this.remote.setBatch(data.items, this.own);
|
|
1548
2637
|
break;
|
|
1549
2638
|
case "deleteBatch":
|
|
1550
|
-
await this.remote.deleteBatch(data.ids);
|
|
2639
|
+
await this.remote.deleteBatch(data.ids, this.own);
|
|
1551
2640
|
break;
|
|
1552
2641
|
case "clear":
|
|
1553
|
-
await this.remote.clear();
|
|
2642
|
+
await this.remote.clear(this.own);
|
|
1554
2643
|
break;
|
|
1555
2644
|
}
|
|
1556
2645
|
}
|