Skip to main content

yjs

Apply Epicenter’s Yjs 14 patterns for row documents, transactions, persistence, and synchronization. Use when working with `@y/y`, CRDTs, collaborative editing, awareness, or Yjs storage and providers.

Source facts

Repository
EpicenterHQ/epicenter
Last source activity
September 29, 2026 at 04:04
Detected SKILL.md language
English
Stars
4,822
Forks
386

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
3 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
yjs
description
Apply Epicenter’s Yjs 14 patterns for row documents, transactions, persistence, and synchronization. Use when working with `@y/y`, CRDTs, collaborative editing, awareness, or Yjs storage and providers.
metadata
{"author":"epicenter","version":"1.0"}
# Yjs 14 CRDT Patterns ## Reference Repositories - [Yjs](https://github.com/yjs/yjs): CRDT framework for shared editing and offline-first data - [Yjs Protocols](https://github.com/yjs/y-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](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](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/y@14.0.0-rc.26`. 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. ```typescript 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/prosemirror@2.0.0-12`: `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](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 - [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 - `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](../../../docs/adr/0295-a-database-is-one-yjs-document-and-a-row-holds-its-rich-content.md): one document per application, and a row holds its rich content (supersedes ADR-0248) - [ADR-0309](../../../docs/adr/0309-a-field-holds-a-value-or-a-node-and-the-retired-words-fail-the-build.md): historical field/node vocabulary, amended by [ADR-0431](../../../docs/adr/0431-rows-return-values-and-own-a-separate-body.md) for the sole-child layout - [ADR-0221](../../../docs/adr/0221-a-table-names-the-rows-a-commit-touched-and-says-so-after-the-projection-commits.md): what `subscribe` reports and when it fires - [ADR-0146](../../../docs/adr/0146-row-documents-use-one-yjs-14-major-and-runtime-native-update-logs.md): Yjs 14-only persistence decision - [ADR-0159](../../../docs/adr/0159-row-documents-persist-in-one-owner-side-sqlite-update-log.md): one owner-side SQLite update log and shared attachment seam
View on GitHub