@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/src/storage/hybrid.ts
CHANGED
|
@@ -21,13 +21,49 @@
|
|
|
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
42
|
|
|
27
|
-
import type {
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
import type {
|
|
44
|
+
CollectionStorage,
|
|
45
|
+
StorageChangeBatch,
|
|
46
|
+
StorageChangeEvent,
|
|
47
|
+
StorageChanges,
|
|
48
|
+
StorageMetadata,
|
|
49
|
+
StorageObserver,
|
|
50
|
+
StorageWriteOptions,
|
|
51
|
+
} from "@procedures/collections";
|
|
52
|
+
import { InMemoryStorage, StorageListeners, type ListenerErrorHandler } from "@procedures/collections";
|
|
53
|
+
import {
|
|
54
|
+
queryEntries,
|
|
55
|
+
queryOptionsProblems,
|
|
56
|
+
selectorProblems,
|
|
57
|
+
type StorageQueryOptions,
|
|
58
|
+
type StorageQueryPolicy,
|
|
59
|
+
type StorageSelector,
|
|
60
|
+
} from "@procedures/client/query";
|
|
61
|
+
import { optionalMs, realClock, type Cancel, type Clock, type Duration } from "@procedures/client";
|
|
62
|
+
import { backoffDelay } from "@mark1russell7/service/backoff";
|
|
30
63
|
import { MemoryQueueStore, idsOf, withoutId, type QueuedOperation, type QueueStore } from "./queue.js";
|
|
64
|
+
import type { SyncError, SyncStatus } from "./types.js";
|
|
65
|
+
import type { RemoteChangeFeed, RemoteChangeHint, RemoteChanges } from "./feed.js";
|
|
66
|
+
import { isSyncState, type SyncStateStore } from "./sync-state.js";
|
|
31
67
|
|
|
32
68
|
/**
|
|
33
69
|
* Conflict resolution strategy.
|
|
@@ -168,11 +204,223 @@ export interface HybridStorageOptions {
|
|
|
168
204
|
* The storage of the local copy (roadmap R45). A durable storage, for example `SqliteStorage`
|
|
169
205
|
* of `@procedures/sqlite`, keeps the local copy after a restart. Then a new storage in
|
|
170
206
|
* `local-only` mode reads the items of the last run. Give it with a durable `queueStore`.
|
|
171
|
-
* `close()` closes it.
|
|
172
|
-
* not see a write from outside.
|
|
207
|
+
* `close()` closes it. Without `capture`, only the storage writes to the local copy: with
|
|
208
|
+
* `versionOf`, a pull does not see a write from outside.
|
|
173
209
|
* @default a new `InMemoryStorage`
|
|
174
210
|
*/
|
|
175
211
|
local?: LocalCopyStorage<any>;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The capture of the writes of the application to the local copy (roadmap CLI-21).
|
|
215
|
+
*
|
|
216
|
+
* - `"none"`: only the storage writes to the local copy.
|
|
217
|
+
* - `"observe"`: the storage observes the local copy, and it queues each change whose origin is
|
|
218
|
+
* not `origin`. The application can then write to the local copy directly, for example with
|
|
219
|
+
* the synchronous writes of a SignalDB collection. The local copy must have `observe()`.
|
|
220
|
+
*
|
|
221
|
+
* A captured change always goes in the queue, because the application made it already: the limit
|
|
222
|
+
* `maxOfflineOps` does not refuse it. In the modes `write-through` and `remote-only`, the storage
|
|
223
|
+
* sends the queue at once.
|
|
224
|
+
* @default "none"
|
|
225
|
+
*/
|
|
226
|
+
capture?: "none" | "observe";
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The tag of the writes of this storage (roadmap CLI-21). Each write of the storage to the local
|
|
230
|
+
* copy and to the remote has it. Thus `capture` knows the writes of the storage, and the change
|
|
231
|
+
* events of a server tell which writes came from this storage.
|
|
232
|
+
* @default a new random tag: `hybrid-` and 8 characters
|
|
233
|
+
*/
|
|
234
|
+
origin?: string;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The origin of the changes that the sync makes in the local copy, in the batches of `observe()`:
|
|
238
|
+
* a pull, a delta of the feed, a fill after a read, a mode switch and the load of the queue.
|
|
239
|
+
* @default "sync"
|
|
240
|
+
*/
|
|
241
|
+
syncOrigin?: string;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The push feed of the remote changes, for example `EventsChangeFeed` (roadmap CLI-21, CLI-15).
|
|
245
|
+
* The storage subscribes in the modes `write-through` and `write-back`.
|
|
246
|
+
*
|
|
247
|
+
* - A delta whose `since` is the cursor applies without a read of the remote.
|
|
248
|
+
* - A delta of the version of the cursor changes nothing: a pull read it already.
|
|
249
|
+
* - Another delta, a gap, a lost event or a new subscription makes the storage pull the changes
|
|
250
|
+
* since its cursor. Many requests while a pull waits make one pull.
|
|
251
|
+
* - A delta that comes during a pull applies after the pull, so the pull does not erase it.
|
|
252
|
+
* - A hint without values (the default of the change events) makes a pull: the values come
|
|
253
|
+
* through the checks of a read. A hint with only deletes applies without a read.
|
|
254
|
+
* - The deltas, the hints, the pulls, the pushes and the mode switches run one at a time. A
|
|
255
|
+
* remote delta and a full sync never run at the same time.
|
|
256
|
+
*/
|
|
257
|
+
feed?: RemoteChangeFeed<any>;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The store of the sync state: the cursor and the base versions (roadmap CLI-21). A new storage
|
|
261
|
+
* with the same store asks the remote only for the changes since the cursor, and it finds the
|
|
262
|
+
* conflicts of the base versions. `IndexedDbSyncStateStore` of `@procedures/store/indexeddb`
|
|
263
|
+
* keeps the state in a browser.
|
|
264
|
+
*
|
|
265
|
+
* The state belongs to the local copy. The storage uses a loaded cursor only with the option
|
|
266
|
+
* `local` and a local copy that has items. Give it with a durable local copy.
|
|
267
|
+
*/
|
|
268
|
+
syncState?: SyncStateStore;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* With `versionOf` or a `feed`: the time that the storage keeps the mark of an own write for its
|
|
272
|
+
* echo (roadmap CLI-21). The mark is the version, or else the JSON of the value. While a newer own
|
|
273
|
+
* write of an id waits for its echo, a remote change with the mark of an older own write is stale,
|
|
274
|
+
* and the storage does not apply it. A local delete is a tombstone: an older echo does not bring
|
|
275
|
+
* the item back. The time starts again when the write goes to the remote.
|
|
276
|
+
* @default 30 seconds
|
|
277
|
+
*/
|
|
278
|
+
echoWindow?: Duration;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The clock of the timers: the push of `write-back`, the periodic pull, and the send of the queue
|
|
282
|
+
* after a failure. A test gives `fakeClock` of `@mark1russell7/service/testing`. The timers do
|
|
283
|
+
* not keep a Node process alive.
|
|
284
|
+
* @default the clock of the platform (`realClock` of `@procedures/client`)
|
|
285
|
+
*/
|
|
286
|
+
clock?: Clock;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The growth of the delays after a failure (roadmap CLI-13). After `n` failed runs of a timer in
|
|
290
|
+
* a row, its next delay is `interval × factor^n`, at most `maxDelay`, with the random part
|
|
291
|
+
* `jitter`. A run that succeeds sets the delay back to the interval.
|
|
292
|
+
*/
|
|
293
|
+
backoff?: {
|
|
294
|
+
/** @default 2 */
|
|
295
|
+
factor?: number;
|
|
296
|
+
/** The longest backoff, for example `minutes(5)`. A timer with a longer interval uses its interval. @default 5 minutes */
|
|
297
|
+
maxDelay?: Duration;
|
|
298
|
+
/** The random part of each delay after a failure, from 0 to 1. @default 0.5 */
|
|
299
|
+
jitter?: number;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The random numbers of the jitter, from 0 up to 1. A test gives a fixed generator.
|
|
304
|
+
* @default Math.random
|
|
305
|
+
*/
|
|
306
|
+
random?: () => number;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The function that gets the error of a listener of `onStatus()`. The default throws the error
|
|
310
|
+
* again in a microtask.
|
|
311
|
+
*/
|
|
312
|
+
onListenerError?: ListenerErrorHandler;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The query policy of `query()` for a call without a policy. The storage gives it to the local
|
|
316
|
+
* copy, or to the remote in `remote-only` mode. The procedure `find` gives the policy of its host.
|
|
317
|
+
* @default DEFAULT_QUERY_POLICY: no `$regex`
|
|
318
|
+
*/
|
|
319
|
+
queryPolicy?: StorageQueryPolicy;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The kinds of the timers of a `HybridStorage`. */
|
|
323
|
+
interface TimerOptions {
|
|
324
|
+
readonly clock: Clock;
|
|
325
|
+
/** The delay of a run after a run that succeeded, in milliseconds */
|
|
326
|
+
readonly interval: number;
|
|
327
|
+
readonly factor: number;
|
|
328
|
+
readonly maxDelay: number;
|
|
329
|
+
readonly jitter: number;
|
|
330
|
+
readonly random: () => number;
|
|
331
|
+
/** True: the timer runs again after a run that succeeded. False: it stops. */
|
|
332
|
+
readonly repeat: boolean;
|
|
333
|
+
readonly work: () => Promise<unknown>;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A timer of a `HybridStorage` on a clock. It runs `work` after its interval. After a failed run,
|
|
338
|
+
* the next delay grows with `backoffDelay` (roadmap CLI-13). The timer does not keep a Node
|
|
339
|
+
* process alive.
|
|
340
|
+
*/
|
|
341
|
+
class SyncTimer {
|
|
342
|
+
private cancel: Cancel | undefined;
|
|
343
|
+
private active = false;
|
|
344
|
+
/** True while `work` runs */
|
|
345
|
+
private running = false;
|
|
346
|
+
/** True when `start()` came during a run: the timer waits again after the run */
|
|
347
|
+
private again = false;
|
|
348
|
+
private failures = 0;
|
|
349
|
+
|
|
350
|
+
constructor(private readonly options: TimerOptions) {}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* This method starts the timer. A timer that waits does not start again. A start during a run
|
|
354
|
+
* makes the timer wait again after the run, also when it does not repeat.
|
|
355
|
+
*/
|
|
356
|
+
start(): void {
|
|
357
|
+
if (this.running) {
|
|
358
|
+
this.again = true;
|
|
359
|
+
if (this.active) return;
|
|
360
|
+
this.active = true;
|
|
361
|
+
this.failures = 0;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (this.active) return;
|
|
365
|
+
this.active = true;
|
|
366
|
+
this.failures = 0;
|
|
367
|
+
this.arm();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** This method stops the timer. A run that is on its way ends, and no next run starts. */
|
|
371
|
+
stop(): void {
|
|
372
|
+
this.active = false;
|
|
373
|
+
this.again = false;
|
|
374
|
+
this.cancel?.();
|
|
375
|
+
this.cancel = undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The delay of the next run: the interval, or the backoff after failures. */
|
|
379
|
+
private delay(): number {
|
|
380
|
+
const { interval, factor, maxDelay, jitter, random } = this.options;
|
|
381
|
+
if (this.failures === 0) return interval;
|
|
382
|
+
return backoffDelay(
|
|
383
|
+
{
|
|
384
|
+
base: interval,
|
|
385
|
+
factor,
|
|
386
|
+
cap: Math.max(maxDelay, interval),
|
|
387
|
+
jitter: jitter > 0 ? { kind: "subtract", fraction: jitter } : { kind: "none" },
|
|
388
|
+
},
|
|
389
|
+
this.failures + 1,
|
|
390
|
+
random,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private arm(): void {
|
|
395
|
+
const cancel = this.options.clock.after(this.delay(), () => {
|
|
396
|
+
this.cancel = undefined;
|
|
397
|
+
void this.run();
|
|
398
|
+
});
|
|
399
|
+
// The timer must not keep the process alive (deep dive DATA-9)
|
|
400
|
+
cancel.unref?.();
|
|
401
|
+
this.cancel = cancel;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async run(): Promise<void> {
|
|
405
|
+
if (!this.active) return;
|
|
406
|
+
this.running = true;
|
|
407
|
+
let failed = false;
|
|
408
|
+
try {
|
|
409
|
+
await this.options.work();
|
|
410
|
+
this.failures = 0;
|
|
411
|
+
} catch {
|
|
412
|
+
// The work reports its error in the status of the storage
|
|
413
|
+
failed = true;
|
|
414
|
+
this.failures++;
|
|
415
|
+
} finally {
|
|
416
|
+
this.running = false;
|
|
417
|
+
}
|
|
418
|
+
if (!this.active) return;
|
|
419
|
+
const again = this.again;
|
|
420
|
+
this.again = false;
|
|
421
|
+
if (failed || again || this.options.repeat) this.arm();
|
|
422
|
+
else this.active = false;
|
|
423
|
+
}
|
|
176
424
|
}
|
|
177
425
|
|
|
178
426
|
/**
|
|
@@ -242,6 +490,18 @@ export class ModeSwitchError extends Error {
|
|
|
242
490
|
}
|
|
243
491
|
}
|
|
244
492
|
|
|
493
|
+
/**
|
|
494
|
+
* The sync state store failed (roadmap CLI-21). The storage goes on: after a failed load, the first
|
|
495
|
+
* pull reads all items, and after a failed write, the next write gives the whole state.
|
|
496
|
+
*/
|
|
497
|
+
export class SyncStateError extends Error {
|
|
498
|
+
override readonly name = "SyncStateError";
|
|
499
|
+
readonly code = "SYNC_STATE";
|
|
500
|
+
constructor(action: "load" | "write", cause: unknown) {
|
|
501
|
+
super(`The sync state store could not ${action === "load" ? "load the state" : "keep the state"}`, { cause });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
245
505
|
const defaultIsUnavailable = (error: unknown): boolean => {
|
|
246
506
|
if (typeof error !== "object" || error === null) return true;
|
|
247
507
|
const { retryable, code } = error as { retryable?: unknown; code?: unknown };
|
|
@@ -267,6 +527,186 @@ function checkMode(mode: unknown): HybridMode {
|
|
|
267
527
|
/** The changes that a pull applies: from `getChangesSince`, or all entries. */
|
|
268
528
|
type PulledChanges<T> = Omit<StorageChanges<T>, "version"> & { version?: string };
|
|
269
529
|
|
|
530
|
+
/** The data of a queued operation of each type. */
|
|
531
|
+
type OperationData<T> = { id: string; value: T; ids: string[]; items: Array<[string, T]> };
|
|
532
|
+
|
|
533
|
+
/** A change with an origin, when there is one. */
|
|
534
|
+
function withOrigin<T>(change: StorageChangeEvent<T>, origin: string | undefined): StorageChangeEvent<T> {
|
|
535
|
+
return origin === undefined ? change : { ...change, origin };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** A `set` change with a copy of the value: an observer cannot change the stored item. */
|
|
539
|
+
function setChange<T>(id: string, value: T, origin: string | undefined): StorageChangeEvent<T> {
|
|
540
|
+
return withOrigin<T>({ type: "set", id, value: structuredClone(value) }, origin);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The queued operations of a batch of changes of the local copy (roadmap CLI-21). The sets in a
|
|
545
|
+
* row go in one operation, and the deletes in a row too. Each value is a copy.
|
|
546
|
+
*/
|
|
547
|
+
function operationsOf<T>(changes: readonly StorageChangeEvent<T>[]): QueuedOperation[] {
|
|
548
|
+
const ops: QueuedOperation[] = [];
|
|
549
|
+
const timestamp = Date.now();
|
|
550
|
+
let sets: Array<[string, T]> = [];
|
|
551
|
+
let deletes: string[] = [];
|
|
552
|
+
const flush = (): void => {
|
|
553
|
+
if (sets.length === 1) ops.push({ type: "set", data: { id: sets[0]![0], value: sets[0]![1] }, timestamp });
|
|
554
|
+
else if (sets.length > 1) ops.push({ type: "setBatch", data: { items: sets }, timestamp });
|
|
555
|
+
if (deletes.length === 1) ops.push({ type: "delete", data: { id: deletes[0] }, timestamp });
|
|
556
|
+
else if (deletes.length > 1) ops.push({ type: "deleteBatch", data: { ids: deletes }, timestamp });
|
|
557
|
+
sets = [];
|
|
558
|
+
deletes = [];
|
|
559
|
+
};
|
|
560
|
+
for (const change of changes) {
|
|
561
|
+
if (change.type === "set") {
|
|
562
|
+
if (deletes.length > 0) flush();
|
|
563
|
+
sets.push([change.id, structuredClone(change.value)]);
|
|
564
|
+
} else if (change.type === "delete") {
|
|
565
|
+
if (sets.length > 0) flush();
|
|
566
|
+
deletes.push(change.id);
|
|
567
|
+
} else {
|
|
568
|
+
flush();
|
|
569
|
+
ops.push({ type: "clear", data: {}, timestamp });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
flush();
|
|
573
|
+
return ops;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The batch of a write of the `remote-only` mode. A delete gives a change only for an id that
|
|
578
|
+
* existed: `existing` has the ids of a `deleteBatch`, or tells if a `clear` found items.
|
|
579
|
+
*/
|
|
580
|
+
function remoteChangesOf<T>(op: QueuedOperation, result: unknown, existing: ReadonlySet<string> | boolean | undefined, origin: string | undefined): StorageChangeEvent<T>[] {
|
|
581
|
+
const data = op.data as OperationData<T>;
|
|
582
|
+
switch (op.type) {
|
|
583
|
+
case "set":
|
|
584
|
+
return [setChange(data.id, data.value, origin)];
|
|
585
|
+
case "setBatch":
|
|
586
|
+
return data.items.map(([id, value]) => setChange(id, value, origin));
|
|
587
|
+
case "delete":
|
|
588
|
+
return result === true ? [withOrigin<T>({ type: "delete", id: data.id }, origin)] : [];
|
|
589
|
+
case "deleteBatch":
|
|
590
|
+
return [...new Set(data.ids)].filter((id) => existing instanceof Set && existing.has(id)).map((id) => withOrigin<T>({ type: "delete", id }, origin));
|
|
591
|
+
case "clear":
|
|
592
|
+
return existing === true ? [withOrigin<T>({ type: "clear" }, origin)] : [];
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** One version of an own write in the echo ledger. */
|
|
597
|
+
interface EchoEntry {
|
|
598
|
+
/** The version that the write gives, or `null` for a delete */
|
|
599
|
+
readonly version: string | null;
|
|
600
|
+
/** The time of the write, or of its send to the remote */
|
|
601
|
+
at: number;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The versions of the own writes of a `HybridStorage` that no remote change echoed yet (roadmap
|
|
606
|
+
* CLI-21, after the EchoLedger of Graph).
|
|
607
|
+
*
|
|
608
|
+
* - The ledger keeps the versions of each id in the order of the writes. It records a write when
|
|
609
|
+
* the local copy takes it, because a stale echo can come before the push.
|
|
610
|
+
* - A remote change with a version of the ledger is an echo: that version and each older version
|
|
611
|
+
* leave the ledger. When a newer version stays, the echo is stale: the newer echo comes later.
|
|
612
|
+
* - A remote change with another version is a write of another writer.
|
|
613
|
+
* - A version expires `window` milliseconds after its write, or after its send to the remote. Thus
|
|
614
|
+
* a write whose echo never comes does not hold an id long.
|
|
615
|
+
*/
|
|
616
|
+
class EchoLedger {
|
|
617
|
+
/** The versions of each id, the oldest first. The ids are in the order of their last write. */
|
|
618
|
+
private readonly entries = new Map<string, EchoEntry[]>();
|
|
619
|
+
|
|
620
|
+
constructor(
|
|
621
|
+
private readonly window: number,
|
|
622
|
+
private readonly now: () => number,
|
|
623
|
+
private readonly maxPerId = 20,
|
|
624
|
+
private readonly maxIds = 10_000,
|
|
625
|
+
) {}
|
|
626
|
+
|
|
627
|
+
/** This method records the version that an own write of an id gives. `null`: a delete. */
|
|
628
|
+
wrote(id: string, version: string | null): void {
|
|
629
|
+
const now = this.now();
|
|
630
|
+
const list = this.live(id, now);
|
|
631
|
+
list.push({ version, at: now });
|
|
632
|
+
if (list.length > this.maxPerId) list.shift();
|
|
633
|
+
// The id moves to the end: the ids stay in the order of their last write
|
|
634
|
+
this.entries.delete(id);
|
|
635
|
+
this.entries.set(id, list);
|
|
636
|
+
this.prune(now);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** This method records a delete of each id of the ledger: an own clear. */
|
|
640
|
+
cleared(): void {
|
|
641
|
+
for (const id of [...this.entries.keys()]) this.wrote(id, null);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** This method starts the time of the versions of an id again: its write goes to the remote. */
|
|
645
|
+
sent(id: string): void {
|
|
646
|
+
const list = this.entries.get(id);
|
|
647
|
+
if (!list) return;
|
|
648
|
+
const now = this.now();
|
|
649
|
+
for (const entry of list) entry.at = now;
|
|
650
|
+
this.entries.delete(id);
|
|
651
|
+
this.entries.set(id, list);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* True when a remote version of an id is a stale echo: a version of the ledger, with a newer
|
|
656
|
+
* version after it. The echo takes its version and each older version out of the ledger.
|
|
657
|
+
*/
|
|
658
|
+
isStale(id: string, version: string | null): boolean {
|
|
659
|
+
const list = this.live(id, this.now());
|
|
660
|
+
const at = list.findIndex((entry) => entry.version === version);
|
|
661
|
+
if (at < 0) return false;
|
|
662
|
+
list.splice(0, at + 1);
|
|
663
|
+
if (list.length > 0) return true;
|
|
664
|
+
this.entries.delete(id);
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The versions of an id that did not expire. */
|
|
669
|
+
private live(id: string, now: number): EchoEntry[] {
|
|
670
|
+
const list = this.entries.get(id);
|
|
671
|
+
if (!list) return [];
|
|
672
|
+
const kept = list.filter((entry) => now - entry.at < this.window);
|
|
673
|
+
if (kept.length === 0) this.entries.delete(id);
|
|
674
|
+
else this.entries.set(id, kept);
|
|
675
|
+
return kept;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** This method drops the ids whose versions expired, and the oldest ids above the limit. */
|
|
679
|
+
private prune(now: number): void {
|
|
680
|
+
for (const [id, list] of this.entries) {
|
|
681
|
+
if (this.entries.size <= this.maxIds && now - list[list.length - 1]!.at < this.window) break;
|
|
682
|
+
this.entries.delete(id);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The base versions of a `HybridStorage`: a map that keeps the ids that changed since the last
|
|
689
|
+
* write of the sync state (roadmap CLI-21). Without a sync state store, it keeps no ids.
|
|
690
|
+
*/
|
|
691
|
+
class BaseVersions extends Map<string, string | null> {
|
|
692
|
+
/** The ids that changed since the last write of the sync state */
|
|
693
|
+
readonly changed = new Set<string>();
|
|
694
|
+
/** True when the storage has a sync state store */
|
|
695
|
+
tracked = false;
|
|
696
|
+
|
|
697
|
+
override set(id: string, version: string | null): this {
|
|
698
|
+
super.set(id, version);
|
|
699
|
+
if (this.tracked) this.changed.add(id);
|
|
700
|
+
return this;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
override delete(id: string): boolean {
|
|
704
|
+
const had = super.delete(id);
|
|
705
|
+
if (had && this.tracked) this.changed.add(id);
|
|
706
|
+
return had;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
270
710
|
/**
|
|
271
711
|
* A storage for the local copy of a `HybridStorage` (roadmap R45): a collection storage that gives
|
|
272
712
|
* its entries. A durable one, for example `SqliteStorage` of `@procedures/sqlite`, keeps the local
|
|
@@ -327,14 +767,25 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
327
767
|
private storeDirty = false;
|
|
328
768
|
/** The calls of the queue store, one at a time */
|
|
329
769
|
private storeChain: Promise<unknown> = Promise.resolve();
|
|
330
|
-
/** The push timer of the write-back mode */
|
|
331
|
-
private
|
|
332
|
-
/** The periodic pull */
|
|
333
|
-
private pullTimer:
|
|
770
|
+
/** The push timer of the write-back mode, or undefined without `syncInterval` */
|
|
771
|
+
private readonly pushTimer: SyncTimer | undefined;
|
|
772
|
+
/** The periodic pull, or undefined without `pullInterval` */
|
|
773
|
+
private readonly pullTimer: SyncTimer | undefined;
|
|
334
774
|
/** True while pushQueue sends the queue: the first operation can be on its way */
|
|
335
775
|
private pushing = false;
|
|
336
776
|
/** The timer that sends the queue of the write-through mode after a failure */
|
|
337
|
-
private drainTimer:
|
|
777
|
+
private readonly drainTimer: SyncTimer;
|
|
778
|
+
/** The failures that a later success did not clear, the newest last */
|
|
779
|
+
private readonly failures = new Map<SyncError["operation"], SyncError>();
|
|
780
|
+
/** The number of pushes and pulls that run */
|
|
781
|
+
private syncs = 0;
|
|
782
|
+
/** The time of the last pull that succeeded (`Date.now()`) */
|
|
783
|
+
private lastSync: number | undefined;
|
|
784
|
+
/** The listeners of `onStatus()`, and the status that they got last */
|
|
785
|
+
private readonly statusListeners: StorageListeners<SyncStatus>;
|
|
786
|
+
private lastStatus: SyncStatus | undefined;
|
|
787
|
+
/** The query policy of a call without a policy */
|
|
788
|
+
private readonly queryPolicy: StorageQueryPolicy | undefined;
|
|
338
789
|
/** The load of the queue. The writes wait for it. */
|
|
339
790
|
private readonly loaded: Promise<void>;
|
|
340
791
|
/** The load and the first sync. The reads wait for it, so a read never sees an empty cache by mistake. */
|
|
@@ -361,7 +812,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
361
812
|
/** True when the remote has `getChangesSince` but its server does not know `changes` */
|
|
362
813
|
private changesUnsupported = false;
|
|
363
814
|
/** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
|
|
364
|
-
private readonly bases = new
|
|
815
|
+
private readonly bases = new BaseVersions();
|
|
365
816
|
/**
|
|
366
817
|
* With `versionOf`: the version of each local item that the storage wrote or read. A pull does
|
|
367
818
|
* not read an item whose version it knows. The version of an id that is not here is unknown.
|
|
@@ -372,6 +823,46 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
372
823
|
private localWriteCount = 0;
|
|
373
824
|
/** The number of clears of the local copy: a clear cancels the records of the writes that run */
|
|
374
825
|
private localClears = 0;
|
|
826
|
+
/** The observers of `observe()` */
|
|
827
|
+
private readonly observers: StorageListeners<StorageChangeBatch<T>>;
|
|
828
|
+
/** The tag of the writes of this storage, and the write options that give it */
|
|
829
|
+
private readonly origin: string;
|
|
830
|
+
private readonly own: StorageWriteOptions;
|
|
831
|
+
/** The origin of the changes that the sync makes in the local copy */
|
|
832
|
+
private readonly syncOrigin: string;
|
|
833
|
+
/** The push feed, the stop of its subscription, and the number of the subscription */
|
|
834
|
+
private readonly feed: RemoteChangeFeed<T> | undefined;
|
|
835
|
+
private feedStop: (() => void) | undefined;
|
|
836
|
+
private feedToken = 0;
|
|
837
|
+
/** True while a pull that a gap asked for waits to start */
|
|
838
|
+
private gapPull = false;
|
|
839
|
+
/** The stop of the capture of the local copy */
|
|
840
|
+
private readonly stopCapture: (() => void) | undefined;
|
|
841
|
+
/** The captured changes that came before the end of the load. Undefined after the load. */
|
|
842
|
+
private captured: StorageChangeEvent<T>[] | undefined = [];
|
|
843
|
+
/** With `versionOf`: the versions of the own writes that wait for their echo */
|
|
844
|
+
private readonly echoes: EchoLedger | undefined;
|
|
845
|
+
/** The store of the sync state, and the state of its writes */
|
|
846
|
+
private readonly stateStore: SyncStateStore | undefined;
|
|
847
|
+
private stateReset = true;
|
|
848
|
+
private savedCursor: string | undefined;
|
|
849
|
+
private stateSaving: Promise<void> | undefined;
|
|
850
|
+
private stateAgain = false;
|
|
851
|
+
/** True when the application gave the local copy */
|
|
852
|
+
private readonly localGiven: boolean;
|
|
853
|
+
/**
|
|
854
|
+
* The own writes of the `write-through` mode that wait for the remote, for each id: their number
|
|
855
|
+
* and the newest value (`undefined` for a delete). A pull does not write over these ids.
|
|
856
|
+
*/
|
|
857
|
+
private readonly inFlight = new Map<string, { count: number; value: T | undefined }>();
|
|
858
|
+
/** The number of own clears that wait for the remote: each id waits */
|
|
859
|
+
private inFlightClears = 0;
|
|
860
|
+
/** The remote values that a pull did not apply because an own write of the id waited: `undefined` for a delete */
|
|
861
|
+
private readonly deferred = new Map<string, { readonly item: T | undefined }>();
|
|
862
|
+
/** The number of the last local write, of the last write of each id, and of the last clear */
|
|
863
|
+
private writeSeq = 0;
|
|
864
|
+
private readonly lastWrite = new Map<string, number>();
|
|
865
|
+
private lastClear = 0;
|
|
375
866
|
private stats = {
|
|
376
867
|
cacheHits: 0,
|
|
377
868
|
cacheMisses: 0,
|
|
@@ -399,6 +890,57 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
399
890
|
offlineQueue: options.offlineQueue ?? true,
|
|
400
891
|
isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
|
|
401
892
|
};
|
|
893
|
+
this.statusListeners = new StorageListeners<SyncStatus>(options.onListenerError);
|
|
894
|
+
this.queryPolicy = options.queryPolicy;
|
|
895
|
+
this.origin = options.origin ?? `hybrid-${Math.random().toString(36).slice(2, 10)}`;
|
|
896
|
+
this.own = { origin: this.origin };
|
|
897
|
+
this.syncOrigin = options.syncOrigin ?? "sync";
|
|
898
|
+
this.observers = new StorageListeners<StorageChangeBatch<T>>(options.onListenerError);
|
|
899
|
+
this.feed = options.feed;
|
|
900
|
+
this.stateStore = options.syncState;
|
|
901
|
+
this.bases.tracked = this.stateStore !== undefined;
|
|
902
|
+
this.localGiven = options.local !== undefined;
|
|
903
|
+
|
|
904
|
+
// The timers on the clock, with the backoff after a failure (roadmap CLI-13)
|
|
905
|
+
const factor = options.backoff?.factor ?? 2;
|
|
906
|
+
if (!(Number.isFinite(factor) && factor >= 1)) throw new RangeError(`backoff.factor must be a number of 1 or more, not ${factor}`);
|
|
907
|
+
const jitter = options.backoff?.jitter ?? 0.5;
|
|
908
|
+
if (!(jitter >= 0 && jitter <= 1)) throw new RangeError(`backoff.jitter must be from 0 to 1, not ${jitter}`);
|
|
909
|
+
const timing = {
|
|
910
|
+
clock: options.clock ?? realClock,
|
|
911
|
+
factor,
|
|
912
|
+
maxDelay: optionalMs(options.backoff?.maxDelay, "backoff.maxDelay") ?? 300_000,
|
|
913
|
+
jitter,
|
|
914
|
+
random: options.random ?? Math.random,
|
|
915
|
+
};
|
|
916
|
+
this.pushTimer =
|
|
917
|
+
this.options.syncInterval > 0
|
|
918
|
+
? new SyncTimer({ ...timing, interval: this.options.syncInterval, repeat: true, work: () => (this.closed ? Promise.resolve() : this.push()) })
|
|
919
|
+
: undefined;
|
|
920
|
+
this.pullTimer =
|
|
921
|
+
this.options.pullInterval > 0
|
|
922
|
+
? new SyncTimer({ ...timing, interval: this.options.pullInterval, repeat: true, work: () => (this.closed ? Promise.resolve() : this.pull()) })
|
|
923
|
+
: undefined;
|
|
924
|
+
this.drainTimer = new SyncTimer({
|
|
925
|
+
...timing,
|
|
926
|
+
interval: this.options.syncInterval > 0 ? this.options.syncInterval : 5000,
|
|
927
|
+
repeat: false,
|
|
928
|
+
work: () => (this.closed ? Promise.resolve() : this.push()),
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// The echoes of the own writes: by version, or by the JSON of the value with a feed (roadmap CLI-21)
|
|
932
|
+
this.echoes = this.options.versionOf || this.feed
|
|
933
|
+
? new EchoLedger(optionalMs(options.echoWindow, "echoWindow") ?? 30_000, () => timing.clock.now())
|
|
934
|
+
: undefined;
|
|
935
|
+
const capture: unknown = options.capture ?? "none";
|
|
936
|
+
if (capture !== "none" && capture !== "observe") throw new TypeError(`Unknown capture ${JSON.stringify(capture)}: use none or observe`);
|
|
937
|
+
if (capture === "observe") {
|
|
938
|
+
if (!this.local.observe) throw new TypeError('capture: "observe" needs a local copy with observe()');
|
|
939
|
+
// The capture starts before the load: a change of the application during the load waits for it
|
|
940
|
+
this.stopCapture = this.local.observe((batch) => this.onLocalChanges(batch));
|
|
941
|
+
} else {
|
|
942
|
+
this.stopCapture = undefined;
|
|
943
|
+
}
|
|
402
944
|
|
|
403
945
|
// The queue first, then the first sync. The reads wait for both (roadmap R2, R28).
|
|
404
946
|
this.loaded = this.loadQueue();
|
|
@@ -406,6 +948,447 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
406
948
|
this.updateTimers();
|
|
407
949
|
}
|
|
408
950
|
|
|
951
|
+
//
|
|
952
|
+
// ═══ The status of the sync ═══
|
|
953
|
+
//
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* The state of the sync now (roadmap CLI-13).
|
|
957
|
+
*
|
|
958
|
+
* - `connected` is false after a remote call failed, until a remote call succeeds.
|
|
959
|
+
* - `pendingChanges` is the number of operations in the queue.
|
|
960
|
+
* - `syncing` is true while a push or a pull runs.
|
|
961
|
+
* - `lastSync` is the time (`Date.now()`) of the last pull that succeeded.
|
|
962
|
+
* - `error` is the newest failure of a load, a push, a pull or the queue store that a later
|
|
963
|
+
* success of the same work did not clear.
|
|
964
|
+
*/
|
|
965
|
+
getStatus(): SyncStatus {
|
|
966
|
+
const error = [...this.failures.values()].at(-1);
|
|
967
|
+
return {
|
|
968
|
+
connected: this.isOnline,
|
|
969
|
+
lastSync: this.lastSync,
|
|
970
|
+
pendingChanges: this.offlineOps.length,
|
|
971
|
+
syncing: this.syncs > 0,
|
|
972
|
+
mode: this.mode,
|
|
973
|
+
...(error !== undefined && { error }),
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* This method starts to send the status to a listener at each change: a failure, a sync that
|
|
979
|
+
* starts or ends, a change of the queue, of the connection or of the mode. A failure of a timer
|
|
980
|
+
* reaches the listeners here: the storage does not write to the console.
|
|
981
|
+
*
|
|
982
|
+
* @returns The function that stops the status. A second use of it does nothing.
|
|
983
|
+
*/
|
|
984
|
+
onStatus(listener: (status: SyncStatus) => void): () => void {
|
|
985
|
+
if (this.closed) throw this.closedError();
|
|
986
|
+
return this.statusListeners.add(listener);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
//
|
|
990
|
+
// ═══ The changes of the storage (roadmap CLI-21) ═══
|
|
991
|
+
//
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* This method starts to send the changes of the storage to an observer. Each write that changes
|
|
995
|
+
* the storage gives one batch, before the write resolves.
|
|
996
|
+
*
|
|
997
|
+
* - A write of this storage gives its batch with the origin of its options.
|
|
998
|
+
* - A pull, a delta of the feed, a fill after a read and a mode switch give their changes with
|
|
999
|
+
* `syncOrigin`.
|
|
1000
|
+
* - With `capture: "observe"`, a write of the application to the local copy gives the batch of
|
|
1001
|
+
* the local copy, with its origin.
|
|
1002
|
+
* - In `remote-only` mode, the batches tell the writes of this storage to the remote.
|
|
1003
|
+
*
|
|
1004
|
+
* @returns The function that stops the batches. A second use of it does nothing.
|
|
1005
|
+
* @throws Error after `close()`
|
|
1006
|
+
*/
|
|
1007
|
+
observe(observer: StorageObserver<T>): () => void {
|
|
1008
|
+
if (this.closed) throw this.closedError();
|
|
1009
|
+
return this.observers.add(observer);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** This method sends a batch to the observers. */
|
|
1013
|
+
private emit(changes: readonly StorageChangeEvent<T>[]): void {
|
|
1014
|
+
if (changes.length > 0 && this.observers.size > 0) this.observers.emit(changes);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* This method takes a batch of the local copy. The changes whose origin is not the origin of this
|
|
1019
|
+
* storage come from the application: the storage queues them, and its observers get them.
|
|
1020
|
+
*/
|
|
1021
|
+
private onLocalChanges(batch: StorageChangeBatch<T>): void {
|
|
1022
|
+
if (this.closed) return;
|
|
1023
|
+
const foreign = batch.filter((change) => change.origin !== this.origin);
|
|
1024
|
+
if (foreign.length === 0) return;
|
|
1025
|
+
if (this.captured) this.captured.push(...foreign);
|
|
1026
|
+
else this.captureChanges(foreign);
|
|
1027
|
+
this.emit(foreign);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* This method queues the changes of the application to the local copy (roadmap CLI-21). The
|
|
1032
|
+
* operations go in the queue at once, in the order of the changes. The queue store keeps them
|
|
1033
|
+
* later. A failure of the store keeps them in memory, and the status shows it.
|
|
1034
|
+
*/
|
|
1035
|
+
private captureChanges(changes: readonly StorageChangeEvent<T>[]): void {
|
|
1036
|
+
if (this.loadError || this.closed) return;
|
|
1037
|
+
for (const op of operationsOf(changes)) {
|
|
1038
|
+
this.noteCaptured(op);
|
|
1039
|
+
this.noteOwnWrite(op);
|
|
1040
|
+
this.attachBase(op);
|
|
1041
|
+
this.enqueueCaptured(op);
|
|
1042
|
+
}
|
|
1043
|
+
this.notify();
|
|
1044
|
+
if (this.mode === "write-through" || this.mode === "remote-only") {
|
|
1045
|
+
// A failed send waits for the timer of the queue
|
|
1046
|
+
void this.push().catch(() => this.scheduleDrain());
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* This method records a captured change as a local write: a sync that runs keeps its value, and
|
|
1052
|
+
* the version of the local item is known.
|
|
1053
|
+
*/
|
|
1054
|
+
private noteCaptured(op: QueuedOperation): void {
|
|
1055
|
+
if (op.type === "clear") {
|
|
1056
|
+
this.bumpClear();
|
|
1057
|
+
this.localClears++;
|
|
1058
|
+
this.localVersions.clear();
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const data = op.data as OperationData<T>;
|
|
1062
|
+
const sets = op.type === "set" ? [[data.id, data.value] as [string, T]] : op.type === "setBatch" ? data.items : [];
|
|
1063
|
+
const deletes = op.type === "delete" ? [data.id] : op.type === "deleteBatch" ? data.ids : [];
|
|
1064
|
+
for (const [id, value] of sets) {
|
|
1065
|
+
this.bump(id);
|
|
1066
|
+
// A local write of the storage that runs now makes the version unknown
|
|
1067
|
+
const version = this.localWrites.has(id) ? undefined : this.recordedVersion(value);
|
|
1068
|
+
this.localWrites.delete(id);
|
|
1069
|
+
if (version === undefined) this.localVersions.delete(id);
|
|
1070
|
+
else this.localVersions.set(id, version);
|
|
1071
|
+
}
|
|
1072
|
+
for (const id of deletes) {
|
|
1073
|
+
this.bump(id);
|
|
1074
|
+
this.localWrites.delete(id);
|
|
1075
|
+
this.localVersions.delete(id);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** This method puts a captured operation in the queue, and then in the queue store. */
|
|
1080
|
+
private enqueueCaptured(op: QueuedOperation): void {
|
|
1081
|
+
const index = this.replaceableIndex(op);
|
|
1082
|
+
if (index >= 0) {
|
|
1083
|
+
const previous = this.offlineOps[index]!;
|
|
1084
|
+
if (previous.base) op.base = previous.base;
|
|
1085
|
+
else delete op.base;
|
|
1086
|
+
this.offlineOps[index] = op;
|
|
1087
|
+
} else {
|
|
1088
|
+
this.offlineOps.push(op);
|
|
1089
|
+
}
|
|
1090
|
+
this.unpersisted.add(op);
|
|
1091
|
+
// The application made the change already: a failure of the store keeps the operation, and
|
|
1092
|
+
// the next write of the store gives the whole queue
|
|
1093
|
+
void this.persist(op, index < 0, () => undefined).catch(() => undefined);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* This method queues the changes that the application made during the load. The load wrote the
|
|
1098
|
+
* loaded queue to the local copy, so the changes go to the local copy again, in order.
|
|
1099
|
+
*/
|
|
1100
|
+
private async flushCaptured(): Promise<void> {
|
|
1101
|
+
const changes: StorageChangeEvent<T>[] = [];
|
|
1102
|
+
while (this.captured && this.captured.length > 0 && !this.loadError) {
|
|
1103
|
+
const batch = this.captured.splice(0);
|
|
1104
|
+
changes.push(...batch);
|
|
1105
|
+
for (const op of operationsOf(batch)) await this.applyToLocal(op, this.syncOrigin);
|
|
1106
|
+
}
|
|
1107
|
+
this.captured = undefined;
|
|
1108
|
+
if (changes.length > 0) this.captureChanges(changes);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* The mark of a value in the echo ledger: its version with `versionOf`, or else its JSON. A
|
|
1113
|
+
* delete is `null` in the ledger: a tombstone, so an older echo does not bring the item back.
|
|
1114
|
+
*/
|
|
1115
|
+
private echoPrint(value: T): string | undefined {
|
|
1116
|
+
if (this.options.versionOf) return this.recordedVersion(value);
|
|
1117
|
+
try {
|
|
1118
|
+
return JSON.stringify(value);
|
|
1119
|
+
} catch {
|
|
1120
|
+
return undefined;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** This method records the versions of an own write, for its echo (roadmap CLI-21). */
|
|
1125
|
+
private noteOwnWrite(op: QueuedOperation): void {
|
|
1126
|
+
const echoes = this.echoes;
|
|
1127
|
+
if (!echoes) return;
|
|
1128
|
+
const data = op.data as OperationData<T>;
|
|
1129
|
+
const wrote = (id: string, value: T): void => {
|
|
1130
|
+
const print = this.echoPrint(value);
|
|
1131
|
+
if (print !== undefined) echoes.wrote(id, print);
|
|
1132
|
+
};
|
|
1133
|
+
switch (op.type) {
|
|
1134
|
+
case "set":
|
|
1135
|
+
wrote(data.id, data.value);
|
|
1136
|
+
break;
|
|
1137
|
+
case "setBatch":
|
|
1138
|
+
for (const [id, value] of data.items) wrote(id, value);
|
|
1139
|
+
break;
|
|
1140
|
+
case "delete":
|
|
1141
|
+
echoes.wrote(data.id, null);
|
|
1142
|
+
break;
|
|
1143
|
+
case "deleteBatch":
|
|
1144
|
+
for (const id of data.ids) echoes.wrote(id, null);
|
|
1145
|
+
break;
|
|
1146
|
+
case "clear":
|
|
1147
|
+
echoes.cleared();
|
|
1148
|
+
break;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** This method starts the echo time of the ids of an operation again: it goes to the remote. */
|
|
1153
|
+
private echoSent(op: QueuedOperation): void {
|
|
1154
|
+
if (!this.echoes) return;
|
|
1155
|
+
for (const id of idsOf(op)) if (id !== "*") this.echoes.sent(id);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
//
|
|
1159
|
+
// ═══ The push feed (roadmap CLI-21, CLI-15) ═══
|
|
1160
|
+
//
|
|
1161
|
+
|
|
1162
|
+
/** This method subscribes to the feed in the modes with a local copy and a remote, and stops it in the others. */
|
|
1163
|
+
private updateFeed(): void {
|
|
1164
|
+
const feed = this.feed;
|
|
1165
|
+
if (!feed) return;
|
|
1166
|
+
const wanted = !this.closed && (this.mode === "write-through" || this.mode === "write-back");
|
|
1167
|
+
if (wanted === (this.feedStop !== undefined)) return;
|
|
1168
|
+
const token = ++this.feedToken;
|
|
1169
|
+
if (!wanted) {
|
|
1170
|
+
const stop = this.feedStop!;
|
|
1171
|
+
this.feedStop = undefined;
|
|
1172
|
+
try {
|
|
1173
|
+
stop();
|
|
1174
|
+
} catch {
|
|
1175
|
+
// The subscription is gone
|
|
1176
|
+
}
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const live = (): boolean => token === this.feedToken && !this.closed;
|
|
1180
|
+
try {
|
|
1181
|
+
const stop = feed.subscribe({
|
|
1182
|
+
onChanges: (changes) => {
|
|
1183
|
+
if (live()) this.onRemoteChanges(changes);
|
|
1184
|
+
},
|
|
1185
|
+
onHint: (hint) => {
|
|
1186
|
+
if (live()) this.onRemoteHint(hint);
|
|
1187
|
+
},
|
|
1188
|
+
onGap: () => {
|
|
1189
|
+
if (live()) this.requestPull();
|
|
1190
|
+
},
|
|
1191
|
+
onError: (error) => {
|
|
1192
|
+
if (live()) this.fail("pull", error);
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
if (live()) this.feedStop = stop;
|
|
1196
|
+
else stop();
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
this.fail("pull", error);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* This method takes a delta of the feed. The delta applies after the syncs that run, so a pull
|
|
1204
|
+
* that runs does not erase it. Each decision about it comes when it applies, at its turn in the
|
|
1205
|
+
* chain of the syncs, from the local state of that time: the queue, the own writes that wait,
|
|
1206
|
+
* the echo ledger and the versions. On arrival, the storage keeps only the count of the local
|
|
1207
|
+
* writes: a local write after the arrival is newer than the delta, and it keeps its value.
|
|
1208
|
+
*/
|
|
1209
|
+
private onRemoteChanges(changes: RemoteChanges<T>): void {
|
|
1210
|
+
const mark = this.writeSeq;
|
|
1211
|
+
void this.loaded
|
|
1212
|
+
.then(() => this.serialize(() => this.track("pull", () => this.applyFeed(changes, mark))))
|
|
1213
|
+
.catch(() => undefined);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* This method applies a delta of the feed: without a read when its `since` is the cursor. A
|
|
1218
|
+
* delta of the version of the cursor changes nothing. Another delta is a gap: a pull.
|
|
1219
|
+
*/
|
|
1220
|
+
private async applyFeed(changes: RemoteChanges<T>, mark: number): Promise<void> {
|
|
1221
|
+
if (this.loadError) throw this.loadError;
|
|
1222
|
+
if (this.closed || (this.mode !== "write-through" && this.mode !== "write-back")) return;
|
|
1223
|
+
if (changes.version === this.cursor) return;
|
|
1224
|
+
if (changes.since === undefined || changes.since !== this.cursor) {
|
|
1225
|
+
await this.pullRemote(false);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
await this.applyChanges(changes, (id) => this.changedSince(mark, id));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* This method takes a hint of the feed: the ids that changed, without their values (roadmap
|
|
1233
|
+
* CLI-15). The hint applies after the syncs that run, against the cursor and the local state of
|
|
1234
|
+
* that time, as a delta does.
|
|
1235
|
+
*/
|
|
1236
|
+
private onRemoteHint(hint: RemoteChangeHint): void {
|
|
1237
|
+
const mark = this.writeSeq;
|
|
1238
|
+
void this.loaded
|
|
1239
|
+
.then(() => this.serialize(() => this.track("pull", () => this.applyHint(hint, mark))))
|
|
1240
|
+
.catch(() => undefined);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* This method applies a hint. A hint of the version of the cursor changes nothing. A hint whose
|
|
1245
|
+
* `since` is the cursor and that changed no value applies its deletes without a read. Each other
|
|
1246
|
+
* hint asks for a pull: the values come through the checks of a read of the remote, and the
|
|
1247
|
+
* hints that come while the pull waits join it.
|
|
1248
|
+
*/
|
|
1249
|
+
private async applyHint(hint: RemoteChangeHint, mark: number): Promise<void> {
|
|
1250
|
+
if (this.loadError) throw this.loadError;
|
|
1251
|
+
if (this.closed || (this.mode !== "write-through" && this.mode !== "write-back")) return;
|
|
1252
|
+
if (hint.version !== undefined && hint.version === this.cursor) return;
|
|
1253
|
+
if (hint.since !== undefined && hint.since === this.cursor && hint.version !== undefined && hint.changed.length === 0) {
|
|
1254
|
+
await this.applyChanges({ version: hint.version, full: false, changed: [], deleted: [...hint.deleted] }, (id) => this.changedSince(mark, id));
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
this.requestPull();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** This method asks for a pull after the syncs that run. A request while a pull waits joins it. */
|
|
1261
|
+
private requestPull(): void {
|
|
1262
|
+
if (this.gapPull) return;
|
|
1263
|
+
this.gapPull = true;
|
|
1264
|
+
void this.loaded
|
|
1265
|
+
.then(() =>
|
|
1266
|
+
this.serialize(() => {
|
|
1267
|
+
this.gapPull = false;
|
|
1268
|
+
if (this.closed) return Promise.resolve();
|
|
1269
|
+
return this.track("pull", async () => {
|
|
1270
|
+
if (this.loadError) throw this.loadError;
|
|
1271
|
+
if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
|
|
1272
|
+
});
|
|
1273
|
+
}),
|
|
1274
|
+
)
|
|
1275
|
+
.catch(() => undefined);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
//
|
|
1279
|
+
// ═══ The sync state (roadmap CLI-21) ═══
|
|
1280
|
+
//
|
|
1281
|
+
|
|
1282
|
+
/** This method loads the sync state: the bases, and the cursor that the load then examines. */
|
|
1283
|
+
private async loadState(): Promise<string | undefined> {
|
|
1284
|
+
if (!this.stateStore) return undefined;
|
|
1285
|
+
try {
|
|
1286
|
+
const state: unknown = await this.stateStore.load();
|
|
1287
|
+
if (state === undefined) return undefined;
|
|
1288
|
+
if (!isSyncState(state)) throw new TypeError("The store gave a value that is not a sync state");
|
|
1289
|
+
for (const [id, version] of state.bases) this.bases.set(id, version);
|
|
1290
|
+
return state.cursor;
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
// The storage goes on without the state: the first pull reads all items
|
|
1293
|
+
this.fail("store", new SyncStateError("load", error));
|
|
1294
|
+
return undefined;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* This method writes the changes of the sync state to its store: the cursor and the bases that
|
|
1300
|
+
* changed. The writes run one at a time, and a change during a write goes in the next write.
|
|
1301
|
+
*/
|
|
1302
|
+
private saveState(): void {
|
|
1303
|
+
if (!this.stateStore || this.loadError) return;
|
|
1304
|
+
if (!this.stateReset && this.bases.changed.size === 0 && this.cursor === this.savedCursor) return;
|
|
1305
|
+
if (this.stateSaving) {
|
|
1306
|
+
this.stateAgain = true;
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
this.stateSaving = this.writeState().finally(() => {
|
|
1310
|
+
this.stateSaving = undefined;
|
|
1311
|
+
if (this.stateAgain) {
|
|
1312
|
+
this.stateAgain = false;
|
|
1313
|
+
this.saveState();
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** One write of the sync state. The first write, and the write after a failure, give the whole state. */
|
|
1319
|
+
private async writeState(): Promise<void> {
|
|
1320
|
+
const reset = this.stateReset;
|
|
1321
|
+
const ids = reset ? [...this.bases.keys()] : [...this.bases.changed];
|
|
1322
|
+
this.bases.changed.clear();
|
|
1323
|
+
this.stateReset = false;
|
|
1324
|
+
const cursor = this.cursor;
|
|
1325
|
+
const bases: Array<[string, string | null]> = [];
|
|
1326
|
+
const forgotten: string[] = [];
|
|
1327
|
+
for (const id of ids) {
|
|
1328
|
+
const version = this.bases.get(id);
|
|
1329
|
+
if (version === undefined) forgotten.push(id);
|
|
1330
|
+
else bases.push([id, version]);
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
await this.stateStore!.write({ reset, cursor, bases, forgotten: reset ? [] : forgotten });
|
|
1334
|
+
this.savedCursor = cursor;
|
|
1335
|
+
this.succeed("store");
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
this.stateReset = true;
|
|
1338
|
+
this.fail("store", new SyncStateError("write", error));
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** This method sends the status to the listeners when it changed. */
|
|
1343
|
+
private notify(): void {
|
|
1344
|
+
if (this.statusListeners.size === 0) return;
|
|
1345
|
+
const status = this.getStatus();
|
|
1346
|
+
const last = this.lastStatus;
|
|
1347
|
+
if (
|
|
1348
|
+
last &&
|
|
1349
|
+
last.connected === status.connected &&
|
|
1350
|
+
last.lastSync === status.lastSync &&
|
|
1351
|
+
last.pendingChanges === status.pendingChanges &&
|
|
1352
|
+
last.syncing === status.syncing &&
|
|
1353
|
+
last.mode === status.mode &&
|
|
1354
|
+
last.error === status.error
|
|
1355
|
+
) {
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
this.lastStatus = status;
|
|
1359
|
+
this.statusListeners.emit(status);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** This method keeps a failure for the status. The failure of an operation replaces its older one. */
|
|
1363
|
+
private fail(operation: SyncError["operation"], error: unknown): void {
|
|
1364
|
+
this.failures.delete(operation);
|
|
1365
|
+
this.failures.set(operation, { operation, error });
|
|
1366
|
+
this.notify();
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** This method clears the failure of an operation after a success. */
|
|
1370
|
+
private succeed(operation: SyncError["operation"]): void {
|
|
1371
|
+
if (this.failures.delete(operation)) this.notify();
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** A push or a pull: the status shows it while it runs, and keeps its failure. */
|
|
1375
|
+
private async track(operation: "push" | "pull", work: () => Promise<void>): Promise<void> {
|
|
1376
|
+
this.syncs++;
|
|
1377
|
+
this.notify();
|
|
1378
|
+
try {
|
|
1379
|
+
await work();
|
|
1380
|
+
this.succeed(operation);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
// A storage whose load failed keeps the failure of the load
|
|
1383
|
+
if (error !== this.loadError) this.fail(operation, error);
|
|
1384
|
+
throw error;
|
|
1385
|
+
} finally {
|
|
1386
|
+
this.syncs--;
|
|
1387
|
+
this.notify();
|
|
1388
|
+
this.saveState();
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
409
1392
|
//
|
|
410
1393
|
// ═══ Modes ═══
|
|
411
1394
|
//
|
|
@@ -465,57 +1448,39 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
465
1448
|
}
|
|
466
1449
|
this.mode = target;
|
|
467
1450
|
this.updateTimers();
|
|
1451
|
+
this.notify();
|
|
1452
|
+
this.saveState();
|
|
468
1453
|
}
|
|
469
1454
|
|
|
470
1455
|
/** This method drops the local copy. A read in flight does not fill it again. */
|
|
471
1456
|
private async dropLocal(): Promise<void> {
|
|
472
|
-
this.
|
|
1457
|
+
this.bumpClear();
|
|
473
1458
|
this.cursor = undefined;
|
|
474
|
-
await this.localClear();
|
|
1459
|
+
await this.localClear(this.syncOrigin);
|
|
475
1460
|
}
|
|
476
1461
|
|
|
477
1462
|
/** This method makes the local copy from the queue only, before a full sync. */
|
|
478
1463
|
private async rebuildLocal(): Promise<void> {
|
|
479
1464
|
await this.dropLocal();
|
|
480
|
-
for (const op of this.offlineOps) await this.applyToLocal(op);
|
|
1465
|
+
for (const op of this.offlineOps) await this.applyToLocal(op, this.syncOrigin);
|
|
481
1466
|
}
|
|
482
1467
|
|
|
483
|
-
/**
|
|
1468
|
+
/**
|
|
1469
|
+
* This method starts and stops the timers for the mode. The push timer runs in `write-back`, the
|
|
1470
|
+
* pull timer in `write-through` and `write-back`, and the send of the queue after a failure only
|
|
1471
|
+
* in `write-through`. A failed run reports its error in the status, and the next delay grows.
|
|
1472
|
+
*/
|
|
484
1473
|
private updateTimers(): void {
|
|
485
1474
|
if (this.closed) return;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
(this.syncTimer as { unref?: () => void }).unref?.();
|
|
496
|
-
} else if (!push && this.syncTimer) {
|
|
497
|
-
clearInterval(this.syncTimer);
|
|
498
|
-
this.syncTimer = null;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
|
|
502
|
-
if (pull && !this.pullTimer) {
|
|
503
|
-
this.pullTimer = setInterval(() => {
|
|
504
|
-
if (this.closed) return;
|
|
505
|
-
this.pull().catch((err) => {
|
|
506
|
-
console.error("Periodic pull failed:", err);
|
|
507
|
-
});
|
|
508
|
-
}, this.options.pullInterval);
|
|
509
|
-
(this.pullTimer as { unref?: () => void }).unref?.();
|
|
510
|
-
} else if (!pull && this.pullTimer) {
|
|
511
|
-
clearInterval(this.pullTimer);
|
|
512
|
-
this.pullTimer = null;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (this.mode !== "write-through" && this.drainTimer) {
|
|
516
|
-
clearTimeout(this.drainTimer);
|
|
517
|
-
this.drainTimer = null;
|
|
518
|
-
}
|
|
1475
|
+
if (this.mode === "write-back") this.pushTimer?.start();
|
|
1476
|
+
else this.pushTimer?.stop();
|
|
1477
|
+
|
|
1478
|
+
if (this.mode === "write-through" || this.mode === "write-back") this.pullTimer?.start();
|
|
1479
|
+
else this.pullTimer?.stop();
|
|
1480
|
+
|
|
1481
|
+
if (this.mode !== "write-through") this.drainTimer.stop();
|
|
1482
|
+
|
|
1483
|
+
this.updateFeed();
|
|
519
1484
|
}
|
|
520
1485
|
|
|
521
1486
|
//
|
|
@@ -538,16 +1503,17 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
538
1503
|
}
|
|
539
1504
|
|
|
540
1505
|
this.stats.cacheMisses++;
|
|
541
|
-
// A pending local delete means "no item": do not read it back from the remote
|
|
542
|
-
|
|
1506
|
+
// A pending local delete means "no item": do not read it back from the remote. An own delete
|
|
1507
|
+
// that waits for the remote too (roadmap CLI-21).
|
|
1508
|
+
if (this.mode === "local-only" || this.hasPendingOp(id) || this.waitsForRemote(id)) {
|
|
543
1509
|
return undefined;
|
|
544
1510
|
}
|
|
545
1511
|
const version = this.writeKey(id);
|
|
546
1512
|
try {
|
|
547
1513
|
const remote = await this.remote.get(id);
|
|
548
1514
|
this.isOnline = true;
|
|
549
|
-
if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
|
|
550
|
-
if (remote !== undefined) await this.localSet(id, remote);
|
|
1515
|
+
if (this.writeKey(id) === version && !this.hasPendingOp(id) && !this.waitsForRemote(id)) {
|
|
1516
|
+
if (remote !== undefined) await this.localSet(id, remote, this.syncOrigin);
|
|
551
1517
|
this.noteBase(id, remote);
|
|
552
1518
|
}
|
|
553
1519
|
return remote;
|
|
@@ -582,6 +1548,26 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
582
1548
|
);
|
|
583
1549
|
}
|
|
584
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* The entries whose items match the selector (roadmap CLI-14): from the local copy, or from the
|
|
1553
|
+
* remote in `remote-only` mode. A storage under it with `query()` runs the selector itself, for
|
|
1554
|
+
* example `ApiStorage` on its server. Else this method runs `queryEntries()` on its entries. The
|
|
1555
|
+
* policy of the call, or of the options, goes to the storage under it.
|
|
1556
|
+
*/
|
|
1557
|
+
async query(selector: StorageSelector, options?: StorageQueryOptions, policy?: StorageQueryPolicy): Promise<Array<[string, T]>> {
|
|
1558
|
+
return this.run(true, async () => {
|
|
1559
|
+
const limits = policy ?? this.queryPolicy;
|
|
1560
|
+
const problems = [...selectorProblems(selector, limits), ...queryOptionsProblems(options, limits)];
|
|
1561
|
+
if (problems.length > 0) throw new TypeError(`The query is not correct: ${problems.join(". ")}`);
|
|
1562
|
+
if (this.mode === "remote-only") {
|
|
1563
|
+
return this.remoteRead(async () =>
|
|
1564
|
+
this.remote.query ? this.remote.query(selector, options, limits) : queryEntries(await this.remoteEntries(), selector, options, limits),
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
return this.local.query ? this.local.query(selector, options, limits) : queryEntries(await this.local.getEntries(), selector, options, limits);
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
585
1571
|
async has(id: string): Promise<boolean> {
|
|
586
1572
|
return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.has(id)) : this.local.has(id)));
|
|
587
1573
|
}
|
|
@@ -615,28 +1601,32 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
615
1601
|
|
|
616
1602
|
/**
|
|
617
1603
|
* This method sets an item. The storage takes a copy of the value, so a later change of the
|
|
618
|
-
* value does not change the local copy, the queue or the remote write.
|
|
1604
|
+
* value does not change the local copy, the queue or the remote write. The origin of the options
|
|
1605
|
+
* goes to the batches of `observe()`. The local copy and the remote get the origin of the storage.
|
|
619
1606
|
*/
|
|
620
|
-
async set(id: string, written: T): Promise<void> {
|
|
1607
|
+
async set(id: string, written: T, options?: StorageWriteOptions): Promise<void> {
|
|
621
1608
|
const value = structuredClone(written);
|
|
622
|
-
|
|
1609
|
+
const origin = options?.origin;
|
|
1610
|
+
await this.write("set", { id, value }, [id], origin, async () => {
|
|
623
1611
|
this.bump(id);
|
|
624
|
-
await this.localSet(id, value);
|
|
625
|
-
}, () => this.remote.set(id, value));
|
|
1612
|
+
await this.localSet(id, value, origin);
|
|
1613
|
+
}, () => this.remote.set(id, value, this.own));
|
|
626
1614
|
}
|
|
627
1615
|
|
|
628
|
-
async delete(id: string): Promise<boolean> {
|
|
629
|
-
|
|
1616
|
+
async delete(id: string, options?: StorageWriteOptions): Promise<boolean> {
|
|
1617
|
+
const origin = options?.origin;
|
|
1618
|
+
return this.write("delete", { id }, [id], origin, async () => {
|
|
630
1619
|
this.bump(id);
|
|
631
|
-
return this.localDelete(id);
|
|
632
|
-
}, () => this.remote.delete(id));
|
|
1620
|
+
return this.localDelete(id, origin);
|
|
1621
|
+
}, () => this.remote.delete(id, this.own));
|
|
633
1622
|
}
|
|
634
1623
|
|
|
635
|
-
async clear(): Promise<void> {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1624
|
+
async clear(options?: StorageWriteOptions): Promise<void> {
|
|
1625
|
+
const origin = options?.origin;
|
|
1626
|
+
await this.write("clear", {}, "*", origin, async () => {
|
|
1627
|
+
this.bumpClear();
|
|
1628
|
+
await this.localClear(origin);
|
|
1629
|
+
}, () => this.remote.clear(this.own));
|
|
640
1630
|
}
|
|
641
1631
|
|
|
642
1632
|
//
|
|
@@ -644,19 +1634,21 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
644
1634
|
//
|
|
645
1635
|
|
|
646
1636
|
/** This method sets several items. The storage takes a copy of each value, as `set` does. */
|
|
647
|
-
async setBatch(written: Array<[string, T]
|
|
1637
|
+
async setBatch(written: Array<[string, T]>, options?: StorageWriteOptions): Promise<void> {
|
|
648
1638
|
const items = written.map(([id, value]): [string, T] => [id, structuredClone(value)]);
|
|
649
|
-
|
|
1639
|
+
const origin = options?.origin;
|
|
1640
|
+
await this.write("setBatch", { items }, items.map(([id]) => id), origin, async () => {
|
|
650
1641
|
for (const [id] of items) this.bump(id);
|
|
651
|
-
await this.localSetBatch(items);
|
|
652
|
-
}, () => this.remote.setBatch(items));
|
|
1642
|
+
await this.localSetBatch(items, origin);
|
|
1643
|
+
}, () => this.remote.setBatch(items, this.own));
|
|
653
1644
|
}
|
|
654
1645
|
|
|
655
|
-
async deleteBatch(ids: string[]): Promise<number> {
|
|
656
|
-
|
|
1646
|
+
async deleteBatch(ids: string[], options?: StorageWriteOptions): Promise<number> {
|
|
1647
|
+
const origin = options?.origin;
|
|
1648
|
+
return this.write("deleteBatch", { ids }, ids, origin, async () => {
|
|
657
1649
|
for (const id of ids) this.bump(id);
|
|
658
|
-
return this.localDeleteBatch(ids);
|
|
659
|
-
}, () => this.remote.deleteBatch(ids));
|
|
1650
|
+
return this.localDeleteBatch(ids, origin);
|
|
1651
|
+
}, () => this.remote.deleteBatch(ids, this.own));
|
|
660
1652
|
}
|
|
661
1653
|
|
|
662
1654
|
/**
|
|
@@ -680,6 +1672,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
680
1672
|
type: QueuedOperation["type"],
|
|
681
1673
|
data: unknown,
|
|
682
1674
|
ids: string[] | "*",
|
|
1675
|
+
origin: string | undefined,
|
|
683
1676
|
applyLocal: () => Promise<R>,
|
|
684
1677
|
applyRemote: () => Promise<R>,
|
|
685
1678
|
): Promise<R> {
|
|
@@ -689,8 +1682,11 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
689
1682
|
if (this.mode === "remote-only") {
|
|
690
1683
|
const send = async (): Promise<R> => {
|
|
691
1684
|
await this.ensureStoreClean();
|
|
1685
|
+
// The observers get a delete only for an id that existed
|
|
1686
|
+
const existing = this.observers.size > 0 ? await this.remoteExisting(op) : undefined;
|
|
692
1687
|
const result = await applyRemote();
|
|
693
1688
|
this.noteRemoteWrite(op);
|
|
1689
|
+
if (this.observers.size > 0) this.emit(remoteChangesOf<T>(op, result, existing, origin));
|
|
694
1690
|
return result;
|
|
695
1691
|
};
|
|
696
1692
|
try {
|
|
@@ -711,55 +1707,184 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
711
1707
|
if (this.mode === "write-back" || this.mode === "local-only") {
|
|
712
1708
|
this.ensureQueueRoom(op);
|
|
713
1709
|
this.attachBase(op);
|
|
714
|
-
const undo = await this.snapshot(ids);
|
|
1710
|
+
const undo = await this.snapshot(ids, origin);
|
|
715
1711
|
const result = await applyLocal();
|
|
1712
|
+
this.noteOwnWrite(op);
|
|
716
1713
|
await this.enqueue(op, undo);
|
|
717
1714
|
return result;
|
|
718
1715
|
}
|
|
719
1716
|
|
|
720
|
-
const undo = await this.snapshot(ids);
|
|
721
|
-
|
|
1717
|
+
const undo = await this.snapshot(ids, origin);
|
|
1718
|
+
// Until the remote takes the write, or the queue or the undo replaces it, a pull does not
|
|
1719
|
+
// write over its local value (roadmap CLI-21)
|
|
1720
|
+
this.startFlight(op);
|
|
722
1721
|
try {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1722
|
+
const result = await applyLocal();
|
|
1723
|
+
this.noteOwnWrite(op);
|
|
1724
|
+
try {
|
|
1725
|
+
if (this.offlineOps.length > 0) {
|
|
1726
|
+
// The queued operations are older: they reach the remote first, or the older value of a
|
|
1727
|
+
// queued write replaces this one later. A failed send keeps this write behind them.
|
|
1728
|
+
await this.serialize(async () => {
|
|
1729
|
+
await this.pushQueue();
|
|
1730
|
+
await this.ensureStoreClean();
|
|
1731
|
+
this.echoSent(op);
|
|
1732
|
+
await applyRemote();
|
|
1733
|
+
});
|
|
1734
|
+
} else {
|
|
728
1735
|
await this.ensureStoreClean();
|
|
1736
|
+
this.echoSent(op);
|
|
729
1737
|
await applyRemote();
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1738
|
+
}
|
|
1739
|
+
this.isOnline = true;
|
|
1740
|
+
this.noteRemoteWrite(op);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
if (
|
|
1743
|
+
!(error instanceof QueueStoreError) &&
|
|
1744
|
+
this.options.offlineQueue &&
|
|
1745
|
+
this.options.isUnavailable(error) &&
|
|
1746
|
+
this.hasQueueRoom(op)
|
|
1747
|
+
) {
|
|
1748
|
+
// The status shows the queue and the connection. The caller gets PendingWriteError.
|
|
1749
|
+
this.isOnline = false;
|
|
1750
|
+
this.attachBase(op);
|
|
1751
|
+
await this.enqueue(op, undo);
|
|
1752
|
+
this.scheduleDrain();
|
|
1753
|
+
throw new PendingWriteError(type, error);
|
|
1754
|
+
}
|
|
1755
|
+
await undo();
|
|
1756
|
+
throw error;
|
|
734
1757
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1758
|
+
return result;
|
|
1759
|
+
} finally {
|
|
1760
|
+
this.endFlight(op);
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
//
|
|
1766
|
+
// ═══ The own writes that wait for the remote (roadmap CLI-21) ═══
|
|
1767
|
+
//
|
|
1768
|
+
|
|
1769
|
+
/** This method records an own write of the `write-through` mode that goes to the remote. */
|
|
1770
|
+
private startFlight(op: QueuedOperation): void {
|
|
1771
|
+
if (op.type === "clear") {
|
|
1772
|
+
this.inFlightClears++;
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
const data = op.data as OperationData<T>;
|
|
1776
|
+
const values: Array<[string, T | undefined]> =
|
|
1777
|
+
op.type === "set" ? [[data.id, data.value]] :
|
|
1778
|
+
op.type === "setBatch" ? data.items :
|
|
1779
|
+
op.type === "delete" ? [[data.id, undefined]] :
|
|
1780
|
+
data.ids.map((id): [string, undefined] => [id, undefined]);
|
|
1781
|
+
for (const [id, value] of values) {
|
|
1782
|
+
const flight = this.inFlight.get(id);
|
|
1783
|
+
if (flight) {
|
|
1784
|
+
flight.count++;
|
|
1785
|
+
flight.value = value;
|
|
1786
|
+
} else {
|
|
1787
|
+
this.inFlight.set(id, { count: 1, value });
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* This method ends an own write that went to the remote: the remote took it, or the queue or the
|
|
1794
|
+
* undo replaced it. The deferred remote values of the ids that no own write waits for now go
|
|
1795
|
+
* through a new read of the remote, after the syncs that run.
|
|
1796
|
+
*/
|
|
1797
|
+
private endFlight(op: QueuedOperation): void {
|
|
1798
|
+
if (op.type === "clear") {
|
|
1799
|
+
this.inFlightClears--;
|
|
1800
|
+
} else {
|
|
1801
|
+
for (const id of new Set(idsOf(op))) {
|
|
1802
|
+
const flight = this.inFlight.get(id);
|
|
1803
|
+
if (!flight) continue;
|
|
1804
|
+
if (--flight.count <= 0) this.inFlight.delete(id);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
const ready = [...this.deferred.keys()].filter((id) => !this.waitsForRemote(id));
|
|
1808
|
+
if (ready.length === 0) return;
|
|
1809
|
+
void this.serialize(() => this.track("pull", () => this.applyDeferred(ready))).catch(() => undefined);
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** True when an own write of the id waits for the remote. */
|
|
1813
|
+
private waitsForRemote(id: string): boolean {
|
|
1814
|
+
return this.inFlightClears > 0 || this.inFlight.has(id);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* True when a remote value of an id is the value of the newest own write that waits for the
|
|
1819
|
+
* remote: its echo. `undefined` is a delete. With `versionOf` the versions decide, else the JSON.
|
|
1820
|
+
*/
|
|
1821
|
+
private isFlightValue(id: string, item: T | undefined): boolean {
|
|
1822
|
+
const flight = this.inFlight.get(id);
|
|
1823
|
+
const value = flight ? flight.value : undefined;
|
|
1824
|
+
if (item === undefined || value === undefined) return item === undefined && value === undefined;
|
|
1825
|
+
if (this.options.versionOf) {
|
|
1826
|
+
const version = this.recordedVersion(item);
|
|
1827
|
+
return version !== undefined && version === this.recordedVersion(value);
|
|
1828
|
+
}
|
|
1829
|
+
return JSON.stringify(item) === JSON.stringify(value);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* A pull keeps the local value of an id whose own write waits for the remote, and the storage
|
|
1834
|
+
* keeps the remote value for later. True when the pull defers the id. The echo of the write
|
|
1835
|
+
* itself is not deferred: it is the local value.
|
|
1836
|
+
*/
|
|
1837
|
+
private deferIfInFlight(id: string, item: T | undefined): boolean {
|
|
1838
|
+
if (!this.waitsForRemote(id) || this.isFlightValue(id, item)) return false;
|
|
1839
|
+
this.deferred.set(id, { item });
|
|
1840
|
+
return true;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* This method applies the deferred remote values of ids whose own writes ended. The storage
|
|
1845
|
+
* reads each id from the remote again: the deferred value can be older than the write, or a
|
|
1846
|
+
* newer write of another writer. Then the rules of a pull decide. When the remote cannot answer,
|
|
1847
|
+
* the deferred value goes through the rules.
|
|
1848
|
+
*/
|
|
1849
|
+
private async applyDeferred(ids: readonly string[]): Promise<void> {
|
|
1850
|
+
if (this.loadError) throw this.loadError;
|
|
1851
|
+
if (this.closed) return;
|
|
1852
|
+
const conflicts: string[] = [];
|
|
1853
|
+
let queueChanged = false;
|
|
1854
|
+
for (const id of ids) {
|
|
1855
|
+
const deferred = this.deferred.get(id);
|
|
1856
|
+
// A new own write of the id waits: its end applies the value
|
|
1857
|
+
if (!deferred || this.waitsForRemote(id)) continue;
|
|
1858
|
+
this.deferred.delete(id);
|
|
1859
|
+
const mark = this.writeSeq;
|
|
1860
|
+
let item = deferred.item;
|
|
1861
|
+
if (this.mode === "write-through" || this.mode === "write-back") {
|
|
1862
|
+
try {
|
|
1863
|
+
item = await this.remote.get(id);
|
|
1864
|
+
this.isOnline = true;
|
|
1865
|
+
} catch {
|
|
1866
|
+
// The deferred value is the newest value that the storage knows
|
|
744
1867
|
this.isOnline = false;
|
|
745
|
-
console.error(`Remote ${type} failed: the operation waits in the queue`);
|
|
746
|
-
this.attachBase(op);
|
|
747
|
-
await this.enqueue(op, undo);
|
|
748
|
-
this.scheduleDrain();
|
|
749
|
-
throw new PendingWriteError(type, error);
|
|
750
1868
|
}
|
|
751
|
-
await undo();
|
|
752
|
-
throw error;
|
|
753
1869
|
}
|
|
754
|
-
|
|
755
|
-
|
|
1870
|
+
const changedLocally = (other: string): boolean => this.changedSince(mark, other);
|
|
1871
|
+
const changed = item === undefined
|
|
1872
|
+
? await this.pullDelete(id, changedLocally, conflicts)
|
|
1873
|
+
: await this.pullItem(id, item, changedLocally, conflicts);
|
|
1874
|
+
if (changed) queueChanged = true;
|
|
1875
|
+
}
|
|
1876
|
+
if (queueChanged) {
|
|
1877
|
+
this.storeDirty = true;
|
|
1878
|
+
await this.persistAll().catch(() => undefined);
|
|
1879
|
+
}
|
|
1880
|
+
if (conflicts.length > 0) throw new ConflictError(conflicts);
|
|
756
1881
|
}
|
|
757
1882
|
|
|
758
1883
|
/**
|
|
759
1884
|
* The local values of the ids before a write, and a function that puts them back. It puts back
|
|
760
1885
|
* only the ids that no later write changed.
|
|
761
1886
|
*/
|
|
762
|
-
private async snapshot(ids: string[] | "*"): Promise<() => Promise<void>> {
|
|
1887
|
+
private async snapshot(ids: string[] | "*", origin: string | undefined): Promise<() => Promise<void>> {
|
|
763
1888
|
const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
|
|
764
1889
|
const touched = ids === "*" ? [...before.keys()] : ids;
|
|
765
1890
|
// The versions after the write: this write adds one to each id (clear adds to clearVersion)
|
|
@@ -770,8 +1895,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
770
1895
|
for (const id of touched) {
|
|
771
1896
|
if ((this.writeVersions.get(id) ?? 0) !== versions.get(id)) continue;
|
|
772
1897
|
const value = before.get(id);
|
|
773
|
-
if (value === undefined) await this.localDelete(id);
|
|
774
|
-
else await this.localSet(id, value);
|
|
1898
|
+
if (value === undefined) await this.localDelete(id, origin);
|
|
1899
|
+
else await this.localSet(id, value, origin);
|
|
775
1900
|
}
|
|
776
1901
|
};
|
|
777
1902
|
}
|
|
@@ -826,22 +1951,26 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
826
1951
|
await this.syncFromRemote();
|
|
827
1952
|
}
|
|
828
1953
|
|
|
829
|
-
/** The pull of `syncFromRemote()`, after the syncs that run. */
|
|
1954
|
+
/** The pull of `syncFromRemote()`, after the syncs that run. The status keeps its failure. */
|
|
830
1955
|
private pull(): Promise<void> {
|
|
831
|
-
return this.serialize(
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1956
|
+
return this.serialize(() =>
|
|
1957
|
+
this.track("pull", async () => {
|
|
1958
|
+
await this.loaded;
|
|
1959
|
+
if (this.loadError) throw this.loadError;
|
|
1960
|
+
if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
|
|
1961
|
+
}),
|
|
1962
|
+
);
|
|
836
1963
|
}
|
|
837
1964
|
|
|
838
|
-
/** The push of `syncToRemote()`, after the syncs that run. */
|
|
1965
|
+
/** The push of `syncToRemote()`, after the syncs that run. The status keeps its failure. */
|
|
839
1966
|
private push(): Promise<void> {
|
|
840
|
-
return this.serialize(
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1967
|
+
return this.serialize(() =>
|
|
1968
|
+
this.track("push", async () => {
|
|
1969
|
+
await this.loaded;
|
|
1970
|
+
if (this.loadError) throw this.loadError;
|
|
1971
|
+
if (this.mode !== "local-only") await this.pushQueue();
|
|
1972
|
+
}),
|
|
1973
|
+
);
|
|
845
1974
|
}
|
|
846
1975
|
|
|
847
1976
|
/** The error of an operation after `close()`. */
|
|
@@ -872,36 +2001,37 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
872
2001
|
|
|
873
2002
|
/** The work of `close()`. */
|
|
874
2003
|
private async shutDown(): Promise<void> {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
this.
|
|
879
|
-
this.
|
|
880
|
-
if (this.drainTimer) {
|
|
881
|
-
clearTimeout(this.drainTimer);
|
|
882
|
-
this.drainTimer = null;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
await this.loaded;
|
|
886
|
-
// The operations and the switch that started before close() end first
|
|
887
|
-
await this.gate;
|
|
888
|
-
await this.idle();
|
|
889
|
-
// After the syncs that run: send the pending operations
|
|
890
|
-
await this.serialize(async () => {
|
|
891
|
-
if (this.loadError || this.offlineOps.length === 0 || this.mode === "local-only") return;
|
|
892
|
-
try {
|
|
893
|
-
await this.pushQueue();
|
|
894
|
-
} catch (error) {
|
|
895
|
-
console.error("Failed to sync before close:", error);
|
|
896
|
-
}
|
|
897
|
-
});
|
|
898
|
-
await this.storeChain;
|
|
2004
|
+
this.pushTimer?.stop();
|
|
2005
|
+
this.pullTimer?.stop();
|
|
2006
|
+
this.drainTimer.stop();
|
|
2007
|
+
this.updateFeed();
|
|
2008
|
+
this.stopCapture?.();
|
|
899
2009
|
|
|
900
|
-
// Close remote storage (an ApiStorage leaves the caller's client open)
|
|
901
2010
|
try {
|
|
902
|
-
await this.
|
|
2011
|
+
await this.loaded;
|
|
2012
|
+
// The operations and the switch that started before close() end first
|
|
2013
|
+
await this.gate;
|
|
2014
|
+
await this.idle();
|
|
2015
|
+
// After the syncs that run: send the pending operations. The queue store keeps the ones that
|
|
2016
|
+
// the remote did not take, and the status gets the failure.
|
|
2017
|
+
await this.serialize(async () => {
|
|
2018
|
+
if (this.loadError || this.offlineOps.length === 0 || this.mode === "local-only") return;
|
|
2019
|
+
await this.track("push", () => this.pushQueue()).catch(() => undefined);
|
|
2020
|
+
});
|
|
2021
|
+
await this.storeChain;
|
|
2022
|
+
// The last changes of the sync state reach its store
|
|
2023
|
+
this.saveState();
|
|
2024
|
+
while (this.stateSaving) await this.stateSaving;
|
|
2025
|
+
|
|
2026
|
+
// Close remote storage (an ApiStorage leaves the caller's client open)
|
|
2027
|
+
try {
|
|
2028
|
+
await this.remote.close();
|
|
2029
|
+
} finally {
|
|
2030
|
+
await this.local.close();
|
|
2031
|
+
}
|
|
903
2032
|
} finally {
|
|
904
|
-
|
|
2033
|
+
this.statusListeners.clear();
|
|
2034
|
+
this.observers.clear();
|
|
905
2035
|
}
|
|
906
2036
|
}
|
|
907
2037
|
|
|
@@ -963,6 +2093,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
963
2093
|
if (--this.active === 0) {
|
|
964
2094
|
for (const resolve of this.idleWaiters.splice(0)) resolve();
|
|
965
2095
|
}
|
|
2096
|
+
// A write changes the queue, and a remote call the connection
|
|
2097
|
+
this.notify();
|
|
2098
|
+
// A remote write or a read of the remote changes the bases
|
|
2099
|
+
this.saveState();
|
|
966
2100
|
}
|
|
967
2101
|
}
|
|
968
2102
|
|
|
@@ -974,12 +2108,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
974
2108
|
|
|
975
2109
|
/** This method loads the queue, and puts its writes in the local copy. */
|
|
976
2110
|
private async loadQueue(): Promise<void> {
|
|
2111
|
+
// The sync state first: its bases are newer than the bases of the queued operations
|
|
2112
|
+
const cursor = await this.loadState();
|
|
977
2113
|
let ops: QueuedOperation[];
|
|
978
2114
|
try {
|
|
979
2115
|
ops = await this.queueStore.load();
|
|
980
2116
|
} catch (error) {
|
|
2117
|
+
// The storage refuses each operation. The status shows the failure.
|
|
981
2118
|
this.loadError = new QueueStoreError("load", error);
|
|
982
|
-
|
|
2119
|
+
this.fail("load", this.loadError);
|
|
2120
|
+
this.captured = undefined;
|
|
983
2121
|
return;
|
|
984
2122
|
}
|
|
985
2123
|
this.offlineOps = ops.slice();
|
|
@@ -990,11 +2128,15 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
990
2128
|
}
|
|
991
2129
|
// A durable local copy can hold the operation already: each operation gives the same
|
|
992
2130
|
// result two times
|
|
993
|
-
await this.applyToLocal(op);
|
|
2131
|
+
await this.applyToLocal(op, this.syncOrigin);
|
|
994
2132
|
}
|
|
2133
|
+
// The cursor belongs to a durable local copy with items. Else the first pull reads all items.
|
|
2134
|
+
if (cursor !== undefined && this.localGiven && (await this.local.size()) > 0) this.cursor = cursor;
|
|
2135
|
+
await this.flushCaptured();
|
|
995
2136
|
} catch (error) {
|
|
996
2137
|
this.loadError = new Error("The local copy could not take the loaded queue: the storage refuses each operation", { cause: error });
|
|
997
|
-
|
|
2138
|
+
this.fail("load", this.loadError);
|
|
2139
|
+
this.captured = undefined;
|
|
998
2140
|
}
|
|
999
2141
|
}
|
|
1000
2142
|
|
|
@@ -1003,38 +2145,33 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1003
2145
|
// A close() before the first sync: the storage makes no remote call
|
|
1004
2146
|
if (this.loadError || this.closed) return;
|
|
1005
2147
|
if (this.options.syncOnInit && this.mode !== "local-only") {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
console.error("Failed to send the queue on init:", err);
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
2148
|
+
// A failure goes to the status: the reads go on with the local copy
|
|
2149
|
+
if (this.offlineOps.length > 0) await this.push().catch(() => undefined);
|
|
1011
2150
|
if (!this.closed && (this.mode === "write-through" || this.mode === "write-back")) {
|
|
1012
|
-
await this.pull().catch((
|
|
1013
|
-
console.error("Failed to sync on init:", err);
|
|
1014
|
-
});
|
|
2151
|
+
await this.pull().catch(() => undefined);
|
|
1015
2152
|
}
|
|
1016
2153
|
}
|
|
1017
2154
|
if (this.offlineOps.length > 0) this.scheduleDrain();
|
|
1018
2155
|
}
|
|
1019
2156
|
|
|
1020
|
-
/** This method applies a queued operation to the local copy. */
|
|
1021
|
-
private async applyToLocal(op: QueuedOperation): Promise<void> {
|
|
1022
|
-
const data = op.data as
|
|
2157
|
+
/** This method applies a queued operation to the local copy. The observers get the origin. */
|
|
2158
|
+
private async applyToLocal(op: QueuedOperation, origin: string | undefined): Promise<void> {
|
|
2159
|
+
const data = op.data as OperationData<T>;
|
|
1023
2160
|
switch (op.type) {
|
|
1024
2161
|
case "set":
|
|
1025
|
-
await this.localSet(data.id, data.value);
|
|
2162
|
+
await this.localSet(data.id, data.value, origin);
|
|
1026
2163
|
break;
|
|
1027
2164
|
case "delete":
|
|
1028
|
-
await this.localDelete(data.id);
|
|
2165
|
+
await this.localDelete(data.id, origin);
|
|
1029
2166
|
break;
|
|
1030
2167
|
case "setBatch":
|
|
1031
|
-
await this.localSetBatch(data.items);
|
|
2168
|
+
await this.localSetBatch(data.items, origin);
|
|
1032
2169
|
break;
|
|
1033
2170
|
case "deleteBatch":
|
|
1034
|
-
await this.localDeleteBatch(data.ids);
|
|
2171
|
+
await this.localDeleteBatch(data.ids, origin);
|
|
1035
2172
|
break;
|
|
1036
2173
|
case "clear":
|
|
1037
|
-
await this.localClear();
|
|
2174
|
+
await this.localClear(origin);
|
|
1038
2175
|
break;
|
|
1039
2176
|
}
|
|
1040
2177
|
}
|
|
@@ -1108,26 +2245,55 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1108
2245
|
}
|
|
1109
2246
|
}
|
|
1110
2247
|
|
|
1111
|
-
|
|
1112
|
-
|
|
2248
|
+
// The writes of the local copy have the origin of this storage, so the capture does not queue
|
|
2249
|
+
// them. The observers get each change with the origin of the caller.
|
|
2250
|
+
|
|
2251
|
+
private async localSet(id: string, value: T, origin: string | undefined): Promise<void> {
|
|
2252
|
+
await this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value, this.own));
|
|
2253
|
+
if (this.observers.size > 0) this.emit([setChange(id, value, origin)]);
|
|
1113
2254
|
}
|
|
1114
2255
|
|
|
1115
|
-
private localDelete(id: string): Promise<boolean> {
|
|
1116
|
-
|
|
2256
|
+
private async localDelete(id: string, origin: string | undefined): Promise<boolean> {
|
|
2257
|
+
const existed = await this.writeLocal(() => [[id, undefined]], () => this.local.delete(id, this.own));
|
|
2258
|
+
if (existed) this.emit([withOrigin<T>({ type: "delete", id }, origin)]);
|
|
2259
|
+
return existed;
|
|
1117
2260
|
}
|
|
1118
2261
|
|
|
1119
|
-
private localSetBatch(items: Array<[string, T]
|
|
1120
|
-
|
|
2262
|
+
private async localSetBatch(items: Array<[string, T]>, origin: string | undefined): Promise<void> {
|
|
2263
|
+
await this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items, this.own));
|
|
2264
|
+
if (this.observers.size > 0) this.emit(items.map(([id, value]) => setChange(id, value, origin)));
|
|
1121
2265
|
}
|
|
1122
2266
|
|
|
1123
|
-
private localDeleteBatch(ids: string[]): Promise<number> {
|
|
1124
|
-
|
|
2267
|
+
private async localDeleteBatch(ids: string[], origin: string | undefined): Promise<number> {
|
|
2268
|
+
// The observers get a delete for each id that existed
|
|
2269
|
+
const before = this.observers.size > 0 ? await this.local.getBatch(ids) : undefined;
|
|
2270
|
+
const count = await this.writeLocal(() => ids.map((id): [string, undefined] => [id, undefined]), () => this.local.deleteBatch(ids, this.own));
|
|
2271
|
+
if (before && count > 0) this.emit([...new Set(ids)].filter((id) => before.has(id)).map((id) => withOrigin<T>({ type: "delete", id }, origin)));
|
|
2272
|
+
return count;
|
|
1125
2273
|
}
|
|
1126
2274
|
|
|
1127
|
-
private localClear(): Promise<void> {
|
|
2275
|
+
private localClear(origin: string | undefined): Promise<void> {
|
|
1128
2276
|
this.localClears++;
|
|
1129
2277
|
this.localVersions.clear();
|
|
1130
|
-
return this.local.clear();
|
|
2278
|
+
if (this.observers.size === 0) return this.local.clear(this.own);
|
|
2279
|
+
return this.observedClear(origin);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/** A clear of the local copy with observers: they get the change when the copy had items. */
|
|
2283
|
+
private async observedClear(origin: string | undefined): Promise<void> {
|
|
2284
|
+
const had = (await this.local.size()) > 0;
|
|
2285
|
+
await this.local.clear(this.own);
|
|
2286
|
+
if (had) this.emit([withOrigin<T>({ type: "clear" }, origin)]);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/** The ids that a delete of the `remote-only` mode finds, for its batch. */
|
|
2290
|
+
private async remoteExisting(op: QueuedOperation): Promise<ReadonlySet<string> | boolean | undefined> {
|
|
2291
|
+
if (op.type === "deleteBatch") {
|
|
2292
|
+
const ids = [...new Set((op.data as OperationData<T>).ids)];
|
|
2293
|
+
return new Set((await this.remote.getBatch(ids)).keys());
|
|
2294
|
+
}
|
|
2295
|
+
if (op.type === "clear") return (await this.remote.size()) > 0;
|
|
2296
|
+
return undefined;
|
|
1131
2297
|
}
|
|
1132
2298
|
|
|
1133
2299
|
/**
|
|
@@ -1138,11 +2304,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1138
2304
|
* comes again at the next pull.
|
|
1139
2305
|
*/
|
|
1140
2306
|
private async pullRemote(full: boolean): Promise<void> {
|
|
1141
|
-
|
|
1142
|
-
const
|
|
1143
|
-
// True when a local write changed the id after the sync started
|
|
1144
|
-
const changedLocally = (id: string): boolean =>
|
|
1145
|
-
this.clearVersion !== clearVersion || (this.writeVersions.get(id) ?? 0) !== (versions.get(id) ?? 0);
|
|
2307
|
+
// A local write after this mark keeps its value: the remote value is older
|
|
2308
|
+
const mark = this.writeSeq;
|
|
1146
2309
|
|
|
1147
2310
|
let changes: PulledChanges<T>;
|
|
1148
2311
|
try {
|
|
@@ -1151,7 +2314,15 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1151
2314
|
if (!(error instanceof MissingKeysError)) this.isOnline = false;
|
|
1152
2315
|
throw error;
|
|
1153
2316
|
}
|
|
2317
|
+
await this.applyChanges(changes, (id) => this.changedSince(mark, id));
|
|
2318
|
+
}
|
|
1154
2319
|
|
|
2320
|
+
/**
|
|
2321
|
+
* This method applies remote changes to the local copy: the changes of a pull, or a delta of
|
|
2322
|
+
* the feed. `changedLocally` tells the ids that a local write changed after the changes left the
|
|
2323
|
+
* remote: they keep their local values. The cursor moves only without conflicts.
|
|
2324
|
+
*/
|
|
2325
|
+
private async applyChanges(changes: PulledChanges<T>, changedLocally: (id: string) => boolean): Promise<void> {
|
|
1155
2326
|
const conflicts: string[] = [];
|
|
1156
2327
|
let queueChanged = false;
|
|
1157
2328
|
const remoteIds = new Set<string>();
|
|
@@ -1168,14 +2339,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1168
2339
|
|
|
1169
2340
|
if (queueChanged) {
|
|
1170
2341
|
this.storeDirty = true;
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
});
|
|
2342
|
+
// The status gets the failure of the queue store. A later direct write repairs the store first.
|
|
2343
|
+
await this.persistAll().catch(() => undefined);
|
|
1174
2344
|
}
|
|
1175
2345
|
this.stats.syncCount++;
|
|
1176
2346
|
this.isOnline = true;
|
|
1177
2347
|
if (conflicts.length > 0) throw new ConflictError(conflicts);
|
|
1178
2348
|
if (changes.version !== undefined) this.cursor = changes.version;
|
|
2349
|
+
this.lastSync = Date.now();
|
|
1179
2350
|
}
|
|
1180
2351
|
|
|
1181
2352
|
/**
|
|
@@ -1213,6 +2384,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1213
2384
|
): Promise<boolean> {
|
|
1214
2385
|
if (changedLocally(id)) return false;
|
|
1215
2386
|
const remoteVersion = this.versionOfItem(item);
|
|
2387
|
+
// The echo of an older own write: a newer own write of the id, or its delete (a tombstone),
|
|
2388
|
+
// waits for its echo (roadmap CLI-21)
|
|
2389
|
+
const print = this.echoes ? this.echoPrint(item) : undefined;
|
|
2390
|
+
if (print !== undefined && this.echoes?.isStale(id, print)) return false;
|
|
2391
|
+
// An own write of the id waits for the remote: the remote value can be older (roadmap CLI-21)
|
|
2392
|
+
if (this.deferIfInFlight(id, item)) return false;
|
|
1216
2393
|
const knownVersion = remoteVersion === undefined ? undefined : this.localVersions.get(id);
|
|
1217
2394
|
|
|
1218
2395
|
if (this.hasPendingOp(id)) {
|
|
@@ -1237,7 +2414,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1237
2414
|
|
|
1238
2415
|
if (knownVersion !== undefined) {
|
|
1239
2416
|
// Only the remote changed: it is not a conflict. The version of the local item is known.
|
|
1240
|
-
if (knownVersion !== remoteVersion) await this.localSet(id, item);
|
|
2417
|
+
if (knownVersion !== remoteVersion) await this.localSet(id, item, this.syncOrigin);
|
|
1241
2418
|
this.bases.set(id, remoteVersion!);
|
|
1242
2419
|
return false;
|
|
1243
2420
|
}
|
|
@@ -1246,10 +2423,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1246
2423
|
if (changedLocally(id)) return false;
|
|
1247
2424
|
const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
|
|
1248
2425
|
if (localItem === undefined) {
|
|
1249
|
-
await this.localSet(id, item);
|
|
2426
|
+
await this.localSet(id, item, this.syncOrigin);
|
|
1250
2427
|
} else if (remoteVersion !== undefined && localVersion !== undefined) {
|
|
1251
2428
|
// Only the remote changed: it is not a conflict
|
|
1252
|
-
if (localVersion !== remoteVersion) await this.localSet(id, item);
|
|
2429
|
+
if (localVersion !== remoteVersion) await this.localSet(id, item, this.syncOrigin);
|
|
1253
2430
|
} else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
|
|
1254
2431
|
// Conflict - resolve according to strategy
|
|
1255
2432
|
if (this.options.conflictResolution === "error") {
|
|
@@ -1258,7 +2435,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1258
2435
|
return false;
|
|
1259
2436
|
}
|
|
1260
2437
|
const resolved = this.resolveConflict(localItem, item);
|
|
1261
|
-
await this.localSet(id, resolved);
|
|
2438
|
+
await this.localSet(id, resolved, this.syncOrigin);
|
|
1262
2439
|
this.stats.conflicts++;
|
|
1263
2440
|
}
|
|
1264
2441
|
if (remoteVersion !== undefined) this.bases.set(id, remoteVersion);
|
|
@@ -1268,8 +2445,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1268
2445
|
/** This method applies one remote delete. It gives true when the queue changed. */
|
|
1269
2446
|
private async pullDelete(id: string, changedLocally: (id: string) => boolean, conflicts: string[]): Promise<boolean> {
|
|
1270
2447
|
if (changedLocally(id)) return false;
|
|
2448
|
+
// The echo of an older own delete: a newer own write of the id waits for its echo (roadmap CLI-21)
|
|
2449
|
+
if (this.echoes?.isStale(id, null)) return false;
|
|
2450
|
+
// An own write of the id waits for the remote: the remote delete can be older (roadmap CLI-21)
|
|
2451
|
+
if (this.deferIfInFlight(id, undefined)) return false;
|
|
1271
2452
|
if (!this.hasPendingOp(id)) {
|
|
1272
|
-
await this.localDelete(id);
|
|
2453
|
+
await this.localDelete(id, this.syncOrigin);
|
|
1273
2454
|
if (this.options.versionOf) this.bases.set(id, null);
|
|
1274
2455
|
return false;
|
|
1275
2456
|
}
|
|
@@ -1288,7 +2469,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1288
2469
|
switch (this.options.conflictResolution) {
|
|
1289
2470
|
case "remote":
|
|
1290
2471
|
if (!this.dropQueued(id)) break;
|
|
1291
|
-
await this.localDelete(id);
|
|
2472
|
+
await this.localDelete(id, this.syncOrigin);
|
|
1292
2473
|
this.bases.set(id, null);
|
|
1293
2474
|
return true;
|
|
1294
2475
|
case "error":
|
|
@@ -1318,14 +2499,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1318
2499
|
case "remote":
|
|
1319
2500
|
// The remote value replaces the queued write. A queued clear cannot lose one id.
|
|
1320
2501
|
if (!this.dropQueued(id)) break;
|
|
1321
|
-
await this.localSet(id, remoteItem);
|
|
2502
|
+
await this.localSet(id, remoteItem, this.syncOrigin);
|
|
1322
2503
|
this.bases.set(id, remoteVersion);
|
|
1323
2504
|
return true;
|
|
1324
2505
|
case "merge": {
|
|
1325
2506
|
if (localItem === undefined) break;
|
|
1326
2507
|
const merged = this.resolveConflict(localItem, remoteItem);
|
|
1327
2508
|
if (!this.dropQueued(id)) break;
|
|
1328
|
-
await this.localSet(id, merged);
|
|
2509
|
+
await this.localSet(id, merged, this.syncOrigin);
|
|
1329
2510
|
this.bases.set(id, remoteVersion);
|
|
1330
2511
|
this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
|
|
1331
2512
|
return true;
|
|
@@ -1404,6 +2585,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1404
2585
|
continue;
|
|
1405
2586
|
}
|
|
1406
2587
|
try {
|
|
2588
|
+
this.echoSent(op);
|
|
1407
2589
|
await this.executeOperation(op);
|
|
1408
2590
|
} catch (error) {
|
|
1409
2591
|
// This operation and the later ones stay queued
|
|
@@ -1422,10 +2604,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1422
2604
|
} finally {
|
|
1423
2605
|
this.pushing = false;
|
|
1424
2606
|
if (sent > 0) {
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
});
|
|
2607
|
+
// The store keeps sent operations, and the status gets the failure. A later direct write
|
|
2608
|
+
// repairs the store first.
|
|
2609
|
+
await this.persistAll().catch(() => undefined);
|
|
1429
2610
|
}
|
|
1430
2611
|
}
|
|
1431
2612
|
}
|
|
@@ -1439,6 +2620,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1439
2620
|
|
|
1440
2621
|
private bump(id: string): void {
|
|
1441
2622
|
this.writeVersions.set(id, (this.writeVersions.get(id) ?? 0) + 1);
|
|
2623
|
+
this.lastWrite.set(id, ++this.writeSeq);
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
/** A local clear: each id changed. */
|
|
2627
|
+
private bumpClear(): void {
|
|
2628
|
+
this.clearVersion++;
|
|
2629
|
+
this.lastClear = ++this.writeSeq;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/** True when a local write changed the id after the mark, a value of `writeSeq`. */
|
|
2633
|
+
private changedSince(mark: number, id: string): boolean {
|
|
2634
|
+
return this.lastClear > mark || (this.lastWrite.get(id) ?? 0) > mark;
|
|
1442
2635
|
}
|
|
1443
2636
|
|
|
1444
2637
|
/** The count of local writes of an id: it changes with each write and each clear. */
|
|
@@ -1634,11 +2827,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1634
2827
|
this.unpersisted.delete(op);
|
|
1635
2828
|
this.storeDirty = false;
|
|
1636
2829
|
}
|
|
2830
|
+
this.succeed("store");
|
|
1637
2831
|
} catch (error) {
|
|
1638
2832
|
this.storeDirty = true;
|
|
1639
2833
|
this.unpersisted.delete(op);
|
|
1640
2834
|
rollback();
|
|
1641
|
-
|
|
2835
|
+
const failure = new QueueStoreError("write", error);
|
|
2836
|
+
this.fail("store", failure);
|
|
2837
|
+
throw failure;
|
|
1642
2838
|
}
|
|
1643
2839
|
});
|
|
1644
2840
|
this.storeChain = run.catch(() => undefined);
|
|
@@ -1655,9 +2851,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1655
2851
|
else await this.queueStore.replace(snapshot);
|
|
1656
2852
|
for (const kept of snapshot) this.unpersisted.delete(kept);
|
|
1657
2853
|
this.storeDirty = false;
|
|
2854
|
+
this.succeed("store");
|
|
1658
2855
|
} catch (error) {
|
|
1659
2856
|
this.storeDirty = true;
|
|
1660
|
-
|
|
2857
|
+
const failure = new QueueStoreError("write", error);
|
|
2858
|
+
this.fail("store", failure);
|
|
2859
|
+
throw failure;
|
|
1661
2860
|
}
|
|
1662
2861
|
});
|
|
1663
2862
|
this.storeChain = run.catch(() => undefined);
|
|
@@ -1700,17 +2899,11 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1700
2899
|
|
|
1701
2900
|
/**
|
|
1702
2901
|
* In write-through mode, a timer sends the queue after a failure, and again after each failed
|
|
1703
|
-
* send, until the queue is empty.
|
|
2902
|
+
* send, until the queue is empty. The delay after a failed send grows with the backoff.
|
|
1704
2903
|
*/
|
|
1705
2904
|
private scheduleDrain(): void {
|
|
1706
|
-
if (this.
|
|
1707
|
-
|
|
1708
|
-
this.drainTimer = setTimeout(() => {
|
|
1709
|
-
this.drainTimer = null;
|
|
1710
|
-
if (this.closed) return;
|
|
1711
|
-
this.push().catch(() => this.scheduleDrain());
|
|
1712
|
-
}, delay);
|
|
1713
|
-
(this.drainTimer as { unref?: () => void }).unref?.();
|
|
2905
|
+
if (this.closed || this.mode !== "write-through") return;
|
|
2906
|
+
this.drainTimer.start();
|
|
1714
2907
|
}
|
|
1715
2908
|
|
|
1716
2909
|
/**
|
|
@@ -1721,23 +2914,23 @@ export class HybridStorage<T> implements CollectionStorage<T> {
|
|
|
1721
2914
|
|
|
1722
2915
|
switch (op.type) {
|
|
1723
2916
|
case "set":
|
|
1724
|
-
await this.remote.set(data.id, data.value);
|
|
2917
|
+
await this.remote.set(data.id, data.value, this.own);
|
|
1725
2918
|
break;
|
|
1726
2919
|
|
|
1727
2920
|
case "delete":
|
|
1728
|
-
await this.remote.delete(data.id);
|
|
2921
|
+
await this.remote.delete(data.id, this.own);
|
|
1729
2922
|
break;
|
|
1730
2923
|
|
|
1731
2924
|
case "setBatch":
|
|
1732
|
-
await this.remote.setBatch(data.items);
|
|
2925
|
+
await this.remote.setBatch(data.items, this.own);
|
|
1733
2926
|
break;
|
|
1734
2927
|
|
|
1735
2928
|
case "deleteBatch":
|
|
1736
|
-
await this.remote.deleteBatch(data.ids);
|
|
2929
|
+
await this.remote.deleteBatch(data.ids, this.own);
|
|
1737
2930
|
break;
|
|
1738
2931
|
|
|
1739
2932
|
case "clear":
|
|
1740
|
-
await this.remote.clear();
|
|
2933
|
+
await this.remote.clear(this.own);
|
|
1741
2934
|
break;
|
|
1742
2935
|
}
|
|
1743
2936
|
}
|