diff --git a/server/api/drizzle/0027_add_pages_note_active_updated_id_index.sql b/server/api/drizzle/0027_add_pages_note_active_updated_id_index.sql new file mode 100644 index 00000000..4c804d63 --- /dev/null +++ b/server/api/drizzle/0027_add_pages_note_active_updated_id_index.sql @@ -0,0 +1,29 @@ +-- 0027: Issue #860 Phase 2 — keyset cursor pagination 用に `pages` の +-- `(note_id, updated_at DESC, id DESC) WHERE is_deleted = false` 部分複合 +-- インデックスを追加する。`GET /api/notes/:noteId/pages` の +-- `WHERE note_id = $1 AND is_deleted = false +-- AND (updated_at < $cur_ts OR (updated_at = $cur_ts AND id < $cur_id)) +-- ORDER BY updated_at DESC, id DESC` を index-only で進められるようにし、 +-- `(updated_at, id)` の tie-break もインデックス内で完結させる。既存の +-- `idx_pages_note_active_updated` は `(note_id, updated_at DESC)` のままなので、 +-- 旧 detail エンドポイント側のクエリプランは変化しない。重複可否は今 phase +-- では判断せず、本 phase ではまず併存させて EXPLAIN で確認する。 +-- +-- 0027: Issue #860 Phase 2 — add the partial composite index +-- `pages (note_id, updated_at DESC, id DESC) WHERE is_deleted = false` so the +-- keyset cursor pagination on `GET /api/notes/:noteId/pages` (added in Phase +-- 1) can satisfy +-- `WHERE note_id = $1 AND is_deleted = false +-- AND (updated_at < $cur_ts OR (updated_at = $cur_ts AND id < $cur_id)) +-- ORDER BY updated_at DESC, id DESC` +-- as an index-only scan, including the `(updated_at, id)` tie-break leg. The +-- existing `idx_pages_note_active_updated` remains intact so the legacy note +-- detail listing keeps its current plan. Deciding whether the old index can +-- be dropped is deferred to a later phase once production query plans are +-- confirmed. +-- +-- Idempotent / re-run safety: CREATE INDEX IF NOT EXISTS. + +CREATE INDEX IF NOT EXISTS "idx_pages_note_active_updated_id" + ON "pages" ("note_id", "updated_at" DESC, "id" DESC) + WHERE "is_deleted" = false; diff --git a/server/api/drizzle/meta/_journal.json b/server/api/drizzle/meta/_journal.json index cd4f28d3..03baab88 100644 --- a/server/api/drizzle/meta/_journal.json +++ b/server/api/drizzle/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1778889600000, "tag": "0026_add_pdf_highlights", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1778976000000, + "tag": "0027_add_pages_note_active_updated_id_index", + "breakpoints": true } ] } diff --git a/server/api/src/__tests__/routes/notes/pages.test.ts b/server/api/src/__tests__/routes/notes/pages.test.ts index d4026340..72517b72 100644 --- a/server/api/src/__tests__/routes/notes/pages.test.ts +++ b/server/api/src/__tests__/routes/notes/pages.test.ts @@ -29,7 +29,6 @@ import { OTHER_USER_ID, TEST_USER_EMAIL, createMockNote, - createMockPageListRow, createTestApp, authHeaders, } from "./setup.js"; @@ -148,14 +147,44 @@ describe("POST /api/notes/:noteId/pages", () => { }); }); -describe("GET /api/notes/:noteId/pages", () => { - it("lists pages filtered by pages.note_id ordered by updated_at desc", async () => { +describe("GET /api/notes/:noteId/pages (Issue #860 Phase 1 cursor window)", () => { + /** + * `pages.ts` の新しい SELECT に合わせたページ行を生成する。 + * `updatedAtIso` は本番経路で pg `to_char(... 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` + * から返るマイクロ秒精度の ISO 文字列。 + * + * Builds a page row matching the new SELECT in `pages.ts`. `updatedAtIso` + * is the microsecond-precision ISO string produced by pg + * `to_char(... 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` in the real path. + */ + function buildPageRow(overrides: Record = {}) { + const updatedAt = (overrides.updatedAt as Date | undefined) ?? new Date("2026-01-01T00:00:00Z"); + return { + id: "pg-1", + ownerId: TEST_USER_ID, + noteId: NOTE_ID, + sourcePageId: null, + title: "First", + contentPreview: "preview body...", + thumbnailUrl: "https://cdn.example/thumb-1.jpg", + sourceUrl: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt, + // Default mirrors `to_char(..., 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` (microseconds). + // 既定値は pg の `to_char` 出力(マイクロ秒精度の ISO 文字列)を模倣する。 + updatedAtIso: `${updatedAt.toISOString().replace("Z", "")}000Z`, + isDeleted: false, + ...overrides, + }; + } + + it("returns items with content_preview/thumbnail nulled by default", async () => { const mockNote = createMockNote(); - const row1 = createMockPageListRow({ page_id: "pg-1", page_title: "First" }); - const row2 = createMockPageListRow({ - page_id: "pg-2", - page_title: "Second", - page_updated_at: new Date("2026-02-01T00:00:00Z"), + const row1 = buildPageRow({ id: "pg-1", title: "First" }); + const row2 = buildPageRow({ + id: "pg-2", + title: "Second", + updatedAt: new Date("2026-02-01T00:00:00Z"), }); const { app } = createTestApp([[mockNote], [row1, row2]]); @@ -165,10 +194,193 @@ describe("GET /api/notes/:noteId/pages", () => { }); expect(res.status).toBe(200); - const body = (await res.json()) as { pages: Array> }; - expect(body.pages).toHaveLength(2); - expect(body.pages[0]).toMatchObject({ page_id: "pg-1", page_title: "First" }); - expect(body.pages[0]).not.toHaveProperty("sort_order"); + const body = (await res.json()) as { + items: Array>; + next_cursor: string | null; + }; + expect(body.items).toHaveLength(2); + expect(body.next_cursor).toBeNull(); + expect(body.items[0]).toMatchObject({ id: "pg-1", title: "First" }); + // `?include=` を指定していない場合は preview / thumbnail は必ず null になる。 + // When `?include=` is omitted, preview and thumbnail must come back as null. + expect(body.items[0]?.content_preview).toBeNull(); + expect(body.items[0]?.thumbnail_url).toBeNull(); + }); + + it("includes content_preview when ?include=preview is set", async () => { + const mockNote = createMockNote(); + const row = buildPageRow({ id: "pg-1", contentPreview: "hello preview" }); + const { app } = createTestApp([[mockNote], [row]]); + + const res = await app.request(`/api/notes/${NOTE_ID}/pages?include=preview`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array> }; + expect(body.items[0]?.content_preview).toBe("hello preview"); + expect(body.items[0]?.thumbnail_url).toBeNull(); + }); + + it("includes thumbnail_url when ?include=thumbnail is set", async () => { + const mockNote = createMockNote(); + const row = buildPageRow({ + id: "pg-1", + contentPreview: "hello preview", + thumbnailUrl: "https://cdn.example/t.jpg", + }); + const { app } = createTestApp([[mockNote], [row]]); + + const res = await app.request(`/api/notes/${NOTE_ID}/pages?include=thumbnail`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array> }; + expect(body.items[0]?.thumbnail_url).toBe("https://cdn.example/t.jpg"); + expect(body.items[0]?.content_preview).toBeNull(); + }); + + it("emits next_cursor when more rows are available", async () => { + const mockNote = createMockNote(); + // `next_cursor` を出すには limit+1 件返す必要がある。 + // The route emits `next_cursor` only when limit+1 rows come back. + const rows = Array.from({ length: 3 }, (_, i) => + buildPageRow({ + id: `pg-${i}`, + title: `Page ${i}`, + updatedAt: new Date(`2026-03-0${i + 1}T00:00:00Z`), + }), + ); + const { app } = createTestApp([[mockNote], rows]); + + const res = await app.request(`/api/notes/${NOTE_ID}/pages?limit=2`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array>; + next_cursor: string | null; + }; + expect(body.items).toHaveLength(2); + expect(typeof body.next_cursor).toBe("string"); + expect(body.next_cursor && body.next_cursor.length).toBeGreaterThan(0); + }); + + it("returns null next_cursor when result fits the page", async () => { + const mockNote = createMockNote(); + const rows = [buildPageRow({ id: "pg-only" })]; + const { app } = createTestApp([[mockNote], rows]); + + const res = await app.request(`/api/notes/${NOTE_ID}/pages?limit=5`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array>; + next_cursor: string | null; + }; + expect(body.items).toHaveLength(1); + expect(body.next_cursor).toBeNull(); + }); + + it("rejects malformed cursor payloads with 400", async () => { + const mockNote = createMockNote(); + const { app } = createTestApp([[mockNote], []]); + + // base64url("{}") はデコード自体は成功するが updatedAt / id を欠くため 400 になる。 + // `base64url("{}")` decodes cleanly but lacks updatedAt/id, so the route rejects it as 400. + const badCursor = Buffer.from("{}", "utf8").toString("base64url"); + const res = await app.request(`/api/notes/${NOTE_ID}/pages?cursor=${badCursor}`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(400); + }); + + it("rejects cursor whose id is not a UUID with 400", async () => { + // `cursor.id` が UUID として不正だと pg `uuid` キャストが 22P02 を投げて + // 500 になるため、ルート側で先に 400 に倒す必要がある(coderabbitai review on #865)。 + // + // A non-UUID `cursor.id` would otherwise reach the pg `uuid` cast and + // surface as a `22P02` 500. The route should 400 it up front instead + // (coderabbitai review on PR #865). + const mockNote = createMockNote(); + const { app } = createTestApp([[mockNote], []]); + + const cursor = Buffer.from( + JSON.stringify({ updatedAt: "2026-01-01T00:00:00.000000Z", id: "not-a-uuid" }), + "utf8", + ).toString("base64url"); + + const res = await app.request(`/api/notes/${NOTE_ID}/pages?cursor=${cursor}`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(400); + }); + + it("preserves microsecond precision in next_cursor (no Date.toISOString truncation)", async () => { + // pg は `timestamp with time zone` をマイクロ秒精度で持つが、JS Date は + // ミリ秒まで。ルートは pg 側の `to_char(...)` を `updatedAtIso` として + // 受け取り、それをそのまま cursor に詰めるため、マイクロ秒桁が失われない + // ことを確認する(gemini-code-assist + codex on PR #865)。 + // + // Postgres stores `timestamp with time zone` at microsecond precision, + // but JS `Date` truncates to milliseconds. The route receives the + // microsecond ISO string from pg via `to_char(...)` as `updatedAtIso` + // and copies it verbatim into the cursor, which the assertion verifies + // (gemini-code-assist + codex on PR #865). + const microIso = "2026-04-01T12:34:56.123456Z"; + const mockNote = createMockNote(); + const rows = [ + buildPageRow({ id: "11111111-1111-4111-8111-111111111111", updatedAtIso: microIso }), + buildPageRow({ id: "22222222-2222-4222-8222-222222222222", updatedAtIso: microIso }), + ]; + const { app } = createTestApp([[mockNote], rows]); + + const res = await app.request(`/api/notes/${NOTE_ID}/pages?limit=1`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { next_cursor: string | null }; + const nextCursor = body.next_cursor; + expect(nextCursor).not.toBeNull(); + if (nextCursor === null) throw new Error("expected next_cursor to be present"); + const decoded = JSON.parse(Buffer.from(nextCursor, "base64url").toString("utf8")) as { + updatedAt: string; + id: string; + }; + expect(decoded.updatedAt).toBe(microIso); + }); + + it("allows guest access on public notes (authOptional)", async () => { + // Public visibility では getNoteRole が認証なしでも guest として解決する。 + // Public visibility → getNoteRole resolves caller as `guest` even without auth. + const mockNote = createMockNote({ ownerId: OTHER_USER_ID, visibility: "public" }); + const row = buildPageRow(); + const { app } = createTestApp([[mockNote], [row]]); + + const res = await app.request(`/api/notes/${NOTE_ID}/pages`, { + method: "GET", + // 認証ヘッダなし: 未ログインリクエスト。 + // No x-test-user-id header → unauthenticated request. + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Array> }; + expect(body.items).toHaveLength(1); }); it("returns 403 when caller has no note role", async () => { diff --git a/server/api/src/__tests__/routes/notes/setup.ts b/server/api/src/__tests__/routes/notes/setup.ts index e355796a..2f362a14 100644 --- a/server/api/src/__tests__/routes/notes/setup.ts +++ b/server/api/src/__tests__/routes/notes/setup.ts @@ -57,18 +57,6 @@ export function createMockPageRow(overrides: Record = {}) { }; } -/** テスト用ページ一覧行のデフォルト / Default mock page list row */ -export function createMockPageListRow(overrides: Record = {}) { - return { - page_id: "page-test-001", - page_title: "Test Page", - page_content_preview: "Preview...", - page_thumbnail_url: null, - page_updated_at: new Date("2026-01-01T00:00:00Z"), - ...overrides, - }; -} - /** Mock row shape matches DB select (camelCase). Route maps to snake_case in response. */ export function createMockMember(overrides: Record = {}) { return { diff --git a/server/api/src/routes/notes/pages.ts b/server/api/src/routes/notes/pages.ts index 2a639004..7b4595bf 100644 --- a/server/api/src/routes/notes/pages.ts +++ b/server/api/src/routes/notes/pages.ts @@ -4,18 +4,169 @@ * POST /:noteId/pages — ノート配下にページ新規作成(タイトル) * DELETE /:noteId/pages/:pageId — ページ削除(所属ノート一致時) * PUT /:noteId/pages — 並び替え noop(Issue #823、`updated_at` 順を使用) - * GET /:noteId/pages — ノートのページ一覧(`pages.note_id` フィルタ) + * GET /:noteId/pages — ノートのページ一覧(keyset cursor pagination, Issue #860 Phase 1) * * Issue #823 で `copy-from-personal` / `copy-to-personal` と `page_id` リンク経路は削除。 */ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; -import { eq, and, desc } from "drizzle-orm"; +import { eq, and, or, lt, desc, sql } from "drizzle-orm"; import { notes, pages } from "../../schema/index.js"; -import { authRequired } from "../../middleware/auth.js"; +import { authRequired, authOptional } from "../../middleware/auth.js"; import type { AppEnv } from "../../types/index.js"; +import type { NotePageWindowItem, NotePageWindowResponse } from "./types.js"; import { getNoteRole, canEdit } from "./helpers.js"; +/** + * `GET /api/notes/:noteId/pages` の最大ページサイズ。issue #860 Phase 1 で 100 件 + * を上限とする。デフォルトは {@link DEFAULT_PAGES_LIMIT}。 + * + * Maximum page size for `GET /api/notes/:noteId/pages` (issue #860 Phase 1). + * Default page size is {@link DEFAULT_PAGES_LIMIT}. + */ +const MAX_PAGES_LIMIT = 100; +const DEFAULT_PAGES_LIMIT = 50; + +/** + * keyset cursor の中身。`(updated_at, id)` の組で `ORDER BY updated_at DESC, id DESC` + * を一意に進める。`updatedAt` はマイクロ秒精度を保つため、JS の `Date.toISOString()` + * ではなく PostgreSQL 側で `to_char(... at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` + * 経由で組み立てた文字列(例: `2026-05-13T12:34:56.123456Z`)をそのまま保持し、 + * 比較時は `::timestamptz` にキャストし直す。pg ドライバ経由で `Date` に + * 変換するとミリ秒に丸まるため、`defaultNow()` 由来の行を取りこぼす(issue #860 + * Phase 1 の gemini-code-assist / codex レビュー)。 + * + * Cursor payload encoding `(updated_at, id)`. `updatedAt` stores the + * Postgres-formatted ISO string with microsecond precision + * (`YYYY-MM-DDTHH24:MI:SS.USZ`) rather than `Date.toISOString()`, because the + * pg driver collapses `timestamptz` values down to JS millisecond `Date`s and + * would otherwise skip rows that share a millisecond but differ in + * microseconds (e.g. consecutive `defaultNow()` inserts). Comparisons cast + * the stored string back via `::timestamptz` so the round-trip is lossless + * (Issue #860 Phase 1; gemini-code-assist + chatgpt-codex review on #865). + */ +interface PagesCursor { + /** + * Postgres-formatted ISO timestamp string with microsecond precision + * (`YYYY-MM-DDTHH24:MI:SS.USZ`) from the last returned row's `updated_at`. + */ + updatedAt: string; + /** UUID of the last returned page. */ + id: string; +} + +/** + * RFC 4122 系の UUID 文字列を許容する正規表現。pg の `uuid` カラムへ流す前に + * cursor 由来の `id` を検証して、`22P02` (invalid_text_representation) 経由の + * 500 を避けるため使う(issue #860 Phase 1 / coderabbitai review on #865)。 + * + * Permissive RFC 4122 UUID matcher used to gate cursor `id` before it + * reaches the pg `uuid` column, so malformed values fall out as a + * deterministic 400 instead of a `22P02` 500 (Issue #860 Phase 1; + * coderabbitai review on PR #865). + */ +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Encodes a {@link PagesCursor} as opaque base64url JSON. The exact encoding + * is an implementation detail; clients must echo it back verbatim. + * + * {@link PagesCursor} を不透明な base64url JSON にエンコードする。形式は + * 実装詳細であり、クライアントは受け取った値をそのまま echo する。 + */ +function encodePagesCursor(cursor: PagesCursor): string { + const json = JSON.stringify(cursor); + return Buffer.from(json, "utf8").toString("base64url"); +} + +/** + * Decodes a client-provided cursor. Returns `null` for an empty / malformed + * input so the route can fall back to "no cursor" semantics; throws 400 when + * the decoded shape is wrong, since that means the client built a cursor it + * does not own. + * + * クライアント由来の cursor をデコードする。空 / 壊れた入力は `null` を返し、 + * 「cursor 無し」と同じ扱いに倒す。デコードできたが形が違う場合は 400 を投げる + * (他経路で組み立てた cursor をそのまま流す誤用を弾く)。 + */ +function decodePagesCursor(raw: string | undefined): PagesCursor | null { + if (!raw || raw.length === 0) return null; + let decoded: string; + try { + decoded = Buffer.from(raw, "base64url").toString("utf8"); + } catch { + return null; + } + if (!decoded) return null; + let parsed: unknown; + try { + parsed = JSON.parse(decoded); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const updatedAtRaw = (parsed as { updatedAt?: unknown }).updatedAt; + const idRaw = (parsed as { id?: unknown }).id; + if (typeof updatedAtRaw !== "string" || typeof idRaw !== "string") { + throw new HTTPException(400, { message: "Invalid cursor" }); + } + // `updatedAt` は微小精度の ISO 文字列を保持するが、JS の `Date` parser は + // マイクロ秒を捨てるため、ここでは「`Date` が解釈可能か」だけを軽く確認する。 + // 厳密な範囲チェックは pg 側の `::timestamptz` キャストに委ねる。 + // + // `updatedAt` keeps a microsecond-precision ISO string, but JS `Date` only + // parses to milliseconds. We use it as a cheap sanity check; the real + // validation happens in Postgres via `::timestamptz` at query time. + const ts = new Date(updatedAtRaw); + if (Number.isNaN(ts.getTime())) { + throw new HTTPException(400, { message: "Invalid cursor" }); + } + // cursor.id は最終的に pg の `uuid` カラム比較に流れる。不正値だと pg が + // `22P02` で 500 を返してしまうため、ここで UUID 形式を強制して 400 に倒す。 + // + // The decoded `id` will be compared against pg's `uuid` column. Anything + // that does not look like a UUID would surface as a `22P02` 500, so reject + // it deterministically as 400 here. + if (!UUID_PATTERN.test(idRaw)) { + throw new HTTPException(400, { message: "Invalid cursor" }); + } + return { updatedAt: updatedAtRaw, id: idRaw }; +} + +/** + * Parses and clamps the `limit` query parameter for the page-window endpoint. + * + * 1..{@link MAX_PAGES_LIMIT} の範囲に収まる limit を返す。未指定や不正値の場合は + * {@link DEFAULT_PAGES_LIMIT} にフォールバックする。 + */ +function parsePagesLimit(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_PAGES_LIMIT; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PAGES_LIMIT; + return Math.min(parsed, MAX_PAGES_LIMIT); +} + +/** + * `?include=preview,thumbnail` をフラグセットに正規化する。未知トークンは + * 無視する(将来追加する場合に古いクライアントが 400 で落ちないように)。 + * + * Normalizes `?include=preview,thumbnail` to a flag set. Unknown tokens are + * ignored so old clients keep working when new tokens are added later. + */ +function parsePagesInclude(raw: string | undefined): { preview: boolean; thumbnail: boolean } { + if (!raw) return { preview: false, thumbnail: false }; + const tokens = new Set( + raw + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0), + ); + return { + preview: tokens.has("preview"), + thumbnail: tokens.has("thumbnail"), + }; +} + const app = new Hono(); // ── POST /:noteId/pages ───────────────────────────────────────────────────── @@ -147,7 +298,27 @@ app.put("/:noteId/pages", authRequired, async (c) => { }); // ── GET /:noteId/pages ────────────────────────────────────────────────────── -app.get("/:noteId/pages", authRequired, async (c) => { +/** + * Lists pages under a note as a keyset-paginated window (Issue #860 Phase 1). + * + * Query parameters: + * - `cursor` Opaque base64url cursor returned in `next_cursor` of the + * previous response. Omit on the first call. + * - `limit` 1..{@link MAX_PAGES_LIMIT} (default {@link DEFAULT_PAGES_LIMIT}). + * - `include` Comma-separated optional fields. `preview` requests + * `content_preview`, `thumbnail` requests `thumbnail_url`. + * Unrecognized tokens are ignored. + * + * Authentication is `authOptional` plus role resolution via + * {@link getNoteRole}; public / unlisted notes are reachable by `guest` + * callers without sign-in. Private / restricted notes still 403 for guests. + * + * ノート配下のページを keyset cursor pagination で返す(Issue #860 Phase 1)。 + * `authOptional` + `getNoteRole` の組み合わせにより、公開 / unlisted ノートでは + * 未ログインの guest でもページ一覧を取得できる。`content_preview` / + * `thumbnail_url` は `?include=` で明示的に要求された場合のみセットされる。 + */ +app.get("/:noteId/pages", authOptional, async (c) => { const noteId = c.req.param("noteId"); const userId = c.get("userId"); const userEmail = c.get("userEmail"); @@ -157,19 +328,100 @@ app.get("/:noteId/pages", authRequired, async (c) => { if (!note) throw new HTTPException(404, { message: "Note not found" }); if (!role) throw new HTTPException(403, { message: "Forbidden" }); - const result = await db + const limit = parsePagesLimit(c.req.query("limit")); + const cursor = decodePagesCursor(c.req.query("cursor")); + const include = parsePagesInclude(c.req.query("include")); + + // keyset 条件: `(updated_at, id)` を `(c.updatedAt, c.id)` より小さい組に絞る。 + // `ORDER BY updated_at DESC, id DESC` と同じ向きで進めるため、 + // `updated_at < cursor.updatedAt OR (updated_at = cursor.updatedAt AND id < cursor.id)` + // を使う。cursor の `updatedAt` は pg 側でマイクロ秒精度の ISO 文字列として + // 保存しているため、比較側でも JS Date を介さず `::timestamptz` キャストで突合 + // し、ms 切り捨てによる行の取りこぼしを防ぐ(gemini-code-assist / codex on PR #865)。 + // `limit + 1` 件取得して、超過したら `next_cursor` を発行する。 + // + // Keyset predicate paired with `ORDER BY updated_at DESC, id DESC`. The + // cursor's `updatedAt` is the Postgres-formatted microsecond ISO string, + // so comparisons cast it back via `::timestamptz` to keep microsecond + // precision end-to-end (avoiding the JS `Date` truncation flagged by + // gemini-code-assist + codex on PR #865). Fetching `limit + 1` rows lets + // us emit `next_cursor` without a separate count query. + const whereClauses = [eq(pages.noteId, noteId), eq(pages.isDeleted, false)]; + if (cursor) { + const cursorTsSql = sql`${cursor.updatedAt}::timestamptz`; + // drizzle の `or()` は要素が空配列のとき undefined を返す型だが、ここでは + // 必ず 2 つ渡しているため undefined は来ない。型を絞るため明示的に分岐する。 + // + // `or()` here always receives two operands, but its return type is + // `SQL | undefined`. Use an explicit `if` to keep TypeScript happy + // without resorting to a non-null assertion. + const keysetPredicate = or( + lt(pages.updatedAt, cursorTsSql), + and(eq(pages.updatedAt, cursorTsSql), lt(pages.id, cursor.id)), + ); + if (keysetPredicate) { + whereClauses.push(keysetPredicate); + } + } + + // `updatedAtIso` は cursor を組み立てるためだけに pg 側で + // マイクロ秒精度の ISO 文字列を生成して持ち帰る。pg ドライバ経由で + // 受け取る `updated_at` は JS Date に丸まる(ms 精度)ため、それだけでは + // 同一ミリ秒で別マイクロ秒の行を取りこぼす(gemini-code-assist / codex + // on PR #865)。 + // + // `updatedAtIso` ships the microsecond-precision ISO string built by + // Postgres so the cursor never loses precision. The `updated_at` field + // returned via the pg driver collapses to a JS `Date` (millisecond), which + // would silently skip rows that share a millisecond but differ in + // microseconds (gemini-code-assist + codex on PR #865). + const rows = await db .select({ - page_id: pages.id, - page_title: pages.title, - page_content_preview: pages.contentPreview, - page_thumbnail_url: pages.thumbnailUrl, - page_updated_at: pages.updatedAt, + id: pages.id, + ownerId: pages.ownerId, + noteId: pages.noteId, + sourcePageId: pages.sourcePageId, + title: pages.title, + contentPreview: pages.contentPreview, + thumbnailUrl: pages.thumbnailUrl, + sourceUrl: pages.sourceUrl, + createdAt: pages.createdAt, + updatedAt: pages.updatedAt, + updatedAtIso: sql`to_char(${pages.updatedAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, + isDeleted: pages.isDeleted, }) .from(pages) - .where(and(eq(pages.noteId, noteId), eq(pages.isDeleted, false))) - .orderBy(desc(pages.updatedAt)); + .where(and(...whereClauses)) + .orderBy(desc(pages.updatedAt), desc(pages.id)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const visible = hasMore ? rows.slice(0, limit) : rows; + const last = visible[visible.length - 1]; + const nextCursor = + hasMore && last + ? encodePagesCursor({ + updatedAt: last.updatedAtIso, + id: last.id, + }) + : null; + + const items: NotePageWindowItem[] = visible.map((p) => ({ + id: p.id, + owner_id: p.ownerId, + note_id: p.noteId, + source_page_id: p.sourcePageId, + title: p.title, + content_preview: include.preview ? (p.contentPreview ?? null) : null, + thumbnail_url: include.thumbnail ? (p.thumbnailUrl ?? null) : null, + source_url: p.sourceUrl, + created_at: p.createdAt, + updated_at: p.updatedAt, + is_deleted: p.isDeleted, + })); - return c.json({ pages: result }); + const response: NotePageWindowResponse = { items, next_cursor: nextCursor }; + return c.json(response); }); export default app; diff --git a/server/api/src/routes/notes/types.ts b/server/api/src/routes/notes/types.ts index 7c97ccc6..de58fe8e 100644 --- a/server/api/src/routes/notes/types.ts +++ b/server/api/src/routes/notes/types.ts @@ -81,14 +81,16 @@ export interface NotePageApiItem { * 一覧カード描画用の先頭プレビュー (`pages.content_preview`)。本文 fetch を * 伴わずにカードへ表示するために、保存時に算出した短い抜粋を返す。Issue #849 * で一時的に常時 `null` 化していたが、Issue #860 Phase 0 で復旧した。 - * Phase 1 以降でノートシェルとページ一覧の API が分離されたら、本フィールド - * は `GET /api/notes/:noteId/pages?include=preview` 側で提供される予定。 + * Phase 1 で導入した `GET /api/notes/:noteId/pages?include=preview` がノート + * シェルとページ一覧を分離した新経路だが、互換期間中は本フィールドも維持する + * (Phase 6 で `pages[]` ごと撤去する予定)。 * * Short head-of-body preview (`pages.content_preview`) used to render list * cards without fetching full page bodies. Temporarily forced to `null` by - * Issue #849 and restored by Issue #860 Phase 0. Once Phase 1 splits the - * note-shell and page-list APIs, this field will live on - * `GET /api/notes/:noteId/pages?include=preview` instead. + * Issue #849 and restored by Issue #860 Phase 0. Phase 1 added + * `GET /api/notes/:noteId/pages?include=preview` as the new split route; + * this field is kept for the compatibility window until `pages[]` itself + * is removed in Phase 6. */ content_preview: string | null; thumbnail_url: string | null; @@ -110,6 +112,52 @@ export interface NoteDetailApiResponse extends NoteApiFields { pages: NotePageApiItem[]; } +/** + * `GET /api/notes/:noteId/pages` の cursor `include` 指定で追加できるオプション + * フィールド。`preview` は `content_preview`、`thumbnail` は `thumbnail_url` の + * 同梱を要求する。未指定時は両フィールドとも `null` で返す(Issue #860 Phase 1)。 + * + * Optional fields requested via `?include=` on `GET /api/notes/:noteId/pages`. + * `preview` toggles `content_preview` and `thumbnail` toggles `thumbnail_url`; + * both are `null` when unrequested (Issue #860 Phase 1). + */ +export type NotePageWindowInclude = "preview" | "thumbnail"; + +/** + * `GET /api/notes/:noteId/pages` のページ行。keyset cursor pagination 経路で + * 返す軽量サマリ。`content_preview` / `thumbnail_url` は `?include=` で + * 明示的に要求された場合のみセットされ、それ以外は `null` で返る。 + * + * Page summary returned by `GET /api/notes/:noteId/pages` (Issue #860 Phase + * 1). `content_preview` and `thumbnail_url` are populated only when their + * corresponding `?include=` token is present; otherwise they are `null`. + */ +export interface NotePageWindowItem { + id: string; + owner_id: string; + note_id: string; + source_page_id: string | null; + title: string | null; + content_preview: string | null; + thumbnail_url: string | null; + source_url: string | null; + created_at: Date; + updated_at: Date; + is_deleted: boolean; +} + +/** + * `GET /api/notes/:noteId/pages` の keyset cursor pagination レスポンス。 + * `next_cursor` が `null` の場合は末尾まで到達済み。 + * + * Keyset cursor pagination response for `GET /api/notes/:noteId/pages`. A + * `null` `next_cursor` means there are no more items. + */ +export interface NotePageWindowResponse { + items: NotePageWindowItem[]; + next_cursor: string | null; +} + /** * `GET /api/notes/discover` のノート行(発見タブ用のサマリ)。 * Note row for the discover tab returned by `GET /api/notes/discover`. diff --git a/server/api/src/schema/pages.ts b/server/api/src/schema/pages.ts index ec21981f..b65a850d 100644 --- a/server/api/src/schema/pages.ts +++ b/server/api/src/schema/pages.ts @@ -130,6 +130,22 @@ export const pages = pgTable( index("idx_pages_note_active_updated") .on(table.noteId, table.updatedAt.desc()) .where(sql`${table.isDeleted} = false`), + /** + * `GET /api/notes/:noteId/pages` (Issue #860 Phase 1) の keyset cursor + * pagination 用部分複合インデックス。 + * `ORDER BY updated_at DESC, id DESC` を index-only でスキャンし、 + * `(updated_at, id)` 二値の cursor 突合(`updated_at = $1 AND id < $2` の + * tie-break 経路)も index 内で解決できるよう、`id DESC` まで含める。 + * + * Partial composite index that backs the keyset cursor pagination on + * `GET /api/notes/:noteId/pages` (Issue #860 Phase 1). Extending the + * existing `(note_id, updated_at DESC)` order with `id DESC` lets the + * `(updated_at, id)` tie-break predicate stay inside the index instead + * of falling back to a heap re-check / sort. + */ + index("idx_pages_note_active_updated_id") + .on(table.noteId, table.updatedAt.desc(), table.id.desc()) + .where(sql`${table.isDeleted} = false`), /** * オーナーごとに有効なウェルカムページは最大 1 件であることを担保する部分 * ユニーク index。`welcomePageService.insertWelcomePage` の `onConflictDoNothing`