@evolu/common 8.10.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.
Files changed (144) hide show
  1. package/dist/src/Config.d.ts +22 -22
  2. package/dist/src/Config.d.ts.map +1 -1
  3. package/dist/src/Console.d.ts +62 -7
  4. package/dist/src/Console.d.ts.map +1 -1
  5. package/dist/src/Console.js +20 -4
  6. package/dist/src/Crypto.d.ts +76 -4
  7. package/dist/src/Crypto.d.ts.map +1 -1
  8. package/dist/src/Crypto.js +55 -4
  9. package/dist/src/Error.d.ts +45 -0
  10. package/dist/src/Error.d.ts.map +1 -1
  11. package/dist/src/Error.js +69 -0
  12. package/dist/src/Fs.d.ts +92 -18
  13. package/dist/src/Fs.d.ts.map +1 -1
  14. package/dist/src/Fs.js +2 -0
  15. package/dist/src/Identicon.d.ts +2 -2
  16. package/dist/src/Identicon.js +2 -2
  17. package/dist/src/LeakDetector.d.ts +22 -3
  18. package/dist/src/LeakDetector.d.ts.map +1 -1
  19. package/dist/src/LeakDetector.js +12 -2
  20. package/dist/src/LockManager.d.ts +8 -0
  21. package/dist/src/LockManager.d.ts.map +1 -1
  22. package/dist/src/LockManager.js +6 -0
  23. package/dist/src/Object.d.ts.map +1 -1
  24. package/dist/src/Object.js +5 -0
  25. package/dist/src/Platform.d.ts +47 -7
  26. package/dist/src/Platform.d.ts.map +1 -1
  27. package/dist/src/Platform.js +24 -5
  28. package/dist/src/Random.d.ts +25 -2
  29. package/dist/src/Random.d.ts.map +1 -1
  30. package/dist/src/Random.js +14 -2
  31. package/dist/src/Resource.d.ts +156 -1
  32. package/dist/src/Resource.d.ts.map +1 -1
  33. package/dist/src/Resource.js +201 -72
  34. package/dist/src/Schedule.d.ts +11 -10
  35. package/dist/src/Schedule.d.ts.map +1 -1
  36. package/dist/src/Schedule.js +1 -1
  37. package/dist/src/Sqlite.d.ts +132 -16
  38. package/dist/src/Sqlite.d.ts.map +1 -1
  39. package/dist/src/Sqlite.js +63 -9
  40. package/dist/src/Task.d.ts +15 -4
  41. package/dist/src/Task.d.ts.map +1 -1
  42. package/dist/src/Task.js +41 -15
  43. package/dist/src/Test.d.ts +9 -0
  44. package/dist/src/Test.d.ts.map +1 -1
  45. package/dist/src/Test.js +4 -0
  46. package/dist/src/Time.d.ts +106 -9
  47. package/dist/src/Time.d.ts.map +1 -1
  48. package/dist/src/Time.js +55 -4
  49. package/dist/src/Type.d.ts +1455 -1310
  50. package/dist/src/Type.d.ts.map +1 -1
  51. package/dist/src/Type.js +1274 -517
  52. package/dist/src/WebSocket.d.ts +164 -13
  53. package/dist/src/WebSocket.d.ts.map +1 -1
  54. package/dist/src/WebSocket.js +133 -24
  55. package/dist/src/Worker.d.ts +90 -8
  56. package/dist/src/Worker.d.ts.map +1 -1
  57. package/dist/src/Worker.js +28 -2
  58. package/dist/src/index.d.ts +6 -7
  59. package/dist/src/index.d.ts.map +1 -1
  60. package/dist/src/index.js +2 -3
  61. package/dist/src/local-first/Db.d.ts +52 -3
  62. package/dist/src/local-first/Db.d.ts.map +1 -1
  63. package/dist/src/local-first/Db.js +412 -137
  64. package/dist/src/local-first/Evolu.d.ts +336 -211
  65. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  66. package/dist/src/local-first/Evolu.js +102 -15
  67. package/dist/src/local-first/Owner.d.ts +13 -30
  68. package/dist/src/local-first/Owner.d.ts.map +1 -1
  69. package/dist/src/local-first/Owner.js +13 -30
  70. package/dist/src/local-first/Protocol.d.ts +94 -16
  71. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  72. package/dist/src/local-first/Protocol.js +118 -38
  73. package/dist/src/local-first/Query.d.ts +8 -15
  74. package/dist/src/local-first/Query.d.ts.map +1 -1
  75. package/dist/src/local-first/Schema.d.ts +335 -21
  76. package/dist/src/local-first/Schema.d.ts.map +1 -1
  77. package/dist/src/local-first/Schema.js +214 -17
  78. package/dist/src/local-first/Shared.d.ts +537 -22
  79. package/dist/src/local-first/Shared.d.ts.map +1 -1
  80. package/dist/src/local-first/Shared.js +1437 -234
  81. package/dist/src/local-first/Storage.d.ts +192 -14
  82. package/dist/src/local-first/Storage.d.ts.map +1 -1
  83. package/dist/src/local-first/Storage.js +81 -20
  84. package/dist/src/local-first/Timestamp.d.ts +392 -41
  85. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  86. package/dist/src/local-first/Timestamp.js +403 -81
  87. package/dist/src/local-first/index.d.ts +0 -1
  88. package/dist/src/local-first/index.d.ts.map +1 -1
  89. package/dist/src/local-first/index.js +0 -1
  90. package/package.json +1 -1
  91. package/src/Assert.test.ts +2 -5
  92. package/src/Config.test.ts +2 -6
  93. package/src/Config.ts +133 -133
  94. package/src/Console.ts +62 -7
  95. package/src/Crypto.ts +76 -4
  96. package/src/Eq.test.ts +2 -3
  97. package/src/Error.test.ts +76 -3
  98. package/src/Error.ts +71 -0
  99. package/src/Fs.ts +92 -18
  100. package/src/Identicon.ts +2 -2
  101. package/src/LeakDetector.ts +22 -3
  102. package/src/LockManager.ts +8 -0
  103. package/src/Object.test.ts +27 -12
  104. package/src/Object.ts +5 -0
  105. package/src/Platform.ts +50 -8
  106. package/src/Random.ts +25 -2
  107. package/src/Resource.test.ts +837 -0
  108. package/src/Resource.ts +235 -15
  109. package/src/Schedule.test.ts +50 -12
  110. package/src/Schedule.ts +24 -14
  111. package/src/Sqlite.ts +137 -17
  112. package/src/Task.test.ts +189 -8
  113. package/src/Task.ts +56 -17
  114. package/src/Test.ts +9 -0
  115. package/src/Time.ts +106 -9
  116. package/src/Type.test.ts +946 -1028
  117. package/src/Type.ts +4195 -3136
  118. package/src/Types.test.ts +4 -14
  119. package/src/WebSocket.ts +313 -40
  120. package/src/Worker.ts +90 -8
  121. package/src/index.ts +15 -6
  122. package/src/local-first/Db.ts +644 -339
  123. package/src/local-first/Evolu.test.ts +686 -21
  124. package/src/local-first/Evolu.ts +450 -228
  125. package/src/local-first/Owner.ts +13 -30
  126. package/src/local-first/Protocol.test.ts +617 -10
  127. package/src/local-first/Protocol.ts +196 -72
  128. package/src/local-first/Query.ts +8 -15
  129. package/src/local-first/Schema.test.ts +143 -0
  130. package/src/local-first/Schema.ts +363 -24
  131. package/src/local-first/Shared.test.ts +7731 -559
  132. package/src/local-first/Shared.ts +2036 -267
  133. package/src/local-first/Storage.ts +218 -32
  134. package/src/local-first/Timestamp.test.ts +344 -70
  135. package/src/local-first/Timestamp.ts +434 -118
  136. package/src/local-first/index.ts +0 -1
  137. package/dist/src/local-first/Error.d.ts +0 -12
  138. package/dist/src/local-first/Error.d.ts.map +0 -1
  139. package/dist/src/local-first/Error.js +0 -6
  140. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  141. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  142. package/dist/src/local-first/LocalAuth.js +0 -179
  143. package/src/local-first/Error.ts +0 -17
  144. 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 { createRefCountByKey, type RefCountByKey } from "../RefCount.ts";
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 type { ExtractTyped, Id, Name, Typed } from "../Type.ts";
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 { CreateWebSocketDep, WebSocket } from "../WebSocket.ts";
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 "./Error.ts";
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 { MutationChange } from "./Schema.ts";
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 = DbWorkerInit;
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
- readonly owner: SyncOwner;
133
- readonly action: "add" | "remove";
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
- readonly callbackId: Id;
161
- readonly request: DbWorkerRequest;
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 [Message in EvoluInput as Message["type"]]: Message;
171
- }["Mutate" | "Query" | "Export"];
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
- readonly type: "CreateSyncMessages";
178
- readonly owners: NonEmptyReadonlyArray<Owner>;
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 callbackId: Id;
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 requestCreateSyncMessages: (ownerIds: ReadonlySet<OwnerId>) => void;
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<TabLeaderPort | null>;
996
+ readonly tabLeaderPortStore: Store<TabPort | null>;
272
997
  }
273
998
 
274
- type TabLeaderPort = Pick<MessagePort<DbWorkerInit>, "postMessage">;
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
- export type SyncState = 123;
1012
+ // Long enough for another build's tabs to reload and its worker to end.
1013
+ const otherBuildRunningReportDelay: PositiveDuration = "3s";
287
1014
 
288
- /** Initializes the platform-agnostic Evolu SharedWorker. */
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
- deps.createWebSocket(transport.url, {
364
- binaryType: "arraybuffer",
365
-
366
- onOpen: () => {
367
- const ownerIds = transports.getClaimsForResource(transport);
368
- console.debug("transportOpen", {
369
- url: transport.url,
370
- ownerIds: [...ownerIds],
371
- });
372
-
373
- forEachTenant((tenant) => {
374
- tenant.requestCreateSyncMessages(ownerIds);
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
- onMessage(data) {
379
- if (!(data instanceof ArrayBuffer)) return;
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
- const message = new Uint8Array(data);
382
- const headerResult = parseProtocolHeader(message);
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
- if (!headerResult.ok) {
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
- console.debug("transportProtocolMessage", {
393
- url: transport.url,
394
- ownerId: headerResult.value.ownerId,
395
- byteLength: message.byteLength,
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.requestApplySyncMessage(
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.send(createProtocolMessageForUnsubscribe(ownerId));
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 = run.create({
426
- ...deps,
427
- postConsoleEntryOrError,
428
- tabLeaderPortStore,
429
- transports,
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(message, currentTenantsByName),
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
- readonly claimLeasesBySyncOwner: LookupMap<SyncOwner, Array<ClaimLease>>;
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
- assertNonNullable(tabLeaderPort);
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
- queueRequestInFlight = false;
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
- callbacks.execute(message.callbackId, message);
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 queue: Array<DbWorkerRequest> = [];
544
- const callbacks = disposer.use(
545
- createCallbacks<ExtractTyped<DbWorkerOutput, "OnQueuedResponse">>(
546
- run.deps,
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
- const request = firstInArray(queue);
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
- const callbackId = callbacks.register(({ response }) => {
559
- switch (response.type) {
560
- case "ForEvolu": {
561
- handleResponseForEvolu(response, request);
562
- break;
563
- }
1818
+ case "Write":
1819
+ for (const ownerId of entry.replicatedOwnerIds)
1820
+ updatePendingCount(pendingWriteCountByOwnerId, ownerId, delta);
1821
+ break;
564
1822
 
565
- case "ForSharedWorker":
566
- handleResponseForSharedWorker(response);
567
- break;
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
- // Complete the current queue item and continue with the next one.
571
- queue.shift();
572
- queueRequestInFlight = false;
573
- runQueue();
574
- });
1841
+ default:
1842
+ exhaustiveCheck(entry);
1843
+ }
1844
+ };
575
1845
 
576
- queueRequestInFlight = true;
577
- dbWorkerPort.postMessage({ type: "Request", callbackId, request });
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
- queueRequestInFlight = false;
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. The wait is unabortable because tenant disposal
589
- // must finish even after tenantRun receives an abort request.
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
- const nextRowsByQuery = new Map(instance.rowsByQuery);
609
- const patchesByQuery = new Map<Query, ReadonlyArray<Patch>>();
610
-
611
- for (const [query, rows] of response.message.rowsByQuery) {
612
- nextRowsByQuery.set(query, rows);
613
- patchesByQuery.set(
614
- query,
615
- makePatches(instance.rowsByQuery.get(query), rows),
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
- instance.rowsByQuery = nextRowsByQuery;
1938
+ instance.rowsByQuery = nextRowsByQuery;
620
1939
 
621
- instance.port.postMessage({
622
- type: "OnPatchesByQuery",
623
- patchesByQuery,
624
- onCompleteIds:
625
- first.message.type === "Mutate"
626
- ? first.message.onCompleteIds
627
- : emptyArray,
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
- for (const [instanceId, instance] of instancesById) {
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
- for (const syncOwner of instance.usedSyncOwners.keys()) {
644
- const { owner } = syncOwner;
645
- const messages = response.message.messagesByOwnerId.get(owner.id);
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
- // Skip owners this instance does not currently sync for
648
- // writing. Read-only owners cannot produce protocol
649
- // messages because they do not have a write key.
650
- if (!messages || !("writeKey" in owner)) continue;
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
- owner.id,
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(protocolMessagesByOwnerId);
1991
+ sendProtocolMessagesByOwnerId(
1992
+ protocolMessagesByOwnerId,
1993
+ allTransports,
1994
+ );
662
1995
  }
663
1996
  break;
664
1997
  }
665
1998
 
666
1999
  case "Export":
667
- instance.port.postMessage(
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 (response.message.type) {
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
- if (response.message.didWriteMessages) {
687
- for (const instance of instancesById.values()) {
688
- instance.port.postMessage({ type: "RefreshQueries" });
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 (!response.message.result.ok) {
693
- if (response.message.result.error.type !== "AbortError") {
694
- deps.postConsoleEntryOrError({
695
- type: "Error",
696
- error: response.message.result.error,
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
- } else {
700
- switch (response.message.result.value.type) {
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 "NoResponse":
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()) return;
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.usedSyncOwners.keys()) {
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
- instance.usedSyncOwners.increment(syncOwner);
764
- let succeeded = false;
765
- using compensation = new DisposableStack();
766
- compensation.defer(() => {
767
- if (!succeeded) instance.usedSyncOwners.decrement(syncOwner);
768
- });
769
- const claimLease = await run.ok(
770
- deps.transports.claim(syncOwner.owner.id, syncOwner.transports),
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
- instance.claimLeasesBySyncOwner
773
- .getOrInsertComputed(syncOwner, () => [])
774
- .push(claimLease);
775
- succeeded = true;
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.usedSyncOwners.decrement(syncOwner);
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
- assertNotUndefined(claimLease);
2468
+ assertNonNullable(claimLease);
782
2469
  claimLease.release();
783
2470
  if (claimLeases.length === 0) {
784
- instance.claimLeasesBySyncOwner.delete(syncOwner);
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
- addInstance: (message, onDisposed) => {
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
- claimLeasesBySyncOwner: createLookupMap({
2530
+ ownerRegistrations: createLookupMap({
800
2531
  lookup: (syncOwner: SyncOwner) =>
801
2532
  structuralLookup<{
802
- readonly ownerId: OwnerId;
2533
+ readonly owner: SyncOwner["owner"];
803
2534
  readonly transports: ReadonlyArray<StructuralLookupKey>;
804
2535
  }>({
805
- ownerId: syncOwner.owner.id,
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(async (run) => {
831
- for (const syncOwner of instance.usedSyncOwners.keys()) {
832
- while (instance.usedSyncOwners.has(syncOwner)) {
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
- queue.push({ type: "ForEvolu", id: instance.id, message });
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
- // TODO: Delegate do vsech evolu instances, co to pouzivaji
873
- queue.push({ type: "ForEvolu", id: instance.id, message });
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 { owner, action } of message.actions) {
881
- console.debug("useOwner", {
882
- id: instance.id,
883
- action,
884
- ownerId: owner.owner.id,
885
- transportUrls: owner.transports.map(({ url }) => url),
886
- });
887
-
888
- await run(toggleSyncOwner(instance, owner, action));
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
- console.debug("requestCreateSyncMessages", {
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
- requestApplySyncMessage: (ownerId, inputMessage): void => {
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
- queue.push({
930
- type: "ForSharedWorker",
931
- message: {
932
- type: "ApplySyncMessage",
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
- // - Make retried DbWorker requests deterministic by materializing clocks and
973
- // timestamps in the SharedWorker before enqueueing them.
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
- // - Consolidate usedSyncOwners and claimLeasesBySyncOwner into one owner-use
978
- // state abstraction without changing repeated-use semantics.
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.