@procedures/store 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +98 -1
  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 +94 -11
  13. package/dist/storage/api.d.ts.map +1 -1
  14. package/dist/storage/api.js +177 -24
  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/file-queue.d.ts.map +1 -1
  21. package/dist/storage/file-queue.js +2 -1
  22. package/dist/storage/file-queue.js.map +1 -1
  23. package/dist/storage/hybrid.d.ts +415 -20
  24. package/dist/storage/hybrid.d.ts.map +1 -1
  25. package/dist/storage/hybrid.js +1405 -196
  26. package/dist/storage/hybrid.js.map +1 -1
  27. package/dist/storage/index.d.ts +6 -2
  28. package/dist/storage/index.d.ts.map +1 -1
  29. package/dist/storage/index.js +6 -1
  30. package/dist/storage/index.js.map +1 -1
  31. package/dist/storage/sync-state.d.ts +56 -0
  32. package/dist/storage/sync-state.d.ts.map +1 -0
  33. package/dist/storage/sync-state.js +58 -0
  34. package/dist/storage/sync-state.js.map +1 -0
  35. package/dist/storage/synced-registry.d.ts +22 -4
  36. package/dist/storage/synced-registry.d.ts.map +1 -1
  37. package/dist/storage/synced-registry.js +63 -31
  38. package/dist/storage/synced-registry.js.map +1 -1
  39. package/dist/storage/types.d.ts +22 -1
  40. package/dist/storage/types.d.ts.map +1 -1
  41. package/package.json +4 -3
  42. package/src/indexeddb/sync-state-store.ts +101 -0
  43. package/src/indexeddb.ts +7 -2
  44. package/src/storage/api.ts +240 -28
  45. package/src/storage/feed.ts +251 -0
  46. package/src/storage/file-queue.ts +2 -1
  47. package/src/storage/hybrid.ts +1526 -203
  48. package/src/storage/index.ts +17 -0
  49. package/src/storage/sync-state.ts +89 -0
  50. package/src/storage/synced-registry.ts +76 -31
  51. package/src/storage/types.ts +25 -1
@@ -19,13 +19,51 @@
19
19
  * - Each write takes a copy of the value (`structuredClone`), for the local copy, the queue and the
20
20
  * remote. The reads give the copies of the local copy, or of the remote in `remote-only` mode.
21
21
  * - A full sync reads the local ids with `getIds()`, so it does not copy each local item.
22
+ * - A pull reads a local item only when the result depends on it. With `versionOf`, the storage
23
+ * knows the version of each local item that it wrote or read.
24
+ * - The timers run on the clock of the options. After a failed push, pull or send of the queue,
25
+ * the next delay grows with `backoffDelay` of `@mark1russell7/service/backoff` (roadmap CLI-13).
26
+ * - `getStatus()` and `onStatus()` give the state of the sync and its errors. Before, the errors
27
+ * went to the console.
28
+ * - `query()` runs a selector on the local copy, or on the remote in `remote-only` mode.
22
29
  * - After `close()`, each operation rejects, and a second `close()` does nothing.
30
+ *
31
+ * The sync engine of roadmap CLI-21 (decision D9: a SignalDB collection is one strategy of the local copy):
32
+ * - `observe()` gives the batches of the changes of the storage, with their origins.
33
+ * - `capture: "observe"` queues the changes that the application writes to the local copy itself.
34
+ * - `syncState` keeps the cursor and the base versions after a reload.
35
+ * - `feed` pushes the remote deltas. A delta whose `since` is the cursor applies without a read.
36
+ * - A pull never erases a delta that came during it, and the echo of an older own write never
37
+ * writes over a newer local write.
38
+ * - The syncs of one storage run one at a time, in their order: the deltas and the hints of the
39
+ * feed, the pulls, the pushes, the reads after an own write, and the mode switches. Thus a
40
+ * remote delta and a full sync never run at the same time (Graph R18).
23
41
  */
24
42
 
25
- import type { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/collections";
26
- import { InMemoryStorage } from "@procedures/collections";
27
- 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";
28
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";
29
67
 
30
68
  /**
31
69
  * Conflict resolution strategy.
@@ -103,6 +141,9 @@ export interface HybridStorageOptions {
103
141
  * The version of an item, for example its ETag or a revision field (roadmap R28). The sync
104
142
  * compares the versions, not the JSON of the values. A remote change of an id with a queued
105
143
  * local write is a conflict.
144
+ *
145
+ * The storage keeps the version of each local item that it wrote or read. A pull then reads a
146
+ * local item only for a conflict, or when it does not know the version of the item.
106
147
  */
107
148
  versionOf?: (item: any) => string | undefined;
108
149
 
@@ -163,10 +204,223 @@ export interface HybridStorageOptions {
163
204
  * The storage of the local copy (roadmap R45). A durable storage, for example `SqliteStorage`
164
205
  * of `@procedures/sqlite`, keeps the local copy after a restart. Then a new storage in
165
206
  * `local-only` mode reads the items of the last run. Give it with a durable `queueStore`.
166
- * `close()` closes it.
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.
167
209
  * @default a new `InMemoryStorage`
168
210
  */
169
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
+ }
170
424
  }
171
425
 
172
426
  /**
@@ -236,6 +490,18 @@ export class ModeSwitchError extends Error {
236
490
  }
237
491
  }
238
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
+
239
505
  const defaultIsUnavailable = (error: unknown): boolean => {
240
506
  if (typeof error !== "object" || error === null) return true;
241
507
  const { retryable, code } = error as { retryable?: unknown; code?: unknown };
@@ -261,6 +527,186 @@ function checkMode(mode: unknown): HybridMode {
261
527
  /** The changes that a pull applies: from `getChangesSince`, or all entries. */
262
528
  type PulledChanges<T> = Omit<StorageChanges<T>, "version"> & { version?: string };
263
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
+
264
710
  /**
265
711
  * A storage for the local copy of a `HybridStorage` (roadmap R45): a collection storage that gives
266
712
  * its entries. A durable one, for example `SqliteStorage` of `@procedures/sqlite`, keeps the local
@@ -321,14 +767,25 @@ export class HybridStorage<T> implements CollectionStorage<T> {
321
767
  private storeDirty = false;
322
768
  /** The calls of the queue store, one at a time */
323
769
  private storeChain: Promise<unknown> = Promise.resolve();
324
- /** The push timer of the write-back mode */
325
- private syncTimer: ReturnType<typeof setInterval> | null = null;
326
- /** The periodic pull */
327
- 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;
328
774
  /** True while pushQueue sends the queue: the first operation can be on its way */
329
775
  private pushing = false;
330
776
  /** The timer that sends the queue of the write-through mode after a failure */
331
- 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;
332
789
  /** The load of the queue. The writes wait for it. */
333
790
  private readonly loaded: Promise<void>;
334
791
  /** The load and the first sync. The reads wait for it, so a read never sees an empty cache by mistake. */
@@ -355,7 +812,57 @@ export class HybridStorage<T> implements CollectionStorage<T> {
355
812
  /** True when the remote has `getChangesSince` but its server does not know `changes` */
356
813
  private changesUnsupported = false;
357
814
  /** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
358
- private readonly bases = new Map<string, string | null>();
815
+ private readonly bases = new BaseVersions();
816
+ /**
817
+ * With `versionOf`: the version of each local item that the storage wrote or read. A pull does
818
+ * not read an item whose version it knows. The version of an id that is not here is unknown.
819
+ */
820
+ private readonly localVersions = new Map<string, string>();
821
+ /** The number of the last local write of each id that runs: only that write records a version */
822
+ private readonly localWrites = new Map<string, number>();
823
+ private localWriteCount = 0;
824
+ /** The number of clears of the local copy: a clear cancels the records of the writes that run */
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;
359
866
  private stats = {
360
867
  cacheHits: 0,
361
868
  cacheMisses: 0,
@@ -383,6 +890,57 @@ export class HybridStorage<T> implements CollectionStorage<T> {
383
890
  offlineQueue: options.offlineQueue ?? true,
384
891
  isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
385
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
+ }
386
944
 
387
945
  // The queue first, then the first sync. The reads wait for both (roadmap R2, R28).
388
946
  this.loaded = this.loadQueue();
@@ -390,6 +948,447 @@ export class HybridStorage<T> implements CollectionStorage<T> {
390
948
  this.updateTimers();
391
949
  }
392
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
+
393
1392
  //
394
1393
  // ═══ Modes ═══
395
1394
  //
@@ -449,57 +1448,39 @@ export class HybridStorage<T> implements CollectionStorage<T> {
449
1448
  }
450
1449
  this.mode = target;
451
1450
  this.updateTimers();
1451
+ this.notify();
1452
+ this.saveState();
452
1453
  }
453
1454
 
454
1455
  /** This method drops the local copy. A read in flight does not fill it again. */
455
1456
  private async dropLocal(): Promise<void> {
456
- this.clearVersion++;
1457
+ this.bumpClear();
457
1458
  this.cursor = undefined;
458
- await this.local.clear();
1459
+ await this.localClear(this.syncOrigin);
459
1460
  }
460
1461
 
461
1462
  /** This method makes the local copy from the queue only, before a full sync. */
462
1463
  private async rebuildLocal(): Promise<void> {
463
1464
  await this.dropLocal();
464
- for (const op of this.offlineOps) await this.applyToLocal(op);
1465
+ for (const op of this.offlineOps) await this.applyToLocal(op, this.syncOrigin);
465
1466
  }
466
1467
 
467
- /** 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
+ */
468
1473
  private updateTimers(): void {
469
1474
  if (this.closed) return;
470
- const push = this.mode === "write-back" && this.options.syncInterval > 0;
471
- if (push && !this.syncTimer) {
472
- this.syncTimer = setInterval(() => {
473
- if (this.closed) return;
474
- this.push().catch((err) => {
475
- console.error("Periodic sync failed:", err);
476
- });
477
- }, this.options.syncInterval);
478
- // The timer must not keep the process alive (deep dive DATA-9)
479
- (this.syncTimer as { unref?: () => void }).unref?.();
480
- } else if (!push && this.syncTimer) {
481
- clearInterval(this.syncTimer);
482
- this.syncTimer = null;
483
- }
484
-
485
- const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
486
- if (pull && !this.pullTimer) {
487
- this.pullTimer = setInterval(() => {
488
- if (this.closed) return;
489
- this.pull().catch((err) => {
490
- console.error("Periodic pull failed:", err);
491
- });
492
- }, this.options.pullInterval);
493
- (this.pullTimer as { unref?: () => void }).unref?.();
494
- } else if (!pull && this.pullTimer) {
495
- clearInterval(this.pullTimer);
496
- this.pullTimer = null;
497
- }
498
-
499
- if (this.mode !== "write-through" && this.drainTimer) {
500
- clearTimeout(this.drainTimer);
501
- this.drainTimer = null;
502
- }
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();
503
1484
  }
504
1485
 
505
1486
  //
@@ -522,16 +1503,17 @@ export class HybridStorage<T> implements CollectionStorage<T> {
522
1503
  }
523
1504
 
524
1505
  this.stats.cacheMisses++;
525
- // A pending local delete means "no item": do not read it back from the remote
526
- 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)) {
527
1509
  return undefined;
528
1510
  }
529
1511
  const version = this.writeKey(id);
530
1512
  try {
531
1513
  const remote = await this.remote.get(id);
532
1514
  this.isOnline = true;
533
- if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
534
- if (remote !== undefined) await this.local.set(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);
535
1517
  this.noteBase(id, remote);
536
1518
  }
537
1519
  return remote;
@@ -566,6 +1548,26 @@ export class HybridStorage<T> implements CollectionStorage<T> {
566
1548
  );
567
1549
  }
568
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
+
569
1571
  async has(id: string): Promise<boolean> {
570
1572
  return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.has(id)) : this.local.has(id)));
571
1573
  }
@@ -599,28 +1601,32 @@ export class HybridStorage<T> implements CollectionStorage<T> {
599
1601
 
600
1602
  /**
601
1603
  * This method sets an item. The storage takes a copy of the value, so a later change of the
602
- * 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.
603
1606
  */
604
- async set(id: string, written: T): Promise<void> {
1607
+ async set(id: string, written: T, options?: StorageWriteOptions): Promise<void> {
605
1608
  const value = structuredClone(written);
606
- await this.write("set", { id, value }, [id], async () => {
1609
+ const origin = options?.origin;
1610
+ await this.write("set", { id, value }, [id], origin, async () => {
607
1611
  this.bump(id);
608
- await this.local.set(id, value);
609
- }, () => this.remote.set(id, value));
1612
+ await this.localSet(id, value, origin);
1613
+ }, () => this.remote.set(id, value, this.own));
610
1614
  }
611
1615
 
612
- async delete(id: string): Promise<boolean> {
613
- 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 () => {
614
1619
  this.bump(id);
615
- return this.local.delete(id);
616
- }, () => this.remote.delete(id));
1620
+ return this.localDelete(id, origin);
1621
+ }, () => this.remote.delete(id, this.own));
617
1622
  }
618
1623
 
619
- async clear(): Promise<void> {
620
- await this.write("clear", {}, "*", async () => {
621
- this.clearVersion++;
622
- await this.local.clear();
623
- }, () => 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));
624
1630
  }
625
1631
 
626
1632
  //
@@ -628,19 +1634,21 @@ export class HybridStorage<T> implements CollectionStorage<T> {
628
1634
  //
629
1635
 
630
1636
  /** This method sets several items. The storage takes a copy of each value, as `set` does. */
631
- async setBatch(written: Array<[string, T]>): Promise<void> {
1637
+ async setBatch(written: Array<[string, T]>, options?: StorageWriteOptions): Promise<void> {
632
1638
  const items = written.map(([id, value]): [string, T] => [id, structuredClone(value)]);
633
- 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 () => {
634
1641
  for (const [id] of items) this.bump(id);
635
- await this.local.setBatch(items);
636
- }, () => this.remote.setBatch(items));
1642
+ await this.localSetBatch(items, origin);
1643
+ }, () => this.remote.setBatch(items, this.own));
637
1644
  }
638
1645
 
639
- async deleteBatch(ids: string[]): Promise<number> {
640
- 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 () => {
641
1649
  for (const id of ids) this.bump(id);
642
- return this.local.deleteBatch(ids);
643
- }, () => this.remote.deleteBatch(ids));
1650
+ return this.localDeleteBatch(ids, origin);
1651
+ }, () => this.remote.deleteBatch(ids, this.own));
644
1652
  }
645
1653
 
646
1654
  /**
@@ -664,6 +1672,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
664
1672
  type: QueuedOperation["type"],
665
1673
  data: unknown,
666
1674
  ids: string[] | "*",
1675
+ origin: string | undefined,
667
1676
  applyLocal: () => Promise<R>,
668
1677
  applyRemote: () => Promise<R>,
669
1678
  ): Promise<R> {
@@ -673,8 +1682,11 @@ export class HybridStorage<T> implements CollectionStorage<T> {
673
1682
  if (this.mode === "remote-only") {
674
1683
  const send = async (): Promise<R> => {
675
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;
676
1687
  const result = await applyRemote();
677
1688
  this.noteRemoteWrite(op);
1689
+ if (this.observers.size > 0) this.emit(remoteChangesOf<T>(op, result, existing, origin));
678
1690
  return result;
679
1691
  };
680
1692
  try {
@@ -695,55 +1707,184 @@ export class HybridStorage<T> implements CollectionStorage<T> {
695
1707
  if (this.mode === "write-back" || this.mode === "local-only") {
696
1708
  this.ensureQueueRoom(op);
697
1709
  this.attachBase(op);
698
- const undo = await this.snapshot(ids);
1710
+ const undo = await this.snapshot(ids, origin);
699
1711
  const result = await applyLocal();
1712
+ this.noteOwnWrite(op);
700
1713
  await this.enqueue(op, undo);
701
1714
  return result;
702
1715
  }
703
1716
 
704
- const undo = await this.snapshot(ids);
705
- 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);
706
1721
  try {
707
- if (this.offlineOps.length > 0) {
708
- // The queued operations are older: they reach the remote first, or the older value of a
709
- // queued write replaces this one later. A failed send keeps this write behind them.
710
- await this.serialize(async () => {
711
- 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 {
712
1735
  await this.ensureStoreClean();
1736
+ this.echoSent(op);
713
1737
  await applyRemote();
714
- });
715
- } else {
716
- await this.ensureStoreClean();
717
- 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;
718
1757
  }
719
- this.isOnline = true;
720
- this.noteRemoteWrite(op);
721
- } catch (error) {
722
- if (
723
- !(error instanceof QueueStoreError) &&
724
- this.options.offlineQueue &&
725
- this.options.isUnavailable(error) &&
726
- this.hasQueueRoom(op)
727
- ) {
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
728
1867
  this.isOnline = false;
729
- console.error(`Remote ${type} failed: the operation waits in the queue`);
730
- this.attachBase(op);
731
- await this.enqueue(op, undo);
732
- this.scheduleDrain();
733
- throw new PendingWriteError(type, error);
734
1868
  }
735
- await undo();
736
- throw error;
737
1869
  }
738
- return result;
739
- });
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);
740
1881
  }
741
1882
 
742
1883
  /**
743
1884
  * The local values of the ids before a write, and a function that puts them back. It puts back
744
1885
  * only the ids that no later write changed.
745
1886
  */
746
- private async snapshot(ids: string[] | "*"): Promise<() => Promise<void>> {
1887
+ private async snapshot(ids: string[] | "*", origin: string | undefined): Promise<() => Promise<void>> {
747
1888
  const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
748
1889
  const touched = ids === "*" ? [...before.keys()] : ids;
749
1890
  // The versions after the write: this write adds one to each id (clear adds to clearVersion)
@@ -754,8 +1895,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
754
1895
  for (const id of touched) {
755
1896
  if ((this.writeVersions.get(id) ?? 0) !== versions.get(id)) continue;
756
1897
  const value = before.get(id);
757
- if (value === undefined) await this.local.delete(id);
758
- else await this.local.set(id, value);
1898
+ if (value === undefined) await this.localDelete(id, origin);
1899
+ else await this.localSet(id, value, origin);
759
1900
  }
760
1901
  };
761
1902
  }
@@ -810,22 +1951,26 @@ export class HybridStorage<T> implements CollectionStorage<T> {
810
1951
  await this.syncFromRemote();
811
1952
  }
812
1953
 
813
- /** The pull of `syncFromRemote()`, after the syncs that run. */
1954
+ /** The pull of `syncFromRemote()`, after the syncs that run. The status keeps its failure. */
814
1955
  private pull(): Promise<void> {
815
- return this.serialize(async () => {
816
- await this.loaded;
817
- if (this.loadError) throw this.loadError;
818
- if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
819
- });
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
+ );
820
1963
  }
821
1964
 
822
- /** The push of `syncToRemote()`, after the syncs that run. */
1965
+ /** The push of `syncToRemote()`, after the syncs that run. The status keeps its failure. */
823
1966
  private push(): Promise<void> {
824
- return this.serialize(async () => {
825
- await this.loaded;
826
- if (this.loadError) throw this.loadError;
827
- if (this.mode !== "local-only") await this.pushQueue();
828
- });
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
+ );
829
1974
  }
830
1975
 
831
1976
  /** The error of an operation after `close()`. */
@@ -856,36 +2001,37 @@ export class HybridStorage<T> implements CollectionStorage<T> {
856
2001
 
857
2002
  /** The work of `close()`. */
858
2003
  private async shutDown(): Promise<void> {
859
- for (const timer of [this.syncTimer, this.pullTimer]) {
860
- if (timer) clearInterval(timer);
861
- }
862
- this.syncTimer = null;
863
- this.pullTimer = null;
864
- if (this.drainTimer) {
865
- clearTimeout(this.drainTimer);
866
- this.drainTimer = null;
867
- }
868
-
869
- await this.loaded;
870
- // The operations and the switch that started before close() end first
871
- await this.gate;
872
- await this.idle();
873
- // After the syncs that run: send the pending operations
874
- await this.serialize(async () => {
875
- if (this.loadError || this.offlineOps.length === 0 || this.mode === "local-only") return;
876
- try {
877
- await this.pushQueue();
878
- } catch (error) {
879
- console.error("Failed to sync before close:", error);
880
- }
881
- });
882
- await this.storeChain;
2004
+ this.pushTimer?.stop();
2005
+ this.pullTimer?.stop();
2006
+ this.drainTimer.stop();
2007
+ this.updateFeed();
2008
+ this.stopCapture?.();
883
2009
 
884
- // Close remote storage (an ApiStorage leaves the caller's client open)
885
2010
  try {
886
- 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
+ }
887
2032
  } finally {
888
- await this.local.close();
2033
+ this.statusListeners.clear();
2034
+ this.observers.clear();
889
2035
  }
890
2036
  }
891
2037
 
@@ -947,6 +2093,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
947
2093
  if (--this.active === 0) {
948
2094
  for (const resolve of this.idleWaiters.splice(0)) resolve();
949
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();
950
2100
  }
951
2101
  }
952
2102
 
@@ -958,12 +2108,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
958
2108
 
959
2109
  /** This method loads the queue, and puts its writes in the local copy. */
960
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();
961
2113
  let ops: QueuedOperation[];
962
2114
  try {
963
2115
  ops = await this.queueStore.load();
964
2116
  } catch (error) {
2117
+ // The storage refuses each operation. The status shows the failure.
965
2118
  this.loadError = new QueueStoreError("load", error);
966
- 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;
967
2121
  return;
968
2122
  }
969
2123
  this.offlineOps = ops.slice();
@@ -974,11 +2128,15 @@ export class HybridStorage<T> implements CollectionStorage<T> {
974
2128
  }
975
2129
  // A durable local copy can hold the operation already: each operation gives the same
976
2130
  // result two times
977
- await this.applyToLocal(op);
2131
+ await this.applyToLocal(op, this.syncOrigin);
978
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();
979
2136
  } catch (error) {
980
2137
  this.loadError = new Error("The local copy could not take the loaded queue: the storage refuses each operation", { cause: error });
981
- console.error(this.loadError.message, error);
2138
+ this.fail("load", this.loadError);
2139
+ this.captured = undefined;
982
2140
  }
983
2141
  }
984
2142
 
@@ -987,42 +2145,157 @@ export class HybridStorage<T> implements CollectionStorage<T> {
987
2145
  // A close() before the first sync: the storage makes no remote call
988
2146
  if (this.loadError || this.closed) return;
989
2147
  if (this.options.syncOnInit && this.mode !== "local-only") {
990
- if (this.offlineOps.length > 0) {
991
- await this.push().catch((err) => {
992
- console.error("Failed to send the queue on init:", err);
993
- });
994
- }
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);
995
2150
  if (!this.closed && (this.mode === "write-through" || this.mode === "write-back")) {
996
- await this.pull().catch((err) => {
997
- console.error("Failed to sync on init:", err);
998
- });
2151
+ await this.pull().catch(() => undefined);
999
2152
  }
1000
2153
  }
1001
2154
  if (this.offlineOps.length > 0) this.scheduleDrain();
1002
2155
  }
1003
2156
 
1004
- /** This method applies a queued operation to the local copy. */
1005
- private async applyToLocal(op: QueuedOperation): Promise<void> {
1006
- 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>;
1007
2160
  switch (op.type) {
1008
2161
  case "set":
1009
- await this.local.set(data.id, data.value);
2162
+ await this.localSet(data.id, data.value, origin);
1010
2163
  break;
1011
2164
  case "delete":
1012
- await this.local.delete(data.id);
2165
+ await this.localDelete(data.id, origin);
1013
2166
  break;
1014
2167
  case "setBatch":
1015
- await this.local.setBatch(data.items);
2168
+ await this.localSetBatch(data.items, origin);
1016
2169
  break;
1017
2170
  case "deleteBatch":
1018
- await this.local.deleteBatch(data.ids);
2171
+ await this.localDeleteBatch(data.ids, origin);
1019
2172
  break;
1020
2173
  case "clear":
1021
- await this.local.clear();
2174
+ await this.localClear(origin);
1022
2175
  break;
1023
2176
  }
1024
2177
  }
1025
2178
 
2179
+ //
2180
+ // ═══ The writes of the local copy ═══
2181
+ //
2182
+
2183
+ /**
2184
+ * This method writes to the local copy. With `versionOf`, it records the version of each item
2185
+ * that the write gives, so a pull does not read the item to know its version.
2186
+ *
2187
+ * - During the write, the versions of its ids are unknown. A failed write keeps them unknown.
2188
+ * - Only the last write of an id that started records its version, and a clear that started
2189
+ * after the write cancels the record.
2190
+ */
2191
+ private writeLocal<R>(versionsOf: () => Array<[string, string | undefined]>, write: () => Promise<R>): Promise<R> {
2192
+ return this.options.versionOf ? this.recordedWrite(versionsOf(), write) : write();
2193
+ }
2194
+
2195
+ /** The write of `writeLocal` with `versionOf`. */
2196
+ private async recordedWrite<R>(versions: Array<[string, string | undefined]>, write: () => Promise<R>): Promise<R> {
2197
+ const clears = this.localClears;
2198
+ const numbers = versions.map(([id]) => {
2199
+ this.localVersions.delete(id);
2200
+ const number = ++this.localWriteCount;
2201
+ this.localWrites.set(id, number);
2202
+ return number;
2203
+ });
2204
+ let written = false;
2205
+ try {
2206
+ const result = await write();
2207
+ written = true;
2208
+ return result;
2209
+ } finally {
2210
+ versions.forEach(([id, version], index) => {
2211
+ if (this.localWrites.get(id) !== numbers[index]) return;
2212
+ this.localWrites.delete(id);
2213
+ if (written && version !== undefined && clears === this.localClears) this.localVersions.set(id, version);
2214
+ });
2215
+ }
2216
+ }
2217
+
2218
+ /**
2219
+ * This method reads a local item for a pull. With `versionOf`, it records the version of the item
2220
+ * when no local write started or ran during the read.
2221
+ */
2222
+ private async readLocal(id: string): Promise<T | undefined> {
2223
+ const writes = this.localWriteCount;
2224
+ const clears = this.localClears;
2225
+ const item = await this.local.get(id);
2226
+ if (
2227
+ item !== undefined &&
2228
+ this.options.versionOf &&
2229
+ writes === this.localWriteCount &&
2230
+ clears === this.localClears &&
2231
+ !this.localWrites.has(id)
2232
+ ) {
2233
+ const version = this.recordedVersion(item);
2234
+ if (version !== undefined) this.localVersions.set(id, version);
2235
+ }
2236
+ return item;
2237
+ }
2238
+
2239
+ /** The version of an item for the record of the local copy, or undefined when `versionOf` fails. */
2240
+ private recordedVersion(item: T): string | undefined {
2241
+ try {
2242
+ return this.versionOfItem(item);
2243
+ } catch {
2244
+ return undefined;
2245
+ }
2246
+ }
2247
+
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)]);
2254
+ }
2255
+
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;
2260
+ }
2261
+
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)));
2265
+ }
2266
+
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;
2273
+ }
2274
+
2275
+ private localClear(origin: string | undefined): Promise<void> {
2276
+ this.localClears++;
2277
+ this.localVersions.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;
2297
+ }
2298
+
1026
2299
  /**
1027
2300
  * One pull. `full`: ask for all items (after the local copy was dropped).
1028
2301
  *
@@ -1031,11 +2304,8 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1031
2304
  * comes again at the next pull.
1032
2305
  */
1033
2306
  private async pullRemote(full: boolean): Promise<void> {
1034
- const versions = new Map(this.writeVersions);
1035
- const clearVersion = this.clearVersion;
1036
- // True when a local write changed the id after the sync started
1037
- const changedLocally = (id: string): boolean =>
1038
- 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;
1039
2309
 
1040
2310
  let changes: PulledChanges<T>;
1041
2311
  try {
@@ -1044,7 +2314,15 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1044
2314
  if (!(error instanceof MissingKeysError)) this.isOnline = false;
1045
2315
  throw error;
1046
2316
  }
2317
+ await this.applyChanges(changes, (id) => this.changedSince(mark, id));
2318
+ }
1047
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> {
1048
2326
  const conflicts: string[] = [];
1049
2327
  let queueChanged = false;
1050
2328
  const remoteIds = new Set<string>();
@@ -1061,14 +2339,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1061
2339
 
1062
2340
  if (queueChanged) {
1063
2341
  this.storeDirty = true;
1064
- await this.persistAll().catch((error) => {
1065
- console.error("The queue store could not keep the queue after the sync:", error);
1066
- });
2342
+ // The status gets the failure of the queue store. A later direct write repairs the store first.
2343
+ await this.persistAll().catch(() => undefined);
1067
2344
  }
1068
2345
  this.stats.syncCount++;
1069
2346
  this.isOnline = true;
1070
2347
  if (conflicts.length > 0) throw new ConflictError(conflicts);
1071
2348
  if (changes.version !== undefined) this.cursor = changes.version;
2349
+ this.lastSync = Date.now();
1072
2350
  }
1073
2351
 
1074
2352
  /**
@@ -1088,17 +2366,31 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1088
2366
  return { full: true, changed: await this.remoteEntries(), deleted: [] };
1089
2367
  }
1090
2368
 
1091
- /** This method applies one remote item. It gives true when the queue changed. */
2369
+ /**
2370
+ * This method applies one remote item. It gives true when the queue changed.
2371
+ *
2372
+ * The pull reads the local item only when the result depends on it (roadmap R28):
2373
+ * - An id with a queued write: only when the remote version changed after the last known remote
2374
+ * version. Without versions, the queued write wins, and the pull reads nothing.
2375
+ * - An id without a queued write: not when the storage knows the version of the local item.
2376
+ * Without versions, the pull compares the JSON of the two items, so it reads the local item.
2377
+ * Before, the pull read each local item.
2378
+ */
1092
2379
  private async pullItem(
1093
2380
  id: string,
1094
2381
  item: T,
1095
2382
  changedLocally: (id: string) => boolean,
1096
2383
  conflicts: string[],
1097
2384
  ): Promise<boolean> {
1098
- if (changedLocally(id)) return false;
1099
- const localItem = await this.local.get(id);
1100
2385
  if (changedLocally(id)) return false;
1101
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;
2393
+ const knownVersion = remoteVersion === undefined ? undefined : this.localVersions.get(id);
1102
2394
 
1103
2395
  if (this.hasPendingOp(id)) {
1104
2396
  // Without versions, the queued write wins, as before
@@ -1107,6 +2399,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1107
2399
  // of the id: the queued write wins
1108
2400
  const base = this.bases.get(id);
1109
2401
  if (base === undefined || base === remoteVersion) return false;
2402
+ if (knownVersion === remoteVersion) {
2403
+ this.bases.set(id, remoteVersion);
2404
+ return false;
2405
+ }
2406
+ const localItem = await this.readLocal(id);
2407
+ if (changedLocally(id)) return false;
1110
2408
  if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
1111
2409
  this.bases.set(id, remoteVersion);
1112
2410
  return false;
@@ -1114,12 +2412,21 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1114
2412
  return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
1115
2413
  }
1116
2414
 
2415
+ if (knownVersion !== undefined) {
2416
+ // Only the remote changed: it is not a conflict. The version of the local item is known.
2417
+ if (knownVersion !== remoteVersion) await this.localSet(id, item, this.syncOrigin);
2418
+ this.bases.set(id, remoteVersion!);
2419
+ return false;
2420
+ }
2421
+
2422
+ const localItem = await this.readLocal(id);
2423
+ if (changedLocally(id)) return false;
1117
2424
  const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
1118
2425
  if (localItem === undefined) {
1119
- await this.local.set(id, item);
2426
+ await this.localSet(id, item, this.syncOrigin);
1120
2427
  } else if (remoteVersion !== undefined && localVersion !== undefined) {
1121
2428
  // Only the remote changed: it is not a conflict
1122
- if (localVersion !== remoteVersion) await this.local.set(id, item);
2429
+ if (localVersion !== remoteVersion) await this.localSet(id, item, this.syncOrigin);
1123
2430
  } else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
1124
2431
  // Conflict - resolve according to strategy
1125
2432
  if (this.options.conflictResolution === "error") {
@@ -1128,7 +2435,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1128
2435
  return false;
1129
2436
  }
1130
2437
  const resolved = this.resolveConflict(localItem, item);
1131
- await this.local.set(id, resolved);
2438
+ await this.localSet(id, resolved, this.syncOrigin);
1132
2439
  this.stats.conflicts++;
1133
2440
  }
1134
2441
  if (remoteVersion !== undefined) this.bases.set(id, remoteVersion);
@@ -1138,8 +2445,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1138
2445
  /** This method applies one remote delete. It gives true when the queue changed. */
1139
2446
  private async pullDelete(id: string, changedLocally: (id: string) => boolean, conflicts: string[]): Promise<boolean> {
1140
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;
1141
2452
  if (!this.hasPendingOp(id)) {
1142
- await this.local.delete(id);
2453
+ await this.localDelete(id, this.syncOrigin);
1143
2454
  if (this.options.versionOf) this.bases.set(id, null);
1144
2455
  return false;
1145
2456
  }
@@ -1147,7 +2458,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1147
2458
  // item only if the last sync saw a version of it.
1148
2459
  const base = this.bases.get(id);
1149
2460
  if (!this.options.versionOf || base === undefined || base === null) return false;
1150
- const localItem = await this.local.get(id);
2461
+ const localItem = await this.readLocal(id);
1151
2462
  if (changedLocally(id)) return false;
1152
2463
  if (localItem === undefined) {
1153
2464
  // Both sides deleted the item
@@ -1158,7 +2469,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1158
2469
  switch (this.options.conflictResolution) {
1159
2470
  case "remote":
1160
2471
  if (!this.dropQueued(id)) break;
1161
- await this.local.delete(id);
2472
+ await this.localDelete(id, this.syncOrigin);
1162
2473
  this.bases.set(id, null);
1163
2474
  return true;
1164
2475
  case "error":
@@ -1188,14 +2499,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1188
2499
  case "remote":
1189
2500
  // The remote value replaces the queued write. A queued clear cannot lose one id.
1190
2501
  if (!this.dropQueued(id)) break;
1191
- await this.local.set(id, remoteItem);
2502
+ await this.localSet(id, remoteItem, this.syncOrigin);
1192
2503
  this.bases.set(id, remoteVersion);
1193
2504
  return true;
1194
2505
  case "merge": {
1195
2506
  if (localItem === undefined) break;
1196
2507
  const merged = this.resolveConflict(localItem, remoteItem);
1197
2508
  if (!this.dropQueued(id)) break;
1198
- await this.local.set(id, merged);
2509
+ await this.localSet(id, merged, this.syncOrigin);
1199
2510
  this.bases.set(id, remoteVersion);
1200
2511
  this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
1201
2512
  return true;
@@ -1274,6 +2585,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1274
2585
  continue;
1275
2586
  }
1276
2587
  try {
2588
+ this.echoSent(op);
1277
2589
  await this.executeOperation(op);
1278
2590
  } catch (error) {
1279
2591
  // This operation and the later ones stay queued
@@ -1292,10 +2604,9 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1292
2604
  } finally {
1293
2605
  this.pushing = false;
1294
2606
  if (sent > 0) {
1295
- await this.persistAll().catch((error) => {
1296
- // The store keeps sent operations. A later direct write repairs it first.
1297
- console.error("The queue store could not remove the sent operations:", error);
1298
- });
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);
1299
2610
  }
1300
2611
  }
1301
2612
  }
@@ -1309,6 +2620,18 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1309
2620
 
1310
2621
  private bump(id: string): void {
1311
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;
1312
2635
  }
1313
2636
 
1314
2637
  /** The count of local writes of an id: it changes with each write and each clear. */
@@ -1504,11 +2827,14 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1504
2827
  this.unpersisted.delete(op);
1505
2828
  this.storeDirty = false;
1506
2829
  }
2830
+ this.succeed("store");
1507
2831
  } catch (error) {
1508
2832
  this.storeDirty = true;
1509
2833
  this.unpersisted.delete(op);
1510
2834
  rollback();
1511
- throw new QueueStoreError("write", error);
2835
+ const failure = new QueueStoreError("write", error);
2836
+ this.fail("store", failure);
2837
+ throw failure;
1512
2838
  }
1513
2839
  });
1514
2840
  this.storeChain = run.catch(() => undefined);
@@ -1525,9 +2851,12 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1525
2851
  else await this.queueStore.replace(snapshot);
1526
2852
  for (const kept of snapshot) this.unpersisted.delete(kept);
1527
2853
  this.storeDirty = false;
2854
+ this.succeed("store");
1528
2855
  } catch (error) {
1529
2856
  this.storeDirty = true;
1530
- throw new QueueStoreError("write", error);
2857
+ const failure = new QueueStoreError("write", error);
2858
+ this.fail("store", failure);
2859
+ throw failure;
1531
2860
  }
1532
2861
  });
1533
2862
  this.storeChain = run.catch(() => undefined);
@@ -1570,17 +2899,11 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1570
2899
 
1571
2900
  /**
1572
2901
  * In write-through mode, a timer sends the queue after a failure, and again after each failed
1573
- * send, until the queue is empty.
2902
+ * send, until the queue is empty. The delay after a failed send grows with the backoff.
1574
2903
  */
1575
2904
  private scheduleDrain(): void {
1576
- if (this.drainTimer || this.closed || this.mode !== "write-through") return;
1577
- const delay = this.options.syncInterval > 0 ? this.options.syncInterval : 5000;
1578
- this.drainTimer = setTimeout(() => {
1579
- this.drainTimer = null;
1580
- if (this.closed) return;
1581
- this.push().catch(() => this.scheduleDrain());
1582
- }, delay);
1583
- (this.drainTimer as { unref?: () => void }).unref?.();
2905
+ if (this.closed || this.mode !== "write-through") return;
2906
+ this.drainTimer.start();
1584
2907
  }
1585
2908
 
1586
2909
  /**
@@ -1591,23 +2914,23 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1591
2914
 
1592
2915
  switch (op.type) {
1593
2916
  case "set":
1594
- await this.remote.set(data.id, data.value);
2917
+ await this.remote.set(data.id, data.value, this.own);
1595
2918
  break;
1596
2919
 
1597
2920
  case "delete":
1598
- await this.remote.delete(data.id);
2921
+ await this.remote.delete(data.id, this.own);
1599
2922
  break;
1600
2923
 
1601
2924
  case "setBatch":
1602
- await this.remote.setBatch(data.items);
2925
+ await this.remote.setBatch(data.items, this.own);
1603
2926
  break;
1604
2927
 
1605
2928
  case "deleteBatch":
1606
- await this.remote.deleteBatch(data.ids);
2929
+ await this.remote.deleteBatch(data.ids, this.own);
1607
2930
  break;
1608
2931
 
1609
2932
  case "clear":
1610
- await this.remote.clear();
2933
+ await this.remote.clear(this.own);
1611
2934
  break;
1612
2935
  }
1613
2936
  }