API reference › @evolu/common › local‑first/Shared
Platform-agnostic Evolu SharedWorker.
Builds
Tabs of different app builds can be open at once, for example an old tab during a deploy. Bundlers derive the worker script's URL from its content, so every build whose worker code differs gets its own SharedWorker, and all of them open the same databases. Only one may use them at a time: a request retried after another worker wrote from the same stored clock can reuse that write's timestamps and then is skipped as already stored. Each worker stamps with its own session NodeId, but an earlier release stamps with the NodeId stored in the clock, which can be another worker's.
The worker therefore takes an origin-wide lock before it answers any tab and holds it for its lifetime. A worker of another build waits, with its tabs' messages buffered, until every tab of the first one is closed or reloaded and the browser ends it. The lock is the one that earlier releases take in their leader tab, so they are excluded too. An earlier release's worker can outlive its leader tab and resume once the lock is free; it computes new timestamps when it retries, so it cannot reuse another worker's. A tab of an earlier release that opens while a worker of this release runs gets no response to its database requests until it reloads, and until then it also blocks workers that start after this one ends.
On the web, the wait is usually short, because tabs of the running build reload to load the build the server now serves:
- A worker tells the tabs that connect before it holds the lock that they wait, and such a tab announces the worker with BuildWaiting. It announces again when a tab that connects asks with BuildWaitingRequest, so a tab that started or connected after the first announcement learns of it too.
- A tab connected to another worker reloads with ReloadApp: at once if the user is not in it, otherwise once they leave it, so a tab never reloads while the user works in it. Focus can leave a page from a frame without a window event, so such a tab checks once a second whether it still has focus. A tab that still waits itself keeps the announcements it receives and handles them once it connects, because the lock can pass to its worker first.
- A page that such a reload loaded never announces, and a tab reloads at most once for each waiting worker, so two builds cannot keep reloading each other, even when a reload loads the running build again. A tab without session storage cannot record its reloads, so it does not reload.
Only the web platform does this, because only there do builds coexist. A reload loses UI state the app did not persist, so apps keep drafts in local-only tables. A write a background tab has in flight can be lost, as when a tab crashes.
The wait lasts while a tab of the running build does not reload: a tab of an
earlier release, a tab Safari has frozen, a tab without session storage, or a
tab whose reload loads the running build again, for example because another
Evolu app shares the origin or a cache still serves the old build. A worker
still waiting after three seconds reports OtherBuildRunningError to
its tabs. Every build broadcasts console entries and errors on
consoleEntryOrErrorBroadcastChannelName, so a waiting tab also prints
the running build's output, reports each UnknownError it sends as its
own evoluError, and only logs any other error, such as an older build's
sync error.
The tabs of one worker elect the host of its DbWorkers among themselves, with a lock scoped to the worker, so a tab of another worker never hosts them. Builds that differ only in DbWorker code share one SharedWorker, and a tab of either can host its DbWorkers.
Safari suspends, rather than ends, a worker whose tabs are all in its back-forward cache, and the suspended worker keeps the lock. There, a waiting build also waits until Safari drops those pages, or until the user goes back to one of them, which reloads it on the web.
Storage
The platform tells the worker with DevicePersistenceDep what it can promise about stored databases. The worker asks once, after it takes the build lock and before any DbWorker starts. A browser checks whether it can open the origin private file system, which Safari's Private Browsing and Firefox's private windows refuse. Without persistent storage, every DbWorker the worker starts keeps its database in memory, replacements included. The decision holds for the worker's lifetime, so all its tabs see one mode, and the next worker checks again.
A tenant tells each instance it adds its Evolu.devicePersistence:
NotPersisted when its database is kept in memory, because of the platform
or EvoluConfig.memoryOnly, otherwise what the platform promised.
Instances of one name share the tenant, so a later instance gets the mode the
first one chose.
The check cannot tell a private session from a storage failure, but memory loses nothing that refusing to start would have kept, and the persistent database stays untouched. Each DbWorker keeps its own memory, so when the tab hosting it closes, or on the web navigates away, data that exists only locally or has not synced yet is lost, even though its replacement starts in memory too.
Synchronization routing
WebSocket transports are shared resources keyed by their configuration and claimed by owner ID, so every tenant (one named local database) using an owner shares that owner's sockets, whichever tenant claimed them. An incoming frame is offered to every tenant with a writable registration for its owner. Each stores the frame's messages, which is idempotent, but only the tenant whose request a Response answers reconciles it, as Synchronization completion describes.
Traffic goes only where it is needed. A continuation returns to the transport that produced the response, a round started by a socket opening or by a transport's first claim for an owner goes through that transport, and a tenant's first writable registration of an owner starts one through each transport already claimed for it. Explicit synchronization requests and mutation uploads go to every open transport claimed for the owner. A write uploads through the database's writable registrations for its owner, whichever instance made it, even one disposed before the database worker answered. When a relay frame stores new messages, the tenant requests a round through each other transport claimed for the owner, so data learned from one relay reaches the others, except through a route that skipped a message it received or could not send, as described below. A closed transport reconciles when it opens, and a replacement leader reconciles every transport again, because a response reporting stored messages may have been lost.
Relays omit the sending socket when broadcasting an upload, so the uploader also delivers it as local Broadcast frames to every other tenant with writable access to the owner, even while sockets are closed. A copy keeps the uploader's target, and a recipient that stores new continuation messages reconciles them through the transports outside that target, except through a route that skipped a message. Local delivery forwards uploads, including historical messages sent during reconciliation, but does not reconcile local database histories with each other. That waits until replication scopes and retention semantics are defined.
Sync state
The shared worker publishes one plain snapshot, SyncState, of every transport it manages and every database it holds, with the owners its instances registered and, for each writable owner, one route per transport claimed for the owner by any database, as specified below. Each part is a union of the states the worker keeps for it: a transport's connection, a database that is active or refused, a writable or readonly owner, and a pending, settled, or complete route. A transport's events drive its connection, so publishing never reads a socket.
Apps show users an owner's OwnerSyncStatus, which syncStateToOwnerSyncStatus derives. A view of each relay pairs each route with its transport by syncStateToRelaySyncStates and tells the relay's status by relaySyncStateToStatus. Snapshots store neither, so a status never disagrees with the routes it comes from.
Each worker broadcasts snapshots on its own channel, whose name a connecting tab receives through its port, so a tab never hears another worker, such as one of a different app version, and deps.syncState keeps the last snapshot. The worker publishes after every change it observes. The snapshot lives in worker memory only, so a new worker starts empty.
Synchronization completion
A protocol frame carries the owner ID and the message type but nothing that correlates it with a request, and a relay answers a converged round, an upload that fits one frame, and an Unsubscribe alike with a header-only Response. So the shared worker keeps the outstanding requests of an owner on a socket from one sender at a time: a tenant, or the worker itself for an Unsubscribe. A Request waits while another sender's requests are outstanding or other requests wait. Once the outstanding requests are answered, the sender of the oldest waiting request sends all its waiting requests, except that the worker's Unsubscribe and tenants' requests never pass each other, so a round that claims the owner again never goes before an earlier Unsubscribe. Every Response, including a relay's version-mismatch reply, which has no message type, answers the sender whose requests are outstanding, in whatever order the relay answers them. An opening socket drops the outstanding and waiting requests, because requests on the previous connection are never answered and its rounds start again.
Every tenant with a writable registration applies every frame for its owner, so each database stores the messages any reply carries. Only the sender of the requests a Response answers reconciles its ranges and may continue; the others only store its messages and record any relay error it carries. If every tenant continued every Response, the requests would multiply with each round of a catch-up. Tenants using one owner through one socket therefore take turns: a waiting request is delayed, but no request is added.
Completion is counted: a route, one tenant's use of one owner through one transport, is complete when:
- The tenant is not refused and the socket is open.
- No request for the owner is outstanding or waiting on the socket.
- The tenant has no apply for the owner queued for that transport or for every transport, because a frame is applied asynchronously, so between a reply and its continuation the owner can have no request outstanding or waiting.
- The tenant has no replicated write for the owner queued, because its upload is sent only after the database worker answers it.
- The tenant has sent a round through the transport since the last event that requires one: the route's creation, the socket opening, an explicit request, a replacement leader, or storing messages from another transport, unless the route skipped a message. No failed result has arrived on the route since.
- No result that skipped a received message or a stored change too large to send has arrived on the route since a round was last requested through it, and since that request, no messages have been stored from another transport, directly or through a sibling copy uploaded outside this transport, and no sibling copy has failed to apply or skipped a message.
A route is settled when every condition except the last holds, so a complete route is settled too. A settled route that is incomplete has ended its reconciliation, but its relay may offer a message the database skipped, the database may store a change too large to send, or messages stored elsewhere may not have reached it; it is published as SettledSyncRoute.
A reconciliation chain, a request and the continuations its sender makes for the replies, ends only with a converged result, a failure, a refusal of the database, a dropped frame, a reopen, which drops its outstanding and waiting requests, or a continuation that finds the socket closed, and relay errors reach every applying tenant, so these conditions mean every chain this tenant started through the transport since the last event that requires a round converged. Another tenant's continuation can make the route pending again. A local Broadcast from a sibling tenant holds every route of its owner until it is applied, because it arrives without a request of its own; one that fails to apply or skips a message requires a round through every transport whose route has not skipped one.
A failed result on a route requests one round through it. Any further failure
before the route settles requests no round of its own and waits for the next
round another event requests, such as an explicit request or a reopen, so a
persistent failure cannot loop; a converged reply in between does not end the
wait, because it may answer a request sent before the failure. An exception
while the database worker creates a round is logged there and fails the
round's routes with SyncFailed without a retry. A mutation that throws is
rolled back and reported as an UnknownError to the tab that made it,
or to every tab when its Evolu instance was disposed first. A received batch
SQLite fails to store, for example on a full disk, is rolled back and fails
its route with an UnknownError. An exception while the database worker
reconciles a received frame's ranges is logged there and fails that route
with SyncFailed. A query or an export SQLite fails is answered to the
instance that requested it with an UnknownError, and the instance
keeps the query's previous rows. Any other exception in the database worker,
such as a response it cannot post, refuses the database with an
UnknownError. A frame the relay silently drops, such as invalid data,
leaves its request outstanding, and the requests of other databases for its
owner on that socket, uploads included, waiting, until the liveness rule
below replaces the socket and the reopen's rounds reconcile them.
A result that skipped a message records the error on the route as its
skippedError, without ending its chain, so it requests no round. A message
is skipped in two cases:
- The relay offers a message the database could not decrypt, verify, or decode. The relay offers it again in every round through the route until the database stores a message with that timestamp.
- The database stores a change it cannot send, a
ProtocolChangeTooLargeError: one saved before
maxMutationSizeexisted, or a crafted one received from a relay. Every route through a relay that lacks it skips it on every sync, so each such route stays settled.
The messages received elsewhere that the last condition names request no round through such a route, even while a requested round checks it again, because each round would download every skipped message again, or skip again a change too large to send. The next round that an explicit request, a reopen, a replacement leader, or a failure sends through the route reconciles them. So while the database stores a change too large to send, messages from other relays reach a route only through such a round, for example after a reconnect or Evolu.requestSync.
Liveness
An open socket can be dead without a close event, when a NAT drops an idle mapping or the path fails while nothing is sent. The relay pings every connection and terminates one from which nothing has arrived since the previous ping, which also keeps NAT mappings alive. The shared worker reconnects a socket when a request for an owner has been outstanding for ninety seconds, enough for a 1 MB frame at about 90 kbit/s, with no Response for that owner since. Frames for other owners do not count: they prove the socket alive, not that the request was received.
A slow link looks like a dead one, because the browser reports no transfer progress and a large frame saves nothing until it arrives whole. Each timeout therefore doubles the transport's timeout, up to twenty-four minutes: a connection that reopens but times out again is more likely slow than dead. The timeout belongs to the transport, not to an owner, because a small reply for one owner can wait behind another owner's large frame on the socket. A grown timeout lasts while a request is outstanding on the socket or a database that is not refused has an unsettled route through it, including one waiting after a failure, and ends with the transport. A settled route that is incomplete does not count, because its reconciliation has ended. A reply's speed proves nothing, because a recovery on a slow link starts with small replies that arrive quickly. The reopen drops the outstanding and waiting requests and starts the open rounds, so a dropped frame delays a route instead of stranding it.
The shared worker sends nothing while idle, so a path that dies then may go unnoticed until its next request; until then, changes from other devices stop arriving while routes still read complete. A periodic empty request would notice it without waiting for the app to send. That is deferred, because relay pings already keep NAT mappings alive, the common cause.
Functions
| Function | Description |
|---|---|
| initSharedWorker | Initializes the platform-agnostic Evolu SharedWorker. |
| postConsoleEntry | Posts a ConsoleEntry to tabs on a channel named consoleEntryOrErrorBroadcastChannelName. |
| relaySyncStateToStatus | Tells the status of one relay; a failure shows before a skipped change. |
| syncStateToOwnerSyncStatus | Tells what an app shows about syncing an owner in a database: Error when a relay has one, holding the newest failure of any relay or, without one, the newest skipped change; otherwise the first of Syncing, Synced, and Offline that a relay has, or NoRelays. Accepts null, the store's value before the first snapshot. See OwnerSyncStatus for what to show. |
| syncStateToRelaySyncStates | Pairs each route of an owner in a database with its transport, in route order. Returns none for a null snapshot, a missing or refused database, and a missing or readonly owner. |
Interfaces
| Interface | Description |
|---|---|
| ActiveSyncTenant | A database that is not refused, including one still starting, with the owners its instances registered. |
| BuildWaiting | Posted on buildsBroadcastChannelName by a tab whose worker waits for the build lock, unless an automatic reload loaded the page, when it starts waiting and for each BuildWaitingRequest. A tab connected to another worker reloads for it; see Builds. |
| BuildWaitingRequest | Posted on buildsBroadcastChannelName by a tab once it connects, asking tabs whose worker waits to post BuildWaiting again; see Builds. |
| CompleteSyncRoute | A route on which the database is reconciled with the relay for the owner. |
| ConnectingSyncConnection | A first connection, which has neither opened nor failed. A connection to a host that drops packets stays here until the operating system or browser gives up on it, which can take a minute or more. |
| DevicePersistenceDep | Tells what the platform can promise about the databases it stores. |
| DisconnectedSyncConnection | No connection: a connection closed or failed, or the relay did not answer a request in time. The transport reconnects by itself. |
| ErrorSyncStatus | A route failed or holds a change the database skipped. |
| NoRelaysSyncStatus | Evolu reports no relay syncing the owner in the database. |
| OfflineSyncStatus | Relays are disconnected and reconnecting, so changes wait on this device. |
| OpenSyncConnection | An open connection. |
| OtherBuildRunningError | Another build of the app holds the local databases, and this tab waits until every tab of that build is closed or reloaded; see Builds. |
| PendingSyncRoute | A route whose reconciliation has not ended: its transport is not open, a request or a received frame is outstanding, a replicated write is queued, or a round must still be sent through it. |
| ReadonlySyncTenantOwner | An owner registered only without its write key. Its registrations hold transports, which other databases' routes for the owner use, but this database does not sync it. |
| RefusedSyncTenant | A database that refused startup or whose worker failed, so nothing it holds syncs. Its tabs also receive the error through evoluError. A failed worker refuses its database with an UnknownError. |
| RelaySyncState | One relay of an owner in one database: a transport and the database's route through it, from syncStateToRelaySyncStates. Its status comes from relaySyncStateToStatus and is not stored beside them. |
| SettledSyncRoute | A route whose reconciliation ended with a skipped change, so it is incomplete: its relay offers a change the database could not store, or the database stores a change it cannot send, a ProtocolChangeTooLargeError. Changes stored from other relays request no round through it; the next round requested through it, such as by Evolu.requestSync or a reopen, checks it again. Every route through a relay that lacks a change too large to send skips it on every sync, so those routes stay settled, and messages from other relays reach them only through such rounds. |
| SharedWorkerDep | - |
| SyncedSyncStatus | A relay is reconciled with the database for the owner. |
| SyncingSyncStatus | A relay makes its first connection or reconciles. |
| SyncState | A snapshot of the transports and databases the shared worker manages. Each part is a union of the states the worker keeps for it, so each part holds only the fields valid for its state. Apps derive what to show with syncStateToOwnerSyncStatus; see Sync state in this module's documentation. |
| SyncTransportError | - |
| WebSocketSyncTransport | A WebSocket SyncTransport. |
| WritableSyncTenantOwner | An owner the database syncs, with one route per transport claimed for it by this or any other database. Routes are briefly empty while the owner's transports are being claimed. |
Type Aliases
| Type Alias | Description |
|---|---|
| ConsoleEntryOrError | - |
| DbWorkerInput | - |
| DbWorkerOutput | - |
| DbWorkerQueuedResponse | - |
| DbWorkerReadRequest | - |
| DbWorkerRequest | - |
| DbWorkerWriteRequest | - |
| EvoluInput | - |
| EvoluInstanceId | - |
| EvoluOutput | - |
| OwnerSyncStatus | What an app shows users about syncing one owner of one database, from syncStateToOwnerSyncStatus or the React and Vue useOwnerSyncStatus. |
| RelaySyncStatus | The status of a RelaySyncState, from relaySyncStateToStatus: Error when its route has a failure or a skipped change, the failure first; otherwise Synced when the route is complete, Syncing while the transport's connection is Connecting or Open, and Offline while it is Disconnected. |
| SharedWorker | - |
| SharedWorkerDeps | - |
| SharedWorkerId | Identifies one running SharedWorker instance. |
| SharedWorkerInput | - |
| SharedWorkerOutput | - |
| SyncConnection | The connection of a SyncTransport: Connecting until its first connection opens or fails, Open while a connection is open, and Disconnected from a close, a failure, or an unanswered request that replaced the connection, until a connection opens again. It never returns to Connecting. The transport's events drive these states. |
| SyncRoute | One database's use of one owner through one transport: Pending until its reconciliation ends, then Complete, or Settled while it skips a change: one its relay offers that the database could not store, or one the database stores but cannot send. See Synchronization completion in this module's documentation. |
| SyncRouteError | A failure or a skipped change of a SyncRoute, with the time it arrived. |
| SyncTenant | One named local database: Active, or Refused when it refused startup or its worker failed. |
| SyncTenantOwner | An owner registered by any instance of an ActiveSyncTenant: Writable when any registration holds its write key, otherwise Readonly. |
| SyncTransport | One transport, shared by every owner and database claiming it. Its type is the kind of transport, and its SyncConnection tells whether it is connected. |
| SyncTransportId | - |
Variables
| Variable | Description |
|---|---|
| buildsBroadcastChannelName | The channel on which a tab announces its waiting worker with BuildWaiting; see Builds. Builds of different releases share it, so its name and messages never change. |
| BuildWaiting | Posted on buildsBroadcastChannelName by a tab whose worker waits for the build lock, unless an automatic reload loaded the page, when it starts waiting and for each BuildWaitingRequest. A tab connected to another worker reloads for it; see Builds. |
| BuildWaitingRequest | Posted on buildsBroadcastChannelName by a tab once it connects, asking tabs whose worker waits to post BuildWaiting again; see Builds. |
| consoleEntryOrErrorBroadcastChannelName | - |
| SharedWorkerId | Identifies one running SharedWorker instance. |