@procedures/store 0.8.0 → 0.9.0

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