diff --git a/.changeset/sqlite-migration.md b/.changeset/sqlite-migration.md new file mode 100644 index 0000000..912300b --- /dev/null +++ b/.changeset/sqlite-migration.md @@ -0,0 +1,47 @@ +--- +"y-durableobjects": major +--- + +Migrate to the Durable Objects SQLite storage backend and fix the defects the +key-value backend had forced. + +**Breaking changes** + +- Requires `new_sqlite_classes` in your wrangler migrations. A v1 namespace + cannot be converted in place — see "Migrating from v1" in the README. +- `updateYDoc()` now takes a raw Yjs update instead of a sync-protocol message, + so it round-trips with `getYDoc()`. +- The exported `YTransactionStorage` type is replaced by `YStorage`. +- `WSSharedDoc.notify(listener)` is now `notify(origin, listener)` and + `WSSharedDoc.update(message)` is now `update(message, origin)`. +- `WebSocketAttachment` is replaced by `SessionAttachment`, which carries the + connection's awareness client ids. + +**Fixes** + +- Documents are no longer capped at 128KiB. +- Compaction no longer exceeds the 128-key limit of `delete()`. +- Closing one connection no longer clears every participant's awareness state. +- Updates are persisted in order and awaited rather than left as floating promises. +- If a storage write fails, the Durable Object now closes every connection + (`1011`) and aborts itself instead of continuing to serve in-memory state + that storage doesn't have. This is a deliberate mass disconnect, not an + outage: Yjs clients hold the full document, so they reconnect and re-sync + automatically. +- Sync step 2 replies go only to the requesting client instead of the whole room. +- Updates are no longer echoed back to their sender. +- A malformed binary message closes only that connection instead of resetting + the Durable Object. +- Stored updates are restored in insertion order. + +**Additions** + +- `destroy()` deletes a room's data and closes its connections. +- A `"ping"` / `"pong"` auto-response keeps keepalives from waking the Durable + Object from hibernation. +- The repeating `setInterval` that `y-protocols`' `Awareness` installs in its + constructor is now cleared immediately. That interval previously kept + every Durable Object instance awake for its entire lifetime, so this is + what actually makes hibernation reachable. As a side effect, the server no + longer expires a stale awareness entry on a timer; each connection's + awareness state is still removed on disconnect. diff --git a/.prettierignore b/.prettierignore index 5dbb561..05e258e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ node_modules pnpm-lock.yaml dist worker-configuration.d.ts +docs/superpowers +.superpowers diff --git a/CLAUDE.md b/CLAUDE.md index 755f0ff..71f3de7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Main Durable Object class extending Cloudflare's DurableObject - Manages WebSocket connections and Yjs document synchronization - - Handles persistence through YTransactionStorage + - Handles persistence through YSqliteStorage - Provides JS RPC methods: `getYDoc()` and `updateYDoc()` 2. **WSSharedDoc** (`src/yjs/remote/ws-shared-doc.ts`) @@ -43,11 +43,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Manages awareness protocol for collaborative features - Handles document updates and broadcasts -3. **YTransactionStorage** (`src/yjs/storage/index.ts`) +3. **YSqliteStorage** (`src/yjs/storage/sqlite.ts`) - - Persistence layer for Yjs updates - - Uses Durable Object storage with transaction support - - Implements incremental update storage with periodic compaction + - Persistence layer backed by the Durable Objects SQLite storage backend + - Single `updates` table; snapshots and incremental updates are not distinguished + - Compacts with `Y.mergeUpdates` on a row-count threshold, splitting the + result into chunks when it exceeds the SQLite BLOB limit + - `PRAGMA` is unavailable on Durable Objects SQLite; schema versioning uses a + `schema_version` table (`src/yjs/storage/schema.ts`) 4. **Hono Integration** (`src/index.ts`) - Provides `yRoute()` helper for easy Hono app integration @@ -74,11 +77,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Consistent type imports/exports required 3. **Code Style** + - Kebab-case for filenames - No console.log in production code - Import ordering enforced by ESLint - Prettier formatting required +4. **SQLite Storage Backend** + + - Requires `new_sqlite_classes` in wrangler migrations + - BLOB columns are returned as `ArrayBuffer`; convert with `new Uint8Array(value)` + - Consecutive synchronous `sql.exec` calls with no intervening `await` form an + implicit transaction — do not await inside a compaction + ### Testing Approach Tests follow these patterns: @@ -86,4 +97,6 @@ Tests follow these patterns: - Unit tests for individual components - Integration tests using Cloudflare Workers test environment - WebSocket connection tests with mock implementations -- Storage tests with in-memory implementations +- Storage tests run against the real Durable Objects SQLite backend via + `runInDurableObject` (`@cloudflare/vitest-pool-workers`), not an in-memory + fake diff --git a/README.md b/README.md index 897cb97..b93fe49 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ This configuration ensures that your Cloudflare Worker can correctly instantiate ```toml name = "your-worker-name" main = "src/index.ts" -compatibility_date = "2024-04-05" +compatibility_date = "2025-04-01" account_id = "your-account-id" workers_dev = true @@ -53,11 +53,125 @@ bindings = [ ] # Durable Objects migrations +# v2 requires the SQLite storage backend. [[migrations]] tag = "v1" -new_classes = ["YDurableObjects"] +new_sqlite_classes = ["YDurableObjects"] ``` +## Migrating from v1 (key-value backend) + +v2 requires the SQLite storage backend. A Durable Object namespace's storage +type is immutable, so an existing v1 namespace cannot be converted in place — +Cloudflare rejects it with `storage_type_mismatch`. + +Run both versions side by side and copy each room across: + +1. Depend on both package versions at once, using npm aliasing to install + the old one under a different name: + + ```json + { + "dependencies": { + "y-durableobjects": "^2.0.0", + "y-durableobjects-v1": "npm:y-durableobjects@^1" + } + } + ``` + + Each installed version exports its own `YDurableObjects` class, so + re-export them from your Worker's entry script under distinct local + names — this is what lets the wrangler config below register them as two + separate Durable Object classes: + + ```typescript + export { YDurableObjects as YDurableObjectsLegacy } from "y-durableobjects-v1"; + export { YDurableObjects as YDurableObjectsSqlite } from "y-durableobjects"; + ``` + +2. In `wrangler.toml`, keep your existing v1 migration entry byte-for-byte — + migration history is append-only and a namespace's storage type is + immutable, so editing tag `"v1"` (renaming its class or switching it to + `new_sqlite_classes`) cannot convert the already-deployed key-value + namespace and will break it. Instead **append** a new migration with a + new tag that registers a distinct class name for the SQLite-backed + object, and add a second binding for it: + + ```toml + [[durable_objects.bindings]] + name = "Y_LEGACY" + class_name = "YDurableObjectsLegacy" + + [[durable_objects.bindings]] + name = "Y_DURABLE_OBJECTS" + class_name = "YDurableObjectsSqlite" + + [[migrations]] + tag = "v1" + new_classes = ["YDurableObjectsLegacy"] # unchanged from what's already deployed + + [[migrations]] + tag = "v2" + new_sqlite_classes = ["YDurableObjectsSqlite"] # appended, new tag, new class name + ``` + + The class name your v1 binding already points to in production may not + literally be `YDurableObjectsLegacy` — use whatever name is actually + recorded in your deployed `"v1"` migration entry (do not rename it), and + pick any unused name for the new SQLite class as long as it's different + from the legacy one. + +3. Copy each room over. `getYDoc()` returns a raw Yjs update and v2's + `updateYDoc()` accepts one, so a single round trip is enough: + +```typescript +app.post("/migrate/:id", async (c) => { + const id = c.req.param("id"); + const legacy = c.env.Y_LEGACY.get(c.env.Y_LEGACY.idFromName(id)); + const next = c.env.Y_DURABLE_OBJECTS.get( + c.env.Y_DURABLE_OBJECTS.idFromName(id), + ); + + await next.updateYDoc(await legacy.getYDoc()); + + return c.json({ ok: true }); +}); +``` + +4. Once every room is copied, remove the v1 binding, its export, and the + `y-durableobjects-v1` dependency. Leave the `"v1"` migration entry in + `wrangler.toml` in place — migration history is append-only, so old tags + must stay even after their class is no longer bound. + +### Document size + +Durable Objects give each instance 10GB of SQLite storage, but the whole +document must fit in the instance's 128MB of memory. That memory limit — not +storage — is the practical ceiling on document size. + +### Keepalive and hibernation + +`y-protocols`' `Awareness` class installs a repeating `setInterval` in its +constructor to time out stale remote clients. Any pending `setInterval` or +`setTimeout` prevents a Durable Object from hibernating at all, so previously +every `YDurableObjects` instance stayed awake — and billed for duration — for +its entire lifetime, regardless of any other keepalive handling. v2 now +clears that interval immediately after constructing `Awareness`, which is +what makes hibernation reachable in the first place. One consequence: the +server no longer expires a client's awareness state on a timer. This is +accepted because each connection's awareness ids are already removed on +disconnect, and awareness lives only in memory, so it is rebuilt from nothing +whenever an instance restarts anyway. + +With hibernation actually reachable, v2 also registers a `"ping"` / `"pong"` +auto-response via `setWebSocketAutoResponse`. When a client sends `"ping"` as +a keepalive, the Workers runtime answers with `"pong"` directly — the Durable +Object is never woken from hibernation to run `webSocketMessage`, because the +auto-response is matched before that handler would be invoked at all. As a +secondary safety net, `webSocketMessage` ignores non-binary (string) +messages, so even if a `"ping"` ever did reach a woken instance it would be a +no-op. + ## Usage ### With Hono shorthand @@ -174,6 +288,9 @@ export { YDurableObjects }; This API updates the state of the YDoc within a Durable Object. +`updateYDoc` takes a raw Yjs update — the same format `getYDoc` returns and +`Y.encodeStateAsUpdate(doc)` produces. It is not a sync-protocol message. + Example usage in Hono: ```typescript @@ -211,24 +328,40 @@ export { YDurableObjects }; By supporting JS RPC, `y-durableobjects` allows for advanced operations through extensions. You can manipulate the protected fields for custom functionality: +`this.doc` is a `WSSharedDoc`, and its `update(message, origin)` method expects a +**sync-protocol-framed message** — the same bytes a WebSocket client sends over +the wire — not a raw Yjs update. `origin` identifies the source of the change; +it must not be a value already registered as a WebSocket listener (see +`notify()`), so a fresh object works. Frame a raw update before passing it in: + Example: ```typescript -import { applyUpdate, encodeStateAsUpdate } from "yjs"; +import { createEncoder, toUint8Array, writeVarUint } from "lib0/encoding"; +import { writeUpdate } from "y-protocols/sync"; import { YDurableObjects } from "y-durableobjects"; export class CustomDurableObject extends YDurableObjects { - async customMethod() { - // Access and manipulate the YDoc state - const update = new Uint8Array([ - /* some update data */ - ]); - this.doc.update(update); + async customMethod(update: Uint8Array) { + // Wrap the raw update as a sync-protocol message (type 0 = sync) so + // this.doc.update() can dispatch it the same way it dispatches an + // incoming WebSocket message. + const encoder = createEncoder(); + writeVarUint(encoder, 0 /* sync */); + writeUpdate(encoder, update); + + this.doc.update(toUint8Array(encoder), {}); await this.cleanup(); } } ``` +If you already have a raw Yjs update and don't need protocol-level dispatch +(sync step replies, etc.), applying it directly with `applyUpdate(this.doc, update)` +from `yjs` — the same way the built-in `updateYDoc()` RPC method does — is +simpler and broadcasts to connected clients just the same, since `WSSharedDoc` +listens for its own `Doc` "update" event either way. + ### Hono RPC support for ClientSide - Utilizes Hono's WebSocket Helper, making the `$ws` method available in `hono/client` for WebSocket communications. diff --git a/docs/superpowers/plans/2026-08-14-sqlite-migration-v2.md b/docs/superpowers/plans/2026-08-14-sqlite-migration-v2.md new file mode 100644 index 0000000..c042bd7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-sqlite-migration-v2.md @@ -0,0 +1,2026 @@ +# y-durableobjects v2 SQLite Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `y-durableobjects` を Durable Objects の legacy key-value ストレージバックエンドから SQLite バックエンドへ完全移行し、その破壊的変更に便乗して既知の重大な不具合(C-1〜C-5 / H-1〜H-5 / H-7)をすべて解消する。 + +**Architecture:** 単一の `updates` テーブルにすべてを保存する。スナップショットと増分更新を区別せず、コンパクションは「全行を `Y.mergeUpdates` で 1 本にして書き戻す」だけの操作にする。1 本化した結果が SQLite の BLOB 上限を超える場合はバイト断片に分割し、`kind` カラムで断片であることを示す。Yjs の transaction origin を WebSocket まで通すことで、ブロードキャストの除外と awareness 所有権の追跡を同一の仕組みで解決する。 + +**Tech Stack:** TypeScript (strict), Cloudflare Durable Objects (SQLite backend), Yjs / y-protocols / lib0, Hono, Vitest + `@cloudflare/vitest-pool-workers`, tsup, ESLint + Prettier, changesets + +**Spec:** `docs/superpowers/specs/2026-08-14-sqlite-migration-design.md` + +## Global Constraints + +- ブランチは `feat/sqlite-migration-v2`。spec は既にこのブランチにコミット済み。 +- テスト実行は `pnpm exec vitest run `。`pnpm test` は全件実行。`mise` 管理のため `pnpm` が見つからない場合は `export PATH="$HOME/.local/share/mise/shims:$PATH"` を先に実行する。 +- コミット前に必ず `pnpm fmt`(Prettier + ESLint --fix)と `pnpm typecheck` を通す。 +- ESLint: `no-console` は `error`。ログ出力が必要な箇所は既存コードと同様に `// eslint-disable-next-line no-console` を直前に置く。`newline-before-return` が `error` なので `return` の前に空行を入れる。 +- TypeScript strict。`any` は使用しない。`ws.deserializeAttachment()` は `any` を返すので `const raw: unknown = ws.deserializeAttachment();` のように `unknown` で受ける。 +- ファイル名は kebab-case。import 順序は ESLint が強制する(`pnpm fmt` で自動整列)。 +- **`PRAGMA` は Durable Objects の SQLite では使用不可**(`Error: not authorized: SQLITE_AUTH`)。スキーマ版管理は `schema_version` テーブルで行う。 +- **BLOB カラムは `ArrayBuffer` として返る。** 読み出し時は `new Uint8Array(row.data)` で変換する。書き込み時は `Uint8Array` をそのままバインドできる。 +- `sql.exec()` は `BEGIN TRANSACTION` / `SAVEPOINT` を実行できない。`await` を挟まない連続した書き込みが暗黙のトランザクションとして atomic に適用されることを利用する。**コンパクションの `DELETE` と `INSERT` の間に `await` を入れてはならない。** +- 確認済みの型定義(`worker-configuration.d.ts`): + - `DurableObjectState.abort(reason?: string): void` + - `DurableObjectState.setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void` + - `new WebSocketRequestResponsePair(request: string, response: string)` + - `SqlStorage.exec>(query: string, ...bindings: any[]): SqlStorageCursor` + - `type SqlStorageValue = ArrayBuffer | string | number | null` + - カーソルは `toArray()` / `one()` / `next()` / `raw()` を持つ + +--- + +### Task 1: SQLite バックエンドへの切り替えとスキーマ管理 + +KV バックエンドから SQLite バックエンドへ設定を切り替え、`schema_version` テーブルによるマイグレーションランナーを追加する。KV API は SQLite バックエンド上でも隠しテーブル `__cf_kv` 経由で透過的に動作するため、既存コードは無変更で全テストが通る(検証済み)。 + +**Files:** +- Modify: `wrangler.toml:3`(`compatibility_date`), `wrangler.toml:12`(`new_classes` → `new_sqlite_classes`) +- Create: `src/yjs/storage/schema.ts` +- Test: `src/yjs/storage/schema.test.ts` + +**Interfaces:** +- Consumes: なし +- Produces: `migrate(sql: SqlStorage): void` — `updates` テーブルと `schema_version` テーブルを作成し、冪等に実行できる + +- [ ] **Step 1: wrangler.toml を SQLite バックエンドに切り替える** + +`wrangler.toml` を以下の内容にする。 + +```toml +name = "yjs-workers" +main = "src/e2e/index.ts" +compatibility_date = "2025-04-01" +compatibility_flags=["nodejs_compat"] + +[[durable_objects.bindings]] +name = "Y_DURABLE_OBJECTS" +class_name = "YDurableObjects" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["YDurableObjects"] +``` + +- [ ] **Step 2: 既存テストが SQLite バックエンドでも全件通ることを確認する** + +Run: `pnpm exec vitest run` +Expected: PASS(41 tests)。`create-app.test.ts` の stderr に `Error: Service Error` が出るが、これは意図的なエラー系テストの出力であり失敗ではない。 + +- [ ] **Step 3: schema.test.ts に失敗するテストを書く** + +```ts +// src/yjs/storage/schema.test.ts +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import { migrate } from "./schema"; + +const withSql = async (fn: (sql: SqlStorage) => void): Promise => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, (_instance, state) => { + fn(state.storage.sql); + }); +}; + +describe("migrate", () => { + it("creates the updates table", async () => { + await withSql((sql) => { + migrate(sql); + + const tables = sql + .exec<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") + .toArray() + .map((row) => row.name); + + expect(tables).toContain("updates"); + expect(tables).toContain("schema_version"); + }); + }); + + it("records the schema version", async () => { + await withSql((sql) => { + migrate(sql); + + const row = sql.exec<{ version: number }>("SELECT version FROM schema_version").one(); + + expect(row.version).toBe(1); + }); + }); + + it("is idempotent and preserves existing rows", async () => { + await withSql((sql) => { + migrate(sql); + sql.exec("INSERT INTO updates (kind, data) VALUES (?, ?)", 0, new Uint8Array([1, 2, 3])); + + migrate(sql); + + const count = sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM updates").one(); + const version = sql.exec<{ version: number }>("SELECT version FROM schema_version").one(); + + expect(count.count).toBe(1); + expect(version.version).toBe(1); + }); + }); +}); +``` + +- [ ] **Step 4: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/yjs/storage/schema.test.ts` +Expected: FAIL — `Failed to resolve import "./schema"` + +- [ ] **Step 5: schema.ts を実装する** + +```ts +// src/yjs/storage/schema.ts + +/** + * スキーマのマイグレーション定義。 + * 変更するときは既存の要素を書き換えず、末尾に ALTER TABLE を追記すること。 + * Durable Object の SQLite はインスタンスごとに独立した DB なので、 + * 各インスタンスが初回起動時に自分のペースでマイグレートする。 + */ +const MIGRATIONS: readonly string[] = [ + `CREATE TABLE updates ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + kind INTEGER NOT NULL, + data BLOB NOT NULL + )`, +]; + +/** + * 未適用のマイグレーションを適用する。すべて同期実行なので、 + * 呼び出し中に await を挟まなければ暗黙のトランザクションとして atomic に完了する。 + * + * PRAGMA user_version は Durable Objects の SQLite では SQLITE_AUTH で拒否されるため、 + * 版番号は schema_version テーブルに保持する。 + */ +export const migrate = (sql: SqlStorage): void => { + sql.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"); + + const current = sql.exec<{ version: number }>("SELECT version FROM schema_version").toArray().at(0); + const applied = current?.version ?? 0; + + for (let i = applied; i < MIGRATIONS.length; i++) { + sql.exec(MIGRATIONS[i]); + } + + if (current === undefined) { + sql.exec("INSERT INTO schema_version (version) VALUES (?)", MIGRATIONS.length); + } else if (applied !== MIGRATIONS.length) { + sql.exec("UPDATE schema_version SET version = ?", MIGRATIONS.length); + } +}; +``` + +- [ ] **Step 6: テストが通ることを確認する** + +Run: `pnpm exec vitest run src/yjs/storage/schema.test.ts` +Expected: PASS(3 tests) + +- [ ] **Step 7: 全体を検証してコミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add wrangler.toml src/yjs/storage/schema.ts src/yjs/storage/schema.test.ts +git commit -m "feat: switch to SQLite storage backend and add schema migration runner" +``` + +--- + +### Task 2: SQL 定義と YSqliteStorage の読み書き + +`updates` テーブルへの読み書きを実装する。コンパクションは Task 3 で追加する。既存の KV 実装には触れないため、この時点では両方の実装が並存する。 + +**Files:** +- Create: `src/yjs/storage/queries.ts` +- Modify: `src/yjs/storage/type.ts`(`YStorage` と `UpdateKind` を追加。既存の `TransactionStorage` は Task 4 まで残す) +- Create: `src/yjs/storage/sqlite.ts` +- Test: `src/yjs/storage/sqlite.test.ts` + +**Interfaces:** +- Consumes: `migrate(sql: SqlStorage): void`(Task 1) +- Produces: + - `const UpdateKind: { readonly standalone: 0; readonly continuation: 1 }` + - `interface YStorage { getUpdate(): Promise; storeUpdate(update: Uint8Array): Promise; commit(): Promise; destroy(): Promise; }` + - `type YSqliteStorageOptions = { maxRows?: number; maxChunkBytes?: number }` + - `class YSqliteStorage implements YStorage`、コンストラクタは `(sql: SqlStorage, options?: YSqliteStorageOptions)` + +- [ ] **Step 1: sqlite.test.ts に失敗するテストを書く** + +```ts +// src/yjs/storage/sqlite.test.ts +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { Doc, applyUpdate, encodeStateAsUpdate } from "yjs"; + +import { YSqliteStorage } from "./sqlite"; + +import type { YSqliteStorageOptions } from "./sqlite"; + +const withStorage = async ( + fn: (storage: YSqliteStorage) => Promise, + options?: YSqliteStorageOptions, +): Promise => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + await fn(new YSqliteStorage(state.storage.sql, options)); + }); +}; + +const docWith = (text: string): Doc => { + const doc = new Doc(); + doc.getText("root").insert(0, text); + + return doc; +}; + +const textOf = (update: Uint8Array): string => { + const doc = new Doc(); + applyUpdate(doc, update); + + return doc.getText("root").toString(); +}; + +describe("YSqliteStorage", () => { + it("returns null when nothing has been stored", async () => { + await withStorage(async (storage) => { + expect(await storage.getUpdate()).toBeNull(); + }); + }); + + it("round-trips a single update", async () => { + await withStorage(async (storage) => { + await storage.storeUpdate(encodeStateAsUpdate(docWith("Hello World!"))); + + const update = await storage.getUpdate(); + + expect(update).not.toBeNull(); + expect(textOf(update!)).toBe("Hello World!"); + }); + }); + + it("applies stored updates in insertion order", async () => { + await withStorage(async (storage) => { + const doc = new Doc(); + const text = doc.getText("root"); + + // 1000 件の更新を個別に保存する。キーの辞書順ではなく seq 順で + // 復元されることを検証する(H-5 の回帰テスト)。 + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + for (let i = 0; i < 1000; i++) { + text.insert(text.length, String(i % 10)); + } + for (const update of updates) { + await storage.storeUpdate(update); + } + + const restored = await storage.getUpdate(); + + expect(textOf(restored!)).toBe(text.toString()); + }); + }); + + it("clears all rows on destroy", async () => { + await withStorage(async (storage) => { + await storage.storeUpdate(encodeStateAsUpdate(docWith("gone"))); + await storage.destroy(); + + expect(await storage.getUpdate()).toBeNull(); + }); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/yjs/storage/sqlite.test.ts` +Expected: FAIL — `Failed to resolve import "./sqlite"` + +- [ ] **Step 3: queries.ts を作成する** + +```ts +// src/yjs/storage/queries.ts + +/** + * SQL 文字列をこのファイルに隔離する。 + * 将来スキーマが複雑になり Kysely 等のクエリビルダを導入する場合も、 + * 変更はこのファイル内で完結する。 + */ +export const SELECT_ALL_UPDATES = "SELECT kind, data FROM updates ORDER BY seq"; +export const INSERT_UPDATE = "INSERT INTO updates (kind, data) VALUES (?, ?)"; +export const DELETE_ALL_UPDATES = "DELETE FROM updates"; +export const COUNT_UPDATES = "SELECT COUNT(*) AS count FROM updates"; +``` + +- [ ] **Step 4: type.ts に YStorage と UpdateKind を追加する** + +`src/yjs/storage/type.ts` の既存の `TransactionStorage` はそのまま残し、末尾に以下を追記する。 + +```ts +/** + * updates テーブルの kind カラムの値。 + * + * Yjs の update はバイト列として単純に分割・連結できないため、 + * コンパクション結果が BLOB 上限を超える場合はバイト断片に分割して保存する。 + * continuation は「直前の行から続く断片」であることを示す。 + */ +export const UpdateKind = { + standalone: 0, + continuation: 1, +} as const; + +export interface YStorage { + /** 保存されているすべての update を 1 本にマージして返す。空なら null */ + getUpdate(): Promise; + /** 増分 update を 1 行追加する。しきい値を超えたらコンパクションする */ + storeUpdate(update: Uint8Array): Promise; + /** 明示的にコンパクションする */ + commit(): Promise; + /** すべての update を削除する。テーブル定義とスキーマ版は維持する */ + destroy(): Promise; +} +``` + +- [ ] **Step 5: sqlite.ts を実装する(コンパクションは Task 3 で追加)** + +```ts +// src/yjs/storage/sqlite.ts +import { mergeUpdates } from "yjs"; + +import { COUNT_UPDATES, DELETE_ALL_UPDATES, INSERT_UPDATE, SELECT_ALL_UPDATES } from "./queries"; +import { migrate } from "./schema"; +import { UpdateKind } from "./type"; + +import type { YStorage } from "./type"; + +type UpdateRow = { + kind: number; + data: ArrayBuffer; +}; + +export type YSqliteStorageOptions = { + /** + * この行数を超えたらコンパクションする。 + * @default 2000 + */ + maxRows?: number; + /** + * コンパクション結果を分割する単位(バイト)。 + * SQLite の BLOB 上限 2MB に対する安全マージンを取る。 + * @default 1024 * 1024 + */ + maxChunkBytes?: number; +}; + +const DEFAULT_MAX_ROWS = 2000; +const DEFAULT_MAX_CHUNK_BYTES = 1024 * 1024; +const SQLITE_BLOB_LIMIT = 2 * 1024 * 1024; + +const concat = (parts: readonly Uint8Array[]): Uint8Array => { + if (parts.length === 1) return parts[0]; + + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + merged.set(part, offset); + offset += part.byteLength; + } + + return merged; +}; + +export class YSqliteStorage implements YStorage { + readonly #sql: SqlStorage; + readonly #maxRows: number; + readonly #maxChunkBytes: number; + #rowCount: number; + + constructor(sql: SqlStorage, options?: YSqliteStorageOptions) { + this.#maxChunkBytes = options?.maxChunkBytes ?? DEFAULT_MAX_CHUNK_BYTES; + if (this.#maxChunkBytes > SQLITE_BLOB_LIMIT) { + // https://developers.cloudflare.com/durable-objects/platform/limits/ + throw new Error("maxChunkBytes must not exceed 2MB"); + } + this.#maxRows = options?.maxRows ?? DEFAULT_MAX_ROWS; + + this.#sql = sql; + migrate(sql); + this.#rowCount = sql.exec<{ count: number }>(COUNT_UPDATES).one().count; + } + + async getUpdate(): Promise { + const updates = this.#readAll(); + if (updates.length === 0) return null; + + return mergeUpdates(updates); + } + + async storeUpdate(update: Uint8Array): Promise { + this.#sql.exec(INSERT_UPDATE, UpdateKind.standalone, update); + this.#rowCount += 1; + } + + async commit(): Promise { + // Task 3 で実装する + } + + async destroy(): Promise { + this.#sql.exec(DELETE_ALL_UPDATES); + this.#rowCount = 0; + } + + /** + * 全行を読み、continuation の断片を連結して独立した update の配列に戻す。 + * BLOB は ArrayBuffer で返るため Uint8Array へ変換する。 + */ + #readAll(): Uint8Array[] { + const rows = this.#sql.exec(SELECT_ALL_UPDATES).toArray(); + + const updates: Uint8Array[] = []; + let pending: Uint8Array[] = []; + for (const row of rows) { + const bytes = new Uint8Array(row.data); + if (row.kind === UpdateKind.continuation && pending.length > 0) { + pending.push(bytes); + continue; + } + if (pending.length > 0) updates.push(concat(pending)); + pending = [bytes]; + } + if (pending.length > 0) updates.push(concat(pending)); + + return updates; + } +} +``` + +- [ ] **Step 6: テストが通ることを確認する** + +Run: `pnpm exec vitest run src/yjs/storage/sqlite.test.ts` +Expected: PASS(4 tests) + +- [ ] **Step 7: 全体を検証してコミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add src/yjs/storage/queries.ts src/yjs/storage/type.ts src/yjs/storage/sqlite.ts src/yjs/storage/sqlite.test.ts +git commit -m "feat: add SQLite-backed Yjs storage with ordered update log" +``` + +--- + +### Task 3: コンパクションとチャンク分割 + +行数しきい値に到達したら全行を 1 本にマージして書き戻す。マージ結果が `maxChunkBytes` を超える場合はバイト断片に分割する。これで C-2(128KiB 制限)と C-3(delete 128 キー制限)が構造的に解消する。 + +**Files:** +- Modify: `src/yjs/storage/sqlite.ts`(`commit()` と `storeUpdate()`、`#split()` を追加) +- Test: `src/yjs/storage/sqlite.test.ts`(テストを追記) + +**Interfaces:** +- Consumes: Task 2 の `YSqliteStorage` +- Produces: 変更なし(`commit()` の実装が入るのみ) + +- [ ] **Step 1: 失敗するテストを追記する** + +`src/yjs/storage/sqlite.test.ts` の `describe("YSqliteStorage", ...)` の中に以下を追記する。`countRows` ヘルパーはファイル冒頭のヘルパー群の隣に置く。 + +```ts +// ファイル冒頭のヘルパー群に追加 +const withStorageAndSql = async ( + fn: (storage: YSqliteStorage, sql: SqlStorage) => Promise, + options?: YSqliteStorageOptions, +): Promise => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + await fn(new YSqliteStorage(state.storage.sql, options), state.storage.sql); + }); +}; + +const countRows = (sql: SqlStorage): number => + sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM updates").one().count; +``` + +```ts +// describe("YSqliteStorage", ...) の中に追加 +it("compacts once the row threshold is exceeded", async () => { + await withStorageAndSql( + async (storage, sql) => { + const doc = new Doc(); + const text = doc.getText("root"); + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + for (let i = 0; i < 50; i++) { + text.insert(text.length, "x"); + } + for (const update of updates) { + await storage.storeUpdate(update); + } + + // maxRows 10 に対し 50 件保存したので、行数は大幅に減っているはず + expect(countRows(sql)).toBeLessThanOrEqual(10); + + const restored = await storage.getUpdate(); + expect(textOf(restored!)).toBe(text.toString()); + }, + { maxRows: 10 }, + ); +}); + +it("splits a compacted update that exceeds maxChunkBytes and restores it", async () => { + await withStorageAndSql( + async (storage, sql) => { + const doc = new Doc(); + const text = doc.getText("root"); + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + for (let i = 0; i < 20; i++) { + text.insert(text.length, "abcdefghij".repeat(50)); + } + for (const update of updates) { + await storage.storeUpdate(update); + } + await storage.commit(); + + // 512 バイトずつに分割されるので、複数行になっているはず + expect(countRows(sql)).toBeGreaterThan(1); + + const restored = await storage.getUpdate(); + expect(textOf(restored!)).toBe(text.toString()); + }, + { maxRows: 1000, maxChunkBytes: 512 }, + ); +}); + +it("stores a document larger than the legacy 128KiB key-value limit", async () => { + await withStorage(async (storage) => { + const doc = new Doc(); + // 300KB 相当。KV バックエンドでは 1 キーに収まらず保存に失敗していた(C-2) + doc.getText("root").insert(0, "y".repeat(300 * 1024)); + await storage.storeUpdate(encodeStateAsUpdate(doc)); + await storage.commit(); + + const restored = await storage.getUpdate(); + + expect(textOf(restored!).length).toBe(300 * 1024); + }); +}); + +it("is a no-op when there is at most one row to compact", async () => { + await withStorageAndSql(async (storage, sql) => { + await storage.storeUpdate(encodeStateAsUpdate(docWith("single"))); + await storage.commit(); + + expect(countRows(sql)).toBe(1); + expect(textOf((await storage.getUpdate())!)).toBe("single"); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/yjs/storage/sqlite.test.ts` +Expected: FAIL — 3 件が失敗する。`commit()` が未実装のため行数が減らず、`expect(countRows(sql)).toBeLessThanOrEqual(10)` などが失敗する。 + +- [ ] **Step 3: storeUpdate にしきい値判定を追加する** + +`src/yjs/storage/sqlite.ts` の `storeUpdate` を置き換える。 + +```ts + async storeUpdate(update: Uint8Array): Promise { + this.#sql.exec(INSERT_UPDATE, UpdateKind.standalone, update); + this.#rowCount += 1; + + if (this.#rowCount > this.#maxRows) { + await this.commit(); + } + } +``` + +- [ ] **Step 4: commit と #split を実装する** + +`src/yjs/storage/sqlite.ts` の `commit()` を置き換え、`#split()` を `#readAll()` の隣に追加する。 + +```ts + async commit(): Promise { + if (this.#rowCount <= 1) return; + + const updates = this.#readAll(); + if (updates.length === 0) return; + + const chunks = this.#split(mergeUpdates(updates)); + + // ここから下では await を挟まないこと。 + // 連続した同期書き込みが暗黙のトランザクションとして atomic に適用される。 + this.#sql.exec(DELETE_ALL_UPDATES); + for (const [index, chunk] of chunks.entries()) { + const kind = index === 0 ? UpdateKind.standalone : UpdateKind.continuation; + this.#sql.exec(INSERT_UPDATE, kind, chunk); + } + this.#rowCount = chunks.length; + } + + /** + * マージ済みの update を maxChunkBytes 以下のバイト断片に分割する。 + * subarray ではなく slice を使ってコピーを作る。ビューをそのまま + * バインドすると基底バッファ全体が書き込まれる可能性があるため。 + */ + #split(update: Uint8Array): Uint8Array[] { + if (update.byteLength <= this.#maxChunkBytes) return [update]; + + const chunks: Uint8Array[] = []; + for (let offset = 0; offset < update.byteLength; offset += this.#maxChunkBytes) { + chunks.push(update.slice(offset, Math.min(offset + this.#maxChunkBytes, update.byteLength))); + } + + return chunks; + } +``` + +- [ ] **Step 5: テストが通ることを確認する** + +Run: `pnpm exec vitest run src/yjs/storage/sqlite.test.ts` +Expected: PASS(8 tests) + +- [ ] **Step 6: 全体を検証してコミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add src/yjs/storage/sqlite.ts src/yjs/storage/sqlite.test.ts +git commit -m "feat: compact update log with chunk splitting for large documents" +``` + +--- + +### Task 4: YDurableObjects を YSqliteStorage に載せ替え、KV 実装を削除 + +Durable Object 本体を新しいストレージに切り替え、KV バックエンド向けの実装をすべて削除する。起動時に Doc を 2 つ構築していた無駄も解消する。 + +**Files:** +- Modify: `src/yjs/index.ts:28-35`(`storage` フィールド), `src/yjs/index.ts:39-46`(コンストラクタ), `src/yjs/index.ts:48-50`(`onStart`) +- Modify: `src/yjs/storage/index.ts`(再エクスポートのみにする) +- Modify: `src/yjs/storage/type.ts`(`TransactionStorage` を削除) +- Modify: `src/yjs/internal.ts:2,7` +- Modify: `src/index.ts:40` +- Delete: `src/yjs/storage/storage-key/index.ts`, `src/yjs/storage/storage-key/storage-key.test.ts`, `src/yjs/storage/storage.test.ts` + +**Interfaces:** +- Consumes: `YSqliteStorage`, `YStorage`(Task 2) +- Produces: `YDurableObjects.storage` の型が `YSqliteStorage` になる。`src/index.ts` が `YStorage` をエクスポートする(旧 `YTransactionStorage` は削除) + +- [ ] **Step 1: 旧実装のテストを削除する** + +```bash +git rm src/yjs/storage/storage.test.ts src/yjs/storage/storage-key/storage-key.test.ts src/yjs/storage/storage-key/index.ts +``` + +- [ ] **Step 2: storage/index.ts を再エクスポートだけにする** + +`src/yjs/storage/index.ts` の内容を完全に置き換える。 + +```ts +export { YSqliteStorage } from "./sqlite"; +export { UpdateKind } from "./type"; + +export type { YSqliteStorageOptions } from "./sqlite"; +export type { YStorage } from "./type"; +``` + +- [ ] **Step 3: type.ts から TransactionStorage を削除する** + +`src/yjs/storage/type.ts` から `ListOptions` インターフェースと `TransactionStorage` インターフェースを削除し、Task 2 で追記した `UpdateKind` と `YStorage` のみを残す。 + +- [ ] **Step 4: YDurableObjects を新しいストレージに載せ替える** + +`src/yjs/index.ts` の該当箇所を書き換える。`storage` はフィールド初期化子ではなくコンストラクタ本体で代入する。フィールド初期化子は `useDefineForClassFields` の下でパラメータプロパティ(`public state`)の代入より先に走るため、初期化子から `this.state` を参照すると `undefined` になる。 + +```ts + protected app = createApp({ + createRoom: this.createRoom.bind(this), + }); + protected doc = new WSSharedDoc(); + protected storage: YSqliteStorage; + protected sessions = new Map void>(); + private awarenessClients = new Set(); + + constructor( + public state: DurableObjectState, + public env: T["Bindings"], + ) { + super(state, env); + + this.storage = new YSqliteStorage(state.storage.sql); + + void this.state.blockConcurrencyWhile(this.onStart.bind(this)); + } + + protected async onStart(): Promise { + const update = await this.storage.getUpdate(); + if (update !== null) { + applyUpdate(this.doc, update); + } + + for (const ws of this.state.getWebSockets()) { + this.registerWebSocket(ws); + } + + this.doc.on("update", async (update) => { + await this.storage.storeUpdate(update); + }); + this.doc.awareness.on( + "update", + async ({ added, removed, updated }: AwarenessChanges) => { + for (const client of [...added, ...updated]) { + this.awarenessClients.add(client); + } + for (const client of removed) { + this.awarenessClients.delete(client); + } + }, + ); + } +``` + +import 文も更新する。`encodeStateAsUpdate` は `getYDoc()` でまだ使うので残す。 + +```ts +import { applyUpdate, encodeStateAsUpdate } from "yjs"; +// ... +import { YSqliteStorage } from "./storage"; +``` + +- [ ] **Step 5: internal.ts と src/index.ts のエクスポートを更新する** + +`src/yjs/internal.ts` の 2 行目と 7 行目: + +```ts +import type { YSqliteStorage } from "./storage"; +``` + +```ts + storage: YSqliteStorage; +``` + +`src/index.ts:40` を置き換える: + +```ts +export type { YStorage, YSqliteStorageOptions } from "./yjs/storage"; +``` + +- [ ] **Step 6: 全テストが通ることを確認する** + +Run: `pnpm typecheck && pnpm exec vitest run` +Expected: PASS。`storage.test.ts` と `storage-key.test.ts` が消えた分テスト数は減る(41 → 34 前後)が、e2e テストは全件通る。 + +- [ ] **Step 7: コミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add -A src/ +git commit -m "refactor!: replace key-value storage layer with SQLite implementation" +``` + +--- + +### Task 5: WSSharedDoc に origin を通し、メッセージ型を拡張する + +Yjs の transaction origin を WebSocket まで伝播させ、syncStep2 を要求元だけに返し(H-1)、送信者へのエコーバックを止める(H-2)。あわせて `queryAwareness` と `auth` を追加し、未知のメッセージ型を無言で捨てないようにする(H-7)。 + +**Files:** +- Modify: `src/yjs/message-type/index.ts` +- Modify: `src/yjs/remote/ws-shared-doc.ts`(全面改修) +- Modify: `src/yjs/index.ts:119-125`(`registerWebSocket`), `src/yjs/index.ts:91-94`(`updateYDoc`) +- Modify: `src/yjs/remote/ws-shared-doc.test.ts`(`notify` / `update` の新シグネチャに追従) +- Modify: `src/yjs/message-type/messaeg-type.test.ts`(型が増えたことに追従) + +**Interfaces:** +- Consumes: なし +- Produces: + - `messageType: { sync: 0; awareness: 1; auth: 2; queryAwareness: 3 }` + - `WSSharedDoc.notify(origin: object, listener: (message: Uint8Array) => void): () => void` + - `WSSharedDoc.update(message: Uint8Array, origin: object): void` + - `const RPC_ORIGIN: object`(`src/yjs/index.ts` 内で定義。WebSocket 由来でない更新の origin として使う) + +- [ ] **Step 1: 失敗するテストを書く** + +`src/yjs/remote/ws-shared-doc.test.ts` に以下を追記する。 + +```ts +it("sends the sync step 2 reply only to the requesting origin", () => { + const doc = new WSSharedDoc(); + const requester = {}; + const bystander = {}; + const toRequester: Uint8Array[] = []; + const toBystander: Uint8Array[] = []; + doc.notify(requester, (message) => toRequester.push(message)); + doc.notify(bystander, (message) => toBystander.push(message)); + + doc.getText("root").insert(0, "seed"); + toRequester.length = 0; + toBystander.length = 0; + + const encoder = createEncoder(); + writeVarUint(encoder, messageType.sync); + writeSyncStep1(encoder, new Doc()); + doc.update(toUint8Array(encoder), requester); + + expect(toRequester.length).toBe(1); + expect(toBystander.length).toBe(0); +}); + +it("does not echo an update back to its origin", () => { + const doc = new WSSharedDoc(); + const sender = {}; + const receiver = {}; + const toSender: Uint8Array[] = []; + const toReceiver: Uint8Array[] = []; + doc.notify(sender, (message) => toSender.push(message)); + doc.notify(receiver, (message) => toReceiver.push(message)); + + const source = new Doc(); + source.getText("root").insert(0, "hello"); + const encoder = createEncoder(); + writeVarUint(encoder, messageType.sync); + writeUpdate(encoder, encodeStateAsUpdate(source)); + doc.update(toUint8Array(encoder), sender); + + expect(toSender.length).toBe(0); + expect(toReceiver.length).toBe(1); +}); + +it("throws on an unknown message type", () => { + const doc = new WSSharedDoc(); + const encoder = createEncoder(); + writeVarUint(encoder, 99); + + expect(() => doc.update(toUint8Array(encoder), {})).toThrow(); +}); +``` + +必要な import を先頭に追加する。 + +```ts +import { createEncoder, toUint8Array, writeVarUint } from "lib0/encoding"; +import { writeSyncStep1, writeUpdate } from "y-protocols/sync"; +import { Doc, encodeStateAsUpdate } from "yjs"; + +import { messageType } from "../message-type"; +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/yjs/remote/ws-shared-doc.test.ts` +Expected: FAIL — `notify` が 2 引数を受け取らず、型エラーまたは実行時エラーになる + +- [ ] **Step 3: message-type/index.ts を拡張する** + +```ts +import { createEncoder, writeVarUint } from "lib0/encoding"; + +export const messageType = { + sync: 0, + awareness: 1, + auth: 2, + queryAwareness: 3, +} as const; + +export const isMessageType = ( + type: string, +): type is keyof typeof messageType => { + return Object.keys(messageType).includes(type); +}; + +export const createTypedEncoder = (type: keyof typeof messageType) => { + if (!isMessageType(type)) { + throw new Error(`Unsupported message type: ${type}`); + } + + const encoder = createEncoder(); + writeVarUint(encoder, messageType[type]); + + return encoder; +}; +``` + +- [ ] **Step 4: ws-shared-doc.ts を書き換える** + +```ts +import { createDecoder, readVarUint, readVarUint8Array } from "lib0/decoding"; +import { + createEncoder, + length, + toUint8Array, + writeVarUint, + writeVarUint8Array, +} from "lib0/encoding"; +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, +} from "y-protocols/awareness"; +import { readSyncMessage, writeUpdate } from "y-protocols/sync"; +import { Doc } from "yjs"; + +import { createTypedEncoder, messageType } from "../message-type"; + +import type { AwarenessChanges, RemoteDoc } from "."; + +type Listener = (message: Uint8Array) => void; +type Unsubscribe = () => void; + +interface Notification extends RemoteDoc { + notify(origin: object, listener: Listener): Unsubscribe; +} + +export class WSSharedDoc extends Doc implements Notification { + /** origin(通常は WebSocket)をキーにした配信先 */ + private listeners = new Map(); + readonly awareness = new Awareness(this); + + constructor(gc = true) { + super({ gc }); + this.awareness.setLocalState(null); + + // カーソルなどの付加情報の更新通知 + this.awareness.on("update", (changes: AwarenessChanges, origin: unknown) => { + this.awarenessChangeHandler(changes, origin); + }); + // yDoc の更新通知 + this.on("update", (update: Uint8Array, origin: unknown) => { + this.syncMessageHandler(update, origin); + }); + } + + update(message: Uint8Array, origin: object) { + const encoder = createEncoder(); + const decoder = createDecoder(message); + const type = readVarUint(decoder); + + switch (type) { + case messageType.sync: { + writeVarUint(encoder, messageType.sync); + readSyncMessage(decoder, encoder, this, origin); + + // sync step 1 への応答は要求元にだけ返す + if (length(encoder) > 1) { + this.send(origin, toUint8Array(encoder)); + } + break; + } + case messageType.awareness: { + applyAwarenessUpdate(this.awareness, readVarUint8Array(decoder), origin); + break; + } + case messageType.queryAwareness: { + const states = this.awareness.getStates(); + if (states.size > 0) { + const reply = createTypedEncoder("awareness"); + writeVarUint8Array( + reply, + encodeAwarenessUpdate(this.awareness, Array.from(states.keys())), + ); + this.send(origin, toUint8Array(reply)); + } + break; + } + case messageType.auth: { + // auth はサーバからクライアントへの一方向のメッセージなので受信しても何もしない + break; + } + default: { + throw new Error(`Unsupported message type: ${type}`); + } + } + } + + notify(origin: object, listener: Listener) { + this.listeners.set(origin, listener); + + return () => { + this.listeners.delete(origin); + }; + } + + private syncMessageHandler(update: Uint8Array, origin: unknown) { + const encoder = createTypedEncoder("sync"); + writeUpdate(encoder, update); + + this.broadcast(toUint8Array(encoder), origin); + } + + private awarenessChangeHandler( + { added, updated, removed }: AwarenessChanges, + origin: unknown, + ) { + const changed = [...added, ...updated, ...removed]; + const encoder = createTypedEncoder("awareness"); + const update = encodeAwarenessUpdate( + this.awareness, + changed, + this.awareness.states, + ); + writeVarUint8Array(encoder, update); + + this.broadcast(toUint8Array(encoder), origin); + } + + private send(origin: object, message: Uint8Array) { + this.listeners.get(origin)?.(message); + } + + private broadcast(message: Uint8Array, exclude: unknown) { + for (const [origin, listener] of this.listeners) { + if (origin === exclude) continue; + listener(message); + } + } +} +``` + +- [ ] **Step 5: 呼び出し側を新シグネチャに合わせる** + +`src/yjs/index.ts` のファイル先頭付近(import の直後)に定数を追加する。 + +```ts +/** WebSocket 由来でない更新(JS RPC 経由)の origin */ +const RPC_ORIGIN: object = Object.freeze({ source: "rpc" }); +``` + +`registerWebSocket` を書き換える。 + +```ts + protected registerWebSocket(ws: WebSocket) { + setupWSConnection(ws, this.doc); + const s = this.doc.notify(ws, (message) => { + ws.send(message); + }); + this.sessions.set(ws, s); + } +``` + +`updateYDoc` と `webSocketMessage` を書き換える。 + +```ts + async updateYDoc(update: Uint8Array): Promise { + this.doc.update(update, RPC_ORIGIN); + await this.cleanup(); + } +``` + +```ts + async webSocketMessage( + ws: WebSocket, + message: string | ArrayBuffer, + ): Promise { + if (!(message instanceof ArrayBuffer)) return; + + this.doc.update(new Uint8Array(message), ws); + await this.cleanup(); + } +``` + +- [ ] **Step 6: message-type のテストを更新する** + +`src/yjs/message-type/messaeg-type.test.ts` に `auth` と `queryAwareness` のケースを追加し、既存のアサーションが新しいキー集合と矛盾しないよう修正する。 + +- [ ] **Step 7: テストが通ることを確認する** + +Run: `pnpm typecheck && pnpm exec vitest run` +Expected: PASS + +- [ ] **Step 8: コミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add -A src/ +git commit -m "fix!: route sync replies to the requester and stop echoing to the sender" +``` + +--- + +### Task 6: SessionRegistry で awareness の所有権を追跡する + +接続ごとの awareness clientID を `serializeAttachment` に永続化し、切断時にその接続の clientID だけを削除する(C-4)。hibernation を跨いでも所有権が保たれる。 + +**Files:** +- Create: `src/yjs/session/index.ts` +- Test: `src/yjs/session/session.test.ts` +- Modify: `src/yjs/index.ts`(`sessions` を `SessionRegistry` に置換、`awarenessClients` を削除、`createRoom` / `onStart` / `registerWebSocket` / `unregisterWebSocket` / `cleanup`) +- Modify: `src/yjs/internal.ts` + +**Interfaces:** +- Consumes: `WSSharedDoc.notify(origin, listener)`(Task 5) +- Produces: + - `type SessionAttachment = { roomId: string; connectedAt: number; clientIds: number[] }` + - `class SessionRegistry` — `size: number`, `add(ws, dispose)`, `remove(ws)`, `has(ws)`, `sockets(): IterableIterator`, `clientIdsOf(ws): number[]`, `track(ws, clientIds)` + +- [ ] **Step 1: 失敗するテストを書く** + +```ts +// src/yjs/session/session.test.ts +import { describe, expect, it, vi } from "vitest"; + +import { SessionRegistry } from "."; + +import type { SessionAttachment } from "."; + +const fakeSocket = (attachment: SessionAttachment | null): WebSocket => { + let current = attachment; + + return { + serializeAttachment: (value: SessionAttachment) => { + current = value; + }, + deserializeAttachment: () => current, + } as unknown as WebSocket; +}; + +const attachment = (clientIds: number[]): SessionAttachment => ({ + roomId: "room1", + connectedAt: 0, + clientIds, +}); + +describe("SessionRegistry", () => { + it("tracks and disposes sockets", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(attachment([])); + const dispose = vi.fn(); + + registry.add(ws, dispose); + expect(registry.size).toBe(1); + expect(registry.has(ws)).toBe(true); + + registry.remove(ws); + expect(dispose).toHaveBeenCalledTimes(1); + expect(registry.size).toBe(0); + }); + + it("records client ids on the attachment", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(attachment([])); + registry.add(ws, () => {}); + + registry.track(ws, [7]); + + expect(registry.clientIdsOf(ws)).toEqual([7]); + }); + + it("does not duplicate client ids", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(attachment([7])); + registry.add(ws, () => {}); + + registry.track(ws, [7, 8]); + registry.track(ws, [8]); + + expect(registry.clientIdsOf(ws).sort()).toEqual([7, 8]); + }); + + it("restores ownership from an existing attachment after hibernation", () => { + // hibernation 復帰を模す。registry は空だが WebSocket の attachment は残っている + const ws = fakeSocket(attachment([42])); + const registry = new SessionRegistry(); + registry.add(ws, () => {}); + + expect(registry.clientIdsOf(ws)).toEqual([42]); + }); + + it("returns an empty list for a socket without a valid attachment", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(null); + registry.add(ws, () => {}); + + expect(registry.clientIdsOf(ws)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/yjs/session/session.test.ts` +Expected: FAIL — `Failed to resolve import "."` + +- [ ] **Step 3: SessionRegistry を実装する** + +```ts +// src/yjs/session/index.ts + +export type SessionAttachment = { + roomId: string; + /** epoch ミリ秒。attachment は structured clone されるが、数値の方が扱いが単純 */ + connectedAt: number; + /** この接続が所有する awareness の clientID */ + clientIds: number[]; +}; + +const isSessionAttachment = (value: unknown): value is SessionAttachment => { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + + return ( + typeof candidate.roomId === "string" && + typeof candidate.connectedAt === "number" && + Array.isArray(candidate.clientIds) && + candidate.clientIds.every((id) => typeof id === "number") + ); +}; + +/** + * WebSocket と awareness の所有権を管理する。 + * + * 所有権は WebSocket の attachment に永続化するため、Durable Object が + * hibernation から復帰してメモリ上の状態を失っても復元できる。 + */ +export class SessionRegistry { + private disposers = new Map void>(); + + get size(): number { + return this.disposers.size; + } + + add(ws: WebSocket, dispose: () => void): void { + this.disposers.set(ws, dispose); + } + + remove(ws: WebSocket): void { + this.disposers.get(ws)?.(); + this.disposers.delete(ws); + } + + has(ws: WebSocket): boolean { + return this.disposers.has(ws); + } + + sockets(): IterableIterator { + return this.disposers.keys(); + } + + clientIdsOf(ws: WebSocket): number[] { + return this.attachmentOf(ws)?.clientIds ?? []; + } + + /** + * この接続が所有する clientID を記録する。 + * 実際に増えたときだけ attachment を書き直すので、通常は接続あたり 1 回で済む。 + */ + track(ws: WebSocket, clientIds: readonly number[]): void { + const current = this.attachmentOf(ws); + if (current === null) return; + + const merged = new Set([...current.clientIds, ...clientIds]); + if (merged.size === current.clientIds.length) return; + + ws.serializeAttachment({ + ...current, + clientIds: Array.from(merged), + } satisfies SessionAttachment); + } + + private attachmentOf(ws: WebSocket): SessionAttachment | null { + const raw: unknown = ws.deserializeAttachment(); + + return isSessionAttachment(raw) ? raw : null; + } +} +``` + +- [ ] **Step 4: テストが通ることを確認する** + +Run: `pnpm exec vitest run src/yjs/session/session.test.ts` +Expected: PASS(5 tests) + +- [ ] **Step 5: YDurableObjects を SessionRegistry に置き換える** + +`src/yjs/index.ts` を以下のように変更する。`WebSocketAttachment` 型のエクスポートは `SessionAttachment` に置き換える。 + +```ts +// フィールド + protected sessions = new SessionRegistry(); + // private awarenessClients = new Set(); ← 削除 + +// onStart の awareness ハンドラを置き換える + this.doc.awareness.on("update", ({ added, updated }: AwarenessChanges, origin: unknown) => { + if (origin instanceof WebSocket) { + this.sessions.track(origin, [...added, ...updated]); + } + }); + +// createRoom + protected createRoom(roomId: string) { + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + server.serializeAttachment({ + roomId, + connectedAt: Date.now(), + clientIds: [], + } satisfies SessionAttachment); + + this.state.acceptWebSocket(server); + this.registerWebSocket(server); + + return client; + } + +// registerWebSocket + protected registerWebSocket(ws: WebSocket) { + setupWSConnection(ws, this.doc); + const dispose = this.doc.notify(ws, (message) => { + ws.send(message); + }); + this.sessions.add(ws, dispose); + } + +// unregisterWebSocket + protected async unregisterWebSocket(ws: WebSocket) { + try { + // この接続が所有する clientID だけを削除する。 + // 部屋全体の clientID を削除すると他の参加者の presence まで消える。 + removeAwarenessStates(this.doc.awareness, this.sessions.clientIdsOf(ws), null); + this.sessions.remove(ws); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + } + +// cleanup + protected async cleanup() { + if (this.sessions.size < 1) { + await this.storage.commit(); + } + } +``` + +import を追加する。 + +```ts +import { SessionRegistry } from "./session"; + +import type { SessionAttachment } from "./session"; +``` + +`export type WebSocketAttachment = { ... }` を削除し、代わりに再エクスポートする。 + +```ts +export type { SessionAttachment } from "./session"; +``` + +- [ ] **Step 6: internal.ts を更新する** + +```ts +import type { SessionRegistry } from "./session"; +``` + +```ts + sessions: SessionRegistry; + // awarenessClients: Set; ← 削除 +``` + +- [ ] **Step 7: e2e テストの awareness 回帰テストを追加する** + +`src/e2e/y-durableobjects.test.ts` に追記する。 + +```ts +it("keeps other clients' awareness when one connection closes", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + await instance.createRoom("room1"); + const [first, second] = Array.from(instance.sessions.sockets()); + + instance.sessions.track(first, [1]); + instance.sessions.track(second, [2]); + instance.doc.awareness.setLocalStateField("user", { name: "a" }); + + await instance.webSocketClose(first); + + // first の clientId だけが除去され、second の所有分は残る + expect(instance.sessions.clientIdsOf(second)).toEqual([2]); + expect(instance.sessions.size).toBe(1); + }); +}); +``` + +- [ ] **Step 8: テストが通ることを確認する** + +Run: `pnpm typecheck && pnpm exec vitest run` +Expected: PASS。既存の `instance.sessions.size` を参照するテストは `SessionRegistry.size` でそのまま動く。`Array.from(instance.sessions.entries())` を使っている既存テスト(`y-durableobjects.test.ts:95,110`)は `Array.from(instance.sessions.sockets())` に書き換える。 + +- [ ] **Step 9: コミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add -A src/ +git commit -m "fix!: scope awareness removal to the disconnecting connection" +``` + +--- + +### Task 7: 永続化を直列化し、失敗時に安全側へ倒す + +宙に浮いていた永続化 Promise を直列化し(C-5)、失敗時は全接続を閉じてメモリ状態を破棄する。あわせて不正メッセージの例外境界を設ける(H-4)。 + +**Files:** +- Modify: `src/yjs/index.ts`(`onStart` の update ハンドラ、`webSocketMessage`、`updateYDoc`、新規プライベートメソッド 2 つ) +- Test: `src/e2e/y-durableobjects.test.ts`(追記) + +**Interfaces:** +- Consumes: `YSqliteStorage.storeUpdate`(Task 2)、`WSSharedDoc.update(message, origin)`(Task 5) +- Produces: なし(内部実装の変更のみ) + +- [ ] **Step 1: 失敗するテストを書く** + +`src/e2e/y-durableobjects.test.ts` に追記する。 + +```ts +it("closes only the offending connection on a malformed message", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + await instance.createRoom("room1"); + const [first] = Array.from(instance.sessions.sockets()); + + // 未知のメッセージ型。例外が外に漏れると DO 全体がリセットされる + const malformed = new Uint8Array([99]).buffer; + await expect(instance.webSocketMessage(first, malformed)).resolves.toBeUndefined(); + + // DO は生存し、他の接続も維持されている + expect(instance.sessions.size).toBe(2); + }); +}); + +it("persists an update before webSocketMessage resolves", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + const client = await instance.createRoom("room1"); + const message = createSyncMessage(createYDocMessage("persisted")); + + await instance.webSocketMessage(client, message.slice(0).buffer); + + // ストレージから読み直しても内容が入っている + const stored = await instance.storage.getUpdate(); + expect(stored).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/e2e/y-durableobjects.test.ts` +Expected: FAIL — 未知のメッセージ型で `webSocketMessage` が reject する + +- [ ] **Step 3: 永続化キューと失敗ハンドラを実装する** + +`src/yjs/index.ts` にフィールドとプライベートメソッドを追加する。 + +```ts + /** 永続化を直列化するためのキュー。Yjs の update イベントは同期的に発火するため必要 */ + private persist: Promise = Promise.resolve(); +``` + +```ts + private schedulePersist(update: Uint8Array): void { + this.persist = this.persist + .then(() => this.storage.storeUpdate(update)) + .catch((error: unknown) => { + this.onPersistFailure(error); + }); + this.state.waitUntil(this.persist); + } + + /** + * 永続化に失敗したら全接続を閉じ、Durable Object をリセットする。 + * + * 接続を閉じるだけではメモリ上の Doc がストレージより進んだまま残り、 + * 後続の接続が「正常に見える」状態を受け取ったあと、eviction 時に + * 差分が無言で失われる。abort() でストレージから読み直させる。 + * + * CRDT ではクライアント側が完全な状態を保持しているため、再接続時の + * sync step 1 / 2 で失われた更新が再送され、障害は自己修復する。 + */ + private onPersistFailure(error: unknown): void { + // eslint-disable-next-line no-console + console.error("[y-durableobjects] failed to persist update", error); + + for (const ws of this.state.getWebSockets()) { + ws.close(1011, "storage failure"); + } + this.state.abort("failed to persist a Yjs update"); + } +``` + +- [ ] **Step 4: onStart の update ハンドラを差し替える** + +```ts + this.doc.on("update", (update: Uint8Array) => { + this.schedulePersist(update); + }); +``` + +- [ ] **Step 5: webSocketMessage に例外境界を設け、永続化を待つ** + +```ts + async webSocketMessage( + ws: WebSocket, + message: string | ArrayBuffer, + ): Promise { + if (!(message instanceof ArrayBuffer)) return; + + try { + this.doc.update(new Uint8Array(message), ws); + } catch (error) { + // eslint-disable-next-line no-console + console.error("[y-durableobjects] invalid message", error); + ws.close(1003, "invalid message"); + + return; + } + + await this.persist; + await this.cleanup(); + } +``` + +`updateYDoc` も同様に永続化を待つ。 + +```ts + async updateYDoc(update: Uint8Array): Promise { + this.doc.update(update, RPC_ORIGIN); + await this.persist; + await this.cleanup(); + } +``` + +- [ ] **Step 6: テストが通ることを確認する** + +Run: `pnpm typecheck && pnpm exec vitest run` +Expected: PASS + +- [ ] **Step 7: コミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add -A src/ +git commit -m "fix!: serialize persistence and fail closed when storage writes fail" +``` + +--- + +### Task 8: hibernation の衛生と KV バックエンドの検出、destroy API + +ping/pong を自動応答にして hibernation 中の起床を防ぎ、KV バックエンドで起動された場合は移行手順を示して即座に失敗させ、部屋の削除 API を追加する。 + +**Files:** +- Modify: `src/yjs/index.ts`(コンストラクタ、`destroy()` の追加) +- Modify: `src/yjs/internal.ts` +- Test: `src/e2e/y-durableobjects.test.ts`(追記) + +**Interfaces:** +- Consumes: `YSqliteStorage.destroy()`(Task 2) +- Produces: `YDurableObjects.destroy(): Promise` + +- [ ] **Step 1: 失敗するテストを書く** + +```ts +it("configures a ping/pong auto response", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (_instance, state) => { + const pair = state.getWebSocketAutoResponse(); + + expect(pair?.request).toBe("ping"); + expect(pair?.response).toBe("pong"); + }); +}); + +it("clears the document and closes connections on destroy", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + await instance.updateYDoc(createSyncMessage(createYDocMessage("bye")).slice(0)); + + await instance.destroy(); + + expect(await instance.storage.getUpdate()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/e2e/y-durableobjects.test.ts` +Expected: FAIL — auto response が未設定、`destroy` が未定義 + +- [ ] **Step 3: コンストラクタに SQLite 検出と auto response を追加する** + +`src/yjs/index.ts` のコンストラクタを書き換える。定数はファイル先頭に置く。 + +```ts +const MIGRATION_GUIDE_URL = + "https://github.com/napolab/y-durableobjects#migrating-from-v1-key-value-backend"; + +/** + * SQLite バックエンドで動作しているかを確認する。 + * KV バックエンドでは sql へのアクセスが失敗するため、 + * 原因不明のクラッシュではなく移行手順を示したエラーにする。 + */ +const assertSqliteBackend = (storage: DurableObjectStorage): void => { + try { + storage.sql.exec("SELECT 1"); + } catch (error) { + throw new Error( + `y-durableobjects v2 requires the SQLite storage backend. ` + + `Use "new_sqlite_classes" in your wrangler migrations. ` + + `Migration guide: ${MIGRATION_GUIDE_URL}`, + { cause: error }, + ); + } +}; +``` + +```ts + constructor( + public state: DurableObjectState, + public env: T["Bindings"], + ) { + super(state, env); + + assertSqliteBackend(state.storage); + this.storage = new YSqliteStorage(state.storage.sql); + + // ping を自動応答にすることで、keepalive で Durable Object を + // 起こさずに済む。duration 課金に最も効く設定。 + state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")); + + void this.state.blockConcurrencyWhile(this.onStart.bind(this)); + } +``` + +- [ ] **Step 4: destroy を実装する** + +`getYDoc` / `updateYDoc` の隣に追加する。 + +```ts + /** 部屋のデータを削除し、すべての接続を閉じる */ + async destroy(): Promise { + for (const ws of this.state.getWebSockets()) { + ws.close(1001, "room destroyed"); + } + await this.storage.destroy(); + } +``` + +`src/yjs/internal.ts` の public api セクションに追記する。 + +```ts + destroy(): Promise; +``` + +- [ ] **Step 5: テストが通ることを確認する** + +Run: `pnpm typecheck && pnpm exec vitest run` +Expected: PASS + +- [ ] **Step 6: コミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add -A src/ +git commit -m "feat!: add hibernation auto-response, SQLite backend assertion, and destroy API" +``` + +--- + +### Task 9: 公開 API を整える + +`updateYDoc` が生の Yjs update を受け取るようにして `getYDoc()` との往復を成立させる(H-3)。`yRoute` の `obj.get(obj.idFromName(id))` はそのまま維持する(`getByName` へは切り替えない — 理由は Step 5 を参照)。 + +**Files:** +- Modify: `src/yjs/index.ts`(`updateYDoc`) +- Modify なし: `src/index.ts`(`idFromName` の二段形式を維持。Step 5 参照) +- Modify なし: `src/e2e/index.ts`(同上) +- Test: `src/e2e/y-durableobjects.test.ts`(`updateYDoc` のテストを書き換え) + +**Interfaces:** +- Consumes: なし +- Produces: `YDurableObjects.updateYDoc(update: Uint8Array): Promise` が **生の Yjs update** を受け取る(v1 はプロトコル framing 済みメッセージを要求していた) + +- [ ] **Step 1: 往復テストを書く** + +`src/e2e/y-durableobjects.test.ts` の `"updates YDoc correctly"` を以下に置き換える。 + +```ts +it("round-trips between getYDoc and updateYDoc", async () => { + const source = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + const target = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + + const message = createYDocMessage("Hello World!"); + await runInDurableObject(source, async (instance: InternalYDurableObject) => { + await instance.updateYDoc(message.slice(0)); + }); + + const exported = await runInDurableObject( + source, + (instance: InternalYDurableObject) => instance.getYDoc(), + ); + await runInDurableObject(target, async (instance: InternalYDurableObject) => { + // getYDoc の出力をそのまま updateYDoc に渡せる + await instance.updateYDoc(exported); + }); + + const copied = await runInDurableObject( + target, + (instance: InternalYDurableObject) => instance.getYDoc(), + ); + + const doc = new Doc(); + applyUpdate(doc, copied); + expect(doc.getText("root").toString()).toBe("Hello World!"); +}); +``` + +ファイル先頭に import を追加する。 + +```ts +import { Doc, applyUpdate } from "yjs"; +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `pnpm exec vitest run src/e2e/y-durableobjects.test.ts -t "round-trips"` +Expected: FAIL — `updateYDoc` が生の update をプロトコルメッセージとして解釈しようとして失敗する + +- [ ] **Step 3: updateYDoc を生の update を受け取るように変更する** + +```ts + /** + * 生の Yjs update を適用する。 + * WebSocket 経路と違い、プロトコルのフレーミングは不要。 + * getYDoc() の戻り値をそのまま渡せる。 + */ + async updateYDoc(update: Uint8Array): Promise { + applyUpdate(this.doc, update, RPC_ORIGIN); + await this.persist; + await this.cleanup(); + } +``` + +- [ ] **Step 4: 他のテストの updateYDoc 呼び出しを修正する** + +`createSyncMessage` でラップして `updateYDoc` に渡している箇所(Task 7・Task 8 で追加したテストを含む)を、生の update を渡すように修正する。`webSocketMessage` に渡す箇所は引き続き `createSyncMessage` が必要である。 + +- [ ] **Step 5: `getByName` は採用しない(変更なし)** + +`src/index.ts:15-16` および `src/e2e/index.ts` の該当箇所は、引き続き +`obj.get(obj.idFromName(id))` の二段形式を使う。`getByName` には切り替えない。 + +> **理由**: 本リポジトリにコミットされている `worker-configuration.d.ts` の +> `DurableObjectNamespace` 宣言には `newUniqueId` / `idFromName` / +> `idFromString` / `get` / `jurisdiction` しか存在せず、`getByName` は +> 宣言されていない。そのため `getByName` を使うコードは本リポジトリの型定義 +> ではコンパイルできず、`pnpm typecheck` が失敗する。 + +- [ ] **Step 6: テストが通ることを確認する** + +Run: `pnpm typecheck && pnpm exec vitest run` +Expected: PASS + +- [ ] **Step 7: コミットする** + +```bash +pnpm fmt && pnpm typecheck && pnpm exec vitest run +git add -A src/ +git commit -m "fix!: make updateYDoc accept a raw Yjs update so it round-trips with getYDoc" +``` + +--- + +### Task 10: ドキュメントとリリース準備 + +README を SQLite 前提に更新し、移行手順を掲載し、changeset を追加する。 + +**Files:** +- Modify: `README.md` +- Modify: `CLAUDE.md`(アーキテクチャ節のストレージ記述) +- Create: `.changeset/sqlite-migration.md` + +**Interfaces:** +- Consumes: Task 1〜9 のすべて +- Produces: なし + +- [ ] **Step 1: README の wrangler 設定を更新する** + +`README.md` の "Configuration for Durable Objects" 節の TOML を置き換える。 + +```toml +name = "your-worker-name" +main = "src/index.ts" +compatibility_date = "2025-04-01" + +account_id = "your-account-id" +workers_dev = true + +# Durable Objects binding +[durable_objects] +bindings = [ + { name = "Y_DURABLE_OBJECTS", class_name = "YDurableObjects" } +] + +# Durable Objects migrations +# v2 requires the SQLite storage backend. +[[migrations]] +tag = "v1" +new_sqlite_classes = ["YDurableObjects"] +``` + +- [ ] **Step 2: README に移行手順の節を追加する** + +`## Usage` の直前に以下を挿入する。アンカーは `assertSqliteBackend` が案内する URL と一致させること(`#migrating-from-v1-key-value-backend`)。 + +```markdown +## Migrating from v1 (key-value backend) + +v2 requires the SQLite storage backend. A Durable Object namespace's storage +type is immutable, so an existing v1 namespace cannot be converted in place — +Cloudflare rejects it with `storage_type_mismatch`. + +Run both versions side by side and copy each room across: + +1. Keep your existing v1 binding (for example `Y_LEGACY`) pinned to + `y-durableobjects@1`. +2. Add a new binding backed by v2 with `new_sqlite_classes`. +3. Copy each room over. `getYDoc()` returns a raw Yjs update and v2's + `updateYDoc()` accepts one, so a single round trip is enough: + +```typescript +app.post("/migrate/:id", async (c) => { + const id = c.req.param("id"); + const legacy = c.env.Y_LEGACY.get(c.env.Y_LEGACY.idFromName(id)); + const next = c.env.Y_DURABLE_OBJECTS.get( + c.env.Y_DURABLE_OBJECTS.idFromName(id), + ); + + await next.updateYDoc(await legacy.getYDoc()); + + return c.json({ ok: true }); +}); +``` + +4. Once every room is copied, remove the v1 binding. + +### Document size + +Durable Objects give each instance 10GB of SQLite storage, but the whole +document must fit in the instance's 128MB of memory. That memory limit — not +storage — is the practical ceiling on document size. + +### Keepalive and hibernation + +v2 registers a `"ping"` / `"pong"` auto-response. If your client sends `"ping"` +as a keepalive, the Durable Object answers without waking from hibernation, +which is the single biggest lever on duration billing. +``` + +- [ ] **Step 3: README の updateYDoc の例を修正する** + +`#### updateYDoc` 節の説明に、生の Yjs update を渡すことを明記する。既存のサンプルはリクエストボディをそのまま渡しているため、v2 の挙動と一致するようになる。以下の一文を追加する。 + +```markdown +`updateYDoc` takes a raw Yjs update — the same format `getYDoc` returns and +`Y.encodeStateAsUpdate(doc)` produces. It is not a sync-protocol message. +``` + +- [ ] **Step 4: CLAUDE.md のアーキテクチャ記述を更新する** + +`3. **YTransactionStorage** (`src/yjs/storage/index.ts`)` の項目を置き換える。 + +```markdown +3. **YSqliteStorage** (`src/yjs/storage/sqlite.ts`) + + - Persistence layer backed by the Durable Objects SQLite storage backend + - Single `updates` table; snapshots and incremental updates are not distinguished + - Compacts with `Y.mergeUpdates` on a row-count threshold, splitting the + result into chunks when it exceeds the SQLite BLOB limit + - `PRAGMA` is unavailable on Durable Objects SQLite; schema versioning uses a + `schema_version` table (`src/yjs/storage/schema.ts`) +``` + +同ファイルの `Development Constraints` に以下を追記する。 + +```markdown +4. **SQLite Storage Backend** + + - Requires `new_sqlite_classes` in wrangler migrations + - BLOB columns are returned as `ArrayBuffer`; convert with `new Uint8Array(value)` + - Consecutive synchronous `sql.exec` calls with no intervening `await` form an + implicit transaction — do not await inside a compaction +``` + +- [ ] **Step 5: changeset を追加する** + +```markdown + +--- +"y-durableobjects": major +--- + +Migrate to the Durable Objects SQLite storage backend and fix the defects the +key-value backend had forced. + +**Breaking changes** + +- Requires `new_sqlite_classes` in your wrangler migrations. A v1 namespace + cannot be converted in place — see "Migrating from v1" in the README. +- `updateYDoc()` now takes a raw Yjs update instead of a sync-protocol message, + so it round-trips with `getYDoc()`. +- The exported `YTransactionStorage` type is replaced by `YStorage`. +- `WSSharedDoc.notify(listener)` is now `notify(origin, listener)` and + `WSSharedDoc.update(message)` is now `update(message, origin)`. +- `WebSocketAttachment` is replaced by `SessionAttachment`, which carries the + connection's awareness client ids. + +**Fixes** + +- Documents are no longer capped at 128KiB. +- Compaction no longer exceeds the 128-key limit of `delete()`. +- Closing one connection no longer clears every participant's awareness state. +- Updates are persisted in order and awaited rather than left as floating promises. +- Sync step 2 replies go only to the requesting client instead of the whole room. +- Updates are no longer echoed back to their sender. +- A malformed binary message closes only that connection instead of resetting + the Durable Object. +- Stored updates are restored in insertion order. + +**Additions** + +- `destroy()` deletes a room's data and closes its connections. +- A `"ping"` / `"pong"` auto-response keeps keepalives from waking the Durable + Object from hibernation. +``` + +- [ ] **Step 6: 最終確認とコミット** + +```bash +pnpm fmt && pnpm typecheck && pnpm lint && pnpm exec vitest run && pnpm build +git add -A +git commit -m "docs: document the SQLite migration and add the v2 changeset" +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec の要求 | 対応タスク | +| --- | --- | +| C-1 SQLite バックエンドへの移行 | Task 1, 4 | +| C-2 128KiB 制限の解消 | Task 3(300KB ドキュメントのテスト) | +| C-3 delete 128 キー制限の解消 | Task 3(`DELETE FROM updates` 1 文) | +| C-4 awareness の所有権 | Task 6 | +| C-5 永続化の直列化 | Task 7 | +| H-1 syncStep2 を要求元にのみ | Task 5 | +| H-2 エコーバックの停止 | Task 5 | +| H-3 `updateYDoc` の往復 | Task 9 | +| H-4 例外境界 | Task 7 | +| H-5 更新順序 | Task 2(1000 件の順序テスト) | +| H-7 メッセージ型の拡張 | Task 5 | +| `schema_version` マイグレーション | Task 1 | +| チャンク分割 | Task 3 | +| `maxRows` / `maxChunkBytes` | Task 2, 3 | +| hibernation 衛生 | Task 8 | +| KV バックエンド検出 | Task 8 | +| `destroy()` | Task 8 | +| `getByName` は不採用(二段形式を維持) | Task 9 | +| 移行レシピの文書化 | Task 10 | +| 公開型の変更 | Task 4(`YStorage`)、Task 5(`WSSharedDoc`)、Task 6(`SessionAttachment`) | + +すべての spec 要求にタスクが対応している。 + +**型の整合性:** + +- `YStorage` は Task 2 で定義し、Task 4 でエクスポートする。メソッド名は全タスクで `getUpdate` / `storeUpdate` / `commit` / `destroy` に統一されている。 +- `SessionRegistry` のメソッド名は Task 6 の定義(`add` / `remove` / `has` / `sockets` / `clientIdsOf` / `track` / `size`)で、Task 7・8 のテストからも同じ名前で参照している。 +- `UpdateKind.standalone` / `UpdateKind.continuation` は Task 2 で定義し Task 3 で使用。 +- `RPC_ORIGIN` は Task 5 で定義し Task 9 で再利用。 +- `WSSharedDoc.notify(origin, listener)` の 2 引数シグネチャは Task 5 で導入し、Task 6 の `registerWebSocket` でも同じ順序で呼んでいる。 + +**残る不確実性:** + +`assertSqliteBackend` が KV バックエンドを実際に検出できるかは、KV バックエンドの Durable Object を用意できないため自動テストでは検証できない。Task 8 では SQLite バックエンド上で例外を投げないことのみを確認する。KV バックエンド上での挙動は、v2 リリース前に手動で 1 度確認することが望ましい。 diff --git a/docs/superpowers/specs/2026-08-14-sqlite-migration-design.md b/docs/superpowers/specs/2026-08-14-sqlite-migration-design.md new file mode 100644 index 0000000..450571f --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-sqlite-migration-design.md @@ -0,0 +1,429 @@ +# y-durableobjects v2: SQLite バックエンドへの完全移行 + +- 日付: 2026-08-14 +- 対象バージョン: v2.0.0(破壊的変更を含むメジャーリリース) +- 現行バージョン: v1.0.5 + +## 1. 背景 + +`y-durableobjects` は Cloudflare Durable Objects の **key-value バックエンド**(`new_classes`)を前提に設計されている。Cloudflare は現在、既存の KV バックエンド namespace を持たないアカウントに対して KV バックエンドの新規作成を許可しておらず、Free プランでは SQLite バックエンドのみが利用可能である。したがって現行の v1 は **新規ユーザーがそもそもデプロイできない**状態にある。 + +加えて、KV バックエンドの制約に起因する複数の重大な不具合が存在する。 + +| ID | 内容 | 根拠 | +| --- | --- | --- | +| C-1 | `new_classes`(KV バックエンド)を前提としており、新規アカウントで利用不可 | `wrangler.toml:12`、README | +| C-2 | スナップショット全体を 1 キーに `put` するため、ドキュメントが 128KiB を超えると保存に失敗する | `src/yjs/storage/index.ts:105`、KV 値上限 128KiB | +| C-3 | コンパクション時の `delete(keys)` が 128 キー制限を超えて例外になる(既定 `maxUpdates` は 500) | `src/yjs/storage/index.ts:102` | +| C-4 | WebSocket が 1 本切断されると、部屋全体の awareness state が消える | `src/yjs/index.ts:132-134` | +| C-5 | 永続化が floating promise であり、完了保証・エラー処理・順序保証のいずれも無い | `src/yjs/index.ts:56-58` | +| H-1 | syncStep2 の応答が要求元だけでなく全接続にブロードキャストされる | `src/yjs/remote/ws-shared-doc.ts:56-58` | +| H-2 | 送信者自身に更新がエコーバックされる(origin を除外していない) | `src/yjs/remote/ws-shared-doc.ts:99-103` | +| H-3 | `getYDoc()` は生の Yjs update を返すが、`updateYDoc()` はプロトコル framing 済みメッセージを要求する。README のサンプルは動作しない | `src/yjs/index.ts:91-97`、`src/e2e/helper.ts:16` | +| H-4 | `webSocketMessage` に例外境界が無く、不正なバイナリ 1 通で DO がリセットされ全接続が落ちる | `src/yjs/index.ts:99-107` | +| H-5 | ストレージキーがゼロパディングされておらず、`list()` の復元順が更新順と一致しない | `src/yjs/storage/storage-key/index.ts:12` | +| H-7 | `queryAwareness`(3) と `auth`(2) が未実装で、`update()` の switch に `default` が無く未知の型を無言で捨てる | `src/yjs/message-type/index.ts:3-6` | + +ストレージ種別は namespace 作成後に変更できない(`storage_type_mismatch`)ため、SQLite への移行はどのみち破壊的変更になる。この一度きりの機会に上記の不具合をすべて解消する。 + +## 2. スコープ + +### 含むもの + +- KV バックエンドから SQLite バックエンドへの完全移行(KV 実装は削除する) +- C-1 〜 C-5、H-1 〜 H-5、H-7 の修正 +- WebSocket Hibernation の衛生(`setWebSocketAutoResponse`、`serializeAttachment` の実用化) +- `destroy()` RPC の追加 +- 既存ユーザー向けの移行手順の文書化と、KV バックエンド検出時の明示的エラー + +### 含まないもの + +- 認証フック(`onBeforeConnect` 等の API 追加)。v2.1 以降に回す +- KV バックエンド向けの互換実装・移行ヘルパーの同梱 +- R2 版履歴、Point-in-Time Recovery、Analytics Engine、Vectorize 連携などの新機能 +- alarm を用いた時間ベースのコンパクションやアイドル部屋のアーカイブ + +## 3. 決定事項 + +| 論点 | 決定 | 理由 | +| --- | --- | --- | +| リリーススコープ | SQLite 移行とバグ修正を v2.0.0 に一括で入れる | ストレージ種別変更が破壊的変更を強制するため、同じリリースにまとめる | +| 既存ユーザーの移行 | ライブラリは移行コードを持たない。KV バックエンドを検出したら手順書 URL 付きで throw し、README に移行レシピを載せる | H-3 修正により v1 の `getYDoc()` → v2 の `updateYDoc()` が成立し、専用コードが不要になる | +| ストレージスキーマ | 単一 `updates` テーブル。スナップショットと増分更新を区別しない | C-2 / C-3 / H-5 が設計から消える。2MB 超のチャンク分割が構造的に自然に扱える | +| コンパクション発火条件 | 行数しきい値(既定 2000 行)と全接続切断時の 2 つのみ。alarm による時間ベースの発火は使わない | 実装が単純でテストしやすい。rows read は rows written の 1/1000 の単価なので、しきい値を高く取るのが最適 | +| awareness の所有権 | `serializeAttachment` に clientID を永続化する | hibernation を跨いで所有権が保たれる。メモリ上の Map のみでは復帰後に幽霊カーソルが残る | +| 永続化失敗時の挙動 | 全接続を閉じ、メモリ上の状態も破棄する | CRDT ではクライアントが完全な状態を保持しており、再接続時に失われた更新が自己修復する | +| ORM / クエリビルダ | 採用しない。SQL は `storage/queries.ts` に隔離する | クエリが 3 本ですべて静的。Drizzle は API が非同期のため暗黙トランザクションの atomicity を壊すリスクがある | +| スキーマ版管理 | `schema_version` テーブルベースの自前マイグレーションランナー | 完全に同期実行でき、依存ゼロで済む。`PRAGMA` は Durable Objects の SQLite では拒否される(下記の検証結果を参照) | + +### ORM を採用しない判断の詳細 + +**Drizzle ORM** は `drizzle-orm/durable-sqlite` で Durable Objects を正式サポートしているが、API が Promise ベースである。本設計はコンパクション時の `DELETE` と複数の `INSERT` が `await` を挟まずに実行されることで暗黙トランザクションの atomicity を得ているため、非同期 API はこの前提を壊すリスクがある。加えて unpacked size が 10MB あり、published library の依存としては重い。 + +**Kysely** は `compile(): CompiledQuery` が同期であり、`{ sql, parameters }` を取り出して自前の `sql.exec()` に渡す「コンパイラとしてのみ使う」構成が可能なため、atomicity の問題は発生しない。unpacked size も 1.7MB と Drizzle の 1/6 である。しかし本設計のクエリは 3 本ですべて静的であり、クエリビルダの価値が発生しない。テーブルが 1 つなので、型安全性は `sql.exec()` に渡す行型を 1 つ手書きすれば実質的に同等に得られる。なお Durable Objects 向けの公式 dialect は `kysely-do@0.0.1-rc.1` のみで、published library の依存としては採用できない(上記の compile-only 構成では dialect 自体が不要)。 + +将来スキーマが増えた場合に備え、SQL 生成は `storage/queries.ts` に隔離する。Kysely への差し替えが必要になった場合、このファイルの内部だけの変更で完結する。 + +## 4. アーキテクチャ + +### ファイル構成 + +``` +src/yjs/ + index.ts YDurableObjects(SQLite 専用) + internal.ts テスト用の内部インターフェース + session/index.ts 新規: SessionRegistry(ws ↔ clientID、attachment 永続化) + storage/ + index.ts YSqliteStorage + type.ts YStorage インターフェース + schema.ts 新規: user_version マイグレーションランナー + queries.ts 新規: SQL 文字列の隔離 + storage-key/ 削除 + remote/ws-shared-doc.ts origin 対応に改修 + message-type/index.ts auth / queryAwareness を追加 + hono/index.ts 変更なし + client/setup.ts 変更なし +src/middleware/index.ts 変更なし +src/index.ts 変更なし(`obj.get(obj.idFromName(id))` の二段形式を維持) +``` + +`SessionRegistry` を独立させることで、awareness の所有権管理・attachment の読み書き・hibernation 復帰時の再構築を 1 箇所に閉じ込め、`YDurableObjects` 本体の肥大化を防ぐ。単体でテスト可能な単位とする。 + +## 5. ストレージ層 + +### スキーマ + +```sql +CREATE TABLE IF NOT EXISTS updates ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + kind INTEGER NOT NULL, -- 0 = 独立した update / 1 = 直前行から続くバイト断片 + data BLOB NOT NULL +); +``` + +`kind` カラムが必要な理由: Yjs の update はバイト列として単純に分割・連結できない。`Y.mergeUpdates([a, b])` は正しく動作するが、1 本の update を機械的に分割した断片は単体では有効な update ではなく、`mergeUpdates` では復元できない。したがってコンパクション結果が SQLite の BLOB 上限を超える場合はバイト断片として分割し、読み出し時に連結してから 1 本の update として扱う必要がある。 + +### インターフェース + +```ts +export interface YStorage { + getUpdate(): Promise; + storeUpdate(update: Uint8Array): Promise; + commit(): Promise; + destroy(): Promise; +} +``` + +`getYDoc(): Promise` は廃止し、`getUpdate(): Promise` にする。呼び出し側は `applyUpdate(this.doc, update)` するだけでよく、v1 の「起動時に Doc を 2 つ構築する」無駄が解消される。 + +### 読み出し + +`SELECT kind, data FROM updates ORDER BY seq` を走査し、`kind = 1` の行は直前のバッファに連結、`kind = 0` で新しい update を開始する。最後に `Y.mergeUpdates([...])` で 1 本にまとめて返す。**Doc を一切構築しない。** + +BLOB カラムは `ArrayBuffer` として返るため、各行で `new Uint8Array(row.data)` への変換が必要である。 + +### 書き込み + +`INSERT INTO updates (kind, data) VALUES (0, ?)` の 1 文のみ。行数はメモリ上のカウンタで保持し、起動時に `SELECT COUNT(*)` で 1 回だけ復元する。v1 の `bytes` / `count` キーへの 2 回の追加 `put` が不要になり、1 更新あたりの書き込みが 3 回から 1 回に減る。 + +### コンパクション + +`maxRows`(既定 2000)に到達したら実行する。 + +1. 全行を `SELECT` し `Y.mergeUpdates` で 1 本化する +2. `DELETE FROM updates` +3. マージ結果を `maxChunkBytes`(1MB)ごとに分割し、先頭を `kind = 0`、以降を `kind = 1` として `INSERT` +4. メモリ上の行数カウンタを分割後のチャンク数で更新する + +`sql.exec` は同期であるため、この一連の書き込みの間に `await` を挟まなければ暗黙のトランザクションとして atomic に適用される。Cloudflare のドキュメントは "Any series of write operations with no intervening `await` will automatically be submitted atomically" と明記している。したがって v1 の `TransactionStorage` 抽象および `storage.transaction()` の利用は不要となり、削除する。 + +加えて、全接続が切断されたとき(`SessionRegistry` が空になったとき)にも `commit()` を実行する。これは v1 の `cleanup()` と同じ発想を維持する。時間ベースの発火(alarm)は導入しない。 + +### コンストラクタオプション + +v1 の `{ maxBytes?, maxUpdates? }` を廃止し、`{ maxRows?, maxChunkBytes? }` に置き換える。 + +| オプション | 既定値 | 意味 | +| --- | --- | --- | +| `maxRows` | 2000 | この行数に到達したらコンパクションを実行する | +| `maxChunkBytes` | 1MB | コンパクション結果を分割する単位。SQLite の 2MB 上限に対する安全マージン | + +`maxBytes` はバイト数ベースの制御であり、KV の 128KiB 値上限に合わせるために存在していた。SQLite では行サイズがコストに影響しないため、この概念は廃止する。 + +### サイズ上限 + +SQLite の BLOB / 行の上限は 2MB のため、チャンクサイズは安全マージンを取って 1MB とする。Durable Object あたりのストレージは 10GB だが、`mergeUpdates` の結果と Yjs の `Doc` をメモリに載せる必要があるため、**実質的な上限は Durable Object の 128MB メモリ**である。この制約は README に明記する。 + +### スキーマ版管理 + +版番号は `schema_version` テーブルに保持する。`PRAGMA` は使えない(後述の検証結果を参照)。 + +```ts +// src/yjs/storage/schema.ts +const MIGRATIONS: readonly string[] = [ + `CREATE TABLE updates ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + kind INTEGER NOT NULL, + data BLOB NOT NULL + )`, +]; + +export const migrate = (sql: SqlStorage): void => { + sql.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"); + const row = sql.exec<{ version: number }>("SELECT version FROM schema_version").toArray().at(0); + const current = row?.version ?? 0; + for (let i = current; i < MIGRATIONS.length; i++) sql.exec(MIGRATIONS[i]); + if (row === undefined) { + sql.exec("INSERT INTO schema_version (version) VALUES (?)", MIGRATIONS.length); + } else { + sql.exec("UPDATE schema_version SET version = ?", MIGRATIONS.length); + } +}; +``` + +すべて同期実行のため `blockConcurrencyWhile` の中で atomic に完了する。将来スキーマを変更する場合は `MIGRATIONS` 配列に `ALTER TABLE` を追記するだけでよい。Durable Object の SQLite はインスタンスごとに独立した DB であるため、各インスタンスがそれぞれ初回起動時に自分のペースでマイグレートする。 + +### 実機で検証済みの挙動 + +設計の前提となる以下の項目を、本リポジトリの `@cloudflare/vitest-pool-workers` 環境(`new_sqlite_classes` に切り替えた状態)で実測した。 + +| 項目 | 結果 | +| --- | --- | +| `PRAGMA user_version` の読み書き | **不可**。`Error: not authorized: SQLITE_AUTH` で throw する | +| `CREATE TABLE ... INTEGER PRIMARY KEY AUTOINCREMENT` | 可 | +| `sql.exec` への `Uint8Array` バインド | 可 | +| `sql.exec` への `ArrayBuffer` バインド | 可 | +| BLOB カラムの戻り値の型 | **`ArrayBuffer`**。`Uint8Array` ではないため読み出し時に変換が必要 | +| `sqlite_master` の SELECT | 可 | +| 既存テスト 41 件を SQLite バックエンドで実行 | 全件 pass。KV API が `__cf_kv` 経由で透過的に動作するため、バックエンド切り替え単体ではコード変更を要しない | + +BLOB が `ArrayBuffer` で返る点は重要である。読み出し時は `new Uint8Array(row.data)` による変換が必須となる。逆に書き込み時は、lib0 のエンコーダが返す `Uint8Array` が大きなバッファへのビューであることが多いため、`u8.buffer` をそのまま渡してはならない。`Uint8Array` を直接バインドできることが確認できたので、変換せずそのまま渡す方針とする。 + +## 6. Durable Object 本体 + +### 6-1. broadcast への origin の伝播(H-1 / H-2) + +`WSSharedDoc` の listener を origin 付きに変更する。 + +```ts +class WSSharedDoc extends Doc { + #listeners = new Map(); // origin(WebSocket) → listener + + notify(origin: object, listener: Listener): Unsubscribe; + + update(message: Uint8Array, origin: object): void { + // sync: readSyncMessage(decoder, encoder, this, origin) + // length(encoder) > 1 なら origin にのみ送信 + // awareness: applyAwarenessUpdate(this.awareness, payload, origin) + } +} +``` + +Yjs は `doc.on("update", (update, origin) => …)` および `awareness.on("update", (changes, origin) => …)` の第 2 引数に transaction origin を渡す。これを利用して、更新のブロードキャストから origin を除外する。 + +- syncStep2 の応答は要求元の接続にのみ返す(H-1) +- 送信者自身にはエコーバックしない(H-2) + +RPC の `updateYDoc()` 経由の更新は origin を持たないため全接続にブロードキャストされる。これは意図した挙動である。 + +### 6-2. awareness の所有権(C-4) + +`awarenessClients: Set` を廃止し、`SessionRegistry` に置き換える。 + +```ts +export type SessionAttachment = { + roomId: string; + connectedAt: number; + clientIds: number[]; +}; +``` + +clientID の特定には 6-1 で伝播させた origin をそのまま使う。 + +```ts +this.doc.awareness.on("update", ({ added, updated }, origin) => { + if (origin instanceof WebSocket) registry.track(origin, [...added, ...updated]); +}); +``` + +`applyAwarenessUpdate(awareness, payload, ws)` に渡した origin が awareness の update イベントまで伝播するため、awareness メッセージをデコードして clientID を抽出する処理は不要である。 + +切断時は該当接続の clientIds のみを `removeAwarenessStates` に渡す。 + +```ts +async webSocketClose(ws: WebSocket) { + removeAwarenessStates(this.doc.awareness, registry.clientIdsOf(ws), null); + registry.remove(ws); + await this.maybeCommit(); +} +``` + +hibernation からの復帰時は `onStart` で `state.getWebSockets()` を走査し、各 WebSocket の `deserializeAttachment()` から `SessionRegistry` を再構築する。v1 で書き込まれていながら一度も読まれていなかった `WebSocketAttachment` が、ここで実際に機能する。 + +### 6-3. 永続化の直列化(C-5) + +```ts +#persist: Promise = Promise.resolve(); + +#schedulePersist(update: Uint8Array): void { + this.#persist = this.#persist + .then(() => this.storage.storeUpdate(update)) + .catch((e) => this.#onPersistFailure(e)); + this.state.waitUntil(this.#persist); +} +``` + +`doc.on("update")` からこれを呼び、`webSocketMessage` の末尾で `await this.#persist` する。宙に浮いた Promise が無くなり、行数カウンタの競合も直列化によって解消する。 + +### 6-4. 永続化失敗時の挙動 + +`#onPersistFailure` は以下を行う。 + +1. エラーをログに出力する +2. `state.getWebSockets()` の全接続を close code `1011`(Internal Error)で閉じる +3. メモリ上の Doc の状態を破棄する + +3 が必要な理由: 書き込み失敗時点でメモリ上の `this.doc` はストレージより進んでおり、DO 自体は生存し続ける。接続を閉じるだけでは、その後に接続したクライアントが「正常に見える」メモリ上の状態を受け取り、DO が evict された時点で差分が無言で失われる。`state.abort()` によって Durable Object をリセットし、次回起動時にストレージから読み直させる方針とする(`state.abort()` の実挙動は実装時に workerd 上で検証する)。 + +この選択が妥当な理由: CRDT ではクライアント側が完全な状態を保持している。`1011` での切断後、y-websocket クライアントは自動再接続し、syncStep1 / syncStep2 の過程で失われた更新を再送するため、障害が自己修復する。 + +### 6-5. 例外境界(H-4) + +```ts +async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + if (!(message instanceof ArrayBuffer)) return; + try { + this.doc.update(new Uint8Array(message), ws); + } catch (e) { + console.error("[y-durableobjects] invalid message", e); + ws.close(1003, "invalid message"); + return; + } + await this.#persist; +} +``` + +不正なバイナリを送った接続のみを閉じ、Durable Object と他の接続は影響を受けない。 + +### 6-6. Hibernation の衛生 + +```ts +this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")); +``` + +文字列メッセージは `webSocketMessage` の先頭で無視されているため、既存の挙動と衝突しない。README に「クライアントが `"ping"` を定期送信すれば Durable Object を起こさずに keepalive できる」ことを明記する。 + +> **訂正(実装後判明)**: 上記の「duration 課金への影響が最も大きい項目」という記述は誤りだった。`WSSharedDoc` が構築する y-protocols の `Awareness` はコンストラクタで repeating `setInterval`(既定 3 秒間隔)を張っており、これが生きている限り Durable Object は ping/pong 対応の有無に関わらずそもそもハイバネートしない。実際に duration 課金へ最も効くのはこの interval を `Awareness` 構築直後に `clearInterval` することであり、ping/pong 自動応答はハイバネーションに到達できるようになって初めて意味を持つ副次的な最適化である。詳細は `src/yjs/remote/ws-shared-doc.ts` の `clearAwarenessCheckInterval()` とその呼び出し箇所のコメントを参照。 + +`acceptWebSocket()` のタグは付けない。clientID は接続確立時点では未確定であり、タグは `acceptWebSocket` 呼び出し時に確定している必要があるため相性が悪い。 + +## 7. 公開 API と移行 + +### RPC API + +```ts +getYDoc(): Promise // 生の Yjs update(v1 から変更なし) +updateYDoc(update: Uint8Array): Promise // 生の Yjs update を受け取る(破壊的変更) +destroy(): Promise // 新規 +``` + +`updateYDoc` からプロトコル framing の要求を外し、内部で `applyUpdate(this.doc, update)` する(H-3)。これにより `getYDoc()` の出力をそのまま `updateYDoc()` に渡せるようになり、README のサンプルが実際に動作する。 + +`destroy()` は全接続を close code `1001` で閉じ、`DELETE FROM updates` を実行する。テーブル定義と `user_version` は維持する。 + +### エクスポートされる型の変更 + +`src/index.ts` から公開している型のうち、以下が破壊的に変更される。 + +| v1 | v2 | 備考 | +| --- | --- | --- | +| `YTransactionStorage` | `YStorage` | `getYDoc(): Promise` が `getUpdate(): Promise` になり、`destroy()` が追加される | +| `WSSharedDoc` | `WSSharedDoc` | `notify(cb)` が `notify(origin, cb)` に、`update(message)` が `update(message, origin)` になる | +| `RemoteDoc` | `RemoteDoc` | 変更なし | + +`TransactionStorage`(`storage/type.ts`)は非公開だったため、削除しても公開 API には影響しない。 + +### KV バックエンドの検出 + +コンストラクタの `blockConcurrencyWhile` 内で SQLite バックエンドかを確認し、そうでなければ移行手順の URL を含むエラーを throw する。`"sql" in storage` による判定は確実でない可能性があるため、`sql.exec("SELECT 1")` を実際に試行する方式とし、workerd 上の実挙動は実装時に検証する。 + +### 移行レシピ(README に掲載) + +v1 と v2 を別バインディングに共存させ、RPC で内容をコピーする。 + +```ts +app.post("/migrate/:id", async (c) => { + const id = c.req.param("id"); + const legacy = c.env.Y_LEGACY.get(c.env.Y_LEGACY.idFromName(id)); // v1 / KV バックエンド + const next = c.env.Y_DURABLE_OBJECTS.get( + c.env.Y_DURABLE_OBJECTS.idFromName(id), + ); // v2 / SQLite バックエンド + await next.updateYDoc(await legacy.getYDoc()); + return c.json({ ok: true }); +}); +``` + +v1 の `getYDoc()` は元から生の update を返しており、v2 の `updateYDoc()` は生の update を受け取るため、この組み合わせが成立する。ライブラリ側に移行専用コードを持つ必要がない。 + +> **`getByName` を採用しなかった理由**: 本リポジトリにコミットされている +> `worker-configuration.d.ts` の `DurableObjectNamespace` 宣言には +> `newUniqueId` / `idFromName` / `idFromString` / `get` / `jurisdiction` しか +> 存在せず、`getByName` は宣言されていない。そのため `getByName` を使うコード +> は本リポジトリの型定義ではコンパイルできない。実装(`src/index.ts` の +> `yRoute`)およびこの移行レシピは、いずれも従来どおり +> `obj.get(obj.idFromName(id))` の二段形式を採用する。 + +### 設定ファイル + +- `wrangler.toml` および README の migration を `new_sqlite_classes` に変更する +- `compatibility_date` を更新する +- README では新しい `exports` 形式にも言及する + +### メッセージ型(H-7) + +`message-type/index.ts` に `auth`(2) と `queryAwareness`(3) を追加し、`WSSharedDoc.update()` の switch に `default` 節を設けて未知の型を無言で捨てないようにする。 + +### `yRoute` + +`obj.get(obj.idFromName(id))` の二段形式を維持する。`getByName` への置き換えは行わない +— 本リポジトリの `worker-configuration.d.ts` の `DurableObjectNamespace` 宣言に +`getByName` が存在しないため、置き換えると型チェックが通らない。挙動は変更しない。 + +## 8. テスト戦略 + +実装は TDD で進める。既知の不具合については、先に失敗するテストを書いてから修正する。 + +`wrangler.toml` のテスト環境も `new_sqlite_classes` に切り替える。現行の `src/yjs/storage/storage.test.ts` は `TransactionStorage` をモックする方式だが、v2 ではテスト対象が SQLite の挙動そのものになるため、`runInDurableObject` 経由で実物の `ctx.storage.sql` を使う統合テストに書き換える。 + +| # | 検証内容 | 対応 ID | +| --- | --- | --- | +| 1 | 2MB を超えるドキュメントがチャンク分割され、round-trip で復元できる | C-2 | +| 2 | しきい値到達でコンパクションが走り、行数が減って内容が保たれる | C-3 | +| 3 | 3 接続のうち 1 つを切断しても、他 2 接続の awareness state が残る | C-4 | +| 4 | 永続化失敗時に全接続が閉じ、メモリ上の状態が破棄される | 6-4 | +| 5 | syncStep2 が要求元にのみ返り、他の接続には届かない | H-1 | +| 6 | 送信者に自分の更新がエコーバックされない | H-2 | +| 7 | `getYDoc()` → `updateYDoc()` の round-trip が成立する | H-3 | +| 8 | 不正なバイナリでその接続のみ閉じ、DO と他接続は生存する | H-4 | +| 9 | 1000 件の更新が `seq` 順に復元される | H-5 | +| 10 | `deserializeAttachment` から awareness の所有権が復元される | 6-2 | +| 11 | `schema_version` マイグレーションが冪等である(2 回実行しても壊れない) | 5 | + +### テスト上の制約 + +`@cloudflare/vitest-pool-workers` には hibernation を強制的に発生させる API が存在しない。したがって #10 は「新しい `SessionRegistry` を生成し、既存の WebSocket の attachment から所有権を再構築できること」をユニットレベルで検証する形になり、実際の hibernation 往復は E2E では検証できない。 + +## 9. 既知の限界 + +- ドキュメントサイズの実質的な上限は Durable Object の 128MB メモリである。SQLite の 10GB は活用しきれない +- 実際の hibernation 往復を自動テストで検証できない(8 節参照) +- KV バックエンドで稼働中の既存ユーザーは、手動でのデータ移行が必要である +- `state.abort()` の実挙動は未検証であり、実装時に確認が必要である +- `sql.exec("SELECT 1")` による KV バックエンド検出の確実性は未検証であり、実装時に確認が必要である + +## 10. 参考 + +- [Durable Objects Pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/) +- [Durable Objects Limits](https://developers.cloudflare.com/durable-objects/platform/limits/) +- [Durable Object Storage API](https://developers.cloudflare.com/durable-objects/api/storage-api/) +- [Durable Objects Migrations](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/) diff --git a/eslint.config.js b/eslint.config.js index 7990ab0..d2c0cb8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,11 @@ const standard = fixupConfigRules(compat.config({ extends: ["standard"] })); /** @type {import("eslint").Linter.Config[]} */ const config = [ + { + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + }, eslint.configs.recommended, ...standard, { @@ -77,7 +82,10 @@ const config = [ DurableObjectStub: "readonly", DurableObjectTransaction: "readonly", DurableObjectState: "readonly", + DurableObjectStorage: "readonly", + SqlStorage: "readonly", WebSocketPair: "readonly", + WebSocketRequestResponsePair: "readonly", RequestInfo: "readonly", }, }, diff --git a/src/e2e/e2e.test.ts b/src/e2e/e2e.test.ts index 4604350..f846bb7 100644 --- a/src/e2e/e2e.test.ts +++ b/src/e2e/e2e.test.ts @@ -2,7 +2,7 @@ import { SELF, env, runInDurableObject } from "cloudflare:test"; import { hc } from "hono/client"; import { fromUint8Array } from "js-base64"; -import { createSyncMessage, createYDocMessage } from "./helper"; +import { createYDocMessage } from "./helper"; import type { AppType } from "."; import type { InternalYDurableObject } from "../yjs/internal"; @@ -56,10 +56,9 @@ describe("endpoint request", () => { const id = env.Y_DURABLE_OBJECTS.idFromName(roomId); const stub = env.Y_DURABLE_OBJECTS.get(id); const message = createYDocMessage("get state"); - const update = createSyncMessage(message); await runInDurableObject(stub, async (instance: InternalYDurableObject) => { - await instance.updateYDoc(update.slice(0)); + await instance.updateYDoc(message.slice(0)); }); const res = await SELF.fetch(`http://localhost/rooms/${roomId}/state`); @@ -71,7 +70,6 @@ describe("endpoint request", () => { it("should update the YDoc state", async () => { const message = createYDocMessage("get state"); - const update = createSyncMessage(message); const roomId = "1"; const id = env.Y_DURABLE_OBJECTS.idFromName(roomId); @@ -79,7 +77,7 @@ describe("endpoint request", () => { const res = await SELF.fetch(`http://localhost/rooms/${roomId}/update`, { method: "POST", - body: update.slice(0).buffer, + body: message.slice(0).buffer, }); expect(res.status).toBe(200); diff --git a/src/e2e/y-durableobjects.test.ts b/src/e2e/y-durableobjects.test.ts index 2ca7713..894226e 100644 --- a/src/e2e/y-durableobjects.test.ts +++ b/src/e2e/y-durableobjects.test.ts @@ -1,14 +1,43 @@ import { env, runInDurableObject } from "cloudflare:test"; import { hc } from "hono/client"; -import { expect, describe, it } from "vitest"; +import { createDecoder, readVarUint } from "lib0/decoding"; +import { + createEncoder, + toUint8Array, + writeVarUint, + writeVarUint8Array, +} from "lib0/encoding"; +import { expect, describe, it, vi } from "vitest"; +import { Awareness, encodeAwarenessUpdate } from "y-protocols/awareness"; +import { readSyncMessage } from "y-protocols/sync"; +import { Doc, applyUpdate, encodeStateAsUpdate } from "yjs"; import { YDurableObjects } from "../yjs"; +import { messageType } from "../yjs/message-type"; +import { YSqliteStorage } from "../yjs/storage"; import { createSyncMessage, createYDocMessage } from "./helper"; import type { YDurableObjectsAppType } from "../yjs"; import type { InternalYDurableObject } from "../yjs/internal"; +// Encodes a real awareness protocol message, the way an actual client would. +// Sending this through webSocketMessage() is the only way to exercise the +// production path that decides ownership: WSSharedDoc.update() passes the +// receiving WebSocket as `origin` into applyAwarenessUpdate(), which is what +// the awareness "update" handler in onStart() branches on with +// `origin instanceof WebSocket`. +const createAwarenessMessage = (awareness: Awareness) => { + const encoder = createEncoder(); + writeVarUint(encoder, messageType.awareness); + writeVarUint8Array( + encoder, + encodeAwarenessUpdate(awareness, [awareness.clientID]), + ); + + return toUint8Array(encoder); +}; + describe("YDurableObjects", () => { it("initializes correctly", async () => { const id = env.Y_DURABLE_OBJECTS.newUniqueId(); @@ -21,6 +50,41 @@ describe("YDurableObjects", () => { }); }); + it("rehydrates the document from SQLite storage on startup", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject( + stub, + async (instance: InternalYDurableObject, state) => { + // Write directly into this instance's SQLite storage, bypassing + // instance.doc entirely, so that the only way for the content to + // reach instance.doc is through onStart()'s getUpdate()/applyUpdate + // wiring — the exact seam this task changed. + const storage = new YSqliteStorage(state.storage.sql); + const seed = new Doc(); + seed.getText("root").insert(0, "Hello World!"); + await storage.storeUpdate(encodeStateAsUpdate(seed)); + + // vitest-pool-workers keeps this Durable Object instance alive for + // the whole worker lifetime, so there is no way to force a genuinely + // fresh construction here. Invoking onStart() directly is the + // workable substitute for a cold start: it re-runs the exact + // rehydration logic construction would have run. + // + // onStart() guards its doc "update" and awareness "update" listener + // registration with a `listenersRegistered` flag (Task 7), so this + // second call re-runs rehydration without re-registering either + // listener. That guard doesn't affect this assertion either way + // (rehydration reads storage once, before any listener fires) — it's + // just why calling onStart() twice here is safe to do at all. + await instance.onStart(); + + expect(instance.doc.getText("root").toString()).toBe("Hello World!"); + }, + ); + }); + it("create a room from request", async () => { const id = env.Y_DURABLE_OBJECTS.newUniqueId(); const stub = env.Y_DURABLE_OBJECTS.get(id); @@ -54,17 +118,90 @@ describe("YDurableObjects", () => { }); }); - it("updates YDoc correctly", async () => { + it("round-trips between getYDoc and updateYDoc", async () => { + const source = env.Y_DURABLE_OBJECTS.get( + env.Y_DURABLE_OBJECTS.newUniqueId(), + ); + const target = env.Y_DURABLE_OBJECTS.get( + env.Y_DURABLE_OBJECTS.newUniqueId(), + ); + + const message = createYDocMessage("Hello World!"); + await runInDurableObject( + source, + async (instance: InternalYDurableObject) => { + await instance.updateYDoc(message.slice(0)); + }, + ); + + const exported = await runInDurableObject( + source, + (instance: InternalYDurableObject) => instance.getYDoc(), + ); + await runInDurableObject( + target, + async (instance: InternalYDurableObject) => { + // getYDoc の出力をそのまま updateYDoc に渡せる + await instance.updateYDoc(exported); + }, + ); + + const copied = await runInDurableObject( + target, + (instance: InternalYDurableObject) => instance.getYDoc(), + ); + + const doc = new Doc(); + applyUpdate(doc, copied); + expect(doc.getText("root").toString()).toBe("Hello World!"); + }); + + it("broadcasts an updateYDoc change to connected WebSocket clients", async () => { const id = env.Y_DURABLE_OBJECTS.newUniqueId(); const stub = env.Y_DURABLE_OBJECTS.get(id); await runInDurableObject(stub, async (instance: InternalYDurableObject) => { - const message = createYDocMessage(); - const update = createSyncMessage(message); - await instance.updateYDoc(update.slice(0)); + await instance.createRoom("room1"); + const [server] = Array.from(instance.sessions.sockets()); - const docState = await instance.getYDoc(); - expect(docState).toEqual(message); + // registerWebSocket() already sent an initial sync/awareness pair + // synchronously when the room was created above; only care about + // what gets sent in response to updateYDoc, so install the spy + // after that initial handshake has already happened. + const sent = vi.fn(); + server.send = sent; + + const message = createYDocMessage("via rpc"); + await instance.updateYDoc(message.slice(0)); + + // applyUpdate(this.doc, update, RPC_ORIGIN) must fire WSSharedDoc's + // "update" handler, which calls broadcast(msg, RPC_ORIGIN) — and + // broadcast only excludes an origin that is itself a registered + // listener key. RPC_ORIGIN is never registered (only WebSockets are, + // via notify()), so every connected socket, including this one, + // must receive the broadcast. + expect(sent).toHaveBeenCalled(); + + const syncMessage = sent.mock.calls + .map((call: unknown[]) => call[0]) + .find((raw): raw is Uint8Array => { + if (!(raw instanceof Uint8Array)) return false; + const decoder = createDecoder(raw); + + return readVarUint(decoder) === messageType.sync; + }); + expect(syncMessage).toBeDefined(); + + // Decode past the outer messageType.sync wrapper and apply the inner + // sync-protocol payload to a fresh Doc exactly as a real client would + // on receipt. This proves the *content* of the RPC update actually + // reached the socket, not merely that some send() happened to fire. + const decoder = createDecoder(syncMessage as Uint8Array); + readVarUint(decoder); // messageType.sync, already checked above + const received = new Doc(); + readSyncMessage(decoder, createEncoder(), received, null); + + expect(received.getText("root").toString()).toBe("via rpc"); }); }); @@ -92,7 +229,7 @@ describe("YDurableObjects", () => { await runInDurableObject(stub, async (instance: InternalYDurableObject) => { const roomId = "room1"; await instance.createRoom(roomId); - const [server] = Array.from(instance.sessions.entries()).at(0)!; + const [server] = Array.from(instance.sessions.sockets()); await instance.webSocketError(server); @@ -107,11 +244,624 @@ describe("YDurableObjects", () => { await runInDurableObject(stub, async (instance: InternalYDurableObject) => { const roomId = "room1"; await instance.createRoom(roomId); - const [server] = Array.from(instance.sessions.entries()).at(0)!; + const [server] = Array.from(instance.sessions.sockets()); await instance.webSocketClose(server); expect(instance.sessions.size).toBe(0); }); }); + + it("keeps other clients' awareness when one connection closes", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + await instance.createRoom("room1"); + const [first, second] = Array.from(instance.sessions.sockets()); + + // Each connection publishes real awareness state through an actual + // awareness-protocol message, the same way "derives awareness + // ownership from the WebSocket origin observed at runtime" does. This + // is essential: driving state through the real onStart() awareness + // handler is what makes removeAwarenessStates() below have anything + // to remove, which is the exact path that broke when + // unregisterWebSocket() removed awareness states while the departing + // socket was still a registered WSSharedDoc listener (broadcast() + // would call send() on an already-closed socket and throw). A version + // of this test that sets clientIds by calling sessions.track() + // directly, or by calling awareness.setLocalStateField() on the + // shared doc (which fires under the *local* client id, not either + // socket's), would never exercise that path and would pass even + // against the broken ordering. + const firstAwareness = new Awareness(new Doc()); + firstAwareness.setLocalStateField("user", { name: "a" }); + await instance.webSocketMessage( + first, + createAwarenessMessage(firstAwareness).slice(0).buffer, + ); + + const secondAwareness = new Awareness(new Doc()); + secondAwareness.setLocalStateField("user", { name: "b" }); + await instance.webSocketMessage( + second, + createAwarenessMessage(secondAwareness).slice(0).buffer, + ); + + expect(instance.sessions.clientIdsOf(first)).toEqual([ + firstAwareness.clientID, + ]); + expect(instance.sessions.clientIdsOf(second)).toEqual([ + secondAwareness.clientID, + ]); + + // Close `first` the way a real disconnect would leave it: the + // underlying socket is already closed by the time webSocketClose() + // fires, so any send() on it throws. This reproduces the exact + // condition the ordering bug hit — a throwing listener still + // registered in WSSharedDoc when removeAwarenessStates() runs. + first.close(); + + // (a) the call completes without throwing, even though `first` is a + // dead socket and, until the fix, awareness removal still tried to + // broadcast to it. + await expect(instance.webSocketClose(first)).resolves.toBeUndefined(); + + // (b) the departing socket is fully unregistered — no leaked session + // or listener. + expect(instance.sessions.has(first)).toBe(false); + expect(instance.sessions.size).toBe(1); + expect(instance.sessions.clientIdsOf(second)).toEqual([ + secondAwareness.clientID, + ]); + + // (c) the remaining connection's awareness state survives; only the + // departing connection's clientId was removed from the room. + const states = instance.doc.awareness.getStates(); + expect(states.has(secondAwareness.clientID)).toBe(true); + expect(states.has(firstAwareness.clientID)).toBe(false); + }); + }); + + it("unsubscribes the departing connection before broadcasting its awareness removal", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + const [server] = Array.from(instance.sessions.sockets()); + + // The departing socket must genuinely own awareness state, or + // removeAwarenessStates() in unregisterWebSocket() has nothing to + // remove and never emits "update" — in which case the handler below + // would never run and the observation would never be recorded, + // making the test vacuously pass no matter the ordering. + const awareness = new Awareness(new Doc()); + awareness.setLocalStateField("user", { name: "a" }); + await instance.webSocketMessage( + server, + createAwarenessMessage(awareness).slice(0).buffer, + ); + expect(instance.sessions.clientIdsOf(server)).toEqual([ + awareness.clientID, + ]); + + // Assert the ordering invariant behaviourally, without relying on + // ws.send() throwing (the broadcast guard added for Finding 2 means a + // throw there no longer surfaces as a test failure on its own — see + // the toggle-off note in the report for why that guard alone doesn't + // prove the ordering is right). Capture the observation inside the + // handler rather than asserting inside it, so a failure surfaces as a + // normal assertion in the test body instead of an exception thrown + // from deep inside Awareness's emit() and swallowed by + // unregisterWebSocket()'s own try/catch. + let serverStillRegisteredWhenAwarenessUpdateFired: boolean | undefined; + instance.doc.awareness.on("update", () => { + serverStillRegisteredWhenAwarenessUpdateFired = + instance.sessions.has(server); + }); + + await instance.webSocketClose(server); + + // Guard against a vacuous pass: the handler must actually have run. + expect(serverStillRegisteredWhenAwarenessUpdateFired).not.toBeUndefined(); + // The session must already be gone by the time the awareness removal + // broadcasts — i.e. sessions.remove(ws) ran before + // removeAwarenessStates(...) in unregisterWebSocket(). + expect(serverStillRegisteredWhenAwarenessUpdateFired).toBe(false); + }); + }); + + it("derives awareness ownership from the WebSocket origin observed at runtime", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + const [server] = Array.from(instance.sessions.sockets()); + + // Simulate a remote peer announcing its presence, exactly as a real + // Yjs client would over the wire. + const remoteDoc = new Doc(); + const remoteAwareness = new Awareness(remoteDoc); + remoteAwareness.setLocalStateField("user", { name: "remote" }); + + const message = createAwarenessMessage(remoteAwareness); + await instance.webSocketMessage(server, message.slice(0).buffer); + + // sessions.track() is only ever called from the awareness "update" + // handler wired in onStart(), and only when `origin instanceof + // WebSocket` is true. This test never calls sessions.track() itself, + // so if that instanceof check silently failed to match (e.g. because + // WebSocketPair sockets, or the sockets returned by + // state.getWebSockets() after hibernation, are not real WebSocket + // instances in this runtime), clientIdsOf(server) would stay empty + // here and the assertion below would fail. + expect(instance.sessions.clientIdsOf(server)).toEqual([ + remoteAwareness.clientID, + ]); + }); + }); + + it("closes only the offending connection on a malformed message", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + await instance.createRoom("room1"); + const [first, other] = Array.from(instance.sessions.sockets()); + + // 未知のメッセージ型。例外が外に漏れると DO 全体がリセットされる + const malformed = new Uint8Array([99]).buffer; + await expect( + instance.webSocketMessage(first, malformed), + ).resolves.toBeUndefined(); + + // DO は生存し、他の(無関係な)接続も維持されている。webSocketClose + // が `first` に対して呼ばれるかどうかはランタイムの詳細であり、この + // テストが依存すべき性質ではない。ここで確かめるべきは「DO がリセット + // されず、正常な接続が生き残ること」だけ。 + expect(instance.sessions.has(other)).toBe(true); + + // 実際に不正なメッセージを送った接続自体は 1003 で閉じられている。 + // これがないと、例外境界が ws.close(1003, ...) を一切呼ばない + // (単に catch { return; } するだけの) 実装でもこのテストは通ってしまう。 + expect(first.readyState).not.toBe(WebSocket.READY_STATE_OPEN); + }); + }); + + it("unregisters the session immediately when the exception boundary closes a socket, without relying on webSocketClose", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + const [server] = Array.from(instance.sessions.sockets()); + + // Give it real awareness state to remove, so the assertions below + // actually exercise removeAwarenessStates() rather than a no-op. + const awareness = new Awareness(new Doc()); + awareness.setLocalStateField("user", { name: "a" }); + await instance.webSocketMessage( + server, + createAwarenessMessage(awareness).slice(0).buffer, + ); + expect(instance.sessions.clientIdsOf(server)).toEqual([ + awareness.clientID, + ]); + + // 未知のメッセージ型。例外境界がこのソケットを 1003 で閉じる。 + const malformed = new Uint8Array([99]).buffer; + await instance.webSocketMessage(server, malformed); + + // This DO closed the socket itself; webSocketClose is not guaranteed + // to fire for a close the DO initiated. Without the exception + // boundary's own explicit unregisterWebSocket() call, the session and + // this socket's awareness state would leak indefinitely (or until + // webSocketClose happens to fire on its own), instead of being gone + // by the time webSocketMessage() resolves. + expect(instance.sessions.has(server)).toBe(false); + expect(instance.doc.awareness.getStates().has(awareness.clientID)).toBe( + false, + ); + }); + }); + + it("still unregisters the session and resolves when close() throws inside the malformed-message exception boundary", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + const [server] = Array.from(instance.sessions.sockets()); + + const awareness = new Awareness(new Doc()); + awareness.setLocalStateField("user", { name: "a" }); + await instance.webSocketMessage( + server, + createAwarenessMessage(awareness).slice(0).buffer, + ); + expect(instance.sessions.clientIdsOf(server)).toEqual([ + awareness.clientID, + ]); + + // workerd throws if close() is called on a socket that is already + // closed or errored -- exactly what could be true of the socket that + // just sent a malformed frame. Simulate that here. + server.close = () => { + throw new Error("already closed"); + }; + + const malformed = new Uint8Array([99]).buffer; + + // webSocketMessage must still resolve normally: the close() throwing + // must not escape the exception boundary and reject the call, or the + // whole room resets on a single malformed frame -- the exact outage + // H-4 exists to prevent. + await expect( + instance.webSocketMessage(server, malformed), + ).resolves.toBeUndefined(); + + // And unregisterWebSocket() must still have run despite close() + // throwing, or the session/awareness leak IMPORTANT 3 fixed comes + // back for any socket close() fails on. + expect(instance.sessions.has(server)).toBe(false); + expect(instance.doc.awareness.getStates().has(awareness.clientID)).toBe( + false, + ); + }); + }); + + it("persists an update before webSocketMessage resolves", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + const client = await instance.createRoom("room1"); + + // storeUpdate に人為的な遅延を挟む。SQLite への実書き込みは同期 API + // なので、遅延を入れずに `await this.persist` を webSocketMessage から + // 消してみても、たまたまマイクロタスクの実行順序だけで書き込みが先に + // 終わってしまい、「直列化されている」ことを何も検証しない空振りの + // テストになる(実際に確認した。トグルオフ検証は報告書を参照)。 + let persisted = false; + const originalStoreUpdate = instance.storage.storeUpdate.bind( + instance.storage, + ); + instance.storage.storeUpdate = async (update: Uint8Array) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + await originalStoreUpdate(update); + persisted = true; + }; + + const message = createSyncMessage(createYDocMessage("persisted")); + await instance.webSocketMessage(client, message.slice(0).buffer); + + // webSocketMessage が解決した時点で、遅延込みの永続化が完了している + expect(persisted).toBe(true); + + // ストレージから読み直しても内容が入っている + const stored = await instance.storage.getUpdate(); + expect(stored).not.toBeNull(); + }); + }); + + it("closes every connection and asks the runtime to reset when persistence fails", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject( + stub, + async (instance: InternalYDurableObject, state: DurableObjectState) => { + const first = await instance.createRoom("room1"); + await instance.createRoom("room1"); + const sockets = Array.from(instance.sessions.sockets()); + + // state.abort() の"本物"の実装は、この Durable Object の + // io-context(≒このテストの runInDurableObject 呼び出しそのもの) + // を丸ごと破棄する。実際に呼ばせると、それを待っている + // runInDurableObject() 自身の Promise が二度と解決/reject されず、 + // このファイルどころか単一ランタイム上の以降の全テストごと + // ハングすることを確認済み(トグルオフ検証時に実測、報告書に記載)。 + // ここで検証したいのは「失敗ハンドラが state.abort() を正しい理由 + // 付きで呼び、全ソケットを 1011 で閉じたか」という自分のコードの + // 振る舞いであり、workerd 自身の abort 実装の正しさではないため、 + // abort() をスパイに差し替えて実行だけ観測する。 + let abortCalled = false; + let abortReason: string | undefined; + state.abort = (reason?: string) => { + abortCalled = true; + abortReason = reason; + }; + + instance.storage.storeUpdate = async () => { + throw new Error("simulated storage failure"); + }; + + const message = createSyncMessage( + createYDocMessage("will not persist"), + ); + + // webSocketMessage 自体は例外を投げずに解決する + // (Step 5 の try/catch は doc.update() の同期例外用。永続化の失敗は + // schedulePersist の中で catch され、webSocketMessage はそれを + // 待つだけなので reject しない)。 + await expect( + instance.webSocketMessage(first, message.slice(0).buffer), + ).resolves.toBeUndefined(); + + for (const ws of sockets) { + expect(ws.readyState).not.toBe(WebSocket.READY_STATE_OPEN); + } + expect(abortCalled).toBe(true); + expect(abortReason).toBe("failed to persist a Yjs update"); + }, + ); + }); + + it("still closes the other sockets and reaches abort() when one socket's close() throws", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject( + stub, + async (instance: InternalYDurableObject, state: DurableObjectState) => { + await instance.createRoom("room1"); + await instance.createRoom("room1"); + const [troublesome, healthy] = Array.from(instance.sessions.sockets()); + + // workerd throws if close() is called on a socket that is already + // closed or errored — exactly what state.getWebSockets() can hand + // the failure handler: the socket the exception boundary just + // closed with 1003, or the socket whose own error caused this + // failure in the first place. Simulate that here. + troublesome.close = () => { + throw new Error("already closed"); + }; + + let abortCalled = false; + state.abort = () => { + abortCalled = true; + }; + + instance.storage.storeUpdate = async () => { + throw new Error("simulated storage failure"); + }; + + const message = createSyncMessage( + createYDocMessage("will not persist"), + ); + + // webSocketMessage still resolves normally: the per-socket + // try/catch in onPersistFailure must contain the throwing close(), + // not let it escape and reject the persist chain. + await expect( + instance.webSocketMessage(troublesome, message.slice(0).buffer), + ).resolves.toBeUndefined(); + + // The other socket is not left open just because one close() threw. + expect(healthy.readyState).not.toBe(WebSocket.READY_STATE_OPEN); + // And critically, abort() — the actual point of this failure + // policy — is still reached despite the throw. + expect(abortCalled).toBe(true); + }, + ); + }); + + it("configures a ping/pong auto response", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (_instance, state) => { + const pair = state.getWebSocketAutoResponse(); + + expect(pair?.request).toBe("ping"); + expect(pair?.response).toBe("pong"); + }); + }); + + it("clears the document and closes connections on destroy", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + const [server] = Array.from(instance.sessions.sockets()); + await instance.updateYDoc(createYDocMessage("bye").slice(0)); + + await instance.destroy(); + + // Storage is cleared. + expect(await instance.storage.getUpdate()).toBeNull(); + + // The connection is actually closed, not just left registered. + expect(server.readyState).not.toBe(WebSocket.READY_STATE_OPEN); + expect(instance.sessions.size).toBe(0); + + // The in-memory Doc is discarded too, not just storage. Otherwise a + // client connecting to this still-live instance after destroy() would + // receive the old content via sync step 1/2, and subsequent deltas + // would reference struct parents that no longer exist anywhere. + expect(instance.doc.getText("root").toString()).toBe(""); + }); + }); + + it("destroys the replaced document instead of only discarding it", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + const oldDoc = instance.doc; + + await instance.destroy(); + + // destroy() replaces this.doc with a fresh WSSharedDoc. WSSharedDoc's + // constructor builds a y-protocols Awareness, which installs a + // repeating setInterval and a `doc.on('destroy', ...)` listener that + // stay alive until the Yjs document itself is destroyed. If the old + // doc is merely dropped (`this.doc = new WSSharedDoc()`) instead of + // destroyed first, that interval and listener leak for the rest of + // this Durable Object's lifetime -- once per room destruction. + // + // Y.Doc.destroy() sets `isDestroyed = true` and emits 'destroy', + // which is what Awareness listens for to clear its own interval (see + // node_modules/.pnpm/y-protocols@*/node_modules/y-protocols/awareness.js, + // lines 78-88: `doc.on('destroy', () => this.destroy())`, and + // `destroy()` calls `clearInterval(this._checkInterval)`). isDestroyed + // is the only state destroy() leaves behind that a test can observe + // directly -- the timer/listener cleanup itself isn't inspectable + // from here. + expect(oldDoc.isDestroyed).toBe(true); + expect(instance.doc).not.toBe(oldDoc); + expect(instance.doc.isDestroyed).toBe(false); + }); + }); + + it("drains the persistence queue before deleting so a slow write cannot resurrect data after destroy", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + const client = await instance.createRoom("room1"); + + // Delay storeUpdate so it is still in flight (queued on `this.persist`) + // when destroy() starts running -- the same hazard a real slow SQLite + // write could hit, made deterministic here. + const originalStoreUpdate = instance.storage.storeUpdate.bind( + instance.storage, + ); + instance.storage.storeUpdate = async (update: Uint8Array) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + await originalStoreUpdate(update); + }; + + const message = createSyncMessage(createYDocMessage("late")); + // Deliberately not awaited: doc.update() runs synchronously inside + // this call and enqueues the delayed write onto `this.persist` before + // this line returns, but the write itself is still pending. + const pending = instance.webSocketMessage( + client, + message.slice(0).buffer, + ); + + // destroy() must await the same persist queue before deleting, or the + // delayed write above would land after DELETE FROM updates and + // resurrect an orphan row. + await instance.destroy(); + await pending; + + expect(await instance.storage.getUpdate()).toBeNull(); + }); + }); + + it("keeps registering the remaining sockets when an earlier one throws on the hibernation-wake path", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject( + stub, + async (instance: InternalYDurableObject, state: DurableObjectState) => { + // Simulate what onStart() sees on a real hibernation wake: sockets + // already accepted by the runtime (state.getWebSockets()), with the + // in-memory SessionRegistry empty because this process never + // registered them. Accept two: the loop must not let the first + // socket's failure stop it from reaching the second. + const brokenPair = new WebSocketPair(); + const brokenServer = brokenPair[1]; + brokenServer.serializeAttachment({ + roomId: "room1", + connectedAt: 0, + clientIds: [], + }); + state.acceptWebSocket(brokenServer); + + const healthyPair = new WebSocketPair(); + const healthyServer = healthyPair[1]; + healthyServer.serializeAttachment({ + roomId: "room1", + connectedAt: 0, + clientIds: [], + }); + state.acceptWebSocket(healthyServer); + + // registerWebSocket() -> setupWSConnection() -> ws.send(). Simulate + // the socket workerd hands back already dead, the way it can on + // hibernation wake. + brokenServer.send = () => { + throw new Error("simulated dead socket on hibernation wake"); + }; + + expect(instance.sessions.has(healthyServer)).toBe(false); + + // onStart() is what a real cold start / hibernation wake runs. + await expect(instance.onStart()).resolves.toBeUndefined(); + + // The broken socket's registration failure must not have stopped + // the loop before it reached the socket after it. + expect(instance.sessions.has(healthyServer)).toBe(true); + }, + ); + }); + + it("restores session ownership from a socket's attachment when onStart() re-registers it", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject( + stub, + async (instance: InternalYDurableObject, state: DurableObjectState) => { + // Simulate what a real hibernation wake leaves behind: a WebSocket + // that state.getWebSockets() already returns, carrying a + // SessionAttachment with clientIds recorded by a prior connection, + // but with the in-memory SessionRegistry never having seen it in + // this process (it never called sessions.add() for it before now). + const pair = new WebSocketPair(); + const server = pair[1]; + server.serializeAttachment({ + roomId: "room1", + connectedAt: 0, + clientIds: [42], + }); + state.acceptWebSocket(server); + + expect(instance.sessions.has(server)).toBe(false); + + // onStart() is the actual reconstruction path a cold start / + // hibernation wake runs -- not a stand-in for it. Real hibernation + // cannot be forced in this test environment, so this drives the + // genuine code path instead of faking eviction/restart. + await instance.onStart(); + + expect(instance.sessions.has(server)).toBe(true); + expect(instance.sessions.clientIdsOf(server)).toEqual([42]); + }, + ); + }); + + it("still destroys storage when one socket's close() throws", async () => { + const id = env.Y_DURABLE_OBJECTS.newUniqueId(); + const stub = env.Y_DURABLE_OBJECTS.get(id); + + await runInDurableObject(stub, async (instance: InternalYDurableObject) => { + await instance.createRoom("room1"); + await instance.createRoom("room1"); + const [troublesome] = Array.from(instance.sessions.sockets()); + + // Same workerd hazard as onPersistFailure: close() on an + // already-closed/errored socket throws. destroy()'s per-socket + // try/catch must contain it so storage.destroy() is still reached — + // otherwise the caller is told the room was destroyed while its data + // is still on disk. + troublesome.close = () => { + throw new Error("already closed"); + }; + + await instance.updateYDoc(createYDocMessage("bye").slice(0)); + + await expect(instance.destroy()).resolves.toBeUndefined(); + expect(await instance.storage.getUpdate()).toBeNull(); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index d039852..f6006c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,5 +37,7 @@ export const yRoute = (selector: Selector) => { export { YDurableObjects, type YDurableObjectsAppType } from "./yjs"; export type YRoute = ReturnType; -export type { YTransactionStorage } from "./yjs/storage"; +export type { SessionAttachment } from "./yjs"; +export { YSqliteStorage } from "./yjs/storage"; +export type { YStorage, YSqliteStorageOptions } from "./yjs/storage"; export { type RemoteDoc, WSSharedDoc } from "./yjs/remote"; diff --git a/src/yjs/index.ts b/src/yjs/index.ts index 684f0bf..1ae6d94 100644 --- a/src/yjs/index.ts +++ b/src/yjs/index.ts @@ -6,16 +6,39 @@ import { WSSharedDoc } from "../yjs/remote"; import { setupWSConnection } from "./client/setup"; import { createApp } from "./hono"; -import { YTransactionStorageImpl } from "./storage"; +import { SessionRegistry } from "./session"; +import { YSqliteStorage } from "./storage"; +import type { SessionAttachment } from "./session"; import type { AwarenessChanges } from "../yjs/remote"; import type { Env } from "hono"; -export type WebSocketAttachment = { - roomId: string; - connectedAt: Date; +/** WebSocket 由来でない更新(JS RPC 経由)の origin */ +const RPC_ORIGIN: object = Object.freeze({ source: "rpc" }); + +const MIGRATION_GUIDE_URL = + "https://github.com/napolab/y-durableobjects#migrating-from-v1-key-value-backend"; + +/** + * SQLite バックエンドで動作しているかを確認する。 + * KV バックエンドでは sql へのアクセスが失敗するため、 + * 原因不明のクラッシュではなく移行手順を示したエラーにする。 + */ +const assertSqliteBackend = (storage: DurableObjectStorage): void => { + try { + storage.sql.exec("SELECT 1"); + } catch (error) { + throw new Error( + `y-durableobjects v2 requires the SQLite storage backend. ` + + `Use "new_sqlite_classes" in your wrangler migrations. ` + + `Migration guide: ${MIGRATION_GUIDE_URL}`, + { cause: error }, + ); + } }; +export type { SessionAttachment } from "./session"; + export type YDurableObjectsAppType = ReturnType; export class YDurableObjects extends DurableObject< @@ -25,16 +48,13 @@ export class YDurableObjects extends DurableObject< createRoom: this.createRoom.bind(this), }); protected doc = new WSSharedDoc(); - protected storage = new YTransactionStorageImpl({ - get: (key) => this.state.storage.get(key), - list: (options) => this.state.storage.list(options), - put: (key, value) => this.state.storage.put(key, value), - delete: async (key) => - this.state.storage.delete(Array.isArray(key) ? key : [key]), - transaction: (closure) => this.state.storage.transaction(closure), - }); - protected sessions = new Map void>(); - private awarenessClients = new Set(); + protected storage: YSqliteStorage; + protected sessions = new SessionRegistry(); + + /** 永続化を直列化するためのキュー。Yjs の update イベントは同期的に発火するため必要 */ + private persist: Promise = Promise.resolve(); + /** onStart() が複数回呼ばれても doc/awareness のリスナーを二重登録しないためのガード */ + private listenersRegistered = false; constructor( public state: DurableObjectState, @@ -42,28 +62,69 @@ export class YDurableObjects extends DurableObject< ) { super(state, env); + assertSqliteBackend(state.storage); + this.storage = new YSqliteStorage(state.storage.sql); + + // ping を自動応答にすることで、keepalive で Durable Object を + // 起こさずに済む。duration 課金に最も効く設定。 + state.setWebSocketAutoResponse( + new WebSocketRequestResponsePair("ping", "pong"), + ); + void this.state.blockConcurrencyWhile(this.onStart.bind(this)); } protected async onStart(): Promise { - const doc = await this.storage.getYDoc(); - applyUpdate(this.doc, encodeStateAsUpdate(doc)); + const update = await this.storage.getUpdate(); + if (update !== null) { + applyUpdate(this.doc, update); + } for (const ws of this.state.getWebSockets()) { - this.registerWebSocket(ws); + // ここはハイバネーションからの復帰パス。state.getWebSockets() が返す + // ソケットのうち 1 つがすでに死んでいる(閉じている・エラー状態)こと + // があり得る。registerWebSocket() は setupWSConnection() 経由で + // ws.send() を呼ぶため、それだけで例外を投げうる。ここでガードせずに + // 例外を外へ伝播させると、この for ループがそこで止まり、以降の + // ソケットが二度と登録されないまま静かに配信を受けなくなる。さらに + // 例外はコンストラクタの blockConcurrencyWhile まで伝播し、Durable + // Object 全体がリセットされる — 例外境界を設けた意味そのものが + // 失われる。1 つのソケットの復帰失敗が他のソケットの復帰を妨げては + // いけない。 + try { + this.registerWebSocket(ws); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } } - this.doc.on("update", async (update) => { - await this.storage.storeUpdate(update); + // 本番では onStart はコンストラクタの blockConcurrencyWhile からしか + // 呼ばれず、二重登録は起こらない。ただし一部のテストは冷起動の代わりに + // onStart() を直接呼び直して再水和ロジックを検証するため、リスナー登録 + // だけは冪等にしておく。ここを冪等にしないと、update リスナーが二重に + // 登録され、以降の update ごとに schedulePersist が二重発火して永続化が + // 重複する。 + if (!this.listenersRegistered) { + this.listenersRegistered = true; + this.wireDocListeners(); + } + } + + /** + * this.doc に対して update / awareness update のリスナーを配線する。 + * onStart() から一度だけ呼ばれるほか、destroy() が this.doc を新しい + * WSSharedDoc に差し替えたときにも呼び直す。 + */ + private wireDocListeners(): void { + this.doc.on("update", (update: Uint8Array) => { + this.schedulePersist(update); }); this.doc.awareness.on( "update", - async ({ added, removed, updated }: AwarenessChanges) => { - for (const client of [...added, ...updated]) { - this.awarenessClients.add(client); - } - for (const client of removed) { - this.awarenessClients.delete(client); + ({ added, updated }: AwarenessChanges, origin: unknown) => { + if (origin instanceof WebSocket) { + this.sessions.track(origin, [...added, ...updated]); } }, ); @@ -75,8 +136,9 @@ export class YDurableObjects extends DurableObject< const server = pair[1]; server.serializeAttachment({ roomId, - connectedAt: new Date(), - } satisfies WebSocketAttachment); + connectedAt: Date.now(), + clientIds: [], + } satisfies SessionAttachment); this.state.acceptWebSocket(server); this.registerWebSocket(server); @@ -88,22 +150,111 @@ export class YDurableObjects extends DurableObject< return this.app.request(request, undefined, this.env); } + /** + * 生の Yjs update を適用する。 + * WebSocket 経路と違い、プロトコルのフレーミングは不要。 + * getYDoc() の戻り値をそのまま渡せる。 + */ async updateYDoc(update: Uint8Array): Promise { - this.doc.update(update); + applyUpdate(this.doc, update, RPC_ORIGIN); + await this.persist; await this.cleanup(); } async getYDoc(): Promise { return encodeStateAsUpdate(this.doc); } + /** 部屋のデータを削除し、すべての接続を閉じる */ + async destroy(): Promise { + for (const ws of this.state.getWebSockets()) { + // すでに閉じている・エラー状態のソケットに close() を呼ぶと workerd は + // 例外を投げることがある。1 つのソケットを閉じられないことが他の + // ソケットを閉じ損ねたり、下の storage.destroy() の呼び出しを妨げたり + // してはいけない。destroy() の存在意義はデータを消すことそのものなので、 + // 閉じ損ねたソケットが 1 つあってもデータ削除は必ず到達させる。 + try { + ws.close(1001, "room destroyed"); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + + // この DO が自分から閉じたソケットは webSocketClose が確実に発火する + // 保証がない。発火しなければ WSSharedDoc のリスナーがリークして以降 + // 毎回の broadcast が失敗するし、このソケットの awareness state が + // 亡霊カーソルとしてルームに残り続け、sessions.size が 0 に落ちないため + // cleanup() の「全員退出でコンパクション」条件も二度と成立しなくなる。 + // unregisterWebSocket は冪等なので、webSocketClose が別途発火しても + // 問題ない。 + await this.unregisterWebSocket(ws); + } + + // destroy() の時点で永続化キューにまだ書き込みが残っていることがある。 + // 先に drain してから DELETE しないと、キューに残っていた storeUpdate が + // DELETE FROM updates の後に着地し、孤児行として復活してしまう。 + await this.persist; + await this.storage.destroy(); + + // メモリ上の Doc をストレージより進んだまま残すと、まだ生きているこの + // インスタンスに接続してきたクライアントが sync step 1/2 で消したはずの + // 内容を受け取ってしまい、以降の差分は実在しない親 struct を参照する + // ようになる。onPersistFailure が state.abort() でインスタンスごと + // 破棄するのと同じ理由だが、destroy() は RPC でありインスタンスを + // 生かしたまま応答する必要があるため、abort() の代わりに Doc を + // 作り直して同じ効果を得る。 + // + // 差し替える前に古い Doc を破棄すること。WSSharedDoc は y-protocols の + // Awareness を構築しており、Awareness は repeating setInterval と + // `doc.on('destroy', () => this.destroy())` を登録する。Y.Doc.destroy() + // を呼ばずに参照を捨てるだけだと、この interval と listener が Durable + // Object の残り寿命の間ずっとリークする(部屋を破棄するたびに 1 つずつ)。 + // Y.Doc.destroy() は 'destroy' イベントを発火して自身の全リスナーを + // 解除するので、Awareness の破棄はそれだけで連鎖する + // (node_modules の y-protocols/awareness.js で確認済み)。 + this.doc.destroy(); + this.doc = new WSSharedDoc(); + this.wireDocListeners(); + } + async webSocketMessage( ws: WebSocket, message: string | ArrayBuffer, ): Promise { if (!(message instanceof ArrayBuffer)) return; - const update = new Uint8Array(message); - await this.updateYDoc(update); + try { + this.doc.update(new Uint8Array(message), ws); + } catch (error) { + // eslint-disable-next-line no-console + console.error("[y-durableobjects] invalid message", error); + + // すでに閉じている・エラー状態のソケットに close() を呼ぶと workerd は + // 例外を投げることがある(destroy()/onPersistFailure と同じ危険)。 + // それがこの下の unregisterWebSocket() 呼び出しを妨げてはいけない。 + // 妨げると unregisterWebSocket が一生呼ばれず、WSSharedDoc のリスナーが + // リークして以降の broadcast がずっと失敗するばかりか、この catch + // ブロックの外へ例外が漏れて webSocketMessage 全体が reject し、DO が + // リセットされて部屋の全接続が落ちる — このガード自体が防ごうとして + // いた全断障害を、形を変えて再現してしまう。 + try { + ws.close(1003, "invalid message"); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + + // この DO 自身が閉じたソケットに対して webSocketClose が確実に発火する + // 保証はない。発火しなければ unregisterWebSocket が一生呼ばれず、 + // WSSharedDoc のリスナーがリークして以降の broadcast がずっと失敗し、 + // このソケットの awareness state もルームに残り続ける。冪等なので + // webSocketClose が別途発火しても問題ない。 + await this.unregisterWebSocket(ws); + + return; + } + + await this.persist; + await this.cleanup(); } async webSocketError(ws: WebSocket): Promise { @@ -118,20 +269,26 @@ export class YDurableObjects extends DurableObject< protected registerWebSocket(ws: WebSocket) { setupWSConnection(ws, this.doc); - const s = this.doc.notify((message) => { + const dispose = this.doc.notify(ws, (message) => { ws.send(message); }); - this.sessions.set(ws, s); + this.sessions.add(ws, dispose); } protected async unregisterWebSocket(ws: WebSocket) { try { - const dispose = this.sessions.get(ws); - dispose?.(); - this.sessions.delete(ws); - const clientIds = this.awarenessClients; + // この接続が所有する clientID だけを削除する。 + // 部屋全体の clientID を削除すると他の参加者の presence まで消える。 + const clientIds = this.sessions.clientIdsOf(ws); - removeAwarenessStates(this.doc.awareness, Array.from(clientIds), null); + // 先にリスナーを解除してから removeAwarenessStates を呼ぶこと。 + // removeAwarenessStates は awareness の "update" を同期的に発火させ、 + // WSSharedDoc.broadcast がまだ登録されたままの ws.send() を呼んでしまう。 + // 切断直後のソケットへの send() は例外を投げるため、その場合に + // sessions.remove(ws) が実行されずリスナーがリークし、以降このルームの + // 配信が全滅する。 + this.sessions.remove(ws); + removeAwarenessStates(this.doc.awareness, clientIds, null); } catch (e) { // eslint-disable-next-line no-console console.error(e); @@ -143,4 +300,44 @@ export class YDurableObjects extends DurableObject< await this.storage.commit(); } } + + private schedulePersist(update: Uint8Array): void { + this.persist = this.persist + .then(() => this.storage.storeUpdate(update)) + .catch((error: unknown) => { + this.onPersistFailure(error); + }); + this.state.waitUntil(this.persist); + } + + /** + * 永続化に失敗したら全接続を閉じ、Durable Object をリセットする。 + * + * 接続を閉じるだけではメモリ上の Doc がストレージより進んだまま残り、 + * 後続の接続が「正常に見える」状態を受け取ったあと、eviction 時に + * 差分が無言で失われる。abort() でストレージから読み直させる。 + * + * CRDT ではクライアント側が完全な状態を保持しているため、再接続時の + * sync step 1 / 2 で失われた更新が再送され、障害は自己修復する。 + */ + private onPersistFailure(error: unknown): void { + // eslint-disable-next-line no-console + console.error("[y-durableobjects] failed to persist update", error); + + for (const ws of this.state.getWebSockets()) { + // すでに閉じている・エラー状態のソケットに close() を呼ぶと workerd は + // 例外を投げる(例えば、この例外境界が 1003 で閉じた直後のソケット、 + // あるいは今回の失敗の原因になったソケット自身)。1 つのソケットを + // 閉じられないことが他のソケットを閉じ損ねたり、下の abort() の + // 呼び出しを妨げたりしてはいけない。abort() こそがこのポリシーの + // 本体であり、閉じ損ねたソケットが 1 つあっても必ず到達させる。 + try { + ws.close(1011, "storage failure"); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + } + this.state.abort("failed to persist a Yjs update"); + } } diff --git a/src/yjs/internal.ts b/src/yjs/internal.ts index b2fea57..19322f8 100644 --- a/src/yjs/internal.ts +++ b/src/yjs/internal.ts @@ -1,12 +1,12 @@ import type { WSSharedDoc } from "./remote"; -import type { YTransactionStorageImpl } from "./storage"; +import type { SessionRegistry } from "./session"; +import type { YSqliteStorage } from "./storage"; export interface InternalYDurableObject { // private state doc: WSSharedDoc; - storage: YTransactionStorageImpl; - sessions: Map void>; - awarenessClients: Set; + storage: YSqliteStorage; + sessions: SessionRegistry; // private api @@ -14,15 +14,16 @@ export interface InternalYDurableObject { createRoom(roomId: string): WebSocket; registerWebSocket(ws: WebSocket): void; - unregisterWebSocket(ws: WebSocket): void; - cleanup(): void; + unregisterWebSocket(ws: WebSocket): Promise; + cleanup(): Promise; // public api fetch(request: Request): Promise; - webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void; - webSocketError(ws: WebSocket): void; - webSocketClose(ws: WebSocket): void; + webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise; + webSocketError(ws: WebSocket): Promise; + webSocketClose(ws: WebSocket): Promise; getYDoc(): Promise; updateYDoc(update: Uint8Array): Promise; + destroy(): Promise; } diff --git a/src/yjs/message-type/index.ts b/src/yjs/message-type/index.ts index a50b03f..052fae9 100644 --- a/src/yjs/message-type/index.ts +++ b/src/yjs/message-type/index.ts @@ -3,7 +3,9 @@ import { createEncoder, writeVarUint } from "lib0/encoding"; export const messageType = { sync: 0, awareness: 1, -}; + auth: 2, + queryAwareness: 3, +} as const; export const isMessageType = ( type: string, diff --git a/src/yjs/message-type/messaeg-type.test.ts b/src/yjs/message-type/messaeg-type.test.ts index 47dcd21..7db9c83 100644 --- a/src/yjs/message-type/messaeg-type.test.ts +++ b/src/yjs/message-type/messaeg-type.test.ts @@ -9,6 +9,8 @@ describe("createTypedEncoder", () => { const cases = [ ["sync", messageType.sync], ["awareness", messageType.awareness], + ["auth", messageType.auth], + ["queryAwareness", messageType.queryAwareness], ] as const; it.each(cases)( diff --git a/src/yjs/remote/ws-shared-doc.test.ts b/src/yjs/remote/ws-shared-doc.test.ts index 309ce04..b74c409 100644 --- a/src/yjs/remote/ws-shared-doc.test.ts +++ b/src/yjs/remote/ws-shared-doc.test.ts @@ -1,7 +1,17 @@ import { createDecoder, readVarUint } from "lib0/decoding"; -import { createEncoder, toUint8Array, writeVarUint } from "lib0/encoding"; +import { + createEncoder, + toUint8Array, + writeVarUint, + writeVarUint8Array, +} from "lib0/encoding"; import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; -import { readSyncMessage, writeUpdate } from "y-protocols/sync"; +import { + Awareness, + encodeAwarenessUpdate, + removeAwarenessStates, +} from "y-protocols/awareness"; +import { readSyncMessage, writeSyncStep1, writeUpdate } from "y-protocols/sync"; import { Doc, encodeStateAsUpdate } from "yjs"; import { messageType } from "../message-type"; @@ -27,6 +37,18 @@ const createSyncMessage = (update: Uint8Array) => { return toUint8Array(encoder); }; +// Encodes a real awareness protocol message, the way an actual client would. +const createAwarenessMessage = (awareness: Awareness) => { + const encoder = createEncoder(); + writeVarUint(encoder, messageType.awareness); + writeVarUint8Array( + encoder, + encodeAwarenessUpdate(awareness, [awareness.clientID]), + ); + + return toUint8Array(encoder); +}; + // Helper to apply a received message to a new document const applyMessage = (message: Uint8Array) => { const receivedDoc = new Doc(); @@ -39,12 +61,14 @@ const applyMessage = (message: Uint8Array) => { describe("WSSharedDoc", () => { let doc: WSSharedDoc; + let origin: object; let mockListener: Mock; beforeEach(() => { doc = new WSSharedDoc(); + origin = {}; mockListener = vi.fn(); - doc.notify(mockListener); + doc.notify(origin, mockListener); }); afterEach(() => { @@ -56,7 +80,7 @@ describe("WSSharedDoc", () => { const update = createYDocMessage("Hello, world!"); const message = createSyncMessage(update); - doc.update(message); + doc.update(message, {}); expect(mockListener).toHaveBeenCalledWith(expect.any(Uint8Array)); expect(mockListener.mock.calls[0][0]).toEqual(message); @@ -68,21 +92,195 @@ describe("WSSharedDoc", () => { describe("Event Notification", () => { it("should add and remove listeners correctly", () => { + const anotherOrigin = {}; const anotherListener = vi.fn(); - const unsubscribe = doc.notify(anotherListener); + const unsubscribe = doc.notify(anotherOrigin, anotherListener); const message1 = createSyncMessage(createYDocMessage("text1")); const message2 = createSyncMessage(createYDocMessage("text2")); - doc.update(message1); + doc.update(message1, {}); expect(mockListener).toHaveBeenCalledTimes(1); expect(anotherListener).toHaveBeenCalledTimes(1); unsubscribe(); - doc.update(message2); + doc.update(message2, {}); expect(mockListener).toHaveBeenCalledTimes(2); expect(anotherListener).toHaveBeenCalledTimes(1); }); + + it("does not let an earlier unsubscribe for an origin remove a later listener registered for that same origin", () => { + const sharedOrigin = {}; + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + const unsubscribeFirst = doc.notify(sharedOrigin, firstListener); + // A second notify() for the same origin overwrites the first entry. + doc.notify(sharedOrigin, secondListener); + + // This should be a no-op: it registered `firstListener`, which is no + // longer the listener stored for `sharedOrigin`. + unsubscribeFirst(); + + doc.update(createSyncMessage(createYDocMessage("text3")), {}); + + expect(secondListener).toHaveBeenCalledTimes(1); + }); + }); + + describe("Origin-aware routing", () => { + it("sends the sync step 2 reply only to the requesting origin", () => { + const doc = new WSSharedDoc(); + const requester = {}; + const bystander = {}; + const toRequester: Uint8Array[] = []; + const toBystander: Uint8Array[] = []; + doc.notify(requester, (message) => toRequester.push(message)); + doc.notify(bystander, (message) => toBystander.push(message)); + + doc.getText("root").insert(0, "seed"); + toRequester.length = 0; + toBystander.length = 0; + + const encoder = createEncoder(); + writeVarUint(encoder, messageType.sync); + writeSyncStep1(encoder, new Doc()); + doc.update(toUint8Array(encoder), requester); + + expect(toRequester.length).toBe(1); + expect(toBystander.length).toBe(0); + }); + + it("does not echo an update back to its origin", () => { + const doc = new WSSharedDoc(); + const sender = {}; + const receiver = {}; + const toSender: Uint8Array[] = []; + const toReceiver: Uint8Array[] = []; + doc.notify(sender, (message) => toSender.push(message)); + doc.notify(receiver, (message) => toReceiver.push(message)); + + const source = new Doc(); + source.getText("root").insert(0, "hello"); + const encoder = createEncoder(); + writeVarUint(encoder, messageType.sync); + writeUpdate(encoder, encodeStateAsUpdate(source)); + doc.update(toUint8Array(encoder), sender); + + expect(toSender.length).toBe(0); + expect(toReceiver.length).toBe(1); + }); + + it("does not let a throwing recipient's send escape update()", () => { + const doc = new WSSharedDoc(); + const requester = {}; + doc.notify(requester, () => { + throw new Error("dead socket"); + }); + + // A sync step 1 reply goes only to the requester via the private + // send() path (this.send(origin, ...)), not broadcast() -- so this + // exercises send()'s own guard, not broadcast()'s. + const encoder = createEncoder(); + writeVarUint(encoder, messageType.sync); + writeSyncStep1(encoder, new Doc()); + + expect(() => doc.update(toUint8Array(encoder), requester)).not.toThrow(); + }); + + it("throws on an unknown message type", () => { + const doc = new WSSharedDoc(); + const encoder = createEncoder(); + writeVarUint(encoder, 99); + + expect(() => doc.update(toUint8Array(encoder), {})).toThrow(); + }); + }); + + describe("Awareness hibernation guard", () => { + // Cloudflare Durable Objects cannot hibernate while any + // setInterval/setTimeout is pending. y-protocols' Awareness installs a + // repeating setInterval in its own constructor, so WSSharedDoc must + // clear it immediately or the Durable Object stays awake -- and billed + // -- for its entire lifetime. These tests verify that mechanism (the + // timer handle gets torn down, and the guard actually fires if + // y-protocols' internals change out from under it). Real hibernation + // itself can't be exercised under @cloudflare/vitest-pool-workers -- + // there is no API to force a Durable Object to hibernate and observe + // that it went dormant -- so the outcome (fewer billed milliseconds) + // is not, and cannot be, asserted here. + it("clears y-protocols' repeating awareness check interval right after construction", () => { + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + + const localDoc = new WSSharedDoc(); + const handle: unknown = localDoc.awareness._checkInterval; + + // y-protocols/awareness.js sets `_checkInterval` to whatever + // `setInterval` returns; in this runtime that's a number (see + // worker-configuration.d.ts). If it were anything else, WSSharedDoc's + // own guard would already have thrown during construction above. + expect(typeof handle).toBe("number"); + expect(clearIntervalSpy).toHaveBeenCalledWith(handle); + }); + + it("throws loudly if y-protocols' Awareness stops exposing a numeric _checkInterval handle", () => { + // Simulates a future y-protocols release changing what `_checkInterval` + // holds (e.g. renaming/removing it, or moving to a non-numeric handle + // in some other runtime). WSSharedDoc must fail construction instead + // of silently leaving the real interval running. + vi.spyOn(globalThis, "setInterval").mockReturnValue( + undefined as unknown as ReturnType, + ); + + expect(() => new WSSharedDoc()).toThrow(/_checkInterval/); + }); + }); + + describe("Awareness protocol", () => { + it("applies a remote awareness update and broadcasts it to other listeners", () => { + const remoteAwareness = new Awareness(new Doc()); + remoteAwareness.setLocalStateField("user", { name: "a" }); + + const anotherOrigin = {}; + const anotherListener = vi.fn(); + doc.notify(anotherOrigin, anotherListener); + + // Sent with `origin` (already registered via notify() in beforeEach) + // as the sender, matching how WSSharedDoc#update() is driven from a + // real WebSocket message. + doc.update(createAwarenessMessage(remoteAwareness), origin); + + expect(doc.awareness.getStates().get(remoteAwareness.clientID)).toEqual({ + user: { name: "a" }, + }); + expect(anotherListener).toHaveBeenCalledTimes(1); + // The sender itself doesn't get its own update echoed back, same as + // sync updates. + expect(mockListener).not.toHaveBeenCalled(); + + remoteAwareness.destroy(); + }); + + it("removes an awareness state and broadcasts the removal, the same way disconnect cleanup does", () => { + const remoteAwareness = new Awareness(new Doc()); + remoteAwareness.setLocalStateField("user", { name: "a" }); + doc.update(createAwarenessMessage(remoteAwareness), {}); + expect(doc.awareness.getStates().has(remoteAwareness.clientID)).toBe( + true, + ); + + mockListener.mockClear(); + // This is exactly what YDurableObjects#unregisterWebSocket calls when + // a connection closes (see src/yjs/index.ts). + removeAwarenessStates(doc.awareness, [remoteAwareness.clientID], null); + + expect(doc.awareness.getStates().has(remoteAwareness.clientID)).toBe( + false, + ); + expect(mockListener).toHaveBeenCalledTimes(1); + + remoteAwareness.destroy(); + }); }); }); diff --git a/src/yjs/remote/ws-shared-doc.ts b/src/yjs/remote/ws-shared-doc.ts index 9eb022b..04b90e5 100644 --- a/src/yjs/remote/ws-shared-doc.ts +++ b/src/yjs/remote/ws-shared-doc.ts @@ -18,31 +18,97 @@ import { createTypedEncoder, messageType } from "../message-type"; import type { AwarenessChanges, RemoteDoc } from "."; -type Listener = (message: T) => void; +type Listener = (message: Uint8Array) => void; type Unsubscribe = () => void; -interface Notification extends RemoteDoc { - notify(cb: Listener): Unsubscribe; + +interface Notification extends RemoteDoc { + notify(origin: object, listener: Listener): Unsubscribe; +} + +/** + * Narrows `_checkInterval` to the numeric interval-id shape that + * `setInterval` / `clearInterval` use in the Workers runtime (see + * `worker-configuration.d.ts`). y-protocols' own `.d.ts` types the field as + * `any`; funneling it through `unknown` here means we never trust that + * `any` directly -- we check its actual runtime shape before touching it. + */ +function isIntervalHandle(value: unknown): value is number { + return typeof value === "number"; } -export class WSSharedDoc extends Doc implements Notification { - private listeners = new Set>(); +/** + * y-protocols' `Awareness` constructor installs a repeating `setInterval` + * that fires every `floor(outdatedTimeout / 10)` ms (3s with the library + * default) -- see the installed y-protocols package's `awareness.js` + * (`node_modules/y-protocols/awareness.js`). + * Any pending `setInterval`/`setTimeout` prevents a Durable Object from + * hibernating, so leaving this running keeps every `YDurableObjects` + * instance awake -- and billed for duration -- for its entire lifetime. + * + * The timer does two things: (1) renew the local client's own clock, gated + * on `getLocalState() !== null`. `WSSharedDoc`'s constructor immediately + * calls `awareness.setLocalState(null)`, so that branch never fires here. + * (2) evict remote clients whose state hasn't been refreshed within + * `outdatedTimeout` (30s). Only (2) does anything on the server, and we + * accept losing it: each connection's awareness ids are torn down on + * disconnect (`unregisterWebSocket`), the Durable Objects runtime delivers + * `webSocketClose`/`webSocketError` for abnormal disconnects too, and + * awareness only ever lives in memory, so it's rebuilt from nothing on + * every restart regardless. The GC's remaining value is small; the + * hibernation cost of keeping the timer alive is not. + * + * `_checkInterval` is underscore-prefixed and not part of y-protocols' + * public API -- a future release could rename or drop it. If that happens + * this must fail loudly instead of silently leaving the interval running: + * a silent regression here goes back to costing real money, continuously + * and invisibly, on every running instance. + */ +function clearAwarenessCheckInterval(awareness: Awareness): void { + const handle: unknown = awareness._checkInterval; + + if (!isIntervalHandle(handle)) { + throw new Error( + "y-protocols Awareness no longer exposes its repeating check timer " + + `as a numeric \`_checkInterval\` handle (got ${typeof handle}). ` + + "Without clearing it, that interval keeps every YDurableObjects " + + "instance from ever hibernating, which is billed as continuous " + + "Durable Object duration. Update clearAwarenessCheckInterval() in " + + "ws-shared-doc.ts for the new y-protocols internals before " + + "releasing this.", + ); + } + + clearInterval(handle); +} + +export class WSSharedDoc extends Doc implements Notification { + /** origin(通常は WebSocket)をキーにした配信先 */ + private listeners = new Map(); readonly awareness = new Awareness(this); constructor(gc = true) { super({ gc }); + + // Awareness のコンストラクタが張る repeating setInterval を即座に破棄する。 + // これが生き続ける限り Durable Object は絶対にハイバネートしない。 + // 詳細は clearAwarenessCheckInterval() のコメントを参照。 + clearAwarenessCheckInterval(this.awareness); this.awareness.setLocalState(null); // カーソルなどの付加情報の更新通知 - this.awareness.on("update", (changes: AwarenessChanges) => { - this.awarenessChangeHandler(changes); - }); + this.awareness.on( + "update", + (changes: AwarenessChanges, origin: unknown) => { + this.awarenessChangeHandler(changes, origin); + }, + ); // yDoc の更新通知 - this.on("update", (update: Uint8Array) => { - this.syncMessageHandler(update); + this.on("update", (update: Uint8Array, origin: unknown) => { + this.syncMessageHandler(update, origin); }); } - update(message: Uint8Array) { + update(message: Uint8Array, origin: object) { const encoder = createEncoder(); const decoder = createDecoder(message); const type = readVarUint(decoder); @@ -50,40 +116,68 @@ export class WSSharedDoc extends Doc implements Notification { switch (type) { case messageType.sync: { writeVarUint(encoder, messageType.sync); - readSyncMessage(decoder, encoder, this, null); + readSyncMessage(decoder, encoder, this, origin); - // changed remote doc + // sync step 1 への応答は要求元にだけ返す if (length(encoder) > 1) { - this._notify(toUint8Array(encoder)); + this.send(origin, toUint8Array(encoder)); } break; } case messageType.awareness: { - applyAwarenessUpdate(this.awareness, readVarUint8Array(decoder), null); + applyAwarenessUpdate( + this.awareness, + readVarUint8Array(decoder), + origin, + ); break; } + case messageType.queryAwareness: { + const states = this.awareness.getStates(); + if (states.size > 0) { + const reply = createTypedEncoder("awareness"); + writeVarUint8Array( + reply, + encodeAwarenessUpdate(this.awareness, Array.from(states.keys())), + ); + this.send(origin, toUint8Array(reply)); + } + break; + } + case messageType.auth: { + // auth はサーバからクライアントへの一方向のメッセージなので受信しても何もしない + break; + } + default: { + throw new Error(`Unsupported message type: ${type}`); + } } } - notify(listener: Listener) { - this.listeners.add(listener); + notify(origin: object, listener: Listener) { + this.listeners.set(origin, listener); return () => { - this.listeners.delete(listener); + // A later notify() for the same origin overwrites this entry; only + // remove it if it's still the listener *this* call registered, so an + // earlier unsubscribe can't delete a newer listener for the same origin. + if (this.listeners.get(origin) === listener) { + this.listeners.delete(origin); + } }; } - private syncMessageHandler(update: Uint8Array) { + private syncMessageHandler(update: Uint8Array, origin: unknown) { const encoder = createTypedEncoder("sync"); writeUpdate(encoder, update); - this._notify(toUint8Array(encoder)); + this.broadcast(toUint8Array(encoder), origin); } - private awarenessChangeHandler({ - added, - updated, - removed, - }: AwarenessChanges) { + + private awarenessChangeHandler( + { added, updated, removed }: AwarenessChanges, + origin: unknown, + ) { const changed = [...added, ...updated, ...removed]; const encoder = createTypedEncoder("awareness"); const update = encodeAwarenessUpdate( @@ -93,12 +187,34 @@ export class WSSharedDoc extends Doc implements Notification { ); writeVarUint8Array(encoder, update); - this._notify(toUint8Array(encoder)); + this.broadcast(toUint8Array(encoder), origin); + } + + private send(origin: object, message: Uint8Array) { + try { + this.listeners.get(origin)?.(message); + } catch (e) { + // A dead socket's send() throwing here would otherwise escape + // update() and be misreported by callers (e.g. webSocketMessage's + // exception boundary) as an "invalid message", when the real cause + // was a disconnected recipient. Same shape as broadcast()'s guard. + // eslint-disable-next-line no-console + console.error(e); + } } - private _notify(message: Uint8Array) { - for (const subscriber of this.listeners) { - subscriber(message); + private broadcast(message: Uint8Array, exclude: unknown) { + for (const [origin, listener] of this.listeners) { + if (origin === exclude) continue; + + try { + listener(message); + } catch (e) { + // 1 つの接続が切断済み・エラー状態で listener(=ws.send) が例外を + // 投げても、それ以降の健全な接続への配信を止めてはいけない。 + // eslint-disable-next-line no-console + console.error(e); + } } } } diff --git a/src/yjs/session/index.ts b/src/yjs/session/index.ts new file mode 100644 index 0000000..0a3e9e9 --- /dev/null +++ b/src/yjs/session/index.ts @@ -0,0 +1,101 @@ +export type SessionAttachment = { + roomId: string; + /** epoch ミリ秒。attachment は structured clone されるが、数値の方が扱いが単純 */ + connectedAt: number; + /** この接続が所有する awareness の clientID */ + clientIds: number[]; +}; + +const isSessionAttachment = (value: unknown): value is SessionAttachment => { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + + return ( + typeof candidate.roomId === "string" && + typeof candidate.connectedAt === "number" && + Array.isArray(candidate.clientIds) && + candidate.clientIds.every((id) => typeof id === "number") + ); +}; + +/** + * WebSocket と awareness の所有権を管理する。 + * + * 所有権は WebSocket の attachment に永続化するため、Durable Object が + * hibernation から復帰してメモリ上の状態を失っても復元できる。 + */ +export class SessionRegistry { + private disposers = new Map void>(); + + get size(): number { + return this.disposers.size; + } + + add(ws: WebSocket, dispose: () => void): void { + this.disposers.set(ws, dispose); + } + + remove(ws: WebSocket): void { + this.disposers.get(ws)?.(); + this.disposers.delete(ws); + } + + has(ws: WebSocket): boolean { + return this.disposers.has(ws); + } + + sockets(): IterableIterator { + return this.disposers.keys(); + } + + clientIdsOf(ws: WebSocket): number[] { + return this.attachmentOf(ws)?.clientIds ?? []; + } + + /** + * この接続が所有する clientID を記録する。 + * 実際に増えたときだけ attachment を書き直すので、通常は接続あたり 1 回で済む。 + * + * 所有権は排他的にする。overlapping reconnect(Yjs の Doc がソケットの + * 再接続を跨いで生き残るケース)では、同じ awareness clientID が旧ソケット + * と新ソケットの両方から送られてくることがある。ここで他のソケットから + * 剥奪しておかないと、旧ソケットが後で閉じたときに + * unregisterWebSocket() が新ソケットも使っている awareness state を + * 消してしまい、他の参加者には再接続したユーザーが消えたように見える。 + */ + track(ws: WebSocket, clientIds: readonly number[]): void { + const current = this.attachmentOf(ws); + if (current === null) return; + + for (const other of this.disposers.keys()) { + if (other === ws) continue; + + const otherAttachment = this.attachmentOf(other); + if (otherAttachment === null) continue; + + const remaining = otherAttachment.clientIds.filter( + (id) => !clientIds.includes(id), + ); + if (remaining.length === otherAttachment.clientIds.length) continue; + + other.serializeAttachment({ + ...otherAttachment, + clientIds: remaining, + } satisfies SessionAttachment); + } + + const merged = new Set([...current.clientIds, ...clientIds]); + if (merged.size === current.clientIds.length) return; + + ws.serializeAttachment({ + ...current, + clientIds: Array.from(merged), + } satisfies SessionAttachment); + } + + private attachmentOf(ws: WebSocket): SessionAttachment | null { + const raw: unknown = ws.deserializeAttachment(); + + return isSessionAttachment(raw) ? raw : null; + } +} diff --git a/src/yjs/session/session.test.ts b/src/yjs/session/session.test.ts new file mode 100644 index 0000000..34c37ed --- /dev/null +++ b/src/yjs/session/session.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; + +import { SessionRegistry } from "."; + +import type { SessionAttachment } from "."; + +const fakeSocket = (attachment: SessionAttachment | null): WebSocket => { + let current = attachment; + + return { + serializeAttachment: (value: SessionAttachment) => { + current = value; + }, + deserializeAttachment: () => current, + } as unknown as WebSocket; +}; + +const attachment = (clientIds: number[]): SessionAttachment => ({ + roomId: "room1", + connectedAt: 0, + clientIds, +}); + +describe("SessionRegistry", () => { + it("tracks and disposes sockets", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(attachment([])); + const dispose = vi.fn(); + + registry.add(ws, dispose); + expect(registry.size).toBe(1); + expect(registry.has(ws)).toBe(true); + + registry.remove(ws); + expect(dispose).toHaveBeenCalledTimes(1); + expect(registry.size).toBe(0); + }); + + it("records client ids on the attachment", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(attachment([])); + registry.add(ws, () => {}); + + registry.track(ws, [7]); + + expect(registry.clientIdsOf(ws)).toEqual([7]); + }); + + it("does not duplicate client ids", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(attachment([7])); + registry.add(ws, () => {}); + + registry.track(ws, [7, 8]); + registry.track(ws, [8]); + + expect(registry.clientIdsOf(ws).sort()).toEqual([7, 8]); + }); + + it("reads clientIds from the attachment already present when add() is called", () => { + // This only exercises SessionRegistry in isolation: add() with a socket + // whose attachment already carries clientIds, on a registry that never + // called track() itself. It does NOT drive state.getWebSockets() or + // onStart()'s reconstruction loop, so it does not cover hibernation + // wake-up -- see "restores session ownership from a socket's attachment + // when onStart() re-registers it" in src/e2e/y-durableobjects.test.ts + // for that path. Real hibernation cannot be forced in this test + // environment, so that test drives the actual onStart() code path + // instead of faking eviction/restart. + const ws = fakeSocket(attachment([42])); + const registry = new SessionRegistry(); + registry.add(ws, () => {}); + + expect(registry.clientIdsOf(ws)).toEqual([42]); + }); + + it("returns an empty list for a socket without a valid attachment", () => { + const registry = new SessionRegistry(); + const ws = fakeSocket(null); + registry.add(ws, () => {}); + + expect(registry.clientIdsOf(ws)).toEqual([]); + }); + + it("makes ownership of a reused client id exclusive between sockets", () => { + // Simulates an overlapping reconnect: the same awareness clientID (the + // Yjs Doc survives socket reconnects, so this can genuinely happen) is + // published by an old socket and then by its replacement. Ownership + // must move to the new socket, not be shared by both -- otherwise + // closing the old socket later removes awareness state the new socket + // is still using. + const registry = new SessionRegistry(); + const oldWs = fakeSocket(attachment([])); + const newWs = fakeSocket(attachment([])); + registry.add(oldWs, () => {}); + registry.add(newWs, () => {}); + + registry.track(oldWs, [7]); + expect(registry.clientIdsOf(oldWs)).toEqual([7]); + + registry.track(newWs, [7]); + + expect(registry.clientIdsOf(oldWs)).toEqual([]); + expect(registry.clientIdsOf(newWs)).toEqual([7]); + }); + + it("does not remove a reclaimed client id when its former socket closes", () => { + const registry = new SessionRegistry(); + const oldWs = fakeSocket(attachment([])); + const newWs = fakeSocket(attachment([])); + const oldDispose = vi.fn(); + registry.add(oldWs, oldDispose); + registry.add(newWs, () => {}); + + registry.track(oldWs, [7]); + registry.track(newWs, [7]); + + registry.remove(oldWs); + + expect(oldDispose).toHaveBeenCalledTimes(1); + expect(registry.clientIdsOf(newWs)).toEqual([7]); + }); +}); diff --git a/src/yjs/storage/index.ts b/src/yjs/storage/index.ts index df6c086..e3d54f2 100644 --- a/src/yjs/storage/index.ts +++ b/src/yjs/storage/index.ts @@ -1,115 +1,5 @@ -import { Doc, applyUpdate, encodeStateAsUpdate } from "yjs"; +export { YSqliteStorage } from "./sqlite"; +export { UpdateKind } from "./type"; -import { storageKey } from "./storage-key"; - -import type { TransactionStorage } from "./type"; - -export interface YTransactionStorage { - getYDoc(): Promise; - storeUpdate(update: Uint8Array): Promise; - commit(): Promise; -} - -type Options = { - /** - * @description default is 10KB - * @default 10 * 1024 * 1 - */ - maxBytes?: number; - /** - * @description default is 500 snapshot - * @default 500 - */ - maxUpdates?: number; -}; - -export class YTransactionStorageImpl implements YTransactionStorage { - private readonly MAX_BYTES: number; - private readonly MAX_UPDATES: number; - - constructor( - private readonly storage: TransactionStorage, - options?: Options, - ) { - this.MAX_BYTES = options?.maxBytes ?? 10 * 1024; - if (this.MAX_BYTES > 128 * 1024) { - // https://developers.cloudflare.com/durable-objects/platform/limits/ - throw new Error("maxBytes must be less than 128KB"); - } - - this.MAX_UPDATES = options?.maxUpdates ?? 500; - } - - async getYDoc(): Promise { - const snapshot = await this.storage.get( - storageKey({ type: "state", name: "doc" }), - ); - const data = await this.storage.list({ - prefix: storageKey({ type: "update" }), - }); - - const updates: Uint8Array[] = Array.from(data.values()); - const doc = new Doc(); - - doc.transact(() => { - if (snapshot) { - applyUpdate(doc, snapshot); - } - for (const update of updates) { - applyUpdate(doc, update); - } - }); - - return doc; - } - - storeUpdate(update: Uint8Array): Promise { - return this.storage.transaction(async (tx) => { - const bytes = - (await tx.get(storageKey({ type: "state", name: "bytes" }))) ?? - 0; - const count = - (await tx.get(storageKey({ type: "state", name: "count" }))) ?? - 0; - - const updateBytes = bytes + update.byteLength; - const updateCount = count + 1; - - if (updateBytes > this.MAX_BYTES || updateCount > this.MAX_UPDATES) { - const doc = await this.getYDoc(); - applyUpdate(doc, update); - - await this._commit(doc, tx); - } else { - await tx.put(storageKey({ type: "state", name: "bytes" }), updateBytes); - await tx.put(storageKey({ type: "state", name: "count" }), updateCount); - await tx.put(storageKey({ type: "update", name: updateCount }), update); - } - }); - } - - private async _commit(doc: Doc, tx: Omit) { - const data = await tx.list({ - prefix: storageKey({ type: "update" }), - }); - - for (const update of data.values()) { - applyUpdate(doc, update); - } - - const update = encodeStateAsUpdate(doc); - - await tx.delete(Array.from(data.keys())); - await tx.put(storageKey({ type: "state", name: "bytes" }), 0); - await tx.put(storageKey({ type: "state", name: "count" }), 0); - await tx.put(storageKey({ type: "state", name: "doc" }), update); - } - - async commit(): Promise { - const doc = await this.getYDoc(); - - return this.storage.transaction(async (tx) => { - await this._commit(doc, tx); - }); - } -} +export type { YSqliteStorageOptions } from "./sqlite"; +export type { YStorage } from "./type"; diff --git a/src/yjs/storage/queries.ts b/src/yjs/storage/queries.ts new file mode 100644 index 0000000..9243da8 --- /dev/null +++ b/src/yjs/storage/queries.ts @@ -0,0 +1,10 @@ +/** + * SQL 文字列をこのファイルに隔離する。 + * 将来スキーマが複雑になり Kysely 等のクエリビルダを導入する場合も、 + * 変更はこのファイル内で完結する。 + */ +export const SELECT_ALL_UPDATES = + "SELECT seq, kind, data FROM updates ORDER BY seq"; +export const INSERT_UPDATE = "INSERT INTO updates (kind, data) VALUES (?, ?)"; +export const DELETE_ALL_UPDATES = "DELETE FROM updates"; +export const COUNT_UPDATES = "SELECT COUNT(*) AS count FROM updates"; diff --git a/src/yjs/storage/schema.test.ts b/src/yjs/storage/schema.test.ts new file mode 100644 index 0000000..40a8922 --- /dev/null +++ b/src/yjs/storage/schema.test.ts @@ -0,0 +1,64 @@ +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import { migrate } from "./schema"; + +const withSql = async (fn: (sql: SqlStorage) => void): Promise => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, (_instance, state) => { + fn(state.storage.sql); + }); +}; + +describe("migrate", () => { + it("creates the updates table", async () => { + await withSql((sql) => { + migrate(sql); + + const tables = sql + .exec<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'table'", + ) + .toArray() + .map((row) => row.name); + + expect(tables).toContain("updates"); + expect(tables).toContain("schema_version"); + }); + }); + + it("records the schema version", async () => { + await withSql((sql) => { + migrate(sql); + + const row = sql + .exec<{ version: number }>("SELECT version FROM schema_version") + .one(); + + expect(row.version).toBe(1); + }); + }); + + it("is idempotent and preserves existing rows", async () => { + await withSql((sql) => { + migrate(sql); + sql.exec( + "INSERT INTO updates (kind, data) VALUES (?, ?)", + 0, + new Uint8Array([1, 2, 3]), + ); + + migrate(sql); + + const count = sql + .exec<{ count: number }>("SELECT COUNT(*) AS count FROM updates") + .one(); + const version = sql + .exec<{ version: number }>("SELECT version FROM schema_version") + .one(); + + expect(count.count).toBe(1); + expect(version.version).toBe(1); + }); + }); +}); diff --git a/src/yjs/storage/schema.ts b/src/yjs/storage/schema.ts new file mode 100644 index 0000000..037d12f --- /dev/null +++ b/src/yjs/storage/schema.ts @@ -0,0 +1,45 @@ +/** + * スキーマのマイグレーション定義。 + * 変更するときは既存の要素を書き換えず、末尾に ALTER TABLE を追記すること。 + * Durable Object の SQLite はインスタンスごとに独立した DB なので、 + * 各インスタンスが初回起動時に自分のペースでマイグレートする。 + */ +const MIGRATIONS: readonly string[] = [ + `CREATE TABLE updates ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + kind INTEGER NOT NULL, + data BLOB NOT NULL + )`, +]; + +/** + * 未適用のマイグレーションを適用する。すべて同期実行なので、 + * 呼び出し中に await を挟まなければ暗黙のトランザクションとして atomic に完了する。 + * + * PRAGMA user_version は Durable Objects の SQLite では SQLITE_AUTH で拒否されるため、 + * 版番号は schema_version テーブルに保持する。 + */ +export const migrate = (sql: SqlStorage): void => { + sql.exec( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)", + ); + + const current = sql + .exec<{ version: number }>("SELECT version FROM schema_version") + .toArray() + .at(0); + const applied = current?.version ?? 0; + + for (let i = applied; i < MIGRATIONS.length; i++) { + sql.exec(MIGRATIONS[i]); + } + + if (current === undefined) { + sql.exec( + "INSERT INTO schema_version (version) VALUES (?)", + MIGRATIONS.length, + ); + } else if (applied !== MIGRATIONS.length) { + sql.exec("UPDATE schema_version SET version = ?", MIGRATIONS.length); + } +}; diff --git a/src/yjs/storage/sqlite.test.ts b/src/yjs/storage/sqlite.test.ts new file mode 100644 index 0000000..6ae8412 --- /dev/null +++ b/src/yjs/storage/sqlite.test.ts @@ -0,0 +1,354 @@ +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { Doc, applyUpdate, encodeStateAsUpdate, mergeUpdates } from "yjs"; + +import { YSqliteStorage } from "./sqlite"; + +import type { YSqliteStorageOptions } from "./sqlite"; + +const withStorage = async ( + fn: (storage: YSqliteStorage) => Promise, + options?: YSqliteStorageOptions, +): Promise => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + await fn(new YSqliteStorage(state.storage.sql, options)); + }); +}; + +const withStorageAndSql = async ( + fn: (storage: YSqliteStorage, sql: SqlStorage) => Promise, + options?: YSqliteStorageOptions, +): Promise => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + await fn(new YSqliteStorage(state.storage.sql, options), state.storage.sql); + }); +}; + +const countRows = (sql: SqlStorage): number => + sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM updates").one() + .count; + +const docWith = (text: string): Doc => { + const doc = new Doc(); + doc.getText("root").insert(0, text); + + return doc; +}; + +const textOf = (update: Uint8Array): string => { + const doc = new Doc(); + applyUpdate(doc, update); + + return doc.getText("root").toString(); +}; + +describe("YSqliteStorage", () => { + it("returns null when nothing has been stored", async () => { + await withStorage(async (storage) => { + expect(await storage.getUpdate()).toBeNull(); + }); + }); + + it("round-trips a single update", async () => { + await withStorage(async (storage) => { + await storage.storeUpdate(encodeStateAsUpdate(docWith("Hello World!"))); + + const update = await storage.getUpdate(); + + expect(update).not.toBeNull(); + expect(textOf(update!)).toBe("Hello World!"); + }); + }); + + it("round-trips a large number of individually stored updates", async () => { + await withStorage(async (storage) => { + const doc = new Doc(); + const text = doc.getText("root"); + + // 1000 件の更新を個別に保存し、まとめて復元しても元のドキュメントと + // 一致することを検証する(大量行のバルクラウンドトリップ)。 + // 注意: Y.mergeUpdates は因果的に依存する struct を並べ替えて解決する + // ため、この結果だけでは seq 順に復元されていることは証明されない。 + // ORDER BY seq を外しても本テストは通ってしまう。順序に依存する + // 検証(バイト断片の再構成)は Task 3 で追加する。 + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + for (let i = 0; i < 1000; i++) { + text.insert(text.length, String(i % 10)); + } + for (const update of updates) { + await storage.storeUpdate(update); + } + + const restored = await storage.getUpdate(); + + expect(textOf(restored!)).toBe(text.toString()); + }); + }); + + it("clears all rows on destroy", async () => { + await withStorage(async (storage) => { + await storage.storeUpdate(encodeStateAsUpdate(docWith("gone"))); + await storage.destroy(); + + expect(await storage.getUpdate()).toBeNull(); + }); + }); + + it("compacts once the row threshold is exceeded", async () => { + await withStorageAndSql( + async (storage, sql) => { + const doc = new Doc(); + const text = doc.getText("root"); + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + for (let i = 0; i < 50; i++) { + text.insert(text.length, "x"); + } + for (const update of updates) { + await storage.storeUpdate(update); + } + + // maxRows 10 に対し 50 件保存したので、行数は大幅に減っているはず + expect(countRows(sql)).toBeLessThanOrEqual(10); + + const restored = await storage.getUpdate(); + expect(textOf(restored!)).toBe(text.toString()); + }, + { maxRows: 10 }, + ); + }); + + it("splits a compacted update that exceeds maxChunkBytes and restores it", async () => { + await withStorageAndSql( + async (storage, sql) => { + const doc = new Doc(); + const text = doc.getText("root"); + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + for (let i = 0; i < 20; i++) { + text.insert(text.length, "abcdefghij".repeat(50)); + } + for (const update of updates) { + await storage.storeUpdate(update); + } + + // This is the ordering regression test for the compaction/chunk-split + // path. Byte-fragment reassembly is the one place in this library + // where ordering is genuinely load-bearing: concatenating chunks out + // of `seq` order produces garbage bytes that no amount of CRDT + // convergence in Y.mergeUpdates repairs. Asserting on the restored + // *text* only proves the document round-trips (mergeUpdates could + // in principle mask a reordering that a stricter comparison would + // catch), so we additionally capture the merged bytes before + // compaction and require getUpdate() to reproduce them exactly. + const expectedBytes = mergeUpdates(updates); + + await storage.commit(); + + // 512 バイトずつに分割されるので、複数行になっているはず + expect(countRows(sql)).toBeGreaterThan(1); + + const restored = await storage.getUpdate(); + expect(textOf(restored!)).toBe(text.toString()); + expect(restored!).toEqual(expectedBytes); + }, + { maxRows: 1000, maxChunkBytes: 512 }, + ); + }); + + it("stores a document larger than the legacy 128KiB key-value limit", async () => { + await withStorage(async (storage) => { + const doc = new Doc(); + // 300KB 相当。KV バックエンドでは 1 キーに収まらず保存に失敗していた(C-2) + doc.getText("root").insert(0, "y".repeat(300 * 1024)); + await storage.storeUpdate(encodeStateAsUpdate(doc)); + await storage.commit(); + + const restored = await storage.getUpdate(); + + expect(textOf(restored!).length).toBe(300 * 1024); + }); + }); + + it("is a no-op when there is at most one row to compact", async () => { + await withStorageAndSql(async (storage, sql) => { + await storage.storeUpdate(encodeStateAsUpdate(docWith("single"))); + await storage.commit(); + + expect(countRows(sql)).toBe(1); + expect(textOf((await storage.getUpdate())!)).toBe("single"); + }); + }); + + it("splits a single update larger than maxChunkBytes without needing commit()", async () => { + await withStorageAndSql( + async (storage, sql) => { + const doc = new Doc(); + doc.getText("root").insert(0, "z".repeat(5000)); + const update = encodeStateAsUpdate(doc); + expect(update.byteLength).toBeGreaterThan(200); + + // storeUpdate() alone, with no commit() call, must split an + // oversized update into multiple rows. A bare INSERT of the whole + // update would eventually exceed Cloudflare's 2MB SQLite BLOB limit + // and throw inside storeUpdate(), wedging the room forever (every + // reconnect resends the same oversized update and hits the same + // error). This uses a small maxChunkBytes instead of an actual >2MB + // payload so the test stays fast, but exercises the same code path. + await storage.storeUpdate(update); + + expect(countRows(sql)).toBeGreaterThan(1); + + const restored = await storage.getUpdate(); + expect(textOf(restored!)).toBe("z".repeat(5000)); + }, + { maxChunkBytes: 200, maxRows: 1000 }, + ); + }); + + it("does not recompact on every storeUpdate once maxRows is below the data's minimum chunk count", async () => { + await withStorageAndSql( + async (storage, sql) => { + let commitCalls = 0; + const originalCommit = storage.commit.bind(storage); + storage.commit = async () => { + commitCalls += 1; + await originalCommit(); + }; + + const doc = new Doc(); + const text = doc.getText("root"); + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + // Each individual insert's update must stay under maxChunkBytes on + // its own (so a single storeUpdate() call never needs to split by + // itself) -- only the *cumulative* merged document, once compacted, + // should need more than maxRows chunks. + for (let i = 0; i < 30; i++) { + text.insert(text.length, "ab"); + } + + for (const update of updates) { + expect(update.byteLength).toBeLessThan(300); + await storage.storeUpdate(update); + } + + // maxRows (1) is far below the number of rows the merged content + // actually needs at maxChunkBytes (300), so compaction can never + // bring the row count back under maxRows. Without flooring the + // threshold at (roughly) the row count the last compaction actually + // produced, every single storeUpdate() call after the first + // compaction re-triggers a full commit() (confirmed empirically: + // 29 of 30 calls recompact without the floor). The floor at least + // halves that (observed: 15 of 30) by skipping compaction until the + // row count has grown past what the last compaction produced, not + // just past the configured maxRows. + expect(commitCalls).toBeGreaterThan(0); + expect(commitCalls).toBeLessThan(updates.length * 0.7); + + const restored = await storage.getUpdate(); + expect(textOf(restored!)).toBe(text.toString()); + }, + { maxRows: 1, maxChunkBytes: 300 }, + ); + }); + + it("rejects a non-positive maxChunkBytes", async () => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + // maxChunkBytes <= 0 makes #split()'s offset never advance, looping + // forever on any non-empty update. This must be rejected eagerly at + // construction time instead of hanging the first storeUpdate() call. + expect( + () => new YSqliteStorage(state.storage.sql, { maxChunkBytes: 0 }), + ).toThrow(/maxChunkBytes/); + expect( + () => new YSqliteStorage(state.storage.sql, { maxChunkBytes: -1 }), + ).toThrow(/maxChunkBytes/); + expect( + () => new YSqliteStorage(state.storage.sql, { maxChunkBytes: 1.5 }), + ).toThrow(/maxChunkBytes/); + }); + }); + + it("rejects a non-positive maxRows", async () => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + expect( + () => new YSqliteStorage(state.storage.sql, { maxRows: 0 }), + ).toThrow(/maxRows/); + expect( + () => new YSqliteStorage(state.storage.sql, { maxRows: -5 }), + ).toThrow(/maxRows/); + expect( + () => new YSqliteStorage(state.storage.sql, { maxRows: 2.5 }), + ).toThrow(/maxRows/); + }); + }); + + it("does not immediately recompact a rehydrated document whose row count already exceeds maxRows", async () => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + const sql = state.storage.sql; + + // Build up a document whose compacted row count (5) exceeds the + // maxRows we'll later reopen it with (1), the same way a real + // compaction under a small maxRows would. + const first = new YSqliteStorage(sql, { maxRows: 1, maxChunkBytes: 50 }); + const doc = new Doc(); + const text = doc.getText("root"); + text.insert(0, "x".repeat(400)); + await first.storeUpdate(encodeStateAsUpdate(doc)); + await first.commit(); + + const rowsAfterCompaction = countRows(sql); + expect(rowsAfterCompaction).toBeGreaterThan(1); + + // Simulate a Durable Object waking up: construct a fresh storage + // instance over the same (already-populated) sql handle, still + // configured with maxRows = 1 -- far below the row count just loaded. + let commitCalls = 0; + const second = new YSqliteStorage(sql, { maxRows: 1, maxChunkBytes: 50 }); + const originalCommit = second.commit.bind(second); + second.commit = async () => { + commitCalls += 1; + await originalCommit(); + }; + + // A single small follow-up update must not trigger a full + // merge/delete/reinsert -- the compaction floor must have been + // initialised from the row count actually loaded, not reset to + // maxRows. + const updates: Uint8Array[] = []; + doc.on("update", (update: Uint8Array) => updates.push(update)); + text.insert(text.length, "y"); + await second.storeUpdate(updates[0]); + + expect(commitCalls).toBe(0); + + const restored = await second.getUpdate(); + expect(textOf(restored!)).toBe(text.toString()); + }); + }); + + it("throws when a continuation row has no preceding row to attach to", async () => { + const stub = env.Y_DURABLE_OBJECTS.get(env.Y_DURABLE_OBJECTS.newUniqueId()); + await runInDurableObject(stub, async (_instance, state) => { + const storage = new YSqliteStorage(state.storage.sql); + + // 破損した状態を直接作る: 先行する standalone 行なしに continuation + // (kind = 1) 行だけを挿入する。 + state.storage.sql.exec( + "INSERT INTO updates (kind, data) VALUES (1, ?)", + new Uint8Array([1, 2, 3]), + ); + + await expect(storage.getUpdate()).rejects.toThrow( + /orphaned continuation row at seq=\d+/, + ); + }); + }); +}); diff --git a/src/yjs/storage/sqlite.ts b/src/yjs/storage/sqlite.ts new file mode 100644 index 0000000..2f3698a --- /dev/null +++ b/src/yjs/storage/sqlite.ts @@ -0,0 +1,225 @@ +import { mergeUpdates } from "yjs"; + +import { + COUNT_UPDATES, + DELETE_ALL_UPDATES, + INSERT_UPDATE, + SELECT_ALL_UPDATES, +} from "./queries"; +import { migrate } from "./schema"; +import { UpdateKind } from "./type"; + +import type { YStorage } from "./type"; + +type UpdateRow = { + seq: number; + kind: number; + data: ArrayBuffer; +}; + +export type YSqliteStorageOptions = { + /** + * この行数を超えたらコンパクションする。 + * @default 2000 + */ + maxRows?: number; + /** + * コンパクション結果を分割する単位(バイト)。 + * SQLite の BLOB 上限 2MB に対する安全マージンを取る。 + * @default 1024 * 1024 + */ + maxChunkBytes?: number; +}; + +const DEFAULT_MAX_ROWS = 2000; +const DEFAULT_MAX_CHUNK_BYTES = 1024 * 1024; +const SQLITE_BLOB_LIMIT = 2 * 1024 * 1024; + +/** + * maxChunkBytes / maxRows は両方とも「この行数・バイト数に到達したら + * 前進する」しきい値として使われる。0 や負の値を渡すと #split() のオフ + * セットが一度も進まず無限ループになる(maxChunkBytes)か、あるいは + * 実際に減らせないしきい値で毎回フルコンパクションを引き起こし続ける + * だけになる(maxRows)。どちらも呼び出し側の設定ミスとして早期に + * throw し、Worker がタイムアウトやメモリ枯渇に至る前に検出できる + * ようにする。 + */ +const assertPositiveInteger = (value: number, name: string): void => { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } +}; + +const concat = (parts: readonly Uint8Array[]): Uint8Array => { + if (parts.length === 1) return parts[0]; + + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + merged.set(part, offset); + offset += part.byteLength; + } + + return merged; +}; + +export class YSqliteStorage implements YStorage { + readonly #sql: SqlStorage; + readonly #maxRows: number; + readonly #maxChunkBytes: number; + #rowCount: number; + /** + * commit() を呼ばずに済む行数の下限。通常は #maxRows と同じだが、 + * コンパクション結果が #maxRows を超える行数を必要とする場合は + * その行数(+1)まで引き上げる。そうしないと、以降の storeUpdate が + * 呼ばれるたびに毎回フルコンパクションを再実行してしまう + * (コンパクションしても #maxRows 以下にはならないため)。 + */ + #compactionFloor: number; + + constructor(sql: SqlStorage, options?: YSqliteStorageOptions) { + this.#maxChunkBytes = options?.maxChunkBytes ?? DEFAULT_MAX_CHUNK_BYTES; + assertPositiveInteger(this.#maxChunkBytes, "maxChunkBytes"); + if (this.#maxChunkBytes > SQLITE_BLOB_LIMIT) { + // https://developers.cloudflare.com/durable-objects/platform/limits/ + throw new Error("maxChunkBytes must not exceed 2MB"); + } + this.#maxRows = options?.maxRows ?? DEFAULT_MAX_ROWS; + assertPositiveInteger(this.#maxRows, "maxRows"); + + this.#sql = sql; + migrate(sql); + this.#rowCount = sql.exec<{ count: number }>(COUNT_UPDATES).one().count; + // コンストラクタは Durable Object が起きるたびに(hibernation からの + // 復帰も含めて)実行される。#compactionFloor を無条件に #maxRows へ + // リセットすると、すでに #maxRows を超える行数を抱えたドキュメントを + // 読み込んだ直後の最初の storeUpdate() が必ずコンパクションの条件を + // 満たしてしまい、フルマージ・削除・再挿入を毎回の起床のたびに + // 引き起こす — フロアが本来防ぐはずのスラッシングそのものが + // 再現してしまう。commit() が算出する "+1" の余白(直後の 1 回の + // storeUpdate では再トリガーしない)と同じ考え方で、実際に読み込んだ + // 行数から初期化する。 + this.#compactionFloor = Math.max(this.#maxRows, this.#rowCount + 1); + } + + async getUpdate(): Promise { + const updates = this.#readAll(); + if (updates.length === 0) return null; + + return mergeUpdates(updates); + } + + async storeUpdate(update: Uint8Array): Promise { + // Cloudflare の SQLite は BLOB 1 行あたり 2MB までしか許さない。単一の + // update がその上限を超えることは実際に起こる(大きな貼り付け、埋め込み + // 画像、新規クライアントの sync step 2 が運ぶ大きなドキュメント全体など)。 + // #split() で maxChunkBytes 以下の断片に割ってから複数行として書き込む。 + // commit() と同じ分割ロジックを再利用するので、#readAll() の復元側は + // 変更不要(continuation はすでに連結される)。 + const chunks = this.#split(update); + + // ここから下では await を挟まないこと。 + // 連続した同期書き込みが暗黙のトランザクションとして atomic に適用される。 + for (const [index, chunk] of chunks.entries()) { + const kind = + index === 0 ? UpdateKind.standalone : UpdateKind.continuation; + this.#sql.exec(INSERT_UPDATE, kind, chunk); + } + this.#rowCount += chunks.length; + + if (this.#rowCount > this.#compactionFloor) { + await this.commit(); + } + } + + async commit(): Promise { + if (this.#rowCount <= 1) return; + + const updates = this.#readAll(); + if (updates.length === 0) return; + + const chunks = this.#split(mergeUpdates(updates)); + + // ここから下では await を挟まないこと。 + // 連続した同期書き込みが暗黙のトランザクションとして atomic に適用される。 + this.#sql.exec(DELETE_ALL_UPDATES); + for (const [index, chunk] of chunks.entries()) { + const kind = + index === 0 ? UpdateKind.standalone : UpdateKind.continuation; + this.#sql.exec(INSERT_UPDATE, kind, chunk); + } + this.#rowCount = chunks.length; + // このコンパクションが生んだ実際の行数が #maxRows を超えるなら、 + // それより低いしきい値で次の storeUpdate を毎回コンパクションさせても + // 無駄な全件書き直しを繰り返すだけで行数は減らない。しきい値をこの + // 行数(+1)まで引き上げて、実際に増えたときだけ再コンパクションする。 + this.#compactionFloor = Math.max(this.#maxRows, chunks.length + 1); + } + + async destroy(): Promise { + this.#sql.exec(DELETE_ALL_UPDATES); + this.#rowCount = 0; + } + + /** + * 全行を読み、continuation の断片を連結して独立した update の配列に戻す。 + * BLOB は ArrayBuffer で返るため Uint8Array へ変換する。 + */ + #readAll(): Uint8Array[] { + const rows = this.#sql.exec(SELECT_ALL_UPDATES).toArray(); + + const updates: Uint8Array[] = []; + let pending: Uint8Array[] = []; + for (const row of rows) { + const bytes = new Uint8Array(row.data); + if (row.kind === UpdateKind.continuation) { + if (pending.length === 0) { + // A continuation row with nothing preceding it to attach to means + // the updates table itself is corrupt (truncated compaction, + // manual tampering, a bug elsewhere). Silently reinterpreting the + // fragment as a standalone update would hand raw fragment bytes + // to Y.mergeUpdates and either corrupt the document without any + // signal or fail later with a confusing decode error far from the + // real cause. A storage library must surface its own corruption + // loudly instead of guessing, so we fail here and name the row. + throw new Error( + `YSqliteStorage: orphaned continuation row at seq=${row.seq} has no preceding row to attach to`, + ); + } + pending.push(bytes); + continue; + } + if (pending.length > 0) updates.push(concat(pending)); + pending = [bytes]; + } + if (pending.length > 0) updates.push(concat(pending)); + + return updates; + } + + /** + * マージ済みの update を maxChunkBytes 以下のバイト断片に分割する。 + * subarray ではなく slice を使ってコピーを作る。ビューをそのまま + * バインドすると基底バッファ全体が書き込まれる可能性があるため。 + */ + #split(update: Uint8Array): Uint8Array[] { + if (update.byteLength <= this.#maxChunkBytes) return [update]; + + const chunks: Uint8Array[] = []; + for ( + let offset = 0; + offset < update.byteLength; + offset += this.#maxChunkBytes + ) { + chunks.push( + update.slice( + offset, + Math.min(offset + this.#maxChunkBytes, update.byteLength), + ), + ); + } + + return chunks; + } +} diff --git a/src/yjs/storage/storage-key/index.ts b/src/yjs/storage/storage-key/index.ts deleted file mode 100644 index 55484a6..0000000 --- a/src/yjs/storage/storage-key/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type Key = - | { - type: "update"; - name?: number; - } - | { - type: "state"; - name: "bytes" | "doc" | "count"; - }; - -export const storageKey = (key: Key) => { - return `ydoc:${key.type}:${key.name ?? ""}`; -}; diff --git a/src/yjs/storage/storage-key/storage-key.test.ts b/src/yjs/storage/storage-key/storage-key.test.ts deleted file mode 100644 index 0b8f2a9..0000000 --- a/src/yjs/storage/storage-key/storage-key.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { storageKey } from "."; - -import type { Key } from "."; - -describe("storageKey functionality", () => { - it.each([ - [{ type: "update" }, "ydoc:update:"], // nameが省略された場合 - [{ type: "update", name: 1 }, "ydoc:update:1"], // nameが数値で提供された場合 - [{ type: "state", name: "bytes" }, "ydoc:state:bytes"], // typeがstateでnameがbytesの場合 - [{ type: "state", name: "doc" }, "ydoc:state:doc"], // typeがstateでnameがdocの場合 - [{ type: "state", name: "count" }, "ydoc:state:count"], // typeがstateでnameが新しく追加されたcountの場合 - ])("correctly generates storage key for key: %o", (key, expected) => { - expect(storageKey(key as Key)).toEqual(expected); - }); -}); diff --git a/src/yjs/storage/storage.test.ts b/src/yjs/storage/storage.test.ts deleted file mode 100644 index c44a30d..0000000 --- a/src/yjs/storage/storage.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { vi } from "vitest"; -import { Doc, encodeStateAsUpdate } from "yjs"; - -import { storageKey } from "./storage-key"; - -import { YTransactionStorageImpl } from "."; - -import type { TransactionStorage } from "./type"; -import type { Mocked } from "vitest"; - -describe("YTransactionStorageImpl", () => { - let storage: Mocked; - - beforeEach(() => { - storage = { - get: vi.fn(), - list: vi.fn(), - put: vi.fn(), - delete: vi.fn(), - transaction: vi.fn(async (closure) => closure(storage)), - } as Mocked; - }); - - afterEach(() => { - vi.clearAllMocks(); - vi.resetAllMocks(); - }); - - describe("Document Retrieval", () => { - it("returns an empty YDoc when there are no updates", async () => { - storage.get.mockResolvedValueOnce(undefined); - storage.list.mockResolvedValueOnce(new Map()); - - const yStorage = new YTransactionStorageImpl(storage); - const doc = await yStorage.getYDoc(); - expect(encodeStateAsUpdate(doc)).toEqual(encodeStateAsUpdate(new Doc())); - }); - - it("reconstructs the correct YDoc state from updates", async () => { - const stored = new Doc(); - stored.getText("root").insert(0, "Hello World"); - const update = encodeStateAsUpdate(stored); - - storage.get.mockResolvedValueOnce(undefined); - storage.list.mockResolvedValueOnce(new Map([["ydoc:update:1", update]])); - - const yStorage = new YTransactionStorageImpl(storage); - const doc = await yStorage.getYDoc(); - expect(encodeStateAsUpdate(doc)).toEqual(encodeStateAsUpdate(stored)); - }); - }); - - describe("Update Storage", () => { - it("stores updates correctly under unique keys", async () => { - const update = new Uint8Array([1, 2, 3]); - storage.get.mockResolvedValueOnce(0).mockResolvedValueOnce(0); - - const yStorage = new YTransactionStorageImpl(storage); - await yStorage.storeUpdate(update); - expect(storage.put).toHaveBeenCalledWith("ydoc:state:bytes", 3); - expect(storage.put).toHaveBeenCalledWith("ydoc:state:count", 1); - expect(storage.put).toHaveBeenCalledWith("ydoc:update:1", update); - }); - - it("increments the update count and update bytes on subsequent updates", async () => { - storage.get.mockResolvedValueOnce(3).mockResolvedValueOnce(1); - - const yStorage = new YTransactionStorageImpl(storage); - const update = new Uint8Array([4, 5, 6]); - await yStorage.storeUpdate(update); - expect(storage.put).toHaveBeenCalledWith( - "ydoc:state:bytes", - 3 + update.byteLength, - ); - expect(storage.put).toHaveBeenCalledWith("ydoc:state:count", 2); - expect(storage.put).toHaveBeenCalledWith("ydoc:update:2", update); - }); - }); - - describe("Handling Exceeded Limits", () => { - it.each([ - [2048 * 1024 * 2 + 1, 10], // Exceeded maxBytes - [10, 501], // Exceeded maxUpdates - ])( - "resets counts and bytes and stores combined state doc when limits are exceeded (%p bytes, %p updates)", - async (exceededBytes, exceededUpdates) => { - const doc = new Doc(); - doc.getText("root").insert(0, "Hello World"); - const update = encodeStateAsUpdate(doc); - - storage.get.mockImplementation((key) => { - switch (key) { - case storageKey({ type: "state", name: "bytes" }): - return Promise.resolve(exceededBytes); - case storageKey({ type: "state", name: "count" }): - return Promise.resolve(exceededUpdates); - default: - return Promise.resolve(undefined); - } - }); - storage.list.mockResolvedValue( - new Map( - Array(exceededUpdates) - .fill(0) - .map((_, i) => [`ydoc:update:${i + 1}`, update]), - ), - ); - - const yStorage = new YTransactionStorageImpl(storage); - await yStorage.storeUpdate(update); - - const newDoc = await yStorage.getYDoc(); - const expectedState = encodeStateAsUpdate(newDoc); - - expect(storage.delete).toHaveBeenCalledWith( - Array(exceededUpdates) - .fill(0) - .map((_, i) => `ydoc:update:${i + 1}`), - ); - expect(storage.put).toHaveBeenCalledWith("ydoc:state:bytes", 0); - expect(storage.put).toHaveBeenCalledWith("ydoc:state:count", 0); - expect(storage.put).toHaveBeenCalledWith( - "ydoc:state:doc", - expect.any(Uint8Array), - ); - expect(expectedState).toEqual(encodeStateAsUpdate(newDoc)); - }, - ); - }); - - describe("Configuration Options", () => { - it("handles options to modify maxBytes and maxUpdates", () => { - const yStorage = new YTransactionStorageImpl(storage, { - maxBytes: 1024, - maxUpdates: 100, - }); - expect(yStorage).toHaveProperty("MAX_BYTES", 1024); - expect(yStorage).toHaveProperty("MAX_UPDATES", 100); - }); - - it("throws an error when maxBytes exceeds 128KB", () => { - expect(() => { - return new YTransactionStorageImpl(storage, { - maxBytes: 128 * 1024 + 1, - maxUpdates: 100, - }); - }).toThrow("maxBytes must be less than 128KB"); - }); - }); - - describe("commit method", () => { - it("commits all updates and clears all related storage keys", async () => { - const yStorage = new YTransactionStorageImpl(storage); - - const doc = new Doc(); - doc.getText("root").insert(0, "Hello World"); - const update = encodeStateAsUpdate(doc); - storage.get.mockImplementation((key) => { - switch (key) { - case storageKey({ type: "state", name: "bytes" }): - return Promise.resolve(update.byteLength); - case storageKey({ type: "state", name: "count" }): - return Promise.resolve(1); - case storageKey({ type: "state", name: "doc" }): - return Promise.resolve(undefined); - default: { - throw new Error("Unexpected key"); - } - } - }); - storage.list.mockResolvedValue( - new Map( - Array(1) - .fill(0) - .map((_, i) => [`ydoc:update:${i + 1}`, update]), - ), - ); - - await yStorage.commit(); - - expect(storage.delete).toHaveBeenCalledWith(expect.any(Array)); - expect(storage.put).toHaveBeenCalledWith( - storageKey({ type: "state", name: "bytes" }), - 0, - ); - expect(storage.put).toHaveBeenCalledWith( - storageKey({ type: "state", name: "count" }), - 0, - ); - expect(storage.put).toHaveBeenCalledWith( - storageKey({ type: "state", name: "doc" }), - expect.any(Uint8Array), - ); - }); - - it("does not throw errors when there are no updates to commit", async () => { - const yStorage = new YTransactionStorageImpl(storage); - storage.get.mockImplementation((key) => { - switch (key) { - case storageKey({ type: "state", name: "bytes" }): - return Promise.resolve(undefined); - case storageKey({ type: "state", name: "count" }): - return Promise.resolve(undefined); - case storageKey({ type: "state", name: "doc" }): - return Promise.resolve(undefined); - default: { - throw new Error("Unexpected key"); - } - } - }); - - storage.list.mockResolvedValue(new Map()); - - await expect(yStorage.commit()).resolves.not.toThrow(); - }); - }); -}); diff --git a/src/yjs/storage/type.ts b/src/yjs/storage/type.ts index 4bc86af..49af110 100644 --- a/src/yjs/storage/type.ts +++ b/src/yjs/storage/type.ts @@ -1,18 +1,22 @@ -interface ListOptions { - start?: string; - startAfter?: string; - end?: string; - prefix?: string; - reverse?: boolean; - limit?: number; -} +/** + * updates テーブルの kind カラムの値。 + * + * Yjs の update はバイト列として単純に分割・連結できないため、 + * コンパクション結果が BLOB 上限を超える場合はバイト断片に分割して保存する。 + * continuation は「直前の行から続く断片」であることを示す。 + */ +export const UpdateKind = { + standalone: 0, + continuation: 1, +} as const; -export interface TransactionStorage { - get(key: string): Promise; - list(options?: ListOptions): Promise>; - put(key: string, value: T): Promise; - delete(key: string | string[]): Promise; - transaction( - closure: (txn: Omit) => Promise, - ): Promise; +export interface YStorage { + /** 保存されているすべての update を 1 本にマージして返す。空なら null */ + getUpdate(): Promise; + /** 増分 update を 1 行追加する。しきい値を超えたらコンパクションする */ + storeUpdate(update: Uint8Array): Promise; + /** 明示的にコンパクションする */ + commit(): Promise; + /** すべての update を削除する。テーブル定義とスキーマ版は維持する */ + destroy(): Promise; } diff --git a/wrangler.toml b/wrangler.toml index 14c9cf4..c97969a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,19 @@ +# NOTE: This file is the test fixture for a fresh, never-deployed local +# environment only (it is exercised by `pnpm test`, not shipped to anyone). +# Rewriting the "v1" migration entry below to `new_sqlite_classes` is safe +# here purely because nothing has ever been deployed against it. +# +# If you already have a deployed v1 (key-value backend) Worker, do NOT copy +# this pattern: migration history in wrangler is append-only and a +# namespace's storage type is immutable, so rewriting an existing "v1" +# entry cannot convert its storage and will leave the binding pointed at a +# SQLite-only class instance backed by key-value storage, which fails +# `assertSqliteBackend`. APPEND a new migration with a new tag and a +# distinct class name instead — see the "Migrating from v1 (key-value +# backend)" section in README.md. name = "yjs-workers" main = "src/e2e/index.ts" -compatibility_date = "2024-04-05" +compatibility_date = "2025-04-01" compatibility_flags=["nodejs_compat"] [[durable_objects.bindings]] @@ -9,5 +22,5 @@ class_name = "YDurableObjects" [[migrations]] tag = "v1" -new_classes = ["YDurableObjects"] +new_sqlite_classes = ["YDurableObjects"]