@evolu/common 8.9.0 → 8.11.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/Bytes.d.ts +647 -0
- package/dist/src/Bytes.d.ts.map +1 -0
- package/dist/src/{Binary.js → Bytes.js} +266 -16
- package/dist/src/Config.d.ts +142 -0
- package/dist/src/Config.d.ts.map +1 -0
- package/dist/src/Config.js +181 -0
- package/dist/src/Console.d.ts +62 -7
- package/dist/src/Console.d.ts.map +1 -1
- package/dist/src/Console.js +20 -4
- package/dist/src/Crypto.d.ts +76 -4
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +55 -4
- package/dist/src/Error.d.ts +45 -0
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +69 -0
- package/dist/src/Fs.d.ts +376 -0
- package/dist/src/Fs.d.ts.map +1 -0
- package/dist/src/Fs.js +113 -0
- package/dist/src/Identicon.d.ts +2 -2
- package/dist/src/Identicon.js +2 -2
- package/dist/src/LeakDetector.d.ts +22 -3
- package/dist/src/LeakDetector.d.ts.map +1 -1
- package/dist/src/LeakDetector.js +12 -2
- package/dist/src/LockManager.d.ts +8 -0
- package/dist/src/LockManager.d.ts.map +1 -1
- package/dist/src/LockManager.js +6 -0
- package/dist/src/Number.d.ts +50 -7
- package/dist/src/Number.d.ts.map +1 -1
- package/dist/src/Number.js +47 -8
- package/dist/src/Object.d.ts +32 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +46 -0
- package/dist/src/Platform.d.ts +47 -7
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +24 -5
- package/dist/src/Random.d.ts +25 -2
- package/dist/src/Random.d.ts.map +1 -1
- package/dist/src/Random.js +14 -2
- package/dist/src/Resource.d.ts +156 -1
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +201 -72
- package/dist/src/Schedule.d.ts +11 -10
- package/dist/src/Schedule.d.ts.map +1 -1
- package/dist/src/Schedule.js +1 -1
- package/dist/src/Sqlite.d.ts +132 -16
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +64 -10
- package/dist/src/Task.d.ts +15 -4
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +41 -15
- package/dist/src/Test.d.ts +9 -0
- package/dist/src/Test.d.ts.map +1 -1
- package/dist/src/Test.js +4 -0
- package/dist/src/Time.d.ts +179 -20
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +95 -6
- package/dist/src/Type.d.ts +3056 -1539
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +2548 -584
- package/dist/src/WebSocket.d.ts +164 -13
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +133 -24
- package/dist/src/Worker.d.ts +90 -8
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +28 -2
- package/dist/src/index.d.ts +9 -8
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -4
- package/dist/src/intl/_en.d.ts +24 -1
- package/dist/src/intl/_en.d.ts.map +1 -1
- package/dist/src/intl/_en.js +20 -0
- package/dist/src/intl/ar.d.ts +24 -1
- package/dist/src/intl/ar.d.ts.map +1 -1
- package/dist/src/intl/ar.js +20 -0
- package/dist/src/intl/bn.d.ts +24 -1
- package/dist/src/intl/bn.d.ts.map +1 -1
- package/dist/src/intl/bn.js +20 -0
- package/dist/src/intl/ca.d.ts +24 -1
- package/dist/src/intl/ca.d.ts.map +1 -1
- package/dist/src/intl/ca.js +20 -0
- package/dist/src/intl/cs.d.ts +24 -1
- package/dist/src/intl/cs.d.ts.map +1 -1
- package/dist/src/intl/cs.js +20 -0
- package/dist/src/intl/da.d.ts +24 -1
- package/dist/src/intl/da.d.ts.map +1 -1
- package/dist/src/intl/da.js +20 -0
- package/dist/src/intl/de.d.ts +24 -1
- package/dist/src/intl/de.d.ts.map +1 -1
- package/dist/src/intl/de.js +20 -0
- package/dist/src/intl/el.d.ts +24 -1
- package/dist/src/intl/el.d.ts.map +1 -1
- package/dist/src/intl/el.js +20 -0
- package/dist/src/intl/es.d.ts +24 -1
- package/dist/src/intl/es.d.ts.map +1 -1
- package/dist/src/intl/es.js +20 -0
- package/dist/src/intl/fa.d.ts +24 -1
- package/dist/src/intl/fa.d.ts.map +1 -1
- package/dist/src/intl/fa.js +20 -0
- package/dist/src/intl/fi.d.ts +24 -1
- package/dist/src/intl/fi.d.ts.map +1 -1
- package/dist/src/intl/fi.js +20 -0
- package/dist/src/intl/fil.d.ts +24 -1
- package/dist/src/intl/fil.d.ts.map +1 -1
- package/dist/src/intl/fil.js +20 -0
- package/dist/src/intl/fr.d.ts +24 -1
- package/dist/src/intl/fr.d.ts.map +1 -1
- package/dist/src/intl/fr.js +20 -0
- package/dist/src/intl/he.d.ts +24 -1
- package/dist/src/intl/he.d.ts.map +1 -1
- package/dist/src/intl/he.js +20 -0
- package/dist/src/intl/hi.d.ts +24 -1
- package/dist/src/intl/hi.d.ts.map +1 -1
- package/dist/src/intl/hi.js +20 -0
- package/dist/src/intl/hr.d.ts +24 -1
- package/dist/src/intl/hr.d.ts.map +1 -1
- package/dist/src/intl/hr.js +20 -0
- package/dist/src/intl/hu.d.ts +22 -1
- package/dist/src/intl/hu.d.ts.map +1 -1
- package/dist/src/intl/hu.js +18 -0
- package/dist/src/intl/id.d.ts +24 -1
- package/dist/src/intl/id.d.ts.map +1 -1
- package/dist/src/intl/id.js +20 -0
- package/dist/src/intl/it.d.ts +24 -1
- package/dist/src/intl/it.d.ts.map +1 -1
- package/dist/src/intl/it.js +20 -0
- package/dist/src/intl/ja.d.ts +24 -1
- package/dist/src/intl/ja.d.ts.map +1 -1
- package/dist/src/intl/ja.js +20 -0
- package/dist/src/intl/ko.d.ts +24 -1
- package/dist/src/intl/ko.d.ts.map +1 -1
- package/dist/src/intl/ko.js +20 -0
- package/dist/src/intl/ml.d.ts +24 -1
- package/dist/src/intl/ml.d.ts.map +1 -1
- package/dist/src/intl/ml.js +20 -0
- package/dist/src/intl/mr.d.ts +24 -1
- package/dist/src/intl/mr.d.ts.map +1 -1
- package/dist/src/intl/mr.js +20 -0
- package/dist/src/intl/ms.d.ts +24 -1
- package/dist/src/intl/ms.d.ts.map +1 -1
- package/dist/src/intl/ms.js +20 -0
- package/dist/src/intl/nb.d.ts +22 -1
- package/dist/src/intl/nb.d.ts.map +1 -1
- package/dist/src/intl/nb.js +18 -0
- package/dist/src/intl/nl.d.ts +24 -1
- package/dist/src/intl/nl.d.ts.map +1 -1
- package/dist/src/intl/nl.js +20 -0
- package/dist/src/intl/pa.d.ts +24 -1
- package/dist/src/intl/pa.d.ts.map +1 -1
- package/dist/src/intl/pa.js +20 -0
- package/dist/src/intl/pl.d.ts +23 -0
- package/dist/src/intl/pl.d.ts.map +1 -1
- package/dist/src/intl/pl.js +20 -0
- package/dist/src/intl/pt-BR.d.ts +24 -1
- package/dist/src/intl/pt-BR.d.ts.map +1 -1
- package/dist/src/intl/pt-BR.js +20 -0
- package/dist/src/intl/pt.d.ts +24 -1
- package/dist/src/intl/pt.d.ts.map +1 -1
- package/dist/src/intl/pt.js +20 -0
- package/dist/src/intl/ro.d.ts +24 -1
- package/dist/src/intl/ro.d.ts.map +1 -1
- package/dist/src/intl/ro.js +20 -0
- package/dist/src/intl/sk.d.ts +24 -1
- package/dist/src/intl/sk.d.ts.map +1 -1
- package/dist/src/intl/sk.js +20 -0
- package/dist/src/intl/sl.d.ts +24 -1
- package/dist/src/intl/sl.d.ts.map +1 -1
- package/dist/src/intl/sl.js +20 -0
- package/dist/src/intl/sv.d.ts +24 -1
- package/dist/src/intl/sv.d.ts.map +1 -1
- package/dist/src/intl/sv.js +20 -0
- package/dist/src/intl/sw.d.ts +21 -0
- package/dist/src/intl/sw.d.ts.map +1 -1
- package/dist/src/intl/sw.js +18 -0
- package/dist/src/intl/ta.d.ts +24 -1
- package/dist/src/intl/ta.d.ts.map +1 -1
- package/dist/src/intl/ta.js +20 -0
- package/dist/src/intl/te.d.ts +24 -1
- package/dist/src/intl/te.d.ts.map +1 -1
- package/dist/src/intl/te.js +20 -0
- package/dist/src/intl/th.d.ts +24 -1
- package/dist/src/intl/th.d.ts.map +1 -1
- package/dist/src/intl/th.js +20 -0
- package/dist/src/intl/tr.d.ts +24 -1
- package/dist/src/intl/tr.d.ts.map +1 -1
- package/dist/src/intl/tr.js +20 -0
- package/dist/src/intl/uk.d.ts +80 -57
- package/dist/src/intl/uk.d.ts.map +1 -1
- package/dist/src/intl/uk.js +174 -149
- package/dist/src/intl/ur.d.ts +24 -1
- package/dist/src/intl/ur.d.ts.map +1 -1
- package/dist/src/intl/ur.js +20 -0
- package/dist/src/intl/vi.d.ts +24 -1
- package/dist/src/intl/vi.d.ts.map +1 -1
- package/dist/src/intl/vi.js +20 -0
- package/dist/src/intl/zh-CN.d.ts +24 -1
- package/dist/src/intl/zh-CN.d.ts.map +1 -1
- package/dist/src/intl/zh-CN.js +20 -0
- package/dist/src/intl/zh-TW.d.ts +24 -1
- package/dist/src/intl/zh-TW.d.ts.map +1 -1
- package/dist/src/intl/zh-TW.js +20 -0
- package/dist/src/local-first/Db.d.ts +52 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +412 -137
- package/dist/src/local-first/Evolu.d.ts +336 -211
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +102 -15
- package/dist/src/local-first/Owner.d.ts +13 -30
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +13 -30
- package/dist/src/local-first/Protocol.d.ts +95 -17
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +119 -39
- package/dist/src/local-first/Query.d.ts +8 -15
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Schema.d.ts +345 -21
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +214 -17
- package/dist/src/local-first/Shared.d.ts +537 -22
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +1437 -234
- package/dist/src/local-first/Storage.d.ts +192 -14
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +82 -21
- package/dist/src/local-first/Timestamp.d.ts +392 -41
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +404 -82
- package/dist/src/local-first/index.d.ts +0 -1
- package/dist/src/local-first/index.d.ts.map +1 -1
- package/dist/src/local-first/index.js +0 -1
- package/package.json +1 -1
- package/src/Assert.test.ts +2 -5
- package/src/{Binary.test.ts → Bytes.test.ts} +286 -1
- package/src/{Binary.ts → Bytes.ts} +652 -21
- package/src/Config.test.ts +668 -0
- package/src/Config.ts +410 -0
- package/src/Console.ts +62 -7
- package/src/Crypto.ts +76 -4
- package/src/Eq.test.ts +2 -3
- package/src/Error.test.ts +76 -3
- package/src/Error.ts +71 -0
- package/src/Fs.test.ts +105 -0
- package/src/Fs.ts +488 -0
- package/src/Identicon.ts +2 -2
- package/src/LeakDetector.ts +22 -3
- package/src/LockManager.ts +8 -0
- package/src/Number.test.ts +82 -18
- package/src/Number.ts +76 -8
- package/src/Object.test.ts +139 -10
- package/src/Object.ts +49 -0
- package/src/Platform.ts +50 -8
- package/src/Random.ts +25 -2
- package/src/Resource.test.ts +837 -0
- package/src/Resource.ts +235 -15
- package/src/Schedule.test.ts +50 -12
- package/src/Schedule.ts +24 -14
- package/src/Sqlite.ts +138 -18
- package/src/Task.test.ts +189 -8
- package/src/Task.ts +56 -17
- package/src/Test.ts +9 -0
- package/src/Time.test.ts +82 -11
- package/src/Time.ts +246 -24
- package/src/Type.test.ts +3994 -1119
- package/src/Type.ts +7258 -3842
- package/src/Types.test.ts +4 -14
- package/src/WebSocket.ts +313 -40
- package/src/Worker.ts +90 -8
- package/src/index.ts +18 -7
- package/src/intl/_en.ts +70 -0
- package/src/intl/ar.ts +71 -0
- package/src/intl/bn.ts +70 -0
- package/src/intl/ca.ts +70 -0
- package/src/intl/cs.ts +70 -0
- package/src/intl/da.ts +70 -0
- package/src/intl/de.ts +70 -0
- package/src/intl/el.ts +70 -0
- package/src/intl/es.ts +70 -0
- package/src/intl/fa.ts +70 -0
- package/src/intl/fi.ts +70 -0
- package/src/intl/fil.ts +70 -0
- package/src/intl/fr.ts +70 -0
- package/src/intl/he.ts +70 -0
- package/src/intl/hi.ts +70 -0
- package/src/intl/hr.ts +70 -0
- package/src/intl/hu.ts +69 -0
- package/src/intl/id.ts +70 -0
- package/src/intl/intl.test.ts +819 -1
- package/src/intl/it.ts +70 -0
- package/src/intl/ja.ts +70 -0
- package/src/intl/ko.ts +68 -0
- package/src/intl/ml.ts +70 -0
- package/src/intl/mr.ts +70 -0
- package/src/intl/ms.ts +71 -0
- package/src/intl/nb.ts +69 -0
- package/src/intl/nl.ts +70 -0
- package/src/intl/pa.ts +70 -0
- package/src/intl/pl.ts +63 -0
- package/src/intl/pt-BR.ts +70 -0
- package/src/intl/pt.ts +71 -0
- package/src/intl/ro.ts +70 -0
- package/src/intl/sk.ts +71 -0
- package/src/intl/sl.ts +70 -0
- package/src/intl/sv.ts +70 -0
- package/src/intl/sw.ts +62 -0
- package/src/intl/ta.ts +70 -0
- package/src/intl/te.ts +70 -0
- package/src/intl/th.ts +68 -0
- package/src/intl/tr.ts +70 -0
- package/src/intl/uk.ts +228 -155
- package/src/intl/ur.ts +70 -0
- package/src/intl/vi.ts +70 -0
- package/src/intl/zh-CN.ts +68 -0
- package/src/intl/zh-TW.ts +68 -0
- package/src/local-first/Db.ts +644 -339
- package/src/local-first/Evolu.test.ts +686 -21
- package/src/local-first/Evolu.ts +450 -228
- package/src/local-first/Owner.ts +13 -30
- package/src/local-first/Protocol.test.ts +618 -11
- package/src/local-first/Protocol.ts +197 -73
- package/src/local-first/Query.ts +8 -15
- package/src/local-first/Schema.test.ts +143 -0
- package/src/local-first/Schema.ts +374 -24
- package/src/local-first/Shared.test.ts +7731 -559
- package/src/local-first/Shared.ts +2036 -267
- package/src/local-first/Storage.ts +219 -33
- package/src/local-first/Timestamp.test.ts +344 -70
- package/src/local-first/Timestamp.ts +435 -119
- package/src/local-first/index.ts +0 -1
- package/dist/src/Binary.d.ts +0 -254
- package/dist/src/Binary.d.ts.map +0 -1
- package/dist/src/local-first/Error.d.ts +0 -12
- package/dist/src/local-first/Error.d.ts.map +0 -1
- package/dist/src/local-first/Error.js +0 -6
- package/dist/src/local-first/LocalAuth.d.ts +0 -150
- package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
- package/dist/src/local-first/LocalAuth.js +0 -179
- package/src/local-first/Error.ts +0 -17
- package/src/local-first/LocalAuth.ts +0 -457
|
@@ -1,6 +1,213 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Platform-agnostic Evolu SharedWorker.
|
|
3
3
|
*
|
|
4
|
+
* ## Builds
|
|
5
|
+
*
|
|
6
|
+
* Tabs of different app builds can be open at once, for example an old tab
|
|
7
|
+
* during a deploy. Bundlers derive the worker script's URL from its content, so
|
|
8
|
+
* every build whose worker code differs gets its own SharedWorker, and all of
|
|
9
|
+
* them open the same databases. Only one may use them at a time: a request
|
|
10
|
+
* retried after another worker wrote from the same stored clock can reuse that
|
|
11
|
+
* write's timestamps and then is skipped as already stored.
|
|
12
|
+
*
|
|
13
|
+
* The worker therefore takes an origin-wide lock before it answers any tab and
|
|
14
|
+
* holds it for its lifetime. A worker of another build waits, with its tabs'
|
|
15
|
+
* messages buffered, until every tab of the first one is closed or reloaded and
|
|
16
|
+
* the browser ends it. The lock is the one that earlier releases take in their
|
|
17
|
+
* leader tab, so they are excluded too. An earlier release's worker can outlive
|
|
18
|
+
* its leader tab and resume once the lock is free; it computes new timestamps
|
|
19
|
+
* when it retries, so it cannot reuse another worker's. A tab of an earlier
|
|
20
|
+
* release that opens while a worker of this release runs gets no response to
|
|
21
|
+
* its database requests until it reloads, and until then it also blocks workers
|
|
22
|
+
* that start after this one ends.
|
|
23
|
+
*
|
|
24
|
+
* On the web, the wait is usually short, because tabs of the running build
|
|
25
|
+
* reload to load the build the server now serves:
|
|
26
|
+
*
|
|
27
|
+
* 1. A worker tells the tabs that connect before it holds the lock that they wait,
|
|
28
|
+
* and such a tab announces the worker with {@link BuildWaiting}. It announces
|
|
29
|
+
* again when a tab that connects asks with {@link BuildWaitingRequest}, so a
|
|
30
|
+
* tab that started or connected after the first announcement learns of it
|
|
31
|
+
* too.
|
|
32
|
+
* 2. A tab connected to another worker reloads with {@link ReloadApp}: at once if
|
|
33
|
+
* the user is not in it, otherwise once they leave it, so a tab never
|
|
34
|
+
* reloads while the user works in it. Focus can leave a page from a frame
|
|
35
|
+
* without a window event, so such a tab checks once a second whether it
|
|
36
|
+
* still has focus. A tab that still waits itself keeps the announcements it
|
|
37
|
+
* receives and handles them once it connects, because the lock can pass to
|
|
38
|
+
* its worker first.
|
|
39
|
+
* 3. A page that such a reload loaded never announces, and a tab reloads at most
|
|
40
|
+
* once for each waiting worker, so two builds cannot keep reloading each
|
|
41
|
+
* other, even when a reload loads the running build again. A tab without
|
|
42
|
+
* session storage cannot record its reloads, so it does not reload.
|
|
43
|
+
*
|
|
44
|
+
* Only the web platform does this, because only there do builds coexist. A
|
|
45
|
+
* reload loses UI state the app did not persist, so apps keep drafts in
|
|
46
|
+
* local-only tables. A write a background tab has in flight can be lost, as
|
|
47
|
+
* when a tab crashes.
|
|
48
|
+
*
|
|
49
|
+
* The wait lasts while a tab of the running build does not reload: a tab of an
|
|
50
|
+
* earlier release, a tab Safari has frozen, a tab without session storage, or a
|
|
51
|
+
* tab whose reload loads the running build again, for example because another
|
|
52
|
+
* Evolu app shares the origin or a cache still serves the old build. A worker
|
|
53
|
+
* still waiting after three seconds reports {@link OtherBuildRunningError} to
|
|
54
|
+
* its tabs. Every build broadcasts console entries and errors on
|
|
55
|
+
* {@link consoleEntryOrErrorBroadcastChannelName}, so a waiting tab also prints
|
|
56
|
+
* the running build's output and reports its errors as its own `evoluError`.
|
|
57
|
+
*
|
|
58
|
+
* The tabs of one worker elect the host of its DbWorkers among themselves, with
|
|
59
|
+
* a lock scoped to the worker, so a tab of another worker never hosts them.
|
|
60
|
+
* Builds that differ only in DbWorker code share one SharedWorker, and a tab of
|
|
61
|
+
* either can host its DbWorkers.
|
|
62
|
+
*
|
|
63
|
+
* Safari suspends, rather than ends, a worker whose tabs are all in its
|
|
64
|
+
* back-forward cache, and the suspended worker keeps the lock. There, a waiting
|
|
65
|
+
* build also waits until Safari drops those pages, or until the user goes back
|
|
66
|
+
* to one of them, which reloads it on the web.
|
|
67
|
+
*
|
|
68
|
+
* ## Storage
|
|
69
|
+
*
|
|
70
|
+
* A platform that can lack persistent storage, as a browser does in Safari's
|
|
71
|
+
* Private Browsing, provides {@link PersistentStorageDep}. The worker checks it
|
|
72
|
+
* once, after it takes the build lock and before any DbWorker starts. Without
|
|
73
|
+
* persistent storage, every DbWorker it starts keeps its database in memory,
|
|
74
|
+
* replacements included, and each tab that connects is told with
|
|
75
|
+
* `StorageUnavailable`. The decision holds for the worker's lifetime, so all
|
|
76
|
+
* its tabs see one mode, and the next worker checks again.
|
|
77
|
+
*
|
|
78
|
+
* The check cannot tell a private session from a storage failure, but memory
|
|
79
|
+
* loses nothing that refusing to start would have kept, and the persistent
|
|
80
|
+
* database stays untouched. Each DbWorker keeps its own memory, so when the tab
|
|
81
|
+
* hosting it closes, or on the web navigates away, data that exists only
|
|
82
|
+
* locally or has not synced yet is lost, even though its replacement starts in
|
|
83
|
+
* memory too.
|
|
84
|
+
*
|
|
85
|
+
* ## Synchronization routing
|
|
86
|
+
*
|
|
87
|
+
* WebSocket transports are shared resources keyed by their configuration and
|
|
88
|
+
* claimed by owner ID, so every tenant (one named local database) using an
|
|
89
|
+
* owner shares that owner's sockets, whichever tenant claimed them. An incoming
|
|
90
|
+
* frame is offered to every tenant with a writable registration for its owner,
|
|
91
|
+
* and each tenant reconciles independently: the protocol exchange is stateless
|
|
92
|
+
* per message and message writes are idempotent, so a response that answered
|
|
93
|
+
* another tenant's request is still a valid reconciliation step.
|
|
94
|
+
*
|
|
95
|
+
* Traffic goes only where it is needed. A continuation returns to the transport
|
|
96
|
+
* that produced the response, and a round started by a socket opening or by a
|
|
97
|
+
* tenant's first use of a transport for an owner goes through that transport.
|
|
98
|
+
* Explicit synchronization requests and mutation uploads go to every open
|
|
99
|
+
* transport claimed for the owner. A write uploads through the database's
|
|
100
|
+
* writable registrations for its owner, whichever instance made it, even one
|
|
101
|
+
* disposed before the database worker answered. When a relay frame stores new
|
|
102
|
+
* messages, the tenant requests a round through each other transport claimed
|
|
103
|
+
* for the owner, so data learned from one relay reaches the others. A closed
|
|
104
|
+
* transport reconciles when it opens, and a replacement leader reconciles every
|
|
105
|
+
* transport again, because a response reporting stored messages may have been
|
|
106
|
+
* lost.
|
|
107
|
+
*
|
|
108
|
+
* Relays omit the sending socket when broadcasting an upload, so the uploader
|
|
109
|
+
* also delivers it as local Broadcast frames to every other tenant with
|
|
110
|
+
* writable access to the owner, even while sockets are closed. A copy keeps the
|
|
111
|
+
* uploader's target, and a recipient that stores new continuation messages
|
|
112
|
+
* reconciles them through the transports outside that target. Local delivery
|
|
113
|
+
* forwards uploads, including historical messages sent during reconciliation,
|
|
114
|
+
* but does not reconcile local database histories with each other. That waits
|
|
115
|
+
* until replication scopes and retention semantics are defined.
|
|
116
|
+
*
|
|
117
|
+
* ## Sync state
|
|
118
|
+
*
|
|
119
|
+
* The shared worker publishes one plain snapshot, {@link SyncState}, of every
|
|
120
|
+
* transport it manages and every database and owner registration it holds, with
|
|
121
|
+
* one route per writable registration and transport, as specified below.
|
|
122
|
+
* {@link syncStateToOwnerSyncStates} derives one state per database and owner.
|
|
123
|
+
*
|
|
124
|
+
* Each worker broadcasts snapshots on its own channel, whose name a connecting
|
|
125
|
+
* tab receives through its port, so a tab never hears another worker, such as
|
|
126
|
+
* one of a different app version, and
|
|
127
|
+
* {@link SyncStateDep.syncState | deps.syncState} keeps the last snapshot. The
|
|
128
|
+
* worker publishes after every change it observes; a transition without an
|
|
129
|
+
* event, such as a closed socket starting to reconnect, appears with the next
|
|
130
|
+
* snapshot. The snapshot lives in worker memory only, so a new worker starts
|
|
131
|
+
* empty.
|
|
132
|
+
*
|
|
133
|
+
* ## Synchronization completion
|
|
134
|
+
*
|
|
135
|
+
* A protocol frame carries the owner ID and the message type but nothing that
|
|
136
|
+
* correlates it with a request, and a relay answers a converged round, an
|
|
137
|
+
* upload that fits one frame, and an Unsubscribe alike with a header-only
|
|
138
|
+
* Response. Every tenant with a writable registration applies every frame for
|
|
139
|
+
* its owner, so a tenant cannot tell which response answered its own request.
|
|
140
|
+
*
|
|
141
|
+
* Completion is therefore counted, not attributed. The shared worker counts
|
|
142
|
+
* outstanding requests per owner and socket: every Request sent on an open
|
|
143
|
+
* socket, including an Unsubscribe, increments the count; every Response,
|
|
144
|
+
* including a relay's version-mismatch reply, which has no message type,
|
|
145
|
+
* decrements it; and an opening socket resets it, because requests on the
|
|
146
|
+
* previous connection are never answered. A route, one tenant's use of one
|
|
147
|
+
* owner through one transport, is complete when:
|
|
148
|
+
*
|
|
149
|
+
* - The tenant has not refused startup and the socket is open.
|
|
150
|
+
* - The count is zero.
|
|
151
|
+
* - The tenant has no apply for the owner queued for that transport or for every
|
|
152
|
+
* transport, because a frame is applied asynchronously, so the count reads
|
|
153
|
+
* zero in the middle of a chain.
|
|
154
|
+
* - The tenant has no replicated write for the owner queued, because its upload
|
|
155
|
+
* is sent only after the database worker answers it.
|
|
156
|
+
* - The tenant has sent a round through the transport since the last event that
|
|
157
|
+
* requires one: its first use of the transport for the owner, the socket
|
|
158
|
+
* opening, an explicit request, a replacement leader, or storing messages
|
|
159
|
+
* from another transport. No failed or aborted result has arrived on the
|
|
160
|
+
* route since.
|
|
161
|
+
*
|
|
162
|
+
* A reconciliation chain ends only with a converged result, a failure, an
|
|
163
|
+
* abort, a dropped frame, or a continuation that finds the socket closed, and
|
|
164
|
+
* relay errors reach every applying tenant, so these conditions mean every
|
|
165
|
+
* chain, including this tenant's, converged. A local Broadcast from a sibling
|
|
166
|
+
* 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 requires a round
|
|
168
|
+
* through every transport.
|
|
169
|
+
*
|
|
170
|
+
* A failed result on a route requests one round through it. Any further failure
|
|
171
|
+
* before the route completes waits for an explicit request or a reopen, so a
|
|
172
|
+
* persistent failure cannot loop; a converged reply in between does not end the
|
|
173
|
+
* wait, because it may answer another tenant's round on the shared socket. An
|
|
174
|
+
* aborted apply leaves its routes incomplete without a retry. An exception
|
|
175
|
+
* while the database worker creates a round is logged there and fails the
|
|
176
|
+
* round's routes with `SyncFailed` without a retry; other unexpected SQLite
|
|
177
|
+
* exceptions remain unsupported and can panic the database worker. A frame the
|
|
178
|
+
* relay silently drops, such as invalid data, leaves the count above zero until
|
|
179
|
+
* the liveness rule below replaces the socket.
|
|
180
|
+
*
|
|
181
|
+
* ### Liveness
|
|
182
|
+
*
|
|
183
|
+
* An open socket can be dead without a close event, when a NAT drops an idle
|
|
184
|
+
* mapping or the path fails while nothing is sent. The relay pings every
|
|
185
|
+
* connection and terminates one from which nothing has arrived since the
|
|
186
|
+
* previous ping, which also keeps NAT mappings alive. The shared worker
|
|
187
|
+
* reconnects a socket when a request for an owner has been outstanding for
|
|
188
|
+
* ninety seconds, enough for a 1 MB frame at about 90 kbit/s, with no Response
|
|
189
|
+
* for that owner since. Frames for other owners do not count: they prove the
|
|
190
|
+
* socket alive, not that the request was received.
|
|
191
|
+
*
|
|
192
|
+
* A slow link looks like a dead one, because the browser reports no transfer
|
|
193
|
+
* progress and a large frame saves nothing until it arrives whole. Each timeout
|
|
194
|
+
* therefore doubles the transport's timeout, up to twenty-four minutes: a
|
|
195
|
+
* connection that reopens but times out again is more likely slow than dead.
|
|
196
|
+
* The timeout belongs to the transport, not to an owner, because a small reply
|
|
197
|
+
* for one owner can wait behind another owner's large frame on the socket. A
|
|
198
|
+
* grown timeout lasts while a request is outstanding on the socket or a
|
|
199
|
+
* database that has not refused startup has an incomplete route through it,
|
|
200
|
+
* including one waiting after a failure, and ends with the transport. A reply's
|
|
201
|
+
* speed proves nothing, because a recovery on a slow link starts with small
|
|
202
|
+
* replies that arrive quickly. The reopen resets the counts and starts the open
|
|
203
|
+
* rounds, so a dropped frame delays a route instead of stranding it.
|
|
204
|
+
*
|
|
205
|
+
* The shared worker sends nothing while idle, so a path that dies then may go
|
|
206
|
+
* unnoticed until its next request; until then, changes from other devices stop
|
|
207
|
+
* arriving while routes still read complete. A periodic empty request would
|
|
208
|
+
* notice it without waiting for the app to send. That is deferred, because
|
|
209
|
+
* relay pings already keep NAT mappings alive, the common cause.
|
|
210
|
+
*
|
|
4
211
|
* @module
|
|
5
212
|
*/
|
|
6
213
|
|
|
@@ -11,15 +218,16 @@ import {
|
|
|
11
218
|
type NonEmptyReadonlyArray,
|
|
12
219
|
} from "../Array.ts";
|
|
13
220
|
import {
|
|
221
|
+
assert,
|
|
14
222
|
assertNonNullable,
|
|
15
223
|
assertNotSame,
|
|
16
224
|
assertNotUndefined,
|
|
225
|
+
assertSame,
|
|
17
226
|
} from "../Assert.ts";
|
|
18
227
|
import type { Brand } from "../Brand.ts";
|
|
19
|
-
import { createCallbacks } from "../Callbacks.ts";
|
|
20
228
|
import type { ConsoleEntry, ConsoleLevel } from "../Console.ts";
|
|
21
229
|
import type { EncryptionKey } from "../Crypto.ts";
|
|
22
|
-
import { disposable } from "../Function.ts";
|
|
230
|
+
import { disposable, exhaustiveCheck } from "../Function.ts";
|
|
23
231
|
import { acquireLeaderLock, type LockManagerDep } from "../LockManager.ts";
|
|
24
232
|
import {
|
|
25
233
|
createLookupMap,
|
|
@@ -27,7 +235,8 @@ import {
|
|
|
27
235
|
type LookupMap,
|
|
28
236
|
type StructuralLookupKey,
|
|
29
237
|
} from "../Lookup.ts";
|
|
30
|
-
import {
|
|
238
|
+
import type { ReloadApp } from "../Platform.ts";
|
|
239
|
+
import { createRefCountedRelation } from "../Relation.ts";
|
|
31
240
|
import {
|
|
32
241
|
createSharedResourceByKey,
|
|
33
242
|
createSharedResourceByKeyWithClaims,
|
|
@@ -46,9 +255,34 @@ import {
|
|
|
46
255
|
type Mutex,
|
|
47
256
|
type Task,
|
|
48
257
|
} from "../Task.ts";
|
|
49
|
-
import
|
|
258
|
+
import {
|
|
259
|
+
performanceDurationBetween,
|
|
260
|
+
PositiveMillis,
|
|
261
|
+
type Millis,
|
|
262
|
+
type PerformanceTime,
|
|
263
|
+
type PositiveDuration,
|
|
264
|
+
type TimeoutId,
|
|
265
|
+
} from "../Time.ts";
|
|
266
|
+
import {
|
|
267
|
+
createId,
|
|
268
|
+
id,
|
|
269
|
+
literal,
|
|
270
|
+
object,
|
|
271
|
+
type ExtractTyped,
|
|
272
|
+
type Id,
|
|
273
|
+
type InferType,
|
|
274
|
+
type LiteralType,
|
|
275
|
+
type Name,
|
|
276
|
+
type ObjectType,
|
|
277
|
+
type Typed,
|
|
278
|
+
} from "../Type.ts";
|
|
50
279
|
import type { Callback } from "../Types.ts";
|
|
51
|
-
import type {
|
|
280
|
+
import type {
|
|
281
|
+
CreateWebSocketDep,
|
|
282
|
+
WebSocket,
|
|
283
|
+
WebSocketError,
|
|
284
|
+
WebSocketReadyState,
|
|
285
|
+
} from "../WebSocket.ts";
|
|
52
286
|
import type {
|
|
53
287
|
SharedWorker as CommonSharedWorker,
|
|
54
288
|
CreateBroadcastChannelDep,
|
|
@@ -58,12 +292,14 @@ import type {
|
|
|
58
292
|
SharedWorkerSelf,
|
|
59
293
|
WorkerDeps,
|
|
60
294
|
} from "../Worker.ts";
|
|
61
|
-
import type { DbWorkerInit } from "./Db.ts";
|
|
62
|
-
import type { EvoluError } from "./
|
|
295
|
+
import type { DbWorkerInit, UnsupportedDbVersionError } from "./Db.ts";
|
|
296
|
+
import type { EvoluError, SyncStateDep } from "./Evolu.ts";
|
|
63
297
|
import type { Owner, OwnerId, OwnerTransport, SyncOwner } from "./Owner.ts";
|
|
64
298
|
import {
|
|
299
|
+
createProtocolBroadcastMessagesFromCrdtMessages,
|
|
65
300
|
createProtocolMessageForUnsubscribe,
|
|
66
301
|
createProtocolMessageFromCrdtMessages,
|
|
302
|
+
MessageType,
|
|
67
303
|
parseProtocolHeader,
|
|
68
304
|
type ApplyProtocolMessageAsClientResult,
|
|
69
305
|
type ProtocolError,
|
|
@@ -76,8 +312,9 @@ import {
|
|
|
76
312
|
type Row,
|
|
77
313
|
type RowsByQueryMap,
|
|
78
314
|
} from "./Query.ts";
|
|
79
|
-
import type
|
|
80
|
-
import type { CrdtMessage } from "./Storage.ts";
|
|
315
|
+
import { isLocalOnlyTable, type MutationChange } from "./Schema.ts";
|
|
316
|
+
import type { CrdtMessage, StorageWriteMessagesError } from "./Storage.ts";
|
|
317
|
+
import { orderTimestamp, type Timestamp } from "./Timestamp.ts";
|
|
81
318
|
|
|
82
319
|
export type SharedWorker = CommonSharedWorker<
|
|
83
320
|
SharedWorkerInput,
|
|
@@ -93,6 +330,13 @@ export type SharedWorkerInput =
|
|
|
93
330
|
readonly type: "AnnounceTabLeader";
|
|
94
331
|
readonly consoleLevel: ConsoleLevel;
|
|
95
332
|
}
|
|
333
|
+
| {
|
|
334
|
+
/**
|
|
335
|
+
* Asks the worker to broadcast a {@link SyncState} on its channel, which
|
|
336
|
+
* the tab opened after receiving its name.
|
|
337
|
+
*/
|
|
338
|
+
readonly type: "RequestSyncState";
|
|
339
|
+
}
|
|
96
340
|
| {
|
|
97
341
|
readonly type: "CreateEvolu";
|
|
98
342
|
readonly name: Name;
|
|
@@ -104,7 +348,43 @@ export type SharedWorkerInput =
|
|
|
104
348
|
readonly evoluPort: NativeMessagePort<EvoluOutput, EvoluInput>;
|
|
105
349
|
};
|
|
106
350
|
|
|
107
|
-
export type SharedWorkerOutput =
|
|
351
|
+
export type SharedWorkerOutput =
|
|
352
|
+
| DbWorkerInit
|
|
353
|
+
| {
|
|
354
|
+
/**
|
|
355
|
+
* Sent to one tab only: its database refused startup, or another build
|
|
356
|
+
* keeps this worker waiting.
|
|
357
|
+
*/
|
|
358
|
+
readonly type: "Error";
|
|
359
|
+
readonly error: UnsupportedDbVersionError | OtherBuildRunningError;
|
|
360
|
+
}
|
|
361
|
+
| {
|
|
362
|
+
/**
|
|
363
|
+
* Sent to a tab that connects while the worker waits for the build lock;
|
|
364
|
+
* see Builds. `Connected` follows once the worker holds it.
|
|
365
|
+
*/
|
|
366
|
+
readonly type: "Waiting";
|
|
367
|
+
readonly workerId: SharedWorkerId;
|
|
368
|
+
}
|
|
369
|
+
| {
|
|
370
|
+
/**
|
|
371
|
+
* Sent to a connecting tab once the worker holds the build lock; see
|
|
372
|
+
* Builds in this module's documentation. The tab elects the host of the
|
|
373
|
+
* worker's DbWorkers among the worker's tabs, scoped by `workerId`, and
|
|
374
|
+
* listens for {@link SyncState} on `syncStateChannelName`.
|
|
375
|
+
*/
|
|
376
|
+
readonly type: "Connected";
|
|
377
|
+
readonly workerId: SharedWorkerId;
|
|
378
|
+
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
|
+
};
|
|
108
388
|
|
|
109
389
|
export type ConsoleEntryOrError =
|
|
110
390
|
| {
|
|
@@ -119,6 +399,311 @@ export type ConsoleEntryOrError =
|
|
|
119
399
|
export const consoleEntryOrErrorBroadcastChannelName =
|
|
120
400
|
"evolu:console-entry-or-error";
|
|
121
401
|
|
|
402
|
+
/** Identifies one running SharedWorker instance. */
|
|
403
|
+
export const SharedWorkerId = /*#__PURE__*/ id("SharedWorker");
|
|
404
|
+
export type SharedWorkerId = typeof SharedWorkerId.Output;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The channel on which a tab announces its waiting worker with
|
|
408
|
+
* {@link BuildWaiting}; see Builds. Builds of different releases share it, so
|
|
409
|
+
* its name and messages never change.
|
|
410
|
+
*/
|
|
411
|
+
export const buildsBroadcastChannelName = "evolu:builds";
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Posted on {@link buildsBroadcastChannelName} by a tab whose worker waits for
|
|
415
|
+
* the build lock, unless an automatic reload loaded the page, when it starts
|
|
416
|
+
* waiting and for each {@link BuildWaitingRequest}. A tab connected to another
|
|
417
|
+
* worker reloads for it; see Builds.
|
|
418
|
+
*/
|
|
419
|
+
export const BuildWaiting: ObjectType<{
|
|
420
|
+
readonly type: LiteralType<"BuildWaiting">;
|
|
421
|
+
readonly workerId: typeof SharedWorkerId;
|
|
422
|
+
}> = /*#__PURE__*/ object({
|
|
423
|
+
type: /*#__PURE__*/ literal("BuildWaiting"),
|
|
424
|
+
workerId: SharedWorkerId,
|
|
425
|
+
});
|
|
426
|
+
export interface BuildWaiting extends InferType<typeof BuildWaiting> {}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Posted on {@link buildsBroadcastChannelName} by a tab once it connects, asking
|
|
430
|
+
* tabs whose worker waits to post {@link BuildWaiting} again; see Builds.
|
|
431
|
+
*/
|
|
432
|
+
export const BuildWaitingRequest: ObjectType<{
|
|
433
|
+
readonly type: LiteralType<"BuildWaitingRequest">;
|
|
434
|
+
}> = /*#__PURE__*/ object({
|
|
435
|
+
type: /*#__PURE__*/ literal("BuildWaitingRequest"),
|
|
436
|
+
});
|
|
437
|
+
export interface BuildWaitingRequest extends InferType<
|
|
438
|
+
typeof BuildWaitingRequest
|
|
439
|
+
> {}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Another build of the app holds the local databases, and this tab waits until
|
|
443
|
+
* every tab of that build is closed or reloaded; see Builds.
|
|
444
|
+
*
|
|
445
|
+
* Tabs of the other build usually reload by themselves, so this is reported
|
|
446
|
+
* only when the wait lasts, for example because the user is still in a tab of
|
|
447
|
+
* the other build, or that tab runs an earlier release or is frozen by Safari.
|
|
448
|
+
* Apps can ask the user to close the app's other tabs. It is cleared once the
|
|
449
|
+
* wait ends.
|
|
450
|
+
*/
|
|
451
|
+
export interface OtherBuildRunningError extends Typed<"OtherBuildRunningError"> {}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* A snapshot of the transports and databases the shared worker manages.
|
|
455
|
+
*
|
|
456
|
+
* See the Sync state section of this module's documentation.
|
|
457
|
+
*/
|
|
458
|
+
export interface SyncState {
|
|
459
|
+
readonly transports: ReadonlyArray<SyncTransport>;
|
|
460
|
+
readonly tenants: ReadonlyArray<SyncTenant>;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** One WebSocket, shared by every owner and database claiming it. */
|
|
464
|
+
export interface SyncTransport {
|
|
465
|
+
/** Opaque and stable for the transport's lifetime, across socket replacements. */
|
|
466
|
+
readonly id: SyncTransportId;
|
|
467
|
+
/** The URL without its query, which carries the owner ID. */
|
|
468
|
+
readonly label: string;
|
|
469
|
+
readonly readyState: WebSocketReadyState;
|
|
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;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export type SyncTransportId = Id & Brand<"SyncTransport">;
|
|
482
|
+
|
|
483
|
+
export interface SyncTransportError {
|
|
484
|
+
readonly type: WebSocketError["type"];
|
|
485
|
+
readonly at: Millis;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** One named local database and the owners it registered. */
|
|
489
|
+
export interface SyncTenant {
|
|
490
|
+
readonly name: Name;
|
|
491
|
+
/** The database refused startup, so nothing it holds synchronizes. */
|
|
492
|
+
readonly refused: boolean;
|
|
493
|
+
readonly owners: ReadonlyArray<SyncTenantOwner>;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export interface SyncTenantOwner {
|
|
497
|
+
readonly ownerId: OwnerId;
|
|
498
|
+
/** A readonly registration holds transports but never synchronizes. */
|
|
499
|
+
readonly writable: boolean;
|
|
500
|
+
/** Every transport claimed for the owner, by any database. */
|
|
501
|
+
readonly transportIds: ReadonlyArray<SyncTransportId>;
|
|
502
|
+
/** One route per transport for a writable owner; none for a readonly one. */
|
|
503
|
+
readonly routes: ReadonlyArray<SyncRoute>;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* One database's use of one owner through one transport. See the
|
|
508
|
+
* Synchronization completion section of this module's documentation.
|
|
509
|
+
*/
|
|
510
|
+
export interface SyncRoute {
|
|
511
|
+
readonly transportId: SyncTransportId;
|
|
512
|
+
/** Whether the database is reconciled with the relay for the owner. */
|
|
513
|
+
readonly complete: boolean;
|
|
514
|
+
/** When the route last became complete, or null. */
|
|
515
|
+
readonly completeAt: Millis | null;
|
|
516
|
+
/** When this database last sent a request through the route, or null. */
|
|
517
|
+
readonly lastSentAt: Millis | null;
|
|
518
|
+
/**
|
|
519
|
+
* When processing a frame from the route last finished, successfully or with
|
|
520
|
+
* a failure, or null. Aborted processing does not update this timestamp.
|
|
521
|
+
*/
|
|
522
|
+
readonly lastReceivedAt: Millis | null;
|
|
523
|
+
/** The last failed result on the route; cleared when the route completes. */
|
|
524
|
+
readonly error: SyncRouteError | null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export interface SyncRouteError {
|
|
528
|
+
readonly type: SyncRouteErrorType;
|
|
529
|
+
readonly at: Millis;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* A {@link ProtocolError} or the original {@link StorageWriteMessagesError} type
|
|
534
|
+
* for a rejected write. `WriteFailed` means a `writeMessages` call that threw,
|
|
535
|
+
* logged by the protocol, and `SyncFailed` means a logged failure while
|
|
536
|
+
* creating a round or reconciling ranges.
|
|
537
|
+
*/
|
|
538
|
+
export type SyncRouteErrorType =
|
|
539
|
+
| ProtocolError["type"]
|
|
540
|
+
| StorageWriteMessagesError["type"]
|
|
541
|
+
| "WriteFailed"
|
|
542
|
+
| "SyncFailed";
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* One owner's standing with its relays in one database, derived from
|
|
546
|
+
* {@link SyncState} by {@link syncStateToOwnerSyncStates}.
|
|
547
|
+
*/
|
|
548
|
+
export interface OwnerSyncState {
|
|
549
|
+
readonly name: Name;
|
|
550
|
+
readonly ownerId: OwnerId;
|
|
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>;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The status of an {@link OwnerSyncState}: the first of `error`, `syncing`,
|
|
562
|
+
* `synced`, and `offline` that one of its relays has, or `initial` before the
|
|
563
|
+
* owner has a relay. Work in progress on an open transport shows before a relay
|
|
564
|
+
* that is already up to date.
|
|
565
|
+
*/
|
|
566
|
+
export type OwnerSyncStatus = "initial" | RelaySyncStatus;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* One relay of an {@link OwnerSyncState}: a transport and the database's route
|
|
570
|
+
* through it.
|
|
571
|
+
*/
|
|
572
|
+
export interface RelaySyncState {
|
|
573
|
+
readonly transport: SyncTransport;
|
|
574
|
+
readonly route: SyncRoute;
|
|
575
|
+
readonly status: RelaySyncStatus;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The status of a {@link RelaySyncState}: `error` when its route failed and has
|
|
580
|
+
* not completed since, `synced` when the route is complete, `syncing` while the
|
|
581
|
+
* transport is open, and `offline` otherwise.
|
|
582
|
+
*/
|
|
583
|
+
export type RelaySyncStatus = "syncing" | "synced" | "offline" | "error";
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Folds the routes of every writable owner registration of every running
|
|
587
|
+
* database in a {@link SyncState} into one {@link OwnerSyncState} per database
|
|
588
|
+
* and owner, which pairs each route with its transport as a
|
|
589
|
+
* {@link RelaySyncState}. A database that refused startup and a readonly
|
|
590
|
+
* registration synchronize nothing, so they are left out.
|
|
591
|
+
*
|
|
592
|
+
* ### Example
|
|
593
|
+
*
|
|
594
|
+
* ```ts
|
|
595
|
+
* import {
|
|
596
|
+
* assertEqual,
|
|
597
|
+
* createId,
|
|
598
|
+
* Millis,
|
|
599
|
+
* testCreateDeps,
|
|
600
|
+
* testName,
|
|
601
|
+
* } from "@evolu/common";
|
|
602
|
+
* import {
|
|
603
|
+
* syncStateToOwnerSyncStates,
|
|
604
|
+
* testAppOwner,
|
|
605
|
+
* type SyncRoute,
|
|
606
|
+
* type SyncState,
|
|
607
|
+
* type SyncTransport,
|
|
608
|
+
* } from "@evolu/common/local-first";
|
|
609
|
+
*
|
|
610
|
+
* const deps = testCreateDeps();
|
|
611
|
+
* const transport: SyncTransport = {
|
|
612
|
+
* id: createId<"SyncTransport">(deps),
|
|
613
|
+
* label: "wss://relay.example",
|
|
614
|
+
* readyState: "open",
|
|
615
|
+
* openedAt: null,
|
|
616
|
+
* closedAt: null,
|
|
617
|
+
* error: null,
|
|
618
|
+
* };
|
|
619
|
+
* const route: SyncRoute = {
|
|
620
|
+
* transportId: transport.id,
|
|
621
|
+
* complete: true,
|
|
622
|
+
* completeAt: Millis.orThrow(1000),
|
|
623
|
+
* lastSentAt: Millis.orThrow(900),
|
|
624
|
+
* lastReceivedAt: Millis.orThrow(1000),
|
|
625
|
+
* error: null,
|
|
626
|
+
* };
|
|
627
|
+
* const state: SyncState = {
|
|
628
|
+
* transports: [transport],
|
|
629
|
+
* tenants: [
|
|
630
|
+
* {
|
|
631
|
+
* name: testName,
|
|
632
|
+
* refused: false,
|
|
633
|
+
* owners: [
|
|
634
|
+
* {
|
|
635
|
+
* ownerId: testAppOwner.id,
|
|
636
|
+
* writable: true,
|
|
637
|
+
* transportIds: [transport.id],
|
|
638
|
+
* routes: [route],
|
|
639
|
+
* },
|
|
640
|
+
* ],
|
|
641
|
+
* },
|
|
642
|
+
* ],
|
|
643
|
+
* };
|
|
644
|
+
*
|
|
645
|
+
* assertEqual(syncStateToOwnerSyncStates(state), [
|
|
646
|
+
* {
|
|
647
|
+
* name: testName,
|
|
648
|
+
* ownerId: testAppOwner.id,
|
|
649
|
+
* status: "synced",
|
|
650
|
+
* syncedAt: Millis.orThrow(1000),
|
|
651
|
+
* error: null,
|
|
652
|
+
* relays: [{ transport, route, status: "synced" }],
|
|
653
|
+
* },
|
|
654
|
+
* ]);
|
|
655
|
+
* ```
|
|
656
|
+
*/
|
|
657
|
+
export const syncStateToOwnerSyncStates = (
|
|
658
|
+
state: SyncState,
|
|
659
|
+
): ReadonlyArray<OwnerSyncState> => {
|
|
660
|
+
const transportById = new Map(
|
|
661
|
+
state.transports.map((transport) => [transport.id, transport]),
|
|
662
|
+
);
|
|
663
|
+
return state.tenants.flatMap(({ name, refused, owners }) =>
|
|
664
|
+
refused
|
|
665
|
+
? []
|
|
666
|
+
: owners.flatMap(({ ownerId, writable, routes }) => {
|
|
667
|
+
if (!writable) return [];
|
|
668
|
+
let syncedAt: Millis | null = null;
|
|
669
|
+
let error: SyncRouteError | null = null;
|
|
670
|
+
const relays: Array<RelaySyncState> = [];
|
|
671
|
+
for (const route of routes) {
|
|
672
|
+
if (
|
|
673
|
+
route.completeAt !== null &&
|
|
674
|
+
(syncedAt === null || route.completeAt > syncedAt)
|
|
675
|
+
)
|
|
676
|
+
syncedAt = route.completeAt;
|
|
677
|
+
if (
|
|
678
|
+
route.error !== null &&
|
|
679
|
+
(error === null || route.error.at > error.at)
|
|
680
|
+
)
|
|
681
|
+
error = route.error;
|
|
682
|
+
// A snapshot lists the transport of every route.
|
|
683
|
+
const transport = transportById.get(route.transportId);
|
|
684
|
+
assertNonNullable(transport);
|
|
685
|
+
relays.push({
|
|
686
|
+
transport,
|
|
687
|
+
route,
|
|
688
|
+
status:
|
|
689
|
+
route.error !== null
|
|
690
|
+
? "error"
|
|
691
|
+
: route.complete
|
|
692
|
+
? "synced"
|
|
693
|
+
: transport.readyState === "open"
|
|
694
|
+
? "syncing"
|
|
695
|
+
: "offline",
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const status: OwnerSyncStatus =
|
|
699
|
+
(["error", "syncing", "synced", "offline"] as const).find(
|
|
700
|
+
(candidate) => relays.some((relay) => relay.status === candidate),
|
|
701
|
+
) ?? "initial";
|
|
702
|
+
return [{ name, ownerId, status, syncedAt, error, relays }];
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
};
|
|
706
|
+
|
|
122
707
|
export type EvoluInput =
|
|
123
708
|
| {
|
|
124
709
|
readonly type: "Mutate";
|
|
@@ -128,10 +713,16 @@ export type EvoluInput =
|
|
|
128
713
|
}
|
|
129
714
|
| {
|
|
130
715
|
readonly type: "UseOwner";
|
|
131
|
-
readonly actions: ReadonlyArray<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
716
|
+
readonly actions: ReadonlyArray<
|
|
717
|
+
| {
|
|
718
|
+
readonly owner: SyncOwner;
|
|
719
|
+
readonly action: "add" | "remove";
|
|
720
|
+
}
|
|
721
|
+
| {
|
|
722
|
+
readonly ownerId: OwnerId;
|
|
723
|
+
readonly action: "sync";
|
|
724
|
+
}
|
|
725
|
+
>;
|
|
135
726
|
}
|
|
136
727
|
| {
|
|
137
728
|
readonly type: "Query";
|
|
@@ -156,42 +747,62 @@ export type EvoluOutput =
|
|
|
156
747
|
};
|
|
157
748
|
|
|
158
749
|
export type DbWorkerInput =
|
|
159
|
-
| (Typed<"Request"> & {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
750
|
+
| (Typed<"Request"> & { readonly attemptId: Id } & (
|
|
751
|
+
| {
|
|
752
|
+
readonly request: DbWorkerWriteRequest;
|
|
753
|
+
readonly clock: Timestamp;
|
|
754
|
+
readonly now: Millis;
|
|
755
|
+
}
|
|
756
|
+
| { readonly request: DbWorkerReadRequest }
|
|
757
|
+
))
|
|
163
758
|
| Typed<"Dispose">;
|
|
164
759
|
|
|
165
|
-
export type DbWorkerRequest =
|
|
760
|
+
export type DbWorkerRequest = DbWorkerWriteRequest | DbWorkerReadRequest;
|
|
761
|
+
|
|
762
|
+
export type DbWorkerWriteRequest =
|
|
166
763
|
| {
|
|
167
764
|
readonly type: "ForEvolu";
|
|
168
765
|
readonly id: EvoluInstanceId;
|
|
766
|
+
readonly message: ExtractTyped<EvoluInput, "Mutate">;
|
|
767
|
+
}
|
|
768
|
+
| {
|
|
769
|
+
readonly type: "ForSharedWorker";
|
|
169
770
|
readonly message: {
|
|
170
|
-
readonly
|
|
171
|
-
|
|
771
|
+
readonly type: "ApplySyncMessage";
|
|
772
|
+
readonly owner: Owner;
|
|
773
|
+
readonly inputMessage: Uint8Array;
|
|
774
|
+
};
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
export type DbWorkerReadRequest =
|
|
778
|
+
| {
|
|
779
|
+
readonly type: "ForEvolu";
|
|
780
|
+
readonly id: EvoluInstanceId;
|
|
781
|
+
readonly message: ExtractTyped<EvoluInput, "Query" | "Export">;
|
|
172
782
|
}
|
|
173
783
|
| {
|
|
174
784
|
readonly type: "ForSharedWorker";
|
|
175
|
-
readonly message:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
| {
|
|
181
|
-
readonly type: "ApplySyncMessage";
|
|
182
|
-
readonly owner: Owner;
|
|
183
|
-
readonly inputMessage: Uint8Array;
|
|
184
|
-
};
|
|
785
|
+
readonly message: {
|
|
786
|
+
readonly type: "CreateSyncMessages";
|
|
787
|
+
readonly owners: NonEmptyReadonlyArray<Owner>;
|
|
788
|
+
};
|
|
185
789
|
};
|
|
186
790
|
|
|
187
791
|
export type DbWorkerOutput =
|
|
188
792
|
| {
|
|
189
793
|
readonly type: "LeaderAcquired";
|
|
190
794
|
readonly name: Name;
|
|
795
|
+
readonly clock: Timestamp;
|
|
796
|
+
}
|
|
797
|
+
| {
|
|
798
|
+
/** Startup was refused; the worker is releasing its resources. */
|
|
799
|
+
readonly type: "LeaderRefused";
|
|
800
|
+
readonly name: Name;
|
|
801
|
+
readonly error: UnsupportedDbVersionError;
|
|
191
802
|
}
|
|
192
803
|
| {
|
|
193
804
|
readonly type: "OnQueuedResponse";
|
|
194
|
-
readonly
|
|
805
|
+
readonly attemptId: Id;
|
|
195
806
|
readonly response: DbWorkerQueuedResponse;
|
|
196
807
|
};
|
|
197
808
|
|
|
@@ -202,6 +813,7 @@ export type DbWorkerQueuedResponse =
|
|
|
202
813
|
readonly message:
|
|
203
814
|
| {
|
|
204
815
|
readonly type: "Mutate";
|
|
816
|
+
readonly clock: Timestamp;
|
|
205
817
|
readonly messagesByOwnerId: ReadonlyMap<
|
|
206
818
|
OwnerId,
|
|
207
819
|
NonEmptyReadonlyArray<CrdtMessage>
|
|
@@ -226,40 +838,128 @@ export type DbWorkerQueuedResponse =
|
|
|
226
838
|
OwnerId,
|
|
227
839
|
ProtocolMessage
|
|
228
840
|
>;
|
|
841
|
+
/** Owners whose message creation threw; the DbWorker logged it. */
|
|
842
|
+
readonly failedOwnerIds: ReadonlySet<OwnerId>;
|
|
229
843
|
}
|
|
230
844
|
| {
|
|
231
845
|
readonly type: "ApplySyncMessage";
|
|
846
|
+
readonly clock: Timestamp;
|
|
232
847
|
readonly ownerId: OwnerId;
|
|
233
848
|
readonly didWriteMessages: boolean;
|
|
234
849
|
readonly result: Result<
|
|
235
850
|
ApplyProtocolMessageAsClientResult,
|
|
236
|
-
ProtocolError | AbortError
|
|
851
|
+
ProtocolError | StorageWriteMessagesError | AbortError
|
|
237
852
|
>;
|
|
238
853
|
};
|
|
239
854
|
};
|
|
240
855
|
|
|
856
|
+
/**
|
|
857
|
+
* Tells whether the platform can store databases persistently.
|
|
858
|
+
*
|
|
859
|
+
* Only a platform that can lack persistent storage provides it, as a browser
|
|
860
|
+
* does in Safari's Private Browsing; see Storage in the Shared module.
|
|
861
|
+
*/
|
|
862
|
+
export interface PersistentStorageDep {
|
|
863
|
+
readonly isPersistentStorageAvailable: () => Promise<boolean>;
|
|
864
|
+
}
|
|
865
|
+
|
|
241
866
|
export type SharedWorkerDeps = WorkerDeps &
|
|
242
867
|
CreateBroadcastChannelDep &
|
|
243
868
|
CreateMessageChannelDep &
|
|
244
869
|
CreateWebSocketDep &
|
|
245
|
-
LockManagerDep
|
|
870
|
+
LockManagerDep &
|
|
871
|
+
Partial<PersistentStorageDep>;
|
|
246
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Coordinates all instances of one named local database within a SharedWorker.
|
|
875
|
+
* Manages their DbWorker request queue and owner registrations.
|
|
876
|
+
*/
|
|
247
877
|
interface EvoluTenant extends AsyncDisposable {
|
|
248
878
|
readonly addInstance: (
|
|
249
879
|
message: ExtractTyped<SharedWorkerInput, "CreateEvolu">,
|
|
880
|
+
tabPort: TabPort,
|
|
250
881
|
onDisposed: () => void,
|
|
251
882
|
) => void;
|
|
252
883
|
|
|
253
|
-
readonly
|
|
884
|
+
readonly getSyncTenant: () => TenantSyncState;
|
|
885
|
+
|
|
886
|
+
readonly refreshSyncRoutes: () => void;
|
|
887
|
+
|
|
888
|
+
readonly requestCreateSyncMessages: (
|
|
889
|
+
ownerIds: ReadonlySet<OwnerId>,
|
|
890
|
+
target: SyncTarget,
|
|
891
|
+
) => void;
|
|
254
892
|
|
|
255
893
|
readonly requestApplySyncMessage: (
|
|
256
894
|
ownerId: OwnerId,
|
|
257
895
|
inputMessage: Uint8Array,
|
|
896
|
+
source: ApplySyncMessageSource,
|
|
258
897
|
) => void;
|
|
259
898
|
}
|
|
260
899
|
|
|
900
|
+
/** A tenant's part of {@link SyncState}, with its transports still keyed. */
|
|
901
|
+
interface TenantSyncState {
|
|
902
|
+
readonly name: Name;
|
|
903
|
+
readonly refused: boolean;
|
|
904
|
+
readonly owners: ReadonlyArray<{
|
|
905
|
+
readonly ownerId: OwnerId;
|
|
906
|
+
readonly writable: boolean;
|
|
907
|
+
readonly transportKeys: ReadonlyArray<StructuralLookupKey>;
|
|
908
|
+
readonly routes: ReadonlyArray<TenantSyncRoute>;
|
|
909
|
+
}>;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
interface TenantSyncRoute extends Omit<SyncRoute, "transportId"> {
|
|
913
|
+
readonly transportKey: StructuralLookupKey;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* How long an unanswered request keeps a socket before it is replaced.
|
|
918
|
+
*
|
|
919
|
+
* A reply cannot arrive before the whole request has been received, and a reply
|
|
920
|
+
* can itself be a large frame. The browser reports no transfer progress, so the
|
|
921
|
+
* timeout must cover a 1 MB frame on a slow link: 90 seconds allows about 90
|
|
922
|
+
* kbit/s.
|
|
923
|
+
*/
|
|
924
|
+
const syncRequestTimeout = PositiveMillis.orThrow(90_000);
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The longest a transport's timeout grows after repeated timeouts: 16 times
|
|
928
|
+
* {@link syncRequestTimeout}, which covers a 1 MB frame at about 6 kbit/s.
|
|
929
|
+
*/
|
|
930
|
+
const maxSyncRequestTimeout = PositiveMillis.orThrow(16 * syncRequestTimeout);
|
|
931
|
+
|
|
932
|
+
/** Where the protocol messages produced by a queued sync request are sent. */
|
|
933
|
+
type SyncTarget =
|
|
934
|
+
| { readonly type: "AllTransports" }
|
|
935
|
+
| {
|
|
936
|
+
/** One transport, identified by its structural lookup key. */
|
|
937
|
+
readonly type: "Transport";
|
|
938
|
+
readonly key: StructuralLookupKey;
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
/** A received frame's transport, or a sibling copy retaining its upload target. */
|
|
942
|
+
type ApplySyncMessageSource =
|
|
943
|
+
| { readonly type: "Transport"; readonly key: StructuralLookupKey }
|
|
944
|
+
| {
|
|
945
|
+
/** A local copy holds every route, whichever target its upload uses. */
|
|
946
|
+
readonly type: "Local";
|
|
947
|
+
readonly uploadTarget: SyncTarget;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const allTransports: SyncTarget = { type: "AllTransports" };
|
|
951
|
+
|
|
952
|
+
const isTargetTransport = (
|
|
953
|
+
target: SyncTarget,
|
|
954
|
+
transport: OwnerTransport,
|
|
955
|
+
): boolean =>
|
|
956
|
+
target.type === "AllTransports" || target.key === structuralLookup(transport);
|
|
957
|
+
|
|
261
958
|
type EvoluTenantDeps = SharedWorkerDeps &
|
|
262
959
|
PostConsoleEntryOrErrorDep &
|
|
960
|
+
PublishSyncStateDep &
|
|
961
|
+
RefreshAllSyncRoutesDep &
|
|
962
|
+
SyncRequestsDep &
|
|
263
963
|
TabLeaderPortStoreDep &
|
|
264
964
|
TransportsDep;
|
|
265
965
|
|
|
@@ -267,11 +967,37 @@ interface PostConsoleEntryOrErrorDep {
|
|
|
267
967
|
readonly postConsoleEntryOrError: Callback<ConsoleEntryOrError>;
|
|
268
968
|
}
|
|
269
969
|
|
|
970
|
+
interface PublishSyncStateDep {
|
|
971
|
+
readonly publishSyncState: () => void;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
interface RefreshAllSyncRoutesDep {
|
|
975
|
+
readonly refreshAllSyncRoutes: () => void;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Outstanding requests per owner and socket, shared by every tenant. */
|
|
979
|
+
interface SyncRequests {
|
|
980
|
+
/**
|
|
981
|
+
* Counts a Request sent on an open socket. The caller refreshes every
|
|
982
|
+
* tenant's routes once after its sends.
|
|
983
|
+
*/
|
|
984
|
+
readonly noteSent: (ownerId: OwnerId, key: StructuralLookupKey) => void;
|
|
985
|
+
readonly getOutstanding: (
|
|
986
|
+
ownerId: OwnerId,
|
|
987
|
+
key: StructuralLookupKey,
|
|
988
|
+
) => number;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
interface SyncRequestsDep {
|
|
992
|
+
readonly syncRequests: SyncRequests;
|
|
993
|
+
}
|
|
994
|
+
|
|
270
995
|
interface TabLeaderPortStoreDep {
|
|
271
|
-
readonly tabLeaderPortStore: Store<
|
|
996
|
+
readonly tabLeaderPortStore: Store<TabPort | null>;
|
|
272
997
|
}
|
|
273
998
|
|
|
274
|
-
|
|
999
|
+
/** A tab's SharedWorker connection, as seen from the SharedWorker. */
|
|
1000
|
+
type TabPort = Pick<MessagePort<SharedWorkerOutput>, "postMessage">;
|
|
275
1001
|
|
|
276
1002
|
interface TransportsDep {
|
|
277
1003
|
readonly transports: SharedResourceByKeyWithClaims<
|
|
@@ -283,9 +1009,16 @@ interface TransportsDep {
|
|
|
283
1009
|
|
|
284
1010
|
export type EvoluInstanceId = Id & Brand<"EvoluInstance">;
|
|
285
1011
|
|
|
286
|
-
|
|
1012
|
+
// Long enough for another build's tabs to reload and its worker to end.
|
|
1013
|
+
const otherBuildRunningReportDelay: PositiveDuration = "3s";
|
|
287
1014
|
|
|
288
|
-
/**
|
|
1015
|
+
/**
|
|
1016
|
+
* Initializes the platform-agnostic Evolu SharedWorker.
|
|
1017
|
+
*
|
|
1018
|
+
* The worker holds the build lock until it is disposed, so a platform connects
|
|
1019
|
+
* every `createEvoluDeps` call in a JS runtime to one worker, as React Native
|
|
1020
|
+
* does; see Builds.
|
|
1021
|
+
*/
|
|
289
1022
|
export const initSharedWorker =
|
|
290
1023
|
(
|
|
291
1024
|
self: SharedWorkerSelf<SharedWorkerInput, SharedWorkerOutput>,
|
|
@@ -296,9 +1029,7 @@ export const initSharedWorker =
|
|
|
296
1029
|
|
|
297
1030
|
await using disposer = new AsyncDisposableStack();
|
|
298
1031
|
|
|
299
|
-
const tabLeaderPortStore = disposer.use(
|
|
300
|
-
createStore<TabLeaderPort | null>(null),
|
|
301
|
-
);
|
|
1032
|
+
const tabLeaderPortStore = disposer.use(createStore<TabPort | null>(null));
|
|
302
1033
|
const consoleEntryOrErrorBroadcastChannel = disposer.use(
|
|
303
1034
|
deps.createBroadcastChannel<ConsoleEntryOrError>(
|
|
304
1035
|
consoleEntryOrErrorBroadcastChannelName,
|
|
@@ -307,11 +1038,42 @@ export const initSharedWorker =
|
|
|
307
1038
|
const postConsoleEntryOrError = (output: ConsoleEntryOrError): void => {
|
|
308
1039
|
consoleEntryOrErrorBroadcastChannel.postMessage(output);
|
|
309
1040
|
};
|
|
1041
|
+
const workerId = createId<"SharedWorker">(deps);
|
|
1042
|
+
// Each worker broadcasts on its own channel, so a tab hears only the
|
|
1043
|
+
// worker its port connects to.
|
|
1044
|
+
const syncStateChannelName = `evolu:sync-state:${workerId}`;
|
|
1045
|
+
const syncStateBroadcastChannel = disposer.use(
|
|
1046
|
+
deps.createBroadcastChannel<SyncState>(syncStateChannelName),
|
|
1047
|
+
);
|
|
310
1048
|
|
|
311
1049
|
const sharedWorkerReady = Promise.withResolvers<void>();
|
|
312
1050
|
|
|
1051
|
+
// Until this worker holds the build lock, it tells connecting tabs that they
|
|
1052
|
+
// wait, and reports a lasting wait to them; see Builds.
|
|
1053
|
+
const starting = disposer.use(new DisposableStack());
|
|
1054
|
+
const waitingTabPorts: Array<TabPort> = [];
|
|
1055
|
+
let isOtherBuildRunning = false;
|
|
1056
|
+
const reportOtherBuildRunning = (port: TabPort): void => {
|
|
1057
|
+
port.postMessage({
|
|
1058
|
+
type: "Error",
|
|
1059
|
+
error: { type: "OtherBuildRunningError" },
|
|
1060
|
+
});
|
|
1061
|
+
};
|
|
1062
|
+
const otherBuildRunningTimeoutId = deps.time.setTimeout(() => {
|
|
1063
|
+
isOtherBuildRunning = true;
|
|
1064
|
+
for (const port of waitingTabPorts) reportOtherBuildRunning(port);
|
|
1065
|
+
}, otherBuildRunningReportDelay);
|
|
1066
|
+
starting.defer(() => {
|
|
1067
|
+
deps.time.clearTimeout(otherBuildRunningTimeoutId);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
313
1070
|
// Register ASAP so the worker does not miss connections.
|
|
314
1071
|
self.onConnect = (port) => {
|
|
1072
|
+
if (!starting.disposed) {
|
|
1073
|
+
port.postMessage({ type: "Waiting", workerId });
|
|
1074
|
+
waitingTabPorts.push(port);
|
|
1075
|
+
if (isOtherBuildRunning) reportOtherBuildRunning(port);
|
|
1076
|
+
}
|
|
315
1077
|
void sharedWorkerReady.promise.then(() => {
|
|
316
1078
|
// The underlying port buffers messages until onMessage is assigned.
|
|
317
1079
|
port.onMessage = (message) => {
|
|
@@ -323,12 +1085,17 @@ export const initSharedWorker =
|
|
|
323
1085
|
break;
|
|
324
1086
|
}
|
|
325
1087
|
|
|
1088
|
+
case "RequestSyncState": {
|
|
1089
|
+
publishSyncState();
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
326
1093
|
case "CreateEvolu": {
|
|
327
1094
|
void sharedWorkerRun(async (run) => {
|
|
328
1095
|
const tenantLease = await run.ok(
|
|
329
1096
|
unabortable(tenantsByName.acquire(message)),
|
|
330
1097
|
);
|
|
331
|
-
tenantLease.resource.addInstance(message, () => {
|
|
1098
|
+
tenantLease.resource.addInstance(message, port, () => {
|
|
332
1099
|
tenantLease.release();
|
|
333
1100
|
});
|
|
334
1101
|
return ok();
|
|
@@ -339,9 +1106,29 @@ export const initSharedWorker =
|
|
|
339
1106
|
console.error("Unknown shared worker input", message);
|
|
340
1107
|
}
|
|
341
1108
|
};
|
|
1109
|
+
port.postMessage({
|
|
1110
|
+
type: "Connected",
|
|
1111
|
+
workerId,
|
|
1112
|
+
syncStateChannelName,
|
|
1113
|
+
});
|
|
1114
|
+
if (isPersistentStorageUnavailable) {
|
|
1115
|
+
port.postMessage({ type: "StorageUnavailable" });
|
|
1116
|
+
}
|
|
342
1117
|
});
|
|
343
1118
|
};
|
|
344
1119
|
|
|
1120
|
+
// Released after every tenant and DbWorker is disposed. Earlier releases
|
|
1121
|
+
// take the same lock in their leader tab; see Builds.
|
|
1122
|
+
disposer.use(await run.ok(acquireLeaderLock("tab")));
|
|
1123
|
+
starting.dispose();
|
|
1124
|
+
|
|
1125
|
+
// Checked once, before any DbWorker starts, so every DbWorker of this
|
|
1126
|
+
// worker, replacements included, keeps its database in memory; see
|
|
1127
|
+
// Storage.
|
|
1128
|
+
const isPersistentStorageUnavailable =
|
|
1129
|
+
deps.isPersistentStorageAvailable !== undefined &&
|
|
1130
|
+
!(await deps.isPersistentStorageAvailable());
|
|
1131
|
+
|
|
345
1132
|
disposer.defer(
|
|
346
1133
|
deps.consoleStoreOutputEntry.subscribe(() => {
|
|
347
1134
|
const entry = deps.consoleStoreOutputEntry.get();
|
|
@@ -350,6 +1137,192 @@ export const initSharedWorker =
|
|
|
350
1137
|
);
|
|
351
1138
|
|
|
352
1139
|
const currentTenantsByName = new Map<Name, BorrowedResource<EvoluTenant>>();
|
|
1140
|
+
|
|
1141
|
+
interface SyncTransportEntry {
|
|
1142
|
+
readonly id: SyncTransportId;
|
|
1143
|
+
readonly label: string;
|
|
1144
|
+
/**
|
|
1145
|
+
* Outstanding requests per owner; `silentSince` is when the first of them
|
|
1146
|
+
* was sent or a Response for the owner last arrived. It is monotonic, so
|
|
1147
|
+
* a system clock adjustment cannot make a request look timed out or keep
|
|
1148
|
+
* one from timing out.
|
|
1149
|
+
*/
|
|
1150
|
+
readonly outstandingByOwnerId: Map<
|
|
1151
|
+
OwnerId,
|
|
1152
|
+
{ count: number; silentSince: PerformanceTime }
|
|
1153
|
+
>;
|
|
1154
|
+
/**
|
|
1155
|
+
* The timeout of every request on the socket: frames arrive one after
|
|
1156
|
+
* another, so a reply can wait behind another owner's large frame. It
|
|
1157
|
+
* doubles after each timeout and survives reconnects until nothing is
|
|
1158
|
+
* outstanding and every route through the transport of a database that
|
|
1159
|
+
* has not refused startup is complete.
|
|
1160
|
+
*/
|
|
1161
|
+
timeout: PositiveMillis;
|
|
1162
|
+
socket: WebSocket | null;
|
|
1163
|
+
openedAt: Millis | null;
|
|
1164
|
+
closedAt: Millis | null;
|
|
1165
|
+
error: SyncTransportError | null;
|
|
1166
|
+
/** Armed while a request is outstanding on an open socket. */
|
|
1167
|
+
timeoutId: TimeoutId | null;
|
|
1168
|
+
}
|
|
1169
|
+
const transportsByKey = new Map<StructuralLookupKey, SyncTransportEntry>();
|
|
1170
|
+
let isDisposed = false;
|
|
1171
|
+
disposer.defer(() => {
|
|
1172
|
+
isDisposed = true;
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
// Changes within one task publish once.
|
|
1176
|
+
let isPublishScheduled = false;
|
|
1177
|
+
const publishSyncState = (): void => {
|
|
1178
|
+
if (isDisposed || isPublishScheduled) return;
|
|
1179
|
+
isPublishScheduled = true;
|
|
1180
|
+
queueMicrotask(() => {
|
|
1181
|
+
isPublishScheduled = false;
|
|
1182
|
+
if (isDisposed) return;
|
|
1183
|
+
const transports = [...transportsByKey.values()].map(
|
|
1184
|
+
({
|
|
1185
|
+
id,
|
|
1186
|
+
label,
|
|
1187
|
+
socket,
|
|
1188
|
+
openedAt,
|
|
1189
|
+
closedAt,
|
|
1190
|
+
error,
|
|
1191
|
+
}): SyncTransport => ({
|
|
1192
|
+
id,
|
|
1193
|
+
label,
|
|
1194
|
+
// The transport drops its socket before disposal, so this never
|
|
1195
|
+
// reads a disposed one.
|
|
1196
|
+
readyState: socket?.getReadyState() ?? "connecting",
|
|
1197
|
+
openedAt,
|
|
1198
|
+
closedAt,
|
|
1199
|
+
error,
|
|
1200
|
+
}),
|
|
1201
|
+
);
|
|
1202
|
+
const tenants = [...currentTenantsByName.values()].map(
|
|
1203
|
+
(tenant): SyncTenant => {
|
|
1204
|
+
const { name, refused, owners } = tenant.getSyncTenant();
|
|
1205
|
+
return {
|
|
1206
|
+
name,
|
|
1207
|
+
refused,
|
|
1208
|
+
owners: owners.map(
|
|
1209
|
+
({ ownerId, writable, transportKeys, routes }) => ({
|
|
1210
|
+
ownerId,
|
|
1211
|
+
writable,
|
|
1212
|
+
transportIds: transportKeys.flatMap((key) => {
|
|
1213
|
+
const entry = transportsByKey.get(key);
|
|
1214
|
+
return entry ? [entry.id] : [];
|
|
1215
|
+
}),
|
|
1216
|
+
routes: routes.flatMap(({ transportKey, ...route }) => {
|
|
1217
|
+
const entry = transportsByKey.get(transportKey);
|
|
1218
|
+
return entry ? [{ transportId: entry.id, ...route }] : [];
|
|
1219
|
+
}),
|
|
1220
|
+
}),
|
|
1221
|
+
),
|
|
1222
|
+
};
|
|
1223
|
+
},
|
|
1224
|
+
);
|
|
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
|
+
for (const entry of transportsByKey.values())
|
|
1237
|
+
if (
|
|
1238
|
+
entry.outstandingByOwnerId.size === 0 &&
|
|
1239
|
+
!incompleteTransportIds.has(entry.id)
|
|
1240
|
+
)
|
|
1241
|
+
entry.timeout = syncRequestTimeout;
|
|
1242
|
+
syncStateBroadcastChannel.postMessage({ transports, tenants });
|
|
1243
|
+
});
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
// Socket counters and owner claims are shared by all tenants. Refresh
|
|
1247
|
+
// after their complete event, independently of snapshot publication.
|
|
1248
|
+
const refreshAllSyncRoutes = (): void => {
|
|
1249
|
+
for (const tenant of currentTenantsByName.values())
|
|
1250
|
+
tenant.refreshSyncRoutes();
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
const clearSyncRequestTimeout = (entry: SyncTransportEntry): void => {
|
|
1254
|
+
if (entry.timeoutId === null) return;
|
|
1255
|
+
deps.time.clearTimeout(entry.timeoutId);
|
|
1256
|
+
entry.timeoutId = null;
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const armSyncRequestTimeout = (
|
|
1260
|
+
entry: SyncTransportEntry,
|
|
1261
|
+
delay: PositiveMillis,
|
|
1262
|
+
): void => {
|
|
1263
|
+
entry.timeoutId = deps.time.setTimeout(() => {
|
|
1264
|
+
entry.timeoutId = null;
|
|
1265
|
+
// Frames for other owners prove the socket alive, not that this
|
|
1266
|
+
// owner's request was received, so each owner's silence is measured
|
|
1267
|
+
// separately.
|
|
1268
|
+
const now = deps.time.performance.now();
|
|
1269
|
+
let shortestRemaining: number | null = null;
|
|
1270
|
+
let isTimedOut = false;
|
|
1271
|
+
for (const { silentSince } of entry.outstandingByOwnerId.values()) {
|
|
1272
|
+
const remaining =
|
|
1273
|
+
entry.timeout - performanceDurationBetween(silentSince, now);
|
|
1274
|
+
if (remaining <= 0) isTimedOut = true;
|
|
1275
|
+
else if (shortestRemaining === null || remaining < shortestRemaining)
|
|
1276
|
+
shortestRemaining = remaining;
|
|
1277
|
+
}
|
|
1278
|
+
if (!isTimedOut) {
|
|
1279
|
+
if (shortestRemaining === null) return;
|
|
1280
|
+
// Timer delays use integer milliseconds; round up to wait at least
|
|
1281
|
+
// the remaining fractional duration.
|
|
1282
|
+
armSyncRequestTimeout(
|
|
1283
|
+
entry,
|
|
1284
|
+
PositiveMillis.orThrow(Math.ceil(shortestRemaining)),
|
|
1285
|
+
);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
// A request went unanswered for the whole timeout: it was lost, the
|
|
1289
|
+
// connection is dead, or the link is too slow for the frames ahead of
|
|
1290
|
+
// its reply. A frame that never arrives whole saves nothing, so on a
|
|
1291
|
+
// slow link the same reply would time out forever. Doubling the
|
|
1292
|
+
// timeout lets it arrive after a reconnect that opens fine.
|
|
1293
|
+
entry.timeout = PositiveMillis.orThrow(
|
|
1294
|
+
Math.min(entry.timeout * 2, maxSyncRequestTimeout),
|
|
1295
|
+
);
|
|
1296
|
+
// Reconnecting abandons the connection and starts a fresh retry
|
|
1297
|
+
// schedule. The socket reports no close for it, so the transport
|
|
1298
|
+
// records the moment here.
|
|
1299
|
+
entry.socket?.reconnect();
|
|
1300
|
+
// `now` is monotonic; the reported close time is wall clock.
|
|
1301
|
+
entry.closedAt = deps.time.now();
|
|
1302
|
+
refreshAllSyncRoutes();
|
|
1303
|
+
publishSyncState();
|
|
1304
|
+
}, delay);
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
const syncRequests: SyncRequests = {
|
|
1308
|
+
noteSent: (ownerId, key) => {
|
|
1309
|
+
const entry = transportsByKey.get(key);
|
|
1310
|
+
if (!entry) return;
|
|
1311
|
+
const outstanding = entry.outstandingByOwnerId.get(ownerId);
|
|
1312
|
+
if (outstanding) outstanding.count++;
|
|
1313
|
+
else
|
|
1314
|
+
entry.outstandingByOwnerId.set(ownerId, {
|
|
1315
|
+
count: 1,
|
|
1316
|
+
silentSince: deps.time.performance.now(),
|
|
1317
|
+
});
|
|
1318
|
+
if (entry.timeoutId === null)
|
|
1319
|
+
armSyncRequestTimeout(entry, entry.timeout);
|
|
1320
|
+
publishSyncState();
|
|
1321
|
+
},
|
|
1322
|
+
getOutstanding: (ownerId, key) =>
|
|
1323
|
+
transportsByKey.get(key)?.outstandingByOwnerId.get(ownerId)?.count ?? 0,
|
|
1324
|
+
};
|
|
1325
|
+
|
|
353
1326
|
const transports = disposer.use(
|
|
354
1327
|
await run.ok(
|
|
355
1328
|
createSharedResourceByKeyWithClaims<
|
|
@@ -359,60 +1332,185 @@ export const initSharedWorker =
|
|
|
359
1332
|
SharedWorkerDeps,
|
|
360
1333
|
StructuralLookupKey
|
|
361
1334
|
>(
|
|
362
|
-
(transport) =>
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
1335
|
+
(transport) => async (run) => {
|
|
1336
|
+
const key = structuralLookup(transport);
|
|
1337
|
+
// The query carries the owner ID; WebSocket accepts URLs that
|
|
1338
|
+
// URL() rejects, so the label is derived without parsing.
|
|
1339
|
+
const queryIndex = transport.url.indexOf("?");
|
|
1340
|
+
const entry: SyncTransportEntry = {
|
|
1341
|
+
id: createId<"SyncTransport">(run.deps),
|
|
1342
|
+
label:
|
|
1343
|
+
queryIndex === -1
|
|
1344
|
+
? transport.url
|
|
1345
|
+
: transport.url.slice(0, queryIndex),
|
|
1346
|
+
outstandingByOwnerId: new Map(),
|
|
1347
|
+
timeout: syncRequestTimeout,
|
|
1348
|
+
socket: null,
|
|
1349
|
+
openedAt: null,
|
|
1350
|
+
closedAt: null,
|
|
1351
|
+
error: null,
|
|
1352
|
+
timeoutId: null,
|
|
1353
|
+
};
|
|
1354
|
+
await using disposer = new AsyncDisposableStack();
|
|
1355
|
+
// Registered before the socket, so an aborted creation also
|
|
1356
|
+
// forgets the transport.
|
|
1357
|
+
disposer.defer(() => {
|
|
1358
|
+
transportsByKey.delete(key);
|
|
1359
|
+
refreshAllSyncRoutes();
|
|
1360
|
+
publishSyncState();
|
|
1361
|
+
});
|
|
1362
|
+
transportsByKey.set(key, entry);
|
|
1363
|
+
publishSyncState();
|
|
1364
|
+
|
|
1365
|
+
const socket = await run.ok(
|
|
1366
|
+
deps.createWebSocket(transport.url, {
|
|
1367
|
+
binaryType: "arraybuffer",
|
|
1368
|
+
|
|
1369
|
+
onOpen: () => {
|
|
1370
|
+
// Requests in flight on the previous connection are never
|
|
1371
|
+
// answered.
|
|
1372
|
+
entry.outstandingByOwnerId.clear();
|
|
1373
|
+
clearSyncRequestTimeout(entry);
|
|
1374
|
+
entry.openedAt = run.deps.time.now();
|
|
1375
|
+
publishSyncState();
|
|
1376
|
+
const ownerIds = transports.getClaimsForResource(transport);
|
|
1377
|
+
console.debug("transportOpen", {
|
|
1378
|
+
url: transport.url,
|
|
1379
|
+
ownerIds: [...ownerIds],
|
|
1380
|
+
});
|
|
377
1381
|
|
|
378
|
-
|
|
379
|
-
|
|
1382
|
+
const target: SyncTarget = {
|
|
1383
|
+
type: "Transport",
|
|
1384
|
+
key: structuralLookup(transport),
|
|
1385
|
+
};
|
|
1386
|
+
forEachTenant((tenant) => {
|
|
1387
|
+
tenant.requestCreateSyncMessages(ownerIds, target);
|
|
1388
|
+
});
|
|
1389
|
+
refreshAllSyncRoutes();
|
|
1390
|
+
},
|
|
380
1391
|
|
|
381
|
-
|
|
382
|
-
|
|
1392
|
+
onClose: (event) => {
|
|
1393
|
+
console.debug("transportClose", {
|
|
1394
|
+
url: transport.url,
|
|
1395
|
+
code: event.code,
|
|
1396
|
+
wasClean: event.wasClean,
|
|
1397
|
+
});
|
|
1398
|
+
entry.closedAt = run.deps.time.now();
|
|
1399
|
+
clearSyncRequestTimeout(entry);
|
|
1400
|
+
refreshAllSyncRoutes();
|
|
1401
|
+
publishSyncState();
|
|
1402
|
+
},
|
|
1403
|
+
|
|
1404
|
+
onError: (error) => {
|
|
1405
|
+
console.debug("transportError", {
|
|
1406
|
+
url: transport.url,
|
|
1407
|
+
type: error.type,
|
|
1408
|
+
});
|
|
1409
|
+
entry.error = {
|
|
1410
|
+
type: error.type,
|
|
1411
|
+
at: run.deps.time.now(),
|
|
1412
|
+
};
|
|
1413
|
+
refreshAllSyncRoutes();
|
|
1414
|
+
publishSyncState();
|
|
1415
|
+
},
|
|
1416
|
+
|
|
1417
|
+
onMessage(data) {
|
|
1418
|
+
if (!(data instanceof ArrayBuffer)) return;
|
|
1419
|
+
|
|
1420
|
+
const message = new Uint8Array(data);
|
|
1421
|
+
const headerResult = parseProtocolHeader(message);
|
|
1422
|
+
|
|
1423
|
+
if (!headerResult.ok) {
|
|
1424
|
+
console.debug("transportInvalidProtocolMessage", {
|
|
1425
|
+
url: transport.url,
|
|
1426
|
+
byteLength: message.byteLength,
|
|
1427
|
+
});
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
383
1430
|
|
|
384
|
-
|
|
385
|
-
console.debug("transportInvalidProtocolMessage", {
|
|
1431
|
+
console.debug("transportProtocolMessage", {
|
|
386
1432
|
url: transport.url,
|
|
1433
|
+
ownerId: headerResult.value.ownerId,
|
|
387
1434
|
byteLength: message.byteLength,
|
|
388
1435
|
});
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
1436
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1437
|
+
// A Response, or a version-mismatch reply without a message
|
|
1438
|
+
// type, answers a request.
|
|
1439
|
+
const { messageType } = headerResult.value;
|
|
1440
|
+
if (
|
|
1441
|
+
messageType === MessageType.Response ||
|
|
1442
|
+
messageType === undefined
|
|
1443
|
+
) {
|
|
1444
|
+
const { ownerId } = headerResult.value;
|
|
1445
|
+
const outstanding = entry.outstandingByOwnerId.get(ownerId);
|
|
1446
|
+
if (outstanding) {
|
|
1447
|
+
outstanding.count--;
|
|
1448
|
+
outstanding.silentSince = run.deps.time.performance.now();
|
|
1449
|
+
if (outstanding.count === 0)
|
|
1450
|
+
entry.outstandingByOwnerId.delete(ownerId);
|
|
1451
|
+
}
|
|
1452
|
+
if (entry.outstandingByOwnerId.size === 0)
|
|
1453
|
+
clearSyncRequestTimeout(entry);
|
|
1454
|
+
publishSyncState();
|
|
1455
|
+
}
|
|
397
1456
|
|
|
1457
|
+
forEachTenant((tenant) => {
|
|
1458
|
+
tenant.requestApplySyncMessage(
|
|
1459
|
+
headerResult.value.ownerId,
|
|
1460
|
+
message,
|
|
1461
|
+
{
|
|
1462
|
+
type: "Transport",
|
|
1463
|
+
key: structuralLookup(transport),
|
|
1464
|
+
},
|
|
1465
|
+
);
|
|
1466
|
+
});
|
|
1467
|
+
// Do not complete a sibling after decrementing the shared
|
|
1468
|
+
// counter but before its apply has been queued.
|
|
1469
|
+
refreshAllSyncRoutes();
|
|
1470
|
+
},
|
|
1471
|
+
}),
|
|
1472
|
+
);
|
|
1473
|
+
disposer.use(socket);
|
|
1474
|
+
// LIFO: the transport drops its timer and its socket reference
|
|
1475
|
+
// before the socket is disposed. `disposable` guards every method
|
|
1476
|
+
// of the socket the claims lease, and disposing the socket awaits
|
|
1477
|
+
// its retry, so a `publishSyncState` microtask can run while this
|
|
1478
|
+
// entry is still registered. It must find no socket rather than
|
|
1479
|
+
// read a disposed one.
|
|
1480
|
+
disposer.defer(() => {
|
|
1481
|
+
clearSyncRequestTimeout(entry);
|
|
1482
|
+
entry.socket = null;
|
|
1483
|
+
refreshAllSyncRoutes();
|
|
1484
|
+
});
|
|
1485
|
+
const disposables = disposer.move();
|
|
1486
|
+
entry.socket = socket;
|
|
1487
|
+
refreshAllSyncRoutes();
|
|
1488
|
+
publishSyncState();
|
|
1489
|
+
// `disposable` replaces the socket's disposal method in place, but
|
|
1490
|
+
// `disposer.use` above already captured the original, so disposing
|
|
1491
|
+
// `disposables` disposes the socket rather than recursing.
|
|
1492
|
+
return ok(disposable<WebSocket>(socket, disposables));
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
onFirstClaimAdded: (ownerId, webSocket, transport) => {
|
|
1496
|
+
if (webSocket.isOpen()) {
|
|
1497
|
+
const target: SyncTarget = {
|
|
1498
|
+
type: "Transport",
|
|
1499
|
+
key: structuralLookup(transport),
|
|
1500
|
+
};
|
|
398
1501
|
forEachTenant((tenant) => {
|
|
399
|
-
tenant.
|
|
400
|
-
headerResult.value.ownerId,
|
|
401
|
-
message,
|
|
402
|
-
);
|
|
1502
|
+
tenant.requestCreateSyncMessages(new Set([ownerId]), target);
|
|
403
1503
|
});
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
{
|
|
407
|
-
onFirstClaimAdded: (ownerId, webSocket) => {
|
|
408
|
-
if (!webSocket.isOpen()) return;
|
|
409
|
-
forEachTenant((tenant) => {
|
|
410
|
-
tenant.requestCreateSyncMessages(new Set([ownerId]));
|
|
411
|
-
});
|
|
1504
|
+
}
|
|
1505
|
+
refreshAllSyncRoutes();
|
|
412
1506
|
},
|
|
413
1507
|
|
|
414
|
-
onLastClaimRemoved: (ownerId, webSocket) => {
|
|
415
|
-
webSocket.
|
|
1508
|
+
onLastClaimRemoved: (ownerId, webSocket, transport) => {
|
|
1509
|
+
if (webSocket.isOpen()) {
|
|
1510
|
+
webSocket.send(createProtocolMessageForUnsubscribe(ownerId));
|
|
1511
|
+
syncRequests.noteSent(ownerId, structuralLookup(transport));
|
|
1512
|
+
}
|
|
1513
|
+
refreshAllSyncRoutes();
|
|
416
1514
|
},
|
|
417
1515
|
// Keep sockets alive briefly across short owner churn.
|
|
418
1516
|
idleDisposeAfter: "3s",
|
|
@@ -422,18 +1520,28 @@ export const initSharedWorker =
|
|
|
422
1520
|
),
|
|
423
1521
|
);
|
|
424
1522
|
|
|
425
|
-
const sharedWorkerRun =
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1523
|
+
const sharedWorkerRun = disposer.use(
|
|
1524
|
+
run.create({
|
|
1525
|
+
...deps,
|
|
1526
|
+
postConsoleEntryOrError,
|
|
1527
|
+
publishSyncState,
|
|
1528
|
+
refreshAllSyncRoutes,
|
|
1529
|
+
syncRequests,
|
|
1530
|
+
tabLeaderPortStore,
|
|
1531
|
+
transports,
|
|
1532
|
+
}),
|
|
1533
|
+
);
|
|
431
1534
|
|
|
432
1535
|
const tenantsByName = disposer.use(
|
|
433
1536
|
await sharedWorkerRun.ok(
|
|
434
1537
|
createSharedResourceByKey(
|
|
435
1538
|
(message: ExtractTyped<SharedWorkerInput, "CreateEvolu">) =>
|
|
436
|
-
createEvoluTenant(
|
|
1539
|
+
createEvoluTenant(
|
|
1540
|
+
isPersistentStorageUnavailable
|
|
1541
|
+
? { ...message, memoryOnly: true }
|
|
1542
|
+
: message,
|
|
1543
|
+
currentTenantsByName,
|
|
1544
|
+
),
|
|
437
1545
|
{
|
|
438
1546
|
idleDisposeAfter: "3s",
|
|
439
1547
|
lookup: (message) => message.name,
|
|
@@ -473,11 +1581,15 @@ const createEvoluTenant =
|
|
|
473
1581
|
|
|
474
1582
|
interface EvoluInstance extends AsyncDisposable {
|
|
475
1583
|
readonly id: EvoluInstanceId;
|
|
476
|
-
|
|
1584
|
+
/** A null lease reserves a registration while its transports are acquired. */
|
|
1585
|
+
readonly ownerRegistrations: LookupMap<
|
|
1586
|
+
SyncOwner,
|
|
1587
|
+
Array<ClaimLease | null>
|
|
1588
|
+
>;
|
|
477
1589
|
readonly onDisposed: () => void;
|
|
478
1590
|
readonly port: MessagePort<EvoluOutput, EvoluInput>;
|
|
1591
|
+
readonly tabPort: TabPort;
|
|
479
1592
|
readonly useOwnerMutex: Mutex;
|
|
480
|
-
readonly usedSyncOwners: RefCountByKey<SyncOwner>;
|
|
481
1593
|
rowsByQuery: Map<Query, ReadonlyArray<Row>>;
|
|
482
1594
|
}
|
|
483
1595
|
|
|
@@ -498,8 +1610,10 @@ const createEvoluTenant =
|
|
|
498
1610
|
const dbWorkerInited = Promise.withResolvers<void>();
|
|
499
1611
|
|
|
500
1612
|
const initDbWorker = (): void => {
|
|
1613
|
+
// Without a tab leader yet, the store subscription starts the DbWorker
|
|
1614
|
+
// once a tab announces itself.
|
|
501
1615
|
const tabLeaderPort = deps.tabLeaderPortStore.get();
|
|
502
|
-
|
|
1616
|
+
if (startupError || !tabLeaderPort) return;
|
|
503
1617
|
|
|
504
1618
|
const dbWorkerChannel = deps.createMessageChannel<
|
|
505
1619
|
DbWorkerOutput,
|
|
@@ -511,16 +1625,109 @@ const createEvoluTenant =
|
|
|
511
1625
|
switch (message.type) {
|
|
512
1626
|
case "LeaderAcquired": {
|
|
513
1627
|
assertNotSame(dbWorkerPort, currentDbWorkerPort);
|
|
1628
|
+
if (startupError || isDisposing) {
|
|
1629
|
+
// This worker was requested before the refusal or before
|
|
1630
|
+
// disposal started. The tenant will not use it, so let it
|
|
1631
|
+
// release the database lock.
|
|
1632
|
+
currentDbWorkerPort.postMessage({ type: "Dispose" });
|
|
1633
|
+
currentDbWorkerPort[Symbol.dispose]();
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
const replacesLeader = dbWorkerPort !== null;
|
|
514
1637
|
dbWorkerPort?.[Symbol.dispose]();
|
|
515
1638
|
dbWorkerPort = currentDbWorkerPort;
|
|
516
|
-
|
|
1639
|
+
activeDispatch = null;
|
|
1640
|
+
// A replacement may advance the clock by releasing quarantine, or
|
|
1641
|
+
// start behind it with an empty memoryOnly database. Keep the
|
|
1642
|
+
// greater clock; pending writes retain their captured inputs.
|
|
1643
|
+
if (
|
|
1644
|
+
sessionClock === null ||
|
|
1645
|
+
orderTimestamp(sessionClock, message.clock) === -1
|
|
1646
|
+
) {
|
|
1647
|
+
sessionClock = message.clock;
|
|
1648
|
+
}
|
|
1649
|
+
// A replacement leader may have committed writes whose responses
|
|
1650
|
+
// were lost, and may have released quarantine at startup. A lost
|
|
1651
|
+
// response may also have reported stored owner messages, so the
|
|
1652
|
+
// owners reconcile through every transport again.
|
|
1653
|
+
if (replacesLeader) {
|
|
1654
|
+
refreshQueries();
|
|
1655
|
+
const usedOwnerIds = new Set<OwnerId>();
|
|
1656
|
+
for (const instance of instancesById.values()) {
|
|
1657
|
+
for (const { owner } of instance.ownerRegistrations.keys()) {
|
|
1658
|
+
usedOwnerIds.add(owner.id);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
requestCreateSyncMessages(usedOwnerIds, allTransports);
|
|
1662
|
+
}
|
|
517
1663
|
console.info("leaderAcquired");
|
|
518
1664
|
dbWorkerInited.resolve();
|
|
519
1665
|
runQueue();
|
|
520
1666
|
break;
|
|
521
1667
|
}
|
|
1668
|
+
case "LeaderRefused": {
|
|
1669
|
+
assertNotSame(dbWorkerPort, currentDbWorkerPort);
|
|
1670
|
+
// The worker refused startup and is releasing its resources.
|
|
1671
|
+
// Requests stay unanswered until their instances are disposed.
|
|
1672
|
+
// Keep the tenant unavailable, tell each connected tab once, and
|
|
1673
|
+
// tell tabs that connect later without starting another worker.
|
|
1674
|
+
dbWorkerPort?.[Symbol.dispose]();
|
|
1675
|
+
dbWorkerPort = null;
|
|
1676
|
+
currentDbWorkerPort[Symbol.dispose]();
|
|
1677
|
+
activeDispatch = null;
|
|
1678
|
+
queue.length = 0;
|
|
1679
|
+
pendingWriteCountByOwnerId.clear();
|
|
1680
|
+
pendingApplyCountForAllRoutesByOwnerId.clear();
|
|
1681
|
+
ownerTransportApplyRelation.clear();
|
|
1682
|
+
startupError = message.error;
|
|
1683
|
+
refreshSyncRoutes();
|
|
1684
|
+
deps.publishSyncState();
|
|
1685
|
+
console.info("leaderRefused", message.error);
|
|
1686
|
+
for (const instance of instancesById.values()) {
|
|
1687
|
+
reportRefusal(instance.tabPort, message.error);
|
|
1688
|
+
}
|
|
1689
|
+
dbWorkerInited.resolve();
|
|
1690
|
+
break;
|
|
1691
|
+
}
|
|
522
1692
|
case "OnQueuedResponse": {
|
|
523
|
-
|
|
1693
|
+
if (activeDispatch?.attemptId !== message.attemptId) return;
|
|
1694
|
+
const { entry } = activeDispatch;
|
|
1695
|
+
const { response } = message;
|
|
1696
|
+
if (
|
|
1697
|
+
response.message.type === "Mutate" ||
|
|
1698
|
+
response.message.type === "ApplySyncMessage"
|
|
1699
|
+
) {
|
|
1700
|
+
// Replays report their computed clock, which a replacement
|
|
1701
|
+
// leader may have passed at startup. Keep the greater clock.
|
|
1702
|
+
assertNonNullable(sessionClock);
|
|
1703
|
+
if (orderTimestamp(sessionClock, response.message.clock) === -1) {
|
|
1704
|
+
sessionClock = response.message.clock;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
switch (entry.type) {
|
|
1708
|
+
case "Read":
|
|
1709
|
+
case "Write":
|
|
1710
|
+
assertSame(response.type, "ForEvolu");
|
|
1711
|
+
handleResponseForEvolu(response, entry.request);
|
|
1712
|
+
break;
|
|
1713
|
+
case "CreateSyncMessages":
|
|
1714
|
+
case "ApplySyncMessage":
|
|
1715
|
+
assertSame(response.type, "ForSharedWorker");
|
|
1716
|
+
handleResponseForSharedWorker(response, entry);
|
|
1717
|
+
break;
|
|
1718
|
+
default:
|
|
1719
|
+
exhaustiveCheck(entry);
|
|
1720
|
+
}
|
|
1721
|
+
const head = queue.shift();
|
|
1722
|
+
assertNonNullable(head);
|
|
1723
|
+
updatePendingWork(head, -1);
|
|
1724
|
+
activeDispatch = null;
|
|
1725
|
+
// Follow-up uploads and retries are already queued or sent. Only
|
|
1726
|
+
// now can removing the completed head establish convergence.
|
|
1727
|
+
refreshSyncRoutes();
|
|
1728
|
+
if (entry.type === "Write" && entry.replicatedOwnerIds.size > 0)
|
|
1729
|
+
deps.publishSyncState();
|
|
1730
|
+
runQueue();
|
|
524
1731
|
break;
|
|
525
1732
|
}
|
|
526
1733
|
}
|
|
@@ -540,131 +1747,257 @@ const createEvoluTenant =
|
|
|
540
1747
|
);
|
|
541
1748
|
};
|
|
542
1749
|
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
),
|
|
548
|
-
);
|
|
549
|
-
let queueRequestInFlight = false;
|
|
550
|
-
|
|
551
|
-
const runQueue = (): void => {
|
|
552
|
-
if (queueRequestInFlight || !isNonEmptyArray(queue) || !dbWorkerPort) {
|
|
553
|
-
return;
|
|
1750
|
+
const refreshQueries = (exceptInstanceId?: EvoluInstanceId): void => {
|
|
1751
|
+
for (const [id, instance] of instancesById) {
|
|
1752
|
+
if (id === exceptInstanceId) continue;
|
|
1753
|
+
instance.port.postMessage({ type: "RefreshQueries" });
|
|
554
1754
|
}
|
|
1755
|
+
};
|
|
1756
|
+
|
|
1757
|
+
// Sync requests keep their target or source on the entry: the DbWorker
|
|
1758
|
+
// does not need it, and a replacement leader replays the same entry.
|
|
1759
|
+
type QueueEntry =
|
|
1760
|
+
| {
|
|
1761
|
+
readonly type: "Read";
|
|
1762
|
+
readonly request: ExtractTyped<DbWorkerReadRequest, "ForEvolu">;
|
|
1763
|
+
}
|
|
1764
|
+
| {
|
|
1765
|
+
readonly type: "Write";
|
|
1766
|
+
readonly request: ExtractTyped<DbWorkerWriteRequest, "ForEvolu">;
|
|
1767
|
+
/** Owners of its changes to replicated tables. */
|
|
1768
|
+
readonly replicatedOwnerIds: ReadonlySet<OwnerId>;
|
|
1769
|
+
now?: Millis;
|
|
1770
|
+
clock?: Timestamp;
|
|
1771
|
+
}
|
|
1772
|
+
| {
|
|
1773
|
+
readonly type: "CreateSyncMessages";
|
|
1774
|
+
readonly request: ExtractTyped<
|
|
1775
|
+
DbWorkerReadRequest,
|
|
1776
|
+
"ForSharedWorker"
|
|
1777
|
+
>;
|
|
1778
|
+
readonly target: SyncTarget;
|
|
1779
|
+
}
|
|
1780
|
+
| {
|
|
1781
|
+
readonly type: "ApplySyncMessage";
|
|
1782
|
+
readonly request: ExtractTyped<
|
|
1783
|
+
DbWorkerWriteRequest,
|
|
1784
|
+
"ForSharedWorker"
|
|
1785
|
+
>;
|
|
1786
|
+
readonly source: ApplySyncMessageSource;
|
|
1787
|
+
now?: Millis;
|
|
1788
|
+
clock?: Timestamp;
|
|
1789
|
+
};
|
|
1790
|
+
const queue: Array<QueueEntry> = [];
|
|
1791
|
+
const pendingWriteCountByOwnerId = new Map<OwnerId, number>();
|
|
1792
|
+
const pendingApplyCountForAllRoutesByOwnerId = new Map<OwnerId, number>();
|
|
1793
|
+
// Queued applies of relay frames, counted per owner and source transport.
|
|
1794
|
+
const ownerTransportApplyRelation = createRefCountedRelation<
|
|
1795
|
+
OwnerId,
|
|
1796
|
+
StructuralLookupKey
|
|
1797
|
+
>();
|
|
1798
|
+
|
|
1799
|
+
const updatePendingCount = <K>(
|
|
1800
|
+
counts: Map<K, number>,
|
|
1801
|
+
key: K,
|
|
1802
|
+
delta: 1 | -1,
|
|
1803
|
+
): void => {
|
|
1804
|
+
const count = (counts.get(key) ?? 0) + delta;
|
|
1805
|
+
assert(count >= 0, "Pending queue count must not become negative");
|
|
1806
|
+
if (count === 0) counts.delete(key);
|
|
1807
|
+
else counts.set(key, count);
|
|
1808
|
+
};
|
|
555
1809
|
|
|
556
|
-
|
|
1810
|
+
// Count each queued entry once, including the dispatched head. Dispatch
|
|
1811
|
+
// and leader replacement leave it queued, so neither changes the counts.
|
|
1812
|
+
const updatePendingWork = (entry: QueueEntry, delta: 1 | -1): void => {
|
|
1813
|
+
switch (entry.type) {
|
|
1814
|
+
case "Read":
|
|
1815
|
+
case "CreateSyncMessages":
|
|
1816
|
+
break;
|
|
557
1817
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
break;
|
|
563
|
-
}
|
|
1818
|
+
case "Write":
|
|
1819
|
+
for (const ownerId of entry.replicatedOwnerIds)
|
|
1820
|
+
updatePendingCount(pendingWriteCountByOwnerId, ownerId, delta);
|
|
1821
|
+
break;
|
|
564
1822
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
1823
|
+
case "ApplySyncMessage": {
|
|
1824
|
+
const ownerId = entry.request.message.owner.id;
|
|
1825
|
+
// A sibling's local copy holds every route, even when its upload
|
|
1826
|
+
// target was a single transport.
|
|
1827
|
+
if (entry.source.type === "Local") {
|
|
1828
|
+
updatePendingCount(
|
|
1829
|
+
pendingApplyCountForAllRoutesByOwnerId,
|
|
1830
|
+
ownerId,
|
|
1831
|
+
delta,
|
|
1832
|
+
);
|
|
1833
|
+
} else if (delta === 1) {
|
|
1834
|
+
ownerTransportApplyRelation.increment(ownerId, entry.source.key);
|
|
1835
|
+
} else {
|
|
1836
|
+
ownerTransportApplyRelation.decrement(ownerId, entry.source.key);
|
|
1837
|
+
}
|
|
1838
|
+
break;
|
|
568
1839
|
}
|
|
569
1840
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
});
|
|
1841
|
+
default:
|
|
1842
|
+
exhaustiveCheck(entry);
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
575
1845
|
|
|
576
|
-
|
|
577
|
-
|
|
1846
|
+
// Every queue mutation updates the pending-work counts with it.
|
|
1847
|
+
const enqueueRequest = (entry: QueueEntry): void => {
|
|
1848
|
+
updatePendingWork(entry, 1);
|
|
1849
|
+
queue.push(entry);
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
let sessionClock: Timestamp | null = null;
|
|
1853
|
+
let startupError: UnsupportedDbVersionError | null = null;
|
|
1854
|
+
let isDisposing = false;
|
|
1855
|
+
// Each tab is told once during this tenant's lifetime, through its own
|
|
1856
|
+
// connection. Recreating the tenant after idle disposal retries startup
|
|
1857
|
+
// and may report the refusal again.
|
|
1858
|
+
const refusedTabPorts = new WeakSet<TabPort>();
|
|
1859
|
+
const reportRefusal = (
|
|
1860
|
+
tabPort: TabPort,
|
|
1861
|
+
error: UnsupportedDbVersionError,
|
|
1862
|
+
): void => {
|
|
1863
|
+
if (refusedTabPorts.has(tabPort)) return;
|
|
1864
|
+
refusedTabPorts.add(tabPort);
|
|
1865
|
+
tabPort.postMessage({ type: "Error", error });
|
|
1866
|
+
};
|
|
1867
|
+
let activeDispatch: {
|
|
1868
|
+
readonly entry: QueueEntry;
|
|
1869
|
+
readonly attemptId: Id;
|
|
1870
|
+
} | null = null;
|
|
1871
|
+
|
|
1872
|
+
const runQueue = (): void => {
|
|
1873
|
+
if (activeDispatch || !isNonEmptyArray(queue) || !dbWorkerPort) return;
|
|
1874
|
+
assertNonNullable(sessionClock);
|
|
1875
|
+
const entry = firstInArray(queue);
|
|
1876
|
+
const attemptId = createId(run.deps);
|
|
1877
|
+
activeDispatch = { entry, attemptId };
|
|
1878
|
+
if (entry.type === "Write" || entry.type === "ApplySyncMessage") {
|
|
1879
|
+
// A write captures its inputs on first dispatch, so a retry after
|
|
1880
|
+
// leader replacement reproduces the same timestamps.
|
|
1881
|
+
entry.now ??= run.deps.time.now();
|
|
1882
|
+
entry.clock ??= sessionClock;
|
|
1883
|
+
dbWorkerPort.postMessage({
|
|
1884
|
+
type: "Request",
|
|
1885
|
+
attemptId,
|
|
1886
|
+
request: entry.request,
|
|
1887
|
+
clock: entry.clock,
|
|
1888
|
+
now: entry.now,
|
|
1889
|
+
});
|
|
1890
|
+
} else {
|
|
1891
|
+
dbWorkerPort.postMessage({
|
|
1892
|
+
type: "Request",
|
|
1893
|
+
attemptId,
|
|
1894
|
+
request: entry.request,
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
578
1897
|
};
|
|
579
1898
|
|
|
580
1899
|
disposer.defer(async () => {
|
|
1900
|
+
isDisposing = true;
|
|
581
1901
|
dbWorkerPort?.postMessage({ type: "Dispose" });
|
|
582
1902
|
dbWorkerPort = null;
|
|
583
|
-
|
|
1903
|
+
activeDispatch = null;
|
|
584
1904
|
|
|
585
1905
|
// The DbWorker holds this tenant leader lock while it is alive. Tenant
|
|
586
1906
|
// disposal sends Dispose, then acquires the same lock to wait until the
|
|
587
1907
|
// DbWorker releases it: either because Dispose was delivered or because
|
|
588
|
-
// the hosting tab closed.
|
|
589
|
-
//
|
|
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.
|
|
590
1912
|
await using _ = await tenantRun.ok(acquireLeaderLock(name));
|
|
591
1913
|
});
|
|
592
1914
|
|
|
593
|
-
disposer.defer(deps.tabLeaderPortStore.subscribe(initDbWorker));
|
|
594
|
-
|
|
595
|
-
initDbWorker();
|
|
596
|
-
await dbWorkerInited.promise;
|
|
597
|
-
|
|
598
1915
|
const handleResponseForEvolu = (
|
|
599
1916
|
response: ExtractTyped<DbWorkerQueuedResponse, "ForEvolu">,
|
|
600
1917
|
first: DbWorkerRequest,
|
|
601
1918
|
): void => {
|
|
1919
|
+
// A disposed instance gets no patches, but its committed write still
|
|
1920
|
+
// synchronizes.
|
|
602
1921
|
const instance = instancesById.get(response.id);
|
|
603
|
-
if (!instance) return;
|
|
604
1922
|
|
|
605
1923
|
switch (response.message.type) {
|
|
606
1924
|
case "Mutate":
|
|
607
1925
|
case "Query": {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1926
|
+
if (instance) {
|
|
1927
|
+
const nextRowsByQuery = new Map(instance.rowsByQuery);
|
|
1928
|
+
const patchesByQuery = new Map<Query, ReadonlyArray<Patch>>();
|
|
1929
|
+
|
|
1930
|
+
for (const [query, rows] of response.message.rowsByQuery) {
|
|
1931
|
+
nextRowsByQuery.set(query, rows);
|
|
1932
|
+
patchesByQuery.set(
|
|
1933
|
+
query,
|
|
1934
|
+
makePatches(instance.rowsByQuery.get(query), rows),
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
618
1937
|
|
|
619
|
-
|
|
1938
|
+
instance.rowsByQuery = nextRowsByQuery;
|
|
620
1939
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1940
|
+
instance.port.postMessage({
|
|
1941
|
+
type: "OnPatchesByQuery",
|
|
1942
|
+
patchesByQuery,
|
|
1943
|
+
onCompleteIds:
|
|
1944
|
+
first.message.type === "Mutate"
|
|
1945
|
+
? first.message.onCompleteIds
|
|
1946
|
+
: emptyArray,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
629
1949
|
|
|
630
1950
|
if (response.message.type === "Mutate") {
|
|
631
|
-
|
|
632
|
-
if (instanceId === response.id) continue;
|
|
633
|
-
instance.port.postMessage({
|
|
634
|
-
type: "RefreshQueries",
|
|
635
|
-
});
|
|
636
|
-
}
|
|
1951
|
+
refreshQueries(response.id);
|
|
637
1952
|
|
|
638
1953
|
const protocolMessagesByOwnerId = new Map<
|
|
639
1954
|
OwnerId,
|
|
640
1955
|
ProtocolMessage
|
|
641
1956
|
>();
|
|
642
1957
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1958
|
+
// The database's writable registrations upload the write,
|
|
1959
|
+
// whichever instance made it and whether it is still alive.
|
|
1960
|
+
const writersById = getUsedOwnersById(
|
|
1961
|
+
new Set(response.message.messagesByOwnerId.keys()),
|
|
1962
|
+
);
|
|
646
1963
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
//
|
|
650
|
-
|
|
1964
|
+
for (const [ownerId, messages] of response.message
|
|
1965
|
+
.messagesByOwnerId) {
|
|
1966
|
+
// Uploading requires the write key. A write for an owner without
|
|
1967
|
+
// a writable registration waits for the round that its first
|
|
1968
|
+
// writable registration starts.
|
|
1969
|
+
const owner = writersById.get(ownerId);
|
|
1970
|
+
if (!owner) continue;
|
|
651
1971
|
|
|
652
1972
|
protocolMessagesByOwnerId.set(
|
|
653
|
-
|
|
1973
|
+
ownerId,
|
|
654
1974
|
createProtocolMessageFromCrdtMessages(run.deps)(
|
|
655
1975
|
owner,
|
|
656
1976
|
messages,
|
|
657
1977
|
),
|
|
658
1978
|
);
|
|
1979
|
+
if (currentTenantsByName.size > 1) {
|
|
1980
|
+
broadcastProtocolMessages(
|
|
1981
|
+
ownerId,
|
|
1982
|
+
createProtocolBroadcastMessagesFromCrdtMessages(run.deps)(
|
|
1983
|
+
owner,
|
|
1984
|
+
messages,
|
|
1985
|
+
),
|
|
1986
|
+
allTransports,
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
659
1989
|
}
|
|
660
1990
|
|
|
661
|
-
sendProtocolMessagesByOwnerId(
|
|
1991
|
+
sendProtocolMessagesByOwnerId(
|
|
1992
|
+
protocolMessagesByOwnerId,
|
|
1993
|
+
allTransports,
|
|
1994
|
+
);
|
|
662
1995
|
}
|
|
663
1996
|
break;
|
|
664
1997
|
}
|
|
665
1998
|
|
|
666
1999
|
case "Export":
|
|
667
|
-
instance
|
|
2000
|
+
instance?.port.postMessage(
|
|
668
2001
|
{ type: "OnExport", file: response.message.file },
|
|
669
2002
|
[response.message.file.buffer],
|
|
670
2003
|
);
|
|
@@ -672,60 +2005,288 @@ const createEvoluTenant =
|
|
|
672
2005
|
}
|
|
673
2006
|
};
|
|
674
2007
|
|
|
2008
|
+
interface RouteState {
|
|
2009
|
+
/** A round must be sent through the route before it can be complete. */
|
|
2010
|
+
roundRequired: boolean;
|
|
2011
|
+
complete: boolean;
|
|
2012
|
+
completeAt: Millis | null;
|
|
2013
|
+
lastSentAt: Millis | null;
|
|
2014
|
+
lastReceivedAt: Millis | null;
|
|
2015
|
+
error: SyncRouteError | null;
|
|
2016
|
+
}
|
|
2017
|
+
const routesByOwnerIdByKey = new Map<
|
|
2018
|
+
StructuralLookupKey,
|
|
2019
|
+
Map<OwnerId, RouteState>
|
|
2020
|
+
>();
|
|
2021
|
+
|
|
2022
|
+
const getRoute = (
|
|
2023
|
+
ownerId: OwnerId,
|
|
2024
|
+
key: StructuralLookupKey,
|
|
2025
|
+
): RouteState => {
|
|
2026
|
+
let routesByOwnerId = routesByOwnerIdByKey.get(key);
|
|
2027
|
+
if (!routesByOwnerId) {
|
|
2028
|
+
routesByOwnerId = new Map();
|
|
2029
|
+
routesByOwnerIdByKey.set(key, routesByOwnerId);
|
|
2030
|
+
}
|
|
2031
|
+
let route = routesByOwnerId.get(ownerId);
|
|
2032
|
+
if (!route) {
|
|
2033
|
+
route = {
|
|
2034
|
+
roundRequired: true,
|
|
2035
|
+
complete: false,
|
|
2036
|
+
completeAt: null,
|
|
2037
|
+
lastSentAt: null,
|
|
2038
|
+
lastReceivedAt: null,
|
|
2039
|
+
error: null,
|
|
2040
|
+
};
|
|
2041
|
+
routesByOwnerId.set(ownerId, route);
|
|
2042
|
+
}
|
|
2043
|
+
return route;
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
const getSyncOwners = () => {
|
|
2047
|
+
const ownersById = new Map<OwnerId, { writable: boolean }>();
|
|
2048
|
+
for (const instance of instancesById.values()) {
|
|
2049
|
+
for (const { owner } of instance.ownerRegistrations.keys()) {
|
|
2050
|
+
const entry = ownersById.get(owner.id) ?? { writable: false };
|
|
2051
|
+
entry.writable ||= "writeKey" in owner;
|
|
2052
|
+
ownersById.set(owner.id, entry);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
return [...ownersById].map(([ownerId, { writable }]) => ({
|
|
2056
|
+
ownerId,
|
|
2057
|
+
writable,
|
|
2058
|
+
transportKeys: getClaimedKeys(ownerId),
|
|
2059
|
+
}));
|
|
2060
|
+
};
|
|
2061
|
+
|
|
2062
|
+
// State-changing handlers own route transitions and claim cleanup.
|
|
2063
|
+
// Reading or delaying a snapshot must not affect protocol retries.
|
|
2064
|
+
// TODO: If profiling warrants it, remove quadratic transport scans per owner:
|
|
2065
|
+
// traverse resources once and use a Set for cleanup membership.
|
|
2066
|
+
const refreshSyncRoutes = (): void => {
|
|
2067
|
+
const owners = getSyncOwners();
|
|
2068
|
+
const ownerById = new Map(owners.map((owner) => [owner.ownerId, owner]));
|
|
2069
|
+
for (const [key, routesByOwnerId] of routesByOwnerIdByKey) {
|
|
2070
|
+
for (const ownerId of routesByOwnerId.keys()) {
|
|
2071
|
+
const owner = ownerById.get(ownerId);
|
|
2072
|
+
if (!owner?.writable || !owner.transportKeys.includes(key))
|
|
2073
|
+
routesByOwnerId.delete(ownerId);
|
|
2074
|
+
}
|
|
2075
|
+
if (routesByOwnerId.size === 0) routesByOwnerIdByKey.delete(key);
|
|
2076
|
+
}
|
|
2077
|
+
// Evaluate each route per the Synchronization completion rules.
|
|
2078
|
+
for (const { ownerId, writable, transportKeys } of owners) {
|
|
2079
|
+
if (!writable) continue;
|
|
2080
|
+
for (const key of transportKeys) {
|
|
2081
|
+
const route = getRoute(ownerId, key);
|
|
2082
|
+
let isOpen = false;
|
|
2083
|
+
deps.transports.forEachResourceForClaim(
|
|
2084
|
+
ownerId,
|
|
2085
|
+
(webSocket, transport) => {
|
|
2086
|
+
if (structuralLookup(transport) === key && webSocket.isOpen())
|
|
2087
|
+
isOpen = true;
|
|
2088
|
+
},
|
|
2089
|
+
);
|
|
2090
|
+
// A received frame is applied asynchronously, so the counter alone
|
|
2091
|
+
// reads zero in the middle of a chain. A dispatched entry stays
|
|
2092
|
+
// queued until it is answered. A sibling's local Broadcast contains
|
|
2093
|
+
// messages that are unstored until it is applied, so it leaves every
|
|
2094
|
+
// route of the owner incomplete.
|
|
2095
|
+
const hasQueuedApply =
|
|
2096
|
+
pendingApplyCountForAllRoutesByOwnerId.has(ownerId) ||
|
|
2097
|
+
ownerTransportApplyRelation.getCount(ownerId, key) > 0;
|
|
2098
|
+
// A replicated write's upload is sent only after the database
|
|
2099
|
+
// worker answers it. Local-only changes create no synchronization
|
|
2100
|
+
// work.
|
|
2101
|
+
const hasQueuedWrite = pendingWriteCountByOwnerId.has(ownerId);
|
|
2102
|
+
// A refused database synchronizes nothing, and refusal discards its
|
|
2103
|
+
// queued writes without uploading them.
|
|
2104
|
+
const complete =
|
|
2105
|
+
startupError === null &&
|
|
2106
|
+
isOpen &&
|
|
2107
|
+
deps.syncRequests.getOutstanding(ownerId, key) === 0 &&
|
|
2108
|
+
!hasQueuedApply &&
|
|
2109
|
+
!hasQueuedWrite &&
|
|
2110
|
+
!route.roundRequired;
|
|
2111
|
+
if (complete && !route.complete) {
|
|
2112
|
+
route.completeAt = deps.time.now();
|
|
2113
|
+
route.error = null;
|
|
2114
|
+
}
|
|
2115
|
+
route.complete = complete;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
|
|
675
2120
|
const handleResponseForSharedWorker = (
|
|
676
2121
|
response: ExtractTyped<DbWorkerQueuedResponse, "ForSharedWorker">,
|
|
2122
|
+
entry: ExtractTyped<
|
|
2123
|
+
QueueEntry,
|
|
2124
|
+
"CreateSyncMessages" | "ApplySyncMessage"
|
|
2125
|
+
>,
|
|
677
2126
|
): void => {
|
|
678
|
-
switch (
|
|
679
|
-
case "CreateSyncMessages":
|
|
2127
|
+
switch (entry.type) {
|
|
2128
|
+
case "CreateSyncMessages": {
|
|
2129
|
+
assertSame(response.message.type, "CreateSyncMessages");
|
|
680
2130
|
sendProtocolMessagesByOwnerId(
|
|
681
2131
|
response.message.protocolMessagesByOwnerId,
|
|
2132
|
+
entry.target,
|
|
2133
|
+
{ isRound: true },
|
|
682
2134
|
);
|
|
2135
|
+
const { failedOwnerIds } = response.message;
|
|
2136
|
+
if (failedOwnerIds.size === 0) break;
|
|
2137
|
+
// A retry would likely fail the same way, so the routes wait for an
|
|
2138
|
+
// explicit request or a reopen.
|
|
2139
|
+
const now = deps.time.now();
|
|
2140
|
+
for (const ownerId of failedOwnerIds) {
|
|
2141
|
+
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
2142
|
+
if (!isTargetTransport(entry.target, transport)) return;
|
|
2143
|
+
const route = routesByOwnerIdByKey
|
|
2144
|
+
.get(structuralLookup(transport))
|
|
2145
|
+
?.get(ownerId);
|
|
2146
|
+
if (!route) return;
|
|
2147
|
+
route.roundRequired = true;
|
|
2148
|
+
route.error = { type: "SyncFailed", at: now };
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
deps.publishSyncState();
|
|
683
2152
|
break;
|
|
2153
|
+
}
|
|
684
2154
|
|
|
685
|
-
case "ApplySyncMessage":
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
2155
|
+
case "ApplySyncMessage": {
|
|
2156
|
+
assertSame(response.message.type, "ApplySyncMessage");
|
|
2157
|
+
const { source } = entry;
|
|
2158
|
+
const target = source.type === "Local" ? source.uploadTarget : source;
|
|
2159
|
+
const { ownerId, result } = response.message;
|
|
2160
|
+
const error = result.ok ? null : result.error;
|
|
2161
|
+
const isAborted = error?.type === "AbortError";
|
|
2162
|
+
let failure: SyncRouteErrorType | null = null;
|
|
2163
|
+
if (isAborted) {
|
|
2164
|
+
// An abort proves no convergence. A local sibling copy affects
|
|
2165
|
+
// every route; a relay frame affects only its source route.
|
|
2166
|
+
// Recovery of a panicked DbWorker remains deferred.
|
|
2167
|
+
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
2168
|
+
const key = structuralLookup(transport);
|
|
2169
|
+
if (source.type === "Transport" && source.key !== key) return;
|
|
2170
|
+
const route = routesByOwnerIdByKey.get(key)?.get(ownerId);
|
|
2171
|
+
if (route) route.roundRequired = true;
|
|
2172
|
+
});
|
|
2173
|
+
} else if (error !== null) {
|
|
2174
|
+
failure = error.type;
|
|
2175
|
+
deps.postConsoleEntryOrError({
|
|
2176
|
+
type: "Error",
|
|
2177
|
+
error,
|
|
2178
|
+
});
|
|
2179
|
+
} else if (result.ok && result.value.type === "Failed") {
|
|
2180
|
+
failure =
|
|
2181
|
+
result.value.cause === "Write" ? "WriteFailed" : "SyncFailed";
|
|
2182
|
+
}
|
|
2183
|
+
if (source.type === "Transport") {
|
|
2184
|
+
// A registration or claim may have been removed while this apply
|
|
2185
|
+
// was queued.
|
|
2186
|
+
const route = routesByOwnerIdByKey.get(source.key)?.get(ownerId);
|
|
2187
|
+
if (route) {
|
|
2188
|
+
const now = deps.time.now();
|
|
2189
|
+
// An aborted apply applied nothing.
|
|
2190
|
+
if (!isAborted) route.lastReceivedAt = now;
|
|
2191
|
+
if (failure !== null) {
|
|
2192
|
+
// The first failure since the route completed requests one
|
|
2193
|
+
// round. Further failures wait for an explicit request or a
|
|
2194
|
+
// reopen, even after a converged reply, which may answer
|
|
2195
|
+
// another request.
|
|
2196
|
+
if (route.error === null)
|
|
2197
|
+
requestCreateSyncMessages(new Set([ownerId]), source);
|
|
2198
|
+
route.roundRequired = true;
|
|
2199
|
+
route.error = { type: failure, at: now };
|
|
2200
|
+
}
|
|
689
2201
|
}
|
|
2202
|
+
} else if (failure !== null) {
|
|
2203
|
+
// A sibling's messages were not stored; rounds fetch them from
|
|
2204
|
+
// the relays.
|
|
2205
|
+
requestCreateSyncMessages(new Set([ownerId]), allTransports);
|
|
690
2206
|
}
|
|
691
2207
|
|
|
692
|
-
if (
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
2208
|
+
if (response.message.didWriteMessages) {
|
|
2209
|
+
refreshQueries();
|
|
2210
|
+
// Reconcile newly stored messages through each other transport.
|
|
2211
|
+
// Rounds toward the same transport coalesce whatever their source.
|
|
2212
|
+
const keys: Array<StructuralLookupKey> = [];
|
|
2213
|
+
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
2214
|
+
if (isTargetTransport(target, transport)) return;
|
|
2215
|
+
keys.push(structuralLookup(transport));
|
|
2216
|
+
});
|
|
2217
|
+
for (const key of keys) {
|
|
2218
|
+
requestCreateSyncMessages(
|
|
2219
|
+
new Set([ownerId]),
|
|
2220
|
+
{ type: "Transport", key },
|
|
2221
|
+
{ afterQueuedWrites: false },
|
|
2222
|
+
);
|
|
698
2223
|
}
|
|
699
|
-
}
|
|
700
|
-
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
if (result.ok) {
|
|
2227
|
+
switch (result.value.type) {
|
|
701
2228
|
case "Response":
|
|
2229
|
+
if (result.value.broadcast) {
|
|
2230
|
+
broadcastProtocolMessages(
|
|
2231
|
+
ownerId,
|
|
2232
|
+
[result.value.broadcast],
|
|
2233
|
+
target,
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
702
2236
|
sendProtocolMessagesByOwnerId(
|
|
703
|
-
new Map([
|
|
704
|
-
|
|
705
|
-
response.message.ownerId,
|
|
706
|
-
response.message.result.value.message,
|
|
707
|
-
],
|
|
708
|
-
]),
|
|
2237
|
+
new Map([[ownerId, result.value.message]]),
|
|
2238
|
+
target,
|
|
709
2239
|
);
|
|
710
2240
|
break;
|
|
711
2241
|
|
|
712
2242
|
case "Broadcast":
|
|
713
|
-
case "
|
|
2243
|
+
case "Converged":
|
|
2244
|
+
case "Readonly":
|
|
2245
|
+
case "Failed":
|
|
714
2246
|
break;
|
|
2247
|
+
default:
|
|
2248
|
+
exhaustiveCheck(result.value);
|
|
715
2249
|
}
|
|
716
2250
|
}
|
|
2251
|
+
deps.publishSyncState();
|
|
717
2252
|
break;
|
|
2253
|
+
}
|
|
2254
|
+
default:
|
|
2255
|
+
exhaustiveCheck(entry);
|
|
2256
|
+
}
|
|
2257
|
+
};
|
|
2258
|
+
|
|
2259
|
+
const broadcastProtocolMessages = (
|
|
2260
|
+
ownerId: OwnerId,
|
|
2261
|
+
messages: ReadonlyArray<ProtocolMessage>,
|
|
2262
|
+
target: SyncTarget,
|
|
2263
|
+
): void => {
|
|
2264
|
+
for (const [tenantName, tenant] of currentTenantsByName) {
|
|
2265
|
+
if (tenantName === name) continue;
|
|
2266
|
+
for (const message of messages)
|
|
2267
|
+
tenant.requestApplySyncMessage(ownerId, message, {
|
|
2268
|
+
type: "Local",
|
|
2269
|
+
uploadTarget: target,
|
|
2270
|
+
});
|
|
718
2271
|
}
|
|
719
2272
|
};
|
|
720
2273
|
|
|
721
2274
|
const sendProtocolMessagesByOwnerId = (
|
|
722
2275
|
protocolMessagesByOwnerId: ReadonlyMap<OwnerId, ProtocolMessage>,
|
|
2276
|
+
target: SyncTarget,
|
|
2277
|
+
{
|
|
2278
|
+
isRound = false,
|
|
2279
|
+
}: {
|
|
2280
|
+
/** Whether the messages start a full round, not an upload. */
|
|
2281
|
+
isRound?: boolean;
|
|
2282
|
+
} = {},
|
|
723
2283
|
): void => {
|
|
724
2284
|
for (const [ownerId, protocolMessage] of protocolMessagesByOwnerId) {
|
|
725
2285
|
deps.transports.forEachResourceForClaim(
|
|
726
2286
|
ownerId,
|
|
727
2287
|
(webSocket, transport) => {
|
|
728
|
-
if (!webSocket.isOpen())
|
|
2288
|
+
if (!isTargetTransport(target, transport) || !webSocket.isOpen())
|
|
2289
|
+
return;
|
|
729
2290
|
|
|
730
2291
|
console.debug("sendProtocolMessage", {
|
|
731
2292
|
ownerId,
|
|
@@ -734,17 +2295,32 @@ const createEvoluTenant =
|
|
|
734
2295
|
});
|
|
735
2296
|
|
|
736
2297
|
webSocket.send(protocolMessage);
|
|
2298
|
+
const key = structuralLookup(transport);
|
|
2299
|
+
deps.syncRequests.noteSent(ownerId, key);
|
|
2300
|
+
const route = getRoute(ownerId, key);
|
|
2301
|
+
route.lastSentAt = deps.time.now();
|
|
2302
|
+
if (isRound) route.roundRequired = false;
|
|
737
2303
|
},
|
|
738
2304
|
);
|
|
739
2305
|
}
|
|
2306
|
+
// Sends raise counters shared by every tenant; one refresh covers them.
|
|
2307
|
+
if (protocolMessagesByOwnerId.size > 0) deps.refreshAllSyncRoutes();
|
|
740
2308
|
};
|
|
741
2309
|
|
|
2310
|
+
/** The keys of every transport claimed for the owner, by any database. */
|
|
2311
|
+
const getClaimedKeys = (
|
|
2312
|
+
ownerId: OwnerId,
|
|
2313
|
+
): ReadonlyArray<StructuralLookupKey> =>
|
|
2314
|
+
[...deps.transports.getResourceKeysForClaim(ownerId)].map(
|
|
2315
|
+
structuralLookup,
|
|
2316
|
+
);
|
|
2317
|
+
|
|
742
2318
|
const getUsedOwnersById = (
|
|
743
2319
|
ownerIds: ReadonlySet<OwnerId>,
|
|
744
2320
|
): ReadonlyMap<OwnerId, Owner> => {
|
|
745
2321
|
const ownersById = new Map<OwnerId, Owner>();
|
|
746
2322
|
for (const instance of instancesById.values()) {
|
|
747
|
-
for (const { owner } of instance.
|
|
2323
|
+
for (const { owner } of instance.ownerRegistrations.keys()) {
|
|
748
2324
|
if (!ownerIds.has(owner.id) || !("writeKey" in owner)) continue;
|
|
749
2325
|
ownersById.set(owner.id, owner);
|
|
750
2326
|
}
|
|
@@ -752,6 +2328,81 @@ const createEvoluTenant =
|
|
|
752
2328
|
return ownersById;
|
|
753
2329
|
};
|
|
754
2330
|
|
|
2331
|
+
const requestCreateSyncMessages = (
|
|
2332
|
+
ownerIds: ReadonlySet<OwnerId>,
|
|
2333
|
+
target: SyncTarget,
|
|
2334
|
+
{
|
|
2335
|
+
afterQueuedWrites = true,
|
|
2336
|
+
}: {
|
|
2337
|
+
/** Whether the round must read the writes queued when it is requested. */
|
|
2338
|
+
afterQueuedWrites?: boolean;
|
|
2339
|
+
} = {},
|
|
2340
|
+
): void => {
|
|
2341
|
+
if (startupError) return;
|
|
2342
|
+
const usedOwnersById = getUsedOwnersById(ownerIds);
|
|
2343
|
+
// Opening, storing messages from another transport, a failure, and an
|
|
2344
|
+
// explicit request each require a new round before completion.
|
|
2345
|
+
for (const ownerId of usedOwnersById.keys()) {
|
|
2346
|
+
deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
|
|
2347
|
+
if (!isTargetTransport(target, transport)) return;
|
|
2348
|
+
getRoute(ownerId, structuralLookup(transport)).roundRequired = true;
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
refreshSyncRoutes();
|
|
2352
|
+
deps.publishSyncState();
|
|
2353
|
+
const ownersToSync = [...usedOwnersById.values()].filter(({ id }) => {
|
|
2354
|
+
let hasOpenTransport = false;
|
|
2355
|
+
deps.transports.forEachResourceForClaim(id, (webSocket, transport) => {
|
|
2356
|
+
if (isTargetTransport(target, transport) && webSocket.isOpen())
|
|
2357
|
+
hasOpenTransport = true;
|
|
2358
|
+
});
|
|
2359
|
+
return hasOpenTransport;
|
|
2360
|
+
});
|
|
2361
|
+
|
|
2362
|
+
if (!isNonEmptyArray(ownersToSync)) return;
|
|
2363
|
+
|
|
2364
|
+
// A queued round reads the database when it is dispatched, so it covers
|
|
2365
|
+
// this request unless the request must also read writes queued behind
|
|
2366
|
+
// that round. A dispatched round covers neither.
|
|
2367
|
+
const ownerIdsToSync = new Set(ownersToSync.map(({ id }) => id));
|
|
2368
|
+
const lastWriteIndex = afterQueuedWrites
|
|
2369
|
+
? queue.findLastIndex(
|
|
2370
|
+
({ type }) => type === "Write" || type === "ApplySyncMessage",
|
|
2371
|
+
)
|
|
2372
|
+
: -1;
|
|
2373
|
+
const isQueued = queue.some(
|
|
2374
|
+
(entry, index) =>
|
|
2375
|
+
index > lastWriteIndex &&
|
|
2376
|
+
entry !== activeDispatch?.entry &&
|
|
2377
|
+
entry.type === "CreateSyncMessages" &&
|
|
2378
|
+
(entry.target.type === "AllTransports" ||
|
|
2379
|
+
(target.type === "Transport" && entry.target.key === target.key)) &&
|
|
2380
|
+
entry.request.message.owners.length === ownerIdsToSync.size &&
|
|
2381
|
+
entry.request.message.owners.every(({ id }) =>
|
|
2382
|
+
ownerIdsToSync.has(id),
|
|
2383
|
+
),
|
|
2384
|
+
);
|
|
2385
|
+
|
|
2386
|
+
console.debug("requestCreateSyncMessages", {
|
|
2387
|
+
ownerIds: [...ownerIdsToSync],
|
|
2388
|
+
target,
|
|
2389
|
+
isQueued,
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
if (isQueued) return;
|
|
2393
|
+
|
|
2394
|
+
enqueueRequest({
|
|
2395
|
+
type: "CreateSyncMessages",
|
|
2396
|
+
request: {
|
|
2397
|
+
type: "ForSharedWorker",
|
|
2398
|
+
message: { type: "CreateSyncMessages", owners: ownersToSync },
|
|
2399
|
+
},
|
|
2400
|
+
target,
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
runQueue();
|
|
2404
|
+
};
|
|
2405
|
+
|
|
755
2406
|
const toggleSyncOwner =
|
|
756
2407
|
(
|
|
757
2408
|
instance: EvoluInstance,
|
|
@@ -760,49 +2411,129 @@ const createEvoluTenant =
|
|
|
760
2411
|
): Task<void, never, EvoluTenantDeps> =>
|
|
761
2412
|
async (run) => {
|
|
762
2413
|
if (action === "add") {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
2414
|
+
const ownerId = syncOwner.owner.id;
|
|
2415
|
+
const isFirstWritableUse =
|
|
2416
|
+
"writeKey" in syncOwner.owner &&
|
|
2417
|
+
!getUsedOwnersById(new Set([ownerId])).has(ownerId);
|
|
2418
|
+
// A transport first claimed for the owner starts every tenant's
|
|
2419
|
+
// round through onFirstClaimAdded. A joining tenant also reconciles
|
|
2420
|
+
// its existing history through the owner's already claimed transports.
|
|
2421
|
+
// Later registrations reconcile only transports newly used here:
|
|
2422
|
+
// earlier rounds may predate writes from an unregistered instance.
|
|
2423
|
+
const claimedKeys = new Set(getClaimedKeys(ownerId));
|
|
2424
|
+
const usedKeys = new Set<StructuralLookupKey>();
|
|
2425
|
+
for (const { ownerRegistrations } of instancesById.values()) {
|
|
2426
|
+
for (const [usedSyncOwner, leases] of ownerRegistrations) {
|
|
2427
|
+
if (usedSyncOwner.owner.id !== ownerId) continue;
|
|
2428
|
+
if (!leases.some((lease) => lease !== null)) continue;
|
|
2429
|
+
for (const transport of usedSyncOwner.transports) {
|
|
2430
|
+
usedKeys.add(structuralLookup(transport));
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
const leases = instance.ownerRegistrations.getOrInsertComputed(
|
|
2435
|
+
syncOwner,
|
|
2436
|
+
() => [],
|
|
771
2437
|
);
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
2438
|
+
// First-claim callbacks must see the owner before acquisition finishes.
|
|
2439
|
+
const index = leases.push(null) - 1;
|
|
2440
|
+
refreshSyncRoutes();
|
|
2441
|
+
try {
|
|
2442
|
+
leases[index] = await run.ok(
|
|
2443
|
+
deps.transports.claim(ownerId, syncOwner.transports),
|
|
2444
|
+
);
|
|
2445
|
+
} finally {
|
|
2446
|
+
if (leases[index] === null) {
|
|
2447
|
+
leases.splice(index, 1);
|
|
2448
|
+
if (leases.length === 0)
|
|
2449
|
+
instance.ownerRegistrations.delete(syncOwner);
|
|
2450
|
+
}
|
|
2451
|
+
deps.refreshAllSyncRoutes();
|
|
2452
|
+
}
|
|
2453
|
+
const keysToSync = isFirstWritableUse
|
|
2454
|
+
? claimedKeys
|
|
2455
|
+
: syncOwner.transports
|
|
2456
|
+
.map(structuralLookup)
|
|
2457
|
+
.filter((key) => claimedKeys.has(key) && !usedKeys.has(key));
|
|
2458
|
+
for (const key of keysToSync) {
|
|
2459
|
+
requestCreateSyncMessages(new Set([ownerId]), {
|
|
2460
|
+
type: "Transport",
|
|
2461
|
+
key,
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
776
2464
|
} else {
|
|
777
|
-
instance.
|
|
778
|
-
const claimLeases = instance.claimLeasesBySyncOwner.get(syncOwner);
|
|
2465
|
+
const claimLeases = instance.ownerRegistrations.get(syncOwner);
|
|
779
2466
|
assertNotUndefined(claimLeases);
|
|
780
2467
|
const claimLease = claimLeases.pop();
|
|
781
|
-
|
|
2468
|
+
assertNonNullable(claimLease);
|
|
782
2469
|
claimLease.release();
|
|
783
2470
|
if (claimLeases.length === 0) {
|
|
784
|
-
instance.
|
|
2471
|
+
instance.ownerRegistrations.delete(syncOwner);
|
|
785
2472
|
}
|
|
786
2473
|
}
|
|
2474
|
+
deps.refreshAllSyncRoutes();
|
|
2475
|
+
deps.publishSyncState();
|
|
787
2476
|
return ok();
|
|
788
2477
|
};
|
|
789
2478
|
|
|
2479
|
+
// Response handlers can refresh routes as soon as the worker answers.
|
|
2480
|
+
// Initialize their state and helpers before starting it.
|
|
2481
|
+
disposer.defer(deps.tabLeaderPortStore.subscribe(initDbWorker));
|
|
2482
|
+
initDbWorker();
|
|
2483
|
+
// Without a tab leader, requests queue until one announces itself and its
|
|
2484
|
+
// DbWorker reports in. Waiting for that here would stall the registry's
|
|
2485
|
+
// disposal, which cannot abort a resource still being created.
|
|
2486
|
+
if (deps.tabLeaderPortStore.get()) await dbWorkerInited.promise;
|
|
2487
|
+
|
|
2488
|
+
// Remove the tenant before any asynchronous disposal step can yield to a
|
|
2489
|
+
// snapshot or another tenant's route refresh.
|
|
790
2490
|
disposer.defer(() => {
|
|
791
2491
|
currentTenantsByName.delete(name);
|
|
2492
|
+
deps.publishSyncState();
|
|
792
2493
|
});
|
|
793
2494
|
const tenant = disposable<EvoluTenant>(
|
|
794
2495
|
{
|
|
795
|
-
|
|
2496
|
+
getSyncTenant: () => ({
|
|
2497
|
+
name,
|
|
2498
|
+
refused: startupError !== null,
|
|
2499
|
+
owners: getSyncOwners().map(
|
|
2500
|
+
({ ownerId, writable, transportKeys }) => ({
|
|
2501
|
+
ownerId,
|
|
2502
|
+
writable,
|
|
2503
|
+
transportKeys,
|
|
2504
|
+
routes: writable
|
|
2505
|
+
? transportKeys.map((key): TenantSyncRoute => {
|
|
2506
|
+
const route = routesByOwnerIdByKey.get(key)?.get(ownerId);
|
|
2507
|
+
// Registration and claim changes refresh routes before yielding.
|
|
2508
|
+
// Snapshot reads must not create missing routes.
|
|
2509
|
+
assertNotUndefined(route);
|
|
2510
|
+
return {
|
|
2511
|
+
transportKey: key,
|
|
2512
|
+
complete: route.complete,
|
|
2513
|
+
completeAt: route.completeAt,
|
|
2514
|
+
lastSentAt: route.lastSentAt,
|
|
2515
|
+
lastReceivedAt: route.lastReceivedAt,
|
|
2516
|
+
error: route.error,
|
|
2517
|
+
};
|
|
2518
|
+
})
|
|
2519
|
+
: [],
|
|
2520
|
+
}),
|
|
2521
|
+
),
|
|
2522
|
+
}),
|
|
2523
|
+
|
|
2524
|
+
refreshSyncRoutes,
|
|
2525
|
+
|
|
2526
|
+
addInstance: (message, tabPort, onDisposed) => {
|
|
796
2527
|
const disposer = new AsyncDisposableStack();
|
|
797
2528
|
const instance: EvoluInstance = {
|
|
798
2529
|
id: message.id,
|
|
799
|
-
|
|
2530
|
+
ownerRegistrations: createLookupMap({
|
|
800
2531
|
lookup: (syncOwner: SyncOwner) =>
|
|
801
2532
|
structuralLookup<{
|
|
802
|
-
readonly
|
|
2533
|
+
readonly owner: SyncOwner["owner"];
|
|
803
2534
|
readonly transports: ReadonlyArray<StructuralLookupKey>;
|
|
804
2535
|
}>({
|
|
805
|
-
|
|
2536
|
+
owner: syncOwner.owner,
|
|
806
2537
|
transports: syncOwner.transports
|
|
807
2538
|
.map(structuralLookup)
|
|
808
2539
|
.toSorted(),
|
|
@@ -811,28 +2542,26 @@ const createEvoluTenant =
|
|
|
811
2542
|
port: deps.createMessagePort<EvoluOutput, EvoluInput>(
|
|
812
2543
|
message.evoluPort,
|
|
813
2544
|
),
|
|
2545
|
+
tabPort,
|
|
814
2546
|
onDisposed,
|
|
815
2547
|
rowsByQuery: new Map<Query, ReadonlyArray<Row>>(),
|
|
816
2548
|
useOwnerMutex: createMutex(),
|
|
817
|
-
usedSyncOwners: createRefCountByKey<SyncOwner, OwnerId>({
|
|
818
|
-
lookup: (syncOwner) => syncOwner.owner.id,
|
|
819
|
-
}),
|
|
820
2549
|
[Symbol.asyncDispose]: () => disposer.disposeAsync(),
|
|
821
2550
|
};
|
|
822
2551
|
|
|
823
2552
|
instancesById.set(instance.id, instance);
|
|
824
2553
|
|
|
825
2554
|
disposer.defer(instance.onDisposed);
|
|
826
|
-
disposer.use(instance.usedSyncOwners);
|
|
827
2555
|
|
|
828
2556
|
disposer.defer(async () => {
|
|
829
2557
|
await tenantRun(
|
|
830
|
-
instance.useOwnerMutex.withLock(
|
|
831
|
-
for (const
|
|
832
|
-
|
|
833
|
-
await run(toggleSyncOwner(instance, syncOwner, "remove"));
|
|
834
|
-
}
|
|
2558
|
+
instance.useOwnerMutex.withLock(() => {
|
|
2559
|
+
for (const leases of instance.ownerRegistrations.values()) {
|
|
2560
|
+
for (const lease of leases) lease?.release();
|
|
835
2561
|
}
|
|
2562
|
+
instance.ownerRegistrations.clear();
|
|
2563
|
+
deps.refreshAllSyncRoutes();
|
|
2564
|
+
deps.publishSyncState();
|
|
836
2565
|
return ok();
|
|
837
2566
|
}),
|
|
838
2567
|
);
|
|
@@ -840,6 +2569,7 @@ const createEvoluTenant =
|
|
|
840
2569
|
|
|
841
2570
|
disposer.defer(() => {
|
|
842
2571
|
instancesById.delete(instance.id);
|
|
2572
|
+
refreshSyncRoutes();
|
|
843
2573
|
console.info("evoluDispose", { name, id: instance.id });
|
|
844
2574
|
});
|
|
845
2575
|
disposer.use(instance.port);
|
|
@@ -861,31 +2591,72 @@ const createEvoluTenant =
|
|
|
861
2591
|
});
|
|
862
2592
|
|
|
863
2593
|
instance.port.onMessage = (message) => {
|
|
2594
|
+
if (startupError) return;
|
|
864
2595
|
switch (message.type) {
|
|
865
2596
|
case "Query":
|
|
866
2597
|
case "Export": {
|
|
867
|
-
|
|
2598
|
+
enqueueRequest({
|
|
2599
|
+
type: "Read",
|
|
2600
|
+
request: { type: "ForEvolu", id: instance.id, message },
|
|
2601
|
+
});
|
|
868
2602
|
runQueue();
|
|
869
2603
|
break;
|
|
870
2604
|
}
|
|
871
2605
|
case "Mutate": {
|
|
872
|
-
|
|
873
|
-
|
|
2606
|
+
const replicatedOwnerIds = new Set<OwnerId>();
|
|
2607
|
+
for (const change of message.changes) {
|
|
2608
|
+
if (!isLocalOnlyTable(change.table))
|
|
2609
|
+
replicatedOwnerIds.add(change.ownerId);
|
|
2610
|
+
}
|
|
2611
|
+
enqueueRequest({
|
|
2612
|
+
type: "Write",
|
|
2613
|
+
request: { type: "ForEvolu", id: instance.id, message },
|
|
2614
|
+
replicatedOwnerIds,
|
|
2615
|
+
});
|
|
2616
|
+
// Local-only changes create no synchronization work.
|
|
2617
|
+
if (replicatedOwnerIds.size > 0) {
|
|
2618
|
+
refreshSyncRoutes();
|
|
2619
|
+
deps.publishSyncState();
|
|
2620
|
+
}
|
|
874
2621
|
runQueue();
|
|
875
2622
|
break;
|
|
876
2623
|
}
|
|
877
2624
|
case "UseOwner": {
|
|
878
2625
|
void tenantRun(
|
|
879
2626
|
instance.useOwnerMutex.withLock(async (run) => {
|
|
880
|
-
for (const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
2627
|
+
for (const action of message.actions) {
|
|
2628
|
+
switch (action.action) {
|
|
2629
|
+
case "sync":
|
|
2630
|
+
console.debug("requestSync", {
|
|
2631
|
+
id: instance.id,
|
|
2632
|
+
ownerId: action.ownerId,
|
|
2633
|
+
});
|
|
2634
|
+
requestCreateSyncMessages(
|
|
2635
|
+
new Set([action.ownerId]),
|
|
2636
|
+
allTransports,
|
|
2637
|
+
);
|
|
2638
|
+
break;
|
|
2639
|
+
case "add":
|
|
2640
|
+
case "remove":
|
|
2641
|
+
console.debug("useOwner", {
|
|
2642
|
+
id: instance.id,
|
|
2643
|
+
action: action.action,
|
|
2644
|
+
ownerId: action.owner.owner.id,
|
|
2645
|
+
transportUrls: action.owner.transports.map(
|
|
2646
|
+
({ url }) => url,
|
|
2647
|
+
),
|
|
2648
|
+
});
|
|
2649
|
+
await run(
|
|
2650
|
+
toggleSyncOwner(
|
|
2651
|
+
instance,
|
|
2652
|
+
action.owner,
|
|
2653
|
+
action.action,
|
|
2654
|
+
),
|
|
2655
|
+
);
|
|
2656
|
+
break;
|
|
2657
|
+
default:
|
|
2658
|
+
exhaustiveCheck(action);
|
|
2659
|
+
}
|
|
889
2660
|
}
|
|
890
2661
|
|
|
891
2662
|
return ok();
|
|
@@ -895,45 +2666,34 @@ const createEvoluTenant =
|
|
|
895
2666
|
}
|
|
896
2667
|
}
|
|
897
2668
|
};
|
|
898
|
-
},
|
|
899
|
-
|
|
900
|
-
requestCreateSyncMessages: (ownerIds): void => {
|
|
901
|
-
const ownersToSync = [...getUsedOwnersById(ownerIds).values()];
|
|
902
|
-
|
|
903
|
-
if (!isNonEmptyArray(ownersToSync)) return;
|
|
904
2669
|
|
|
905
|
-
|
|
906
|
-
ownerIds: ownersToSync.map(({ id }) => id),
|
|
907
|
-
});
|
|
908
|
-
|
|
909
|
-
queue.push({
|
|
910
|
-
type: "ForSharedWorker",
|
|
911
|
-
message: {
|
|
912
|
-
type: "CreateSyncMessages",
|
|
913
|
-
owners: ownersToSync,
|
|
914
|
-
},
|
|
915
|
-
});
|
|
916
|
-
|
|
917
|
-
runQueue();
|
|
2670
|
+
if (startupError) reportRefusal(instance.tabPort, startupError);
|
|
918
2671
|
},
|
|
919
2672
|
|
|
920
|
-
|
|
2673
|
+
requestCreateSyncMessages,
|
|
2674
|
+
|
|
2675
|
+
requestApplySyncMessage: (ownerId, inputMessage, source): void => {
|
|
2676
|
+
if (startupError) return;
|
|
921
2677
|
const owner = getUsedOwnersById(new Set([ownerId])).get(ownerId);
|
|
922
2678
|
if (!owner) return;
|
|
923
2679
|
|
|
924
2680
|
console.debug("requestApplySyncMessage", {
|
|
925
2681
|
ownerId,
|
|
2682
|
+
source,
|
|
926
2683
|
byteLength: inputMessage.byteLength,
|
|
927
2684
|
});
|
|
928
2685
|
|
|
929
|
-
|
|
930
|
-
type: "
|
|
931
|
-
|
|
932
|
-
type: "
|
|
933
|
-
owner,
|
|
934
|
-
inputMessage,
|
|
2686
|
+
const entry: QueueEntry = {
|
|
2687
|
+
type: "ApplySyncMessage",
|
|
2688
|
+
request: {
|
|
2689
|
+
type: "ForSharedWorker",
|
|
2690
|
+
message: { type: "ApplySyncMessage", owner, inputMessage },
|
|
935
2691
|
},
|
|
936
|
-
|
|
2692
|
+
source,
|
|
2693
|
+
};
|
|
2694
|
+
enqueueRequest(entry);
|
|
2695
|
+
refreshSyncRoutes();
|
|
2696
|
+
deps.publishSyncState();
|
|
937
2697
|
|
|
938
2698
|
runQueue();
|
|
939
2699
|
},
|
|
@@ -941,6 +2701,7 @@ const createEvoluTenant =
|
|
|
941
2701
|
disposer,
|
|
942
2702
|
);
|
|
943
2703
|
currentTenantsByName.set(name, tenant);
|
|
2704
|
+
deps.publishSyncState();
|
|
944
2705
|
return ok(tenant);
|
|
945
2706
|
};
|
|
946
2707
|
|
|
@@ -969,15 +2730,23 @@ const createEvoluTenant =
|
|
|
969
2730
|
|
|
970
2731
|
// TODO: SharedWorker follow-ups.
|
|
971
2732
|
// - Complete the queue head when a DbWorker mutation returns an error.
|
|
972
|
-
// -
|
|
973
|
-
//
|
|
974
|
-
// - Replace the callback registry and queueRequestInFlight flag with one
|
|
975
|
-
// explicit in-flight request state.
|
|
2733
|
+
// - Rotate the node ID when a copied database is detected; see the Duplicate
|
|
2734
|
+
// node IDs section in the Timestamp module.
|
|
976
2735
|
// - Detect DbWorker and port liveness so a worker-only crash resumes the queue.
|
|
977
|
-
// -
|
|
978
|
-
//
|
|
2736
|
+
// Defer panicked-worker restart until failure detection and recovery are
|
|
2737
|
+
// defined, accounting for SQLite WASM's detection limits. Normal SQLite
|
|
2738
|
+
// operations are expected not to throw; user-defined UNIQUE indexes, which
|
|
2739
|
+
// can make replicated writes fail, are planned to be forbidden.
|
|
979
2740
|
// - Split worker protocol types and the EvoluTenant implementation into focused
|
|
980
2741
|
// modules.
|
|
981
2742
|
// - Remove the obsolete commented protocol block above.
|
|
982
|
-
// - Replace the SyncState placeholder with actual sync monitoring state.
|
|
983
2743
|
// - Propagate invalid protocol messages to sync state.
|
|
2744
|
+
// - Bound sync state publishing during a bulk catch-up: every sent and applied
|
|
2745
|
+
// frame changes a route timestamp, so each frame broadcasts a full snapshot
|
|
2746
|
+
// to every tab. Throttling needs a wall-clock policy and a deterministic way
|
|
2747
|
+
// to test it.
|
|
2748
|
+
// - Measure sync traffic before bounding it: several tenants using one owner
|
|
2749
|
+
// multiply rounds, and a bulk catch-up from one relay starts up to one round
|
|
2750
|
+
// to each other relay per frame that stores new messages, depending on queued
|
|
2751
|
+
// round coalescing. Forwarding the stored messages the way mutation uploads
|
|
2752
|
+
// do would replace those rounds.
|