Skip to content

CRDTs

Peerborne does not implement its own CRDT. It adapts existing, well-tested CRDT libraries — Yjs and Automerge — wrapping them with encryption, signing, access control, and peer-to-peer transport. The application interacts with Yjs shared types or Automerge documents directly. Peerborne handles everything else.

The goal: merge edits from multiple peers without a central consensus service, a lock manager, or a leader election protocol. No server assigns a global write order. Two peers editing concurrently produce a deterministic merged result.

const todos = swarm.doc('/todo-list');
await todos.create();
await todos.change((state) => {
state.getArray<string>('items').push(['buy milk']);
});

Each document.change() call creates a CID-addressed stored payload and a separate wire message:

A document change checks writer access, delegates the local mutation to CRDTProvider.localChange, serializes, encrypts, and stores the returned provider-specific CRDT delta under a CID, then builds, conditionally signs, encrypts, and publishes a separate CRDTSyncMessage.
Document change lifecycle: CRDTProvider.localChange precedes storage and publication.
View diagram at full sizeFit diagram to page

Peerborne does not put graph links inside each stored change payload. Instead, the separately exchanged CRDTSyncMessage carries a shadow sync graph whose node keys are CIDs:

An encrypted CRDTSyncMessage has inline root C with sibling child references B and deferred concurrent B-prime; B references ancestor A. Separate CID-addressed Helia blocks contain encrypted changes but no graph links.
Shadow graph: B and B-prime are sibling references under C; stored blocks contain no links.
View diagram at full sizeFit diagram to page

This means:

  • Child-map keys in the wire tree reference prior or concurrent change CIDs
  • The CRDT layer resolves concurrent edits (e.g., Yjs merges Y.Map updates from two peers)
  • Stored ciphertext is immutable under its CID; later messages can reference it without rewriting it
  • The implementation tracks a tip-set (multiple concurrent heads), computing a combined tip-set hash
  • Receivers apply inline tree content and fetch only missing or deferred stored payloads
  • The shadow graph is used for synchronization, not for application data modeling

The CRDTProvider interface abstracts the CRDT library from Peerborne’s core:

interface CRDTProvider<DocType, ChangesType, ChangeFnType> {
newDocument(): DocType;
localChange(
document: DocType,
message: string,
changeFn: ChangeFnType,
): [DocType, ChangesType];
remoteChange(document: DocType, changes: ChangesType): DocType;
getHistory(document: DocType): ChangesType;
getSnapshot?(document: DocType): ChangesType;
applySnapshot?(document: DocType, snapshot: ChangesType): DocType;
}

Four required methods (newDocument, localChange, remoteChange, getHistory) and two optional snapshot methods.

Two implementations exist:

  • YjsProvider — wraps Yjs Doc, Y.encodeStateAsUpdate, Y.applyUpdate, Y.encodeStateVector, Y.diffUpdate
  • AutomergeProvider — wraps Automerge Doc, Automerge.save, Automerge.loadIncremental, Automerge.getLastLocalChange

Yjs provides shared types that merge deterministically:

// Y.Map: deterministic per-key conflict resolution
const ymap = state.getMap('settings');
ymap.set('theme', 'dark');
// Y.Array: concurrent insertions preserved
const yarray = state.getArray('items');
yarray.insert(0, ['first']);
// Y.Text: concurrent character insertions merged
const ytext = state.getText('content');
ytext.insert(0, 'Hello');

See the Yjs schema design cookbook for merge behavior tables, ID patterns, and current-schema validation.

Automerge provides a JSON-like CRDT document model:

// Automerge: concurrent field updates merged
state.title = 'New title';
// Automerge: concurrent list insertions preserved
state.items.push({ text: 'buy milk', done: false });
// Automerge: Text type for rich text
state.content = new Automerge.Text('Hello');

Peerborne does not provide:

  • Agreement-right-now. Two peers editing concurrently will see different states until they exchange updates. There is no real-time synchronization lock.
  • Conflict-free in the database sense. CRDTs resolve structural conflicts (concurrent edits to the same field), but application-level conflicts (e.g., two peers setting a title to different values) are handled by the CRDT’s merge rule, not by application logic.
  • Guaranteed delivery. Updates are published via GossipSub but not acknowledged. A peer that goes offline before publishing may lose updates.
  • Convergence under partition. If a peer is partitioned for a long time, its replica diverges. When the partition heals, the CRDT merges. But if the local blocks are lost (e.g., IndexedDB cleared), convergence may fail.

Automatic snapshots are off by default. A writer can create a full-state snapshot, signed when signing is enabled (the signature field is empty otherwise):

await document.snapshot();

A snapshot contains the complete CRDT state at that point plus boundary metadata, which is signed when signing is enabled. It is carried inside an encrypted load or snapshot-load response rather than stored as a CID-addressed Helia change payload. A peer that can authenticate and apply it can avoid replaying the entire change history.

Automatic compaction is off by default. When enabled, configured change-count thresholds call snapshot() and may prune older nodes from the in-memory shadow tree. This does not delete stored block bytes unless opt-in gcAfterPrune is also enabled. In particular:

  • In-memory pruning can limit the history included in later sync messages
  • Opt-in block GC deletes eligible local copies, so recovery then depends on another provider
  • A quorum-bound first load rejects a snapshot-only response because it lacks a prior writer set for snapshot authentication
  • Applications can call snapshot() manually even when automatic compaction is disabled

V4 initial loads require trusted signing authorities, a locally captured control/group tuple, and a fresh request challenge. The optional quorum gate requires Q distinct authenticated authorities to agree on a complete response digest covering the served graph, state, and frontier. An explicit Q is never lowered. Multiple PeerIds sharing one signing credential count once; the application must still establish independent trusted authorities. See security for the trust configuration and remaining limits.

Verified in CI:

  • Document creation, mutation, and retrieval in a single browser
  • Yjs and Automerge provider initialization
  • Encrypted existing-history retrieval through Circuit Relay
  • Cross-NAT document retrieval with Docker-backed topologies
  • Snapshot, compaction, and blockstore-GC behavior in focused suites
  • Initial-load quorum decisions and orchestration in focused suites

Not verified:

  • Multi-peer concurrent editing and convergence under partition
  • Snapshot bootstrap across real peers
  • Conflicting real peers serving adversarial shadow trees during quorum loading
  • Long-running multi-peer compaction and GC followed by recovery