Yjs CRDT patterns, shared types (Y.Map, Y.Array, Y.Text), transactions, y-protocols sync and awareness, y-indexeddb persistence, conflict resolution, and document storage. Use when mentioning Yjs, Y.Doc, CRDTs, collaborative editing, real-time sync, awareness, IndexeddbPersistence, or Yjs providers.
68
82%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
When conflict semantics, transaction origins, shared-type behavior, update encoding, storage growth, or shared-type APIs affect correctness, use source-backed grounding before relying on memory. If DeepWiki MCP is available, ask a narrow question against yjs/yjs; for sync, awareness, auth protocol helpers, or provider interoperability, ask against yjs/y-protocols; for browser persistence behavior, ask against yjs/y-indexeddb. If DeepWiki is unavailable or the repo is not indexed, use upstream source or official docs directly. Treat DeepWiki as orientation, then verify decisive details against local installed types, source, or official docs before changing code.
Skip DeepWiki for stable basics and repo-local patterns already documented below.
Related Skills: See
workspace-apifor the workspace abstraction built on Yjs.
Y.encodeStateVector(doc) to describe local clocks, then Y.encodeStateAsUpdate(doc, remoteStateVector) to send only missing updates.doc.transact(() => { ... }, origin). This reduces observer churn and gives persistence, providers, and undo logic a useful origin.Y.UndoManager to concrete shared types. Set trackedOrigins, tune captureTimeout, and call stopCapturing() between logically separate commands.Y.snapshot() is a historical marker that depends on retained delete history. Y.encodeStateAsUpdate(doc) is the self-contained checkpoint format.SyncStep1, SyncStep2, and incremental Update messages. Providers should implement that flow rather than exchanging full documents by default.null so peers do not wait for timeout.IndexeddbPersistence(name, doc) starts local loading immediately. Wait for upstream whenSynced, or Epicenter's wrapper whenLoaded, before assuming local state is hydrated.whenSynced means local IndexedDB load complete. It does not mean remote network convergence._storeUpdate; it is an internal listener installed by the provider.destroy() stops persistence and closes the DB connection. clearData() destroys persistence and deletes local stored data.clearData().set, get, and del only for local provider metadata. Collaborative state belongs in shared Yjs types.Yjs provides six shared types. You'll mostly use three:
Y.Map - Key-value pairs (like JavaScript Map)Y.Array - Ordered lists (like JavaScript Array)Y.Text - Rich text with formattingThe other three (Y.XmlElement, Y.XmlFragment, Y.XmlText) are for rich text editor integrations.
Every Y.Doc gets a random clientID on creation. Raw Yjs conflict ordering can
use this id, so concurrent writes to the same raw map key are not "latest
timestamp wins" unless the data structure adds its own timestamp policy.
const doc = new Y.Doc();
console.log(doc.clientID); // Random number like 1090160253From dmonad (Yjs creator):
"The 'winner' is decided by
ydoc.clientIDof the document (which is a generated number). The higher clientID wins."Source: GitHub issue #520
The actual comparison in source (updates.js#L357):
return dec2.curr.id.client - dec1.curr.id.client; // Higher clientID winsThis is deterministic (all clients converge to the same state) but not
intuitive: a later edit can lose. Epicenter's table, KV, and record surfaces do
not expose this raw policy directly; they sit on YKeyValueLww, which adds a
timestamped last-write-wins layer for keyed rows.
Once you add a shared type to a document, it can never be moved. "Moving" an item in an array is actually delete + insert. Yjs doesn't know these operations are related.
Problem: Multiple writers updating the same key causes lost writes.
// BAD: Both clients read 5, both write 6, one click lost
function increment(ymap) {
const count = ymap.get('count') || 0;
ymap.set('count', count + 1);
}Solution: Partition by clientID. Each writer owns their key.
// GOOD: Each client writes to their own key
function increment(ymap) {
const key = ymap.doc.clientID;
const count = ymap.get(key) || 0;
ymap.set(key, count + 1);
}
function getCount(ymap) {
let sum = 0;
for (const value of ymap.values()) {
sum += value;
}
return sum;
}Problem: Drag-and-drop reordering with delete+insert causes duplicates and lost updates.
// BAD: "Move" = delete + insert = broken
function move(yarray, from, to) {
const [item] = yarray.delete(from, 1);
yarray.insert(to, [item]);
}Solution: Add an index property. Sort by index. Reordering = updating a property.
// GOOD: Reorder by changing index property
function move(yarray, from, to) {
const sorted = [...yarray].sort((a, b) => a.get('index') - b.get('index'));
const item = sorted[from];
const earlier = from > to;
const before = sorted[earlier ? to - 1 : to];
const after = sorted[earlier ? to : to + 1];
const start = before?.get('index') ?? 0;
const end = after?.get('index') ?? 1;
// Add randomness to prevent collisions
const index = (end - start) * (Math.random() + Number.MIN_VALUE) + start;
item.set('index', index);
}Problem: Storing entire objects under one key means any property change conflicts with any other.
// BAD: Alice changes nullable, Bob changes default, one loses
schema.set('title', {
type: 'text',
nullable: true,
default: 'Untitled',
});Solution: Use nested Y.Maps so each property is a separate key.
// GOOD: Each property is independent
const titleSchema = schema.get('title'); // Y.Map
titleSchema.set('type', 'text');
titleSchema.set('nullable', true);
titleSchema.set('default', 'Untitled');
// Alice and Bob edit different keys = no conflictY.Map tombstones retain the key forever. Every ymap.set(key, value) creates a new internal item and tombstones the previous one.
In Epicenter, do not reach for upstream y-utility from app code. The workspace
package owns keyed table, KV, and record storage through internal
YKeyValueLww, a timestamped last-write-wins store over a Y.Array.
// App code should usually stay at this level.
workspace.tables.notes.set(note);
workspace.kv.set('theme.mode', 'dark');
using messages = workspace.tables.conversations.docs.messages.open(id);
messages.set(message.id, message);Use raw Y.Map for bounded, rarely changing structures inside a private
attachment. Use workspace tables, KV, or attachRecords for keyed app data.
Only edit YKeyValueLww itself when you are working inside
packages/workspace/src/document/y-keyvalue/; ground that work in the local
tests and benchmarks.
If your architecture uses versioned snapshots, you get free compaction:
// Compact a Y.Doc by re-encoding current state
const snapshot = Y.encodeStateAsUpdate(doc);
const freshDoc = new Y.Doc({ guid: doc.guid });
Y.applyUpdate(freshDoc, snapshot);
// freshDoc has same content, no history overheadIt doesn't. Raw Yjs conflict ordering can use clientID, not wall-clock time.
Design around this, use single-writer keys, or use an Epicenter surface that
already owns timestamped LWW semantics (YKeyValueLww through tables, KV, or
records).
Array position is for append-only data (logs, chat). User-reorderable lists need fractional indexing.
Y types must be added to a document before use:
// BAD: Orphan Y.Map
const orphan = new Y.Map();
orphan.set('key', 'value'); // Works but doesn't sync
// GOOD: Attached to document
const attached = doc.getMap('myMap');
attached.set('key', 'value'); // Syncs to peersY types store JSON-serializable data. No functions, no class instances, no circular references.
// This creates a NEW item, not a moved item
yarray.delete(0);
yarray.push([sameItem]); // Different Y.Map instance internallyAny concurrent edits to the "moved" item are lost because you deleted the original.
Y.js shared types (Y.Map, Y.Text, Y.XmlFragment, Y.Array) are implementation details that should stay behind typed APIs. When consumer code reaches through an abstraction to manipulate raw shared types, it creates coupling that's hard to change later.
The pattern: If a module returns Y.js shared types for editor binding (e.g., handle.asText() returns Y.Text), that's intentional: the consumer needs the live CRDT reference. But if consumer code is constructing, casting, or mutating Y.js types that the owning module should encapsulate, that's a leak.
// BAD: consumer reaches through handle to do raw Y.Text mutation
const entry = handle.currentEntry;
if (entry?.type === 'text') {
handle.batch(() => entry.content.insert(entry.content.length, text));
}
// GOOD: timeline owns the append operation
handle.append(text);// BAD: consumer constructs Y.Maps to call an internal CSV helper
import { parseSheetFromCsv } from '@epicenter/workspace';
const columns = new Y.Map<Y.Map<string>>();
const rows = new Y.Map<Y.Map<string>>();
parseSheetFromCsv(csv, columns, rows);
// GOOD: use the handle's write method, which encapsulates CSV parsing
handle.write(csv); // mode-aware, handles sheet internallyThese are code smell indicators that Y.js internals are leaking:
as Y.Map, as Y.Text, as Y.XmlFragment outside the owning module means someone is working with untyped data and forcing it into shape. The typed API is incomplete.if (entry.type === 'text') ... else if (entry.type === 'sheet') in consumer code means the consumer knows about internal content modes that the abstraction should handle.handle.batch(() => ytext.insert(...)) means the consumer is doing CRDT operations that should be a method on the handle.Y.Map<Y.Map<string>> parameters on a public API force consumers to have raw Y.js references to call them.ydoc.getArray()/ydoc.getMap() outside infrastructure: Consumer code accessing the raw Y.Doc to read/write data bypasses the table/kv/timeline APIs.Three layers, each with clear Y.js exposure:
┌──────────────────────────────────────────────────────┐
│ Consumer Code (apps, features) │
│ • Uses handle.read(), handle.write(), tables.*.set()│
│ • MAY bind to Y.Text/Y.XmlFragment from as*() │
│ • NEVER constructs Y.js types │
│ • NEVER casts to Y.js types │
│ • NEVER calls .insert()/.delete() on raw types │
├──────────────────────────────────────────────────────┤
│ Format Bridges (markdown, sheet converters) │
│ • Accepts Y.js types as parameters (they're bridges)│
│ • Converts between Y.js ↔ string/JSON │
│ • Lives close to the owning module │
├──────────────────────────────────────────────────────┤
│ Timeline / Table / KV Internals │
│ • Constructs and manages Y.js shared types │
│ • Owns the Y.Doc layout (array keys, map structure) │
│ • Exposes typed APIs that hide the CRDT details │
└──────────────────────────────────────────────────────┘When reviewing code, ask: "Could this consumer do its job with only the typed API?" If yes and it's using raw Y.js types instead, that's a leak worth fixing.
See the article docs/articles/yjs-abstraction-leaks-cost-more-than-the-abstraction.md for the full pattern with real examples.
console.log(doc.toJSON()); // Full document as plain JSON// See who would win a conflict
console.log('My ID:', doc.clientID);If documents grow unexpectedly, check for:
gc: falsepackages/workspace/src/document/y-keyvalue/y-keyvalue-lww.ts - Epicenter's timestamped keyed store for tables, KV, and recordspackages/workspace/src/document/attach-indexed-db.ts - Epicenter's wrapper around y-indexeddbcb12bcc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.