WorkerDataAdapter
import { WorkerDataAdapter, WorkerDataAdapterHost } from '@signaldb/core'Runs the data layer in a web worker. The two halves are a pair:
WorkerDataAdapterHostlives inside the worker. It owns the storage adapters and does the actual querying and writing.WorkerDataAdapterlives on the main thread. It is theDataAdapteryour collections are constructed with, and it forwards everything to the host.
Use it when queries or writes are large enough that doing them on the main thread costs you frames.
Usage
Inside the worker:
// data-worker.js
import { WorkerDataAdapterHost } from '@signaldb/core'
import createIndexedDBAdapter from '@signaldb/indexeddb'
new WorkerDataAdapterHost(self, {
id: 'app-data',
storage: createIndexedDBAdapter({
databaseName: 'my-app',
version: 1,
schema: { posts: ['authorId'] },
}),
})On the main thread:
import { Collection, WorkerDataAdapter } from '@signaldb/core'
const worker = new Worker(new URL('./data-worker.js', import.meta.url), {
type: 'module',
})
const dataAdapter = new WorkerDataAdapter(worker, { id: 'app-data' })
const Posts = new Collection('posts', dataAdapter, {
indices: ['authorId'],
})The id on both sides must match — it is how messages are routed when several adapters share a worker or several workers share a page. Both sides default to 'default-worker-data-adapter', so a single pair works without one.
WorkerDataAdapter
new WorkerDataAdapter(worker: WorkerDataAdapterEndpoint, options: {
id?: string
log?: (message: string, ...args: any[]) => void
})WorkerDataAdapterEndpoint is the part of the Worker interface this adapter uses, so anything with the same shape works — a MessagePort, a SharedWorker's port, or a stub in a test:
interface WorkerDataAdapterEndpoint {
addEventListener: (type: 'message', listener: (event: MessageEvent) => void) => void
removeEventListener: (type: 'message', listener: (event: MessageEvent) => void) => void
postMessage: (message: unknown) => void
terminate?: () => void
}The adapter waits for the host to announce itself. If no WorkerDataAdapterHost with the same id answers within 5 seconds — the worker failed to load, or the ids differ — initialization fails with WorkerDataAdapter initialization timed out.
Disposing a collection unregisters it in the worker; the other collections of the adapter keep working. When the last of them is disposed, the adapter calls terminate() on the endpoint, if it has one, and rejects every further call with WorkerDataAdapter is disposed.
WorkerDataAdapterHost
new WorkerDataAdapterHost(context: WorkerDataAdapterHostEndpoint, options: {
id?: string
storage: (name: string) => StorageAdapter<any, any>
onError?: (error: Error) => void
log?: (message: string, ...args: any[]) => void
})WorkerDataAdapterHostEndpoint is the part of the worker's global scope the host uses — self inside a worker:
interface WorkerDataAdapterHostEndpoint {
addEventListener: (type: 'message', listener: (event: MessageEvent) => any) => void
postMessage: (message: any) => void
}storage: Called once per collection with the collection's name. Required.onError: Called when something fails inside the worker. Worth wiring up — an error thrown in a worker with nowhere to go is invisible from the main thread.
Queries are asynchronous
Every answer crosses a message boundary, so a query is not answered the moment you ask for it. As with the AsyncDataAdapter, the cursor serves its neutral empty result until then, and Cursor#isLoading() is what tells that apart from a query that matched nothing.
A write, though, is shown immediately. While it is in flight the adapter layers it onto the results it holds, so your UI does not wait for the round trip.
There is one exception. An item the adapter knows only through a projected query — one using fields — is not shown optimistically. Applying a modifier to an item that has had fields removed produces something that is not the item, and a selector naming a projected-away field would no longer match it. The write still happens; it simply becomes visible when the worker answers rather than immediately.
The message boundary is a cost
Everything crossing between the two halves is serialized on its way out and deserialized on the consumer's main thread. The work behind a message happens in the worker; the size of the message does not.
That is why the host sends the full result only the first time a query is answered, and only what changed after that, and why a write that leaves a query's result unchanged produces no message at all. If you write your own adapter across such a boundary, do the same — see the delta contract.
Projecting a query with fields is the other lever: it is the difference between sending a row and sending the two columns you render.