The client

meshSync gives you a status signal, a presence roster, per-path merge policies, automatic reconnection with rebase, and a peer-to-peer variant. The store stays a plain store the whole time.

@mmstack/meshnpm

#Status and registration

mesh.status() is 'connecting' | 'live' | 'reconnecting' | 'ejected'. Reconnection is automatic with exponential backoff. On reconnect the client resumes from a delta when it can, and re-applies any writes made while offline on top of whatever the room moved to. A relay restart is detected through a room epoch, so a stale sequence number never corrupts state. With register: 'track' the store also joins the nearest transition scope, so a reconnect shows up as pending to a boundary.

const mesh = meshSync(board, {
  room: 'board-42',
  writer: currentUserId,
  transport: webSocketTransport('wss://sync.example.com'),
  register: 'track', // a reconnect surfaces as pending to a <mm-suspense> boundary
});

mesh.status(); // 'connecting' | 'live' | 'reconnecting' | 'ejected'

#Conflict policies

The default is last-writer-wins, decided by a hybrid logical clock. Attach a merge policy per path for anything else: reconcile a list by item identity with keyedArray, or keep both sides of a clash as data with preserve. A custom merge can also wrap another CRDT such as Yjs for rich text. The package README has the pattern.

import { keyedArray, preserve } from '@mmstack/primitives';

meshSync(board, {
  room, writer, transport,
  policies: [
    { path: 'todos', merge: keyedArray((t) => t.id) },
    { path: 'title', merge: preserve },
  ],
});

#Offline and durable outbox

Writes made while disconnected are held locally and sent on reconnect. That queue lives in memory by default, so a reload loses any write the room never acknowledged. Pass outbox to persist it to any AsyncStore, the same interface persist takes. On boot the client restores the queue, adopts the origin it used before, and resends the unacknowledged writes, which then rebase onto whatever the room moved to.

One origin is driven by one tab at a time. crossTab: 'queue' (the default) takes a Web Lock on the key, so a second tab on the same key waits with status() reading 'connecting' until the first tab closes. Use 'off' to coordinate ownership yourself.

The outbox persists your unacknowledged writes, not a full snapshot. For a meshed store, use it in place of wrapping the store in persist. The two race on boot, and the outbox is the one that rebases offline edits onto the room.

import * as idbKeyval from 'idb-keyval';

meshSync(board, {
  room, writer, transport,
  outbox: { key: 'board-42', store: idbKeyval }, // survives a reload
});

#Assemble a base before connecting

Pass whenReady to hold the connection until a local base is in place. meshSync awaits it before it connects and before it restores the outbox, so a store filled from another source is ready when the room welcome arrives and rebases your pending writes on top. This is the boot order for a worker-owned, meshed, persisted graph: the worker hydrates the base, the outbox restores this device's offline writes, then the room welcome supersedes the base and rebases those writes. Each source runs in turn instead of racing.

meshSync(graph, {
  room, writer, transport,
  outbox: { key: 'graph-7', store: idbKeyval },
  whenReady: () => baseReady, // resolves once the base is filled
});

#Multiple tabs

Run tabSync and meshSync on the same store to share it across a user's tabs while one connection carries it to the room. The outbox lock elects the leader, so only one tab holds the relay connection and the others share state over tabSync. A write in any tab reaches the room through the leader, and a room write reaches every tab through tabSync. When the leader closes, another tab takes over and adopts the persisted origin. Each layer is a separate reader on the store's op stream, so a follower's meshSync stays idle until it holds the lock and never opens a second connection.

import { store, tabSync } from '@mmstack/primitives';
import { meshSync, webSocketTransport } from '@mmstack/mesh';

const board = store<Board>(initialBoard());
tabSync(board, { id: 'board-42' }); // share across this user's tabs
meshSync(board, {
  room: 'board-42',
  writer: currentUserId,
  transport: webSocketTransport('wss://sync.example.com'),
  outbox: { key: 'board-42', store: idbKeyval }, // one tab leads the connection
});

#Presence

An ephemeral side channel. Never persisted, never conflicts, drops when a peer leaves. Shape the payload however you like: cursors, selection, who is here, or an agent's current activity.

mesh.setPresence({ cursor: [x, y], section: 'pricing' });

const others = mesh.peers(); // [{ writer, origin, data }, ...]

#Trust

Pass a policy (and a ctx when it reads claims) and the client validates each write before it hits the wire, matching the relay's own check. An honest client never emits an op the relay would reject, so the tripwire only fires on a broken or hostile peer, and an agent can be given a narrower write surface than a human.

meshSync(store, {
  room, writer, transport,
  policy: myOpPolicy,
  ctx: { kind: 'human', claims: { role: 'editor' } },
});

#Agents

An agent acts under the same protocol as a person. mesh.fork() gives it a branch of the synced store and its writes stay off the room until a person approves: ops() is the staged change as data, commit() emits it to the room, and discard() drops it. The commit cites what the fork observed, so an edit that lands mid-review stays a concurrent value the merge policy decides rather than being overwritten by the approval.

const proposal = mesh.fork(); // the agent's branch, off the room
runAgent(proposal.store);      // it writes here

proposal.ops();      // StoreOp[] for the reviewer to see
proposal.commit();   // approve: emits as concurrent writes; a mid-review room edit is never steamrolled
// proposal.rebase();  // re-observe the room, then commit on top
// proposal.discard(); // reject: drops the staged writes

An agent can also join the room directly, scoped by the relay ACL through its ctx and policy. A live agent inherits the same conflict rules as everyone else, so a fast agent can win a last-writer-wins race on a shared field. Reach for the branch when a write should be seen before it lands.

meshSync(board, {
  room, writer: agentId, transport,
  ctx: { kind: 'agent', claims: { scope: 'pricing' } },
  policy: pricingScopeOnly,
});

See Agents for when to use each and how attribution works.

#Peer to peer

webRtcMesh runs the same convergence over WebRTC data channels, using the relay only for signaling and membership. Peers exchange watermarks when a channel opens and catch each other up pairwise. It takes an injectable connector, defaulting to an RTCPeerConnection adapter with perfect-negotiation handling built in.

import { webRtcMesh, webSocketTransport } from '@mmstack/mesh';

const mesh = webRtcMesh(store, {
  room: 'call-7',
  writer: currentUserId,
  signaling: webSocketTransport('wss://sync.example.com'), // data flows peer to peer
});