Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions server/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
236 changes: 224 additions & 12 deletions server/api/src/__tests__/routes/notes/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
OTHER_USER_ID,
TEST_USER_EMAIL,
createMockNote,
createMockPageListRow,
createTestApp,
authHeaders,
} from "./setup.js";
Expand Down Expand Up @@ -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<string, unknown> = {}) {
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]]);

Expand All @@ -165,10 +194,193 @@ describe("GET /api/notes/:noteId/pages", () => {
});

expect(res.status).toBe(200);
const body = (await res.json()) as { pages: Array<Record<string, unknown>> };
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<Record<string, unknown>>;
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<Record<string, unknown>> };
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<Record<string, unknown>> };
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>> };
expect(body.items).toHaveLength(1);
});

it("returns 403 when caller has no note role", async () => {
Expand Down
12 changes: 0 additions & 12 deletions server/api/src/__tests__/routes/notes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,6 @@ export function createMockPageRow(overrides: Record<string, unknown> = {}) {
};
}

/** テスト用ページ一覧行のデフォルト / Default mock page list row */
export function createMockPageListRow(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
return {
Expand Down
Loading
Loading