feat: Propagate page title renames to WikiLinks, tags, and ghost links (issue #726 Phase 2)#736
Conversation
…#726 Phase 2) When a page title changes via PUT /api/pages/:id/content, traverse every source document referencing it and rewrite the matching `wikiLink` marks (`attrs.title`) and `tag` marks (`attrs.name`) inside the Y.Doc stored in `page_contents.ydoc_state`. Segment text is only replaced when it still matches the old title (preserving manually-edited link text), while the mark attribute is always refreshed. Ghost links whose text now matches the new title are promoted to real `links` rows. Design choices (see issue #726 open questions): - Background (fire-and-forget) propagation; the main PUT response is not blocked on the rewrite. - Row-locked per-source transactions serialize with Hocuspocus's `onStoreDocument` writes; the live Y.Doc cache is invalidated after each successful source-page rewrite so collaborators reload from DB. - Best-effort: per-source failures are logged and counted, never aborting the batch or the originating request. Also extracts `invalidateHocuspocusDocument` into `server/api/src/lib/hocuspocusInvalidation.ts` so both the rename flow and the existing snapshot-restore flow share the same helper. Closes #726. https://claude.ai/code/session_01BMij57Zvw2nvhf7tdKwQ3f
📝 WalkthroughWalkthroughDetects meaningful page title renames on PUT, returns response immediately, and fires a background propagation task that rewrites Yjs wiki-link/tag marks, promotes matching ghost links to real links, persists updated Y.Doc state and previews, and best-effort invalidates Hocuspocus documents. Changes
Sequence DiagramsequenceDiagram
participant Client
participant API as API Server (pages.ts)
participant DB as Database
participant YRewrite as Yjs Rewriter
participant Hocus as Hocuspocus
Client->>API: PUT /pages/:id/content (with body.title)
activate API
API->>DB: SELECT current title
API->>DB: UPDATE/UPSERT page_contents & pages
API-->>Client: 200 OK
deactivate API
Note over API: Background task — propagateTitleRename
API->>DB: SELECT source pages linking → renamedPageId
loop per source page
DB->>API: return sourceId, page_contents.ydocState
API->>YRewrite: rewriteTitleRefsInDoc(old→new)
YRewrite-->>API: RewriteResult
alt changes present
API->>DB: UPDATE page_contents (ydocState, version, contentText, updatedAt)
API->>DB: UPDATE pages (contentPreview, updatedAt)
API->>Hocus: POST /internal/documents/:id/invalidate
end
end
par ghost promotion
API->>DB: SELECT ghostLinks matching newTitle
API->>DB: DELETE ghostLinks
API->>DB: INSERT links (onConflictDoNothing)
end
API-->>API: Log summary counters
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Code Review
This pull request implements Phase 2 of the title rename propagation feature (Issue #726). It introduces a service to update WikiLink and tag marks across all referencing documents when a page is renamed, and promotes matching ghost links to real links. Key additions include the titleRenamePropagationService, a ydocRenameRewrite helper for Y.Doc mutations, and a centralized hocuspocusInvalidation utility to ensure clients reload updated content. Feedback was provided regarding a performance optimization in the Y.Doc node iteration logic to avoid O(N^2) complexity by using an iterator or converting the node to an array.
| for (let i = 0; i < total; i++) { | ||
| const child = node.get(i) as XmlNode; |
There was a problem hiding this comment.
Yjs の Y.XmlFragment や Y.XmlElement において、node.get(i) はインデックス i に比例した計算量(O(i))がかかります。そのため、このループ全体では要素数 N に対して O(N^2) の計算量となり、ドキュメントが大きくなった場合にパフォーマンスが著しく低下する恐れがあります。
node.toArray() を一度呼び出して配列に変換してからループを回すか、イテレータを使用することで O(N) に改善できます。
for (const child of node.toArray()) {
if (child instanceof Y.XmlText) {There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 11cc096bc7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| sql`LOWER(TRIM(${ghostLinks.linkText})) = LOWER(TRIM(${newTitle}))`, | ||
| ne(ghostLinks.sourcePageId, renamedPageId), |
There was a problem hiding this comment.
Restrict ghost-link promotion to renamed page scope
promoteGhostLinks currently deletes/promotes rows by link_text match only, so a rename in one workspace/user scope will also consume unrelated ghost_links from other owners/notes that happen to use the same title text. In a multi-user database this can silently create cross-tenant links edges (and remove the original ghosts), corrupting reference graphs outside the renamed page’s scope. The promotion query should join/filter by the renamed page’s ownership/note scope (or equivalent scope key) before deleting and inserting.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/api/src/lib/hocuspocusInvalidation.ts`:
- Around line 46-53: The code currently returns silently when baseUrl or
internalSecret is missing; update the check around baseUrl/internalSecret to
emit a clear error in non-development environments (use processLogger.error or
console.error) including which envs are missing (HOCUSPOCUS_INTERNAL_URL /
BETTER_AUTH_SECRET) and contextual identifiers (prefix and pageId) so operators
see misconfiguration, then still return (or optionally throw) to prevent silent
invalidation disabling; ensure you reference the same variables baseUrl,
internalSecret, prefix, and pageId in the log.
In `@server/api/src/services/titleRenamePropagationService.ts`:
- Around line 163-169: The transaction updates only ydocState
(tx.update(pageContents).set) so the visible plain-text and preview stay stale;
after reconstructing or applying edits to the Y.Doc used to produce
encodedState, compute the new plain text/preview (e.g., derive contentText and
preview from the rewritten Y.Doc) and include those fields in the same
tx.update(...) .set call alongside ydocState, version, and updatedAt so
page_contents.contentText and any preview metadata are persisted atomically with
ydocState.
- Around line 261-271: The rewrite currently only matches by title and can
retarget links when duplicate titles exist; update the flow to carry the renamed
page's ID through the rewrite so rewrites match both title and identity. Change
the call site in titleRenamePropagationService where you call
rewriteSourcePage(db, sourceId, trimmedOld, trimmedNew) to pass the
renamedPageId as an additional argument, update rewriteSourcePage(...) signature
to accept targetId and forward it into rewriteTitleRefsInDoc(...), and modify
rewriteTitleRefsInDoc(oldTitle, newTitle, ...) to accept the targetId and only
replace WikiLink/tag marks when the normalized title matches AND the mark’s
resolved target id (or explicit id in the mark) equals the renamedPageId (or
skip marks without identity); alternatively, if you prefer enforcing uniqueness,
add a schema unique constraint on pages.title and migrate data -- pick one
approach and implement the corresponding signature and matching changes for
rewriteSourcePage and rewriteTitleRefsInDoc.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 59c2e86f-afcc-4053-9462-b836d7929ac6
📒 Files selected for processing (8)
server/api/src/__tests__/routes/pages.test.tsserver/api/src/__tests__/services/titleRenamePropagationService.test.tsserver/api/src/__tests__/services/ydocRenameRewrite.test.tsserver/api/src/lib/hocuspocusInvalidation.tsserver/api/src/routes/pageSnapshots.tsserver/api/src/routes/pages.tsserver/api/src/services/titleRenamePropagationService.tsserver/api/src/services/ydocRenameRewrite.ts
| const sourceRows = await db | ||
| .select({ sourceId: links.sourceId }) | ||
| .from(links) | ||
| .where(eq(links.targetId, renamedPageId)); | ||
|
|
||
| const uniqueSourceIds = Array.from(new Set(sourceRows.map((r) => r.sourceId))); | ||
|
|
||
| for (const sourceId of uniqueSourceIds) { | ||
| result.sourcePagesAttempted += 1; | ||
| try { | ||
| const { changed, rewrite } = await rewriteSourcePage(db, sourceId, trimmedOld, trimmedNew); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== pages schema / uniqueness ==\n'
rg -n -C4 'pgTable\("pages"|title:|unique|uniqueIndex|UNIQUE' server/api/src/schema/index.ts
printf '\n== rename propagation / title-only matching ==\n'
rg -n -C3 'rewriteTitleRefsInDoc|targetId|sourceId|oldTitle|newTitle' \
server/api/src/services/titleRenamePropagationService.ts \
server/api/src/services/ydocRenameRewrite.ts
printf '\n== page creation/update paths that may allow duplicate titles ==\n'
rg -n -C3 'insert\(pages\)|update\(pages\)|title:' server/api/src/routes/pages.tsRepository: otomatty/zedi
Length of output: 90
🏁 Script executed:
#!/bin/bash
# First, check if the files exist
echo "== Checking file existence =="
find server -name "index.ts" -path "*/schema/*" 2>/dev/null | head -20
find server -name "titleRenamePropagationService.ts" 2>/dev/null
find server -name "pages.ts" -path "*/routes/*" 2>/dev/nullRepository: otomatty/zedi
Length of output: 243
🏁 Script executed:
#!/bin/bash
# Check schema file size and examine pages table definition
echo "== Examining schema/index.ts =="
wc -l server/api/src/schema/index.ts
# Look for pages table definition and uniqueness constraints
rg -n "pages\s*=" server/api/src/schema/index.ts | head -20
# Get context around pages table
rg -n -A 30 "pgTable\(" server/api/src/schema/index.ts | grep -A 30 "pages"Repository: otomatty/zedi
Length of output: 124
🏁 Script executed:
#!/bin/bash
# Read the entire schema file
echo "== Full schema/index.ts =="
cat -n server/api/src/schema/index.tsRepository: otomatty/zedi
Length of output: 3797
🏁 Script executed:
#!/bin/bash
# Find and examine pages.js in the schema directory
echo "== Locating pages.js =="
find server -name "pages.js" -path "*/schema/*"
# Check size
wc -l server/api/src/schema/pages.js
# Show the pages table definition
echo "== Full pages.js =="
cat -n server/api/src/schema/pages.jsRepository: otomatty/zedi
Length of output: 244
🏁 Script executed:
#!/bin/bash
# List all files in schema directory
echo "== Files in schema directory =="
ls -la server/api/src/schema/
# Look for pages definitions
echo "== Searching for pages table definition =="
rg -l "pgTable\|export.*pages" server/api/src/schema/Repository: otomatty/zedi
Length of output: 1404
🏁 Script executed:
#!/bin/bash
# Read pages.ts schema file
echo "== pages.ts schema =="
cat -n server/api/src/schema/pages.tsRepository: otomatty/zedi
Length of output: 4992
🏁 Script executed:
#!/bin/bash
# Read the titleRenamePropagationService.ts file
echo "== titleRenamePropagationService.ts =="
wc -l server/api/src/services/titleRenamePropagationService.ts
# Read the relevant sections - around lines 261-271 and the rewriteSourcePage/rewriteTitleRefsInDoc functions
head -280 server/api/src/services/titleRenamePropagationService.ts | tail -50Repository: otomatty/zedi
Length of output: 2049
🏁 Script executed:
#!/bin/bash
# Read the full titleRenamePropagationService.ts
cat -n server/api/src/services/titleRenamePropagationService.tsRepository: otomatty/zedi
Length of output: 12714
🏁 Script executed:
#!/bin/bash
# Read ydocRenameRewrite.ts to understand rewriteTitleRefsInDoc
echo "== ydocRenameRewrite.ts =="
wc -l server/api/src/services/ydocRenameRewrite.ts
cat -n server/api/src/services/ydocRenameRewrite.tsRepository: otomatty/zedi
Length of output: 10640
This title-only rewrite can silently retarget unrelated references when duplicate page titles exist.
The bug is real: pages.ts schema has no unique constraint on title, so duplicate titles are allowed. When propagateTitleRename() selects source pages via links.targetId (correct UUID-based identity), it then calls rewriteSourcePage() → rewriteTitleRefsInDoc(), which receives only oldTitle/newTitle strings. rewriteTitleRefsInDoc() then rewrites every WikiLink and tag mark in that document whose normalized title matches the old value, with no identity check against the actual renamed page's ID.
Example: If a source page contains links to both "Page A (id: 123, title: 'Foo')" and "Page B (id: 456, title: 'Foo')", renaming Page A from 'Foo' to 'Bar' will rewrite all 'Foo' marks to 'Bar', including those that actually pointed to Page B.
Fix: Either enforce title uniqueness in the schema, or carry the target page ID through the rewrite to match both title and identity before updating marks.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/api/src/services/titleRenamePropagationService.ts` around lines 261 -
271, The rewrite currently only matches by title and can retarget links when
duplicate titles exist; update the flow to carry the renamed page's ID through
the rewrite so rewrites match both title and identity. Change the call site in
titleRenamePropagationService where you call rewriteSourcePage(db, sourceId,
trimmedOld, trimmedNew) to pass the renamedPageId as an additional argument,
update rewriteSourcePage(...) signature to accept targetId and forward it into
rewriteTitleRefsInDoc(...), and modify rewriteTitleRefsInDoc(oldTitle, newTitle,
...) to accept the targetId and only replace WikiLink/tag marks when the
normalized title matches AND the mark’s resolved target id (or explicit id in
the mark) equals the renamedPageId (or skip marks without identity);
alternatively, if you prefer enforcing uniqueness, add a schema unique
constraint on pages.title and migrate data -- pick one approach and implement
the corresponding signature and matching changes for rewriteSourcePage and
rewriteTitleRefsInDoc.
- gemini: use `node.toArray()` in the Y.Doc walker to replace the O(N^2) `node.get(i)` index loop with a single O(N) pass. - coderabbit: `hocuspocusInvalidation` now logs a warn in every env when `HOCUSPOCUS_INTERNAL_URL` / `BETTER_AUTH_SECRET` is missing (previously silent in production), naming the missing variable(s) in the message. - chatgpt-codex P1: `promoteGhostLinks` now scopes promotion by the renamed page's `pages.note_id` / `pages.owner_id`. Without this, renaming a page in one tenant could consume ghost rows on unrelated users' / notes' pages and create cross-scope `links` edges. Note-native targets match by `note_id`; personal targets match by `note_id IS NULL AND owner_id = ...`. A new test covers the guard. - coderabbit: rewriting a source page's Y.Doc now re-derives the plain text via `extractTextFromYXml`, writes it into `page_contents.content_text` in the same transaction, and refreshes `pages.content_preview`. Search and listing metadata stay consistent with the rewritten document. - Adds `server/api/src/lib/extractPlainTextFromYXml.ts` duplicating the Hocuspocus-side helper (server/api and server/hocuspocus are separate Bun projects per CLAUDE.md; snapshot utilities already follow this mirror-with-note pattern). Tests updated to reflect the new query plans. Tracks #726 / PR #736. https://claude.ai/code/session_01BMij57Zvw2nvhf7tdKwQ3f
There was a problem hiding this comment.
♻️ Duplicate comments (1)
server/api/src/services/titleRenamePropagationService.ts (1)
321-358:⚠️ Potential issue | 🟠 MajorTitle-only rewrite can still retarget unrelated references when duplicate titles exist.
The concern from the past review remains: the service queries links by
targetId(UUID-based identity, line 326), butrewriteTitleRefsInDocmatches marks purely by normalized title string. If a source page contains WikiLinks to two different pages that happen to share the same title, renaming one will incorrectly rewrite all matching marks.While duplicate titles within the same owner are uncommon, the schema allows them (
pages.titlehas no unique constraint).Mitigation options:
- Carry
renamedPageIdinto the rewrite and match both title and mark identity (if marks store target page IDs).- Add a unique constraint on
(ownerId, title)or(noteId, title).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/src/services/titleRenamePropagationService.ts` around lines 321 - 358, The rewrite currently finds source pages by links.targetId but rewriteSourcePage and its helper rewriteTitleRefsInDoc only match marks by normalized title, causing unrelated same-title marks to be retargeted; fix by passing the renamedPageId into rewriteSourcePage and down into rewriteTitleRefsInDoc so the mark matching checks both normalized title and the mark's stored target id (or mark identity field) before rewriting; update rewriteSourcePage(signature) and rewriteTitleRefsInDoc(signature) to accept renamedPageId and adjust their matching logic to require title match AND mark.targetId === renamedPageId (or equivalent mark id field), leaving the existing link-based source selection intact.
🧹 Nitpick comments (1)
server/api/src/services/ydocRenameRewrite.ts (1)
47-56: The character sets are already in sync, but consider extracting the shared character class to a dedicated module.The character set
[A-Za-z0-9_\--ヿ㐀-鿿]is identical in bothVALID_TAG_NAME_REGEX(server) andTAG_PASTE_REGEX(client), confirming the comment's sync intention is currently maintained. However, the two regexes serve different purposes: the client regex detects tags with#prefix during paste, while the server regex validates tag name strings after rename.Rather than extracting the full regexes (which would be architecturally difficult), consider creating a shared constant for the character class alone in a location both server and client can access, reducing duplication and making the sync dependency explicit.
Note: The original comment references
TAG_PASTE_REGEXinsrc/lib/tagUtils.ts, but it is actually defined insrc/components/editor/extensions/TagExtension.ts.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/src/services/ydocRenameRewrite.ts` around lines 47 - 56, Extract the shared character class string (currently "[A-Za-z0-9_\\--ヿ㐀-鿿]") into a small shared constant module that both server and client can import, update VALID_TAG_NAME_REGEX in ydocRenameRewrite.ts to build its regex from that shared character-class constant (e.g., new RegExp(`^${CHAR_CLASS}+$/`)), update TAG_PASTE_REGEX in TagExtension.ts to use the same shared constant instead of its own literal, and fix the comment in ydocRenameRewrite.ts to point to the actual TagExtension.ts location rather than src/lib/tagUtils.ts so the sync dependency is explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@server/api/src/services/titleRenamePropagationService.ts`:
- Around line 321-358: The rewrite currently finds source pages by
links.targetId but rewriteSourcePage and its helper rewriteTitleRefsInDoc only
match marks by normalized title, causing unrelated same-title marks to be
retargeted; fix by passing the renamedPageId into rewriteSourcePage and down
into rewriteTitleRefsInDoc so the mark matching checks both normalized title and
the mark's stored target id (or mark identity field) before rewriting; update
rewriteSourcePage(signature) and rewriteTitleRefsInDoc(signature) to accept
renamedPageId and adjust their matching logic to require title match AND
mark.targetId === renamedPageId (or equivalent mark id field), leaving the
existing link-based source selection intact.
---
Nitpick comments:
In `@server/api/src/services/ydocRenameRewrite.ts`:
- Around line 47-56: Extract the shared character class string (currently
"[A-Za-z0-9_\\--ヿ㐀-鿿]") into a small shared constant module that both server
and client can import, update VALID_TAG_NAME_REGEX in ydocRenameRewrite.ts to
build its regex from that shared character-class constant (e.g., new
RegExp(`^${CHAR_CLASS}+$/`)), update TAG_PASTE_REGEX in TagExtension.ts to use
the same shared constant instead of its own literal, and fix the comment in
ydocRenameRewrite.ts to point to the actual TagExtension.ts location rather than
src/lib/tagUtils.ts so the sync dependency is explicit.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 6db650af-6678-4a1b-87f2-e52d2f44b3a7
📒 Files selected for processing (5)
server/api/src/__tests__/services/titleRenamePropagationService.test.tsserver/api/src/lib/extractPlainTextFromYXml.tsserver/api/src/lib/hocuspocusInvalidation.tsserver/api/src/services/titleRenamePropagationService.tsserver/api/src/services/ydocRenameRewrite.ts
✅ Files skipped from review due to trivial changes (1)
- server/api/src/tests/services/titleRenamePropagationService.test.ts
概要
ページのタイトル変更を、参照元ドキュメント内の WikiLink / タグマークおよびゴーストリンクへ自動的に伝播させる機能を実装しました。タイトル変更時に関連するすべての参照を一貫性のある状態に保ちます。
変更点
titleRenamePropagationService.ts(新規): タイトル変更の伝播を統合するメインサービスydocRenameRewrite.ts(新規): Y.Doc 内の WikiLink / タグマークを書き換えるピュアヘルパーattrs.title)と tag マーク(attrs.name)を対象hocuspocusInvalidation.ts(新規): Hocuspocus のライブドキュメント無効化ヘルパーroutes/pages.ts(修正): PUT /content エンドポイントでタイトル変更を検出routes/pageSnapshots.ts(修正): Hocuspocus 無効化ロジックを共通ライブラリへ抽出テスト (新規): 3 つの包括的なテストスイート
ydocRenameRewrite.test.ts: Y.Doc 書き換えロジックの単体テスト(294 行)titleRenamePropagationService.test.ts: サービス統合テスト(282 行)pages.test.ts: エンドポイント統合テスト(50 行追加)変更の種類
テスト方法
npm testで以下が実https://claude.ai/code/session_01BMij57Zvw2nvhf7tdKwQ3f
Summary by CodeRabbit
New Features
Utilities
Tests