Skip to content

feat(api): keyset pagination + index for note pages (#860 Phase 1/2)#865

Merged
otomatty merged 2 commits into
developfrom
claude/issue-860-remaining-tasks-8jLJV
May 13, 2026
Merged

feat(api): keyset pagination + index for note pages (#860 Phase 1/2)#865
otomatty merged 2 commits into
developfrom
claude/issue-860-remaining-tasks-8jLJV

Conversation

@otomatty

@otomatty otomatty commented May 13, 2026

Copy link
Copy Markdown
Owner

Phase 1 — GET /api/notes/:noteId/pagesauthRequired から
authOptional + getNoteRole に切り替え、公開 / unlisted ノートの guest
でも取得可能にする。同時に keyset cursor pagination
(?cursor=&limit=&include=) を導入し、レスポンスを
{ items, next_cursor } の新形状にする。content_preview /
thumbnail_urlinclude=preview / include=thumbnail 時のみ
セットする。

Phase 2 — keyset pagination 用に
pages (note_id, updated_at DESC, id DESC) WHERE is_deleted = false
部分複合インデックスを追加する。(updated_at, id) の tie-break
予測子も index 内で解決できるよう既存
idx_pages_note_active_updated の隣に併設する。

https://claude.ai/code/session_016XUBJqunaZPnLTdB4eFTzt

Summary by CodeRabbit

  • New Features

    • Pages endpoint now supports efficient keyset cursor-based pagination with opaque cursor and configurable limits.
    • Response shape changed to { items, next_cursor }; preview and thumbnail fields are returned only when explicitly requested.
    • Public/unlisted notes can be accessed without authentication; private notes remain protected.
  • Tests

    • Expanded test coverage for pagination behavior, cursor validation, include flags, and precision-preserving cursor round-trip.

Review Change Stack

Phase 1 — `GET /api/notes/:noteId/pages` を `authRequired` から
`authOptional` + `getNoteRole` に切り替え、公開 / unlisted ノートの guest
でも取得可能にする。同時に keyset cursor pagination
(`?cursor=&limit=&include=`) を導入し、レスポンスを
`{ items, next_cursor }` の新形状にする。`content_preview` /
`thumbnail_url` は `include=preview` / `include=thumbnail` 時のみ
セットする。

Phase 2 — keyset pagination 用に
`pages (note_id, updated_at DESC, id DESC) WHERE is_deleted = false`
部分複合インデックスを追加する。`(updated_at, id)` の tie-break
予測子も index 内で解決できるよう既存
`idx_pages_note_active_updated` の隣に併設する。

https://claude.ai/code/session_016XUBJqunaZPnLTdB4eFTzt
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 93fbc4be-dbf6-4f9f-b2f8-59375ec9e6b8

📥 Commits

Reviewing files that changed from the base of the PR and between 4fcd712 and d801c97.

📒 Files selected for processing (2)
  • server/api/src/__tests__/routes/notes/pages.test.ts
  • server/api/src/routes/notes/pages.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/api/src/routes/notes/pages.ts
  • server/api/src/tests/routes/notes/pages.test.ts

📝 Walkthrough

Walkthrough

This PR implements keyset cursor pagination for GET /api/notes/:noteId/pages by adding a partial index, new response types, cursor encoding/decoding and query parsing, handler logic that returns { items, next_cursor } with optional preview/thumbnail inclusion, switching the route to authOptional, and updated tests.

Changes

Note pages cursor pagination

Layer / File(s) Summary
Database index for pagination support
server/api/src/schema/pages.ts, server/api/drizzle/0027_add_pages_note_active_updated_id_index.sql, server/api/drizzle/meta/_journal.json
Creates a partial composite index idx_pages_note_active_updated_id on (note_id, updated_at DESC, id DESC) filtered to non-deleted rows, enabling keyset pagination queries to be satisfied via index-only scans.
Response contract types for paginated window
server/api/src/routes/notes/types.ts
Introduces NotePageWindowInclude, NotePageWindowItem, and NotePageWindowResponse types modeling cursor-paginated responses where content_preview and thumbnail_url are null unless explicitly requested via ?include=. Updates NotePageApiItem.content_preview documentation for Phase 1 compatibility.
Cursor and query parsing primitives
server/api/src/routes/notes/pages.ts
Adds PagesCursor encoding/decoding helpers with validation, and query parsers for clamped limit and normalized include. Updates endpoint docs/middleware to use authOptional and role resolution.
Route handler with cursor pagination logic
server/api/src/routes/notes/pages.ts
Implements keyset-predicated query (ORDER BY updatedAt DESC, id DESC), fetches limit + 1 rows, computes microsecond-precision next_cursor, maps rows to items, conditionally includes preview/thumbnail, and returns { items, next_cursor }.
Test validation of pagination behavior
server/api/src/__tests__/routes/notes/pages.test.ts, server/api/src/__tests__/routes/notes/setup.ts
Validates new { items, next_cursor } response contract, default null behavior for preview/thumbnail, conditional ?include= behavior, cursor pagination presence/nulling, 400 handling for malformed cursors, and guest access on public notes. Removes shared createMockPageListRow and adds local buildPageRow helper.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • otomatty/zedi#714: Introduced the pages.note_id scaffolding and related indexing that this change builds upon.
  • otomatty/zedi#855: Added a partial pages index; this PR extends that index ordering to include id DESC for keyset tie-breaking.

Poem

🐰 A rabbit hops through pages so fleet,
Cursors curled tight, indices neat,
Microsecond ticks kept safe in thread,
Items and next_cursor—forward we tread,
Preview or thumbnail if you call, quick and sweet.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: implementing keyset pagination and adding a supporting database index for note pages, with explicit reference to the issue and phase.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/issue-860-remaining-tasks-8jLJV

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 keyset cursor pagination for the GET /api/notes/:noteId/pages endpoint, supported by a new partial composite index on the pages table. The implementation includes cursor encoding/decoding, limit clamping, and optional field inclusion for previews and thumbnails. Feedback from the review identifies a critical precision loss issue where JavaScript's millisecond-precision Date objects may skip records when compared against PostgreSQL's microsecond-precision timestamps; it is recommended to use high-precision strings for cursor values. Additionally, a suggestion was made to use the Drizzle eq operator instead of raw SQL for timestamp comparisons to maintain consistency and readability.

Comment on lines +311 to +314
const keysetPredicate = or(
lt(pages.updatedAt, cursorTs),
and(sql`${pages.updatedAt} = ${cursorTs}`, lt(pages.id, cursor.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.

high

The current keyset pagination logic is susceptible to precision loss. JavaScript's Date object and toISOString() only support millisecond precision, whereas PostgreSQL's timestamp columns (and the defaultNow() used in the schema) support microsecond precision.

When a cursor is generated from a row with microseconds (e.g., .123456), it is truncated to milliseconds (e.g., .123) in the cursor payload. The subsequent query using lt(pages.updatedAt, cursorTs) will then skip any rows that fall within that same millisecond but have higher microsecond values than the truncated cursor.

To fix this, you should fetch the updated_at value as a high-precision string in the select clause (using to_char) and use that string directly in the SQL comparison with a cast, rather than converting it to a JS Date object.

Comment thread server/api/src/routes/notes/pages.ts Outdated
hasMore && last
? encodePagesCursor({
updatedAt:
last.updatedAt instanceof Date ? last.updatedAt.toISOString() : String(last.updatedAt),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

As mentioned in the previous comment regarding precision, toISOString() truncates microseconds. For a robust keyset cursor, consider selecting updated_at as a formatted string with full microsecond precision directly from the database.

Comment thread server/api/src/routes/notes/pages.ts Outdated
// without resorting to a non-null assertion.
const keysetPredicate = or(
lt(pages.updatedAt, cursorTs),
and(sql`${pages.updatedAt} = ${cursorTs}`, lt(pages.id, cursor.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.

medium

Using sql for a simple equality check is unnecessary here. You can use the eq operator from Drizzle for better readability and consistency with the rest of the codebase.

Suggested change
and(sql`${pages.updatedAt} = ${cursorTs}`, lt(pages.id, cursor.id)),
and(eq(pages.updatedAt, cursorTs), lt(pages.id, cursor.id)),

@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: 4fcd712c90

ℹ️ 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/notes/pages.ts Outdated
Comment on lines +345 to +346
updatedAt:
last.updatedAt instanceof Date ? last.updatedAt.toISOString() : String(last.updatedAt),

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 Preserve full timestamp precision in pagination cursor

next_cursor is built from last.updatedAt.toISOString(), which only keeps millisecond precision, but pages.updated_at is timestamp with time zone (microsecond precision by default). When two rows on a page boundary share the same millisecond but different microseconds, the follow-up predicate (updated_at < cursorTs OR (updated_at = cursorTs AND id < ...)) can skip valid rows entirely because the cursor timestamp was truncated. This makes keyset pagination lossy under real write patterns (e.g., fast inserts/updates). Store a lossless sort key in the cursor (e.g., raw DB timestamp string or epoch micros) and compare against that exact precision.

Useful? React with 👍 / 👎.

@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: 2

🤖 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/src/__tests__/routes/notes/pages.test.ts`:
- Line 151: Update the English-only test comments in
server/api/src/__tests__/routes/notes/pages.test.ts to include Japanese
translations alongside the English text; specifically replace/comment the line
containing "Builds a page row matching the new SELECT in `pages.ts`." and the
other referenced comment lines (the ones around the same test block and the
comments that appear near the SELECT-related tests) with bilingual comments —
English followed by Japanese — keeping the same meaning and context for each
comment.

In `@server/api/src/routes/notes/pages.ts`:
- Around line 85-92: The decoded cursor validation in decodePagesCursor
currently only checks that id is a string but not that it's a valid UUID, which
can leak invalid values into the DB keyset predicate; update decodePagesCursor
to validate cursor.id as a proper UUID (e.g., using a UUID regex or util/isUuid
helper) and throw HTTPException(400, { message: "Invalid cursor" }) when it
fails; ensure the function (decodePagesCursor) returns only { updatedAt, id }
with id guaranteed to be a valid UUID so downstream code that builds the keyset
predicate (using cursor.id) never receives invalid input.
🪄 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: b6af5ef8-84ec-4c4b-89da-50d56c2d7f95

📥 Commits

Reviewing files that changed from the base of the PR and between 478fce4 and 4fcd712.

📒 Files selected for processing (7)
  • server/api/drizzle/0027_add_pages_note_active_updated_id_index.sql
  • server/api/drizzle/meta/_journal.json
  • server/api/src/__tests__/routes/notes/pages.test.ts
  • server/api/src/__tests__/routes/notes/setup.ts
  • server/api/src/routes/notes/pages.ts
  • server/api/src/routes/notes/types.ts
  • server/api/src/schema/pages.ts
💤 Files with no reviewable changes (1)
  • server/api/src/tests/routes/notes/setup.ts

Comment thread server/api/src/__tests__/routes/notes/pages.test.ts Outdated
Comment thread server/api/src/routes/notes/pages.ts
PR #865 のレビュー指摘への対応:

- keyset cursor の `updatedAt` を pg 側で `to_char(... 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`
  経由のマイクロ秒 ISO 文字列として持ち回し、比較は `::timestamptz` キャストで
  突合する。pg ドライバ経由で JS Date に丸まる経路を断ち、同一ミリ秒で別マイクロ秒
  の行を取りこぼす不具合を防ぐ (gemini-code-assist / chatgpt-codex on #865)。
- cursor.id を RFC 4122 UUID 正規表現で 400 に倒し、pg `uuid` キャストの `22P02`
  500 を未然に防ぐ (coderabbitai on #865)。
- tie-break を `sql\`=\`` から `eq()` に統一 (gemini-code-assist medium)。
- 新規追加した英語コメントに日本語訳を併記 (coderabbitai on #865)。
- マイクロ秒精度保持と非 UUID 拒否のリグレッションテストを追加。

https://claude.ai/code/session_016XUBJqunaZPnLTdB4eFTzt
@otomatty otomatty self-assigned this May 13, 2026
@otomatty otomatty merged commit 12151a7 into develop May 13, 2026
16 checks passed
@otomatty otomatty deleted the claude/issue-860-remaining-tasks-8jLJV branch May 13, 2026 22:14
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.

[Epic] 超巨大ノート対応: ページ一覧の段階取得とプレビュー配信の再設計

2 participants