@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
|
|
@@ -264,6 +308,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
264
308
|
});
|
|
265
309
|
import { emptyArray, firstInArray, isNonEmptyArray, } from "../Array.js";
|
|
266
310
|
import { assert, assertNonNullable, assertNotSame, assertNotUndefined, assertSame, } from "../Assert.js";
|
|
311
|
+
import { createUnknownError } from "../Error.js";
|
|
267
312
|
import { disposable, exhaustiveCheck } from "../Function.js";
|
|
268
313
|
import { acquireLeaderLock } from "../LockManager.js";
|
|
269
314
|
import { createLookupMap, structuralLookup, } from "../Lookup.js";
|
|
@@ -305,24 +350,200 @@ export const BuildWaitingRequest = /*#__PURE__*/ object({
|
|
|
305
350
|
type: /*#__PURE__*/ literal("BuildWaitingRequest"),
|
|
306
351
|
});
|
|
307
352
|
/**
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
353
|
+
* Tells what an app shows about syncing an owner in a database: `Error` when a
|
|
354
|
+
* relay has one, holding the newest failure of any relay or, without one, the
|
|
355
|
+
* newest skipped change; otherwise the first of `Syncing`, `Synced`, and
|
|
356
|
+
* `Offline` that a relay has, or `NoRelays`. Accepts null, the store's value
|
|
357
|
+
* before the first snapshot. See {@link OwnerSyncStatus} for what to show.
|
|
358
|
+
*
|
|
359
|
+
* The same status is the same object: statuses other than `Error` are shared
|
|
360
|
+
* constants, and an `Error` status is the same object for the same error
|
|
361
|
+
* object, which {@link SyncStateDep.syncState} keeps between snapshots while it
|
|
362
|
+
* is unchanged. So compare statuses with `===`.
|
|
313
363
|
*
|
|
314
364
|
* ### Example
|
|
315
365
|
*
|
|
316
366
|
* ```ts
|
|
317
367
|
* import {
|
|
318
368
|
* assertEqual,
|
|
369
|
+
* assertSame,
|
|
319
370
|
* createId,
|
|
371
|
+
* createUnknownError,
|
|
320
372
|
* Millis,
|
|
321
373
|
* testCreateDeps,
|
|
322
374
|
* testName,
|
|
323
375
|
* } from "@evolu/common";
|
|
324
376
|
* import {
|
|
325
|
-
*
|
|
377
|
+
* syncStateToOwnerSyncStatus,
|
|
378
|
+
* testAppOwner,
|
|
379
|
+
* type PendingSyncRoute,
|
|
380
|
+
* type SyncConnection,
|
|
381
|
+
* type SyncRoute,
|
|
382
|
+
* type SyncState,
|
|
383
|
+
* type SyncTransportId,
|
|
384
|
+
* } from "@evolu/common/local-first";
|
|
385
|
+
*
|
|
386
|
+
* const deps = testCreateDeps();
|
|
387
|
+
* const primaryId = createId<"SyncTransport">(deps);
|
|
388
|
+
* const backupId = createId<"SyncTransport">(deps);
|
|
389
|
+
*
|
|
390
|
+
* const stateOf = (
|
|
391
|
+
* connections: ReadonlyArray<SyncConnection>,
|
|
392
|
+
* routes: ReadonlyArray<SyncRoute>,
|
|
393
|
+
* ): SyncState => ({
|
|
394
|
+
* transports: connections.map((connection, index) => ({
|
|
395
|
+
* type: "WebSocket",
|
|
396
|
+
* id: index === 0 ? primaryId : backupId,
|
|
397
|
+
* label: "wss://relay.example",
|
|
398
|
+
* connection,
|
|
399
|
+
* })),
|
|
400
|
+
* tenants: [
|
|
401
|
+
* {
|
|
402
|
+
* type: "Active",
|
|
403
|
+
* name: testName,
|
|
404
|
+
* owners: [{ type: "Writable", ownerId: testAppOwner.id, routes }],
|
|
405
|
+
* },
|
|
406
|
+
* ],
|
|
407
|
+
* });
|
|
408
|
+
* const pending = (transportId: SyncTransportId): PendingSyncRoute => ({
|
|
409
|
+
* type: "Pending",
|
|
410
|
+
* transportId,
|
|
411
|
+
* failure: null,
|
|
412
|
+
* skippedError: null,
|
|
413
|
+
* completeAt: null,
|
|
414
|
+
* lastSentAt: null,
|
|
415
|
+
* lastReceivedAt: null,
|
|
416
|
+
* });
|
|
417
|
+
* const statusOf = (state: SyncState | null) =>
|
|
418
|
+
* syncStateToOwnerSyncStatus(state, testName, testAppOwner.id);
|
|
419
|
+
*
|
|
420
|
+
* // Before the first snapshot, nothing syncs the owner.
|
|
421
|
+
* assertEqual(statusOf(null), { type: "NoRelays" });
|
|
422
|
+
*
|
|
423
|
+
* // A first connection is syncing; a lost one is offline.
|
|
424
|
+
* const connecting: SyncConnection = { type: "Connecting" };
|
|
425
|
+
* assertEqual(statusOf(stateOf([connecting], [pending(primaryId)])), {
|
|
426
|
+
* type: "Syncing",
|
|
427
|
+
* });
|
|
428
|
+
* const disconnected: SyncConnection = {
|
|
429
|
+
* type: "Disconnected",
|
|
430
|
+
* disconnectedAt: Millis.orThrow(2000),
|
|
431
|
+
* openedAt: Millis.orThrow(1000),
|
|
432
|
+
* error: null,
|
|
433
|
+
* };
|
|
434
|
+
* assertEqual(statusOf(stateOf([disconnected], [pending(primaryId)])), {
|
|
435
|
+
* type: "Offline",
|
|
436
|
+
* });
|
|
437
|
+
*
|
|
438
|
+
* // A quota failure on one relay shows before a newer skipped change on
|
|
439
|
+
* // another, because the app can act on it.
|
|
440
|
+
* const quotaError = {
|
|
441
|
+
* type: "ProtocolQuotaError",
|
|
442
|
+
* ownerId: testAppOwner.id,
|
|
443
|
+
* at: Millis.orThrow(3000),
|
|
444
|
+
* } as const;
|
|
445
|
+
* const open: SyncConnection = {
|
|
446
|
+
* type: "Open",
|
|
447
|
+
* openedAt: Millis.orThrow(3400),
|
|
448
|
+
* error: null,
|
|
449
|
+
* };
|
|
450
|
+
* assertEqual(
|
|
451
|
+
* statusOf(
|
|
452
|
+
* stateOf(
|
|
453
|
+
* [disconnected, open],
|
|
454
|
+
* [
|
|
455
|
+
* { ...pending(primaryId), failure: quotaError },
|
|
456
|
+
* {
|
|
457
|
+
* type: "Settled",
|
|
458
|
+
* transportId: backupId,
|
|
459
|
+
* skippedError: {
|
|
460
|
+
* type: "DecryptWithXChaCha20Poly1305Error",
|
|
461
|
+
* error: createUnknownError(new Error("invalid tag")),
|
|
462
|
+
* at: Millis.orThrow(4000),
|
|
463
|
+
* },
|
|
464
|
+
* completeAt: null,
|
|
465
|
+
* lastSentAt: Millis.orThrow(3500),
|
|
466
|
+
* lastReceivedAt: Millis.orThrow(4000),
|
|
467
|
+
* },
|
|
468
|
+
* ],
|
|
469
|
+
* ),
|
|
470
|
+
* ),
|
|
471
|
+
* { type: "Error", error: quotaError },
|
|
472
|
+
* );
|
|
473
|
+
*
|
|
474
|
+
* // The same error gives the same status object.
|
|
475
|
+
* const quotaState = stateOf(
|
|
476
|
+
* [disconnected],
|
|
477
|
+
* [{ ...pending(primaryId), failure: quotaError }],
|
|
478
|
+
* );
|
|
479
|
+
* assertSame(statusOf(quotaState), statusOf(quotaState));
|
|
480
|
+
* ```
|
|
481
|
+
*/
|
|
482
|
+
export const syncStateToOwnerSyncStatus = (state, name, ownerId) => {
|
|
483
|
+
let failure = null;
|
|
484
|
+
let skippedError = null;
|
|
485
|
+
let isSyncing = false;
|
|
486
|
+
let isSynced = false;
|
|
487
|
+
let isOffline = false;
|
|
488
|
+
for (const relay of syncStateToRelaySyncStates(state, name, ownerId)) {
|
|
489
|
+
const { route } = relay;
|
|
490
|
+
// A failure stops syncing through its relay, and a skipped change is
|
|
491
|
+
// stamped again by every reply that skips it, so a newer skipped change
|
|
492
|
+
// must not hide an older failure the app can act on.
|
|
493
|
+
if (route.type === "Pending" &&
|
|
494
|
+
route.failure &&
|
|
495
|
+
(!failure || route.failure.at > failure.at))
|
|
496
|
+
failure = route.failure;
|
|
497
|
+
if (route.type !== "Complete" &&
|
|
498
|
+
route.skippedError &&
|
|
499
|
+
(!skippedError || route.skippedError.at > skippedError.at))
|
|
500
|
+
skippedError = route.skippedError;
|
|
501
|
+
const status = relaySyncStateToStatus(relay);
|
|
502
|
+
switch (status.type) {
|
|
503
|
+
case "Error":
|
|
504
|
+
break;
|
|
505
|
+
case "Syncing":
|
|
506
|
+
isSyncing = true;
|
|
507
|
+
break;
|
|
508
|
+
case "Synced":
|
|
509
|
+
isSynced = true;
|
|
510
|
+
break;
|
|
511
|
+
case "Offline":
|
|
512
|
+
isOffline = true;
|
|
513
|
+
break;
|
|
514
|
+
default:
|
|
515
|
+
exhaustiveCheck(status);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const error = failure ?? skippedError;
|
|
519
|
+
return error
|
|
520
|
+
? syncRouteErrorToSyncStatus(error)
|
|
521
|
+
: isSyncing
|
|
522
|
+
? syncingSyncStatus
|
|
523
|
+
: isSynced
|
|
524
|
+
? syncedSyncStatus
|
|
525
|
+
: isOffline
|
|
526
|
+
? offlineSyncStatus
|
|
527
|
+
: noRelaysSyncStatus;
|
|
528
|
+
};
|
|
529
|
+
/**
|
|
530
|
+
* Pairs each route of an owner in a database with its transport, in route
|
|
531
|
+
* order. Returns none for a null snapshot, a missing or refused database, and a
|
|
532
|
+
* missing or readonly owner.
|
|
533
|
+
*
|
|
534
|
+
* ### Example
|
|
535
|
+
*
|
|
536
|
+
* ```ts
|
|
537
|
+
* import {
|
|
538
|
+
* assertEqual,
|
|
539
|
+
* createId,
|
|
540
|
+
* Millis,
|
|
541
|
+
* testCreateDeps,
|
|
542
|
+
* testName,
|
|
543
|
+
* } from "@evolu/common";
|
|
544
|
+
* import {
|
|
545
|
+
* relaySyncStateToStatus,
|
|
546
|
+
* syncStateToRelaySyncStates,
|
|
326
547
|
* testAppOwner,
|
|
327
548
|
* type SyncRoute,
|
|
328
549
|
* type SyncState,
|
|
@@ -331,86 +552,94 @@ export const BuildWaitingRequest = /*#__PURE__*/ object({
|
|
|
331
552
|
*
|
|
332
553
|
* const deps = testCreateDeps();
|
|
333
554
|
* const transport: SyncTransport = {
|
|
555
|
+
* type: "WebSocket",
|
|
334
556
|
* id: createId<"SyncTransport">(deps),
|
|
335
557
|
* label: "wss://relay.example",
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
558
|
+
* connection: {
|
|
559
|
+
* type: "Open",
|
|
560
|
+
* openedAt: Millis.orThrow(800),
|
|
561
|
+
* error: null,
|
|
562
|
+
* },
|
|
340
563
|
* };
|
|
341
564
|
* const route: SyncRoute = {
|
|
565
|
+
* type: "Complete",
|
|
342
566
|
* transportId: transport.id,
|
|
343
|
-
* complete: true,
|
|
344
567
|
* completeAt: Millis.orThrow(1000),
|
|
345
568
|
* lastSentAt: Millis.orThrow(900),
|
|
346
569
|
* lastReceivedAt: Millis.orThrow(1000),
|
|
347
|
-
* error: null,
|
|
348
570
|
* };
|
|
349
571
|
* const state: SyncState = {
|
|
350
572
|
* transports: [transport],
|
|
351
573
|
* tenants: [
|
|
352
574
|
* {
|
|
575
|
+
* type: "Active",
|
|
353
576
|
* name: testName,
|
|
354
|
-
* refused: false,
|
|
355
577
|
* owners: [
|
|
356
|
-
* {
|
|
357
|
-
* ownerId: testAppOwner.id,
|
|
358
|
-
* writable: true,
|
|
359
|
-
* transportIds: [transport.id],
|
|
360
|
-
* routes: [route],
|
|
361
|
-
* },
|
|
578
|
+
* { type: "Writable", ownerId: testAppOwner.id, routes: [route] },
|
|
362
579
|
* ],
|
|
363
580
|
* },
|
|
364
581
|
* ],
|
|
365
582
|
* };
|
|
366
583
|
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
584
|
+
* const relays = syncStateToRelaySyncStates(
|
|
585
|
+
* state,
|
|
586
|
+
* testName,
|
|
587
|
+
* testAppOwner.id,
|
|
588
|
+
* );
|
|
589
|
+
* assertEqual(relays, [{ transport, route }]);
|
|
590
|
+
* assertEqual(relays.map(relaySyncStateToStatus), [{ type: "Synced" }]);
|
|
591
|
+
* assertEqual(
|
|
592
|
+
* syncStateToRelaySyncStates(null, testName, testAppOwner.id),
|
|
593
|
+
* [],
|
|
594
|
+
* );
|
|
377
595
|
* ```
|
|
378
596
|
*/
|
|
379
|
-
export const
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
597
|
+
export const syncStateToRelaySyncStates = (state, name, ownerId) => {
|
|
598
|
+
const tenant = state?.tenants.find((tenant) => tenant.name === name);
|
|
599
|
+
if (!state || tenant?.type !== "Active")
|
|
600
|
+
return emptyArray;
|
|
601
|
+
const owner = tenant.owners.find((owner) => owner.ownerId === ownerId);
|
|
602
|
+
if (owner?.type !== "Writable")
|
|
603
|
+
return emptyArray;
|
|
604
|
+
return owner.routes.map((route) => {
|
|
605
|
+
// A snapshot lists the transport of every route.
|
|
606
|
+
const transport = state.transports.find(({ id }) => id === route.transportId);
|
|
607
|
+
assertNonNullable(transport);
|
|
608
|
+
return { transport, route };
|
|
609
|
+
});
|
|
610
|
+
};
|
|
611
|
+
/** Tells the status of one relay; a failure shows before a skipped change. */
|
|
612
|
+
export const relaySyncStateToStatus = ({ transport, route, }) => {
|
|
613
|
+
switch (route.type) {
|
|
614
|
+
case "Complete":
|
|
615
|
+
return syncedSyncStatus;
|
|
616
|
+
case "Settled":
|
|
617
|
+
return syncRouteErrorToSyncStatus(route.skippedError);
|
|
618
|
+
case "Pending": {
|
|
619
|
+
const error = route.failure ?? route.skippedError;
|
|
620
|
+
if (error)
|
|
621
|
+
return syncRouteErrorToSyncStatus(error);
|
|
622
|
+
return transport.connection.type === "Disconnected"
|
|
623
|
+
? offlineSyncStatus
|
|
624
|
+
: syncingSyncStatus;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
// Every status keeps its reference while it is unchanged, so bindings compare
|
|
629
|
+
// statuses with `===`. The store shares an unchanged error object between
|
|
630
|
+
// snapshots, and an error gives the same Error status object.
|
|
631
|
+
const noRelaysSyncStatus = { type: "NoRelays" };
|
|
632
|
+
const syncingSyncStatus = { type: "Syncing" };
|
|
633
|
+
const syncedSyncStatus = { type: "Synced" };
|
|
634
|
+
const offlineSyncStatus = { type: "Offline" };
|
|
635
|
+
const errorSyncStatusByError = /*#__PURE__*/ new WeakMap();
|
|
636
|
+
const syncRouteErrorToSyncStatus = (error) => {
|
|
637
|
+
let status = errorSyncStatusByError.get(error);
|
|
638
|
+
if (!status) {
|
|
639
|
+
status = { type: "Error", error };
|
|
640
|
+
errorSyncStatusByError.set(error, status);
|
|
641
|
+
}
|
|
642
|
+
return status;
|
|
414
643
|
};
|
|
415
644
|
/**
|
|
416
645
|
* How long an unanswered request keeps a socket before it is replaced.
|
|
@@ -426,8 +655,78 @@ const syncRequestTimeout = PositiveMillis.orThrow(90_000);
|
|
|
426
655
|
* {@link syncRequestTimeout}, which covers a 1 MB frame at about 6 kbit/s.
|
|
427
656
|
*/
|
|
428
657
|
const maxSyncRequestTimeout = PositiveMillis.orThrow(16 * syncRequestTimeout);
|
|
658
|
+
const connectingSyncConnection = {
|
|
659
|
+
type: "Connecting",
|
|
660
|
+
};
|
|
661
|
+
/**
|
|
662
|
+
* A connection already disconnected keeps when it disconnected, so failed
|
|
663
|
+
* reconnect attempts only record their error.
|
|
664
|
+
*/
|
|
665
|
+
const disconnectSyncConnection = (connection, at, error) => {
|
|
666
|
+
switch (connection.type) {
|
|
667
|
+
case "Connecting":
|
|
668
|
+
return {
|
|
669
|
+
type: "Disconnected",
|
|
670
|
+
disconnectedAt: at,
|
|
671
|
+
openedAt: null,
|
|
672
|
+
error,
|
|
673
|
+
};
|
|
674
|
+
case "Open":
|
|
675
|
+
return {
|
|
676
|
+
type: "Disconnected",
|
|
677
|
+
disconnectedAt: at,
|
|
678
|
+
openedAt: connection.openedAt,
|
|
679
|
+
error: error ?? connection.error,
|
|
680
|
+
};
|
|
681
|
+
case "Disconnected":
|
|
682
|
+
return error ? { ...connection, error } : connection;
|
|
683
|
+
}
|
|
684
|
+
};
|
|
429
685
|
const allTransports = { type: "AllTransports" };
|
|
430
686
|
const isTargetTransport = (target, transport) => target.type === "AllTransports" || target.key === structuralLookup(transport);
|
|
687
|
+
const routeToPending = (progress) => {
|
|
688
|
+
switch (progress.type) {
|
|
689
|
+
case "Pending":
|
|
690
|
+
return progress;
|
|
691
|
+
case "Settled":
|
|
692
|
+
return {
|
|
693
|
+
type: "Pending",
|
|
694
|
+
roundRequired: false,
|
|
695
|
+
failure: null,
|
|
696
|
+
skip: { error: progress.error, isRechecking: false },
|
|
697
|
+
completeAt: progress.completeAt,
|
|
698
|
+
};
|
|
699
|
+
case "Complete":
|
|
700
|
+
return {
|
|
701
|
+
type: "Pending",
|
|
702
|
+
roundRequired: false,
|
|
703
|
+
failure: null,
|
|
704
|
+
skip: null,
|
|
705
|
+
completeAt: progress.completeAt,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
/**
|
|
710
|
+
* Converts an error to a {@link SyncRouteError}: a caught value becomes an
|
|
711
|
+
* {@link UnknownError}, and a {@link ProtocolInvalidDataError} leaves out its
|
|
712
|
+
* data.
|
|
713
|
+
*/
|
|
714
|
+
const errorToSyncRouteError = (error, at) => {
|
|
715
|
+
// A caught value inside an error becomes an UnknownError, and a
|
|
716
|
+
// ProtocolInvalidDataError leaves out its data, which can be a whole frame.
|
|
717
|
+
if (error.type === "ProtocolInvalidDataError") {
|
|
718
|
+
const { data: _data, ...rest } = error;
|
|
719
|
+
return { ...rest, error: createUnknownError(rest.error), at };
|
|
720
|
+
}
|
|
721
|
+
if (error.type === "DecryptWithXChaCha20Poly1305Error")
|
|
722
|
+
return { ...error, error: createUnknownError(error.error), at };
|
|
723
|
+
return { ...error, at };
|
|
724
|
+
};
|
|
725
|
+
const failRoute = (progress, failure) => ({
|
|
726
|
+
...routeToPending(progress),
|
|
727
|
+
roundRequired: true,
|
|
728
|
+
failure,
|
|
729
|
+
});
|
|
431
730
|
// Long enough for another build's tabs to reload and its worker to end.
|
|
432
731
|
const otherBuildRunningReportDelay = "3s";
|
|
433
732
|
/**
|
|
@@ -514,20 +813,22 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
514
813
|
workerId,
|
|
515
814
|
syncStateChannelName,
|
|
516
815
|
});
|
|
517
|
-
if (isPersistentStorageUnavailable) {
|
|
518
|
-
port.postMessage({ type: "StorageUnavailable" });
|
|
519
|
-
}
|
|
520
816
|
});
|
|
521
817
|
};
|
|
522
|
-
//
|
|
523
|
-
//
|
|
818
|
+
// Held while this worker runs. Its DbWorkers stop once they can take it,
|
|
819
|
+
// because a Dispose posted right before this worker closes can be lost, as
|
|
820
|
+
// in Firefox. Taken before the build lock, so nothing delays the end of
|
|
821
|
+
// starting once that lock is held.
|
|
822
|
+
disposer.use(await run.ok(acquireLeaderLock(workerId)));
|
|
823
|
+
// Released after every tenant is disposed and has told its DbWorker to
|
|
824
|
+
// stop. Earlier releases take the same lock in their leader tab; see
|
|
825
|
+
// Builds.
|
|
524
826
|
disposer.use(await run.ok(acquireLeaderLock("tab")));
|
|
525
827
|
starting.dispose();
|
|
526
|
-
// Checked once, before any DbWorker starts, so
|
|
527
|
-
// worker, replacements included, keeps its
|
|
528
|
-
// Storage.
|
|
529
|
-
const
|
|
530
|
-
!(await deps.isPersistentStorageAvailable());
|
|
828
|
+
// Checked once, before any DbWorker starts, so without persistent
|
|
829
|
+
// storage every DbWorker of this worker, replacements included, keeps its
|
|
830
|
+
// database in memory; see Storage.
|
|
831
|
+
const platformDevicePersistence = await deps.getDevicePersistence();
|
|
531
832
|
disposer.defer(deps.consoleStoreOutputEntry.subscribe(() => {
|
|
532
833
|
const entry = deps.consoleStoreOutputEntry.get();
|
|
533
834
|
if (entry)
|
|
@@ -549,51 +850,85 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
549
850
|
isPublishScheduled = false;
|
|
550
851
|
if (isDisposed)
|
|
551
852
|
return;
|
|
552
|
-
const transports = [...transportsByKey.values()].map(({ id, label,
|
|
853
|
+
const transports = [...transportsByKey.values()].map(({ id, label, connection }) => ({
|
|
854
|
+
type: "WebSocket",
|
|
553
855
|
id,
|
|
554
856
|
label,
|
|
555
|
-
|
|
556
|
-
// reads a disposed one.
|
|
557
|
-
readyState: socket?.getReadyState() ?? "connecting",
|
|
558
|
-
openedAt,
|
|
559
|
-
closedAt,
|
|
560
|
-
error,
|
|
857
|
+
connection,
|
|
561
858
|
}));
|
|
859
|
+
// A grown timeout lasts while a request is outstanding on the socket
|
|
860
|
+
// or a database that has not refused startup has an unsettled route
|
|
861
|
+
// through it. Every change to either publishes, including a route that
|
|
862
|
+
// goes away without settling.
|
|
863
|
+
const unsettledTransportIds = new Set();
|
|
562
864
|
const tenants = [...currentTenantsByName.values()].map((tenant) => {
|
|
563
|
-
const
|
|
865
|
+
const state = tenant.getSyncTenant();
|
|
866
|
+
if (state.type === "Refused")
|
|
867
|
+
return state;
|
|
564
868
|
return {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
owners: owners.map((
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
869
|
+
type: "Active",
|
|
870
|
+
name: state.name,
|
|
871
|
+
owners: state.owners.map((owner) => owner.type === "Readonly"
|
|
872
|
+
? {
|
|
873
|
+
type: "Readonly",
|
|
874
|
+
ownerId: owner.ownerId,
|
|
875
|
+
transportIds: owner.transportKeys.flatMap((key) => {
|
|
876
|
+
const entry = transportsByKey.get(key);
|
|
877
|
+
return entry ? [entry.id] : [];
|
|
878
|
+
}),
|
|
879
|
+
}
|
|
880
|
+
: {
|
|
881
|
+
type: "Writable",
|
|
882
|
+
ownerId: owner.ownerId,
|
|
883
|
+
routes: owner.routes.flatMap(({ transportKey, progress, lastSentAt, lastReceivedAt, }) => {
|
|
884
|
+
const entry = transportsByKey.get(transportKey);
|
|
885
|
+
if (!entry)
|
|
886
|
+
return [];
|
|
887
|
+
const transportId = entry.id;
|
|
888
|
+
if (progress.type !== "Pending") {
|
|
889
|
+
// Only a sent round settles a route or completes
|
|
890
|
+
// it.
|
|
891
|
+
assertNonNullable(lastSentAt);
|
|
892
|
+
return [
|
|
893
|
+
progress.type === "Complete"
|
|
894
|
+
? {
|
|
895
|
+
type: "Complete",
|
|
896
|
+
transportId,
|
|
897
|
+
completeAt: progress.completeAt,
|
|
898
|
+
lastSentAt,
|
|
899
|
+
lastReceivedAt,
|
|
900
|
+
}
|
|
901
|
+
: {
|
|
902
|
+
type: "Settled",
|
|
903
|
+
transportId,
|
|
904
|
+
skippedError: progress.error,
|
|
905
|
+
completeAt: progress.completeAt,
|
|
906
|
+
lastSentAt,
|
|
907
|
+
lastReceivedAt,
|
|
908
|
+
},
|
|
909
|
+
];
|
|
910
|
+
}
|
|
911
|
+
// Only a database that has not refused startup
|
|
912
|
+
// publishes routes.
|
|
913
|
+
unsettledTransportIds.add(transportId);
|
|
914
|
+
return [
|
|
915
|
+
{
|
|
916
|
+
type: "Pending",
|
|
917
|
+
transportId,
|
|
918
|
+
failure: progress.failure,
|
|
919
|
+
skippedError: progress.skip?.error ?? null,
|
|
920
|
+
completeAt: progress.completeAt,
|
|
921
|
+
lastSentAt,
|
|
922
|
+
lastReceivedAt,
|
|
923
|
+
},
|
|
924
|
+
];
|
|
925
|
+
}),
|
|
577
926
|
}),
|
|
578
|
-
})),
|
|
579
927
|
};
|
|
580
928
|
});
|
|
581
|
-
// A grown timeout lasts while a request is outstanding on the socket
|
|
582
|
-
// or a database that has not refused startup has an incomplete route
|
|
583
|
-
// through it. Every change to either publishes, including a route that
|
|
584
|
-
// goes away without completing.
|
|
585
|
-
const incompleteTransportIds = new Set();
|
|
586
|
-
for (const { refused, owners } of tenants) {
|
|
587
|
-
if (refused)
|
|
588
|
-
continue;
|
|
589
|
-
for (const { routes } of owners)
|
|
590
|
-
for (const { transportId, complete } of routes)
|
|
591
|
-
if (!complete)
|
|
592
|
-
incompleteTransportIds.add(transportId);
|
|
593
|
-
}
|
|
594
929
|
for (const entry of transportsByKey.values())
|
|
595
930
|
if (entry.outstandingByOwnerId.size === 0 &&
|
|
596
|
-
!
|
|
931
|
+
!unsettledTransportIds.has(entry.id))
|
|
597
932
|
entry.timeout = syncRequestTimeout;
|
|
598
933
|
syncStateBroadcastChannel.postMessage({ transports, tenants });
|
|
599
934
|
});
|
|
@@ -642,10 +977,10 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
642
977
|
entry.timeout = PositiveMillis.orThrow(Math.min(entry.timeout * 2, maxSyncRequestTimeout));
|
|
643
978
|
// Reconnecting abandons the connection and starts a fresh retry
|
|
644
979
|
// schedule. The socket reports no close for it, so the transport
|
|
645
|
-
//
|
|
980
|
+
// disconnects here.
|
|
646
981
|
entry.socket?.reconnect();
|
|
647
|
-
// `now` is monotonic; the reported
|
|
648
|
-
entry.
|
|
982
|
+
// `now` is monotonic; the reported time is wall clock.
|
|
983
|
+
entry.connection = disconnectSyncConnection(entry.connection, deps.time.now(), null);
|
|
649
984
|
refreshAllSyncRoutes();
|
|
650
985
|
publishSyncState();
|
|
651
986
|
}, delay);
|
|
@@ -684,9 +1019,7 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
684
1019
|
outstandingByOwnerId: new Map(),
|
|
685
1020
|
timeout: syncRequestTimeout,
|
|
686
1021
|
socket: null,
|
|
687
|
-
|
|
688
|
-
closedAt: null,
|
|
689
|
-
error: null,
|
|
1022
|
+
connection: connectingSyncConnection,
|
|
690
1023
|
timeoutId: null,
|
|
691
1024
|
};
|
|
692
1025
|
const disposer = __addDisposableResource(env_2, new AsyncDisposableStack(), true);
|
|
@@ -706,7 +1039,15 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
706
1039
|
// answered.
|
|
707
1040
|
entry.outstandingByOwnerId.clear();
|
|
708
1041
|
clearSyncRequestTimeout(entry);
|
|
709
|
-
|
|
1042
|
+
// A connection that opens keeps the last error of an earlier
|
|
1043
|
+
// one.
|
|
1044
|
+
entry.connection = {
|
|
1045
|
+
type: "Open",
|
|
1046
|
+
openedAt: run.deps.time.now(),
|
|
1047
|
+
error: entry.connection.type === "Connecting"
|
|
1048
|
+
? null
|
|
1049
|
+
: entry.connection.error,
|
|
1050
|
+
};
|
|
710
1051
|
publishSyncState();
|
|
711
1052
|
const ownerIds = transports.getClaimsForResource(transport);
|
|
712
1053
|
console.debug("transportOpen", {
|
|
@@ -728,7 +1069,7 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
728
1069
|
code: event.code,
|
|
729
1070
|
wasClean: event.wasClean,
|
|
730
1071
|
});
|
|
731
|
-
entry.
|
|
1072
|
+
entry.connection = disconnectSyncConnection(entry.connection, run.deps.time.now(), null);
|
|
732
1073
|
clearSyncRequestTimeout(entry);
|
|
733
1074
|
refreshAllSyncRoutes();
|
|
734
1075
|
publishSyncState();
|
|
@@ -738,10 +1079,13 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
738
1079
|
url: transport.url,
|
|
739
1080
|
type: error.type,
|
|
740
1081
|
});
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1082
|
+
// Every error disconnects. A browser reports a failed
|
|
1083
|
+
// attempt only as an error, because the socket stops
|
|
1084
|
+
// listening before its close arrives. A connection error
|
|
1085
|
+
// arrives once the socket is closed, before its close, and
|
|
1086
|
+
// exhausted retries end reconnecting.
|
|
1087
|
+
const now = run.deps.time.now();
|
|
1088
|
+
entry.connection = disconnectSyncConnection(entry.connection, now, { type: error.type, at: now });
|
|
745
1089
|
refreshAllSyncRoutes();
|
|
746
1090
|
publishSyncState();
|
|
747
1091
|
},
|
|
@@ -794,9 +1138,8 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
794
1138
|
// LIFO: the transport drops its timer and its socket reference
|
|
795
1139
|
// before the socket is disposed. `disposable` guards every method
|
|
796
1140
|
// of the socket the claims lease, and disposing the socket awaits
|
|
797
|
-
// its retry, so
|
|
798
|
-
//
|
|
799
|
-
// read a disposed one.
|
|
1141
|
+
// its retry, so this entry stays registered meanwhile. It keeps its
|
|
1142
|
+
// last connection and holds no socket to reconnect.
|
|
800
1143
|
disposer.defer(() => {
|
|
801
1144
|
clearSyncRequestTimeout(entry);
|
|
802
1145
|
entry.socket = null;
|
|
@@ -853,9 +1196,11 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
853
1196
|
tabLeaderPortStore,
|
|
854
1197
|
transports,
|
|
855
1198
|
}));
|
|
856
|
-
const tenantsByName = disposer.use(await sharedWorkerRun.ok(createSharedResourceByKey((message) =>
|
|
857
|
-
|
|
858
|
-
|
|
1199
|
+
const tenantsByName = disposer.use(await sharedWorkerRun.ok(createSharedResourceByKey((message) => {
|
|
1200
|
+
const memoryOnly = message.memoryOnly ||
|
|
1201
|
+
platformDevicePersistence === "NotPersisted";
|
|
1202
|
+
return createEvoluTenant({ ...message, memoryOnly }, memoryOnly ? "NotPersisted" : platformDevicePersistence, currentTenantsByName, workerId);
|
|
1203
|
+
}, {
|
|
859
1204
|
idleDisposeAfter: "3s",
|
|
860
1205
|
lookup: (message) => message.name,
|
|
861
1206
|
})));
|
|
@@ -876,7 +1221,7 @@ export const initSharedWorker = (self) => async (run) => {
|
|
|
876
1221
|
await result_1;
|
|
877
1222
|
}
|
|
878
1223
|
};
|
|
879
|
-
const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, memoryOnly, }, currentTenantsByName) => async (run) => {
|
|
1224
|
+
const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, memoryOnly, }, devicePersistence, currentTenantsByName, workerId) => async (run) => {
|
|
880
1225
|
const env_3 = { stack: [], error: void 0, hasError: false };
|
|
881
1226
|
try {
|
|
882
1227
|
const disposer = __addDisposableResource(env_3, new AsyncDisposableStack(), true);
|
|
@@ -1026,6 +1371,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1026
1371
|
sqliteSchema,
|
|
1027
1372
|
encryptionKey,
|
|
1028
1373
|
memoryOnly,
|
|
1374
|
+
sharedWorkerId: workerId,
|
|
1029
1375
|
port: dbWorkerChannel.port1.native,
|
|
1030
1376
|
}, [dbWorkerChannel.port1.native]);
|
|
1031
1377
|
};
|
|
@@ -1126,31 +1472,16 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1126
1472
|
});
|
|
1127
1473
|
}
|
|
1128
1474
|
};
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
// the hosting tab closed. A worker requested from a later tab leader may
|
|
1140
|
-
// be queued for the lock first; it is told to stop when it reports in.
|
|
1141
|
-
// The wait is unabortable because tenant disposal must finish even after
|
|
1142
|
-
// tenantRun receives an abort request.
|
|
1143
|
-
const _ = __addDisposableResource(env_5, await tenantRun.ok(acquireLeaderLock(name)), true);
|
|
1144
|
-
}
|
|
1145
|
-
catch (e_5) {
|
|
1146
|
-
env_5.error = e_5;
|
|
1147
|
-
env_5.hasError = true;
|
|
1148
|
-
}
|
|
1149
|
-
finally {
|
|
1150
|
-
const result_5 = __disposeResources(env_5);
|
|
1151
|
-
if (result_5)
|
|
1152
|
-
await result_5;
|
|
1153
|
-
}
|
|
1475
|
+
// Disposal does not wait for the DbWorker. It holds the database lock
|
|
1476
|
+
// until Dispose arrives, its tab closes, or this worker ends, and the next
|
|
1477
|
+
// DbWorker for this database, of this worker or another build, waits for
|
|
1478
|
+
// that lock before it reads the clock. A requested DbWorker that reports
|
|
1479
|
+
// in later gets Dispose from the isDisposing check.
|
|
1480
|
+
disposer.defer(() => {
|
|
1481
|
+
isDisposing = true;
|
|
1482
|
+
dbWorkerPort?.postMessage({ type: "Dispose" });
|
|
1483
|
+
dbWorkerPort = null;
|
|
1484
|
+
activeDispatch = null;
|
|
1154
1485
|
});
|
|
1155
1486
|
const handleResponseForEvolu = (response, first) => {
|
|
1156
1487
|
// A disposed instance gets no patches, but its committed write still
|
|
@@ -1198,9 +1529,36 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1198
1529
|
}
|
|
1199
1530
|
break;
|
|
1200
1531
|
}
|
|
1532
|
+
case "MutateFailed": {
|
|
1533
|
+
// The tab shows it, and the instance releases the mutation's
|
|
1534
|
+
// onComplete callbacks without running them. A write outlives its
|
|
1535
|
+
// instance, so without one, every tab is told.
|
|
1536
|
+
//
|
|
1537
|
+
// A replay after leader replacement can fail although the earlier
|
|
1538
|
+
// leader committed the write and only its answer was lost. The tab
|
|
1539
|
+
// then shows an error for a stored write, and its onComplete
|
|
1540
|
+
// callbacks never run. Nothing is lost or reused: the replacement
|
|
1541
|
+
// adopted the stored clock, refreshed every instance's queries, and
|
|
1542
|
+
// reconciles every used owner.
|
|
1543
|
+
const { error } = response.message;
|
|
1544
|
+
if (instance) {
|
|
1545
|
+
assertSame(first.message.type, "Mutate");
|
|
1546
|
+
instance.tabPort.postMessage({ type: "Error", error });
|
|
1547
|
+
instance.port.postMessage({
|
|
1548
|
+
type: "OnMutateFailed",
|
|
1549
|
+
onCompleteIds: first.message.onCompleteIds,
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
deps.postConsoleEntryOrError({ type: "Error", error });
|
|
1554
|
+
}
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1201
1557
|
case "Export":
|
|
1202
1558
|
instance?.port.postMessage({ type: "OnExport", file: response.message.file }, [response.message.file.buffer]);
|
|
1203
1559
|
break;
|
|
1560
|
+
default:
|
|
1561
|
+
exhaustiveCheck(response.message);
|
|
1204
1562
|
}
|
|
1205
1563
|
};
|
|
1206
1564
|
const routesByOwnerIdByKey = new Map();
|
|
@@ -1213,12 +1571,15 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1213
1571
|
let route = routesByOwnerId.get(ownerId);
|
|
1214
1572
|
if (!route) {
|
|
1215
1573
|
route = {
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1574
|
+
progress: {
|
|
1575
|
+
type: "Pending",
|
|
1576
|
+
roundRequired: true,
|
|
1577
|
+
failure: null,
|
|
1578
|
+
skip: null,
|
|
1579
|
+
completeAt: null,
|
|
1580
|
+
},
|
|
1219
1581
|
lastSentAt: null,
|
|
1220
1582
|
lastReceivedAt: null,
|
|
1221
|
-
error: null,
|
|
1222
1583
|
};
|
|
1223
1584
|
routesByOwnerId.set(ownerId, route);
|
|
1224
1585
|
}
|
|
@@ -1277,19 +1638,27 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1277
1638
|
// worker answers it. Local-only changes create no synchronization
|
|
1278
1639
|
// work.
|
|
1279
1640
|
const hasQueuedWrite = pendingWriteCountByOwnerId.has(ownerId);
|
|
1280
|
-
|
|
1281
|
-
// queued writes without uploading them.
|
|
1282
|
-
const complete = startupError === null &&
|
|
1283
|
-
isOpen &&
|
|
1641
|
+
const isReconciled = isOpen &&
|
|
1284
1642
|
deps.syncRequests.getOutstanding(ownerId, key) === 0 &&
|
|
1285
1643
|
!hasQueuedApply &&
|
|
1286
|
-
!hasQueuedWrite
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
route.
|
|
1644
|
+
!hasQueuedWrite;
|
|
1645
|
+
// Settling drops the failure, so the next one requests a round
|
|
1646
|
+
// again. A route with a skip it is not rechecking settles without
|
|
1647
|
+
// completing, because its relay may offer the skipped message or
|
|
1648
|
+
// lack messages stored elsewhere.
|
|
1649
|
+
const pending = routeToPending(route.progress);
|
|
1650
|
+
route.progress =
|
|
1651
|
+
!isReconciled || pending.roundRequired
|
|
1652
|
+
? pending
|
|
1653
|
+
: pending.skip && !pending.skip.isRechecking
|
|
1654
|
+
? {
|
|
1655
|
+
type: "Settled",
|
|
1656
|
+
error: pending.skip.error,
|
|
1657
|
+
completeAt: pending.completeAt,
|
|
1658
|
+
}
|
|
1659
|
+
: route.progress.type === "Complete"
|
|
1660
|
+
? route.progress
|
|
1661
|
+
: { type: "Complete", completeAt: deps.time.now() };
|
|
1293
1662
|
}
|
|
1294
1663
|
}
|
|
1295
1664
|
};
|
|
@@ -1313,8 +1682,10 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1313
1682
|
?.get(ownerId);
|
|
1314
1683
|
if (!route)
|
|
1315
1684
|
return;
|
|
1316
|
-
route.
|
|
1317
|
-
|
|
1685
|
+
route.progress = failRoute(route.progress, {
|
|
1686
|
+
type: "SyncFailed",
|
|
1687
|
+
at: now,
|
|
1688
|
+
});
|
|
1318
1689
|
});
|
|
1319
1690
|
}
|
|
1320
1691
|
deps.publishSyncState();
|
|
@@ -1327,6 +1698,8 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1327
1698
|
const { ownerId, result } = response.message;
|
|
1328
1699
|
const error = result.ok ? null : result.error;
|
|
1329
1700
|
const isAborted = error?.type === "AbortError";
|
|
1701
|
+
// An aborted apply reports no skipped message.
|
|
1702
|
+
const skippedError = isAborted ? null : response.message.skippedError;
|
|
1330
1703
|
let failure = null;
|
|
1331
1704
|
if (isAborted) {
|
|
1332
1705
|
// An abort proves no convergence. A local sibling copy affects
|
|
@@ -1338,19 +1711,22 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1338
1711
|
return;
|
|
1339
1712
|
const route = routesByOwnerIdByKey.get(key)?.get(ownerId);
|
|
1340
1713
|
if (route)
|
|
1341
|
-
route.
|
|
1714
|
+
route.progress = {
|
|
1715
|
+
...routeToPending(route.progress),
|
|
1716
|
+
roundRequired: true,
|
|
1717
|
+
};
|
|
1342
1718
|
});
|
|
1343
1719
|
}
|
|
1344
1720
|
else if (error !== null) {
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
});
|
|
1721
|
+
// A relay's error shows on its route, not as an EvoluError,
|
|
1722
|
+
// because it belongs to one relay and often repeats in every
|
|
1723
|
+
// round. A sibling copy's error is reported below.
|
|
1724
|
+
failure = error;
|
|
1350
1725
|
}
|
|
1351
1726
|
else if (result.ok && result.value.type === "Failed") {
|
|
1352
|
-
failure =
|
|
1353
|
-
result.value.cause === "Write" ? "WriteFailed" : "SyncFailed"
|
|
1727
|
+
failure = {
|
|
1728
|
+
type: result.value.cause === "Write" ? "WriteFailed" : "SyncFailed",
|
|
1729
|
+
};
|
|
1354
1730
|
}
|
|
1355
1731
|
if (source.type === "Transport") {
|
|
1356
1732
|
// A registration or claim may have been removed while this apply
|
|
@@ -1361,36 +1737,63 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1361
1737
|
// An aborted apply applied nothing.
|
|
1362
1738
|
if (!isAborted)
|
|
1363
1739
|
route.lastReceivedAt = now;
|
|
1740
|
+
// A skipped message is recorded as the route's skip, not a
|
|
1741
|
+
// failure, and does not end the round, whose response below is
|
|
1742
|
+
// still sent, so it requests no round. The relay offers the
|
|
1743
|
+
// message again in every later round, so the route stays
|
|
1744
|
+
// incomplete until a round requested through it settles without
|
|
1745
|
+
// skipping a message and without messages stored elsewhere since
|
|
1746
|
+
// that request.
|
|
1747
|
+
if (skippedError !== null)
|
|
1748
|
+
route.progress = {
|
|
1749
|
+
...routeToPending(route.progress),
|
|
1750
|
+
skip: {
|
|
1751
|
+
error: errorToSyncRouteError(skippedError, now),
|
|
1752
|
+
isRechecking: false,
|
|
1753
|
+
},
|
|
1754
|
+
};
|
|
1364
1755
|
if (failure !== null) {
|
|
1365
|
-
// The first failure since the route
|
|
1756
|
+
// The first failure since the route settled requests one
|
|
1366
1757
|
// round. Further failures wait for an explicit request or a
|
|
1367
1758
|
// reopen, even after a converged reply, which may answer
|
|
1368
1759
|
// another request.
|
|
1369
|
-
|
|
1760
|
+
const requestsRetry = route.progress.type !== "Pending" ||
|
|
1761
|
+
route.progress.failure === null;
|
|
1762
|
+
route.progress = failRoute(route.progress, errorToSyncRouteError(failure, now));
|
|
1763
|
+
if (requestsRetry)
|
|
1370
1764
|
requestCreateSyncMessages(new Set([ownerId]), source);
|
|
1371
|
-
route.roundRequired = true;
|
|
1372
|
-
route.error = { type: failure, at: now };
|
|
1373
1765
|
}
|
|
1374
1766
|
}
|
|
1375
1767
|
}
|
|
1376
|
-
else if (failure !== null) {
|
|
1377
|
-
// A sibling's
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1768
|
+
else if (failure !== null || skippedError !== null) {
|
|
1769
|
+
// A sibling's copy comes from this worker, not from a relay, so no
|
|
1770
|
+
// route shows its failure. It is a Broadcast, which carries no
|
|
1771
|
+
// relay error, so an error or a skip means a bug, such as
|
|
1772
|
+
// databases holding different keys for the owner, and is reported
|
|
1773
|
+
// as an unexpected failure. A Failed result was logged, which
|
|
1774
|
+
// reports it already. An error is the failure, which is never an
|
|
1775
|
+
// abort, and like a route, the report leaves out its frame.
|
|
1776
|
+
const unexpected = error !== null ? failure : skippedError;
|
|
1777
|
+
if (unexpected !== null)
|
|
1778
|
+
deps.postConsoleEntryOrError({
|
|
1779
|
+
type: "Error",
|
|
1780
|
+
error: createUnknownError(errorToSyncRouteError(unexpected, deps.time.now())),
|
|
1781
|
+
});
|
|
1782
|
+
// Some of a sibling's messages were not stored; rounds fetch them
|
|
1783
|
+
// from the relays, whose routes then record a skip for any message
|
|
1784
|
+
// this database skips.
|
|
1785
|
+
requestRoundsForReceivedMessages(ownerId, {
|
|
1786
|
+
except: null,
|
|
1787
|
+
afterQueuedWrites: true,
|
|
1788
|
+
});
|
|
1380
1789
|
}
|
|
1381
1790
|
if (response.message.didWriteMessages) {
|
|
1382
1791
|
refreshQueries();
|
|
1383
1792
|
// Reconcile newly stored messages through each other transport.
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
if (isTargetTransport(target, transport))
|
|
1388
|
-
return;
|
|
1389
|
-
keys.push(structuralLookup(transport));
|
|
1793
|
+
requestRoundsForReceivedMessages(ownerId, {
|
|
1794
|
+
except: target,
|
|
1795
|
+
afterQueuedWrites: false,
|
|
1390
1796
|
});
|
|
1391
|
-
for (const key of keys) {
|
|
1392
|
-
requestCreateSyncMessages(new Set([ownerId]), { type: "Transport", key }, { afterQueuedWrites: false });
|
|
1393
|
-
}
|
|
1394
1797
|
}
|
|
1395
1798
|
if (result.ok) {
|
|
1396
1799
|
switch (result.value.type) {
|
|
@@ -1443,13 +1846,49 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1443
1846
|
const route = getRoute(ownerId, key);
|
|
1444
1847
|
route.lastSentAt = deps.time.now();
|
|
1445
1848
|
if (isRound)
|
|
1446
|
-
route.
|
|
1849
|
+
route.progress = {
|
|
1850
|
+
...routeToPending(route.progress),
|
|
1851
|
+
roundRequired: false,
|
|
1852
|
+
};
|
|
1447
1853
|
});
|
|
1448
1854
|
}
|
|
1449
1855
|
// Sends raise counters shared by every tenant; one refresh covers them.
|
|
1450
1856
|
if (protocolMessagesByOwnerId.size > 0)
|
|
1451
1857
|
deps.refreshAllSyncRoutes();
|
|
1452
1858
|
};
|
|
1859
|
+
/**
|
|
1860
|
+
* Requests a round through each transport claimed for the owner outside
|
|
1861
|
+
* `except`, after messages from elsewhere were stored or a sibling copy was
|
|
1862
|
+
* not fully stored. Rounds toward the same transport coalesce whatever
|
|
1863
|
+
* their source. A route that skipped a message gets none, because its relay
|
|
1864
|
+
* would offer that message again, and a route rechecking one stops
|
|
1865
|
+
* rechecking, because its round may have read the database before this
|
|
1866
|
+
* event. A later requested round through such a route reconciles it.
|
|
1867
|
+
*/
|
|
1868
|
+
const requestRoundsForReceivedMessages = (ownerId, { except, afterQueuedWrites, }) => {
|
|
1869
|
+
const keys = [];
|
|
1870
|
+
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
1871
|
+
if (except && isTargetTransport(except, transport))
|
|
1872
|
+
return;
|
|
1873
|
+
const key = structuralLookup(transport);
|
|
1874
|
+
const route = routesByOwnerIdByKey.get(key)?.get(ownerId);
|
|
1875
|
+
if (route?.progress.type === "Settled")
|
|
1876
|
+
return;
|
|
1877
|
+
if (route?.progress.type === "Pending" && route.progress.skip) {
|
|
1878
|
+
// A round already requested may have read the database before these
|
|
1879
|
+
// messages were stored, so it can no longer complete the route.
|
|
1880
|
+
route.progress = {
|
|
1881
|
+
...route.progress,
|
|
1882
|
+
skip: { ...route.progress.skip, isRechecking: false },
|
|
1883
|
+
};
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
keys.push(key);
|
|
1887
|
+
});
|
|
1888
|
+
for (const key of keys) {
|
|
1889
|
+
requestCreateSyncMessages(new Set([ownerId]), { type: "Transport", key }, { afterQueuedWrites });
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1453
1892
|
/** The keys of every transport claimed for the owner, by any database. */
|
|
1454
1893
|
const getClaimedKeys = (ownerId) => [...deps.transports.getResourceKeysForClaim(ownerId)].map(structuralLookup);
|
|
1455
1894
|
const getUsedOwnersById = (ownerIds) => {
|
|
@@ -1468,12 +1907,19 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1468
1907
|
return;
|
|
1469
1908
|
const usedOwnersById = getUsedOwnersById(ownerIds);
|
|
1470
1909
|
// Opening, storing messages from another transport, a failure, and an
|
|
1471
|
-
// explicit request each require a new round before completion.
|
|
1910
|
+
// explicit request each require a new round before completion. The
|
|
1911
|
+
// round also checks again whether the relay holds a skipped message.
|
|
1472
1912
|
for (const ownerId of usedOwnersById.keys()) {
|
|
1473
1913
|
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
1474
1914
|
if (!isTargetTransport(target, transport))
|
|
1475
1915
|
return;
|
|
1476
|
-
getRoute(ownerId, structuralLookup(transport))
|
|
1916
|
+
const route = getRoute(ownerId, structuralLookup(transport));
|
|
1917
|
+
const pending = routeToPending(route.progress);
|
|
1918
|
+
route.progress = {
|
|
1919
|
+
...pending,
|
|
1920
|
+
roundRequired: true,
|
|
1921
|
+
skip: pending.skip && { ...pending.skip, isRechecking: true },
|
|
1922
|
+
};
|
|
1477
1923
|
});
|
|
1478
1924
|
}
|
|
1479
1925
|
refreshSyncRoutes();
|
|
@@ -1599,31 +2045,28 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1599
2045
|
deps.publishSyncState();
|
|
1600
2046
|
});
|
|
1601
2047
|
const tenant = disposable({
|
|
1602
|
-
getSyncTenant: () =>
|
|
1603
|
-
name,
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
writable,
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
: [],
|
|
1625
|
-
})),
|
|
1626
|
-
}),
|
|
2048
|
+
getSyncTenant: () => startupError
|
|
2049
|
+
? { type: "Refused", name, error: startupError }
|
|
2050
|
+
: {
|
|
2051
|
+
type: "Active",
|
|
2052
|
+
name,
|
|
2053
|
+
owners: getSyncOwners().map(({ ownerId, writable, transportKeys }) => writable
|
|
2054
|
+
? {
|
|
2055
|
+
type: "Writable",
|
|
2056
|
+
ownerId,
|
|
2057
|
+
routes: transportKeys.map((key) => {
|
|
2058
|
+
const route = routesByOwnerIdByKey
|
|
2059
|
+
.get(key)
|
|
2060
|
+
?.get(ownerId);
|
|
2061
|
+
// Registration and claim changes refresh routes
|
|
2062
|
+
// before yielding. Snapshot reads must not create
|
|
2063
|
+
// missing routes.
|
|
2064
|
+
assertNotUndefined(route);
|
|
2065
|
+
return { transportKey: key, ...route };
|
|
2066
|
+
}),
|
|
2067
|
+
}
|
|
2068
|
+
: { type: "Readonly", ownerId, transportKeys }),
|
|
2069
|
+
},
|
|
1627
2070
|
refreshSyncRoutes,
|
|
1628
2071
|
addInstance: (message, tabPort, onDisposed) => {
|
|
1629
2072
|
const disposer = new AsyncDisposableStack();
|
|
@@ -1646,18 +2089,20 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1646
2089
|
};
|
|
1647
2090
|
instancesById.set(instance.id, instance);
|
|
1648
2091
|
disposer.defer(instance.onDisposed);
|
|
1649
|
-
disposer.defer(
|
|
1650
|
-
|
|
1651
|
-
for (const
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
deps.publishSyncState();
|
|
1658
|
-
return ok();
|
|
1659
|
-
}));
|
|
2092
|
+
disposer.defer(() => {
|
|
2093
|
+
for (const leases of instance.ownerRegistrations.values()) {
|
|
2094
|
+
for (const lease of leases)
|
|
2095
|
+
lease?.release();
|
|
2096
|
+
}
|
|
2097
|
+
instance.ownerRegistrations.clear();
|
|
2098
|
+
deps.refreshAllSyncRoutes();
|
|
2099
|
+
deps.publishSyncState();
|
|
1660
2100
|
});
|
|
2101
|
+
// Cleanup starts no Task, because a root abort may dispose every Run
|
|
2102
|
+
// first. Disposed before the claims above are released, this Run
|
|
2103
|
+
// aborts queued UseOwner batches and waits for the running one, whose
|
|
2104
|
+
// transport claim cannot be aborted, so the release sees every lease.
|
|
2105
|
+
const instanceRun = disposer.use(tenantRun.create());
|
|
1661
2106
|
disposer.defer(() => {
|
|
1662
2107
|
instancesById.delete(instance.id);
|
|
1663
2108
|
refreshSyncRoutes();
|
|
@@ -1667,11 +2112,17 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1667
2112
|
disposer.defer(() => {
|
|
1668
2113
|
instance.port.onMessage = null;
|
|
1669
2114
|
});
|
|
2115
|
+
// Instances of one name share the tenant, so each learns the mode
|
|
2116
|
+
// the first one chose.
|
|
2117
|
+
instance.port.postMessage({
|
|
2118
|
+
type: "OnDevicePersistence",
|
|
2119
|
+
devicePersistence,
|
|
2120
|
+
});
|
|
1670
2121
|
// The main-thread Evolu instance holds this per-instance leader lock
|
|
1671
2122
|
// while it is alive. Acquiring the same lock here means the main
|
|
1672
2123
|
// thread instance was disposed or its tab closed, so the tenant-side
|
|
1673
2124
|
// instance must dispose itself.
|
|
1674
|
-
void
|
|
2125
|
+
void instanceRun
|
|
1675
2126
|
.abortable(acquireLeaderLock(message.id))
|
|
1676
2127
|
.then((lock) => {
|
|
1677
2128
|
if (!lock.ok)
|
|
@@ -1712,7 +2163,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1712
2163
|
break;
|
|
1713
2164
|
}
|
|
1714
2165
|
case "UseOwner": {
|
|
1715
|
-
void
|
|
2166
|
+
void instanceRun(instance.useOwnerMutex.withLock(async (run) => {
|
|
1716
2167
|
for (const action of message.actions) {
|
|
1717
2168
|
switch (action.action) {
|
|
1718
2169
|
case "sync":
|
|
@@ -1808,14 +2259,14 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
|
|
|
1808
2259
|
// readonly reload: boolean;
|
|
1809
2260
|
// })
|
|
1810
2261
|
// TODO: SharedWorker follow-ups.
|
|
1811
|
-
// - Complete the queue head when a DbWorker mutation returns an error.
|
|
1812
2262
|
// - Rotate the node ID when a copied database is detected; see the Duplicate
|
|
1813
2263
|
// node IDs section in the Timestamp module.
|
|
1814
2264
|
// - Detect DbWorker and port liveness so a worker-only crash resumes the queue.
|
|
1815
2265
|
// Defer panicked-worker restart until failure detection and recovery are
|
|
1816
|
-
// defined, accounting for SQLite WASM's detection limits.
|
|
1817
|
-
// operations are expected not to throw;
|
|
1818
|
-
// can make replicated writes fail, are
|
|
2266
|
+
// defined, accounting for SQLite WASM's detection limits. A mutation that
|
|
2267
|
+
// throws is answered, but other SQLite operations are expected not to throw;
|
|
2268
|
+
// user-defined UNIQUE indexes, which can make replicated writes fail, are
|
|
2269
|
+
// planned to be forbidden.
|
|
1819
2270
|
// - Split worker protocol types and the EvoluTenant implementation into focused
|
|
1820
2271
|
// modules.
|
|
1821
2272
|
// - Remove the obsolete commented protocol block above.
|