@procedures/store 0.17.3 → 0.17.4

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.
@@ -0,0 +1,692 @@
1
+ /**
2
+ * The coordinator of the tabs of a browser (roadmap CLI-25, server stack D9).
3
+ *
4
+ * Each tab of a page has a local copy of a collection. Without a coordinator, each tab runs its own
5
+ * `HybridStorage`: each tab pulls, each tab pushes, and the tabs share one queue store. A
6
+ * `TabCoordinator` lets one leader tab run the sync, and the other tabs copy its changes.
7
+ *
8
+ * - The election uses the Web Locks API: the tab that holds the lock of the coordinator is the
9
+ * leader. When the leader closes, or the browser closes its tab, the lock goes to the next tab.
10
+ * - Only the leader makes a `HybridStorage`, with `capture: "observe"` on the local copy. The
11
+ * followers make no remote call, and they run no timer of a sync.
12
+ * - The tabs send the change batches of their local copies on a `BroadcastChannel`. A tab gives the
13
+ * batches of the other tabs to its observers, with their origins.
14
+ * - The leader captures the batches of the followers: its `HybridStorage` queues them and pushes
15
+ * them. Only the leader writes to the queue store, so the tabs can share it by its name.
16
+ * - A follower keeps each own batch until the leader acknowledges it. The leader acknowledges a
17
+ * batch when its queue store holds it. A new leader gets the batches that no leader
18
+ * acknowledged.
19
+ * - Without the Web Locks API or `BroadcastChannel`, the coordinator is in the single mode: the tab
20
+ * is the leader at once, and it opens no channel.
21
+ *
22
+ * The limits:
23
+ * - When the leader stops during a push, the next leader sends the operation again: the delivery to
24
+ * the remote is at least once.
25
+ * - With a shared local copy, a pull of the leader can come between the write of a follower and its
26
+ * capture. The tabs agree again after the push and the next pull.
27
+ * - The load of the queue in a new leader writes the queued operations to the local copy again, and
28
+ * the observers get them again with the origin `syncOrigin`.
29
+ *
30
+ * Nothing in this module reads a global value when it loads: Node can import it. The coordinator
31
+ * reads `navigator.locks` and `BroadcastChannel` when it starts, and a test gives its own.
32
+ */
33
+
34
+ import type {
35
+ CollectionStorage,
36
+ StorageChangeBatch,
37
+ StorageChangeEvent,
38
+ StorageObserver,
39
+ StorageWriteOptions,
40
+ } from "@procedures/collections";
41
+ import { StorageListeners, reportListenerError, type ListenerErrorHandler } from "@procedures/collections";
42
+ import { HybridStorage, type HybridStorageOptions, type LocalCopyStorage } from "./hybrid.js";
43
+
44
+ /** The signal that stops a lock request that waits. The `AbortSignal` of a browser fits it. */
45
+ export interface TabAbortSignalLike {
46
+ readonly aborted: boolean;
47
+ }
48
+
49
+ /**
50
+ * The part of the Web Locks API that the coordinator uses: `navigator.locks` of a browser. The
51
+ * lock is exclusive. The manager calls `callback` when the tab gets the lock, and it holds the lock
52
+ * until the promise of `callback` settles. A test gives a lock manager in memory.
53
+ */
54
+ export interface TabLockManagerLike {
55
+ request(name: string, options: { readonly signal?: TabAbortSignalLike }, callback: (lock: unknown) => Promise<void>): Promise<unknown>;
56
+ }
57
+
58
+ /**
59
+ * A channel to the other tabs of the same origin: a `BroadcastChannel` of a browser. A message goes
60
+ * to each other channel of the same name, as a structured clone. The channel does not get its own
61
+ * messages.
62
+ */
63
+ export interface TabChannelLike {
64
+ postMessage(message: unknown): void;
65
+ // The handler takes `any`: the handler of the DOM takes a `MessageEvent`
66
+ onmessage: ((event: any) => void) | null;
67
+ close(): void;
68
+ }
69
+
70
+ /** The function that opens a channel with a name. The default opens a `BroadcastChannel`. */
71
+ export type TabChannelFactory = (name: string) => TabChannelLike;
72
+
73
+ /**
74
+ * The role of a tab.
75
+ *
76
+ * - `follower`: the tab waits for the lock. It copies the changes of the other tabs, and it sends
77
+ * its own changes to the leader.
78
+ * - `leader`: the tab holds the lock, and its `HybridStorage` runs the sync.
79
+ * - `single`: the browser has no Web Locks API or no `BroadcastChannel`. The tab runs the sync
80
+ * alone, and it opens no channel.
81
+ * - `closed`: after `close()`.
82
+ */
83
+ export type TabRole = "follower" | "leader" | "single" | "closed";
84
+
85
+ /** The options of a `TabCoordinator`. */
86
+ export interface TabCoordinatorOptions<T> {
87
+ /**
88
+ * The name of the coordinator: one name for each collection, the same in each tab. The lock and
89
+ * the channel get it. Give a name for each tenant too.
90
+ */
91
+ name: string;
92
+
93
+ /** The local copy of this tab. It must have `observe()`. The application reads it and writes to it. */
94
+ local: LocalCopyStorage<T>;
95
+
96
+ /**
97
+ * The kind of the local copy.
98
+ *
99
+ * - `"shared"`: the tabs open the same durable local copy, for example an `IndexedDbStorage` of
100
+ * one database. A tab does not write the batches of another tab, because they are in the copy.
101
+ * - `"tab"`: each tab has its own local copy, for example a SignalDB collection or an
102
+ * `InMemoryStorage`. A tab writes the batches of the other tabs to its copy.
103
+ */
104
+ copy: "shared" | "tab";
105
+
106
+ /** The function that makes the remote of the `HybridStorage`. Only the leader calls it. */
107
+ remote: () => CollectionStorage<T>;
108
+
109
+ /**
110
+ * The options of the `HybridStorage` of the leader. The coordinator sets `local` and
111
+ * `capture: "observe"`. Give the same `queueStore` and `syncState` in each tab, for example an
112
+ * `IndexedDbQueueStore` of one database: a new leader loads the queue of the last leader.
113
+ */
114
+ hybrid?: Omit<HybridStorageOptions, "local" | "capture">;
115
+
116
+ /**
117
+ * The lock manager. `null` sets the single mode.
118
+ * @default `navigator.locks`, when the browser has it
119
+ */
120
+ locks?: TabLockManagerLike | null;
121
+
122
+ /**
123
+ * The function that opens the channel. `null` sets the single mode.
124
+ * @default a function that opens a `BroadcastChannel`, when the browser has it
125
+ */
126
+ channel?: TabChannelFactory | null;
127
+
128
+ /**
129
+ * The function that gets the errors: of the lock, of the channel, of a write to the local copy,
130
+ * of the `HybridStorage` and of the observers. The default throws the error again in a microtask.
131
+ */
132
+ onError?: ListenerErrorHandler;
133
+ }
134
+
135
+ /** A change batch of a tab, with its number. */
136
+ interface SentBatch<T> {
137
+ readonly seq: number;
138
+ readonly changes: StorageChangeBatch<T>;
139
+ }
140
+
141
+ /** The version of the messages. A tab ignores a message of another version. */
142
+ const PROTOCOL = 1;
143
+
144
+ /**
145
+ * A message of the channel.
146
+ *
147
+ * - `changes`: a change batch of the local copy of the tab `from`. The numbers of the batches of a
148
+ * tab increase by one.
149
+ * - `hello`: the tab `from` tells the leader its last number, and the batches that no leader
150
+ * acknowledged. A tab sends it when it starts, and when a new leader starts.
151
+ * - `leader`: the tab `from` is the new leader.
152
+ * - `ack`: the queue store of the leader holds the batches of the tab `to`, up to `seq`.
153
+ */
154
+ type TabMessage<T> =
155
+ | { readonly v: typeof PROTOCOL; readonly type: "changes"; readonly from: string; readonly seq: number; readonly changes: StorageChangeBatch<T> }
156
+ | { readonly v: typeof PROTOCOL; readonly type: "hello"; readonly from: string; readonly seq: number; readonly pending: ReadonlyArray<SentBatch<T>> }
157
+ | { readonly v: typeof PROTOCOL; readonly type: "leader"; readonly from: string }
158
+ | { readonly v: typeof PROTOCOL; readonly type: "ack"; readonly from: string; readonly to: string; readonly seq: number };
159
+
160
+ /**
161
+ * One leader tab runs the sync of a `HybridStorage`, and the other tabs copy its changes.
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * import { ApiStorage, IndexedDbQueueStore, IndexedDbStorage, IndexedDbSyncStateStore, TabCoordinator } from "@procedures/store/browser";
166
+ *
167
+ * const local = new IndexedDbStorage<User>("users");
168
+ * const users = new TabCoordinator<User>({
169
+ * name: "users",
170
+ * local,
171
+ * copy: "shared",
172
+ * remote: () => new ApiStorage(client, { collection: "users" }),
173
+ * hybrid: {
174
+ * mode: "write-back",
175
+ * queueStore: new IndexedDbQueueStore("users-queue"),
176
+ * syncState: new IndexedDbSyncStateStore("users-sync"),
177
+ * },
178
+ * });
179
+ *
180
+ * users.observe((batch) => render(batch)); // The changes of this tab and of the other tabs
181
+ * await local.set("1", { name: "Ada" }); // The leader pushes the write
182
+ * ```
183
+ */
184
+ export class TabCoordinator<T> {
185
+ /** The id of this tab: a new random id for each coordinator. */
186
+ readonly id: string;
187
+ /** The name of the lock and of the channel. */
188
+ readonly lockName: string;
189
+
190
+ private readonly local: LocalCopyStorage<T>;
191
+ private readonly shared: boolean;
192
+ private readonly makeRemote: () => CollectionStorage<T>;
193
+ private readonly hybridOptions: Omit<HybridStorageOptions, "local" | "capture">;
194
+ /** The origin of the `HybridStorage` and of the writes of the coordinator to the local copy. */
195
+ private readonly tag: string;
196
+ private readonly writeOptions: StorageWriteOptions;
197
+ private readonly onError: ListenerErrorHandler;
198
+ private readonly observers: StorageListeners<StorageChangeBatch<T>>;
199
+ private readonly roleListeners: StorageListeners<TabRole>;
200
+ /** The observers that the `HybridStorage` of the leader adds to its local copy: the capture */
201
+ private readonly captures: StorageListeners<StorageChangeBatch<T>>;
202
+ private currentRole: TabRole = "follower";
203
+ private channel: TabChannelLike | undefined;
204
+ private hybrid: HybridStorage<T> | undefined;
205
+ /** The stop of the observer of the changes of this tab: on the local copy, or on the `HybridStorage` of the leader */
206
+ private stopTap: (() => void) | undefined;
207
+ /** True while the leader gives a batch of another tab to the capture of its `HybridStorage` */
208
+ private capturing = false;
209
+ /** The number of the last batch of this tab */
210
+ private seq = 0;
211
+ /** The batches of this tab that no leader acknowledged, the oldest first */
212
+ private outbox: SentBatch<T>[] = [];
213
+ /** The number of the last batch of each other tab that this tab copied */
214
+ private readonly seen = new Map<string, number>();
215
+ /** In the leader: the number of the last batch of each tab that the leader captured. A tab without a hello is not here. */
216
+ private readonly captured = new Map<string, number>();
217
+ /** The batches of the other tabs: they reach the local copy and the observers in their order */
218
+ private incoming: Promise<void> = Promise.resolve();
219
+ private closed = false;
220
+ private closing: Promise<void> | undefined;
221
+ /** The abort of the lock request that waits */
222
+ private abortRequest: (() => void) | undefined;
223
+ /** The release of the lock */
224
+ private releaseLock: (() => void) | undefined;
225
+
226
+ constructor(options: TabCoordinatorOptions<T>) {
227
+ if (typeof options?.name !== "string" || options.name === "") throw new TypeError("TabCoordinator needs a name");
228
+ if (typeof options.local?.observe !== "function") throw new TypeError("TabCoordinator needs a local copy with observe()");
229
+ if (options.copy !== "shared" && options.copy !== "tab") {
230
+ throw new TypeError(`Unknown copy ${JSON.stringify(options.copy)}: use shared or tab`);
231
+ }
232
+ if (typeof options.remote !== "function") throw new TypeError("TabCoordinator needs the function remote");
233
+ this.id = randomId();
234
+ this.lockName = `@procedures/store/tab:${options.name}`;
235
+ this.local = options.local;
236
+ this.shared = options.copy === "shared";
237
+ this.makeRemote = options.remote;
238
+ this.hybridOptions = options.hybrid ?? {};
239
+ this.tag = this.hybridOptions.origin ?? `tab-${this.id}`;
240
+ this.writeOptions = { origin: this.tag };
241
+ this.onError = options.onError ?? reportListenerError;
242
+ this.observers = new StorageListeners<StorageChangeBatch<T>>(this.onError);
243
+ this.roleListeners = new StorageListeners<TabRole>(this.onError);
244
+ this.captures = new StorageListeners<StorageChangeBatch<T>>(this.onError);
245
+
246
+ const locks = options.locks === undefined ? globalLocks() : (options.locks ?? undefined);
247
+ const openChannel = options.channel === undefined ? globalChannelFactory() : (options.channel ?? undefined);
248
+ if (!locks || !openChannel) {
249
+ this.lead("single");
250
+ return;
251
+ }
252
+ try {
253
+ this.channel = openChannel(this.lockName);
254
+ } catch (error) {
255
+ this.onError(error);
256
+ this.lead("single");
257
+ return;
258
+ }
259
+ this.channel.onmessage = (event: { data?: unknown } | undefined) => this.receive(event?.data);
260
+ this.stopTap = this.local.observe!((batch) => this.onFollowerBatch(batch));
261
+ this.hello();
262
+ this.elect(locks);
263
+ }
264
+
265
+ /** The role of this tab now. */
266
+ get role(): TabRole {
267
+ return this.currentRole;
268
+ }
269
+
270
+ /** True when this tab runs the sync: the leader, or a tab in the single mode. */
271
+ get isLeader(): boolean {
272
+ return this.currentRole === "leader" || this.currentRole === "single";
273
+ }
274
+
275
+ /**
276
+ * The `HybridStorage` of the sync while this tab runs it, or else undefined. Its `getStatus()`
277
+ * gives the state of the sync. Write to the local copy, not to this storage: each tab then writes
278
+ * in the same way.
279
+ */
280
+ get syncStorage(): HybridStorage<T> | undefined {
281
+ return this.hybrid;
282
+ }
283
+
284
+ /**
285
+ * This method starts to send the change batches to an observer: the batches of the local copy of
286
+ * this tab, and the batches of the other tabs. Each batch comes one time, with the origin of each
287
+ * change. In the leader, the batches of the sync have the origin `syncOrigin` of the
288
+ * `HybridStorage`. A batch of another tab comes after the local copy of this tab has it.
289
+ *
290
+ * @returns The function that stops the batches. A second use of it does nothing.
291
+ * @throws Error after `close()`
292
+ */
293
+ observe(observer: StorageObserver<T>): () => void {
294
+ if (this.closed) throw new Error("The TabCoordinator is closed");
295
+ return this.observers.add(observer);
296
+ }
297
+
298
+ /**
299
+ * This method starts to send the role of this tab to a listener at each change.
300
+ *
301
+ * @returns The function that stops the roles. A second use of it does nothing.
302
+ * @throws Error after `close()`
303
+ */
304
+ onRole(listener: (role: TabRole) => void): () => void {
305
+ if (this.closed) throw new Error("The TabCoordinator is closed");
306
+ return this.roleListeners.add(listener);
307
+ }
308
+
309
+ /**
310
+ * This method closes the coordinator.
311
+ *
312
+ * - A follower stops its lock request. It does not become the leader.
313
+ * - The leader closes its `HybridStorage`: it sends the queue, and the queue store keeps the
314
+ * operations that the remote did not take. Then the leader releases the lock, and the next
315
+ * tab becomes the leader.
316
+ * - The coordinator closes its channel. It does not close the local copy: the application owns it.
317
+ * - After it, the coordinator sends and copies nothing. A second `close()` gives the promise of
318
+ * the first one.
319
+ */
320
+ close(): Promise<void> {
321
+ if (!this.closing) this.closing = this.shutDown();
322
+ return this.closing;
323
+ }
324
+
325
+ private async shutDown(): Promise<void> {
326
+ this.closed = true;
327
+ this.stopTap?.();
328
+ this.stopTap = undefined;
329
+ this.abortRequest?.();
330
+ this.abortRequest = undefined;
331
+ this.setRole("closed");
332
+ const hybrid = this.hybrid;
333
+ this.hybrid = undefined;
334
+ try {
335
+ // The leader holds the lock until its queue reached the remote or its queue store
336
+ if (hybrid) await hybrid.close();
337
+ } catch (error) {
338
+ this.onError(error);
339
+ } finally {
340
+ this.releaseLock?.();
341
+ this.releaseLock = undefined;
342
+ // The acknowledgements of the close of the HybridStorage went out before
343
+ try {
344
+ this.channel?.close();
345
+ } catch {
346
+ // The channel is closed already
347
+ }
348
+ this.channel = undefined;
349
+ this.observers.clear();
350
+ this.roleListeners.clear();
351
+ this.captures.clear();
352
+ }
353
+ }
354
+
355
+ //
356
+ // ═══ The election ═══
357
+ //
358
+
359
+ /** This method asks for the lock. The tab becomes the leader when it gets the lock. */
360
+ private elect(locks: TabLockManagerLike): void {
361
+ const Controller = globalAbortController();
362
+ const controller = Controller ? new Controller() : undefined;
363
+ this.abortRequest = controller ? () => controller.abort() : undefined;
364
+ const held = new Promise<void>((resolve) => (this.releaseLock = resolve));
365
+ let request: Promise<unknown>;
366
+ try {
367
+ request = locks.request(this.lockName, controller ? { signal: controller.signal } : {}, async () => {
368
+ // A lock after close(): the tab releases it at once
369
+ if (this.closed) return;
370
+ this.abortRequest = undefined;
371
+ // A HybridStorage that cannot start releases the lock: another tab tries
372
+ if (!this.lead("leader")) return;
373
+ await held;
374
+ });
375
+ } catch (error) {
376
+ request = Promise.reject(error);
377
+ }
378
+ request.then(undefined, (error: unknown) => {
379
+ // The abort of close(), or a tab that leads already
380
+ if (this.closed || this.hybrid) return;
381
+ // The lock manager refused the request: this tab runs the sync alone
382
+ this.onError(error);
383
+ this.stopChannel();
384
+ this.lead("single");
385
+ });
386
+ }
387
+
388
+ /**
389
+ * This method makes the `HybridStorage` of the sync: the tab becomes the leader, or the single
390
+ * tab. The capture of the storage gets the batches of this tab that no leader acknowledged.
391
+ *
392
+ * @returns False when the storage could not start
393
+ */
394
+ private lead(role: "leader" | "single"): boolean {
395
+ if (this.closed) return false;
396
+ let hybrid: HybridStorage<T>;
397
+ try {
398
+ hybrid = new HybridStorage<T>(this.makeRemote(), {
399
+ ...this.hybridOptions,
400
+ local: this.captureCopy(),
401
+ capture: "observe",
402
+ origin: this.tag,
403
+ });
404
+ } catch (error) {
405
+ this.onError(error);
406
+ return false;
407
+ }
408
+ this.hybrid = hybrid;
409
+ // The new observer starts before the old one stops: no change of the local copy goes between them
410
+ const stopFollower = this.stopTap;
411
+ this.stopTap = hybrid.observe((batch) => this.onLeaderBatch(batch));
412
+ stopFollower?.();
413
+ for (const batch of this.outbox.splice(0)) this.capture(batch.changes);
414
+ this.setRole(role);
415
+ if (role === "leader") this.post({ v: PROTOCOL, type: "leader", from: this.id });
416
+ return true;
417
+ }
418
+
419
+ /**
420
+ * The local copy for the `HybridStorage` of the leader. Its `observe()` gives the changes of the
421
+ * local copy and the batches of the other tabs, so the capture queues both. Its `close()` does
422
+ * nothing: the application owns the local copy.
423
+ */
424
+ private captureCopy(): LocalCopyStorage<T> {
425
+ const captures = this.captures;
426
+ const observe = (observer: StorageObserver<T>): (() => void) => {
427
+ const stopLocal = this.local.observe!(observer);
428
+ const stopCapture = captures.add(observer);
429
+ return () => {
430
+ stopLocal();
431
+ stopCapture();
432
+ };
433
+ };
434
+ return new Proxy(this.local, {
435
+ get(target, property) {
436
+ if (property === "observe") return observe;
437
+ if (property === "close") return () => Promise.resolve();
438
+ const value: unknown = Reflect.get(target, property, target);
439
+ return typeof value === "function" ? (value as (...args: unknown[]) => unknown).bind(target) : value;
440
+ },
441
+ });
442
+ }
443
+
444
+ /**
445
+ * This method gives a batch of another tab to the capture of the `HybridStorage` of the leader.
446
+ * The storage queues it. Its observers get it at once, and the coordinator ignores it there: the
447
+ * observers of the coordinator get it from the channel.
448
+ */
449
+ private capture(changes: StorageChangeBatch<T>): void {
450
+ if (changes.length === 0) return;
451
+ this.capturing = true;
452
+ try {
453
+ this.captures.emit(changes);
454
+ } finally {
455
+ this.capturing = false;
456
+ }
457
+ }
458
+
459
+ private setRole(role: TabRole): void {
460
+ if (this.currentRole === role) return;
461
+ this.currentRole = role;
462
+ this.roleListeners.emit(role);
463
+ }
464
+
465
+ //
466
+ // ═══ The batches of this tab ═══
467
+ //
468
+
469
+ /**
470
+ * A batch of the local copy of a follower. The writes of the coordinator itself (the batches of
471
+ * the other tabs in a copy of each tab) have the tag of the coordinator, and do not go out again.
472
+ */
473
+ private onFollowerBatch(batch: StorageChangeBatch<T>): void {
474
+ if (this.closed) return;
475
+ const own = batch.filter((change) => change.origin !== this.tag);
476
+ if (own.length > 0) this.share(own, true);
477
+ }
478
+
479
+ /**
480
+ * A batch of the `HybridStorage` of the leader: a write of the application to the local copy,
481
+ * a write to the storage, or a change of the sync.
482
+ */
483
+ private onLeaderBatch(batch: StorageChangeBatch<T>): void {
484
+ if (this.closed || this.capturing) return;
485
+ this.share(batch, false);
486
+ }
487
+
488
+ /** This method gives a batch of this tab to its observers and to the other tabs. A follower keeps it for the leader. */
489
+ private share(changes: StorageChangeBatch<T>, keep: boolean): void {
490
+ const seq = ++this.seq;
491
+ this.observers.emit(changes);
492
+ const sent = this.post({ v: PROTOCOL, type: "changes", from: this.id, seq, changes });
493
+ if (sent && keep) this.outbox.push({ seq, changes });
494
+ }
495
+
496
+ /** This method tells the leader the batches of this tab that no leader acknowledged. */
497
+ private hello(): void {
498
+ this.post({ v: PROTOCOL, type: "hello", from: this.id, seq: this.seq, pending: this.outbox });
499
+ }
500
+
501
+ /** @returns False when the message did not go out: no channel, or a value that the channel cannot clone */
502
+ private post(message: TabMessage<T>): boolean {
503
+ const channel = this.channel;
504
+ if (!channel) return false;
505
+ try {
506
+ channel.postMessage(message);
507
+ return true;
508
+ } catch (error) {
509
+ this.onError(error);
510
+ return false;
511
+ }
512
+ }
513
+
514
+ private stopChannel(): void {
515
+ const channel = this.channel;
516
+ this.channel = undefined;
517
+ if (!channel) return;
518
+ channel.onmessage = null;
519
+ try {
520
+ channel.close();
521
+ } catch {
522
+ // The channel is closed already
523
+ }
524
+ }
525
+
526
+ //
527
+ // ═══ The batches of the other tabs ═══
528
+ //
529
+
530
+ private receive(data: unknown): void {
531
+ if (this.closed || !isTabMessage<T>(data) || data.from === this.id) return;
532
+ switch (data.type) {
533
+ case "changes":
534
+ this.onChanges(data.from, { seq: data.seq, changes: data.changes });
535
+ break;
536
+ case "hello":
537
+ this.onHello(data.from, data.seq, data.pending);
538
+ break;
539
+ case "leader":
540
+ // The new leader gets the batches that no leader acknowledged
541
+ if (this.currentRole === "follower") this.hello();
542
+ break;
543
+ case "ack":
544
+ if (data.to === this.id) this.outbox = this.outbox.filter((batch) => batch.seq > data.seq);
545
+ break;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * A batch of another tab. The leader captures it when the tab said hello to it. Each tab copies
551
+ * it one time.
552
+ */
553
+ private onChanges(from: string, batch: SentBatch<T>): void {
554
+ const known = this.captured.get(from);
555
+ if (this.hybrid && this.currentRole === "leader" && known !== undefined && batch.seq > known) {
556
+ this.capture(batch.changes);
557
+ this.captured.set(from, batch.seq);
558
+ this.acknowledge(from, batch.seq);
559
+ }
560
+ this.copy(from, batch);
561
+ }
562
+
563
+ /**
564
+ * The hello of another tab. The leader captures the batches that it did not capture, and then each
565
+ * batch of the tab after `seq`. Each tab copies the batches that it did not see.
566
+ */
567
+ private onHello(from: string, seq: number, pending: ReadonlyArray<SentBatch<T>>): void {
568
+ if (this.hybrid && this.currentRole === "leader") {
569
+ const known = this.captured.get(from);
570
+ for (const batch of pending) if (known === undefined || batch.seq > known) this.capture(batch.changes);
571
+ this.captured.set(from, Math.max(known ?? 0, seq));
572
+ if (pending.length > 0) this.acknowledge(from, seq);
573
+ }
574
+ for (const batch of pending) this.copy(from, batch);
575
+ }
576
+
577
+ /**
578
+ * This method copies a batch of another tab that this tab did not see: to the local copy of a
579
+ * tab, and then to the observers. The batches go in their order.
580
+ */
581
+ private copy(from: string, batch: SentBatch<T>): void {
582
+ if (batch.seq <= (this.seen.get(from) ?? 0)) return;
583
+ this.seen.set(from, batch.seq);
584
+ this.incoming = this.incoming
585
+ .then(async () => {
586
+ if (this.closed) return;
587
+ if (!this.shared) await this.write(batch.changes);
588
+ if (!this.closed) this.observers.emit(batch.changes);
589
+ })
590
+ .catch((error: unknown) => this.onError(error));
591
+ }
592
+
593
+ /**
594
+ * This method writes a batch of another tab to the local copy of this tab, with the tag of the
595
+ * coordinator: the observer of this tab and the capture of the leader ignore the writes. The sets
596
+ * in a row go in one `setBatch`, and the deletes in a row in one `deleteBatch`.
597
+ */
598
+ private async write(changes: StorageChangeBatch<T>): Promise<void> {
599
+ let index = 0;
600
+ while (index < changes.length) {
601
+ const change = changes[index]!;
602
+ if (change.type === "clear") {
603
+ await this.local.clear(this.writeOptions);
604
+ index++;
605
+ } else if (change.type === "set") {
606
+ const items: Array<[string, T]> = [];
607
+ for (let next = changes[index]; next?.type === "set"; next = changes[++index]) items.push([next.id, next.value]);
608
+ await this.local.setBatch(items, this.writeOptions);
609
+ } else {
610
+ const ids: string[] = [];
611
+ for (let next = changes[index]; next?.type === "delete"; next = changes[++index]) ids.push(next.id);
612
+ await this.local.deleteBatch(ids, this.writeOptions);
613
+ }
614
+ }
615
+ }
616
+
617
+ /** This method tells a tab that the queue store of the leader holds its batches up to `seq`. */
618
+ private acknowledge(to: string, seq: number): void {
619
+ const hybrid = this.hybrid;
620
+ if (!hybrid) return;
621
+ hybrid.whenQueued().then(
622
+ () => void this.post({ v: PROTOCOL, type: "ack", from: this.id, to, seq }),
623
+ (error: unknown) => this.onError(error),
624
+ );
625
+ }
626
+ }
627
+
628
+ /** A new random id. */
629
+ function randomId(): string {
630
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
631
+ }
632
+
633
+ /** `navigator.locks` of the global scope, or undefined. */
634
+ function globalLocks(): TabLockManagerLike | undefined {
635
+ return (globalThis as { navigator?: { locks?: TabLockManagerLike } }).navigator?.locks ?? undefined;
636
+ }
637
+
638
+ /** A function that opens a `BroadcastChannel` of the global scope, or undefined. */
639
+ function globalChannelFactory(): TabChannelFactory | undefined {
640
+ const Channel = (globalThis as { BroadcastChannel?: new (name: string) => TabChannelLike }).BroadcastChannel;
641
+ return typeof Channel === "function" ? (name) => new Channel(name) : undefined;
642
+ }
643
+
644
+ /** The `AbortController` of the global scope, or undefined. */
645
+ function globalAbortController(): (new () => { readonly signal: TabAbortSignalLike; abort(): void }) | undefined {
646
+ const Controller = (globalThis as { AbortController?: new () => { readonly signal: TabAbortSignalLike; abort(): void } }).AbortController;
647
+ return typeof Controller === "function" ? Controller : undefined;
648
+ }
649
+
650
+ function isSeq(value: unknown): value is number {
651
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
652
+ }
653
+
654
+ /** True when a value is a change batch. The value of a message of another tab can be anything. */
655
+ function isBatch(value: unknown): value is StorageChangeEvent<unknown>[] {
656
+ if (!Array.isArray(value)) return false;
657
+ return value.every((change: unknown) => {
658
+ if (typeof change !== "object" || change === null) return false;
659
+ const { type, id, origin } = change as { type?: unknown; id?: unknown; origin?: unknown };
660
+ if (origin !== undefined && typeof origin !== "string") return false;
661
+ if (type === "clear") return true;
662
+ if (typeof id !== "string") return false;
663
+ return type === "delete" || (type === "set" && "value" in change);
664
+ });
665
+ }
666
+
667
+ /** True when a value is a message of the channel, of this version. */
668
+ function isTabMessage<T>(value: unknown): value is TabMessage<T> {
669
+ if (typeof value !== "object" || value === null) return false;
670
+ const message = value as { v?: unknown; type?: unknown; from?: unknown; to?: unknown; seq?: unknown; changes?: unknown; pending?: unknown };
671
+ if (message.v !== PROTOCOL || typeof message.from !== "string") return false;
672
+ switch (message.type) {
673
+ case "changes":
674
+ return isSeq(message.seq) && isBatch(message.changes);
675
+ case "hello":
676
+ return (
677
+ isSeq(message.seq) &&
678
+ Array.isArray(message.pending) &&
679
+ message.pending.every((batch: unknown) => {
680
+ if (typeof batch !== "object" || batch === null) return false;
681
+ const { seq, changes } = batch as { seq?: unknown; changes?: unknown };
682
+ return isSeq(seq) && isBatch(changes);
683
+ })
684
+ );
685
+ case "leader":
686
+ return true;
687
+ case "ack":
688
+ return typeof message.to === "string" && isSeq(message.seq);
689
+ default:
690
+ return false;
691
+ }
692
+ }