Skip to content

feat(api): migrate personal pages into default note and drop note_pages (#823)#831

Merged
otomatty merged 3 commits into
developfrom
feature/823
May 9, 2026
Merged

feat(api): migrate personal pages into default note and drop note_pages (#823)#831
otomatty merged 3 commits into
developfrom
feature/823

Conversation

@otomatty

@otomatty otomatty commented May 8, 2026

Copy link
Copy Markdown
Owner

概要

Issue #823 / PR 1b: 既存個人ページ (pages.note_id IS NULL) をすべて所有者のデフォルトノートに移行し、pages.note_id を NOT NULL に昇格、note_pages 中間テーブルを廃止する。これでフロント以外のすべての層が「ページは必ずちょうど 1 つのノートに属する」モデルに揃う(A 案統合の API 側準備)。

PR #821 (PR 1a) で導入した notes.is_default の上に乗せた破壊的変更で、フロント (/home) は PR 2 (#824 配下の #825#830) で別途置き換える。

変更点

server/api/drizzle/

  • 新規マイグレーション 0023_migrate_personal_pages_drop_note_pages.sql: 孤児個人ページの物理削除 → 安全弁の既定ノート作成 → 既定ノートへバックフィル → pages.note_id を NOT NULL → DROP TABLE IF EXISTS note_pages。手動適用済み環境でも壊れないよう IF NOT EXISTS / NOT EXISTS ガード付き。
  • meta/_journal.json に idx 22 のエントリを追記。

server/api/src/schema/

  • notes.ts: notePages テーブル定義と NotePage / NewNotePage 型 export を削除。
  • pages.ts: noteId.notNull() に。JSDoc を「単一ノート所属モデル」へ書き換え。
  • index.ts / relations.ts: notePages / notePagesRelations の export を撤去(notes.pages = many(pages) は維持)。

server/api/src/routes/

パス 変更
pages.ts GET /api/pages410 GoneDeprecation: true ヘッダ)。POST /api/pagesnote_id 必須、未指定時は ensureDefaultNote で解決。
notes/pages.ts POST /:noteId/pagestitle 経路のみpage_id リンクは 400)。PUT /:noteId/pagesnoopupdated_at DESC が順序)。copy-from-personal / copy-to-personal を削除。GET /:noteId/pagespages.noteId = :noteId 直接 SELECT に書き換え。
notes/crud.ts GET /:noteId のページ一覧を pages.noteId = :noteId で取得。NotePageApiItem から sort_order / added_by_* を削除。
notes/helpers.ts getActivePageCountspages.note_id ベースに。getNoteRoleselect 結果を防御的に Array.isArray で扱う。
notes/search.ts note_pages JOIN を削除し WHERE p.note_id = :noteId に置換。
search.ts scope=own は呼び出し元のデフォルトノートに絞る。scope=sharednote_pages JOIN を撤去し owner / accepted member / domain access の EXISTS 群に再構築。
pageSnapshots.ts コメントを「note 所属モデル」へ統一。select 結果を防御的に扱う。
syncPages.ts スコープを「呼び出し元のデフォルトノート + 所有者一致」に変更。すべての INSERT に noteId を埋める。select 結果を Array.isArray でガード。
wikiSchema.ts スキーマページ作成時に noteId = ensureDefaultNote(...).id を埋める。

server/api/src/services/

  • pageAccessService.ts: pageRow.noteId IS NULL 分岐とリンク済み個人ページ経由の lookup を削除。assertPageViewAccess / assertPageEditAccess常に getNoteRole(pages.note_id) 経由で判定。
  • titleRenamePropagationService.ts: isNull(pages.noteId) 個人スコープ分岐を撤去し、常に eq(pages.noteId, scope.noteId)
  • defaultNoteService.ts / indexBuilder.ts: 共有用の DbOrTx 型を新規 types/dbOrTx.ts に切り出し。

server/api/src/lib/

  • welcomePageService.ts: ウェルカムページ作成時に noteId = ensureDefaultNote(tx, userId).id を埋める。
  • clipAndCreate.ts: クリップ起点のページ作成でも同様に noteId を埋める。

server/api/src/__tests__/

  • setup.tscreateMockPageRow から sortOrder / addedByUserId を削除し noteId をデフォルトに追加。createMockPageListRow から sort_order / added_by を削除。
  • routes/notes/pages.test.ts を全面書き換え(title 経路のみ・page_id 400・PUT noop・DELETE soft-delete・コピー系 404)。
  • routes/pages.test.ts を 410 / ensureDefaultNote モック / アクセス用 SELECT 連鎖前提に書き換え。
  • routes/search.test.ts / routes/notes/search.test.ts を新 SQL(p.note_id = :noteId / note_pages 不在)前提に更新。
  • routes/notes/crud.test.ts から sort_order / added_by_* 検証を削除。
  • routes/syncPages.test.tsensureDefaultNotevi.mocknoteId を含めて検証。
  • routes/pageSnapshots.test.tsassertPageViewAccess の 3 段アクセスチェック(pages / users / note)に合わせて再構成。
  • services/pageAccessService.test.ts を「すべてノートロール経由」シナリオに書き換え。
  • routes/media.test.ts@aws-sdk/client-s3 モックに HeadObjectCommand を追加し mockS3Send の既定値を整備。

変更の種類

  • ✨ 新機能 (New feature)
  • 💥 破壊的変更 (Breaking change)
  • 🎨 スタイル/リファクタリング (Style/Refactor)
  • 🧪 テスト (Tests)

テスト方法

  1. cd server/api && bun install
  2. bun run typecheck — 通ること。
  3. bun run test:run — Vitest 全件パス(90 ファイル / 1150 件)。
  4. ローカル DB に対して bunx drizzle-kit migrate を実行 → note_pages テーブルが消え、すべての pages 行で note_id IS NOT NULL であることを確認。
  5. GET /api/notes/meGET /api/notes/:noteId/pages で旧個人ページがデフォルトノートの中身として返ることを確認。
  6. GET /api/pages410 GoneDeprecation: true ヘッダ)を返すことを確認。
  7. POST /api/notes/:noteId/pagespage_id 付きで叩くと 400 が返ること、title のみで叩くとページが作成されることを確認。
  8. PUT /api/notes/:noteId/pages(並び替え)が 200 noop で notes.updated_at のみ進むことを確認。

チェックリスト

  • テストがすべてパスする (bun run test:run)
  • Lint エラーがない (bun run lint / bun run format:check)
  • DB スキーマ変更に対応する server/api/drizzle/0023_*.sql_journal.json のエントリを追加した
  • コミットメッセージが Conventional Commits に従っている

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

UI 変更は無し(フロントは PR 2 で別途実施)。

関連 Issue

Closes #823
Related to #821 (PR 1a) / #824 (PR 2 meta) / #825#830 (PR 2 子 issue 群)

Summary by CodeRabbit

Release Notes

  • Chores

    • Migrated page organization system to require all pages to belong to notes. Personal pages are automatically moved to users' default notes, which are created as needed.
    • Removed legacy page-linking functionality; pages now have direct note ownership via database schema.
    • Marked legacy /api/pages list endpoint as deprecated.
  • Tests

    • Updated test suites to reflect new page-note relationship model.

…es (#823)

PR 1b: backfill all `pages.note_id IS NULL` rows into each owner's default
note, promote `pages.note_id` to NOT NULL, and drop the `note_pages`
junction table. After this PR every page belongs to exactly one note via
`pages.note_id`, so authorization, search, and sync all flow through the
note model uniformly.

API:
- `GET /api/pages` returns 410 Gone with `Deprecation: true` header.
- `POST /api/pages` requires `note_id`; falls back to `ensureDefaultNote`.
- `POST /api/notes/:noteId/pages` accepts only the title path; `page_id`
  linking is rejected with 400.
- `PUT /api/notes/:noteId/pages` is now a noop (order is `updated_at DESC`).
- `copy-from-personal` / `copy-to-personal` routes are removed.
- Note search / global search drop the `note_pages` join and use
  `pages.note_id` directly.
- `assertPageViewAccess` / `assertPageEditAccess` resolve roles solely
  through `getNoteRole` on `pages.note_id` (no personal-page branch).
- `welcomePageService`, `clipAndCreate`, `wikiSchema`, `indexBuilder`,
  `syncPages`, etc. all set `noteId` from `ensureDefaultNote`.

Schema / migration:
- New SQL migration `0023_migrate_personal_pages_drop_note_pages.sql`
  with safety inserts, default-note backfill, NOT NULL promotion, and
  `DROP TABLE note_pages` (idempotent guards).
- `schema/notes.ts` removes `notePages`; `schema/pages.ts` makes
  `noteId` non-null; `schema/relations.ts` drops `notePagesRelations`.

Tests:
- Rewrites for `pageAccessService`, route tests for pages / notes pages
  / search / pageSnapshots / syncPages / media to match the new contract
  (note-role chains, `HeadObjectCommand` mock, `ensureDefaultNote` mock,
  removal of `sort_order` / `added_by` fields).

Refs: #823 (PR 1b), #821 (PR 1a), #824 (PR 2 meta).
Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Rate limit exceeded

@otomatty has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 51 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 514dc8f7-7cc4-44ef-8069-8bae4bbaa3f8

📥 Commits

Reviewing files that changed from the base of the PR and between 6cedb50 and 005f0d3.

📒 Files selected for processing (3)
  • server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql
  • server/api/src/__tests__/routes/media.test.ts
  • server/api/src/routes/notes/search.ts
📝 Walkthrough

Walkthrough

This PR migrates all personal pages (previously pages.note_id IS NULL) into each user's default note, makes pages.note_id non-nullable, drops the note_pages junction table, and rewrites authorization, routes, and services to use direct note ownership rather than multi-table linking.

Changes

Issue #823: Migrate Personal Pages to Default Notes

Layer / File(s) Summary
Database Migration
server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql, server/api/drizzle/meta/_journal.json
Deletes orphan pages, ensures each user has a default note for personal pages, backfills pages.note_id to the owner's default note, enforces NOT NULL, and drops note_pages.
Schema Model
server/api/src/schema/pages.ts, server/api/src/schema/notes.ts, server/api/src/schema/index.ts, server/api/src/schema/relations.ts
pages.noteId becomes non-nullable; notePages table definition and exports removed; notePagesRelations removed; relation linkage refactored.
Type Foundation
server/api/src/types/dbOrTx.ts
New exported type DbOrTx unifies database handle and transaction context for flexible service signatures.
Service Dependencies
server/api/src/services/defaultNoteService.ts, server/api/src/lib/welcomePageService.ts, server/api/src/lib/clipAndCreate.ts, server/api/src/services/indexBuilder.ts, server/api/src/routes/wikiSchema.ts
Services refactored to use ensureDefaultNote during special/welcome/clipped page creation; DbOrTx import centralized.
Page Authorization
server/api/src/services/pageAccessService.ts
PageOwnership.noteId always non-null; both view and edit access depend solely on getNoteRole checks against the page's owning note.
Notes Pages & CRUD Routes
server/api/src/routes/notes/pages.ts, server/api/src/routes/notes/crud.ts, server/api/src/routes/notes/helpers.ts, server/api/src/routes/notes/search.ts, server/api/src/routes/notes/types.ts
POST/DELETE/GET/PUT simplified: rejects page_id linking; queries pages by note_id directly; orders by updated_at instead of sort_order; NotePageApiItem.note_id is non-null.
Pages Listing & Creation
server/api/src/routes/pages.ts
GET /api/pages reimplemented with Deprecation header, lists pages from caller's default note or shared-note pages; POST /api/pages defaults to/validates note_id.
Search Scoping
server/api/src/routes/search.ts
scope=own scoped to default note via getDefaultNoteOrNull; scope=shared uses EXISTS checks on note membership; removed note_pages and is_default references.
Page Sync Scoping
server/api/src/routes/syncPages.ts
GET/POST/links scoped to caller's default note and owned pages within that note; removed isNull filters and noteId IS NULL logic.
Ghost Link Promotion
server/api/src/services/titleRenamePropagationService.ts
Ghost-link promotion scope simplified to filter by pages.note_id matching; removed personal-page branching.
Snapshots Documentation
server/api/src/routes/pageSnapshots.ts
Restore authorization docs updated to reflect pages.note_id ownership and note-role-based canEdit checks.
Test Infrastructure
server/api/src/__tests__/routes/notes/setup.ts, server/api/src/__tests__/routes/media.test.ts, server/api/src/__tests__/routes/pages.test.ts
Mock factories include noteId; helpers (mockNoteRow, pageAccessPrefix, viewAccessPrefix) standardize DB result chains; media test adds S3 HeadObjectCommand.
Route Tests
server/api/src/__tests__/routes/notes/*.test.ts, server/api/src/__tests__/routes/pages.test.ts, server/api/src/__tests__/routes/pageSnapshots.test.ts, server/api/src/__tests__/routes/search.test.ts, server/api/src/__tests__/routes/syncPages.test.ts
Refactored: removed sort_order/added_by assertions; updated access-check mocks; validated searches without note_pages; deprecated copy endpoints as 404 removed routes.
Service Tests
server/api/src/__tests__/services/pageAccessService.test.ts
Authorization tests rewritten for #823 semantics: pages always belong to a note; authorization via note role + canEdit; personal-page scenarios removed.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • otomatty/zedi#821: Establishes default-note foundation that this PR builds upon and completes by migrating personal pages into default notes.
  • otomatty/zedi#714: Introduced pages.note_id and note-native vs personal semantics; this PR continues that refactor by making note_id non-null and removing note_pages.
  • otomatty/zedi#727: Related page ownership contract changes including propagation of p.note_id throughout API/client code.

Poem

🐰 The pages hop into their default homes,
No more links scattered, each one now roams,
With note_id always set and true,
Personal pages join the note-crew!
The note_pages table bids farewell,
As simpler queries work quite well. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(api): migrate personal pages into default note and drop note_pages (#823)' clearly and concisely summarizes the main change: migrating personal pages to default notes and removing the note_pages table.
Linked Issues check ✅ Passed All primary coding objectives from issue #823 are met: personal pages migrated to default notes via SQL migration, pages.note_id promoted to NOT NULL, note_pages table dropped, API routes/services refactored to use pages.note_id directly, page ordering changed to updated_at DESC, and tests updated accordingly.
Out of Scope Changes check ✅ Passed All changes are directly related to issue #823 objectives: migration implementation, schema updates, API refactoring to remove note_pages dependencies, and comprehensive test updates reflecting the new data model.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/823

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements a single membership model for pages by migrating legacy personal pages into users' default notes, promoting pages.note_id to NOT NULL, and removing the note_pages link table. The changes include a database migration, the deprecation of the general pages listing endpoint in favor of note-scoped routes, and simplified authorization logic that relies on note roles. Review feedback identifies opportunities to optimize search queries by pre-fetching default note IDs instead of using SQL subqueries and suggests removing redundant array type checks on Drizzle ORM query results.

Comment thread server/api/src/routes/search.ts Outdated
Comment on lines +106 to +112
AND p.note_id = (
SELECT n.id FROM notes n
WHERE n.owner_id = ${userId}
AND n.is_default = true
AND n.is_deleted = false
LIMIT 1
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

scope=own の検索において、note_id を特定するために SQL 内でサブクエリを実行していますが、ハンドラーの冒頭で getDefaultNoteOrNull を使用して ID を取得し、それをパラメータとして渡す方が効率的でコードも読みやすくなります。また、他のエンドポイント(syncPages.ts など)と同様に ensureDefaultNote を使用して、未作成の場合に作成を保証するアプローチも検討に値します(ただし GET リクエストでの副作用を許容する場合)。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

対応しました。scope=own ではハンドラ冒頭で getDefaultNoteOrNull を呼び、既定ノートの id を SQL にバインドする形に変更しています。GET での副作用を避けるため ensureDefaultNote は使わず、既定ノート未作成時は空配列で返します。

Comment thread server/api/src/routes/notes/helpers.ts Outdated
.limit(1);

const firstMember = member[0];
const firstMember = (Array.isArray(member) ? member : [])[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Drizzle ORM の select() は常に配列を返すため、Array.isArray(member) によるチェックは冗長です。他の箇所(pageSnapshots.tssyncPages.ts など)でも同様のチェックが追加されていますが、型定義とライブラリの挙動を信頼して簡略化することを検討してください。

Suggested change
const firstMember = (Array.isArray(member) ? member : [])[0];
const firstMember = member[0];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

ご指摘どおり、Drizzle の select 戻りは常に配列として扱う前提に戻し、Array.isArray のガードを helpers / pageAccessService / pageSnapshots / syncPages から削除しました。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8fa80538c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server/api/src/routes/pages.ts Outdated
Comment on lines +124 to +129
app.get("/", authRequired, async (c) => {
const userId = c.get("userId");
const db = c.get("db");

// クエリパラメータは整数として明示的にパースする。`Number("abc")` だと NaN が SQL に渡るため。
// Parse query params as integers — `Number("abc")` would propagate NaN into SQL.
const limit = Math.min(Math.max(parseInt(c.req.query("limit") ?? "20", 10) || 20, 1), 100);
const offset = Math.max(parseInt(c.req.query("offset") ?? "0", 10) || 0, 0);
const scope = c.req.query("scope") === "shared" ? "shared" : "own";

// アクセス制御だけを変数化して SELECT 文の重複を避ける。
// `shared` は `services/pageAccessService.ts` と同じ正規の認可モデルを採用:
// - notes が未削除であること
// - note_members.status = 'accepted' (招待を受諾済み) であること
// - note_members / note_pages が未削除であること
// 大規模データセットでもプランナーが効きやすい EXISTS + JOIN を使う。
// Vary only the access predicate to avoid duplicating the SELECT.
// `shared` mirrors the canonical authorization model from `services/pageAccessService.ts`:
// the linked note must be active, the membership must be accepted, and the join rows
// must not be soft-deleted. EXISTS + JOIN keeps the planner happy on large datasets.
// `own` スコープは個人ページ(`pages.note_id IS NULL`)のみを返す。
// ノートネイティブページ(issue #713)は、ノート画面または `scope=shared`
// 経由でのみアクセスする。`shared` 経由の場合は (a) note_members 経由の
// メンバーシップ、または (b) `note_pages -> notes.owner_id = userId` 経由の
// オーナーシップで含まれる。オーナー経路を note-native page だけに限定すると、
// linked personal page が listing から消えて `assertPageViewAccess` と非対称になる。
// `getNoteRole` の解決順 (owner → member → ...) と listing predicate を揃える。
//
// The `own` scope returns personal pages only (`pages.note_id IS NULL`).
// Note-native pages (issue #713) are accessed via the note view or
// `scope=shared`. `shared` includes them either through (a) `note_members`
// membership or (b) note ownership reached through `note_pages`. That owner
// branch must cover linked personal pages too; otherwise owners could open
// them via `assertPageViewAccess` while the listing hides them.
const accessFilter =
scope === "shared"
? sql`(
(p.owner_id = ${userId} AND p.note_id IS NULL)
OR EXISTS (
SELECT 1 FROM note_pages np
JOIN notes n ON n.id = np.note_id
JOIN note_members nm ON nm.note_id = np.note_id
JOIN "user" u ON u.email = nm.member_email
WHERE np.page_id = p.id
AND u.id = ${userId}
AND nm.status = 'accepted'
AND nm.is_deleted = false
AND np.is_deleted = false
AND n.is_deleted = false
)
OR EXISTS (
SELECT 1 FROM note_pages np
JOIN notes n ON n.id = np.note_id
WHERE np.page_id = p.id
AND np.is_deleted = false
AND n.owner_id = ${userId}
AND n.is_deleted = false
)
)`
: sql`p.owner_id = ${userId} AND p.note_id IS NULL`;

// Wiki の内部システムページ(`special_kind` が `__index__` / `__log__`、
// および `is_schema = true` のスキーマページ)は通常一覧から除外する。
// クライアントがそれらを編集するための専用 UI が別にあるため、`/api/pages`
// で返すと NotFound 化したり、ヘッダ付きカードの中に編集不能な行が混ざる。
// include_special=true を指定したクライアントのみオプトインで取得できる。
// Hide internal/system pages (special_kind set or is_schema=true) from the
// generic listing; clients that need them can opt in with include_special=true.
const includeSpecial = c.req.query("include_special") === "true";
const specialKindFilter = includeSpecial
? sql`TRUE`
: sql`p.special_kind IS NULL AND p.is_schema = false`;

// `note_id` を返すことで、`scope=shared` で混在 listing を受け取った
// クライアントが個人ページ(`note_id IS NULL`)とノートネイティブページを
// 区別できる。MCP の `zedi_list_pages` ツールはこれに依存している。
// Surface `note_id` so callers receiving mixed `scope=shared` results (e.g.
// the `zedi_list_pages` MCP tool) can distinguish personal vs note-native.
const result = await db.execute(sql`
SELECT p.id, p.title, p.content_preview, p.updated_at, p.note_id
FROM pages p
WHERE p.is_deleted = false
AND ${specialKindFilter}
AND ${accessFilter}
ORDER BY p.updated_at DESC
LIMIT ${limit}
OFFSET ${offset}
`);

return c.json({ pages: result.rows });
c.header("Deprecation", "true");
return c.json(
{
message:
"GET /api/pages is gone (issue #823). Use GET /api/notes/me then GET /api/notes/:noteId/pages.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep /api/pages listing compatible for existing callers

Returning 410 for GET /api/pages here breaks in-repo consumers that still rely on this contract, such as MCP listPages() in server/mcp/src/client/httpClient.ts (it still calls /api/pages and expects a { pages: [...] } payload). Because this commit does not migrate those callers in the same change, zedi_list_pages and other legacy clients will start failing as soon as this API change is deployed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

ご指摘のとおり 410 だけだと MCP の listPages が壊れるため、GET /api/pages を 200 の後方互換シムとして復元しました (Deprecation: true ヘッダ付き)。応答形は { pages } のまま、フィルタは Issue #823 の pages.note_id モデルに合わせています。

Comment on lines +362 to +366
let resolvedNoteId =
typeof body.note_id === "string" && body.note_id.trim() !== "" ? body.note_id.trim() : null;
if (!resolvedNoteId) {
const defaultNote = await ensureDefaultNote(db, userId);
resolvedNoteId = defaultNote.id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce note access before accepting note_id on create

This accepts any non-empty note_id from the request and uses it as-is, but never checks whether the caller can access or edit that note. A user can therefore create pages under another user's note by supplying its id (or trigger unhandled FK failures for invalid ids), which is an authorization and data-integrity regression; this path should validate note permissions before insert.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

POST /api/pages でリクエストが note_id を明示した場合は、getNoteRole と canEdit で編集権を検証してから insert するようにしました。note_id 省略時は従来どおり ensureDefaultNote によるマイノート解決のみです。

@otomatty otomatty self-assigned this May 8, 2026
… prefetch)

- Restore GET /api/pages as a deprecation shim (200 + Deprecation header) using
  pages.note_id and the same own/shared access model as search, so MCP listPages
  keeps working.
- POST /api/pages: require getNoteRole + canEdit when the client supplies note_id.
- GET /api/search scope=own: resolve default note via getDefaultNoteOrNull instead
  of a SQL subquery; early-empty when missing.
- Drop redundant Array.isArray guards on Drizzle select results in helpers,
  pageAccessService, pageSnapshots, and syncPages.

Co-authored-by: Cursor <cursoragent@cursor.com>
@otomatty

otomatty commented May 8, 2026

Copy link
Copy Markdown
Owner Author

レビューコメントへの対応をコミットしました(6cedb500)。GET /api/search の scope=own 最適化、GET /api/pages の後方互換シム、POST /api/pages の note_id 認可、Array.isArray 整理を含みます。再レビューをお願いします。

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown

@otomatty コミット 6cedb500 での対応を確認しました。再レビューを開始します。

✅ Actions performed

Review triggered.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/api/src/routes/search.ts (1)

71-99: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Exclude schema/special pages from generic search.

This query now matches every non-deleted page in the note. In the same PR, wikiSchema and __index__ creation paths started attaching those system pages to the owner's default note, so /api/search will now surface them in normal results, including to collaborators on a shared default note. If those rows are meant to stay behind their dedicated APIs, filter them out here too.

🔧 Suggested fix
       WHERE p.is_deleted = false
+        AND p.is_schema = false
+        AND p.special_kind IS NULL
         AND (
           EXISTS (
             SELECT 1 FROM notes n
@@
       WHERE p.is_deleted = false
+        AND p.is_schema = false
+        AND p.special_kind IS NULL
         AND p.note_id = ${defaultNote.id}
         AND (
           p.title ILIKE ${pattern}

Also applies to: 102-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/routes/search.ts` around lines 71 - 99, The search query in
db.execute(sql`...`) currently returns system/schema pages; update the WHERE
clause for the pages table (alias p) to explicitly exclude schema/special pages
by adding predicates such as p.is_schema = false (or p.schema IS NULL / NOT IN
('wikiSchema', ...)) and exclude known special paths (e.g. p.path !=
'__index__'), so these rows are filtered out from generic search results; apply
the same predicate changes to the other search query block referenced around
lines 102-118 to keep behavior consistent.
server/api/src/services/titleRenamePropagationService.ts (1)

222-235: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Skip deleted source pages during ghost-link promotion.

The new note-scoped candidate query joins pages but never filters out soft-deleted sources. A rename can therefore promote stale ghost links from deleted pages back into real links rows.

🔧 Suggested fix
       .where(
         and(
           sql`LOWER(TRIM(${ghostLinks.linkText})) = LOWER(TRIM(${newTitle}))`,
           ne(ghostLinks.sourcePageId, renamedPageId),
+          eq(pages.isDeleted, false),
           eq(pages.noteId, scope.noteId),
         ),
       );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/services/titleRenamePropagationService.ts` around lines 222 -
235, The candidates query can promote ghost links from soft-deleted source pages
because it joins pages but doesn't exclude deleted rows; update the where clause
for the query that builds candidates (the select from ghostLinks joined with
pages using ghostLinks.sourcePageId) to add a condition excluding soft-deleted
pages — e.g. require the pages deletion marker to be null (pages.deletedAt IS
NULL or pages.deleted_at IS NULL / pages.isDeleted = false depending on your
schema) so that ghostLinks from deleted source pages are skipped when promoting
to links.
🧹 Nitpick comments (4)
server/api/src/routes/notes/crud.ts (1)

338-339: ⚡ Quick win

Add the English half of this new inline doc.

This new note_id comment is Japanese-only right now. Please add the matching English line or remove the comment if it is not needed.

As per coding guidelines "Comments and documentation should include both Japanese and English".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/routes/notes/crud.ts` around lines 338 - 339, The inline
comment above the object property note_id (value p.noteId) is currently
Japanese-only; add the matching English comment line (e.g., "Belonging note ID
(from Issue `#823` onward this note ID is always used).") directly above or beside
the existing Japanese comment so both languages are present, or remove the
comment entirely if unnecessary; update the comment near the note_id: p.noteId
property to comply with the bilingual guideline.
server/api/src/__tests__/routes/syncPages.test.ts (1)

20-35: ⚡ Quick win

Make the new test comments bilingual.

The new block comment at Line 20 and the helper doc at Line 44 only carry one language each. Please add the missing English/Japanese half or drop the comments so this file stays consistent with the repo’s documentation convention.

As per coding guidelines "Comments and documentation should include both Japanese and English".

Also applies to: 44-53

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/__tests__/routes/syncPages.test.ts` around lines 20 - 35, The
inline comment above the mock for ensureDefaultNote and the helper doc later in
syncPages.test.ts are only in one language; update those comments to include
both Japanese and English (or remove them) to match the repository convention.
Locate the comment near the vi.mock for ensureDefaultNote and the helper doc
block that documents test behavior, and either add the corresponding
English/Japanese translation so both languages are present or delete the comment
blocks entirely so the file stays consistent with the bilingual comment
guideline.
server/api/src/schema/pages.ts (1)

111-116: Consider indexing the new live-note read path.

A lot of the PR now reads pages with WHERE note_id = ? AND is_deleted = false ORDER BY updated_at DESC (for example note detail and search). idx_pages_note_id helps the filter, but a partial/composite index such as (note_id, updated_at DESC) WHERE is_deleted = false would match the post-note_pages hot path much more closely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/schema/pages.ts` around lines 111 - 116, The existing index
"idx_pages_note_id" on table.noteId should be replaced or supplemented with a
composite/partial index tailored to the hot read path that queries pages WHERE
note_id = ? AND is_deleted = false ORDER BY updated_at DESC; update the schema
in pages.ts to create a composite index covering (note_id, updated_at DESC) with
a partial filter is_deleted = false (or an equivalent DB-specific partial index
expression) so the queries on pages (note_id, is_deleted, updated_at) use the
index for both filtering and ordering.
server/api/src/routes/syncPages.ts (1)

205-223: ⚡ Quick win

Keep ExistingRow.noteId non-null to match the migrated schema.

pages.noteId is now mandatory, but this local type still allows null. That weakens the new invariant in exactly the route that is enforcing the default-note model, and it lets stale fixtures/branches compile unnoticed.

♻️ Proposed fix
   type ExistingRow = {
     id: string;
     ownerId: string;
-    noteId: string | null;
+    noteId: string;
     updatedAt: Date;
   };

As per coding guidelines "TypeScript strict mode; any is forbidden, explicitly declare types".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/routes/syncPages.ts` around lines 205 - 223,
ExistingRow.noteId is declared nullable but pages.noteId is now non-nullable;
update the local type and related handling so the invariant matches the migrated
schema. Change ExistingRow.noteId from string | null to string, ensure any code
that consumes existingRows (e.g., logic after existingRaw assignment in
syncPages.ts) no longer expects null and remove/null-guard branches if present,
and fix any fixtures/tests that produced null noteId values so existingRaw
assignment (which selects pages.noteId) always maps to the non-null string type.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql`:
- Around line 16-29: The INSERT into "notes" that creates a user's default note
should be made idempotent by adding explicit conflict handling: modify the
INSERT INTO "notes" (the statement that selects u."id", COALESCE(u."name", '')
|| 'のノート', ... and tests NOT EXISTS for n."is_default") to include an ON
CONFLICT ... DO NOTHING clause (targeting the unique constraint that would
conflict for a user's default note—e.g., the unique/index on owner_id +
is_default or the notes primary key) so reruns or races won't error; update the
statement at the statement-breakpoint accordingly.

In `@server/api/src/__tests__/routes/media.test.ts`:
- Line 50: The test file media.test.ts contains new English-only inline comments
(e.g., the "/* stub — ownership probe on /confirm */" comment around the
/confirm test and another at line 93); update those comments to include Japanese
equivalents alongside the English so they follow the existing JP/EN
paired-comment convention used throughout the file—locate the English-only
comment text and add a Japanese translation immediately before or after it
(matching nearby comment formatting and style).

In `@server/api/src/routes/notes/search.ts`:
- Around line 6-14: The route is wrapped with authRequired which blocks
unauthenticated callers, so either update the route wrapper to authOptional to
allow resolving a guest role via getNoteRole (change the middleware from
authRequired to authOptional on the search route) or, if you want to keep
requiring authentication, tighten the doc comment to remove “guest” as an
allowed resolver; locate the search route that currently uses authRequired and
either replace that middleware with authOptional or update the comment block
above (and any similar occurrences) to state that only authenticated roles are
supported.

---

Outside diff comments:
In `@server/api/src/routes/search.ts`:
- Around line 71-99: The search query in db.execute(sql`...`) currently returns
system/schema pages; update the WHERE clause for the pages table (alias p) to
explicitly exclude schema/special pages by adding predicates such as p.is_schema
= false (or p.schema IS NULL / NOT IN ('wikiSchema', ...)) and exclude known
special paths (e.g. p.path != '__index__'), so these rows are filtered out from
generic search results; apply the same predicate changes to the other search
query block referenced around lines 102-118 to keep behavior consistent.

In `@server/api/src/services/titleRenamePropagationService.ts`:
- Around line 222-235: The candidates query can promote ghost links from
soft-deleted source pages because it joins pages but doesn't exclude deleted
rows; update the where clause for the query that builds candidates (the select
from ghostLinks joined with pages using ghostLinks.sourcePageId) to add a
condition excluding soft-deleted pages — e.g. require the pages deletion marker
to be null (pages.deletedAt IS NULL or pages.deleted_at IS NULL /
pages.isDeleted = false depending on your schema) so that ghostLinks from
deleted source pages are skipped when promoting to links.

---

Nitpick comments:
In `@server/api/src/__tests__/routes/syncPages.test.ts`:
- Around line 20-35: The inline comment above the mock for ensureDefaultNote and
the helper doc later in syncPages.test.ts are only in one language; update those
comments to include both Japanese and English (or remove them) to match the
repository convention. Locate the comment near the vi.mock for ensureDefaultNote
and the helper doc block that documents test behavior, and either add the
corresponding English/Japanese translation so both languages are present or
delete the comment blocks entirely so the file stays consistent with the
bilingual comment guideline.

In `@server/api/src/routes/notes/crud.ts`:
- Around line 338-339: The inline comment above the object property note_id
(value p.noteId) is currently Japanese-only; add the matching English comment
line (e.g., "Belonging note ID (from Issue `#823` onward this note ID is always
used).") directly above or beside the existing Japanese comment so both
languages are present, or remove the comment entirely if unnecessary; update the
comment near the note_id: p.noteId property to comply with the bilingual
guideline.

In `@server/api/src/routes/syncPages.ts`:
- Around line 205-223: ExistingRow.noteId is declared nullable but pages.noteId
is now non-nullable; update the local type and related handling so the invariant
matches the migrated schema. Change ExistingRow.noteId from string | null to
string, ensure any code that consumes existingRows (e.g., logic after
existingRaw assignment in syncPages.ts) no longer expects null and
remove/null-guard branches if present, and fix any fixtures/tests that produced
null noteId values so existingRaw assignment (which selects pages.noteId) always
maps to the non-null string type.

In `@server/api/src/schema/pages.ts`:
- Around line 111-116: The existing index "idx_pages_note_id" on table.noteId
should be replaced or supplemented with a composite/partial index tailored to
the hot read path that queries pages WHERE note_id = ? AND is_deleted = false
ORDER BY updated_at DESC; update the schema in pages.ts to create a composite
index covering (note_id, updated_at DESC) with a partial filter is_deleted =
false (or an equivalent DB-specific partial index expression) so the queries on
pages (note_id, is_deleted, updated_at) use the index for both filtering and
ordering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1c470cf6-0b90-47d4-bf08-740556c00313

📥 Commits

Reviewing files that changed from the base of the PR and between 49ba374 and 6cedb50.

📒 Files selected for processing (33)
  • server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql
  • server/api/drizzle/meta/_journal.json
  • server/api/src/__tests__/routes/media.test.ts
  • server/api/src/__tests__/routes/notes/crud.test.ts
  • server/api/src/__tests__/routes/notes/pages.test.ts
  • server/api/src/__tests__/routes/notes/search.test.ts
  • server/api/src/__tests__/routes/notes/setup.ts
  • server/api/src/__tests__/routes/pageSnapshots.test.ts
  • server/api/src/__tests__/routes/pages.test.ts
  • server/api/src/__tests__/routes/search.test.ts
  • server/api/src/__tests__/routes/syncPages.test.ts
  • server/api/src/__tests__/services/pageAccessService.test.ts
  • server/api/src/lib/clipAndCreate.ts
  • server/api/src/lib/welcomePageService.ts
  • server/api/src/routes/notes/crud.ts
  • server/api/src/routes/notes/helpers.ts
  • server/api/src/routes/notes/pages.ts
  • server/api/src/routes/notes/search.ts
  • server/api/src/routes/notes/types.ts
  • server/api/src/routes/pageSnapshots.ts
  • server/api/src/routes/pages.ts
  • server/api/src/routes/search.ts
  • server/api/src/routes/syncPages.ts
  • server/api/src/routes/wikiSchema.ts
  • server/api/src/schema/index.ts
  • server/api/src/schema/notes.ts
  • server/api/src/schema/pages.ts
  • server/api/src/schema/relations.ts
  • server/api/src/services/defaultNoteService.ts
  • server/api/src/services/indexBuilder.ts
  • server/api/src/services/pageAccessService.ts
  • server/api/src/services/titleRenamePropagationService.ts
  • server/api/src/types/dbOrTx.ts
💤 Files with no reviewable changes (1)
  • server/api/src/schema/index.ts

Comment thread server/api/drizzle/0023_migrate_personal_pages_drop_note_pages.sql
Comment thread server/api/src/__tests__/routes/media.test.ts Outdated
Comment thread server/api/src/routes/notes/search.ts
…s, comments)

- 0023 migration: append ON CONFLICT DO NOTHING targeting the partial unique
  index on owner_id for default-note inserts.
- notes/search.ts: clarify JSDoc that authRequired applies; guest role is for
  authenticated callers with note access.
- media.test.ts: restore JP/EN paired comments on HeadObject stub and beforeEach.

Co-authored-by: Cursor <cursoragent@cursor.com>
@otomatty

otomatty commented May 9, 2026

Copy link
Copy Markdown
Owner Author

CodeRabbit 追従コミットを push しました(005f0d33)。0023 の ON CONFLICT、media.test の JP/EN コメント、notes/search の JSDoc 認証整理です。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

既存個人ページをデフォルトノートに移行し note_pages を廃止する (default note PR 1b)

1 participant