Sync and convergence

The op-log turns a store into a stream of small structural operations. On top of it sit the pieces that keep two copies of a store in agreement: tab sync, a per-path merge policy, and a rebase routine. These are the same building blocks the worker and mesh packages use across a thread or a network boundary.

@mmstack/primitivesnpm

A store updates copy-on-write, so every change keeps the references it did not touch. opLog diffs that into a minimal batch of set and delete operations, each along a path. Ship the batch and apply it elsewhere and you have replicated the change without sending the whole object. Everything on this page builds on that.

#Syncing a store across tabs

tabSync has a store overload. Where the signal version mirrors a whole value, the store version ships operations, so two tabs editing different fields merge instead of clobbering each other. A tab that opens later hydrates from an existing one through a short handshake, then goes live.

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

const prefs = tabSync(store({ theme: 'dark', sidebar: true }), { id: 'prefs' });

// another tab toggling the sidebar and this tab changing the theme both survive
prefs.theme.set('light');

Pass an explicit id. For a network boundary instead of tabs, the same engine is exposed as opSync, which emits and receives envelopes over any transport you give it. That is what @mmstack/mesh wraps.

#Merge policies

When two peers change the same path at the same time, a merge policy decides the result. The default is last-writer-wins, ordered by a hybrid logical clock so every peer agrees. You attach a different policy per path pattern:

  • lww: the latest write wins. The default, invisible.
  • mergeThree: a three-way merge against the common ancestor, so concurrent edits to different fields of one object both land.
  • keyedArray(idFn): reconcile a list by item identity, so concurrent edits to different items survive and an edit beats a concurrent removal.
  • preserve: keep both sides of a clash as a Conflicted value instead of dropping one. Resolution is just a later write.

Last-writer-wins drops one side of a true clash on the same field. Set preserve on the paths where losing a write matters.

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

const policies = [
  { path: 'todos', merge: keyedArray((t) => t.id) },
  { path: 'settings', merge: mergeThree },
  { path: 'title', merge: preserve },
];

#Conflicts as data

preserve exists for the cases where silently dropping a value is wrong, a clinical note edited by two people at once, say. The clashing leaf becomes a Conflicted holding every side that clashed in siblings, with mine and theirs naming the first two and ancestor the common base. Three or more people editing the same field at once all survive, not just two. Sync never blocks on it, and you resolve it when and how you choose. isConflicted(value) narrows the type.

import { isConflicted } from '@mmstack/primitives';

const title = store.title();
if (isConflicted(title)) {
  // title.mine, title.theirs, title.ancestor
  store.title.set(chosenValue); // resolve with a normal write
}

#Concurrent edits to a subtree

When one peer replaces a whole object and another edits a field inside it at the same time, the field edit survives the replace. Replace settings while someone changes settings.theme and the result is the new settings with their theme kept, the merge people usually expect, rather than the replace erasing the concurrent edit. The edit was never observed by the replace, so it stays a live concurrent value.

A privileged writer that needs the replace to win outright, an owner correcting a subtree, raises the write's authority so it clears the concurrent edits along with the old value. So an ordinary replace merges and an authoritative one overwrites, from the same mechanism.

#Ordered lists that merge

A keyed container is a record whose entries each carry their own position, so it reads back as an ordered list while staying a plain object under the hood. Because every element and its position are tracked on their own, two people reordering, editing, and inserting at once all keep their changes, and a single edit ships just that element rather than the whole list.

  • orderedEntries(container): the elements in reading order, each with its key, position, and value.
  • insertElement / moveElement / removeElement: add at a position, reorder, or remove an element by key.
  • rebalanceContainer: even out positions after many inserts into the same spot. Rarely needed; a dev warning tells you when.

Reach for this over keyedArray when order matters or a list is edited by several people at once. keyedArray reconciles a whole array as one value; a keyed container tracks each element, so a move and an edit to the same list merge instead of racing.

import { signal } from '@angular/core';
import { orderedEntries, insertElement, moveElement } from '@mmstack/primitives';

// a record of tasks, each carrying its own position
const tasks = signal<Record<string, Task>>({});

insertElement(tasks, 't1', { title: 'Draft' });          // append
insertElement(tasks, 't2', { title: 'Review' });
moveElement(tasks, 't2', 0);                             // to the front

for (const { key, value } of orderedEntries(tasks())) {  // reading order
  render(key, value);
}

#Rebase and forks

rebaseOps is the routine that reconciles local pending changes against a change that arrived first: invert the pending ops, apply the remote batch, then re-apply the pending on top through the merge policies. It is pure, and it is what an optimistic update or an offline queue needs.

A fork is the same idea across time rather than space. fork.ops() exposes the staged delta as operations, and policyStrategy(policies) gives a fork the same per-path resolution, so a fork can hold a Conflicted value when the base moves underneath it instead of clobbering an edit.

import { rebaseOps, forkStore, policyStrategy, preserve } from '@mmstack/primitives';

// optimistic / offline: reconcile pending local ops against the server's change
const { root, pending } = rebaseOps(localRoot, pendingBatches, remoteBatch, policies);

// a fork that preserves a clash when the base moves under it
const fork = forkStore(base, {
  strategy: policyStrategy([{ path: 'title', merge: preserve }]),
});
fork.ops(); // the staged delta, as structural operations

A fork is also how you review changes before they reach a synced room: the writer, an AI agent for instance, works on a fork, and a person approves the commit. syncedFork wires a fork to a sync so the commit cites what the fork observed when it forked: an edit that lands on the room mid-review stays a concurrent value the merge policy decides, never silently steamrolled by the approval. Call rebase() to re-observe the room before committing on top. See agents for the full pattern.