diff --git a/bun.lock b/bun.lock index b200265d..198ca3cb 100644 --- a/bun.lock +++ b/bun.lock @@ -116,7 +116,6 @@ "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", "tesseract.js": "^7.0.0", - "uuid": "^14.0.0", "vaul": "^1.1.2", "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.5.0", @@ -147,7 +146,6 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/sql.js": "^1.4.9", - "@types/uuid": "^11.0.0", "@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react-swc": "^4.2.3", "@vitest/coverage-v8": "^4.0.18", @@ -1446,8 +1444,6 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], - "@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="], - "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -3116,7 +3112,7 @@ "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], - "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -3384,8 +3380,6 @@ "@types/sql.js/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - "@types/uuid/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], - "@types/ws/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], "@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], @@ -3534,8 +3528,6 @@ "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], diff --git a/knip.json b/knip.json index d4b235d5..9260e55c 100644 --- a/knip.json +++ b/knip.json @@ -98,7 +98,6 @@ "aws-jwt-verify", "ioredis", "@types/sql.js", - "@types/uuid", "@vitejs/plugin-react", "wrangler", "@tauri-apps/plugin-shell", diff --git a/package.json b/package.json index 052143d9..03ced0a5 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,6 @@ "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", "tesseract.js": "^7.0.0", - "uuid": "^14.0.0", "vaul": "^1.1.2", "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.5.0", @@ -233,7 +232,6 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/sql.js": "^1.4.9", - "@types/uuid": "^11.0.0", "@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react-swc": "^4.2.3", "@vitest/coverage-v8": "^4.0.18", diff --git a/server/api/src/__tests__/routes/syncPages.test.ts b/server/api/src/__tests__/routes/syncPages.test.ts index b1a8492d..a1ee5f49 100644 --- a/server/api/src/__tests__/routes/syncPages.test.ts +++ b/server/api/src/__tests__/routes/syncPages.test.ts @@ -63,6 +63,47 @@ function createSyncApp(dbResults: unknown[]) { return { app, chains: mock.chains }; } +describe("GET /api/sync/pages — note_id / default_note_id in response (issue #1020)", () => { + it("returns note_id on each page row and default_note_id at the top level", async () => { + const now = new Date("2025-06-01T00:00:00Z"); + const { app } = createSyncApp([ + // 1: pages query + [ + { + id: OWNED_PAGE, + owner_id: TEST_USER_ID, + note_id: "sync-default-note-id", + title: "P", + content_preview: null, + thumbnail_url: null, + source_url: null, + source_page_id: null, + is_deleted: false, + created_at: now, + updated_at: now, + }, + ], + // 2: links query + [], + // 3: ghost_links query + [], + ]); + + const res = await app.request("/api/sync/pages", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + pages: Array<{ id: string; note_id: string }>; + default_note_id: string; + }; + expect(body.default_note_id).toBe("sync-default-note-id"); + expect(body.pages[0]?.note_id).toBe("sync-default-note-id"); + }); +}); + describe("GET /api/sync/pages — link_type in response (issue #725 Phase 1)", () => { it("returns link_type on each links row and ghost_links row", async () => { const now = new Date("2025-06-01T00:00:00Z"); @@ -72,6 +113,7 @@ describe("GET /api/sync/pages — link_type in response (issue #725 Phase 1)", ( { id: OWNED_PAGE, owner_id: TEST_USER_ID, + note_id: "sync-default-note-id", title: "P", content_preview: null, thumbnail_url: null, diff --git a/server/api/src/routes/syncPages.ts b/server/api/src/routes/syncPages.ts index 3844ed03..095948c7 100644 --- a/server/api/src/routes/syncPages.ts +++ b/server/api/src/routes/syncPages.ts @@ -56,6 +56,11 @@ app.get("/", authRequired, async (c) => { .select({ id: pages.id, owner_id: pages.ownerId, + // Issue #1020: クライアントが `noteId: null`(旧個人ページ)を生成し続け + // ないよう、各行の所属ノート ID をワイヤに載せる。 + // Issue #1020: carry the owning note id so clients stop materializing + // legacy `noteId: null` personal pages. + note_id: pages.noteId, title: pages.title, content_preview: pages.contentPreview, thumbnail_url: pages.thumbnailUrl, @@ -110,6 +115,11 @@ app.get("/", authRequired, async (c) => { original_target_page_id: g.originalTargetPageId, original_note_id: g.originalNoteId, })), + // Issue #1020: 既存クライアントの `noteId: null` 行をデフォルトノートへ + // 付け替えるための移行情報。pages が 0 件でも参照できるようトップレベルに置く。 + // Issue #1020: lets clients reassign legacy `noteId: null` rows to the + // default note even when the pull returns zero pages. + default_note_id: defaultNote.id, server_time: new Date().toISOString(), }); }); diff --git a/src/components/aiChat/AIChatWikiLink.tsx b/src/components/aiChat/AIChatWikiLink.tsx index 779feaea..4803b7f0 100644 --- a/src/components/aiChat/AIChatWikiLink.tsx +++ b/src/components/aiChat/AIChatWikiLink.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router-dom"; import { HoverCard, HoverCardTrigger, HoverCardContent } from "@zedi/ui"; import { useWikiLinkNavigation } from "@/components/editor/TiptapEditor/useWikiLinkNavigation"; import { CreatePageDialog } from "@/components/editor/TiptapEditor/CreatePageDialog"; -import { usePageStore } from "../../stores/pageStore"; +import { usePageByTitle, useGhostLinkReferenced } from "@/hooks/usePageQueries"; import { WikiLinkPreviewContent } from "../wikiLink/WikiLinkPreviewContent"; interface AIChatWikiLinkProps { @@ -27,10 +27,14 @@ const LONG_PRESS_MS = 500; */ export function AIChatWikiLink({ title }: AIChatWikiLinkProps) { const normalizedTitle = title.trim(); - const page = usePageStore((state) => state.getPageByTitle(normalizedTitle)); - const referenced = usePageStore( - (state) => !page && state.ghostLinks.some((gl) => gl.linkText === normalizedTitle), - ); + // 旧ゲストストア (`pageStore`) は Issue #1020 で廃止したため、リポジトリ + // (IndexedDB)ベースのクエリでページ解決とゴースト参照判定を行う。 + // The legacy guest store (`pageStore`) was retired by issue #1020; resolve + // the page and the ghost-reference state via the repository (IndexedDB). + const { data: resolvedPage } = usePageByTitle(normalizedTitle); + const page = resolvedPage ?? undefined; + const { data: ghostReferenced = false } = useGhostLinkReferenced(normalizedTitle); + const referenced = !page && ghostReferenced; const { handleLinkClick: navigateWikiLinkByTitle, diff --git a/src/components/aiChat/PromoteToWikiDialog.tsx b/src/components/aiChat/PromoteToWikiDialog.tsx index de0ce333..e525a1a1 100644 --- a/src/components/aiChat/PromoteToWikiDialog.tsx +++ b/src/components/aiChat/PromoteToWikiDialog.tsx @@ -245,17 +245,12 @@ function PromoteToWikiDialogBody({ ), ); - // Issue #889 Phase 3: 遷移先は `/notes/:noteId/:pageId` のため、`id` と - // `noteId` の両方が揃ったページのみ "successful" として扱う。`noteId` - // 欠落時は不正な URL を組み立ててしまうので作成失敗扱いにする。 + // Issue #889 Phase 3: 遷移先は `/notes/:noteId/:pageId`。`Page.noteId` は + // Issue #1020 以降 non-null なので、作成成功ページがあればそのまま遷移できる。 // Issue #889 Phase 3: the canonical landing route is - // `/notes/:noteId/:pageId`, so both `id` and `noteId` must be present. - // Treat a missing `noteId` as a failed creation rather than navigating - // to `/notes/undefined/...`. - const firstCreated = created.find( - (p): p is NonNullable & { noteId: string } => - p != null && Boolean(p.id) && Boolean(p.noteId), - ); + // `/notes/:noteId/:pageId`; `Page.noteId` is non-null since issue #1020, + // so any successfully created page can be navigated to directly. + const firstCreated = created.find((p): p is NonNullable => p != null); if (!firstCreated) throw new Error("no pages created"); const firstEntity = selectedEntities[created.indexOf(firstCreated)]; diff --git a/src/components/editor/TiptapEditor/WikiLinkHoverCardLayer.tsx b/src/components/editor/TiptapEditor/WikiLinkHoverCardLayer.tsx index 8358635d..a29bfb52 100644 --- a/src/components/editor/TiptapEditor/WikiLinkHoverCardLayer.tsx +++ b/src/components/editor/TiptapEditor/WikiLinkHoverCardLayer.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; import { createPortal } from "react-dom"; import type { Editor } from "@tiptap/core"; -import { usePageStore } from "@/stores/pageStore"; +import { usePageByTitle, useGhostLinkReferenced } from "@/hooks/usePageQueries"; import { WikiLinkPreviewContent } from "@/components/wikiLink/WikiLinkPreviewContent"; import { useWikiLinkHover } from "./useWikiLinkHover"; @@ -39,16 +39,15 @@ export const WikiLinkHoverCardLayer: React.FC = ({ const { target, isVisible, cardRef, closeCard, handleCardMouseEnter, handleCardMouseLeave } = useWikiLinkHover(editor, editorContainerRef); - const page = usePageStore((state) => { - if (!target) return undefined; - return state.getPageByTitle(target.title); - }); + // 旧ゲストストア (`pageStore`) は Issue #1020 で廃止したため、リポジトリ + // (IndexedDB)ベースのクエリでページ解決とゴースト参照判定を行う。 + // The legacy guest store (`pageStore`) was retired by issue #1020; resolve + // the page and the ghost-reference state via the repository (IndexedDB). + const { data: resolvedPage } = usePageByTitle(target?.title ?? ""); + const page = resolvedPage ?? undefined; - const referenced = usePageStore((state) => { - if (!target) return false; - const resolved = state.getPageByTitle(target.title); - return !resolved && state.ghostLinks.some((gl) => gl.linkText === target.title); - }); + const { data: ghostReferenced = false } = useGhostLinkReferenced(target?.title ?? ""); + const referenced = !page && ghostReferenced; const handleCardClick = useCallback(() => { if (!target) return; diff --git a/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts b/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts index c3acc71f..3a4d52ec 100644 --- a/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts +++ b/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts @@ -5,15 +5,16 @@ import { useNoteTitleIndex } from "@/hooks/useNoteQueries"; interface UseWikiLinkNavigationOptions { /** - * 編集中ページの noteId。`null` はレガシー個人ページ呼び出しの fallback で、 - * Issue #889 Phase 3 で `/pages/:id` ルートが廃止された後は、解決された - * `foundPage.noteId` を使って `/notes/:noteId/:pageId` に統合的に遷移する。 - * 通常はノート ID を渡す(Issue #713 Phase 4 / #889 Phase 3)。 + * 編集中ページの noteId。`null` はノートコンテキスト外(AI チャット等)からの + * 呼び出しを表し、ローカル(IndexedDB=デフォルトノート)のページ集合で解決 + * する。遷移は常に解決された `foundPage.noteId` を使って + * `/notes/:noteId/:pageId` に着地する(Issue #889 Phase 3 / #1020)。 * - * Owning note ID of the page being edited. `null` is kept for legacy - * personal-page callers, but Issue #889 Phase 3 retired `/pages/:id` so - * navigation always lands on `/notes/:noteId/:pageId` using the resolved - * `foundPage.noteId`. Callers normally pass the owning note id. + * Owning note ID of the page being edited. `null` means the caller has no + * note context (e.g. AI chat) and resolution runs against the local + * IndexedDB set (the default note). Navigation always lands on + * `/notes/:noteId/:pageId` via the resolved `foundPage.noteId` + * (issues #889 Phase 3 / #1020). */ pageNoteId: string | null; } @@ -44,11 +45,12 @@ interface UseWikiLinkNavigationReturn { * or shows a dialog to create a new page. * * WikiLink クリック時、`pageNoteId` に応じて候補スコープを切り替える。 - * - `pageNoteId === null` → 個人ページのみを検索し、`/pages/:id` に遷移。 + * - `pageNoteId === null` → ローカル(IndexedDB=デフォルトノート)の + * ページ集合を検索し、解決ページの noteId で `/notes/:noteId/:id` に遷移。 * - `pageNoteId !== null` → そのノート内のページのみを検索し、 * canonical ルート `/notes/:pageNoteId/:id` に遷移。 * - * Issue #713 Phase 4。 + * Issue #713 Phase 4 / #1020。 */ export function useWikiLinkNavigation( options: UseWikiLinkNavigationOptions = { pageNoteId: null }, diff --git a/src/components/layout/FloatingActionButton.tsx b/src/components/layout/FloatingActionButton.tsx index 49dd170b..6cbb61c6 100644 --- a/src/components/layout/FloatingActionButton.tsx +++ b/src/components/layout/FloatingActionButton.tsx @@ -23,11 +23,11 @@ import { useFloatingActionButtonHandlers } from "./useFloatingActionButtonHandle type FloatingActionButtonProps = { noteId?: string; /** - * 追加で非表示にするメニュー項目。未ログイン時の `url` 非表示ロジックは - * 内部で自動適用されるため、重ねて渡す必要はない。 + * 追加で非表示にするメニュー項目。未サインイン時は FAB 自体が描画されない + * (ページ作成はサインイン必須、Issue #1020)。 * - * Additional menu options to hide. The built-in `url` hide rule for guests is - * applied automatically, so callers don't need to re-specify it. + * Additional menu options to hide. For guests the FAB renders nothing at + * all — page creation requires sign-in (issue #1020). */ hiddenOptions?: FABMenuOption[]; } & ( @@ -75,12 +75,16 @@ const FloatingActionButton: React.FC = ({ noteId, }); - const mergedHidden: FABMenuOption[] = [ - ...(isSignedIn ? [] : (["url"] as FABMenuOption[])), - ...(extraHiddenOptions ?? []), - ]; const hiddenOptions: FABMenuOption[] | undefined = - mergedHidden.length > 0 ? mergedHidden : undefined; + extraHiddenOptions && extraHiddenOptions.length > 0 ? extraHiddenOptions : undefined; + + // ページ作成はサインイン必須(Issue #1020 でゲストのローカル作成を廃止)。 + // FAB のメニューは全てページ作成系のため、未サインイン時は FAB 自体を出さない。 + // Page creation requires sign-in (guest-local creation was retired by issue + // #1020). Every FAB menu option creates a page, so hide the FAB for guests. + if (!isSignedIn) { + return null; + } const fabButton = ( diff --git a/src/components/note/NotePagePublicView.tsx b/src/components/note/NotePagePublicView.tsx index f0dab23b..cb304536 100644 --- a/src/components/note/NotePagePublicView.tsx +++ b/src/components/note/NotePagePublicView.tsx @@ -139,7 +139,7 @@ export const NotePagePublicView: React.FC = ({ pageId, showToolbar={false} onContentChange={NOOP} onContentError={NOOP} - pageNoteId={page.noteId ?? null} + pageNoteId={page.noteId} /> ); }; diff --git a/src/components/page/LinkGroupRow.tsx b/src/components/page/LinkGroupRow.tsx index 947e2467..867d13f6 100644 --- a/src/components/page/LinkGroupRow.tsx +++ b/src/components/page/LinkGroupRow.tsx @@ -10,7 +10,7 @@ interface LinkGroupRowProps { * `/notes/:noteId/:pageId` 遷移用に noteId も渡す(Issue #889 Phase 3)。 * Passes `noteId` so the parent can build the `/notes/:noteId/:pageId` URL. */ - onPageClick: (pageId: string, noteId: string | null) => void; + onPageClick: (pageId: string, noteId: string) => void; } /** diff --git a/src/components/page/LinkSection.tsx b/src/components/page/LinkSection.tsx index f1a04ab2..1d8a4dd7 100644 --- a/src/components/page/LinkSection.tsx +++ b/src/components/page/LinkSection.tsx @@ -12,7 +12,7 @@ interface LinkSectionProps { * `/notes/:noteId/:pageId` requires both ids — pass the page's `noteId` to * the parent (Issue #889 Phase 3). */ - onPageClick: (pageId: string, noteId: string | null) => void; + onPageClick: (pageId: string, noteId: string) => void; } /** diff --git a/src/components/page/LinkedPagesSection.tsx b/src/components/page/LinkedPagesSection.tsx index b1e79bbd..bf99e0d5 100644 --- a/src/components/page/LinkedPagesSection.tsx +++ b/src/components/page/LinkedPagesSection.tsx @@ -135,7 +135,7 @@ export function LinkedPagesSection({ * Navigate to a linked page. `PageCard` carries `noteId`, so we can build * `/notes/:noteId/:pageId` directly (Issue #889 Phase 3). */ - const handlePageClick = (id: string, noteId: string | null) => { + const handlePageClick = (id: string, noteId: string) => { navigate(`/notes/${noteId}/${id}`); }; diff --git a/src/components/search/SearchResultCard.tsx b/src/components/search/SearchResultCard.tsx index 3c52257e..28ceff0f 100644 --- a/src/components/search/SearchResultCard.tsx +++ b/src/components/search/SearchResultCard.tsx @@ -38,13 +38,20 @@ export interface SearchResultCardPageItem extends SearchResultCardBase { kind: "page"; pageId: string; /** - * 所属ノート ID。`null` は個人ページ(`Boolean(noteId)` で shared/personal を - * 判定)。note-native ページは `/notes/:noteId/:pageId` へ遷移する(Issue #889 - * Phase 3)。`Page.noteId` の暫定 `string | null` を反映。 - * Owning note id; `null` is a personal page (shared vs personal is decided by - * `Boolean(noteId)`). Mirrors the interim `string | null` on `Page.noteId`. + * 所属ノート ID。`/notes/:noteId/:pageId` 遷移用(Issue #889 Phase 3)。 + * Issue #1020 以降は常に非 null。 + * Owning note id used for `/notes/:noteId/:pageId` routing (Issue #889 + * Phase 3). Always non-null since issue #1020. */ - noteId: string | null; + noteId: string; + /** + * 共有検索(参加ノート横断の API 検索)由来の行か。「共有」バッジの表示に使う。 + * `noteId` の有無では判定できない(Issue #1020 で全ページが noteId を持つため)。 + * Whether the row came from the shared (cross-note API) search; drives the + * "共有" badge. Cannot be derived from `noteId` anymore — every page carries + * one since issue #1020. + */ + isShared: boolean; sourceUrl?: string; } @@ -101,7 +108,7 @@ export function SearchResultCard({ item, onClick }: SearchResultCardProps) { ); const isPdf = item.kind === "pdf_highlight"; - const isShared = item.kind === "page" && Boolean(item.noteId); + const isShared = item.kind === "page" && item.isShared; const hasSourceUrl = item.kind === "page" && Boolean(item.sourceUrl); return ( diff --git a/src/components/wikiLink/WikiLinkPreviewContent.test.tsx b/src/components/wikiLink/WikiLinkPreviewContent.test.tsx index f696b47e..9a3c67af 100644 --- a/src/components/wikiLink/WikiLinkPreviewContent.test.tsx +++ b/src/components/wikiLink/WikiLinkPreviewContent.test.tsx @@ -14,7 +14,7 @@ function renderWithI18n(ui: React.ReactElement) { const createMockPage = (overrides?: Partial): Page => ({ id: "page-1", ownerUserId: "user-1", - noteId: null, + noteId: "note-default", title: "テストページ", content: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"これはテストのプレビューです"}]}]}', diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index 19af1f93..13b69d9b 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -43,26 +43,17 @@ type SharedResultRow = SearchResultRow; /** * Issue #718 Phase 5-4 の dedup 契約を一箇所に集約するヘルパー。 * - * `scope=shared` レスポンスは以下の 3 種類のページを返す - * (`server/api/src/routes/search.ts`): + * `scope=shared` レスポンス (`server/api/src/routes/search.ts`) には、呼び出し元の + * デフォルトノート配下のページ(IDB が保持し `useSearchPages` でも出る)と、 + * 参加ノートのページの両方が混ざる。ここでは「`useSearchPages` で既に出ている + * page id」を集合で受け取り、それと一致する shared 行だけを落とす。`note_id` + * では判定しない(呼び出し元のデフォルトノート ID をこのヘルパーは知らない)。 * - * 1. 呼び出し元自身の個人ページ (`owner_id = me AND note_id IS NULL`) - * 2. ノートメンバーシップ / オーナーシップ経由で見えるリンク済み個人ページ - * (`note_pages` 経由、他ユーザー所有の `note_id IS NULL` ページも含み得る) - * 3. ノートネイティブページ (`note_id IS NOT NULL`) - * - * IDB は (1) しか持たないので、ここでは「`useSearchPages` で既に出ている page - * id」を集合で受け取り、それと一致する shared 行だけを落とす。`note_id` の - * null/non-null では判定しない (それだと (2) のリンク済み個人ページが脱落する。 - * Codex 指摘)。 - * - * Centralizes the Phase 5-4 dedup contract. `scope=shared` returns three kinds - * of rows: (1) the caller's own personal pages, (2) linked personal pages - * visible through note membership or ownership (these may belong to other - * users and can have `note_id IS NULL`), and (3) note-native pages. IDB only - * holds (1), so we dedup against the personal page id set instead of using - * `note_id` as a proxy — otherwise (2) would silently disappear (Codex - * review). + * Centralizes the Phase 5-4 dedup contract. `scope=shared` returns both pages + * under the caller's default note (also surfaced by the IDB-backed + * `useSearchPages`) and pages from notes the caller participates in. We dedup + * against the local page id set instead of `note_id` — this helper has no + * knowledge of the caller's default note id. * * Issue #864: PDF ハイライト行 (`kind="pdf_highlight"`) は別エンティティで個人 * ページとは重複しないため、`page` 種別だけを dedup の対象とする。 @@ -113,17 +104,13 @@ export interface GlobalSearchPageResultItem extends GlobalSearchResultBase { kind: "page"; pageId: string; /** - * 所属ノート ID。`null` は個人ページ(`Page.noteId` の暫定 `string | null` を - * 反映)。note-native ページでは `/notes/:noteId/:pageId` の遷移先に使う - * (Issue #889 Phase 3 で `/pages/:id` を廃止)。null 個人ページの遷移は - * 個人ページ概念の根絶エピックで解消する。 + * 所属ノート ID。`/notes/:noteId/:pageId` の遷移先に使う(Issue #889 Phase 3 + * で `/pages/:id` を廃止)。Issue #1020 以降は常に非 null。 * - * Owning note id. `null` is a personal page (mirrors the interim - * `string | null` on `Page.noteId`). For note-native pages it builds the - * `/notes/:noteId/:pageId` target (Issue #889 Phase 3 retired `/pages/:id`). - * Navigating null personal pages is addressed by the personal-page removal epic. + * Owning note id; builds the `/notes/:noteId/:pageId` target (Issue #889 + * Phase 3 retired `/pages/:id`). Always non-null since issue #1020. */ - noteId: string | null; + noteId: string; sourceUrl?: string; } @@ -291,15 +278,11 @@ export function buildPdfHighlightItem( * Exported so the dedup behavior can be tested without spinning up React * Query (Issue #718 Phase 5-4). * - * **Dedup contract**: dedup is by `pageId` against the personal id set — - * see {@link dedupSharedRowsAgainstPersonal}. We can't filter by `note_id` - * alone because the server's `scope=shared` SQL returns linked personal - * pages (other users' `note_id IS NULL` pages reachable via `note_pages`) - * that are NOT covered by IDB. + * **Dedup contract**: dedup is by `pageId` against the local (IDB) result id + * set — see {@link dedupSharedRowsAgainstPersonal}. * * **重複排除の契約**: dedup は `pageId` 一致でのみ行う - * ({@link dedupSharedRowsAgainstPersonal})。`note_id IS NULL` の中には IDB に - * 載っていないリンク済み個人ページが混ざるので、`note_id` での絞り込みは不可。 + * ({@link dedupSharedRowsAgainstPersonal})。 * * Issue #864: shared 結果には `kind="pdf_highlight"` 行も混ざる。dedup は * `kind="page"` のみに作用し、ハイライトはスコア順序を保ったまま末尾に並ぶ。 diff --git a/src/hooks/useLinkedPages.ts b/src/hooks/useLinkedPages.ts index 12979edb..bf9cc17f 100644 --- a/src/hooks/useLinkedPages.ts +++ b/src/hooks/useLinkedPages.ts @@ -19,9 +19,7 @@ import type { PagePublicLinksResponse, PagePublicLinkCard } from "@/lib/api/type */ export interface PageCard { id: string; - // `null` は個人ページ(`Page.noteId` の暫定 `string | null` を反映)。 - // `null` is a personal page, mirroring the interim `string | null` on `Page.noteId`. - noteId: string | null; + noteId: string; title: string; preview: string; // Content preview (50 chars) updatedAt: number; diff --git a/src/hooks/usePageQueries.ts b/src/hooks/usePageQueries.ts index 88e914a7..d1cb2498 100644 --- a/src/hooks/usePageQueries.ts +++ b/src/hooks/usePageQueries.ts @@ -43,6 +43,8 @@ export const pageKeys = { byTitle: (userId: string, title: string) => [...pageKeys.byTitles(userId), title.trim()] as const, search: (userId: string, query: string) => [...pageKeys.all, "search", userId, query] as const, searchShared: (query: string) => [...pageKeys.all, "searchShared", query] as const, + ghostReferenced: (userId: string, linkText: string) => + [...pageKeys.all, "ghostReferenced", userId, linkText] as const, }; /** @@ -271,27 +273,22 @@ export function usePage(pageId: string, options?: UsePageOptions) { } /** - * 個人スコープ専用のページ検索フック(IndexedDB 経由)。 + * ローカル(IndexedDB)ページ検索フック。 * - * **スコープ契約 (Issue #718 Phase 5-4)**: - * - 返すのは `noteId === null` の個人ページのみ。 - * - 実装は `IndexedDBStorageAdapter.searchPages` に委ねており、IDB には個人 - * ページしか永続化されない(`getAllPages` も `noteId === null` で防御的に - * フィルタしている)。ノートネイティブページは API 経由で取得する。 - * - ノート配下の検索が必要な場合は `useSearchSharedNotes`(混在)か、将来 - * 実装される note-scoped 検索フック(Phase 5-2 の - * `GET /api/notes/:noteId/search` を呼ぶ)を使うこと。 + * **スコープ契約 (Issue #718 Phase 5-4 / #1020)**: + * - IndexedDB は呼び出し元のデフォルトノート配下のページのみを保持するため、 + * 返るのはデフォルトノートのページに限られる。 + * - 他ノートの検索は `useSearchSharedNotes`(参加ノート横断)か + * `GET /api/notes/:noteId/search`(ノートスコープ)を使うこと。 * - * Personal-scope-only page search (via IndexedDB). + * Local (IndexedDB) page search. * - * **Scope contract (Issue #718 Phase 5-4)**: returns only personal pages - * (`noteId === null`). The implementation delegates to - * `IndexedDBStorageAdapter.searchPages`, which only ever holds personal pages. - * For note-native results, callers must reach for `useSearchSharedNotes` (mixed - * scope) or a future note-scoped hook backed by Phase 5-2's + * **Scope contract (Issues #718 Phase 5-4 / #1020)**: IndexedDB only holds + * pages under the caller's default note, so results are limited to that note. + * For other notes, use `useSearchSharedNotes` (cross-note) or the note-scoped * `GET /api/notes/:noteId/search` endpoint. * - * @returns React Query result whose `data` is `Page[]` of personal pages. + * @returns React Query result whose `data` is `Page[]` of default-note pages. */ export function useSearchPages(query: string) { const { getRepository, userId, isLoaded } = useRepository(); @@ -318,24 +315,19 @@ export function useSearchPages(query: string) { } /** - * 混在スコープのページ検索フック(API: `GET /api/search?q=&scope=shared`)。 + * 参加ノート横断のページ検索フック(API: `GET /api/search?q=&scope=shared`)。 * C3-8 / Issue #718 Phase 5-4。 * - * **スコープ挙動**: - * - サーバーは個人ページ (`note_id IS NULL`) と、自分が参加するノートの - * ネイティブページの両方を返す(Phase 5-1 で `scope=own` の方は - * `note_id IS NULL` の防御フィルタを追加済みだが、`shared` は意図的に - * 混在のまま)。 - * - 各行は `note_id: string | null` を含むので、呼び出し側は必要に応じて - * 個人 / ノートネイティブを判別できる(`useGlobalSearch` / - * `SearchResults` は個人ページ重複を避けるためここでフィルタしている)。 + * サーバーは呼び出し元のデフォルトノートを含む、参加している全ノートの + * ページを返す。各行は `note_id`(non-null)を持つ。`useSearchPages` + * (ローカル)との重複は `useGlobalSearch` / `SearchResults` 側で page id + * ベースの dedup を行う。 * - * Mixed-scope search hook (`GET /api/search?q=&scope=shared`). - * - * The server returns both personal pages and note-native pages from notes the - * caller participates in. Each row carries `note_id: string | null` so callers - * can branch on it (`useGlobalSearch` / `SearchResults` filter to note-native - * rows so personal pages from `useSearchPages` are not double-counted). + * Cross-note search hook (`GET /api/search?q=&scope=shared`). The server + * returns pages from every note the caller participates in, including the + * default note; each row carries a non-null `note_id`. Overlap with the + * local `useSearchPages` results is deduped by page id in `useGlobalSearch` + * / `SearchResults`. */ export function useSearchSharedNotes(query: string) { const { getToken, isSignedIn } = useAuth(); @@ -412,8 +404,6 @@ export function useCreatePage() { const newSummary: PageSummary = { id: newPage.id, ownerUserId: newPage.ownerUserId, - // useCreatePage は個人ページ作成しか経由しないので常に `null`。Issue #713。 - // useCreatePage only creates personal pages, so noteId is always null. noteId: newPage.noteId, title: newPage.title, contentPreview: newPage.contentPreview, @@ -677,6 +667,31 @@ export function useCheckGhostLinkReferenced() { return { checkReferenced }; } +/** + * 指定リンクテキストが ghost_links(WikiLink バケット)に登場するかを返す + * リアクティブ版フック。WikiLink ホバープレビューの「未作成(参照あり)」 + * 表示に使う。旧ゲストストア (`pageStore`) の ghostLinks 参照を置き換える + * (Issue #1020)。 + * + * Reactive variant of the ghost-link reference check, used by WikiLink hover + * previews to render the "not created (with refs)" state. Replaces the + * retired guest store's (`pageStore`) ghostLinks lookup (issue #1020). + */ +export function useGhostLinkReferenced(linkText: string) { + const { getRepository, userId, isLoaded } = useRepository(); + const normalized = linkText.trim(); + + return useQuery({ + queryKey: pageKeys.ghostReferenced(userId, normalized), + queryFn: async (): Promise => { + const repo = await getRepository(); + const sources = await repo.getGhostLinkSources(normalized, "wiki"); + return sources.length > 0; + }, + enabled: isLoaded && normalized.length > 0, + }); +} + /** * Hook to check for duplicate page titles */ @@ -733,18 +748,19 @@ export function usePromoteGhostLink() { } /** - * `useSyncWikiLinks` のオプション。WikiLink 同期のスコープを個人ページと - * ノートネイティブページで切り替える(Issue #713 Phase 4)。 + * `useSyncWikiLinks` のオプション。WikiLink 同期のスコープを切り替える + * (Issue #713 Phase 4 / #1020)。 * - * - `pageNoteId === null` / 省略: 個人スコープ。`repo.getPagesSummary()` - * が返す個人ページのみを解決候補にする。 + * - `pageNoteId === null` / 省略: ローカルスコープ。`repo.getPagesSummary()` + * が返すローカル(IndexedDB=デフォルトノート)のページのみを解決候補にする。 * - `pageNoteId !== null`: ノートスコープ。呼び出し側は同じノートに所属する * ページ一覧(`useNotePages` で取得)を `notePages` に渡す。 * - * Options for {@link useSyncWikiLinks}. Switches sync scope between personal - * and note-native pages. When `pageNoteId` is set, callers must supply - * `notePages` (typically from `useNotePages`) because the repository does - * not hold note-native page summaries locally. See issue #713 Phase 4. + * Options for {@link useSyncWikiLinks}. Switches sync scope between the local + * (IndexedDB = default note) page set and an explicit note. When `pageNoteId` + * is set, callers must supply `notePages` (typically from `useNotePages`) + * because the repository does not hold other notes' page summaries locally. + * See issues #713 Phase 4 / #1020. */ export type UseSyncWikiLinksOptions = { pageNoteId?: string | null; @@ -815,26 +831,28 @@ export function useSyncWikiLinks(options: UseSyncWikiLinksOptions = {}) { /** * `useWikiLinkExistsChecker` のオプション。WikiLink の解決スコープを - * 個人ページとノートネイティブページで切り替える(Issue #713 Phase 4)。 + * 切り替える(Issue #713 Phase 4 / #1020)。 * * Options for {@link useWikiLinkExistsChecker}. Switches WikiLink resolution - * scope between personal pages and note-native pages. See issue #713 Phase 4. + * scope between the local (IndexedDB = default note) page set and an + * explicit note. See issues #713 Phase 4 / #1020. */ export type UseWikiLinkExistsCheckerOptions = { /** - * 編集中ページの noteId。`null`(既定)は個人ページ、文字列は - * ノートネイティブページ。 + * 編集中ページの noteId。`null`(既定)はノートコンテキスト外で、ローカル + * (IndexedDB=デフォルトノート)スコープ。文字列はそのノートのスコープ。 * - * Owning note ID. `null` (default) → personal scope; string → note scope. + * Owning note ID. `null` (default) → local (default-note) scope; string → + * note scope. */ pageNoteId?: string | null; /** * `pageNoteId !== null` のときに使う候補ページ一覧。IndexedDB には - * ノートネイティブページが載らないため、API 経由で取得したノート配下の + * 他ノートのページが載らないため、API 経由で取得したノート配下の * ページ一覧を呼び出し側が渡す。 * * Candidate pages used when `pageNoteId` is a string. IndexedDB does not - * hold note-native pages, so callers must supply the note's page list + * hold other notes' pages, so callers must supply the note's page list * (typically from `useNotePages`). */ notePages?: Array>; diff --git a/src/hooks/useSyncWikiLinks.test.ts b/src/hooks/useSyncWikiLinks.test.ts index 35889622..0856a867 100644 --- a/src/hooks/useSyncWikiLinks.test.ts +++ b/src/hooks/useSyncWikiLinks.test.ts @@ -47,7 +47,7 @@ describe("syncLinksWithRepo", () => { { id: "page-a", ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "Page A", contentPreview: undefined, thumbnailUrl: undefined, @@ -93,7 +93,7 @@ describe("syncLinksWithRepo", () => { { id: "page-a", ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "Page A", contentPreview: undefined, thumbnailUrl: undefined, @@ -142,7 +142,7 @@ describe("syncLinksWithRepo", () => { { id: "page-a", ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "Page A", contentPreview: undefined, thumbnailUrl: undefined, @@ -154,7 +154,7 @@ describe("syncLinksWithRepo", () => { { id: "page-b", ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "Page B", contentPreview: undefined, thumbnailUrl: undefined, @@ -190,7 +190,7 @@ describe("syncLinksWithRepo", () => { { id: "page-a", ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "Page A", contentPreview: undefined, thumbnailUrl: undefined, @@ -227,7 +227,7 @@ describe("syncLinksWithRepo", () => { { id: sourcePageId, ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "My Page", contentPreview: undefined, thumbnailUrl: undefined, @@ -269,7 +269,7 @@ describe("syncLinksWithRepo", () => { { id: "personal-a", ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "Personal A", contentPreview: undefined, thumbnailUrl: undefined, @@ -397,7 +397,7 @@ describe("syncLinksWithRepo", () => { { id: "tag-target", ownerUserId: userId, - noteId: null, + noteId: "note-default", title: "Foo", contentPreview: undefined, thumbnailUrl: undefined, diff --git a/src/hooks/useWikiLinkCandidates.test.ts b/src/hooks/useWikiLinkCandidates.test.ts index 77275504..9bf5a059 100644 --- a/src/hooks/useWikiLinkCandidates.test.ts +++ b/src/hooks/useWikiLinkCandidates.test.ts @@ -29,7 +29,7 @@ function makePersonalSummary(overrides: Partial = {}): PageSummary return { id: overrides.id ?? "p-1", ownerUserId: overrides.ownerUserId ?? "user-1", - noteId: null, + noteId: "note-default", title: overrides.title ?? "Personal", contentPreview: undefined, thumbnailUrl: undefined, diff --git a/src/hooks/useWikiLinkCandidates.ts b/src/hooks/useWikiLinkCandidates.ts index a0f9fcb7..2bd66319 100644 --- a/src/hooks/useWikiLinkCandidates.ts +++ b/src/hooks/useWikiLinkCandidates.ts @@ -7,11 +7,10 @@ import type { PageSummary } from "@/types/page"; * WikiLink のサジェスト・タイトル検索に使う候補ページのスコープ。 * Scope of candidate pages used by WikiLink suggestion / title lookups. * - * - `pageNoteId === null` → 個人ページのみ(`note_id IS NULL`) + * - `pageNoteId === null` → ローカル(IndexedDB=デフォルトノート)のページのみ * - `pageNoteId !== null` → そのノートに所属するページのみ * - * ノートを跨いだ参照、ノート↔個人を跨いだ参照は v1 では非対応 - * (Issue #713 Phase 4)。 + * ノートを跨いだ参照は v1 では非対応(Issue #713 Phase 4 / #1020)。 */ export interface WikiLinkCandidatesResult { pages: Array>; @@ -19,21 +18,21 @@ export interface WikiLinkCandidatesResult { } /** - * 現在編集中のページ所属(個人 or ノート)に基づく WikiLink 候補を返す。 + * 現在編集中のページ所属に基づく WikiLink 候補を返す。 * 呼び出し側は `WikiLinkSuggestion` や `getPageByTitle` などスコープを尊重 * したい処理で利用する。 * * Returns WikiLink candidate pages scoped to the current editor context - * (personal pages when `pageNoteId === null`, same-note pages otherwise). - * See issue #713 Phase 4. + * (the local default-note set when `pageNoteId === null`, same-note pages + * otherwise). See issues #713 Phase 4 / #1020. */ export function useWikiLinkCandidates( pageNoteId: string | null | undefined, ): WikiLinkCandidatesResult { const noteId = pageNoteId ?? null; - // ノートスコープでは個人ページを取りに行かない(IndexedDB への不要な + // ノートスコープではローカル(IndexedDB)ページを取りに行かない(不要な // アクセスを避ける)。`enabled` は react-query で queryFn を抑止する。 - // In note scope, skip the personal pages lookup to avoid unnecessary + // In note scope, skip the local pages lookup to avoid unnecessary // IndexedDB access; `enabled` suppresses the react-query queryFn. const personal = usePagesSummary({ enabled: noteId === null }); // issue #860 Phase 6: ノートスコープではタイトル文字列だけ使うため、 diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 6a3e8171..d92127a7 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -8,6 +8,13 @@ export interface SyncPagesResponse { pages: SyncPageItem[]; links: SyncLinkItem[]; ghost_links: SyncGhostLinkItem[]; + /** + * 呼び出し元のデフォルトノート ID。旧クライアントが残した `noteId: null` + * 行をローカルで付け替えるために使う(Issue #1020)。 + * The caller's default note id; used to reassign legacy `noteId: null` + * rows left behind by older clients (issue #1020). + */ + default_note_id: string; server_time: string; } diff --git a/src/lib/dateUtils.test.ts b/src/lib/dateUtils.test.ts index 32b9aab1..98f8c3ab 100644 --- a/src/lib/dateUtils.test.ts +++ b/src/lib/dateUtils.test.ts @@ -10,7 +10,7 @@ function makePage(overrides: Partial = {}): Page { return { id: overrides.id ?? "p1", ownerUserId: "u1", - noteId: null, + noteId: "note-default", title: overrides.title ?? "Test", content: "{}", createdAt: overrides.createdAt ?? Date.now(), diff --git a/src/lib/pageRepository.test.ts b/src/lib/pageRepository.test.ts index cd444ccc..f9770bec 100644 --- a/src/lib/pageRepository.test.ts +++ b/src/lib/pageRepository.test.ts @@ -51,7 +51,7 @@ function createInMemoryRepository( const page: Page = { id, ownerUserId: userId, - noteId: null, + noteId: "note-default", title, content, thumbnailUrl: opts?.thumbnailUrl ?? undefined, @@ -432,6 +432,7 @@ function createMockAdapter(): StorageAdapter { getPage: vi.fn().mockResolvedValue(null), upsertPage: vi.fn().mockResolvedValue(undefined), deletePage: vi.fn().mockResolvedValue(undefined), + reassignNullNotePages: vi.fn().mockResolvedValue(undefined), getYDocState: vi.fn().mockResolvedValue(null), saveYDocState: vi.fn().mockResolvedValue(undefined), getYDocVersion: vi.fn().mockResolvedValue(0), @@ -470,12 +471,23 @@ describe("StorageAdapterPageRepository (production) satisfies IPageRepository", repo = new StorageAdapterPageRepository(adapter, api as ApiClient); }); - it("delegates createPage (guest) to adapter.upsertPage and skips api.createPage", async () => { + it("delegates createPage to api.createPage + adapter.upsertPage (guest-local creation retired by issue #1020)", async () => { + (api.createPage as ReturnType).mockResolvedValue({ + id: "api-page-1", + owner_id: "auth-user", + note_id: "note-default", + title: "Hello", + content_preview: null, + source_page_id: null, + thumbnail_url: null, + source_url: null, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + is_deleted: false, + }); await repo.createPage("local-user", "Hello"); + expect(api.createPage).toHaveBeenCalledOnce(); expect(adapter.upsertPage).toHaveBeenCalledOnce(); - // CodeRabbit のレビュー対応: ゲスト経路で API が呼ばれない不変条件をガード。 - // Guard the guest-path invariant: API must not be invoked for `local-user`. - expect(api.createPage).not.toHaveBeenCalled(); }); it("delegates getPage to adapter.getPage", async () => { diff --git a/src/lib/pageRepository/StorageAdapterPageRepository.test.ts b/src/lib/pageRepository/StorageAdapterPageRepository.test.ts index 114215be..b9807c14 100644 --- a/src/lib/pageRepository/StorageAdapterPageRepository.test.ts +++ b/src/lib/pageRepository/StorageAdapterPageRepository.test.ts @@ -3,7 +3,6 @@ import { StorageAdapterPageRepository } from "./StorageAdapterPageRepository"; import type { StorageAdapter } from "@/lib/storageAdapter/StorageAdapter"; import type { ApiClient } from "@/lib/api/apiClient"; import type { PageMetadata, Link } from "@/lib/storageAdapter/types"; -import type { SyncPageItem } from "@/lib/api/types"; vi.mock("@/lib/contentUtils", () => ({ getPageListPreview: vi.fn((content: string) => (content ? content.slice(0, 50) : "")), @@ -16,6 +15,7 @@ function createMockAdapter(): StorageAdapter { getPage: vi.fn().mockResolvedValue(null), upsertPage: vi.fn().mockResolvedValue(undefined), deletePage: vi.fn().mockResolvedValue(undefined), + reassignNullNotePages: vi.fn().mockResolvedValue(undefined), getYDocState: vi.fn().mockResolvedValue(null), saveYDocState: vi.fn().mockResolvedValue(undefined), getYDocVersion: vi.fn().mockResolvedValue(0), @@ -59,6 +59,7 @@ function createMockApi(): ApiClient { } const LOCAL_USER_ID = "local-user"; +const DEFAULT_NOTE_ID = "default-note-1"; const AUTH_USER_ID = "auth-user-123"; describe("StorageAdapterPageRepository", () => { @@ -73,14 +74,28 @@ describe("StorageAdapterPageRepository", () => { }); describe("createPage", () => { - it("creates page locally without API call for local user", async () => { - const page = await repo.createPage(LOCAL_USER_ID, "Test Title", ""); + it("always creates via the API, even for the local user (guest-local creation retired by issue #1020)", async () => { + const now = new Date().toISOString(); + (api.createPage as ReturnType).mockResolvedValue({ + id: "api-page-1", + owner_id: AUTH_USER_ID, + note_id: DEFAULT_NOTE_ID, + title: "API Page", + content_preview: "preview", + source_page_id: null, + thumbnail_url: null, + source_url: null, + created_at: now, + updated_at: now, + is_deleted: false, + }); + + const page = await repo.createPage(LOCAL_USER_ID, "API Page", "body"); + expect(api.createPage).toHaveBeenCalledOnce(); expect(adapter.upsertPage).toHaveBeenCalledOnce(); - expect(api.createPage).not.toHaveBeenCalled(); - expect(page.title).toBe("Test Title"); - expect(page.ownerUserId).toBe(LOCAL_USER_ID); - expect(page.id).toBeTruthy(); + expect(page.id).toBe("api-page-1"); + expect(page.noteId).toBe(DEFAULT_NOTE_ID); }); it("calls API and stores in adapter for authenticated user", async () => { @@ -88,6 +103,7 @@ describe("StorageAdapterPageRepository", () => { (api.createPage as ReturnType).mockResolvedValue({ id: "api-page-1", owner_id: AUTH_USER_ID, + note_id: DEFAULT_NOTE_ID, title: "API Page", content_preview: "preview", source_page_id: null, @@ -113,7 +129,7 @@ describe("StorageAdapterPageRepository", () => { const meta: PageMetadata = { id: "page-1", ownerId: LOCAL_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Hello", contentPreview: "preview", @@ -145,7 +161,7 @@ describe("StorageAdapterPageRepository", () => { { id: "p1", ownerId: LOCAL_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "A", contentPreview: null, @@ -158,7 +174,7 @@ describe("StorageAdapterPageRepository", () => { { id: "p2", ownerId: LOCAL_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "B", contentPreview: null, @@ -183,7 +199,7 @@ describe("StorageAdapterPageRepository", () => { const existing: PageMetadata = { id: "page-1", ownerId: LOCAL_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Old Title", contentPreview: null, @@ -328,7 +344,7 @@ describe("StorageAdapterPageRepository", () => { { id: "p1", ownerId: LOCAL_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Unique Title", contentPreview: null, @@ -341,7 +357,7 @@ describe("StorageAdapterPageRepository", () => { { id: "p2", ownerId: LOCAL_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Another Title", contentPreview: null, @@ -373,28 +389,22 @@ describe("StorageAdapterPageRepository", () => { }); }); - describe("noteId passthrough (issue #713 Phase 2)", () => { - // 個人ページ作成 (`createPage`) は常に `noteId = null` のメタデータを書き、 - // 取得系は adapter から渡された `noteId` をそのまま `Page` / `PageSummary` - // に伝播させる。ノートネイティブページは `note-native` の値を保つ。 + describe("noteId passthrough (issues #713 / #1020)", () => { + // 作成系はサーバが返す `note_id`(呼び出し元のデフォルトノート)をそのまま + // 書き、取得系は adapter から渡された `noteId` をそのまま `Page` / + // `PageSummary` に伝播させる。 // - // `createPage` always writes personal-page metadata (`noteId = null`); read - // paths surface whatever `noteId` the adapter returns into `Page` / - // `PageSummary` unchanged so callers can scope behaviour without re-querying. - - it("createPage (local) writes noteId: null to the adapter", async () => { - await repo.createPage(LOCAL_USER_ID, "Personal", ""); - const upsertMock = adapter.upsertPage as ReturnType; - expect(upsertMock).toHaveBeenCalledOnce(); - const stored = upsertMock.mock.calls[0][0] as PageMetadata; - expect(stored.noteId).toBeNull(); - }); + // Creation persists the server-returned `note_id` (the caller's default + // note); read paths surface whatever `noteId` the adapter returns into + // `Page` / `PageSummary` unchanged so callers can scope behaviour without + // re-querying. - it("createPage (remote) defaults noteId to null when API omits it", async () => { + it("createPage persists the API note_id to the adapter", async () => { const now = new Date().toISOString(); (api.createPage as ReturnType).mockResolvedValue({ id: "api-page-1", owner_id: AUTH_USER_ID, + note_id: DEFAULT_NOTE_ID, title: "API Page", content_preview: null, source_page_id: null, @@ -405,19 +415,19 @@ describe("StorageAdapterPageRepository", () => { is_deleted: false, }); const page = await repo.createPage(AUTH_USER_ID, "API Page"); - expect(page.noteId).toBeNull(); + expect(page.noteId).toBe(DEFAULT_NOTE_ID); const upsertMock = adapter.upsertPage as ReturnType; const stored = upsertMock.mock.calls[0][0] as PageMetadata; - expect(stored.noteId).toBeNull(); + expect(stored.noteId).toBe(DEFAULT_NOTE_ID); }); it("getPage / getPagesSummary forward adapter noteId verbatim", async () => { - const personal: PageMetadata = { + const defaultNotePage: PageMetadata = { id: "p1", ownerId: LOCAL_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, - title: "Personal", + title: "Default-note page", contentPreview: null, thumbnailUrl: null, sourceUrl: null, @@ -444,85 +454,13 @@ describe("StorageAdapterPageRepository", () => { expect(fetched?.noteId).toBe("note-1"); (adapter.getAllPages as ReturnType).mockResolvedValueOnce([ - personal, + defaultNotePage, noteNative, ]); const summaries = await repo.getPagesSummary(LOCAL_USER_ID); const byId = new Map(summaries.map((s) => [s.id, s])); - expect(byId.get("p1")?.noteId).toBeNull(); + expect(byId.get("p1")?.noteId).toBe(DEFAULT_NOTE_ID); expect(byId.get("p2")?.noteId).toBe("note-1"); }); }); - - describe("importPersonalPageFromApi (issue #713 Phase 3, Codex P1)", () => { - // 「ノート → 個人に取り込み」で生まれた新ページをサーバーレスポンスから直接 IDB に - // 書き戻す経路をカバーする。こうすることで `/home` は次回 sync を待たずに表示可能。 - // `note_id` があるページ(ノートネイティブ)は個人 `/home` に属さないので拒否し、 - // 呼び出し側に `null` を返して誤書き込みを防ぐ。 - // - // Exercises the write-through path used after "copy to personal": the server - // response is persisted to IDB immediately so `/home` does not need to wait - // for the next sync pull. Rows with `note_id != null` are note-native and do - // not belong on personal `/home`; the helper returns `null` and skips the - // adapter write so we cannot accidentally leak them into the personal grid. - - it("writes a personal page (`note_id: null`) through to the adapter and returns it", async () => { - const nowIso = "2026-04-23T00:00:00.000Z"; - // Personal copies arrive from the API with note_id = null (SyncPageItem types - // it as string; importPersonalPageFromApi handles null at runtime). - const page = await repo.importPersonalPageFromApi({ - id: "copy-1", - owner_id: AUTH_USER_ID, - note_id: null, - source_page_id: "src-note-page", - title: "Copied Page", - content_preview: "preview", - thumbnail_url: null, - source_url: null, - created_at: nowIso, - updated_at: nowIso, - is_deleted: false, - } as unknown as SyncPageItem); - - expect(page).not.toBeNull(); - expect(page?.id).toBe("copy-1"); - expect(page?.noteId).toBeNull(); - - const upsertMock = adapter.upsertPage as ReturnType; - expect(upsertMock).toHaveBeenCalledOnce(); - const stored = upsertMock.mock.calls[0][0] as PageMetadata; - expect(stored).toMatchObject({ - id: "copy-1", - ownerId: AUTH_USER_ID, - noteId: null, - sourcePageId: "src-note-page", - title: "Copied Page", - isDeleted: false, - }); - }); - - it("returns null and skips the adapter write when the page is note-native", async () => { - // ノートネイティブページが誤って個人スコープの IDB に入らないことを保証する。 - // `/home` のフィルタは `note_id IS NULL` なので、ここを堅牢に弾くと二重防御になる。 - // Defensively reject note-native rows so they cannot slip into the - // personal grid (whose filter is `note_id IS NULL`). - const nowIso = "2026-04-23T00:00:00.000Z"; - const result = await repo.importPersonalPageFromApi({ - id: "note-native-1", - owner_id: AUTH_USER_ID, - note_id: "some-note", - source_page_id: null, - title: "Note-native", - content_preview: null, - thumbnail_url: null, - source_url: null, - created_at: nowIso, - updated_at: nowIso, - is_deleted: false, - }); - - expect(result).toBeNull(); - expect(adapter.upsertPage).not.toHaveBeenCalled(); - }); - }); }); diff --git a/src/lib/pageRepository/StorageAdapterPageRepository.ts b/src/lib/pageRepository/StorageAdapterPageRepository.ts index fa8cc896..b68f4c46 100644 --- a/src/lib/pageRepository/StorageAdapterPageRepository.ts +++ b/src/lib/pageRepository/StorageAdapterPageRepository.ts @@ -1,7 +1,8 @@ /** * C3-7: Page repository backed by StorageAdapter + ApiClient. * Uses StorageAdapter + API for reads and writes. Page "content" is Y.Doc (returned as ""). - * When userId is LOCAL_USER_ID (guest), create/delete use adapter only (no API). + * Guest-local page creation was retired by issue #1020; only deletes still + * branch on LOCAL_USER_ID (guest deletes stay local and skip the API). */ import type { StorageAdapter } from "@/lib/storageAdapter/StorageAdapter"; @@ -31,7 +32,7 @@ function syncPageItemToMetadata(row: SyncPageItem): PageMetadata { return { id: row.id, ownerId: row.owner_id, - noteId: row.note_id ?? null, + noteId: row.note_id, sourcePageId: row.source_page_id ?? null, title: row.title ?? null, contentPreview: row.content_preview ?? null, @@ -47,7 +48,7 @@ function metadataToPage(m: PageMetadata): Page { return { id: m.id, ownerUserId: m.ownerId, - noteId: m.noteId ?? null, + noteId: m.noteId, title: m.title ?? "", content: "", // Y.Doc; load via adapter.getYDocState or API contentPreview: m.contentPreview ?? undefined, @@ -63,7 +64,7 @@ function metadataToPageSummary(m: PageMetadata): PageSummary { return { id: m.id, ownerUserId: m.ownerId, - noteId: m.noteId ?? null, + noteId: m.noteId, title: m.title ?? "", contentPreview: m.contentPreview ?? undefined, thumbnailUrl: m.thumbnailUrl ?? undefined, @@ -76,12 +77,13 @@ function metadataToPageSummary(m: PageMetadata): PageSummary { /** * ローカル IndexedDB (StorageAdapter) と REST API (ApiClient) を束ねる - * ページリポジトリ。ゲスト (`LOCAL_USER_ID`) は adapter のみを使い、 - * 認証済みユーザーは adapter + API の両方を使って CRUD を行う。 + * ページリポジトリ。読み取りはローカル優先、作成は API 経由(所属は + * デフォルトノート)。ゲスト (`LOCAL_USER_ID`) の削除のみローカル完結。 * * Page repository that bridges the local IndexedDB (StorageAdapter) with the - * REST API (ApiClient). Guest users (`LOCAL_USER_ID`) go through the adapter - * only, while authenticated users read/write via adapter + API. + * REST API (ApiClient). Reads are local-first; creation goes through the API + * (landing in the default note). Only guest (`LOCAL_USER_ID`) deletes stay + * local. */ export class StorageAdapterPageRepository { /** @@ -94,48 +96,23 @@ export class StorageAdapterPageRepository { ) {} /** - * 新しいページを作成する。ゲストはローカルのみ、認証済みは API 経由で作成。 - * Create a new page. Guest users stay local; authenticated users hit the API. + * 新しいページを作成する(API 経由、所属は呼び出し元のデフォルトノート)。 + * 旧ゲスト向けローカル作成(`noteId: null` の個人ページ)は Issue #1020 で + * 廃止した。未認証で呼ばれた場合は API 側で 401 になる。 + * + * Create a new page via the API; it lands in the caller's default note. + * Guest-local creation (legacy `noteId: null` personal pages) was retired + * by issue #1020 — unauthenticated calls now fail with the API's 401. */ async createPage( - userId: string, + _userId: string, title: string = "", content: string = "", options?: CreatePageOptions, ): Promise { - if (userId === LOCAL_USER_ID) { - return this.createPageLocal(title, content, options); - } return this.createPageRemote(title, content, options); } - private async createPageLocal( - title: string, - content: string, - options?: CreatePageOptions, - ): Promise { - const contentPreview = getPageListPreview(content); - const now = Date.now(); - const id = crypto.randomUUID(); - const meta: PageMetadata = { - id, - ownerId: LOCAL_USER_ID, - // ローカル (ゲスト) で作るのは個人ページのみ。Issue #713。 - // Local (guest) creation always produces a personal page. Issue #713. - noteId: null, - sourcePageId: null, - title: title || null, - contentPreview: contentPreview || null, - thumbnailUrl: options?.thumbnailUrl ?? null, - sourceUrl: options?.sourceUrl ?? null, - createdAt: now, - updatedAt: now, - isDeleted: false, - }; - await this.adapter.upsertPage(meta); - return metadataToPage(meta); - } - private async createPageRemote( title: string, content: string, @@ -154,26 +131,6 @@ export class StorageAdapterPageRepository { return metadataToPage(meta); } - /** - * サーバーから取得済みの個人ページ行(`SyncPageItem`)を、API 呼び出しなしで - * ローカル IndexedDB に書き戻す。「ノート → 個人に取り込み」など、サーバー側で - * 既に作成済みのページを `/home` へ即時反映させたい場合に使う。 - * `note_id !== null` のノートネイティブページは個人 `/home` のスコープに入れない - * ため、呼び出し側で弾く(ここでは書き込みを行わず `null` を返す)。 - * - * Write-through for a page row that was already created on the server. - * Used after "copy to personal" so the new personal page shows up on `/home` - * without a full sync. Note-native pages (`note_id !== null`) belong to a - * note, not the caller's personal `/home`, so they are rejected here (no - * IDB write; returns `null`). See issue #713 Phase 3. - */ - async importPersonalPageFromApi(page: SyncPageItem): Promise { - if (page.note_id != null) return null; - const meta = syncPageItemToMetadata(page); - await this.adapter.upsertPage(meta); - return metadataToPage(meta); - } - /** * ID 指定で単一ページを取得する(論理削除済みは `null`)。 * Fetch a single page by ID; `null` if missing or soft-deleted. diff --git a/src/lib/searchUtils.test.ts b/src/lib/searchUtils.test.ts index a26a6d68..a5fb2003 100644 --- a/src/lib/searchUtils.test.ts +++ b/src/lib/searchUtils.test.ts @@ -16,7 +16,7 @@ function createTestPage(id: string, title: string, content: string, options?: Pa return { id, ownerUserId: "test-user", - noteId: null, + noteId: "note-default", title, content, createdAt: now, diff --git a/src/lib/storageAdapter/IndexedDBStorageAdapter.test.ts b/src/lib/storageAdapter/IndexedDBStorageAdapter.test.ts index 1c0a3140..e828d08b 100644 --- a/src/lib/storageAdapter/IndexedDBStorageAdapter.test.ts +++ b/src/lib/storageAdapter/IndexedDBStorageAdapter.test.ts @@ -138,3 +138,195 @@ describe("IndexedDBStorageAdapter.resetDatabase (issue #608)", () => { expect(nullCauseError.originalError).toBeUndefined(); }); }); + +/** + * `my_pages` ストアだけを持つ最小のインメモリ IDBDatabase モック。 + * `getAll` / `openCursor` (+`cursor.update`) を実装し、Issue #1020 の + * レガシー `noteId: null` 行の移行・除外ロジックを検証する。 + * + * Minimal in-memory IDBDatabase mock exposing only `my_pages` with `getAll` + * and `openCursor` (+ `cursor.update`), enough to exercise the issue #1020 + * legacy `noteId: null` migration / exclusion logic. + */ +function makePagesDb(rows: Array>): { + db: IDBDatabase; + data: Map>; +} { + const data = new Map(rows.map((r) => [r.id as string, { ...r }])); + + function makeTransaction() { + const tx: { + oncomplete: (() => void) | null; + onerror: (() => void) | null; + onabort: (() => void) | null; + error: null; + objectStore: (name: string) => unknown; + } = { + oncomplete: null, + onerror: null, + onabort: null, + error: null, + objectStore: () => store, + }; + + const store = { + getAll: () => { + const req: { result?: unknown; onsuccess: (() => void) | null; onerror: null } = { + onsuccess: null, + onerror: null, + }; + queueMicrotask(() => { + req.result = [...data.values()].map((r) => ({ ...r })); + req.onsuccess?.(); + }); + return req; + }, + get: (id: string) => { + const req: { result?: unknown; onsuccess: (() => void) | null; onerror: null } = { + onsuccess: null, + onerror: null, + }; + queueMicrotask(() => { + req.result = data.has(id) ? { ...data.get(id) } : undefined; + req.onsuccess?.(); + }); + return req; + }, + openCursor: () => { + const ids = [...data.keys()]; + const req: { result: unknown; onsuccess: (() => void) | null; onerror: null } = { + result: null, + onsuccess: null, + onerror: null, + }; + let index = 0; + const step = () => { + if (index >= ids.length) { + req.result = null; + req.onsuccess?.(); + // 全行を走査し終えたら version-change なしの readwrite tx として + // oncomplete を発火する。Fire oncomplete once iteration ends. + queueMicrotask(() => tx.oncomplete?.()); + return; + } + const id = ids[index]; + index += 1; + req.result = { + value: { ...data.get(id) }, + update: (newRow: Record) => { + data.set(id, { ...newRow }); + }, + continue: () => queueMicrotask(step), + }; + req.onsuccess?.(); + }; + queueMicrotask(step); + return req; + }, + }; + + return tx; + } + + const db = { + transaction: vi.fn(() => makeTransaction()), + close: vi.fn(), + } as unknown as IDBDatabase; + + return { db, data }; +} + +describe("IndexedDBStorageAdapter legacy noteId:null rows (issue #1020)", () => { + const originalIndexedDB = (globalThis as { indexedDB?: IDBFactory }).indexedDB; + let userCounter = 0; + + /** + * モック DB を `indexedDB.open` に差し込み、ユニークな userId で初期化済みの + * adapter を返す。Install the mock DB behind `indexedDB.open` and return an + * adapter initialized with a unique user id. + */ + async function setupAdapter(rows: Array>) { + const { db, data } = makePagesDb(rows); + (globalThis as { indexedDB?: IDBFactory }).indexedDB = { + open: vi.fn(() => { + const req: { + result: unknown; + onsuccess: (() => void) | null; + onerror: null; + onupgradeneeded: null; + } = { result: db, onsuccess: null, onerror: null, onupgradeneeded: null }; + queueMicrotask(() => req.onsuccess?.()); + return req as unknown as IDBOpenDBRequest; + }), + deleteDatabase: vi.fn(), + } as unknown as IDBFactory; + + const adapter = new IndexedDBStorageAdapter(); + userCounter += 1; + await adapter.initialize(`legacy-user-${userCounter}`); + return { adapter, data }; + } + + afterEach(async () => { + // モジュールスコープの adapterDb を確実に解放する。Release the module-scoped handle. + await new IndexedDBStorageAdapter().close(); + if (originalIndexedDB === undefined) { + delete (globalThis as { indexedDB?: IDBFactory }).indexedDB; + } else { + (globalThis as { indexedDB?: IDBFactory }).indexedDB = originalIndexedDB; + } + vi.restoreAllMocks(); + }); + + const legacyRow = { + id: "legacy-1", + ownerId: "u", + noteId: null, + sourcePageId: null, + title: "Legacy", + contentPreview: null, + thumbnailUrl: null, + sourceUrl: null, + createdAt: 1, + updatedAt: 2, + isDeleted: false, + }; + const migratedRow = { + ...legacyRow, + id: "migrated-1", + noteId: "note-default", + title: "Migrated", + }; + + it("getAllPages / getPage exclude rows whose noteId is still null (pending migration)", async () => { + const { adapter } = await setupAdapter([legacyRow, migratedRow]); + + const pages = await adapter.getAllPages(); + expect(pages.map((p) => p.id)).toEqual(["migrated-1"]); + + expect(await adapter.getPage("legacy-1")).toBeNull(); + expect((await adapter.getPage("migrated-1"))?.noteId).toBe("note-default"); + }); + + it("reassignNullNotePages adopts null rows into the given note and leaves others untouched", async () => { + const { adapter, data } = await setupAdapter([legacyRow, migratedRow]); + + await adapter.reassignNullNotePages("note-default"); + + expect(data.get("legacy-1")?.noteId).toBe("note-default"); + expect(data.get("migrated-1")?.noteId).toBe("note-default"); + + const pages = await adapter.getAllPages(); + expect(pages.map((p) => p.id).sort()).toEqual(["legacy-1", "migrated-1"]); + }); + + it("reassignNullNotePages also adopts v1 rows that lack the noteId field entirely", async () => { + const v1Row = { ...legacyRow, id: "v1-1" } as Record; + delete v1Row.noteId; + const { adapter, data } = await setupAdapter([v1Row]); + + await adapter.reassignNullNotePages("note-default"); + + expect(data.get("v1-1")?.noteId).toBe("note-default"); + }); +}); diff --git a/src/lib/storageAdapter/IndexedDBStorageAdapter.ts b/src/lib/storageAdapter/IndexedDBStorageAdapter.ts index 6e3a06a1..c47f783d 100644 --- a/src/lib/storageAdapter/IndexedDBStorageAdapter.ts +++ b/src/lib/storageAdapter/IndexedDBStorageAdapter.ts @@ -32,11 +32,15 @@ interface StoredPage { id: string; ownerId: string; /** - * 所属ノート ID。`null` は個人ページ、文字列値はノートネイティブページ。 - * Web 版の同期パスは個人ページしか持ち込まないので、実運用では常に `null`。 + * 所属ノート ID。新規行は常に非 null(呼び出し元のデフォルトノート)。 + * 旧「個人ページ」時代に永続化された行は `null` のまま残っているため、 + * 同期時に `reassignNullNotePages` でデフォルトノートへ付け替えるまでは + * `null` があり得る(Issue #1020)。 * - * Owning note ID; `null` is a personal page. The web sync path only ever - * imports personal pages, so this is always `null` in practice. + * Owning note ID. New rows are always non-null (the caller's default + * note). Rows persisted under the retired "personal page" model may still + * carry `null` until `reassignNullNotePages` adopts them during sync + * (issue #1020). */ noteId: string | null; sourcePageId: string | null; @@ -77,7 +81,7 @@ function pageToStored(p: PageMetadata): StoredPage { return { id: p.id, ownerId: p.ownerId, - noteId: p.noteId ?? null, + noteId: p.noteId, sourcePageId: p.sourcePageId ?? null, title: p.title ?? null, contentPreview: p.contentPreview ?? null, @@ -89,12 +93,21 @@ function pageToStored(p: PageMetadata): StoredPage { }; } -function storedToPage(s: StoredPage): PageMetadata { - // v1 で永続化された行は `noteId` を持たない。`null` を返して個人ページ扱いに - // 揃える。Issue #713。 - // Rows persisted under v1 lack `noteId`; coerce to `null` so they read as - // personal pages. Issue #713. - return { ...s, noteId: s.noteId ?? null }; +/** + * `noteId` が確定している行かを判定する型ガード。旧個人ページ時代の `null` / + * 未設定行は `reassignNullNotePages` による移行が済むまで読み出し対象から外す + * (Issue #1020)。 + * + * Type guard for rows whose `noteId` is materialized. Legacy `null` / + * missing rows are excluded from reads until `reassignNullNotePages` + * migrates them (issue #1020). + */ +function hasNoteId(s: StoredPage): s is StoredPage & { noteId: string } { + return typeof s.noteId === "string"; +} + +function storedToPage(s: StoredPage & { noteId: string }): PageMetadata { + return { ...s }; } function ensureDb(): Promise { @@ -509,25 +522,15 @@ export class IndexedDBStorageAdapter implements StorageAdapter { // ── メタデータ / Page metadata ── /** - * 削除されていない個人ページ(`noteId === null`)を `updatedAt` 降順で返す。 - * ノートネイティブページ(issue #713)はサーバー API 経由でのみ表示するため - * IndexedDB には基本的に持ち込まれないが、混入時もここで除外する。 + * 削除されていないページを `updatedAt` 降順で返す。IndexedDB が保持するのは + * 呼び出し元のデフォルトノート配下のページのみ(Issue #823 / #1020)。 + * 旧個人ページ時代の `noteId: null` 行は `reassignNullNotePages` による + * 移行が済むまで除外する。 * - * Return all non-deleted personal pages (`noteId === null`), sorted by - * `updatedAt` descending. Note-native pages (issue #713) are not expected - * to land in IndexedDB but are filtered defensively if they do. - * - * NOTE: `by_note` index は `noteId = null` の行をインデックスから除外する - * (IndexedDB 仕様: キー値が null の場合レコードは index に含まれない)。 - * そのため `index("by_note").getAll(IDBKeyRange.only(null))` は 0 件しか - * 返せず、ここでは使えない。`by_note` index は将来ノートネイティブページを - * 扱う実装(Phase 3 以降)で noteId 指定クエリ側に使う想定。 - * - * NOTE: the `by_note` index excludes rows whose `noteId` is null (per the - * IndexedDB spec, records are dropped from an index when the key value is - * null). `index("by_note").getAll(IDBKeyRange.only(null))` therefore returns - * zero rows and cannot replace this scan. The index is reserved for future - * note-scoped queries once note-native pages are fetched into IDB. + * Return all non-deleted pages sorted by `updatedAt` descending. IndexedDB + * only holds pages under the caller's default note (issues #823 / #1020). + * Legacy `noteId: null` rows are excluded until `reassignNullNotePages` + * migrates them. */ async getAllPages(): Promise { const db = await ensureDb(); @@ -537,7 +540,7 @@ export class IndexedDBStorageAdapter implements StorageAdapter { req.onsuccess = () => { const rows = (req.result as StoredPage[]) || []; const list = rows - .filter((r) => !r.isDeleted && (r.noteId ?? null) === null) + .filter((r): r is StoredPage & { noteId: string } => !r.isDeleted && hasNoteId(r)) .map(storedToPage); list.sort((a, b) => b.updatedAt - a.updatedAt); resolve(list); @@ -546,6 +549,34 @@ export class IndexedDBStorageAdapter implements StorageAdapter { }); } + /** + * 旧個人ページ時代に `noteId: null` / 未設定で永続化された行を、指定ノート + * (呼び出し元のデフォルトノート)へ付け替える(Issue #1020)。冪等。 + * + * Adopt legacy rows persisted with `noteId: null` / missing into the given + * note — the caller's default note (issue #1020). Idempotent. + */ + async reassignNullNotePages(noteId: string): Promise { + const db = await ensureDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction("my_pages", "readwrite"); + const store = tx.objectStore("my_pages"); + const cursorReq = store.openCursor(); + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor) return; + const row = cursor.value as StoredPage; + if (!hasNoteId(row)) { + cursor.update({ ...row, noteId }); + } + cursor.continue(); + }; + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } + /** * Return a single page by id, or `null` when missing or soft-deleted. * 指定 id のページを返す。存在しないか論理削除済みなら `null`。 @@ -557,7 +588,7 @@ export class IndexedDBStorageAdapter implements StorageAdapter { const req = tx.objectStore("my_pages").get(pageId); req.onsuccess = () => { const row = req.result as StoredPage | undefined; - resolve(row && !row.isDeleted ? storedToPage(row) : null); + resolve(row && !row.isDeleted && hasNoteId(row) ? storedToPage(row) : null); }; req.onerror = () => reject(req.error); }); diff --git a/src/lib/storageAdapter/StorageAdapter.ts b/src/lib/storageAdapter/StorageAdapter.ts index f81b8475..97f0820b 100644 --- a/src/lib/storageAdapter/StorageAdapter.ts +++ b/src/lib/storageAdapter/StorageAdapter.ts @@ -22,6 +22,18 @@ export interface StorageAdapter { upsertPage(page: PageMetadata): Promise; deletePage(pageId: string): Promise; + /** + * 旧クライアント(個人ページ時代)が `noteId: null` で残した行を、指定ノート + * (呼び出し元のデフォルトノート)へ付け替える。同期エンジンがサーバから + * `default_note_id` を受け取った直後に呼ぶ(Issue #1020)。 + * + * Reassign legacy rows persisted with `noteId: null` (the retired + * "personal page" model) to the given note — the caller's default note. + * Invoked by the sync engine right after the server reports + * `default_note_id` (issue #1020). + */ + reassignNullNotePages(noteId: string): Promise; + // ── Y.Doc ── getYDocState(pageId: string): Promise; saveYDocState(pageId: string, state: Uint8Array, version: number): Promise; diff --git a/src/lib/storageAdapter/types.ts b/src/lib/storageAdapter/types.ts index 329db794..bdcc5fcb 100644 --- a/src/lib/storageAdapter/types.ts +++ b/src/lib/storageAdapter/types.ts @@ -9,15 +9,17 @@ export interface PageMetadata { id: string; ownerId: string; /** - * 所属ノート ID。`null` は個人ページ、文字列値はそのノートに所属する - * ノートネイティブページ。Web の IndexedDB は個人ページのみを保持する - * 想定だが、将来の混在に備えてフィールドだけは持たせている。Issue #713 を参照。 + * 所属ノート ID。すべてのページはちょうど 1 つのノートに属する(Issue #823 / + * #1020)。Web の IndexedDB が保持するのは呼び出し元のデフォルトノート配下の + * ページのみ。旧「個人ページ」(`null`)は廃止済みで、レガシー行は同期時に + * デフォルトノートへ付け替えられる。 * - * Owning note ID. `null` is a personal page; a string identifies a - * note-native page. The web IndexedDB is expected to hold only personal - * pages, but the field is carried for forward compatibility. See issue #713. + * Owning note ID. Every page belongs to exactly one note (issues #823 / + * #1020); the web IndexedDB only holds pages under the caller's default + * note. The legacy "personal page" (`null`) model is retired — leftover + * rows are reassigned to the default note during sync. */ - noteId: string | null; + noteId: string; sourcePageId: string | null; title: string | null; contentPreview: string | null; diff --git a/src/lib/sync/syncWithApi.test.ts b/src/lib/sync/syncWithApi.test.ts index a6381342..11276b3b 100644 --- a/src/lib/sync/syncWithApi.test.ts +++ b/src/lib/sync/syncWithApi.test.ts @@ -15,6 +15,7 @@ function createMockAdapter(overrides: Partial = {}): StorageAdap getPage: vi.fn().mockResolvedValue(null), upsertPage: vi.fn().mockResolvedValue(undefined), deletePage: vi.fn().mockResolvedValue(undefined), + reassignNullNotePages: vi.fn().mockResolvedValue(undefined), getYDocState: vi.fn().mockResolvedValue(null), saveYDocState: vi.fn().mockResolvedValue(undefined), getYDocVersion: vi.fn().mockResolvedValue(0), @@ -41,6 +42,7 @@ function createMockApi(overrides: Partial = {}): ApiClient { pages: [], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), postSyncPages: vi.fn().mockResolvedValue({ @@ -68,6 +70,7 @@ function createMockApi(overrides: Partial = {}): ApiClient { } const TEST_USER_ID = "user-1"; +const DEFAULT_NOTE_ID = "default-note-1"; describe("syncWithApi", () => { beforeEach(async () => { @@ -106,6 +109,7 @@ describe("syncWithApi", () => { pages: [serverPage], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: serverTime, }), }); @@ -123,7 +127,7 @@ describe("syncWithApi", () => { getPage: vi.fn().mockResolvedValue({ id: "p1", ownerId: TEST_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Local Version", contentPreview: null, @@ -152,6 +156,7 @@ describe("syncWithApi", () => { ], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -171,6 +176,7 @@ describe("syncWithApi", () => { { source_id: "p1", target_id: "p3", created_at: "2025-01-01T00:00:00Z" }, ], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -210,6 +216,7 @@ describe("syncWithApi", () => { pages: [serverPage], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -263,6 +270,7 @@ describe("syncWithApi", () => { }, ], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -310,6 +318,7 @@ describe("syncWithApi", () => { // ghost_links は空(この配列単独では link_type 非対応に見える) // ghost_links empty (looks legacy on its own) ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -326,7 +335,7 @@ describe("syncWithApi", () => { const localPage: PageMetadata = { id: "p1", ownerId: TEST_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Page with thumbnail", contentPreview: "preview", @@ -360,6 +369,7 @@ describe("syncWithApi", () => { ], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -378,7 +388,7 @@ describe("syncWithApi", () => { const localPage: PageMetadata = { id: "p1", ownerId: TEST_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Page", contentPreview: null, @@ -412,6 +422,7 @@ describe("syncWithApi", () => { ], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -432,7 +443,7 @@ describe("syncWithApi", () => { const localPage: PageMetadata = { id: "local-1", ownerId: TEST_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Local Page", contentPreview: null, @@ -459,7 +470,7 @@ describe("syncWithApi", () => { const manyPages: PageMetadata[] = Array.from({ length: PAGE_PUSH_CHUNK_SIZE + 1 }, (_, i) => ({ id: `page-${i}`, ownerId: TEST_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: `Page ${i}`, contentPreview: null, @@ -496,6 +507,7 @@ describe("syncWithApi", () => { pages: [], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), postSyncPages, @@ -520,7 +532,7 @@ describe("syncWithApi", () => { const localPage: PageMetadata = { id: "p1", ownerId: TEST_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Local", contentPreview: null, @@ -563,17 +575,17 @@ describe("syncWithApi", () => { ); }); - it("excludes note-native pages from push (issue #713 Phase 2 defensive filter)", async () => { - // ノートネイティブページ(`noteId !== null`)は POST /api/sync/pages の LWW - // 対象外。サーバー側でも skip されるが、フロント側でも push 前に除外することで - // 余計なリクエストを発生させない。Issue #713 Phase 2。 - // Note-native rows (`noteId !== null`) are not part of personal-page sync. - // Filter them out client-side too so we never put them on the wire even - // when the server would skip them. Issue #713 Phase 2. + it("excludes pages outside the default note from push (issue #1020 defensive filter)", async () => { + // POST /api/sync/pages の LWW 対象は呼び出し元のデフォルトノート配下のみ。 + // サーバー側でも skip されるが、フロント側でも push 前に除外することで + // 余計なリクエストを発生させない。Issue #823 / #1020。 + // LWW sync only covers rows under the caller's default note. Filter + // foreign-note rows out client-side too so we never put them on the wire + // even when the server would skip them. Issues #823 / #1020. const personalPage: PageMetadata = { id: "personal-1", ownerId: TEST_USER_ID, - noteId: null, + noteId: DEFAULT_NOTE_ID, sourcePageId: null, title: "Personal", contentPreview: null, @@ -614,13 +626,13 @@ describe("syncWithApi", () => { expect(pushedIds).not.toContain("note-native-1"); }); - it("propagates note_id from SyncPageItem into PageMetadata (defensive read)", async () => { - // GET /api/sync/pages は現状個人ページしか返さないが、将来 `note_id` が - // ワイヤに乗った場合に `PageMetadata.noteId` までそのまま伝わることを保証する。 - // Issue #713 Phase 2。 - // GET /api/sync/pages currently only returns personal pages, but if the - // wire ever surfaces `note_id` we want it to land on `PageMetadata.noteId` - // without further plumbing. Issue #713 Phase 2. + it("propagates note_id from SyncPageItem and falls back to default_note_id when missing (issue #1020)", async () => { + // GET /api/sync/pages の各行は `note_id` を持つ(Issue #1020)。万一 + // `note_id` を欠く行(旧サーバのキャッシュ応答など)が来た場合は、同 + // レスポンスの `default_note_id` で補い、`noteId: null` を復活させない。 + // Every pulled row carries `note_id` (issue #1020). Rows lacking it + // (e.g. cached pre-#1020 payloads) fall back to the response's + // `default_note_id` instead of resurrecting `noteId: null`. const adapter = createMockAdapter(); const api = createMockApi({ getSyncPages: vi.fn().mockResolvedValue({ @@ -628,7 +640,6 @@ describe("syncWithApi", () => { { id: "p1", owner_id: TEST_USER_ID, - note_id: null, source_page_id: null, title: "Personal", content_preview: null, @@ -654,6 +665,7 @@ describe("syncWithApi", () => { ], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -667,13 +679,19 @@ describe("syncWithApi", () => { return [m.id, m] as [string, PageMetadata]; }), ); - expect(upsertedById.get("p1")?.noteId).toBeNull(); + expect(upsertedById.get("p1")?.noteId).toBe(DEFAULT_NOTE_ID); expect(upsertedById.get("p2")?.noteId).toBe("note-1"); }); - it("skips push on initial sync when local was empty", async () => { + it("reassigns legacy noteId:null rows to the default note before applying the pull (issue #1020)", async () => { + const callOrder: string[] = []; const adapter = createMockAdapter({ - getAllPages: vi.fn().mockResolvedValue([]), + reassignNullNotePages: vi.fn().mockImplementation(async () => { + callOrder.push("reassign"); + }), + upsertPage: vi.fn().mockImplementation(async () => { + callOrder.push("upsert"); + }), }); const api = createMockApi({ getSyncPages: vi.fn().mockResolvedValue({ @@ -681,18 +699,86 @@ describe("syncWithApi", () => { { id: "p1", owner_id: TEST_USER_ID, + note_id: DEFAULT_NOTE_ID, source_page_id: null, title: "S", content_preview: null, thumbnail_url: null, source_url: null, created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", + updated_at: "2025-05-01T00:00:00Z", is_deleted: false, }, ], links: [], ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, + server_time: new Date().toISOString(), + }), + }); + + await syncWithApi(adapter, api, TEST_USER_ID); + + expect(adapter.reassignNullNotePages).toHaveBeenCalledWith(DEFAULT_NOTE_ID); + expect(callOrder[0]).toBe("reassign"); + }); + + it("fails the sync when the response lacks default_note_id (pre-#1020 server)", async () => { + const adapter = createMockAdapter(); + const api = createMockApi({ + getSyncPages: vi.fn().mockResolvedValue({ + pages: [], + links: [], + ghost_links: [], + server_time: new Date().toISOString(), + }), + }); + + await expect(syncWithApi(adapter, api, TEST_USER_ID)).rejects.toThrow(/default_note_id/); + expect(adapter.upsertPage).not.toHaveBeenCalled(); + expect(api.postSyncPages).not.toHaveBeenCalled(); + }); + + it("skips push on initial sync when local was empty", async () => { + const serverPage = { + id: "p1", + owner_id: TEST_USER_ID, + note_id: DEFAULT_NOTE_ID, + source_page_id: null, + title: "S", + content_preview: null, + thumbnail_url: null, + source_url: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + is_deleted: false, + }; + const adapter = createMockAdapter({ + getAllPages: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: serverPage.id, + ownerId: TEST_USER_ID, + noteId: DEFAULT_NOTE_ID, + sourcePageId: null, + title: serverPage.title, + contentPreview: null, + thumbnailUrl: null, + sourceUrl: null, + createdAt: new Date(serverPage.created_at).getTime(), + updatedAt: new Date(serverPage.updated_at).getTime(), + isDeleted: false, + }, + ]), + }); + const api = createMockApi({ + getSyncPages: vi.fn().mockResolvedValue({ + pages: [serverPage], + links: [], + ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, server_time: new Date().toISOString(), }), }); @@ -702,6 +788,53 @@ describe("syncWithApi", () => { expect(api.postSyncPages).not.toHaveBeenCalled(); }); + it("pushes local-only legacy pages after migration when pre-pull count was zero (issue #1020)", async () => { + const legacyLocalPage: PageMetadata = { + id: "legacy-local-only", + ownerId: TEST_USER_ID, + noteId: DEFAULT_NOTE_ID, + sourcePageId: null, + title: "Offline draft", + contentPreview: null, + thumbnailUrl: null, + sourceUrl: null, + createdAt: 1, + updatedAt: 2, + isDeleted: false, + }; + const adapter = createMockAdapter({ + getLastSyncTime: vi.fn().mockResolvedValue(null), + getAllPages: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([legacyLocalPage]), + reassignNullNotePages: vi.fn().mockImplementation(async () => { + const getAllPages = adapter.getAllPages as ReturnType; + getAllPages.mockResolvedValueOnce([legacyLocalPage]); + }), + }); + const api = createMockApi({ + getSyncPages: vi.fn().mockResolvedValue({ + pages: [], + links: [], + ghost_links: [], + default_note_id: DEFAULT_NOTE_ID, + server_time: new Date().toISOString(), + }), + }); + + await syncWithApi(adapter, api, TEST_USER_ID); + + expect(adapter.reassignNullNotePages).toHaveBeenCalledWith(DEFAULT_NOTE_ID); + expect(api.postSyncPages).toHaveBeenCalledWith( + expect.objectContaining({ + pages: [ + expect.objectContaining({ + id: legacyLocalPage.id, + owner_id: TEST_USER_ID, + }), + ], + }), + ); + }); + // ── Error handling ──────────────────────────────────────────────────── it("increments consecutive failures on error", async () => { diff --git a/src/lib/sync/syncWithApi.ts b/src/lib/sync/syncWithApi.ts index a198e965..81d481c0 100644 --- a/src/lib/sync/syncWithApi.ts +++ b/src/lib/sync/syncWithApi.ts @@ -22,17 +22,17 @@ function normalizeWireLinkType(value: "wiki" | "tag" | undefined | null): LinkTy return value === "tag" ? "tag" : "wiki"; } -function syncPageToMetadata(row: SyncPageItem): PageMetadata { +function syncPageToMetadata(row: SyncPageItem, defaultNoteId: string): PageMetadata { return { id: row.id, ownerId: row.owner_id, - // GET /api/sync/pages は個人ページ (`note_id IS NULL`) のみを返すため - // 実運用では常に `null`。将来 `note_id` がワイヤに乗る場合に備えて値が - // 来たらそのまま採用する。Issue #713。 - // GET /api/sync/pages only returns personal pages (`note_id IS NULL`), - // so this is effectively always `null`. We still honor an explicit value - // if the wire format ever carries one. Issue #713. - noteId: row.note_id ?? null, + // GET /api/sync/pages は呼び出し元のデフォルトノート配下のページのみを + // 返す(Issue #823 / #1020)。`note_id` を返さない旧サーバ応答(キャッシュ + // 等)に備え、欠落時は同レスポンスの `default_note_id` で補う。 + // GET /api/sync/pages only returns pages under the caller's default note + // (issues #823 / #1020). Fall back to the response's `default_note_id` + // for stale/cached payloads that predate the per-row `note_id`. + noteId: row.note_id ?? defaultNoteId, sourcePageId: row.source_page_id ?? null, title: row.title ?? null, contentPreview: row.content_preview ?? null, @@ -182,6 +182,7 @@ function normalizeSyncResponse(raw: unknown): { pages: SyncPageItem[]; links: SyncLinkItem[]; ghost_links: SyncGhostLinkItem[]; + default_note_id: string; server_time?: string; } { const candidate = unwrapEnvelope(raw); @@ -192,13 +193,28 @@ function normalizeSyncResponse(raw: unknown): { const links = obj?.links ?? []; const ghostLinks = obj?.ghost_links ?? []; const serverTime = obj?.server_time ?? obj?.synced_at; + const defaultNoteId = obj?.default_note_id; validateSyncArrays(obj, pages, links, ghostLinks); + // Issue #1020: `default_note_id` はページ行への noteId 付与とレガシー + // `noteId: null` 行の移行に必須。欠落=旧サーバ応答なので、黙って null を + // 復活させるより同期失敗(リトライ)に倒す。 + // Issue #1020: `default_note_id` is required to stamp noteId on rows and + // migrate legacy `noteId: null` rows. A missing field means a pre-#1020 + // server payload; fail the sync (and retry later) instead of silently + // resurrecting null. + if (typeof defaultNoteId !== "string" || defaultNoteId.length === 0) { + throw new TypeError( + "[Sync/API] Invalid sync payload: missing default_note_id (server predates issue #1020?)", + ); + } + return { pages: pages as SyncPageItem[], links: links as SyncLinkItem[], ghost_links: ghostLinks as SyncGhostLinkItem[], + default_note_id: defaultNoteId, server_time: typeof serverTime === "string" ? serverTime : undefined, }; } @@ -270,12 +286,21 @@ async function applyPull( pages: SyncPageItem[]; links: SyncLinkItem[]; ghost_links: SyncGhostLinkItem[]; + default_note_id: string; }, ): Promise { + // Issue #1020: 旧個人ページ時代の `noteId: null` 行をデフォルトノートへ + // 付け替えてから pull を適用する。差分 pull(since 指定)は古い行を再送 + // しないため、ここで移行しないとレガシー行が読めないまま残る。 + // Issue #1020: adopt legacy `noteId: null` rows into the default note + // before applying the pull. Incremental pulls never resend old rows, so + // skipping this would leave legacy rows permanently unreadable. + await adapter.reassignNullNotePages(res.default_note_id); + const pulledPageIds = new Set(res.pages.map((p) => p.id)); for (const row of res.pages) { - const meta = syncPageToMetadata(row); + const meta = syncPageToMetadata(row, res.default_note_id); const local = await adapter.getPage(meta.id); if (local && local.updatedAt > meta.updatedAt) continue; // thumbnailUrl はクライアント側で extractFirstImage から生成されるため、 @@ -359,16 +384,18 @@ function getPagesForPush( lastSync: number | null, allLocalPages: PageMetadata[], pulledPageIds: Set, + defaultNoteId: string, ): PageMetadata[] { - // ノートネイティブページ(issue #713)は POST /api/sync/pages では LWW - // 対象外。サーバー側でも skip されるが、誤って IndexedDB に入った場合に - // 余計なリクエストを発生させないよう、push 前にも除外する。 - // Drop note-native rows (issue #713) before push — the server skips them - // anyway, but filtering here avoids needless wire traffic if any sneak in. - const personalOnly = allLocalPages.filter((p) => (p.noteId ?? null) === null); + // POST /api/sync/pages の LWW 対象は呼び出し元のデフォルトノート配下のみ + // (Issue #823 / #1020)。サーバー側でも skip されるが、他ノートの行が誤って + // IndexedDB に入った場合に余計なリクエストを発生させないよう push 前にも除外する。 + // LWW sync only covers rows under the caller's default note (issues #823 / + // #1020). The server skips anything else anyway, but filtering here avoids + // needless wire traffic if foreign-note rows ever sneak into IndexedDB. + const defaultNoteOnly = allLocalPages.filter((p) => p.noteId === defaultNoteId); return lastSync - ? personalOnly.filter((p) => p.updatedAt > lastSync && !pulledPageIds.has(p.id)) - : personalOnly; + ? defaultNoteOnly.filter((p) => p.updatedAt > lastSync && !pulledPageIds.has(p.id)) + : defaultNoteOnly; } async function finishSyncNoPush( @@ -386,9 +413,19 @@ function finishSyncIfNoPushNeeded( res: { server_time?: string }, isInitialSync: boolean, localPageCount: number, + pulledPageIds: Set, pagesForPush: PageMetadata[], ): Promise { - const noPush = (isInitialSync && localPageCount === 0) || pagesForPush.length === 0; + // Issue #1020: `getAllPages()` hides legacy `noteId: null` rows until + // `reassignNullNotePages` runs in `applyPull`, so `localPageCount` can be 0 + // while local-only pages still exist. Only skip push on initial sync when + // every row we would push was just pulled from the server (redundant echo). + const onlyEchoingPulledPages = + isInitialSync && + localPageCount === 0 && + pagesForPush.length > 0 && + pagesForPush.every((p) => pulledPageIds.has(p.id)); + const noPush = pagesForPush.length === 0 || onlyEchoingPulledPages; if (!noPush) return Promise.resolve(false); return finishSyncNoPush(adapter, res).then(() => true); } @@ -462,9 +499,23 @@ export async function syncWithApi( const pulledPageIds = new Set(res.pages.map((r) => r.id)); const allLocalPages = await adapter.getAllPages(); - const pagesForPush = getPagesForPush(lastSync, allLocalPages, pulledPageIds); + const pagesForPush = getPagesForPush( + lastSync, + allLocalPages, + pulledPageIds, + res.default_note_id, + ); - if (await finishSyncIfNoPushNeeded(adapter, res, isInitialSync, localPageCount, pagesForPush)) { + if ( + await finishSyncIfNoPushNeeded( + adapter, + res, + isInitialSync, + localPageCount, + pulledPageIds, + pagesForPush, + ) + ) { return; } diff --git a/src/lib/syncWikiLinks.ts b/src/lib/syncWikiLinks.ts index cfe68c39..12ec0186 100644 --- a/src/lib/syncWikiLinks.ts +++ b/src/lib/syncWikiLinks.ts @@ -16,19 +16,19 @@ export interface WikiLinkForSync { * の外部候補リスト `notePages` を受け取る。 * * Extra options for `syncLinksWithRepo`. Used to scope WikiLink resolution - * (issue #713 Phase 4). `pageNoteId` identifies whether the source page is - * personal (`null`) or note-native (`string`); `notePages` supplies an - * external candidate list when the repository does not hold note-native - * pages locally (IndexedDB holds only personal pages). + * (issues #713 Phase 4 / #1020). `pageNoteId` selects between the local + * (IndexedDB = default note) page set (`null`) and an explicit note + * (`string`); `notePages` supplies an external candidate list because the + * repository only holds the caller's default-note pages locally. */ export interface SyncLinksOptions { /** - * リンク元ページの所属ノート ID。`null` なら個人ページとして同期し、 - * `repo.getPagesSummary()` が返す個人ページのみを解決候補にする。 - * 文字列値なら `notePages` に渡された候補リストだけを使う。 + * リンク元ページの所属ノート ID。`null` ならローカル(IndexedDB=デフォルト + * ノート)スコープで同期し、`repo.getPagesSummary()` の結果を解決候補に + * する。文字列値なら `notePages` に渡された候補リストだけを使う。 * - * Note id that owns the source page. `null` → personal; the repo's - * personal page summaries are used. A string → note-native; callers must + * Note id that owns the source page. `null` → local (default-note) scope + * using the repo's page summaries. A string → note scope; callers must * supply `notePages` with the note's page list. */ pageNoteId?: string | null; @@ -64,18 +64,18 @@ export interface SyncLinksOptions { * * Extracted for unit testing with a mock repo. * - * Scope (Issue #713 Phase 4): - * - `options.pageNoteId === null`(既定): 個人ページに対する同期。解決候補 - * は `repo.getPagesSummary(userId)` が返す個人ページのみ。 - * - `options.pageNoteId === string`: ノートネイティブページに対する同期。 - * 解決候補は `options.notePages` のみ(呼び出し側が API から取得した - * ノート配下のページ一覧を渡す)。 + * Scope (Issues #713 Phase 4 / #1020): + * - `options.pageNoteId === null`(既定): ローカル(IndexedDB=デフォルト + * ノート)スコープの同期。解決候補は `repo.getPagesSummary(userId)` の結果。 + * - `options.pageNoteId === string`: ノートスコープの同期。解決候補は + * `options.notePages` のみ(呼び出し側が API から取得したノート配下の + * ページ一覧を渡す)。 * - * - `options.pageNoteId === null` (default): sync for a personal page. + * - `options.pageNoteId === null` (default): local (default-note) scope. * Candidates come from `repo.getPagesSummary(userId)`. - * - `options.pageNoteId === string`: sync for a note-native page. Candidates - * come from `options.notePages` only (caller must pre-fetch the note's - * pages from the API). + * - `options.pageNoteId === string`: note scope. Candidates come from + * `options.notePages` only (caller must pre-fetch the note's pages from + * the API). */ export async function syncLinksWithRepo( repo: IPageRepository, @@ -85,12 +85,12 @@ export async function syncLinksWithRepo( options: SyncLinksOptions = {}, ): Promise { // `pageNoteId` は `null` / 文字列の 2 値契約。`pageNoteId ? ... : ...` は - // 空文字列 ("") の場合も個人スコープに倒れるため、明示的に `!== null` で - // 判別する。`null` なら個人 (`repo.getPagesSummary`)、文字列なら + // 空文字列 ("") の場合もローカルスコープに倒れるため、明示的に `!== null` で + // 判別する。`null` ならローカル (`repo.getPagesSummary`)、文字列なら // `options.notePages` を使う。 // // `pageNoteId` is a null | string contract. Using truthiness would treat - // an empty string as personal scope; compare to `null` explicitly so the + // an empty string as local scope; compare to `null` explicitly so the // scope switch follows the documented contract. const pageNoteId = options.pageNoteId ?? null; // issue #725 Phase 1: `linkType` 省略時は `'wiki'`(既存の WikiLink 同期)。 diff --git a/src/pages/NotePageView.test.tsx b/src/pages/NotePageView.test.tsx index 647273e6..16cc758b 100644 --- a/src/pages/NotePageView.test.tsx +++ b/src/pages/NotePageView.test.tsx @@ -1149,34 +1149,6 @@ describe("NotePageView", () => { consoleError.mockRestore(); }); - it("keeps linked personal page titles read-only for non-owners", async () => { - vi.mocked(useNote).mockReturnValue({ - note: { id: "note-1" }, - access: { canView: true, canEdit: true }, - source: "local", - isLoading: false, - } as never); - vi.mocked(useNotePage).mockReturnValue({ - data: { - id: "page-1", - title: "Original title", - content: "{}", - ownerUserId: "user-other", - noteId: null, - }, - isLoading: false, - } as never); - - renderNotePageView(); - fireEvent.click(screen.getByText("change-title")); - vi.advanceTimersByTime(500); - await Promise.resolve(); - - expect(screen.getByTestId("page-title")).toHaveTextContent("Original title"); - expect(mockApi.updatePageMetadata).not.toHaveBeenCalled(); - expect(mockUpdatePageMutateAsync).not.toHaveBeenCalled(); - }); - it("keeps note-native pages read-only for owners without note edit permission", async () => { vi.mocked(useNote).mockReturnValue({ note: { id: "note-1" }, @@ -1240,32 +1212,4 @@ describe("NotePageView", () => { }), ); }); - - it("keeps AI chat on personal scope for linked personal pages", () => { - vi.mocked(useNote).mockReturnValue({ - note: { id: "note-1" }, - access: { canView: true, canEdit: true }, - source: "local", - isLoading: false, - } as never); - vi.mocked(useNotePage).mockReturnValue({ - data: { - id: "page-1", - title: "Linked personal", - content: "{}", - ownerUserId: "user-1", - noteId: null, - }, - isLoading: false, - } as never); - - renderNotePageView(); - - expect(mockSetPageContext).toHaveBeenCalledWith( - expect.objectContaining({ - pageId: "page-1", - noteId: undefined, - }), - ); - }); }); diff --git a/src/pages/NotePageView.tsx b/src/pages/NotePageView.tsx index 8a7d6b90..cd9814e6 100644 --- a/src/pages/NotePageView.tsx +++ b/src/pages/NotePageView.tsx @@ -48,29 +48,15 @@ const TITLE_SAVE_DEBOUNCE_MS = 500; function canEditPage( access: { canEdit?: boolean; canView?: boolean } | undefined, - userId: string | undefined, - page: { ownerUserId?: string; noteId?: string | null } | null | undefined, -): boolean { - if (!access?.canView || !page) return false; - if (page.noteId !== null && page.noteId !== undefined) { - return Boolean(access.canEdit); - } - if (access.canEdit) return true; - return Boolean(userId && page?.ownerUserId && page.ownerUserId === userId); -} - -/** - * リンク済み個人ページ (`noteId === null`) のタイトル更新はページ所有者だけに許す。 - * ノートネイティブページ (`noteId !== null`) はノート権限 (`canEdit`) 側で別判定する。 - * For linked personal pages (`noteId === null`), only the page owner may edit - * the title. Note-native pages (`noteId !== null`) are gated separately by - * note-level edit permission. - */ -function canEditTitle( - userId: string | undefined, page: { ownerUserId?: string } | null | undefined, ): boolean { - return Boolean(userId && page?.ownerUserId && page.ownerUserId === userId); + // Issue #1020: 旧「リンク済み個人ページ」(`noteId === null`)の所有者特例は + // 廃止。全ページがノート所属になったため、編集可否はノート権限で一元判定する。 + // Issue #1020: the legacy owner-override for "linked personal pages" + // (`noteId === null`) is gone — every page belongs to a note, so edit + // permission is decided solely by the note-level role. + if (!access?.canView || !page) return false; + return Boolean(access.canEdit); } /** @@ -218,7 +204,7 @@ function NotePageEditorEditable({ setPageContext({ type: "editor", pageId: page.id, - noteId: page.noteId ?? undefined, + noteId: page.noteId, claudeWorkspaceRoot: workspaceRoot ?? undefined, pageTitle: title, pageContent: editorContent.slice(0, 3000), @@ -499,7 +485,7 @@ function NotePageEditorEditable({ collaboration={isCollaborationEnabled ? collaboration : undefined} insertAtCursorRef={editorInsertRef} pageActionHubRef={pageActionHubRef} - pageNoteId={page.noteId ?? null} + pageNoteId={page.noteId} initialContent={initialContent} onInitialContentApplied={onInitialContentApplied} wikiComposeHref={ @@ -766,7 +752,7 @@ const NotePageView: React.FC = () => { const { noteId, pageId } = useParams<{ noteId: string; pageId: string }>(); const navigate = useNavigate(); const location = useLocation(); - const { isSignedIn, userId } = useAuth(); + const { isSignedIn } = useAuth(); const { t } = useTranslation(); const { toast } = useToast(); const removeFromNoteMutation = useRemovePageFromNote(); @@ -873,11 +859,8 @@ const NotePageView: React.FC = () => { navigate(`/notes/${noteId}`); }, [navigate, noteId]); - const canEdit = canEditPage(access ?? undefined, userId ?? undefined, page); - const isTitleEditable = - page?.noteId != null - ? Boolean(access?.canEdit) - : canEdit && canEditTitle(userId ?? undefined, page); + const canEdit = canEditPage(access ?? undefined, page); + const isTitleEditable = Boolean(access?.canEdit); const collaborationPageId = page?.id ?? ""; const isCollaborationEnabled = Boolean(collaborationPageId && isSignedIn && canEdit); const collaboration = useCollaboration({ diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index f46ef608..4fe298df 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -83,6 +83,7 @@ export default function SearchResults() { kind: "page", pageId: page.id, noteId: page.noteId, + isShared: false, title: page.title || t("common.untitledPage"), snippet, highlightedSnippet, @@ -95,14 +96,11 @@ export default function SearchResults() { }); // Issue #718 Phase 5-4: dedup 契約は `dedupSharedRowsAgainstPersonal` に集約。 - // 個人 IDB に既に出ている page id だけを shared から落とす。`note_id` が - // null でも他ユーザー所有のリンク済み個人ページは IDB に無いので残す - // (Codex / CodeRabbit 指摘)。 + // ローカル (IDB) 検索で既に出ている page id だけを shared から落とす。 // // Issue #718 Phase 5-4: dedup is centralized in - // `dedupSharedRowsAgainstPersonal` and works by `pageId` so linked personal - // pages owned by other note members (which IDB does not have) survive - // (Codex / CodeRabbit review). + // `dedupSharedRowsAgainstPersonal` and works by `pageId` against the + // local (IDB) result set. const personalIds = new Set(personal.map((item) => item.pageId)); const dedupedShared = dedupSharedRowsAgainstPersonal(sharedResults, personalIds); @@ -119,6 +117,7 @@ export default function SearchResults() { kind: "page", pageId: r.id, noteId: r.note_id, + isShared: true, title: r.title?.trim() ? r.title : t("common.untitledPage"), snippet, highlightedSnippet, diff --git a/src/stores/pageStore.test.ts b/src/stores/pageStore.test.ts deleted file mode 100644 index a5434f0c..00000000 --- a/src/stores/pageStore.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { act } from "@testing-library/react"; -import { usePageStore } from "./pageStore"; - -/** - * pageStore はゲストセッション向けの zustand + persist ストア。 - * 各テストで `setState` でリセットし、localStorage も明示的にクリアする。 - * - * pageStore is the zustand + persist guest store. Reset state via `setState` - * and clear localStorage between tests so persisted data does not leak. - */ -function resetStore(): void { - act(() => { - usePageStore.setState({ pages: [], links: [], ghostLinks: [] }); - }); -} - -describe("pageStore", () => { - beforeEach(() => { - localStorage.clear(); - resetStore(); - }); - - describe("createPage", () => { - it("creates a personal page with default empty title and content", () => { - const before = Date.now() - 1; - const page = usePageStore.getState().createPage(); - - expect(page.id).toBeTruthy(); - expect(page.title).toBe(""); - expect(page.content).toBe(""); - expect(page.ownerUserId).toBe("local-user"); - expect(page.noteId).toBeNull(); - expect(page.isDeleted).toBe(false); - expect(page.createdAt).toBeGreaterThan(before); - expect(page.updatedAt).toBe(page.createdAt); - - expect(usePageStore.getState().pages).toHaveLength(1); - expect(usePageStore.getState().pages[0]).toEqual(page); - }); - - it("prepends newly created pages to the list", () => { - const first = usePageStore.getState().createPage("first"); - const second = usePageStore.getState().createPage("second"); - - const ids = usePageStore.getState().pages.map((p) => p.id); - expect(ids).toEqual([second.id, first.id]); - }); - - it("persists created pages to localStorage", () => { - usePageStore.getState().createPage("Persisted", "body"); - - const raw = localStorage.getItem("zedi-pages"); - expect(raw).toBeTruthy(); - const parsed = JSON.parse(raw as string) as { state: { pages: Array<{ title: string }> } }; - expect(parsed.state.pages[0].title).toBe("Persisted"); - }); - }); - - describe("updatePage", () => { - it("merges updates and bumps updatedAt", async () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date(1_000_000)); - const page = usePageStore.getState().createPage("orig", "body"); - vi.setSystemTime(new Date(2_000_000)); - - usePageStore.getState().updatePage(page.id, { title: "updated" }); - - const stored = usePageStore.getState().getPage(page.id); - expect(stored?.title).toBe("updated"); - expect(stored?.content).toBe("body"); - expect(stored?.updatedAt).toBe(2_000_000); - } finally { - vi.useRealTimers(); - } - }); - - it("is a no-op for an unknown id", () => { - const page = usePageStore.getState().createPage("orig"); - const before = usePageStore.getState().pages; - - usePageStore.getState().updatePage("missing", { title: "x" }); - - expect(usePageStore.getState().pages).toEqual(before); - expect(usePageStore.getState().getPage(page.id)?.title).toBe("orig"); - }); - }); - - describe("deletePage", () => { - it("marks the page as deleted and drops attached links", () => { - const a = usePageStore.getState().createPage("A"); - const b = usePageStore.getState().createPage("B"); - const c = usePageStore.getState().createPage("C"); - - usePageStore.getState().addLink(a.id, b.id); - usePageStore.getState().addLink(c.id, a.id); - usePageStore.getState().addLink(b.id, c.id); - - usePageStore.getState().deletePage(a.id); - - const state = usePageStore.getState(); - expect(state.pages.find((p) => p.id === a.id)?.isDeleted).toBe(true); - // a を含むリンクは消え、b → c のリンクだけ残る - // links touching a are removed; b → c survives - expect(state.links).toEqual([ - expect.objectContaining({ sourceId: b.id, targetId: c.id, linkType: "wiki" }), - ]); - }); - - it("hides deleted pages from getPage / getPageByTitle", () => { - const page = usePageStore.getState().createPage("Hidden"); - usePageStore.getState().deletePage(page.id); - - expect(usePageStore.getState().getPage(page.id)).toBeUndefined(); - expect(usePageStore.getState().getPageByTitle("Hidden")).toBeUndefined(); - }); - }); - - describe("getPage / getPageByTitle", () => { - it("returns undefined for a missing id", () => { - expect(usePageStore.getState().getPage("missing")).toBeUndefined(); - }); - - it("getPageByTitle is case-insensitive and trims whitespace", () => { - const page = usePageStore.getState().createPage("Hello World"); - expect(usePageStore.getState().getPageByTitle(" hello world ")?.id).toBe(page.id); - expect(usePageStore.getState().getPageByTitle("HELLO WORLD")?.id).toBe(page.id); - }); - }); - - describe("addLink / removeLink", () => { - it("adds a wiki link by default and de-duplicates repeat inserts", () => { - usePageStore.getState().addLink("a", "b"); - usePageStore.getState().addLink("a", "b"); - - expect(usePageStore.getState().links).toEqual([ - expect.objectContaining({ sourceId: "a", targetId: "b", linkType: "wiki" }), - ]); - }); - - it("treats wiki and tag edges on the same pair as distinct rows (issue #725)", () => { - usePageStore.getState().addLink("a", "b", "wiki"); - usePageStore.getState().addLink("a", "b", "tag"); - - expect(usePageStore.getState().links).toHaveLength(2); - const types = usePageStore.getState().links.map((l) => l.linkType); - expect(types.sort()).toEqual(["tag", "wiki"]); - }); - - it("removeLink only deletes rows of the matching linkType", () => { - usePageStore.getState().addLink("a", "b", "wiki"); - usePageStore.getState().addLink("a", "b", "tag"); - - usePageStore.getState().removeLink("a", "b", "wiki"); - - expect(usePageStore.getState().links).toEqual([ - expect.objectContaining({ sourceId: "a", targetId: "b", linkType: "tag" }), - ]); - }); - }); - - describe("getOutgoingLinks / getBacklinks", () => { - it("filters by linkType (default 'wiki')", () => { - usePageStore.getState().addLink("a", "b", "wiki"); - usePageStore.getState().addLink("a", "c", "wiki"); - usePageStore.getState().addLink("a", "d", "tag"); - - expect(usePageStore.getState().getOutgoingLinks("a").sort()).toEqual(["b", "c"]); - expect(usePageStore.getState().getOutgoingLinks("a", "tag")).toEqual(["d"]); - }); - - it("getBacklinks returns sources pointing at the page", () => { - usePageStore.getState().addLink("p1", "target"); - usePageStore.getState().addLink("p2", "target"); - usePageStore.getState().addLink("p3", "other"); - - expect(usePageStore.getState().getBacklinks("target").sort()).toEqual(["p1", "p2"]); - expect(usePageStore.getState().getBacklinks("other")).toEqual(["p3"]); - }); - }); - - describe("ghost links", () => { - it("addGhostLink de-duplicates and supports linkType scoping", () => { - usePageStore.getState().addGhostLink("Topic", "p1"); - usePageStore.getState().addGhostLink("Topic", "p1"); - usePageStore.getState().addGhostLink("Topic", "p1", "tag"); - - expect(usePageStore.getState().ghostLinks).toHaveLength(2); - }); - - it("removeGhostLink only deletes the matching (text, source, type) tuple", () => { - usePageStore.getState().addGhostLink("Topic", "p1"); - usePageStore.getState().addGhostLink("Topic", "p2"); - - usePageStore.getState().removeGhostLink("Topic", "p1"); - - expect(usePageStore.getState().ghostLinks).toEqual([ - expect.objectContaining({ linkText: "Topic", sourcePageId: "p2", linkType: "wiki" }), - ]); - }); - - it("getGhostLinkSources collects pages by linkText + linkType", () => { - usePageStore.getState().addGhostLink("Topic", "p1"); - usePageStore.getState().addGhostLink("Topic", "p2"); - usePageStore.getState().addGhostLink("Topic", "p3", "tag"); - - expect(usePageStore.getState().getGhostLinkSources("Topic").sort()).toEqual(["p1", "p2"]); - expect(usePageStore.getState().getGhostLinkSources("Topic", "tag")).toEqual(["p3"]); - }); - - it("promoteGhostLink only promotes when 2+ sources exist for the wiki bucket", () => { - usePageStore.getState().addGhostLink("Solo", "p1"); - expect(usePageStore.getState().promoteGhostLink("Solo")).toBeNull(); - - usePageStore.getState().addGhostLink("Pair", "p1"); - usePageStore.getState().addGhostLink("Pair", "p2"); - - const promoted = usePageStore.getState().promoteGhostLink("Pair"); - expect(promoted).not.toBeNull(); - expect(promoted?.title).toBe("Pair"); - - const state = usePageStore.getState(); - // ゴーストは消費され、各ソースから新ページへの実リンクが張られる - // ghosts consumed; real links from each source to the promoted page exist - expect(state.ghostLinks.find((g) => g.linkText === "Pair")).toBeUndefined(); - const targets = state.links.filter((l) => l.targetId === promoted?.id).map((l) => l.sourceId); - expect(targets.sort()).toEqual(["p1", "p2"]); - }); - - it("promoteGhostLink ignores tag-only ghosts (issue #725 Phase 1)", () => { - usePageStore.getState().addGhostLink("Tagged", "p1", "tag"); - usePageStore.getState().addGhostLink("Tagged", "p2", "tag"); - - expect(usePageStore.getState().promoteGhostLink("Tagged")).toBeNull(); - expect(usePageStore.getState().pages).toHaveLength(0); - }); - }); - - describe("searchPages", () => { - beforeEach(() => { - usePageStore.getState().createPage("Hello World", "first body"); - usePageStore.getState().createPage("Other", "contains hello"); - usePageStore.getState().createPage("Trash", "ignored"); - }); - - it("returns title and content matches case-insensitively", () => { - const results = usePageStore.getState().searchPages("HELLO"); - expect(results.map((p) => p.title).sort()).toEqual(["Hello World", "Other"]); - }); - - it("returns [] for a blank query", () => { - expect(usePageStore.getState().searchPages(" ")).toEqual([]); - }); - - it("excludes soft-deleted pages from search results", () => { - const target = usePageStore.getState().getPageByTitle("Hello World"); - if (!target) throw new Error("fixture page missing"); - usePageStore.getState().deletePage(target.id); - - const results = usePageStore.getState().searchPages("hello"); - expect(results.map((p) => p.title)).toEqual(["Other"]); - }); - }); - - describe("persist migrate", () => { - /** - * persist の `migrate` は zustand 内部から呼ばれる private 関数なので、ここでは - * 永続化キーに古いバージョンの payload を直接書き、ストアを `rehydrate` で - * 再水和して結果を確認する。 - * - * `migrate` is invoked internally by zustand. We seed localStorage with an - * older-versioned payload and trigger `rehydrate()` to validate the upgrade. - */ - it("backfills missing noteId to null (v1 → v2)", async () => { - localStorage.setItem( - "zedi-pages", - JSON.stringify({ - version: 1, - state: { - pages: [ - { - id: "old-1", - ownerUserId: "local-user", - title: "Legacy", - content: "", - createdAt: 1, - updatedAt: 1, - isDeleted: false, - }, - ], - links: [], - ghostLinks: [], - }, - }), - ); - - await usePageStore.persist.rehydrate(); - - const page = usePageStore.getState().pages.find((p) => p.id === "old-1"); - expect(page?.noteId).toBeNull(); - }); - - it("backfills missing linkType to 'wiki' for links and ghost links (v2 → v3)", async () => { - localStorage.setItem( - "zedi-pages", - JSON.stringify({ - version: 2, - state: { - pages: [], - links: [{ sourceId: "a", targetId: "b", createdAt: 1 }], - ghostLinks: [{ linkText: "X", sourcePageId: "a", createdAt: 1 }], - }, - }), - ); - - await usePageStore.persist.rehydrate(); - - const state = usePageStore.getState(); - expect(state.links[0].linkType).toBe("wiki"); - expect(state.ghostLinks[0].linkType).toBe("wiki"); - }); - }); -}); diff --git a/src/stores/pageStore.ts b/src/stores/pageStore.ts deleted file mode 100644 index 8c7ef596..00000000 --- a/src/stores/pageStore.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import { v4 as uuidv4 } from "uuid"; -import type { Page, Link, GhostLink, LinkType } from "@/types/page"; - -/** - * ゲストセッション向けのインメモリページストアのインターフェース。 - * 認証済みユーザーの正規パスは `StorageAdapterPageRepository` 側。 - * - * Shape of the in-memory page store used by guest sessions. Authenticated - * users go through `StorageAdapterPageRepository` instead. - */ -interface PageStore { - pages: Page[]; - links: Link[]; - ghostLinks: GhostLink[]; - - // Page CRUD - createPage: (title?: string, content?: string) => Page; - updatePage: ( - id: string, - updates: Partial>, - ) => void; - deletePage: (id: string) => void; - getPage: (id: string) => Page | undefined; - getPageByTitle: (title: string) => Page | undefined; - - // Link operations. `linkType` は issue #725 Phase 1 で追加。未指定は `'wiki'`。 - // `linkType` added in issue #725 Phase 1; defaults to `'wiki'`. - addLink: (sourceId: string, targetId: string, linkType?: LinkType) => void; - removeLink: (sourceId: string, targetId: string, linkType?: LinkType) => void; - getOutgoingLinks: (pageId: string, linkType?: LinkType) => string[]; - getBacklinks: (pageId: string, linkType?: LinkType) => string[]; - - // Ghost link operations - addGhostLink: (linkText: string, sourcePageId: string, linkType?: LinkType) => void; - removeGhostLink: (linkText: string, sourcePageId: string, linkType?: LinkType) => void; - getGhostLinkSources: (linkText: string, linkType?: LinkType) => string[]; - promoteGhostLink: (linkText: string) => Page | null; - - // Search - searchPages: (query: string) => Page[]; -} - -/** - * localStorage に永続化されるゲスト用ページストア。サインイン前後の一時編集や - * オンボーディング時のデータ保持に使う(個人ページのみ、Issue #713)。 - * - * Guest-mode page store persisted in localStorage. Holds personal pages only - * (issue #713) for pre-sign-in drafts and onboarding flows. - */ -export const usePageStore = create()( - persist( - (set, get) => ({ - pages: [], - links: [], - ghostLinks: [], - - createPage: (title = "", content = "") => { - const now = Date.now(); - const newPage: Page = { - id: uuidv4(), - ownerUserId: "local-user", - // ローカル zustand ストアは個人ページ専用。Issue #713。 - // The local zustand store only holds personal pages. Issue #713. - noteId: null, - title, - content, - createdAt: now, - updatedAt: now, - isDeleted: false, - }; - - set((state) => ({ - pages: [newPage, ...state.pages], - })); - - return newPage; - }, - - updatePage: (id, updates) => { - set((state) => ({ - pages: state.pages.map((page) => - page.id === id ? { ...page, ...updates, updatedAt: Date.now() } : page, - ), - })); - }, - - deletePage: (id) => { - set((state) => ({ - pages: state.pages.map((page) => (page.id === id ? { ...page, isDeleted: true } : page)), - links: state.links.filter((link) => link.sourceId !== id && link.targetId !== id), - })); - }, - - getPage: (id) => { - return get().pages.find((page) => page.id === id && !page.isDeleted); - }, - - getPageByTitle: (title) => { - const normalizedTitle = title.toLowerCase().trim(); - return get().pages.find( - (page) => page.title.toLowerCase().trim() === normalizedTitle && !page.isDeleted, - ); - }, - - addLink: (sourceId, targetId, linkType = "wiki") => { - const exists = get().links.some( - (link) => - link.sourceId === sourceId && link.targetId === targetId && link.linkType === linkType, - ); - if (!exists) { - set((state) => ({ - links: [...state.links, { sourceId, targetId, linkType, createdAt: Date.now() }], - })); - } - }, - - removeLink: (sourceId, targetId, linkType = "wiki") => { - set((state) => ({ - links: state.links.filter( - (link) => - !( - link.sourceId === sourceId && - link.targetId === targetId && - link.linkType === linkType - ), - ), - })); - }, - - getOutgoingLinks: (pageId, linkType = "wiki") => { - return get() - .links.filter((link) => link.sourceId === pageId && link.linkType === linkType) - .map((link) => link.targetId); - }, - - getBacklinks: (pageId, linkType = "wiki") => { - return get() - .links.filter((link) => link.targetId === pageId && link.linkType === linkType) - .map((link) => link.sourceId); - }, - - addGhostLink: (linkText, sourcePageId, linkType = "wiki") => { - const exists = get().ghostLinks.some( - (gl) => - gl.linkText === linkText && - gl.sourcePageId === sourcePageId && - gl.linkType === linkType, - ); - if (!exists) { - set((state) => ({ - ghostLinks: [ - ...state.ghostLinks, - { linkText, sourcePageId, linkType, createdAt: Date.now() }, - ], - })); - } - }, - - removeGhostLink: (linkText, sourcePageId, linkType = "wiki") => { - set((state) => ({ - ghostLinks: state.ghostLinks.filter( - (gl) => - !( - gl.linkText === linkText && - gl.sourcePageId === sourcePageId && - gl.linkType === linkType - ), - ), - })); - }, - - getGhostLinkSources: (linkText, linkType = "wiki") => { - return get() - .ghostLinks.filter((gl) => gl.linkText === linkText && gl.linkType === linkType) - .map((gl) => gl.sourcePageId); - }, - - promoteGhostLink: (linkText) => { - // ゴースト昇格は WikiLink 限定。タグゴーストは通常のタグ同期で解決する - // 想定のため、多元ソース昇格の対象外(issue #725 Phase 1)。 - // Promotion is wiki-only; tag ghosts are resolved via tag sync, not - // multi-source promotion (issue #725 Phase 1). - const sources = get().getGhostLinkSources(linkText, "wiki"); - if (sources.length >= 2) { - // Create a new page from the ghost link - const newPage = get().createPage(linkText); - - // Convert ghost links to real links - sources.forEach((sourceId) => { - get().addLink(sourceId, newPage.id, "wiki"); - }); - - // Remove ghost links - set((state) => ({ - ghostLinks: state.ghostLinks.filter( - (gl) => !(gl.linkText === linkText && gl.linkType === "wiki"), - ), - })); - - return newPage; - } - return null; - }, - - searchPages: (query) => { - const normalizedQuery = query.toLowerCase().trim(); - if (!normalizedQuery) return []; - - return get().pages.filter((page) => { - if (page.isDeleted) return false; - - const titleMatch = page.title.toLowerCase().includes(normalizedQuery); - const contentMatch = page.content.toLowerCase().includes(normalizedQuery); - - return titleMatch || contentMatch; - }); - }, - }), - { - name: "zedi-pages", - // v2: `Page.noteId` (Issue #713 / Phase 2) を必須化したため、v1 で - // localStorage に保存された `noteId` 未設定のページを `null` に寄せる。 - // これをしないと deserialize 後 `page.noteId === undefined` となり、 - // `noteId === null` を期待するコード(個人ページ判定)で取りこぼす。 - // - // v3: `Link.linkType` / `GhostLink.linkType` (Issue #725 Phase 1) を必須化 - // したため、v2 以前で永続化された `linkType` 未設定の行を `'wiki'` に寄せる。 - // これをしないと `addLink` / `removeLink` 等の `linkType === linkType` 比較 - // が失敗し、重複 insert や削除漏れが起きる(IndexedDB 側は `migrateLinkStoreToV3` - // で対処済、その対応物をゲストストアでも実行する)。 - // - // v2: persisted pages from v1 (pre-#713) lack `noteId`. Backfill them to - // `null` on load so the `Page` type contract (`noteId: string | null`) holds. - // v3: persisted links / ghost links from v1–v2 lack `linkType` (issue - // #725 Phase 1). Backfill to `'wiki'` so the new `linkType === linkType` - // comparisons in `addLink` / `removeLink` don't silently drop to the - // `undefined === 'wiki'` branch. Mirrors the IndexedDB v3 migration. - version: 3, - migrate: (persistedState: unknown, version: number) => { - if (!persistedState || typeof persistedState !== "object") { - return persistedState; - } - const state = persistedState as { - pages?: Array>; - links?: Array>; - ghostLinks?: Array>; - }; - - if (version < 2 && Array.isArray(state.pages)) { - state.pages = state.pages.map((p) => ({ ...p, noteId: p.noteId ?? null })); - } - - if (version < 3) { - if (Array.isArray(state.links)) { - state.links = state.links.map((l) => ({ ...l, linkType: l.linkType ?? "wiki" })); - } - if (Array.isArray(state.ghostLinks)) { - state.ghostLinks = state.ghostLinks.map((g) => ({ - ...g, - linkType: g.linkType ?? "wiki", - })); - } - } - - return persistedState; - }, - }, - ), -); diff --git a/src/types/page.ts b/src/types/page.ts index 8badabc9..8ec59f1a 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -6,25 +6,14 @@ export interface Page { id: string; ownerUserId: string; /** - * 所属ノート ID。`null` は「個人ページ(`note_id IS NULL`)」を表す。 + * 所属ノート ID。すべてのページはちょうど 1 つのノートに属する(Issue #823)。 + * 旧「個人ページ」(`noteId === null`)は Issue #1020 でソースから根絶済み。 * - * Issue #823/#825 はデフォルトノート導入に伴いこの概念を廃止する方針だが、 - * ストレージ層(`PageMetadata.noteId: string | null`)・ゲストストア - * (`pageStore`)・IndexedDB の個人ページ判定 (`noteId === null`) が依然として - * `null` を生成・依存しているため、フロントのドメイン型も実態に合わせて - * `string | null` とする(`strict: true` 化で型穴を顕在化)。`null` をソース - * から完全に除去して non-null へ再 tighten するのは個人ページ概念の根絶 - * エピックで対応する。 - * - * Owning note ID. `null` denotes a legacy "personal page" (`note_id IS NULL`). - * Issues #823/#825 aim to retire this concept, but the storage layer - * (`PageMetadata.noteId: string | null`), the guest store (`pageStore`), and - * IndexedDB's personal-page filter (`noteId === null`) still produce and rely - * on `null`, so the frontend domain type matches reality as `string | null` - * (surfaced by enabling `strict: true`). Eliminating `null` at the source and - * re-tightening to non-null is tracked by the personal-page removal epic. + * Owning note ID. Every page belongs to exactly one note (issue #823); the + * legacy "personal page" (`noteId === null`) concept was eradicated at the + * source by issue #1020. */ - noteId: string | null; + noteId: string; title: string; content: string; // Tiptap JSON stringified contentPreview?: string; @@ -43,13 +32,10 @@ export interface PageSummary { id: string; ownerUserId: string; /** - * 所属ノート ID。`Page.noteId` と同様、`null` は個人ページを表す暫定形。 - * 詳細と今後の方針(個人ページ概念の根絶エピック)は `Page.noteId` を参照。 - * - * Owning note ID. Like `Page.noteId`, `null` denotes a personal page as an - * interim shape; see `Page.noteId` for details and the planned removal epic. + * 所属ノート ID。`Page.noteId` と同じ non-null 契約(Issue #823 / #1020)。 + * Owning note ID; same non-null contract as `Page.noteId` (issues #823 / #1020). */ - noteId: string | null; + noteId: string; title: string; contentPreview?: string; thumbnailUrl?: string;