Skip to content

feat: page version history (snapshots) - #505

Merged
otomatty merged 6 commits into
developfrom
feature/page-snapshots-version-history
Apr 7, 2026
Merged

feat: page version history (snapshots)#505
otomatty merged 6 commits into
developfrom
feature/page-snapshots-version-history

Conversation

@otomatty

@otomatty otomatty commented Apr 7, 2026

Copy link
Copy Markdown
Owner

概要

ページの変更履歴(スナップショット)を保存・閲覧・復元できるようにする。10分間隔の自動スナップショット、API と Hocuspocus の両方からの保存、オーナーのみ復元可能、エディタから履歴モーダルでプレビュー・比較・復元が可能。

Adds page version history: periodic snapshots, list/detail/restore API, auto-snapshots from API and Hocuspocus, and an editor modal for preview, compare, and restore (owner-only restore).

変更点

領域 主な変更
db/migrations/ page_snapshots テーブル(002)
server/api/ /api/pages/:id/snapshots 一覧・詳細・復元、pageAccessServicesnapshotService、PUT content 時のベストエフォート自動スナップショット
server/hocuspocus/ ドキュメント保存後の自動スナップショット(snapshotUtils
src/ PageHistoryModal、React Query フック、Y.Doc→TipTap JSON、apiClient 拡張、i18n

変更の種類

  • 🐛 バグ修正 (Bug fix)
  • ✨ 新機能 (New feature)
  • 💥 破壊的変更 (Breaking change)
  • 📝 ドキュメント (Documentation)
  • 🎨 スタイル/リファクタリング (Style/Refactor)
  • 🧪 テスト (Tests)
  • 🔧 ビルド/CI (Build/CI)

テスト方法

  1. DB にマイグレーション 002_add_page_snapshots.sql を適用する。
  2. bun run test:run(または CI と同様の単体テスト)で API / Hocuspocus / フロントの関連テストが通ることを確認する。
  3. アプリを起動し、ページ編集画面のメニューから「変更履歴 / Version History」を開き、一覧・プレビュー・比較・復元(オーナー)を確認する。

チェックリスト

  • テストがすべてパスする
  • Lint エラーがない
  • 必要に応じてドキュメントを更新した
  • コミットメッセージが Conventional Commits に従っている

スクリーンショット(UI 変更がある場合)

履歴モーダル・一覧・プレビュー画面のスクリーンショットを PR に添付するとレビューしやすいです。

関連 Issue

Made with Cursor


Open with Devin

Summary by CodeRabbit

  • 新機能
    • ページのバージョン履歴機能を追加(履歴モーダル、一覧、プレビュー、比較、復元)。ヘッダーから履歴を開けます。
  • 改善
    • 編集内容の自動スナップショット保存を導入し、過去バージョンの取得・復元が可能に。復元後はライブ編集セッションの無効化を試行します。
  • ドキュメント/ローカライズ
    • 英語・日本語の編集UI文言を追加。
  • テスト
    • 履歴関連のUI/API/ユーティリティテストを多数追加。
  • Chores
    • サンプル環境変数と.gitignoreの小修正を追加。

Add page_snapshots table, REST API for list/detail/restore, auto-snapshots
from API and Hocuspocus, and editor UI for history preview/compare/restore.

Made-with: Cursor
@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

ページのバージョン履歴(スナップショット)機能を追加します。DBスキーマ、APIエンドポイント(一覧・詳細・復元)、自動スナップショット作成・剪定ロジック、Hocuspocus 側の無効化エンドポイント、フロントエンドの履歴モーダル/プレビュー/比較、関連フック・型・テストが導入されます。

Changes

Cohort / File(s) Summary
Database schema
db/migrations/002_add_page_snapshots.sql, server/api/src/schema/pageSnapshots.ts, server/api/src/schema/relations.ts
page_snapshots テーブル追加(UUID PK、pageId FK、version、ydoc_state(bytea)、contentText、createdBy、trigger、createdAt)とインデックス、Drizzle の relations に snapshots 関係を導入。
API routes & access
server/api/src/routes/pageSnapshots.ts, server/api/src/services/pageAccessService.ts, server/api/src/app.ts
スナップショット一覧/詳細/復元エンドポイント実装。閲覧権限チェック (assertPageViewAccess) を導入し、復元はトランザクション内で pre-restore/restore スナップショット作成、page_contents/pages 更新、剪定を実行。ルートを app にマウント。
Auto-snapshot service
server/api/src/services/snapshotService.ts, server/api/src/constants.ts
自動スナップショット判定と挿入・剪定ロジックを追加(SNAPSHOT_INTERVAL_MS, MAX_SNAPSHOTS_PER_PAGE を定義)。prune SQL フラグメントをエクスポート。
Pages persistence hooks
server/api/src/routes/pages.ts, server/api/src/__tests__/routes/pages.test.ts
PUT /api/pages/:id/content の更新経路に fire-and-forget で auto-snapshot 呼び出しを追加(失敗を API フローに影響させない)。既存テストを snapshot チェーン検査で拡張。
Hocuspocus integration
server/hocuspocus/src/snapshotUtils.ts, server/hocuspocus/src/index.ts, server/hocuspocus/src/snapshotUtils.test.ts
Hocuspocus 側に maybeCreateSnapshot 実装を追加し、saveDocumentToDb 後に別コネクションでベストエフォート保存・剪定を実行。内部認証ヘッダを用いる /internal/documents/:pageId/invalidate エンドポイントを追加し、無効化処理を提供。関連テスト追加。
Frontend UI components
src/components/editor/PageEditor/PageEditorHeader.tsx, src/components/editor/PageEditor/PageEditorLayout.tsx, src/components/editor/pageHistory/PageHistoryModal.tsx, src/components/editor/pageHistory/SnapshotList.tsx, src/components/editor/pageHistory/SnapshotPreview.tsx, src/components/editor/pageHistory/SnapshotCompare.tsx, src/components/editor/...
エディタヘッダーに履歴メニューを追加、レイアウトで履歴モーダルを統合。履歴モーダルに一覧・プレビュー・比較・復元フローを実装(Y.Doc → TipTap 変換を利用)。関連コンポーネントとテストを追加。
Frontend hooks / API client / types
src/hooks/usePageSnapshotQueries.ts, src/lib/api/apiClient.ts, src/lib/api/types.ts, src/types/pageSnapshot.ts
React Query フック(一覧・詳細・復元)、API クライアントに getPageSnapshots/getPageSnapshot/restorePageSnapshot を追加。API 型とフロント型(PageSnapshot / PageSnapshotDetail)を追加。
Yjs → TipTap utilities
src/lib/ydoc/yDocToTiptapJson.ts, src/lib/ydoc/__tests__/yDocToTiptapJson.test.ts
Yjs XML 断片を TipTap/ProseMirror JSON に変換するユーティリティを追加し、ユニットテストを追加。
Editor config
src/components/editor/TiptapEditor/editorConfig.ts
スナップショットプレビュー用の拡張セット createSnapshotPreviewExtensions() を追加。エディタ拡張の共通化とプラグインの条件付けを導入。
i18n / misc / tests
src/i18n/locales/en/editor.json, src/i18n/locales/ja/editor.json, 多数の __tests__.env.example, .gitignore
ページ履歴用の英日翻訳キー追加、関連ユニット/統合テスト群追加、.env.example に HOCUSPOCUS_INTERNAL_URL コメント追加、.gitignore にローカル設定除外を追記。

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as Server API
    participant DB as Database
    participant Hocuspocus as Hocuspocus Server

    Client->>API: PUT /api/pages/:id/content
    API->>DB: UPDATE page_contents, UPDATE pages
    Note over API: Fire-and-forget maybeCreateSnapshot (separate connection)
    API-->>Client: 200 OK (version)

    Hocuspocus->>DB: saveDocumentToDb (commit)
    Note over Hocuspocus: Post-commit, open separate DB client
    Hocuspocus->>DB: SELECT last page_snapshots for pageId
    alt snapshot due or none
        Hocuspocus->>DB: INSERT page_snapshots (trigger='auto')
        Hocuspocus->>DB: DELETE old snapshots (prune)
    else skip
        Note over Hocuspocus: No snapshot inserted
    end
Loading
sequenceDiagram
    participant User as Client (User)
    participant UI as Page History Modal
    participant API as Server API
    participant DB as Database

    User->>UI: Open history
    UI->>API: GET /api/pages/:id/snapshots
    API->>DB: SELECT page_snapshots WHERE page_id=...
    API->>DB: SELECT users WHERE id IN (...)
    API-->>UI: snapshots (with created_by_email)

    User->>UI: Select snapshot
    UI->>API: GET /api/pages/:id/snapshots/:snapshotId
    API->>DB: SELECT snapshot by id,pageId
    API-->>UI: snapshot detail (ydoc_state base64)

    User->>UI: Confirm restore
    UI->>API: POST /api/pages/:id/snapshots/:snapshotId/restore
    API->>DB: BEGIN TRANSACTION
    API->>DB: INSERT pre-restore snapshot (optional)
    API->>DB: UPDATE page_contents (restored ydoc, version++)
    API->>DB: INSERT restore snapshot
    API->>DB: UPDATE pages (contentPreview, updatedAt)
    API->>DB: DELETE old snapshots (prune)
    API->>DB: COMMIT
    API->>Hocuspocus: POST /internal/documents/:pageId/invalidate (best-effort)
    API-->>UI: 200 OK (version, snapshot_id)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 ちょこんと跳ねてスナップを取ったよ
古い章もそっと保存したよ
瞬間を戻せば記憶が蘇る
ふわふわ履歴、復元はぽんっと
また一緒に書き続けようね

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR タイトル「feat: page version history (snapshots)」は、追加された主要な機能(ページのスナップショットに基づくバージョン履歴)を明確かつ簡潔に要約している。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/page-snapshots-version-history

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a version history system for pages, featuring automatic snapshotting every 10 minutes, a new page_snapshots database table, and a frontend modal for previewing and restoring versions. Feedback identifies a critical bug where restoring a snapshot via the API may be overwritten by Hocuspocus's in-memory state if not properly synchronized. Further improvements are suggested to reduce API latency by making snapshot creation asynchronous, refactoring duplicated pruning logic, and optimizing the performance of Y.Doc encoding on the frontend.

Comment on lines +125 to +228
app.post("/:id/snapshots/:snapshotId/restore", authRequired, async (c) => {
const pageId = c.req.param("id");
const snapshotId = c.req.param("snapshotId");
const userId = c.get("userId");
const db = c.get("db");

// 復元は編集権限が必要(所有者のみ) / Restore requires owner permission
const page = await db
.select({ id: pages.id, ownerId: pages.ownerId })
.from(pages)
.where(and(eq(pages.id, pageId), eq(pages.isDeleted, false)))
.limit(1);

const pageRow = page[0];
if (!pageRow) throw new HTTPException(404, { message: "Page not found" });
if (pageRow.ownerId !== userId) throw new HTTPException(403, { message: "Forbidden" });

// 復元対象のスナップショットを取得
const snapRows = await db
.select()
.from(pageSnapshots)
.where(and(eq(pageSnapshots.id, snapshotId), eq(pageSnapshots.pageId, pageId)))
.limit(1);

const snap = snapRows[0];
if (!snap) throw new HTTPException(404, { message: "Snapshot not found" });

// トランザクションで復元処理
const result = await db.transaction(async (tx) => {
// 1. 現在の状態をスナップショットとして保存
const currentContent = await tx
.select()
.from(pageContents)
.where(eq(pageContents.pageId, pageId))
.limit(1);

const current = currentContent[0];
if (current) {
await tx.insert(pageSnapshots).values({
pageId,
version: current.version,
ydocState: current.ydocState,
contentText: current.contentText,
createdBy: userId,
trigger: "pre-restore",
});
}

// 2. page_contents を復元対象で上書き(version +1)
const updated = await tx
.update(pageContents)
.set({
ydocState: snap.ydocState,
version: sql`${pageContents.version} + 1`,
contentText: snap.contentText,
updatedAt: new Date(),
})
.where(eq(pageContents.pageId, pageId))
.returning();

const updatedRow = updated[0];
if (!updatedRow) throw new HTTPException(500, { message: "Restore failed" });

// 3. 復元後の状態もスナップショットとして保存 (trigger: 'restore')
const restoreSnap = await tx
.insert(pageSnapshots)
.values({
pageId,
version: updatedRow.version,
ydocState: snap.ydocState,
contentText: snap.contentText,
createdBy: userId,
trigger: "restore",
})
.returning();

// 4. pages メタデータ更新
const contentPreview = snap.contentText
? snap.contentText.trim().replace(/\s+/g, " ").slice(0, 120)
: null;
await tx
.update(pages)
.set({ contentPreview, updatedAt: new Date() })
.where(eq(pages.id, pageId));

// 5. 100件超過分を削除
await tx.execute(
sql`DELETE FROM page_snapshots WHERE id IN (
SELECT id FROM page_snapshots WHERE page_id = ${pageId}
ORDER BY created_at DESC OFFSET ${MAX_SNAPSHOTS_PER_PAGE}
)`,
);

return {
version: updatedRow.version,
snapshotId: restoreSnap[0]?.id,
};
});

return c.json({
version: result.version,
snapshot_id: result.snapshotId,
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There is a critical issue with the restore logic in a collaborative environment. When a snapshot is restored via this API, the Hocuspocus server is not notified. If there are active users connected to the page, Hocuspocus will keep the old state in memory. When those users eventually disconnect or Hocuspocus performs a periodic save, the restored state in the database will be overwritten by the stale in-memory state. You should implement a mechanism to invalidate or refresh the Hocuspocus document cache (e.g., via a internal webhook or Redis Pub/Sub) when a restore occurs.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応済みです(9ae82ad)。復元トランザクション内で page_contentsFOR UPDATE でロックし、コミット後に Hocuspocus へ POST /internal/documents/:pageId/invalidatex-internal-secret: BETTER_AUTH_SECRET)でライブ Y.Doc を無効化するようにしました。.env.exampleHOCUSPOCUS_INTERNAL_URL を追記しています。

Addressed in 9ae82ad: row lock on page_contents, post-commit Hocuspocus invalidation via internal endpoint + secret header; see .env.example for HOCUSPOCUS_INTERNAL_URL.

Comment thread server/api/src/routes/pages.ts Outdated

await applyPagesMetadataUpdate(db, pageId, body);

await tryAutoSnapshot(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Awaiting tryAutoSnapshot here adds unnecessary latency to the PUT /content request. Since this is a best-effort operation that doesn't affect the main update logic, it should be executed without blocking the response.

Suggested change
await tryAutoSnapshot(
tryAutoSnapshot(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応済みです(9ae82ad)。tryAutoSnapshotvoid で非ブロッキングです。

Already non-blocking via void tryAutoSnapshot(...) (9ae82ad).

Comment thread server/api/src/routes/pages.ts Outdated
const resultRow = result[0];
if (!resultRow) throw new HTTPException(500, { message: "Upsert failed" });

await tryAutoSnapshot(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous comment, awaiting tryAutoSnapshot here increases the response time for the client. It's better to fire-and-forget this best-effort task.

Suggested change
await tryAutoSnapshot(
tryAutoSnapshot(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同上(9ae82ad)。void tryAutoSnapshot です。

Same as above — fire-and-forget (9ae82ad).

Comment thread server/api/src/routes/pageSnapshots.ts Outdated
Comment on lines +212 to +216
sql`DELETE FROM page_snapshots WHERE id IN (
SELECT id FROM page_snapshots WHERE page_id = ${pageId}
ORDER BY created_at DESC OFFSET ${MAX_SNAPSHOTS_PER_PAGE}
)`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The pruning logic is duplicated in multiple places (API service, Hocuspocus utils, and here). It should be refactored into a shared utility function within the snapshotService to ensure consistency and easier maintenance.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応済みです(9ae82ad)。pruning SQL は snapshotService.pruneSnapshotsExceedingLimitSql に集約済みです。

Centralized in pruneSnapshotsExceedingLimitSql (9ae82ad).

Comment on lines +127 to +139
const currentYdocState = useMemo((): string => {
if (!historyOpen || !ydoc) return "";
try {
const state = Y.encodeStateAsUpdate(ydoc);
const chunks: string[] = [];
for (let i = 0; i < state.length; i += 8192) {
chunks.push(String.fromCharCode.apply(null, [...state.subarray(i, i + 8192)]));
}
return btoa(chunks.join(""));
} catch {
return "";
}
}, [historyOpen, ydoc]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Encoding the entire Y.Doc to base64 on every change while the history modal is open can be very expensive for large documents. This might cause UI lag during collaborative editing. Consider computing this state only when the 'Compare' tab is actually selected, or debouncing the update.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応済みです(9ae82ad)。現在 Y.Doc の base64 化は PageHistoryModal で Compare タブ選択時のみ実行するようになっています。

Encoding runs only on the Compare tab in PageHistoryModal (9ae82ad).

@otomatty otomatty self-assigned this Apr 7, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

otomatty added 2 commits April 7, 2026 12:21
Use expect.objectContaining so TS2532 does not apply to snapshots[0].

Made-with: Cursor
- Join notes with is_deleted=false in assertPageViewAccess (align with Hocuspocus)
- Fix Y.XmlText→TipTap: inline nodes under block elements; fragment wraps top-level text
- Export pruneSnapshotsExceedingLimitSql; use in restore tx and maybeCreateSnapshot
- Fire-and-forget tryAutoSnapshot on PUT /content; document restore+Hocuspocus caveat
- Encode current Y.Doc for compare only when Compare tab is open; fix PageHistoryModal export

Made-with: Cursor
@otomatty

otomatty commented Apr 7, 2026

Copy link
Copy Markdown
Owner Author

レビュー指摘への対応を push しました(6588f67)。

対応した内容

  • assertPageViewAccess: notesis_deleted = false で JOIN(削除済みノート経由の一覧参照を遮断)
  • yDocToTiptapJson: ブロック内の XmlText はインライン text ノードとして親にマージ(見出し等の不正ネストを修正)
  • pruneSnapshotsExceedingLimitSqlsnapshotService に集約し、復元トランザクションと自動スナップショットで共有
  • PUT /content: tryAutoSnapshot を await せずベストエフォート実行
  • 復元 API: Hocuspocus との整合について TSDoc で既知制限を明記
  • 履歴モーダル: 壊れていた export を修正し、Compare タブ選択時のみ現在 Y.Doc を base64 化

未実装(別タスク候補)

  • 復元後の Hocuspocus インメモリ無効化(Redis Pub/Sub 等)は本 PR ではドキュメントのみ

各スレッドに返信を付けます。

@otomatty

otomatty commented Apr 7, 2026

Copy link
Copy Markdown
Owner Author

再レビュー依頼

最新の変更に対するレビューをお願いします。

@coderabbitai review

Claude Code Review は Actions から workflow_dispatch で PR #505 を指定して実行しました。

@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown

@otomatty、対応内容を確認しました。以下の変更が含まれていますね。

  • assertPageViewAccess: 削除済みノート経由の参照を is_deleted = false で遮断
  • yDocToTiptapJson: XmlText のインラインマージによる不正ネスト修正
  • pruneSnapshotsExceedingLimitSql の集約によるロジック共有
  • tryAutoSnapshot のベストエフォート実行(非 await 化)
  • 復元 API の TSDoc による既知制限の明記
  • PageHistoryModalexport 修正と Compare タブ最適化

最新のコミット(6588f67)を対象にレビューを開始します。


🧠 Learnings used
Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T12:40:46.017Z
Learning: PR review focus areas: check for security and performance impacts, breaking changes in public APIs or types, proper error handling and logging, and ensure comments in Japanese/English match project tone.

Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-06T03:00:23.809Z
Learning: PR review should evaluate security and performance impact, breaking changes in public APIs and types, error handling and logging appropriateness, and whether Japanese/English comments match project tone

Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T12:40:46.017Z
Learning: PR titles must follow Conventional Commits format (e.g., `feat(admin): AIモデル管理UI拡張 (`#218`)`) and accurately represent the changes. When using Cursor Cloud Agent, include instructions in the prompt to ensure PR titles follow Conventional Commits format.

Learnt from: CR
Repo: otomatty/zedi PR: 0
File: .cursor/rules/pr-and-branch-naming.mdc:0-0
Timestamp: 2026-03-08T07:34:11.938Z
Learning: PR titles must accurately represent the changes made. Use Conventional Commits format (e.g., `feat(admin): AIモデル管理UI拡張 (`#218`)`). For single-topic PRs, use the representative commit message directly. Match the language (Japanese or English) to the majority language of commits.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@otomatty

otomatty commented Apr 7, 2026

Copy link
Copy Markdown
Owner Author

レビューコメントへの対応を push しました(9ae82ad)。再レビューをお願いします。

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown

@otomatty9ae82ad の内容を確認します。最新のコミットを対象にレビューを開始します。


🧠 Learnings used
Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T12:40:46.017Z
Learning: PR review focus areas: check for security and performance impacts, breaking changes in public APIs or types, proper error handling and logging, and ensure comments in Japanese/English match project tone.

Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T12:40:46.017Z
Learning: PR titles must follow Conventional Commits format (e.g., `feat(admin): AIモデル管理UI拡張 (`#218`)`) and accurately represent the changes. When using Cursor Cloud Agent, include instructions in the prompt to ensure PR titles follow Conventional Commits format.

Learnt from: CR
Repo: otomatty/zedi PR: 0
File: .cursor/rules/pr-and-branch-naming.mdc:0-0
Timestamp: 2026-03-08T07:34:11.938Z
Learning: PR titles must accurately represent the changes made. Use Conventional Commits format (e.g., `feat(admin): AIモデル管理UI拡張 (`#218`)`). For single-topic PRs, use the representative commit message directly. Match the language (Japanese or English) to the majority language of commits.

Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-06T03:00:23.809Z
Learning: PR review should evaluate security and performance impact, breaking changes in public APIs and types, error handling and logging appropriateness, and whether Japanese/English comments match project tone
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 12 additional findings in Devin Review.

Open in Devin Review

Comment thread server/hocuspocus/src/index.ts Outdated
// 対象ドキュメントをキャッシュから外してから接続を閉じ、stale state の再保存を防ぐ。
// Remove the live document from cache before disconnecting clients to avoid stale re-persist.
hocuspocus.documents.delete(documentName);
hocuspocus.closeConnections();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 closeConnections() disconnects ALL WebSocket clients instead of only the target document's clients

invalidateLiveDocument at server/hocuspocus/src/index.ts:394 calls hocuspocus.closeConnections() without a document name argument. This is the exact same call used in the SIGTERM/SIGINT shutdown handlers (server/hocuspocus/src/index.ts:500, server/hocuspocus/src/index.ts:512) to terminate every WebSocket connection server-wide. Because the intent here is to invalidate only a single document after a snapshot restore, this means restoring one page disconnects every active collaborator on every document across the entire Hocuspocus server. The documents.delete(documentName) on the line before only removes the target document from cache, so when the onDisconnect callback fires for other documents their live docs are still in the map, triggering unnecessary saveDocumentToDb calls for every unrelated document as well.

Prompt for agents
In server/hocuspocus/src/index.ts, the invalidateLiveDocument function calls hocuspocus.closeConnections() which disconnects ALL WebSocket clients server-wide. The intent is to only disconnect clients for the specific documentName being invalidated. Hocuspocus v3 does not provide a built-in per-document closeConnections overload, so the fix requires iterating over the connections for the target document and closing them individually. One approach: iterate hocuspocus.documents or the underlying WebSocket connections, filter by documentName, and close only those sockets. Alternatively, use the Hocuspocus document-level API if available (check the @hocuspocus/server v3.4.4 source for document.getConnections() or similar). The key constraint is that only the target document's connections should be closed, and the document should be removed from the documents map before closing to prevent stale re-persist in onDisconnect.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘の通りでした。closeConnections() 無引数は全セッション切断でした。invalidateLiveDocument では hocuspocus.closeConnections(documentName)先に呼び、その後 documents.delete(documentName) します(closeConnectionsdocuments を走査するため、delete より前が必須)。対応コミット: 322ddb2

Devin is correct: no-arg closeConnections() closed every client. We now call hocuspocus.closeConnections(documentName) before documents.delete(documentName) (order matters because closeConnections iterates the map). Fix: 322ddb2.

coderabbitai[bot]

This comment was marked as resolved.

- invalidateHocuspocusDocument: AbortController + 2.5s timeout, log-only failures
- Hocuspocus invalidate: closeConnections(documentName) before documents.delete
- pageSnapshots tests: stub fetch to avoid env-dependent HTTP
- pages.test: relax DB chain assertion for snapshot path
- editorConfig: bilingual TSDoc for createEditorExtensions

Made-with: Cursor
@otomatty

otomatty commented Apr 7, 2026

Copy link
Copy Markdown
Owner Author

レビュー対応を push しました(322ddb2)。

  • Hocuspocus: invalidateLiveDocumentcloseConnections(documentName)先に呼び、その後 documents.delete(単一ドキュメントのみ切断)。
  • API: invalidateHocuspocusDocument に 2.5s タイムアウト(AbortController)、失敗はログのみ。
  • テスト: restore 周りで fetch をスタブ、pages.test のチェーン完全一致を緩和。
  • editorConfig: createEditorExtensions に日英 TSDoc。

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown

@otomatty322ddb2 の対応内容を確認しました。以下の変更が含まれていますね。

  • Hocuspocus invalidateLiveDocument: closeConnections(documentName) を先に呼び、その後 documents.delete — 接続切断の順序を適切に修正
  • API invalidateHocuspocusDocument: AbortController による 2.5s タイムアウトを追加し、失敗はログのみ — ネットワーク障害時の応答ブロックを防止
  • テスト: fetch スタブの追加と pages.test のチェーン完全一致を緩和 — テストの堅牢性を向上
  • editorConfig: createEditorExtensions に日英 TSDoc を追加 — ドキュメントの充実

最新のコミット(322ddb2)を対象にレビューを開始します。


🧠 Learnings used
Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T12:40:46.017Z
Learning: PR review focus areas: check for security and performance impacts, breaking changes in public APIs or types, proper error handling and logging, and ensure comments in Japanese/English match project tone.

Learnt from: CR
Repo: otomatty/zedi PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T12:40:46.017Z
Learning: PR titles must follow Conventional Commits format (e.g., `feat(admin): AIモデル管理UI拡張 (`#218`)`) and accurately represent the changes. When using Cursor Cloud Agent, include instructions in the prompt to ensure PR titles follow Conventional Commits format.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 16 additional findings in Devin Review.

Open in Devin Review

Comment on lines +387 to +396
async function invalidateLiveDocument(documentName: string): Promise<boolean> {
if (!hocuspocus.documents.has(documentName)) {
return false;
}

// closeConnections(documentName) は documents マップを走査するため、delete より先に呼ぶ。
// Pass documentName so only that document's WebSocket connections close (not server-wide).
hocuspocus.closeConnections(documentName);
hocuspocus.documents.delete(documentName);
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Race condition: Hocuspocus debounced onStoreDocument can overwrite restored content

After a snapshot restore, the API commits the restored content to page_contents and then calls invalidateLiveDocument on Hocuspocus. invalidateLiveDocument calls closeConnections(documentName) and then documents.delete(documentName). However, Hocuspocus is configured with debounce: 2000 and maxDebounce: 10000 (server/hocuspocus/src/index.ts:292-293). If a document change occurred shortly before the restore (within the debounce window), the debounced onStoreDocument timer may still be pending. Manually deleting the document from the documents Map bypasses Hocuspocus's normal document lifecycle and may not cancel these internal debounce timers. When the timer fires, onStoreDocument (server/hocuspocus/src/index.ts:373-380) calls saveDocumentToDb with the stale Y.Doc state, which unconditionally upserts into page_contents (lines 214-224), overwriting the just-restored content.

Additionally, the onDisconnect handler (server/hocuspocus/src/index.ts:340-361) triggered by closeConnections checks hocuspocus.documents.get(documentName) and attempts saveDocumentToDb when remaining === 0. If onDisconnect runs synchronously during closeConnections (before documents.delete), the stale document is still in the map and gets saved back to the DB.

Prompt for agents
The invalidateLiveDocument function in server/hocuspocus/src/index.ts manually deletes a document from hocuspocus.documents, but this bypasses Hocuspocus internal cleanup (debounce timer cancellation). The onDisconnect handler at line 340 also attempts to save the document on last disconnect.

To fix:
1. Clear documentConnectionCounts for the documentName before calling closeConnections, so onDisconnect handlers see remaining=0 already handled and skip the save. Or set a flag/set tracking invalidated documents so onDisconnect and onStoreDocument skip saves for them.
2. Consider using Hocuspocus's built-in document unloading mechanism if available, instead of manually deleting from the documents Map, so debounce timers are properly canceled.
3. As a safety net, add version-checking to saveDocumentToDb so it only saves if the version matches what the in-memory doc expects (similar to the optimistic locking in the API's PUT endpoint).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@otomatty
otomatty merged commit 832cc60 into develop Apr 7, 2026
10 checks passed
@otomatty
otomatty deleted the feature/page-snapshots-version-history branch April 7, 2026 06:47
@otomatty otomatty mentioned this pull request Apr 8, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant