From 17e57e5025448a008ab342b0129a4b71c80d9247 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 08:22:53 +0000 Subject: [PATCH] refactor(api): retire GET/PUT /api/pages/:id/content (Issue #889 Phase 4) Phase 4 of #889 removes the legacy REST Y.Doc round-trip and the API-side duplicate implementations now that all editing flows through Hocuspocus. - Delete `GET/PUT /api/pages/:id/content` route handlers (along with their lazy-migration / defense-in-depth normalization helpers). - Delete duplicated `server/api/src/services/snapshotService.ts` and `server/api/src/services/ydocWikiLinkNormalizer.ts`; the prune helper used by snapshot restore is inlined into `pageSnapshots.ts`. - Drop the cross-server drift detector `ydocWikiLinkNormalizerSync.test.ts`; Hocuspocus is now the sole owner of these implementations. - Migrate `NotePageView` title persistence from `getPageContent` + `putPageContent` to the metadata-only `updatePageMetadata` (`PUT /api/pages/:id`); remove `getPageContent` / `putPageContent` from the API client and types. - Refresh route / service / cache-key doc comments to reference the retained metadata route and the Hocuspocus normalization path. https://claude.ai/code/session_018GADvyHXYY4Zem7Q7y8T6G --- .../src/__tests__/routes/notes/crud.test.ts | 13 +- server/api/src/__tests__/routes/pages.test.ts | 387 +-------------- .../services/snapshotService.test.ts | 90 ---- .../services/ydocWikiLinkNormalizer.test.ts | 390 --------------- server/api/src/routes/notes/crud.ts | 21 +- server/api/src/routes/pageSnapshots.ts | 21 +- server/api/src/routes/pages.ts | 463 ++---------------- .../api/src/services/pageGraphSyncService.ts | 8 +- server/api/src/services/snapshotService.ts | 76 --- .../src/services/ydocWikiLinkNormalizer.ts | 257 ---------- server/hocuspocus/src/snapshotUtils.ts | 25 +- .../src/ydocWikiLinkNormalizer.test.ts | 22 +- .../hocuspocus/src/ydocWikiLinkNormalizer.ts | 54 +- .../editor/TiptapEditor/useEditorLifecycle.ts | 17 +- src/lib/api/apiClient.test.ts | 4 +- src/lib/api/apiClient.ts | 25 +- src/lib/api/types.ts | 17 - .../StorageAdapterPageRepository.test.ts | 2 - src/lib/sync/syncWithApi.test.ts | 2 - src/lib/ydocWikiLinkNormalizerSync.test.ts | 57 --- src/pages/NotePageView.test.tsx | 136 +++-- src/pages/NotePageView.tsx | 42 +- 22 files changed, 261 insertions(+), 1868 deletions(-) delete mode 100644 server/api/src/__tests__/services/snapshotService.test.ts delete mode 100644 server/api/src/__tests__/services/ydocWikiLinkNormalizer.test.ts delete mode 100644 server/api/src/services/snapshotService.ts delete mode 100644 server/api/src/services/ydocWikiLinkNormalizer.ts delete mode 100644 src/lib/ydocWikiLinkNormalizerSync.test.ts diff --git a/server/api/src/__tests__/routes/notes/crud.test.ts b/server/api/src/__tests__/routes/notes/crud.test.ts index f2071479..b14a9a67 100644 --- a/server/api/src/__tests__/routes/notes/crud.test.ts +++ b/server/api/src/__tests__/routes/notes/crud.test.ts @@ -688,14 +688,15 @@ describe("GET /api/notes/:noteId", () => { }); it("should change ETag when a page is edited even if notes.updated_at is unchanged", async () => { - // Codex P1 (#856 review): ページ単体編集 (`PUT /api/pages/:id/content` 等) - // で `notes.updated_at` が動かなくても、ETag は変わるべき。pages signal - // (MAX(updated_at), COUNT) を ETag のハッシュ入力に混ぜることで保証する。 + // Codex P1 (#856 review): ページ単体編集 (Hocuspocus 経由の本文保存・ + // `PUT /api/pages/:id` 等) で `notes.updated_at` が動かなくても、ETag は + // 変わるべき。pages signal (MAX(updated_at), COUNT) を ETag のハッシュ + // 入力に混ぜることで保証する。 // // Codex P1 (#856 review): editing a page via routes that do not bump - // `notes.updated_at` (e.g. `PUT /api/pages/:id/content`) must still - // shift the ETag. Verified by sending the same note row twice with - // different `MAX(pages.updated_at)` values. + // `notes.updated_at` (Hocuspocus-driven content saves, title renames via + // `PUT /api/pages/:id`) must still shift the ETag. Verified by sending + // the same note row twice with different `MAX(pages.updated_at)` values. const mockNote = createMockNote(); const { app } = createTestApp([ [mockNote], diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index 9f86492d..f9f8df28 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -1,6 +1,13 @@ /** - * GET/PUT /api/pages/:id/content など pages ルートのテスト。 - * Tests for pages routes including empty page_contents handling on GET. + * pages ルートのテスト。Issue #889 Phase 4 で `GET/PUT /api/pages/:id/content` + * を廃止して以降は、メタデータ系ルート(`PUT /api/pages/:id`・ + * `GET /api/pages/:id`・`GET /api/pages/:id/public-content`)と一覧 / 作成 / + * 削除をカバーする。 + * + * Tests for the pages routes. Issue #889 Phase 4 removed the `GET/PUT + * /api/pages/:id/content` endpoints, so this suite now covers metadata-only + * routes (`PUT /api/pages/:id`, `GET /api/pages/:id`, + * `GET /api/pages/:id/public-content`) plus listing, creation, and deletion. */ import { describe, it, expect, vi } from "vitest"; import type { Context, Next } from "hono"; @@ -54,7 +61,6 @@ vi.mock("../../services/defaultNoteService.js", () => ({ })); import { Hono } from "hono"; -import * as Y from "yjs"; import pageRoutes from "../../routes/pages.js"; import { ensureDefaultNote, getDefaultNoteOrNull } from "../../services/defaultNoteService.js"; import { createMockDb } from "../createMockDb.js"; @@ -118,147 +124,6 @@ function createPagesAppWithChains(dbResults: unknown[]) { return { app, chains }; } -describe("GET /api/pages/:id/content", () => { - it("returns 200 with empty ydoc_state when page exists but page_contents row is missing", async () => { - const app = createPagesApp([...pageAccessPrefix(), []]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "GET", - headers: authHeaders(), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body).toMatchObject({ - ydoc_state: "", - version: 0, - content_text: null, - }); - expect(body.updated_at).toBeUndefined(); - }); - - it("returns 404 when page does not exist", async () => { - const app = createPagesApp([[]]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "GET", - headers: authHeaders(), - }); - - expect(res.status).toBe(404); - }); - - it("returns 401 without auth header", async () => { - const app = createPagesApp([]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "GET", - }); - - expect(res.status).toBe(401); - }); - - // Issue #880 Phase B リグレッション対応: GET 時の lazy migration。 - // `[[Title]]` を含む未 mark の Y.Doc を読んだら、サーバが mark 化してから - // 返し、楽観ロックで page_contents を更新する。 - // Issue #880 Phase B regression fix: lazy migration on GET. When a row - // contains unmarked `[[Title]]`, the route normalizes before returning - // and persists via optimistic lock. - it("normalizes unmarked `[[Title]]` text on GET and bumps the version", async () => { - // 未 mark の `[[Foo]]` を持つ Y.Doc を作る。 - // Build a Y.Doc carrying plain `[[Foo]]` (no wikiLink mark yet). - 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, "see [[Foo]] for details"); - const buffer = Buffer.from(Y.encodeStateAsUpdate(doc)); - - const app = createPagesApp([ - ...pageAccessPrefix(), - // pageContents SELECT - [ - { - ydocState: buffer, - version: 3, - contentText: "see [[Foo]] for details", - updatedAt: new Date("2026-05-16T10:00:00Z"), - }, - ], - // pageContents UPDATE (optimistic lock success) - [{ version: 4, updatedAt: new Date("2026-05-16T10:00:01Z") }], - ]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "GET", - headers: authHeaders(), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { ydoc_state: string; version: number }; - expect(body.version).toBe(4); - - // Decode the returned bytes and confirm wikiLink mark is now present. - const decoded = new Y.Doc(); - Y.applyUpdate(decoded, new Uint8Array(Buffer.from(body.ydoc_state, "base64"))); - const returnedFragment = decoded.getXmlFragment("default"); - let hasWikiLink = false; - returnedFragment.toArray().forEach((node) => { - if (node instanceof Y.XmlElement) { - node.toArray().forEach((child) => { - if (child instanceof Y.XmlText) { - const delta = child.toDelta() as Array<{ attributes?: Record }>; - if (delta.some((s) => s.attributes?.wikiLink)) hasWikiLink = true; - } - }); - } - }); - expect(hasWikiLink).toBe(true); - }); - - it("returns already-marked content as-is and does not consume an UPDATE chain", async () => { - // wikiLink mark 済みの Y.Doc。GET 経路では正規化が no-op になり UPDATE を - // 呼ばないため、UPDATE 結果はキューしない。 - // Already-marked Y.Doc — the GET path's normalization is a no-op so no - // UPDATE chain should be consumed. - 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, "[[Foo]]", { - wikiLink: { title: "Foo", exists: true, referenced: false, targetId: "page-foo" }, - }); - const buffer = Buffer.from(Y.encodeStateAsUpdate(doc)); - - const app = createPagesApp([ - ...pageAccessPrefix(), - [ - { - ydocState: buffer, - version: 7, - contentText: "[[Foo]]", - updatedAt: new Date("2026-05-16T11:00:00Z"), - }, - ], - // Intentionally NOT queuing an UPDATE result; the handler must skip it. - ]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "GET", - headers: authHeaders(), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { version: number }; - // Version is unchanged because the normalizer found nothing to do. - expect(body.version).toBe(7); - }); -}); - describe("GET /api/pages/:id (single page metadata, Issue #860 Phase 6)", () => { /** * 単一ページ取得経路で返される metadata 行のテンプレート。実際の SELECT @@ -507,232 +372,18 @@ describe("POST /api/pages", () => { }); }); -describe("PUT /api/pages/:id/content", () => { - it("creates page_contents when expected_version is 0 and no row exists (aligns with GET version 0)", async () => { - const ydocB64 = Buffer.from("hello").toString("base64"); - const { app, chains } = createPagesAppWithChains([ - ...pageAccessPrefix(), - [{ version: 1, pageId: PAGE_ID }], - [], - [{ id: "snap-1" }], - [], - ]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "PUT", - headers: authHeaders(), - body: JSON.stringify({ - ydoc_state: ydocB64, - expected_version: 0, - }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { version: number }; - expect(body.version).toBe(1); - // maybeCreateSnapshot の内部実装順に依存しないよう、スナップショット経路が走ったことだけ確認する。 - const methods = chains.map((chain) => chain.startMethod); - expect(methods).toContain("insert"); - }); - - it("accepts ydoc_state empty string for first save (matches GET when page_contents is missing)", async () => { - const app = createPagesApp([ - ...pageAccessPrefix(), - [{ version: 1, pageId: PAGE_ID }], - [], - [{ id: "snap-2" }], - [], - ]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "PUT", - headers: authHeaders(), - body: JSON.stringify({ - ydoc_state: "", - expected_version: 0, - }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { version: number }; - expect(body.version).toBe(1); - }); - - it("returns 400 when ydoc_state is omitted (before DB access checks)", async () => { - const app = createPagesApp([]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "PUT", - headers: authHeaders(), - body: JSON.stringify({ - expected_version: 0, - }), - }); - - expect(res.status).toBe(400); - }); - - // Issue #726: タイトル変更検出のため、PUT に title が含まれるとき pages.title - // を SELECT してから UPDATE を行う。これにより伝播処理の起点になる。 - // Issue #860 Phase 4 (PR #867 review fix) で、メタデータが実際に変化したとき - // だけ pages テーブルを UPDATE する最適化が入った。タイトルが変わるケースは - // 引き続き SELECT + UPDATE の 2 段ステップを踏む。 - // - // 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. Issue #860 Phase 4 (PR #867 review) - // additionally gates the metadata UPDATE on a real value diff; when the - // title genuinely changes the SELECT + UPDATE pair is still issued. - it("issues an extra SELECT and pages UPDATE for rename detection when body.title differs", async () => { - const ydocB64 = Buffer.from("hello").toString("base64"); - const { app, chains } = createPagesAppWithChains([ - ...pageAccessPrefix(), - [{ version: 2, pageId: PAGE_ID }], - // 現在のタイトルが "Old Title"、新タイトルが "New Title" なので - // SELECT + UPDATE が実際に走る。 - // Current title "Old Title" ≠ new title "New Title", so the SELECT - // detects a rename and the metadata UPDATE actually fires. - [{ title: "Old Title", contentPreview: null }], - [{ id: PAGE_ID, title: "New Title" }], - [], - [], - ]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "PUT", - headers: authHeaders(), - body: JSON.stringify({ - ydoc_state: ydocB64, - expected_version: 1, - title: "New Title", - }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { version: number }; - expect(body.version).toBe(2); - - const selectChains = chains.filter((c) => c.startMethod === "select"); - expect(selectChains.length).toBeGreaterThanOrEqual(2); - const updateChains = chains.filter((c) => c.startMethod === "update"); - expect(updateChains.length).toBeGreaterThanOrEqual(2); - }); - - // Issue #860 Phase 4 (PR #867 review): クライアントが現在値を round-trip した - // だけの保存では、SSE が暴発しないように pages テーブルの UPDATE を skip する。 - // SELECT 1 回(現在値の取得)は走るが、metadata UPDATE は走らない。 - // - // When the client round-trips unchanged title / content_preview values, - // the route now skips the metadata UPDATE entirely so `page.updated` is - // not broadcast on a no-op save. - it("skips the pages metadata UPDATE when title/content_preview match current (PR #867)", async () => { - const ydocB64 = Buffer.from("hello").toString("base64"); - const { app, chains } = createPagesAppWithChains([ - ...pageAccessPrefix(), - [{ version: 2, pageId: PAGE_ID }], - [{ title: "Same Title", contentPreview: "Same Preview" }], - [], - [], - ]); - - 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", - content_preview: "Same Preview", - }), - }); - - expect(res.status).toBe(200); - // SELECT は現在値取得のため 1 回走るが、pages の UPDATE は走らない。 - // page_contents の UPDATE は走る (version bump)。 - // SELECT for current values still happens; the pages UPDATE is - // skipped, while page_contents still updates (version bump). - const updateChains = chains.filter((c) => c.startMethod === "update"); - // ノートアクセス可否を見るための SELECT が起点となる UPDATE も無いため、 - // 残るのは page_contents の version bump 1 件のみ。 - // The only UPDATE left is the page_contents version bump. - expect(updateChains.length).toBe(1); - }); - - // Issue #880 Phase B リグレッション対応: PUT 経路でも defense-in-depth で - // 未 mark の `[[Title]]` を `wikiLink` mark 化してから保存する。 - // Defense-in-depth: PUT path normalizes unmarked `[[Title]]` text before - // persisting so future GETs never see raw brackets. - it("normalizes unmarked `[[Title]]` text before persisting on PUT", async () => { - 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, "see [[Bar]] here"); - const ydocB64 = Buffer.from(Y.encodeStateAsUpdate(doc)).toString("base64"); - - const { app, chains } = createPagesAppWithChains([ - ...pageAccessPrefix(), - [{ version: 5, pageId: PAGE_ID }], - [{ title: "T", contentPreview: "P" }], - [], - [], - ]); - - const res = await app.request(`/api/pages/${PAGE_ID}/content`, { - method: "PUT", - headers: authHeaders(), - body: JSON.stringify({ - ydoc_state: ydocB64, - expected_version: 4, - }), - }); - - expect(res.status).toBe(200); - - // The page_contents UPDATE should have been called with a buffer where - // `[[Bar]]` is now wikiLink-marked. Decode the buffer from the `set()` op - // and confirm. - const updateChain = chains.find( - (c) => c.startMethod === "update" && c.ops.some((op) => op.method === "set"), - ); - expect(updateChain).toBeDefined(); - const setOp = updateChain?.ops.find((op) => op.method === "set"); - expect(setOp).toBeDefined(); - const setPayload = setOp?.args[0] as { ydocState?: Buffer } | undefined; - expect(setPayload?.ydocState).toBeInstanceOf(Buffer); - if (!setPayload?.ydocState) return; - - const decoded = new Y.Doc(); - Y.applyUpdate(decoded, new Uint8Array(setPayload.ydocState)); - const decodedFragment = decoded.getXmlFragment("default"); - let hasWikiLink = false; - decodedFragment.toArray().forEach((node) => { - if (node instanceof Y.XmlElement) { - node.toArray().forEach((child) => { - if (child instanceof Y.XmlText) { - const delta = child.toDelta() as Array<{ attributes?: Record }>; - if (delta.some((s) => s.attributes?.wikiLink)) hasWikiLink = true; - } - }); - } - }); - expect(hasWikiLink).toBe(true); - }); -}); - -// ── PUT /api/pages/:id (metadata only, post-`local` cleanup) ──────────────── +// ── PUT /api/pages/:id (metadata only) ───────────────────────────────────── // -// `local` モード廃止後、タイトル等のメタデータ更新は Y.Doc 経路 (`PUT /content`) -// を介さず、この新エンドポイントで行う。`applyPagesMetadataUpdate` を再利用する -// ため SSE 通知・タイトル伝播のゲーティングは PUT /content と同等に動作する。 +// Issue #889 Phase 4 で `local` モードと `PUT /api/pages/:id/content` を廃止 +// したため、タイトル等のメタデータ更新は本ルートへ一本化されている。 +// `applyPagesMetadataUpdate` を経由するため、SSE 通知・タイトル伝播の +// ゲーティングは旧 `/content` 経路と同等に動作する。 // -// After retiring the `local` collaboration mode, page metadata updates flow -// through this new endpoint instead of riding on the Y.Doc payload. The -// helpers (`applyPagesMetadataUpdate`, `tryPropagateTitleRename`, -// `emitPageUpdatedIfChanged`) are reused so the SSE-emit and title-propagation -// invariants match the legacy `PUT /:id/content` path. +// Issue #889 Phase 4 retired the `local` mode and the +// `PUT /api/pages/:id/content` route, so page metadata updates flow through +// this endpoint exclusively. The shared `applyPagesMetadataUpdate` helper +// keeps the SSE-emit and title-propagation invariants consistent with the +// old `/content` path. describe("PUT /api/pages/:id (metadata only)", () => { it("returns 200 and updates the title when body.title differs from current", async () => { const { app, chains } = createPagesAppWithChains([ diff --git a/server/api/src/__tests__/services/snapshotService.test.ts b/server/api/src/__tests__/services/snapshotService.test.ts deleted file mode 100644 index 29a5fd43..00000000 --- a/server/api/src/__tests__/services/snapshotService.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * snapshotService のテスト - * Tests for snapshotService (API-side auto-snapshot logic) - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createMockDb } from "../createMockDb.js"; -import { maybeCreateSnapshot } from "../../services/snapshotService.js"; - -// SNAPSHOT_INTERVAL_MS = 600_000 (10 minutes) -const TEN_MINUTES = 10 * 60 * 1000; - -const PAGE_ID = "page-aaa-111"; -const USER_ID = "user-bbb-222"; - -function makeYdocBuffer(): Buffer { - return Buffer.from("fake-ydoc-state"); -} - -describe("maybeCreateSnapshot", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("前回スナップショットがない場合、スナップショットを作成する / creates snapshot when no prior snapshot exists", async () => { - vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); - - // Query 1: select last snapshot → empty - // Query 2: insert snapshot - // Query 3: delete pruning (execute) - const { db } = createMockDb([ - [], // no prior snapshots - [{ id: "snap-1" }], // insert result (not used) - [], // pruning result - ]); - - await maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), "some text", 5, USER_ID); - - // 3 DB operations: select, insert, execute(delete) - expect(true).toBe(true); // No error thrown = success - }); - - it("前回スナップショットから10分経過している場合、スナップショットを作成する / creates snapshot when 10+ minutes elapsed", async () => { - const now = new Date("2026-04-07T12:10:00Z"); - vi.setSystemTime(now); - - const lastCreatedAt = new Date(now.getTime() - TEN_MINUTES); // exactly 10 min ago - - const { db } = createMockDb([ - [{ createdAt: lastCreatedAt }], // last snapshot - [{ id: "snap-new" }], // insert - [], // pruning - ]); - - await maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), "updated text", 10, USER_ID); - - expect(true).toBe(true); // No error thrown = success - }); - - it("前回スナップショットから10分未満の場合、スナップショットを作成しない / skips snapshot when less than 10 minutes elapsed", async () => { - const now = new Date("2026-04-07T12:05:00Z"); - vi.setSystemTime(now); - - const lastCreatedAt = new Date(now.getTime() - (TEN_MINUTES - 1000)); // 9 min 59 sec ago - - // Only 1 query: select last snapshot - // No insert or pruning should happen - const { db, chains } = createMockDb([[{ createdAt: lastCreatedAt }]]); - - await maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), "text", 3, USER_ID); - - // Should only have 1 chain (the select query) - expect(chains.length).toBe(1); - expect(chains[0]?.startMethod).toBe("select"); - }); - - it("contentText が null でもエラーにならない / handles null contentText", async () => { - vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); - - const { db } = createMockDb([[], [{ id: "snap-1" }], []]); - - await expect( - maybeCreateSnapshot(db as never, PAGE_ID, makeYdocBuffer(), null, 1, USER_ID), - ).resolves.toBeUndefined(); - }); -}); diff --git a/server/api/src/__tests__/services/ydocWikiLinkNormalizer.test.ts b/server/api/src/__tests__/services/ydocWikiLinkNormalizer.test.ts deleted file mode 100644 index c08d3bc8..00000000 --- a/server/api/src/__tests__/services/ydocWikiLinkNormalizer.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * `applyWikiLinkMarksToYDoc` の単体テスト。 - * - * Unit tests for the server-side WikiLink normalizer that replaces the - * client's `applyWikiLinkMarksToEditor` post-sync helper (Issue #880 - * Phase B regression — y-prosemirror `unexpectedCase` on multi-mark - * dispatch in collaborative mode). - * - * 検証観点 / Coverage: - * - 未 mark の `[[Title]]` を `wikiLink` mark に昇格させる - * - 既存 mark を二重 mark しない(冪等) - * - インラインコード / コードブロック内はスキップ - * - 空タイトルはスキップ - * - 1 段落内の複数マッチ / 複数段落の同時処理 - * - 既存の他 mark(`bold` 等)を温存する - * - `marksApplied` カウンタが返る - * - `format` のみを使うためテキスト長は不変 - */ - -import { describe, it, expect } from "vitest"; -import * as Y from "yjs"; - -import { applyWikiLinkMarksToYDoc } from "../../services/ydocWikiLinkNormalizer.js"; - -/** - * 1 段落 (`paragraph`) + 1 つの Y.XmlText で構成される最小ドキュメントを組む。 - * Build a minimal Tiptap-like Y.Doc with one paragraph and one Y.XmlText. - */ -function buildParagraphDoc( - 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 }; -} - -/** - * 指定したノード名のブロック (`codeBlock` 等) + 内側に 1 つの Y.XmlText を構成する。 - * Build a Y.Doc whose top-level child is `nodeName` (e.g. `codeBlock`) with a - * single Y.XmlText inside it. - */ -function buildContainerDoc( - nodeName: string, - segments: Array<{ insert: string; attributes?: Record }>, -): { doc: Y.Doc; text: Y.XmlText } { - const doc = new Y.Doc(); - const fragment = doc.getXmlFragment("default"); - const container = new Y.XmlElement(nodeName); - fragment.insert(0, [container]); - const text = new Y.XmlText(); - container.insert(0, [text]); - for (const segment of segments) { - text.insert(text.length, segment.insert, segment.attributes); - } - return { doc, text }; -} - -/** - * Y.XmlText のプレーンテキストを取り出すヘルパー。`toJSON()` は mark を XML - * 要素として直列化するため文字列比較には使えない。 - * - * Extract plain text from a Y.XmlText. `toJSON()` serializes marks as XML - * elements, so reconstruct the string from the 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("applyWikiLinkMarksToYDoc", () => { - it("promotes a plain `[[Title]]` segment to a wikiLink mark", () => { - const { doc, text } = buildParagraphDoc([{ insert: "see [[Foo]] for details" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(1); - expect(plainText(text)).toBe("see [[Foo]] for details"); - expect(text.toDelta()).toEqual([ - { insert: "see " }, - { - insert: "[[Foo]]", - attributes: { - wikiLink: { title: "Foo", exists: false, referenced: false, targetId: null }, - }, - }, - { insert: " for details" }, - ]); - }); - - it("does not double-mark an already-marked wikiLink segment", () => { - const { doc, text } = buildParagraphDoc([ - { - insert: "[[Foo]]", - attributes: { - wikiLink: { title: "Foo", exists: true, referenced: false, targetId: "page-1" }, - }, - }, - ]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - // 既存属性 (exists=true, targetId) を温存することがコア要件。 - // Existing attributes (exists=true, targetId) must be preserved verbatim. - expect(text.toDelta()).toEqual([ - { - insert: "[[Foo]]", - attributes: { - wikiLink: { title: "Foo", exists: true, referenced: false, targetId: "page-1" }, - }, - }, - ]); - }); - - it("skips `[[Title]]` inside an inline code mark", () => { - // Y.XmlText の `insert` は直前のセグメントの attributes を継承する - // ことがあるため、`format` で `code` mark を `[[Foo]]` の範囲だけに - // 明示的に付けたうえで検証する。 - // Y.XmlText.insert may inherit the preceding segment's attributes, so - // we set `code: true` explicitly via `format` only on the `[[Foo]]` - // span. - 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, "before [[Foo]] after"); - text.format(7, 7, { code: true }); // mark only "[[Foo]]" - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - // wikiLink 属性は付与されないこと、テキストが不変であることを確認。 - // Verify no wikiLink attr appears and the plain text is intact. - const delta = text.toDelta() as Array<{ - insert: unknown; - attributes?: Record; - }>; - expect(delta.some((s) => s.attributes?.wikiLink)).toBe(false); - const reconstructed = delta.map((s) => (typeof s.insert === "string" ? s.insert : "")).join(""); - expect(reconstructed).toBe("before [[Foo]] after"); - }); - - it("skips text inside a `codeBlock`", () => { - const { doc, text } = buildContainerDoc("codeBlock", [{ insert: "see [[Foo]] inside code" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - expect(text.toDelta()).toEqual([{ insert: "see [[Foo]] inside code" }]); - }); - - it("skips text inside a `code_block` (snake_case alias)", () => { - const { doc, text } = buildContainerDoc("code_block", [{ insert: "[[Foo]] still literal" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - expect(text.toDelta()).toEqual([{ insert: "[[Foo]] still literal" }]); - }); - - it("skips text inside `executableCodeBlock`", () => { - const { doc, text } = buildContainerDoc("executableCodeBlock", [{ insert: "[[Foo]]" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - expect(text.toDelta()).toEqual([{ insert: "[[Foo]]" }]); - }); - - it("skips text nested under a code-container via an intermediate element", () => { - // 将来 `codeBlock > customNode > text` のようなスキーマ拡張が行われても、 - // 祖先のいずれかが code-container ならテキストはリテラル扱いになることを - // 検証する(Gemini レビュー指摘 #887: 直近親だけのチェックでは取りこぼし)。 - // Verify that nested text deeper than one level under a code-container - // is still skipped — the `inCodeContainer` flag must propagate through - // intermediate elements (Gemini review #887: checking only the immediate - // parent would let nested text slip through). - const doc = new Y.Doc(); - const fragment = doc.getXmlFragment("default"); - const codeBlock = new Y.XmlElement("codeBlock"); - fragment.insert(0, [codeBlock]); - const customNode = new Y.XmlElement("customNode"); - codeBlock.insert(0, [customNode]); - const text = new Y.XmlText(); - customNode.insert(0, [text]); - text.insert(0, "see [[Foo]] should stay literal"); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - expect(text.toDelta()).toEqual([{ insert: "see [[Foo]] should stay literal" }]); - }); - - it("skips empty title `[[ ]]`", () => { - const { doc, text } = buildParagraphDoc([{ insert: "garbage [[ ]] here" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - expect(plainText(text)).toBe("garbage [[ ]] here"); - }); - - it("marks multiple `[[Title]]` patterns in the same paragraph", () => { - const { doc, text } = buildParagraphDoc([{ insert: "see [[A]] and [[B]]" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(2); - expect(text.toDelta()).toEqual([ - { insert: "see " }, - { - insert: "[[A]]", - attributes: { - wikiLink: { title: "A", exists: false, referenced: false, targetId: null }, - }, - }, - { insert: " and " }, - { - insert: "[[B]]", - attributes: { - wikiLink: { title: "B", exists: false, referenced: false, targetId: null }, - }, - }, - ]); - }); - - it("marks `[[Title]]` across multiple paragraphs", () => { - const doc = new Y.Doc(); - const fragment = doc.getXmlFragment("default"); - const paragraph1 = new Y.XmlElement("paragraph"); - const paragraph2 = new Y.XmlElement("paragraph"); - fragment.insert(0, [paragraph1, paragraph2]); - const text1 = new Y.XmlText(); - const text2 = new Y.XmlText(); - paragraph1.insert(0, [text1]); - paragraph2.insert(0, [text2]); - text1.insert(0, "Has [[One]] mark"); - text2.insert(0, "And [[Two]] here"); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(2); - expect(text1.toDelta()).toEqual([ - { insert: "Has " }, - { - insert: "[[One]]", - attributes: { - wikiLink: { title: "One", exists: false, referenced: false, targetId: null }, - }, - }, - { insert: " mark" }, - ]); - expect(text2.toDelta()).toEqual([ - { insert: "And " }, - { - insert: "[[Two]]", - attributes: { - wikiLink: { title: "Two", exists: false, referenced: false, targetId: null }, - }, - }, - { insert: " here" }, - ]); - }); - - it("preserves co-existing marks (e.g. `bold`) on the matched range", () => { - const { doc, text } = buildParagraphDoc([ - { insert: "see [[Foo]] here", attributes: { bold: true } }, - ]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(1); - // 既存の `bold` は維持されつつ、`[[Foo]]` の範囲だけに wikiLink が追加される。 - // `bold` is preserved everywhere; `wikiLink` is layered on the `[[Foo]]` span only. - expect(text.toDelta()).toEqual([ - { insert: "see ", attributes: { bold: true } }, - { - insert: "[[Foo]]", - attributes: { - bold: true, - wikiLink: { title: "Foo", exists: false, referenced: false, targetId: null }, - }, - }, - { insert: " here", attributes: { bold: true } }, - ]); - }); - - it("trims whitespace from the inner title", () => { - const { doc, text } = buildParagraphDoc([{ insert: "[[ Foo Bar ]]" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(1); - const delta = text.toDelta() as Array<{ - insert: unknown; - attributes?: Record; - }>; - const marked = delta.find((s) => isWikiLinkAttrs(s.attributes)); - expect(marked).toBeDefined(); - expect(marked?.insert).toBe("[[ Foo Bar ]]"); - expect((marked?.attributes?.wikiLink as Record).title).toBe("Foo Bar"); - }); - - it("returns marksApplied=0 when the document has no `[[Title]]` text", () => { - const { doc, text } = buildParagraphDoc([{ insert: "plain text without brackets" }]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - expect(plainText(text)).toBe("plain text without brackets"); - }); - - it("is idempotent — running twice marks the same number then zero", () => { - const { doc } = buildParagraphDoc([{ insert: "see [[Foo]] and [[Bar]]" }]); - - const first = applyWikiLinkMarksToYDoc(doc); - expect(first.marksApplied).toBe(2); - - const second = applyWikiLinkMarksToYDoc(doc); - expect(second.marksApplied).toBe(0); - }); - - it("does not change plain text length when applying marks", () => { - const { doc, text } = buildParagraphDoc([{ insert: "long: [[Alpha]] and [[Beta]] and tail" }]); - const before = plainText(text); - - applyWikiLinkMarksToYDoc(doc); - - expect(plainText(text)).toBe(before); - }); - - it("ignores `[[Title]]` patterns straddling segments with different mark sets", () => { - // 異なる mark セット (bold/italic) で分断されると、各 Y.XmlText セグメントは - // 別々に走査されるため、境界をまたぐパターンはマッチしない。クライアントの - // 既存実装と同じ挙動を維持する。 - // Patterns split across segments with different mark sets are not matched - // because each segment is scanned independently. Mirrors the existing - // client-side contract. - const { doc, text } = buildParagraphDoc([ - { insert: "[[", attributes: { bold: true } }, - { insert: "Foo", attributes: { italic: true } }, - { insert: "]]", attributes: { bold: true } }, - ]); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - expect(plainText(text)).toBe("[[Foo]]"); - }); - - it("emits a single Y.Doc update for the whole batch", () => { - const { doc } = buildParagraphDoc([{ insert: "[[A]] and [[B]] and [[C]]" }]); - let updateCount = 0; - doc.on("update", () => { - updateCount += 1; - }); - - applyWikiLinkMarksToYDoc(doc); - - // 単一 transact のため observer 通知は 1 回だけ。Hocuspocus の保存 / - // graph-sync が 1 回だけ走ることを担保する。 - // Single transact => single update => downstream observers (Hocuspocus - // save, graph sync) fire only once. - expect(updateCount).toBe(1); - }); - - it("does nothing on an empty Y.Doc", () => { - const doc = new Y.Doc(); - doc.getXmlFragment("default"); - - const result = applyWikiLinkMarksToYDoc(doc); - - expect(result.marksApplied).toBe(0); - }); -}); - -function isWikiLinkAttrs(attributes: Record | undefined): boolean { - if (!attributes) return false; - const wikiLink = attributes.wikiLink; - return typeof wikiLink === "object" && wikiLink !== null; -} diff --git a/server/api/src/routes/notes/crud.ts b/server/api/src/routes/notes/crud.ts index 3d4dca3d..4df42dcd 100644 --- a/server/api/src/routes/notes/crud.ts +++ b/server/api/src/routes/notes/crud.ts @@ -133,19 +133,19 @@ function toEpochMillis(value: Date | string | null | undefined): number { /** * Note 詳細レスポンス用の weak ETag を生成する。`note.updatedAt` と role に * 加えて、ページの最大 `updated_at` と件数も混ぜることで、`notes.updated_at` - * を経由しないページ単体編集(`PUT /api/pages/:id/content`・タイトル変更・ - * ハード削除)でも ETag が変わるようにする。 + * を経由しないページ単体編集(Hocuspocus 経由の本文保存・`PUT /api/pages/:id` + * によるタイトル変更・ハード削除)でも ETag が変わるようにする。 * weak (`W/...`) を採用するのは、`view_count` のフェイヤアンドフォーゲット * 更新によりレスポンス body が byte-for-byte 一致しないため。 * * Generates a weak ETag for note detail responses. The hash mixes * `note.updatedAt`, the resolved role, and a pages-change signal * (`MAX(pages.updated_at) + COUNT(*)`) so that page-only edits which do not - * bump `notes.updated_at` (e.g. `PUT /api/pages/:id/content`, title rename, - * hard delete) still invalidate the validator. The ETag is weak (`W/...`) - * because the fire-and-forget `view_count` update can shift the response - * body byte-for-byte even when the semantically meaningful state has not - * changed. + * bump `notes.updated_at` (e.g. Hocuspocus-driven content saves, title + * renames via `PUT /api/pages/:id`, hard delete) still invalidate the + * validator. The ETag is weak (`W/...`) because the fire-and-forget + * `view_count` update can shift the response body byte-for-byte even when + * the semantically meaningful state has not changed. */ function makeNoteETag( noteId: string, @@ -433,9 +433,10 @@ app.get("/:noteId", authOptional, async (c) => { // // Aggregate `MAX(updated_at)` and `COUNT(*)` over active pages so the ETag // captures page-level mutations that do not bump `notes.updated_at` - // (content edits via `/api/pages/:id/content`, title rename, hard - // delete). The partial composite index added in Phase 3 lets Postgres - // resolve this from the index alone, keeping the cost negligible. + // (Hocuspocus-driven content edits, title renames via + // `PUT /api/pages/:id`, hard delete). The partial composite index added + // in Phase 3 lets Postgres resolve this from the index alone, keeping the + // cost negligible. const pagesSignalRows = await db .select({ // drizzle の `sql\`...\`` は型ヒントだけで、raw SQL 集約 (typed diff --git a/server/api/src/routes/pageSnapshots.ts b/server/api/src/routes/pageSnapshots.ts index 516c1dd5..a41fd2e5 100644 --- a/server/api/src/routes/pageSnapshots.ts +++ b/server/api/src/routes/pageSnapshots.ts @@ -13,9 +13,26 @@ import { pages, pageContents, pageSnapshots, users } from "../schema/index.js"; 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 { MAX_SNAPSHOTS_PER_PAGE } from "../constants.js"; import { invalidateHocuspocusDocument } from "../lib/hocuspocusInvalidation.js"; +/** + * 保持上限を超えた古いスナップショットを削除する SQL(Drizzle raw)。 + * 復元トランザクションの末尾で実行する。 + * + * Raw SQL fragment that deletes snapshots beyond the retention limit. Run at + * the tail of the restore transaction so a restore never pushes the page over + * `MAX_SNAPSHOTS_PER_PAGE`. Auto-snapshot pruning now lives on the Hocuspocus + * side (server/hocuspocus/src/snapshotUtils.ts) — this helper is the only + * pruning path on the API side. + */ +function pruneSnapshotsExceedingLimitSql(pageId: string) { + return 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} + )`; +} + const app = new Hono(); // ── GET /:id/snapshots ────────────────────────────────────────────────────── @@ -115,7 +132,7 @@ app.get("/:id/snapshots/:snapshotId", authRequired, async (c) => { * POST /:id/snapshots/:snapshotId/restore * * スナップショットを復元する。編集権限を持つユーザーのみが実行可能で、判定は - * 他のページ書き込み系エンドポイント(`PUT /api/pages/:id/content` など)と + * 他のページ書き込み系エンドポイント(`PUT /api/pages/:id` など)と * 同じく `assertPageEditAccess` に委譲する。 * * - Issue #823 以降、すべてのページは `pages.note_id` でノートに所属する。復元は diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index ff9e0456..51581af0 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -1,160 +1,42 @@ /** - * /api/pages — ページ CRUD + コンテンツ管理 + * /api/pages — ページ CRUD + 読み取り専用コンテンツ取得 * * GET /api/pages — 後方互換のページ一覧(Issue #823 以降は `Deprecation: true`)。 * 新規実装は `GET /api/notes/me` → `GET /api/notes/:noteId/pages` を推奨。 * — Legacy page listing (sends `Deprecation: true` after issue #823). * Prefer `GET /api/notes/me` then `GET /api/notes/:noteId/pages` for new clients. - * GET /api/pages/:id/content — Y.Doc コンテンツ取得(`page_contents` 行が未作成の空ページは 200 + 空 ydoc) - * — Retrieve Y.Doc content (200 + empty `ydoc_state` when no `page_contents` row). * GET /api/pages/:id/public-content — 読み取り専用のページ本文(ゲスト・viewer 用 / Y.Doc を返さない) * — Read-only page text for guests / viewer-only callers (no Y.Doc bytes). - * PUT /api/pages/:id/content — Y.Doc コンテンツ更新 (楽観的ロック) / Update with optimistic locking + * GET /api/pages/:id — 単一ページのメタデータ取得 / Single-page metadata fetch * PUT /api/pages/:id — ページメタデータ(タイトル等)更新 / Update page metadata (title, preview) * POST /api/pages — 新規ページ作成 / Create page * DELETE /api/pages/:id — ページ論理削除 / Soft-delete page + * + * Issue #889 Phase 4: `local` コラボレーションモード廃止に伴い、Y.Doc バイト列を + * 直接やり取りする `GET/PUT /api/pages/:id/content` ルートは削除した。本文の + * 編集はすべて Hocuspocus WebSocket 経由、読み取り専用閲覧は + * `GET /api/pages/:id/public-content` 経由に統一されている。 + * + * Issue #889 Phase 4: the `local` collaboration mode has been retired. The + * legacy `GET/PUT /api/pages/:id/content` routes that exchanged raw Y.Doc + * bytes are gone — editors flow through Hocuspocus over WebSocket, while + * read-only consumers use `GET /api/pages/:id/public-content`. */ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { eq, and, sql } from "drizzle-orm"; -import * as Y from "yjs"; import { pages, pageContents, type Page } from "../schema/index.js"; import { authRequired, authOptional } from "../middleware/auth.js"; import type { AppEnv, Database } from "../types/index.js"; import { ensureDefaultNote, getDefaultNoteOrNull } from "../services/defaultNoteService.js"; import { getNoteRole, canEdit } from "./notes/helpers.js"; import { extractEmailDomain } from "../lib/freeEmailDomains.js"; -import { maybeCreateSnapshot } from "../services/snapshotService.js"; -import { assertPageViewAccess, assertPageEditAccess } from "../services/pageAccessService.js"; +import { assertPageEditAccess } from "../services/pageAccessService.js"; import { propagateTitleRename } from "../services/titleRenamePropagationService.js"; import { deleteThumbnailObject } from "../services/thumbnailGcService.js"; import { publishNoteEvent } from "../services/noteEventBroadcaster.js"; -import { syncPageGraphFromStoredYDoc } from "../services/pageGraphSyncService.js"; -import { applyWikiLinkMarksToYDoc } from "../services/ydocWikiLinkNormalizer.js"; import { pageRowToWindowItem } from "./notes/eventHelpers.js"; -/** - * 未 mark の `[[Title]]` プレーンテキストを `wikiLink` mark へ昇格させる - * 「読み出し時の lazy migration」ヘルパー。Issue #880 Phase B 由来の - * y-prosemirror `unexpectedCase` を二度と踏まないよう、サーバが返すバイト列の - * 段階で確実に正規化済みにすることが目的。`marksApplied > 0` のときは楽観 - * ロックで page_contents を更新し、競合した場合は in-memory バッファだけ - * 正規化したまま返す(次回保存で自然に追従する)。 - * - * 楽観ロックでの永続化に成功した場合は、新しい `wikiLink` mark に対応する - * `links` / `ghost_links` を再構築するため `tryGraphSync` を fire-and-forget - * で呼ぶ。PR #887 のレビュー(CodeRabbit)で指摘された、GET 経路の lazy - * migration ではグラフ同期が走らずバックリンクが古いまま残る問題への対応。 - * - * Lazy migration helper for the GET path: ensures the bytes returned by - * `/api/pages/:id/content` never contain unmarked `[[Title]]` plain text, - * eliminating the y-prosemirror `unexpectedCase` boundary case from Issue - * #880 Phase B. When marks are applied, persist with optimistic locking; - * on lock conflict, return the normalized buffer in-memory only and let - * the next save reconcile. - * - * When persistence succeeds, fire `tryGraphSync` so `links` / `ghost_links` - * are rebuilt to reflect the newly added `wikiLink` marks (PR #887 review - * by CodeRabbit: GET-side migration must not leave the graph stale). - */ -async function normalizeWikiLinksOnRead( - db: Database, - pageId: string, - buffer: Buffer, - version: number, - updatedAt: Date | null, -): Promise<{ buffer: Buffer; version: number; updatedAt: Date | null }> { - if (buffer.length === 0) { - return { buffer, version, updatedAt }; - } - try { - const doc = new Y.Doc(); - Y.applyUpdate(doc, new Uint8Array(buffer)); - const { marksApplied } = applyWikiLinkMarksToYDoc(doc); - if (marksApplied === 0) { - return { buffer, version, updatedAt }; - } - const normalized = Buffer.from(Y.encodeStateAsUpdate(doc)); - const now = new Date(); - // 楽観ロック: 我々が読んだ version と DB が一致するときだけ更新する。 - // 競合時は in-memory の正規化バッファだけ返し、永続化は次回の save に任せる。 - // Optimistic lock: only update when DB still sits at our observed version. - // On conflict, return the in-memory normalized bytes; the next save by - // the client will eventually persist a marked-up state. - const updated = await db - .update(pageContents) - .set({ - ydocState: normalized, - version: sql`${pageContents.version} + 1`, - updatedAt: now, - }) - .where(and(eq(pageContents.pageId, pageId), eq(pageContents.version, version))) - .returning({ version: pageContents.version, updatedAt: pageContents.updatedAt }); - const row = updated[0]; - if (row) { - // 永続化に成功した場合のみ `links` / `ghost_links` を再構築する。楽観 - // ロックが競合した場合は別経路の保存が同時に走っており、そちらが自分の - // タイミングでグラフ同期を発火するためここからは呼ばない。 - // Trigger graph sync only when our optimistic-lock update actually wrote - // the new bytes. On lock conflict, a concurrent writer owns the next - // graph-sync invocation, so we stay silent here. - tryGraphSync(db, pageId); - return { - buffer: normalized, - version: row.version ?? version + 1, - updatedAt: row.updatedAt ?? now, - }; - } - return { buffer: normalized, version, updatedAt }; - } catch (error) { - console.error(`[WikiLinkNormalize] GET path failed for page=${pageId}:`, error); - return { buffer, version, updatedAt }; - } -} - -/** - * PUT 経路用の defense-in-depth 正規化。クライアントが何らかの理由で未 mark の - * `[[Title]]` を含む Y.Doc を送ってきても、永続化前に確実に mark 化してから - * 保存する。`buffer.length === 0` のときはスキップ。失敗時は原文を返してログのみ。 - * - * Defense-in-depth normalizer for the PUT path. Should the client ever send - * a Y.Doc with unmarked `[[Title]]` text, normalize it before persistence so - * subsequent loads never see the un-promoted form. No-op on empty buffers; - * on error log and return the original buffer. - */ -function normalizeWikiLinksOnWrite(pageId: string, buffer: Buffer): Buffer { - if (buffer.length === 0) return buffer; - try { - const doc = new Y.Doc(); - Y.applyUpdate(doc, new Uint8Array(buffer)); - const { marksApplied } = applyWikiLinkMarksToYDoc(doc); - if (marksApplied === 0) return buffer; - return Buffer.from(Y.encodeStateAsUpdate(doc)); - } catch (error) { - console.error(`[WikiLinkNormalize] PUT path failed for page=${pageId}:`, error); - return buffer; - } -} - -/** - * ベストエフォートで自動スナップショットを作成する。失敗してもメイン処理には影響しない。 - * Best-effort auto-snapshot creation. Failures are logged but never propagate. - */ -async function tryAutoSnapshot( - db: Database, - pageId: string, - ydocState: Buffer, - contentText: string | null, - version: number, - userId: string, -): Promise { - try { - await maybeCreateSnapshot(db, pageId, ydocState, contentText, version, userId); - } catch (error) { - console.error(`[Snapshot] Failed to create auto-snapshot for page ${pageId}:`, error); - } -} - const app = new Hono(); /** @@ -182,24 +64,9 @@ function tryPropagateTitleRename( } /** - * 本文保存後に、現在の Y.Doc から `links` / `ghost_links` を再構築する - * (issue #880 Phase C)。本文保存のレスポンスはブロックしないよう - * fire-and-forget。失敗は本文保存とは独立にログに残す。 - * - * Fire-and-forget rebuild of `links` / `ghost_links` from the just-saved - * Y.Doc (issue #880 Phase C). Failures are logged but do not block the - * content save response. - */ -function tryGraphSync(db: Database, pageId: string): void { - void syncPageGraphFromStoredYDoc(db, pageId).catch((error) => { - console.error(`[PageGraphSync] Background graph sync crashed for page ${pageId}:`, error); - }); -} - -/** - * Issue #860 Phase 4: PUT /content で title / content_preview が「実際に」変わった - * ときだけ `page.updated` をノート購読者へ配信する。クライアントが現在値を - * 毎回ラウンドトリップする実装でも spam しないよう、`applyPagesMetadataUpdate` + * Issue #860 Phase 4: title / content_preview が「実際に」変わったときだけ + * `page.updated` をノート購読者へ配信する。クライアントが現在値を毎回 + * ラウンドトリップする実装でも spam しないよう、`applyPagesMetadataUpdate` * の戻り値 `metadataChanged` で判定する。`updatedRow` も同じ helper の * `.returning()` から渡るため、ここでは追加 SELECT を発生させない * (gemini-code-assist + coderabbitai review on PR #867)。 @@ -221,9 +88,9 @@ function emitPageUpdatedIfChanged(metadataChanged: boolean, updatedRow: Page | n } /** - * PUT /content リクエストから pages テーブルの更新セットを構築し、変更があれば適用する。 - * タイトル更新を検出した場合は旧タイトルを返して呼び出し側から伝播処理を - * 起動できるようにする(issue #726)。 + * PUT メタデータリクエストから pages テーブルの更新セットを構築し、変更があれば + * 適用する。タイトル更新を検出した場合は旧タイトルを返して呼び出し側から + * 伝播処理を起動できるようにする(issue #726)。 * * Issue #860 Phase 4 で 2 つの戻り値を追加した: * - `metadataChanged`: 現在値と比較して title または content_preview が @@ -392,269 +259,6 @@ app.get("/", authRequired, async (c) => { return c.json({ pages: result.rows }); }); -// ── GET /pages/:id/content ────────────────────────────────────────────────── -app.get("/:id/content", authRequired, async (c) => { - const pageId = c.req.param("id"); - const userId = c.get("userId"); - const db = c.get("db"); - - // すべてのページはノート所属。閲覧は `getNoteRole(pages.note_id)` が成立すれば可。 - // Every page belongs to a note; viewing requires a resolved note role on `pages.note_id`. - await assertPageViewAccess(db, pageId, userId); - - // コンテンツ取得 - const content = await db - .select() - .from(pageContents) - .where(eq(pageContents.pageId, pageId)) - .limit(1); - - const row = content[0]; - if (!row) { - return c.json({ - ydoc_state: "", - version: 0, - content_text: null, - }); - } - const rawBuffer = - row.ydocState instanceof Buffer - ? row.ydocState - : typeof row.ydocState === "string" - ? Buffer.from(row.ydocState, "base64") - : Buffer.from(row.ydocState as unknown as ArrayBufferLike); - - // Issue #880 Phase B リグレッション対応: 未 mark の `[[Title]]` プレーン - // テキストをサーバ側で `wikiLink` mark に昇格させる。`local` モード経路は - // Hocuspocus を通らないため、ここが lazy migration の入口になる。 - // Issue #880 Phase B regression fix: lazily migrate unmarked `[[Title]]` - // text on read. The `local` collaboration mode bypasses Hocuspocus, so - // this is the entry point for that path. - const normalized = await normalizeWikiLinksOnRead( - db, - pageId, - rawBuffer, - row.version ?? 0, - row.updatedAt ?? null, - ); - const ydocBase64 = normalized.buffer.toString("base64"); - - return c.json({ - ydoc_state: ydocBase64, - version: normalized.version, - content_text: row.contentText, - updated_at: normalized.updatedAt?.toISOString(), - }); -}); - -// ── PUT /pages/:id/content ────────────────────────────────────────────────── -app.put("/:id/content", authRequired, async (c) => { - const pageId = c.req.param("id"); - const userId = c.get("userId"); - const db = c.get("db"); - - const body = await c.req.json<{ - ydoc_state: string; // base64-encoded Y.Doc state - expected_version?: number; - content_text?: string; - content_preview?: string; - title?: string; - }>(); - - // Allow "" so clients can round-trip GET (empty ydoc_state) with PUT + expected_version. - // GET が ydoc_state: "" を返した場合もそのまま初回保存できるようにする。 - if (body.ydoc_state === undefined || body.ydoc_state === null) { - throw new HTTPException(400, { message: "ydoc_state is required" }); - } - - // 編集はノートロール + `editPermission` (`canEdit`) で判定する。 - // Editing requires note role + `canEdit` against the owning note. - await assertPageEditAccess(db, pageId, userId); - - // クライアントから届いたバイト列を defense-in-depth で正規化。万一未 mark の - // `[[Title]]` テキストが含まれていれば、永続化前に `wikiLink` mark を適用する。 - // Defense-in-depth: normalize the incoming Y.Doc bytes so unmarked - // `[[Title]]` text never persists, even if the client sends it. - const rawYdocBuffer = Buffer.from(body.ydoc_state, "base64"); - const ydocBuffer = normalizeWikiLinksOnWrite(pageId, rawYdocBuffer); - - // UPSERT page_contents with optimistic locking - if (body.expected_version != null) { - // First save after GET returned version 0 with no row: insert the initial row. - // GET が page_contents 未作成で version:0 を返した契約に合わせ、expected_version:0 で初回 INSERT を許容する。 - if (body.expected_version === 0) { - const firstSave = await db.transaction(async (tx) => { - const inserted = await tx - .insert(pageContents) - .values({ - pageId, - ydocState: ydocBuffer, - version: 1, - contentText: body.content_text ?? null, - }) - .onConflictDoNothing({ target: pageContents.pageId }) - .returning(); - - if (!inserted.length) { - return { done: false as const }; - } - - const insertedRow = inserted[0]; - if (!insertedRow) throw new HTTPException(500, { message: "Insert failed" }); - - const { renamed, metadataChanged, updatedRow } = await applyPagesMetadataUpdate( - tx, - pageId, - body, - ); - - return { - done: true as const, - version: insertedRow.version ?? 1, - renamed, - metadataChanged, - updatedRow, - }; - }); - - if (firstSave.done) { - void tryAutoSnapshot( - db, - pageId, - ydocBuffer, - body.content_text ?? null, - firstSave.version, - userId, - ); - if (firstSave.renamed) { - tryPropagateTitleRename( - db, - pageId, - firstSave.renamed.oldTitle, - firstSave.renamed.newTitle, - ); - } - // Issue #880 Phase C: 本文保存と同じトリガーでリンクグラフを再構築する。 - // Issue #880 Phase C: rebuild outgoing edges from the saved Y.Doc. - tryGraphSync(db, pageId); - // Issue #860 Phase 4: メタデータが実際に変化したときだけ通知。 - // Issue #860 Phase 4: emit only when metadata really changed. - emitPageUpdatedIfChanged(firstSave.metadataChanged, firstSave.updatedRow); - return c.json({ version: firstSave.version }); - } - } - - // 楽観的ロック: expected_version と一致する場合のみ更新 - const updated = await db - .update(pageContents) - .set({ - ydocState: ydocBuffer, - version: sql`${pageContents.version} + 1`, - contentText: body.content_text ?? null, - updatedAt: new Date(), - }) - .where(and(eq(pageContents.pageId, pageId), eq(pageContents.version, body.expected_version))) - .returning(); - - if (!updated.length) { - // バージョン不一致: 現在のバージョンを返す - const current = await db - .select({ version: pageContents.version }) - .from(pageContents) - .where(eq(pageContents.pageId, pageId)) - .limit(1); - - throw new HTTPException(409, { - message: `Version conflict. Current version: ${current[0]?.version ?? 0}`, - }); - } - - const updatedRow = updated[0]; - if (!updatedRow) throw new HTTPException(500, { message: "Update failed" }); - - const { - renamed, - metadataChanged, - updatedRow: metadataRow, - } = await applyPagesMetadataUpdate(db, pageId, body); - - void tryAutoSnapshot( - db, - pageId, - ydocBuffer, - body.content_text ?? null, - updatedRow.version ?? 0, - userId, - ); - - if (renamed) { - tryPropagateTitleRename(db, pageId, renamed.oldTitle, renamed.newTitle); - } - - // Issue #880 Phase C: 楽観的ロック成功経路でもグラフ再構築を発火する。 - // Issue #880 Phase C: trigger graph rebuild on the optimistic-lock path. - tryGraphSync(db, pageId); - - // Issue #860 Phase 4: optimistic-lock 経路のメタデータ変化を通知。 - // Notify subscribers from the optimistic-lock path as well. - emitPageUpdatedIfChanged(metadataChanged, metadataRow); - - return c.json({ version: updatedRow.version ?? 0 }); - } - - // No optimistic locking: UPSERT - const result = await db - .insert(pageContents) - .values({ - pageId, - ydocState: ydocBuffer, - version: 1, - contentText: body.content_text ?? null, - }) - .onConflictDoUpdate({ - target: pageContents.pageId, - set: { - ydocState: ydocBuffer, - version: sql`${pageContents.version} + 1`, - contentText: body.content_text ?? null, - updatedAt: new Date(), - }, - }) - .returning(); - - const { - renamed, - metadataChanged, - updatedRow: metadataRow, - } = await applyPagesMetadataUpdate(db, pageId, body); - - const resultRow = result[0]; - if (!resultRow) throw new HTTPException(500, { message: "Upsert failed" }); - - void tryAutoSnapshot( - db, - pageId, - ydocBuffer, - body.content_text ?? null, - resultRow.version ?? 0, - userId, - ); - - if (renamed) { - tryPropagateTitleRename(db, pageId, renamed.oldTitle, renamed.newTitle); - } - - // Issue #880 Phase C: UPSERT 経路(楽観的ロック未使用)でも graph 再構築する。 - // Issue #880 Phase C: trigger graph rebuild on the UPSERT path too. - tryGraphSync(db, pageId); - - // Issue #860 Phase 4: UPSERT 経路(楽観的ロック未使用)でも emit。 - // Issue #860 Phase 4: emit from the UPSERT path too (no optimistic lock). - emitPageUpdatedIfChanged(metadataChanged, metadataRow); - - return c.json({ version: resultRow.version }); -}); - // ── POST /pages ───────────────────────────────────────────────────────────── app.post("/", authRequired, async (c) => { const userId = c.get("userId"); @@ -884,21 +488,21 @@ app.delete("/:id", authRequired, async (c) => { * 更新する。Y.Doc 本体は Hocuspocus WebSocket 経由で更新されるため、本ルートで * バイト列を受け付けることはない。 * - * `local` コラボレーションモード廃止(REST 経由で Y.Doc を同期する経路の削除)に - * 伴い、タイトル変更を REST から行いたいクライアント向けに独立した経路として - * 用意した。既存の `applyPagesMetadataUpdate` / `tryPropagateTitleRename` / - * `emitPageUpdatedIfChanged` を再利用するため、`PUT /:id/content` 経路と同じ整合性 - * (タイトル伝播・SSE 通知のゲーティング)が保たれる。 + * Issue #889 で `local` コラボレーションモードと REST 経由の Y.Doc 同期を廃止 + * したため、タイトル変更等の純粋なメタデータ更新は本ルートに一本化されている。 + * `applyPagesMetadataUpdate` / `tryPropagateTitleRename` / + * `emitPageUpdatedIfChanged` を通じて、タイトル伝播・SSE 通知のゲーティングを + * 一貫して適用する。 * * `PUT /api/pages/:pageId` updates page metadata only (title and * content_preview). The Y.Doc payload is owned by Hocuspocus and is never * accepted here. * - * Introduced when the `local` collaboration mode was retired so callers that - * need to rename a page via REST have a stable endpoint. Reuses - * `applyPagesMetadataUpdate`, `tryPropagateTitleRename`, and - * `emitPageUpdatedIfChanged` so the title-propagation and SSE-emit invariants - * stay identical to the legacy `PUT /:id/content` path. + * Issue #889 retired the `local` collaboration mode along with the REST Y.Doc + * sync path, so this is now the canonical REST entry point for pure metadata + * updates. The shared helpers (`applyPagesMetadataUpdate`, + * `tryPropagateTitleRename`, `emitPageUpdatedIfChanged`) keep the + * title-propagation and SSE-emit invariants consistent. * * @returns 200 + `{ id, title, content_preview, updated_at }` on success. * 400 when the body is empty (neither `title` nor `content_preview`). @@ -992,10 +596,11 @@ app.put("/:id", authRequired, async (c) => { * `GET /api/pages/:pageId/public-content` — 読み取り専用のページ本文 API。 * `page_contents.content_text` と派生情報のみ返し、Y.Doc バイト列は返さない。 * - * `local` モード廃止後、編集者は Hocuspocus WebSocket 経由でページを開くが、 - * 未ログインの guest(public / unlisted ノートを覗いている読者)や viewer ロールの - * メンバーは WebSocket 接続を張らずに REST で本文だけ読みたい。本ルートはその - * 経路を提供する。Y.Doc バイト列を返さないため、編集セッションは絶対に始まらない。 + * Issue #889 で `local` モードと `GET /api/pages/:id/content` を廃止したため、 + * 編集者は Hocuspocus WebSocket 経由でページを開く。未ログインの guest + * (public / unlisted ノートを覗いている読者)や viewer ロールのメンバーは + * WebSocket 接続を張らずに REST で本文だけ読みたいので、本ルートがその唯一の + * 経路となる。Y.Doc バイト列を返さないため、編集セッションは絶対に始まらない。 * * 認証は `authOptional`。所属ノートに対する `getNoteRole` で role を解決し、 * `null`(private / restricted ノートを未認可で要求した等)の場合は 403 を返す。 diff --git a/server/api/src/services/pageGraphSyncService.ts b/server/api/src/services/pageGraphSyncService.ts index c79d1268..c3a21b3a 100644 --- a/server/api/src/services/pageGraphSyncService.ts +++ b/server/api/src/services/pageGraphSyncService.ts @@ -8,9 +8,15 @@ * match the current document contents (issue #880 Phase C). * * 呼び出し元 / Callers: - * - `PUT /api/pages/:id/content` (REST 経路の本文保存後) * - Hocuspocus 保存経路 (internal HTTP `POST /api/internal/pages/:id/graph-sync`) * + * Issue #889 Phase 4 で `PUT /api/pages/:id/content` ルートを廃止して以降、 + * 本サービスを直接叩く REST 経路は無くなり、本文保存後の再構築は Hocuspocus + * からの internal HTTP のみが発火する。 + * Issue #889 Phase 4 retired the `PUT /api/pages/:id/content` route, so the + * only remaining trigger is the Hocuspocus-side internal HTTP call after + * persistence. + * * 設計方針 / Design notes: * - 解決スコープは「ソースページと同じ `pages.note_id`」のページ集合のみ。 * ノートを跨ぐ解決は行わない(個人ページはオーナーのデフォルトノートが diff --git a/server/api/src/services/snapshotService.ts b/server/api/src/services/snapshotService.ts deleted file mode 100644 index f054840c..00000000 --- a/server/api/src/services/snapshotService.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * スナップショット自動保存サービス(API 用) - * Auto-snapshot service for the API server. - * - * ⚠️ hocuspocus 側にも同様のスナップショット作成ロジックがあります: - * - server/hocuspocus/src/snapshotUtils.ts - * 定数やpruning SQLを変更する場合は、必ず両方を同時に更新してください。 - * - * ⚠️ A similar snapshot creation logic exists on the hocuspocus side: - * - server/hocuspocus/src/snapshotUtils.ts - * When changing constants or pruning SQL, always update both files. - */ -import { eq, desc, sql } from "drizzle-orm"; -import { pageSnapshots } from "../schema/index.js"; -import { SNAPSHOT_INTERVAL_MS, MAX_SNAPSHOTS_PER_PAGE } from "../constants.js"; -import type { Database } from "../types/index.js"; - -/** - * 保持上限を超えたスナップショットを削除する SQL(Drizzle raw)。 - * Raw SQL fragment to delete snapshots beyond the retention limit. - * - * API の `maybeCreateSnapshot` と復元トランザクションの両方で共有する。 - * Shared by `maybeCreateSnapshot` and the restore transaction. - */ -export function pruneSnapshotsExceedingLimitSql(pageId: string) { - return 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} - )`; -} - -/** - * 前回スナップショットから10分経過していればスナップショットを自動作成する。 - * Creates an auto-snapshot if 10+ minutes have elapsed since the last one. - * - * API 経由のスナップショットは `created_by` に userId が設定される。 - * API-created snapshots set `created_by` to the userId. - * - * ⚠️ hocuspocus 側にも同様のロジックがあります(server/hocuspocus/src/snapshotUtils.ts)。 - * インターバル判定や pruning SQL を変更する場合は両方を更新してください。 - * ⚠️ A similar logic exists on the hocuspocus side (server/hocuspocus/src/snapshotUtils.ts). - * When changing interval checks or pruning SQL, update both. - */ -export async function maybeCreateSnapshot( - db: Database, - pageId: string, - ydocState: Buffer, - contentText: string | null, - version: number, - userId: string, -): Promise { - const lastSnap = await db - .select({ createdAt: pageSnapshots.createdAt }) - .from(pageSnapshots) - .where(eq(pageSnapshots.pageId, pageId)) - .orderBy(desc(pageSnapshots.createdAt)) - .limit(1); - - const now = Date.now(); - const shouldSnapshot = - !lastSnap[0] || now - lastSnap[0].createdAt.getTime() >= SNAPSHOT_INTERVAL_MS; - - if (!shouldSnapshot) return; - - await db.insert(pageSnapshots).values({ - pageId, - version, - ydocState: ydocState, - contentText: contentText ?? null, - createdBy: userId, - trigger: "auto", - }); - - // 100件超過分を削除 / Prune snapshots exceeding the limit - await db.execute(pruneSnapshotsExceedingLimitSql(pageId)); -} diff --git a/server/api/src/services/ydocWikiLinkNormalizer.ts b/server/api/src/services/ydocWikiLinkNormalizer.ts deleted file mode 100644 index c715c7aa..00000000 --- a/server/api/src/services/ydocWikiLinkNormalizer.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Y.Doc 上の未 mark な `[[Title]]` プレーンテキストを `wikiLink` mark へ昇格させる - * 純粋なヘルパー。クライアント側で同等処理を行っていた - * `src/components/editor/extensions/applyWikiLinkMarksToEditor.ts` の責務を - * サーバへ移管し、y-prosemirror の同期境界条件(lib0 `unexpectedCase`)が - * 多数 mark 適用時にトリガされてしまう問題を恒久回避する(Issue #880 Phase B - * リグレッション)。 - * - * Pure helper that promotes unmarked `[[Title]]` plain text inside a Y.Doc to - * `wikiLink` marks. Replaces the equivalent client-side helper - * (`applyWikiLinkMarksToEditor`) so the boundary condition where a single - * ProseMirror transaction carrying many `addMark` calls trips lib0's - * `unexpectedCase` inside y-prosemirror is no longer reachable from the - * synced document path (Issue #880 Phase B regression). - * - * 設計方針 / Design notes: - * - `Y.XmlText.format(index, length, attrs)` のみを使い、テキストの内容や位置を - * 変更しない。並走している他クライアントの編集と衝突しても、Yjs の CRDT - * セマンティクスで安全にマージされる。 - * - 適用は単一の `doc.transact` にまとめ、observer 通知を 1 回に集約する。 - * Hocuspocus の保存 / グラフ同期はこの単一更新に反応する。 - * - スキップ条件はクライアント版と同契約: - * 1. 既に `wikiLink` 属性を持つセグメント(再 mark 抑止)。 - * 2. `code` 属性を持つセグメント(インラインコード)。 - * 3. `codeBlock` / `code_block` / `executableCodeBlock` 配下のテキスト全体。 - * 4. 空タイトル `[[ ]]`。 - * - 既存の他 mark(`bold` 等)は `format` の上書き挙動で温存される。Yjs の - * `format` は新しい attribute だけを差分適用するため、Quill の retain と - * 同等のセマンティクス。 - * - * - Mutates only via `Y.XmlText.format(index, length, attrs)`; text content - * and absolute positions are untouched. Concurrent edits on other clients - * merge cleanly under Yjs's CRDT semantics. - * - All formatting changes are batched into a single `doc.transact` so - * observers (e.g. Hocuspocus persistence, link graph rebuild) wake up once. - * - Skip rules mirror the client implementation: - * 1. Segments already carrying a `wikiLink` mark (no double-marking). - * 2. Segments inside an inline `code` mark. - * 3. Text inside `codeBlock` / `code_block` / `executableCodeBlock`. - * 4. Empty titles like `[[ ]]`. - * - Other marks (e.g. `bold`) are preserved because `Y.XmlText.format` only - * touches the attributes it receives — analogous to a Quill `retain` with - * attributes. - */ - -import * as Y from "yjs"; - -/** - * `[[Title]]` パターン(global, lastIndex 副作用は `matchAll` でのみ使うため - * 漏れない)。クライアント側の paste rule `WikiLinkExtension.WIKI_LINK_PASTE_REGEX` - * と意味的に一致させる(パスタ時 / 保存時の両方で同じ文字列集合をリンクと判定する)。 - * - * Matches `[[Title]]` literals. Kept semantically aligned with the - * client-side paste rule `WikiLinkExtension.WIKI_LINK_PASTE_REGEX` so that - * inputs marked at paste time and inputs lazily migrated server-side cover - * the exact same set of strings. - */ -const WIKI_LINK_TEXT_REGEX = /\[\[([^[\]]+)\]\]/g; - -/** - * Tiptap がコードコンテナとして扱う Y.XmlElement 名の集合。 - * Tiptap node names treated as code containers — text inside is preserved - * verbatim (no WikiLink promotion). - */ -const CODE_CONTAINER_TYPES = new Set(["codeBlock", "code_block", "executableCodeBlock"]); - -/** - * 正規化結果のカウンタ。ログ・テスト・「変更ありか」判定で利用する。 - * Counters describing what the normalizer did. Used for logs, tests, and to - * decide whether persistence is needed. - */ -export interface NormalizeWikiLinkResult { - /** Number of `wikiLink` marks newly applied. / 新規に適用した `wikiLink` mark 数。 */ - marksApplied: number; -} - -/** - * `applyWikiLinkMarksToYDoc` のオプション。Tiptap の既定フラグメント `"default"` - * を指定変更したいテスト用途のみ `fragmentName` を渡す。 - * - * Options for `applyWikiLinkMarksToYDoc`. Override `fragmentName` only in - * tests; production code uses Tiptap's default `"default"`. - */ -export interface ApplyWikiLinkMarksOptions { - /** XmlFragment 名 / fragment name. */ - fragmentName?: string; -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -interface PendingFormat { - /** Absolute index within the Y.XmlText. / Y.XmlText 内の絶対位置。 */ - index: number; - /** Length of the `[[Title]]` literal. / `[[Title]]` の長さ。 */ - length: number; - /** Trimmed inner title. / トリム済み内側タイトル。 */ - title: string; -} - -/** - * 単一 Y.XmlText 上を走査し、未 mark の `[[Title]]` セグメントだけに対して - * `wikiLink` mark を適用する候補リストを集計したのち、まとめて `format` する。 - * - * Walk a single Y.XmlText, collect format candidates for unmarked - * `[[Title]]` runs, then apply them all in one pass. Position math uses the - * segment offsets from `toDelta()`; embeds occupy one position each. - * - * `inCodeContainer` は祖先のいずれかが code-container ノードであるかを示す - * 伝播フラグ。直近の親だけを見ると、将来 codeBlock → customNode → text の - * ようなネストが追加されたときにスキップを取りこぼすため、`walk` 側で OR - * 伝播した結果を受け取って判定する。 - * - * `inCodeContainer` is propagated from `walk` and is `true` when any - * ancestor — not just the immediate parent — is a code-container node. - * Checking only the direct parent would miss cases like codeBlock → - * customNode → text if the schema ever nests an intermediate element. - */ -function normalizeText( - text: Y.XmlText, - inCodeContainer: boolean, - result: NormalizeWikiLinkResult, -): void { - // 祖先のいずれかが code-container ならテキストはリテラル扱いで全スキップ。 - // Skip if any ancestor is a code-container node. - if (inCodeContainer) return; - - const delta = text.toDelta() as Array<{ insert: unknown; attributes?: Record }>; - if (delta.length === 0) return; - - const pending: PendingFormat[] = []; - let offset = 0; - - for (const segment of delta) { - if (typeof segment.insert !== "string") { - // 埋め込み (画像等) は Y.XmlText 上で 1 文字分の位置を占める。 - // Embeds (e.g. images) occupy a single position inside Y.XmlText. - offset += 1; - continue; - } - - const segmentText = segment.insert; - const length = segmentText.length; - if (length === 0) continue; - - const attrs = isPlainObject(segment.attributes) ? segment.attributes : undefined; - - // 既存 wikiLink mark を二重適用しない / 既存 code mark 内はリテラル維持。 - // Skip if already wiki-marked or sitting inside an inline-code mark. - if (attrs && (isPlainObject(attrs.wikiLink) || attrs.code)) { - offset += length; - continue; - } - - if (segmentText.includes("[[")) { - for (const match of segmentText.matchAll(WIKI_LINK_TEXT_REGEX)) { - const raw = match[1] ?? ""; - const title = raw.trim(); - if (!title) continue; - pending.push({ - index: offset + (match.index ?? 0), - length: match[0].length, - title, - }); - } - } - - offset += length; - } - - if (pending.length === 0) return; - - // `format` はテキスト長・位置を変更しないため適用順は不問。可読性のため - // ドキュメント順に処理する。 - // `format` does not move text or change positions, so the order is - // irrelevant; iterate in document order for clarity. - for (const edit of pending) { - text.format(edit.index, edit.length, { - wikiLink: { - title: edit.title, - exists: false, - referenced: false, - targetId: null, - }, - }); - result.marksApplied += 1; - } -} - -type XmlChild = Y.XmlFragment | Y.XmlElement | Y.XmlText | Y.XmlHook; - -/** - * XmlFragment / XmlElement を再帰的に走査する。`inCodeContainer` を - * OR 伝播することで、深さに関わらず祖先のどこかが code-container ノード - * であればテキストをリテラル扱いにできる。 - * - * Recursively walk `node`. The `inCodeContainer` flag is OR-propagated, so - * any text whose ancestor chain contains a code-container node is preserved - * verbatim regardless of nesting depth (e.g. `codeBlock > customNode > text`). - */ -function walk( - node: Y.XmlFragment | Y.XmlElement, - inCodeContainer: boolean, - result: NormalizeWikiLinkResult, -): void { - // `node.get(i)` は Yjs の連結リストを毎回頭から辿るため、インデックス - // ループで N 要素を回ると O(N^2) になる。`toArray()` で 1 回 O(N) で - // 配列化し、その後は通常イテレーションに切り替える。`ydocRenameRewrite.walk` - // と同じ理由で同じパターンを採用する。 - // `node.get(i)` is O(i) on Yjs' linked list, making index-based loops - // O(N^2). One `toArray()` pass is O(N); iterate the resulting array - // instead. Mirrors `ydocRenameRewrite.walk` for the same reason. - const children = node.toArray() as XmlChild[]; - for (const child of children) { - if (child instanceof Y.XmlText) { - normalizeText(child, inCodeContainer, result); - } else if (child instanceof Y.XmlElement) { - const nextInCodeContainer = inCodeContainer || CODE_CONTAINER_TYPES.has(child.nodeName); - walk(child, nextInCodeContainer, result); - } - // Y.XmlHook は Tiptap の既定スキーマでは現れないためスキップ。 - // Y.XmlHook is not part of the default Tiptap schema; skip it. - } -} - -/** - * `doc` の指定フラグメント以下に存在する未 mark の `[[Title]]` プレーンテキストを - * `wikiLink` mark に昇格させる。冪等: 2 回目以降は `marksApplied === 0` を返す。 - * - * Promote unmarked `[[Title]]` plain text inside `doc`'s named fragment to - * `wikiLink` marks. Idempotent — subsequent invocations return - * `marksApplied === 0` once everything is already marked. - * - * @param doc - 対象 Y.Doc / the Y.Doc to mutate. - * @param options - 任意設定 / optional settings (chiefly `fragmentName`). - * @returns 適用件数 / count of marks applied during this call. - */ -export function applyWikiLinkMarksToYDoc( - doc: Y.Doc, - options: ApplyWikiLinkMarksOptions = {}, -): NormalizeWikiLinkResult { - const result: NormalizeWikiLinkResult = { marksApplied: 0 }; - const fragmentName = options.fragmentName ?? "default"; - const fragment = doc.getXmlFragment(fragmentName); - - // 単一 `transact` でまとめることで、Hocuspocus の保存トリガやリンクグラフ - // 再構築 observer が 1 回だけ呼ばれるようにする。origin にラベルを付けて - // 由来を追跡可能にする。 - // Wrap in a single `transact` so observers fire once and the origin label - // makes the source traceable in Hocuspocus / graph-sync logs. - doc.transact(() => { - walk(fragment, false, result); - }, "wikilink-normalize"); - - return result; -} diff --git a/server/hocuspocus/src/snapshotUtils.ts b/server/hocuspocus/src/snapshotUtils.ts index b494fc05..6a7cb866 100644 --- a/server/hocuspocus/src/snapshotUtils.ts +++ b/server/hocuspocus/src/snapshotUtils.ts @@ -1,14 +1,20 @@ /** - * スナップショット自動保存ユーティリティ(hocuspocus 用) + * スナップショット自動保存ユーティリティ(hocuspocus 用)。 * Auto-snapshot utility for the hocuspocus server. * - * ⚠️ API 側にも同様のスナップショット作成ロジックがあります: - * - server/api/src/services/snapshotService.ts - * 定数やpruning SQLを変更する場合は、必ず両方を同時に更新してください。 + * Issue #889 Phase 4 で `local` コラボレーションモードと + * `GET/PUT /api/pages/:id/content` を廃止した結果、本文編集はすべて + * Hocuspocus 経由になった。それに伴い API 側にあった `maybeCreateSnapshot` + * の二重実装は撤去され、自動スナップショット作成のロジックは本ファイルが + * 唯一の実装となる。API 側 (`server/api/src/routes/pageSnapshots.ts`) で残って + * いるのは復元時の保持上限プルーニングのみ。 * - * ⚠️ A similar snapshot creation logic exists on the API side: - * - server/api/src/services/snapshotService.ts - * When changing constants or pruning SQL, always update both files. + * Issue #889 Phase 4 retired the `local` collaboration mode along with + * `GET/PUT /api/pages/:id/content`, so all edits now flow through + * Hocuspocus. The API-side duplicate of `maybeCreateSnapshot` has been + * deleted; this file is now the sole owner of the auto-snapshot path. The + * API side keeps only retention-limit pruning for the restore route in + * `server/api/src/routes/pageSnapshots.ts`. */ import type { PoolClient } from "pg"; @@ -37,11 +43,6 @@ export const MAX_SNAPSHOTS_PER_PAGE = 100; * * Snapshots created via hocuspocus have `created_by = NULL`. * `created_by IS NULL` indicates an auto-save by the hocuspocus server. - * - * ⚠️ API 側にも同様のロジックがあります(server/api/src/services/snapshotService.ts)。 - * インターバル判定や pruning SQL を変更する場合は両方を更新してください。 - * ⚠️ A similar logic exists on the API side (server/api/src/services/snapshotService.ts). - * When changing interval checks or pruning SQL, update both. */ export async function maybeCreateSnapshot( client: PoolClient, diff --git a/server/hocuspocus/src/ydocWikiLinkNormalizer.test.ts b/server/hocuspocus/src/ydocWikiLinkNormalizer.test.ts index fd127c7c..c67968bf 100644 --- a/server/hocuspocus/src/ydocWikiLinkNormalizer.test.ts +++ b/server/hocuspocus/src/ydocWikiLinkNormalizer.test.ts @@ -1,16 +1,14 @@ /** - * Hocuspocus 側に複製した `applyWikiLinkMarksToYDoc` のスモークテスト。 - * 詳細な受入条件テストは `server/api/src/__tests__/services/ydocWikiLinkNormalizer.test.ts` - * 側で実施しており、こちらは「複製がリンクできる / 主要 3 パスで期待動作する」 - * のみを最小限で確認する。`src/lib/ydocWikiLinkNormalizerSync.test.ts` がバイト - * レベル一致を別途保証する。 + * `applyWikiLinkMarksToYDoc` の動作確認テスト。Issue #889 Phase 4 で API 側の + * 二重実装と client 側 drift 検出テストを削除して以降、本テストが唯一の + * 自動テストとなる。promote / skip-already-marked / idempotent の 3 経路を + * 中心にカバーする。 * - * Smoke tests for the Hocuspocus copy of `applyWikiLinkMarksToYDoc`. The - * exhaustive contract suite lives in the api-side test; this file only - * checks that the copy compiles, links, and behaves correctly on the three - * canonical paths (promote, skip-already-marked, idempotent). The drift - * detector in `src/lib/ydocWikiLinkNormalizerSync.test.ts` guarantees the - * two implementations stay byte-equivalent. + * Behaviour tests for `applyWikiLinkMarksToYDoc`. Since Issue #889 Phase 4 + * removed the api-side duplicate implementation and the client-side drift + * detector, this file is now the sole automated coverage. It exercises the + * three canonical paths: promotion of unmarked literals, skipping marks that + * already exist, and idempotency on repeat invocation. */ import { describe, it, expect } from "vitest"; @@ -33,7 +31,7 @@ function buildParagraphDoc( return { doc, text }; } -describe("applyWikiLinkMarksToYDoc (hocuspocus copy)", () => { +describe("applyWikiLinkMarksToYDoc", () => { it("promotes unmarked `[[Title]]` to a wikiLink mark", () => { const { doc, text } = buildParagraphDoc([{ insert: "see [[Foo]] now" }]); const result = applyWikiLinkMarksToYDoc(doc); diff --git a/server/hocuspocus/src/ydocWikiLinkNormalizer.ts b/server/hocuspocus/src/ydocWikiLinkNormalizer.ts index e3ae42d2..10822fdc 100644 --- a/server/hocuspocus/src/ydocWikiLinkNormalizer.ts +++ b/server/hocuspocus/src/ydocWikiLinkNormalizer.ts @@ -1,23 +1,49 @@ /** * Y.Doc 上の未 mark な `[[Title]]` プレーンテキストを `wikiLink` mark へ昇格させる - * 純粋なヘルパー。`server/api/src/services/ydocWikiLinkNormalizer.ts` と意図的に - * 同等のロジックを複製している(CLAUDE.md / AGENTS.md にあるとおり Hocuspocus - * パッケージは独立した Bun build context のため共有 import 不可)。 + * 純粋なヘルパー。 + * + * Issue #889 Phase 4 で `local` コラボレーションモードと + * `GET/PUT /api/pages/:id/content` 経路を廃止した結果、Y.Doc バイト列に対する + * 正規化を行う必要があるのは Hocuspocus 側のみとなった。これに伴い、かつて + * 同一ロジックを保持していた API 側の `ydocWikiLinkNormalizer` および + * クライアント側の drift 検出テストは削除されている。 * * Pure helper that promotes unmarked `[[Title]]` plain text inside a Y.Doc to - * `wikiLink` marks. Intentionally mirrors - * `server/api/src/services/ydocWikiLinkNormalizer.ts`; the Hocuspocus server - * runs in its own Bun build context (Railway) and cannot import from - * `server/api`. The drift detector at - * `src/lib/ydocWikiLinkNormalizerSync.test.ts` fails CI if the body diverges. + * `wikiLink` marks. + * + * Issue #889 Phase 4 retired the `local` collaboration mode and the + * `GET/PUT /api/pages/:id/content` REST routes, so Y.Doc byte-stream + * normalization is only required on the Hocuspocus side. The previous API-side + * copy and the client-side drift detector have been removed accordingly. * - * ⚠️ 変更時は `server/api/src/services/ydocWikiLinkNormalizer.ts` も合わせて - * 更新すること(先頭 docblock 以外はバイト等価で同期される前提)。 - * Keep this byte-equivalent (below the leading docblock) with the - * api-side copy whenever it changes. + * 設計方針 / Design notes: + * - `Y.XmlText.format(index, length, attrs)` のみを使い、テキストの内容や位置を + * 変更しない。並走している他クライアントの編集と衝突しても、Yjs の CRDT + * セマンティクスで安全にマージされる。 + * - 適用は単一の `doc.transact` にまとめ、observer 通知を 1 回に集約する。 + * Hocuspocus の保存 / グラフ同期はこの単一更新に反応する。 + * - スキップ条件: + * 1. 既に `wikiLink` 属性を持つセグメント(再 mark 抑止)。 + * 2. `code` 属性を持つセグメント(インラインコード)。 + * 3. `codeBlock` / `code_block` / `executableCodeBlock` 配下のテキスト全体。 + * 4. 空タイトル `[[ ]]`。 + * - 既存の他 mark(`bold` 等)は `format` の上書き挙動で温存される。Yjs の + * `format` は新しい attribute だけを差分適用するため、Quill の retain と + * 同等のセマンティクス。 * - * 詳細な設計方針 / スキップ条件 / 冪等性については API 側の同名ファイルを参照。 - * See the api-side copy for design notes, skip rules, and idempotency. + * - Mutates only via `Y.XmlText.format(index, length, attrs)`; text content + * and absolute positions are untouched. Concurrent edits on other clients + * merge cleanly under Yjs's CRDT semantics. + * - All formatting changes are batched into a single `doc.transact` so + * observers (e.g. Hocuspocus persistence, link graph rebuild) wake up once. + * - Skip rules: + * 1. Segments already carrying a `wikiLink` mark (no double-marking). + * 2. Segments inside an inline `code` mark. + * 3. Text inside `codeBlock` / `code_block` / `executableCodeBlock`. + * 4. Empty titles like `[[ ]]`. + * - Other marks (e.g. `bold`) are preserved because `Y.XmlText.format` only + * touches the attributes it receives — analogous to a Quill `retain` with + * attributes. */ import * as Y from "yjs"; diff --git a/src/components/editor/TiptapEditor/useEditorLifecycle.ts b/src/components/editor/TiptapEditor/useEditorLifecycle.ts index 6a05ffe0..4dee011d 100644 --- a/src/components/editor/TiptapEditor/useEditorLifecycle.ts +++ b/src/components/editor/TiptapEditor/useEditorLifecycle.ts @@ -144,18 +144,19 @@ export function useEditorLifecycle({ // ProseMirror トランザクション経由で `wikiLink` mark に昇格していたが、 // y-prosemirror が多数 mark を一括同期する境界で lib0 の // `unexpectedCase` を踏み、editor view ごと破壊されるリグレッションが - // 発生した。サーバ側 (Hocuspocus `onLoadDocument` および - // `GET /api/pages/:id/content` / `PUT /api/pages/:id/content`) で - // `applyWikiLinkMarksToYDoc` による正規化を行うように移行したため、 - // クライアントの post-sync 正規化は不要となり撤去した。 + // 発生した。Issue #889 Phase 4 で `local` モードと + // `GET/PUT /api/pages/:id/content` を廃止して以降、Y.Doc バイト列の正規化は + // Hocuspocus `onLoadDocument` の `applyWikiLinkMarksToYDoc` に一本化されて + // おり、クライアント側の post-sync 正規化は不要なため撤去した状態を維持する。 // // The post-sync client normalization that called // `applyWikiLinkMarksToEditor` here used to trigger a y-prosemirror // `unexpectedCase` boundary case on large multi-mark dispatches (Issue - // #880 Phase B regression). Normalization now happens server-side - // (Hocuspocus on-load + API GET/PUT via `applyWikiLinkMarksToYDoc`), so - // the client never receives an un-promoted `[[Title]]` plain text from a - // synced document. + // #880 Phase B regression). Since Issue #889 Phase 4 retired the `local` + // mode and the `GET/PUT /api/pages/:id/content` routes, normalization now + // happens exclusively in Hocuspocus `onLoadDocument` via + // `applyWikiLinkMarksToYDoc`, so the client never receives an un-promoted + // `[[Title]]` plain text from a synced document. usePasteImageHandler({ editor, handleImageUpload }); diff --git a/src/lib/api/apiClient.test.ts b/src/lib/api/apiClient.test.ts index 5cbf31be..03b2e757 100644 --- a/src/lib/api/apiClient.test.ts +++ b/src/lib/api/apiClient.test.ts @@ -149,7 +149,7 @@ describe("apiClient", () => { const client = createApiClient({ getToken, baseUrl: "https://api.test.example.com" }); try { - await client.getPageContent("missing"); + await client.getPagePublicContent("missing"); expect.fail("Expected ApiError"); } catch (err) { expect(err).toBeInstanceOf(ApiError); @@ -297,7 +297,7 @@ describe("apiClient", () => { const client = createApiClient({ baseUrl: "https://api.test.example.com" }); - await expect(client.getPageContent("x")).rejects.toMatchObject({ + await expect(client.getPagePublicContent("x")).rejects.toMatchObject({ status: 418, message: "I'm a teapot", }); diff --git a/src/lib/api/apiClient.ts b/src/lib/api/apiClient.ts index e7cce2c1..3771689c 100644 --- a/src/lib/api/apiClient.ts +++ b/src/lib/api/apiClient.ts @@ -7,8 +7,6 @@ import type { SyncPagesResponse, PostSyncPagesBody, PostSyncPagesResponse, - PageContentResponse, - PutPageContentBody, PagePublicContentResponse, UpdatePageMetadataBody, UpdatePageMetadataResponse, @@ -317,26 +315,17 @@ export function createApiClient(options?: Partial) { } }, - /** GET /api/pages/:id/content — ydoc_state (base64), version. 404 if no content. */ - async getPageContent(pageId: string): Promise { - return req("GET", `/api/pages/${encodeURIComponent(pageId)}/content`); - }, - - /** PUT /api/pages/:id/content — upload Y.Doc state. Optional version for optimistic lock. */ - async putPageContent(pageId: string, body: PutPageContentBody): Promise<{ version: number }> { - return req<{ version: number }>("PUT", `/api/pages/${encodeURIComponent(pageId)}/content`, { - body, - }); - }, - /** * `PUT /api/pages/:id` — タイトル等のメタデータだけを更新する。 - * `local` モード廃止に伴い、Y.Doc を扱わない純粋な REST メタデータ更新経路として - * 追加された。サインインユーザーの編集権限が前提。 + * Issue #889 で `local` モードと旧 `GET/PUT /api/pages/:id/content` を撤去した + * 結果、Y.Doc は Hocuspocus が一括で扱い、本ルートが REST 経由のタイトル / + * メタデータ更新の唯一の正規経路となった。サインインユーザーの編集権限が前提。 * * `PUT /api/pages/:id` updates page metadata only (title, - * content_preview). Introduced when the `local` collaboration mode was - * retired so callers that rename via REST have a stable endpoint. + * content_preview). Issue #889 retired the `local` mode and the legacy + * `GET/PUT /api/pages/:id/content` round-trip; Hocuspocus now owns the + * Y.Doc state exclusively, so this is the canonical REST entry point for + * title / metadata updates. */ async updatePageMetadata( pageId: string, diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 12f71fc7..bae30d31 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -104,23 +104,6 @@ export interface PostSyncPagesResponse { conflicts: Array<{ id: string; server_updated_at: string }>; } -/** GET /api/pages/:id/content response. */ -export interface PageContentResponse { - ydoc_state: string; // base64 - version: number; - content_text?: string | null; - updated_at?: string; -} - -/** PUT /api/pages/:id/content body. */ -export interface PutPageContentBody { - ydoc_state: string; // base64 - content_text?: string; - content_preview?: string; - title?: string; - expected_version?: number; -} - /** * `PUT /api/pages/:id` ボディ。タイトル等のメタデータだけを更新する。 * Y.Doc バイト列は Hocuspocus 経由で扱うため含めない。 diff --git a/src/lib/pageRepository/StorageAdapterPageRepository.test.ts b/src/lib/pageRepository/StorageAdapterPageRepository.test.ts index 68395480..61db9b80 100644 --- a/src/lib/pageRepository/StorageAdapterPageRepository.test.ts +++ b/src/lib/pageRepository/StorageAdapterPageRepository.test.ts @@ -37,8 +37,6 @@ function createMockApi(): ApiClient { upsertMe: vi.fn(), getSyncPages: vi.fn(), postSyncPages: vi.fn(), - getPageContent: vi.fn(), - putPageContent: vi.fn(), createPage: vi.fn(), deletePage: vi.fn(), getNotes: vi.fn(), diff --git a/src/lib/sync/syncWithApi.test.ts b/src/lib/sync/syncWithApi.test.ts index d19f3fad..9aad2bb8 100644 --- a/src/lib/sync/syncWithApi.test.ts +++ b/src/lib/sync/syncWithApi.test.ts @@ -46,8 +46,6 @@ function createMockApi(overrides: Partial = {}): ApiClient { server_time: new Date().toISOString(), conflicts: [], }), - getPageContent: vi.fn().mockResolvedValue({ ydoc_state: "", version: 0 }), - putPageContent: vi.fn().mockResolvedValue({ version: 1 }), createPage: vi.fn().mockResolvedValue({}), deletePage: vi.fn().mockResolvedValue({ deleted: true }), getNotes: vi.fn().mockResolvedValue([]), diff --git a/src/lib/ydocWikiLinkNormalizerSync.test.ts b/src/lib/ydocWikiLinkNormalizerSync.test.ts deleted file mode 100644 index 37d002cc..00000000 --- a/src/lib/ydocWikiLinkNormalizerSync.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * `server/api/src/services/ydocWikiLinkNormalizer.ts` と - * `server/hocuspocus/src/ydocWikiLinkNormalizer.ts` のロジック本体(中央領域) - * がバイト等価でドリフトしていないことを CI で保証するテスト。 - * - * 両ファイルは独立した Bun build context (Railway) に置かれているため、 - * `import` で共有することができない(`server/api` も `server/hocuspocus` も - * ルート Bun workspace の外側)。本テストはクライアント側 vitest から - * `fs.readFileSync` で両者を読み、ロジック本体(先頭の docblock を除いた - * 範囲)を文字列等価で比較する。一方を書き換えたらもう一方も同じ手で - * 更新すること。 - * - * Drift detector that fails CI when the api-side and hocuspocus-side copies - * of `ydocWikiLinkNormalizer.ts` diverge. The two files live in separate - * Bun build contexts (Railway) so neither can import the other; this test - * reads them from disk and compares the post-header (logic) region. - */ - -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, it, expect } from "vitest"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, "../.."); - -/** - * 先頭の docblock(`/** ... *\/`)と直後の空行を取り除き、ロジック本体だけを - * 取り出す。各ファイルで docblock 表現は変わってよいが、それ以降は一致して - * いなければならない。 - * - * Strip the leading docblock (license / file overview) so logic is compared - * but headers are allowed to differ. The first `*\/` followed by whitespace - * is the boundary. - */ -function stripLeadingDocblock(source: string): string { - // 最初の `/**` から最初の `*/` までを取り除く。直後の改行・空行も除去する。 - // Drop the first `/** ... */` block and any leading blank lines that follow. - const match = source.match(/^\s*\/\*\*[\s\S]*?\*\/\s*/); - if (!match) return source; - return source.slice(match[0].length); -} - -describe("ydocWikiLinkNormalizer sync between api and hocuspocus", () => { - it("logic body matches byte-for-byte between the two copies", () => { - const apiPath = resolve(REPO_ROOT, "server/api/src/services/ydocWikiLinkNormalizer.ts"); - const hocuspocusPath = resolve(REPO_ROOT, "server/hocuspocus/src/ydocWikiLinkNormalizer.ts"); - const apiSource = readFileSync(apiPath, "utf8"); - const hocuspocusSource = readFileSync(hocuspocusPath, "utf8"); - - const apiBody = stripLeadingDocblock(apiSource); - const hocuspocusBody = stripLeadingDocblock(hocuspocusSource); - - expect(apiBody).toBe(hocuspocusBody); - }); -}); diff --git a/src/pages/NotePageView.test.tsx b/src/pages/NotePageView.test.tsx index ab85716e..1e926882 100644 --- a/src/pages/NotePageView.test.tsx +++ b/src/pages/NotePageView.test.tsx @@ -28,8 +28,13 @@ const { mockToast: vi.fn(), mockUpdatePageMutateAsync: vi.fn().mockResolvedValue({ skipped: false }), mockApi: { - getPageContent: vi.fn(), - putPageContent: vi.fn(), + // Issue #889 Phase 4: タイトル保存は `PUT /api/pages/:id` + // (`updatePageMetadata`) に統一されたため、旧 `getPageContent` / + // `putPageContent` のモックは撤去している。 + // Issue #889 Phase 4: title saves now go through + // `PUT /api/pages/:id` (`updatePageMetadata`) exclusively, so the legacy + // `getPageContent` / `putPageContent` mocks have been removed. + updatePageMetadata: vi.fn(), }, mockSetPageContext: vi.fn(), mockExportMarkdown: vi.fn(), @@ -315,8 +320,7 @@ describe("NotePageView", () => { mockSetPageContext.mockReset(); mockNavigate.mockReset(); mockUpdatePageMutateAsync.mockResolvedValue({ skipped: false }); - mockApi.getPageContent.mockReset(); - mockApi.putPageContent.mockReset(); + mockApi.updatePageMetadata.mockReset(); mockExportMarkdown.mockReset(); mockCopyMarkdown.mockReset().mockResolvedValue(undefined); mockRemoveFromNoteMutate.mockReset(); @@ -714,28 +718,31 @@ describe("NotePageView", () => { expect(modal).toHaveAttribute("data-page-id", "page-1"); }); - // Codex P2 review on PR #891: 削除成功直後の navigate でアンマウント flush が - // 走ると、保留中のタイトル保存が「もう存在しないページ」に対する - // `putPageContent` を撃ってしまい spurious な failed-toast が出る。 - // `change-title` で debounce 中のタイトルを作った状態で削除を成功させ、 - // `mockApi.putPageContent` が呼ばれていないことで cancel hook の効果を検証する。 + // Codex P2 review on PR #891 + Issue #889 Phase 4: 削除成功直後の navigate で + // アンマウント flush が走ると、保留中のタイトル保存が「もう存在しない + // ページ」に対する `updatePageMetadata` を撃ってしまい spurious な + // failed-toast が出る。`change-title` で debounce 中のタイトルを作った状態で + // 削除を成功させ、`mockApi.updatePageMetadata` が呼ばれていないことで + // cancel hook の効果を検証する。 // - // Pin the cancel-pending-title-save behavior added for Codex P2. We prime - // a pending debounced title change, then confirm a successful delete; the - // editable child's unmount flush must be neutered before navigation so - // `putPageContent` is never invoked against the just-removed page. + // Pin the cancel-pending-title-save behavior added for Codex P2 (now + // exercising the Phase 4 `updatePageMetadata` path). We prime a pending + // debounced title change, then confirm a successful delete; the editable + // child's unmount flush must be neutered before navigation so + // `updatePageMetadata` is never invoked against the just-removed page. it("削除成功時に保留中のタイトル保存をキャンセルする / cancels pending title save before delete-success navigation", async () => { setupEditableRender(); - // 漏れた unmount-flush が `putPageContent` まで到達できるよう、 - // `getPageContent` も成功させておく。 - // Make `getPageContent` succeed so a leaked unmount-flush would reach - // `putPageContent` — the assertions below check both entry points. - mockApi.getPageContent.mockResolvedValue({ - ydoc_state: "AQ==", - version: 3, - content_text: "body", + // 漏れた unmount-flush が `updatePageMetadata` まで到達したケースを検出 + // できるように、応答も用意しておく。 + // Wire up a resolved response so any leaked unmount-flush would actually + // reach `updatePageMetadata` — the assertion below depends on this never + // firing. + mockApi.updatePageMetadata.mockResolvedValue({ + id: "page-1", + title: "Edited title", + content_preview: null, + updated_at: "2026-05-17T00:00:00Z", }); - mockApi.putPageContent.mockResolvedValue({ version: 4 }); mockRemoveFromNoteMutate.mockImplementation((_args, options) => { options?.onSuccess?.(); }); @@ -769,13 +776,12 @@ describe("NotePageView", () => { vi.useRealTimers(); await new Promise((resolve) => setTimeout(resolve, 0)); - // タイトル保存経路の最初のサーバ呼び出しが getPageContent。これが呼ばれて + // タイトル保存経路は `updatePageMetadata` 一発のみ。これが呼ばれて // いなければ pendingTitleRef は確実に null 化されている。 - // `getPageContent` is the first server call in the title-save flush - // path; asserting it never fires guarantees the cancel hook neutered + // The Phase 4 title-save flush issues a single `updatePageMetadata` + // call; asserting it never fires guarantees the cancel hook neutered // the pending state before unmount. - expect(mockApi.getPageContent).not.toHaveBeenCalled(); - expect(mockApi.putPageContent).not.toHaveBeenCalled(); + expect(mockApi.updatePageMetadata).not.toHaveBeenCalled(); // unmount-flush が走らなければ `errors.titleSaveFailedTitle` の // destructive トーストも当然出ない。 // No spurious title-save-failed toast either. @@ -784,8 +790,8 @@ describe("NotePageView", () => { ); }); - // CodeRabbit major review on PR #891: debounce が既に発火して - // `persistTitleRef.current()` が `getPageContent`/`putPageContent` を await + // CodeRabbit major review on PR #891 + Issue #889 Phase 4: debounce が既に + // 発火して `persistTitleRef.current()` が `updatePageMetadata` を await // している最中に削除が成功すると、後から in-flight save が「ページが既に // 存在しない」エラーを返し、削除成功の直後に `errors.titleSaveFailedTitle` // の destructive トーストが出てしまう。`suppressTitleSaveEffectsRef` が @@ -793,16 +799,11 @@ describe("NotePageView", () => { // // Regression test for the in-flight title-save suppression. When the // debounce has already fired and the save is mid-await, the cancel hook - // raises a suppress flag so the rejected `putPageContent` does not toast - // `errors.titleSaveFailedTitle` after the delete has navigated away. + // raises a suppress flag so the rejected `updatePageMetadata` does not + // toast `errors.titleSaveFailedTitle` after the delete has navigated away. it("削除成功時に進行中のタイトル保存失敗のトーストを抑止する / suppresses in-flight title-save failure toast after delete success", async () => { setupEditableRender(); - mockApi.getPageContent.mockResolvedValue({ - ydoc_state: "AQ==", - version: 3, - content_text: "body", - }); - // `putPageContent` 呼び出し時に毎回 fresh な保留 promise を返すよう + // `updatePageMetadata` 呼び出し時に毎回 fresh な保留 promise を返すよう // `mockImplementation` を使う。`mockReturnValue` で一度作る方式だと // `rejectPut` の捕捉が呼び出しタイミングと無関係に進んで「テストが本当に // in-flight 経路を踏んだか」を保証しにくい (CodeRabbit major)。 @@ -812,7 +813,7 @@ describe("NotePageView", () => { // `mockReturnValue` makes it harder to prove the test actually reached // the in-flight path before we reject. CodeRabbit major review. let rejectPut: (err: Error) => void = () => undefined; - mockApi.putPageContent.mockImplementation( + mockApi.updatePageMetadata.mockImplementation( () => new Promise((_, reject) => { rejectPut = reject; @@ -825,29 +826,28 @@ describe("NotePageView", () => { const { unmount } = renderNotePageView(); // タイトルを編集して debounce timer を仕掛け、500ms 経過させて save を発火。 - // `getPageContent` は解決済み、`putPageContent` は保留 = in-flight save。 + // `updatePageMetadata` は保留 = in-flight save。 // Stage a title change, flush the debounce so the save enters its - // awaiting state — `getPageContent` resolves, `putPageContent` is left - // pending so the save is genuinely mid-flight. + // awaiting state — `updatePageMetadata` is left pending so the save is + // genuinely mid-flight. fireEvent.click(screen.getByText("change-title")); await vi.advanceTimersByTimeAsync(500); - // save が `putPageContent` まで進んでいることを明示的に確認する。ここを - // 通らない限り `rejectPut(...)` は no-op になり、最終アサートが理由で - // 通ったように見えてしまう (CodeRabbit major)。`vi.waitFor` は実時間で - // ポーリングするので real timers に切り替えてから呼ぶ。 + // save が `updatePageMetadata` まで進んでいることを明示的に確認する。 + // ここを通らない限り `rejectPut(...)` は no-op になり、最終アサートが + // 理由で通ったように見えてしまう (CodeRabbit major)。`vi.waitFor` は + // 実時間でポーリングするので real timers に切り替えてから呼ぶ。 // Pin that the save actually entered the in-flight path before we // trigger delete; otherwise `rejectPut(...)` is a no-op and the final // assertion passes for the wrong reason. `vi.waitFor` polls in real // time so flip to real timers first. vi.useRealTimers(); await vi.waitFor(() => { - expect(mockApi.getPageContent).toHaveBeenCalledTimes(1); - expect(mockApi.putPageContent).toHaveBeenCalledTimes(1); + expect(mockApi.updatePageMetadata).toHaveBeenCalledTimes(1); }); // この時点で in-flight。削除を発火する。 - // Trigger delete while the save is still awaiting `putPageContent`. + // Trigger delete while the save is still awaiting `updatePageMetadata`. fireEvent.click(screen.getByText("editor.pageMenu.deletePage")); fireEvent.click(screen.getByTestId("confirm-delete")); @@ -855,8 +855,8 @@ describe("NotePageView", () => { // Simulate the post-navigate unmount. unmount(); - // 削除済みページ側の `putPageContent` を 404 相当で決着させる。 - // Resolve the deferred `putPageContent` with the "deleted" error. + // 削除済みページ側の `updatePageMetadata` を 404 相当で決着させる。 + // Resolve the deferred `updatePageMetadata` with the "deleted" error. rejectPut(new Error("page not found")); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -1044,7 +1044,7 @@ describe("NotePageView", () => { expect(copyItem).toBeDisabled(); }); - it("saves note-native page titles through the page-content API for note editors", async () => { + it("saves note-native page titles through the metadata API for note editors", async () => { vi.mocked(useNote).mockReturnValue({ note: { id: "note-1" }, access: { canView: true, canEdit: true }, @@ -1061,12 +1061,12 @@ describe("NotePageView", () => { }, isLoading: false, } as never); - mockApi.getPageContent.mockResolvedValue({ - ydoc_state: "AQ==", - version: 3, - content_text: "body", + mockApi.updatePageMetadata.mockResolvedValue({ + id: "page-1", + title: "Edited title", + content_preview: null, + updated_at: "2026-05-17T00:00:00Z", }); - mockApi.putPageContent.mockResolvedValue({ version: 4 }); renderNotePageView(); fireEvent.click(screen.getByText("change-title")); @@ -1074,11 +1074,14 @@ describe("NotePageView", () => { await Promise.resolve(); await Promise.resolve(); - expect(mockApi.putPageContent).toHaveBeenCalledTimes(1); - expect(mockApi.putPageContent).toHaveBeenCalledWith("page-1", { - ydoc_state: "AQ==", - content_text: "body", - expected_version: 3, + // Issue #889 Phase 4: タイトル保存は Y.Doc を介さず、`PUT /api/pages/:id` + // (`updatePageMetadata`) ひとつだけが発火する。旧 `getPageContent` + + // `putPageContent` の 2 段経路は廃止されている。 + // Issue #889 Phase 4: title saves now fire a single `updatePageMetadata` + // call instead of the legacy `getPageContent` + `putPageContent` + // round-trip. + expect(mockApi.updatePageMetadata).toHaveBeenCalledTimes(1); + expect(mockApi.updatePageMetadata).toHaveBeenCalledWith("page-1", { title: "Edited title", }); expect(mockUpdatePageMutateAsync).not.toHaveBeenCalled(); @@ -1102,12 +1105,7 @@ describe("NotePageView", () => { }, isLoading: false, } as never); - mockApi.getPageContent.mockResolvedValue({ - ydoc_state: "AQ==", - version: 3, - content_text: "body", - }); - mockApi.putPageContent.mockRejectedValue(new Error("save failed")); + mockApi.updatePageMetadata.mockRejectedValue(new Error("save failed")); renderNotePageView(); fireEvent.click(screen.getByText("change-title")); @@ -1152,7 +1150,7 @@ describe("NotePageView", () => { await Promise.resolve(); expect(screen.getByTestId("page-title")).toHaveTextContent("Original title"); - expect(mockApi.putPageContent).not.toHaveBeenCalled(); + expect(mockApi.updatePageMetadata).not.toHaveBeenCalled(); expect(mockUpdatePageMutateAsync).not.toHaveBeenCalled(); }); @@ -1188,7 +1186,7 @@ describe("NotePageView", () => { expect(screen.getByTestId("note-public-view")).toBeInTheDocument(); expect(screen.queryByTestId("page-editor")).not.toBeInTheDocument(); expect(screen.queryByText("change-title")).not.toBeInTheDocument(); - expect(mockApi.putPageContent).not.toHaveBeenCalled(); + expect(mockApi.updatePageMetadata).not.toHaveBeenCalled(); expect(mockUpdatePageMutateAsync).not.toHaveBeenCalled(); }); diff --git a/src/pages/NotePageView.tsx b/src/pages/NotePageView.tsx index 60ce8b15..4402111b 100644 --- a/src/pages/NotePageView.tsx +++ b/src/pages/NotePageView.tsx @@ -28,7 +28,6 @@ import { useNoteApi, useRemovePageFromNote, } from "@/hooks/useNoteQueries"; -import { useUpdatePage } from "@/hooks/usePageQueries"; import { useAuth } from "@/hooks/useAuth"; import { useCollaboration } from "@/hooks/useCollaboration"; import { ContentWithAIChat } from "@/components/ai-chat/ContentWithAIChat"; @@ -170,7 +169,7 @@ function NotePageEditorEditable({ * 親が削除成功時に呼ぶ「保留中タイトル保存のキャンセル」を流す ref。 * 削除直後の navigate で `NotePageEditorEditable` がアンマウントされる際、 * 既存の cleanup が pending title を flush して既に消したページに対して - * `putPageContent` を発火する競合 (Codex P2) を抑止する。 + * `updatePageMetadata` を発火する競合 (Codex P2) を抑止する。 * * Mutable ref the parent calls in the delete-success path to cancel any * debounced title save before navigation. Without this, the unmount @@ -187,7 +186,6 @@ function NotePageEditorEditable({ const noteWorkspace = useNoteWorkspaceOptional(); const workspaceRoot = noteWorkspace?.workspaceRoot ?? null; const editorInsertRef = useRef<((content: unknown) => boolean) | null>(null); - const updatePageMutation = useUpdatePage(); const queryClient = useQueryClient(); const { toast } = useToast(); const { t } = useTranslation(); @@ -272,32 +270,24 @@ function NotePageEditorEditable({ persistTitleRef.current = async (nextTitle: string) => { const previousTitle = lastSavedTitleRef.current; try { - if (page.noteId !== null) { - const current = await api.getPageContent(page.id); - if (suppressTitleSaveEffectsRef.current) return; - await api.putPageContent(page.id, { - ydoc_state: current.ydoc_state, - content_text: current.content_text ?? undefined, - expected_version: current.version, - title: nextTitle, - }); - } else { - await updatePageMutation.mutateAsync({ - pageId: page.id, - updates: { title: nextTitle }, - }); - } + // Issue #889 Phase 4: 旧来の `getPageContent` + `putPageContent` 経由のタイトル + // 更新は撤廃し、Y.Doc バイト列を介さない `PUT /api/pages/:id` (metadata only) + // に一本化する。`local` モード廃止に伴い、すべてのページは note 配下にあり + // `updatePageMetadata` が単一の正規 REST 経路。 + // Issue #889 Phase 4: drop the legacy `getPageContent` + `putPageContent` + // round-trip and rename via `PUT /api/pages/:id` (metadata only). Every + // page now belongs to a note, so `updatePageMetadata` is the single + // canonical REST entry point for title saves. + await api.updatePageMetadata(page.id, { title: nextTitle }); // 削除完了後はキャッシュ無効化も lastSaved 更新もスキップする。 // After cancel (delete path), skip cache invalidation and lastSaved update — // `useRemovePageFromNote` has already invalidated note caches. if (suppressTitleSaveEffectsRef.current) return; lastSavedTitleRef.current = nextTitle; - // `useUpdatePage` updates `pageKeys.*` caches, but the note page list and - // detail are held under `noteKeys.*`. Invalidate those so the new title - // propagates to the note view and sidebar. - // `useUpdatePage` は `pageKeys.*` を更新するが、ノート側のキャッシュは - // `noteKeys.*` にあるため、タイトル変更をノート表示やサイドバーに反映 - // させるには明示的に無効化する必要がある。 + // ノート側のキャッシュは `noteKeys.*` 配下にあるため、タイトル変更を + // ノート表示やサイドバーに反映させるために明示的に無効化する。 + // The note view and sidebar pull from `noteKeys.*`, so invalidate those + // explicitly to propagate the rename. queryClient.invalidateQueries({ queryKey: noteKeys.page(noteId, page.id) }); queryClient.invalidateQueries({ queryKey: noteKeys.detailsByNoteId(noteId) }); } catch (error) { @@ -812,7 +802,7 @@ const NotePageView: React.FC = () => { // delete-success navigation. The editable child writes its own cancel // function into this ref while mounted (cleared on unmount); the parent // invokes it inside `onSuccess` so the about-to-unmount cleanup no longer - // fires a `putPageContent` against the page we just removed. Codex P2 + // fires a `updatePageMetadata` against the page we just removed. Codex P2 // review on PR #891. const cancelPendingTitleSaveRef = useRef<(() => void) | null>(null); @@ -873,7 +863,7 @@ const NotePageView: React.FC = () => { setDeleteConfirmOpen(false); // navigate でアンマウントされる前に、編集中の保留タイトル保存を破棄する。 // 既存の cleanup が pending を flush すると、消したばかりのページに - // `putPageContent` が飛んで保存失敗トーストが出てしまう (Codex P2)。 + // `updatePageMetadata` が飛んで保存失敗トーストが出てしまう (Codex P2)。 // // Cancel any debounced title save before navigation so the editable // child's unmount cleanup does not flush against the just-removed