Skip to content
Homeostate

Core

Sync engine API

Signatures, options, lifecycle methods and adapter contracts for createSyncEngine and defaultSyncFilter.

@homeostate/core@0.3.0npmSource

Import these APIs and types from @homeostate/core. For an app you can run, start with Getting started; for the architecture, read Concepts.

createSyncEngine

declare function createSyncEngine<S extends object>(
  backend: CrdtBackend,
  adapter: StoreAdapter<S>,
  config?: SyncEngineConfig,
): SyncEngine;

Creates a disconnected engine. Call connect() to reconcile the current state and start watching both sides. Creation alone does not read, write or subscribe to either side.

ParameterRequiredDescription
backendYesThe replicated state, exposed through CrdtBackend.
adapterYesThe application store, exposed through StoreAdapter<S>.
configNoSync options. Defaults to {}.

Returns a SyncEngine. The engine is synchronous: adapter, backend and filter errors propagate to the caller of the operation that triggered them, which is whoever calls flush for an apply deferred by schedule. There is no engine-level onError option or retry mechanism.

Example

This example uses the Zustand adapter and an in-memory backend to show both directions without a server:

sync-engine-example.ts
import { createStore } from "zustand/vanilla";
import { createSyncEngine, defaultSyncFilter } from "@homeostate/core";
import { createMemoryBackend } from "@homeostate/core/testing";
import { createZustandAdapter } from "@homeostate/store-zustand";

const store = createStore(() => ({ count: 0, draft: "Only on this device" }));
const backend = createMemoryBackend({ count: 7 });
const engine = createSyncEngine(backend, createZustandAdapter(store), {
  filter: (key, value) => defaultSyncFilter(key, value) && key !== "draft",
  seed: "if-empty",
});

engine.connect();
console.log(store.getState().count); // 7: the existing backend value wins

store.setState({ count: 8 });
console.log(backend.read()); // { count: 8 }

backend.receive({ count: 9 });
console.log(store.getState()); // { count: 9, draft: "Only on this device" }

engine.disconnect();
console.log(engine.isConnected()); // false

SyncEngineConfig

interface SyncEngineConfig {
  filter?: (key: string, value: unknown) => boolean;
  seed?: SeedStrategy;
  schedule?: (flush: () => void) => void;
}

type SeedStrategy = "if-empty" | "never";
OptionDefaultBehavior
filterdefaultSyncFilterSelects top-level keys independently in the store and backend, in both directions. Excluded keys stay local.
seed"if-empty"On connection, writes synced keys that exist only in the store into the backend. "never" skips this initial write.
schedulenoneDefers remote changes to the flush it schedules, then applies them with one read and one setState. See below.

A custom filter replaces the default. Call defaultSyncFilter within it if you also want to exclude functions. Filtering is top-level; it does not remove nested functions or turn non-JSON values into JSON. Use plain JSON for the synced data.

Despite its name, "if-empty" works per key, even when the backend already holds other keys. With either strategy, a backend key wins over the corresponding store value, and a store-only key remains in the store during connection. With "never", the next local change still writes the store's entire filtered state, including those store-only keys.

Coalescing remote changes

Without schedule, each backend notification is applied to the store at once, so a provider that applies 50 queued updates as 50 transactions causes 50 reads and 50 setState calls. With schedule, the first notification hands it a flush and later ones wait for it, so the whole burst is applied with one read and one setState:

createSyncEngine(backend, adapter, {
  schedule: (flush) => queueMicrotask(flush),
});

queueMicrotask coalesces what arrives in one task; requestAnimationFrame applies at most once per frame. Until the flush, getState() does not show the pending remote changes.

Local changes are still written synchronously. One made while an apply is pending runs that apply first, and the remote changes win: what the local change did to synced keys is lost, and only its changes to keys the filter excludes stay. With queueMicrotask that only affects code that changes the store in the same task as a remote update; a longer delay, such as requestAnimationFrame, lets user input fall in the window too. disconnect() also runs a pending apply, and a flush called after it does nothing.

SyncEngine

interface SyncEngine {
  connect: () => void;
  disconnect: () => void;
  isConnected: () => boolean;
}
MethodReturnsBehavior
connect()voidReconciles current state per key, then subscribes to backend and store changes. Calling it again while connected does nothing.
disconnect()voidApplies a pending remote change, then removes both subscriptions. Calling it while disconnected does nothing. It does not destroy the store, document, provider or persistence.
isConnected()booleanWhether the engine is subscribed. This does not report network connectivity or whether a provider has received the room's state.

After connection, each local notification writes the full filtered store state to the backend, along with what the backend holds, so the backend can diff against it instead of reading its document; see CrdtBackend. Each backend notification, or each flush with schedule, applies the backend's full filtered state to the store, including deletions. Unchanged subtrees keep their identity. Store notifications raised synchronously while the engine applies remote state are ignored to avoid echoing that state back.

Reconnecting runs reconciliation again: backend values replace disconnected local edits for matching keys. For offline editing, leave the engine connected and disconnect the network provider instead. See Connecting.

defaultSyncFilter

declare const defaultSyncFilter: (key: string, value: unknown) => boolean;

Returns typeof value !== "function"; the key does not affect the result. This keeps top-level actions out of shared state. It is not a JSON validator: values such as Date, Map and Set are outside the supported synced-state model.

StoreAdapter

interface StoreAdapter<S extends object> {
  getState: () => S;
  setState: (state: S) => void;
  subscribe: (onStoreChange: () => void) => Unsubscribe;
}
MemberContract
getState()Returns the current store state.
setState(state)Replaces the state with the engine's reconciled state. Called for backend changes and, when needed, during connection.
subscribe(callback)Calls the callback when the store changes; returns a function that removes that subscription.

Use a store-* package for your state manager, or implement this interface for your own store. S may include local actions, but the part selected for sync must be plain JSON.

Update the synced state immutably, with a new object for every container that changes. The engine diffs each write against the state it last wrote, and a container that is the same object in both is taken as unchanged, so an array or object changed in place is not written. Adapters whose getState() returns a fresh copy, such as the MobX adapter, meet this anyway.

CrdtBackend

interface CrdtBackend {
  read: () => unknown;
  write: (next: unknown, previous?: unknown) => void;
  subscribe: (onRemoteChange: () => void) => Unsubscribe;
}
MemberContract
read()Returns a plain JSON snapshot that does not alias mutable backend internals. For the sync engine, expose the synced top-level state as an object.
write(next, previous?)Makes the synced subtree equal to next in one atomic transaction. The backend chooses how to turn that snapshot into CRDT operations.
subscribe(callback)Reports changes outside this backend's own write, including imports from peers and local edits made directly on the document. Returns an unsubscribe function.

previous, when given, is what the synced subtree holds now: the synced state the engine last wrote, or the whole state it last read after a remote change. A backend can diff next against it with applyChanges and json: true instead of reading its document; unchanged subtrees of next are then the same objects and compare by identity. Like next, it may hold undefined and functions, which json: true treats as absent. The engine passes nothing for the seed write during connect() and after a write that threw, and a backend may ignore previous.

previous is exact only when subscribe reports every other change synchronously, so a backend must read its document while a change may still be unreported: the Yjs backend does inside another transaction, and the Loro backend while other code has uncommitted edits.

The engine treats a null or non-object read() result as an empty state. Objects in the snapshot must inherit from Object.prototype: a library that builds them by assignment turns a peer's __proto__ entry into the prototype, which the Yjs and Loro backends undo before returning. Keep document replication in your CRDT provider. To write small edits instead of replacing the document, see the Diff and apply API.

Unsubscribe

type Unsubscribe = () => void;

The cleanup function returned by a subscription. The engine calls it on disconnect.

Source: sync-engine.ts and types.ts.