@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,12 +19,124 @@
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
- import { InMemoryStorage } from "@procedures/collections";
25
- 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";
26
46
  import { MemoryQueueStore, idsOf, withoutId } from "./queue.js";
47
+ import { isSyncState } from "./sync-state.js";
27
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
+ }
28
140
  /**
29
141
  * The remote did not take a write of the write-through mode. The write is in the local cache and
30
142
  * in the queue, and the storage sends it later (roadmap R2). `cause` is the error of the remote.
@@ -92,6 +204,17 @@ export class ModeSwitchError extends Error {
92
204
  this.to = to;
93
205
  }
94
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
+ }
95
218
  const defaultIsUnavailable = (error) => {
96
219
  if (typeof error !== "object" || error === null)
97
220
  return true;
@@ -110,6 +233,184 @@ function checkMode(mode) {
110
233
  }
111
234
  return mode;
112
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
+ }
113
414
  /**
114
415
  * Hybrid storage combining local cache with remote persistence.
115
416
  *
@@ -151,14 +452,25 @@ export class HybridStorage {
151
452
  storeDirty = false;
152
453
  /** The calls of the queue store, one at a time */
153
454
  storeChain = Promise.resolve();
154
- /** The push timer of the write-back mode */
155
- syncTimer = null;
156
- /** The periodic pull */
157
- 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;
158
459
  /** True while pushQueue sends the queue: the first operation can be on its way */
159
460
  pushing = false;
160
461
  /** The timer that sends the queue of the write-through mode after a failure */
161
- 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;
162
474
  /** The load of the queue. The writes wait for it. */
163
475
  loaded;
164
476
  /** The load and the first sync. The reads wait for it, so a read never sees an empty cache by mistake. */
@@ -185,7 +497,57 @@ export class HybridStorage {
185
497
  /** True when the remote has `getChangesSince` but its server does not know `changes` */
186
498
  changesUnsupported = false;
187
499
  /** With `versionOf`: the last known remote version of each id. `null`: the remote has no item. */
188
- bases = new Map();
500
+ bases = new BaseVersions();
501
+ /**
502
+ * With `versionOf`: the version of each local item that the storage wrote or read. A pull does
503
+ * not read an item whose version it knows. The version of an id that is not here is unknown.
504
+ */
505
+ localVersions = new Map();
506
+ /** The number of the last local write of each id that runs: only that write records a version */
507
+ localWrites = new Map();
508
+ localWriteCount = 0;
509
+ /** The number of clears of the local copy: a clear cancels the records of the writes that run */
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;
189
551
  stats = {
190
552
  cacheHits: 0,
191
553
  cacheMisses: 0,
@@ -209,12 +571,533 @@ export class HybridStorage {
209
571
  offlineQueue: options.offlineQueue ?? true,
210
572
  isUnavailable: options.isUnavailable ?? defaultIsUnavailable,
211
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
+ }
212
628
  // The queue first, then the first sync. The reads wait for both (roadmap R2, R28).
213
629
  this.loaded = this.loadQueue();
214
630
  this.ready = this.loaded.then(() => this.initialSync());
215
631
  this.updateTimers();
216
632
  }
217
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
+ //
218
1101
  // ═══ Modes ═══
219
1102
  //
220
1103
  /** The mode of the storage now. */
@@ -277,58 +1160,40 @@ export class HybridStorage {
277
1160
  }
278
1161
  this.mode = target;
279
1162
  this.updateTimers();
1163
+ this.notify();
1164
+ this.saveState();
280
1165
  }
281
1166
  /** This method drops the local copy. A read in flight does not fill it again. */
282
1167
  async dropLocal() {
283
- this.clearVersion++;
1168
+ this.bumpClear();
284
1169
  this.cursor = undefined;
285
- await this.local.clear();
1170
+ await this.localClear(this.syncOrigin);
286
1171
  }
287
1172
  /** This method makes the local copy from the queue only, before a full sync. */
288
1173
  async rebuildLocal() {
289
1174
  await this.dropLocal();
290
1175
  for (const op of this.offlineOps)
291
- await this.applyToLocal(op);
1176
+ await this.applyToLocal(op, this.syncOrigin);
292
1177
  }
293
- /** 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
+ */
294
1183
  updateTimers() {
295
1184
  if (this.closed)
296
1185
  return;
297
- const push = this.mode === "write-back" && this.options.syncInterval > 0;
298
- if (push && !this.syncTimer) {
299
- this.syncTimer = setInterval(() => {
300
- if (this.closed)
301
- return;
302
- this.push().catch((err) => {
303
- console.error("Periodic sync failed:", err);
304
- });
305
- }, this.options.syncInterval);
306
- // The timer must not keep the process alive (deep dive DATA-9)
307
- this.syncTimer.unref?.();
308
- }
309
- else if (!push && this.syncTimer) {
310
- clearInterval(this.syncTimer);
311
- this.syncTimer = null;
312
- }
313
- const pull = this.options.pullInterval > 0 && (this.mode === "write-through" || this.mode === "write-back");
314
- if (pull && !this.pullTimer) {
315
- this.pullTimer = setInterval(() => {
316
- if (this.closed)
317
- return;
318
- this.pull().catch((err) => {
319
- console.error("Periodic pull failed:", err);
320
- });
321
- }, this.options.pullInterval);
322
- this.pullTimer.unref?.();
323
- }
324
- else if (!pull && this.pullTimer) {
325
- clearInterval(this.pullTimer);
326
- this.pullTimer = null;
327
- }
328
- if (this.mode !== "write-through" && this.drainTimer) {
329
- clearTimeout(this.drainTimer);
330
- this.drainTimer = null;
331
- }
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();
332
1197
  }
333
1198
  //
334
1199
  // ═══ Read Operations ═══
@@ -349,17 +1214,18 @@ export class HybridStorage {
349
1214
  return cached;
350
1215
  }
351
1216
  this.stats.cacheMisses++;
352
- // A pending local delete means "no item": do not read it back from the remote
353
- 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)) {
354
1220
  return undefined;
355
1221
  }
356
1222
  const version = this.writeKey(id);
357
1223
  try {
358
1224
  const remote = await this.remote.get(id);
359
1225
  this.isOnline = true;
360
- if (this.writeKey(id) === version && !this.hasPendingOp(id)) {
1226
+ if (this.writeKey(id) === version && !this.hasPendingOp(id) && !this.waitsForRemote(id)) {
361
1227
  if (remote !== undefined)
362
- await this.local.set(id, remote);
1228
+ await this.localSet(id, remote, this.syncOrigin);
363
1229
  this.noteBase(id, remote);
364
1230
  }
365
1231
  return remote;
@@ -388,6 +1254,24 @@ export class HybridStorage {
388
1254
  async find(predicate) {
389
1255
  return this.run(true, () => this.mode === "remote-only" ? this.remoteRead(() => this.remote.find(predicate)) : this.local.find(predicate));
390
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
+ }
391
1275
  async has(id) {
392
1276
  return this.run(true, () => (this.mode === "remote-only" ? this.remoteRead(() => this.remote.has(id)) : this.local.has(id)));
393
1277
  }
@@ -417,45 +1301,51 @@ export class HybridStorage {
417
1301
  //
418
1302
  /**
419
1303
  * This method sets an item. The storage takes a copy of the value, so a later change of the
420
- * 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.
421
1306
  */
422
- async set(id, written) {
1307
+ async set(id, written, options) {
423
1308
  const value = structuredClone(written);
424
- await this.write("set", { id, value }, [id], async () => {
1309
+ const origin = options?.origin;
1310
+ await this.write("set", { id, value }, [id], origin, async () => {
425
1311
  this.bump(id);
426
- await this.local.set(id, value);
427
- }, () => this.remote.set(id, value));
1312
+ await this.localSet(id, value, origin);
1313
+ }, () => this.remote.set(id, value, this.own));
428
1314
  }
429
- async delete(id) {
430
- 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 () => {
431
1318
  this.bump(id);
432
- return this.local.delete(id);
433
- }, () => this.remote.delete(id));
1319
+ return this.localDelete(id, origin);
1320
+ }, () => this.remote.delete(id, this.own));
434
1321
  }
435
- async clear() {
436
- await this.write("clear", {}, "*", async () => {
437
- this.clearVersion++;
438
- await this.local.clear();
439
- }, () => 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));
440
1328
  }
441
1329
  //
442
1330
  // ═══ Bulk Operations ═══
443
1331
  //
444
1332
  /** This method sets several items. The storage takes a copy of each value, as `set` does. */
445
- async setBatch(written) {
1333
+ async setBatch(written, options) {
446
1334
  const items = written.map(([id, value]) => [id, structuredClone(value)]);
447
- 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 () => {
448
1337
  for (const [id] of items)
449
1338
  this.bump(id);
450
- await this.local.setBatch(items);
451
- }, () => this.remote.setBatch(items));
1339
+ await this.localSetBatch(items, origin);
1340
+ }, () => this.remote.setBatch(items, this.own));
452
1341
  }
453
- async deleteBatch(ids) {
454
- 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 () => {
455
1345
  for (const id of ids)
456
1346
  this.bump(id);
457
- return this.local.deleteBatch(ids);
458
- }, () => this.remote.deleteBatch(ids));
1347
+ return this.localDeleteBatch(ids, origin);
1348
+ }, () => this.remote.deleteBatch(ids, this.own));
459
1349
  }
460
1350
  /**
461
1351
  * One write, in the mode of the storage. The result is the result of the local copy, or of the
@@ -474,14 +1364,18 @@ export class HybridStorage {
474
1364
  * queue, a second try queued the operation again, and a full queue dropped the oldest operation
475
1365
  * without a word (roadmap R2).
476
1366
  */
477
- write(type, data, ids, applyLocal, applyRemote) {
1367
+ write(type, data, ids, origin, applyLocal, applyRemote) {
478
1368
  return this.run(false, async () => {
479
1369
  const op = { type, data, timestamp: Date.now() };
480
1370
  if (this.mode === "remote-only") {
481
1371
  const send = async () => {
482
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;
483
1375
  const result = await applyRemote();
484
1376
  this.noteRemoteWrite(op);
1377
+ if (this.observers.size > 0)
1378
+ this.emit(remoteChangesOf(op, result, existing, origin));
485
1379
  return result;
486
1380
  };
487
1381
  try {
@@ -503,53 +1397,188 @@ export class HybridStorage {
503
1397
  if (this.mode === "write-back" || this.mode === "local-only") {
504
1398
  this.ensureQueueRoom(op);
505
1399
  this.attachBase(op);
506
- const undo = await this.snapshot(ids);
1400
+ const undo = await this.snapshot(ids, origin);
507
1401
  const result = await applyLocal();
1402
+ this.noteOwnWrite(op);
508
1403
  await this.enqueue(op, undo);
509
1404
  return result;
510
1405
  }
511
- const undo = await this.snapshot(ids);
512
- 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);
513
1410
  try {
514
- if (this.offlineOps.length > 0) {
515
- // The queued operations are older: they reach the remote first, or the older value of a
516
- // queued write replaces this one later. A failed send keeps this write behind them.
517
- await this.serialize(async () => {
518
- 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 {
519
1425
  await this.ensureStoreClean();
1426
+ this.echoSent(op);
520
1427
  await applyRemote();
521
- });
1428
+ }
1429
+ this.isOnline = true;
1430
+ this.noteRemoteWrite(op);
522
1431
  }
523
- else {
524
- await this.ensureStoreClean();
525
- 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;
526
1446
  }
527
- this.isOnline = true;
528
- this.noteRemoteWrite(op);
1447
+ return result;
529
1448
  }
530
- catch (error) {
531
- if (!(error instanceof QueueStoreError) &&
532
- this.options.offlineQueue &&
533
- this.options.isUnavailable(error) &&
534
- 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
535
1560
  this.isOnline = false;
536
- console.error(`Remote ${type} failed: the operation waits in the queue`);
537
- this.attachBase(op);
538
- await this.enqueue(op, undo);
539
- this.scheduleDrain();
540
- throw new PendingWriteError(type, error);
541
1561
  }
542
- await undo();
543
- throw error;
544
1562
  }
545
- return result;
546
- });
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);
547
1576
  }
548
1577
  /**
549
1578
  * The local values of the ids before a write, and a function that puts them back. It puts back
550
1579
  * only the ids that no later write changed.
551
1580
  */
552
- async snapshot(ids) {
1581
+ async snapshot(ids, origin) {
553
1582
  const before = ids === "*" ? new Map(await this.local.getEntries()) : await this.local.getBatch(ids);
554
1583
  const touched = ids === "*" ? [...before.keys()] : ids;
555
1584
  // The versions after the write: this write adds one to each id (clear adds to clearVersion)
@@ -563,9 +1592,9 @@ export class HybridStorage {
563
1592
  continue;
564
1593
  const value = before.get(id);
565
1594
  if (value === undefined)
566
- await this.local.delete(id);
1595
+ await this.localDelete(id, origin);
567
1596
  else
568
- await this.local.set(id, value);
1597
+ await this.localSet(id, value, origin);
569
1598
  }
570
1599
  };
571
1600
  }
@@ -616,25 +1645,25 @@ export class HybridStorage {
616
1645
  await this.syncToRemote();
617
1646
  await this.syncFromRemote();
618
1647
  }
619
- /** The pull of `syncFromRemote()`, after the syncs that run. */
1648
+ /** The pull of `syncFromRemote()`, after the syncs that run. The status keeps its failure. */
620
1649
  pull() {
621
- return this.serialize(async () => {
1650
+ return this.serialize(() => this.track("pull", async () => {
622
1651
  await this.loaded;
623
1652
  if (this.loadError)
624
1653
  throw this.loadError;
625
1654
  if (this.mode === "write-through" || this.mode === "write-back")
626
1655
  await this.pullRemote(false);
627
- });
1656
+ }));
628
1657
  }
629
- /** The push of `syncToRemote()`, after the syncs that run. */
1658
+ /** The push of `syncToRemote()`, after the syncs that run. The status keeps its failure. */
630
1659
  push() {
631
- return this.serialize(async () => {
1660
+ return this.serialize(() => this.track("push", async () => {
632
1661
  await this.loaded;
633
1662
  if (this.loadError)
634
1663
  throw this.loadError;
635
1664
  if (this.mode !== "local-only")
636
1665
  await this.pushQueue();
637
- });
1666
+ }));
638
1667
  }
639
1668
  /** The error of an operation after `close()`. */
640
1669
  closedError() {
@@ -662,38 +1691,39 @@ export class HybridStorage {
662
1691
  }
663
1692
  /** The work of `close()`. */
664
1693
  async shutDown() {
665
- for (const timer of [this.syncTimer, this.pullTimer]) {
666
- if (timer)
667
- clearInterval(timer);
668
- }
669
- this.syncTimer = null;
670
- this.pullTimer = null;
671
- if (this.drainTimer) {
672
- clearTimeout(this.drainTimer);
673
- this.drainTimer = null;
674
- }
675
- await this.loaded;
676
- // The operations and the switch that started before close() end first
677
- await this.gate;
678
- await this.idle();
679
- // After the syncs that run: send the pending operations
680
- await this.serialize(async () => {
681
- if (this.loadError || this.offlineOps.length === 0 || this.mode === "local-only")
682
- 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)
683
1717
  try {
684
- await this.pushQueue();
1718
+ await this.remote.close();
685
1719
  }
686
- catch (error) {
687
- console.error("Failed to sync before close:", error);
1720
+ finally {
1721
+ await this.local.close();
688
1722
  }
689
- });
690
- await this.storeChain;
691
- // Close remote storage (an ApiStorage leaves the caller's client open)
692
- try {
693
- await this.remote.close();
694
1723
  }
695
1724
  finally {
696
- await this.local.close();
1725
+ this.statusListeners.clear();
1726
+ this.observers.clear();
697
1727
  }
698
1728
  }
699
1729
  /**
@@ -757,6 +1787,10 @@ export class HybridStorage {
757
1787
  for (const resolve of this.idleWaiters.splice(0))
758
1788
  resolve();
759
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();
760
1794
  }
761
1795
  }
762
1796
  /** A promise that resolves when no operation runs. */
@@ -767,13 +1801,17 @@ export class HybridStorage {
767
1801
  }
768
1802
  /** This method loads the queue, and puts its writes in the local copy. */
769
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();
770
1806
  let ops;
771
1807
  try {
772
1808
  ops = await this.queueStore.load();
773
1809
  }
774
1810
  catch (error) {
1811
+ // The storage refuses each operation. The status shows the failure.
775
1812
  this.loadError = new QueueStoreError("load", error);
776
- 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;
777
1815
  return;
778
1816
  }
779
1817
  this.offlineOps = ops.slice();
@@ -785,12 +1823,17 @@ export class HybridStorage {
785
1823
  }
786
1824
  // A durable local copy can hold the operation already: each operation gives the same
787
1825
  // result two times
788
- await this.applyToLocal(op);
1826
+ await this.applyToLocal(op, this.syncOrigin);
789
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();
790
1832
  }
791
1833
  catch (error) {
792
1834
  this.loadError = new Error("The local copy could not take the loaded queue: the storage refuses each operation", { cause: error });
793
- console.error(this.loadError.message, error);
1835
+ this.fail("load", this.loadError);
1836
+ this.captured = undefined;
794
1837
  }
795
1838
  }
796
1839
  /** The first sync: send the loaded queue, then read the remote. */
@@ -799,41 +1842,154 @@ export class HybridStorage {
799
1842
  if (this.loadError || this.closed)
800
1843
  return;
801
1844
  if (this.options.syncOnInit && this.mode !== "local-only") {
802
- if (this.offlineOps.length > 0) {
803
- await this.push().catch((err) => {
804
- console.error("Failed to send the queue on init:", err);
805
- });
806
- }
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);
807
1848
  if (!this.closed && (this.mode === "write-through" || this.mode === "write-back")) {
808
- await this.pull().catch((err) => {
809
- console.error("Failed to sync on init:", err);
810
- });
1849
+ await this.pull().catch(() => undefined);
811
1850
  }
812
1851
  }
813
1852
  if (this.offlineOps.length > 0)
814
1853
  this.scheduleDrain();
815
1854
  }
816
- /** This method applies a queued operation to the local copy. */
817
- async applyToLocal(op) {
1855
+ /** This method applies a queued operation to the local copy. The observers get the origin. */
1856
+ async applyToLocal(op, origin) {
818
1857
  const data = op.data;
819
1858
  switch (op.type) {
820
1859
  case "set":
821
- await this.local.set(data.id, data.value);
1860
+ await this.localSet(data.id, data.value, origin);
822
1861
  break;
823
1862
  case "delete":
824
- await this.local.delete(data.id);
1863
+ await this.localDelete(data.id, origin);
825
1864
  break;
826
1865
  case "setBatch":
827
- await this.local.setBatch(data.items);
1866
+ await this.localSetBatch(data.items, origin);
828
1867
  break;
829
1868
  case "deleteBatch":
830
- await this.local.deleteBatch(data.ids);
1869
+ await this.localDeleteBatch(data.ids, origin);
831
1870
  break;
832
1871
  case "clear":
833
- await this.local.clear();
1872
+ await this.localClear(origin);
834
1873
  break;
835
1874
  }
836
1875
  }
1876
+ //
1877
+ // ═══ The writes of the local copy ═══
1878
+ //
1879
+ /**
1880
+ * This method writes to the local copy. With `versionOf`, it records the version of each item
1881
+ * that the write gives, so a pull does not read the item to know its version.
1882
+ *
1883
+ * - During the write, the versions of its ids are unknown. A failed write keeps them unknown.
1884
+ * - Only the last write of an id that started records its version, and a clear that started
1885
+ * after the write cancels the record.
1886
+ */
1887
+ writeLocal(versionsOf, write) {
1888
+ return this.options.versionOf ? this.recordedWrite(versionsOf(), write) : write();
1889
+ }
1890
+ /** The write of `writeLocal` with `versionOf`. */
1891
+ async recordedWrite(versions, write) {
1892
+ const clears = this.localClears;
1893
+ const numbers = versions.map(([id]) => {
1894
+ this.localVersions.delete(id);
1895
+ const number = ++this.localWriteCount;
1896
+ this.localWrites.set(id, number);
1897
+ return number;
1898
+ });
1899
+ let written = false;
1900
+ try {
1901
+ const result = await write();
1902
+ written = true;
1903
+ return result;
1904
+ }
1905
+ finally {
1906
+ versions.forEach(([id, version], index) => {
1907
+ if (this.localWrites.get(id) !== numbers[index])
1908
+ return;
1909
+ this.localWrites.delete(id);
1910
+ if (written && version !== undefined && clears === this.localClears)
1911
+ this.localVersions.set(id, version);
1912
+ });
1913
+ }
1914
+ }
1915
+ /**
1916
+ * This method reads a local item for a pull. With `versionOf`, it records the version of the item
1917
+ * when no local write started or ran during the read.
1918
+ */
1919
+ async readLocal(id) {
1920
+ const writes = this.localWriteCount;
1921
+ const clears = this.localClears;
1922
+ const item = await this.local.get(id);
1923
+ if (item !== undefined &&
1924
+ this.options.versionOf &&
1925
+ writes === this.localWriteCount &&
1926
+ clears === this.localClears &&
1927
+ !this.localWrites.has(id)) {
1928
+ const version = this.recordedVersion(item);
1929
+ if (version !== undefined)
1930
+ this.localVersions.set(id, version);
1931
+ }
1932
+ return item;
1933
+ }
1934
+ /** The version of an item for the record of the local copy, or undefined when `versionOf` fails. */
1935
+ recordedVersion(item) {
1936
+ try {
1937
+ return this.versionOfItem(item);
1938
+ }
1939
+ catch {
1940
+ return undefined;
1941
+ }
1942
+ }
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) {
1970
+ this.localClears++;
1971
+ this.localVersions.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;
1992
+ }
837
1993
  /**
838
1994
  * One pull. `full`: ask for all items (after the local copy was dropped).
839
1995
  *
@@ -842,10 +1998,8 @@ export class HybridStorage {
842
1998
  * comes again at the next pull.
843
1999
  */
844
2000
  async pullRemote(full) {
845
- const versions = new Map(this.writeVersions);
846
- const clearVersion = this.clearVersion;
847
- // True when a local write changed the id after the sync started
848
- 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;
849
2003
  let changes;
850
2004
  try {
851
2005
  changes = await this.fetchChanges(full);
@@ -855,6 +2009,14 @@ export class HybridStorage {
855
2009
  this.isOnline = false;
856
2010
  throw error;
857
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) {
858
2020
  const conflicts = [];
859
2021
  let queueChanged = false;
860
2022
  const remoteIds = new Set();
@@ -872,9 +2034,8 @@ export class HybridStorage {
872
2034
  }
873
2035
  if (queueChanged) {
874
2036
  this.storeDirty = true;
875
- await this.persistAll().catch((error) => {
876
- console.error("The queue store could not keep the queue after the sync:", error);
877
- });
2037
+ // The status gets the failure of the queue store. A later direct write repairs the store first.
2038
+ await this.persistAll().catch(() => undefined);
878
2039
  }
879
2040
  this.stats.syncCount++;
880
2041
  this.isOnline = true;
@@ -882,6 +2043,7 @@ export class HybridStorage {
882
2043
  throw new ConflictError(conflicts);
883
2044
  if (changes.version !== undefined)
884
2045
  this.cursor = changes.version;
2046
+ this.lastSync = Date.now();
885
2047
  }
886
2048
  /**
887
2049
  * The remote changes: since the cursor from `getChangesSince` when the remote has it, or else
@@ -901,14 +2063,29 @@ export class HybridStorage {
901
2063
  }
902
2064
  return { full: true, changed: await this.remoteEntries(), deleted: [] };
903
2065
  }
904
- /** This method applies one remote item. It gives true when the queue changed. */
2066
+ /**
2067
+ * This method applies one remote item. It gives true when the queue changed.
2068
+ *
2069
+ * The pull reads the local item only when the result depends on it (roadmap R28):
2070
+ * - An id with a queued write: only when the remote version changed after the last known remote
2071
+ * version. Without versions, the queued write wins, and the pull reads nothing.
2072
+ * - An id without a queued write: not when the storage knows the version of the local item.
2073
+ * Without versions, the pull compares the JSON of the two items, so it reads the local item.
2074
+ * Before, the pull read each local item.
2075
+ */
905
2076
  async pullItem(id, item, changedLocally, conflicts) {
906
- if (changedLocally(id))
907
- return false;
908
- const localItem = await this.local.get(id);
909
2077
  if (changedLocally(id))
910
2078
  return false;
911
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;
2088
+ const knownVersion = remoteVersion === undefined ? undefined : this.localVersions.get(id);
912
2089
  if (this.hasPendingOp(id)) {
913
2090
  // Without versions, the queued write wins, as before
914
2091
  if (remoteVersion === undefined)
@@ -918,20 +2095,37 @@ export class HybridStorage {
918
2095
  const base = this.bases.get(id);
919
2096
  if (base === undefined || base === remoteVersion)
920
2097
  return false;
2098
+ if (knownVersion === remoteVersion) {
2099
+ this.bases.set(id, remoteVersion);
2100
+ return false;
2101
+ }
2102
+ const localItem = await this.readLocal(id);
2103
+ if (changedLocally(id))
2104
+ return false;
921
2105
  if (localItem !== undefined && this.versionOfItem(localItem) === remoteVersion) {
922
2106
  this.bases.set(id, remoteVersion);
923
2107
  return false;
924
2108
  }
925
2109
  return this.resolvePending(id, localItem, item, remoteVersion, conflicts);
926
2110
  }
2111
+ if (knownVersion !== undefined) {
2112
+ // Only the remote changed: it is not a conflict. The version of the local item is known.
2113
+ if (knownVersion !== remoteVersion)
2114
+ await this.localSet(id, item, this.syncOrigin);
2115
+ this.bases.set(id, remoteVersion);
2116
+ return false;
2117
+ }
2118
+ const localItem = await this.readLocal(id);
2119
+ if (changedLocally(id))
2120
+ return false;
927
2121
  const localVersion = localItem === undefined ? undefined : this.versionOfItem(localItem);
928
2122
  if (localItem === undefined) {
929
- await this.local.set(id, item);
2123
+ await this.localSet(id, item, this.syncOrigin);
930
2124
  }
931
2125
  else if (remoteVersion !== undefined && localVersion !== undefined) {
932
2126
  // Only the remote changed: it is not a conflict
933
2127
  if (localVersion !== remoteVersion)
934
- await this.local.set(id, item);
2128
+ await this.localSet(id, item, this.syncOrigin);
935
2129
  }
936
2130
  else if (JSON.stringify(localItem) !== JSON.stringify(item)) {
937
2131
  // Conflict - resolve according to strategy
@@ -941,7 +2135,7 @@ export class HybridStorage {
941
2135
  return false;
942
2136
  }
943
2137
  const resolved = this.resolveConflict(localItem, item);
944
- await this.local.set(id, resolved);
2138
+ await this.localSet(id, resolved, this.syncOrigin);
945
2139
  this.stats.conflicts++;
946
2140
  }
947
2141
  if (remoteVersion !== undefined)
@@ -952,8 +2146,14 @@ export class HybridStorage {
952
2146
  async pullDelete(id, changedLocally, conflicts) {
953
2147
  if (changedLocally(id))
954
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;
955
2155
  if (!this.hasPendingOp(id)) {
956
- await this.local.delete(id);
2156
+ await this.localDelete(id, this.syncOrigin);
957
2157
  if (this.options.versionOf)
958
2158
  this.bases.set(id, null);
959
2159
  return false;
@@ -963,7 +2163,7 @@ export class HybridStorage {
963
2163
  const base = this.bases.get(id);
964
2164
  if (!this.options.versionOf || base === undefined || base === null)
965
2165
  return false;
966
- const localItem = await this.local.get(id);
2166
+ const localItem = await this.readLocal(id);
967
2167
  if (changedLocally(id))
968
2168
  return false;
969
2169
  if (localItem === undefined) {
@@ -976,7 +2176,7 @@ export class HybridStorage {
976
2176
  case "remote":
977
2177
  if (!this.dropQueued(id))
978
2178
  break;
979
- await this.local.delete(id);
2179
+ await this.localDelete(id, this.syncOrigin);
980
2180
  this.bases.set(id, null);
981
2181
  return true;
982
2182
  case "error":
@@ -1000,7 +2200,7 @@ export class HybridStorage {
1000
2200
  // The remote value replaces the queued write. A queued clear cannot lose one id.
1001
2201
  if (!this.dropQueued(id))
1002
2202
  break;
1003
- await this.local.set(id, remoteItem);
2203
+ await this.localSet(id, remoteItem, this.syncOrigin);
1004
2204
  this.bases.set(id, remoteVersion);
1005
2205
  return true;
1006
2206
  case "merge": {
@@ -1009,7 +2209,7 @@ export class HybridStorage {
1009
2209
  const merged = this.resolveConflict(localItem, remoteItem);
1010
2210
  if (!this.dropQueued(id))
1011
2211
  break;
1012
- await this.local.set(id, merged);
2212
+ await this.localSet(id, merged, this.syncOrigin);
1013
2213
  this.bases.set(id, remoteVersion);
1014
2214
  this.offlineOps.push({ type: "set", data: { id, value: merged }, timestamp: Date.now(), base: [[id, remoteVersion]] });
1015
2215
  return true;
@@ -1091,6 +2291,7 @@ export class HybridStorage {
1091
2291
  continue;
1092
2292
  }
1093
2293
  try {
2294
+ this.echoSent(op);
1094
2295
  await this.executeOperation(op);
1095
2296
  }
1096
2297
  catch (error) {
@@ -1111,10 +2312,9 @@ export class HybridStorage {
1111
2312
  finally {
1112
2313
  this.pushing = false;
1113
2314
  if (sent > 0) {
1114
- await this.persistAll().catch((error) => {
1115
- // The store keeps sent operations. A later direct write repairs it first.
1116
- console.error("The queue store could not remove the sent operations:", error);
1117
- });
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);
1118
2318
  }
1119
2319
  }
1120
2320
  }
@@ -1126,6 +2326,16 @@ export class HybridStorage {
1126
2326
  }
1127
2327
  bump(id) {
1128
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;
1129
2339
  }
1130
2340
  /** The count of local writes of an id: it changes with each write and each clear. */
1131
2341
  writeKey(id) {
@@ -1326,12 +2536,15 @@ export class HybridStorage {
1326
2536
  this.unpersisted.delete(op);
1327
2537
  this.storeDirty = false;
1328
2538
  }
2539
+ this.succeed("store");
1329
2540
  }
1330
2541
  catch (error) {
1331
2542
  this.storeDirty = true;
1332
2543
  this.unpersisted.delete(op);
1333
2544
  rollback();
1334
- throw new QueueStoreError("write", error);
2545
+ const failure = new QueueStoreError("write", error);
2546
+ this.fail("store", failure);
2547
+ throw failure;
1335
2548
  }
1336
2549
  });
1337
2550
  this.storeChain = run.catch(() => undefined);
@@ -1351,10 +2564,13 @@ export class HybridStorage {
1351
2564
  for (const kept of snapshot)
1352
2565
  this.unpersisted.delete(kept);
1353
2566
  this.storeDirty = false;
2567
+ this.succeed("store");
1354
2568
  }
1355
2569
  catch (error) {
1356
2570
  this.storeDirty = true;
1357
- throw new QueueStoreError("write", error);
2571
+ const failure = new QueueStoreError("write", error);
2572
+ this.fail("store", failure);
2573
+ throw failure;
1358
2574
  }
1359
2575
  });
1360
2576
  this.storeChain = run.catch(() => undefined);
@@ -1397,19 +2613,12 @@ export class HybridStorage {
1397
2613
  }
1398
2614
  /**
1399
2615
  * In write-through mode, a timer sends the queue after a failure, and again after each failed
1400
- * send, until the queue is empty.
2616
+ * send, until the queue is empty. The delay after a failed send grows with the backoff.
1401
2617
  */
1402
2618
  scheduleDrain() {
1403
- if (this.drainTimer || this.closed || this.mode !== "write-through")
2619
+ if (this.closed || this.mode !== "write-through")
1404
2620
  return;
1405
- const delay = this.options.syncInterval > 0 ? this.options.syncInterval : 5000;
1406
- this.drainTimer = setTimeout(() => {
1407
- this.drainTimer = null;
1408
- if (this.closed)
1409
- return;
1410
- this.push().catch(() => this.scheduleDrain());
1411
- }, delay);
1412
- this.drainTimer.unref?.();
2621
+ this.drainTimer.start();
1413
2622
  }
1414
2623
  /**
1415
2624
  * Execute a queued operation against remote storage.
@@ -1418,19 +2627,19 @@ export class HybridStorage {
1418
2627
  const data = op.data;
1419
2628
  switch (op.type) {
1420
2629
  case "set":
1421
- await this.remote.set(data.id, data.value);
2630
+ await this.remote.set(data.id, data.value, this.own);
1422
2631
  break;
1423
2632
  case "delete":
1424
- await this.remote.delete(data.id);
2633
+ await this.remote.delete(data.id, this.own);
1425
2634
  break;
1426
2635
  case "setBatch":
1427
- await this.remote.setBatch(data.items);
2636
+ await this.remote.setBatch(data.items, this.own);
1428
2637
  break;
1429
2638
  case "deleteBatch":
1430
- await this.remote.deleteBatch(data.ids);
2639
+ await this.remote.deleteBatch(data.ids, this.own);
1431
2640
  break;
1432
2641
  case "clear":
1433
- await this.remote.clear();
2642
+ await this.remote.clear(this.own);
1434
2643
  break;
1435
2644
  }
1436
2645
  }