Skip to content

P2P Sync ​

Document Store syncs between peers using a pull-based protocol. Your app declares which document trees to sync; the system handles discovery, incremental transfer, and progress tracking.

Setup ​

typescript
import { RpcApp, SyncProtocol } from '@wishcore/wish-sdk';
import { createStore } from 'document-store';
import { SqliteSyncProgress } from 'document-store/sync';

const store = await createStore({ storage: './data/ds.sqlite', renderHandler });
await store.storage.ready();

store.registerType('note');

const sync = new SyncProtocol({
    store,

    // Your app decides what to sync — return root hashes per identity
    getRoots: async (uid) => {
        return myApp.getRootsForUser(uid);
    },

    // Optional: persist sync progress across restarts (see "Sync progress")
    syncProgress: new SqliteSyncProgress(db),

    // Called after documents arrive from a peer
    onSync: ({ injected }) => {
        if (injected) console.log('Got new documents');
    }
});

const app = new RpcApp({
    name: 'MyApp',
    protocols: { myapp: sync }
});

How sync works ​

Sync is pull-based — the receiver decides what to fetch.

The pull flow ​

When a peer connects, the local side pulls from them:

1. Discover    — fetch documents directly shared with me (invites, grants)
2. Get roots   — ask the app what document trees to sync (getRoots callback)
3. Pull trees  — for each root, fetch changes since last sync
4. Inject      — verify and store each document locally

Both sides pull independently — A pulls from B, B pulls from A.

Incremental sync ​

The protocol tracks progress per peer per root. After the initial sync, only new changes are transferred. If a peer disconnects and reconnects, sync resumes from where it left off.

Progress is tracked using receivedAt timestamps — when documents were received locally, not when they were created. This ensures children added after a parent are never missed.

The sync cursor only advances for successfully injected items. If a document fails validation or signature verification, the cursor stays put — the document will be retried on the next sync cycle.

On local change ​

When you add or edit a document, notify peers so they pull the change:

typescript
sync.notifyPeers();

This emits a document.changed signal. Connected peers then run the pull flow to fetch what's new.

App owns the roots ​

Your app decides what to sync by returning root hashes from the getRoots callback. This is the key design decision — the sync engine doesn't scan all documents or maintain a global sync list.

Common patterns:

typescript
getRoots: async (uid) => {
    // Sync all discussions the user is in
    const discussions = await db.query(
        'SELECT hash FROM discussion WHERE uid = $uid',
        { uid }
    );
    return discussions.map(d => d.hash);
}
typescript
getRoots: async (uid) => {
    // Read from a pullRoots document (syncs between own devices)
    const pullRoots = await getPullRootsForUser(uid);
    return pullRoots?.roots || [];
}

When roots change (user joins a discussion, accepts an invite), call triggerPull to sync immediately:

typescript
sync.controller.triggerPull(uid);

Notifying a frontend ​

If your app has a WebSocket frontend, combine peer and frontend notifications:

typescript
import { createWebRpc } from '@wishcore/wish-sdk';

const webRpc = createWebRpc({ port: 8090 });

const sync = new SyncProtocol({
    store,
    getRoots: async (uid) => getMyRoots(uid),
    onSync: ({ injected }) => {
        if (injected) webRpc.emit('signals', ['document.changed']);
    }
});

const notifyChange = () => {
    webRpc.emit('signals', ['document.changed']);
    sync.notifyPeers();
};

// Use after every write
const [errors, hash] = await store.add('note', { uid, title: 'Hello' });
if (!errors) notifyChange();

What syncs ​

Everything in the change log:

  • Document creates (the original CBOR + signatures)
  • Document edits (each edit as a separate CBOR record)
  • Deletes (a $delete edit)

The rendered view is not synced — it's rebuilt locally from the change log. This means peers with different storage backends still sync correctly.

Access control ​

The server side checks share policies before sending documents:

  • share: { self: true } — syncs only to the owner's other devices
  • share: { users: { ... } } — syncs only to listed users (used by invites)
  • share: { ref: parentHash } — inherits access from parent (membership tokens)
  • share: { public: ... } — syncs to everyone

A custom shareResolver callback can extend these policies — for example, sharing identity documents with all contacts.

See Share Policies for configuration.

Sync progress ​

By default, sync progress is in-memory (lost on restart → full re-sync). For persistent progress:

typescript
import { SqliteSyncProgress } from 'document-store/sync';

const sync = new SyncProtocol({
    store,
    getRoots: async (uid) => getMyRoots(uid),
    // Inject YOUR already-open DB handle (the same rendered DB works);
    // table name configurable, default `ds_sync_progress`.
    syncProgress: new SqliteSyncProgress(db),
});

Available implementations (in document-store/sync):

ImplementationBackendUse case
MemorySyncProgressIn-memoryTests only — real apps need persistence or every restart re-walks history
SqliteSyncProgressSQLite (app-injected handle)Desktop/node apps — synchronous writes, no flush step
SurrealSyncProgressSurrealDB (app-injected query fn)Surreal-lane apps — write-back cache, flushed per sync cycle

The package carries no database driver — both persistent stores work over a connection your app already owns. Another backend is a small class: the SyncProgressStore interface is get/set/clear.

Resetting progress ​

Force a full re-sync when needed:

typescript
// Re-pull a specific tree from all peers
await sync.controller.resetForRoot(rootHash);

// Re-pull everything
await sync.controller.resetAll();

Conflict resolution ​

Document Store uses operation-based sync, not state-based. Each edit is a discrete operation (like $set: { title: 'New' }) applied in timestamp order.

When two peers edit the same document offline:

Peer A: $set: { title: 'A title' }   at T=100
Peer B: $set: { title: 'B title' }   at T=101

After sync, both peers apply edits in order: A's edit first, then B's. Result: title = 'B title' on both sides. Last write wins per field.

For array operations, edits are also applied in order. See Editing for details on operator semantics.

Protocol endpoints ​

The sync protocol registers three RPC endpoints that peers call:

EndpointPurpose
document.discoverReturns documents directly shared with the requesting peer (via share.users)
document.pullReturns tree changes for a root hash since a timestamp
document.findReturns a specific document by type + uid (point lookup)

Plus one signal:

SignalPurpose
document.changedNotifies peers that new data is available — triggers them to pull