diff --git a/docs/en/references/data/api-types.md b/docs/en/references/data/api-types.md index 452b3a22c0..c507b3a15d 100644 --- a/docs/en/references/data/api-types.md +++ b/docs/en/references/data/api-types.md @@ -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` | `items`, `total`, `page` | Page-based results | +| `CursorPaginationResponse` | `items`, `nextCursor?` | Cursor-based results | +| `PaginationResponse` | 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 + +// Cursor pagination for infinite scroll +query?: CursorPaginationParams & { + userId: string +} +response: CursorPaginationResponse +``` + +### 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 // response is required + query?: OffsetPaginationParams & SortParams & SearchParams + response: OffsetPaginationResponse // 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 +const topics = await api.get('/topics', { + query: { page: 1, limit: 20, search: 'hello' } +}) // Returns OffsetPaginationResponse await api.post('/topics', { body: { name: 'New' } }) // Body is typed as CreateTopicDto ``` diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index 8f17294fd5..e6ce39ee28 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -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 { +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 { /** Items for current page */ items: T[] /** Total number of items */ total: number -} - -/** - * Offset-based paginated response - */ -export interface OffsetPaginatedResponse extends BasePaginatedResponse { /** 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 extends BasePaginatedResponse { +export interface CursorPaginationResponse { + /** 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 = OffsetPaginatedResponse | CursorPaginatedResponse +export type InferPaginationMode = R extends OffsetPaginationResponse + ? 'offset' + : R extends CursorPaginationResponse + ? 'cursor' + : never + +/** + * Infer item type from pagination response + */ +export type InferPaginationItem = R extends OffsetPaginationResponse + ? T + : R extends CursorPaginationResponse + ? T + : never + +/** + * Union type for both pagination responses + */ +export type PaginationResponse = OffsetPaginationResponse | CursorPaginationResponse /** * Type guard: check if response is offset-based */ -export function isOffsetPaginatedResponse(response: PaginatedResponse): response is OffsetPaginatedResponse { - return 'page' in response && 'pageCount' in response +export function isOffsetPaginationResponse( + response: PaginationResponse +): response is OffsetPaginationResponse { + return 'page' in response && 'total' in response } /** * Type guard: check if response is cursor-based */ -export function isCursorPaginatedResponse(response: PaginatedResponse): response is CursorPaginatedResponse { - return 'nextCursor' in response || !('page' in response) +export function isCursorPaginationResponse( + response: PaginationResponse +): response is CursorPaginationResponse { + return !('page' in response) } /** diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts index 33e878a653..a90c4e2c51 100644 --- a/packages/shared/data/api/index.ts +++ b/packages/shared/data/api/index.ts @@ -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 diff --git a/packages/shared/data/api/schemas/test.ts b/packages/shared/data/api/schemas/test.ts index 6fd8681633..b1627c1ed8 100644 --- a/packages/shared/data/api/schemas/test.ts +++ b/packages/shared/data/api/schemas/test.ts @@ -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 + query?: OffsetPaginationParams & + SortParams & + SearchParams & { + /** Filter by item type */ + type?: string + /** Filter by status */ + status?: string + } + response: OffsetPaginationResponse } /** 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 + response: OffsetPaginationResponse } } diff --git a/src/main/data/api/index.ts b/src/main/data/api/index.ts index c479db0050..d2cf988727 100644 --- a/src/main/data/api/index.ts +++ b/src/main/data/api/index.ts @@ -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' diff --git a/src/main/data/services/base/IBaseService.ts b/src/main/data/services/base/IBaseService.ts index 446de55716..d9b3a4b0be 100644 --- a/src/main/data/services/base/IBaseService.ts +++ b/src/main/data/services/base/IBaseService.ts @@ -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 /** * Standard service interface for data operations @@ -14,12 +19,12 @@ export interface IBaseService { * Find multiple entities with pagination */ findMany( - params: PaginationParams & Record, + params: BasePaginationParams, options?: ServiceOptions ): Promise<{ items: T[] - total: number - hasNext?: boolean + total?: number + page?: number nextCursor?: string }> @@ -68,12 +73,12 @@ export interface ISearchableService 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 diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index a4a73230e4..4af746d9cf 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -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 = ResponseForPath extends PaginatedResponse< +type InferPaginatedItem = ResponseForPath extends PaginationResponse< infer T > ? T @@ -50,7 +50,7 @@ export interface UseMutationResult< /** useInfiniteQuery result type */ export interface UseInfiniteQueryResult { items: T[] - pages: PaginatedResponse[] + pages: PaginationResponse[] total: number size: number isLoading: boolean @@ -61,7 +61,7 @@ export interface UseInfiniteQueryResult { setSize: (size: number | ((size: number) => number)) => void refresh: () => void reset: () => void - mutate: KeyedMutator[]> + mutate: KeyedMutator[]> } /** usePaginatedQuery result type */ @@ -356,7 +356,7 @@ export function useInfiniteQuery( /** 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( const enabled = options?.enabled !== false const getKey = useCallback( - (pageIndex: number, previousPageData: PaginatedResponse | null) => { + (pageIndex: number, previousPageData: PaginationResponse | 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 + // 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( 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( ) const infiniteFetcher = (key: [ConcreteApiPaths, Record?]) => { - return getFetcher(key) as Promise> + return getFetcher(key) as Promise> } const swrResult = useSWRInfinite(getKey, infiniteFetcher, { @@ -416,7 +419,7 @@ export function useInfiniteQuery( }) const { error, isLoading, isValidating, mutate, size, setSize } = swrResult - const data = swrResult.data as PaginatedResponse[] | undefined + const data = swrResult.data as PaginationResponse[] | undefined const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data]) @@ -424,10 +427,13 @@ export function useInfiniteQuery( 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).hasNext - }, [data, mode]) + // Offset mode: check if there are more items + if (isCursorPaginationResponse(last)) return false + const offsetData = last as OffsetPaginationResponse + return offsetData.page * limit < offsetData.total + }, [data, mode, limit]) const loadNext = useCallback(() => { if (!hasNext || isValidating) return @@ -437,10 +443,18 @@ export function useInfiniteQuery( 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).total + }, [data]) + return { items, pages: data ?? [], - total: data?.[0]?.total ?? 0, + total, size, isLoading, isRefreshing: isValidating, @@ -501,7 +515,8 @@ export function usePaginatedQuery( swrOptions: options?.swrOptions }) - const paginatedData = data as PaginatedResponse + // usePaginatedQuery is only for offset pagination + const paginatedData = data as OffsetPaginationResponse | undefined const items = paginatedData?.items || [] const total = paginatedData?.total || 0 const totalPages = Math.ceil(total / limit) diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts index a1af44d41c..760a134583 100644 --- a/tests/__mocks__/renderer/useDataApi.ts +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -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 extends PaginatedResponse + ): ResponseForPath extends PaginationResponse ? { 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 extends PaginatedResponse + } as unknown as ResponseForPath extends PaginationResponse ? { items: T[] total: number