feat: implement new pagination types and enhance API documentation

- Introduced `OffsetPaginationParams`, `CursorPaginationParams`, and their corresponding response types to standardize pagination handling across the API.
- Updated existing API types and hooks to support both offset and cursor-based pagination, improving data fetching capabilities.
- Enhanced documentation with detailed usage examples for pagination, including request parameters and response structures, to aid developers in implementing pagination effectively.
- Refactored related components to utilize the new pagination types, ensuring consistency and clarity in data management.
This commit is contained in:
fullex 2026-01-04 21:12:41 +08:00
parent 6a6f114946
commit 81bb8e7981
8 changed files with 227 additions and 98 deletions

View File

@ -37,7 +37,15 @@ import type {
DataRequest,
DataResponse,
ApiClient,
PaginatedResponse
// Pagination types
OffsetPaginationParams,
OffsetPaginationResponse,
CursorPaginationParams,
CursorPaginationResponse,
PaginationResponse,
// Query parameter types
SortParams,
SearchParams
} from '@shared/data/api'
import {
@ -45,7 +53,10 @@ import {
DataApiError,
DataApiErrorFactory,
isDataApiError,
toDataApiError
toDataApiError,
// Pagination type guards
isOffsetPaginationResponse,
isCursorPaginationResponse
} from '@shared/data/api'
```
@ -64,12 +75,68 @@ import type { Message, CreateMessageDto } from '@shared/data/api/schemas/message
import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test'
```
## Pagination Types
The API system supports two pagination modes with composable query parameters.
### Request Parameters
| Type | Fields | Use Case |
|------|--------|----------|
| `OffsetPaginationParams` | `page?`, `limit?` | Traditional page-based navigation |
| `CursorPaginationParams` | `cursor?`, `limit?` | Infinite scroll, real-time feeds |
| `SortParams` | `sortBy?`, `sortOrder?` | Sorting (combine as needed) |
| `SearchParams` | `search?` | Text search (combine as needed) |
### Response Types
| Type | Fields | Description |
|------|--------|-------------|
| `OffsetPaginationResponse<T>` | `items`, `total`, `page` | Page-based results |
| `CursorPaginationResponse<T>` | `items`, `nextCursor?` | Cursor-based results |
| `PaginationResponse<T>` | Union of both | When either mode is acceptable |
### Usage Examples
```typescript
// Offset pagination with sort and search
query?: OffsetPaginationParams & SortParams & SearchParams & {
type?: string
}
response: OffsetPaginationResponse<Item>
// Cursor pagination for infinite scroll
query?: CursorPaginationParams & {
userId: string
}
response: CursorPaginationResponse<Message>
```
### Client-side Calculations
For `OffsetPaginationResponse`, clients can calculate:
```typescript
const pageCount = Math.ceil(total / limit)
const hasNext = page * limit < total
const hasPrev = page > 1
```
For `CursorPaginationResponse`:
```typescript
const hasNext = nextCursor !== undefined
```
## Adding a New Domain Schema
1. Create the schema file (e.g., `schemas/topic.ts`):
```typescript
import type { PaginatedResponse } from '../apiTypes'
import type {
OffsetPaginationParams,
OffsetPaginationResponse,
SearchParams,
SortParams
} from '../apiTypes'
// Domain models
export interface Topic {
@ -86,7 +153,8 @@ export interface CreateTopicDto {
export interface TopicSchemas {
'/topics': {
GET: {
response: PaginatedResponse<Topic> // response is required
query?: OffsetPaginationParams & SortParams & SearchParams
response: OffsetPaginationResponse<Topic> // response is required
}
POST: {
body: CreateTopicDto
@ -152,7 +220,9 @@ const handlers: ApiImplementation = {
```typescript
const topic = await api.get('/topics/123') // Returns Topic
const topics = await api.get('/topics', { query: { page: 1 } }) // Returns PaginatedResponse<Topic>
const topics = await api.get('/topics', {
query: { page: 1, limit: 20, search: 'hello' }
}) // Returns OffsetPaginationResponse<Topic>
await api.post('/topics', { body: { name: 'New' } }) // Body is typed as CreateTopicDto
```

View File

@ -138,79 +138,111 @@ export type { SerializedDataApiError } from './apiErrors'
// Pagination Types
// ============================================================================
/**
* Pagination mode
*/
export type PaginationMode = 'offset' | 'cursor'
// ----- Request Parameters -----
/**
* Pagination parameters for list operations
* Offset-based pagination parameters (page + limit)
*/
export interface PaginationParams {
export interface OffsetPaginationParams {
/** Page number (1-based) */
page?: number
/** Items per page */
limit?: number
/** Page number (offset mode, 1-based) */
page?: number
/** Cursor (cursor mode) */
cursor?: string
/** Sort field and direction */
sort?: {
field: string
order: 'asc' | 'desc'
}
}
/**
* Base paginated response (shared fields)
* Cursor-based pagination parameters (cursor + limit)
*/
export interface BasePaginatedResponse<T> {
export interface CursorPaginationParams {
/** Cursor for next page (undefined for first page) */
cursor?: string
/** Items per page */
limit?: number
}
/**
* Sort parameters (independent, combine as needed)
*/
export interface SortParams {
/** Field to sort by */
sortBy?: string
/** Sort direction */
sortOrder?: 'asc' | 'desc'
}
/**
* Search parameters (independent, combine as needed)
*/
export interface SearchParams {
/** Search query string */
search?: string
}
// ----- Response Types -----
/**
* Offset-based pagination response
*/
export interface OffsetPaginationResponse<T> {
/** Items for current page */
items: T[]
/** Total number of items */
total: number
}
/**
* Offset-based paginated response
*/
export interface OffsetPaginatedResponse<T> extends BasePaginatedResponse<T> {
/** Current page number (1-based) */
page: number
/** Total number of pages */
pageCount: number
/** Whether there are more pages */
hasNext: boolean
/** Whether there are previous pages */
hasPrev: boolean
}
/**
* Cursor-based paginated response
* Cursor-based pagination response
*/
export interface CursorPaginatedResponse<T> extends BasePaginatedResponse<T> {
export interface CursorPaginationResponse<T> {
/** Items for current page */
items: T[]
/** Next cursor (undefined means no more data) */
nextCursor?: string
/** Previous cursor */
prevCursor?: string
}
// ----- Type Utilities -----
/**
* Unified paginated response (union type)
* Infer pagination mode from response type
*/
export type PaginatedResponse<T> = OffsetPaginatedResponse<T> | CursorPaginatedResponse<T>
export type InferPaginationMode<R> = R extends OffsetPaginationResponse<any>
? 'offset'
: R extends CursorPaginationResponse<any>
? 'cursor'
: never
/**
* Infer item type from pagination response
*/
export type InferPaginationItem<R> = R extends OffsetPaginationResponse<infer T>
? T
: R extends CursorPaginationResponse<infer T>
? T
: never
/**
* Union type for both pagination responses
*/
export type PaginationResponse<T> = OffsetPaginationResponse<T> | CursorPaginationResponse<T>
/**
* Type guard: check if response is offset-based
*/
export function isOffsetPaginatedResponse<T>(response: PaginatedResponse<T>): response is OffsetPaginatedResponse<T> {
return 'page' in response && 'pageCount' in response
export function isOffsetPaginationResponse<T>(
response: PaginationResponse<T>
): response is OffsetPaginationResponse<T> {
return 'page' in response && 'total' in response
}
/**
* Type guard: check if response is cursor-based
*/
export function isCursorPaginatedResponse<T>(response: PaginatedResponse<T>): response is CursorPaginatedResponse<T> {
return 'nextCursor' in response || !('page' in response)
export function isCursorPaginationResponse<T>(
response: PaginationResponse<T>
): response is CursorPaginationResponse<T> {
return !('page' in response)
}
/**

View File

@ -19,12 +19,18 @@
// ============================================================================
export type {
CursorPaginationParams,
CursorPaginationResponse,
DataRequest,
DataResponse,
HttpMethod,
PaginatedResponse,
PaginationParams
OffsetPaginationParams,
OffsetPaginationResponse,
PaginationResponse,
SearchParams,
SortParams
} from './apiTypes'
export { isCursorPaginationResponse, isOffsetPaginationResponse } from './apiTypes'
// ============================================================================
// API Schema Type Utilities

View File

@ -5,7 +5,7 @@
* These endpoints demonstrate the API patterns and provide testing utilities.
*/
import type { PaginatedResponse, PaginationParams } from '../apiTypes'
import type { OffsetPaginationParams, OffsetPaginationResponse, SearchParams, SortParams } from '../apiTypes'
// ============================================================================
// Domain Models & DTOs
@ -98,15 +98,15 @@ export interface TestSchemas {
'/test/items': {
/** List all test items with optional filtering and pagination */
GET: {
query?: PaginationParams & {
/** Search items by title or description */
search?: string
/** Filter by item type */
type?: string
/** Filter by status */
status?: string
}
response: PaginatedResponse<TestItem>
query?: OffsetPaginationParams &
SortParams &
SearchParams & {
/** Filter by item type */
type?: string
/** Filter by status */
status?: string
}
response: OffsetPaginationResponse<TestItem>
}
/** Create a new test item */
POST: {
@ -147,18 +147,14 @@ export interface TestSchemas {
'/test/search': {
/** Search test items */
GET: {
query: {
query: OffsetPaginationParams & {
/** Search query string */
query: string
/** Page number for pagination */
page?: number
/** Number of results per page */
limit?: number
/** Additional filters */
type?: string
status?: string
}
response: PaginatedResponse<TestItem>
response: OffsetPaginationResponse<TestItem>
}
}

View File

@ -21,12 +21,17 @@ export { TestService } from '@data/services/TestService'
// Re-export types for convenience
export type {
CursorPaginationParams,
CursorPaginationResponse,
DataRequest,
DataResponse,
Middleware,
PaginatedResponse,
PaginationParams,
OffsetPaginationParams,
OffsetPaginationResponse,
PaginationResponse,
RequestContext,
ServiceOptions
SearchParams,
ServiceOptions,
SortParams
} from '@shared/data/api/apiTypes'
export type { CreateTestItemDto, TestItem, UpdateTestItemDto } from '@shared/data/api/schemas/test'

View File

@ -1,4 +1,9 @@
import type { PaginationParams, ServiceOptions } from '@shared/data/api/apiTypes'
import type { CursorPaginationParams, OffsetPaginationParams, ServiceOptions } from '@shared/data/api/apiTypes'
/**
* Base pagination params for service layer (supports both modes)
*/
type BasePaginationParams = (OffsetPaginationParams | CursorPaginationParams) & Record<string, any>
/**
* Standard service interface for data operations
@ -14,12 +19,12 @@ export interface IBaseService<T = any, TCreate = any, TUpdate = any> {
* Find multiple entities with pagination
*/
findMany(
params: PaginationParams & Record<string, any>,
params: BasePaginationParams,
options?: ServiceOptions
): Promise<{
items: T[]
total: number
hasNext?: boolean
total?: number
page?: number
nextCursor?: string
}>
@ -68,12 +73,12 @@ export interface ISearchableService<T = any, TCreate = any, TUpdate = any> exten
*/
search(
query: string,
params?: PaginationParams,
params?: BasePaginationParams,
options?: ServiceOptions
): Promise<{
items: T[]
total: number
hasNext?: boolean
total?: number
page?: number
nextCursor?: string
}>
}
@ -87,12 +92,12 @@ export interface IHierarchicalService<TParent = any, TChild = any, TChildCreate
*/
getChildren(
parentId: string,
params?: PaginationParams,
params?: BasePaginationParams,
options?: ServiceOptions
): Promise<{
items: TChild[]
total: number
hasNext?: boolean
total?: number
page?: number
nextCursor?: string
}>

View File

@ -1,9 +1,9 @@
import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
import type { ConcreteApiPaths, PaginationMode } from '@shared/data/api/apiTypes'
import type { ConcreteApiPaths } from '@shared/data/api/apiTypes'
import {
isCursorPaginatedResponse,
type OffsetPaginatedResponse,
type PaginatedResponse
isCursorPaginationResponse,
type OffsetPaginationResponse,
type PaginationResponse
} from '@shared/data/api/apiTypes'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyedMutator } from 'swr'
@ -18,7 +18,7 @@ import { dataApiService } from '../DataApiService'
// ============================================================================
/** Infer item type from paginated response path */
type InferPaginatedItem<TPath extends ConcreteApiPaths> = ResponseForPath<TPath, 'GET'> extends PaginatedResponse<
type InferPaginatedItem<TPath extends ConcreteApiPaths> = ResponseForPath<TPath, 'GET'> extends PaginationResponse<
infer T
>
? T
@ -50,7 +50,7 @@ export interface UseMutationResult<
/** useInfiniteQuery result type */
export interface UseInfiniteQueryResult<T> {
items: T[]
pages: PaginatedResponse<T>[]
pages: PaginationResponse<T>[]
total: number
size: number
isLoading: boolean
@ -61,7 +61,7 @@ export interface UseInfiniteQueryResult<T> {
setSize: (size: number | ((size: number) => number)) => void
refresh: () => void
reset: () => void
mutate: KeyedMutator<PaginatedResponse<T>[]>
mutate: KeyedMutator<PaginationResponse<T>[]>
}
/** usePaginatedQuery result type */
@ -356,7 +356,7 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
/** Items per page (default: 10) */
limit?: number
/** Pagination mode (default: 'cursor') */
mode?: PaginationMode
mode?: 'offset' | 'cursor'
/** Whether to enable the query (default: true) */
enabled?: boolean
/** SWR options (including initialSize, revalidateAll, etc.) */
@ -368,19 +368,22 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
const enabled = options?.enabled !== false
const getKey = useCallback(
(pageIndex: number, previousPageData: PaginatedResponse<any> | null) => {
(pageIndex: number, previousPageData: PaginationResponse<any> | null) => {
if (!enabled) return null
if (previousPageData) {
if (mode === 'cursor') {
if (!isCursorPaginatedResponse(previousPageData) || !previousPageData.nextCursor) {
if (!isCursorPaginationResponse(previousPageData) || !previousPageData.nextCursor) {
return null
}
} else {
if (isCursorPaginatedResponse(previousPageData)) {
// Offset mode: check if we've reached the end
if (isCursorPaginationResponse(previousPageData)) {
return null
}
if (!previousPageData.hasNext) {
const offsetData = previousPageData as OffsetPaginationResponse<any>
// No more pages if items returned is less than limit or we've fetched all
if (offsetData.items.length < limit || pageIndex * limit >= offsetData.total) {
return null
}
}
@ -391,7 +394,7 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
limit
}
if (mode === 'cursor' && previousPageData && isCursorPaginatedResponse(previousPageData)) {
if (mode === 'cursor' && previousPageData && isCursorPaginationResponse(previousPageData)) {
paginationQuery.cursor = previousPageData.nextCursor
} else if (mode === 'offset') {
paginationQuery.page = pageIndex + 1
@ -403,7 +406,7 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
)
const infiniteFetcher = (key: [ConcreteApiPaths, Record<string, any>?]) => {
return getFetcher(key) as Promise<PaginatedResponse<any>>
return getFetcher(key) as Promise<PaginationResponse<any>>
}
const swrResult = useSWRInfinite(getKey, infiniteFetcher, {
@ -416,7 +419,7 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
})
const { error, isLoading, isValidating, mutate, size, setSize } = swrResult
const data = swrResult.data as PaginatedResponse<any>[] | undefined
const data = swrResult.data as PaginationResponse<any>[] | undefined
const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data])
@ -424,10 +427,13 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
if (!data?.length) return false
const last = data[data.length - 1]
if (mode === 'cursor') {
return isCursorPaginatedResponse(last) && !!last.nextCursor
return isCursorPaginationResponse(last) && !!last.nextCursor
}
return !isCursorPaginatedResponse(last) && (last as OffsetPaginatedResponse<any>).hasNext
}, [data, mode])
// Offset mode: check if there are more items
if (isCursorPaginationResponse(last)) return false
const offsetData = last as OffsetPaginationResponse<any>
return offsetData.page * limit < offsetData.total
}, [data, mode, limit])
const loadNext = useCallback(() => {
if (!hasNext || isValidating) return
@ -437,10 +443,18 @@ export function useInfiniteQuery<TPath extends ConcreteApiPaths>(
const refresh = useCallback(() => mutate(), [mutate])
const reset = useCallback(() => setSize(1), [setSize])
// Total is only available in offset mode
const total = useMemo(() => {
if (!data?.length) return 0
const first = data[0]
if (isCursorPaginationResponse(first)) return 0
return (first as OffsetPaginationResponse<any>).total
}, [data])
return {
items,
pages: data ?? [],
total: data?.[0]?.total ?? 0,
total,
size,
isLoading,
isRefreshing: isValidating,
@ -501,7 +515,8 @@ export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
swrOptions: options?.swrOptions
})
const paginatedData = data as PaginatedResponse<any>
// usePaginatedQuery is only for offset pagination
const paginatedData = data as OffsetPaginationResponse<any> | undefined
const items = paginatedData?.items || []
const total = paginatedData?.total || 0
const totalPages = Math.ceil(total / limit)

View File

@ -1,5 +1,5 @@
import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
import type { ConcreteApiPaths, PaginatedResponse } from '@shared/data/api/apiTypes'
import type { ConcreteApiPaths, PaginationResponse } from '@shared/data/api/apiTypes'
import type { KeyedMutator } from 'swr'
import { vi } from 'vitest'
@ -146,7 +146,7 @@ export const mockUsePaginatedQuery = vi.fn(
limit?: number
swrOptions?: any
}
): ResponseForPath<TPath, 'GET'> extends PaginatedResponse<infer T>
): ResponseForPath<TPath, 'GET'> extends PaginationResponse<infer T>
? {
items: T[]
total: number
@ -181,7 +181,7 @@ export const mockUsePaginatedQuery = vi.fn(
nextPage: vi.fn(),
refresh: vi.fn(),
reset: vi.fn()
} as unknown as ResponseForPath<TPath, 'GET'> extends PaginatedResponse<infer T>
} as unknown as ResponseForPath<TPath, 'GET'> extends PaginationResponse<infer T>
? {
items: T[]
total: number