feat: enhance cursor-based pagination in API documentation and types

- Added detailed explanations and examples for cursor semantics in `api-types.md`, clarifying the exclusive nature of cursors in pagination.
- Updated `CursorPaginationParams` interface to emphasize the cursor's role as an exclusive boundary in responses.
- Refactored `BranchMessagesQueryParams` to extend `CursorPaginationParams`, aligning with the new pagination logic.
- Modified `MessageService` to implement cursor-based pagination semantics, ensuring accurate message retrieval and response structure.
- Enhanced documentation throughout to provide clearer guidance on pagination behavior and usage patterns.
This commit is contained in:
fullex 2026-01-04 22:31:15 +08:00
parent 81bb8e7981
commit 7cac5b55f6
6 changed files with 75 additions and 20 deletions

View File

@ -88,6 +88,36 @@ The API system supports two pagination modes with composable query parameters.
| `SortParams` | `sortBy?`, `sortOrder?` | Sorting (combine as needed) |
| `SearchParams` | `search?` | Text search (combine as needed) |
### Cursor Semantics
The `cursor` in `CursorPaginationParams` marks an **exclusive boundary** - the cursor item itself is never included in the response.
**Common patterns:**
| Pattern | Use Case | Behavior |
|---------|----------|----------|
| "after cursor" | Forward pagination, new items | Returns items AFTER cursor |
| "before cursor" | Backward/historical loading | Returns items BEFORE cursor |
The specific semantic depends on the API endpoint. For example:
- `GET /topics/:id/messages` uses "before cursor" for loading historical messages
- Other endpoints may use "after cursor" for forward pagination
**Example: Loading historical messages**
```typescript
// First request - get most recent messages
const res1 = await api.get('/topics/123/messages', { query: { limit: 20 } })
// res1: { items: [msg80...msg99], nextCursor: 'msg80-id', activeNodeId: '...' }
// Load more - get older messages before the cursor
const res2 = await api.get('/topics/123/messages', {
query: { cursor: res1.nextCursor, limit: 20 }
})
// res2: { items: [msg60...msg79], nextCursor: 'msg60-id', activeNodeId: '...' }
// Note: msg80 is NOT in res2 (cursor is exclusive)
```
### Response Types
| Type | Fields | Description |

View File

@ -152,9 +152,18 @@ export interface OffsetPaginationParams {
/**
* Cursor-based pagination parameters (cursor + limit)
*
* The cursor is typically an opaque reference to a record in the dataset.
* The cursor itself is NEVER included in the response - it marks an exclusive boundary.
*
* Common semantics:
* - "after cursor": Returns items AFTER the cursor (forward pagination)
* - "before cursor": Returns items BEFORE the cursor (backward/historical pagination)
*
* The specific semantic depends on the API endpoint. Check endpoint documentation.
*/
export interface CursorPaginationParams {
/** Cursor for next page (undefined for first page) */
/** Cursor for pagination boundary (exclusive - cursor item not included in response) */
cursor?: string
/** Items per page */
limit?: number

View File

@ -5,6 +5,7 @@
* Includes endpoints for tree visualization and conversation view.
*/
import type { CursorPaginationParams } from '@shared/data/api/apiTypes'
import type {
BranchMessagesResponse,
Message,
@ -115,14 +116,16 @@ export interface TreeQueryParams {
/**
* Query parameters for GET /topics/:id/messages
*
* Uses "before cursor" semantics for loading historical messages:
* - First request (no cursor): Returns the most recent `limit` messages
* - Subsequent requests: Pass `nextCursor` from previous response as `cursor`
* to load older messages towards root
* - The cursor message itself is NOT included in the response
*/
export interface BranchMessagesQueryParams {
export interface BranchMessagesQueryParams extends CursorPaginationParams {
/** End node ID (defaults to topic.activeNodeId) */
nodeId?: string
/** Pagination cursor: return messages before this node */
beforeNodeId?: string
/** Number of messages to return */
limit?: number
/** Whether to include siblingsGroup in response */
includeSiblings?: boolean
}

View File

@ -1,3 +1,4 @@
import type { CursorPaginationResponse } from '@shared/data/api/apiTypes'
/**
* Message Statistics - combines token usage and performance metrics
* Replaces the separate `usage` and `metrics` fields
@ -474,9 +475,7 @@ export interface BranchMessage {
/**
* Branch messages response structure
*/
export interface BranchMessagesResponse {
/** Messages in root-to-leaf order */
messages: BranchMessage[]
export interface BranchMessagesResponse extends CursorPaginationResponse<BranchMessage> {
/** Current active node ID */
activeNodeId: string | null
}

View File

@ -45,7 +45,7 @@ export const messageHandlers: {
const q = (query || {}) as BranchMessagesQueryParams
return await messageService.getBranchMessages(params.topicId, {
nodeId: q.nodeId,
beforeNodeId: q.beforeNodeId,
cursor: q.cursor,
limit: q.limit,
includeSiblings: q.includeSiblings
})

View File

@ -315,13 +315,23 @@ export class MessageService {
* Optimized implementation using recursive CTE to fetch only the path
* from nodeId to root, avoiding loading all messages for large topics.
* Siblings are batch-queried in a single additional query.
*
* Uses "before cursor" pagination semantics:
* - cursor: Message ID marking the pagination boundary (exclusive)
* - Returns messages BEFORE the cursor (towards root)
* - The cursor message itself is NOT included
* - nextCursor points to the oldest message in current batch
*
* Example flow:
* 1. First request (no cursor) returns msg80-99, nextCursor=msg80.id
* 2. Second request (cursor=msg80.id) returns msg60-79, nextCursor=msg60.id
*/
async getBranchMessages(
topicId: string,
options: { nodeId?: string; beforeNodeId?: string; limit?: number; includeSiblings?: boolean } = {}
options: { nodeId?: string; cursor?: string; limit?: number; includeSiblings?: boolean } = {}
): Promise<BranchMessagesResponse> {
const db = dbService.getDb()
const { limit = DEFAULT_LIMIT, includeSiblings = true } = options
const { cursor, limit = DEFAULT_LIMIT, includeSiblings = true } = options
// Get topic
const [topic] = await db.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1)
@ -334,7 +344,7 @@ export class MessageService {
// Return empty if no active node
if (!nodeId) {
return { messages: [], activeNodeId: null }
return { items: [], nextCursor: undefined, activeNodeId: null }
}
// Use recursive CTE to get path from nodeId to root (single query)
@ -359,19 +369,22 @@ export class MessageService {
let startIndex = 0
let endIndex = fullPath.length
if (options.beforeNodeId) {
const beforeIndex = fullPath.findIndex((m) => m.id === options.beforeNodeId)
if (beforeIndex === -1) {
throw DataApiErrorFactory.notFound('Message', options.beforeNodeId)
if (cursor) {
const cursorIndex = fullPath.findIndex((m) => m.id === cursor)
if (cursorIndex === -1) {
throw DataApiErrorFactory.notFound('Message (cursor)', cursor)
}
startIndex = Math.max(0, beforeIndex - limit)
endIndex = beforeIndex
startIndex = Math.max(0, cursorIndex - limit)
endIndex = cursorIndex
} else {
startIndex = Math.max(0, fullPath.length - limit)
}
const paginatedPath = fullPath.slice(startIndex, endIndex)
// Calculate nextCursor: if there are more historical messages
const nextCursor = startIndex > 0 ? fullPath[startIndex].id : undefined
// Build result with optional siblings
const result: BranchMessage[] = []
@ -435,7 +448,8 @@ export class MessageService {
}
return {
messages: result,
items: result,
nextCursor,
activeNodeId: topic.activeNodeId
}
}