@procedures/store 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +88 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/indexeddb/sync-state-store.d.ts +40 -0
  5. package/dist/indexeddb/sync-state-store.d.ts.map +1 -0
  6. package/dist/indexeddb/sync-state-store.js +94 -0
  7. package/dist/indexeddb/sync-state-store.js.map +1 -0
  8. package/dist/indexeddb.d.ts +7 -2
  9. package/dist/indexeddb.d.ts.map +1 -1
  10. package/dist/indexeddb.js +6 -2
  11. package/dist/indexeddb.js.map +1 -1
  12. package/dist/storage/api.d.ts +84 -11
  13. package/dist/storage/api.d.ts.map +1 -1
  14. package/dist/storage/api.js +148 -25
  15. package/dist/storage/api.js.map +1 -1
  16. package/dist/storage/feed.d.ts +133 -0
  17. package/dist/storage/feed.d.ts.map +1 -0
  18. package/dist/storage/feed.js +159 -0
  19. package/dist/storage/feed.js.map +1 -0
  20. package/dist/storage/hybrid.d.ts +367 -20
  21. package/dist/storage/hybrid.d.ts.map +1 -1
  22. package/dist/storage/hybrid.js +1295 -206
  23. package/dist/storage/hybrid.js.map +1 -1
  24. package/dist/storage/index.d.ts +6 -2
  25. package/dist/storage/index.d.ts.map +1 -1
  26. package/dist/storage/index.js +6 -1
  27. package/dist/storage/index.js.map +1 -1
  28. package/dist/storage/sync-state.d.ts +56 -0
  29. package/dist/storage/sync-state.d.ts.map +1 -0
  30. package/dist/storage/sync-state.js +58 -0
  31. package/dist/storage/sync-state.js.map +1 -0
  32. package/dist/storage/synced-registry.d.ts +22 -4
  33. package/dist/storage/synced-registry.d.ts.map +1 -1
  34. package/dist/storage/synced-registry.js +63 -31
  35. package/dist/storage/synced-registry.js.map +1 -1
  36. package/dist/storage/types.d.ts +22 -1
  37. package/dist/storage/types.d.ts.map +1 -1
  38. package/package.json +4 -3
  39. package/src/indexeddb/sync-state-store.ts +101 -0
  40. package/src/indexeddb.ts +7 -2
  41. package/src/storage/api.ts +210 -29
  42. package/src/storage/feed.ts +251 -0
  43. package/src/storage/hybrid.ts +1404 -211
  44. package/src/storage/index.ts +17 -0
  45. package/src/storage/sync-state.ts +89 -0
  46. package/src/storage/synced-registry.ts +76 -31
  47. package/src/storage/types.ts +25 -1
@@ -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 { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/collections";
28
- import { InMemoryStorage } from "@procedures/collections";
29
- import { optionalMs, type Duration } from "@procedures/client";
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. Only the storage writes to the local copy: with `versionOf`, a pull does
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 syncTimer: ReturnType<typeof setInterval> | null = null;
332
- /** The periodic pull */
333
- private pullTimer: ReturnType<typeof setInterval> | null = null;
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: ReturnType<typeof setTimeout> | null = null;
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 Map<string, string | null>();
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.clearVersion++;
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
- /** This method starts and stops the timers for the mode. */
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
- const push = this.mode === "write-back" && this.options.syncInterval > 0;
487
- if (push && !this.syncTimer) {
488
- this.syncTimer = setInterval(() => {
489
- if (this.closed) return;
490
- this.push().catch((err) => {
491
- console.error("Periodic sync failed:", err);
492
- });
493
- }, this.options.syncInterval);
494
- // The timer must not keep the process alive (deep dive DATA-9)
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
- if (this.mode === "local-only" || this.hasPendingOp(id)) {
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
- await this.write("set", { id, value }, [id], async () => {
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
- return this.write("delete", { id }, [id], async () => {
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
- await this.write("clear", {}, "*", async () => {
637
- this.clearVersion++;
638
- await this.localClear();
639
- }, () => this.remote.clear());
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]>): Promise<void> {
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
- await this.write("setBatch", { items }, items.map(([id]) => id), async () => {
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
- return this.write("deleteBatch", { ids }, ids, async () => {
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
- const result = await applyLocal();
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
- if (this.offlineOps.length > 0) {
724
- // The queued operations are older: they reach the remote first, or the older value of a
725
- // queued write replaces this one later. A failed send keeps this write behind them.
726
- await this.serialize(async () => {
727
- await this.pushQueue();
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
- } else {
732
- await this.ensureStoreClean();
733
- await applyRemote();
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
- this.isOnline = true;
736
- this.noteRemoteWrite(op);
737
- } catch (error) {
738
- if (
739
- !(error instanceof QueueStoreError) &&
740
- this.options.offlineQueue &&
741
- this.options.isUnavailable(error) &&
742
- this.hasQueueRoom(op)
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
- return result;
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(async () => {
832
- await this.loaded;
833
- if (this.loadError) throw this.loadError;
834
- if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
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(async () => {
841
- await this.loaded;
842
- if (this.loadError) throw this.loadError;
843
- if (this.mode !== "local-only") await this.pushQueue();
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
- for (const timer of [this.syncTimer, this.pullTimer]) {
876
- if (timer) clearInterval(timer);
877
- }
878
- this.syncTimer = null;
879
- this.pullTimer = null;
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.remote.close();
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
- await this.local.close();
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
- console.error("The queue store could not load the queue: the storage refuses each operation", error);
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
- console.error(this.loadError.message, error);
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
- if (this.offlineOps.length > 0) {
1007
- await this.push().catch((err) => {
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((err) => {
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 { id: string; value: T; ids: string[]; items: Array<[string, T]> };
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
- private localSet(id: string, value: T): Promise<void> {
1112
- return this.writeLocal(() => [[id, this.recordedVersion(value)]], () => this.local.set(id, value));
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
- return this.writeLocal(() => [[id, undefined]], () => this.local.delete(id));
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]>): Promise<void> {
1120
- return this.writeLocal(() => items.map(([id, value]) => [id, this.recordedVersion(value)]), () => this.local.setBatch(items));
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
- return this.writeLocal(() => ids.map((id): [string, undefined] => [id, undefined]), () => this.local.deleteBatch(ids));
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
- const versions = new Map(this.writeVersions);
1142
- const clearVersion = this.clearVersion;
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
- await this.persistAll().catch((error) => {
1172
- console.error("The queue store could not keep the queue after the sync:", error);
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
- await this.persistAll().catch((error) => {
1426
- // The store keeps sent operations. A later direct write repairs it first.
1427
- console.error("The queue store could not remove the sent operations:", error);
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
- throw new QueueStoreError("write", error);
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
- throw new QueueStoreError("write", error);
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.drainTimer || this.closed || this.mode !== "write-through") return;
1707
- const delay = this.options.syncInterval > 0 ? this.options.syncInterval : 5000;
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
  }