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. With no policy the latest write wins, ordered by a hybrid logical clock so every peer agrees, except for arrays (see below). You attach a different policy per path pattern:
lww: the latest write wins, whole. Set it on an array to keep that array out of the positional merge.mergeThree: a three-way merge against the value the other write replaced, so concurrent edits to different fields of one object both land.keyedArray(idFn): thekeyedArraymerge policy reconciles a list by item identity, so concurrent edits to different items survive and an edit beats a concurrent removal. Items that share an identity correspond by value: one equal to an item in the base is that item, the rest pair in order, and two writers duplicating the same item give two copies, not three.preserve: keep both sides of a clash as aConflictedvalue 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. The merges that look for an unchanged side (mergeThree, the positional default, the keyedArray merge policy) decide it by value: a side equal to what it replaced made no change, even as a fresh copy. lww and preserve do not look for one.
import { keyedArray, mergeThree, preserve } from '@mmstack/primitives';
const policies = [
{ path: 'todos', merge: keyedArray((t) => t.id) },
{ path: 'settings', merge: mergeThree },
{ path: 'title', merge: preserve },
]; An array travels as one value, and with no policy two concurrent writes of it merge position by position: when both kept the length and neither moved a value, each slot takes the side that changed it, so base [0, 0] with [1, 0] and [0, 2] becomes [1, 2] everywhere. A length change or a moved value keeps the latest array whole. The merge does not know identities: a reorder that also edits every moved element reads as edits in place, so a concurrent edit lands on whatever now sits in its slot, and replacing an element while someone edits it merges the two field by field. For lists like that, use the keyedArray merge policy or a keyed container.
This changed the fold, so 22.23 / 21.23 speak protocol 4 and refuse protocol 3 peers. An unversioned, protocol 3 or protocol 4 checkpoint loads and is re-derived from its registers (any other version throws); an offline outbox from before the upgrade is dropped, not replayed.
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 value the second one replaced (not a 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. A position is an index or a neighbour, see below.rebalanceContainer: even out positions after many inserts into the same spot. Rarely needed; a dev warning tells you when.
Reach for this over the keyedArray merge policy when order matters or a list is edited by several people at once. The policy reconciles a whole array as one value after the fact; a keyed container is the identity-on-the-wire form, tracking 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
moveElement(tasks, 't2', { after: 't1' }); // next to a neighbour, as the list stands now
insertElement(tasks, 't3', { title: 'Ship' }, { before: 't2' }); // same for an insert
const pos = moveElement(tasks, 't3', { after: 'gone' }); // no such neighbour
pos === undefined; // nothing was written
for (const { key, value } of orderedEntries(tasks())) { // reading order
render(key, value);
} An insert or a move can name its place by a neighbour instead of an index. The Anchor type has three forms: { after: key }, { before: key }, and { empty: true } for a container with nothing else in it. insertElement, moveElement, and the insert and move of the keyedContainer and wrappedContainer helpers all accept one wherever they take an index.
The anchor is resolved when the write is applied, against the order the container has then. A drop decided as "after t7" still lands right after t7 when someone inserted elsewhere in the meantime, where an index would land in the slot it named, whatever sits there now. That makes an anchor the right thing to keep for a drop from another list or a palette, as much as for a local reorder. Resolving one is a single pass over the container, with no sort.
If the anchor names no other element present, an absent key, the moved key itself, or empty while siblings remain, nothing is written and the call returns undefined. The empty misuse also logs in dev mode. Check the return value when the neighbour may have gone.
An anchor is local intent, not a shared guarantee. Two peers that apply the same anchor against different states can place the element differently, exactly as they would with an index; the position writes that result still converge. And when two neighbours hold the same position, after concurrent inserts into one gap, there is no room between them, so a move after the first can land after both. An index has the same limit.
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. Both diff arrays as units, so array policies and the positional default apply to them as in the room.
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. So the committed value can differ from the preview: a fork showing [1, 0] can commit to [1, 2] next to a peer's [0, 2]. Call rebase() to re-observe the room before committing on top. See agents for the full pattern.
Undo in a synced store
#storeHistory(store, { track: sync, policies }) makes only your own writes undoable, and undo is selective: it takes back what your change did and keeps what others wrote since. In the array above, undoing your [1, 0] after the merge gives [0, 2]. Pass the same policies as the sync so each path undoes the way it merges. If others moved elements since your change, your previous array comes back whole and their move is lost, and an undo of your own reorder is identity-blind; keyed lists avoid both.
With coalesce, a run of your changes is one entry: one write from the run's first value to its last, and its net is what differs between them. A peer's edit elsewhere never enters it; a peer's edit to a path you touch next ends the run.