EpicenterHQ / yjs
Install for your project team
Run this command in your project directory to install the skill for your entire team:
mkdir -p .claude/skills/yjs && curl -L -o skill.zip "https://fastmcp.me/Skills/Download/2298" && unzip -o skill.zip -d .claude/skills/yjs && rm skill.zip
Project Skills
This skill will be saved in .claude/skills/yjs/ and checked into git. All team members will have access to it automatically.
Important: Please verify the skill by reviewing its instructions before using it.
Yjs CRDT patterns, shared types, conflict resolution, and meta data structures. Use when building collaborative apps with Yjs, handling Y.Map/Y.Array/Y.Text, implementing drag-and-drop reordering, or optimizing document storage.
0 views
0 installs
Skill Content
---
name: yjs
description: Yjs CRDT patterns, shared types (Y.Map, Y.Array, Y.Text), conflict resolution, and document storage. Use when the user mentions Yjs, Y.Doc, CRDTs, collaborative editing, or when handling shared types, implementing real-time sync, or optimizing document storage.
metadata:
author: epicenter
version: '1.0'
---
# Yjs CRDT Patterns
## Reference Repositories
- [Yjs](https://github.com/yjs/yjs) — CRDT framework for shared editing and offline-first data
> **Related Skills**: See `workspace-api` for the workspace abstraction built on Yjs.
## When to Apply This Skill
Use this pattern when you need to:
- Design collaborative data models with Y.Map, Y.Array, or Y.Text.
- Handle conflict-prone updates with single-writer keys or nested maps.
- Implement drag-and-drop reordering with fractional indexing.
- Optimize Yjs storage for high-churn key-value workloads.
- Review boundaries to prevent raw Yjs type leaks into consumer code.
## Core Concepts
### Shared 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 formatting
The other three (`Y.XmlElement`, `Y.XmlFragment`, `Y.XmlText`) are for rich text editor integrations.
### Client ID
Every Y.Doc gets a random `clientID` on creation. This ID is used for conflict resolution—when two clients write to the same key simultaneously, the **higher clientID wins**, not the later timestamp.
```typescript
const doc = new Y.Doc();
console.log(doc.clientID); // Random number like 1090160253
```
From dmonad (Yjs creator):
> "The 'winner' is decided by `ydoc.clientID` of the document (which is a generated number). The higher clientID wins."
>
> — [GitHub issue #520](https://github.com/yjs/yjs/issues/520)
The actual comparison in source ([updates.js#L357](https://github.com/yjs/yjs/blob/main/src/utils/updates.js#L357)):
```javascript
return dec2.curr.id.client - dec1.curr.id.client; // Higher clientID wins
```
This is deterministic (all clients converge to same state) but not intuitive (later edits can lose).
### Shared Types Cannot Move
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.
## Critical Patterns
### 1. Single-Writer Keys (Counters, Votes, Presence)
**Problem**: Multiple writers updating the same key causes lost writes.
```typescript
// 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.
```typescript
// 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;
}
```
### 2. Fractional Indexing (Reordering)
**Problem**: Drag-and-drop reordering with delete+insert causes duplicates and lost updates.
```typescript
// 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.
```typescript
// 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);
}
```
### 3. Nested Structures for Conflict Avoidance
**Problem**: Storing entire objects under one key means any property change conflicts with any other.
```typescript
// 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.
```typescript
// 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 conflict
```
## Storage Optimization
### Y.Map vs Y.Array for Key-Value Data
`Y.Map` tombstones retain the key forever. Every `ymap.set(key, value)` creates a new internal item and tombstones the previous one.
For high-churn key-value data (frequently updated rows), consider `YKeyValue` from `yjs/y-utility`:
```typescript
// YKeyValue stores {key, val} pairs in Y.Array
// Deletions are structural, not per-key tombstones
import { YKeyValue } from 'y-utility/y-keyvalue';
const kv = new YKeyValue(yarray);
kv.set('myKey', { data: 'value' });
```
**When to use Y.Map**: Bounded keys, rarely changing values (settings, config).
**When to use YKeyValue**: Many keys, frequent updates, storage-sensitive.
### Epoch-Based Compaction
If your architecture uses versioned snapshots, you get free compaction:
```typescript
// 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 overhead
```
## Common Mistakes
### 1. Assuming "Last Write Wins" Means Timestamps
It doesn't. Higher clientID wins, not later timestamp. Design around this or add explicit timestamps with `y-lwwmap`.
### 2. Using Y.Array Position for User-Controlled Order
Array position is for append-only data (logs, chat). User-reorderable lists need fractional indexing.
### 3. Forgetting Document Integration
Y types must be added to a document before use:
```typescript
// 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 peers
```
### 4. Storing Non-Serializable Values
Y types store JSON-serializable data. No functions, no class instances, no circular references.
### 5. Expecting Moves to Preserve Identity
```typescript
// This creates a NEW item, not a moved item
yarray.delete(0);
yarray.push([sameItem]); // Different Y.Map instance internally
```
Any concurrent edits to the "moved" item are lost because you deleted the original.
### 6. Working with Raw Y.js Types Outside Their Owning Module
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.
```typescript
// 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);
```
```typescript
// 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 internally
```
### How to Spot Abstraction Leaks
These are code smell indicators that Y.js internals are leaking:
- **Type assertions**: `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.
- **Mode branching**: `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.
- **Raw mutations in batch callbacks**: `handle.batch(() => ytext.insert(...))` means the consumer is doing CRDT operations that should be a method on the handle.
- **Internal helper re-exports**: Functions that take `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.
### The Boundary Rule
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.
## Debugging Tips
### Inspect Document State
```typescript
console.log(doc.toJSON()); // Full document as plain JSON
```
### Check Client IDs
```typescript
// See who would win a conflict
console.log('My ID:', doc.clientID);
```
### Watch for Tombstone Bloat
If documents grow unexpectedly, check for:
- Frequent Y.Map key overwrites
- "Move" operations on arrays
- Missing epoch compaction
## References
- [Learn Yjs](https://learn.yjs.dev/) - Interactive tutorials
- [Yjs Documentation](https://docs.yjs.dev/) - API reference
- [Yjs INTERNALS.md](https://github.com/yjs/yjs/blob/main/INTERNALS.md) - How Yjs works internally
- [GitHub issue #520](https://github.com/yjs/yjs/issues/520) - Conflict resolution discussion with dmonad
- [yjs/y-utility](https://github.com/yjs/y-utility) - YKeyValue and helpers
- [y-lwwmap](https://github.com/rozek/y-lwwmap) - Timestamp-based LWW
- [fractional-indexing](https://github.com/rocicorp/fractional-indexing) - Production library
- [YATA paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types) - Academic foundation