Free SKILL.md scraped from GitHub. Clone the repo or copy the file directly into your Claude Code skills directory.
npx versuz@latest install cdn37421-typeless-custom-agents-skills-yjsgit clone https://github.com/cdn37421/typeless-custom.gitcp typeless-custom/SKILL.MD ~/.claude/skills/cdn37421-typeless-custom-agents-skills-yjs/SKILL.md---
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
## Upstream Grounding
When conflict semantics, transaction origins, shared-type behavior, update encoding, storage growth, or provider interoperability affects correctness, ask DeepWiki a narrow question against `yjs/yjs` before relying on memory. Use it to orient, 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-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