@evolu/common 8.12.0 → 8.14.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.
- package/dist/src/Callbacks.d.ts +12 -1
- package/dist/src/Callbacks.d.ts.map +1 -1
- package/dist/src/Callbacks.js +3 -0
- package/dist/src/Error.d.ts +10 -4
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +10 -4
- package/dist/src/Object.d.ts +65 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +142 -0
- package/dist/src/Resource.d.ts +0 -5
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +6 -13
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +7 -0
- package/dist/src/Task.d.ts +10 -8
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +41 -5
- package/dist/src/Worker.d.ts +3 -3
- package/dist/src/local-first/Db.d.ts +8 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +56 -19
- package/dist/src/local-first/Evolu.d.ts +147 -39
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +53 -9
- package/dist/src/local-first/Owner.d.ts +9 -0
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +9 -0
- package/dist/src/local-first/Protocol.d.ts +6 -4
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +1 -6
- package/dist/src/local-first/Schema.d.ts +7 -1
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Shared.d.ts +550 -153
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +724 -273
- package/dist/src/local-first/Storage.d.ts +16 -12
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Callbacks.test.ts +20 -0
- package/src/Callbacks.ts +17 -1
- package/src/Error.ts +10 -4
- package/src/Object.test.ts +296 -0
- package/src/Object.ts +163 -0
- package/src/Resource.test.ts +20 -16
- package/src/Resource.ts +6 -20
- package/src/Sqlite.ts +7 -0
- package/src/Task.test.ts +233 -62
- package/src/Task.ts +47 -13
- package/src/Worker.ts +3 -3
- package/src/local-first/Db.ts +88 -18
- package/src/local-first/Evolu.test.ts +381 -11
- package/src/local-first/Evolu.ts +219 -51
- package/src/local-first/Owner.ts +9 -0
- package/src/local-first/Protocol.test.ts +42 -60
- package/src/local-first/Protocol.ts +7 -9
- package/src/local-first/Schema.ts +8 -2
- package/src/local-first/Shared.test.ts +2714 -644
- package/src/local-first/Shared.ts +1245 -401
- package/src/local-first/Storage.ts +18 -19
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
* still waiting after three seconds reports {@link OtherBuildRunningError} to
|
|
54
54
|
* its tabs. Every build broadcasts console entries and errors on
|
|
55
55
|
* {@link consoleEntryOrErrorBroadcastChannelName}, so a waiting tab also prints
|
|
56
|
-
* the running build's output
|
|
56
|
+
* the running build's output, reports each {@link UnknownError} it sends as its
|
|
57
|
+
* own `evoluError`, and only logs any other error, such as an older build's
|
|
58
|
+
* sync error.
|
|
57
59
|
*
|
|
58
60
|
* The tabs of one worker elect the host of its DbWorkers among themselves, with
|
|
59
61
|
* a lock scoped to the worker, so a tab of another worker never hosts them.
|
|
@@ -67,13 +69,20 @@
|
|
|
67
69
|
*
|
|
68
70
|
* ## Storage
|
|
69
71
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* its tabs see one mode, and
|
|
72
|
+
* The platform tells the worker with {@link DevicePersistenceDep} what it can
|
|
73
|
+
* promise about stored databases. The worker asks once, after it takes the
|
|
74
|
+
* build lock and before any DbWorker starts. A browser checks whether it can
|
|
75
|
+
* open the origin private file system, which Safari's Private Browsing and
|
|
76
|
+
* Firefox's private windows refuse. Without persistent storage, every DbWorker
|
|
77
|
+
* the worker starts keeps its database in memory, replacements included. The
|
|
78
|
+
* decision holds for the worker's lifetime, so all its tabs see one mode, and
|
|
79
|
+
* the next worker checks again.
|
|
80
|
+
*
|
|
81
|
+
* A tenant tells each instance it adds its {@link Evolu.devicePersistence}:
|
|
82
|
+
* `NotPersisted` when its database is kept in memory, because of the platform
|
|
83
|
+
* or {@link EvoluConfig.memoryOnly}, otherwise what the platform promised.
|
|
84
|
+
* Instances of one name share the tenant, so a later instance gets the mode the
|
|
85
|
+
* first one chose.
|
|
77
86
|
*
|
|
78
87
|
* The check cannot tell a private session from a storage failure, but memory
|
|
79
88
|
* loses nothing that refusing to start would have kept, and the persistent
|
|
@@ -100,7 +109,8 @@
|
|
|
100
109
|
* writable registrations for its owner, whichever instance made it, even one
|
|
101
110
|
* disposed before the database worker answered. When a relay frame stores new
|
|
102
111
|
* messages, the tenant requests a round through each other transport claimed
|
|
103
|
-
* for the owner, so data learned from one relay reaches the others
|
|
112
|
+
* for the owner, so data learned from one relay reaches the others, except
|
|
113
|
+
* through a route that skipped a message, as described below. A closed
|
|
104
114
|
* transport reconciles when it opens, and a replacement leader reconciles every
|
|
105
115
|
* transport again, because a response reporting stored messages may have been
|
|
106
116
|
* lost.
|
|
@@ -109,26 +119,34 @@
|
|
|
109
119
|
* also delivers it as local Broadcast frames to every other tenant with
|
|
110
120
|
* writable access to the owner, even while sockets are closed. A copy keeps the
|
|
111
121
|
* uploader's target, and a recipient that stores new continuation messages
|
|
112
|
-
* reconciles them through the transports outside that target
|
|
113
|
-
*
|
|
114
|
-
* but does not reconcile local
|
|
115
|
-
* until replication scopes and
|
|
122
|
+
* reconciles them through the transports outside that target, except through a
|
|
123
|
+
* route that skipped a message. Local delivery forwards uploads, including
|
|
124
|
+
* historical messages sent during reconciliation, but does not reconcile local
|
|
125
|
+
* database histories with each other. That waits until replication scopes and
|
|
126
|
+
* retention semantics are defined.
|
|
116
127
|
*
|
|
117
128
|
* ## Sync state
|
|
118
129
|
*
|
|
119
130
|
* The shared worker publishes one plain snapshot, {@link SyncState}, of every
|
|
120
131
|
* transport it manages and every database and owner registration it holds, with
|
|
121
|
-
* one route per writable registration and transport, as specified below.
|
|
122
|
-
*
|
|
132
|
+
* one route per writable registration and transport, as specified below. Each
|
|
133
|
+
* part is a union of the states the worker keeps for it: a transport's
|
|
134
|
+
* connection, a database that is active or refused startup, a writable or
|
|
135
|
+
* readonly owner, and a pending, settled, or complete route. A transport's
|
|
136
|
+
* events drive its connection, so publishing never reads a socket.
|
|
137
|
+
*
|
|
138
|
+
* Apps show users an owner's {@link OwnerSyncStatus}, which
|
|
139
|
+
* {@link syncStateToOwnerSyncStatus} derives. A view of each relay pairs each
|
|
140
|
+
* route with its transport by {@link syncStateToRelaySyncStates} and tells the
|
|
141
|
+
* relay's status by {@link relaySyncStateToStatus}. Snapshots store neither, so
|
|
142
|
+
* a status never disagrees with the routes it comes from.
|
|
123
143
|
*
|
|
124
144
|
* Each worker broadcasts snapshots on its own channel, whose name a connecting
|
|
125
145
|
* tab receives through its port, so a tab never hears another worker, such as
|
|
126
146
|
* one of a different app version, and
|
|
127
147
|
* {@link SyncStateDep.syncState | deps.syncState} keeps the last snapshot. The
|
|
128
|
-
* worker publishes after every change it observes
|
|
129
|
-
*
|
|
130
|
-
* snapshot. The snapshot lives in worker memory only, so a new worker starts
|
|
131
|
-
* empty.
|
|
148
|
+
* worker publishes after every change it observes. The snapshot lives in worker
|
|
149
|
+
* memory only, so a new worker starts empty.
|
|
132
150
|
*
|
|
133
151
|
* ## Synchronization completion
|
|
134
152
|
*
|
|
@@ -156,27 +174,51 @@
|
|
|
156
174
|
* - The tenant has sent a round through the transport since the last event that
|
|
157
175
|
* requires one: its first use of the transport for the owner, the socket
|
|
158
176
|
* opening, an explicit request, a replacement leader, or storing messages
|
|
159
|
-
* from another transport
|
|
160
|
-
* route since.
|
|
177
|
+
* from another transport, unless the route skipped a message. No failed or
|
|
178
|
+
* aborted result has arrived on the route since.
|
|
179
|
+
* - No result that skipped a received message has arrived on the route since a
|
|
180
|
+
* round was last requested through it, and since that request, no messages
|
|
181
|
+
* have been stored from another transport, directly or through a sibling copy
|
|
182
|
+
* uploaded outside this transport, and no sibling copy has failed to apply or
|
|
183
|
+
* skipped a message.
|
|
184
|
+
*
|
|
185
|
+
* A route is settled when every condition except the last holds, so a complete
|
|
186
|
+
* route is settled too. A settled route that is incomplete has ended its
|
|
187
|
+
* reconciliation, but its relay may offer a message the database skipped, or
|
|
188
|
+
* messages stored elsewhere may not have reached it; it is published as
|
|
189
|
+
* {@link SettledSyncRoute}.
|
|
161
190
|
*
|
|
162
191
|
* A reconciliation chain ends only with a converged result, a failure, an
|
|
163
192
|
* abort, a dropped frame, or a continuation that finds the socket closed, and
|
|
164
193
|
* relay errors reach every applying tenant, so these conditions mean every
|
|
165
194
|
* chain, including this tenant's, converged. A local Broadcast from a sibling
|
|
166
195
|
* tenant holds every route of its owner until it is applied, because it arrives
|
|
167
|
-
* without a request of its own; one that fails to apply
|
|
168
|
-
* through every transport.
|
|
196
|
+
* without a request of its own; one that fails to apply or skips a message
|
|
197
|
+
* requires a round through every transport whose route has not skipped one.
|
|
169
198
|
*
|
|
170
199
|
* A failed result on a route requests one round through it. Any further failure
|
|
171
|
-
* before the route
|
|
200
|
+
* before the route settles waits for an explicit request or a reopen, so a
|
|
172
201
|
* persistent failure cannot loop; a converged reply in between does not end the
|
|
173
202
|
* wait, because it may answer another tenant's round on the shared socket. An
|
|
174
203
|
* aborted apply leaves its routes incomplete without a retry. An exception
|
|
175
204
|
* while the database worker creates a round is logged there and fails the
|
|
176
|
-
* round's routes with `SyncFailed` without a retry
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
205
|
+
* round's routes with `SyncFailed` without a retry. A mutation that throws is
|
|
206
|
+
* rolled back and reported as an {@link UnknownError} to the tab that made it,
|
|
207
|
+
* or to every tab when its Evolu instance was disposed first. Other unexpected
|
|
208
|
+
* SQLite exceptions remain unsupported and can panic the database worker. A
|
|
209
|
+
* frame the relay silently drops, such as invalid data, leaves the count above
|
|
210
|
+
* zero until the liveness rule below replaces the socket.
|
|
211
|
+
*
|
|
212
|
+
* A result that skipped a received message the database could not decrypt,
|
|
213
|
+
* verify, or decode records the error on the route as its `skippedError`,
|
|
214
|
+
* without ending its chain, so it requests no round, and the relay offers the
|
|
215
|
+
* message again in every round through the route until the database stores a
|
|
216
|
+
* message with that timestamp. The messages received elsewhere that the last
|
|
217
|
+
* condition names request no round through such a route, even while a requested
|
|
218
|
+
* round checks it again, because each round would download every skipped
|
|
219
|
+
* message again. The next round that a first use of the transport for the
|
|
220
|
+
* owner, an explicit request, a reopen, a replacement leader, or a failure
|
|
221
|
+
* sends through the route reconciles them.
|
|
180
222
|
*
|
|
181
223
|
* ### Liveness
|
|
182
224
|
*
|
|
@@ -196,11 +238,13 @@
|
|
|
196
238
|
* The timeout belongs to the transport, not to an owner, because a small reply
|
|
197
239
|
* for one owner can wait behind another owner's large frame on the socket. A
|
|
198
240
|
* grown timeout lasts while a request is outstanding on the socket or a
|
|
199
|
-
* database that has not refused startup has an
|
|
200
|
-
* including one waiting after a failure, and ends with the transport. A
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
241
|
+
* database that has not refused startup has an unsettled route through it,
|
|
242
|
+
* including one waiting after a failure, and ends with the transport. A settled
|
|
243
|
+
* route that is incomplete does not count, because its reconciliation has
|
|
244
|
+
* ended. A reply's speed proves nothing, because a recovery on a slow link
|
|
245
|
+
* starts with small replies that arrive quickly. The reopen resets the counts
|
|
246
|
+
* and starts the open rounds, so a dropped frame delays a route instead of
|
|
247
|
+
* stranding it.
|
|
204
248
|
*
|
|
205
249
|
* The shared worker sends nothing while idle, so a path that dies then may go
|
|
206
250
|
* unnoticed until its next request; until then, changes from other devices stop
|
|
@@ -226,7 +270,11 @@ import {
|
|
|
226
270
|
} from "../Assert.ts";
|
|
227
271
|
import type { Brand } from "../Brand.ts";
|
|
228
272
|
import type { ConsoleEntry, ConsoleLevel } from "../Console.ts";
|
|
229
|
-
import type {
|
|
273
|
+
import type {
|
|
274
|
+
DecryptWithXChaCha20Poly1305Error,
|
|
275
|
+
EncryptionKey,
|
|
276
|
+
} from "../Crypto.ts";
|
|
277
|
+
import { createUnknownError, type UnknownError } from "../Error.ts";
|
|
230
278
|
import { disposable, exhaustiveCheck } from "../Function.ts";
|
|
231
279
|
import { acquireLeaderLock, type LockManagerDep } from "../LockManager.ts";
|
|
232
280
|
import {
|
|
@@ -281,7 +329,6 @@ import type {
|
|
|
281
329
|
CreateWebSocketDep,
|
|
282
330
|
WebSocket,
|
|
283
331
|
WebSocketError,
|
|
284
|
-
WebSocketReadyState,
|
|
285
332
|
} from "../WebSocket.ts";
|
|
286
333
|
import type {
|
|
287
334
|
SharedWorker as CommonSharedWorker,
|
|
@@ -293,7 +340,12 @@ import type {
|
|
|
293
340
|
WorkerDeps,
|
|
294
341
|
} from "../Worker.ts";
|
|
295
342
|
import type { DbWorkerInit, UnsupportedDbVersionError } from "./Db.ts";
|
|
296
|
-
import type {
|
|
343
|
+
import type {
|
|
344
|
+
DevicePersistence,
|
|
345
|
+
Evolu,
|
|
346
|
+
EvoluConfig,
|
|
347
|
+
SyncStateDep,
|
|
348
|
+
} from "./Evolu.ts";
|
|
297
349
|
import type { Owner, OwnerId, OwnerTransport, SyncOwner } from "./Owner.ts";
|
|
298
350
|
import {
|
|
299
351
|
createProtocolBroadcastMessagesFromCrdtMessages,
|
|
@@ -303,7 +355,10 @@ import {
|
|
|
303
355
|
parseProtocolHeader,
|
|
304
356
|
type ApplyProtocolMessageAsClientResult,
|
|
305
357
|
type ProtocolError,
|
|
358
|
+
type ProtocolInvalidDataError,
|
|
306
359
|
type ProtocolMessage,
|
|
360
|
+
type ProtocolQuotaError,
|
|
361
|
+
type ProtocolTimestampMismatchError,
|
|
307
362
|
} from "./Protocol.ts";
|
|
308
363
|
import {
|
|
309
364
|
makePatches,
|
|
@@ -352,11 +407,12 @@ export type SharedWorkerOutput =
|
|
|
352
407
|
| DbWorkerInit
|
|
353
408
|
| {
|
|
354
409
|
/**
|
|
355
|
-
* Sent to one tab only: its database refused startup,
|
|
356
|
-
* keeps this worker waiting.
|
|
410
|
+
* Sent to one tab only: its database refused startup, a mutation it made
|
|
411
|
+
* could not be stored, or another build keeps this worker waiting.
|
|
357
412
|
*/
|
|
358
413
|
readonly type: "Error";
|
|
359
|
-
readonly error:
|
|
414
|
+
readonly error:
|
|
415
|
+
OtherBuildRunningError | UnknownError | UnsupportedDbVersionError;
|
|
360
416
|
}
|
|
361
417
|
| {
|
|
362
418
|
/**
|
|
@@ -376,14 +432,6 @@ export type SharedWorkerOutput =
|
|
|
376
432
|
readonly type: "Connected";
|
|
377
433
|
readonly workerId: SharedWorkerId;
|
|
378
434
|
readonly syncStateChannelName: string;
|
|
379
|
-
}
|
|
380
|
-
| {
|
|
381
|
-
/**
|
|
382
|
-
* Sent to a connecting tab after `Connected` when the platform offers no
|
|
383
|
-
* persistent storage, so the worker keeps every database in memory; see
|
|
384
|
-
* Storage in this module's documentation.
|
|
385
|
-
*/
|
|
386
|
-
readonly type: "StorageUnavailable";
|
|
387
435
|
};
|
|
388
436
|
|
|
389
437
|
export type ConsoleEntryOrError =
|
|
@@ -393,7 +441,7 @@ export type ConsoleEntryOrError =
|
|
|
393
441
|
}
|
|
394
442
|
| {
|
|
395
443
|
readonly type: "Error";
|
|
396
|
-
readonly error:
|
|
444
|
+
readonly error: UnknownError;
|
|
397
445
|
};
|
|
398
446
|
|
|
399
447
|
export const consoleEntryOrErrorBroadcastChannelName =
|
|
@@ -451,31 +499,31 @@ export interface BuildWaitingRequest extends InferType<
|
|
|
451
499
|
export interface OtherBuildRunningError extends Typed<"OtherBuildRunningError"> {}
|
|
452
500
|
|
|
453
501
|
/**
|
|
454
|
-
* A snapshot of the transports and databases the shared worker manages.
|
|
455
|
-
*
|
|
456
|
-
*
|
|
502
|
+
* A snapshot of the transports and databases the shared worker manages. Each
|
|
503
|
+
* part is a union of the states the worker keeps for it, so each part holds
|
|
504
|
+
* only the fields valid for its state. Apps derive what to show with
|
|
505
|
+
* {@link syncStateToOwnerSyncStatus}; see Sync state in this module's
|
|
506
|
+
* documentation.
|
|
457
507
|
*/
|
|
458
508
|
export interface SyncState {
|
|
459
509
|
readonly transports: ReadonlyArray<SyncTransport>;
|
|
460
510
|
readonly tenants: ReadonlyArray<SyncTenant>;
|
|
461
511
|
}
|
|
462
512
|
|
|
463
|
-
/**
|
|
464
|
-
|
|
465
|
-
|
|
513
|
+
/**
|
|
514
|
+
* One transport, shared by every owner and database claiming it. Its `type` is
|
|
515
|
+
* the kind of transport, and its {@link SyncConnection} tells whether it is
|
|
516
|
+
* connected.
|
|
517
|
+
*/
|
|
518
|
+
export type SyncTransport = WebSocketSyncTransport;
|
|
519
|
+
|
|
520
|
+
/** A WebSocket {@link SyncTransport}. */
|
|
521
|
+
export interface WebSocketSyncTransport extends Typed<"WebSocket"> {
|
|
522
|
+
/** Opaque and stable for the transport's lifetime, across reconnects. */
|
|
466
523
|
readonly id: SyncTransportId;
|
|
467
|
-
/** The URL without
|
|
524
|
+
/** The relay URL without the owner-specific query. */
|
|
468
525
|
readonly label: string;
|
|
469
|
-
readonly
|
|
470
|
-
/** When the connection last opened, or null before its first open. */
|
|
471
|
-
readonly openedAt: Millis | null;
|
|
472
|
-
/** When the connection last closed, or null before its first close. */
|
|
473
|
-
readonly closedAt: Millis | null;
|
|
474
|
-
/**
|
|
475
|
-
* The last error, retained after a successful reconnect; null if none. Errors
|
|
476
|
-
* while reconnecting are routine.
|
|
477
|
-
*/
|
|
478
|
-
readonly error: SyncTransportError | null;
|
|
526
|
+
readonly connection: SyncConnection;
|
|
479
527
|
}
|
|
480
528
|
|
|
481
529
|
export type SyncTransportId = Id & Brand<"SyncTransport">;
|
|
@@ -485,109 +533,519 @@ export interface SyncTransportError {
|
|
|
485
533
|
readonly at: Millis;
|
|
486
534
|
}
|
|
487
535
|
|
|
488
|
-
/**
|
|
489
|
-
|
|
536
|
+
/**
|
|
537
|
+
* The connection of a {@link SyncTransport}: `Connecting` until its first
|
|
538
|
+
* connection opens or fails, `Open` while a connection is open, and
|
|
539
|
+
* `Disconnected` from a close, a failure, or an unanswered request that
|
|
540
|
+
* replaced the connection, until a connection opens again. It never returns to
|
|
541
|
+
* `Connecting`. The transport's events drive these states.
|
|
542
|
+
*/
|
|
543
|
+
export type SyncConnection =
|
|
544
|
+
ConnectingSyncConnection | OpenSyncConnection | DisconnectedSyncConnection;
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* A first connection, which has neither opened nor failed. A connection to a
|
|
548
|
+
* host that drops packets stays here until the operating system or browser
|
|
549
|
+
* gives up on it, which can take a minute or more.
|
|
550
|
+
*/
|
|
551
|
+
export interface ConnectingSyncConnection extends Typed<"Connecting"> {}
|
|
552
|
+
|
|
553
|
+
/** An open connection. */
|
|
554
|
+
export interface OpenSyncConnection extends Typed<"Open"> {
|
|
555
|
+
/** When this connection opened. */
|
|
556
|
+
readonly openedAt: Millis;
|
|
557
|
+
/** The last error of an earlier connection or attempt, or null. */
|
|
558
|
+
readonly error: SyncTransportError | null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* No connection: a connection closed or failed, or the relay did not answer a
|
|
563
|
+
* request in time. The transport reconnects by itself.
|
|
564
|
+
*/
|
|
565
|
+
export interface DisconnectedSyncConnection extends Typed<"Disconnected"> {
|
|
566
|
+
/** When it disconnected. Failed reconnect attempts leave it unchanged. */
|
|
567
|
+
readonly disconnectedAt: Millis;
|
|
568
|
+
/** When the last connection opened, or null when none has. */
|
|
569
|
+
readonly openedAt: Millis | null;
|
|
570
|
+
/**
|
|
571
|
+
* The last error, or null. A close or an unanswered request records none, so
|
|
572
|
+
* it can come from an earlier connection. Errors while reconnecting are
|
|
573
|
+
* routine.
|
|
574
|
+
*/
|
|
575
|
+
readonly error: SyncTransportError | null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** One named local database: `Active`, or `Refused` when it refused startup. */
|
|
579
|
+
export type SyncTenant = ActiveSyncTenant | RefusedSyncTenant;
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A database that has not refused startup, including one still starting, with
|
|
583
|
+
* the owners its instances registered.
|
|
584
|
+
*/
|
|
585
|
+
export interface ActiveSyncTenant extends Typed<"Active"> {
|
|
490
586
|
readonly name: Name;
|
|
491
|
-
/** The database refused startup, so nothing it holds synchronizes. */
|
|
492
|
-
readonly refused: boolean;
|
|
493
587
|
readonly owners: ReadonlyArray<SyncTenantOwner>;
|
|
494
588
|
}
|
|
495
589
|
|
|
496
|
-
|
|
590
|
+
/**
|
|
591
|
+
* A database that refused startup, so nothing it holds syncs. Its tabs also
|
|
592
|
+
* receive the error through `evoluError`.
|
|
593
|
+
*/
|
|
594
|
+
export interface RefusedSyncTenant extends Typed<"Refused"> {
|
|
595
|
+
readonly name: Name;
|
|
596
|
+
readonly error: UnsupportedDbVersionError;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* An owner registered by any instance of an {@link ActiveSyncTenant}: `Writable`
|
|
601
|
+
* when any registration holds its write key, otherwise `Readonly`.
|
|
602
|
+
*/
|
|
603
|
+
export type SyncTenantOwner = WritableSyncTenantOwner | ReadonlySyncTenantOwner;
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* An owner the database syncs, with one route per transport claimed for it by
|
|
607
|
+
* this or any other database. Routes are briefly empty while the owner's
|
|
608
|
+
* transports are being claimed.
|
|
609
|
+
*/
|
|
610
|
+
export interface WritableSyncTenantOwner extends Typed<"Writable"> {
|
|
611
|
+
readonly ownerId: OwnerId;
|
|
612
|
+
readonly routes: ReadonlyArray<SyncRoute>;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* An owner registered only without its write key. Its registrations hold
|
|
617
|
+
* transports, which other databases' routes for the owner use, but this
|
|
618
|
+
* database does not sync it.
|
|
619
|
+
*/
|
|
620
|
+
export interface ReadonlySyncTenantOwner extends Typed<"Readonly"> {
|
|
497
621
|
readonly ownerId: OwnerId;
|
|
498
|
-
/** A readonly registration holds transports but never synchronizes. */
|
|
499
|
-
readonly writable: boolean;
|
|
500
622
|
/** Every transport claimed for the owner, by any database. */
|
|
501
623
|
readonly transportIds: ReadonlyArray<SyncTransportId>;
|
|
502
|
-
/** One route per transport for a writable owner; none for a readonly one. */
|
|
503
|
-
readonly routes: ReadonlyArray<SyncRoute>;
|
|
504
624
|
}
|
|
505
625
|
|
|
506
626
|
/**
|
|
507
|
-
* One database's use of one owner through one transport
|
|
508
|
-
*
|
|
627
|
+
* One database's use of one owner through one transport: `Pending` until its
|
|
628
|
+
* reconciliation ends, then `Complete`, or `Settled` while its relay offers a
|
|
629
|
+
* change the database skipped. See Synchronization completion in this module's
|
|
630
|
+
* documentation.
|
|
509
631
|
*/
|
|
510
|
-
export
|
|
632
|
+
export type SyncRoute = PendingSyncRoute | SettledSyncRoute | CompleteSyncRoute;
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* A route whose reconciliation has not ended: its transport is not open, a
|
|
636
|
+
* request or a received frame is outstanding, a replicated write is queued, or
|
|
637
|
+
* a round must still be sent through it.
|
|
638
|
+
*/
|
|
639
|
+
export interface PendingSyncRoute extends Typed<"Pending"> {
|
|
511
640
|
readonly transportId: SyncTransportId;
|
|
512
|
-
/**
|
|
513
|
-
|
|
641
|
+
/**
|
|
642
|
+
* The failure since the route last settled, or null. The first one requests a
|
|
643
|
+
* round; a further one waits for {@link Evolu.requestSync} or a reopen.
|
|
644
|
+
*/
|
|
645
|
+
readonly failure: SyncRouteError | null;
|
|
646
|
+
/**
|
|
647
|
+
* The first change skipped in the latest reply that skipped one, or null. The
|
|
648
|
+
* relay offers it again in every round through the route until this database
|
|
649
|
+
* stores a change with that timestamp.
|
|
650
|
+
*/
|
|
651
|
+
readonly skippedError: SyncRouteError | null;
|
|
514
652
|
/** When the route last became complete, or null. */
|
|
515
653
|
readonly completeAt: Millis | null;
|
|
516
654
|
/** When this database last sent a request through the route, or null. */
|
|
517
655
|
readonly lastSentAt: Millis | null;
|
|
518
656
|
/**
|
|
519
657
|
* When processing a frame from the route last finished, successfully or with
|
|
520
|
-
* a failure, or null. Aborted processing does not update
|
|
658
|
+
* a failure, or null. Aborted processing does not update it.
|
|
521
659
|
*/
|
|
522
660
|
readonly lastReceivedAt: Millis | null;
|
|
523
|
-
/** The last failed result on the route; cleared when the route completes. */
|
|
524
|
-
readonly error: SyncRouteError | null;
|
|
525
661
|
}
|
|
526
662
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
663
|
+
/**
|
|
664
|
+
* A route whose reconciliation ended while its relay offers a change the
|
|
665
|
+
* database skipped, so it is incomplete. Changes stored from other relays
|
|
666
|
+
* request no round through it; the next round requested through it, such as by
|
|
667
|
+
* {@link Evolu.requestSync} or a reopen, checks it again.
|
|
668
|
+
*/
|
|
669
|
+
export interface SettledSyncRoute extends Typed<"Settled"> {
|
|
670
|
+
readonly transportId: SyncTransportId;
|
|
671
|
+
/** The first change skipped in the latest reply that skipped one. */
|
|
672
|
+
readonly skippedError: SyncRouteError;
|
|
673
|
+
/** When the route last became complete, or null. */
|
|
674
|
+
readonly completeAt: Millis | null;
|
|
675
|
+
/** When this database last sent a request through the route. */
|
|
676
|
+
readonly lastSentAt: Millis;
|
|
677
|
+
/** When processing a frame from the route last finished, or null. */
|
|
678
|
+
readonly lastReceivedAt: Millis | null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** A route on which the database is reconciled with the relay for the owner. */
|
|
682
|
+
export interface CompleteSyncRoute extends Typed<"Complete"> {
|
|
683
|
+
readonly transportId: SyncTransportId;
|
|
684
|
+
/** When the route became complete. */
|
|
685
|
+
readonly completeAt: Millis;
|
|
686
|
+
/** When this database last sent a request through the route. */
|
|
687
|
+
readonly lastSentAt: Millis;
|
|
688
|
+
/** When processing a frame from the route last finished, or null. */
|
|
689
|
+
readonly lastReceivedAt: Millis | null;
|
|
530
690
|
}
|
|
531
691
|
|
|
532
692
|
/**
|
|
533
|
-
* A
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
693
|
+
* A failure or a skipped change of a {@link SyncRoute}, with the time it
|
|
694
|
+
* arrived.
|
|
695
|
+
*
|
|
696
|
+
* It is the error itself, so it carries its details, such as the expected and
|
|
697
|
+
* actual timestamps of a {@link ProtocolTimestampMismatchError}. A skipped
|
|
698
|
+
* message adds {@link DecryptWithXChaCha20Poly1305Error}. A
|
|
699
|
+
* {@link ProtocolInvalidDataError} leaves out its data, which can be a whole
|
|
700
|
+
* frame. The caught value in the `error` of either is an {@link UnknownError}.
|
|
701
|
+
* `WriteFailed` means a `writeMessages` call that threw, logged by the
|
|
702
|
+
* protocol, and `SyncFailed` means a logged failure while creating a round or
|
|
703
|
+
* reconciling ranges.
|
|
537
704
|
*/
|
|
538
|
-
export type
|
|
539
|
-
| ProtocolError
|
|
540
|
-
|
|
|
541
|
-
|
|
|
542
|
-
|
|
|
705
|
+
export type SyncRouteError = (
|
|
706
|
+
| Exclude<ProtocolError, ProtocolInvalidDataError>
|
|
707
|
+
| Omit<ProtocolInvalidDataError, "data">
|
|
708
|
+
| StorageWriteMessagesError
|
|
709
|
+
| DecryptWithXChaCha20Poly1305Error
|
|
710
|
+
| Typed<"WriteFailed">
|
|
711
|
+
| Typed<"SyncFailed">
|
|
712
|
+
) & { readonly at: Millis };
|
|
713
|
+
|
|
714
|
+
/** The type of a {@link SyncRouteError}. */
|
|
715
|
+
export type SyncRouteErrorType = SyncRouteError["type"];
|
|
543
716
|
|
|
544
717
|
/**
|
|
545
|
-
* One
|
|
546
|
-
*
|
|
718
|
+
* One relay of an owner in one database: a transport and the database's route
|
|
719
|
+
* through it, from {@link syncStateToRelaySyncStates}. Its status comes from
|
|
720
|
+
* {@link relaySyncStateToStatus} and is not stored beside them.
|
|
547
721
|
*/
|
|
548
|
-
export interface
|
|
549
|
-
readonly
|
|
550
|
-
readonly
|
|
551
|
-
readonly status: OwnerSyncStatus;
|
|
552
|
-
/** When a route of the owner last became complete, or null. */
|
|
553
|
-
readonly syncedAt: Millis | null;
|
|
554
|
-
/** The newest route error of the owner, or null. */
|
|
555
|
-
readonly error: SyncRouteError | null;
|
|
556
|
-
/** Each relay of the owner, in the order of its routes. */
|
|
557
|
-
readonly relays: ReadonlyArray<RelaySyncState>;
|
|
722
|
+
export interface RelaySyncState {
|
|
723
|
+
readonly transport: SyncTransport;
|
|
724
|
+
readonly route: SyncRoute;
|
|
558
725
|
}
|
|
559
726
|
|
|
560
727
|
/**
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
728
|
+
* What an app shows users about syncing one owner of one database, from
|
|
729
|
+
* {@link syncStateToOwnerSyncStatus} or the React and Vue `useOwnerSyncStatus`.
|
|
730
|
+
*
|
|
731
|
+
* Its variants:
|
|
732
|
+
*
|
|
733
|
+
* - `NoRelays`: nothing syncs the owner here. There is no snapshot yet, the
|
|
734
|
+
* owner's transports are still being set up, the app uses no relays for it,
|
|
735
|
+
* it is registered only as readonly, or the database refused startup, which
|
|
736
|
+
* `evoluError` reports.
|
|
737
|
+
* - `Syncing`: a relay connects for the first time or reconciles. A first
|
|
738
|
+
* connection to a host that drops packets stays `Syncing` until the platform
|
|
739
|
+
* gives up on it, which can take a minute.
|
|
740
|
+
* - `Synced`: a relay is up to date, and none syncs.
|
|
741
|
+
* - `Offline`: every relay is disconnected. Evolu keeps reconnecting, up to 30
|
|
742
|
+
* seconds apart, so `Offline` can briefly outlast the outage.
|
|
743
|
+
* - `Error`: a relay failed or offers a change this database skipped. `error` is
|
|
744
|
+
* the newest failure, or without one, the newest skipped change: a failure
|
|
745
|
+
* stops syncing through its relay, while a skipped change leaves out only
|
|
746
|
+
* that change.
|
|
747
|
+
*
|
|
748
|
+
* Evolu stores changes in the local database before they sync, so sync needs no
|
|
749
|
+
* UI while it works: show nothing for `NoRelays`, `Syncing`, and `Synced`. An
|
|
750
|
+
* indicator that changes with every edit distracts, and screen readers announce
|
|
751
|
+
* each change.
|
|
752
|
+
*
|
|
753
|
+
* For `Offline` and `Error`, show one quiet line that lasts as long as the
|
|
754
|
+
* status, not a dialog, which interrupts, or a toast, which disappears while
|
|
755
|
+
* the problem lasts. Do not say that changes are saved on this device unless
|
|
756
|
+
* {@link Evolu.devicePersistence} is `Persisted`: a browser may keep them only
|
|
757
|
+
* for a private session or delete them later. Evolu reports `Offline` at once;
|
|
758
|
+
* an app may wait a few seconds before showing it, because brief
|
|
759
|
+
* disconnections, such as waking from sleep, reconnect quickly. For `Error`,
|
|
760
|
+
* write actionable text for the error types the app can act on, such as a
|
|
761
|
+
* {@link ProtocolQuotaError}: the relay stores no more data for the owner, so
|
|
762
|
+
* offer more quota, such as a plan upgrade, then call {@link Evolu.requestSync}
|
|
763
|
+
* with the owner's ID. For any other error, show generic text that names the
|
|
764
|
+
* error type, which helps when the user reports it.
|
|
765
|
+
*
|
|
766
|
+
* Render the line inside one element with `role="status"` that stays mounted:
|
|
767
|
+
* screen readers announce changes only in a live region that already exists,
|
|
768
|
+
* and a polite announcement fits a status that loses nothing. Do not use
|
|
769
|
+
* `role="alert"`.
|
|
770
|
+
*
|
|
771
|
+
* Show the app owner's status once for the whole app, such as below the header,
|
|
772
|
+
* and another owner's status where the app shows that owner's data. Each Evolu
|
|
773
|
+
* instance finds its own status by {@link Evolu.name}, so an app with several
|
|
774
|
+
* databases shows the status of the one the user works in.
|
|
775
|
+
*
|
|
776
|
+
* ### Example
|
|
777
|
+
*
|
|
778
|
+
* ```ts
|
|
779
|
+
* import { assertEqual, Millis } from "@evolu/common";
|
|
780
|
+
* import {
|
|
781
|
+
* testAppOwner,
|
|
782
|
+
* type OwnerSyncStatus,
|
|
783
|
+
* } from "@evolu/common/local-first";
|
|
784
|
+
*
|
|
785
|
+
* // What to tell the user, or null while sync works or is not used.
|
|
786
|
+
* const syncStatusToMessage = (status: OwnerSyncStatus): string | null => {
|
|
787
|
+
* switch (status.type) {
|
|
788
|
+
* case "NoRelays":
|
|
789
|
+
* case "Syncing":
|
|
790
|
+
* case "Synced":
|
|
791
|
+
* return null;
|
|
792
|
+
* case "Offline":
|
|
793
|
+
* return "Offline. Changes will sync when you're back online.";
|
|
794
|
+
* case "Error":
|
|
795
|
+
* return status.error.type === "ProtocolQuotaError"
|
|
796
|
+
* ? "Sync is paused because the sync server is full."
|
|
797
|
+
* : `Sync error: ${status.error.type}.`;
|
|
798
|
+
* }
|
|
799
|
+
* };
|
|
800
|
+
*
|
|
801
|
+
* assertEqual(syncStatusToMessage({ type: "Synced" }), null);
|
|
802
|
+
* assertEqual(
|
|
803
|
+
* syncStatusToMessage({ type: "Offline" }),
|
|
804
|
+
* "Offline. Changes will sync when you're back online.",
|
|
805
|
+
* );
|
|
806
|
+
* assertEqual(
|
|
807
|
+
* syncStatusToMessage({
|
|
808
|
+
* type: "Error",
|
|
809
|
+
* error: {
|
|
810
|
+
* type: "ProtocolQuotaError",
|
|
811
|
+
* ownerId: testAppOwner.id,
|
|
812
|
+
* at: Millis.orThrow(1000),
|
|
813
|
+
* },
|
|
814
|
+
* }),
|
|
815
|
+
* "Sync is paused because the sync server is full.",
|
|
816
|
+
* );
|
|
817
|
+
* assertEqual(
|
|
818
|
+
* syncStatusToMessage({
|
|
819
|
+
* type: "Error",
|
|
820
|
+
* error: { type: "SyncFailed", at: Millis.orThrow(1000) },
|
|
821
|
+
* }),
|
|
822
|
+
* "Sync error: SyncFailed.",
|
|
823
|
+
* );
|
|
824
|
+
* ```
|
|
565
825
|
*/
|
|
566
|
-
export type OwnerSyncStatus =
|
|
826
|
+
export type OwnerSyncStatus = NoRelaysSyncStatus | RelaySyncStatus;
|
|
567
827
|
|
|
568
828
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
829
|
+
* The status of a {@link RelaySyncState}, from {@link relaySyncStateToStatus}:
|
|
830
|
+
* `Error` when its route has a failure or a skipped change, the failure first;
|
|
831
|
+
* otherwise `Synced` when the route is complete, `Syncing` while the
|
|
832
|
+
* transport's connection is `Connecting` or `Open`, and `Offline` while it is
|
|
833
|
+
* `Disconnected`.
|
|
571
834
|
*/
|
|
572
|
-
export
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
835
|
+
export type RelaySyncStatus =
|
|
836
|
+
SyncingSyncStatus | SyncedSyncStatus | OfflineSyncStatus | ErrorSyncStatus;
|
|
837
|
+
|
|
838
|
+
/** Evolu reports no relay syncing the owner in the database. */
|
|
839
|
+
export interface NoRelaysSyncStatus extends Typed<"NoRelays"> {}
|
|
840
|
+
|
|
841
|
+
/** A relay makes its first connection or reconciles. */
|
|
842
|
+
export interface SyncingSyncStatus extends Typed<"Syncing"> {}
|
|
843
|
+
|
|
844
|
+
/** A relay is reconciled with the database for the owner. */
|
|
845
|
+
export interface SyncedSyncStatus extends Typed<"Synced"> {}
|
|
846
|
+
|
|
847
|
+
/** Relays are disconnected and reconnecting, so changes wait on this device. */
|
|
848
|
+
export interface OfflineSyncStatus extends Typed<"Offline"> {}
|
|
849
|
+
|
|
850
|
+
/** A route failed or holds a change the database skipped. */
|
|
851
|
+
export interface ErrorSyncStatus extends Typed<"Error"> {
|
|
852
|
+
/**
|
|
853
|
+
* The failure, or without one, the skipped change. For an owner, the newest
|
|
854
|
+
* failure of any relay, or without one, the newest skipped change.
|
|
855
|
+
*/
|
|
856
|
+
readonly error: SyncRouteError;
|
|
576
857
|
}
|
|
577
858
|
|
|
578
859
|
/**
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
860
|
+
* Tells what an app shows about syncing an owner in a database: `Error` when a
|
|
861
|
+
* relay has one, holding the newest failure of any relay or, without one, the
|
|
862
|
+
* newest skipped change; otherwise the first of `Syncing`, `Synced`, and
|
|
863
|
+
* `Offline` that a relay has, or `NoRelays`. Accepts null, the store's value
|
|
864
|
+
* before the first snapshot. See {@link OwnerSyncStatus} for what to show.
|
|
865
|
+
*
|
|
866
|
+
* The same status is the same object: statuses other than `Error` are shared
|
|
867
|
+
* constants, and an `Error` status is the same object for the same error
|
|
868
|
+
* object, which {@link SyncStateDep.syncState} keeps between snapshots while it
|
|
869
|
+
* is unchanged. So compare statuses with `===`.
|
|
870
|
+
*
|
|
871
|
+
* ### Example
|
|
872
|
+
*
|
|
873
|
+
* ```ts
|
|
874
|
+
* import {
|
|
875
|
+
* assertEqual,
|
|
876
|
+
* assertSame,
|
|
877
|
+
* createId,
|
|
878
|
+
* createUnknownError,
|
|
879
|
+
* Millis,
|
|
880
|
+
* testCreateDeps,
|
|
881
|
+
* testName,
|
|
882
|
+
* } from "@evolu/common";
|
|
883
|
+
* import {
|
|
884
|
+
* syncStateToOwnerSyncStatus,
|
|
885
|
+
* testAppOwner,
|
|
886
|
+
* type PendingSyncRoute,
|
|
887
|
+
* type SyncConnection,
|
|
888
|
+
* type SyncRoute,
|
|
889
|
+
* type SyncState,
|
|
890
|
+
* type SyncTransportId,
|
|
891
|
+
* } from "@evolu/common/local-first";
|
|
892
|
+
*
|
|
893
|
+
* const deps = testCreateDeps();
|
|
894
|
+
* const primaryId = createId<"SyncTransport">(deps);
|
|
895
|
+
* const backupId = createId<"SyncTransport">(deps);
|
|
896
|
+
*
|
|
897
|
+
* const stateOf = (
|
|
898
|
+
* connections: ReadonlyArray<SyncConnection>,
|
|
899
|
+
* routes: ReadonlyArray<SyncRoute>,
|
|
900
|
+
* ): SyncState => ({
|
|
901
|
+
* transports: connections.map((connection, index) => ({
|
|
902
|
+
* type: "WebSocket",
|
|
903
|
+
* id: index === 0 ? primaryId : backupId,
|
|
904
|
+
* label: "wss://relay.example",
|
|
905
|
+
* connection,
|
|
906
|
+
* })),
|
|
907
|
+
* tenants: [
|
|
908
|
+
* {
|
|
909
|
+
* type: "Active",
|
|
910
|
+
* name: testName,
|
|
911
|
+
* owners: [{ type: "Writable", ownerId: testAppOwner.id, routes }],
|
|
912
|
+
* },
|
|
913
|
+
* ],
|
|
914
|
+
* });
|
|
915
|
+
* const pending = (transportId: SyncTransportId): PendingSyncRoute => ({
|
|
916
|
+
* type: "Pending",
|
|
917
|
+
* transportId,
|
|
918
|
+
* failure: null,
|
|
919
|
+
* skippedError: null,
|
|
920
|
+
* completeAt: null,
|
|
921
|
+
* lastSentAt: null,
|
|
922
|
+
* lastReceivedAt: null,
|
|
923
|
+
* });
|
|
924
|
+
* const statusOf = (state: SyncState | null) =>
|
|
925
|
+
* syncStateToOwnerSyncStatus(state, testName, testAppOwner.id);
|
|
926
|
+
*
|
|
927
|
+
* // Before the first snapshot, nothing syncs the owner.
|
|
928
|
+
* assertEqual(statusOf(null), { type: "NoRelays" });
|
|
929
|
+
*
|
|
930
|
+
* // A first connection is syncing; a lost one is offline.
|
|
931
|
+
* const connecting: SyncConnection = { type: "Connecting" };
|
|
932
|
+
* assertEqual(statusOf(stateOf([connecting], [pending(primaryId)])), {
|
|
933
|
+
* type: "Syncing",
|
|
934
|
+
* });
|
|
935
|
+
* const disconnected: SyncConnection = {
|
|
936
|
+
* type: "Disconnected",
|
|
937
|
+
* disconnectedAt: Millis.orThrow(2000),
|
|
938
|
+
* openedAt: Millis.orThrow(1000),
|
|
939
|
+
* error: null,
|
|
940
|
+
* };
|
|
941
|
+
* assertEqual(statusOf(stateOf([disconnected], [pending(primaryId)])), {
|
|
942
|
+
* type: "Offline",
|
|
943
|
+
* });
|
|
944
|
+
*
|
|
945
|
+
* // A quota failure on one relay shows before a newer skipped change on
|
|
946
|
+
* // another, because the app can act on it.
|
|
947
|
+
* const quotaError = {
|
|
948
|
+
* type: "ProtocolQuotaError",
|
|
949
|
+
* ownerId: testAppOwner.id,
|
|
950
|
+
* at: Millis.orThrow(3000),
|
|
951
|
+
* } as const;
|
|
952
|
+
* const open: SyncConnection = {
|
|
953
|
+
* type: "Open",
|
|
954
|
+
* openedAt: Millis.orThrow(3400),
|
|
955
|
+
* error: null,
|
|
956
|
+
* };
|
|
957
|
+
* assertEqual(
|
|
958
|
+
* statusOf(
|
|
959
|
+
* stateOf(
|
|
960
|
+
* [disconnected, open],
|
|
961
|
+
* [
|
|
962
|
+
* { ...pending(primaryId), failure: quotaError },
|
|
963
|
+
* {
|
|
964
|
+
* type: "Settled",
|
|
965
|
+
* transportId: backupId,
|
|
966
|
+
* skippedError: {
|
|
967
|
+
* type: "DecryptWithXChaCha20Poly1305Error",
|
|
968
|
+
* error: createUnknownError(new Error("invalid tag")),
|
|
969
|
+
* at: Millis.orThrow(4000),
|
|
970
|
+
* },
|
|
971
|
+
* completeAt: null,
|
|
972
|
+
* lastSentAt: Millis.orThrow(3500),
|
|
973
|
+
* lastReceivedAt: Millis.orThrow(4000),
|
|
974
|
+
* },
|
|
975
|
+
* ],
|
|
976
|
+
* ),
|
|
977
|
+
* ),
|
|
978
|
+
* { type: "Error", error: quotaError },
|
|
979
|
+
* );
|
|
980
|
+
*
|
|
981
|
+
* // The same error gives the same status object.
|
|
982
|
+
* const quotaState = stateOf(
|
|
983
|
+
* [disconnected],
|
|
984
|
+
* [{ ...pending(primaryId), failure: quotaError }],
|
|
985
|
+
* );
|
|
986
|
+
* assertSame(statusOf(quotaState), statusOf(quotaState));
|
|
987
|
+
* ```
|
|
582
988
|
*/
|
|
583
|
-
export
|
|
989
|
+
export const syncStateToOwnerSyncStatus = (
|
|
990
|
+
state: SyncState | null,
|
|
991
|
+
name: Name,
|
|
992
|
+
ownerId: OwnerId,
|
|
993
|
+
): OwnerSyncStatus => {
|
|
994
|
+
let failure: SyncRouteError | null = null;
|
|
995
|
+
let skippedError: SyncRouteError | null = null;
|
|
996
|
+
let isSyncing = false;
|
|
997
|
+
let isSynced = false;
|
|
998
|
+
let isOffline = false;
|
|
999
|
+
for (const relay of syncStateToRelaySyncStates(state, name, ownerId)) {
|
|
1000
|
+
const { route } = relay;
|
|
1001
|
+
// A failure stops syncing through its relay, and a skipped change is
|
|
1002
|
+
// stamped again by every reply that skips it, so a newer skipped change
|
|
1003
|
+
// must not hide an older failure the app can act on.
|
|
1004
|
+
if (
|
|
1005
|
+
route.type === "Pending" &&
|
|
1006
|
+
route.failure &&
|
|
1007
|
+
(!failure || route.failure.at > failure.at)
|
|
1008
|
+
)
|
|
1009
|
+
failure = route.failure;
|
|
1010
|
+
if (
|
|
1011
|
+
route.type !== "Complete" &&
|
|
1012
|
+
route.skippedError &&
|
|
1013
|
+
(!skippedError || route.skippedError.at > skippedError.at)
|
|
1014
|
+
)
|
|
1015
|
+
skippedError = route.skippedError;
|
|
1016
|
+
const status = relaySyncStateToStatus(relay);
|
|
1017
|
+
switch (status.type) {
|
|
1018
|
+
case "Error":
|
|
1019
|
+
break;
|
|
1020
|
+
case "Syncing":
|
|
1021
|
+
isSyncing = true;
|
|
1022
|
+
break;
|
|
1023
|
+
case "Synced":
|
|
1024
|
+
isSynced = true;
|
|
1025
|
+
break;
|
|
1026
|
+
case "Offline":
|
|
1027
|
+
isOffline = true;
|
|
1028
|
+
break;
|
|
1029
|
+
default:
|
|
1030
|
+
exhaustiveCheck(status);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const error = failure ?? skippedError;
|
|
1034
|
+
return error
|
|
1035
|
+
? syncRouteErrorToSyncStatus(error)
|
|
1036
|
+
: isSyncing
|
|
1037
|
+
? syncingSyncStatus
|
|
1038
|
+
: isSynced
|
|
1039
|
+
? syncedSyncStatus
|
|
1040
|
+
: isOffline
|
|
1041
|
+
? offlineSyncStatus
|
|
1042
|
+
: noRelaysSyncStatus;
|
|
1043
|
+
};
|
|
584
1044
|
|
|
585
1045
|
/**
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* {@link RelaySyncState}. A database that refused startup and a readonly
|
|
590
|
-
* registration synchronize nothing, so they are left out.
|
|
1046
|
+
* Pairs each route of an owner in a database with its transport, in route
|
|
1047
|
+
* order. Returns none for a null snapshot, a missing or refused database, and a
|
|
1048
|
+
* missing or readonly owner.
|
|
591
1049
|
*
|
|
592
1050
|
* ### Example
|
|
593
1051
|
*
|
|
@@ -600,7 +1058,8 @@ export type RelaySyncStatus = "syncing" | "synced" | "offline" | "error";
|
|
|
600
1058
|
* testName,
|
|
601
1059
|
* } from "@evolu/common";
|
|
602
1060
|
* import {
|
|
603
|
-
*
|
|
1061
|
+
* relaySyncStateToStatus,
|
|
1062
|
+
* syncStateToRelaySyncStates,
|
|
604
1063
|
* testAppOwner,
|
|
605
1064
|
* type SyncRoute,
|
|
606
1065
|
* type SyncState,
|
|
@@ -609,99 +1068,106 @@ export type RelaySyncStatus = "syncing" | "synced" | "offline" | "error";
|
|
|
609
1068
|
*
|
|
610
1069
|
* const deps = testCreateDeps();
|
|
611
1070
|
* const transport: SyncTransport = {
|
|
1071
|
+
* type: "WebSocket",
|
|
612
1072
|
* id: createId<"SyncTransport">(deps),
|
|
613
1073
|
* label: "wss://relay.example",
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
1074
|
+
* connection: {
|
|
1075
|
+
* type: "Open",
|
|
1076
|
+
* openedAt: Millis.orThrow(800),
|
|
1077
|
+
* error: null,
|
|
1078
|
+
* },
|
|
618
1079
|
* };
|
|
619
1080
|
* const route: SyncRoute = {
|
|
1081
|
+
* type: "Complete",
|
|
620
1082
|
* transportId: transport.id,
|
|
621
|
-
* complete: true,
|
|
622
1083
|
* completeAt: Millis.orThrow(1000),
|
|
623
1084
|
* lastSentAt: Millis.orThrow(900),
|
|
624
1085
|
* lastReceivedAt: Millis.orThrow(1000),
|
|
625
|
-
* error: null,
|
|
626
1086
|
* };
|
|
627
1087
|
* const state: SyncState = {
|
|
628
1088
|
* transports: [transport],
|
|
629
1089
|
* tenants: [
|
|
630
1090
|
* {
|
|
1091
|
+
* type: "Active",
|
|
631
1092
|
* name: testName,
|
|
632
|
-
* refused: false,
|
|
633
1093
|
* owners: [
|
|
634
|
-
* {
|
|
635
|
-
* ownerId: testAppOwner.id,
|
|
636
|
-
* writable: true,
|
|
637
|
-
* transportIds: [transport.id],
|
|
638
|
-
* routes: [route],
|
|
639
|
-
* },
|
|
1094
|
+
* { type: "Writable", ownerId: testAppOwner.id, routes: [route] },
|
|
640
1095
|
* ],
|
|
641
1096
|
* },
|
|
642
1097
|
* ],
|
|
643
1098
|
* };
|
|
644
1099
|
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
1100
|
+
* const relays = syncStateToRelaySyncStates(
|
|
1101
|
+
* state,
|
|
1102
|
+
* testName,
|
|
1103
|
+
* testAppOwner.id,
|
|
1104
|
+
* );
|
|
1105
|
+
* assertEqual(relays, [{ transport, route }]);
|
|
1106
|
+
* assertEqual(relays.map(relaySyncStateToStatus), [{ type: "Synced" }]);
|
|
1107
|
+
* assertEqual(
|
|
1108
|
+
* syncStateToRelaySyncStates(null, testName, testAppOwner.id),
|
|
1109
|
+
* [],
|
|
1110
|
+
* );
|
|
655
1111
|
* ```
|
|
656
1112
|
*/
|
|
657
|
-
export const
|
|
658
|
-
state: SyncState,
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1113
|
+
export const syncStateToRelaySyncStates = (
|
|
1114
|
+
state: SyncState | null,
|
|
1115
|
+
name: Name,
|
|
1116
|
+
ownerId: OwnerId,
|
|
1117
|
+
): ReadonlyArray<RelaySyncState> => {
|
|
1118
|
+
const tenant = state?.tenants.find((tenant) => tenant.name === name);
|
|
1119
|
+
if (!state || tenant?.type !== "Active") return emptyArray;
|
|
1120
|
+
const owner = tenant.owners.find((owner) => owner.ownerId === ownerId);
|
|
1121
|
+
if (owner?.type !== "Writable") return emptyArray;
|
|
1122
|
+
return owner.routes.map((route) => {
|
|
1123
|
+
// A snapshot lists the transport of every route.
|
|
1124
|
+
const transport = state.transports.find(
|
|
1125
|
+
({ id }) => id === route.transportId,
|
|
1126
|
+
);
|
|
1127
|
+
assertNonNullable(transport);
|
|
1128
|
+
return { transport, route };
|
|
1129
|
+
});
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
/** Tells the status of one relay; a failure shows before a skipped change. */
|
|
1133
|
+
export const relaySyncStateToStatus = ({
|
|
1134
|
+
transport,
|
|
1135
|
+
route,
|
|
1136
|
+
}: RelaySyncState): RelaySyncStatus => {
|
|
1137
|
+
switch (route.type) {
|
|
1138
|
+
case "Complete":
|
|
1139
|
+
return syncedSyncStatus;
|
|
1140
|
+
case "Settled":
|
|
1141
|
+
return syncRouteErrorToSyncStatus(route.skippedError);
|
|
1142
|
+
case "Pending": {
|
|
1143
|
+
const error = route.failure ?? route.skippedError;
|
|
1144
|
+
if (error) return syncRouteErrorToSyncStatus(error);
|
|
1145
|
+
return transport.connection.type === "Disconnected"
|
|
1146
|
+
? offlineSyncStatus
|
|
1147
|
+
: syncingSyncStatus;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
// Every status keeps its reference while it is unchanged, so bindings compare
|
|
1153
|
+
// statuses with `===`. The store shares an unchanged error object between
|
|
1154
|
+
// snapshots, and an error gives the same Error status object.
|
|
1155
|
+
const noRelaysSyncStatus: NoRelaysSyncStatus = { type: "NoRelays" };
|
|
1156
|
+
const syncingSyncStatus: SyncingSyncStatus = { type: "Syncing" };
|
|
1157
|
+
const syncedSyncStatus: SyncedSyncStatus = { type: "Synced" };
|
|
1158
|
+
const offlineSyncStatus: OfflineSyncStatus = { type: "Offline" };
|
|
1159
|
+
const errorSyncStatusByError = /*#__PURE__*/ new WeakMap<
|
|
1160
|
+
SyncRouteError,
|
|
1161
|
+
ErrorSyncStatus
|
|
1162
|
+
>();
|
|
1163
|
+
|
|
1164
|
+
const syncRouteErrorToSyncStatus = (error: SyncRouteError): ErrorSyncStatus => {
|
|
1165
|
+
let status = errorSyncStatusByError.get(error);
|
|
1166
|
+
if (!status) {
|
|
1167
|
+
status = { type: "Error", error };
|
|
1168
|
+
errorSyncStatusByError.set(error, status);
|
|
1169
|
+
}
|
|
1170
|
+
return status;
|
|
705
1171
|
};
|
|
706
1172
|
|
|
707
1173
|
export type EvoluInput =
|
|
@@ -744,6 +1210,16 @@ export type EvoluOutput =
|
|
|
744
1210
|
| {
|
|
745
1211
|
readonly type: "OnExport";
|
|
746
1212
|
readonly file: Uint8Array<ArrayBuffer>;
|
|
1213
|
+
}
|
|
1214
|
+
| {
|
|
1215
|
+
/** The mutation with these onComplete callbacks could not be stored. */
|
|
1216
|
+
readonly type: "OnMutateFailed";
|
|
1217
|
+
readonly onCompleteIds: ReadonlyArray<Id>;
|
|
1218
|
+
}
|
|
1219
|
+
| {
|
|
1220
|
+
/** Sent once, when the tenant adds the instance; see Storage. */
|
|
1221
|
+
readonly type: "OnDevicePersistence";
|
|
1222
|
+
readonly devicePersistence: DevicePersistence;
|
|
747
1223
|
};
|
|
748
1224
|
|
|
749
1225
|
export type DbWorkerInput =
|
|
@@ -820,6 +1296,11 @@ export type DbWorkerQueuedResponse =
|
|
|
820
1296
|
>;
|
|
821
1297
|
readonly rowsByQuery: RowsByQueryMap;
|
|
822
1298
|
}
|
|
1299
|
+
| {
|
|
1300
|
+
/** The mutation threw, so it rolled back and nothing was stored. */
|
|
1301
|
+
readonly type: "MutateFailed";
|
|
1302
|
+
readonly error: UnknownError;
|
|
1303
|
+
}
|
|
823
1304
|
| {
|
|
824
1305
|
readonly type: "Query";
|
|
825
1306
|
readonly rowsByQuery: RowsByQueryMap;
|
|
@@ -846,6 +1327,15 @@ export type DbWorkerQueuedResponse =
|
|
|
846
1327
|
readonly clock: Timestamp;
|
|
847
1328
|
readonly ownerId: OwnerId;
|
|
848
1329
|
readonly didWriteMessages: boolean;
|
|
1330
|
+
/**
|
|
1331
|
+
* The first error of a received message the DbWorker skipped while
|
|
1332
|
+
* storing the rest, or null. It does not end the round.
|
|
1333
|
+
*/
|
|
1334
|
+
readonly skippedError:
|
|
1335
|
+
| DecryptWithXChaCha20Poly1305Error
|
|
1336
|
+
| ProtocolInvalidDataError
|
|
1337
|
+
| ProtocolTimestampMismatchError
|
|
1338
|
+
| null;
|
|
849
1339
|
readonly result: Result<
|
|
850
1340
|
ApplyProtocolMessageAsClientResult,
|
|
851
1341
|
ProtocolError | StorageWriteMessagesError | AbortError
|
|
@@ -854,21 +1344,22 @@ export type DbWorkerQueuedResponse =
|
|
|
854
1344
|
};
|
|
855
1345
|
|
|
856
1346
|
/**
|
|
857
|
-
* Tells
|
|
1347
|
+
* Tells what the platform can promise about the databases it stores.
|
|
858
1348
|
*
|
|
859
|
-
*
|
|
860
|
-
* does in Safari's Private Browsing
|
|
1349
|
+
* `NotPersisted` means the platform offers no persistent storage now, as a
|
|
1350
|
+
* browser does in Safari's Private Browsing or a Firefox private window, so the
|
|
1351
|
+
* worker keeps every database in memory; see Storage in the Shared module.
|
|
861
1352
|
*/
|
|
862
|
-
export interface
|
|
863
|
-
readonly
|
|
1353
|
+
export interface DevicePersistenceDep {
|
|
1354
|
+
readonly getDevicePersistence: () => Promise<DevicePersistence>;
|
|
864
1355
|
}
|
|
865
1356
|
|
|
866
1357
|
export type SharedWorkerDeps = WorkerDeps &
|
|
867
1358
|
CreateBroadcastChannelDep &
|
|
868
1359
|
CreateMessageChannelDep &
|
|
869
1360
|
CreateWebSocketDep &
|
|
870
|
-
|
|
871
|
-
|
|
1361
|
+
DevicePersistenceDep &
|
|
1362
|
+
LockManagerDep;
|
|
872
1363
|
|
|
873
1364
|
/**
|
|
874
1365
|
* Coordinates all instances of one named local database within a SharedWorker.
|
|
@@ -898,19 +1389,31 @@ interface EvoluTenant extends AsyncDisposable {
|
|
|
898
1389
|
}
|
|
899
1390
|
|
|
900
1391
|
/** A tenant's part of {@link SyncState}, with its transports still keyed. */
|
|
901
|
-
|
|
1392
|
+
type TenantSyncState = ActiveTenantSyncState | RefusedSyncTenant;
|
|
1393
|
+
|
|
1394
|
+
interface ActiveTenantSyncState extends Typed<"Active"> {
|
|
902
1395
|
readonly name: Name;
|
|
903
|
-
readonly
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1396
|
+
readonly owners: ReadonlyArray<TenantSyncOwner>;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
type TenantSyncOwner = WritableTenantSyncOwner | ReadonlyTenantSyncOwner;
|
|
1400
|
+
|
|
1401
|
+
interface WritableTenantSyncOwner extends Typed<"Writable"> {
|
|
1402
|
+
readonly ownerId: OwnerId;
|
|
1403
|
+
readonly routes: ReadonlyArray<TenantSyncRoute>;
|
|
910
1404
|
}
|
|
911
1405
|
|
|
912
|
-
interface
|
|
1406
|
+
interface ReadonlyTenantSyncOwner extends Typed<"Readonly"> {
|
|
1407
|
+
readonly ownerId: OwnerId;
|
|
1408
|
+
readonly transportKeys: ReadonlyArray<StructuralLookupKey>;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** A tenant's route, with its transport still keyed. */
|
|
1412
|
+
interface TenantSyncRoute {
|
|
913
1413
|
readonly transportKey: StructuralLookupKey;
|
|
1414
|
+
readonly progress: RouteProgress;
|
|
1415
|
+
readonly lastSentAt: Millis | null;
|
|
1416
|
+
readonly lastReceivedAt: Millis | null;
|
|
914
1417
|
}
|
|
915
1418
|
|
|
916
1419
|
/**
|
|
@@ -929,6 +1432,39 @@ const syncRequestTimeout = PositiveMillis.orThrow(90_000);
|
|
|
929
1432
|
*/
|
|
930
1433
|
const maxSyncRequestTimeout = PositiveMillis.orThrow(16 * syncRequestTimeout);
|
|
931
1434
|
|
|
1435
|
+
const connectingSyncConnection: ConnectingSyncConnection = {
|
|
1436
|
+
type: "Connecting",
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* A connection already disconnected keeps when it disconnected, so failed
|
|
1441
|
+
* reconnect attempts only record their error.
|
|
1442
|
+
*/
|
|
1443
|
+
const disconnectSyncConnection = (
|
|
1444
|
+
connection: SyncConnection,
|
|
1445
|
+
at: Millis,
|
|
1446
|
+
error: SyncTransportError | null,
|
|
1447
|
+
): DisconnectedSyncConnection => {
|
|
1448
|
+
switch (connection.type) {
|
|
1449
|
+
case "Connecting":
|
|
1450
|
+
return {
|
|
1451
|
+
type: "Disconnected",
|
|
1452
|
+
disconnectedAt: at,
|
|
1453
|
+
openedAt: null,
|
|
1454
|
+
error,
|
|
1455
|
+
};
|
|
1456
|
+
case "Open":
|
|
1457
|
+
return {
|
|
1458
|
+
type: "Disconnected",
|
|
1459
|
+
disconnectedAt: at,
|
|
1460
|
+
openedAt: connection.openedAt,
|
|
1461
|
+
error: error ?? connection.error,
|
|
1462
|
+
};
|
|
1463
|
+
case "Disconnected":
|
|
1464
|
+
return error ? { ...connection, error } : connection;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
|
|
932
1468
|
/** Where the protocol messages produced by a queued sync request are sent. */
|
|
933
1469
|
type SyncTarget =
|
|
934
1470
|
| { readonly type: "AllTransports" }
|
|
@@ -955,6 +1491,118 @@ const isTargetTransport = (
|
|
|
955
1491
|
): boolean =>
|
|
956
1492
|
target.type === "AllTransports" || target.key === structuralLookup(transport);
|
|
957
1493
|
|
|
1494
|
+
/**
|
|
1495
|
+
* A tenant's progress toward completing a route, per the Synchronization
|
|
1496
|
+
* completion rules. Events move the route to `Pending`, which keeps what the
|
|
1497
|
+
* route must remember until it settles, except that messages stored elsewhere
|
|
1498
|
+
* leave a `Settled` route settled. Refreshing the routes settles a `Pending`
|
|
1499
|
+
* route once the tenant's reconciliation conditions hold, and returns a settled
|
|
1500
|
+
* or complete route to `Pending` when they no longer hold.
|
|
1501
|
+
*/
|
|
1502
|
+
type RouteProgress = PendingRoute | SettledRoute | CompleteRoute;
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* A route whose reconciliation has not ended, or has not been evaluated since
|
|
1506
|
+
* an event.
|
|
1507
|
+
*/
|
|
1508
|
+
interface PendingRoute extends Typed<"Pending"> {
|
|
1509
|
+
/** A round must be sent through the route before it can settle. */
|
|
1510
|
+
readonly roundRequired: boolean;
|
|
1511
|
+
/**
|
|
1512
|
+
* The failure since the route settled, or null. A further failure requests no
|
|
1513
|
+
* round until the route settles.
|
|
1514
|
+
*/
|
|
1515
|
+
readonly failure: SyncRouteError | null;
|
|
1516
|
+
/** The route's skipped message, or null. */
|
|
1517
|
+
readonly skip: RouteSkip | null;
|
|
1518
|
+
/** When the route last became complete, or null. */
|
|
1519
|
+
readonly completeAt: Millis | null;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* A message a route skipped. Its relay offers the message again in every round,
|
|
1524
|
+
* so messages received elsewhere request no round through the route.
|
|
1525
|
+
*/
|
|
1526
|
+
interface RouteSkip {
|
|
1527
|
+
readonly error: SyncRouteError;
|
|
1528
|
+
/**
|
|
1529
|
+
* A round was requested through the route since the skip, and no messages
|
|
1530
|
+
* received elsewhere have been stored since that request, so the route
|
|
1531
|
+
* completes if it settles first.
|
|
1532
|
+
*/
|
|
1533
|
+
readonly isRechecking: boolean;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* A route whose reconciliation has ended, but whose relay may offer a skipped
|
|
1538
|
+
* message or lack messages stored elsewhere, so it is incomplete.
|
|
1539
|
+
*/
|
|
1540
|
+
interface SettledRoute extends Typed<"Settled"> {
|
|
1541
|
+
readonly error: SyncRouteError;
|
|
1542
|
+
readonly completeAt: Millis | null;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
interface CompleteRoute extends Typed<"Complete"> {
|
|
1546
|
+
readonly completeAt: Millis;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const routeToPending = (progress: RouteProgress): PendingRoute => {
|
|
1550
|
+
switch (progress.type) {
|
|
1551
|
+
case "Pending":
|
|
1552
|
+
return progress;
|
|
1553
|
+
case "Settled":
|
|
1554
|
+
return {
|
|
1555
|
+
type: "Pending",
|
|
1556
|
+
roundRequired: false,
|
|
1557
|
+
failure: null,
|
|
1558
|
+
skip: { error: progress.error, isRechecking: false },
|
|
1559
|
+
completeAt: progress.completeAt,
|
|
1560
|
+
};
|
|
1561
|
+
case "Complete":
|
|
1562
|
+
return {
|
|
1563
|
+
type: "Pending",
|
|
1564
|
+
roundRequired: false,
|
|
1565
|
+
failure: null,
|
|
1566
|
+
skip: null,
|
|
1567
|
+
completeAt: progress.completeAt,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Converts an error to a {@link SyncRouteError}: a caught value becomes an
|
|
1574
|
+
* {@link UnknownError}, and a {@link ProtocolInvalidDataError} leaves out its
|
|
1575
|
+
* data.
|
|
1576
|
+
*/
|
|
1577
|
+
const errorToSyncRouteError = (
|
|
1578
|
+
error:
|
|
1579
|
+
| ProtocolError
|
|
1580
|
+
| StorageWriteMessagesError
|
|
1581
|
+
| DecryptWithXChaCha20Poly1305Error
|
|
1582
|
+
| Typed<"WriteFailed">
|
|
1583
|
+
| Typed<"SyncFailed">,
|
|
1584
|
+
at: Millis,
|
|
1585
|
+
): SyncRouteError => {
|
|
1586
|
+
// A caught value inside an error becomes an UnknownError, and a
|
|
1587
|
+
// ProtocolInvalidDataError leaves out its data, which can be a whole frame.
|
|
1588
|
+
if (error.type === "ProtocolInvalidDataError") {
|
|
1589
|
+
const { data: _data, ...rest } = error;
|
|
1590
|
+
return { ...rest, error: createUnknownError(rest.error), at };
|
|
1591
|
+
}
|
|
1592
|
+
if (error.type === "DecryptWithXChaCha20Poly1305Error")
|
|
1593
|
+
return { ...error, error: createUnknownError(error.error), at };
|
|
1594
|
+
return { ...error, at };
|
|
1595
|
+
};
|
|
1596
|
+
|
|
1597
|
+
const failRoute = (
|
|
1598
|
+
progress: RouteProgress,
|
|
1599
|
+
failure: SyncRouteError,
|
|
1600
|
+
): PendingRoute => ({
|
|
1601
|
+
...routeToPending(progress),
|
|
1602
|
+
roundRequired: true,
|
|
1603
|
+
failure,
|
|
1604
|
+
});
|
|
1605
|
+
|
|
958
1606
|
type EvoluTenantDeps = SharedWorkerDeps &
|
|
959
1607
|
PostConsoleEntryOrErrorDep &
|
|
960
1608
|
PublishSyncStateDep &
|
|
@@ -1111,23 +1759,25 @@ export const initSharedWorker =
|
|
|
1111
1759
|
workerId,
|
|
1112
1760
|
syncStateChannelName,
|
|
1113
1761
|
});
|
|
1114
|
-
if (isPersistentStorageUnavailable) {
|
|
1115
|
-
port.postMessage({ type: "StorageUnavailable" });
|
|
1116
|
-
}
|
|
1117
1762
|
});
|
|
1118
1763
|
};
|
|
1119
1764
|
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1765
|
+
// Held while this worker runs. Its DbWorkers stop once they can take it,
|
|
1766
|
+
// because a Dispose posted right before this worker closes can be lost, as
|
|
1767
|
+
// in Firefox. Taken before the build lock, so nothing delays the end of
|
|
1768
|
+
// starting once that lock is held.
|
|
1769
|
+
disposer.use(await run.ok(acquireLeaderLock(workerId)));
|
|
1770
|
+
|
|
1771
|
+
// Released after every tenant is disposed and has told its DbWorker to
|
|
1772
|
+
// stop. Earlier releases take the same lock in their leader tab; see
|
|
1773
|
+
// Builds.
|
|
1122
1774
|
disposer.use(await run.ok(acquireLeaderLock("tab")));
|
|
1123
1775
|
starting.dispose();
|
|
1124
1776
|
|
|
1125
|
-
// Checked once, before any DbWorker starts, so
|
|
1126
|
-
// worker, replacements included, keeps its
|
|
1127
|
-
// Storage.
|
|
1128
|
-
const
|
|
1129
|
-
deps.isPersistentStorageAvailable !== undefined &&
|
|
1130
|
-
!(await deps.isPersistentStorageAvailable());
|
|
1777
|
+
// Checked once, before any DbWorker starts, so without persistent
|
|
1778
|
+
// storage every DbWorker of this worker, replacements included, keeps its
|
|
1779
|
+
// database in memory; see Storage.
|
|
1780
|
+
const platformDevicePersistence = await deps.getDevicePersistence();
|
|
1131
1781
|
|
|
1132
1782
|
disposer.defer(
|
|
1133
1783
|
deps.consoleStoreOutputEntry.subscribe(() => {
|
|
@@ -1156,13 +1806,12 @@ export const initSharedWorker =
|
|
|
1156
1806
|
* another, so a reply can wait behind another owner's large frame. It
|
|
1157
1807
|
* doubles after each timeout and survives reconnects until nothing is
|
|
1158
1808
|
* outstanding and every route through the transport of a database that
|
|
1159
|
-
* has not refused startup
|
|
1809
|
+
* has not refused startup has settled.
|
|
1160
1810
|
*/
|
|
1161
1811
|
timeout: PositiveMillis;
|
|
1162
1812
|
socket: WebSocket | null;
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
error: SyncTransportError | null;
|
|
1813
|
+
/** Socket events drive it, so publishing never reads the socket. */
|
|
1814
|
+
connection: SyncConnection;
|
|
1166
1815
|
/** Armed while a request is outstanding on an open socket. */
|
|
1167
1816
|
timeoutId: TimeoutId | null;
|
|
1168
1817
|
}
|
|
@@ -1181,62 +1830,96 @@ export const initSharedWorker =
|
|
|
1181
1830
|
isPublishScheduled = false;
|
|
1182
1831
|
if (isDisposed) return;
|
|
1183
1832
|
const transports = [...transportsByKey.values()].map(
|
|
1184
|
-
({
|
|
1185
|
-
|
|
1186
|
-
label,
|
|
1187
|
-
socket,
|
|
1188
|
-
openedAt,
|
|
1189
|
-
closedAt,
|
|
1190
|
-
error,
|
|
1191
|
-
}): SyncTransport => ({
|
|
1833
|
+
({ id, label, connection }): SyncTransport => ({
|
|
1834
|
+
type: "WebSocket",
|
|
1192
1835
|
id,
|
|
1193
1836
|
label,
|
|
1194
|
-
|
|
1195
|
-
// reads a disposed one.
|
|
1196
|
-
readyState: socket?.getReadyState() ?? "connecting",
|
|
1197
|
-
openedAt,
|
|
1198
|
-
closedAt,
|
|
1199
|
-
error,
|
|
1837
|
+
connection,
|
|
1200
1838
|
}),
|
|
1201
1839
|
);
|
|
1840
|
+
// A grown timeout lasts while a request is outstanding on the socket
|
|
1841
|
+
// or a database that has not refused startup has an unsettled route
|
|
1842
|
+
// through it. Every change to either publishes, including a route that
|
|
1843
|
+
// goes away without settling.
|
|
1844
|
+
const unsettledTransportIds = new Set<SyncTransportId>();
|
|
1202
1845
|
const tenants = [...currentTenantsByName.values()].map(
|
|
1203
1846
|
(tenant): SyncTenant => {
|
|
1204
|
-
const
|
|
1847
|
+
const state = tenant.getSyncTenant();
|
|
1848
|
+
if (state.type === "Refused") return state;
|
|
1205
1849
|
return {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
owners: owners.map(
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1850
|
+
type: "Active",
|
|
1851
|
+
name: state.name,
|
|
1852
|
+
owners: state.owners.map((owner): SyncTenantOwner =>
|
|
1853
|
+
owner.type === "Readonly"
|
|
1854
|
+
? {
|
|
1855
|
+
type: "Readonly",
|
|
1856
|
+
ownerId: owner.ownerId,
|
|
1857
|
+
transportIds: owner.transportKeys.flatMap((key) => {
|
|
1858
|
+
const entry = transportsByKey.get(key);
|
|
1859
|
+
return entry ? [entry.id] : [];
|
|
1860
|
+
}),
|
|
1861
|
+
}
|
|
1862
|
+
: {
|
|
1863
|
+
type: "Writable",
|
|
1864
|
+
ownerId: owner.ownerId,
|
|
1865
|
+
routes: owner.routes.flatMap(
|
|
1866
|
+
({
|
|
1867
|
+
transportKey,
|
|
1868
|
+
progress,
|
|
1869
|
+
lastSentAt,
|
|
1870
|
+
lastReceivedAt,
|
|
1871
|
+
}): Array<SyncRoute> => {
|
|
1872
|
+
const entry = transportsByKey.get(transportKey);
|
|
1873
|
+
if (!entry) return [];
|
|
1874
|
+
const transportId = entry.id;
|
|
1875
|
+
if (progress.type !== "Pending") {
|
|
1876
|
+
// Only a sent round settles a route or completes
|
|
1877
|
+
// it.
|
|
1878
|
+
assertNonNullable(lastSentAt);
|
|
1879
|
+
return [
|
|
1880
|
+
progress.type === "Complete"
|
|
1881
|
+
? {
|
|
1882
|
+
type: "Complete",
|
|
1883
|
+
transportId,
|
|
1884
|
+
completeAt: progress.completeAt,
|
|
1885
|
+
lastSentAt,
|
|
1886
|
+
lastReceivedAt,
|
|
1887
|
+
}
|
|
1888
|
+
: {
|
|
1889
|
+
type: "Settled",
|
|
1890
|
+
transportId,
|
|
1891
|
+
skippedError: progress.error,
|
|
1892
|
+
completeAt: progress.completeAt,
|
|
1893
|
+
lastSentAt,
|
|
1894
|
+
lastReceivedAt,
|
|
1895
|
+
},
|
|
1896
|
+
];
|
|
1897
|
+
}
|
|
1898
|
+
// Only a database that has not refused startup
|
|
1899
|
+
// publishes routes.
|
|
1900
|
+
unsettledTransportIds.add(transportId);
|
|
1901
|
+
return [
|
|
1902
|
+
{
|
|
1903
|
+
type: "Pending",
|
|
1904
|
+
transportId,
|
|
1905
|
+
failure: progress.failure,
|
|
1906
|
+
skippedError: progress.skip?.error ?? null,
|
|
1907
|
+
completeAt: progress.completeAt,
|
|
1908
|
+
lastSentAt,
|
|
1909
|
+
lastReceivedAt,
|
|
1910
|
+
},
|
|
1911
|
+
];
|
|
1912
|
+
},
|
|
1913
|
+
),
|
|
1914
|
+
},
|
|
1221
1915
|
),
|
|
1222
1916
|
};
|
|
1223
1917
|
},
|
|
1224
1918
|
);
|
|
1225
|
-
// A grown timeout lasts while a request is outstanding on the socket
|
|
1226
|
-
// or a database that has not refused startup has an incomplete route
|
|
1227
|
-
// through it. Every change to either publishes, including a route that
|
|
1228
|
-
// goes away without completing.
|
|
1229
|
-
const incompleteTransportIds = new Set<SyncTransportId>();
|
|
1230
|
-
for (const { refused, owners } of tenants) {
|
|
1231
|
-
if (refused) continue;
|
|
1232
|
-
for (const { routes } of owners)
|
|
1233
|
-
for (const { transportId, complete } of routes)
|
|
1234
|
-
if (!complete) incompleteTransportIds.add(transportId);
|
|
1235
|
-
}
|
|
1236
1919
|
for (const entry of transportsByKey.values())
|
|
1237
1920
|
if (
|
|
1238
1921
|
entry.outstandingByOwnerId.size === 0 &&
|
|
1239
|
-
!
|
|
1922
|
+
!unsettledTransportIds.has(entry.id)
|
|
1240
1923
|
)
|
|
1241
1924
|
entry.timeout = syncRequestTimeout;
|
|
1242
1925
|
syncStateBroadcastChannel.postMessage({ transports, tenants });
|
|
@@ -1295,10 +1978,14 @@ export const initSharedWorker =
|
|
|
1295
1978
|
);
|
|
1296
1979
|
// Reconnecting abandons the connection and starts a fresh retry
|
|
1297
1980
|
// schedule. The socket reports no close for it, so the transport
|
|
1298
|
-
//
|
|
1981
|
+
// disconnects here.
|
|
1299
1982
|
entry.socket?.reconnect();
|
|
1300
|
-
// `now` is monotonic; the reported
|
|
1301
|
-
entry.
|
|
1983
|
+
// `now` is monotonic; the reported time is wall clock.
|
|
1984
|
+
entry.connection = disconnectSyncConnection(
|
|
1985
|
+
entry.connection,
|
|
1986
|
+
deps.time.now(),
|
|
1987
|
+
null,
|
|
1988
|
+
);
|
|
1302
1989
|
refreshAllSyncRoutes();
|
|
1303
1990
|
publishSyncState();
|
|
1304
1991
|
}, delay);
|
|
@@ -1346,9 +2033,7 @@ export const initSharedWorker =
|
|
|
1346
2033
|
outstandingByOwnerId: new Map(),
|
|
1347
2034
|
timeout: syncRequestTimeout,
|
|
1348
2035
|
socket: null,
|
|
1349
|
-
|
|
1350
|
-
closedAt: null,
|
|
1351
|
-
error: null,
|
|
2036
|
+
connection: connectingSyncConnection,
|
|
1352
2037
|
timeoutId: null,
|
|
1353
2038
|
};
|
|
1354
2039
|
await using disposer = new AsyncDisposableStack();
|
|
@@ -1371,7 +2056,16 @@ export const initSharedWorker =
|
|
|
1371
2056
|
// answered.
|
|
1372
2057
|
entry.outstandingByOwnerId.clear();
|
|
1373
2058
|
clearSyncRequestTimeout(entry);
|
|
1374
|
-
|
|
2059
|
+
// A connection that opens keeps the last error of an earlier
|
|
2060
|
+
// one.
|
|
2061
|
+
entry.connection = {
|
|
2062
|
+
type: "Open",
|
|
2063
|
+
openedAt: run.deps.time.now(),
|
|
2064
|
+
error:
|
|
2065
|
+
entry.connection.type === "Connecting"
|
|
2066
|
+
? null
|
|
2067
|
+
: entry.connection.error,
|
|
2068
|
+
};
|
|
1375
2069
|
publishSyncState();
|
|
1376
2070
|
const ownerIds = transports.getClaimsForResource(transport);
|
|
1377
2071
|
console.debug("transportOpen", {
|
|
@@ -1395,7 +2089,11 @@ export const initSharedWorker =
|
|
|
1395
2089
|
code: event.code,
|
|
1396
2090
|
wasClean: event.wasClean,
|
|
1397
2091
|
});
|
|
1398
|
-
entry.
|
|
2092
|
+
entry.connection = disconnectSyncConnection(
|
|
2093
|
+
entry.connection,
|
|
2094
|
+
run.deps.time.now(),
|
|
2095
|
+
null,
|
|
2096
|
+
);
|
|
1399
2097
|
clearSyncRequestTimeout(entry);
|
|
1400
2098
|
refreshAllSyncRoutes();
|
|
1401
2099
|
publishSyncState();
|
|
@@ -1406,10 +2104,17 @@ export const initSharedWorker =
|
|
|
1406
2104
|
url: transport.url,
|
|
1407
2105
|
type: error.type,
|
|
1408
2106
|
});
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
2107
|
+
// Every error disconnects. A browser reports a failed
|
|
2108
|
+
// attempt only as an error, because the socket stops
|
|
2109
|
+
// listening before its close arrives. A connection error
|
|
2110
|
+
// arrives once the socket is closed, before its close, and
|
|
2111
|
+
// exhausted retries end reconnecting.
|
|
2112
|
+
const now = run.deps.time.now();
|
|
2113
|
+
entry.connection = disconnectSyncConnection(
|
|
2114
|
+
entry.connection,
|
|
2115
|
+
now,
|
|
2116
|
+
{ type: error.type, at: now },
|
|
2117
|
+
);
|
|
1413
2118
|
refreshAllSyncRoutes();
|
|
1414
2119
|
publishSyncState();
|
|
1415
2120
|
},
|
|
@@ -1474,9 +2179,8 @@ export const initSharedWorker =
|
|
|
1474
2179
|
// LIFO: the transport drops its timer and its socket reference
|
|
1475
2180
|
// before the socket is disposed. `disposable` guards every method
|
|
1476
2181
|
// of the socket the claims lease, and disposing the socket awaits
|
|
1477
|
-
// its retry, so
|
|
1478
|
-
//
|
|
1479
|
-
// read a disposed one.
|
|
2182
|
+
// its retry, so this entry stays registered meanwhile. It keeps its
|
|
2183
|
+
// last connection and holds no socket to reconnect.
|
|
1480
2184
|
disposer.defer(() => {
|
|
1481
2185
|
clearSyncRequestTimeout(entry);
|
|
1482
2186
|
entry.socket = null;
|
|
@@ -1535,13 +2239,17 @@ export const initSharedWorker =
|
|
|
1535
2239
|
const tenantsByName = disposer.use(
|
|
1536
2240
|
await sharedWorkerRun.ok(
|
|
1537
2241
|
createSharedResourceByKey(
|
|
1538
|
-
(message: ExtractTyped<SharedWorkerInput, "CreateEvolu">) =>
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
2242
|
+
(message: ExtractTyped<SharedWorkerInput, "CreateEvolu">) => {
|
|
2243
|
+
const memoryOnly =
|
|
2244
|
+
message.memoryOnly ||
|
|
2245
|
+
platformDevicePersistence === "NotPersisted";
|
|
2246
|
+
return createEvoluTenant(
|
|
2247
|
+
{ ...message, memoryOnly },
|
|
2248
|
+
memoryOnly ? "NotPersisted" : platformDevicePersistence,
|
|
1543
2249
|
currentTenantsByName,
|
|
1544
|
-
|
|
2250
|
+
workerId,
|
|
2251
|
+
);
|
|
2252
|
+
},
|
|
1545
2253
|
{
|
|
1546
2254
|
idleDisposeAfter: "3s",
|
|
1547
2255
|
lookup: (message) => message.name,
|
|
@@ -1570,7 +2278,9 @@ const createEvoluTenant =
|
|
|
1570
2278
|
encryptionKey,
|
|
1571
2279
|
memoryOnly,
|
|
1572
2280
|
}: ExtractTyped<SharedWorkerInput, "CreateEvolu">,
|
|
2281
|
+
devicePersistence: DevicePersistence,
|
|
1573
2282
|
currentTenantsByName: Map<Name, BorrowedResource<EvoluTenant>>,
|
|
2283
|
+
workerId: SharedWorkerId,
|
|
1574
2284
|
): Task<EvoluTenant, never, EvoluTenantDeps> =>
|
|
1575
2285
|
async (run) => {
|
|
1576
2286
|
await using disposer = new AsyncDisposableStack();
|
|
@@ -1741,6 +2451,7 @@ const createEvoluTenant =
|
|
|
1741
2451
|
sqliteSchema,
|
|
1742
2452
|
encryptionKey,
|
|
1743
2453
|
memoryOnly,
|
|
2454
|
+
sharedWorkerId: workerId,
|
|
1744
2455
|
port: dbWorkerChannel.port1.native,
|
|
1745
2456
|
},
|
|
1746
2457
|
[dbWorkerChannel.port1.native],
|
|
@@ -1896,20 +2607,16 @@ const createEvoluTenant =
|
|
|
1896
2607
|
}
|
|
1897
2608
|
};
|
|
1898
2609
|
|
|
1899
|
-
|
|
2610
|
+
// Disposal does not wait for the DbWorker. It holds the database lock
|
|
2611
|
+
// until Dispose arrives, its tab closes, or this worker ends, and the next
|
|
2612
|
+
// DbWorker for this database, of this worker or another build, waits for
|
|
2613
|
+
// that lock before it reads the clock. A requested DbWorker that reports
|
|
2614
|
+
// in later gets Dispose from the isDisposing check.
|
|
2615
|
+
disposer.defer(() => {
|
|
1900
2616
|
isDisposing = true;
|
|
1901
2617
|
dbWorkerPort?.postMessage({ type: "Dispose" });
|
|
1902
2618
|
dbWorkerPort = null;
|
|
1903
2619
|
activeDispatch = null;
|
|
1904
|
-
|
|
1905
|
-
// The DbWorker holds this tenant leader lock while it is alive. Tenant
|
|
1906
|
-
// disposal sends Dispose, then acquires the same lock to wait until the
|
|
1907
|
-
// DbWorker releases it: either because Dispose was delivered or because
|
|
1908
|
-
// the hosting tab closed. A worker requested from a later tab leader may
|
|
1909
|
-
// be queued for the lock first; it is told to stop when it reports in.
|
|
1910
|
-
// The wait is unabortable because tenant disposal must finish even after
|
|
1911
|
-
// tenantRun receives an abort request.
|
|
1912
|
-
await using _ = await tenantRun.ok(acquireLeaderLock(name));
|
|
1913
2620
|
});
|
|
1914
2621
|
|
|
1915
2622
|
const handleResponseForEvolu = (
|
|
@@ -1996,23 +2703,47 @@ const createEvoluTenant =
|
|
|
1996
2703
|
break;
|
|
1997
2704
|
}
|
|
1998
2705
|
|
|
2706
|
+
case "MutateFailed": {
|
|
2707
|
+
// The tab shows it, and the instance releases the mutation's
|
|
2708
|
+
// onComplete callbacks without running them. A write outlives its
|
|
2709
|
+
// instance, so without one, every tab is told.
|
|
2710
|
+
//
|
|
2711
|
+
// A replay after leader replacement can fail although the earlier
|
|
2712
|
+
// leader committed the write and only its answer was lost. The tab
|
|
2713
|
+
// then shows an error for a stored write, and its onComplete
|
|
2714
|
+
// callbacks never run. Nothing is lost or reused: the replacement
|
|
2715
|
+
// adopted the stored clock, refreshed every instance's queries, and
|
|
2716
|
+
// reconciles every used owner.
|
|
2717
|
+
const { error } = response.message;
|
|
2718
|
+
if (instance) {
|
|
2719
|
+
assertSame(first.message.type, "Mutate");
|
|
2720
|
+
instance.tabPort.postMessage({ type: "Error", error });
|
|
2721
|
+
instance.port.postMessage({
|
|
2722
|
+
type: "OnMutateFailed",
|
|
2723
|
+
onCompleteIds: first.message.onCompleteIds,
|
|
2724
|
+
});
|
|
2725
|
+
} else {
|
|
2726
|
+
deps.postConsoleEntryOrError({ type: "Error", error });
|
|
2727
|
+
}
|
|
2728
|
+
break;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
1999
2731
|
case "Export":
|
|
2000
2732
|
instance?.port.postMessage(
|
|
2001
2733
|
{ type: "OnExport", file: response.message.file },
|
|
2002
2734
|
[response.message.file.buffer],
|
|
2003
2735
|
);
|
|
2004
2736
|
break;
|
|
2737
|
+
|
|
2738
|
+
default:
|
|
2739
|
+
exhaustiveCheck(response.message);
|
|
2005
2740
|
}
|
|
2006
2741
|
};
|
|
2007
2742
|
|
|
2008
2743
|
interface RouteState {
|
|
2009
|
-
|
|
2010
|
-
roundRequired: boolean;
|
|
2011
|
-
complete: boolean;
|
|
2012
|
-
completeAt: Millis | null;
|
|
2744
|
+
progress: RouteProgress;
|
|
2013
2745
|
lastSentAt: Millis | null;
|
|
2014
2746
|
lastReceivedAt: Millis | null;
|
|
2015
|
-
error: SyncRouteError | null;
|
|
2016
2747
|
}
|
|
2017
2748
|
const routesByOwnerIdByKey = new Map<
|
|
2018
2749
|
StructuralLookupKey,
|
|
@@ -2031,12 +2762,15 @@ const createEvoluTenant =
|
|
|
2031
2762
|
let route = routesByOwnerId.get(ownerId);
|
|
2032
2763
|
if (!route) {
|
|
2033
2764
|
route = {
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2765
|
+
progress: {
|
|
2766
|
+
type: "Pending",
|
|
2767
|
+
roundRequired: true,
|
|
2768
|
+
failure: null,
|
|
2769
|
+
skip: null,
|
|
2770
|
+
completeAt: null,
|
|
2771
|
+
},
|
|
2037
2772
|
lastSentAt: null,
|
|
2038
2773
|
lastReceivedAt: null,
|
|
2039
|
-
error: null,
|
|
2040
2774
|
};
|
|
2041
2775
|
routesByOwnerId.set(ownerId, route);
|
|
2042
2776
|
}
|
|
@@ -2099,20 +2833,28 @@ const createEvoluTenant =
|
|
|
2099
2833
|
// worker answers it. Local-only changes create no synchronization
|
|
2100
2834
|
// work.
|
|
2101
2835
|
const hasQueuedWrite = pendingWriteCountByOwnerId.has(ownerId);
|
|
2102
|
-
|
|
2103
|
-
// queued writes without uploading them.
|
|
2104
|
-
const complete =
|
|
2105
|
-
startupError === null &&
|
|
2836
|
+
const isReconciled =
|
|
2106
2837
|
isOpen &&
|
|
2107
2838
|
deps.syncRequests.getOutstanding(ownerId, key) === 0 &&
|
|
2108
2839
|
!hasQueuedApply &&
|
|
2109
|
-
!hasQueuedWrite
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
route.
|
|
2840
|
+
!hasQueuedWrite;
|
|
2841
|
+
// Settling drops the failure, so the next one requests a round
|
|
2842
|
+
// again. A route with a skip it is not rechecking settles without
|
|
2843
|
+
// completing, because its relay may offer the skipped message or
|
|
2844
|
+
// lack messages stored elsewhere.
|
|
2845
|
+
const pending = routeToPending(route.progress);
|
|
2846
|
+
route.progress =
|
|
2847
|
+
!isReconciled || pending.roundRequired
|
|
2848
|
+
? pending
|
|
2849
|
+
: pending.skip && !pending.skip.isRechecking
|
|
2850
|
+
? {
|
|
2851
|
+
type: "Settled",
|
|
2852
|
+
error: pending.skip.error,
|
|
2853
|
+
completeAt: pending.completeAt,
|
|
2854
|
+
}
|
|
2855
|
+
: route.progress.type === "Complete"
|
|
2856
|
+
? route.progress
|
|
2857
|
+
: { type: "Complete", completeAt: deps.time.now() };
|
|
2116
2858
|
}
|
|
2117
2859
|
}
|
|
2118
2860
|
};
|
|
@@ -2144,8 +2886,10 @@ const createEvoluTenant =
|
|
|
2144
2886
|
.get(structuralLookup(transport))
|
|
2145
2887
|
?.get(ownerId);
|
|
2146
2888
|
if (!route) return;
|
|
2147
|
-
route.
|
|
2148
|
-
|
|
2889
|
+
route.progress = failRoute(route.progress, {
|
|
2890
|
+
type: "SyncFailed",
|
|
2891
|
+
at: now,
|
|
2892
|
+
});
|
|
2149
2893
|
});
|
|
2150
2894
|
}
|
|
2151
2895
|
deps.publishSyncState();
|
|
@@ -2159,7 +2903,14 @@ const createEvoluTenant =
|
|
|
2159
2903
|
const { ownerId, result } = response.message;
|
|
2160
2904
|
const error = result.ok ? null : result.error;
|
|
2161
2905
|
const isAborted = error?.type === "AbortError";
|
|
2162
|
-
|
|
2906
|
+
// An aborted apply reports no skipped message.
|
|
2907
|
+
const skippedError = isAborted ? null : response.message.skippedError;
|
|
2908
|
+
let failure:
|
|
2909
|
+
| ProtocolError
|
|
2910
|
+
| StorageWriteMessagesError
|
|
2911
|
+
| Typed<"WriteFailed">
|
|
2912
|
+
| Typed<"SyncFailed">
|
|
2913
|
+
| null = null;
|
|
2163
2914
|
if (isAborted) {
|
|
2164
2915
|
// An abort proves no convergence. A local sibling copy affects
|
|
2165
2916
|
// every route; a relay frame affects only its source route.
|
|
@@ -2168,17 +2919,22 @@ const createEvoluTenant =
|
|
|
2168
2919
|
const key = structuralLookup(transport);
|
|
2169
2920
|
if (source.type === "Transport" && source.key !== key) return;
|
|
2170
2921
|
const route = routesByOwnerIdByKey.get(key)?.get(ownerId);
|
|
2171
|
-
if (route)
|
|
2922
|
+
if (route)
|
|
2923
|
+
route.progress = {
|
|
2924
|
+
...routeToPending(route.progress),
|
|
2925
|
+
roundRequired: true,
|
|
2926
|
+
};
|
|
2172
2927
|
});
|
|
2173
2928
|
} else if (error !== null) {
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
});
|
|
2929
|
+
// A relay's error shows on its route, not as an EvoluError,
|
|
2930
|
+
// because it belongs to one relay and often repeats in every
|
|
2931
|
+
// round. A sibling copy's error is reported below.
|
|
2932
|
+
failure = error;
|
|
2179
2933
|
} else if (result.ok && result.value.type === "Failed") {
|
|
2180
|
-
failure =
|
|
2181
|
-
|
|
2934
|
+
failure = {
|
|
2935
|
+
type:
|
|
2936
|
+
result.value.cause === "Write" ? "WriteFailed" : "SyncFailed",
|
|
2937
|
+
};
|
|
2182
2938
|
}
|
|
2183
2939
|
if (source.type === "Transport") {
|
|
2184
2940
|
// A registration or claim may have been removed while this apply
|
|
@@ -2188,39 +2944,69 @@ const createEvoluTenant =
|
|
|
2188
2944
|
const now = deps.time.now();
|
|
2189
2945
|
// An aborted apply applied nothing.
|
|
2190
2946
|
if (!isAborted) route.lastReceivedAt = now;
|
|
2947
|
+
// A skipped message is recorded as the route's skip, not a
|
|
2948
|
+
// failure, and does not end the round, whose response below is
|
|
2949
|
+
// still sent, so it requests no round. The relay offers the
|
|
2950
|
+
// message again in every later round, so the route stays
|
|
2951
|
+
// incomplete until a round requested through it settles without
|
|
2952
|
+
// skipping a message and without messages stored elsewhere since
|
|
2953
|
+
// that request.
|
|
2954
|
+
if (skippedError !== null)
|
|
2955
|
+
route.progress = {
|
|
2956
|
+
...routeToPending(route.progress),
|
|
2957
|
+
skip: {
|
|
2958
|
+
error: errorToSyncRouteError(skippedError, now),
|
|
2959
|
+
isRechecking: false,
|
|
2960
|
+
},
|
|
2961
|
+
};
|
|
2191
2962
|
if (failure !== null) {
|
|
2192
|
-
// The first failure since the route
|
|
2963
|
+
// The first failure since the route settled requests one
|
|
2193
2964
|
// round. Further failures wait for an explicit request or a
|
|
2194
2965
|
// reopen, even after a converged reply, which may answer
|
|
2195
2966
|
// another request.
|
|
2196
|
-
|
|
2967
|
+
const requestsRetry =
|
|
2968
|
+
route.progress.type !== "Pending" ||
|
|
2969
|
+
route.progress.failure === null;
|
|
2970
|
+
route.progress = failRoute(
|
|
2971
|
+
route.progress,
|
|
2972
|
+
errorToSyncRouteError(failure, now),
|
|
2973
|
+
);
|
|
2974
|
+
if (requestsRetry)
|
|
2197
2975
|
requestCreateSyncMessages(new Set([ownerId]), source);
|
|
2198
|
-
route.roundRequired = true;
|
|
2199
|
-
route.error = { type: failure, at: now };
|
|
2200
2976
|
}
|
|
2201
2977
|
}
|
|
2202
|
-
} else if (failure !== null) {
|
|
2203
|
-
// A sibling's
|
|
2204
|
-
//
|
|
2205
|
-
|
|
2978
|
+
} else if (failure !== null || skippedError !== null) {
|
|
2979
|
+
// A sibling's copy comes from this worker, not from a relay, so no
|
|
2980
|
+
// route shows its failure. It is a Broadcast, which carries no
|
|
2981
|
+
// relay error, so an error or a skip means a bug, such as
|
|
2982
|
+
// databases holding different keys for the owner, and is reported
|
|
2983
|
+
// as an unexpected failure. A Failed result was logged, which
|
|
2984
|
+
// reports it already. An error is the failure, which is never an
|
|
2985
|
+
// abort, and like a route, the report leaves out its frame.
|
|
2986
|
+
const unexpected = error !== null ? failure : skippedError;
|
|
2987
|
+
if (unexpected !== null)
|
|
2988
|
+
deps.postConsoleEntryOrError({
|
|
2989
|
+
type: "Error",
|
|
2990
|
+
error: createUnknownError(
|
|
2991
|
+
errorToSyncRouteError(unexpected, deps.time.now()),
|
|
2992
|
+
),
|
|
2993
|
+
});
|
|
2994
|
+
// Some of a sibling's messages were not stored; rounds fetch them
|
|
2995
|
+
// from the relays, whose routes then record a skip for any message
|
|
2996
|
+
// this database skips.
|
|
2997
|
+
requestRoundsForReceivedMessages(ownerId, {
|
|
2998
|
+
except: null,
|
|
2999
|
+
afterQueuedWrites: true,
|
|
3000
|
+
});
|
|
2206
3001
|
}
|
|
2207
3002
|
|
|
2208
3003
|
if (response.message.didWriteMessages) {
|
|
2209
3004
|
refreshQueries();
|
|
2210
3005
|
// Reconcile newly stored messages through each other transport.
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
if (isTargetTransport(target, transport)) return;
|
|
2215
|
-
keys.push(structuralLookup(transport));
|
|
3006
|
+
requestRoundsForReceivedMessages(ownerId, {
|
|
3007
|
+
except: target,
|
|
3008
|
+
afterQueuedWrites: false,
|
|
2216
3009
|
});
|
|
2217
|
-
for (const key of keys) {
|
|
2218
|
-
requestCreateSyncMessages(
|
|
2219
|
-
new Set([ownerId]),
|
|
2220
|
-
{ type: "Transport", key },
|
|
2221
|
-
{ afterQueuedWrites: false },
|
|
2222
|
-
);
|
|
2223
|
-
}
|
|
2224
3010
|
}
|
|
2225
3011
|
|
|
2226
3012
|
if (result.ok) {
|
|
@@ -2299,7 +3085,11 @@ const createEvoluTenant =
|
|
|
2299
3085
|
deps.syncRequests.noteSent(ownerId, key);
|
|
2300
3086
|
const route = getRoute(ownerId, key);
|
|
2301
3087
|
route.lastSentAt = deps.time.now();
|
|
2302
|
-
if (isRound)
|
|
3088
|
+
if (isRound)
|
|
3089
|
+
route.progress = {
|
|
3090
|
+
...routeToPending(route.progress),
|
|
3091
|
+
roundRequired: false,
|
|
3092
|
+
};
|
|
2303
3093
|
},
|
|
2304
3094
|
);
|
|
2305
3095
|
}
|
|
@@ -2307,6 +3097,48 @@ const createEvoluTenant =
|
|
|
2307
3097
|
if (protocolMessagesByOwnerId.size > 0) deps.refreshAllSyncRoutes();
|
|
2308
3098
|
};
|
|
2309
3099
|
|
|
3100
|
+
/**
|
|
3101
|
+
* Requests a round through each transport claimed for the owner outside
|
|
3102
|
+
* `except`, after messages from elsewhere were stored or a sibling copy was
|
|
3103
|
+
* not fully stored. Rounds toward the same transport coalesce whatever
|
|
3104
|
+
* their source. A route that skipped a message gets none, because its relay
|
|
3105
|
+
* would offer that message again, and a route rechecking one stops
|
|
3106
|
+
* rechecking, because its round may have read the database before this
|
|
3107
|
+
* event. A later requested round through such a route reconciles it.
|
|
3108
|
+
*/
|
|
3109
|
+
const requestRoundsForReceivedMessages = (
|
|
3110
|
+
ownerId: OwnerId,
|
|
3111
|
+
{
|
|
3112
|
+
except,
|
|
3113
|
+
afterQueuedWrites,
|
|
3114
|
+
}: { except: SyncTarget | null; afterQueuedWrites: boolean },
|
|
3115
|
+
): void => {
|
|
3116
|
+
const keys: Array<StructuralLookupKey> = [];
|
|
3117
|
+
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
3118
|
+
if (except && isTargetTransport(except, transport)) return;
|
|
3119
|
+
const key = structuralLookup(transport);
|
|
3120
|
+
const route = routesByOwnerIdByKey.get(key)?.get(ownerId);
|
|
3121
|
+
if (route?.progress.type === "Settled") return;
|
|
3122
|
+
if (route?.progress.type === "Pending" && route.progress.skip) {
|
|
3123
|
+
// A round already requested may have read the database before these
|
|
3124
|
+
// messages were stored, so it can no longer complete the route.
|
|
3125
|
+
route.progress = {
|
|
3126
|
+
...route.progress,
|
|
3127
|
+
skip: { ...route.progress.skip, isRechecking: false },
|
|
3128
|
+
};
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
keys.push(key);
|
|
3132
|
+
});
|
|
3133
|
+
for (const key of keys) {
|
|
3134
|
+
requestCreateSyncMessages(
|
|
3135
|
+
new Set([ownerId]),
|
|
3136
|
+
{ type: "Transport", key },
|
|
3137
|
+
{ afterQueuedWrites },
|
|
3138
|
+
);
|
|
3139
|
+
}
|
|
3140
|
+
};
|
|
3141
|
+
|
|
2310
3142
|
/** The keys of every transport claimed for the owner, by any database. */
|
|
2311
3143
|
const getClaimedKeys = (
|
|
2312
3144
|
ownerId: OwnerId,
|
|
@@ -2341,11 +3173,18 @@ const createEvoluTenant =
|
|
|
2341
3173
|
if (startupError) return;
|
|
2342
3174
|
const usedOwnersById = getUsedOwnersById(ownerIds);
|
|
2343
3175
|
// Opening, storing messages from another transport, a failure, and an
|
|
2344
|
-
// explicit request each require a new round before completion.
|
|
3176
|
+
// explicit request each require a new round before completion. The
|
|
3177
|
+
// round also checks again whether the relay holds a skipped message.
|
|
2345
3178
|
for (const ownerId of usedOwnersById.keys()) {
|
|
2346
3179
|
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
2347
3180
|
if (!isTargetTransport(target, transport)) return;
|
|
2348
|
-
getRoute(ownerId, structuralLookup(transport))
|
|
3181
|
+
const route = getRoute(ownerId, structuralLookup(transport));
|
|
3182
|
+
const pending = routeToPending(route.progress);
|
|
3183
|
+
route.progress = {
|
|
3184
|
+
...pending,
|
|
3185
|
+
roundRequired: true,
|
|
3186
|
+
skip: pending.skip && { ...pending.skip, isRechecking: true },
|
|
3187
|
+
};
|
|
2349
3188
|
});
|
|
2350
3189
|
}
|
|
2351
3190
|
refreshSyncRoutes();
|
|
@@ -2493,33 +3332,32 @@ const createEvoluTenant =
|
|
|
2493
3332
|
});
|
|
2494
3333
|
const tenant = disposable<EvoluTenant>(
|
|
2495
3334
|
{
|
|
2496
|
-
getSyncTenant: () =>
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
}),
|
|
3335
|
+
getSyncTenant: () =>
|
|
3336
|
+
startupError
|
|
3337
|
+
? { type: "Refused", name, error: startupError }
|
|
3338
|
+
: {
|
|
3339
|
+
type: "Active",
|
|
3340
|
+
name,
|
|
3341
|
+
owners: getSyncOwners().map(
|
|
3342
|
+
({ ownerId, writable, transportKeys }): TenantSyncOwner =>
|
|
3343
|
+
writable
|
|
3344
|
+
? {
|
|
3345
|
+
type: "Writable",
|
|
3346
|
+
ownerId,
|
|
3347
|
+
routes: transportKeys.map((key): TenantSyncRoute => {
|
|
3348
|
+
const route = routesByOwnerIdByKey
|
|
3349
|
+
.get(key)
|
|
3350
|
+
?.get(ownerId);
|
|
3351
|
+
// Registration and claim changes refresh routes
|
|
3352
|
+
// before yielding. Snapshot reads must not create
|
|
3353
|
+
// missing routes.
|
|
3354
|
+
assertNotUndefined(route);
|
|
3355
|
+
return { transportKey: key, ...route };
|
|
3356
|
+
}),
|
|
3357
|
+
}
|
|
3358
|
+
: { type: "Readonly", ownerId, transportKeys },
|
|
3359
|
+
),
|
|
3360
|
+
},
|
|
2523
3361
|
|
|
2524
3362
|
refreshSyncRoutes,
|
|
2525
3363
|
|
|
@@ -2553,19 +3391,19 @@ const createEvoluTenant =
|
|
|
2553
3391
|
|
|
2554
3392
|
disposer.defer(instance.onDisposed);
|
|
2555
3393
|
|
|
2556
|
-
disposer.defer(
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
deps.refreshAllSyncRoutes();
|
|
2564
|
-
deps.publishSyncState();
|
|
2565
|
-
return ok();
|
|
2566
|
-
}),
|
|
2567
|
-
);
|
|
3394
|
+
disposer.defer(() => {
|
|
3395
|
+
for (const leases of instance.ownerRegistrations.values()) {
|
|
3396
|
+
for (const lease of leases) lease?.release();
|
|
3397
|
+
}
|
|
3398
|
+
instance.ownerRegistrations.clear();
|
|
3399
|
+
deps.refreshAllSyncRoutes();
|
|
3400
|
+
deps.publishSyncState();
|
|
2568
3401
|
});
|
|
3402
|
+
// Cleanup starts no Task, because a root abort may dispose every Run
|
|
3403
|
+
// first. Disposed before the claims above are released, this Run
|
|
3404
|
+
// aborts queued UseOwner batches and waits for the running one, whose
|
|
3405
|
+
// transport claim cannot be aborted, so the release sees every lease.
|
|
3406
|
+
const instanceRun = disposer.use(tenantRun.create());
|
|
2569
3407
|
|
|
2570
3408
|
disposer.defer(() => {
|
|
2571
3409
|
instancesById.delete(instance.id);
|
|
@@ -2576,12 +3414,18 @@ const createEvoluTenant =
|
|
|
2576
3414
|
disposer.defer(() => {
|
|
2577
3415
|
instance.port.onMessage = null;
|
|
2578
3416
|
});
|
|
3417
|
+
// Instances of one name share the tenant, so each learns the mode
|
|
3418
|
+
// the first one chose.
|
|
3419
|
+
instance.port.postMessage({
|
|
3420
|
+
type: "OnDevicePersistence",
|
|
3421
|
+
devicePersistence,
|
|
3422
|
+
});
|
|
2579
3423
|
|
|
2580
3424
|
// The main-thread Evolu instance holds this per-instance leader lock
|
|
2581
3425
|
// while it is alive. Acquiring the same lock here means the main
|
|
2582
3426
|
// thread instance was disposed or its tab closed, so the tenant-side
|
|
2583
3427
|
// instance must dispose itself.
|
|
2584
|
-
void
|
|
3428
|
+
void instanceRun
|
|
2585
3429
|
.abortable(acquireLeaderLock(message.id))
|
|
2586
3430
|
.then((lock) => {
|
|
2587
3431
|
if (!lock.ok) return;
|
|
@@ -2622,7 +3466,7 @@ const createEvoluTenant =
|
|
|
2622
3466
|
break;
|
|
2623
3467
|
}
|
|
2624
3468
|
case "UseOwner": {
|
|
2625
|
-
void
|
|
3469
|
+
void instanceRun(
|
|
2626
3470
|
instance.useOwnerMutex.withLock(async (run) => {
|
|
2627
3471
|
for (const action of message.actions) {
|
|
2628
3472
|
switch (action.action) {
|
|
@@ -2729,14 +3573,14 @@ const createEvoluTenant =
|
|
|
2729
3573
|
// })
|
|
2730
3574
|
|
|
2731
3575
|
// TODO: SharedWorker follow-ups.
|
|
2732
|
-
// - Complete the queue head when a DbWorker mutation returns an error.
|
|
2733
3576
|
// - Rotate the node ID when a copied database is detected; see the Duplicate
|
|
2734
3577
|
// node IDs section in the Timestamp module.
|
|
2735
3578
|
// - Detect DbWorker and port liveness so a worker-only crash resumes the queue.
|
|
2736
3579
|
// Defer panicked-worker restart until failure detection and recovery are
|
|
2737
|
-
// defined, accounting for SQLite WASM's detection limits.
|
|
2738
|
-
// operations are expected not to throw;
|
|
2739
|
-
// can make replicated writes fail, are
|
|
3580
|
+
// defined, accounting for SQLite WASM's detection limits. A mutation that
|
|
3581
|
+
// throws is answered, but other SQLite operations are expected not to throw;
|
|
3582
|
+
// user-defined UNIQUE indexes, which can make replicated writes fail, are
|
|
3583
|
+
// planned to be forbidden.
|
|
2740
3584
|
// - Split worker protocol types and the EvoluTenant implementation into focused
|
|
2741
3585
|
// modules.
|
|
2742
3586
|
// - Remove the obsolete commented protocol block above.
|