diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index ec8a27b0..a36b3d67 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -334,4 +334,50 @@ describe("PUT /api/pages/:id/content", () => { expect(res.status).toBe(400); }); + + // Issue #726: タイトル変更検出のため、PUT に title が含まれるとき pages.title + // を SELECT してから UPDATE を行う。これにより伝播処理の起点になる。 + // Issue #726: when PUT carries `title`, the route SELECTs the current + // `pages.title` before UPDATE so the handler can detect a rename and + // trigger background propagation. + it("issues an extra SELECT for rename detection when body.title is provided", async () => { + const ydocB64 = Buffer.from("hello").toString("base64"); + const { app, chains } = createPagesAppWithChains([ + // 1. access check select + [{ id: PAGE_ID, ownerId: TEST_USER_ID }], + // 2. UPDATE page_contents (optimistic version path) + [{ version: 2, pageId: PAGE_ID }], + // 3. SELECT pages.title in applyPagesMetadataUpdate (rename detection) + // Same title as body → no propagation triggered. + [{ title: "Same Title" }], + // 4. UPDATE pages (title + updatedAt) + [], + // 5. auto-snapshot select (empty → no snapshot) + [], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/content`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ + ydoc_state: ydocB64, + expected_version: 1, + title: "Same Title", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { version: number }; + expect(body.version).toBe(2); + + // applyPagesMetadataUpdate must have issued the extra SELECT for the + // pages.title read. The shape includes access-check SELECT + title-read + // SELECT (+ auto-snapshot SELECT), and at least one UPDATE chain. + // リネーム検出のため pages.title を読む SELECT が増えること。 + const selectChains = chains.filter((c) => c.startMethod === "select"); + expect(selectChains.length).toBeGreaterThanOrEqual(2); + const updateChains = chains.filter((c) => c.startMethod === "update"); + // UPDATE page_contents + UPDATE pages + expect(updateChains.length).toBeGreaterThanOrEqual(2); + }); }); diff --git a/server/api/src/__tests__/services/titleRenamePropagationService.test.ts b/server/api/src/__tests__/services/titleRenamePropagationService.test.ts new file mode 100644 index 00000000..8bcaa7cb --- /dev/null +++ b/server/api/src/__tests__/services/titleRenamePropagationService.test.ts @@ -0,0 +1,340 @@ +/** + * `titleRenamePropagationService` の単体テスト。 + * Unit tests for `titleRenamePropagationService` — orchestrates WikiLink / + * tag rewrites across source pages and ghost promotion when a page is + * renamed (issue #726). + */ + +import { describe, it, expect, vi } from "vitest"; +import * as Y from "yjs"; + +import { createMockDb } from "../createMockDb.js"; +import { propagateTitleRename } from "../../services/titleRenamePropagationService.js"; + +/** + * page_contents 行に入っているようなバイナリ Y.Doc を生成するヘルパー。 + * Build an encoded Y.Doc blob shaped like a `page_contents.ydoc_state` row. + */ +function makeYdocWithWikiLink(title: string): Buffer { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + text.insert(0, title, { wikiLink: { title, exists: true, referenced: false } }); + return Buffer.from(Y.encodeStateAsUpdate(doc)); +} + +function decodeYdocWikiLinkTitle(buffer: Buffer): string | null { + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(buffer)); + const fragment = doc.getXmlFragment("default"); + const paragraph = fragment.get(0); + if (!(paragraph instanceof Y.XmlElement)) return null; + const text = paragraph.get(0); + if (!(text instanceof Y.XmlText)) return null; + const delta = text.toDelta() as Array<{ insert: unknown; attributes?: Record }>; + for (const item of delta) { + const wl = item.attributes?.wikiLink as { title?: string } | undefined; + if (wl?.title) return wl.title; + } + return null; +} + +const PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000001"; +const SOURCE_PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000002"; +const OWNER_ID = "owner-user-1"; + +/** Default scope result: personal page owned by OWNER_ID. 個人ページ既定スコープ。 */ +const PERSONAL_SCOPE_ROW = [{ noteId: null, ownerId: OWNER_ID }]; + +describe("propagateTitleRename", () => { + it("returns a zero result and skips all DB work when oldTitle or newTitle is missing", async () => { + const { db, chains } = createMockDb([]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const a = await propagateTitleRename(db as never, PAGE_ID, "", "Bar", { + invalidateDocument: invalidate, + }); + const b = await propagateTitleRename(db as never, PAGE_ID, "Foo", undefined, { + invalidateDocument: invalidate, + }); + const c = await propagateTitleRename(db as never, PAGE_ID, null, "Foo", { + invalidateDocument: invalidate, + }); + + for (const r of [a, b, c]) { + expect(r.sourcePagesAttempted).toBe(0); + expect(r.wikiLinkMarksUpdated).toBe(0); + expect(r.tagMarksUpdated).toBe(0); + expect(r.ghostPromotionsCount).toBe(0); + } + expect(chains.length).toBe(0); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("returns a zero result when oldTitle and newTitle normalize to the same value", async () => { + const { db, chains } = createMockDb([]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", " foo ", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(0); + expect(result.ghostPromotionsCount).toBe(0); + expect(chains.length).toBe(0); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("rewrites matching wikiLink marks, updates contentText/preview, and invalidates the doc", async () => { + const originalYdoc = makeYdocWithWikiLink("Foo"); + + // Query plan: + // 1. SELECT sourceId FROM links WHERE targetId = ... → sources + // 2. TX: SELECT 1 ... FOR UPDATE → (ignored) + // 3. TX: SELECT * FROM page_contents → row with old ydoc + // 4. TX: UPDATE page_contents → (ignored) + // 5. TX: UPDATE pages (content_preview) → (ignored) + // 6. TX (promote): SELECT pages scope → personal scope + // 7. TX (promote): SELECT candidates (join) → [] (no ghosts) + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], // 1 + [], // 2 — FOR UPDATE + [{ pageId: SOURCE_PAGE_ID, ydocState: originalYdoc, version: 7 }], // 3 + [{ version: 8 }], // 4 + [], // 5 + PERSONAL_SCOPE_ROW, // 6 + [], // 7 — no candidates + ]); + const invalidate = vi.fn().mockResolvedValue(undefined); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesSucceeded).toBe(1); + expect(result.sourcePagesFailed).toBe(0); + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + expect(invalidate).toHaveBeenCalledTimes(1); + expect(invalidate).toHaveBeenCalledWith(SOURCE_PAGE_ID); + + // UPDATE page_contents carries ydoc_state (wikiLink title → "Bar") and + // the freshly-extracted contentText. UPDATE pages carries content_preview. + // UPDATE page_contents は ydoc_state と contentText を更新し、UPDATE pages は + // content_preview を更新する。 + const updateChains = chains.filter((c) => c.startMethod === "update"); + expect(updateChains.length).toBe(2); + const pageContentsUpdate = updateChains.find((c) => { + const setArg = c.ops.find((op) => op.method === "set")?.args[0] as + | Record + | undefined; + return setArg && "ydocState" in setArg; + }); + expect(pageContentsUpdate).toBeTruthy(); + const pcSetArg = pageContentsUpdate?.ops.find((op) => op.method === "set")?.args[0] as + | { ydocState: Buffer; contentText: string } + | undefined; + expect(pcSetArg?.ydocState).toBeInstanceOf(Buffer); + // `extractTextFromYXml` appends a newline after block-level XmlElements + // (e.g. paragraph), so the raw plain text is `"Bar\n"`. + // `extractTextFromYXml` はブロック要素 (paragraph 等) の後に改行を付けるため、 + // プレーンテキストは末尾に改行が付く。 + expect(pcSetArg?.contentText).toBe("Bar\n"); + if (pcSetArg?.ydocState) { + expect(decodeYdocWikiLinkTitle(pcSetArg.ydocState)).toBe("Bar"); + } + + const pagesUpdate = updateChains.find((c) => { + const setArg = c.ops.find((op) => op.method === "set")?.args[0] as + | Record + | undefined; + return setArg && "contentPreview" in setArg; + }); + expect(pagesUpdate).toBeTruthy(); + const pagesSetArg = pagesUpdate?.ops.find((op) => op.method === "set")?.args[0] as + | { contentPreview: string } + | undefined; + expect(pagesSetArg?.contentPreview).toBe("Bar"); + }); + + it("skips rewriting when the source page has no page_contents row", async () => { + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], // sources + [], // FOR UPDATE + [], // page_contents empty + PERSONAL_SCOPE_ROW, // ghost scope + [], // ghost candidates (none) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesSucceeded).toBe(1); + expect(result.wikiLinkMarksUpdated).toBe(0); + // No UPDATE when there's no content row. / コンテンツ行が無ければ UPDATE しない。 + const updateChain = chains.find((c) => c.startMethod === "update"); + expect(updateChain).toBeUndefined(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("skips UPDATE and invalidation when rewriting yields zero changes", async () => { + // Source page has no matching wiki-link: the rewriter returns zero changes. + // ソース側にマッチするリンクが無ければ書き換えゼロで終わる。 + const unrelatedYdoc = makeYdocWithWikiLink("Unrelated"); + + const { db, chains } = createMockDb([ + [{ sourceId: SOURCE_PAGE_ID }], + [], // FOR UPDATE + [{ pageId: SOURCE_PAGE_ID, ydocState: unrelatedYdoc, version: 1 }], + PERSONAL_SCOPE_ROW, // ghost scope + [], // ghost candidates (none) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(chains.find((c) => c.startMethod === "update")).toBeUndefined(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("promotes in-scope ghost links whose text matches the new title", async () => { + const GHOST_SOURCE = "11111111-aaaa-bbbb-cccc-000000000003"; + + const { db, chains } = createMockDb([ + [], // no real link sources + PERSONAL_SCOPE_ROW, // renamed-page scope (personal) + // in-scope ghost candidates (SELECT … INNER JOIN pages) + [ + { sourcePageId: GHOST_SOURCE, linkType: "wiki" }, + { sourcePageId: GHOST_SOURCE, linkType: "tag" }, + ], + [], // DELETE ghost_links (result ignored) + [], // INSERT links (result ignored) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.ghostPromotionsCount).toBe(2); + + // Delete on ghost_links and Insert on links should both be present. + // 削除と挿入の両方が行われる。 + const deleteChain = chains.find((c) => c.startMethod === "delete"); + expect(deleteChain).toBeTruthy(); + const insertChain = chains.find((c) => c.startMethod === "insert"); + expect(insertChain).toBeTruthy(); + const valuesCall = insertChain?.ops.find((op) => op.method === "values"); + const valuesArg = valuesCall?.args[0] as Array<{ + sourceId: string; + targetId: string; + linkType: string; + }>; + expect(valuesArg).toHaveLength(2); + expect(valuesArg?.every((v) => v.targetId === PAGE_ID)).toBe(true); + }); + + it("does not issue DELETE or INSERT when no in-scope ghost candidates match", async () => { + const { db, chains } = createMockDb([ + [], // no sources + PERSONAL_SCOPE_ROW, // scope + [], // candidates (empty) + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.ghostPromotionsCount).toBe(0); + expect(chains.find((c) => c.startMethod === "delete")).toBeUndefined(); + expect(chains.find((c) => c.startMethod === "insert")).toBeUndefined(); + }); + + it("skips ghost promotion when the renamed page's scope row is missing", async () => { + // The renamed page was deleted between the title change and the background + // propagation run. Without a scope row we can't decide which ghosts belong + // to the same tenant, so we skip promotion entirely. + // リネーム対象の pages 行が消えた場合はスコープ判定が出来ないため、 + // ゴースト昇格はスキップする(PR #736 P1 レビュー対応)。 + const { db, chains } = createMockDb([ + [], // no sources + [], // pages scope — empty + ]); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.ghostPromotionsCount).toBe(0); + // Only the initial "sources" select + the scope select should have run. + // 初期の sources SELECT とスコープ SELECT のみ。 + expect(chains.filter((c) => c.startMethod === "select")).toHaveLength(2); + expect(chains.find((c) => c.startMethod === "delete")).toBeUndefined(); + expect(chains.find((c) => c.startMethod === "insert")).toBeUndefined(); + }); + + it("records failures per source page and still attempts ghost promotion", async () => { + // 1st source: FOR UPDATE rejects with an error → counted as failure. + // ただしベストエフォート方針で後続処理(ghost 昇格)は続行する。 + // Best-effort: a per-source failure must not abort ghost promotion. + const baseResults = [ + [{ sourceId: SOURCE_PAGE_ID }], // sources + PERSONAL_SCOPE_ROW, // promote scope + [], // promote candidates (none) + ]; + const base = createMockDb(baseResults); + let forUpdateCallCount = 0; + const db = new Proxy(base.db as unknown as Record, { + get(target, prop: string) { + if (prop === "transaction") { + return async (fn: (tx: unknown) => Promise) => { + const txProxy = new Proxy(target, { + get(t, p: string) { + if (p === "execute") { + // First FOR UPDATE execute call throws. + // 1 回目の FOR UPDATE を失敗させる。 + return () => { + forUpdateCallCount += 1; + if (forUpdateCallCount === 1) { + return Promise.reject(new Error("lock failed")); + } + return Promise.resolve([]); + }; + } + return (t as never)[p]; + }, + }); + return fn(txProxy); + }; + } + return (target as never)[prop]; + }, + }); + const invalidate = vi.fn(); + + const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", { + invalidateDocument: invalidate, + }); + + expect(result.sourcePagesAttempted).toBe(1); + expect(result.sourcePagesFailed).toBe(1); + expect(result.sourcePagesSucceeded).toBe(0); + expect(invalidate).not.toHaveBeenCalled(); + // Ghost promotion path still ran (empty result here). / ゴースト昇格の経路は通る。 + expect(result.ghostPromotionsCount).toBe(0); + }); +}); diff --git a/server/api/src/__tests__/services/ydocRenameRewrite.test.ts b/server/api/src/__tests__/services/ydocRenameRewrite.test.ts new file mode 100644 index 00000000..932a689b --- /dev/null +++ b/server/api/src/__tests__/services/ydocRenameRewrite.test.ts @@ -0,0 +1,294 @@ +/** + * `ydocRenameRewrite` の単体テスト。 + * Unit tests for `ydocRenameRewrite` — the pure Y.Doc mutation helper that + * rewrites WikiLink and tag marks when a page is renamed. + * + * Part of issue #726 (Phase 2 rename propagation). + */ + +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; + +import { rewriteTitleRefsInDoc } from "../../services/ydocRenameRewrite.js"; + +/** + * 最小の Tiptap 風 Y.Doc ツリーを組み立てるヘルパー。`segments` は Y.XmlText + * の delta と同じ形式で、`attributes` にマーク情報(`wikiLink` / `tag` 等)を入れる。 + * + * Build a minimal Tiptap-like Y.Doc tree. `segments` follows the Y.XmlText + * delta format; put mark info (`wikiLink` / `tag`) inside `attributes`. + */ +function buildDocWithParagraph( + segments: Array<{ insert: string; attributes?: Record }>, +): { doc: Y.Doc; text: Y.XmlText } { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const paragraph = new Y.XmlElement("paragraph"); + fragment.insert(0, [paragraph]); + const text = new Y.XmlText(); + paragraph.insert(0, [text]); + for (const segment of segments) { + text.insert(text.length, segment.insert, segment.attributes); + } + return { doc, text }; +} + +/** + * Y.XmlText のプレーンテキストを取り出すテスト用ヘルパー。`toJSON()` は + * マークを XML 要素として直列化するため、素のテキスト比較には使えない。 + * + * Extract plain text from a Y.XmlText. `toJSON()` serializes marks as XML + * elements, so we reconstruct the string from its delta instead. + */ +function plainText(text: Y.XmlText): string { + const delta = text.toDelta() as Array<{ insert: unknown }>; + return delta.map((item) => (typeof item.insert === "string" ? item.insert : "")).join(""); +} + +describe("rewriteTitleRefsInDoc", () => { + describe("WikiLink marks", () => { + it("updates mark title and text when the segment text matches the old title", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result).toMatchObject({ + wikiLinkMarksUpdated: 1, + wikiLinkTextUpdated: 1, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }); + expect(text.toDelta()).toEqual([ + { + insert: "Bar", + attributes: { wikiLink: { title: "Bar", exists: true, referenced: false } }, + }, + ]); + }); + + it("is case-insensitive and trim-insensitive on the old title match", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: " FOO ", + attributes: { wikiLink: { title: " foo ", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + const delta = text.toDelta(); + expect(delta[0]?.insert).toBe("Bar"); + expect(delta[0]?.attributes?.wikiLink).toEqual({ + title: "Bar", + exists: true, + referenced: false, + }); + }); + + it("updates only the mark attribute when the segment text does not match (manual edit)", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Custom label", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { + insert: "Custom label", + attributes: { wikiLink: { title: "Bar", exists: true, referenced: false } }, + }, + ]); + }); + + it("does not touch wikiLink marks whose title does not match the old title", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Baz", + attributes: { wikiLink: { title: "Baz", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(result.wikiLinkTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { + insert: "Baz", + attributes: { wikiLink: { title: "Baz", exists: true, referenced: false } }, + }, + ]); + }); + + it("rewrites multiple wikiLink occurrences across segments and siblings", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const p1 = new Y.XmlElement("paragraph"); + const p2 = new Y.XmlElement("paragraph"); + fragment.insert(0, [p1, p2]); + + const t1 = new Y.XmlText(); + p1.insert(0, [t1]); + // Use `wikiLink: null` to break the formatting inheritance between the + // two link segments — Y.XmlText inserts otherwise inherit the preceding + // segment's marks. / Yjs は直前のフォーマットを引き継ぐため、明示的に + // null を渡して二つの wikiLink 区間を独立させる。 + t1.insert(0, "Foo", { wikiLink: { title: "Foo", exists: true, referenced: false } }); + t1.insert(t1.length, " and ", { wikiLink: null }); + t1.insert(t1.length, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: true }, + }); + + const t2 = new Y.XmlText(); + p2.insert(0, [t2]); + t2.insert(0, "related: "); + t2.insert(t2.length, "Foo", { + wikiLink: { title: "Foo", exists: true, referenced: false }, + }); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(3); + expect(result.wikiLinkTextUpdated).toBe(3); + expect(plainText(t1)).toBe("Bar and Bar"); + expect(plainText(t2)).toBe("related: Bar"); + }); + }); + + describe("Tag marks", () => { + it("updates tag name and text when the segment text matches the old tag", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { tag: { name: "foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + expect(text.toDelta()).toEqual([ + { insert: "Bar", attributes: { tag: { name: "Bar", exists: true, referenced: false } } }, + ]); + }); + + it("leaves tag marks untouched when the new title is not a valid tag name", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "foo", + attributes: { tag: { name: "foo", exists: true, referenced: false } }, + }, + ]); + + // Spaces are not valid tag characters — tag cannot follow the rename. + // スペースはタグ名として無効なので、タグは追従させない。 + const result = rewriteTitleRefsInDoc(doc, "foo", "bar baz"); + + expect(result.tagMarksUpdated).toBe(0); + expect(result.tagTextUpdated).toBe(0); + expect(text.toDelta()).toEqual([ + { insert: "foo", attributes: { tag: { name: "foo", exists: true, referenced: false } } }, + ]); + }); + + it("handles wikiLink and tag marks in the same paragraph", () => { + // Explicitly null surrounding marks so neighbouring segments do not + // inherit each other's formatting (Yjs default behaviour). + // 隣接セグメント間のフォーマット継承を断ち切るため、null を渡して + // マーク境界を明示する。 + const { doc, text } = buildDocWithParagraph([ + { insert: "hello " }, + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + { insert: " and ", attributes: { wikiLink: null, tag: null } }, + { insert: "foo", attributes: { tag: { name: "foo", exists: true, referenced: false } } }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(result.wikiLinkTextUpdated).toBe(1); + expect(result.tagMarksUpdated).toBe(1); + expect(result.tagTextUpdated).toBe(1); + expect(plainText(text)).toBe("hello Bar and Bar"); + }); + }); + + describe("Guards and edge cases", () => { + it("is a no-op when oldTitle and newTitle normalize to the same value", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", " foo "); + + expect(result.wikiLinkMarksUpdated).toBe(0); + expect(result.wikiLinkTextUpdated).toBe(0); + // Content unchanged. 内容に変化がない。 + expect(plainText(text)).toBe("Foo"); + }); + + it("is a no-op when either title is empty", () => { + const { doc, text } = buildDocWithParagraph([ + { + insert: "Foo", + attributes: { wikiLink: { title: "Foo", exists: true, referenced: false } }, + }, + ]); + + expect(rewriteTitleRefsInDoc(doc, "", "Bar").wikiLinkMarksUpdated).toBe(0); + expect(rewriteTitleRefsInDoc(doc, "Foo", "").wikiLinkMarksUpdated).toBe(0); + expect(plainText(text)).toBe("Foo"); + }); + + it("recurses into nested XmlElement children (e.g. list items)", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + const list = new Y.XmlElement("bulletList"); + fragment.insert(0, [list]); + const item = new Y.XmlElement("listItem"); + list.insert(0, [item]); + const para = new Y.XmlElement("paragraph"); + item.insert(0, [para]); + const text = new Y.XmlText(); + para.insert(0, [text]); + text.insert(0, "Foo", { wikiLink: { title: "Foo", exists: true, referenced: false } }); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result.wikiLinkMarksUpdated).toBe(1); + expect(plainText(text)).toBe("Bar"); + }); + + it("returns a zero-result object when the document has no matching refs", () => { + const { doc } = buildDocWithParagraph([{ insert: "plain text, no marks" }]); + + const result = rewriteTitleRefsInDoc(doc, "Foo", "Bar"); + + expect(result).toEqual({ + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }); + }); + }); +}); diff --git a/server/api/src/lib/extractPlainTextFromYXml.ts b/server/api/src/lib/extractPlainTextFromYXml.ts new file mode 100644 index 00000000..23541c12 --- /dev/null +++ b/server/api/src/lib/extractPlainTextFromYXml.ts @@ -0,0 +1,77 @@ +/** + * Y.Doc / Y.Xml からプレーンテキストおよびコンテンツプレビューを抽出する + * ユーティリティ。`server/hocuspocus/src/extractPlainTextFromYXml.ts` と + * 同等の実装を意図的に複製している。 + * + * Plain-text / preview extraction from Y.Doc trees. Intentionally mirrors + * `server/hocuspocus/src/extractPlainTextFromYXml.ts` so the API server can + * derive content text when it rewrites a Y.Doc server-side (issue #726). + * + * ⚠️ 変更時は Hocuspocus 側の同名ファイルも合わせて更新すること。両者は + * Bun workspace が分かれているため共有パッケージにはなっていない(CLAUDE.md + * 参照)。 + * ⚠️ When editing, keep the Hocuspocus copy in sync — the two servers live + * in separate Bun projects and do not share a package (see CLAUDE.md). + */ + +import * as Y from "yjs"; + +const INLINE_XML_ELEMENT_NAMES = new Set([ + "bold", + "italic", + "strike", + "code", + "link", + "underline", + "highlight", + "subscript", + "superscript", + "textStyle", +]); + +function isInlineXmlElement(node: Y.XmlElement): boolean { + return INLINE_XML_ELEMENT_NAMES.has(node.nodeName); +} + +/** + * Y.Doc の XmlFragment(または XmlElement 根)からプレーンテキストを再帰的に抽出する。 + * Recursively extract plain text from a Y.XmlFragment or Y.XmlElement subtree. + */ +export function extractTextFromYXml(node: Y.XmlFragment | Y.XmlElement): string { + let text = ""; + + // `node.get(i)` は O(i) なのでインデックスループは全体で O(N^2)。 + // `toArray()` は一度だけ O(N) で配列化できる(PR #736 レビュー参照)。 + // `node.get(i)` is O(i); iterating by index is O(N^2) total. `toArray()` + // does a single O(N) pass — see PR #736 review comments. + for (const child of node.toArray() as Array) { + if (child instanceof Y.XmlText) { + for (const op of child.toDelta() as Array<{ insert: unknown }>) { + if (typeof op.insert === "string") { + text += op.insert; + } + } + } else if (child instanceof Y.XmlElement) { + const inner = extractTextFromYXml(child); + const suffix = isInlineXmlElement(child) ? " " : "\n"; + text += inner + suffix; + } + } + return text; +} + +/** + * プレビュー文字列の最大長(`pages.content_preview` と一致)。 + * Max length for content preview (aligned with `pages.content_preview`). + */ +export const CONTENT_PREVIEW_MAX_LENGTH = 120; + +/** + * プレーンテキストからコンテンツプレビューを生成する。 + * Generate a content preview (first 120 chars) from plain text. + */ +export function buildContentPreview(text: string): string { + const trimmed = text.trim().replace(/\s+/g, " "); + if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed; + return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "..."; +} diff --git a/server/api/src/lib/hocuspocusInvalidation.ts b/server/api/src/lib/hocuspocusInvalidation.ts new file mode 100644 index 00000000..ffbb7ebd --- /dev/null +++ b/server/api/src/lib/hocuspocusInvalidation.ts @@ -0,0 +1,89 @@ +/** + * Hocuspocus のインメモリ Y.Doc を破棄するための内部 HTTP クライアント。 + * Best-effort HTTP client that asks the Hocuspocus server to drop its cached + * Y.Doc for a given page, so subsequent clients reload from the database. + * + * 共通呼び出し元 / Callers: + * - ページスナップショットの復元 (`routes/pageSnapshots.ts`) + * - タイトルリネーム伝播 (`services/titleRenamePropagationService.ts`, issue #726) + * + * ネットワーク失敗・タイムアウトは ログに残すだけで呼び出し側に伝播させない。 + * Failures (timeout, non-2xx, network) are logged only and never thrown — the + * caller should continue with its main flow. + */ + +const DEFAULT_HOCUSPOCUS_INTERNAL_URL = "http://127.0.0.1:1234"; +/** HTTP timeout for invalidation (ms). / 無効化 HTTP のタイムアウト (ミリ秒) */ +const HOCUSPOCUS_INVALIDATE_TIMEOUT_MS = 2500; + +function getHocuspocusInternalUrl(): string | null { + const explicitUrl = process.env.HOCUSPOCUS_INTERNAL_URL?.trim(); + if (explicitUrl) { + return explicitUrl.replace(/\/$/, ""); + } + return process.env.NODE_ENV === "development" ? DEFAULT_HOCUSPOCUS_INTERNAL_URL : null; +} + +/** + * Hocuspocus にライブドキュメントの破棄を依頼する(ベストエフォート)。 + * + * 環境変数 `HOCUSPOCUS_INTERNAL_URL` と `BETTER_AUTH_SECRET` が揃っている + * 場合のみ動作する。開発環境では `HOCUSPOCUS_INTERNAL_URL` が未設定でも + * デフォルトの `http://127.0.0.1:1234` にフォールバックする。 + * + * Ask Hocuspocus to drop its live Y.Doc for `pageId`. Requires + * `HOCUSPOCUS_INTERNAL_URL` and `BETTER_AUTH_SECRET`. In development the + * URL defaults to `http://127.0.0.1:1234`. + */ +export async function invalidateHocuspocusDocument( + pageId: string, + opts?: { logPrefix?: string }, +): Promise { + const baseUrl = getHocuspocusInternalUrl(); + const internalSecret = process.env.BETTER_AUTH_SECRET?.trim(); + const prefix = opts?.logPrefix ?? "[Hocuspocus]"; + + if (!baseUrl || !internalSecret) { + // Always log — silent skipping in production hides misconfiguration + // until stale Y.Docs start winning against committed writes. List which + // envs are missing so operators can diagnose. 本番で silent に無効化 + // すると古い Y.Doc が勝ち続ける原因調査が難しくなるため、常にログを残す。 + const missing = [ + baseUrl ? null : "HOCUSPOCUS_INTERNAL_URL", + internalSecret ? null : "BETTER_AUTH_SECRET", + ] + .filter((v): v is string => v !== null) + .join(", "); + console.warn( + `${prefix} Skipped invalidation for page ${pageId}: missing env var(s): ${missing}`, + ); + return; + } + + const url = `${baseUrl}/internal/documents/${encodeURIComponent(pageId)}/invalidate`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HOCUSPOCUS_INVALIDATE_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "x-internal-secret": internalSecret, + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + console.warn(`${prefix} Invalidation HTTP ${response.status} for page ${pageId}`); + } + } catch (error) { + clearTimeout(timeoutId); + const name = error instanceof Error ? error.name : ""; + if (name === "AbortError") { + console.warn(`${prefix} Invalidation timed out for page ${pageId}`); + return; + } + console.warn(`${prefix} Invalidation failed for page ${pageId}:`, error); + } +} diff --git a/server/api/src/routes/pageSnapshots.ts b/server/api/src/routes/pageSnapshots.ts index 94975c93..ecf3f5c9 100644 --- a/server/api/src/routes/pageSnapshots.ts +++ b/server/api/src/routes/pageSnapshots.ts @@ -14,69 +14,9 @@ import { authRequired } from "../middleware/auth.js"; import type { AppEnv } from "../types/index.js"; import { assertPageViewAccess, assertPageEditAccess } from "../services/pageAccessService.js"; import { pruneSnapshotsExceedingLimitSql } from "../services/snapshotService.js"; +import { invalidateHocuspocusDocument } from "../lib/hocuspocusInvalidation.js"; const app = new Hono(); -const DEFAULT_HOCUSPOCUS_INTERNAL_URL = "http://127.0.0.1:1234"; -/** Best-effort invalidation HTTP timeout (ms). / ベストエフォート無効化の HTTP タイムアウト(ミリ秒) */ -const HOCUSPOCUS_INVALIDATE_TIMEOUT_MS = 2500; - -function getHocuspocusInternalUrl(): string | null { - const explicitUrl = process.env.HOCUSPOCUS_INTERNAL_URL?.trim(); - if (explicitUrl) { - return explicitUrl.replace(/\/$/, ""); - } - return process.env.NODE_ENV === "development" ? DEFAULT_HOCUSPOCUS_INTERNAL_URL : null; -} - -/** - * Hocuspocus に復元後のライブドキュメント無効化を依頼する(ベストエフォート)。 - * タイムアウト・HTTP エラーはログのみで呼び出し元には伝えない。 - * - * Best-effort: asks Hocuspocus to drop live Y.Doc after restore. Timeouts and HTTP - * errors are logged only and never thrown to the caller. - */ -async function invalidateHocuspocusDocument(pageId: string): Promise { - const baseUrl = getHocuspocusInternalUrl(); - const internalSecret = process.env.BETTER_AUTH_SECRET?.trim(); - - if (!baseUrl || !internalSecret) { - if (process.env.NODE_ENV === "development") { - console.warn( - `[Snapshots] Skipped Hocuspocus invalidation for page ${pageId}: internal URL or secret is missing.`, - ); - } - return; - } - - const url = `${baseUrl}/internal/documents/${encodeURIComponent(pageId)}/invalidate`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), HOCUSPOCUS_INVALIDATE_TIMEOUT_MS); - - try { - const response = await fetch(url, { - method: "POST", - headers: { - "x-internal-secret": internalSecret, - }, - signal: controller.signal, - }); - clearTimeout(timeoutId); - - if (!response.ok) { - console.warn( - `[Snapshots] Hocuspocus invalidation HTTP ${response.status} for page ${pageId}`, - ); - } - } catch (error) { - clearTimeout(timeoutId); - const name = error instanceof Error ? error.name : ""; - if (name === "AbortError") { - console.warn(`[Snapshots] Hocuspocus invalidation timed out for page ${pageId}`); - return; - } - console.warn(`[Snapshots] Hocuspocus invalidation failed for page ${pageId}:`, error); - } -} // ── GET /:id/snapshots ────────────────────────────────────────────────────── app.get("/:id/snapshots", authRequired, async (c) => { @@ -294,7 +234,7 @@ app.post("/:id/snapshots/:snapshotId/restore", authRequired, async (c) => { }; }); - await invalidateHocuspocusDocument(pageId); + await invalidateHocuspocusDocument(pageId, { logPrefix: "[Snapshots]" }); return c.json({ version: result.version, diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index 6ab73316..62488114 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -17,6 +17,7 @@ import { authRequired } from "../middleware/auth.js"; import type { AppEnv, Database } from "../types/index.js"; import { maybeCreateSnapshot } from "../services/snapshotService.js"; import { assertPageViewAccess, assertPageEditAccess } from "../services/pageAccessService.js"; +import { propagateTitleRename } from "../services/titleRenamePropagationService.js"; /** * ベストエフォートで自動スナップショットを作成する。失敗してもメイン処理には影響しない。 @@ -39,21 +40,77 @@ async function tryAutoSnapshot( const app = new Hono(); +/** + * タイトル変更を検出した際に WikiLink / タグを他ページへ伝播させる + * (issue #726)。リネーム本体のレスポンスはブロックしないよう fire-and-forget + * で呼び出す。失敗時はログのみ。 + * + * Fire-and-forget propagation of a title rename into referencing documents + * and ghost-link promotion (issue #726). The caller is not blocked; failures + * are logged but do not affect the main response. + */ +function tryPropagateTitleRename( + db: Database, + pageId: string, + oldTitle: string, + newTitle: string, +): void { + void propagateTitleRename(db, pageId, oldTitle, newTitle).catch((error) => { + console.error( + `[RenamePropagation] Background propagation crashed for ${pageId} ` + + `(${oldTitle} → ${newTitle}):`, + error, + ); + }); +} + /** * PUT /content リクエストから pages テーブルの更新セットを構築し、変更があれば適用する。 - * Build and apply pages-table updates (title, content_preview, updated_at) from PUT body. + * タイトル更新を検出した場合は旧タイトルを返して呼び出し側から伝播処理を + * 起動できるようにする(issue #726)。 + * + * Build and apply pages-table updates (title, content_preview, updated_at) + * from the PUT body. When the title is being changed, return the old / new + * title pair so the caller can kick off rename propagation once the row + * update is durable (issue #726). */ async function applyPagesMetadataUpdate( - db: { update: Database["update"] }, + db: { select: Database["select"]; update: Database["update"] }, pageId: string, body: { title?: string; content_preview?: string }, -): Promise { +): Promise<{ renamed: { oldTitle: string; newTitle: string } | null }> { + let renamed: { oldTitle: string; newTitle: string } | null = null; + + if (body.title !== undefined) { + const current = await db + .select({ title: pages.title }) + .from(pages) + .where(eq(pages.id, pageId)) + .limit(1); + const previousRaw = current[0]?.title ?? null; + const previousTrimmed = typeof previousRaw === "string" ? previousRaw.trim() : ""; + const nextTrimmed = body.title.trim(); + // 正規化(小文字化)して比較することで "Foo" → "foo" のような表記揺れだけの + // 変更は伝播をスキップする。`wikiLinkUtils` / `tagUtils` の照合も同一正規化。 + // Normalize for comparison so "Foo" → "foo" — a change that wouldn't + // affect matching — does not trigger propagation. Mirrors the client-side + // `wikiLinkUtils` / `tagUtils` normalization. + if ( + previousTrimmed.length > 0 && + nextTrimmed.length > 0 && + previousTrimmed.toLowerCase() !== nextTrimmed.toLowerCase() + ) { + renamed = { oldTitle: previousTrimmed, newTitle: nextTrimmed }; + } + } + const set: Record = {}; if (body.title !== undefined) set.title = body.title; if (body.content_preview !== undefined) set.contentPreview = body.content_preview; - if (Object.keys(set).length === 0) return; + if (Object.keys(set).length === 0) return { renamed }; set.updatedAt = new Date(); await db.update(pages).set(set).where(eq(pages.id, pageId)); + return { renamed }; } // ── GET /pages ────────────────────────────────────────────────────────────── @@ -247,9 +304,9 @@ app.put("/:id/content", authRequired, async (c) => { const insertedRow = inserted[0]; if (!insertedRow) throw new HTTPException(500, { message: "Insert failed" }); - await applyPagesMetadataUpdate(tx, pageId, body); + const { renamed } = await applyPagesMetadataUpdate(tx, pageId, body); - return { done: true as const, version: insertedRow.version ?? 1 }; + return { done: true as const, version: insertedRow.version ?? 1, renamed }; }); if (firstSave.done) { @@ -261,6 +318,14 @@ app.put("/:id/content", authRequired, async (c) => { firstSave.version, userId, ); + if (firstSave.renamed) { + tryPropagateTitleRename( + db, + pageId, + firstSave.renamed.oldTitle, + firstSave.renamed.newTitle, + ); + } return c.json({ version: firstSave.version }); } } @@ -293,7 +358,7 @@ app.put("/:id/content", authRequired, async (c) => { const updatedRow = updated[0]; if (!updatedRow) throw new HTTPException(500, { message: "Update failed" }); - await applyPagesMetadataUpdate(db, pageId, body); + const { renamed } = await applyPagesMetadataUpdate(db, pageId, body); void tryAutoSnapshot( db, @@ -304,6 +369,10 @@ app.put("/:id/content", authRequired, async (c) => { userId, ); + if (renamed) { + tryPropagateTitleRename(db, pageId, renamed.oldTitle, renamed.newTitle); + } + return c.json({ version: updatedRow.version ?? 0 }); } @@ -327,7 +396,7 @@ app.put("/:id/content", authRequired, async (c) => { }) .returning(); - await applyPagesMetadataUpdate(db, pageId, body); + const { renamed } = await applyPagesMetadataUpdate(db, pageId, body); const resultRow = result[0]; if (!resultRow) throw new HTTPException(500, { message: "Upsert failed" }); @@ -341,6 +410,10 @@ app.put("/:id/content", authRequired, async (c) => { userId, ); + if (renamed) { + tryPropagateTitleRename(db, pageId, renamed.oldTitle, renamed.newTitle); + } + return c.json({ version: resultRow.version }); }); diff --git a/server/api/src/services/titleRenamePropagationService.ts b/server/api/src/services/titleRenamePropagationService.ts new file mode 100644 index 00000000..0ee91a88 --- /dev/null +++ b/server/api/src/services/titleRenamePropagationService.ts @@ -0,0 +1,371 @@ +/** + * ページタイトルのリネームを、参照元ドキュメントとゴーストリンクへ伝播する + * サービス (issue #726 Phase 2)。 + * + * Propagate a page-title rename into (1) WikiLink / tag marks inside every + * source document that links to the renamed page and (2) ghost_links whose + * text now matches the new title (promotion). Issue #726 Phase 2. + * + * スコープ / Scope: + * - 実体 → 実体 の書き換え: `links` で `target_id = renamedPageId` のソース + * ページを全走査し、Y.Doc 内の対象マークのテキストと属性を書き換える。 + * 手動編集されたテキスト(属性と一致しない区間)はテキストを残し、属性 + * だけ更新する。`ydocRenameRewrite.ts` を参照。 + * - ゴースト → 実体 の昇格: `ghost_links.link_text` が newTitle に一致する + * 行を `links` に挿入し、ゴースト行を削除する。自己参照(同一ページ + * 内の行)は DB CHECK で拒否されるためスキップする。 + * - 伝播後、`Hocuspocus` のライブドキュメントを破棄して次回クライアント + * 接続で DB から再読込させる(ベストエフォート、失敗はログのみ)。 + * + * - Real → real: find every source page via `links.target_id = renamedPageId`, + * open its Y.Doc, and rewrite the matching wiki-link / + * tag marks. Manually-edited link text (segments that no longer match + * the mark title) keeps its text; only the attribute is refreshed. See + * `ydocRenameRewrite.ts`. + * - Ghost → real (promotion): move `ghost_links` rows whose normalized + * `link_text` matches the new title into `links`. Self-references are + * rejected by a DB CHECK constraint so we filter them out up front. + * - After rewriting each source page, ask Hocuspocus to drop its cached + * Y.Doc so next clients reload from DB (best-effort; failures logged). + * + * 非スコープ / Out of scope: + * - 永続的な非同期ジョブキュー(本実装は呼び出し元が `void` で捨てる + * fire-and-forget を想定)。リトライ戦略は呼び出し側 TODO。 + * - 実体 → ゴーストへの降格(削除由来のため issue #726 では扱わない)。 + * - ノート・テナント境界フィルタ(`links` 自体に到達できる行は既存認可 + * 経路で担保されている前提)。 + * + * - Durable retry queue (callers should fire-and-forget with `.catch`; + * persistent retries live in a follow-up ticket). + * - Real → ghost demotion on deletion (separate issue). + * - Tenant-scoped filtering — `links` rows are authorized upstream. + */ + +import * as Y from "yjs"; +import { and, eq, sql, ne, inArray, isNull } from "drizzle-orm"; +import { links, ghostLinks, pageContents, pages } from "../schema/index.js"; +import type { Database } from "../types/index.js"; +import { rewriteTitleRefsInDoc, type RewriteResult } from "./ydocRenameRewrite.js"; +import { invalidateHocuspocusDocument } from "../lib/hocuspocusInvalidation.js"; +import { buildContentPreview, extractTextFromYXml } from "../lib/extractPlainTextFromYXml.js"; + +/** + * `propagateTitleRename` の結果カウンタ。ログ出力・監視用。 + * Counters returned by `propagateTitleRename` for logging and observability. + */ +export interface TitleRenamePropagationResult extends RewriteResult { + /** Number of source pages scanned (had an outgoing link to the renamed page). */ + sourcePagesAttempted: number; + /** Source pages whose transaction completed without throwing. */ + sourcePagesSucceeded: number; + /** Source pages whose rewrite transaction threw (best-effort: error is logged). */ + sourcePagesFailed: number; + /** Ghost-link rows promoted to real `links` rows because their text now matches. */ + ghostPromotionsCount: number; +} + +/** + * `propagateTitleRename` のオプション。テスト用に Hocuspocus 無効化の + * 呼び出しを差し替え可能にする。 + * + * Options for `propagateTitleRename`. Allows tests to inject a stub in + * place of the real Hocuspocus invalidation HTTP call. + */ +export interface PropagateTitleRenameOptions { + /** + * Override the Hocuspocus invalidation call. Defaults to the real HTTP + * helper; tests inject a stub. 既定値はテスト時に差し替え可能。 + */ + invalidateDocument?: (pageId: string) => Promise; +} + +function normalizeTitle(value: string): string { + return value.toLowerCase().trim(); +} + +function emptyResult(): TitleRenamePropagationResult { + return { + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + sourcePagesAttempted: 0, + sourcePagesSucceeded: 0, + sourcePagesFailed: 0, + ghostPromotionsCount: 0, + }; +} + +function toBuffer(ydocState: unknown): Buffer | null { + if (ydocState instanceof Buffer) return ydocState; + if (ydocState instanceof Uint8Array) return Buffer.from(ydocState); + if (typeof ydocState === "string") { + // 万一 DB 層が base64 文字列で返してきた場合のフォールバック。 + // Defensive path in case a driver hands back a base64 string. + return Buffer.from(ydocState, "base64"); + } + return null; +} + +/** + * 1 つのソースページについて Y.Doc を読み込んで書き換え、変更があれば + * 楽観バージョンを +1 して書き戻す。 + * + * Rewrite a single source page's Y.Doc in a serialized transaction. Returns + * `{ changed: false }` when either the page has no `page_contents` row or + * the rewriter produced zero changes — callers should skip the Hocuspocus + * invalidation in that case. + */ +async function rewriteSourcePage( + db: Database, + sourcePageId: string, + oldTitle: string, + newTitle: string, +): Promise<{ changed: boolean; rewrite: RewriteResult }> { + const zeroRewrite: RewriteResult = { + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }; + + return db.transaction(async (tx) => { + // 行ロックを取って Hocuspocus の並行書き込みと直列化する + // (snapshot restore と同じパターン)。 + // Serialize with Hocuspocus' concurrent `onStoreDocument` writes by + // grabbing the same row lock the snapshot-restore path uses. + await tx.execute(sql`SELECT 1 FROM page_contents WHERE page_id = ${sourcePageId} FOR UPDATE`); + + const current = await tx + .select() + .from(pageContents) + .where(eq(pageContents.pageId, sourcePageId)) + .limit(1); + + const row = current[0]; + if (!row) { + return { changed: false, rewrite: zeroRewrite }; + } + + const buffer = toBuffer(row.ydocState); + if (!buffer) { + return { changed: false, rewrite: zeroRewrite }; + } + + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(buffer)); + const rewrite = rewriteTitleRefsInDoc(doc, oldTitle, newTitle); + const hasChanges = rewrite.wikiLinkMarksUpdated > 0 || rewrite.tagMarksUpdated > 0; + if (!hasChanges) { + return { changed: false, rewrite }; + } + + const encodedState = Buffer.from(Y.encodeStateAsUpdate(doc)); + // リネーム後の Y.Doc からプレーンテキストとプレビューを取り直し、 + // `page_contents.content_text` / `pages.content_preview` が古い + // タイトルのまま取り残されないようにする(PR #736 レビュー参照)。 + // Derive the new plain text / preview from the rewritten Y.Doc and + // persist them atomically with `ydoc_state` so search, listing, and + // snapshot metadata stay consistent. See PR #736 review. + const newContentText = extractTextFromYXml(doc.getXmlFragment("default")); + const newContentPreview = buildContentPreview(newContentText); + await tx + .update(pageContents) + .set({ + ydocState: encodedState, + version: sql`${pageContents.version} + 1`, + contentText: newContentText, + updatedAt: new Date(), + }) + .where(eq(pageContents.pageId, sourcePageId)); + + await tx + .update(pages) + .set({ contentPreview: newContentPreview, updatedAt: new Date() }) + .where(eq(pages.id, sourcePageId)); + + return { changed: true, rewrite }; + }); +} + +/** + * 新タイトルと一致するゴーストリンクを、リネーム対象と同一スコープ内でのみ + * 実体リンクへ昇格させる。スコープはリネーム対象の `pages.note_id` と + * `pages.owner_id` から決定する: + * + * - リネーム対象がノートネイティブ (`note_id` 非 NULL): ソースの `note_id` + * が同一の場合のみ昇格。 + * - リネーム対象が個人ページ (`note_id` NULL): ソースも個人 (`note_id` NULL) + * かつオーナーが同一の場合のみ昇格。 + * + * これにより、別テナント/別ノートで同一テキストを持つ `ghost_links` が + * 誤って消費されることを防ぐ(PR #736 P1 レビュー参照)。 + * + * Promote ghost-link rows matching the new title — but only within the + * renamed page's ownership / note scope. Without this filter, a rename in + * one tenant would silently consume unrelated ghost rows elsewhere that + * happen to share the same text, creating cross-tenant link edges + * (reviewed as P1 on PR #736). + * + * - Note-native target (`note_id != null`): only promote ghosts whose + * source page is in the same `note_id`. + * - Personal target (`note_id = null`): only promote ghosts whose source + * is also personal (`note_id = null`) and has the same `owner_id`. + */ +async function promoteGhostLinks( + db: Database, + renamedPageId: string, + newTitle: string, +): Promise { + return db.transaction(async (tx) => { + // 1. Resolve the scope of the renamed page. Missing row → nothing to do. + // リネーム対象のスコープを解決。行が無ければ何もしない。 + const scopeRows = await tx + .select({ noteId: pages.noteId, ownerId: pages.ownerId }) + .from(pages) + .where(eq(pages.id, renamedPageId)) + .limit(1); + const scope = scopeRows[0]; + if (!scope) return 0; + + // 2. 同一スコープかつテキスト一致のゴースト行を列挙する。 + // Find in-scope ghost rows whose text matches the new title. + const scopePredicate = + scope.noteId !== null + ? eq(pages.noteId, scope.noteId) + : and(isNull(pages.noteId), eq(pages.ownerId, scope.ownerId)); + + const candidates = await tx + .select({ + sourcePageId: ghostLinks.sourcePageId, + linkType: ghostLinks.linkType, + }) + .from(ghostLinks) + .innerJoin(pages, eq(pages.id, ghostLinks.sourcePageId)) + .where( + and( + sql`LOWER(TRIM(${ghostLinks.linkText})) = LOWER(TRIM(${newTitle}))`, + ne(ghostLinks.sourcePageId, renamedPageId), + scopePredicate, + ), + ); + + if (candidates.length === 0) return 0; + + // 3. Delete the matching in-scope ghost rows and insert real links. + // 同一スコープのゴースト行だけ削除し、本物のリンクを挿入する。 + const scopedSourceIds = Array.from(new Set(candidates.map((c) => c.sourcePageId))); + await tx + .delete(ghostLinks) + .where( + and( + sql`LOWER(TRIM(${ghostLinks.linkText})) = LOWER(TRIM(${newTitle}))`, + inArray(ghostLinks.sourcePageId, scopedSourceIds), + ), + ); + + await tx + .insert(links) + .values( + candidates.map((row) => ({ + sourceId: row.sourcePageId, + targetId: renamedPageId, + linkType: row.linkType, + })), + ) + // 競合(既に同じエッジがある場合)は無視する。 + // Conflicts (an authoritative edge already exists) are harmless. + .onConflictDoNothing(); + + return candidates.length; + }); +} + +/** + * ページ `renamedPageId` のタイトル変更 `oldTitle` → `newTitle` を、参照元 + * ドキュメントおよびゴーストリンクへ伝播する。 + * + * Propagate a rename `oldTitle` → `newTitle` for `renamedPageId` through + * every source page's Y.Doc and through the ghost-link graph. + * + * この関数はベストエフォート動作である: + * - 個々のソースページ書き換えが失敗しても、残りのページとゴースト昇格 + * 処理は続行する。失敗はカウンタとログに残る。 + * - Hocuspocus 無効化の失敗は呼び出し側に伝播させない。 + * + * Best-effort: + * - Per-source rewrite failures are logged and counted in + * `sourcePagesFailed`; they do not abort the rest of the work. + * - Hocuspocus invalidation failures are logged and swallowed. + */ +export async function propagateTitleRename( + db: Database, + renamedPageId: string, + oldTitle: string | null | undefined, + newTitle: string | null | undefined, + options?: PropagateTitleRenameOptions, +): Promise { + const result = emptyResult(); + + const trimmedOld = typeof oldTitle === "string" ? oldTitle.trim() : ""; + const trimmedNew = typeof newTitle === "string" ? newTitle.trim() : ""; + + if (!trimmedOld || !trimmedNew) return result; + if (normalizeTitle(trimmedOld) === normalizeTitle(trimmedNew)) return result; + + const invalidate = + options?.invalidateDocument ?? + ((pageId: string) => + invalidateHocuspocusDocument(pageId, { logPrefix: "[RenamePropagation]" })); + + // 1. Rewrite source pages that have a real link to the renamed page. + // 実体リンク経由でリネーム対象を参照しているページ群を書き換える。 + 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); + result.sourcePagesSucceeded += 1; + result.wikiLinkMarksUpdated += rewrite.wikiLinkMarksUpdated; + result.wikiLinkTextUpdated += rewrite.wikiLinkTextUpdated; + result.tagMarksUpdated += rewrite.tagMarksUpdated; + result.tagTextUpdated += rewrite.tagTextUpdated; + + if (changed) { + try { + await invalidate(sourceId); + } catch (error) { + console.warn( + `[RenamePropagation] Invalidation failed for source page ${sourceId}:`, + error, + ); + } + } + } catch (error) { + result.sourcePagesFailed += 1; + console.error( + `[RenamePropagation] Failed to rewrite source page ${sourceId} ` + + `for rename ${renamedPageId} (${trimmedOld} → ${trimmedNew}):`, + error, + ); + } + } + + // 2. Promote matching ghost links. ベストエフォートで昇格させる。 + try { + result.ghostPromotionsCount = await promoteGhostLinks(db, renamedPageId, trimmedNew); + } catch (error) { + console.error( + `[RenamePropagation] Ghost-link promotion failed for ${renamedPageId} (new title ${trimmedNew}):`, + error, + ); + } + + return result; +} diff --git a/server/api/src/services/ydocRenameRewrite.ts b/server/api/src/services/ydocRenameRewrite.ts new file mode 100644 index 00000000..36424fbb --- /dev/null +++ b/server/api/src/services/ydocRenameRewrite.ts @@ -0,0 +1,272 @@ +/** + * Y.Doc 上の WikiLink / タグマークのテキストおよび属性を書き換えるピュアな + * ヘルパー。`titleRenamePropagationService.ts` から呼び出される。 + * + * Pure helper that rewrites WikiLink and tag marks inside a Y.Doc when the + * referenced page title changes. Called from `titleRenamePropagationService`. + * + * 設計方針 / Design notes (issue #726): + * - 対象: `wikiLink` マーク(`attrs.title`)と `tag` マーク(`attrs.name`)。 + * - マッチは小文字・前後スペース除去で大文字小文字・空白差異を吸収する。 + * - セグメントのテキストが旧タイトル(正規化済み)と一致する場合にのみ + * テキストを書き換える。一致しない場合は「手動で編集された」扱いで + * テキストはそのままにし、マーク属性だけ更新する。 + * - タグ書き換えは新タイトルがタグ名として有効な文字集合(`tagUtils.ts` の + * `TAG_PASTE_REGEX` に準拠)のときだけ行う。スペースや無効文字を含む + * タイトルへ追従するとタグが壊れるため。 + * + * - Targets `wikiLink` marks (keyed on `attrs.title`) and `tag` marks + * (keyed on `attrs.name`). + * - Matching is case/whitespace insensitive to line up with `wikiLinkUtils` + * / `tagUtils` client-side normalization. + * - Segment text is replaced only when it matches the old title after + * normalization. Non-matching segments are treated as manual edits — only + * the mark attribute is updated; the text is left alone. + * - Tag rewrites happen only when the new title consists of valid tag + * characters (mirroring `TAG_PASTE_REGEX` in `src/lib/tagUtils.ts`). + * Otherwise the tag would become syntactically invalid after rename. + */ + +import * as Y from "yjs"; + +/** + * 書き換え結果のカウンタ。運用ログ・テスト検証に使う。 + * Counters reporting what the rewrite touched. Used for logs and tests. + */ +export interface RewriteResult { + /** Number of `wikiLink` marks whose `title` attribute was rewritten. */ + wikiLinkMarksUpdated: number; + /** Of the updated `wikiLink` marks, how many also had their text replaced. */ + wikiLinkTextUpdated: number; + /** Number of `tag` marks whose `name` attribute was rewritten. */ + tagMarksUpdated: number; + /** Of the updated `tag` marks, how many also had their text replaced. */ + tagTextUpdated: number; +} + +/** + * タグ名として許容する文字集合。`src/lib/tagUtils.ts` の `TAG_PASTE_REGEX` + * と同じ字種(英数字・アンダースコア・ハイフン + ひらがな/カタカナ/CJK) + * を想定している。`TAG_PASTE_REGEX` が変わったらここも更新する必要がある。 + * + * Allowed characters for a tag name. Mirrors `TAG_PASTE_REGEX` in + * `src/lib/tagUtils.ts` (alphanumerics, underscore, hyphen, plus hiragana / + * katakana / CJK). Keep the two in sync if the client-side regex changes. + */ +const VALID_TAG_NAME_REGEX = /^[A-Za-z0-9_\-぀-ヿ㐀-鿿]+$/; + +function normalizeTitle(value: string): string { + return value.toLowerCase().trim(); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +interface DeltaSegment { + insert: string; + attributes?: Record; +} + +interface PendingEdit { + /** Start position within the Y.XmlText. / Y.XmlText 内の開始位置。 */ + index: number; + /** Length of the segment being replaced. / 置換対象セグメントの長さ。 */ + length: number; + /** Text to insert. When unchanged we reinsert the original text. / 挿入するテキスト。未変更なら元のテキストを再挿入する。 */ + text: string; + /** Attribute set applied to the re-inserted text. / 再挿入テキストに適用する属性セット。 */ + attributes: Record; +} + +function extractStringAttr(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +interface SegmentPlan { + wikiMatches: boolean; + tagMatches: boolean; + wikiLinkMark: Record | null; + tagMark: Record | null; +} + +function planSegment( + attributes: Record, + normalizedOld: string, + allowTagRewrite: boolean, +): SegmentPlan { + const wikiLinkMark = isPlainObject(attributes.wikiLink) ? attributes.wikiLink : null; + const tagMark = isPlainObject(attributes.tag) ? attributes.tag : null; + const wikiTitle = wikiLinkMark ? extractStringAttr(wikiLinkMark.title) : null; + const tagName = tagMark ? extractStringAttr(tagMark.name) : null; + return { + wikiLinkMark, + tagMark, + wikiMatches: wikiTitle !== null && normalizeTitle(wikiTitle) === normalizedOld, + tagMatches: tagName !== null && allowTagRewrite && normalizeTitle(tagName) === normalizedOld, + }; +} + +function applyPlanToAttributes( + attributes: Record, + plan: SegmentPlan, + newTitle: string, + segmentMatchesOld: boolean, + result: RewriteResult, +): Record { + const next: Record = { ...attributes }; + if (plan.wikiMatches && plan.wikiLinkMark) { + next.wikiLink = { ...plan.wikiLinkMark, title: newTitle }; + result.wikiLinkMarksUpdated += 1; + if (segmentMatchesOld) result.wikiLinkTextUpdated += 1; + } + if (plan.tagMatches && plan.tagMark) { + next.tag = { ...plan.tagMark, name: newTitle }; + result.tagMarksUpdated += 1; + if (segmentMatchesOld) result.tagTextUpdated += 1; + } + return next; +} + +function rewriteText( + text: Y.XmlText, + oldTitle: string, + newTitle: string, + allowTagRewrite: boolean, + result: RewriteResult, +): void { + const delta = text.toDelta() as Array; + if (delta.length === 0) return; + + const normalizedOld = normalizeTitle(oldTitle); + const edits: PendingEdit[] = []; + + let offset = 0; + for (const raw of delta) { + if (!isPlainObject(raw) || typeof raw.insert !== "string") { + // Embeds (non-string inserts) still occupy one position in Y.XmlText. + // 埋め込み(非文字列 insert)も Y.XmlText 上で 1 文字分の位置を占める。 + offset += 1; + continue; + } + + const segmentText = raw.insert; + const length = segmentText.length; + const attributes: DeltaSegment["attributes"] = isPlainObject(raw.attributes) + ? raw.attributes + : undefined; + + if (length === 0) continue; + if (!attributes) { + offset += length; + continue; + } + + const plan = planSegment(attributes, normalizedOld, allowTagRewrite); + if (!plan.wikiMatches && !plan.tagMatches) { + offset += length; + continue; + } + + const segmentMatchesOld = normalizeTitle(segmentText) === normalizedOld; + const nextAttributes = applyPlanToAttributes( + attributes, + plan, + newTitle, + segmentMatchesOld, + result, + ); + + edits.push({ + index: offset, + length, + text: segmentMatchesOld ? newTitle : segmentText, + attributes: nextAttributes, + }); + + offset += length; + } + + if (edits.length === 0) return; + + // 末尾から適用することで、先に処理したセグメントの長さ変化が後続の + // オフセットに影響するのを避ける。 + // Apply from the end so earlier edits' length changes do not shift later + // offsets. + for (let i = edits.length - 1; i >= 0; i--) { + const edit = edits[i]; + if (!edit) continue; + text.delete(edit.index, edit.length); + text.insert(edit.index, edit.text, edit.attributes); + } +} + +type XmlNode = Y.XmlFragment | Y.XmlElement | Y.XmlText | Y.XmlHook; + +function walk( + node: Y.XmlFragment | Y.XmlElement, + oldTitle: string, + newTitle: string, + allowTagRewrite: boolean, + result: RewriteResult, +): void { + // `node.get(i)` は Yjs の連結リストを頭から辿るため O(i)。インデックス + // ループにすると N 要素で O(N^2) になる。`toArray()` で一度だけ O(N) + // 走査して配列化し、その後は通常のイテレーションに切り替える。 + // `node.get(i)` walks Yjs' linked list from the head (O(i)), so an + // index-based loop is O(N^2) in the number of children. `toArray()` does + // a single O(N) pass; iterate the resulting array instead. + const children = node.toArray() as XmlNode[]; + for (const child of children) { + if (child instanceof Y.XmlText) { + rewriteText(child, oldTitle, newTitle, allowTagRewrite, result); + } else if (child instanceof Y.XmlElement) { + walk(child, oldTitle, newTitle, allowTagRewrite, result); + } + // Y.XmlHook は Tiptap のスキーマで通常使わないためスキップする。 + // Y.XmlHook is not used by Tiptap's default schema, so skip it. + } +} + +/** + * `doc` 内の WikiLink / タグマークについて、旧タイトル `oldTitle` を参照 + * しているものを新タイトル `newTitle` へ書き換える。テキストノードは + * セグメントのテキストが旧タイトルと一致する場合のみ書き換える。 + * + * Rewrite WikiLink and tag marks in `doc` whose target matches `oldTitle` + * so they point at `newTitle`. Segment text is only rewritten when it + * matches the old title (so user-edited link text is preserved). + * + * @param doc - 書き換え対象の Y.Doc / the Y.Doc to mutate. + * @param oldTitle - 旧ページタイトル / previous page title. + * @param newTitle - 新ページタイトル / new page title. + * @param fragmentName - 対象 XmlFragment 名(デフォルト `"default"`)/ fragment name (default `"default"`). + * @returns 書き換え件数 / counts of what was rewritten. + */ +export function rewriteTitleRefsInDoc( + doc: Y.Doc, + oldTitle: string, + newTitle: string, + fragmentName = "default", +): RewriteResult { + const result: RewriteResult = { + wikiLinkMarksUpdated: 0, + wikiLinkTextUpdated: 0, + tagMarksUpdated: 0, + tagTextUpdated: 0, + }; + + if (!oldTitle || !newTitle) return result; + if (normalizeTitle(oldTitle) === normalizeTitle(newTitle)) return result; + + const allowTagRewrite = VALID_TAG_NAME_REGEX.test(newTitle); + const fragment = doc.getXmlFragment(fragmentName); + + doc.transact(() => { + walk(fragment, oldTitle, newTitle, allowTagRewrite, result); + }, "rename-propagation"); + + return result; +}