mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 21:50:46 +08:00
Pagination docs were scattered across api-types.md (types + cursor semantics), data-api-in-renderer.md (hooks), data-api-in-main.md (offset example + keyset note), api-design-guidelines.md (query params), and data-ordering-guide.md (cache shapes + determinism), with no single discoverable home for the offset-vs-cursor model. Add docs/references/data/data-pagination-guide.md as the canonical hub (mirrors data-ordering-guide.md): two modes, four-layer quickstart, wire contract, server impl (offset + keyset cursor + multi-band caveat), renderer consumption, FTS pagination, gotchas, and a see-also map. Other docs keep their authoritative slice and link to the guide; the migrated conceptual prose is removed from api-types.md to avoid duplication. Also fix two pre-existing broken anchors found while verifying links (database-patterns withWriteTx; ordering guide section number).
303 lines
15 KiB
Markdown
303 lines
15 KiB
Markdown
# Pagination Guide
|
|
|
|
Canonical spec for paginating any list endpoint in the DataApi system. It is the
|
|
single home for the cross-cutting pagination concepts; the type signatures, hook
|
|
APIs, and the server-side codec each keep their own authoritative doc and are
|
|
linked from here. This is the sibling of [Ordering Guide](./data-ordering-guide.md) —
|
|
ordering and pagination are the two list-shaping concerns, and a list endpoint
|
|
often uses both at once.
|
|
|
|
> If you only need one fact: pagination has **two modes** — offset and cursor.
|
|
> A given endpoint is **one or the other**, fixed in its schema, and the type
|
|
> system enforces the matching hook and response shape end to end.
|
|
|
|
## 1. Two Modes — Pick One Per Endpoint
|
|
|
|
| Mode | Request params | Response | Renderer hook | Use it for |
|
|
|---|---|---|---|---|
|
|
| **Offset** | `page` + `limit` | `{ items, total, page }` | `usePaginatedQuery` | Page navigation, tables, "page 3 of 12", anything that needs a `total` count |
|
|
| **Cursor** (keyset) | `cursor` + `limit` | `{ items, nextCursor? }` | `useInfiniteQuery` | Infinite scroll, chat history, feeds, large/append-only data where offset would drift under concurrent writes |
|
|
|
|
The mode is a property of the endpoint, declared once in its API schema, and is
|
|
**not caller-configurable**. Mixing them is a compile-time error, not a runtime
|
|
hang: `usePaginatedQuery` rejects a cursor path and `useInfiniteQuery` rejects an
|
|
offset path (the path generic is constrained via `OffsetPaginatedPath` /
|
|
`CursorPaginatedPath`, both derived from `InferPaginationMode`).
|
|
|
|
**Choosing offset vs cursor.** Prefer **cursor** for anything that grows without
|
|
bound or is read newest-first while being written to (messages, sessions,
|
|
translate/painting history) — offset's `page * limit` window silently skips or
|
|
repeats rows when items are inserted between requests. Prefer **offset** when the
|
|
UI shows discrete page controls or needs an exact total (knowledge bases,
|
|
assistants, files, MCP servers).
|
|
|
|
## 2. The Four Layers (Quickstart)
|
|
|
|
Adding pagination to a list endpoint touches the same four layers as ordering.
|
|
Each layer has an authoritative doc — this guide is the map.
|
|
|
|
| Layer | What you write | Authoritative doc |
|
|
|---|---|---|
|
|
| **1. API schema** | `query` = `OffsetPaginationParams` / `CursorPaginationParams` (compose with `SortParams` / `SearchParams`); `response` = `OffsetPaginationResponse<T>` / `CursorPaginationResponse<T>` | [api-types.md § Pagination Types](./api-types.md#pagination-types) |
|
|
| **2. Server service** | Offset: `(page-1)*limit` + `count(*)`. Cursor: the shared `keysetCursor` codec + `keysetOrdering` (never hand-roll the cursor or the `ORDER BY`) | [data-api-in-main.md](./data-api-in-main.md), [services/utils README — `keysetCursor.ts`](../../../src/main/data/services/utils/README.md) |
|
|
| **3. Renderer hook** | Offset: `usePaginatedQuery`. Cursor: `useInfiniteQuery` + `useInfiniteFlatItems` | [data-api-in-renderer.md](./data-api-in-renderer.md#useinfinitequery-cursor-based-infinite-scroll) |
|
|
| **4. Query-param wire format** | `page`+`limit`, `cursor`+`limit`, `sortBy`+`sortOrder`, `search` | [api-design-guidelines.md § Query Parameters](./api-design-guidelines.md#query-parameters) |
|
|
|
|
## 3. Wire Contract
|
|
|
|
### Request parameters
|
|
|
|
These four composable param interfaces live in `src/shared/data/api/apiTypes.ts`
|
|
(see [api-types.md § Pagination Types](./api-types.md#pagination-types) for the
|
|
full table):
|
|
|
|
| Type | Fields | Notes |
|
|
|---|---|---|
|
|
| `OffsetPaginationParams` | `page?`, `limit?` | `page` is 1-based |
|
|
| `CursorPaginationParams` | `cursor?`, `limit?` | `cursor` is an **opaque, exclusive** boundary token |
|
|
| `SortParams` | `sortBy?`, `sortOrder?` | `sortOrder` is `'asc'` / `'desc'` |
|
|
| `SearchParams` | `search?` | Compose as needed |
|
|
|
|
Compose them in a route's `query` with `&`:
|
|
|
|
```typescript
|
|
// Offset list with sort + search
|
|
query?: OffsetPaginationParams & SortParams & SearchParams & { type?: string }
|
|
response: OffsetPaginationResponse<Item>
|
|
|
|
// Cursor feed
|
|
query?: CursorPaginationParams & { userId: string }
|
|
response: CursorPaginationResponse<Message>
|
|
```
|
|
|
|
### Response shapes
|
|
|
|
| Type | Fields | Description |
|
|
|---|---|---|
|
|
| `OffsetPaginationResponse<T>` | `items`, `total`, `page` | Page-based results |
|
|
| `CursorPaginationResponse<T>` | `items`, `nextCursor?` | `nextCursor` absent ⇒ no more data |
|
|
| `PaginationResponse<T>` | union of both | Use only when either mode is acceptable; narrow with `isOffsetPaginationResponse` / `isCursorPaginationResponse` |
|
|
|
|
Endpoints frequently **extend** these base responses with extra top-level
|
|
metadata — e.g. `TranslateHistoryListResponse extends CursorPaginationResponse<T>`
|
|
adds `total`, and `BranchMessagesResponse` (`GET /topics/:id/messages`) adds
|
|
`activeNodeId` / `rootId` / `assistantId`. The `items` array and the pagination
|
|
fields stay exactly as above; `useInfiniteFlatItems` reads only `items` while
|
|
consumers read the extras off `pages[0]` (see § 5).
|
|
|
|
### Cursor semantics — exclusive boundary
|
|
|
|
The `cursor` marks an **exclusive** boundary: the cursor item itself is never
|
|
included in the response. Direction is per-endpoint:
|
|
|
|
| Pattern | Use case | Behaviour |
|
|
|---|---|---|
|
|
| "after cursor" | Forward pagination, newer items | Returns items **after** the cursor |
|
|
| "before cursor" | Backward / historical loading | Returns items **before** the cursor |
|
|
|
|
For example, `GET /topics/:id/messages` uses "before cursor" to walk backward
|
|
through history; other endpoints may page forward. The concrete direction is the
|
|
endpoint's documented contract.
|
|
|
|
```typescript
|
|
// Illustrative — load most-recent messages, then older ones
|
|
const res1 = await api.get('/topics/123/messages', { query: { limit: 20 } })
|
|
// res1: { items: [msg80...msg99], nextCursor: 'msg80-id', activeNodeId: '...' }
|
|
|
|
const res2 = await api.get('/topics/123/messages', {
|
|
query: { cursor: res1.nextCursor, limit: 20 }
|
|
})
|
|
// res2: { items: [msg60...msg79], nextCursor: 'msg60-id' }
|
|
// msg80 is NOT in res2 — the cursor is exclusive.
|
|
```
|
|
|
|
### Client-side derivations
|
|
|
|
```typescript
|
|
// OffsetPaginationResponse
|
|
const pageCount = Math.ceil(total / limit)
|
|
const hasNext = page * limit < total
|
|
const hasPrev = page > 1
|
|
|
|
// CursorPaginationResponse
|
|
const hasNext = nextCursor !== undefined
|
|
```
|
|
|
|
The renderer hooks compute these for you (see § 5) — derive by hand only when
|
|
calling `DataApiService` directly.
|
|
|
|
## 4. Server Implementation
|
|
|
|
### Offset
|
|
|
|
Compute `offset = (page - 1) * limit`, run the page query and a `count(*)` in
|
|
one `Promise.all`, and return `{ items, total, page }`. The canonical real
|
|
example is `AssistantService.list` (`src/main/data/services/AssistantService.ts`,
|
|
backing `GET /assistants`):
|
|
|
|
```typescript
|
|
async list(query: ListAssistantsQuery): Promise<{ items: Assistant[]; total: number; page: number }> {
|
|
const { page, limit } = query
|
|
const offset = (page - 1) * limit
|
|
const [rows, [{ count }]] = await Promise.all([
|
|
this.db.select().from(assistantTable).where(whereClause)
|
|
.orderBy(...orderByClauses).limit(limit).offset(offset),
|
|
this.db.select({ count: sql<number>`count(*)` }).from(assistantTable).where(whereClause)
|
|
])
|
|
return { items: rows.map(rowToEntity), total: Number(count), page }
|
|
}
|
|
```
|
|
|
|
Keep the same `whereClause` on both queries so the count matches the page. When
|
|
filtering against a related table, filter via a `WHERE` subquery (not a `JOIN`)
|
|
so `count(*)` does not multiply rows.
|
|
|
|
### Cursor (keyset)
|
|
|
|
List endpoints that page by a `(sortKey, id)` tuple **must** use the shared codec
|
|
and ordering builder in `src/main/data/services/utils/keysetCursor.ts` — never
|
|
hand-write the cursor encode/decode, the keyset `WHERE` tuple, or the `ORDER BY`.
|
|
Doing it by hand is how the WHERE predicate and the ORDER BY drift apart and
|
|
silently skip or repeat rows at the page boundary.
|
|
|
|
```typescript
|
|
import { asNumericKey, decodeListCursor, encodeCursor, keysetOrdering } from './utils/keysetCursor'
|
|
|
|
// One direction spec yields BOTH the WHERE predicate and its matching ORDER BY.
|
|
const ordering = keysetOrdering(table.createdAt, table.id, { major: 'desc', tie: 'asc' })
|
|
const cursor = decodeListCursor(query.cursor, asNumericKey, 'translate-history')
|
|
|
|
const conditions: SQL[] = [...filterConditions]
|
|
if (cursor) conditions.push(ordering.where(cursor))
|
|
|
|
const rows = await db.select().from(table)
|
|
.where(and(...conditions))
|
|
.orderBy(...ordering.orderBy) // cannot drift from ordering.where — same dir spec
|
|
.limit(limit + 1) // fetch one extra to detect "has next"
|
|
|
|
const hasNext = rows.length > limit
|
|
const pageRows = hasNext ? rows.slice(0, limit) : rows
|
|
const tail = pageRows.at(-1)
|
|
const nextCursor = hasNext && tail ? encodeCursor(tail.createdAt, tail.id) : undefined
|
|
return { items: pageRows.map(rowToEntity), nextCursor }
|
|
```
|
|
|
|
`TranslateHistoryService` (`src/main/data/services/TranslateHistoryService.ts`)
|
|
is the canonical real implementation of this pattern (it returns an extended
|
|
response that also carries `total` — see § 3). The codec's full
|
|
export surface, the `<key>:<id>` wire format, the empty-key guard, and the
|
|
list-vs-search decode policy split are documented in
|
|
[services/utils README — `keysetCursor.ts`](../../../src/main/data/services/utils/README.md).
|
|
|
|
**Decode policy — fall back, don't throw.** `decodeListCursor` treats an absent
|
|
cursor as "first page" (no warn) and a malformed cursor as a warn-and-fall-back
|
|
to the first page. A server-issued opaque token going stale must never throw and
|
|
lock the renderer. (Full-text search uses the opposite policy — see § 6.)
|
|
|
|
**Multi-band cursors are not routable through `keysetOrdering`.** A cursor that
|
|
encodes more than a single `(key, id)` tuple — e.g. `TopicService.listByCursor`,
|
|
which pages a pinned section then an unpinned section with a first-page sentinel —
|
|
cannot be expressed as one tuple and keeps its **own** codec. Do not force such
|
|
endpoints through the shared helper.
|
|
|
|
**Determinism under ties.** `keysetOrdering` always appends the `id` tiebreaker
|
|
(`[<major> keyCol, <tie> idCol]`), so page-walking stays deterministic even when
|
|
two rows share the same sort key (e.g. an `order_key` collision). This is by
|
|
construction, not a fix for an observed skip/dup — see
|
|
[Ordering Guide § 8 FAQ — fractional-indexing collisions](./data-ordering-guide.md#8-faq).
|
|
|
|
## 5. Renderer Consumption
|
|
|
|
### Offset — `usePaginatedQuery`
|
|
|
|
```typescript
|
|
import { usePaginatedQuery } from '@data/hooks/useDataApi'
|
|
|
|
const { items, page, total, hasNext, hasPrev, nextPage, prevPage } =
|
|
usePaginatedQuery('/assistants', { limit: 10 }) // limit defaults to 10
|
|
```
|
|
|
|
It manages the `page`/`limit` query params internally and resets to page 1 when
|
|
the rest of the query changes. The full result also exposes `isLoading`,
|
|
`isRefreshing`, `error`, `refresh`, and `reset`. Rejects cursor-paginated paths
|
|
at compile time.
|
|
|
|
### Cursor — `useInfiniteQuery` + `useInfiniteFlatItems`
|
|
|
|
`useInfiniteQuery` exposes the **raw `pages` array**; consumers derive a flat
|
|
item list with `useInfiniteFlatItems`, explicitly picking the order that matches
|
|
the endpoint and the container layout — never assume page-load order equals
|
|
display order.
|
|
|
|
```typescript
|
|
import { useInfiniteQuery, useInfiniteFlatItems } from '@data/hooks/useDataApi'
|
|
|
|
// Simple feed: page 0 newest, within-page descending — page order == display order
|
|
const { pages, hasNext, loadNext, isLoading } = useInfiniteQuery('/feed')
|
|
const items = useInfiniteFlatItems(pages)
|
|
|
|
// Branch-walk in a `column-reverse` chat container: flip each page so the flat
|
|
// output is newest-first and feeds straight into the reversed layout.
|
|
const { pages, hasNext, loadNext } = useInfiniteQuery('/topics/:topicId/messages', {
|
|
params: { topicId }
|
|
})
|
|
const messages = useInfiniteFlatItems(pages, { reverseItems: true })
|
|
const activeNodeId = pages[0]?.activeNodeId ?? null // top-level metadata, no cast
|
|
|
|
// Time-ascending render in a non-`column-reverse` container: flip page order
|
|
const ascItems = useInfiniteFlatItems(pages, { reversePages: true })
|
|
```
|
|
|
|
`pages` is reference-stable across rerenders while SWR's underlying data is
|
|
unchanged, so `useInfiniteFlatItems(pages)` skips recomputation. Rejects
|
|
offset-paginated paths at compile time. See
|
|
[data-api-in-renderer.md](./data-api-in-renderer.md#useinfinitequery-cursor-based-infinite-scroll)
|
|
for the hook signatures and the hook-choosing table.
|
|
|
|
### Reorder + pagination
|
|
|
|
`useReorder` works on paginated lists transparently. Both
|
|
`OffsetPaginationResponse` (`{ items, total, page }`) and
|
|
`CursorPaginationResponse` (`{ items, nextCursor }`) fall under the same
|
|
`{ items }` cache branch — metadata fields pass through unchanged on optimistic
|
|
writes, and any visible row's id is a valid drag anchor even when the list never
|
|
fits on screen. See
|
|
[Ordering Guide § 4.3 Supported cache shapes](./data-ordering-guide.md#43-supported-cache-shapes).
|
|
|
|
## 6. Full-Text Search Pagination
|
|
|
|
FTS5 search endpoints paginate with the **same `<key>:<id>` cursor format** but a
|
|
**different decode policy**: a malformed search cursor is a client contract
|
|
violation and throws `422` (`ftsSearch.decodeSearchCursor`), whereas a malformed
|
|
list cursor warns and falls back to the first page (`decodeListCursor`, § 4). The
|
|
two share `parseCursor` / `encodeCursor` and differ only in the throw policy. The
|
|
FTS core (candidate filtering, bounded offset scanning, next-cursor assembly)
|
|
lives in `src/main/data/services/utils/ftsSearch.ts`; see
|
|
[services/utils README — `ftsSearch.ts`](../../../src/main/data/services/utils/README.md).
|
|
|
|
## 7. Gotchas
|
|
|
|
- **Cursor is exclusive** — the cursor row is never re-returned. Off-by-one bugs
|
|
come from assuming inclusivity.
|
|
- **Never hand-roll keyset SQL** — use `keysetOrdering` so the `WHERE` tuple and
|
|
`ORDER BY` derive from one direction spec and cannot disagree.
|
|
- **Always append the `id` tiebreaker** — a sort on the major key alone is
|
|
non-deterministic under ties and breaks keyset page-walking.
|
|
- **Keep `whereClause` identical** on the offset page query and its `count(*)`,
|
|
or `total` won't match the page.
|
|
- **List cursors warn-and-fall-back; search cursors throw 422** — don't copy one
|
|
policy into the other.
|
|
- **Multi-band cursors keep their own codec** — `TopicService.listByCursor` is
|
|
not routable through `keysetOrdering`.
|
|
- **Page-load order ≠ display order** — choose `reversePages` / `reverseItems`
|
|
in `useInfiniteFlatItems` deliberately.
|
|
|
|
## 8. See Also
|
|
|
|
- [api-types.md § Pagination Types](./api-types.md#pagination-types) — type signatures, guards, `Infer*` helpers
|
|
- [data-api-in-renderer.md](./data-api-in-renderer.md#useinfinitequery-cursor-based-infinite-scroll) — `usePaginatedQuery` / `useInfiniteQuery` / `useInfiniteFlatItems`
|
|
- [data-api-in-main.md](./data-api-in-main.md) — service-layer patterns
|
|
- [services/utils README](../../../src/main/data/services/utils/README.md) — `keysetCursor.ts` codec + `ftsSearch.ts`
|
|
- [api-design-guidelines.md § Query Parameters](./api-design-guidelines.md#query-parameters) — wire-format conventions
|
|
- [Ordering Guide](./data-ordering-guide.md) — the sibling list-shaping concern; reorder cache shapes and tie determinism
|