mcpField
Y
yjs

yjs

github.com/epicenterhqDevelopment4.8k125 installsUpdated 2w ago
Repository

About yjs

A project-specific skill documenting Yjs 14 CRDT patterns for Epicenter, including document structure, synchronization, persistence, and editor bindings. It is for developers changing shared data behavior and requires grounding consequential decisions in source or official documentation.

Key features

  1. 01Yjs 14 document and row structure guidance
  2. 02State-vector and update synchronization patterns
  3. 03Transaction origins and undo management
  4. 04Store subscriptions and editor bindings
  5. 05Owner-side persistence and compaction rules
  6. 06Upstream verification for behavior-sensitive changes

Use cases

  1. 01Designing a new shared-data document
  2. 02Implementing synchronization or persistence changes
  3. 03Debugging unexpected document state or growth
  4. 04Binding an editor to collaborative content

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/epicenterhq/epicenter --skill yjs

After installing, type/yjsin Claude Code to confirm it loaded.

Bundled filesreferences/debugging.mdreferences/document-design.md

Details

From the project README

Yjs 14 CRDT Patterns

Reference Repositories

  • Yjs: CRDT framework for shared editing and offline-first data
  • Yjs Protocols: algorithmic grounding for sync and awareness

Upstream Grounding

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 and awareness algorithms, ask against yjs/y-protocols. 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 the locally pinned @y/y types and source before changing code.

Epicenter targets @y/y 14 only. Do not add yjs 13, y-indexeddb, a compatibility reader, a package alias, a dual wire, or a fallback. Existing Yjs 13 code is replacement material, not a compatibility surface.

Skip DeepWiki for stable basics and repo-local patterns already documented below.

Read references/document-design.md before choosing how a new row document is structured. Counters, user-controlled ordering, and nested shapes each have a conflict behavior that is expensive to change once data exists.

Read references/debugging.md when a document converges to unexpected state or grows faster than its content.

Related Skills: See svelte for reading store data into a component, and typebox for the schemas behind the field.* declaration helpers.

Transactions, Origins, And Undo

  • Yjs updates are commutative and idempotent. A state vector describes what a replica HAS; it does not order what it owes. A delete moves no client clock, so two replicas can hold the same vector and differ, which is why obligation in this store is a log position rather than a vector. Both the cursor and the outbox are then DERIVED from one column on the update rows, MAX(authoritySeq) and authoritySeq IS NULL, so neither can disagree with the bytes it accounts for (packages/app/evidence/data/invariants.test.ts, ADR-0298).
  • Use Y.encodeStateVector(doc) to describe local clocks, then Y.encodeStateAsUpdateV2(doc, remoteStateVector) to send only missing updates.
  • Persist and transmit bytes from the updateV2 event. Replay them with Y.applyUpdateV2(doc, update, origin).
  • Wrap multi-write user actions in doc.transact(() => { ... }, origin). This reduces observer churn and gives persistence, providers, and undo logic a useful origin.
  • Treat transaction origins as the boundary for filtering provider echoes, app-authored operations, and undo tracking.
  • Scope Y.UndoManager to concrete shared types. Set trackedOrigins, tune captureTimeout, and call stopCapturing() between logically separate commands.
  • Use relative positions for collaborative cursor and selection anchors. Raw numeric indexes drift under remote edits.
  • Y.snapshot() is a historical marker that depends on retained delete history. Y.encodeStateAsUpdateV2(doc) is the self-contained checkpoint format.
  • Prefer separate top-level docs over Yjs subdocuments unless Epicenter owns the whole provider lifecycle for the subdoc path.

Store Connection

  • Yjs is network-agnostic. It supplies CRDT state, state vectors, updates, and awareness behavior, not Epicenter's connection topology, authorization, or durability contract.
  • One socket per application, not one per open document. A replica connects to STORE_SYNC_ROUTE.pattern (/api/store/v1/sync, in packages/sync/src/store-route.ts) with a namespace naming the workspace and a cursor naming its own durably applied position, so a reconnect is a catch-up rather than a fresh start (ADR-0222).
  • Whose data it is never appears in the query. It comes from the resolved bearer, server-side, so there is no value a client can put in the URL that reaches another partition (ADR-0092).
  • Browser upgrades authenticate through exactly one bearer.<token> subprotocol entry, because a browser upgrade cannot set Authorization; the mount echoes only the main subprotocol on the 101, so the token never round-trips. Non-browser clients may use an Authorization header. Do not use cookie-only upgrades, query-string credentials, or post-accept authentication frames.
  • The wire is framing and nothing else: push, ack, refuse, entry, offer, snapshot, wanted (packages/app/src/data/sync/frames.ts). No frame knows what an update means, what a row is, or what Yjs is, which is exactly why chunking is safe at that layer.
  • Large updates are chunked at CHUNK_BYTES, set by Cloudflare's documented Durable Object SQLite value cap rather than by anything about Yjs. Do not raise it to the measured wall; the documented limit is the one Cloudflare is entitled to enforce.
  • Presence is deliberately absent until a concrete consumer earns awareness state and disconnect cleanup. If added later, awareness is ephemeral and must never be persisted into the Y.Doc or the SQLite update log as canonical data.

One document per store

Epicenter pins @y/[email protected]. A store is one Y.Doc with table roots named tables:<name> and a kv root. Each row is a nested Y.Node attribute on its table root. The row’s attributes are JSON metadata and its sole sequence child is the stable body node (ADR-0431).

The store creates both nodes together. Reads never create or repair them and require exactly one child of the correct node type. Field updates cannot replace the child; deleting the row removes its subtree. Child index zero is a storage convention, while the child has its own stable CRDT identity.

get, rows, and create return value snapshots. table.body(id) returns the live body independently of metadata conformance. body, content, and !-prefixed names are ordinary metadata; structural id remains reserved.

const notes = defineTable({
  fields: { title: field.string(), body: field.string() },
  body: plainText(),
});

const row = db.tables.notes.create({ title: 'Trip', body: 'metadata' });
const body = db.tables.notes.body(row.id);
body?.insert(0, ['The writing.']);

Keep field.* helpers, which use TypeBox internally. The current two-method body file format and artifact sequence replacement are specified in packages/app/src/data/README.md. Artifact reads expose { id, fields, body }; values become frontmatter and the codec supplies the text below it. Checkout distinguishes a field named body from a body edit by operation kind.

Only declared table/KV roots reach Doc.get, which creates on miss. Row lookups use getAttr and never mint roots. Keeping rows nested also avoids findRootTypeKey scanning one document root per row during encoding.

Editor bindings

Bind editors to the body child. Honeycrisp uses @y/[email protected]: ynodeToPmnode(node, schema) reads and pmnodeToDelta(pmNode) produces an insertion delta for node.applyDelta. The converter fills an empty top-level document from the schema without writing to Yjs. To rewrite, delete the old sequence and apply the new delta inside one store transaction; never replace the body.

Editor normalization owns body attributes. Parent metadata stays outside it. A metadata edit must neither notify body watchers nor clear editor undo history. Honeycrisp clears history for foreign body edits and preserves undo/redo origins. The Skills CodeMirror adapter writes only its supplied body but replaces the whole text on each edit; this has weaker concurrent-edit behavior than an incremental collaborative binding.

Root overrides keep RC26 and lib0 RC32 consistent through the dependency closure. The old renderer and internal-export patches are removed. A new ProseMirror patch restores the virtual empty-content gate after undo to preserve redo. Do not introduce Yjs 13 to accommodate stock Tiptap collaboration.

Three Signals, And Which One Fires

  • table.subscribe fires when a table's SHAPE changes: a row added, removed, or a value edited. It does NOT fire for an edit inside a body node. It hands the listener the ROW IDS the commit touched, so a consumer holding a projection rebuilds only what moved; a consumer that just re-reads may ignore them.
  • table.watch(node) fires for edits inside one body node, keyed by the node's own identity.
  • kv.subscribe fires when any declared key changes, and carries nothing. There are ten keys, so naming them would buy nothing.

The distinction is forced by the library. Delivery routes off transaction.changed, which Yjs fills with the types a transaction modified DIRECTLY, so a keystroke in a body puts the BODY's type there; its parent is the row, not the table root. Nothing bubbles to the table. A surface that watches a table for changes inside a node sees nothing.

Owner-Side Persistence

One document, so one chain: _updates (id, bytes, authoritySeq) in SQLite, and the matching object store in the browser's IndexedDB. There is no per-document partition, no _tombstones, and no separate _outbox — what a replica still owes is the rows with authoritySeq IS NULL, which is a partial index rather than a second table (ADR-0238). Do not add a separate IndexedDB provider or a second document store.

  • Hydrate BEFORE attaching the updateV2 listener. Replaying stored bytes through the listener would re-append them; the engine applies its history first and then attaches, and throws if a foreign apply ever reaches the listener, so a mistake here fails the open loudly rather than duplicating a log (packages/app/src/data/store/store.ts).
  • A locally authored append joins the durable queue owed. Authority-accepted bytes arrive on a remote origin and create no outbound obligation, which is what the one listener checks before appending.
  • The chain compacts by ROW, and the row's authoritySeq picks which of two mechanisms applies (ADR-0301). Rows the authority has taken replay into a fresh gc: true document and rewrite as one complete V2 state update: replay rather than mergeUpdatesV2, because merging does not GC and collapsing tombstones is the point. encodeStateAsUpdateV2 folds buffered pending state back into its output, so a fold taken while dependencies are missing cannot silently drop them.
  • Rows still OWED cannot take that path, and this is the one to get right. A whole-document re-encode is not a delta the authority could be offered, so owed rows collapse with mergeUpdatesV2 into one resendable row that takes a NEW id above every existing one. Folding them like acknowledged rows would offer the authority a whole document per keystroke; inheriting a lower id would let an earlier acknowledgement stamp bytes it never carried.
  • Treat replay corruption as storage failure: a document that cannot hydrate refuses its open rather than handing out a half-hydrated handle.

Storage Optimization

v14 has ONE shared type, Y.Node, reached as doc.get(name) for a map-like root or doc.get(name, 'text') for a text one. There is no Y.Map, Y.Text, Y.Array or Y.XmlFragment; code or advice naming them is Yjs 13 and is replacement material.

Attribute tombstones retain the key forever, and every setAttr(key, value) creates a new internal item and tombstones the previous one, which is why gc: true is what collapses a field edited 5,000 times down to two structs.

Raw Types At The Boundary

A Y.Node handed out by a table handle is a live CRDT reference and is MEANT to be bound to an editor. That is the design: the store hands the editor the real thing rather than proxying it, because a copy would break the merge that makes it worth having.

A raw node can reach its parent and document. It is not a security sandbox. Features must use table.body(id) instead of constructing or repairing the row layout. The store owns the invariant in packages/app/src/data/store/document.ts.

RC26 still types configured children as Fingerprintable, which does not include a live Y.Node, and insert does not translate delta children into node children. Keep the insertion escape localized at row creation; do not broaden JSON metadata types or publish a compatibility alias to hide this gap. Verify the installed declarations before changing that boundary.

References

  • Learn Yjs - Interactive tutorials
  • Yjs Documentation - API reference
  • Yjs INTERNALS.md - How Yjs works internally
  • GitHub issue #520 - Conflict resolution discussion with dmonad
  • fractional-indexing - Production library
  • YATA paper - Academic foundation
  • packages/app/src/data/store/document.ts: the application-document grammar (roots, row types, field reads)
  • packages/app/src/data/store/log.ts and packages/app/src/data/store/persistence.ts: the durable update log, the outbox, and the persistence queue
  • packages/app/evidence/data/invariants.test.ts: the library behaviour this design rests on, pinned against the installed rc
  • packages/app/src/data/sync/: the Yjs 14 wire (frames, connection, client, authority)
  • ADR-0295: one document per application, and a row holds its rich content (supersedes ADR-0248)
  • ADR-0309: historical field/node vocabulary, amended by ADR-0431 for the sole-child layout
  • ADR-0221: what subscribe reports and when it fires
  • ADR-0146: Yjs 14-only persistence decision
  • ADR-0159: one owner-side SQLite update log and shared attachment seam

Comments

Markdown supported