yjs について
Epicenter向けにYjs 14のCRDTパターンをまとめたプロジェクト固有のスキルです。ドキュメント構造、同期、永続化、エディター連携を扱い、共有データの動作を変更する開発者に、重要な判断のソースや公式資料による確認を促します。
主な機能
- 01Yjs 14のドキュメントと行構造の指針
- 02状態ベクターと更新同期のパターン
- 03トランザクションのoriginとUndo管理
- 04ストアの購読とエディター連携
- 05所有者側の永続化と圧縮規則
- 06動作に関わる変更の上流確認
活用シーン
- 01新しい共有データドキュメントの設計
- 02同期や永続化の変更の実装
- 03予期しないドキュメント状態や増大の調査
- 04エディターと共同編集コンテンツの接続
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ epicenterhq/ epicenter --skill yjsインストール後、Claude Code で/yjsを入力して読み込まれたか確認します。
同梱ファイルreferences/debugging.mdreferences/document-design.md
詳細
Yjs 14 CRDT パターン
リファレンスリポジトリ
- Yjs:共有編集とオフラインファーストのデータに対応する CRDT フレームワーク
- Yjs Protocols:同期とアウェアネスのアルゴリズム的な基盤
上流ソースによる確認
競合の意味論、トランザクションのオリジン、共有型の動作、更新のエンコーディング、ストレージの増加、共有型 API が正確性に影響する場合は、記憶に頼る前にソースに基づいて確認してください。DeepWiki MCP を利用できる場合は、yjs/yjs を対象に絞った質問をしてください。同期とアウェアネスのアルゴリズムについては、yjs/y-protocols を対象に質問してください。DeepWiki を利用できない場合や、リポジトリがインデックス化されていない場合は、上流ソースまたは公式ドキュメントを直接参照してください。DeepWiki は概要の把握に使い、コードを変更する前に、決定的な詳細をローカルで固定された @y/y の型定義とソースで確認してください。
Epicenter が対象とするのは @y/y 14 のみです。yjs 13、y-indexeddb、互換性リーダー、パッケージエイリアス、デュアルワイヤー、フォールバックは追加しないでください。既存の Yjs 13 のコードは置き換え対象であり、互換性のためのインターフェースではありません。
安定した基本事項や、以下にすでに記載されているリポジトリ内のパターンについては、DeepWiki を使う必要はありません。
新しい行ドキュメントの構成を決める前に、references/document-design.md を読んでください。カウンター、ユーザーが制御する順序、ネスト構造には、それぞれ既存のデータができてから変更するとコストがかかる競合時の動作があります。
ドキュメントが予期しない状態に収束する場合や、コンテンツに比べて急速に肥大化する場合は、references/debugging.md を読んでください。
関連スキル:ストアのデータをコンポーネントに読み込む方法は
svelteを、field.*宣言ヘルパーの背後にあるスキーマはtypeboxを参照してください。
トランザクション、オリジン、取り消し
- Yjs の更新は可換かつ冪等です。ステートベクターが表すのはレプリカが保持している内容であり、未適用の更新の順序ではありません。削除ではクライアントのクロックが進まないため、2 つのレプリカが同じベクターを保持していても内容が異なることがあります。そのため、このストアでは未適用更新をベクターではなくログ位置で管理します。これにより、カーソルとアウトボックスはどちらも更新行の同じ列、
MAX(authoritySeq)とauthoritySeq IS NULLから導出されるため、どちらも対応するバイト列と食い違うことはありません(packages/app/evidence/data/invariants.test.ts、ADR-0298)。 Y.encodeStateVector(doc)を使ってローカルクロックを表し、その後Y.encodeStateAsUpdateV2(doc, remoteStateVector)を使って、不足している更新のみを送信します。updateV2イベントのバイト列を永続化し、送信してください。Y.applyUpdateV2(doc, update, origin)で再適用します。- 複数の書き込みを行うユーザー操作は、
doc.transact(() => { ... }, origin)で囲みます。これによりオブザーバーの処理が減り、永続化、プロバイダー、取り消しの各ロジックに役立つオリジンを指定できます。 - トランザクションのオリジンを、プロバイダーからのエコー、アプリが作成した操作、取り消しのトラッキングをフィルタリングする境界として扱ってください。
Y.UndoManagerは具体的な共有型に限定して適用します。trackedOriginsを設定し、captureTimeoutを調整して、論理的に別個のコマンドの間ではstopCapturing()を呼び出してください。- 共同編集のカーソルや選択範囲のアンカーには、相対位置を使ってください。リモート編集が行われると、数値インデックスはずれていきます。
Y.snapshot()は、保持されている削除履歴に依存する履歴マーカーです。Y.encodeStateAsUpdateV2(doc)は自己完結型のチェックポイント形式です。- Epicenter がサブドキュメント経路のプロバイダーのライフサイクル全体を管理する場合を除き、Yjs のサブドキュメントではなく、トップレベルのドキュメントを分けて使ってください。
ストア接続
- Yjs はネットワークに依存しません。提供するのは CRDT の状態、状態ベクトル、更新、アウェアネスの動作であり、Epicenter の接続トポロジー、認可、永続性の契約ではありません。
- 開いているドキュメントごとではなく、アプリケーションごとにソケットを 1 つ使用します。レプリカは、
namespaceでワークスペースを指定し、cursorで自身が永続的に適用した位置を指定してSTORE_SYNC_ROUTE.pattern(packages/sync/src/store-route.tsにある/api/store/v1/sync)に接続します。これにより、再接続時は最初からではなく、追いつくための同期になります(ADR-0222)。 - データの所有者をクエリに含めることはありません。所有者はサーバー側で解決されたベアラーから決まるため、別のパーティションに到達する値をクライアントが URL に設定することはできません(ADR-0092)。
- ブラウザーのアップグレードでは、
Authorizationを設定できないため、bearer.<token>サブプロトコルエントリー 1 つだけを使って認証します。マウント処理は 101 応答でメインサブプロトコルのみをエコーするため、トークンが往復することはありません。ブラウザー以外のクライアントではAuthorizationヘッダーを使用できます。Cookie だけを使ったアップグレード、クエリ文字列の認証情報、受け入れ後の認証フレームは使用しないでください。 - ワイヤー形式が扱うのはフレーミングのみです。
push、ack、refuse、entry、offer、snapshot、wanted(packages/app/src/data/sync/frames.ts)。どのフレームも、更新の意味や行の定義、Yjs について認識しません。だからこそ、このレイヤーではチャンク化しても安全です。 - 大きな更新は
CHUNK_BYTESでチャンク化します。この値は Yjs の仕様ではなく、Cloudflare が文書化している Durable Object SQLite の値サイズ上限によって決まります。実測された上限まで引き上げないでください。Cloudflare が適用する権利を持つのは、文書化された制限です。 - 具体的な利用者がアウェアネス状態と切断時のクリーンアップを必要とするまで、プレゼンスは意図的に含めません。後から追加する場合、アウェアネスは一時的なものであり、Y.Doc や SQLite の更新ログに正規データとして永続化してはなりません。
ストアごとに 1 つのドキュメント
Epicenter は @y/[email protected] に固定しています。ストアは、tables:<name> という名前のテーブルルートと kv ルートを持つ 1 つの Y.Doc です。各行は、テーブルルート上のネストされた Y.Node 属性です。行の属性は JSON メタデータであり、その唯一のシーケンス子が安定したボディノードです(ADR-0431)。
ストアは両方のノードを一緒に作成します。読み取りによってノードが作成または修復されることはなく、正しいノード型の子がちょうど 1 つあることを要求します。フィールドの更新で子を置き換えることはできません。行を削除すると、そのサブツリーも削除されます。子インデックス 0 はストレージ上の規約ですが、子は独自の安定した CRDT ID を持ちます。
get、rows、create は値のスナップショットを返します。table.body(id) はメタデータへの適合性とは無関係に、ライブのボディを返します。body、content、! で始まる名前は通常のメタデータです。構造上の id は予約済みのままです。
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.']);
TypeBox を内部で使用する field.* ヘルパーは維持してください。現在の 2 メソッドのボディファイル形式とアーティファクトのシーケンス置換については、packages/app/src/data/README.md で仕様を定めています。アーティファクトの読み取りでは { id, fields, body } を公開します。値はフロントマターになり、コーデックがその下のテキストを提供します。チェックアウトでは、操作の種類によって、body という名前のフィールドとボディの編集を区別します。
宣言されたテーブル/KV ルートだけが Doc.get に到達します。Doc.get は対象がない場合に作成します。行の検索では getAttr を使用し、ルートを新たに作成することはありません。行をネストしておくことで、エンコード中に findRootTypeKey がドキュメントルートを行ごとにスキャンする事態も避けられます。
エディターのバインディング
エディターをボディの子にバインドします。Honeycrisp は @y/[email protected] を使用します。
ynodeToPmnode(node, schema) で読み込み、pmnodeToDelta(pmNode) で
node.applyDelta に渡す挿入デルタを生成します。このコンバーターは Yjs に書き込まずに、スキーマに基づいて空のトップレベルドキュメントを補完します。書き換えるには、1 つのストアトランザクション内で古いシーケンスを削除し、
新しいデルタを適用します。ボディを置き換えてはいけません。
エディターの正規化では、ボディ属性を管理します。親のメタデータはその外部に保持します。 メタデータの編集によって、ボディのウォッチャーに通知したり、エディターの元に戻す履歴を消去したりしてはいけません。 Honeycrisp は外部からのボディ編集時に履歴を消去し、元に戻す/やり直しのオリジンを保持します。 Skills の CodeMirror アダプターは、渡されたボディにのみ書き込みますが、編集のたびにテキスト全体を置き換えます。そのため、増分型の共同編集バインディングよりも同時編集時の挙動が劣ります。
ルートオーバーライドにより、依存関係全体で RC26 と lib0 RC32 の一貫性を保ちます。古いレンダラーと内部エクスポートのパッチは削除されています。新しい ProseMirror パッチでは、元に戻した後に仮想的な空コンテンツのゲートを復元し、やり直しができるようにします。標準の Tiptap 共同編集に対応するために Yjs 13 を導入してはいけません。
3 つのシグナルと、どれが発火するか
table.subscribeは、テーブルの形状が変化したときに発火します。行の追加、削除、値の編集が該当します。ボディノード内の編集では発火しません。コミットで変更された行 ID をリスナーに渡すため、プロジェクションを保持するコンシューマーは、移動した部分だけを再構築できます。単に再読み込みするコンシューマーは、それらを無視できます。table.watch(node)は、1 つのボディノード内の編集時に、そのノード自体の ID をキーとして発火します。kv.subscribeは、宣言されたキーのいずれかが変更されたときに発火し、何も渡しません。キーは 10 個あるため、名前を渡しても意味はありません。
この違いはライブラリの仕様によるものです。通知の振り分けは
transaction.changed に基づいて行われます。Yjs はここに、トランザクションが直接変更した型を設定するため、ボディ内のキー入力では BODY の型がここに入ります。その親は行であり、テーブルのルートではありません。テーブルまで変更が伝播することはありません。ノード内の変更をテーブルで監視しても、何も検出できません。
オーナー側の永続化
ドキュメントは1つなので、チェーンも1つです。SQLite の _updates (id, bytes, authoritySeq) と、ブラウザーの IndexedDB にある対応するオブジェクトストアです。ドキュメントごとのパーティションも、_tombstones も、独立した _outbox もありません。レプリカがまだ送信義務を負っているのは authoritySeq IS NULL の行であり、これは2つ目のテーブルではなく部分インデックスです(ADR-0238)。IndexedDB プロバイダーを別途追加したり、2つ目のドキュメントストアを設けたりしないでください。
updateV2リスナーをアタッチする前にハイドレートしてください。保存済みバイト列をリスナー経由で再生すると、再び追加されてしまいます。エンジンは履歴を先に適用してからリスナーをアタッチし、外部からの適用がリスナーに届いた場合は例外をスローします。そのため、ここで間違えるとログが重複するのではなく、オープン時にはっきり失敗します(packages/app/src/data/store/store.ts)。- ローカルで作成された追加は、永続化された送信義務のキューに加わります。権限を持つ側で承認されたバイト列はリモートオリジンから届き、送信義務は発生しません。追加する前にこの条件を確認するのが、唯一のリスナーです。
- チェーンは行単位でコンパクションされ、どちらのメカニズムを適用するかは行の
authoritySeqで決まります(ADR-0301)。権限を持つ側が取り込んだ行は、新しいgc: trueのドキュメントに再生され、完全な V2 状態更新1つとして書き換えられます。これはmergeUpdatesV2ではなく再生を使います。マージでは GC が行われず、トゥームストーンをまとめることが目的だからです。encodeStateAsUpdateV2はバッファー内の保留状態を出力に戻すため、依存関係が不足している間に実行した集約で、それらがひそかに失われることはありません。 - まだ送信義務のある行には、この方法を適用できません。ここを正しく扱う必要があります。ドキュメント全体を再エンコードしたものは、権限を持つ側に渡せる差分ではないため、送信義務のある行は
mergeUpdatesV2でまとめ、既存のすべての ID より大きい新しい ID を持つ、再送可能な1行にします。承認済みの行と同じようにまとめると、キー入力のたびにドキュメント全体を権限を持つ側に渡すことになります。小さい ID を引き継ぐと、先に届いた承認によって、そこに含まれていなかったバイト列にまで承認済みの印が付いてしまいます。 - 再生の破損はストレージ障害として扱ってください。ハイドレートできないドキュメントは、半端にハイドレートされたハンドルを渡すのではなく、オープンを拒否します。
ストレージの最適化
v14 には共有型が1つだけあり、Y.Node をマップ風のルートなら doc.get(name)、テキストのルートなら doc.get(name, 'text') で取得します。Y.Map、Y.Text、Y.Array、Y.XmlFragment はありません。これらの型を挙げるコードや助言は Yjs 13 のものであり、置き換える必要があります。
属性のトゥームストーンはキーを永久に保持します。また、setAttr(key, value) を呼び出すたびに新しい内部アイテムが作成され、以前のアイテムはトゥームストーンになります。そのため、5,000回編集したフィールドを2つの構造体にまとめるには gc: true が必要です。
境界における生の型
テーブルハンドルから渡される Y.Node はライブな CRDT 参照であり、エディターにバインドされることを想定しています。これが設計です。コピーではマージが機能しなくなり、それこそがこの仕組みを使う理由だからです。ストアはプロキシを介さず、実体をエディターに渡します。
生のノードから親やドキュメントにアクセスできます。これはセキュリティサンドボックスではありません。機能は、行レイアウトを自分で構築したり修復したりするのではなく、table.body(id) を使う必要があります。不変条件はストアが所有しています(packages/app/src/data/store/document.ts)。
RC26 では、設定された子要素は依然として Fingerprintable として型付けされていますが、これにはライブな Y.Node が含まれません。また、insert はデルタの子要素をノードの子要素に変換しません。挿入時の例外処理は行の作成箇所に局所化してください。JSON メタデータの型を広げたり、この不整合を隠すための互換性エイリアスを公開したりしないでください。この境界を変更する前に、インストール済みの宣言を確認してください。
参考資料
- Learn Yjs - インタラクティブなチュートリアル
- Yjs Documentation - API リファレンス
- Yjs INTERNALS.md - Yjs の内部の仕組み
- GitHub issue #520 - dmonad との競合解決に関する議論
- fractional-indexing - 本番環境向けライブラリ
- YATA paper - 学術的な基盤
packages/app/src/data/store/document.ts:アプリケーション文書の文法(ルート、行の種類、フィールドの読み取り)packages/app/src/data/store/log.tsおよびpackages/app/src/data/store/persistence.ts:永続更新ログ、送信待ちキュー、永続化キューpackages/app/evidence/data/invariants.test.ts:この設計の基盤となるライブラリの動作。インストール済みの rc に対して固定されていますpackages/app/src/data/sync/:Yjs 14 のワイヤープロトコル(フレーム、接続、クライアント、オーソリティ)- ADR-0295:アプリケーションごとに 1 つの文書を使用し、行にリッチコンテンツを保持します(ADR-0248 に優先)
- ADR-0309:フィールド/ノードの歴史的な用語。単一子レイアウトについては ADR-0431 によって修正されています
- ADR-0221:
subscribeが何を報告し、いつ呼び出されるか - ADR-0146:Yjs 14 のみを対象とする永続化の決定
- ADR-0159:オーナー側の SQLite 更新ログを 1 つ使用し、添付ファイルの共有用接続点を設けます