Skip to content

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 Collection are async now.
    • insert, insertMany, updateOne, updateMany, replaceOne, removeOne, removeMany return 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.
    • combinePersistenceAdapters was 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.
  • AutoFetchCollection was removed. Use AutoFetchDataAdapter instead.
  • Persistence events on Collection were removed. Each has a different replacement — see Event Changes.
  • createMemoryAdapter and the memory option 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 the Collection no longer describes the initial load. It starts out false and is true only while an { async: true } query or a write is running. Use ready() / isReady() for the initial load and Cursor#isLoading() for a single query.
  • Collection constructor: prefer new Collection(name, dataAdapter, options?); deprecated persistence option is still accepted and wrapped by DefaultDataAdapter.
  • SyncManager: the persistenceAdapter option was removed. Use dataAdapter instead.
  • StorageAdapter.readIndex now declares Map<string | null, Set<I>> instead of Map<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):

ts
const id = Posts.insert({ title: 'Hello' })
Posts.updateOne({ id }, { $set: { title: 'Hi' } })
Posts.removeOne({ id })

After (v2):

ts
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 items

Return 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() replaces await 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 turns true at the same moment.
  • Loading states of a single query: cursor.isLoading() is reactive and true until 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 is true while a find(…, { 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 is true while a write is running.
    • collection.isLoading() is reactive and true if either of the two is. It starts as false.

Before (v1):

ts
await collection.isReady()

After (v2):

ts
await collection.ready()
// reactive checks (in a reactive context)
collection.isReady() // initial load finished
collection.find({ published: true }).isLoading() // this query not answered yet

Indices Configuration ​

Indices are now defined by field names directly.

Before (v1):

ts
import { createIndex } from '@signaldb/core'

const Posts = new Collection({
  indices: [
    createIndex('title'),
    createIndex('author.id'),
  ],
})

After (v2):

ts
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.
  • combinePersistenceAdapters was removed.
  • Persistence-related Collection events were removed. See Event Changes for what replaces each of them.

New StorageAdapter API surface:

ts
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 into teardown().
  • Replace “load everything” with readAll(); selective lookups go through readIds().
  • Replace “save/patch” with explicit insert, replace, remove, and removeAll operations.
  • 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:

ts
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.

Packagev1v2
@signaldb/localstoragekey signaldb-collection-<name>key <databaseName>-<name> (default signaldb-<name>), plus one key per declared index
@signaldb/indexeddbcreateIndexedDBAdapter('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/fscreateFileSystemAdapter('posts.json') — one JSON file per collectioncreateFilesystemAdapter('./data/posts') — a folder with one file per document under items/ and one file per indexed value under index/
@signaldb/opfscreateOPFSAdapter('posts.json') — one file per collectioncreateOPFSAdapter('posts') — a folder, laid out like @signaldb/fs
@signaldb/generic-fsone file per collectiona 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:

ts
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:

ts
/**
 * 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.

Standard setup with DefaultDataAdapter (recommended baseline):

ts
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 deprecated persistence option still works and is wrapped by DefaultDataAdapter.
  • new Collection(name, dataAdapter, options?) (recommended): pass a collection name and a DataAdapter instance explicitly.

Example migrating from v1: Before (v1):

ts
const Posts = new Collection({
  name: 'posts',
  persistence: /* old adapter */
})

After (v2):

ts
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 eventv2 replacement
persistence.initawait 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.transmittedAwait the write — with the DefaultDataAdapter it resolves once the storage adapter has written it. collection.isPushing() reflects running writes reactively.
persistence.errorA 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.receivedNo 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:

ts
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:

js
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 await the new async methods and handle new return values.
  • Replace index providers with indices: string[].
  • Rename persistence APIs to storage (createStorageAdapter, StorageAdapter), remove combinePersistenceAdapters.
  • Replace AutoFetchCollection with AutoFetchDataAdapter.
  • Switch await collection.isReady() to await collection.ready() and use reactive collection.isReady() where needed.
  • Check any code that used collection.isLoading() to wait for the initial load; use ready() / isReady() or cursor.isLoading() instead.
  • Replace persistence events as described in Event Changes — ready(), cursor.isLoading(), awaited writes and the data adapter's onError — and consider listening for query.error.
  • Migrate the data your v1 storage adapters wrote; v2 does not read it (see Storage adapters: your existing data).
  • Remove createMemoryAdapter and the memory option; implement an in-memory StorageAdapter if necessary.
  • Remove calls to resetData().
  • Replace the SyncManager's persistenceAdapter option with dataAdapter.
  • In a custom StorageAdapter, key readIndex by serializeValue(value).
  • Review anything counting movedBefore events or relying on changed for 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.

Released under the MIT License.