@procedures/store 0.23.0 → 0.24.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.
@@ -25,6 +25,39 @@
25
25
  * - The leader sends the status of its sync to the followers: at each change, and in answer to a
26
26
  * hello. `getStatus()` and `onStatus()` give the status in each tab (roadmap CLI-65).
27
27
  *
28
+ * A `TabGroup` runs the sync of many collections with one lock and one channel (roadmap CLI-64),
29
+ * for example all the collections of a tenant.
30
+ *
31
+ * - The leader of the group runs one `HybridStorage` for each collection. Each message has the
32
+ * field `collection`, and each collection has its own batches, acknowledgements and status.
33
+ * - Each collection leads when its own queue loaded, and it tells the other tabs at once. Thus a
34
+ * slow collection does not stop the batches of the other collections.
35
+ * - A collection whose setup fails tries again alone after its backoff, and the other collections
36
+ * keep their sync. The tab sends a status with the error of the setup to the other tabs.
37
+ * - When no collection of the group leads or loads its queue, the tab releases the lock. It asks for
38
+ * the lock again after the backoff of the group, as a `TabCoordinator` does. This is also true for
39
+ * a collection that the application adds after the tab got the lock.
40
+ * - The lock and the channel of a group have the prefix `@procedures/store/tabs:`. A
41
+ * `TabCoordinator` has the prefix `@procedures/store/tab:`. Thus a group and a coordinator never
42
+ * share a lock or a channel, also with the same name.
43
+ * - With the option `open`, the leader makes a collection that another tab has and that this tab
44
+ * does not have. Thus the tabs can open different collections.
45
+ *
46
+ * A snapshot for a new tab (roadmap CLI-66): with `copy: "tab"` and the option `snapshot`, a new
47
+ * tab asks the leader for the entries of its local copy.
48
+ *
49
+ * - The leader reads the entries of its copy, and it sends them in chunks of `snapshot.chunk`
50
+ * entries. Each chunk has the numbers of the last batches that the copy holds, one for each tab.
51
+ * - The new tab keeps the batches of the other tabs until the last chunk comes. It writes the
52
+ * entries to its copy, and then it writes the batches with a number after the snapshot.
53
+ * - The new tab keeps the values of its own writes after the snapshot.
54
+ * - A set in the entries can come again in a batch after the snapshot, for example a write of the
55
+ * leader during its read. The second write of the set gives the same state.
56
+ * - When the leader changes before the last chunk, the tab asks the new leader. When no chunk comes
57
+ * in the time `snapshot.timeout`, the tab reports an error and continues without the snapshot.
58
+ * - In the tab that holds the lock, the wait renews itself while the collection loads its queue, at
59
+ * most `SNAPSHOT_RENEWALS` times. Thus the batches of the other tabs wait for a limited time.
60
+ *
28
61
  * The limits:
29
62
  * - When the leader stops during a push, the next leader sends the operation again: the delivery to
30
63
  * the remote is at least once.
@@ -32,6 +65,12 @@
32
65
  * capture. The tabs agree again after the push and the next pull.
33
66
  * - The load of the queue in a new leader writes the queued operations to the local copy again, and
34
67
  * the observers get them again with the origin `syncOrigin`.
68
+ * - The leader reads all the entries of a snapshot at one time, and the new tab keeps all the chunks
69
+ * until the last one. Thus a snapshot is for a local copy that fits in the memory of a tab.
70
+ * - `snapshot.chunk` counts the entries of a message, not its bytes: large items give large messages.
71
+ * - A `TabCoordinator` and a `TabGroup` of one collection do not see each other. When tabs of both
72
+ * releases are open, two tabs lead the collection. Give the group a new queue store, or close
73
+ * the tabs of the older release first.
35
74
  *
36
75
  * Nothing in this module reads a global value when it loads: Node can import it. The coordinator
37
76
  * reads `navigator.locks` and `BroadcastChannel` when it starts, and a test gives its own.
@@ -80,7 +119,7 @@ export interface TabChannelLike {
80
119
  export type TabChannelFactory = (name: string) => TabChannelLike;
81
120
 
82
121
  /**
83
- * The role of a tab.
122
+ * The role of a tab, or of a collection of a `TabGroup` in a tab.
84
123
  *
85
124
  * - `follower`: the tab waits for the lock. It copies the changes of the other tabs, and it sends
86
125
  * its own changes to the leader. A tab whose setup failed is a follower until its next try.
@@ -88,6 +127,9 @@ export type TabChannelFactory = (name: string) => TabChannelLike;
88
127
  * - `single`: the browser has no Web Locks API or no `BroadcastChannel`. The tab runs the sync
89
128
  * alone, and it opens no channel.
90
129
  * - `closed`: after `close()`.
130
+ *
131
+ * A `TabGroup` is `leader` when it holds the lock. Each collection of the group is `leader` only
132
+ * when its own `HybridStorage` runs.
91
133
  */
92
134
  export type TabRole = "follower" | "leader" | "single" | "closed";
93
135
 
@@ -95,7 +137,7 @@ export type TabRole = "follower" | "leader" | "single" | "closed";
95
137
  export interface TabCoordinatorOptions<T> {
96
138
  /**
97
139
  * The name of the coordinator: one name for each collection, the same in each tab. The lock and
98
- * the channel get it. Give a name for each tenant too.
140
+ * the channel get it. Give a name for each tenant too. For many collections, use a `TabGroup`.
99
141
  */
100
142
  name: string;
101
143
 
@@ -125,6 +167,13 @@ export interface TabCoordinatorOptions<T> {
125
167
  */
126
168
  hybrid?: Omit<HybridStorageOptions, "local" | "capture">;
127
169
 
170
+ /**
171
+ * The snapshot for a new tab (roadmap CLI-66). Only `copy: "tab"` takes it. With it, a new tab
172
+ * asks the leader for the entries of its local copy. Give it to a local copy that starts empty.
173
+ * @default false
174
+ */
175
+ snapshot?: boolean | TabSnapshotOptions;
176
+
128
177
  /**
129
178
  * The lock manager. `null` sets the single mode.
130
179
  * @default `navigator.locks`, when the browser has it
@@ -150,7 +199,8 @@ export interface TabCoordinatorOptions<T> {
150
199
  retry?: TabRetryOptions;
151
200
 
152
201
  /**
153
- * The clock of the wait of `retry`. A test gives `fakeClock` of `@mark1russell7/service/testing`.
202
+ * The clock of the wait of `retry` and of `snapshot.timeout`. A test gives `fakeClock` of
203
+ * `@mark1russell7/service/testing`.
154
204
  * @default the `clock` of `hybrid`, or else the clock of the platform (`realClock` of `@procedures/client`)
155
205
  */
156
206
  clock?: Clock;
@@ -163,8 +213,8 @@ export interface TabCoordinatorOptions<T> {
163
213
 
164
214
  /**
165
215
  * The function that gets the errors: of the lock, of the channel, of a write to the local copy,
166
- * of the `HybridStorage`, of a failed setup and of the observers. The default throws the error
167
- * again in a microtask.
216
+ * of the `HybridStorage`, of a failed setup, of a snapshot and of the observers. The default
217
+ * throws the error again in a microtask.
168
218
  */
169
219
  onError?: ListenerErrorHandler;
170
220
  }
@@ -181,6 +231,107 @@ export interface TabRetryOptions {
181
231
  jitter?: number;
182
232
  }
183
233
 
234
+ /** The options of the snapshot for a new tab (roadmap CLI-66). */
235
+ export interface TabSnapshotOptions {
236
+ /**
237
+ * The largest number of entries in one message of the snapshot, 1 or more. The leader uses its
238
+ * own value when it sends a snapshot. It does not limit the bytes: large items give large messages.
239
+ * @default 500
240
+ */
241
+ chunk?: number;
242
+
243
+ /**
244
+ * The longest wait for the next message of the snapshot, for example `seconds(10)`. After it, the
245
+ * tab gives an error to `onError`, and it copies the batches of the other tabs without a snapshot.
246
+ * In the tab that holds the lock, the wait renews itself while the collection loads its queue, at
247
+ * most 5 times.
248
+ * @default 10 seconds
249
+ */
250
+ timeout?: Duration;
251
+ }
252
+
253
+ /** The options of one collection of a `TabGroup` (roadmap CLI-64). */
254
+ export interface TabCollectionOptions<T> {
255
+ /** The local copy of the collection in this tab. It must have `observe()`. */
256
+ local: LocalCopyStorage<T>;
257
+
258
+ /** The kind of the local copy: `"shared"` or `"tab"`, as the option `copy` of a `TabCoordinator`. */
259
+ copy: "shared" | "tab";
260
+
261
+ /** The function that makes the remote of the collection. Only the leader calls it. */
262
+ remote: () => CollectionStorage<T>;
263
+
264
+ /**
265
+ * The options of the `HybridStorage` of the collection in the leader. Give a `queueStore` for each
266
+ * collection, the same in each tab.
267
+ */
268
+ hybrid?: Omit<HybridStorageOptions, "local" | "capture">;
269
+
270
+ /** The snapshot for a new tab (roadmap CLI-66), as the option `snapshot` of a `TabCoordinator`. @default false */
271
+ snapshot?: boolean | TabSnapshotOptions;
272
+ }
273
+
274
+ /** The options of a `TabGroup` (roadmap CLI-64). */
275
+ export interface TabGroupOptions {
276
+ /**
277
+ * The name of the group, the same in each tab, for example the name of a tenant. The lock and the
278
+ * channel get it.
279
+ */
280
+ name: string;
281
+
282
+ /**
283
+ * The function that gives the options of a collection with its name. `collection(name)` uses it
284
+ * without options. The leader also uses it for a collection that only another tab opened. It
285
+ * gives `undefined` for a collection without a sync.
286
+ */
287
+ // The collections of a group have different item types
288
+ open?: (collection: string) => TabCollectionOptions<any> | undefined;
289
+
290
+ /** The lock manager. `null` sets the single mode. @default `navigator.locks`, when the browser has it */
291
+ locks?: TabLockManagerLike | null;
292
+
293
+ /** The function that opens the channel. `null` sets the single mode. @default a `BroadcastChannel`, when the browser has it */
294
+ channel?: TabChannelFactory | null;
295
+
296
+ /**
297
+ * The backoff of a new try after a failed setup (roadmap CLI-67). The group and each collection
298
+ * count their failures. A collection whose setup fails in the leader tries again alone.
299
+ */
300
+ retry?: TabRetryOptions;
301
+
302
+ /** The clock of the waits. @default the clock of the platform (`realClock` of `@procedures/client`) */
303
+ clock?: Clock;
304
+
305
+ /** The random numbers of the jitter of `retry`, from 0 up to 1. @default `Math.random` */
306
+ random?: () => number;
307
+
308
+ /** The function that gets the errors of the group and of its collections. The default throws the error again in a microtask. */
309
+ onError?: ListenerErrorHandler;
310
+ }
311
+
312
+ /**
313
+ * One collection of a `TabGroup` in this tab (roadmap CLI-64). It has the methods of a
314
+ * `TabCoordinator` for one collection, except `close()`: `close()` of the group closes it.
315
+ */
316
+ export interface TabCollection<T> {
317
+ /** The name of the collection in the group. */
318
+ readonly name: string;
319
+ /** The role of this collection in this tab: `leader` or `single` only while its `HybridStorage` runs. */
320
+ readonly role: TabRole;
321
+ /** True when this tab runs the sync of this collection. */
322
+ readonly isLeader: boolean;
323
+ /** The `HybridStorage` of the collection while this tab runs its sync, or else undefined. */
324
+ readonly syncStorage: HybridStorage<T> | undefined;
325
+ /** This method starts to send the change batches of the collection to an observer, as `TabCoordinator.observe()`. */
326
+ observe(observer: StorageObserver<T>): () => void;
327
+ /** This method starts to send the role of the collection to a listener at each change. */
328
+ onRole(listener: (role: TabRole) => void): () => void;
329
+ /** The status of the sync of the collection (roadmap CLI-65), as `TabCoordinator.getStatus()`. */
330
+ getStatus(): SyncStatus | undefined;
331
+ /** This method starts to send the status of the sync of the collection to a listener at each change. */
332
+ onStatus(listener: (status: SyncStatus) => void): () => void;
333
+ }
334
+
184
335
  /** The storage of a tab that got the lock, before the tab becomes the leader. */
185
336
  interface Candidate<T> {
186
337
  readonly hybrid: HybridStorage<T>;
@@ -217,14 +368,48 @@ interface SentBatch<T> {
217
368
  readonly changes: StorageChangeBatch<T>;
218
369
  }
219
370
 
371
+ /** The entries of a snapshot, and the number of the last batch of each tab that they hold. */
372
+ type SnapshotEntries<T> = ReadonlyArray<readonly [string, T]>;
373
+ type SnapshotSeqs = ReadonlyArray<readonly [string, number]>;
374
+
375
+ /** A snapshot that a follower waits for (roadmap CLI-66). */
376
+ interface PendingSnapshot<T> {
377
+ /** The number of the last request of this tab */
378
+ readonly request: number;
379
+ /** The tab that sent the first chunk, the number of the chunks and the numbers of the batches */
380
+ from?: string;
381
+ parts?: number;
382
+ seqs?: SnapshotSeqs;
383
+ readonly chunks: Array<SnapshotEntries<T> | undefined>;
384
+ received: number;
385
+ }
386
+
220
387
  /**
221
388
  * The version of the messages. A tab ignores a message of another version, and a message of a type
222
- * that it does not know. Thus a new type of message (`status`, roadmap CLI-65) keeps the version 1:
223
- * a tab of an older release ignores it, and it still copies and sends the batches. A new version
224
- * stops the batches between the tabs of two releases, so only a change of a known message needs it.
389
+ * that it does not know. Thus a new type of message (`status`, roadmap CLI-65, and `snapshot`,
390
+ * roadmap CLI-66) keeps the version 1: a tab of an older release ignores it, and it still copies
391
+ * and sends the batches. A new version stops the batches between the tabs of two releases, so only
392
+ * a change of a known message needs it.
225
393
  */
226
394
  const PROTOCOL = 1;
227
395
 
396
+ /** The prefixes of the lock and of the channel: of a `TabCoordinator`, and of a `TabGroup`. */
397
+ const COORDINATOR_PREFIX = "@procedures/store/tab:";
398
+ const GROUP_PREFIX = "@procedures/store/tabs:";
399
+
400
+ /** The defaults of the snapshot (roadmap CLI-66). */
401
+ const SNAPSHOT_CHUNK = 500;
402
+ const SNAPSHOT_TIMEOUT = 10_000;
403
+ /** The largest number of renewals of the wait for a snapshot in the tab that holds the lock. */
404
+ const SNAPSHOT_RENEWALS = 5;
405
+
406
+ /** The fields of each message. A message of a `TabGroup` has `collection`, except `leader` for all the collections. */
407
+ interface MessageBase {
408
+ readonly v: typeof PROTOCOL;
409
+ readonly from: string;
410
+ readonly collection?: string;
411
+ }
412
+
228
413
  /**
229
414
  * A message of the channel.
230
415
  *
@@ -232,18 +417,36 @@ const PROTOCOL = 1;
232
417
  * tab increase by one.
233
418
  * - `hello`: the tab `from` tells the leader its last number, and the batches that no leader
234
419
  * acknowledged. A tab sends it when it starts, and when a new leader starts.
235
- * - `leader`: the tab `from` is the new leader.
420
+ * - `leader`: the tab `from` is the new leader. In a group, a message with `collection` comes when
421
+ * that collection leads. The message without `collection` comes when the setup of each collection
422
+ * ended: a collection that the leader announced already ignores it.
236
423
  * - `ack`: the queue store of the leader holds the batches of the tab `to`, up to `seq`.
237
424
  * - `status`: the status of the sync of the leader `from` (roadmap CLI-65). The leader sends it at
238
425
  * each change, and in answer to a hello. The numbers of the statuses of a leader increase by one,
239
426
  * and the answer to a hello sends the last number again: a tab ignores a number that it has.
427
+ * - `snapshot-request`: the tab `from` asks the leader for the entries of its copy (roadmap CLI-66).
428
+ * A new request of a tab has a larger number.
429
+ * - `snapshot`: one chunk of the answer of the leader to the request `request` of the tab `to`.
430
+ * `seqs` has the number of the last batch of each tab that the entries hold.
240
431
  */
241
432
  type TabMessage<T> =
242
- | { readonly v: typeof PROTOCOL; readonly type: "changes"; readonly from: string; readonly seq: number; readonly changes: StorageChangeBatch<T> }
243
- | { readonly v: typeof PROTOCOL; readonly type: "hello"; readonly from: string; readonly seq: number; readonly pending: ReadonlyArray<SentBatch<T>> }
244
- | { readonly v: typeof PROTOCOL; readonly type: "leader"; readonly from: string }
245
- | { readonly v: typeof PROTOCOL; readonly type: "ack"; readonly from: string; readonly to: string; readonly seq: number }
246
- | { readonly v: typeof PROTOCOL; readonly type: "status"; readonly from: string; readonly seq: number; readonly status: PortableStatus };
433
+ | (MessageBase & { readonly type: "changes"; readonly seq: number; readonly changes: StorageChangeBatch<T> })
434
+ | (MessageBase & { readonly type: "hello"; readonly seq: number; readonly pending: ReadonlyArray<SentBatch<T>> })
435
+ | (MessageBase & { readonly type: "leader" })
436
+ | (MessageBase & { readonly type: "ack"; readonly to: string; readonly seq: number })
437
+ | (MessageBase & { readonly type: "status"; readonly seq: number; readonly status: PortableStatus })
438
+ | (MessageBase & { readonly type: "snapshot-request"; readonly request: number })
439
+ | (MessageBase & {
440
+ readonly type: "snapshot";
441
+ readonly to: string;
442
+ readonly request: number;
443
+ readonly part: number;
444
+ readonly parts: number;
445
+ readonly seqs: SnapshotSeqs;
446
+ readonly entries: SnapshotEntries<T>;
447
+ });
448
+
449
+ type SnapshotMessage<T> = Extract<TabMessage<T>, { readonly type: "snapshot" }>;
247
450
 
248
451
  /**
249
452
  * One leader tab runs the sync of a `HybridStorage`, and the other tabs copy its changes.
@@ -276,31 +479,595 @@ export class TabCoordinator<T> {
276
479
  /** The name of the lock and of the channel. */
277
480
  readonly lockName: string;
278
481
 
482
+ private readonly group: GroupEngine;
483
+ private readonly collection: CollectionSync<T>;
484
+
485
+ constructor(options: TabCoordinatorOptions<T>) {
486
+ if (typeof options?.name !== "string" || options.name === "") throw new TypeError("TabCoordinator needs a name");
487
+ const settings = collectionSettings(options, "TabCoordinator");
488
+ const hybrid = options.hybrid ?? {};
489
+ this.group = new GroupEngine({
490
+ prefix: COORDINATOR_PREFIX,
491
+ name: options.name,
492
+ single: true,
493
+ open: undefined,
494
+ backoff: retryBackoff(options.retry),
495
+ clock: options.clock ?? hybrid.clock ?? realClock,
496
+ random: options.random ?? hybrid.random ?? Math.random,
497
+ onError: options.onError ?? reportListenerError,
498
+ });
499
+ this.id = this.group.id;
500
+ this.lockName = this.group.lockName;
501
+ this.collection = this.group.add(options.name, options, settings);
502
+ this.group.start(options.locks, options.channel);
503
+ }
504
+
505
+ /** The role of this tab now. */
506
+ get role(): TabRole {
507
+ return this.collection.role;
508
+ }
509
+
510
+ /** True when this tab runs the sync: the leader, or a tab in the single mode. */
511
+ get isLeader(): boolean {
512
+ return this.collection.isLeader;
513
+ }
514
+
515
+ /**
516
+ * The `HybridStorage` of the sync while this tab runs it, or else undefined. `getStatus()` of the
517
+ * coordinator gives the state of the sync in each tab. Write to the local copy, not to this
518
+ * storage: each tab then writes in the same way.
519
+ */
520
+ get syncStorage(): HybridStorage<T> | undefined {
521
+ return this.collection.syncStorage;
522
+ }
523
+
524
+ /**
525
+ * This method starts to send the change batches to an observer: the batches of the local copy of
526
+ * this tab, and the batches of the other tabs. Each batch comes one time, with the origin of each
527
+ * change. In the leader, the batches of the sync have the origin `syncOrigin` of the
528
+ * `HybridStorage`. A batch of another tab comes after the local copy of this tab has it. The
529
+ * entries of a snapshot come in one batch of sets, with the origin `syncOrigin`. A set of the
530
+ * snapshot can come again in a later batch, with the same value.
531
+ *
532
+ * @returns The function that stops the batches. A second use of it does nothing.
533
+ * @throws Error after `close()`
534
+ */
535
+ observe(observer: StorageObserver<T>): () => void {
536
+ return this.collection.observe(observer);
537
+ }
538
+
539
+ /**
540
+ * This method starts to send the role of this tab to a listener at each change.
541
+ *
542
+ * @returns The function that stops the roles. A second use of it does nothing.
543
+ * @throws Error after `close()`
544
+ */
545
+ onRole(listener: (role: TabRole) => void): () => void {
546
+ return this.collection.onRole(listener);
547
+ }
548
+
549
+ /**
550
+ * The status of the sync (roadmap CLI-65), or undefined before the first status and after
551
+ * `close()`.
552
+ *
553
+ * - In the leader and in the single mode, it is the status of the `HybridStorage` of this tab.
554
+ * - In a follower, it is the last status that the leader sent. A new tab gets it in the answer to
555
+ * its hello. After a change of the leader, a follower keeps the last status until the new
556
+ * leader sends its status.
557
+ * - In a follower, `error.error` is a copy of the error of the leader: an `Error` with its `name`,
558
+ * its `message` and its `code`. The copy has no `cause`.
559
+ */
560
+ getStatus(): SyncStatus | undefined {
561
+ return this.collection.getStatus();
562
+ }
563
+
564
+ /**
565
+ * This method starts to send the status of the sync to a listener at each change (roadmap
566
+ * CLI-65): in the leader, each status of its `HybridStorage`, and in a follower, each status that
567
+ * the leader sends. The listener gets each status one time. When the tab becomes the leader, the
568
+ * listener gets the status of its own `HybridStorage`.
569
+ *
570
+ * @returns The function that stops the statuses. A second use of it does nothing.
571
+ * @throws Error after `close()`
572
+ */
573
+ onStatus(listener: (status: SyncStatus) => void): () => void {
574
+ return this.collection.onStatus(listener);
575
+ }
576
+
577
+ /**
578
+ * This method closes the coordinator.
579
+ *
580
+ * - A follower stops its lock request, or its wait before a new try. It does not become the leader.
581
+ * - A tab that loads its queue to become the leader closes its `HybridStorage`, and releases the lock.
582
+ * - The leader closes its `HybridStorage`: it sends the queue, and the queue store keeps the
583
+ * operations that the remote did not take. Then the leader releases the lock, and the next
584
+ * tab becomes the leader.
585
+ * - The coordinator closes its channel. It does not close the local copy: the application owns it.
586
+ * - After it, the coordinator sends and copies nothing. A second `close()` gives the promise of
587
+ * the first one.
588
+ */
589
+ close(): Promise<void> {
590
+ return this.group.close();
591
+ }
592
+ }
593
+
594
+ /**
595
+ * One leader tab runs the sync of many collections, with one lock and one channel (roadmap CLI-64).
596
+ * The other tabs copy the changes of each collection.
597
+ *
598
+ * @example
599
+ * ```typescript
600
+ * import { ApiStorage, IndexedDbQueueStore, TabGroup } from "@procedures/store/browser";
601
+ *
602
+ * const tenant = new TabGroup({
603
+ * name: tenantId,
604
+ * open: (collection) => ({
605
+ * local: localCopyOf(collection),
606
+ * copy: "tab",
607
+ * remote: () => new ApiStorage(client, { collection }),
608
+ * hybrid: { queueStore: new IndexedDbQueueStore(`${tenantId}-${collection}-queue`) },
609
+ * snapshot: true,
610
+ * }),
611
+ * });
612
+ *
613
+ * const notes = tenant.collection<Note>("notes");
614
+ * notes.observe((batch) => render(batch));
615
+ * notes.onStatus((status) => showSync("notes", status));
616
+ * ```
617
+ */
618
+ export class TabGroup {
619
+ /** The id of this tab: a new random id for each group. */
620
+ readonly id: string;
621
+ /** The name of the lock and of the channel. */
622
+ readonly lockName: string;
623
+
624
+ private readonly group: GroupEngine;
625
+
626
+ constructor(options: TabGroupOptions) {
627
+ if (typeof options?.name !== "string" || options.name === "") throw new TypeError("TabGroup needs a name");
628
+ if (options.open !== undefined && typeof options.open !== "function") throw new TypeError("The option open of a TabGroup must be a function");
629
+ this.group = new GroupEngine({
630
+ prefix: GROUP_PREFIX,
631
+ name: options.name,
632
+ single: false,
633
+ open: options.open,
634
+ backoff: retryBackoff(options.retry),
635
+ clock: options.clock ?? realClock,
636
+ random: options.random ?? Math.random,
637
+ onError: options.onError ?? reportListenerError,
638
+ });
639
+ this.id = this.group.id;
640
+ this.lockName = this.group.lockName;
641
+ this.group.start(options.locks, options.channel);
642
+ }
643
+
644
+ /** The role of this tab in the group. A collection has its own role too. */
645
+ get role(): TabRole {
646
+ return this.group.role;
647
+ }
648
+
649
+ /** True when this tab holds the lock of the group, or runs in the single mode. */
650
+ get isLeader(): boolean {
651
+ return this.group.role === "leader" || this.group.role === "single";
652
+ }
653
+
654
+ /** The names of the collections of the group in this tab. */
655
+ get collections(): string[] {
656
+ return [...this.group.members.keys()];
657
+ }
658
+
659
+ /**
660
+ * This method gives a collection of the group. It adds the collection at its first use. When the
661
+ * group has the collection, the method gives it and ignores `options`.
662
+ *
663
+ * @param options The options of a new collection. The default is the result of `open(name)`.
664
+ * @throws TypeError for a new collection without options, or with options of a wrong shape
665
+ * @throws Error after `close()`
666
+ */
667
+ collection<T>(name: string, options?: TabCollectionOptions<T>): TabCollection<T> {
668
+ return this.group.collection(name, options);
669
+ }
670
+
671
+ /**
672
+ * This method starts to send the role of this tab in the group to a listener at each change.
673
+ *
674
+ * @returns The function that stops the roles. A second use of it does nothing.
675
+ * @throws Error after `close()`
676
+ */
677
+ onRole(listener: (role: TabRole) => void): () => void {
678
+ if (this.group.closed) throw new Error("The TabGroup is closed");
679
+ return this.group.roleListeners.add(listener);
680
+ }
681
+
682
+ /**
683
+ * This method closes the group and each of its collections, as `TabCoordinator.close()` does.
684
+ * The leader closes each `HybridStorage`, and then it releases the lock.
685
+ */
686
+ close(): Promise<void> {
687
+ return this.group.close();
688
+ }
689
+ }
690
+
691
+ /** The settings of a collection that the options give. */
692
+ interface CollectionSettings {
693
+ /** The longest number of entries in a chunk, and the wait for a chunk. `want` is true with the option `snapshot`. */
694
+ readonly snapshot: { readonly want: boolean; readonly chunk: number; readonly timeout: number };
695
+ }
696
+
697
+ /** The options of the engine of a group. */
698
+ interface EngineOptions {
699
+ readonly prefix: string;
700
+ readonly name: string;
701
+ /** True for a `TabCoordinator`: one collection, and messages without the field `collection` */
702
+ readonly single: boolean;
703
+ readonly open: ((collection: string) => TabCollectionOptions<any> | undefined) | undefined;
704
+ readonly backoff: Backoff;
705
+ readonly clock: Clock;
706
+ readonly random: () => number;
707
+ readonly onError: ListenerErrorHandler;
708
+ }
709
+
710
+ /**
711
+ * The lock, the channel and the collections of a tab. A `TabCoordinator` is a group with one
712
+ * collection, whose messages have no field `collection`: thus it talks with a tab of an older release.
713
+ */
714
+ class GroupEngine {
715
+ readonly id: string;
716
+ readonly lockName: string;
717
+ /** True for a `TabCoordinator` */
718
+ readonly legacy: boolean;
719
+ readonly backoff: Backoff;
720
+ readonly clock: Clock;
721
+ readonly random: () => number;
722
+ readonly onError: ListenerErrorHandler;
723
+ readonly roleListeners: StorageListeners<TabRole>;
724
+ /** The collections of this tab, by name */
725
+ readonly members = new Map<string, CollectionSync<any>>();
726
+ /** An object for each time that this tab holds the lock. A collection leads only with the lease of now. */
727
+ lease: object | undefined;
728
+ /** True in the single mode */
729
+ alone = false;
730
+ closed = false;
731
+
732
+ private readonly open: EngineOptions["open"];
733
+ /** The names for which `open` gave no options */
734
+ private readonly refused = new Set<string>();
735
+ private currentRole: TabRole = "follower";
736
+ private started = false;
737
+ /** The lock manager, or undefined in the single mode */
738
+ private locks: TabLockManagerLike | undefined;
739
+ private channel: TabChannelLike | undefined;
740
+ /** The number of the failed setups of the group in a row: each setup where no collection could lead */
741
+ private failures = 0;
742
+ /** The stop of the wait before the next try */
743
+ private cancelRetry: Cancel | undefined;
744
+ private closing: Promise<void> | undefined;
745
+ /** The abort of the lock request that waits */
746
+ private abortRequest: (() => void) | undefined;
747
+ /** The release of the lock */
748
+ private releaseLock: (() => void) | undefined;
749
+
750
+ constructor(options: EngineOptions) {
751
+ this.id = randomId();
752
+ this.lockName = `${options.prefix}${options.name}`;
753
+ this.legacy = options.single;
754
+ this.open = options.open;
755
+ this.backoff = options.backoff;
756
+ this.clock = options.clock;
757
+ this.random = options.random;
758
+ this.onError = options.onError;
759
+ this.roleListeners = new StorageListeners<TabRole>(this.onError);
760
+ }
761
+
762
+ get role(): TabRole {
763
+ return this.currentRole;
764
+ }
765
+
766
+ /** True while the tab has a channel. */
767
+ get hasChannel(): boolean {
768
+ return this.channel !== undefined;
769
+ }
770
+
771
+ /** The name of the coordinator or of the group, for the messages of the errors. */
772
+ get kind(): string {
773
+ return this.legacy ? "TabCoordinator" : "TabGroup";
774
+ }
775
+
776
+ /** This method opens the channel and asks for the lock, or starts the single mode. */
777
+ start(locksOption: TabLockManagerLike | null | undefined, channelOption: TabChannelFactory | null | undefined): void {
778
+ this.started = true;
779
+ const locks = locksOption === undefined ? globalLocks() : (locksOption ?? undefined);
780
+ const openChannel = channelOption === undefined ? globalChannelFactory() : (channelOption ?? undefined);
781
+ if (!locks || !openChannel) {
782
+ this.startSingle();
783
+ return;
784
+ }
785
+ try {
786
+ this.channel = openChannel(this.lockName);
787
+ } catch (error) {
788
+ this.onError(error);
789
+ this.startSingle();
790
+ return;
791
+ }
792
+ this.locks = locks;
793
+ this.channel.onmessage = (event: { data?: unknown } | undefined) => this.receive(event?.data);
794
+ for (const member of this.members.values()) member.begin();
795
+ this.elect(locks);
796
+ }
797
+
798
+ /** This method gives a collection of a `TabGroup`, and adds it at its first use. */
799
+ collection<T>(name: string, options: TabCollectionOptions<T> | undefined): CollectionSync<T> {
800
+ if (this.closed) throw new Error("The TabGroup is closed");
801
+ if (typeof name !== "string" || name === "") throw new TypeError("A collection of a TabGroup needs a name");
802
+ const known = this.members.get(name);
803
+ if (known) return known as CollectionSync<T>;
804
+ const resolved = options ?? (this.open?.(name) as TabCollectionOptions<T> | undefined);
805
+ if (!resolved) throw new TypeError(`The TabGroup has no options for the collection ${JSON.stringify(name)}: give options or the function open`);
806
+ return this.add(name, resolved, collectionSettings(resolved, "A collection of a TabGroup"));
807
+ }
808
+
809
+ /** This method adds a collection. After the start, the collection says hello, and it leads when the tab holds the lock. */
810
+ add<T>(name: string, options: TabCollectionOptions<T>, settings: CollectionSettings): CollectionSync<T> {
811
+ const member = new CollectionSync<T>(this, name, options, settings);
812
+ this.members.set(name, member);
813
+ this.refused.delete(name);
814
+ if (!this.started) return member;
815
+ if (this.alone) {
816
+ member.startSingle();
817
+ return member;
818
+ }
819
+ member.begin();
820
+ if (this.lease) member.startLead(this.lease, true);
821
+ return member;
822
+ }
823
+
824
+ /** @returns False when the message did not go out: no channel, or a value that the channel cannot clone */
825
+ post(collection: string | undefined, message: TabMessage<unknown>): boolean {
826
+ const channel = this.channel;
827
+ if (!channel) return false;
828
+ try {
829
+ channel.postMessage(this.legacy || collection === undefined ? message : { ...message, collection });
830
+ return true;
831
+ } catch (error) {
832
+ this.onError(error);
833
+ return false;
834
+ }
835
+ }
836
+
837
+ close(): Promise<void> {
838
+ if (!this.closing) this.closing = this.shutDown();
839
+ return this.closing;
840
+ }
841
+
842
+ private async shutDown(): Promise<void> {
843
+ this.closed = true;
844
+ this.abortRequest?.();
845
+ this.abortRequest = undefined;
846
+ this.cancelRetry?.();
847
+ this.cancelRetry = undefined;
848
+ this.lease = undefined;
849
+ this.setRole("closed");
850
+ const members = [...this.members.values()];
851
+ try {
852
+ // The leader holds the lock until each queue reached the remote or its queue store
853
+ await Promise.all(members.map((member) => member.shutDown()));
854
+ } finally {
855
+ this.releaseLock?.();
856
+ this.releaseLock = undefined;
857
+ // The acknowledgements of the close of the storages went out before
858
+ try {
859
+ this.channel?.close();
860
+ } catch {
861
+ // The channel is closed already
862
+ }
863
+ this.channel = undefined;
864
+ for (const member of members) member.clearListeners();
865
+ this.roleListeners.clear();
866
+ }
867
+ }
868
+
869
+ //
870
+ // ═══ The election ═══
871
+ //
872
+
873
+ /** This method asks for the lock. The tab becomes the leader when it gets the lock. */
874
+ private elect(locks: TabLockManagerLike): void {
875
+ const Controller = globalAbortController();
876
+ const controller = Controller ? new Controller() : undefined;
877
+ this.abortRequest = controller ? () => controller.abort() : undefined;
878
+ const held = new Promise<void>((resolve) => (this.releaseLock = resolve));
879
+ let request: Promise<unknown>;
880
+ try {
881
+ request = locks.request(this.lockName, controller ? { signal: controller.signal } : {}, async () => {
882
+ // A lock after close(): the tab releases it at once
883
+ if (this.closed) return;
884
+ this.abortRequest = undefined;
885
+ if (await this.lead()) {
886
+ // close() ends the hold. A group whose collections all failed after the start ends it too.
887
+ await held;
888
+ if (this.closed) return;
889
+ }
890
+ // A failed setup releases the lock: another tab tries, and this tab tries again after the backoff
891
+ this.retryLater();
892
+ });
893
+ } catch (error) {
894
+ request = Promise.reject(error);
895
+ }
896
+ request.then(undefined, (error: unknown) => {
897
+ // The abort of close(), or a tab that leads already
898
+ if (this.closed || this.lease) return;
899
+ // The lock manager refused the request: this tab runs the sync alone
900
+ this.onError(error);
901
+ this.stopChannel();
902
+ this.locks = undefined;
903
+ this.startSingle();
904
+ });
905
+ }
906
+
907
+ /**
908
+ * This method runs in a tab that got the lock. Each collection makes its `HybridStorage` and
909
+ * loads its queue, and it leads at the end of its load. When no collection can lead, the tab
910
+ * releases the lock. Else the collections whose setup failed try again alone after their backoff.
911
+ *
912
+ * @returns False when the tab must release the lock: no collection could lead, or `close()`
913
+ */
914
+ private async lead(): Promise<boolean> {
915
+ const lease = {};
916
+ this.lease = lease;
917
+ const members = [...this.members.values()];
918
+ // Each collection tells the other tabs at once when it leads: a slow collection does not stop the batches of the others
919
+ const results = await Promise.all(members.map((member) => member.takeLead(lease, true)));
920
+ if (this.lease !== lease) return false;
921
+ // A group without collections keeps the lock: its first collection leads, or releases the lock when its setup fails
922
+ if (!this.canLead()) {
923
+ this.stepDown();
924
+ return false;
925
+ }
926
+ this.failures = 0;
927
+ this.setRole("leader");
928
+ // Each follower says hello for each collection that it has and that the leader did not announce: the leader makes the collections that it does not have
929
+ if (!this.legacy) this.post(undefined, { v: PROTOCOL, type: "leader", from: this.id });
930
+ members.forEach((member, at) => {
931
+ if (!results[at]) member.retryInLeader();
932
+ });
933
+ return true;
934
+ }
935
+
936
+ /**
937
+ * This method runs after a failed setup of a collection in a tab that holds the lock, and keeps
938
+ * it after `lead()`. When no collection of the group leads or loads its queue, the tab releases
939
+ * the lock and asks for it again after the backoff of the group. Else the collection tries again
940
+ * alone after its own backoff.
941
+ */
942
+ afterFailedSetup(member: CollectionSync<any>, lease: object): void {
943
+ if (this.closed || this.lease !== lease) return;
944
+ if (this.canLead()) {
945
+ member.retryInLeader();
946
+ return;
947
+ }
948
+ this.stepDown();
949
+ this.setRole("follower");
950
+ // The lock callback then asks for the lock again after the backoff
951
+ const release = this.releaseLock;
952
+ this.releaseLock = undefined;
953
+ release?.();
954
+ }
955
+
956
+ /** True when the group has no collections yet, or when one of its collections leads or loads its queue. */
957
+ private canLead(): boolean {
958
+ const members = [...this.members.values()];
959
+ return members.length === 0 || members.some((member) => member.leads || member.settingUp);
960
+ }
961
+
962
+ /** This method ends the lease of the lock. A collection that waits for its next try stops its wait: the next lease tries it. */
963
+ private stepDown(): void {
964
+ this.lease = undefined;
965
+ for (const member of this.members.values()) member.stopRetry();
966
+ }
967
+
968
+ /** This method starts the single mode: each collection runs its sync alone. */
969
+ private startSingle(): void {
970
+ this.alone = true;
971
+ this.setRole("single");
972
+ for (const member of [...this.members.values()]) member.startSingle();
973
+ }
974
+
975
+ /** This method asks for the lock again after the backoff (roadmap CLI-67). */
976
+ private retryLater(): void {
977
+ if (this.closed) return;
978
+ this.failures++;
979
+ const wait = backoffDelay(this.backoff, this.failures, this.random);
980
+ const cancel = this.clock.after(wait, () => {
981
+ this.cancelRetry = undefined;
982
+ if (this.closed || !this.locks) return;
983
+ this.elect(this.locks);
984
+ });
985
+ // The wait must not keep a Node process alive
986
+ cancel.unref?.();
987
+ this.cancelRetry = cancel;
988
+ }
989
+
990
+ private setRole(role: TabRole): void {
991
+ if (this.currentRole === role) return;
992
+ this.currentRole = role;
993
+ this.roleListeners.emit(role);
994
+ }
995
+
996
+ private stopChannel(): void {
997
+ const channel = this.channel;
998
+ this.channel = undefined;
999
+ if (!channel) return;
1000
+ channel.onmessage = null;
1001
+ try {
1002
+ channel.close();
1003
+ } catch {
1004
+ // The channel is closed already
1005
+ }
1006
+ }
1007
+
1008
+ //
1009
+ // ═══ The messages of the other tabs ═══
1010
+ //
1011
+
1012
+ private receive(data: unknown): void {
1013
+ if (this.closed || !isTabMessage<unknown>(data) || data.from === this.id) return;
1014
+ if (this.legacy) {
1015
+ for (const member of this.members.values()) member.receive(data);
1016
+ return;
1017
+ }
1018
+ const name = data.collection;
1019
+ if (name === undefined) {
1020
+ // A new leader of the group: each collection of this tab that the leader did not announce says hello
1021
+ if (data.type === "leader") for (const member of [...this.members.values()]) member.onLeader(data.from, false);
1022
+ return;
1023
+ }
1024
+ const member = this.members.get(name) ?? this.adopt(name, data.type);
1025
+ member?.receive(data);
1026
+ }
1027
+
1028
+ /**
1029
+ * The leader makes a collection that another tab has, with the function `open`. Thus the leader
1030
+ * runs the sync of each collection of each tab.
1031
+ */
1032
+ private adopt(name: string, type: TabMessage<unknown>["type"]): CollectionSync<unknown> | undefined {
1033
+ if ((type !== "hello" && type !== "changes") || this.currentRole !== "leader" || !this.open || this.refused.has(name)) return undefined;
1034
+ try {
1035
+ const options = this.open(name);
1036
+ if (options) return this.add(name, options, collectionSettings(options, "A collection of a TabGroup"));
1037
+ } catch (error) {
1038
+ this.onError(error);
1039
+ }
1040
+ this.refused.add(name);
1041
+ return undefined;
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * The sync of one collection in a tab: the leader runs its `HybridStorage`, and a follower copies
1047
+ * the batches of the other tabs. The group gives the lock and the channel.
1048
+ */
1049
+ class CollectionSync<T> implements TabCollection<T> {
1050
+ readonly name: string;
1051
+
1052
+ private readonly group: GroupEngine;
279
1053
  private readonly local: LocalCopyStorage<T>;
280
1054
  private readonly shared: boolean;
281
1055
  private readonly makeRemote: () => CollectionStorage<T>;
282
1056
  private readonly hybridOptions: Omit<HybridStorageOptions, "local" | "capture">;
1057
+ private readonly settings: CollectionSettings;
283
1058
  /** The origin of the `HybridStorage` and of the writes of the coordinator to the local copy. */
284
1059
  private readonly tag: string;
285
1060
  private readonly writeOptions: StorageWriteOptions;
286
- private readonly onError: ListenerErrorHandler;
287
1061
  private readonly observers: StorageListeners<StorageChangeBatch<T>>;
288
1062
  private readonly roleListeners: StorageListeners<TabRole>;
289
1063
  private readonly statusListeners: StorageListeners<SyncStatus>;
290
1064
  /** The observers that the `HybridStorage` of the leader adds to its local copy: the capture */
291
1065
  private readonly captures: StorageListeners<StorageChangeBatch<T>>;
292
- /** The backoff of a new try after a failed setup, its clock and its random numbers */
293
- private readonly backoff: Backoff;
294
- private readonly clock: Clock;
295
- private readonly random: () => number;
296
1066
  private currentRole: TabRole = "follower";
297
- /** The lock manager, or undefined in the single mode */
298
- private locks: TabLockManagerLike | undefined;
299
- private channel: TabChannelLike | undefined;
300
1067
  private hybrid: HybridStorage<T> | undefined;
301
1068
  /** The storage of this tab while it loads its queue, before the tab leads */
302
1069
  private candidate: Candidate<T> | undefined;
303
- /** The number of the failed setups in a row */
1070
+ /** The number of the failed setups of this collection in a row, when the group leads or is single */
304
1071
  private failures = 0;
305
1072
  /** The stop of the wait before the next try */
306
1073
  private cancelRetry: Cancel | undefined;
@@ -321,160 +1088,105 @@ export class TabCoordinator<T> {
321
1088
  private outbox: SentBatch<T>[] = [];
322
1089
  /** The number of the last batch of each other tab that this tab copied */
323
1090
  private readonly seen = new Map<string, number>();
1091
+ /** The number of the last batch of each other tab that the local copy of this tab holds (roadmap CLI-66) */
1092
+ private readonly applied = new Map<string, number>();
324
1093
  /** In the leader: the number of the last batch of each tab that the leader captured. A tab without a hello is not here. */
325
1094
  private readonly captured = new Map<string, number>();
326
1095
  /** The batches of the other tabs: they reach the local copy and the observers in their order */
327
1096
  private incoming: Promise<void> = Promise.resolve();
328
1097
  private closed = false;
329
- private closing: Promise<void> | undefined;
330
- /** The abort of the lock request that waits */
331
- private abortRequest: (() => void) | undefined;
332
- /** The release of the lock */
333
- private releaseLock: (() => void) | undefined;
334
-
335
- constructor(options: TabCoordinatorOptions<T>) {
336
- if (typeof options?.name !== "string" || options.name === "") throw new TypeError("TabCoordinator needs a name");
337
- if (typeof options.local?.observe !== "function") throw new TypeError("TabCoordinator needs a local copy with observe()");
338
- if (options.copy !== "shared" && options.copy !== "tab") {
339
- throw new TypeError(`Unknown copy ${JSON.stringify(options.copy)}: use shared or tab`);
340
- }
341
- if (typeof options.remote !== "function") throw new TypeError("TabCoordinator needs the function remote");
342
- this.backoff = retryBackoff(options.retry);
343
- this.id = randomId();
344
- this.lockName = `@procedures/store/tab:${options.name}`;
345
- this.local = options.local;
346
- this.shared = options.copy === "shared";
347
- this.makeRemote = options.remote;
348
- this.hybridOptions = options.hybrid ?? {};
349
- this.tag = this.hybridOptions.origin ?? `tab-${this.id}`;
350
- this.writeOptions = { origin: this.tag };
351
- this.onError = options.onError ?? reportListenerError;
352
- this.observers = new StorageListeners<StorageChangeBatch<T>>(this.onError);
353
- this.roleListeners = new StorageListeners<TabRole>(this.onError);
354
- this.statusListeners = new StorageListeners<SyncStatus>(this.onError);
355
- this.captures = new StorageListeners<StorageChangeBatch<T>>(this.onError);
356
- this.clock = options.clock ?? this.hybridOptions.clock ?? realClock;
357
- this.random = options.random ?? this.hybridOptions.random ?? Math.random;
358
-
359
- // The observer of a follower. A tab that leads stops it. A single tab whose setup failed keeps its batches with it.
360
- this.stopTap = this.local.observe!((batch) => this.onFollowerBatch(batch));
361
- const locks = options.locks === undefined ? globalLocks() : (options.locks ?? undefined);
362
- const openChannel = options.channel === undefined ? globalChannelFactory() : (options.channel ?? undefined);
363
- if (!locks || !openChannel) {
364
- this.startSingle();
365
- return;
366
- }
367
- try {
368
- this.channel = openChannel(this.lockName);
369
- } catch (error) {
370
- this.onError(error);
371
- this.startSingle();
372
- return;
373
- }
374
- this.locks = locks;
375
- this.channel.onmessage = (event: { data?: unknown } | undefined) => this.receive(event?.data);
376
- this.hello();
377
- this.elect(locks);
1098
+ /** The snapshot that this tab waits for, and the number of its last request (roadmap CLI-66) */
1099
+ private snapshot: PendingSnapshot<T> | undefined;
1100
+ private requests = 0;
1101
+ /** The batches of the other tabs that wait for the snapshot, in their order */
1102
+ private held: Array<{ readonly from: string; readonly batch: SentBatch<T> }> = [];
1103
+ /** The own writes while the snapshot waits: the number of the last own batch of each id, and of the last clear */
1104
+ private readonly ownWrites = new Map<string, number>();
1105
+ private ownClear = 0;
1106
+ /** The stop of the wait for the next chunk */
1107
+ private cancelSnapshotWait: Cancel | undefined;
1108
+ /** The last tab that told this tab that it leads this collection: the `leader` message of its group does not start a second hello */
1109
+ private announcedBy: string | undefined;
1110
+ /** The error of the last failed setup of the sync */
1111
+ private setupError: unknown;
1112
+
1113
+ constructor(group: GroupEngine, name: string, options: TabCollectionOptions<T>, settings: CollectionSettings) {
1114
+ this.group = group;
1115
+ this.name = name;
1116
+ this.local = options.local;
1117
+ this.shared = options.copy === "shared";
1118
+ this.makeRemote = options.remote;
1119
+ this.hybridOptions = options.hybrid ?? {};
1120
+ this.settings = settings;
1121
+ this.tag = this.hybridOptions.origin ?? `tab-${group.id}`;
1122
+ this.writeOptions = { origin: this.tag };
1123
+ this.observers = new StorageListeners<StorageChangeBatch<T>>(group.onError);
1124
+ this.roleListeners = new StorageListeners<TabRole>(group.onError);
1125
+ this.statusListeners = new StorageListeners<SyncStatus>(group.onError);
1126
+ this.captures = new StorageListeners<StorageChangeBatch<T>>(group.onError);
1127
+ // The observer of a follower. A tab that leads stops it. A single tab whose setup failed keeps its batches with it.
1128
+ this.stopTap = this.local.observe!((batch) => this.onFollowerBatch(batch));
378
1129
  }
379
1130
 
380
- /** The role of this tab now. */
381
1131
  get role(): TabRole {
382
1132
  return this.currentRole;
383
1133
  }
384
1134
 
385
- /** True when this tab runs the sync: the leader, or a tab in the single mode. */
386
1135
  get isLeader(): boolean {
387
1136
  return this.currentRole === "leader" || this.currentRole === "single";
388
1137
  }
389
1138
 
390
- /**
391
- * The `HybridStorage` of the sync while this tab runs it, or else undefined. `getStatus()` of the
392
- * coordinator gives the state of the sync in each tab. Write to the local copy, not to this
393
- * storage: each tab then writes in the same way.
394
- */
395
1139
  get syncStorage(): HybridStorage<T> | undefined {
396
1140
  return this.hybrid;
397
1141
  }
398
1142
 
399
- /**
400
- * This method starts to send the change batches to an observer: the batches of the local copy of
401
- * this tab, and the batches of the other tabs. Each batch comes one time, with the origin of each
402
- * change. In the leader, the batches of the sync have the origin `syncOrigin` of the
403
- * `HybridStorage`. A batch of another tab comes after the local copy of this tab has it.
404
- *
405
- * @returns The function that stops the batches. A second use of it does nothing.
406
- * @throws Error after `close()`
407
- */
1143
+ /** True while the `HybridStorage` of this tab runs the sync of the collection. */
1144
+ get leads(): boolean {
1145
+ return this.hybrid !== undefined;
1146
+ }
1147
+
1148
+ /** True while the `HybridStorage` of this tab loads its queue, before the collection leads. */
1149
+ get settingUp(): boolean {
1150
+ return this.candidate !== undefined;
1151
+ }
1152
+
408
1153
  observe(observer: StorageObserver<T>): () => void {
409
- if (this.closed) throw new Error("The TabCoordinator is closed");
1154
+ if (this.closed) throw new Error(`The ${this.group.kind} is closed`);
410
1155
  return this.observers.add(observer);
411
1156
  }
412
1157
 
413
- /**
414
- * This method starts to send the role of this tab to a listener at each change.
415
- *
416
- * @returns The function that stops the roles. A second use of it does nothing.
417
- * @throws Error after `close()`
418
- */
419
1158
  onRole(listener: (role: TabRole) => void): () => void {
420
- if (this.closed) throw new Error("The TabCoordinator is closed");
1159
+ if (this.closed) throw new Error(`The ${this.group.kind} is closed`);
421
1160
  return this.roleListeners.add(listener);
422
1161
  }
423
1162
 
424
- /**
425
- * The status of the sync (roadmap CLI-65), or undefined before the first status and after
426
- * `close()`.
427
- *
428
- * - In the leader and in the single mode, it is the status of the `HybridStorage` of this tab.
429
- * - In a follower, it is the last status that the leader sent. A new tab gets it in the answer to
430
- * its hello. After a change of the leader, a follower keeps the last status until the new
431
- * leader sends its status.
432
- * - In a follower, `error.error` is a copy of the error of the leader: an `Error` with its `name`,
433
- * its `message` and its `code`. The copy has no `cause`.
434
- */
435
1163
  getStatus(): SyncStatus | undefined {
436
1164
  return this.status;
437
1165
  }
438
1166
 
439
- /**
440
- * This method starts to send the status of the sync to a listener at each change (roadmap
441
- * CLI-65): in the leader, each status of its `HybridStorage`, and in a follower, each status that
442
- * the leader sends. The listener gets each status one time. When the tab becomes the leader, the
443
- * listener gets the status of its own `HybridStorage`.
444
- *
445
- * @returns The function that stops the statuses. A second use of it does nothing.
446
- * @throws Error after `close()`
447
- */
448
1167
  onStatus(listener: (status: SyncStatus) => void): () => void {
449
- if (this.closed) throw new Error("The TabCoordinator is closed");
1168
+ if (this.closed) throw new Error(`The ${this.group.kind} is closed`);
450
1169
  return this.statusListeners.add(listener);
451
1170
  }
452
1171
 
453
- /**
454
- * This method closes the coordinator.
455
- *
456
- * - A follower stops its lock request, or its wait before a new try. It does not become the leader.
457
- * - A tab that loads its queue to become the leader closes its `HybridStorage`, and releases the lock.
458
- * - The leader closes its `HybridStorage`: it sends the queue, and the queue store keeps the
459
- * operations that the remote did not take. Then the leader releases the lock, and the next
460
- * tab becomes the leader.
461
- * - The coordinator closes its channel. It does not close the local copy: the application owns it.
462
- * - After it, the coordinator sends and copies nothing. A second `close()` gives the promise of
463
- * the first one.
464
- */
465
- close(): Promise<void> {
466
- if (!this.closing) this.closing = this.shutDown();
467
- return this.closing;
1172
+ /** This method starts the collection on the channel: it says hello, and it asks for a snapshot. */
1173
+ begin(): void {
1174
+ if (this.settings.snapshot.want) this.snapshot = { request: 0, chunks: [], received: 0 };
1175
+ this.hello();
1176
+ this.requestSnapshot();
468
1177
  }
469
1178
 
470
- private async shutDown(): Promise<void> {
1179
+ /** This method closes the storage of the collection. The group then releases the lock and closes the channel. */
1180
+ async shutDown(): Promise<void> {
471
1181
  this.closed = true;
472
1182
  this.stopTap?.();
473
1183
  this.stopTap = undefined;
474
- this.abortRequest?.();
475
- this.abortRequest = undefined;
476
1184
  this.cancelRetry?.();
477
1185
  this.cancelRetry = undefined;
1186
+ this.cancelSnapshotWait?.();
1187
+ this.cancelSnapshotWait = undefined;
1188
+ this.snapshot = undefined;
1189
+ this.held = [];
478
1190
  this.stopStatus?.();
479
1191
  this.stopStatus = undefined;
480
1192
  this.setRole("closed");
@@ -485,74 +1197,63 @@ export class TabCoordinator<T> {
485
1197
  this.hybrid = undefined;
486
1198
  this.status = undefined;
487
1199
  try {
488
- // The leader holds the lock until its queue reached the remote or its queue store
489
1200
  if (hybrid) await hybrid.close();
490
1201
  } catch (error) {
491
- this.onError(error);
492
- } finally {
493
- this.releaseLock?.();
494
- this.releaseLock = undefined;
495
- // The acknowledgements of the close of the HybridStorage went out before
496
- try {
497
- this.channel?.close();
498
- } catch {
499
- // The channel is closed already
500
- }
501
- this.channel = undefined;
502
- this.observers.clear();
503
- this.roleListeners.clear();
504
- this.statusListeners.clear();
505
- this.captures.clear();
1202
+ this.group.onError(error);
506
1203
  }
507
1204
  }
508
1205
 
1206
+ clearListeners(): void {
1207
+ this.observers.clear();
1208
+ this.roleListeners.clear();
1209
+ this.statusListeners.clear();
1210
+ this.captures.clear();
1211
+ }
1212
+
509
1213
  //
510
- // ═══ The election ═══
1214
+ // ═══ The setup of the sync ═══
511
1215
  //
512
1216
 
513
- /** This method asks for the lock. The tab becomes the leader when it gets the lock. */
514
- private elect(locks: TabLockManagerLike): void {
515
- const Controller = globalAbortController();
516
- const controller = Controller ? new Controller() : undefined;
517
- this.abortRequest = controller ? () => controller.abort() : undefined;
518
- const held = new Promise<void>((resolve) => (this.releaseLock = resolve));
519
- let request: Promise<unknown>;
520
- try {
521
- request = locks.request(this.lockName, controller ? { signal: controller.signal } : {}, async () => {
522
- // A lock after close(): the tab releases it at once
523
- if (this.closed) return;
524
- this.abortRequest = undefined;
525
- if (await this.takeLead()) {
526
- await held;
527
- return;
528
- }
529
- // A failed setup releases the lock: another tab tries, and this tab tries again after the backoff
530
- this.retryLater();
531
- });
532
- } catch (error) {
533
- request = Promise.reject(error);
534
- }
535
- request.then(undefined, (error: unknown) => {
536
- // The abort of close(), or a tab that leads already
537
- if (this.closed || this.hybrid) return;
538
- // The lock manager refused the request: this tab runs the sync alone
539
- this.onError(error);
540
- this.stopChannel();
541
- this.locks = undefined;
542
- this.startSingle();
1217
+ /**
1218
+ * This method sets up the sync in a tab that holds the lock. After a failure, the collection tries
1219
+ * again after its backoff. When no collection of the group can lead, the tab releases the lock.
1220
+ */
1221
+ startLead(lease: object, announce: boolean): void {
1222
+ void this.takeLead(lease, announce).then((led) => {
1223
+ if (!led && !this.closed && this.group.lease === lease) this.group.afterFailedSetup(this, lease);
543
1224
  });
544
1225
  }
545
1226
 
1227
+ /** This method stops the wait before the next try of the setup. */
1228
+ stopRetry(): void {
1229
+ this.cancelRetry?.();
1230
+ this.cancelRetry = undefined;
1231
+ }
1232
+
1233
+ /**
1234
+ * This method runs after a failed setup in a tab that keeps the lock. No other tab leads the
1235
+ * collection, so no snapshot comes: the batches that waited reach the local copy now.
1236
+ */
1237
+ retryInLeader(): void {
1238
+ this.endSnapshot();
1239
+ this.retryLater();
1240
+ }
1241
+
546
1242
  /**
547
1243
  * This method makes the `HybridStorage` of a tab that got the lock, and waits for the load of its
548
- * queue. Then the tab becomes the leader. Until then, the tab is a follower: its own batches go to
549
- * the other tabs and to its outbox, and the capture of the storage ignores the local copy.
1244
+ * queue. Then the collection leads. Until then, it is a follower: its own batches go to the other
1245
+ * tabs and to its outbox, and the capture of the storage ignores the local copy.
550
1246
  *
551
- * @returns False when the setup failed (the error went to `onError`), or after `close()`
1247
+ * @param announce True: the collection tells the other tabs that it leads. Else the group tells it.
1248
+ * @returns False when the setup failed (the error went to `onError`), after `close()`, or when the tab lost the lease
552
1249
  */
553
- private async takeLead(): Promise<boolean> {
1250
+ async takeLead(lease: object, announce: boolean): Promise<boolean> {
1251
+ if (this.hybrid) return true;
554
1252
  const candidate = this.makeCandidate();
555
- if (!candidate) return false;
1253
+ if (!candidate) {
1254
+ this.reportFailure(lease);
1255
+ return false;
1256
+ }
556
1257
  this.candidate = candidate;
557
1258
  let failed = false;
558
1259
  let failure: unknown;
@@ -566,27 +1267,50 @@ export class TabCoordinator<T> {
566
1267
  // close() took the storage
567
1268
  if (this.candidate !== candidate) return false;
568
1269
  this.candidate = undefined;
569
- if (!failed) {
570
- this.promote(candidate, "leader");
1270
+ if (!failed && this.group.lease === lease) {
1271
+ this.promote(candidate, "leader", announce);
571
1272
  return true;
572
1273
  }
573
- this.onError(failure);
1274
+ if (failed) {
1275
+ this.setupFailed(failure);
1276
+ this.reportFailure(lease);
1277
+ }
574
1278
  candidate.stop();
575
1279
  try {
576
1280
  await candidate.hybrid.close();
577
1281
  } catch (error) {
578
- this.onError(error);
1282
+ this.group.onError(error);
579
1283
  }
580
1284
  return false;
581
1285
  }
582
1286
 
583
- /** This method makes the `HybridStorage` of the single mode. The tab tries again after the backoff when it fails. */
584
- private startSingle(): void {
1287
+ /** This method makes the `HybridStorage` of the single mode. The collection tries again after the backoff when it fails. */
1288
+ startSingle(): void {
1289
+ if (this.closed || this.hybrid) return;
1290
+ // No other tab sends a snapshot
1291
+ this.endSnapshot();
585
1292
  const candidate = this.makeCandidate();
586
- if (candidate) this.promote(candidate, "single");
1293
+ if (candidate) this.promote(candidate, "single", false);
587
1294
  else this.retryLater();
588
1295
  }
589
1296
 
1297
+ /** This method tries the setup again after the backoff of the collection (roadmap CLI-67). */
1298
+ retryLater(): void {
1299
+ if (this.closed) return;
1300
+ this.cancelRetry?.();
1301
+ this.failures++;
1302
+ const wait = backoffDelay(this.group.backoff, this.failures, this.group.random);
1303
+ const cancel = this.group.clock.after(wait, () => {
1304
+ this.cancelRetry = undefined;
1305
+ if (this.closed || this.hybrid) return;
1306
+ if (this.group.alone) this.startSingle();
1307
+ else if (this.group.lease) this.startLead(this.group.lease, true);
1308
+ });
1309
+ // The wait must not keep a Node process alive
1310
+ cancel.unref?.();
1311
+ this.cancelRetry = cancel;
1312
+ }
1313
+
590
1314
  /**
591
1315
  * This method makes the `HybridStorage` of the sync, with its observer. Its capture ignores the
592
1316
  * local copy until the tab leads.
@@ -599,7 +1323,7 @@ export class TabCoordinator<T> {
599
1323
  try {
600
1324
  remote = this.makeRemote();
601
1325
  } catch (error) {
602
- this.onError(error);
1326
+ this.setupFailed(error);
603
1327
  return undefined;
604
1328
  }
605
1329
  const gate = { open: false };
@@ -612,11 +1336,11 @@ export class TabCoordinator<T> {
612
1336
  origin: this.tag,
613
1337
  });
614
1338
  } catch (error) {
615
- this.onError(error);
1339
+ this.setupFailed(error);
616
1340
  // The remote of a storage that did not start stays open otherwise
617
1341
  Promise.resolve()
618
1342
  .then(() => remote.close())
619
- .catch((closeError: unknown) => this.onError(closeError));
1343
+ .catch((closeError: unknown) => this.group.onError(closeError));
620
1344
  return undefined;
621
1345
  }
622
1346
  // The observer starts at once: the other tabs get the changes of the load and of the first sync
@@ -624,13 +1348,47 @@ export class TabCoordinator<T> {
624
1348
  return { hybrid, gate, stop };
625
1349
  }
626
1350
 
1351
+ /** This method gives the error of a failed setup to `onError`, and keeps it for the status of the failure. */
1352
+ private setupFailed(error: unknown): void {
1353
+ this.setupError = error;
1354
+ this.group.onError(error);
1355
+ }
1356
+
627
1357
  /**
628
- * This method makes this tab the leader, or the single tab, with the storage of a candidate. The
629
- * capture of the storage gets the batches of this tab that no leader acknowledged.
1358
+ * In a group, the tab that holds the lock tells the other tabs that the setup of the collection
1359
+ * failed (roadmap CLI-65). Its status has the error, with the operation `load`, and the other
1360
+ * values of the last status. Thus a follower does not keep the status of an old leader. A
1361
+ * `TabCoordinator` releases the lock after a failed setup, and sends no status.
630
1362
  */
631
- private promote(candidate: Candidate<T>, role: "leader" | "single"): void {
1363
+ private reportFailure(lease: object): void {
1364
+ if (this.group.legacy || this.closed || this.group.lease !== lease) return;
1365
+ const last = this.status;
1366
+ const status: SyncStatus = {
1367
+ connected: false,
1368
+ pendingChanges: last?.pendingChanges ?? 0,
1369
+ syncing: false,
1370
+ ...(last?.endpoint !== undefined && { endpoint: last.endpoint }),
1371
+ ...(last?.lastSync !== undefined && { lastSync: last.lastSync }),
1372
+ ...(last?.mode !== undefined && { mode: last.mode }),
1373
+ error: { operation: "load", error: this.setupError },
1374
+ };
1375
+ this.status = status;
1376
+ // The status is the status of this tab now: its number follows the last number
1377
+ this.statusFrom = undefined;
1378
+ this.statusSeq++;
1379
+ this.postStatus();
1380
+ this.statusListeners.emit(status);
1381
+ }
1382
+
1383
+ /**
1384
+ * This method makes this tab the leader of the collection, or the single tab, with the storage of
1385
+ * a candidate. The capture of the storage gets the batches of this tab that no leader acknowledged.
1386
+ */
1387
+ private promote(candidate: Candidate<T>, role: "leader" | "single", announce: boolean): void {
632
1388
  const { hybrid, gate, stop } = candidate;
633
1389
  this.hybrid = hybrid;
1390
+ this.cancelRetry?.();
1391
+ this.cancelRetry = undefined;
634
1392
  this.failures = 0;
635
1393
  // The capture starts and the observer of the follower stops in one step: no change of the local copy goes between them
636
1394
  gate.open = true;
@@ -638,8 +1396,10 @@ export class TabCoordinator<T> {
638
1396
  this.stopTap = stop;
639
1397
  stopFollower?.();
640
1398
  for (const batch of this.outbox.splice(0)) this.capture(batch.changes);
1399
+ // The copy of this tab is now the copy that the other tabs get: a snapshot that waits ends without entries
1400
+ this.endSnapshot();
641
1401
  this.setRole(role);
642
- if (role === "leader") this.post({ v: PROTOCOL, type: "leader", from: this.id });
1402
+ if (announce) this.post({ v: PROTOCOL, type: "leader", from: this.group.id });
643
1403
  // The status of this tab is now the status of its storage, also when it has the values of the last status of the old leader. The followers get it.
644
1404
  this.status = undefined;
645
1405
  this.statusFrom = undefined;
@@ -647,22 +1407,6 @@ export class TabCoordinator<T> {
647
1407
  this.onOwnStatus(hybrid, hybrid.getStatus());
648
1408
  }
649
1409
 
650
- /** This method asks for the lock again, or tries the single mode again, after the backoff (roadmap CLI-67). */
651
- private retryLater(): void {
652
- if (this.closed) return;
653
- this.failures++;
654
- const wait = backoffDelay(this.backoff, this.failures, this.random);
655
- const cancel = this.clock.after(wait, () => {
656
- this.cancelRetry = undefined;
657
- if (this.closed) return;
658
- if (this.locks) this.elect(this.locks);
659
- else this.startSingle();
660
- });
661
- // The wait must not keep a Node process alive
662
- cancel.unref?.();
663
- this.cancelRetry = cancel;
664
- }
665
-
666
1410
  /**
667
1411
  * The local copy for the `HybridStorage` of the leader. Its `observe()` gives the changes of the
668
1412
  * local copy and the batches of the other tabs, so the capture queues both. It gives the changes
@@ -729,7 +1473,7 @@ export class TabCoordinator<T> {
729
1473
  /** This method sends the last status of the leader, with its number. */
730
1474
  private postStatus(): void {
731
1475
  if (this.status === undefined) return;
732
- this.post({ v: PROTOCOL, type: "status", from: this.id, seq: this.statusSeq, status: portableStatus(this.status) });
1476
+ this.post({ v: PROTOCOL, type: "status", from: this.group.id, seq: this.statusSeq, status: portableStatus(this.status) });
733
1477
  }
734
1478
 
735
1479
  /** A status of the leader. A tab that runs the sync keeps its own status. */
@@ -772,67 +1516,70 @@ export class TabCoordinator<T> {
772
1516
  */
773
1517
  private share(changes: StorageChangeBatch<T>, keep: boolean): void {
774
1518
  const seq = ++this.seq;
1519
+ if (this.snapshot) this.noteOwn(changes, seq);
775
1520
  this.observers.emit(changes);
776
- const sent = this.post({ v: PROTOCOL, type: "changes", from: this.id, seq, changes });
777
- if (keep && (sent || !this.channel)) this.outbox.push({ seq, changes });
1521
+ const sent = this.post({ v: PROTOCOL, type: "changes", from: this.group.id, seq, changes });
1522
+ if (keep && (sent || !this.group.hasChannel)) this.outbox.push({ seq, changes });
778
1523
  }
779
1524
 
780
1525
  /** This method tells the leader the batches of this tab that no leader acknowledged. */
781
1526
  private hello(): void {
782
- this.post({ v: PROTOCOL, type: "hello", from: this.id, seq: this.seq, pending: this.outbox });
1527
+ this.post({ v: PROTOCOL, type: "hello", from: this.group.id, seq: this.seq, pending: this.outbox });
783
1528
  }
784
1529
 
785
- /** @returns False when the message did not go out: no channel, or a value that the channel cannot clone */
1530
+ /** @returns False when the message did not go out */
786
1531
  private post(message: TabMessage<T>): boolean {
787
- const channel = this.channel;
788
- if (!channel) return false;
789
- try {
790
- channel.postMessage(message);
791
- return true;
792
- } catch (error) {
793
- this.onError(error);
794
- return false;
795
- }
796
- }
797
-
798
- private stopChannel(): void {
799
- const channel = this.channel;
800
- this.channel = undefined;
801
- if (!channel) return;
802
- channel.onmessage = null;
803
- try {
804
- channel.close();
805
- } catch {
806
- // The channel is closed already
807
- }
1532
+ return this.group.post(this.name, message);
808
1533
  }
809
1534
 
810
1535
  //
811
1536
  // ═══ The batches of the other tabs ═══
812
1537
  //
813
1538
 
814
- private receive(data: unknown): void {
815
- if (this.closed || !isTabMessage<T>(data) || data.from === this.id) return;
816
- switch (data.type) {
1539
+ /** A message of another tab for this collection. */
1540
+ receive(data: TabMessage<unknown>): void {
1541
+ if (this.closed) return;
1542
+ const message = data as TabMessage<T>;
1543
+ switch (message.type) {
817
1544
  case "changes":
818
- this.onChanges(data.from, { seq: data.seq, changes: data.changes });
1545
+ this.onChanges(message.from, { seq: message.seq, changes: message.changes });
819
1546
  break;
820
1547
  case "hello":
821
- this.onHello(data.from, data.seq, data.pending);
1548
+ this.onHello(message.from, message.seq, message.pending);
822
1549
  break;
823
1550
  case "leader":
824
- // The new leader gets the batches that no leader acknowledged
825
- if (this.currentRole === "follower") this.hello();
1551
+ this.onLeader(message.from, true);
826
1552
  break;
827
1553
  case "ack":
828
- if (data.to === this.id) this.outbox = this.outbox.filter((batch) => batch.seq > data.seq);
1554
+ if (message.to === this.group.id) this.outbox = this.outbox.filter((batch) => batch.seq > message.seq);
829
1555
  break;
830
1556
  case "status":
831
- this.onLeaderStatus(data.from, data.seq, data.status);
1557
+ this.onLeaderStatus(message.from, message.seq, message.status);
1558
+ break;
1559
+ case "snapshot-request":
1560
+ this.sendSnapshot(message.from, message.request);
1561
+ break;
1562
+ case "snapshot":
1563
+ this.onSnapshot(message);
832
1564
  break;
833
1565
  }
834
1566
  }
835
1567
 
1568
+ /**
1569
+ * A new leader. A follower tells it the batches that no leader acknowledged, and asks it for the
1570
+ * snapshot that waits.
1571
+ *
1572
+ * @param own True when the tab `from` leads this collection. False for the message of a group for
1573
+ * all its collections: a collection that the tab `from` announced already ignores it.
1574
+ */
1575
+ onLeader(from: string, own: boolean): void {
1576
+ if (this.closed || this.currentRole !== "follower") return;
1577
+ if (own) this.announcedBy = from;
1578
+ else if (this.announcedBy === from) return;
1579
+ this.hello();
1580
+ this.requestSnapshot();
1581
+ }
1582
+
836
1583
  /**
837
1584
  * A batch of another tab. The leader captures it when the tab said hello to it. Each tab copies
838
1585
  * it one time.
@@ -860,24 +1607,35 @@ export class TabCoordinator<T> {
860
1607
  if (pending.length > 0) this.acknowledge(from, seq);
861
1608
  // A new tab gets the status now. The other tabs have its number already.
862
1609
  this.postStatus();
1610
+ } else if (!this.group.legacy && !this.hybrid && this.group.lease && this.status !== undefined && this.statusFrom === undefined) {
1611
+ // The status of a failed setup in the tab that holds the lock (roadmap CLI-65)
1612
+ this.postStatus();
863
1613
  }
864
1614
  for (const batch of pending) this.copy(from, batch);
865
1615
  }
866
1616
 
867
1617
  /**
868
1618
  * This method copies a batch of another tab that this tab did not see: to the local copy of a
869
- * tab, and then to the observers. The batches go in their order.
1619
+ * tab, and then to the observers. The batches go in their order. While a snapshot waits, the
1620
+ * batch waits for it.
870
1621
  */
871
1622
  private copy(from: string, batch: SentBatch<T>): void {
872
1623
  if (batch.seq <= (this.seen.get(from) ?? 0)) return;
873
1624
  this.seen.set(from, batch.seq);
1625
+ if (this.snapshot) this.held.push({ from, batch });
1626
+ else this.apply(from, batch);
1627
+ }
1628
+
1629
+ /** This method writes a batch of another tab to the local copy, and then gives it to the observers. */
1630
+ private apply(from: string, batch: SentBatch<T>): void {
874
1631
  this.incoming = this.incoming
875
1632
  .then(async () => {
876
1633
  if (this.closed) return;
877
1634
  if (!this.shared) await this.write(batch.changes);
1635
+ this.applied.set(from, Math.max(this.applied.get(from) ?? 0, batch.seq));
878
1636
  if (!this.closed) this.observers.emit(batch.changes);
879
1637
  })
880
- .catch((error: unknown) => this.onError(error));
1638
+ .catch((error: unknown) => this.group.onError(error));
881
1639
  }
882
1640
 
883
1641
  /**
@@ -909,10 +1667,170 @@ export class TabCoordinator<T> {
909
1667
  const hybrid = this.hybrid;
910
1668
  if (!hybrid) return;
911
1669
  hybrid.whenQueued().then(
912
- () => void this.post({ v: PROTOCOL, type: "ack", from: this.id, to, seq }),
913
- (error: unknown) => this.onError(error),
1670
+ () => void this.post({ v: PROTOCOL, type: "ack", from: this.group.id, to, seq }),
1671
+ (error: unknown) => this.group.onError(error),
914
1672
  );
915
1673
  }
1674
+
1675
+ //
1676
+ // ═══ The snapshot for a new tab (roadmap CLI-66) ═══
1677
+ //
1678
+
1679
+ /** This method asks the leader for a snapshot, while the snapshot waits. A new request replaces the last one. */
1680
+ private requestSnapshot(): void {
1681
+ if (!this.snapshot) return;
1682
+ const request = ++this.requests;
1683
+ this.snapshot = { request, chunks: [], received: 0 };
1684
+ this.post({ v: PROTOCOL, type: "snapshot-request", from: this.group.id, request });
1685
+ this.waitSnapshot();
1686
+ }
1687
+
1688
+ /**
1689
+ * This method starts the wait for the next chunk again. At its end, the tab continues without the
1690
+ * snapshot. In the tab that holds the lock, the wait renews itself while the collection loads its
1691
+ * queue, at most `SNAPSHOT_RENEWALS` times: a load that hangs does not hold the batches forever.
1692
+ *
1693
+ * @param renewals The number of the renewals of the wait in the tab that holds the lock
1694
+ */
1695
+ private waitSnapshot(renewals = 0): void {
1696
+ this.cancelSnapshotWait?.();
1697
+ const cancel = this.group.clock.after(this.settings.snapshot.timeout, () => {
1698
+ this.cancelSnapshotWait = undefined;
1699
+ if (this.closed || !this.snapshot) return;
1700
+ if (this.group.lease) {
1701
+ // This tab holds the lock and loads its queue: the snapshot ends when the collection leads
1702
+ if (renewals < SNAPSHOT_RENEWALS) {
1703
+ this.waitSnapshot(renewals + 1);
1704
+ return;
1705
+ }
1706
+ this.group.onError(new Error(`The collection ${JSON.stringify(this.name)} did not lead in time: it continues without the snapshot`));
1707
+ } else {
1708
+ this.group.onError(new Error(`No leader sent the snapshot of the collection ${JSON.stringify(this.name)} in time`));
1709
+ }
1710
+ this.endSnapshot();
1711
+ });
1712
+ // The wait must not keep a Node process alive
1713
+ cancel.unref?.();
1714
+ this.cancelSnapshotWait = cancel;
1715
+ }
1716
+
1717
+ /**
1718
+ * The leader sends the entries of its local copy to the tab `to`, in chunks. The numbers come
1719
+ * before the read of the entries: thus the entries hold each batch up to the numbers. A batch
1720
+ * after them can be in the entries too, and the new tab writes it again with the same values.
1721
+ */
1722
+ private sendSnapshot(to: string, request: number): void {
1723
+ if (!this.hybrid || this.currentRole !== "leader") return;
1724
+ const chunk = this.settings.snapshot.chunk;
1725
+ // The batches of the other tabs that this tab got before the request reach the local copy first
1726
+ this.incoming = this.incoming
1727
+ .then(async () => {
1728
+ if (this.closed || this.currentRole !== "leader") return;
1729
+ const seqs: Array<[string, number]> = [[this.group.id, this.seq], ...this.applied];
1730
+ const entries = await this.local.getEntries();
1731
+ if (this.closed) return;
1732
+ const parts = Math.max(1, Math.ceil(entries.length / chunk));
1733
+ for (let part = 0; part < parts; part++) {
1734
+ const slice = entries.slice(part * chunk, (part + 1) * chunk);
1735
+ if (!this.post({ v: PROTOCOL, type: "snapshot", from: this.group.id, to, request, part, parts, seqs, entries: slice })) return;
1736
+ }
1737
+ })
1738
+ .catch((error: unknown) => this.group.onError(error));
1739
+ }
1740
+
1741
+ /** A chunk of a snapshot. The tab keeps the chunks of its last request from one leader, and applies them after the last one. */
1742
+ private onSnapshot(message: SnapshotMessage<T>): void {
1743
+ const pending = this.snapshot;
1744
+ if (!pending || message.to !== this.group.id || message.request !== pending.request) return;
1745
+ if (pending.from === undefined) {
1746
+ pending.from = message.from;
1747
+ pending.parts = message.parts;
1748
+ pending.seqs = message.seqs;
1749
+ } else if (message.from !== pending.from || message.parts !== pending.parts) {
1750
+ return;
1751
+ }
1752
+ if (pending.chunks[message.part] !== undefined) return;
1753
+ pending.chunks[message.part] = message.entries;
1754
+ pending.received++;
1755
+ if (pending.received < message.parts) this.waitSnapshot();
1756
+ else this.applySnapshot(pending);
1757
+ }
1758
+
1759
+ /**
1760
+ * This method writes the entries of a snapshot to the local copy, and then the batches that
1761
+ * waited. It skips the batches that the snapshot holds, and the entries of the ids that this tab
1762
+ * wrote after the snapshot.
1763
+ */
1764
+ private applySnapshot(pending: PendingSnapshot<T>): void {
1765
+ this.snapshot = undefined;
1766
+ this.cancelSnapshotWait?.();
1767
+ this.cancelSnapshotWait = undefined;
1768
+ const seqs = new Map<string, number>(pending.seqs ?? []);
1769
+ const mine = seqs.get(this.group.id) ?? 0;
1770
+ const entries: Array<[string, T]> = [];
1771
+ if (this.ownClear <= mine) {
1772
+ for (const chunk of pending.chunks) {
1773
+ for (const [id, value] of chunk ?? []) if ((this.ownWrites.get(id) ?? 0) <= mine) entries.push([id, value]);
1774
+ }
1775
+ }
1776
+ this.ownWrites.clear();
1777
+ this.ownClear = 0;
1778
+ seqs.delete(this.group.id);
1779
+ // A batch that the snapshot holds and that comes later is not new
1780
+ for (const [from, seq] of seqs) if (seq > (this.seen.get(from) ?? 0)) this.seen.set(from, seq);
1781
+ const origin = this.hybridOptions.syncOrigin ?? "sync";
1782
+ this.incoming = this.incoming
1783
+ .then(async () => {
1784
+ if (this.closed) return;
1785
+ if (entries.length > 0) await this.local.setBatch(entries, this.writeOptions);
1786
+ for (const [from, seq] of seqs) this.applied.set(from, Math.max(this.applied.get(from) ?? 0, seq));
1787
+ if (!this.closed && entries.length > 0) this.observers.emit(entries.map(([id, value]): StorageChangeEvent<T> => ({ type: "set", id, value, origin })));
1788
+ })
1789
+ .catch((error: unknown) => this.group.onError(error));
1790
+ for (const { from, batch } of this.held.splice(0)) if (batch.seq > (seqs.get(from) ?? 0)) this.apply(from, batch);
1791
+ }
1792
+
1793
+ /** This method ends a snapshot that waits, without entries: the batches that waited reach the local copy. */
1794
+ private endSnapshot(): void {
1795
+ if (!this.snapshot) return;
1796
+ this.snapshot = undefined;
1797
+ this.cancelSnapshotWait?.();
1798
+ this.cancelSnapshotWait = undefined;
1799
+ this.ownWrites.clear();
1800
+ this.ownClear = 0;
1801
+ for (const { from, batch } of this.held.splice(0)) this.apply(from, batch);
1802
+ }
1803
+
1804
+ /** This method records the ids of an own batch while the snapshot waits. */
1805
+ private noteOwn(changes: StorageChangeBatch<T>, seq: number): void {
1806
+ for (const change of changes) {
1807
+ if (change.type === "clear") this.ownClear = seq;
1808
+ else this.ownWrites.set(change.id, seq);
1809
+ }
1810
+ }
1811
+ }
1812
+
1813
+ /**
1814
+ * The settings of the options of a collection.
1815
+ *
1816
+ * @throws TypeError for a local copy without `observe()`, an unknown `copy`, no `remote`, or a snapshot of a shared copy
1817
+ * @throws RangeError for a `snapshot.chunk` that is not a whole number of 1 or more
1818
+ */
1819
+ function collectionSettings<T>(options: TabCollectionOptions<T>, owner: string): CollectionSettings {
1820
+ if (typeof options?.local?.observe !== "function") throw new TypeError(`${owner} needs a local copy with observe()`);
1821
+ if (options.copy !== "shared" && options.copy !== "tab") {
1822
+ throw new TypeError(`Unknown copy ${JSON.stringify(options.copy)}: use shared or tab`);
1823
+ }
1824
+ if (typeof options.remote !== "function") throw new TypeError(`${owner} needs the function remote`);
1825
+ const snapshot = options.snapshot ?? false;
1826
+ const want = snapshot !== false;
1827
+ if (want && options.copy !== "tab") throw new TypeError('The option snapshot needs copy: "tab": the tabs of a shared copy have the entries');
1828
+ const details = typeof snapshot === "object" && snapshot !== null ? snapshot : {};
1829
+ const chunk = details.chunk ?? SNAPSHOT_CHUNK;
1830
+ if (!(Number.isSafeInteger(chunk) && chunk >= 1)) throw new RangeError(`snapshot.chunk must be a whole number of 1 or more, not ${chunk}`);
1831
+ const timeout = optionalMs(details.timeout, "snapshot.timeout") ?? SNAPSHOT_TIMEOUT;
1832
+ if (!(timeout > 0)) throw new RangeError(`snapshot.timeout must be a time of more than zero, not ${timeout} ms`);
1833
+ return { snapshot: { want, chunk, timeout } };
916
1834
  }
917
1835
 
918
1836
  /**
@@ -1068,6 +1986,11 @@ function isBatch(value: unknown): value is StorageChangeEvent<unknown>[] {
1068
1986
  });
1069
1987
  }
1070
1988
 
1989
+ /** True when a value is a list of pairs whose first value is a string, and whose second value passes `second`. */
1990
+ function isPairs(value: unknown, second: (item: unknown) => boolean): boolean {
1991
+ return Array.isArray(value) && value.every((pair: unknown) => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === "string" && second(pair[1]));
1992
+ }
1993
+
1071
1994
  /**
1072
1995
  * True when a value is a message of the channel, of this version. A message of a type that this
1073
1996
  * release does not know is not a message: a tab of an older release ignores a new type.
@@ -1079,12 +2002,19 @@ function isTabMessage<T>(value: unknown): value is TabMessage<T> {
1079
2002
  type?: unknown;
1080
2003
  from?: unknown;
1081
2004
  to?: unknown;
2005
+ collection?: unknown;
1082
2006
  seq?: unknown;
1083
2007
  changes?: unknown;
1084
2008
  pending?: unknown;
1085
2009
  status?: unknown;
2010
+ request?: unknown;
2011
+ part?: unknown;
2012
+ parts?: unknown;
2013
+ seqs?: unknown;
2014
+ entries?: unknown;
1086
2015
  };
1087
2016
  if (message.v !== PROTOCOL || typeof message.from !== "string") return false;
2017
+ if (message.collection !== undefined && typeof message.collection !== "string") return false;
1088
2018
  switch (message.type) {
1089
2019
  case "changes":
1090
2020
  return isSeq(message.seq) && isBatch(message.changes);
@@ -1104,6 +2034,19 @@ function isTabMessage<T>(value: unknown): value is TabMessage<T> {
1104
2034
  return typeof message.to === "string" && isSeq(message.seq);
1105
2035
  case "status":
1106
2036
  return isSeq(message.seq) && isPortableStatus(message.status);
2037
+ case "snapshot-request":
2038
+ return isSeq(message.request);
2039
+ case "snapshot":
2040
+ return (
2041
+ typeof message.to === "string" &&
2042
+ isSeq(message.request) &&
2043
+ isSeq(message.parts) &&
2044
+ message.parts >= 1 &&
2045
+ isSeq(message.part) &&
2046
+ message.part < message.parts &&
2047
+ isPairs(message.seqs, isSeq) &&
2048
+ isPairs(message.entries, () => true)
2049
+ );
1107
2050
  default:
1108
2051
  return false;
1109
2052
  }