diff --git a/docs/en/references/data/api-types.md b/docs/en/references/data/api-types.md index c507b3a15d..a475b4d0c4 100644 --- a/docs/en/references/data/api-types.md +++ b/docs/en/references/data/api-types.md @@ -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 | diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index e6ce39ee28..4cdfc08761 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -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 diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts index 59c0e15c6a..4cc0a54998 100644 --- a/packages/shared/data/api/schemas/messages.ts +++ b/packages/shared/data/api/schemas/messages.ts @@ -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 } diff --git a/packages/shared/data/types/message.ts b/packages/shared/data/types/message.ts index 6906d68e4d..3542c30a57 100644 --- a/packages/shared/data/types/message.ts +++ b/packages/shared/data/types/message.ts @@ -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 { /** Current active node ID */ activeNodeId: string | null } diff --git a/src/main/data/api/handlers/messages.ts b/src/main/data/api/handlers/messages.ts index 2f7faaded3..d89457ab5e 100644 --- a/src/main/data/api/handlers/messages.ts +++ b/src/main/data/api/handlers/messages.ts @@ -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 }) diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 85e02ec8e2..1dfb7f378e 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -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 { 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 } }