Upgrade to v2
This guide explains what changed in SignalDB v2 and how to migrate your application safely. It focuses on API changes, the new DataAdapter/StorageAdapter split, and practical before/after examples.
Summary of Breaking Changes
- CRUD methods on
Collectionare async now.insert,insertMany,updateOne,updateMany,replaceOne,removeOne,removeManyreturn Promises.- They resolve to what they returned synchronously in v1: the new ID(s) or the number of affected items (see examples below).
- Indices are configured as simple field names:
indices: string[]. - Persistence was renamed and redesigned:
PersistenceAdapter→StorageAdapter(new API shape).createPersistenceAdapter→createStorageAdapter.combinePersistenceAdapterswas removed.
- The storage adapters shipped with SignalDB changed their configuration and their storage layout. Data written by v1 is not read by v2 — see Storage adapters: your existing data.
AutoFetchCollectionwas removed. UseAutoFetchDataAdapterinstead.- Persistence events on
Collectionwere removed. Each has a different replacement — see Event Changes. createMemoryAdapterand thememoryoption were removed.Collection#resetData()was removed without replacement. To reload a collection from storage, dispose it and create it again.- Readiness API changed:
.isReady()(promise) was renamed to.ready(). A new reactive.isReady()getter was added. isLoading()on theCollectionno longer describes the initial load. It starts outfalseand istrueonly while an{ async: true }query or a write is running. Useready()/isReady()for the initial load andCursor#isLoading()for a single query.- Collection constructor: prefer
new Collection(name, dataAdapter, options?); deprecatedpersistenceoption is still accepted and wrapped byDefaultDataAdapter. SyncManager: thepersistenceAdapteroption was removed. UsedataAdapterinstead.StorageAdapter.readIndexnow declaresMap<string | null, Set<I>>instead ofMap<any, Set<I>>. Only relevant if you wrote your own adapter — see Custom storage adapters below.- Observer semantics changed in two ways — see Observer events below.
Core CRUD API (async)
All write operations are asynchronous. Update your call sites to await them and adapt to the new return values.
Before (v1):
const id = Posts.insert({ title: 'Hello' })
Posts.updateOne({ id }, { $set: { title: 'Hi' } })
Posts.removeOne({ id })After (v2):
const id = await Posts.insert({ title: 'Hello' }) // returns the inserted ID
await Posts.updateOne({ id }, { $set: { title: 'Hi' } }) // resolves to number of updated items
await Posts.removeOne({ id }) // resolves to number of removed itemsReturn values in v2:
insert(item)→Promise<I>(inserted ID)insertMany(items)→Promise<I[]>(inserted IDs)updateOne(...)→Promise<number>(0 or 1)updateMany(...)→Promise<number>replaceOne(...)→Promise<number>(0 or 1)removeOne(...)→Promise<number>(0 or 1)removeMany(...)→Promise<number>
Readiness and Loading
- Promise-based readiness:
await collection.ready()replacesawait collection.isReady(). It resolves once the storage adapter is set up and its data has been loaded. - Reactive readiness:
collection.isReady()now returns a reactive boolean that turnstrueat the same moment. - Loading states of a single query:
cursor.isLoading()is reactive andtrueuntil the query has been answered. Use it to tell a query that has not been answered yet from one that legitimately matched nothing — both return an empty list. - Activity of the collection as a whole:
collection.isPulling()is a reactive boolean that istruewhile afind(…, { async: true })query is running. It does not reflect the initial load from storage or a sync pull.collection.isPushing()is a reactive boolean that istruewhile a write is running.collection.isLoading()is reactive andtrueif either of the two is. It starts asfalse.
Before (v1):
await collection.isReady()After (v2):
await collection.ready()
// reactive checks (in a reactive context)
collection.isReady() // initial load finished
collection.find({ published: true }).isLoading() // this query not answered yetIndices Configuration
Indices are now defined by field names directly.
Before (v1):
import { createIndex } from '@signaldb/core'
const Posts = new Collection({
indices: [
createIndex('title'),
createIndex('author.id'),
],
})After (v2):
const Posts = new Collection({
indices: ['title', 'author.id'],
})Remove any usages of createIndex and createIndexProvider. Use string field paths instead (dot-notation supported).
Storage vs. Persistence
The persistence layer has been renamed to Storage and modernized.
PersistenceAdapter→StorageAdapter(new API).createPersistenceAdapter→createStorageAdapter.combinePersistenceAdapterswas removed.- Persistence-related
Collectionevents were removed. See Event Changes for what replaces each of them.
New StorageAdapter API surface:
interface StorageAdapter<T extends { id: I }, I> {
// lifecycle
setup(): Promise<void>
teardown(): Promise<void>
// reads
readAll(): Promise<T[]>
readIds(ids: I[]): Promise<T[]>
query?(query: StorageQuery<T>): Promise<StorageQueryAnswer<T>> // optional
// indices
createIndex(field: string): Promise<void>
dropIndex(field: string): Promise<void>
readIndex(field: string): Promise<Map<string | null, Set<I>>>
// writes
insert(items: T[]): Promise<void>
replace(items: T[]): Promise<void>
remove(items: T[]): Promise<void>
removeAll(): Promise<void>
}Migration tips from old PersistenceAdapter:
- Move one-time initialization into
setup(), cleanup intoteardown(). - Replace “load everything” with
readAll(); selective lookups go throughreadIds(). - Replace “save/patch” with explicit
insert,replace,remove, andremoveAlloperations. - Provide index operations (
createIndex,dropIndex,readIndex) if your storage can accelerate queries (dot-notation field names are passed in).
Minimal in-memory example for testing:
import { createStorageAdapter } from '@signaldb/core'
type Item = { id: string; [k: string]: any }
export const memoryStorage = () => {
let items: Item[] = []
return createStorageAdapter<Item, string>({
async setup() {},
async teardown() {},
async readAll() { return items },
async readIds(ids) { return items.filter(i => ids.includes(i.id)) },
async createIndex(field) { /* no-op for memory */ },
async dropIndex(field) { /* no-op */ },
async readIndex(field) { return new Map() },
async insert(newItems) { items = [...items, ...newItems] },
async replace(newItems) {
const byId = new Map(items.map(i => [i.id, i]))
for (const it of newItems) byId.set(it.id, it)
items = [...byId.values()]
},
async remove(toRemove) {
const ids = new Set(toRemove.map(i => i.id))
items = items.filter(i => !ids.has(i.id))
},
async removeAll() { items = [] },
})
}Note: For simple upgrades, you can still pass the deprecated persistence option to the Collection constructor; v2 wraps it with DefaultDataAdapter. Prefer the explicit DataAdapter + StorageAdapter setup for new code.
Storage adapters: your existing data
Every storage adapter shipped with SignalDB changed how it is configured and where it keeps its data, and none of them requires a @signaldb/core older than 2.0.0 anymore. v2 does not read what v1 wrote. The old data is left untouched, so nothing is lost, but a collection starts out empty after the upgrade until you migrate it once.
| Package | v1 | v2 |
|---|---|---|
@signaldb/localstorage | key signaldb-collection-<name> | key <databaseName>-<name> (default signaldb-<name>), plus one key per declared index |
@signaldb/indexeddb | createIndexedDBAdapter('posts') — one database per collection (signaldb-posts, store items) | createIndexedDBAdapter({ databaseName, version, schema, onUpgrade? }) — one database for all collections, returns the storage function for a data adapter |
@signaldb/fs | createFileSystemAdapter('posts.json') — one JSON file per collection | createFilesystemAdapter('./data/posts') — a folder with one file per document under items/ and one file per indexed value under index/ |
@signaldb/opfs | createOPFSAdapter('posts.json') — one file per collection | createOPFSAdapter('posts') — a folder, laid out like @signaldb/fs |
@signaldb/generic-fs | one file per collection | a folder, laid out like @signaldb/fs; a custom Driver has to be rewritten against the new interface (path building, directory creation, recursive listing) |
With @signaldb/indexeddb, the schema is the complete description of the database: a store that exists but is missing from schema is dropped on upgrade. List every collection you use — and, if you pass the same data adapter to the SyncManager, its stores as well.
Migrating the data once
Read the v1 data yourself, insert it into the v2 collection, then remove the old copy. For @signaldb/localstorage:
await Posts.ready()
const legacyKey = 'signaldb-collection-posts'
const legacyData = localStorage.getItem(legacyKey)
if (legacyData != null) {
await Posts.insertMany(JSON.parse(legacyData))
localStorage.removeItem(legacyKey)
}If you configured a custom serialize/deserialize in v1, use your deserialize instead of JSON.parse.
For @signaldb/indexeddb, the v1 data is in the database signaldb-<name> (or <prefix><name> if you set prefix), in the object store items:
/**
* Reads every item a v1 IndexedDB adapter stored for a collection.
* @param name - The name the v1 adapter was created with.
* @returns The stored items.
*/
function readLegacyItems(name: string) {
return new Promise<any[]>((resolve, reject) => {
const request = indexedDB.open(`signaldb-${name}`)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const database = request.result
if (!database.objectStoreNames.contains('items')) {
database.close()
resolve([])
return
}
const getAll = database.transaction('items').objectStore('items').getAll()
getAll.onerror = () => reject(getAll.error)
getAll.onsuccess = () => {
database.close()
resolve(getAll.result)
}
}
})
}
await Posts.ready()
const legacyItems = await readLegacyItems('posts')
if (legacyItems.length > 0) await Posts.insertMany(legacyItems)
indexedDB.deleteDatabase('signaldb-posts')For @signaldb/fs, @signaldb/opfs and @signaldb/generic-fs, point the v2 adapter at a new folder, read the old JSON file once, insertMany its contents and delete the file afterwards.
New DataAdapter Layer
v2 introduces a DataAdapter abstraction to separate collection behavior from storage mechanics and to enable advanced scenarios. See the Data Adapters chapter for the full picture.
DefaultDataAdapter: simple, standard choice that works with aStorageAdapter.AsyncDataAdapter: async-first flow with explicit storage setup.WorkerDataAdapter/WorkerDataAdapterHost: run data operations in a Web Worker.AutoFetchDataAdapter: replacement forAutoFetchCollection(if you previously relied on it).
Standard setup with DefaultDataAdapter (recommended baseline):
import { Collection, DefaultDataAdapter } from '@signaldb/core'
const dataAdapter = new DefaultDataAdapter({
storage: (name) => myStorageFor(name), // returns a StorageAdapter for the given collection name
})
const Posts = new Collection('posts', dataAdapter, {
indices: ['id', 'authorId', 'createdAt'],
})If you previously used AutoFetchCollection, migrate to AutoFetchDataAdapter. Keep the same Collection constructor pattern shown above but swap the adapter class.
Collection Constructor Changes
The Collection constructor now supports two forms:
new Collection(options?)(legacy-compatible). The deprecatedpersistenceoption still works and is wrapped byDefaultDataAdapter.new Collection(name, dataAdapter, options?)(recommended): pass a collection name and aDataAdapterinstance explicitly.
Example migrating from v1: Before (v1):
const Posts = new Collection({
name: 'posts',
persistence: /* old adapter */
})After (v2):
import { DefaultDataAdapter } from '@signaldb/core'
const dataAdapter = new DefaultDataAdapter({
storage: (name) => myStorageFor(name),
})
const Posts = new Collection('posts', dataAdapter, {
indices: ['title']
})Removed Memory Adapter
createMemoryAdapter and the memory option were removed. For tests or ephemeral storage, implement a trivial in-memory StorageAdapter with createStorageAdapter that keeps items in a local array or Map.
Event Changes
All persistence-level events on Collection were removed. They described one adapter loading and saving one collection as a whole, which is no longer how data moves. Replace them by what you used them for:
| v1 event | v2 replacement |
|---|---|
persistence.init | await collection.ready(), or the reactive collection.isReady() |
persistence.pullStarted / persistence.pullCompleted (initial load) | collection.ready() / collection.isReady() for the collection, cursor.isLoading() for a single query |
persistence.pushStarted / persistence.pushCompleted / persistence.transmitted | Await the write — with the DefaultDataAdapter it resolves once the storage adapter has written it. collection.isPushing() reflects running writes reactively. |
persistence.error | A failed write rejects its promise. A failure outside of a write — while setting up the storage adapter and loading its data, for instance — goes to the onError option of the data adapter (DefaultDataAdapter, AsyncDataAdapter, AutoFetchDataAdapter); without it, it is only logged with console.error. A collection whose stored data could not be loaded also rejects ready(). |
persistence.received | No equivalent. A StorageAdapter is not notified of changes made outside the collection; changes from a server reach the collection through SyncManager or the AutoFetchDataAdapter. |
collection.isPulling() is not a replacement for the pull events: it is only true while a find(…, { async: true }) query is running.
Existing CRUD events remain: added, changed, removed and their corresponding action events (insert, updateOne, updateMany, replaceOne, removeOne, removeMany).
A query that fails now announces itself through the new query.error event. This matters more than it looks: a cursor whose query failed keeps returning its neutral empty result, which is indistinguishable from a query that legitimately matched nothing. If your application needs to tell the two apart, listen here.
Observer events
Two changes to what observeChanges reports. Neither changes the data you end up with — both change how many events you are told about.
movedBefore reports the minimal set of moves. In v1, every item whose neighbouring item had changed was reported as moved, so moving a single item could produce a movedBefore for several of them. Applying the reported moves still produces the same order. Consumers that count movedBefore calls, or that rely on being notified about items which did not themselves move, will now see fewer events.
changed is no longer emitted for a write that matched nothing. It previously was, whenever the item had still existed at the moment it was read back.
Custom storage adapters: index keys
StorageAdapter.readIndex now declares Map<string | null, Set<I>> rather than Map<any, Set<I>>. The keys always had to be serializeValue(value) — that is what SignalDB looks an index up with — but the type did not say so, and an adapter keying its index by the raw field value answered nothing for every non-string field and everything for a $ne on one.
If your adapter stores raw keys, wrap them:
import { serializeValue } from '@signaldb/core'
const key = serializeValue(item[field])Only string-valued fields were unaffected, which is why this can go unnoticed until someone indexes a number or a boolean.
SyncManager
The persistenceAdapter option was removed. Pass a dataAdapter instead — the same one your collections use:
const syncManager = new SyncManager({
dataAdapter,
// …
})@signaldb/sync v2 requires @signaldb/core 2.0.0 or later.
If you create the SyncManager without an id, its own collections are now named default-sync-manager-changes, default-sync-manager-snapshots and default-sync-manager-sync-operations instead of undefined-changes and so on. Unpushed changes and snapshots stored under the old names are not read anymore. Pass id: 'undefined' to keep the old names, or — better — choose an id and migrate the data once.
Migration Checklist
- Update all write calls to
awaitthe new async methods and handle new return values. - Replace index providers with
indices: string[]. - Rename persistence APIs to storage (
createStorageAdapter,StorageAdapter), removecombinePersistenceAdapters. - Replace
AutoFetchCollectionwithAutoFetchDataAdapter. - Switch
await collection.isReady()toawait collection.ready()and use reactivecollection.isReady()where needed. - Check any code that used
collection.isLoading()to wait for the initial load; useready()/isReady()orcursor.isLoading()instead. - Replace persistence events as described in Event Changes —
ready(),cursor.isLoading(), awaited writes and the data adapter'sonError— and consider listening forquery.error. - Migrate the data your v1 storage adapters wrote; v2 does not read it (see Storage adapters: your existing data).
- Remove
createMemoryAdapterand thememoryoption; implement an in-memoryStorageAdapterif necessary. - Remove calls to
resetData(). - Replace the
SyncManager'spersistenceAdapteroption withdataAdapter. - In a custom
StorageAdapter, keyreadIndexbyserializeValue(value). - Review anything counting
movedBeforeevents or relying onchangedfor a write that matched nothing.
If you run into something not covered here, see the changelog for @signaldb/core and the API reference, or open a discussion/issue.