@procedures/store 0.17.4 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +22 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/tab-coordinator.d.ts +131 -15
- package/dist/storage/tab-coordinator.d.ts.map +1 -1
- package/dist/storage/tab-coordinator.js +374 -32
- package/dist/storage/tab-coordinator.js.map +1 -1
- package/package.json +4 -4
- package/src/browser.ts +1 -0
- package/src/storage/index.ts +1 -0
- package/src/storage/tab-coordinator.ts +455 -37
|
@@ -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
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|
|
152
|
-
* gives the state of the sync. Write to the local copy, not to this
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
326
|
+
if (await this.takeLead()) {
|
|
327
|
+
await held;
|
|
250
328
|
return;
|
|
251
|
-
|
|
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.
|
|
344
|
+
this.locks = undefined;
|
|
345
|
+
this.startSingle();
|
|
265
346
|
});
|
|
266
347
|
}
|
|
267
348
|
/**
|
|
268
|
-
* This method makes the `HybridStorage` of
|
|
269
|
-
*
|
|
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
|
|
353
|
+
* @returns False when the setup failed (the error went to `onError`), or after `close()`
|
|
272
354
|
*/
|
|
273
|
-
|
|
274
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
304
|
-
* nothing: the application owns
|
|
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(
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
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
|
-
/**
|
|
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
|
}
|