@procedures/store 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,12 @@
18
18
  * acknowledged.
19
19
  * - Without the Web Locks API or `BroadcastChannel`, the coordinator is in the single mode: the tab
20
20
  * is the leader at once, and it opens no channel.
21
+ * - A tab becomes the leader only after its `HybridStorage` loaded the queue. When the setup fails
22
+ * (the function `remote` throws, or the load of the queue fails), the tab reports the error and
23
+ * releases the lock. It stays a follower, and it asks for the lock again after a backoff (roadmap
24
+ * CLI-67). Thus a failure that goes away does not leave the browser without a sync.
25
+ * - The leader sends the status of its sync to the followers: at each change, and in answer to a
26
+ * hello. `getStatus()` and `onStatus()` give the status in each tab (roadmap CLI-65).
21
27
  *
22
28
  * The limits:
23
29
  * - When the leader stops during a push, the next leader sends the operation again: the delivery to
@@ -31,8 +37,15 @@
31
37
  * reads `navigator.locks` and `BroadcastChannel` when it starts, and a test gives its own.
32
38
  */
33
39
  import { StorageListeners, reportListenerError } from "@procedures/collections";
40
+ import { optionalMs, realClock } from "@procedures/client/browser";
41
+ import { backoffDelay } from "@mark1russell7/service/backoff";
34
42
  import { HybridStorage } from "./hybrid.js";
35
- /** The version of the messages. A tab ignores a message of another version. */
43
+ /**
44
+ * The version of the messages. A tab ignores a message of another version, and a message of a type
45
+ * that it does not know. Thus a new type of message (`status`, roadmap CLI-65) keeps the version 1:
46
+ * a tab of an older release ignores it, and it still copies and sends the batches. A new version
47
+ * stops the batches between the tabs of two releases, so only a change of a known message needs it.
48
+ */
36
49
  const PROTOCOL = 1;
37
50
  /**
38
51
  * One leader tab runs the sync of a `HybridStorage`, and the other tabs copy its changes.
@@ -55,6 +68,7 @@ const PROTOCOL = 1;
55
68
  * });
56
69
  *
57
70
  * users.observe((batch) => render(batch)); // The changes of this tab and of the other tabs
71
+ * users.onStatus((status) => showSync(status)); // The status of the sync of the leader, in each tab
58
72
  * await local.set("1", { name: "Ada" }); // The leader pushes the write
59
73
  * ```
60
74
  */
@@ -73,11 +87,31 @@ export class TabCoordinator {
73
87
  onError;
74
88
  observers;
75
89
  roleListeners;
90
+ statusListeners;
76
91
  /** The observers that the `HybridStorage` of the leader adds to its local copy: the capture */
77
92
  captures;
93
+ /** The backoff of a new try after a failed setup, its clock and its random numbers */
94
+ backoff;
95
+ clock;
96
+ random;
78
97
  currentRole = "follower";
98
+ /** The lock manager, or undefined in the single mode */
99
+ locks;
79
100
  channel;
80
101
  hybrid;
102
+ /** The storage of this tab while it loads its queue, before the tab leads */
103
+ candidate;
104
+ /** The number of the failed setups in a row */
105
+ failures = 0;
106
+ /** The stop of the wait before the next try */
107
+ cancelRetry;
108
+ /** The status of the sync: of the `HybridStorage` of this tab, or of the leader */
109
+ status;
110
+ /** In the leader: the number of its last status. In a follower: the tab and the number of the last status that it got. */
111
+ statusSeq = 0;
112
+ statusFrom;
113
+ /** The stop of the status of the `HybridStorage` of this tab */
114
+ stopStatus;
81
115
  /** The stop of the observer of the changes of this tab: on the local copy, or on the `HybridStorage` of the leader */
82
116
  stopTap;
83
117
  /** True while the leader gives a batch of another tab to the capture of its `HybridStorage` */
@@ -108,6 +142,7 @@ export class TabCoordinator {
108
142
  }
109
143
  if (typeof options.remote !== "function")
110
144
  throw new TypeError("TabCoordinator needs the function remote");
145
+ this.backoff = retryBackoff(options.retry);
111
146
  this.id = randomId();
112
147
  this.lockName = `@procedures/store/tab:${options.name}`;
113
148
  this.local = options.local;
@@ -119,11 +154,16 @@ export class TabCoordinator {
119
154
  this.onError = options.onError ?? reportListenerError;
120
155
  this.observers = new StorageListeners(this.onError);
121
156
  this.roleListeners = new StorageListeners(this.onError);
157
+ this.statusListeners = new StorageListeners(this.onError);
122
158
  this.captures = new StorageListeners(this.onError);
159
+ this.clock = options.clock ?? this.hybridOptions.clock ?? realClock;
160
+ this.random = options.random ?? this.hybridOptions.random ?? Math.random;
161
+ // The observer of a follower. A tab that leads stops it. A single tab whose setup failed keeps its batches with it.
162
+ this.stopTap = this.local.observe((batch) => this.onFollowerBatch(batch));
123
163
  const locks = options.locks === undefined ? globalLocks() : (options.locks ?? undefined);
124
164
  const openChannel = options.channel === undefined ? globalChannelFactory() : (options.channel ?? undefined);
125
165
  if (!locks || !openChannel) {
126
- this.lead("single");
166
+ this.startSingle();
127
167
  return;
128
168
  }
129
169
  try {
@@ -131,11 +171,11 @@ export class TabCoordinator {
131
171
  }
132
172
  catch (error) {
133
173
  this.onError(error);
134
- this.lead("single");
174
+ this.startSingle();
135
175
  return;
136
176
  }
177
+ this.locks = locks;
137
178
  this.channel.onmessage = (event) => this.receive(event?.data);
138
- this.stopTap = this.local.observe((batch) => this.onFollowerBatch(batch));
139
179
  this.hello();
140
180
  this.elect(locks);
141
181
  }
@@ -148,9 +188,9 @@ export class TabCoordinator {
148
188
  return this.currentRole === "leader" || this.currentRole === "single";
149
189
  }
150
190
  /**
151
- * The `HybridStorage` of the sync while this tab runs it, or else undefined. Its `getStatus()`
152
- * gives the state of the sync. Write to the local copy, not to this storage: each tab then writes
153
- * in the same way.
191
+ * The `HybridStorage` of the sync while this tab runs it, or else undefined. `getStatus()` of the
192
+ * coordinator gives the state of the sync in each tab. Write to the local copy, not to this
193
+ * storage: each tab then writes in the same way.
154
194
  */
155
195
  get syncStorage() {
156
196
  return this.hybrid;
@@ -180,10 +220,39 @@ export class TabCoordinator {
180
220
  throw new Error("The TabCoordinator is closed");
181
221
  return this.roleListeners.add(listener);
182
222
  }
223
+ /**
224
+ * The status of the sync (roadmap CLI-65), or undefined before the first status and after
225
+ * `close()`.
226
+ *
227
+ * - In the leader and in the single mode, it is the status of the `HybridStorage` of this tab.
228
+ * - In a follower, it is the last status that the leader sent. A new tab gets it in the answer to
229
+ * its hello. After a change of the leader, a follower keeps the last status until the new
230
+ * leader sends its status.
231
+ * - In a follower, `error.error` is a copy of the error of the leader: an `Error` with its `name`,
232
+ * its `message` and its `code`. The copy has no `cause`.
233
+ */
234
+ getStatus() {
235
+ return this.status;
236
+ }
237
+ /**
238
+ * This method starts to send the status of the sync to a listener at each change (roadmap
239
+ * CLI-65): in the leader, each status of its `HybridStorage`, and in a follower, each status that
240
+ * the leader sends. The listener gets each status one time. When the tab becomes the leader, the
241
+ * listener gets the status of its own `HybridStorage`.
242
+ *
243
+ * @returns The function that stops the statuses. A second use of it does nothing.
244
+ * @throws Error after `close()`
245
+ */
246
+ onStatus(listener) {
247
+ if (this.closed)
248
+ throw new Error("The TabCoordinator is closed");
249
+ return this.statusListeners.add(listener);
250
+ }
183
251
  /**
184
252
  * This method closes the coordinator.
185
253
  *
186
- * - A follower stops its lock request. It does not become the leader.
254
+ * - A follower stops its lock request, or its wait before a new try. It does not become the leader.
255
+ * - A tab that loads its queue to become the leader closes its `HybridStorage`, and releases the lock.
187
256
  * - The leader closes its `HybridStorage`: it sends the queue, and the queue store keeps the
188
257
  * operations that the remote did not take. Then the leader releases the lock, and the next
189
258
  * tab becomes the leader.
@@ -202,9 +271,17 @@ export class TabCoordinator {
202
271
  this.stopTap = undefined;
203
272
  this.abortRequest?.();
204
273
  this.abortRequest = undefined;
274
+ this.cancelRetry?.();
275
+ this.cancelRetry = undefined;
276
+ this.stopStatus?.();
277
+ this.stopStatus = undefined;
205
278
  this.setRole("closed");
206
- const hybrid = this.hybrid;
279
+ const candidate = this.candidate;
280
+ this.candidate = undefined;
281
+ candidate?.stop();
282
+ const hybrid = this.hybrid ?? candidate?.hybrid;
207
283
  this.hybrid = undefined;
284
+ this.status = undefined;
208
285
  try {
209
286
  // The leader holds the lock until its queue reached the remote or its queue store
210
287
  if (hybrid)
@@ -226,6 +303,7 @@ export class TabCoordinator {
226
303
  this.channel = undefined;
227
304
  this.observers.clear();
228
305
  this.roleListeners.clear();
306
+ this.statusListeners.clear();
229
307
  this.captures.clear();
230
308
  }
231
309
  }
@@ -245,10 +323,12 @@ export class TabCoordinator {
245
323
  if (this.closed)
246
324
  return;
247
325
  this.abortRequest = undefined;
248
- // A HybridStorage that cannot start releases the lock: another tab tries
249
- if (!this.lead("leader"))
326
+ if (await this.takeLead()) {
327
+ await held;
250
328
  return;
251
- await held;
329
+ }
330
+ // A failed setup releases the lock: another tab tries, and this tab tries again after the backoff
331
+ this.retryLater();
252
332
  });
253
333
  }
254
334
  catch (error) {
@@ -261,52 +341,153 @@ export class TabCoordinator {
261
341
  // The lock manager refused the request: this tab runs the sync alone
262
342
  this.onError(error);
263
343
  this.stopChannel();
264
- this.lead("single");
344
+ this.locks = undefined;
345
+ this.startSingle();
265
346
  });
266
347
  }
267
348
  /**
268
- * This method makes the `HybridStorage` of the sync: the tab becomes the leader, or the single
269
- * tab. The capture of the storage gets the batches of this tab that no leader acknowledged.
349
+ * This method makes the `HybridStorage` of a tab that got the lock, and waits for the load of its
350
+ * queue. Then the tab becomes the leader. Until then, the tab is a follower: its own batches go to
351
+ * the other tabs and to its outbox, and the capture of the storage ignores the local copy.
270
352
  *
271
- * @returns False when the storage could not start
353
+ * @returns False when the setup failed (the error went to `onError`), or after `close()`
272
354
  */
273
- lead(role) {
274
- if (this.closed)
355
+ async takeLead() {
356
+ const candidate = this.makeCandidate();
357
+ if (!candidate)
275
358
  return false;
359
+ this.candidate = candidate;
360
+ let failed = false;
361
+ let failure;
362
+ try {
363
+ // A storage whose load failed refuses each operation: it cannot lead
364
+ await candidate.hybrid.whenQueued();
365
+ }
366
+ catch (error) {
367
+ failed = true;
368
+ failure = error;
369
+ }
370
+ // close() took the storage
371
+ if (this.candidate !== candidate)
372
+ return false;
373
+ this.candidate = undefined;
374
+ if (!failed) {
375
+ this.promote(candidate, "leader");
376
+ return true;
377
+ }
378
+ this.onError(failure);
379
+ candidate.stop();
380
+ try {
381
+ await candidate.hybrid.close();
382
+ }
383
+ catch (error) {
384
+ this.onError(error);
385
+ }
386
+ return false;
387
+ }
388
+ /** This method makes the `HybridStorage` of the single mode. The tab tries again after the backoff when it fails. */
389
+ startSingle() {
390
+ const candidate = this.makeCandidate();
391
+ if (candidate)
392
+ this.promote(candidate, "single");
393
+ else
394
+ this.retryLater();
395
+ }
396
+ /**
397
+ * This method makes the `HybridStorage` of the sync, with its observer. Its capture ignores the
398
+ * local copy until the tab leads.
399
+ *
400
+ * @returns Undefined when the remote or the storage could not start (the error went to `onError`), or after `close()`
401
+ */
402
+ makeCandidate() {
403
+ if (this.closed)
404
+ return undefined;
405
+ let remote;
406
+ try {
407
+ remote = this.makeRemote();
408
+ }
409
+ catch (error) {
410
+ this.onError(error);
411
+ return undefined;
412
+ }
413
+ const gate = { open: false };
276
414
  let hybrid;
277
415
  try {
278
- hybrid = new HybridStorage(this.makeRemote(), {
416
+ hybrid = new HybridStorage(remote, {
279
417
  ...this.hybridOptions,
280
- local: this.captureCopy(),
418
+ local: this.captureCopy(gate),
281
419
  capture: "observe",
282
420
  origin: this.tag,
283
421
  });
284
422
  }
285
423
  catch (error) {
286
424
  this.onError(error);
287
- return false;
425
+ // The remote of a storage that did not start stays open otherwise
426
+ Promise.resolve()
427
+ .then(() => remote.close())
428
+ .catch((closeError) => this.onError(closeError));
429
+ return undefined;
288
430
  }
431
+ // The observer starts at once: the other tabs get the changes of the load and of the first sync
432
+ const stop = hybrid.observe((batch) => this.onLeaderBatch(batch));
433
+ return { hybrid, gate, stop };
434
+ }
435
+ /**
436
+ * This method makes this tab the leader, or the single tab, with the storage of a candidate. The
437
+ * capture of the storage gets the batches of this tab that no leader acknowledged.
438
+ */
439
+ promote(candidate, role) {
440
+ const { hybrid, gate, stop } = candidate;
289
441
  this.hybrid = hybrid;
290
- // The new observer starts before the old one stops: no change of the local copy goes between them
442
+ this.failures = 0;
443
+ // The capture starts and the observer of the follower stops in one step: no change of the local copy goes between them
444
+ gate.open = true;
291
445
  const stopFollower = this.stopTap;
292
- this.stopTap = hybrid.observe((batch) => this.onLeaderBatch(batch));
446
+ this.stopTap = stop;
293
447
  stopFollower?.();
294
448
  for (const batch of this.outbox.splice(0))
295
449
  this.capture(batch.changes);
296
450
  this.setRole(role);
297
451
  if (role === "leader")
298
452
  this.post({ v: PROTOCOL, type: "leader", from: this.id });
299
- return true;
453
+ // 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.
454
+ this.status = undefined;
455
+ this.statusFrom = undefined;
456
+ this.stopStatus = hybrid.onStatus((status) => this.onOwnStatus(hybrid, status));
457
+ this.onOwnStatus(hybrid, hybrid.getStatus());
458
+ }
459
+ /** This method asks for the lock again, or tries the single mode again, after the backoff (roadmap CLI-67). */
460
+ retryLater() {
461
+ if (this.closed)
462
+ return;
463
+ this.failures++;
464
+ const wait = backoffDelay(this.backoff, this.failures, this.random);
465
+ const cancel = this.clock.after(wait, () => {
466
+ this.cancelRetry = undefined;
467
+ if (this.closed)
468
+ return;
469
+ if (this.locks)
470
+ this.elect(this.locks);
471
+ else
472
+ this.startSingle();
473
+ });
474
+ // The wait must not keep a Node process alive
475
+ cancel.unref?.();
476
+ this.cancelRetry = cancel;
300
477
  }
301
478
  /**
302
479
  * The local copy for the `HybridStorage` of the leader. Its `observe()` gives the changes of the
303
- * local copy and the batches of the other tabs, so the capture queues both. Its `close()` does
304
- * nothing: the application owns the local copy.
480
+ * local copy and the batches of the other tabs, so the capture queues both. It gives the changes
481
+ * of the local copy only when `gate` is open. Its `close()` does nothing: the application owns
482
+ * the local copy.
305
483
  */
306
- captureCopy() {
484
+ captureCopy(gate) {
307
485
  const captures = this.captures;
308
486
  const observe = (observer) => {
309
- const stopLocal = this.local.observe(observer);
487
+ const stopLocal = this.local.observe((batch) => {
488
+ if (gate.open)
489
+ observer(batch);
490
+ });
310
491
  const stopCapture = captures.add(observer);
311
492
  return () => {
312
493
  stopLocal();
@@ -347,6 +528,39 @@ export class TabCoordinator {
347
528
  this.roleListeners.emit(role);
348
529
  }
349
530
  //
531
+ // ═══ The status of the sync (roadmap CLI-65) ═══
532
+ //
533
+ /** A status of the `HybridStorage` of this tab. The leader sends each new status to the followers. */
534
+ onOwnStatus(hybrid, status) {
535
+ if (this.closed || this.hybrid !== hybrid)
536
+ return;
537
+ if (this.status !== undefined && sameStatus(this.status, status))
538
+ return;
539
+ this.status = status;
540
+ this.statusSeq++;
541
+ if (this.currentRole === "leader")
542
+ this.postStatus();
543
+ this.statusListeners.emit(status);
544
+ }
545
+ /** This method sends the last status of the leader, with its number. */
546
+ postStatus() {
547
+ if (this.status === undefined)
548
+ return;
549
+ this.post({ v: PROTOCOL, type: "status", from: this.id, seq: this.statusSeq, status: portableStatus(this.status) });
550
+ }
551
+ /** A status of the leader. A tab that runs the sync keeps its own status. */
552
+ onLeaderStatus(from, seq, status) {
553
+ if (this.hybrid)
554
+ return;
555
+ // The answer to a hello sends the last status again
556
+ if (from === this.statusFrom && seq <= this.statusSeq)
557
+ return;
558
+ this.statusFrom = from;
559
+ this.statusSeq = seq;
560
+ this.status = statusOf(status);
561
+ this.statusListeners.emit(this.status);
562
+ }
563
+ //
350
564
  // ═══ The batches of this tab ═══
351
565
  //
352
566
  /**
@@ -369,12 +583,15 @@ export class TabCoordinator {
369
583
  return;
370
584
  this.share(batch, false);
371
585
  }
372
- /** This method gives a batch of this tab to its observers and to the other tabs. A follower keeps it for the leader. */
586
+ /**
587
+ * This method gives a batch of this tab to its observers and to the other tabs. A follower keeps
588
+ * it for the leader. A single tab whose setup failed keeps it for its next try.
589
+ */
373
590
  share(changes, keep) {
374
591
  const seq = ++this.seq;
375
592
  this.observers.emit(changes);
376
593
  const sent = this.post({ v: PROTOCOL, type: "changes", from: this.id, seq, changes });
377
- if (sent && keep)
594
+ if (keep && (sent || !this.channel))
378
595
  this.outbox.push({ seq, changes });
379
596
  }
380
597
  /** This method tells the leader the batches of this tab that no leader acknowledged. */
@@ -430,6 +647,9 @@ export class TabCoordinator {
430
647
  if (data.to === this.id)
431
648
  this.outbox = this.outbox.filter((batch) => batch.seq > data.seq);
432
649
  break;
650
+ case "status":
651
+ this.onLeaderStatus(data.from, data.seq, data.status);
652
+ break;
433
653
  }
434
654
  }
435
655
  /**
@@ -447,7 +667,8 @@ export class TabCoordinator {
447
667
  }
448
668
  /**
449
669
  * The hello of another tab. The leader captures the batches that it did not capture, and then each
450
- * batch of the tab after `seq`. Each tab copies the batches that it did not see.
670
+ * batch of the tab after `seq`. It answers with its status. Each tab copies the batches that it
671
+ * did not see.
451
672
  */
452
673
  onHello(from, seq, pending) {
453
674
  if (this.hybrid && this.currentRole === "leader") {
@@ -458,6 +679,8 @@ export class TabCoordinator {
458
679
  this.captured.set(from, Math.max(known ?? 0, seq));
459
680
  if (pending.length > 0)
460
681
  this.acknowledge(from, seq);
682
+ // A new tab gets the status now. The other tabs have its number already.
683
+ this.postStatus();
461
684
  }
462
685
  for (const batch of pending)
463
686
  this.copy(from, batch);
@@ -516,6 +739,120 @@ export class TabCoordinator {
516
739
  hybrid.whenQueued().then(() => void this.post({ v: PROTOCOL, type: "ack", from: this.id, to, seq }), (error) => this.onError(error));
517
740
  }
518
741
  }
742
+ /**
743
+ * The backoff of the option `retry`.
744
+ *
745
+ * @throws RangeError for a `delay` of zero or less, a `factor` below 1, or a `jitter` outside 0 to 1
746
+ * @throws TypeError for a time that is not a `Duration`
747
+ */
748
+ function retryBackoff(retry) {
749
+ const delay = optionalMs(retry?.delay, "retry.delay") ?? 1000;
750
+ const maxDelay = optionalMs(retry?.maxDelay, "retry.maxDelay") ?? 60_000;
751
+ const factor = retry?.factor ?? 2;
752
+ const jitter = retry?.jitter ?? 0.5;
753
+ if (!(delay > 0))
754
+ throw new RangeError(`retry.delay must be a time of more than zero, not ${delay} ms`);
755
+ if (!(Number.isFinite(factor) && factor >= 1))
756
+ throw new RangeError(`retry.factor must be a number of 1 or more, not ${factor}`);
757
+ if (!(jitter >= 0 && jitter <= 1))
758
+ throw new RangeError(`retry.jitter must be from 0 to 1, not ${jitter}`);
759
+ return {
760
+ base: delay,
761
+ factor,
762
+ cap: Math.max(maxDelay, delay),
763
+ jitter: jitter > 0 ? { kind: "subtract", fraction: jitter } : { kind: "none" },
764
+ };
765
+ }
766
+ /** True when two statuses have the same values. The errors are the same object. */
767
+ function sameStatus(a, b) {
768
+ return (a.connected === b.connected &&
769
+ a.endpoint === b.endpoint &&
770
+ a.lastSync === b.lastSync &&
771
+ a.pendingChanges === b.pendingChanges &&
772
+ a.syncing === b.syncing &&
773
+ a.mode === b.mode &&
774
+ a.error === b.error);
775
+ }
776
+ /**
777
+ * The status in a form that a structured clone keeps. The status has numbers, strings and booleans,
778
+ * and they clone. Its error can be any value, for example an error with a function in a property,
779
+ * so the message has only the name, the message and the code of the error.
780
+ */
781
+ function portableStatus(status) {
782
+ return {
783
+ connected: status.connected,
784
+ pendingChanges: status.pendingChanges,
785
+ syncing: status.syncing,
786
+ ...(status.endpoint !== undefined && { endpoint: status.endpoint }),
787
+ ...(status.lastSync !== undefined && { lastSync: status.lastSync }),
788
+ ...(status.mode !== undefined && { mode: status.mode }),
789
+ ...(status.error !== undefined && { error: { operation: status.error.operation, ...portableError(status.error.error) } }),
790
+ };
791
+ }
792
+ /** The name, the message and the code of an error. A value that is not an error becomes its text. */
793
+ function portableError(value) {
794
+ try {
795
+ if (typeof value === "object" && value !== null) {
796
+ const { name, message, code } = value;
797
+ if (typeof message === "string") {
798
+ return {
799
+ name: typeof name === "string" ? name : "Error",
800
+ message,
801
+ ...((typeof code === "string" || typeof code === "number") && { code }),
802
+ };
803
+ }
804
+ }
805
+ return { name: "Error", message: String(value) };
806
+ }
807
+ catch {
808
+ // A value without a text, for example an object without a prototype
809
+ return { name: "Error", message: "An error without a text" };
810
+ }
811
+ }
812
+ /** The status of a message of the leader. Its error becomes an `Error` again. */
813
+ function statusOf(status) {
814
+ const error = status.error;
815
+ return {
816
+ connected: status.connected,
817
+ pendingChanges: status.pendingChanges,
818
+ syncing: status.syncing,
819
+ ...(status.endpoint !== undefined && { endpoint: status.endpoint }),
820
+ ...(status.lastSync !== undefined && { lastSync: status.lastSync }),
821
+ ...(status.mode !== undefined && { mode: status.mode }),
822
+ ...(error !== undefined && {
823
+ error: {
824
+ operation: error.operation,
825
+ error: Object.assign(new Error(error.message), { name: error.name }, error.code !== undefined ? { code: error.code } : {}),
826
+ },
827
+ }),
828
+ };
829
+ }
830
+ const MODES = new Set(["local-only", "write-through", "write-back", "remote-only"]);
831
+ const OPERATIONS = new Set(["load", "push", "pull", "sync", "store"]);
832
+ /** True when a value is the status of a message. */
833
+ function isPortableStatus(value) {
834
+ if (typeof value !== "object" || value === null)
835
+ return false;
836
+ const status = value;
837
+ if (typeof status.connected !== "boolean" || typeof status.syncing !== "boolean" || !isSeq(status.pendingChanges))
838
+ return false;
839
+ if (status.endpoint !== undefined && typeof status.endpoint !== "string")
840
+ return false;
841
+ if (status.lastSync !== undefined && typeof status.lastSync !== "number")
842
+ return false;
843
+ if (status.mode !== undefined && !(typeof status.mode === "string" && MODES.has(status.mode)))
844
+ return false;
845
+ if (status.error === undefined)
846
+ return true;
847
+ if (typeof status.error !== "object" || status.error === null)
848
+ return false;
849
+ const { operation, name, message, code } = status.error;
850
+ return (typeof operation === "string" &&
851
+ OPERATIONS.has(operation) &&
852
+ typeof name === "string" &&
853
+ typeof message === "string" &&
854
+ (code === undefined || typeof code === "string" || typeof code === "number"));
855
+ }
519
856
  /** A new random id. */
520
857
  function randomId() {
521
858
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -554,7 +891,10 @@ function isBatch(value) {
554
891
  return type === "delete" || (type === "set" && "value" in change);
555
892
  });
556
893
  }
557
- /** True when a value is a message of the channel, of this version. */
894
+ /**
895
+ * True when a value is a message of the channel, of this version. A message of a type that this
896
+ * release does not know is not a message: a tab of an older release ignores a new type.
897
+ */
558
898
  function isTabMessage(value) {
559
899
  if (typeof value !== "object" || value === null)
560
900
  return false;
@@ -577,6 +917,8 @@ function isTabMessage(value) {
577
917
  return true;
578
918
  case "ack":
579
919
  return typeof message.to === "string" && isSeq(message.seq);
920
+ case "status":
921
+ return isSeq(message.seq) && isPortableStatus(message.status);
580
922
  default:
581
923
  return false;
582
924
  }