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