DataAdapter
import type {
DataAdapter,
CollectionBackend,
QueryOptions,
StateChangeCallback,
QueryDelta,
WriteResult,
DetailedWriteResult,
} from '@signaldb/core'The interface a data adapter implements. See the data adapters chapter for what an adapter is and which of the built-in ones to pick.
DataAdapter
interface DataAdapter {
createCollectionBackend<T, I, E, U>(
collection: Collection<T, I, E, U>,
indices: string[],
): CollectionBackend<T, I>
}Called once per collection. indices are the field names the collection was constructed with; an adapter that cannot use indices may ignore them.
CollectionBackend
The object that answers everything for one collection.
Writes
insert(item: T): Promise<T>
updateOne(selector: Selector<T>, modifier: Modifier<T>): Promise<WriteResult<T>>
updateMany(selector: Selector<T>, modifier: Modifier<T>): Promise<WriteResult<T>>
replaceOne(selector: Selector<T>, replacement: Omit<T, 'id'> & Partial<Pick<T, 'id'>>): Promise<WriteResult<T>>
removeOne(selector: Selector<T>): Promise<T[]>
removeMany(selector: Selector<T>): Promise<T[]>Each resolves to the items it affected. An empty result means nothing matched — which is what turns an upsert into an insert, so it has to be accurate.
The three updating writes resolve to a WriteResult<T>:
type WriteResult<T> = T[] | { items: T[], previousItems: T[] }Returning the items on their own is still valid, so an existing adapter keeps compiling and behaving as before. An adapter that also knows what the items looked like before the write returns the object form instead, and the collection's 'changed' event then carries that previous state as its third argument. previousItems[n] is what items[n] was before the write, so an adapter that reports it reports it for every item it changed.
Queries
registerQuery<O extends QueryOptions<T>>(selector: Selector<T>, options: O): void
unregisterQuery<O extends QueryOptions<T>>(selector: Selector<T>, options: O): void
retryQuery?<O extends QueryOptions<T>>(selector: Selector<T>, options: O): void
getQueryState<O extends QueryOptions<T>>(selector: Selector<T>, options: O): 'active' | 'complete' | 'error'
getQueryError<O extends QueryOptions<T>>(selector: Selector<T>, options: O): Error | null
getQueryResult<O extends QueryOptions<T>>(selector: Selector<T>, options: O): T[]
executeQuery<O extends QueryOptions<T>>(selector: Selector<T>, options: O): Promise<T[]>
onQueryStateChange<O extends QueryOptions<T>>(
selector: Selector<T>,
options: O,
callback: StateChangeCallback<T>,
): () => voidregisterQuery starts a live query; the returned function of onQueryStateChange unsubscribes. getQueryResult is synchronous and must always return something — see the neutral result below.
retryQuery is optional so that existing custom adapters keep compiling, and the Collection never calls it — it is there for code that holds a backend and wants to re-run a failed query on demand. The AsyncDataAdapter implements it and also retries a failed query by itself when a new observer subscribes to it. An adapter that never surfaces an 'error' state has nothing to implement.
Lifecycle
dispose(): Promise<void>
isReady(): Promise<void>QueryOptions
interface QueryOptions<T> {
sort?: SortSpecifier<T>
skip?: number
limit?: number
fields?: FieldSpecifier<T>
}StateChangeCallback
type StateChangeCallback<T> = (
state: 'active' | 'complete' | 'error',
delta?: QueryDelta<T>,
) => voidThe second argument is optional and only ever accompanies 'complete'. An adapter that can describe the change passes it; one that cannot omits it, and its listeners fall back to comparing the whole result against the previous one.
QueryDelta
interface QueryDelta<T> {
/** Items that were not in the previous result, at their position in the new one. */
added: { index: number, item: T }[]
/** Items that were in the previous result and whose contents changed. */
changed: T[]
/** Ids of items that are no longer in the result. */
removed: any[]
/** Items that stayed, at their new position, because the order around them changed. */
moved: { index: number, id: any }[]
/** Length of the resulting array. */
resultCount: number
}resultCount lets a recipient verify it applied the delta to the result it was computed against. A recipient whose own length disagrees falls back to re-reading the query rather than serving a result it cannot trust.
What a custom adapter has to uphold
The neutral result
A query that has not been answered yet publishes an empty list, and that empty list is indistinguishable from a real one — because empty is a legitimate answer. getQueryState is the only thing that tells the two apart, and Cursor#isLoading() is how a consumer sees it.
Two consequences. Publish 'complete' only once you actually have the answer, not when you start looking for it. And if your adapter can fail, publish 'error' and record the reason in getQueryError — a query left sitting on its neutral value is a consumer staring at an empty screen with nothing to tell them why.
The delta contract
A delta is only ever passed when it is relative to what getQueryResult returned the last time it was asked.
If your adapter layers anything on top of its stored result — an optimistic write still in flight, for instance — it must omit the delta for as long as it does, because the listener's last result included that layer and your delta does not describe it.
A wrong delta is worse than no delta. Omitting it costs the listener one comparison; getting it wrong desynchronises them silently and permanently.
Cost
The cost of a write should scale with the size of the change, not with the size of the data or of the results on screen. That is what the delta is for. An adapter that answers every write by re-running each affected query and diffing the results works correctly and gets slower the more the application shows.
For an adapter across a serialization boundary — a worker, a socket — the message size is a cost paid on the consumer's thread even though the work behind it is not. Send deltas rather than results.