Yjs CRDT document sync with state vectors, relay and undo: working versions (tested 2026-10-15)
Sync two Yjs docs by exchanging state-vector diffs, converge concurrent text and map edits, relay live updates like a provider, undo, and merge updates out of order.
passed5 passed · 0 failed2026-10-15 20:11 UTC- Status
- passed
- Last passed
- 2026-10-15 20:11 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions3
yjs@13.6.33@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38799498972
The code
Exactly the files that passed. Your agent can pull them with get_recipe("yjs-crdt-doc-sync") (install), or see them on GitHub.
package.json
{
"name": "yjs-crdt-doc-sync",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"yjs": "13.6.33"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
src/sync.ts
import * as Y from "yjs";
/** Exchange only the missing updates between two docs using state vectors (the y-protocols sync step 1/2). */
export function syncDocs(a: Y.Doc, b: Y.Doc) {
const aMissing = Y.encodeStateAsUpdate(b, Y.encodeStateVector(a));
const bMissing = Y.encodeStateAsUpdate(a, Y.encodeStateVector(b));
Y.applyUpdate(a, aMissing, "remote");
Y.applyUpdate(b, bMissing, "remote");
return { aBytes: aMissing.byteLength, bBytes: bMissing.byteLength };
}
export function newDoc(clientID: number) {
const d = new Y.Doc();
d.clientID = clientID; // fixed ids make conflict resolution deterministic in tests
return d;
}
/** Wire a doc pair so each local update is relayed to the other, like a websocket provider would. */
export function connect(a: Y.Doc, b: Y.Doc) {
const relay = (to: Y.Doc) => (update: Uint8Array, origin: unknown) => {
if (origin !== "remote") Y.applyUpdate(to, update, "remote");
};
const ha = relay(b), hb = relay(a);
a.on("update", ha);
b.on("update", hb);
return () => { a.off("update", ha); b.off("update", hb); };
}
test/sync.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import * as Y from "yjs";
import { syncDocs, newDoc, connect } from "../src/sync.ts";
test("concurrent text edits merge identically on both peers", () => {
const a = newDoc(1), b = newDoc(2);
a.getText("t").insert(0, "hello");
syncDocs(a, b);
a.getText("t").insert(5, " world");
b.getText("t").insert(0, ">> ");
syncDocs(a, b);
assert.equal(a.getText("t").toString(), ">> hello world");
assert.equal(b.getText("t").toString(), a.getText("t").toString());
});
test("concurrent map writes converge to the same winner", () => {
const a = newDoc(1), b = newDoc(2);
a.getMap("m").set("status", "draft-a");
b.getMap("m").set("status", "draft-b");
syncDocs(a, b);
const va = a.getMap("m").get("status"), vb = b.getMap("m").get("status");
assert.equal(va, vb);
assert.ok(va === "draft-a" || va === "draft-b");
});
test("state-vector sync sends only the diff", () => {
const a = newDoc(1), b = newDoc(2);
a.getArray<number>("arr").push(Array.from({ length: 500 }, (_, i) => i));
syncDocs(a, b);
a.getArray<number>("arr").push([999]);
const { bBytes, aBytes } = syncDocs(a, b);
assert.ok(bBytes < 50, `diff should be tiny, got ${bBytes}`);
assert.ok(aBytes <= 2);
assert.equal(b.getArray<number>("arr").length, 501);
});
test("live relay plus undo manager", () => {
const a = newDoc(1), b = newDoc(2);
const stop = connect(a, b);
const undo = new Y.UndoManager(a.getText("t"));
a.getText("t").insert(0, "abc");
assert.equal(b.getText("t").toString(), "abc");
undo.undo();
assert.equal(b.getText("t").toString(), "");
stop();
a.getText("t").insert(0, "x");
assert.equal(b.getText("t").toString(), "");
});
test("updates can be merged and replayed in any order", () => {
const a = newDoc(1);
const updates: Uint8Array[] = [];
a.on("update", (u: Uint8Array) => updates.push(u));
a.getText("t").insert(0, "one ");
a.getText("t").insert(4, "two");
const c = newDoc(3);
Y.applyUpdate(c, Y.mergeUpdates(updates.reverse()));
assert.equal(c.getText("t").toString(), "one two");
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.