feat: add Data API channels and integrate swr for data fetching

- Introduced new IPC channels for Data API requests and responses in IpcChannel.
- Added swr library to package.json for improved data fetching capabilities.
- Updated preload API to include Data API related methods for handling requests and subscriptions.
- Removed deprecated pending_default_values.ts file as part of data refactor.
This commit is contained in:
fullex 2025-09-13 00:55:15 +08:00
parent 9e3618bc17
commit a7d12abd1f
32 changed files with 7013 additions and 39 deletions

View File

@ -310,6 +310,7 @@
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swr": "^2.3.6",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",

View File

@ -307,6 +307,15 @@ export enum IpcChannel {
Preference_Subscribe = 'preference:subscribe',
Preference_Changed = 'preference:changed',
// Data API channels
DataApi_Request = 'data-api:request',
DataApi_Response = 'data-api:response',
DataApi_Batch = 'data-api:batch',
DataApi_Transaction = 'data-api:transaction',
DataApi_Subscribe = 'data-api:subscribe',
DataApi_Unsubscribe = 'data-api:unsubscribe',
DataApi_Stream = 'data-api:stream',
// TRACE
TRACE_SAVE_DATA = 'trace:saveData',
TRACE_GET_DATA = 'trace:getData',

View File

@ -0,0 +1,198 @@
# Cherry Studio Shared Data
This directory contains shared data structures and API type definitions for the Cherry Studio application. It includes both persistent data schemas and API contract definitions.
## 📁 File Organization
### Persistent Data (Root Level)
- **`preferences.ts`** - User preference data schema and default values
- **`preferenceTypes.ts`** - TypeScript types for preference system
### API Types (`api/` subdirectory)
- **`api/index.ts`** - Barrel export file providing clean imports for all API types
- **`api/apiTypes.ts`** - Core request/response types and API infrastructure
- **`api/apiModels.ts`** - Business entity types and Data Transfer Objects (DTOs)
- **`api/apiSchemas.ts`** - Complete API endpoint definitions with type mappings
- **`api/errorCodes.ts`** - Error handling utilities and standardized error codes
## 🏗️ Architecture Overview
These files are part of the **Renderer-Main Virtual Data Acquisition Architecture** that enables:
- **Type-safe IPC communication** between Electron processes
- **RESTful API patterns** within the Electron application
- **Standardized error handling** across the application
- **Future extensibility** to standalone API servers
## 🔄 Classification Status
**Important**: These files are **NOT classified** in the data refactor system because they are:
- ✅ **Type definitions** - Not actual data storage
- ✅ **Compile-time artifacts** - Exist only during TypeScript compilation
- ✅ **Framework infrastructure** - Enable the data API architecture
- ✅ **Development tools** - Similar to interfaces in other languages
## 📖 Usage Examples
### Basic Imports
```typescript
// Import API types from the api subdirectory
import {
Topic,
CreateTopicDto,
DataRequest,
ApiSchemas,
ErrorCode
} from '@shared/data/api'
// Import specific groups
import type { TopicTypes, MessageTypes } from '@shared/data/api'
// Import preferences
import type { UserPreferences } from '@shared/data/preferences'
```
### API Schema Usage
```typescript
import type { ApiSchemas, ApiResponse } from '@shared/data/api'
// Get the response type for a specific endpoint
type TopicsListResponse = ApiResponse<'/topics', 'GET'>
// Result: PaginatedResponse<Topic>
type CreateTopicResponse = ApiResponse<'/topics', 'POST'>
// Result: Topic
```
### Error Handling
```typescript
import { DataApiErrorFactory, ErrorCode, isDataApiError } from '@shared/data/api'
// Create standardized errors
const notFoundError = DataApiErrorFactory.notFound('Topic', '123')
const validationError = DataApiErrorFactory.validation({
title: ['Title is required']
})
// Check if error is a Data API error
if (isDataApiError(error)) {
console.log(`Error ${error.code}: ${error.message}`)
}
```
### Request/Response Types
```typescript
import type { DataRequest, DataResponse, PaginatedResponse, Topic } from '@shared/data/api'
// Type-safe request construction
const request: DataRequest = {
id: 'req_123',
method: 'GET',
path: '/topics',
params: { page: 1, limit: 10 }
}
// Type-safe response handling
const response: DataResponse<PaginatedResponse<Topic>> = {
id: 'req_123',
status: 200,
data: {
items: [...],
total: 100,
page: 1,
pageCount: 10,
hasNext: true,
hasPrev: false
}
}
```
## 🔧 Development Guidelines
### Adding New Domain Models
1. Add the interface to `api/apiModels.ts`
2. Create corresponding DTOs (Create/Update)
3. Export from `api/index.ts`
4. Update API schemas if needed
```typescript
// In api/apiModels.ts
export interface NewEntity {
id: string
name: string
createdAt: string
updatedAt: string
}
export interface CreateNewEntityDto {
name: string
}
```
### Adding New API Endpoints
1. Add endpoint definition to `api/apiSchemas.ts`
2. Include JSDoc comments with examples
3. Ensure all types are properly referenced
```typescript
// In api/apiSchemas.ts
export interface ApiSchemas {
/**
* New entity endpoint
* @example GET /entities?page=1&limit=10
*/
'/entities': {
/** List all entities */
GET: {
query?: PaginationParams
response: PaginatedResponse<NewEntity>
}
}
}
```
### Type Safety Best Practices
- Always use `import type` for type-only imports
- Leverage the type helpers (`ApiResponse`, `ApiParams`, etc.)
- Use the barrel export for clean imports
- Document complex types with JSDoc comments
## 🔗 Related Files
### Main Process Implementation
- `src/main/data/DataApiService.ts` - Main process data service
- `src/main/data/api/` - Controllers, services, and routing
### Renderer Process Implementation
- `src/renderer/src/data/DataApiService.ts` - Renderer API client
- `src/renderer/src/data/hooks/` - React hooks for data fetching
### Shared Data Types
- `packages/shared/data/api/` - API contract definitions
- `packages/shared/data/preferences.ts` - User preference schemas
### Architecture Documentation
- `.claude/data-request-arch.md` - Complete architecture documentation
- `CLAUDE.md` - Project development guidelines
## 🚀 Future Enhancements
The type system is designed to support:
- **HTTP API Server** - Types can be reused for standalone HTTP APIs
- **GraphQL Integration** - Schema can be mapped to GraphQL resolvers
- **Real-time Subscriptions** - WebSocket/SSE event types are defined
- **Advanced Caching** - Cache-related types are ready for implementation
---
*This README is part of the Cherry Studio data refactor project. For more information, see the project documentation in `.claude/` directory.*

View File

@ -0,0 +1,107 @@
/**
* Generic test model definitions
* Contains flexible types for comprehensive API testing
*/
/**
* Generic test item entity - flexible structure for testing various scenarios
*/
export interface TestItem {
/** Unique identifier */
id: string
/** Item title */
title: string
/** Optional description */
description?: string
/** Type category */
type: string
/** Current status */
status: string
/** Priority level */
priority: string
/** Associated tags */
tags: string[]
/** Creation timestamp */
createdAt: string
/** Last update timestamp */
updatedAt: string
/** Additional metadata */
metadata: Record<string, any>
}
/**
* Data Transfer Objects (DTOs) for test operations
*/
/**
* DTO for creating a new test item
*/
export interface CreateTestItemDto {
/** Item title */
title: string
/** Optional description */
description?: string
/** Type category */
type?: string
/** Current status */
status?: string
/** Priority level */
priority?: string
/** Associated tags */
tags?: string[]
/** Additional metadata */
metadata?: Record<string, any>
}
/**
* DTO for updating an existing test item
*/
export interface UpdateTestItemDto {
/** Updated title */
title?: string
/** Updated description */
description?: string
/** Updated type */
type?: string
/** Updated status */
status?: string
/** Updated priority */
priority?: string
/** Updated tags */
tags?: string[]
/** Updated metadata */
metadata?: Record<string, any>
}
/**
* Bulk operation types for batch processing
*/
/**
* Request for bulk operations on multiple items
*/
export interface BulkOperationRequest<TData = any> {
/** Type of bulk operation to perform */
operation: 'create' | 'update' | 'delete' | 'archive' | 'restore'
/** Array of data items to process */
data: TData[]
}
/**
* Response from a bulk operation
*/
export interface BulkOperationResponse {
/** Number of successfully processed items */
successful: number
/** Number of items that failed processing */
failed: number
/** Array of errors that occurred during processing */
errors: Array<{
/** Index of the item that failed */
index: number
/** Error message */
error: string
/** Optional additional error data */
data?: any
}>
}

View File

@ -0,0 +1,63 @@
import type { ApiSchemas } from './apiSchemas'
/**
* Template literal type utilities for converting parameterized paths to concrete paths
* This enables type-safe API calls with actual paths like '/test/items/123' instead of '/test/items/:id'
*/
/**
* Convert parameterized path templates to concrete path types
* @example '/test/items/:id' -> '/test/items/${string}'
* @example '/topics/:id/messages' -> '/topics/${string}/messages'
*/
export type ResolvedPath<T extends string> = T extends `${infer Prefix}/:${string}/${infer Suffix}`
? `${Prefix}/${string}/${ResolvedPath<Suffix>}`
: T extends `${infer Prefix}/:${string}`
? `${Prefix}/${string}`
: T
/**
* Generate all possible concrete paths from ApiSchemas
* This creates a union type of all valid API paths
*/
export type ConcreteApiPaths = {
[K in keyof ApiSchemas]: ResolvedPath<K & string>
}[keyof ApiSchemas]
/**
* Reverse lookup: from concrete path back to original template path
* Used to determine which ApiSchema entry matches a concrete path
*/
export type MatchApiPath<Path extends string> = {
[K in keyof ApiSchemas]: Path extends ResolvedPath<K & string> ? K : never
}[keyof ApiSchemas]
/**
* Extract query parameters type for a given concrete path
*/
export type QueryParamsForPath<Path extends string> =
MatchApiPath<Path> extends keyof ApiSchemas
? ApiSchemas[MatchApiPath<Path>] extends { GET: { query?: infer Q } }
? Q
: Record<string, any>
: Record<string, any>
/**
* Extract request body type for a given concrete path and HTTP method
*/
export type BodyForPath<Path extends string, Method extends string> =
MatchApiPath<Path> extends keyof ApiSchemas
? ApiSchemas[MatchApiPath<Path>] extends { [M in Method]: { body: infer B } }
? B
: any
: any
/**
* Extract response type for a given concrete path and HTTP method
*/
export type ResponseForPath<Path extends string, Method extends string> =
MatchApiPath<Path> extends keyof ApiSchemas
? ApiSchemas[MatchApiPath<Path>] extends { [M in Method]: { response: infer R } }
? R
: any
: any

View File

@ -0,0 +1,492 @@
// NOTE: Types are defined inline in the schema for simplicity
// If needed, specific types can be imported from './apiModels'
import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths'
import type { HttpMethod, PaginatedResponse, PaginationParams } from './apiTypes'
// Re-export for external use
export type { ConcreteApiPaths } from './apiPaths'
/**
* Complete API Schema definitions for Test API
*
* Each path defines the supported HTTP methods with their:
* - Request parameters (params, query, body)
* - Response types
* - Type safety guarantees
*
* This schema serves as the contract between renderer and main processes,
* enabling full TypeScript type checking across IPC boundaries.
*/
export interface ApiSchemas {
/**
* Test items collection endpoint
* @example GET /test/items?page=1&limit=10&search=hello
* @example POST /test/items { "title": "New Test Item" }
*/
'/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<any>
}
/** Create a new test item */
POST: {
body: {
title: string
description?: string
type?: string
status?: string
priority?: string
tags?: string[]
metadata?: Record<string, any>
}
response: any
}
}
/**
* Individual test item endpoint
* @example GET /test/items/123
* @example PUT /test/items/123 { "title": "Updated Title" }
* @example DELETE /test/items/123
*/
'/test/items/:id': {
/** Get a specific test item by ID */
GET: {
params: { id: string }
response: any
}
/** Update a specific test item */
PUT: {
params: { id: string }
body: {
title?: string
description?: string
type?: string
status?: string
priority?: string
tags?: string[]
metadata?: Record<string, any>
}
response: any
}
/** Delete a specific test item */
DELETE: {
params: { id: string }
response: void
}
}
/**
* Test search endpoint
* @example GET /test/search?query=hello&page=1&limit=20
*/
'/test/search': {
/** Search test items */
GET: {
query: {
/** 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<any>
}
}
/**
* Test statistics endpoint
* @example GET /test/stats
*/
'/test/stats': {
/** Get comprehensive test statistics */
GET: {
response: {
/** Total number of items */
total: number
/** Item count grouped by type */
byType: Record<string, number>
/** Item count grouped by status */
byStatus: Record<string, number>
/** Item count grouped by priority */
byPriority: Record<string, number>
/** Recent activity timeline */
recentActivity: Array<{
/** Date of activity */
date: string
/** Number of items on that date */
count: number
}>
}
}
}
/**
* Test bulk operations endpoint
* @example POST /test/bulk { "operation": "create", "data": [...] }
*/
'/test/bulk': {
/** Perform bulk operations on test items */
POST: {
body: {
/** Operation type */
operation: 'create' | 'update' | 'delete'
/** Array of data items to process */
data: any[]
}
response: {
successful: number
failed: number
errors: string[]
}
}
}
/**
* Test error simulation endpoint
* @example POST /test/error { "errorType": "timeout" }
*/
'/test/error': {
/** Simulate various error scenarios for testing */
POST: {
body: {
/** Type of error to simulate */
errorType:
| 'timeout'
| 'network'
| 'server'
| 'notfound'
| 'validation'
| 'unauthorized'
| 'ratelimit'
| 'generic'
}
response: never
}
}
/**
* Test slow response endpoint
* @example POST /test/slow { "delay": 2000 }
*/
'/test/slow': {
/** Test slow response for performance testing */
POST: {
body: {
/** Delay in milliseconds */
delay: number
}
response: {
message: string
delay: number
timestamp: string
}
}
}
/**
* Test data reset endpoint
* @example POST /test/reset
*/
'/test/reset': {
/** Reset all test data to initial state */
POST: {
response: {
message: string
timestamp: string
}
}
}
/**
* Test config endpoint
* @example GET /test/config
* @example PUT /test/config { "setting": "value" }
*/
'/test/config': {
/** Get test configuration */
GET: {
response: Record<string, any>
}
/** Update test configuration */
PUT: {
body: Record<string, any>
response: Record<string, any>
}
}
/**
* Test status endpoint
* @example GET /test/status
*/
'/test/status': {
/** Get system test status */
GET: {
response: {
status: string
timestamp: string
version: string
uptime: number
environment: string
}
}
}
/**
* Test performance endpoint
* @example GET /test/performance
*/
'/test/performance': {
/** Get performance metrics */
GET: {
response: {
requestsPerSecond: number
averageLatency: number
memoryUsage: number
cpuUsage: number
uptime: number
}
}
}
/**
* Batch execution of multiple requests
* @example POST /batch { "requests": [...], "parallel": true }
*/
'/batch': {
/** Execute multiple API requests in a single call */
POST: {
body: {
/** Array of requests to execute */
requests: Array<{
/** HTTP method for the request */
method: HttpMethod
/** API path for the request */
path: string
/** URL parameters */
params?: any
/** Request body */
body?: any
}>
/** Execute requests in parallel vs sequential */
parallel?: boolean
}
response: {
/** Results array matching input order */
results: Array<{
/** HTTP status code */
status: number
/** Response data if successful */
data?: any
/** Error information if failed */
error?: any
}>
/** Batch execution metadata */
metadata: {
/** Total execution duration in ms */
duration: number
/** Number of successful requests */
successCount: number
/** Number of failed requests */
errorCount: number
}
}
}
}
/**
* Atomic transaction of multiple operations
* @example POST /transaction { "operations": [...], "options": { "rollbackOnError": true } }
*/
'/transaction': {
/** Execute multiple operations in a database transaction */
POST: {
body: {
/** Array of operations to execute atomically */
operations: Array<{
/** HTTP method for the operation */
method: HttpMethod
/** API path for the operation */
path: string
/** URL parameters */
params?: any
/** Request body */
body?: any
}>
/** Transaction configuration options */
options?: {
/** Database isolation level */
isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable'
/** Rollback all operations on any error */
rollbackOnError?: boolean
/** Transaction timeout in milliseconds */
timeout?: number
}
}
response: Array<{
/** HTTP status code */
status: number
/** Response data if successful */
data?: any
/** Error information if failed */
error?: any
}>
}
}
}
/**
* Simplified type extraction helpers
*/
export type ApiPaths = keyof ApiSchemas
export type ApiMethods<TPath extends ApiPaths> = keyof ApiSchemas[TPath] & HttpMethod
export type ApiResponse<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { response: infer R }
? R
: never
: never
: never
export type ApiParams<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { params: infer P }
? P
: never
: never
: never
export type ApiQuery<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { query: infer Q }
? Q
: never
: never
: never
export type ApiBody<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { body: infer B }
? B
: never
: never
: never
/**
* Type-safe API client interface using concrete paths
* Accepts actual paths like '/test/items/123' instead of '/test/items/:id'
* Automatically infers query, body, and response types from ApiSchemas
*/
export interface ApiClient {
get<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: QueryParamsForPath<TPath>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<ResponseForPath<TPath, 'GET'>>
post<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: BodyForPath<TPath, 'POST'>
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<ResponseForPath<TPath, 'POST'>>
put<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body: BodyForPath<TPath, 'PUT'>
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<ResponseForPath<TPath, 'PUT'>>
delete<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<ResponseForPath<TPath, 'DELETE'>>
patch<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: BodyForPath<TPath, 'PATCH'>
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<ResponseForPath<TPath, 'PATCH'>>
}
/**
* Helper types to determine if parameters are required based on schema
*/
type HasRequiredQuery<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { query: any }
? true
: false
: false
: false
type HasRequiredBody<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { body: any }
? true
: false
: false
: false
type HasRequiredParams<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { params: any }
? true
: false
: false
: false
/**
* Handler function for a specific API endpoint
* Provides type-safe parameter extraction based on ApiSchemas
* Parameters are required or optional based on the schema definition
*/
export type ApiHandler<Path extends ApiPaths, Method extends ApiMethods<Path>> = (
params: (HasRequiredParams<Path, Method> extends true
? { params: ApiParams<Path, Method> }
: { params?: ApiParams<Path, Method> }) &
(HasRequiredQuery<Path, Method> extends true
? { query: ApiQuery<Path, Method> }
: { query?: ApiQuery<Path, Method> }) &
(HasRequiredBody<Path, Method> extends true ? { body: ApiBody<Path, Method> } : { body?: ApiBody<Path, Method> })
) => Promise<ApiResponse<Path, Method>>
/**
* Complete API implementation that must match ApiSchemas structure
* TypeScript will error if any endpoint is missing - this ensures exhaustive coverage
*/
export type ApiImplementation = {
[Path in ApiPaths]: {
[Method in ApiMethods<Path>]: ApiHandler<Path, Method>
}
}

View File

@ -0,0 +1,289 @@
/**
* Core types for the Data API system
* Provides type definitions for request/response handling across renderer-main IPC communication
*/
/**
* Standard HTTP methods supported by the Data API
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
/**
* Request object structure for Data API calls
*/
export interface DataRequest<T = any> {
/** Unique request identifier for tracking and correlation */
id: string
/** HTTP method for the request */
method: HttpMethod
/** API path (e.g., '/topics', '/topics/123') */
path: string
/** URL parameters for the request */
params?: Record<string, any>
/** Request body data */
body?: T
/** Request headers */
headers?: Record<string, string>
/** Additional metadata for request processing */
metadata?: {
/** Request timestamp */
timestamp: number
/** OpenTelemetry span context for tracing */
spanContext?: any
/** Cache options for this specific request */
cache?: CacheOptions
}
}
/**
* Response object structure for Data API calls
*/
export interface DataResponse<T = any> {
/** Request ID that this response corresponds to */
id: string
/** HTTP status code */
status: number
/** Response data if successful */
data?: T
/** Error information if request failed */
error?: DataApiError
/** Response metadata */
metadata?: {
/** Request processing duration in milliseconds */
duration: number
/** Whether response was served from cache */
cached?: boolean
/** Cache TTL if applicable */
cacheTtl?: number
/** Response timestamp */
timestamp: number
}
}
/**
* Standardized error structure for Data API
*/
export interface DataApiError {
/** Error code for programmatic handling */
code: string
/** Human-readable error message */
message: string
/** HTTP status code */
status: number
/** Additional error details */
details?: any
/** Error stack trace (development mode only) */
stack?: string
}
/**
* Standard error codes for Data API
*/
export enum ErrorCode {
// Client errors (4xx)
BAD_REQUEST = 'BAD_REQUEST',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NOT_FOUND = 'NOT_FOUND',
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
VALIDATION_ERROR = 'VALIDATION_ERROR',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
// Server errors (5xx)
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
// Custom application errors
MIGRATION_ERROR = 'MIGRATION_ERROR',
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_LOCKED = 'RESOURCE_LOCKED',
CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION'
}
/**
* Cache configuration options
*/
export interface CacheOptions {
/** Cache TTL in seconds */
ttl?: number
/** Return stale data while revalidating in background */
staleWhileRevalidate?: boolean
/** Custom cache key override */
cacheKey?: string
/** Operations that should invalidate this cache entry */
invalidateOn?: string[]
/** Whether to bypass cache entirely */
noCache?: boolean
}
/**
* Transaction request wrapper for atomic operations
*/
export interface TransactionRequest {
/** List of operations to execute in transaction */
operations: DataRequest[]
/** Transaction options */
options?: {
/** Database isolation level */
isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable'
/** Whether to rollback entire transaction on any error */
rollbackOnError?: boolean
/** Transaction timeout in milliseconds */
timeout?: number
}
}
/**
* Batch request for multiple operations
*/
export interface BatchRequest {
/** List of requests to execute */
requests: DataRequest[]
/** Whether to execute requests in parallel */
parallel?: boolean
/** Stop on first error */
stopOnError?: boolean
}
/**
* Batch response containing results for all requests
*/
export interface BatchResponse {
/** Individual response for each request */
results: DataResponse[]
/** Overall batch execution metadata */
metadata: {
/** Total execution time */
duration: number
/** Number of successful operations */
successCount: number
/** Number of failed operations */
errorCount: number
}
}
/**
* Pagination parameters for list operations
*/
export interface PaginationParams {
/** Page number (1-based) */
page?: number
/** Items per page */
limit?: number
/** Cursor for cursor-based pagination */
cursor?: string
/** Sort field and direction */
sort?: {
field: string
order: 'asc' | 'desc'
}
}
/**
* Paginated response wrapper
*/
export interface PaginatedResponse<T> {
/** Items for current page */
items: T[]
/** Total number of items */
total: number
/** Current page number */
page: number
/** Total number of pages */
pageCount: number
/** Whether there are more pages */
hasNext: boolean
/** Whether there are previous pages */
hasPrev: boolean
/** Next cursor for cursor-based pagination */
nextCursor?: string
/** Previous cursor for cursor-based pagination */
prevCursor?: string
}
/**
* Subscription options for real-time data updates
*/
export interface SubscriptionOptions {
/** Path pattern to subscribe to */
path: string
/** Filters to apply to subscription */
filters?: Record<string, any>
/** Whether to receive initial data */
includeInitial?: boolean
/** Custom subscription metadata */
metadata?: Record<string, any>
}
/**
* Subscription callback function
*/
export type SubscriptionCallback<T = any> = (data: T, event: SubscriptionEvent) => void
/**
* Subscription event types
*/
export enum SubscriptionEvent {
CREATED = 'created',
UPDATED = 'updated',
DELETED = 'deleted',
INITIAL = 'initial',
ERROR = 'error'
}
/**
* Middleware interface
*/
export interface Middleware {
/** Middleware name */
name: string
/** Execution priority (lower = earlier) */
priority?: number
/** Middleware execution function */
execute(req: DataRequest, res: DataResponse, next: () => Promise<void>): Promise<void>
}
/**
* Request context passed through middleware chain
*/
export interface RequestContext {
/** Original request */
request: DataRequest
/** Response being built */
response: DataResponse
/** Path that matched this request */
path?: string
/** HTTP method */
method?: HttpMethod
/** Authenticated user (if any) */
user?: any
/** Additional context data */
data: Map<string, any>
}
/**
* Base options for service operations
*/
export interface ServiceOptions {
/** Database transaction to use */
transaction?: any
/** User context for authorization */
user?: any
/** Additional service-specific options */
metadata?: Record<string, any>
}
/**
* Standard service response wrapper
*/
export interface ServiceResult<T = any> {
/** Whether operation was successful */
success: boolean
/** Result data if successful */
data?: T
/** Error information if failed */
error?: DataApiError
/** Additional metadata */
metadata?: Record<string, any>
}

View File

@ -0,0 +1,193 @@
/**
* Centralized error code definitions for the Data API system
* Provides consistent error handling across renderer and main processes
*/
import { DataApiError, ErrorCode } from './apiTypes'
// Re-export ErrorCode for convenience
export { ErrorCode } from './apiTypes'
/**
* Error code to HTTP status mapping
*/
export const ERROR_STATUS_MAP: Record<ErrorCode, number> = {
// Client errors (4xx)
[ErrorCode.BAD_REQUEST]: 400,
[ErrorCode.UNAUTHORIZED]: 401,
[ErrorCode.FORBIDDEN]: 403,
[ErrorCode.NOT_FOUND]: 404,
[ErrorCode.METHOD_NOT_ALLOWED]: 405,
[ErrorCode.VALIDATION_ERROR]: 422,
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
// Server errors (5xx)
[ErrorCode.INTERNAL_SERVER_ERROR]: 500,
[ErrorCode.DATABASE_ERROR]: 500,
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
// Custom application errors (5xx)
[ErrorCode.MIGRATION_ERROR]: 500,
[ErrorCode.PERMISSION_DENIED]: 403,
[ErrorCode.RESOURCE_LOCKED]: 423,
[ErrorCode.CONCURRENT_MODIFICATION]: 409
}
/**
* Default error messages for each error code
*/
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
[ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters',
[ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required',
[ErrorCode.FORBIDDEN]: 'Forbidden: Insufficient permissions',
[ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist',
[ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint',
[ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements',
[ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests',
[ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred',
[ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data',
[ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable',
[ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data',
[ErrorCode.PERMISSION_DENIED]: 'Permission denied: Operation not allowed for current user',
[ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation',
[ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user'
}
/**
* Utility class for creating standardized Data API errors
*/
export class DataApiErrorFactory {
/**
* Create a DataApiError with standard properties
*/
static create(code: ErrorCode, customMessage?: string, details?: any, stack?: string): DataApiError {
return {
code,
message: customMessage || ERROR_MESSAGES[code],
status: ERROR_STATUS_MAP[code],
details,
stack: process.env.NODE_ENV === 'development' ? stack : undefined
}
}
/**
* Create a validation error with field-specific details
*/
static validation(fieldErrors: Record<string, string[]>, message?: string): DataApiError {
return this.create(ErrorCode.VALIDATION_ERROR, message || 'Request validation failed', { fieldErrors })
}
/**
* Create a not found error for specific resource
*/
static notFound(resource: string, id?: string): DataApiError {
const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`
return this.create(ErrorCode.NOT_FOUND, message, { resource, id })
}
/**
* Create a database error with query details
*/
static database(originalError: Error, operation?: string): DataApiError {
return this.create(
ErrorCode.DATABASE_ERROR,
`Database operation failed${operation ? `: ${operation}` : ''}`,
{
originalError: originalError.message,
operation
},
originalError.stack
)
}
/**
* Create a permission denied error
*/
static permissionDenied(action: string, resource?: string): DataApiError {
const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}`
return this.create(ErrorCode.PERMISSION_DENIED, message, { action, resource })
}
/**
* Create an internal server error from an unexpected error
*/
static internal(originalError: Error, context?: string): DataApiError {
const message = context
? `Internal error in ${context}: ${originalError.message}`
: `Internal error: ${originalError.message}`
return this.create(
ErrorCode.INTERNAL_SERVER_ERROR,
message,
{ originalError: originalError.message, context },
originalError.stack
)
}
/**
* Create a rate limit exceeded error
*/
static rateLimit(limit: number, windowMs: number): DataApiError {
return this.create(ErrorCode.RATE_LIMIT_EXCEEDED, `Rate limit exceeded: ${limit} requests per ${windowMs}ms`, {
limit,
windowMs
})
}
/**
* Create a resource locked error
*/
static resourceLocked(resource: string, id: string, lockedBy?: string): DataApiError {
const message = lockedBy
? `${resource} '${id}' is locked by ${lockedBy}`
: `${resource} '${id}' is currently locked`
return this.create(ErrorCode.RESOURCE_LOCKED, message, { resource, id, lockedBy })
}
/**
* Create a concurrent modification error
*/
static concurrentModification(resource: string, id: string): DataApiError {
return this.create(ErrorCode.CONCURRENT_MODIFICATION, `${resource} '${id}' was modified by another user`, {
resource,
id
})
}
}
/**
* Check if an error is a Data API error
*/
export function isDataApiError(error: any): error is DataApiError {
return (
error &&
typeof error === 'object' &&
typeof error.code === 'string' &&
typeof error.message === 'string' &&
typeof error.status === 'number'
)
}
/**
* Convert a generic error to a DataApiError
*/
export function toDataApiError(error: unknown, context?: string): DataApiError {
if (isDataApiError(error)) {
return error
}
if (error instanceof Error) {
return DataApiErrorFactory.internal(error, context)
}
return DataApiErrorFactory.create(
ErrorCode.INTERNAL_SERVER_ERROR,
`Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`,
{ originalError: error, context }
)
}

View File

@ -0,0 +1,121 @@
/**
* Cherry Studio Data API - Barrel Exports
*
* This file provides a centralized entry point for all data API types,
* schemas, and utilities. Import everything you need from this single location.
*
* @example
* ```typescript
* import { Topic, CreateTopicDto, ApiSchemas, DataRequest, ErrorCode } from '@/shared/data'
* ```
*/
// Core data API types and infrastructure
export type {
BatchRequest,
BatchResponse,
CacheOptions,
DataApiError,
DataRequest,
DataResponse,
HttpMethod,
Middleware,
PaginatedResponse,
PaginationParams,
RequestContext,
ServiceOptions,
ServiceResult,
SubscriptionCallback,
SubscriptionOptions,
TransactionRequest
} from './apiTypes'
export { ErrorCode, SubscriptionEvent } from './apiTypes'
// Domain models and DTOs
export type {
BulkOperationRequest,
BulkOperationResponse,
CreateTestItemDto,
TestItem,
UpdateTestItemDto
} from './apiModels'
// API schema definitions and type helpers
export type {
ApiBody,
ApiClient,
ApiMethods,
ApiParams,
ApiPaths,
ApiQuery,
ApiResponse,
ApiSchemas
} from './apiSchemas'
// Path type utilities for template literal types
export type {
BodyForPath,
ConcreteApiPaths,
MatchApiPath,
QueryParamsForPath,
ResolvedPath,
ResponseForPath
} from './apiPaths'
// Error handling utilities
export {
ErrorCode as DataApiErrorCode,
DataApiErrorFactory,
ERROR_MESSAGES,
ERROR_STATUS_MAP,
isDataApiError,
toDataApiError
} from './errorCodes'
/**
* Re-export commonly used type combinations for convenience
*/
// Import types for re-export convenience types
import type { CreateTestItemDto, TestItem, UpdateTestItemDto } from './apiModels'
import type {
BatchRequest,
BatchResponse,
DataApiError,
DataRequest,
DataResponse,
ErrorCode,
PaginatedResponse,
PaginationParams,
TransactionRequest
} from './apiTypes'
import type { DataApiErrorFactory } from './errorCodes'
/** All test item-related types */
export type TestItemTypes = {
TestItem: TestItem
CreateTestItemDto: CreateTestItemDto
UpdateTestItemDto: UpdateTestItemDto
}
/** All error-related types and utilities */
export type ErrorTypes = {
DataApiError: DataApiError
ErrorCode: ErrorCode
ErrorFactory: typeof DataApiErrorFactory
}
/** All request/response types */
export type RequestTypes = {
DataRequest: DataRequest
DataResponse: DataResponse
BatchRequest: BatchRequest
BatchResponse: BatchResponse
TransactionRequest: TransactionRequest
}
/** All pagination-related types */
export type PaginationTypes = {
PaginationParams: PaginationParams
PaginatedResponse: PaginatedResponse<any>
}

View File

@ -1,22 +0,0 @@
/**
* preferences.ts中
*/
import type { SelectionActionItem } from './preferenceTypes'
export const defaultActionItems: SelectionActionItem[] = [
{ id: 'translate', name: 'selection.action.builtin.translate', enabled: true, isBuiltIn: true, icon: 'languages' },
{ id: 'explain', name: 'selection.action.builtin.explain', enabled: true, isBuiltIn: true, icon: 'file-question' },
{ id: 'summary', name: 'selection.action.builtin.summary', enabled: true, isBuiltIn: true, icon: 'scan-text' },
{
id: 'search',
name: 'selection.action.builtin.search',
enabled: true,
isBuiltIn: true,
icon: 'search',
searchEngine: 'Google|https://www.google.com/search?q={{queryString}}'
},
{ id: 'copy', name: 'selection.action.builtin.copy', enabled: true, isBuiltIn: true, icon: 'clipboard-copy' },
{ id: 'refine', name: 'selection.action.builtin.refine', enabled: false, isBuiltIn: true, icon: 'wand-sparkles' },
{ id: 'quote', name: 'selection.action.builtin.quote', enabled: false, isBuiltIn: true, icon: 'quote' }
]

View File

@ -0,0 +1,128 @@
import { loggerService } from '@logger'
import { ApiServer, IpcAdapter } from './api'
import { apiHandlers } from './api/handlers'
const logger = loggerService.withContext('DataApiService')
/**
* Data API service for Electron environment
* Coordinates the API server and IPC adapter
*/
class DataApiService {
private static instance: DataApiService
private initialized = false
private apiServer: ApiServer
private ipcAdapter: IpcAdapter
private constructor() {
// Initialize ApiServer with handlers
this.apiServer = ApiServer.initialize(apiHandlers)
this.ipcAdapter = new IpcAdapter(this.apiServer)
}
/**
* Get singleton instance
*/
public static getInstance(): DataApiService {
if (!DataApiService.instance) {
DataApiService.instance = new DataApiService()
}
return DataApiService.instance
}
/**
* Initialize the Data API system
*/
public async initialize(): Promise<void> {
if (this.initialized) {
logger.warn('DataApiService already initialized')
return
}
try {
logger.info('Initializing Data API system...')
// API handlers are already registered during ApiServer initialization
logger.debug('API handlers initialized with type-safe routing')
// Setup IPC handlers
this.ipcAdapter.setupHandlers()
this.initialized = true
logger.info('Data API system initialized successfully')
// Log system info
this.logSystemInfo()
} catch (error) {
logger.error('Failed to initialize Data API system', error as Error)
throw error
}
}
/**
* Log system information for debugging
*/
private logSystemInfo(): void {
const systemInfo = this.apiServer.getSystemInfo()
logger.info('Data API system ready', {
server: systemInfo.server,
version: systemInfo.version,
handlers: systemInfo.handlers,
middlewares: systemInfo.middlewares
})
}
/**
* Get system status and statistics
*/
public getSystemStatus() {
if (!this.initialized) {
return {
initialized: false,
error: 'DataApiService not initialized'
}
}
const systemInfo = this.apiServer.getSystemInfo()
return {
initialized: true,
ipcInitialized: this.ipcAdapter.isInitialized(),
...systemInfo
}
}
/**
* Get API server instance (for advanced usage)
*/
public getApiServer(): ApiServer {
return this.apiServer
}
/**
* Shutdown the Data API system
*/
public async shutdown(): Promise<void> {
if (!this.initialized) {
return
}
try {
logger.info('Shutting down Data API system...')
// Remove IPC handlers
this.ipcAdapter.removeHandlers()
this.initialized = false
logger.info('Data API system shutdown complete')
} catch (error) {
logger.error('Error during Data API shutdown', error as Error)
throw error
}
}
}
// Export singleton instance
export const dataApiService = DataApiService.getInstance()

View File

@ -0,0 +1,264 @@
import { loggerService } from '@logger'
import type { ApiImplementation } from '@shared/data/api/apiSchemas'
import type { DataRequest, DataResponse, HttpMethod, RequestContext } from '@shared/data/api/apiTypes'
import { DataApiErrorFactory, ErrorCode } from '@shared/data/api/errorCodes'
import { MiddlewareEngine } from './MiddlewareEngine'
// Handler function type
type HandlerFunction = (params: { params?: Record<string, string>; query?: any; body?: any }) => Promise<any>
const logger = loggerService.withContext('DataApiServer')
/**
* Core API Server - Transport agnostic request processor
* Now uses direct handler mapping for type-safe routing
*/
export class ApiServer {
private static instance: ApiServer
private middlewareEngine: MiddlewareEngine
private handlers: ApiImplementation
private constructor(handlers: ApiImplementation) {
this.middlewareEngine = new MiddlewareEngine()
this.handlers = handlers
}
/**
* Initialize singleton instance with handlers
*/
public static initialize(handlers: ApiImplementation): ApiServer {
if (ApiServer.instance) {
throw new Error('ApiServer already initialized')
}
ApiServer.instance = new ApiServer(handlers)
return ApiServer.instance
}
/**
* Get singleton instance
*/
public static getInstance(): ApiServer {
if (!ApiServer.instance) {
throw new Error('ApiServer not initialized. Call initialize() first.')
}
return ApiServer.instance
}
/**
* Register middleware
*/
use(middleware: any): void {
this.middlewareEngine.use(middleware)
}
/**
* Main request handler - direct handler lookup
*/
async handleRequest(request: DataRequest): Promise<DataResponse> {
const { method, path } = request
const startTime = Date.now()
logger.debug(`Processing request: ${method} ${path}`)
try {
// Find handler
const handlerMatch = this.findHandler(path, method as HttpMethod)
if (!handlerMatch) {
throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, `Handler not found: ${method} ${path}`)
}
// Create request context
const requestContext = this.createRequestContext(request, path, method as HttpMethod)
// Execute middleware chain
await this.middlewareEngine.executeMiddlewares(requestContext)
// Execute handler if middleware didn't set error
if (!requestContext.response.error) {
await this.executeHandler(requestContext, handlerMatch)
}
// Set timing metadata
requestContext.response.metadata = {
...requestContext.response.metadata,
duration: Date.now() - startTime,
timestamp: Date.now()
}
return requestContext.response
} catch (error) {
logger.error(`Request handling failed: ${method} ${path}`, error as Error)
const apiError = DataApiErrorFactory.create(ErrorCode.INTERNAL_SERVER_ERROR, (error as Error).message)
return {
id: request.id,
status: apiError.status,
error: apiError,
metadata: {
duration: Date.now() - startTime,
timestamp: Date.now()
}
}
}
}
/**
* Handle batch requests
*/
async handleBatchRequest(batchRequest: DataRequest): Promise<DataResponse> {
const requests = batchRequest.body?.requests || []
if (!Array.isArray(requests)) {
throw DataApiErrorFactory.create(ErrorCode.VALIDATION_ERROR, 'Batch request body must contain requests array')
}
logger.debug(`Processing batch request with ${requests.length} requests`)
// Use the batch handler from our handlers
const batchHandler = this.handlers['/batch']?.POST
if (!batchHandler) {
throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, 'Batch handler not found')
}
const result = await batchHandler({ body: batchRequest.body })
return {
id: batchRequest.id,
status: 200,
data: result,
metadata: {
duration: 0,
timestamp: Date.now()
}
}
}
/**
* Find handler for given path and method
*/
private findHandler(
path: string,
method: HttpMethod
): { handler: HandlerFunction; params: Record<string, string> } | null {
// Direct lookup first
const directHandler = (this.handlers as any)[path]?.[method]
if (directHandler) {
return { handler: directHandler, params: {} }
}
// Pattern matching for parameterized paths
for (const [pattern, methods] of Object.entries(this.handlers)) {
if (pattern.includes(':') && (methods as any)[method]) {
const params = this.extractPathParams(pattern, path)
if (params !== null) {
return { handler: (methods as any)[method], params }
}
}
}
return null
}
/**
* Extract path parameters from URL
*/
private extractPathParams(pattern: string, path: string): Record<string, string> | null {
const patternParts = pattern.split('/')
const pathParts = path.split('/')
if (patternParts.length !== pathParts.length) {
return null
}
const params: Record<string, string> = {}
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) {
const paramName = patternParts[i].slice(1)
params[paramName] = pathParts[i]
} else if (patternParts[i] !== pathParts[i]) {
return null
}
}
return params
}
/**
* Create request context
*/
private createRequestContext(request: DataRequest, path: string, method: HttpMethod): RequestContext {
const response: DataResponse = {
id: request.id,
status: 200
}
return {
request,
response,
path,
method,
data: new Map()
}
}
/**
* Execute handler function
*/
private async executeHandler(
context: RequestContext,
handlerMatch: { handler: HandlerFunction; params: Record<string, string> }
): Promise<void> {
const { request, response } = context
const { handler, params } = handlerMatch
try {
// Prepare handler parameters
const handlerParams = {
params,
query: request.params, // URL query parameters
body: request.body
}
// Execute handler
const result = await handler(handlerParams)
// Set response data
if (result !== undefined) {
response.data = result
}
if (!response.status) {
response.status = 200
}
} catch (error) {
logger.error('Handler execution failed', error as Error)
throw error
}
}
/**
* Get system information
*/
getSystemInfo() {
const handlerPaths = Object.keys(this.handlers)
const handlerCount = handlerPaths.reduce((count, path) => {
return count + Object.keys((this.handlers as any)[path]).length
}, 0)
const middlewares = this.middlewareEngine.getMiddlewares()
return {
server: 'DataApiServer',
version: '2.0',
handlers: {
paths: handlerPaths,
total: handlerCount
},
middlewares: middlewares
}
}
}

View File

@ -0,0 +1,177 @@
import { loggerService } from '@logger'
import type { DataRequest, DataResponse, Middleware, RequestContext } from '@shared/data/api/apiTypes'
import { toDataApiError } from '@shared/data/api/errorCodes'
const logger = loggerService.withContext('MiddlewareEngine')
/**
* Middleware engine for executing middleware chains
* Extracted from ResponseService to support reusability
*/
export class MiddlewareEngine {
private middlewares = new Map<string, Middleware>()
private middlewareOrder: string[] = []
constructor() {
this.setupDefaultMiddlewares()
}
/**
* Register middleware
*/
use(middleware: Middleware): void {
this.middlewares.set(middleware.name, middleware)
// Insert based on priority
const priority = middleware.priority || 50
let insertIndex = 0
for (let i = 0; i < this.middlewareOrder.length; i++) {
const existingMiddleware = this.middlewares.get(this.middlewareOrder[i])
const existingPriority = existingMiddleware?.priority || 50
if (priority < existingPriority) {
insertIndex = i
break
}
insertIndex = i + 1
}
this.middlewareOrder.splice(insertIndex, 0, middleware.name)
logger.debug(`Registered middleware: ${middleware.name} (priority: ${priority})`)
}
/**
* Execute middleware chain
*/
async executeMiddlewares(context: RequestContext, middlewareNames: string[] = this.middlewareOrder): Promise<void> {
let index = 0
const next = async (): Promise<void> => {
if (index >= middlewareNames.length) {
return
}
const middlewareName = middlewareNames[index++]
const middleware = this.middlewares.get(middlewareName)
if (!middleware) {
logger.warn(`Middleware not found: ${middlewareName}`)
return next()
}
await middleware.execute(context.request, context.response, next)
}
await next()
}
/**
* Setup default middlewares
*/
private setupDefaultMiddlewares(): void {
// Error handling middleware (should be first)
this.use({
name: 'error-handler',
priority: 0,
execute: async (req: DataRequest, res: DataResponse, next: () => Promise<void>) => {
try {
await next()
} catch (error) {
logger.error(`Request error: ${req.method} ${req.path}`, error as Error)
const apiError = toDataApiError(error, `${req.method} ${req.path}`)
res.error = apiError
res.status = apiError.status
}
}
})
// Request logging middleware
this.use({
name: 'request-logger',
priority: 10,
execute: async (req: DataRequest, res: DataResponse, next: () => Promise<void>) => {
const startTime = Date.now()
logger.debug(`Incoming request: ${req.method} ${req.path}`, {
id: req.id,
params: req.params,
body: req.body
})
await next()
const duration = Date.now() - startTime
res.metadata = {
...res.metadata,
duration,
timestamp: Date.now()
}
logger.debug(`Request completed: ${req.method} ${req.path}`, {
id: req.id,
status: res.status,
duration
})
}
})
// Response formatting middleware (should be last)
this.use({
name: 'response-formatter',
priority: 100,
execute: async (_req: DataRequest, res: DataResponse, next: () => Promise<void>) => {
await next()
// Ensure response always has basic structure
if (!res.status) {
res.status = 200
}
if (!res.metadata) {
res.metadata = {
duration: 0,
timestamp: Date.now()
}
}
}
})
}
/**
* Get all middleware names in execution order
*/
getMiddlewares(): string[] {
return [...this.middlewareOrder]
}
/**
* Get middleware by name
*/
getMiddleware(name: string): Middleware | undefined {
return this.middlewares.get(name)
}
/**
* Remove middleware
*/
removeMiddleware(name: string): void {
this.middlewares.delete(name)
const index = this.middlewareOrder.indexOf(name)
if (index > -1) {
this.middlewareOrder.splice(index, 1)
}
logger.debug(`Removed middleware: ${name}`)
}
/**
* Clear all middlewares
*/
clear(): void {
this.middlewares.clear()
this.middlewareOrder = []
logger.debug('Cleared all middlewares')
}
}

View File

@ -0,0 +1,158 @@
import { loggerService } from '@logger'
import type { DataRequest, DataResponse } from '@shared/data/api/apiTypes'
import { toDataApiError } from '@shared/data/api/errorCodes'
import { IpcChannel } from '@shared/IpcChannel'
import { ipcMain } from 'electron'
import { ApiServer } from '../ApiServer'
const logger = loggerService.withContext('DataApiIpcAdapter')
/**
* IPC Adapter for Electron environment
* Handles IPC communication and forwards requests to ApiServer
*/
export class IpcAdapter {
private initialized = false
constructor(private apiServer: ApiServer) {}
/**
* Setup IPC handlers
*/
setupHandlers(): void {
if (this.initialized) {
logger.warn('IPC handlers already initialized')
return
}
logger.debug('Setting up IPC handlers...')
// Main data request handler
ipcMain.handle(IpcChannel.DataApi_Request, async (event, request: DataRequest): Promise<DataResponse> => {
try {
logger.debug(`Handling data request: ${request.method} ${request.path}`, {
id: request.id,
params: request.params
})
const response = await this.apiServer.handleRequest(request)
// Send response back via Data_Response channel for async handling
event.sender.send(IpcChannel.DataApi_Response, response)
return response
} catch (error) {
logger.error(`Data request failed: ${request.method} ${request.path}`, error as Error)
const apiError = toDataApiError(error, `${request.method} ${request.path}`)
const errorResponse: DataResponse = {
id: request.id,
status: apiError.status,
error: apiError,
metadata: {
duration: 0,
timestamp: Date.now()
}
}
// Send error response
event.sender.send(IpcChannel.DataApi_Response, errorResponse)
return errorResponse
}
})
// Batch request handler
ipcMain.handle(IpcChannel.DataApi_Batch, async (_event, batchRequest: DataRequest): Promise<DataResponse> => {
try {
logger.debug('Handling batch request', { requestCount: batchRequest.body?.requests?.length })
const response = await this.apiServer.handleBatchRequest(batchRequest)
return response
} catch (error) {
logger.error('Batch request failed', error as Error)
const apiError = toDataApiError(error, 'batch request')
return {
id: batchRequest.id,
status: apiError.status,
error: apiError,
metadata: {
duration: 0,
timestamp: Date.now()
}
}
}
})
// Transaction handler (placeholder)
ipcMain.handle(
IpcChannel.DataApi_Transaction,
async (_event, transactionRequest: DataRequest): Promise<DataResponse> => {
try {
logger.debug('Handling transaction request')
// TODO: Implement transaction support
throw new Error('Transaction support not yet implemented')
} catch (error) {
logger.error('Transaction request failed', error as Error)
const apiError = toDataApiError(error, 'transaction request')
return {
id: transactionRequest.id,
status: apiError.status,
error: apiError,
metadata: {
duration: 0,
timestamp: Date.now()
}
}
}
}
)
// Subscription handlers (placeholder for future real-time features)
ipcMain.handle(IpcChannel.DataApi_Subscribe, async (_event, path: string) => {
logger.debug(`Data subscription request: ${path}`)
// TODO: Implement real-time subscriptions
return { success: true, subscriptionId: `sub_${Date.now()}` }
})
ipcMain.handle(IpcChannel.DataApi_Unsubscribe, async (_event, subscriptionId: string) => {
logger.debug(`Data unsubscription request: ${subscriptionId}`)
// TODO: Implement real-time subscriptions
return { success: true }
})
this.initialized = true
logger.debug('IPC handlers setup complete')
}
/**
* Remove IPC handlers
*/
removeHandlers(): void {
if (!this.initialized) {
return
}
logger.debug('Removing IPC handlers...')
ipcMain.removeHandler(IpcChannel.DataApi_Request)
ipcMain.removeHandler(IpcChannel.DataApi_Batch)
ipcMain.removeHandler(IpcChannel.DataApi_Transaction)
ipcMain.removeHandler(IpcChannel.DataApi_Subscribe)
ipcMain.removeHandler(IpcChannel.DataApi_Unsubscribe)
this.initialized = false
logger.debug('IPC handlers removed')
}
/**
* Check if handlers are initialized
*/
isInitialized(): boolean {
return this.initialized
}
}

View File

@ -0,0 +1,211 @@
/**
* Complete API handler implementation
*
* This file implements ALL endpoints defined in ApiSchemas.
* TypeScript will error if any endpoint is missing.
*/
import type { ApiImplementation } from '@shared/data/api/apiSchemas'
import { TestService } from '../services/TestService'
// Service instances
const testService = TestService.getInstance()
/**
* Complete API handlers implementation
* Must implement every path+method combination from ApiSchemas
*/
export const apiHandlers: ApiImplementation = {
'/test/items': {
GET: async ({ query }) => {
return await testService.getItems({
page: (query as any)?.page,
limit: (query as any)?.limit,
search: (query as any)?.search,
type: (query as any)?.type,
status: (query as any)?.status
})
},
POST: async ({ body }) => {
return await testService.createItem({
title: body.title,
description: body.description,
type: body.type,
status: body.status,
priority: body.priority,
tags: body.tags,
metadata: body.metadata
})
}
},
'/test/items/:id': {
GET: async ({ params }) => {
const item = await testService.getItemById(params.id)
if (!item) {
throw new Error(`Test item not found: ${params.id}`)
}
return item
},
PUT: async ({ params, body }) => {
const item = await testService.updateItem(params.id, {
title: body.title,
description: body.description,
type: body.type,
status: body.status,
priority: body.priority,
tags: body.tags,
metadata: body.metadata
})
if (!item) {
throw new Error(`Test item not found: ${params.id}`)
}
return item
},
DELETE: async ({ params }) => {
const deleted = await testService.deleteItem(params.id)
if (!deleted) {
throw new Error(`Test item not found: ${params.id}`)
}
return undefined
}
},
'/test/search': {
GET: async ({ query }) => {
return await testService.searchItems(query.query, {
page: query.page,
limit: query.limit,
filters: {
type: query.type,
status: query.status
}
})
}
},
'/test/stats': {
GET: async () => {
return await testService.getStats()
}
},
'/test/bulk': {
POST: async ({ body }) => {
return await testService.bulkOperation(body.operation, body.data)
}
},
'/test/error': {
POST: async ({ body }) => {
return await testService.simulateError(body.errorType)
}
},
'/test/slow': {
POST: async ({ body }) => {
const delay = body.delay
await new Promise((resolve) => setTimeout(resolve, delay))
return {
message: `Slow response completed after ${delay}ms`,
delay,
timestamp: new Date().toISOString()
}
}
},
'/test/reset': {
POST: async () => {
await testService.resetData()
return {
message: 'Test data reset successfully',
timestamp: new Date().toISOString()
}
}
},
'/test/config': {
GET: async () => {
return {
environment: 'test',
version: '1.0.0',
debug: true,
features: {
bulkOperations: true,
search: true,
statistics: true
}
}
},
PUT: async ({ body }) => {
return {
...body,
updated: true,
timestamp: new Date().toISOString()
}
}
},
'/test/status': {
GET: async () => {
return {
status: 'healthy',
timestamp: new Date().toISOString(),
version: '1.0.0',
uptime: Math.floor(process.uptime()),
environment: 'test'
}
}
},
'/test/performance': {
GET: async () => {
const memUsage = process.memoryUsage()
return {
requestsPerSecond: Math.floor(Math.random() * 100) + 50,
averageLatency: Math.floor(Math.random() * 200) + 50,
memoryUsage: memUsage.heapUsed / 1024 / 1024, // MB
cpuUsage: Math.random() * 100,
uptime: Math.floor(process.uptime())
}
}
},
'/batch': {
POST: async ({ body }) => {
// Mock batch implementation - can be enhanced with actual batch processing
const { requests } = body
const results = requests.map(() => ({
status: 200,
data: { processed: true, timestamp: new Date().toISOString() }
}))
return {
results,
metadata: {
duration: Math.floor(Math.random() * 500) + 100,
successCount: requests.length,
errorCount: 0
}
}
}
},
'/transaction': {
POST: async ({ body }) => {
// Mock transaction implementation - can be enhanced with actual transaction support
const { operations } = body
return operations.map(() => ({
status: 200,
data: { executed: true, timestamp: new Date().toISOString() }
}))
}
}
}

View File

@ -0,0 +1,32 @@
/**
* API Module - Unified entry point
*
* This module exports all necessary components for the Data API system
* Designed to be portable and reusable in different environments
*/
// Core components
export { ApiServer } from './core/ApiServer'
export { MiddlewareEngine } from './core/MiddlewareEngine'
// Adapters
export { IpcAdapter } from './core/adapters/IpcAdapter'
// export { HttpAdapter } from './core/adapters/HttpAdapter' // Future implementation
// Handlers (new type-safe system)
export { apiHandlers } from './handlers'
// Services (still used by handlers)
export { TestService } from './services/TestService'
// Re-export types for convenience
export type { CreateTestItemDto, TestItem, UpdateTestItemDto } from '@shared/data/api'
export type {
DataRequest,
DataResponse,
Middleware,
PaginatedResponse,
PaginationParams,
RequestContext,
ServiceOptions
} from '@shared/data/api/apiTypes'

View File

@ -0,0 +1,108 @@
import type { PaginationParams, ServiceOptions } from '@shared/data/api/apiTypes'
/**
* Standard service interface for data operations
* Defines the contract that all services should implement
*/
export interface IBaseService<T = any, TCreate = any, TUpdate = any> {
/**
* Find entity by ID
*/
findById(id: string, options?: ServiceOptions): Promise<T | null>
/**
* Find multiple entities with pagination
*/
findMany(
params: PaginationParams & Record<string, any>,
options?: ServiceOptions
): Promise<{
items: T[]
total: number
hasNext?: boolean
nextCursor?: string
}>
/**
* Create new entity
*/
create(data: TCreate, options?: ServiceOptions): Promise<T>
/**
* Update existing entity
*/
update(id: string, data: TUpdate, options?: ServiceOptions): Promise<T>
/**
* Delete entity (hard or soft delete depending on implementation)
*/
delete(id: string, options?: ServiceOptions): Promise<void>
/**
* Check if entity exists
*/
exists(id: string, options?: ServiceOptions): Promise<boolean>
}
/**
* Extended service interface for soft delete operations
*/
export interface ISoftDeleteService<T = any, TCreate = any, TUpdate = any> extends IBaseService<T, TCreate, TUpdate> {
/**
* Archive entity (soft delete)
*/
archive(id: string, options?: ServiceOptions): Promise<T | void>
/**
* Restore archived entity
*/
restore(id: string, options?: ServiceOptions): Promise<T | void>
}
/**
* Extended service interface for search operations
*/
export interface ISearchableService<T = any, TCreate = any, TUpdate = any> extends IBaseService<T, TCreate, TUpdate> {
/**
* Search entities by query
*/
search(
query: string,
params?: PaginationParams,
options?: ServiceOptions
): Promise<{
items: T[]
total: number
hasNext?: boolean
nextCursor?: string
}>
}
/**
* Service interface for hierarchical data (parent-child relationships)
*/
export interface IHierarchicalService<TParent = any, TChild = any, TChildCreate = any> extends IBaseService<TParent> {
/**
* Get child entities for a parent
*/
getChildren(
parentId: string,
params?: PaginationParams,
options?: ServiceOptions
): Promise<{
items: TChild[]
total: number
hasNext?: boolean
nextCursor?: string
}>
/**
* Add child entity to parent
*/
addChild(parentId: string, childData: TChildCreate, options?: ServiceOptions): Promise<TChild>
/**
* Remove child entity from parent
*/
removeChild(parentId: string, childId: string, options?: ServiceOptions): Promise<void>
}

View File

@ -0,0 +1,452 @@
import { loggerService } from '@logger'
const logger = loggerService.withContext('TestService')
/**
* Test service for API testing scenarios
* Provides mock data and various test cases for comprehensive API testing
*/
export class TestService {
private static instance: TestService
private testItems: any[] = []
private nextId = 1
private constructor() {
this.initializeMockData()
}
/**
* Get singleton instance
*/
public static getInstance(): TestService {
if (!TestService.instance) {
TestService.instance = new TestService()
}
return TestService.instance
}
/**
* Initialize mock test data
*/
private initializeMockData() {
// Initialize test items with various types
for (let i = 1; i <= 20; i++) {
this.testItems.push({
id: `test-item-${i}`,
title: `Test Item ${i}`,
description: `This is test item ${i} for comprehensive API testing`,
type: ['data', 'config', 'user', 'system'][i % 4],
status: ['active', 'inactive', 'pending', 'archived'][i % 4],
priority: ['low', 'medium', 'high'][i % 3],
tags: [`tag${(i % 3) + 1}`, `category${(i % 2) + 1}`],
createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - i * 12 * 60 * 60 * 1000).toISOString(),
metadata: {
version: `1.${i % 10}.0`,
size: Math.floor(Math.random() * 1000) + 100,
author: `TestUser${(i % 5) + 1}`
}
})
}
this.nextId = 100
logger.info('Mock test data initialized', {
itemCount: this.testItems.length,
types: ['data', 'config', 'user', 'system'],
statuses: ['active', 'inactive', 'pending', 'archived']
})
}
/**
* Generate new test ID
*/
private generateId(prefix: string = 'test-item'): string {
return `${prefix}-${this.nextId++}`
}
/**
* Simulate network delay for realistic testing
*/
private async simulateDelay(min = 100, max = 500): Promise<void> {
const delay = Math.floor(Math.random() * (max - min + 1)) + min
await new Promise((resolve) => setTimeout(resolve, delay))
}
/**
* Get paginated list of test items
*/
async getItems(
params: {
page?: number
limit?: number
type?: string
status?: string
search?: string
} = {}
): Promise<{
items: any[]
total: number
page: number
pageCount: number
hasNext: boolean
hasPrev: boolean
}> {
await this.simulateDelay()
const { page = 1, limit = 20, type, status, search } = params
let filteredItems = [...this.testItems]
// Apply filters
if (type) {
filteredItems = filteredItems.filter((item) => item.type === type)
}
if (status) {
filteredItems = filteredItems.filter((item) => item.status === status)
}
if (search) {
const searchLower = search.toLowerCase()
filteredItems = filteredItems.filter(
(item) => item.title.toLowerCase().includes(searchLower) || item.description.toLowerCase().includes(searchLower)
)
}
// Apply pagination
const startIndex = (page - 1) * limit
const items = filteredItems.slice(startIndex, startIndex + limit)
const total = filteredItems.length
const pageCount = Math.ceil(total / limit)
logger.debug('Retrieved test items', {
page,
limit,
filters: { type, status, search },
total,
itemCount: items.length
})
return {
items,
total,
page,
pageCount,
hasNext: startIndex + limit < total,
hasPrev: page > 1
}
}
/**
* Get single test item by ID
*/
async getItemById(id: string): Promise<any | null> {
await this.simulateDelay()
const item = this.testItems.find((item) => item.id === id)
if (!item) {
logger.warn('Test item not found', { id })
return null
}
logger.debug('Retrieved test item by ID', { id, title: item.title })
return item
}
/**
* Create new test item
*/
async createItem(data: {
title: string
description?: string
type?: string
status?: string
priority?: string
tags?: string[]
metadata?: Record<string, any>
}): Promise<any> {
await this.simulateDelay()
const newItem = {
id: this.generateId(),
title: data.title,
description: data.description || '',
type: data.type || 'data',
status: data.status || 'active',
priority: data.priority || 'medium',
tags: data.tags || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {
version: '1.0.0',
size: Math.floor(Math.random() * 1000) + 100,
author: 'TestUser',
...data.metadata
}
}
this.testItems.unshift(newItem)
logger.info('Created new test item', { id: newItem.id, title: newItem.title })
return newItem
}
/**
* Update existing test item
*/
async updateItem(
id: string,
data: Partial<{
title: string
description: string
type: string
status: string
priority: string
tags: string[]
metadata: Record<string, any>
}>
): Promise<any | null> {
await this.simulateDelay()
const itemIndex = this.testItems.findIndex((item) => item.id === id)
if (itemIndex === -1) {
logger.warn('Test item not found for update', { id })
return null
}
const updatedItem = {
...this.testItems[itemIndex],
...data,
updatedAt: new Date().toISOString(),
metadata: {
...this.testItems[itemIndex].metadata,
...data.metadata
}
}
this.testItems[itemIndex] = updatedItem
logger.info('Updated test item', { id, changes: Object.keys(data) })
return updatedItem
}
/**
* Delete test item
*/
async deleteItem(id: string): Promise<boolean> {
await this.simulateDelay()
const itemIndex = this.testItems.findIndex((item) => item.id === id)
if (itemIndex === -1) {
logger.warn('Test item not found for deletion', { id })
return false
}
this.testItems.splice(itemIndex, 1)
logger.info('Deleted test item', { id })
return true
}
/**
* Get test statistics
*/
async getStats(): Promise<{
total: number
byType: Record<string, number>
byStatus: Record<string, number>
byPriority: Record<string, number>
recentActivity: Array<{
date: string
count: number
}>
}> {
await this.simulateDelay()
const byType: Record<string, number> = {}
const byStatus: Record<string, number> = {}
const byPriority: Record<string, number> = {}
this.testItems.forEach((item) => {
byType[item.type] = (byType[item.type] || 0) + 1
byStatus[item.status] = (byStatus[item.status] || 0) + 1
byPriority[item.priority] = (byPriority[item.priority] || 0) + 1
})
// Generate recent activity (mock data)
const recentActivity: Array<{ date: string; count: number }> = []
for (let i = 6; i >= 0; i--) {
const date = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
recentActivity.push({
date,
count: Math.floor(Math.random() * 10) + 1
})
}
const stats = {
total: this.testItems.length,
byType,
byStatus,
byPriority,
recentActivity
}
logger.debug('Retrieved test statistics', stats)
return stats
}
/**
* Bulk operations on test items
*/
async bulkOperation(
operation: 'create' | 'update' | 'delete',
data: any[]
): Promise<{
successful: number
failed: number
errors: string[]
}> {
await this.simulateDelay(200, 800)
let successful = 0
let failed = 0
const errors: string[] = []
for (const item of data) {
try {
switch (operation) {
case 'create':
await this.createItem(item)
successful++
break
case 'update': {
const updated = await this.updateItem(item.id, item)
if (updated) successful++
else {
failed++
errors.push(`Item not found: ${item.id}`)
}
break
}
case 'delete': {
const deleted = await this.deleteItem(item.id)
if (deleted) successful++
else {
failed++
errors.push(`Item not found: ${item.id}`)
}
break
}
}
} catch (error) {
failed++
errors.push(`Error processing item: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
logger.info('Completed bulk operation', { operation, successful, failed, errorCount: errors.length })
return { successful, failed, errors }
}
/**
* Search test items
*/
async searchItems(
query: string,
options: {
page?: number
limit?: number
filters?: Record<string, any>
} = {}
): Promise<{
items: any[]
total: number
page: number
pageCount: number
hasNext: boolean
hasPrev: boolean
}> {
await this.simulateDelay()
const { page = 1, limit = 20, filters = {} } = options
const queryLower = query.toLowerCase()
const results = this.testItems.filter((item) => {
// Text search
const matchesQuery =
item.title.toLowerCase().includes(queryLower) ||
item.description.toLowerCase().includes(queryLower) ||
item.tags.some((tag: string) => tag.toLowerCase().includes(queryLower))
// Apply additional filters
let matchesFilters = true
Object.entries(filters).forEach(([key, value]) => {
if (value && item[key] !== value) {
matchesFilters = false
}
})
return matchesQuery && matchesFilters
})
// Apply pagination
const startIndex = (page - 1) * limit
const items = results.slice(startIndex, startIndex + limit)
const total = results.length
const pageCount = Math.ceil(total / limit)
logger.debug('Search completed', { query, total, itemCount: items.length })
return {
items,
total,
page,
pageCount,
hasNext: startIndex + limit < total,
hasPrev: page > 1
}
}
/**
* Simulate error scenarios for testing
*/
async simulateError(errorType: string): Promise<never> {
await this.simulateDelay()
logger.warn('Simulating error scenario', { errorType })
switch (errorType) {
case 'timeout':
await new Promise((resolve) => setTimeout(resolve, 35000))
throw new Error('Request timeout')
case 'network':
throw new Error('Network connection failed')
case 'server':
throw new Error('Internal server error (500)')
case 'notfound':
throw new Error('Resource not found (404)')
case 'validation':
throw new Error('Validation failed: Invalid input data')
case 'unauthorized':
throw new Error('Unauthorized access (401)')
case 'ratelimit':
throw new Error('Rate limit exceeded (429)')
default:
throw new Error('Generic test error occurred')
}
}
/**
* Reset all test data to initial state
*/
async resetData(): Promise<void> {
await this.simulateDelay()
this.testItems = []
this.nextId = 1
this.initializeMockData()
logger.info('Test data reset to initial state')
}
}

View File

@ -9,6 +9,7 @@ import { loggerService } from '@logger'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { dbService } from '@data/db/DbService'
import { preferenceService } from '@data/PreferenceService'
import { dataApiService } from '@data/DataApiService'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app, dialog } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
@ -163,16 +164,19 @@ if (!app.requestSingleInstanceLock()) {
await preferenceService.initialize()
// // Create two test windows for cross-window preference sync testing
// logger.info('Creating test windows for PreferenceService cross-window sync testing')
// const testWindow1 = dataRefactorMigrateService.createTestWindow()
// const testWindow2 = dataRefactorMigrateService.createTestWindow()
// Initialize DataApiService
await dataApiService.initialize()
// // Position windows to avoid overlap
// testWindow1.once('ready-to-show', () => {
// const [x, y] = testWindow1.getPosition()
// testWindow2.setPosition(x + 50, y + 50)
// })
// Create two test windows for cross-window preference sync testing
logger.info('Creating test windows for PreferenceService cross-window sync testing')
const testWindow1 = dataRefactorMigrateService.createTestWindow()
const testWindow2 = dataRefactorMigrateService.createTestWindow()
// Position windows to avoid overlap
testWindow1.once('ready-to-show', () => {
const [x, y] = testWindow1.getPosition()
testWindow2.setPosition(x + 50, y + 50)
})
/************FOR TESTING ONLY END****************/
// Set app user model id for windows

View File

@ -48,7 +48,7 @@ const DEFAULT_LEVEL = isDev ? LEVEL.SILLY : LEVEL.INFO
* English: `docs/technical/how-to-use-logger-en.md`
* Chinese: `docs/technical/how-to-use-logger-zh.md`
*/
class LoggerService {
export class LoggerService {
private static instance: LoggerService
private logger: winston.Logger

View File

@ -451,6 +451,8 @@ const api = {
}
}
},
// PreferenceService related APIs
// DO NOT MODIFY THIS SECTION
preference: {
get: <K extends PreferenceKeyType>(key: K): Promise<PreferenceDefaultScopeType[K]> =>
ipcRenderer.invoke(IpcChannel.Preference_Get, key),
@ -467,6 +469,23 @@ const api = {
ipcRenderer.on(IpcChannel.Preference_Changed, listener)
return () => ipcRenderer.off(IpcChannel.Preference_Changed, listener)
}
},
// Data API related APIs
dataApi: {
request: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Request, req),
batch: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Batch, req),
transaction: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Transaction, req),
subscribe: (path: string, callback: (data: any, event: string) => void) => {
const channel = `${IpcChannel.DataApi_Stream}:${path}`
const listener = (_: any, data: any, event: string) => callback(data, event)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.off(channel, listener)
},
onResponse: (callback: (response: any) => void) => {
const listener = (_: any, response: any) => callback(response)
ipcRenderer.on(IpcChannel.DataApi_Response, listener)
return () => ipcRenderer.off(IpcChannel.DataApi_Response, listener)
}
}
}

View File

@ -0,0 +1,517 @@
import { loggerService } from '@logger'
import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiSchemas'
import type {
BatchRequest,
BatchResponse,
DataRequest,
DataResponse,
HttpMethod,
SubscriptionCallback,
SubscriptionEvent,
SubscriptionOptions,
TransactionRequest
} from '@shared/data/api/apiTypes'
import { toDataApiError } from '@shared/data/api/errorCodes'
const logger = loggerService.withContext('DataApiService')
/**
* Retry options interface
*/
interface RetryOptions {
maxRetries: number
retryDelay: number
backoffMultiplier: number
retryCondition: (error: Error) => boolean
}
/**
* Strongly-typed HTTP client for Data API
* Simplified version using SWR for caching and request management
* Focuses on IPC communication between renderer and main process
*/
export class DataApiService implements ApiClient {
private static instance: DataApiService
private requestId = 0
private pendingRequests = new Map<
string,
{
resolve: (value: DataResponse) => void
reject: (error: Error) => void
timeout?: NodeJS.Timeout
abortController?: AbortController
}
>()
// Subscriptions
private subscriptions = new Map<
string,
{
callback: SubscriptionCallback
options: SubscriptionOptions
}
>()
// Default retry options
private defaultRetryOptions: RetryOptions = {
maxRetries: 2,
retryDelay: 1000,
backoffMultiplier: 2,
retryCondition: (error: Error) => {
// Retry on network errors or temporary failures
const message = error.message.toLowerCase()
return (
message.includes('timeout') ||
message.includes('network') ||
message.includes('connection') ||
message.includes('unavailable') ||
message.includes('500') ||
message.includes('503')
)
}
}
private constructor() {
this.setupResponseHandler()
}
/**
* Get singleton instance
*/
public static getInstance(): DataApiService {
if (!DataApiService.instance) {
DataApiService.instance = new DataApiService()
}
return DataApiService.instance
}
/**
* Setup IPC response handler
*/
private setupResponseHandler() {
if (!window.api.dataApi?.onResponse) {
logger.error('Data API not available in preload context')
return
}
window.api.dataApi.onResponse((response: DataResponse) => {
const pending = this.pendingRequests.get(response.id)
if (pending) {
clearTimeout(pending.timeout)
this.pendingRequests.delete(response.id)
if (response.error) {
pending.reject(new Error(response.error.message))
} else {
pending.resolve(response)
}
}
})
}
/**
* Generate unique request ID
*/
private generateRequestId(): string {
return `req_${Date.now()}_${++this.requestId}`
}
/**
* Cancel request by ID
*/
cancelRequest(requestId: string): void {
const pending = this.pendingRequests.get(requestId)
if (pending) {
pending.abortController?.abort()
clearTimeout(pending.timeout)
this.pendingRequests.delete(requestId)
pending.reject(new Error('Request cancelled'))
}
}
/**
* Cancel all pending requests
*/
cancelAllRequests(): void {
const requestIds = Array.from(this.pendingRequests.keys())
requestIds.forEach((id) => this.cancelRequest(id))
}
/**
* Configure retry options
* @param options Partial retry options to override defaults
*/
configureRetry(options: Partial<RetryOptions>): void {
this.defaultRetryOptions = {
...this.defaultRetryOptions,
...options
}
logger.debug('Retry options updated', this.defaultRetryOptions)
}
/**
* Get current retry configuration
*/
getRetryConfig(): RetryOptions {
return { ...this.defaultRetryOptions }
}
/**
* Send request via IPC with AbortController support and retry logic
*/
private async sendRequest<T>(request: DataRequest, retryCount = 0): Promise<T> {
return new Promise((resolve, reject) => {
if (!window.api.dataApi.request) {
reject(new Error('Data API not available'))
return
}
// Create abort controller for cancellation
const abortController = new AbortController()
// Set up timeout
const timeout = setTimeout(() => {
this.pendingRequests.delete(request.id)
const timeoutError = new Error(`Request timeout: ${request.path}`)
// Check if should retry
if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(timeoutError)) {
logger.debug(
`Request timeout, retrying attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`
)
// Calculate delay with exponential backoff
const delay =
this.defaultRetryOptions.retryDelay * Math.pow(this.defaultRetryOptions.backoffMultiplier, retryCount)
setTimeout(() => {
// Create new request with new ID for retry
const retryRequest = { ...request, id: this.generateRequestId() }
this.sendRequest<T>(retryRequest, retryCount + 1)
.then(resolve)
.catch(reject)
}, delay)
} else {
reject(timeoutError)
}
}, 30000) // 30 second timeout
// Store pending request with abort controller
this.pendingRequests.set(request.id, {
resolve: (response: DataResponse) => resolve(response.data),
reject: (error: Error) => {
// Check if should retry on error
if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(error)) {
logger.debug(
`Request failed, retrying attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`,
error
)
const delay =
this.defaultRetryOptions.retryDelay * Math.pow(this.defaultRetryOptions.backoffMultiplier, retryCount)
setTimeout(() => {
const retryRequest = { ...request, id: this.generateRequestId() }
this.sendRequest<T>(retryRequest, retryCount + 1)
.then(resolve)
.catch(reject)
}, delay)
} else {
reject(error)
}
},
timeout,
abortController
})
// Handle abort signal
abortController.signal.addEventListener('abort', () => {
clearTimeout(timeout)
this.pendingRequests.delete(request.id)
reject(new Error('Request cancelled'))
})
// Send request
window.api.dataApi.request(request).catch((error) => {
clearTimeout(timeout)
this.pendingRequests.delete(request.id)
// Check if should retry
if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(error)) {
logger.debug(
`Request failed, retrying attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`,
error
)
const delay =
this.defaultRetryOptions.retryDelay * Math.pow(this.defaultRetryOptions.backoffMultiplier, retryCount)
setTimeout(() => {
const retryRequest = { ...request, id: this.generateRequestId() }
this.sendRequest<T>(retryRequest, retryCount + 1)
.then(resolve)
.catch(reject)
}, delay)
} else {
reject(error)
}
})
})
}
/**
* Make HTTP request with enhanced features
*/
private async makeRequest<T>(
method: HttpMethod,
path: string,
options: {
params?: any
body?: any
headers?: Record<string, string>
metadata?: Record<string, any>
signal?: AbortSignal
} = {}
): Promise<T> {
const { params, body, headers, metadata, signal } = options
// Check if already aborted
if (signal?.aborted) {
throw new Error('Request cancelled')
}
// Create request
const request: DataRequest = {
id: this.generateRequestId(),
method,
path,
params,
body,
headers,
metadata: {
timestamp: Date.now(),
...metadata
}
}
logger.debug(`Making ${method} request to ${path}`, { request })
// Set up external abort signal handling
const requestPromise = this.sendRequest<T>(request)
if (signal) {
// If external signal is aborted during request, cancel our internal request
const abortListener = () => {
this.cancelRequest(request.id)
}
signal.addEventListener('abort', abortListener)
// Clean up listener when request completes
requestPromise.finally(() => {
signal.removeEventListener('abort', abortListener)
})
}
return requestPromise.catch((error) => {
logger.error(`Request failed: ${method} ${path}`, error)
throw toDataApiError(error, `${method} ${path}`)
})
}
/**
* Type-safe GET request
*/
async get<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: any
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<any> {
return this.makeRequest<any>('GET', path as string, {
params: options?.query,
headers: options?.headers,
signal: options?.signal
})
}
/**
* Type-safe POST request
*/
async post<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: any
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<any> {
return this.makeRequest<any>('POST', path as string, {
params: options.query,
body: options.body,
headers: options.headers,
signal: options.signal
})
}
/**
* Type-safe PUT request
*/
async put<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body: any
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<any> {
return this.makeRequest<any>('PUT', path as string, {
params: options.query,
body: options.body,
headers: options.headers,
signal: options.signal
})
}
/**
* Type-safe DELETE request
*/
async delete<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<any> {
return this.makeRequest<any>('DELETE', path as string, {
params: options?.query,
headers: options?.headers,
signal: options?.signal
})
}
/**
* Type-safe PATCH request
*/
async patch<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: any
query?: Record<string, any>
headers?: Record<string, string>
signal?: AbortSignal
}
): Promise<any> {
return this.makeRequest<any>('PATCH', path as string, {
params: options.query,
body: options.body,
headers: options.headers,
signal: options.signal
})
}
/**
* Execute multiple requests in batch
*/
async batch(requests: DataRequest[], options: { parallel?: boolean } = {}): Promise<BatchResponse> {
const batchRequest: BatchRequest = {
requests,
parallel: options.parallel ?? true
}
return this.makeRequest<BatchResponse>('POST', '/batch', { body: batchRequest })
}
/**
* Execute requests in a transaction
*/
async transaction(operations: DataRequest[], options?: TransactionRequest['options']): Promise<DataResponse[]> {
const transactionRequest: TransactionRequest = {
operations,
options
}
return this.makeRequest<DataResponse[]>('POST', '/transaction', { body: transactionRequest })
}
/**
* Subscribe to real-time updates
*/
subscribe<T>(options: SubscriptionOptions, callback: SubscriptionCallback<T>): () => void {
if (!window.api.dataApi?.subscribe) {
throw new Error('Real-time subscriptions not supported')
}
const subscriptionId = `sub_${Date.now()}_${Math.random()}`
this.subscriptions.set(subscriptionId, {
callback: callback as SubscriptionCallback,
options
})
const unsubscribe = window.api.dataApi.subscribe(options.path, (data, event) => {
// Convert string event to SubscriptionEvent enum
const subscriptionEvent = event as SubscriptionEvent
callback(data, subscriptionEvent)
})
logger.debug(`Subscribed to ${options.path}`, { subscriptionId })
// Return unsubscribe function
return () => {
this.subscriptions.delete(subscriptionId)
unsubscribe()
logger.debug(`Unsubscribed from ${options.path}`, { subscriptionId })
}
}
/**
* Create an AbortController for request cancellation
* @returns Object with AbortController and convenience methods
*
* @example
* ```typescript
* const { signal, cancel } = requestService.createAbortController()
*
* // Use signal in requests
* const dataPromise = requestService.get('/topics', { signal })
*
* // Cancel if needed
* setTimeout(() => cancel(), 5000) // Cancel after 5 seconds
* ```
*/
createAbortController() {
const controller = new AbortController()
return {
signal: controller.signal,
cancel: () => controller.abort(),
aborted: () => controller.signal.aborted
}
}
/**
* Get comprehensive request statistics
*/
getRequestStats() {
return {
pendingRequests: this.pendingRequests.size,
activeSubscriptions: this.subscriptions.size
}
}
}
// Export singleton instance
export const dataApiService = DataApiService.getInstance()
// Clean up on window unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
dataApiService.cancelAllRequests()
})
}

View File

@ -0,0 +1,609 @@
import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths'
import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas'
import type { PaginatedResponse } from '@shared/data/api/apiTypes'
import { useState } from 'react'
import useSWR, { useSWRConfig } from 'swr'
import useSWRMutation from 'swr/mutation'
import { dataApiService } from '../DataApiService'
// buildPath function removed - users now pass concrete paths directly
/**
* Unified fetcher utility for API requests
* Provides type-safe method dispatching to reduce code duplication
*/
function createApiFetcher<TPath extends ConcreteApiPaths, TMethod extends 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod
) {
return async (
path: TPath,
options?: {
body?: BodyForPath<TPath, TMethod>
query?: Record<string, any>
}
): Promise<ResponseForPath<TPath, TMethod>> => {
switch (method) {
case 'GET':
return dataApiService.get(path, { query: options?.query })
case 'POST':
return dataApiService.post(path, { body: options?.body, query: options?.query })
case 'PUT':
return dataApiService.put(path, { body: options?.body || {}, query: options?.query })
case 'DELETE':
return dataApiService.delete(path, { query: options?.query })
case 'PATCH':
return dataApiService.patch(path, { body: options?.body, query: options?.query })
default:
throw new Error(`Unsupported method: ${method}`)
}
}
}
/**
* Build SWR cache key for request identification and caching
* Creates a unique key based on path and query parameters
*
* @param path - The concrete API path
* @param query - Query parameters
* @returns SWR key tuple or null if disabled
*
* @example
* ```typescript
* buildSWRKey('/topics', { page: 1 }) // Returns ['/topics', { page: 1 }]
* buildSWRKey('/topics/123') // Returns ['/topics/123']
* ```
*/
function buildSWRKey<TPath extends ConcreteApiPaths>(
path: TPath,
query?: Record<string, any>
): [TPath, Record<string, any>?] | null {
if (query && Object.keys(query).length > 0) {
return [path, query]
}
return [path]
}
/**
* GET request fetcher for SWR
* @param args - Tuple containing [path, query] parameters
* @returns Promise resolving to the fetched data
*/
function getFetcher<TPath extends ConcreteApiPaths>([path, query]: [TPath, Record<string, any>?]): Promise<
ResponseForPath<TPath, 'GET'>
> {
const apiFetcher = createApiFetcher('GET')
return apiFetcher(path, { query })
}
/**
* React hook for data fetching with SWR
* Provides type-safe API calls with caching, revalidation, and error handling
*
* @template TPath - The concrete API path type
* @param path - The concrete API endpoint path (e.g., '/test/items/123')
* @param options - Configuration options
* @param options.query - Query parameters for filtering, pagination, etc.
* @param options.enabled - Whether the request should be executed (default: true)
* @param options.swrOptions - Additional SWR configuration options
* @returns Object containing data, loading state, error state, and control functions
*
* @example
* ```typescript
* // Basic usage with type-safe concrete path
* const { data, loading, error } = useQuery('/test/items')
* // data is automatically typed as PaginatedResponse<any>
*
* // With dynamic ID - full type safety
* const { data, loading, error } = useQuery(`/test/items/${itemId}`, {
* enabled: !!itemId
* })
* // data is automatically typed as the specific item type
*
* // With type-safe query parameters
* const { data, loading, error } = useQuery('/test/items', {
* query: {
* page: 1,
* limit: 20,
* search: 'hello', // TypeScript validates these fields
* status: 'active'
* }
* })
*
* // With custom SWR options
* const { data, loading, error, refetch } = useQuery('/test/items', {
* swrOptions: {
* refreshInterval: 5000, // Auto-refresh every 5 seconds
* revalidateOnFocus: true
* }
* })
* ```
*/
export function useQuery<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
/** Query parameters for filtering, pagination, etc. */
query?: QueryParamsForPath<TPath>
/** Disable the request */
enabled?: boolean
/** Custom SWR options */
swrOptions?: Parameters<typeof useSWR>[2]
}
): {
/** The fetched data */
data?: ResponseForPath<TPath, 'GET'>
/** Loading state */
loading: boolean
/** Error if request failed */
error?: Error
/** Function to manually refetch data */
refetch: () => void
} {
// Internal type conversion for SWR compatibility
const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record<string, any>) : null
const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: true,
dedupingInterval: 5000,
errorRetryCount: 3,
errorRetryInterval: 1000,
...options?.swrOptions
})
const refetch = () => {
mutate()
}
return {
data,
loading: isLoading || isValidating,
error: error as Error | undefined,
refetch
}
}
/**
* React hook for mutation operations (POST, PUT, DELETE, PATCH)
* Provides optimized handling of side-effect operations with automatic cache invalidation
*
* @template TPath - The concrete API path type
* @param method - HTTP method for the mutation
* @param path - The concrete API endpoint path (e.g., '/test/items/123')
* @param options - Configuration options
* @param options.onSuccess - Callback executed on successful mutation
* @param options.onError - Callback executed on mutation error
* @param options.revalidate - Cache invalidation strategy (true = invalidate all, string[] = specific paths)
* @returns Object containing mutate function, loading state, and error state
*
* @example
* ```typescript
* // Create a new item with full type safety
* const createItem = useMutation('POST', '/test/items', {
* onSuccess: (data) => {
* console.log('Item created:', data) // data is properly typed
* },
* onError: (error) => {
* console.error('Failed to create item:', error)
* },
* revalidate: ['/test/items'] // Refresh items list after creation
* })
*
* // Update existing item with optimistic updates
* const updateItem = useMutation('PUT', `/test/items/${itemId}`, {
* optimistic: true,
* optimisticData: { id: itemId, title: 'Updated Item' }, // Type-safe
* revalidate: true // Refresh all cached data
* })
*
* // Delete item
* const deleteItem = useMutation('DELETE', `/test/items/${itemId}`)
*
* // Usage in component with type-safe parameters
* const handleCreate = async () => {
* try {
* const result = await createItem.mutate({
* body: {
* title: 'New Item',
* description: 'Item description',
* // TypeScript validates all fields against ApiSchemas
* tags: ['tag1', 'tag2']
* }
* })
* console.log('Created:', result)
* } catch (error) {
* console.error('Creation failed:', error)
* }
* }
*
* const handleUpdate = async () => {
* try {
* const result = await updateItem.mutate({
* body: { title: 'Updated Item' } // Type-safe, only valid fields allowed
* })
* } catch (error) {
* console.error('Update failed:', error)
* }
* }
*
* const handleDelete = async () => {
* try {
* await deleteItem.mutate()
* } catch (error) {
* console.error('Delete failed:', error)
* }
* }
* ```
*/
export function useMutation<TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH'>(
method: TMethod,
path: TPath,
options?: {
/** Called when mutation succeeds */
onSuccess?: (data: ResponseForPath<TPath, TMethod>) => void
/** Called when mutation fails */
onError?: (error: Error) => void
/** Automatically revalidate these SWR keys on success */
revalidate?: boolean | string[]
/** Enable optimistic updates */
optimistic?: boolean
/** Optimistic data to use for updates */
optimisticData?: ResponseForPath<TPath, TMethod>
}
): {
/** Function to execute the mutation */
mutate: (data?: {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}) => Promise<ResponseForPath<TPath, TMethod>>
/** True while the mutation is in progress */
loading: boolean
/** Error object if the mutation failed */
error: Error | undefined
} {
const { mutate: globalMutate } = useSWRConfig()
const apiFetcher = createApiFetcher(method)
const fetcher = async (
_key: string,
{
arg
}: {
arg?: {
body?: BodyForPath<TPath, TMethod>
query?: Record<string, any>
}
}
): Promise<ResponseForPath<TPath, TMethod>> => {
return apiFetcher(path, { body: arg?.body, query: arg?.query })
}
const { trigger, isMutating, error } = useSWRMutation(path as string, fetcher, {
populateCache: false,
revalidate: false,
onSuccess: async (data) => {
options?.onSuccess?.(data)
if (options?.revalidate === true) {
await globalMutate(() => true)
} else if (Array.isArray(options?.revalidate)) {
for (const path of options.revalidate) {
await globalMutate(path)
}
}
},
onError: options?.onError
})
const optimisticMutate = async (data?: {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}): Promise<ResponseForPath<TPath, TMethod>> => {
if (options?.optimistic && options?.optimisticData) {
// Apply optimistic update
await globalMutate(path, options.optimisticData, false)
}
try {
// Convert user's strongly-typed query to Record<string, any> for internal use
const convertedData = data
? {
body: data.body,
query: data.query as Record<string, any>
}
: undefined
const result = await trigger(convertedData)
// Revalidate with real data after successful mutation
if (options?.optimistic) {
await globalMutate(path)
}
return result
} catch (err) {
// Revert optimistic update on error
if (options?.optimistic && options?.optimisticData) {
await globalMutate(path)
}
throw err
}
}
// Wrapper for non-optimistic mutations to handle type conversion
const normalMutate = async (data?: {
body?: BodyForPath<TPath, TMethod>
query?: QueryParamsForPath<TPath>
}): Promise<ResponseForPath<TPath, TMethod>> => {
// Convert user's strongly-typed query to Record<string, any> for internal use
const convertedData = data
? {
body: data.body,
query: data.query as Record<string, any>
}
: undefined
return trigger(convertedData)
}
return {
mutate: options?.optimistic ? optimisticMutate : normalMutate,
loading: isMutating,
error
}
}
/**
* Hook for invalidating SWR cache entries
* Must be used inside a React component or hook
*
* @returns Function to invalidate cache entries
*
* @example
* ```typescript
* function MyComponent() {
* const invalidate = useInvalidateCache()
*
* const handleInvalidate = async () => {
* // Invalidate specific cache key
* await invalidate('/test/items')
*
* // Invalidate multiple keys
* await invalidate(['/test/items', '/test/stats'])
*
* // Invalidate all cache entries
* await invalidate(true)
* }
*
* return <button onClick={handleInvalidate}>Refresh Data</button>
* }
* ```
*/
export function useInvalidateCache() {
const { mutate } = useSWRConfig()
const invalidate = (keys?: string | string[] | boolean): Promise<any> => {
if (keys === true || keys === undefined) {
return mutate(() => true)
} else if (typeof keys === 'string') {
return mutate(keys)
} else if (Array.isArray(keys)) {
return Promise.all(keys.map((key) => mutate(key)))
}
return Promise.resolve()
}
return invalidate
}
/**
* Prefetch data for a given path without caching
* Useful for warming up data before user interactions or pre-loading critical resources
*
* @template TPath - The concrete API path type
* @param path - The concrete API endpoint path (e.g., '/test/items/123')
* @param options - Configuration options for the prefetch request
* @param options.query - Query parameters for filtering, pagination, etc.
* @returns Promise resolving to the prefetched data
*
* @example
* ```typescript
* // Prefetch items list on component mount
* useEffect(() => {
* prefetch('/test/items', {
* query: { page: 1, limit: 20 }
* })
* }, [])
*
* // Prefetch specific item on hover
* const handleItemHover = (itemId: string) => {
* prefetch(`/test/items/${itemId}`)
* }
*
* // Prefetch with search parameters
* const preloadSearchResults = async (searchTerm: string) => {
* const results = await prefetch('/test/search', {
* query: {
* query: searchTerm,
* limit: 10
* }
* })
* console.log('Preloaded:', results)
* }
* ```
*/
export function prefetch<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: QueryParamsForPath<TPath>
}
): Promise<ResponseForPath<TPath, 'GET'>> {
const apiFetcher = createApiFetcher('GET')
return apiFetcher(path, { query: options?.query as Record<string, any> })
}
/**
* React hook for paginated data fetching with type safety
* Automatically manages pagination state and provides navigation controls
* Works with API endpoints that return PaginatedResponse<T>
*
* @template TPath - The concrete API path type
* @param path - API endpoint path that returns paginated data (e.g., '/test/items')
* @param options - Configuration options for pagination and filtering
* @param options.query - Additional query parameters (excluding page/limit)
* @param options.limit - Items per page (default: 10)
* @param options.swrOptions - Additional SWR configuration options
* @returns Object containing paginated data, navigation controls, and state
*
* @example
* ```typescript
* // Basic paginated list
* const {
* items,
* loading,
* total,
* page,
* hasMore,
* nextPage,
* prevPage
* } = usePaginatedQuery('/test/items', {
* limit: 20
* })
*
* // With search and filtering
* const paginatedItems = usePaginatedQuery('/test/items', {
* query: {
* search: searchTerm,
* status: 'active',
* type: 'premium'
* },
* limit: 25,
* swrOptions: {
* refreshInterval: 30000 // Refresh every 30 seconds
* }
* })
*
* // Navigation controls usage
* <div>
* <button onClick={prevPage} disabled={!hasPrev}>
* Previous
* </button>
* <span>Page {page} of {Math.ceil(total / 20)}</span>
* <button onClick={nextPage} disabled={!hasMore}>
* Next
* </button>
* </div>
*
* // Reset pagination when search changes
* useEffect(() => {
* reset() // Go back to first page
* }, [searchTerm])
* ```
*/
export function usePaginatedQuery<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
/** Additional query parameters (excluding pagination) */
query?: Omit<QueryParamsForPath<TPath>, 'page' | 'limit'>
/** Items per page (default: 10) */
limit?: number
/** Custom SWR options */
swrOptions?: Parameters<typeof useSWR>[2]
}
): ResponseForPath<TPath, 'GET'> extends PaginatedResponse<infer T>
? {
/** Array of items for current page */
items: T[]
/** Total number of items across all pages */
total: number
/** Current page number (1-based) */
page: number
/** Loading state */
loading: boolean
/** Error if request failed */
error?: Error
/** Whether there are more pages available */
hasMore: boolean
/** Whether there are previous pages available */
hasPrev: boolean
/** Navigate to previous page */
prevPage: () => void
/** Navigate to next page */
nextPage: () => void
/** Refresh current page data */
refresh: () => void
/** Reset to first page */
reset: () => void
}
: never {
const [currentPage, setCurrentPage] = useState(1)
const limit = options?.limit || 10
// Convert user's strongly-typed query with pagination for internal use
const queryWithPagination = {
...options?.query,
page: currentPage,
limit
} as Record<string, any>
const { data, loading, error, refetch } = useQuery(path, {
query: queryWithPagination as QueryParamsForPath<TPath>,
swrOptions: options?.swrOptions
})
// Extract paginated response data with type safety
const paginatedData = data as PaginatedResponse<any>
const items = paginatedData?.items || []
const total = paginatedData?.total || 0
const totalPages = Math.ceil(total / limit)
const hasMore = currentPage < totalPages
const hasPrev = currentPage > 1
const nextPage = () => {
if (hasMore) {
setCurrentPage((prev) => prev + 1)
}
}
const prevPage = () => {
if (hasPrev) {
setCurrentPage((prev) => prev - 1)
}
}
const reset = () => {
setCurrentPage(1)
}
return {
items,
total,
page: currentPage,
loading,
error,
hasMore,
hasPrev,
prevPage,
nextPage,
refresh: refetch,
reset
} as ResponseForPath<TPath, 'GET'> extends PaginatedResponse<infer T>
? {
items: T[]
total: number
page: number
loading: boolean
error?: Error
hasMore: boolean
hasPrev: boolean
prevPage: () => void
nextPage: () => void
refresh: () => void
reset: () => void
}
: never
}

View File

@ -1,3 +1,7 @@
/**
* Data Refactor, notes by fullex
* //TODO @deprecated this file will be removed
*/
import i18n from '@renderer/i18n'
import store, { useAppSelector } from '@renderer/store'

View File

@ -1,5 +1,7 @@
//TODO data refactor
// this file will be removed
/**
* Data Refactor, notes by fullex
* //TODO @deprecated this file will be removed
*/
import { usePreference } from '@data/hooks/usePreference'
import { useAppSelector } from '@renderer/store'

View File

@ -1,3 +1,7 @@
/**
* Data Refactor, notes by fullex
* //TODO @deprecated this file will be removed
*/
import { loggerService } from '@logger'
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector, useStore } from 'react-redux'

View File

@ -3,10 +3,14 @@ import { usePreference } from '@renderer/data/hooks/usePreference'
import { loggerService } from '@renderer/services/LoggerService'
import { ThemeMode } from '@shared/data/preferenceTypes'
import { Button, Card, Col, Divider, Layout, Row, Space, Typography } from 'antd'
import { Database, FlaskConical, Settings, TestTube } from 'lucide-react'
import { Activity, AlertTriangle, Database, FlaskConical, Settings, TestTube, TrendingUp, Zap } from 'lucide-react'
import React from 'react'
import styled from 'styled-components'
import DataApiAdvancedTests from './components/DataApiAdvancedTests'
import DataApiBasicTests from './components/DataApiBasicTests'
import DataApiHookTests from './components/DataApiHookTests'
import DataApiStressTests from './components/DataApiStressTests'
import PreferenceBasicTests from './components/PreferenceBasicTests'
import PreferenceHookTests from './components/PreferenceHookTests'
import PreferenceMultipleTests from './components/PreferenceMultipleTests'
@ -92,19 +96,22 @@ const TestApp: React.FC = () => {
<Space align="center">
<TestTube size={24} color="var(--color-primary)" />
<Title level={3} style={{ margin: 0, color: textColor }}>
PreferenceService ( #{windowNumber})
( #{windowNumber})
</Title>
</Space>
<Text style={{ color: isDarkTheme ? '#d9d9d9' : 'rgba(0, 0, 0, 0.45)' }}>
PreferenceService usePreference hooks
PreferenceServiceDataApiService React hooks
</Text>
<Text style={{ color: isDarkTheme ? '#d9d9d9' : 'rgba(0, 0, 0, 0.45)' }}>
使
PreferenceService 使DataApiService 使
</Text>
<Text style={{ color: 'var(--color-primary)', fontWeight: 'bold' }}>
📋
</Text>
<Text style={{ color: 'var(--color-secondary)', fontWeight: 'bold' }}>
🚀 API测试CRUDReact hooks和压力测试
</Text>
</Space>
</Card>
</Col>
@ -170,6 +177,75 @@ const TestApp: React.FC = () => {
</Col>
</Row>
<Divider orientation="left" style={{ color: textColor }}>
<Space>
<Zap size={20} color="var(--color-primary)" />
<Text style={{ color: textColor, fontSize: 16, fontWeight: 600 }}>DataApiService </Text>
</Space>
</Divider>
<Row gutter={[24, 24]}>
{/* DataApi Basic Tests */}
<Col span={24}>
<Card
title={
<Space>
<Database size={18} color={isDarkTheme ? '#fff' : '#000'} />
<span style={{ color: textColor }}>DataApi (CRUD操作)</span>
</Space>
}
size="small"
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
<DataApiBasicTests />
</Card>
</Col>
{/* DataApi Advanced Tests */}
<Col span={24}>
<Card
title={
<Space>
<Activity size={18} color={isDarkTheme ? '#fff' : '#000'} />
<span style={{ color: textColor }}>DataApi ()</span>
</Space>
}
size="small"
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
<DataApiAdvancedTests />
</Card>
</Col>
{/* DataApi Hook Tests */}
<Col span={24}>
<Card
title={
<Space>
<TrendingUp size={18} color={isDarkTheme ? '#fff' : '#000'} />
<span style={{ color: textColor }}>DataApi React Hooks </span>
</Space>
}
size="small"
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
<DataApiHookTests />
</Card>
</Col>
{/* DataApi Stress Tests */}
<Col span={24}>
<Card
title={
<Space>
<AlertTriangle size={18} color={isDarkTheme ? '#fff' : '#000'} />
<span style={{ color: textColor }}>DataApi ()</span>
</Space>
}
size="small"
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
<DataApiStressTests />
</Card>
</Col>
</Row>
<Divider />
<Row justify="center">

View File

@ -0,0 +1,713 @@
import { loggerService } from '@renderer/services/LoggerService'
import { Alert, Button, Card, Col, message, Row, Space, Statistic, Table, Tag, Typography } from 'antd'
import {
Activity,
AlertTriangle,
CheckCircle,
Clock,
RotateCcw,
Shield,
StopCircle,
Timer,
XCircle,
Zap
} from 'lucide-react'
import React, { useRef, useState } from 'react'
import styled from 'styled-components'
import { dataApiService } from '../../../data/DataApiService'
const { Text } = Typography
const logger = loggerService.withContext('DataApiAdvancedTests')
interface AdvancedTestResult {
id: string
name: string
category: 'cancellation' | 'retry' | 'batch' | 'error' | 'performance'
status: 'pending' | 'running' | 'success' | 'error' | 'cancelled'
startTime?: number
duration?: number
response?: any
error?: string
metadata?: Record<string, any>
}
interface RetryTestConfig {
maxRetries: number
retryDelay: number
backoffMultiplier: number
}
const DataApiAdvancedTests: React.FC = () => {
const [testResults, setTestResults] = useState<AdvancedTestResult[]>([])
const [isRunning, setIsRunning] = useState(false)
const [, setCancelledRequests] = useState<string[]>([])
const [, setRetryStats] = useState<any>(null)
const [performanceStats, setPerformanceStats] = useState<any>(null)
// Keep track of active abort controllers
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
const updateTestResult = (id: string, updates: Partial<AdvancedTestResult>) => {
setTestResults((prev) => prev.map((result) => (result.id === id ? { ...result, ...updates } : result)))
}
const addTestResult = (result: AdvancedTestResult) => {
setTestResults((prev) => [...prev, result])
}
const runAdvancedTest = async (
testId: string,
testName: string,
category: AdvancedTestResult['category'],
testFn: (signal?: AbortSignal) => Promise<any>
) => {
const startTime = Date.now()
const testResult: AdvancedTestResult = {
id: testId,
name: testName,
category,
status: 'running',
startTime
}
addTestResult(testResult)
// Create abort controller for cancellation testing
const abortController = new AbortController()
abortControllersRef.current.set(testId, abortController)
try {
logger.info(`Starting advanced test: ${testName}`, { category })
const response = await testFn(abortController.signal)
const duration = Date.now() - startTime
logger.info(`Advanced test completed: ${testName}`, { duration, response })
updateTestResult(testId, {
status: 'success',
duration,
response,
error: undefined
})
message.success(`${testName} completed successfully`)
return response
} catch (error) {
const duration = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
const isAborted = error instanceof Error && error.message.includes('cancelled')
const status = isAborted ? 'cancelled' : 'error'
logger.error(`Advanced test ${status}: ${testName}`, error as Error)
updateTestResult(testId, {
status,
duration,
error: errorMessage,
response: undefined
})
if (isAborted) {
message.warning(`${testName} was cancelled`)
setCancelledRequests((prev) => [...prev, testId])
} else {
message.error(`${testName} failed: ${errorMessage}`)
}
throw error
} finally {
abortControllersRef.current.delete(testId)
}
}
const cancelTest = (testId: string) => {
const controller = abortControllersRef.current.get(testId)
if (controller) {
controller.abort()
message.info(`Test ${testId} cancellation requested`)
}
}
const cancelAllTests = () => {
const controllers = Array.from(abortControllersRef.current.entries())
controllers.forEach(([, controller]) => {
controller.abort()
})
message.info(`${controllers.length} tests cancelled`)
}
// Request Cancellation Tests
const testRequestCancellation = async () => {
if (isRunning) return
setIsRunning(true)
try {
// Test 1: Cancel a slow request
const slowTestPromise = runAdvancedTest('cancel-slow', 'Cancel Slow Request (5s)', 'cancellation', (signal) =>
dataApiService.post('/test/slow', { body: { delay: 5000 }, signal })
)
// Wait a bit then cancel
setTimeout(() => cancelTest('cancel-slow'), 2000)
try {
await slowTestPromise
} catch {
// Expected to be cancelled
}
// Test 2: Cancel a request that should succeed
const quickTestPromise = runAdvancedTest('cancel-quick', 'Cancel Quick Request', 'cancellation', (signal) =>
dataApiService.get('/test/items', { signal })
)
// Cancel immediately
setTimeout(() => cancelTest('cancel-quick'), 100)
try {
await quickTestPromise
} catch {
// Expected to be cancelled
}
// Test 3: Test using DataApiService's built-in cancellation
await runAdvancedTest('service-cancel', 'DataApiService Built-in Cancellation', 'cancellation', async () => {
const { signal, cancel } = dataApiService.createAbortController()
// Start a slow request
const requestPromise = dataApiService.post('/test/slow', { body: { delay: 3000 }, signal })
// Cancel after 1 second
setTimeout(() => cancel(), 1000)
try {
return await requestPromise
} catch (error) {
if (error instanceof Error && error.message.includes('cancelled')) {
return { cancelled: true, message: 'Successfully cancelled using DataApiService' }
}
throw error
}
})
} finally {
setIsRunning(false)
}
}
// Retry Mechanism Tests
const testRetryMechanism = async () => {
if (isRunning) return
setIsRunning(true)
try {
// Configure retry settings for testing
const originalConfig = dataApiService.getRetryConfig()
const testConfig: RetryTestConfig = {
maxRetries: 3,
retryDelay: 500,
backoffMultiplier: 2
}
dataApiService.configureRetry(testConfig)
setRetryStats({ config: testConfig, attempts: [] })
// Test 1: Network error retry
await runAdvancedTest('retry-network', 'Network Error Retry Test', 'retry', async () => {
try {
return await dataApiService.post('/test/error', { body: { errorType: 'network' } })
} catch (error) {
return {
retriedAndFailed: true,
error: error instanceof Error ? error.message : 'Unknown error',
message: 'Retry mechanism tested - expected to fail after retries'
}
}
})
// Test 2: Timeout retry
await runAdvancedTest('retry-timeout', 'Timeout Error Retry Test', 'retry', async () => {
try {
return await dataApiService.post('/test/error', { body: { errorType: 'timeout' } })
} catch (error) {
return {
retriedAndFailed: true,
error: error instanceof Error ? error.message : 'Unknown error',
message: 'Timeout retry tested - expected to fail after retries'
}
}
})
// Test 3: Server error retry
await runAdvancedTest('retry-server', 'Server Error Retry Test', 'retry', async () => {
try {
return await dataApiService.post('/test/error', { body: { errorType: 'server' } })
} catch (error) {
return {
retriedAndFailed: true,
error: error instanceof Error ? error.message : 'Unknown error',
message: 'Server error retry tested - expected to fail after retries'
}
}
})
// Restore original retry configuration
dataApiService.configureRetry(originalConfig)
} finally {
setIsRunning(false)
}
}
// Batch Operations Test
const testBatchOperations = async () => {
if (isRunning) return
setIsRunning(true)
try {
// Test 1: Batch GET requests
await runAdvancedTest('batch-get', 'Batch GET Requests', 'batch', async () => {
const requests = [
{ method: 'GET', path: '/test/items', params: { page: 1, limit: 3 } },
{ method: 'GET', path: '/test/stats' },
{ method: 'GET', path: '/test/items', params: { page: 2, limit: 3 } }
]
return await dataApiService.batch(
requests.map((req, index) => ({
id: `batch-get-${index}`,
method: req.method as any,
path: req.path,
params: req.params
})),
{ parallel: true }
)
})
// Test 2: Mixed batch operations
await runAdvancedTest('batch-mixed', 'Mixed Batch Operations', 'batch', async () => {
const requests = [
{
id: 'batch-create-item',
method: 'POST',
path: '/test/items',
body: {
title: `Batch Created Item ${Date.now()}`,
description: 'Created via batch operation',
type: 'batch-test'
}
},
{
id: 'batch-get-items',
method: 'GET',
path: '/test/items',
params: { page: 1, limit: 5 }
}
]
return await dataApiService.batch(requests as any, { parallel: false })
})
} finally {
setIsRunning(false)
}
}
// Error Handling Tests
const testErrorHandling = async () => {
if (isRunning) return
setIsRunning(true)
const errorTypes = ['notfound', 'validation', 'unauthorized', 'server', 'ratelimit']
try {
for (const errorType of errorTypes) {
await runAdvancedTest(`error-${errorType}`, `Error Type: ${errorType.toUpperCase()}`, 'error', async () => {
try {
return await dataApiService.post('/test/error', { body: { errorType: errorType as any } })
} catch (error) {
return {
errorTested: true,
errorType,
errorMessage: error instanceof Error ? error.message : 'Unknown error',
message: `Successfully caught and handled ${errorType} error`
}
}
})
}
} finally {
setIsRunning(false)
}
}
// Performance Tests
const testPerformance = async () => {
if (isRunning) return
setIsRunning(true)
try {
const concurrentRequests = 10
const stats = {
concurrentRequests,
totalTime: 0,
averageTime: 0,
successCount: 0,
errorCount: 0,
requests: [] as any[]
}
// Test concurrent requests
await runAdvancedTest('perf-concurrent', `${concurrentRequests} Concurrent Requests`, 'performance', async () => {
const startTime = Date.now()
const promises = Array.from({ length: concurrentRequests }, (_, i) =>
dataApiService.get('/test/items').then(
(result) => ({ success: true, result, index: i }),
(error) => ({ success: false, error: error instanceof Error ? error.message : 'Unknown error', index: i })
)
)
const results = await Promise.all(promises)
const totalTime = Date.now() - startTime
stats.totalTime = totalTime
stats.averageTime = totalTime / concurrentRequests
stats.successCount = results.filter((r) => r.success).length
stats.errorCount = results.filter((r) => !r.success).length
stats.requests = results
return stats
})
setPerformanceStats(stats)
// Test memory usage
await runAdvancedTest('perf-memory', 'Memory Usage Test', 'performance', async () => {
const initialMemory = (performance as any).memory?.usedJSHeapSize || 0
// Create many requests to test memory handling
const largeRequests = Array.from({ length: 50 }, () => dataApiService.get('/test/items'))
await Promise.allSettled(largeRequests)
const finalMemory = (performance as any).memory?.usedJSHeapSize || 0
const memoryIncrease = finalMemory - initialMemory
return {
initialMemory,
finalMemory,
memoryIncrease,
memoryIncreaseKB: Math.round(memoryIncrease / 1024),
message: `Memory increase: ${Math.round(memoryIncrease / 1024)}KB after 50 requests`
}
})
} finally {
setIsRunning(false)
}
}
const resetTests = () => {
// Cancel any running tests
cancelAllTests()
setTestResults([])
setCancelledRequests([])
setRetryStats(null)
setPerformanceStats(null)
setIsRunning(false)
message.info('Advanced tests reset')
}
const getStatusColor = (status: AdvancedTestResult['status']) => {
switch (status) {
case 'success':
return 'success'
case 'error':
return 'error'
case 'cancelled':
return 'warning'
case 'running':
return 'processing'
default:
return 'default'
}
}
const getStatusIcon = (status: AdvancedTestResult['status']) => {
switch (status) {
case 'success':
return <CheckCircle size={16} />
case 'error':
return <XCircle size={16} />
case 'cancelled':
return <StopCircle size={16} />
case 'running':
return <Activity size={16} className="animate-spin" />
default:
return <Clock size={16} />
}
}
const getCategoryIcon = (category: AdvancedTestResult['category']) => {
switch (category) {
case 'cancellation':
return <StopCircle size={16} />
case 'retry':
return <RotateCcw size={16} />
case 'batch':
return <Zap size={16} />
case 'error':
return <AlertTriangle size={16} />
case 'performance':
return <Timer size={16} />
default:
return <Activity size={16} />
}
}
const tableColumns = [
{
title: 'Category',
dataIndex: 'category',
key: 'category',
render: (category: string) => (
<Space>
{getCategoryIcon(category as any)}
<Text>{category}</Text>
</Space>
)
},
{
title: 'Test',
dataIndex: 'name',
key: 'name',
render: (name: string) => (
<Text code style={{ fontSize: 12 }}>
{name}
</Text>
)
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: AdvancedTestResult['status']) => (
<Tag color={getStatusColor(status)} icon={getStatusIcon(status)}>
{status.toUpperCase()}
</Tag>
)
},
{
title: 'Duration',
dataIndex: 'duration',
key: 'duration',
render: (duration?: number) => (duration ? `${duration}ms` : '-')
},
{
title: 'Actions',
key: 'actions',
render: (_: any, record: AdvancedTestResult) => (
<Space>
{record.status === 'running' && (
<Button
size="small"
type="text"
danger
icon={<StopCircle size={12} />}
onClick={() => cancelTest(record.id)}>
Cancel
</Button>
)}
</Space>
)
}
]
return (
<TestContainer>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Control Panel */}
<Card size="small">
<Row gutter={16}>
<Col span={6}>
<Button
type="primary"
icon={<StopCircle size={16} />}
onClick={testRequestCancellation}
loading={isRunning}
disabled={isRunning}
block>
Test Cancellation
</Button>
</Col>
<Col span={6}>
<Button
type="primary"
icon={<RotateCcw size={16} />}
onClick={testRetryMechanism}
loading={isRunning}
disabled={isRunning}
block>
Test Retry
</Button>
</Col>
<Col span={6}>
<Button
type="primary"
icon={<Zap size={16} />}
onClick={testBatchOperations}
loading={isRunning}
disabled={isRunning}
block>
Test Batch
</Button>
</Col>
<Col span={6}>
<Button
type="primary"
icon={<AlertTriangle size={16} />}
onClick={testErrorHandling}
loading={isRunning}
disabled={isRunning}
block>
Test Errors
</Button>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={6}>
<Button
type="primary"
icon={<Timer size={16} />}
onClick={testPerformance}
loading={isRunning}
disabled={isRunning}
block>
Test Performance
</Button>
</Col>
<Col span={6}>
<Button icon={<Shield size={16} />} onClick={resetTests} disabled={isRunning} block>
Reset Tests
</Button>
</Col>
<Col span={12}>
<Space style={{ float: 'right' }}>
{abortControllersRef.current.size > 0 && (
<Button danger icon={<StopCircle size={16} />} onClick={cancelAllTests}>
Cancel All ({abortControllersRef.current.size})
</Button>
)}
<Text type="secondary">Running: {testResults.filter((t) => t.status === 'running').length}</Text>
</Space>
</Col>
</Row>
</Card>
{/* Statistics */}
<Row gutter={16}>
<Col span={6}>
<Card size="small">
<Statistic title="Total Tests" value={testResults.length} prefix={<Activity size={16} />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Successful"
value={testResults.filter((t) => t.status === 'success').length}
prefix={<CheckCircle size={16} />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Failed"
value={testResults.filter((t) => t.status === 'error').length}
prefix={<XCircle size={16} />}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="Cancelled"
value={testResults.filter((t) => t.status === 'cancelled').length}
prefix={<StopCircle size={16} />}
valueStyle={{ color: '#d48806' }}
/>
</Card>
</Col>
</Row>
{/* Performance Stats */}
{performanceStats && (
<Card title="Performance Statistics" size="small">
<Row gutter={16}>
<Col span={6}>
<Statistic title="Concurrent Requests" value={performanceStats.concurrentRequests} />
</Col>
<Col span={6}>
<Statistic title="Total Time" value={performanceStats.totalTime} suffix="ms" />
</Col>
<Col span={6}>
<Statistic title="Average Time" value={Math.round(performanceStats.averageTime)} suffix="ms" />
</Col>
<Col span={6}>
<Statistic
title="Success Rate"
value={Math.round((performanceStats.successCount / performanceStats.concurrentRequests) * 100)}
suffix="%"
/>
</Col>
</Row>
</Card>
)}
{/* Test Results Table */}
<Card title="Advanced Test Results" size="small">
{testResults.length === 0 ? (
<Alert
message="No advanced tests executed yet"
description="Click any of the test buttons above to start testing advanced features"
type="info"
showIcon
/>
) : (
<Table
dataSource={testResults}
columns={tableColumns}
rowKey="id"
size="small"
pagination={{ pageSize: 15 }}
scroll={{ x: true }}
/>
)}
</Card>
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`
export default DataApiAdvancedTests

View File

@ -0,0 +1,604 @@
import { loggerService } from '@renderer/services/LoggerService'
import { Alert, Button, Card, Col, Divider, Input, message, Row, Space, Table, Tag, Typography } from 'antd'
import { Check, Database, Play, RotateCcw, X } from 'lucide-react'
import React, { useState } from 'react'
import styled from 'styled-components'
import { dataApiService } from '../../../data/DataApiService'
const { Text, Paragraph } = Typography
const { TextArea } = Input
const logger = loggerService.withContext('DataApiBasicTests')
interface TestResult {
id: string
name: string
status: 'pending' | 'running' | 'success' | 'error'
duration?: number
response?: any
error?: string
timestamp?: string
}
interface TestItem {
id: string
title: string
description: string
type: string
status: string
priority: string
tags: string[]
createdAt: string
updatedAt: string
metadata: Record<string, any>
}
const DataApiBasicTests: React.FC = () => {
const [testResults, setTestResults] = useState<TestResult[]>([])
const [isRunning, setIsRunning] = useState(false)
const [testItems, setTestItems] = useState<TestItem[]>([])
const [selectedItem, setSelectedItem] = useState<TestItem | null>(null)
const [newItemTitle, setNewItemTitle] = useState('')
const [newItemDescription, setNewItemDescription] = useState('')
const [newItemType, setNewItemType] = useState('data')
const updateTestResult = (id: string, updates: Partial<TestResult>) => {
setTestResults((prev) => prev.map((result) => (result.id === id ? { ...result, ...updates } : result)))
}
const runTest = async (testId: string, testName: string, testFn: () => Promise<any>) => {
const startTime = Date.now()
updateTestResult(testId, {
status: 'running',
timestamp: new Date().toISOString()
})
try {
logger.info(`Starting test: ${testName}`)
const response = await testFn()
const duration = Date.now() - startTime
logger.info(`Test completed: ${testName}`, { duration, response })
updateTestResult(testId, {
status: 'success',
duration,
response,
error: undefined
})
message.success(`${testName} completed successfully`)
return response
} catch (error) {
const duration = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`Test failed: ${testName}`, error as Error)
updateTestResult(testId, {
status: 'error',
duration,
error: errorMessage,
response: undefined
})
message.error(`${testName} failed: ${errorMessage}`)
throw error
}
}
const initializeTests = () => {
const tests: TestResult[] = [
{ id: 'get-items', name: 'GET /test/items', status: 'pending' },
{ id: 'create-item', name: 'POST /test/items', status: 'pending' },
{ id: 'get-item-by-id', name: 'GET /test/items/:id', status: 'pending' },
{ id: 'update-item', name: 'PUT /test/items/:id', status: 'pending' },
{ id: 'delete-item', name: 'DELETE /test/items/:id', status: 'pending' },
{ id: 'search-items', name: 'GET /test/search', status: 'pending' },
{ id: 'get-stats', name: 'GET /test/stats', status: 'pending' }
]
setTestResults(tests)
setTestItems([])
setSelectedItem(null)
}
const runAllTests = async () => {
if (isRunning) return
setIsRunning(true)
initializeTests()
try {
// Test 1: Get test items list
const itemsResponse = await runTest('get-items', 'Get Test Items List', () => dataApiService.get('/test/items'))
setTestItems(itemsResponse.items || [])
// Test 2: Create a new test item
const newItem = {
title: `Test Item ${Date.now()}`,
description: 'This is a test item created by the DataApi test suite',
type: 'data',
status: 'active',
priority: 'medium',
tags: ['test', 'api'],
metadata: { source: 'test-suite', version: '1.0.0' }
}
const createResponse = await runTest('create-item', 'Create New Test Item', () =>
dataApiService.post('/test/items', { body: newItem })
)
// Debug: Log the create response
logger.info('Create response received', { createResponse, id: createResponse?.id })
// Update items list with new item
if (createResponse) {
setTestItems((prev) => [createResponse, ...prev])
setSelectedItem(createResponse)
}
// Test 3: Get item by ID
if (createResponse?.id) {
logger.info('About to test get item by ID', { id: createResponse.id })
await runTest('get-item-by-id', 'Get Test Item By ID', () =>
dataApiService.get(`/test/items/${createResponse.id}`)
)
} else {
logger.warn('Skipping get item by ID test - no valid item ID from create response', { createResponse })
updateTestResult('get-item-by-id', {
status: 'error',
error: 'No valid item ID from create response'
})
}
// Only proceed with update/delete if we have a valid ID
if (createResponse?.id) {
// Test 4: Update item
const updatedData = {
title: `${createResponse.title} (Updated)`,
description: `${createResponse.description}\n\nUpdated by test at ${new Date().toISOString()}`,
priority: 'high'
}
const updateResponse = await runTest('update-item', 'Update Test Item', () =>
dataApiService.put(`/test/items/${createResponse.id}`, {
body: updatedData
})
)
if (updateResponse) {
setSelectedItem(updateResponse)
setTestItems((prev) => prev.map((item) => (item.id === createResponse.id ? updateResponse : item)))
}
}
// Test 5: Search items
await runTest('search-items', 'Search Test Items', () =>
dataApiService.get('/test/search', {
query: {
query: 'test',
page: 1,
limit: 10
}
})
)
// Test 6: Get statistics
await runTest('get-stats', 'Get Test Statistics', () => dataApiService.get('/test/stats'))
// Test 7: Delete item (optional, comment out to keep test data)
// if (createResponse?.id) {
// await runTest(
// 'delete-item',
// 'Delete Test Item',
// () => dataApiService.delete(`/test/items/${createResponse.id}`)
// )
// }
message.success('All basic tests completed!')
} catch (error) {
logger.error('Test suite failed', error as Error)
message.error('Test suite execution failed')
} finally {
setIsRunning(false)
}
}
const runSingleTest = async (testId: string) => {
if (isRunning) return
switch (testId) {
case 'create-item': {
if (!newItemTitle.trim()) {
message.warning('Please enter an item title')
return
}
const createResponse = await runTest(testId, 'Create New Item', () =>
dataApiService.post('/test/items', {
body: {
title: newItemTitle,
description: newItemDescription,
type: newItemType
}
})
)
if (createResponse) {
setTestItems((prev) => [createResponse, ...prev])
setNewItemTitle('')
setNewItemDescription('')
}
break
}
default:
message.warning(`Single test execution not implemented for ${testId}`)
}
}
const resetTests = () => {
setTestResults([])
setTestItems([])
setSelectedItem(null)
setNewItemTitle('')
setNewItemDescription('')
message.info('Tests reset')
}
const resetTestData = async () => {
try {
await dataApiService.post('/test/reset', {})
message.success('Test data reset successfully')
setTestItems([])
setSelectedItem(null)
} catch (error) {
logger.error('Failed to reset test data', error as Error)
message.error('Failed to reset test data')
}
}
const getStatusColor = (status: TestResult['status']) => {
switch (status) {
case 'success':
return 'success'
case 'error':
return 'error'
case 'running':
return 'processing'
default:
return 'default'
}
}
const getStatusIcon = (status: TestResult['status']) => {
switch (status) {
case 'success':
return <Check size={16} />
case 'error':
return <X size={16} />
case 'running':
return <RotateCcw size={16} className="animate-spin" />
default:
return null
}
}
const tableColumns = [
{
title: 'Test',
dataIndex: 'name',
key: 'name',
render: (name: string) => <Text code>{name}</Text>
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: TestResult['status']) => (
<Tag color={getStatusColor(status)} icon={getStatusIcon(status)}>
{status.toUpperCase()}
</Tag>
)
},
{
title: 'Duration',
dataIndex: 'duration',
key: 'duration',
render: (duration?: number) => (duration ? `${duration}ms` : '-')
},
{
title: 'Actions',
key: 'actions',
render: (_: any, record: TestResult) => (
<Button
size="small"
type="link"
icon={<Play size={14} />}
onClick={() => runSingleTest(record.id)}
disabled={isRunning}>
Run
</Button>
)
}
]
const itemsColumns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
render: (id: string) => <Text code>{id.substring(0, 20)}...</Text>
},
{
title: 'Title',
dataIndex: 'title',
key: 'title'
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (type: string) => <Tag>{type}</Tag>
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: string) => <Tag color={status === 'active' ? 'green' : 'orange'}>{status}</Tag>
},
{
title: 'Priority',
dataIndex: 'priority',
key: 'priority',
render: (priority: string) => (
<Tag color={priority === 'high' ? 'red' : priority === 'medium' ? 'orange' : 'blue'}>{priority}</Tag>
)
},
{
title: 'Actions',
key: 'actions',
render: (_: any, record: TestItem) => (
<Button size="small" type="link" onClick={() => setSelectedItem(record)}>
View
</Button>
)
}
]
return (
<TestContainer>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Control Panel */}
<Card size="small">
<Row gutter={16} align="middle">
<Col span={12}>
<Space>
<Button
type="primary"
icon={<Play size={16} />}
onClick={runAllTests}
loading={isRunning}
disabled={isRunning}>
Run All Basic Tests
</Button>
<Button icon={<RotateCcw size={16} />} onClick={resetTests} disabled={isRunning}>
Reset Tests
</Button>
</Space>
</Col>
<Col span={12}>
<Space style={{ float: 'right' }}>
<Button icon={<Database size={16} />} onClick={resetTestData} disabled={isRunning}>
Reset Test Data
</Button>
</Space>
</Col>
</Row>
</Card>
{/* Test Results */}
<Card title="Test Results" size="small">
{testResults.length === 0 ? (
<Alert
message="No tests executed yet"
description="Click 'Run All Basic Tests' to start testing basic CRUD operations"
type="info"
showIcon
/>
) : (
<Table
dataSource={testResults}
columns={tableColumns}
rowKey="id"
size="small"
pagination={false}
scroll={{ x: true }}
/>
)}
</Card>
{/* Manual Testing */}
<Row gutter={16}>
<Col span={12}>
<Card title="Create Item Manually" size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<Input
placeholder="Item Title"
value={newItemTitle}
onChange={(e) => setNewItemTitle(e.target.value)}
/>
<TextArea
placeholder="Item Description (optional)"
value={newItemDescription}
onChange={(e) => setNewItemDescription(e.target.value)}
rows={3}
/>
<Input placeholder="Item Type" value={newItemType} onChange={(e) => setNewItemType(e.target.value)} />
<Button
type="primary"
onClick={() => runSingleTest('create-item')}
disabled={isRunning || !newItemTitle.trim()}
block>
Create Item
</Button>
</Space>
</Card>
</Col>
<Col span={12}>
<Card title="Selected Item Details" size="small">
{selectedItem ? (
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<strong>ID:</strong> <Text code>{selectedItem.id}</Text>
</div>
<div>
<strong>Title:</strong> {selectedItem.title}
</div>
<div>
<strong>Type:</strong> <Tag>{selectedItem.type}</Tag>
</div>
<div>
<strong>Status:</strong>{' '}
<Tag color={selectedItem.status === 'active' ? 'green' : 'orange'}>{selectedItem.status}</Tag>
</div>
<div>
<strong>Priority:</strong>{' '}
<Tag
color={
selectedItem.priority === 'high'
? 'red'
: selectedItem.priority === 'medium'
? 'orange'
: 'blue'
}>
{selectedItem.priority}
</Tag>
</div>
<div>
<strong>Created:</strong> {new Date(selectedItem.createdAt).toLocaleString()}
</div>
{selectedItem.description && (
<div>
<strong>Description:</strong>
<Paragraph ellipsis={{ rows: 3, expandable: true }}>{selectedItem.description}</Paragraph>
</div>
)}
{selectedItem.tags && selectedItem.tags.length > 0 && (
<div>
<strong>Tags:</strong>{' '}
{selectedItem.tags.map((tag: string) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
)}
</Space>
) : (
<Text type="secondary">No item selected</Text>
)}
</Card>
</Col>
</Row>
{/* Items Table */}
<Card title={`Test Items (${testItems.length})`} size="small">
{testItems.length === 0 ? (
<Alert
message="No items loaded"
description="Run the tests or create a new item to see data here"
type="info"
showIcon
/>
) : (
<Table
dataSource={testItems}
columns={itemsColumns}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
scroll={{ x: true }}
/>
)}
</Card>
{/* Test Details */}
{testResults.some((t) => t.response || t.error) && (
<Card title="Test Details" size="small">
<Space direction="vertical" style={{ width: '100%' }}>
{testResults.map(
(result) =>
(result.response || result.error) && (
<div key={result.id}>
<Divider orientation="left" plain>
<Text code>{result.name}</Text> -
<Tag color={getStatusColor(result.status)}>{result.status}</Tag>
</Divider>
{result.response && (
<div>
<Text strong>Response:</Text>
<pre
style={{
background: '#f5f5f5',
padding: 8,
borderRadius: 4,
fontSize: 12,
overflow: 'auto'
}}>
{JSON.stringify(result.response, null, 2)}
</pre>
</div>
)}
{result.error && (
<div>
<Text strong type="danger">
Error:
</Text>
<pre
style={{
background: '#fff2f0',
padding: 8,
borderRadius: 4,
fontSize: 12,
overflow: 'auto',
border: '1px solid #ffccc7'
}}>
{result.error}
</pre>
</div>
)}
</div>
)
)}
</Space>
</Card>
)}
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
pre {
margin: 8px 0;
max-height: 200px;
}
`
export default DataApiBasicTests

View File

@ -0,0 +1,658 @@
import { prefetch, useInvalidateCache, useMutation, usePaginatedQuery, useQuery } from '@renderer/data/hooks/useDataApi'
import { loggerService } from '@renderer/services/LoggerService'
import { Alert, Button, Card, Col, Input, message, Row, Space, Spin, Table, Tag, Typography } from 'antd'
import {
ArrowLeft,
ArrowRight,
Database,
Edit,
Eye,
GitBranch,
Plus,
RefreshCw,
Trash2,
TrendingUp,
Zap
} from 'lucide-react'
import React, { useCallback, useState } from 'react'
import styled from 'styled-components'
const { Text } = Typography
const { TextArea } = Input
const logger = loggerService.withContext('DataApiHookTests')
interface TestItem {
id: string
title: string
description: string
status: string
type: string
priority: string
tags: string[]
createdAt: string
updatedAt: string
metadata: Record<string, any>
}
const DataApiHookTests: React.FC = () => {
// Hook for cache invalidation
const invalidateCache = useInvalidateCache()
// State for manual testing
const [selectedItemId, setSelectedItemId] = useState<string>('')
const [newItemTitle, setNewItemTitle] = useState('')
const [newItemDescription, setNewItemDescription] = useState('')
const [updateTitle, setUpdateTitle] = useState('')
const [updateDescription, setUpdateDescription] = useState('')
const [queryParams, setQueryParams] = useState({ page: 1, limit: 5 })
const [cacheTestCount, setCacheTestCount] = useState(0)
// useQuery hook test - Basic data fetching
const {
data: itemsData,
loading: itemsLoading,
error: itemsError,
refetch: refreshItems
} = useQuery('/test/items' as any, {
query: queryParams,
swrOptions: {
revalidateOnFocus: false,
dedupingInterval: 2000
}
})
// useQuery hook test - Single item by ID
const {
data: singleItem,
loading: singleItemLoading,
error: singleItemError,
refetch: refreshSingleItem
} = useQuery(selectedItemId ? `/test/items/${selectedItemId}` : (null as any), {
enabled: !!selectedItemId,
swrOptions: {
revalidateOnFocus: false
}
})
// usePaginatedQuery hook test
const {
items: paginatedItems,
total: totalItems,
page: currentPage,
loading: paginatedLoading,
error: paginatedError,
hasMore,
hasPrev,
prevPage,
nextPage,
refresh: refreshPaginated,
reset: resetPagination
} = usePaginatedQuery('/test/items' as any, {
query: { type: 'test' },
limit: 3,
swrOptions: {
revalidateOnFocus: false
}
})
// useMutation hook tests
const createItemMutation = useMutation('POST', '/test/items', {
onSuccess: (data) => {
logger.info('Item created successfully', { data })
message.success('Item created!')
setNewItemTitle('')
setNewItemDescription('')
// Invalidate cache to refresh the list
invalidateCache(['/test/items'])
},
onError: (error) => {
logger.error('Failed to create item', error as Error)
message.error(`Failed to create item: ${error.message}`)
}
})
const updateItemMutation = useMutation(
'PUT',
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
{
onSuccess: (data) => {
logger.info('Item updated successfully', { data })
message.success('Item updated!')
setUpdateTitle('')
setUpdateDescription('')
// Invalidate specific item and items list
invalidateCache(['/test/items', ...(selectedItemId ? [`/test/items/${selectedItemId}`] : [])])
},
onError: (error) => {
logger.error('Failed to update item', error as Error)
message.error(`Failed to update item: ${error.message}`)
}
}
)
const deleteItemMutation = useMutation(
'DELETE',
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
{
onSuccess: () => {
logger.info('Item deleted successfully')
message.success('Item deleted!')
setSelectedItemId('')
// Invalidate cache
invalidateCache(['/test/items'])
},
onError: (error) => {
logger.error('Failed to delete item', error as Error)
message.error(`Failed to delete item: ${error.message}`)
}
}
)
// useMutation with optimistic updates
const optimisticUpdateMutation = useMutation(
'PUT',
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
{
optimistic: true,
optimisticData: { title: 'Optimistically Updated Item' },
revalidate: ['/test/items']
}
)
// Handle functions
const handleCreateItem = useCallback(async () => {
if (!newItemTitle.trim()) {
message.warning('Please enter an item title')
return
}
try {
await createItemMutation.mutate({
body: {
title: newItemTitle,
description: newItemDescription,
type: 'hook-test'
}
})
} catch (error) {
// Error already handled by mutation
}
}, [newItemTitle, newItemDescription, createItemMutation])
const handleUpdateTopic = useCallback(async () => {
if (!selectedItemId || !updateTitle.trim()) {
message.warning('Please select an item and enter a title')
return
}
try {
await updateItemMutation.mutate({
body: {
title: updateTitle,
description: updateDescription
}
})
} catch (error) {
// Error already handled by mutation
}
}, [selectedItemId, updateTitle, updateDescription, updateItemMutation])
const handleDeleteTopic = useCallback(async () => {
if (!selectedItemId) {
message.warning('Please select an item to delete')
return
}
try {
await deleteItemMutation.mutate()
} catch (error) {
// Error already handled by mutation
}
}, [selectedItemId, deleteItemMutation])
const handleOptimisticUpdate = useCallback(async () => {
if (!selectedItemId || !singleItem) {
message.warning('Please select an item for optimistic update')
return
}
const optimisticData = {
...(singleItem || {}),
title: `${(singleItem as any)?.title || 'Unknown'} (Optimistic Update)`,
updatedAt: new Date().toISOString()
}
try {
await optimisticUpdateMutation.mutate({
body: {
title: optimisticData.title,
description: (singleItem as any)?.description
}
})
message.success('Optimistic update completed!')
} catch (error) {
message.error(`Optimistic update failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}, [selectedItemId, singleItem, optimisticUpdateMutation])
const handlePrefetch = useCallback(async () => {
try {
// Prefetch the next page of items
await prefetch('/test/items', {
query: { page: queryParams.page + 1, limit: queryParams.limit }
})
message.success('Next page prefetched!')
} catch (error) {
message.error('Prefetch failed')
}
}, [queryParams])
const handleCacheTest = useCallback(async () => {
// Test cache invalidation and refresh
setCacheTestCount((prev) => prev + 1)
if (cacheTestCount % 3 === 0) {
await invalidateCache()
message.info('All cache invalidated')
} else if (cacheTestCount % 3 === 1) {
await invalidateCache('/test/items')
message.info('Topics cache invalidated')
} else {
refreshItems()
message.info('Topics refreshed')
}
}, [cacheTestCount, refreshItems, invalidateCache])
const itemsColumns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
render: (id: string) => (
<Text code style={{ fontSize: 11 }}>
{id.substring(0, 15)}...
</Text>
)
},
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (type: string) => <Tag>{type}</Tag>
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: string) => <Tag color={status === 'active' ? 'green' : 'orange'}>{status}</Tag>
},
{
title: 'Actions',
key: 'actions',
render: (_: any, record: TestItem) => (
<Space>
<Button
size="small"
type="link"
icon={<Eye size={12} />}
onClick={() => {
setSelectedItemId(record.id)
setUpdateTitle(record.title)
setUpdateDescription((record as any).description || '')
}}>
Select
</Button>
</Space>
)
}
]
return (
<TestContainer>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Hook Status Overview */}
<Row gutter={16}>
<Col span={6}>
<Card size="small">
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<Database size={20} />
<Text strong>useQuery</Text>
{itemsLoading ? (
<Spin size="small" />
) : itemsError ? (
<Tag color="red">Error</Tag>
) : (
<Tag color="green">Active</Tag>
)}
<Text type="secondary">{(itemsData as any)?.items?.length || 0} items</Text>
</Space>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<TrendingUp size={20} />
<Text strong>usePaginated</Text>
{paginatedLoading ? (
<Spin size="small" />
) : paginatedError ? (
<Tag color="red">Error</Tag>
) : (
<Tag color="green">Active</Tag>
)}
<Text type="secondary">
{paginatedItems.length} / {totalItems}
</Text>
</Space>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<GitBranch size={20} />
<Text strong>useMutation</Text>
{createItemMutation.loading || updateItemMutation.loading || deleteItemMutation.loading ? (
<Spin size="small" />
) : (
<Tag color="blue">Ready</Tag>
)}
<Text type="secondary">CRUD Operations</Text>
</Space>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<Zap size={20} />
<Text strong>Cache</Text>
<Tag color="purple">Active</Tag>
<Text type="secondary">Test #{cacheTestCount}</Text>
</Space>
</Card>
</Col>
</Row>
{/* useQuery Test - Basic Topics List */}
<Card title="useQuery Hook Test - Topics List" size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<Row justify="space-between" align="middle">
<Col>
<Space>
<Text>Page: {queryParams.page}</Text>
<Text>Limit: {queryParams.limit}</Text>
{itemsLoading && <Spin size="small" />}
</Space>
</Col>
<Col>
<Space>
<Button size="small" icon={<RefreshCw size={14} />} onClick={refreshItems} loading={itemsLoading}>
Refresh
</Button>
<Button size="small" onClick={handlePrefetch}>
Prefetch Next
</Button>
<Button size="small" onClick={handleCacheTest}>
Cache Test
</Button>
</Space>
</Col>
</Row>
{itemsError ? (
<Alert message="useQuery Error" description={itemsError.message} type="error" showIcon />
) : (
<Table
dataSource={(itemsData as any)?.items || []}
columns={itemsColumns}
rowKey="id"
size="small"
pagination={false}
loading={itemsLoading}
scroll={{ x: true }}
/>
)}
<Row justify="space-between">
<Col>
<Space>
<Button
size="small"
disabled={queryParams.page <= 1}
onClick={() => setQueryParams((prev) => ({ ...prev, page: prev.page - 1 }))}>
Previous
</Button>
<Button size="small" onClick={() => setQueryParams((prev) => ({ ...prev, page: prev.page + 1 }))}>
Next
</Button>
</Space>
</Col>
<Col>
<Text type="secondary">Total: {(itemsData as any)?.total || 0} items</Text>
</Col>
</Row>
</Space>
</Card>
{/* Single Topic Query */}
<Card title="useQuery Hook Test - Single Topic" size="small">
<Row gutter={16}>
<Col span={12}>
<Space direction="vertical" style={{ width: '100%' }}>
<Input
placeholder="Enter item ID"
value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)}
/>
<Button
icon={<RefreshCw size={14} />}
onClick={refreshSingleItem}
loading={singleItemLoading}
disabled={!selectedItemId}
block>
Fetch Topic
</Button>
</Space>
</Col>
<Col span={12}>
{singleItemLoading ? (
<Spin />
) : singleItemError ? (
<Alert message="Error" description={singleItemError.message} type="error" showIcon />
) : singleItem ? (
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<strong>Title:</strong> {(singleItem as any)?.title || 'N/A'}
</div>
<div>
<strong>Type:</strong> <Tag>{(singleItem as any)?.type || 'N/A'}</Tag>
</div>
<div>
<strong>Status:</strong>{' '}
<Tag color={(singleItem as any)?.status === 'active' ? 'green' : 'orange'}>
{(singleItem as any)?.status || 'N/A'}
</Tag>
</div>
<div>
<strong>Updated:</strong> {new Date((singleItem as any)?.updatedAt || Date.now()).toLocaleString()}
</div>
</Space>
) : (
<Text type="secondary">No item selected</Text>
)}
</Col>
</Row>
</Card>
{/* usePaginatedQuery Test */}
<Card title="usePaginatedQuery Hook Test" size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<Row justify="space-between" align="middle">
<Col>
<Space>
<Text>Page: {currentPage}</Text>
<Text>Items: {paginatedItems.length}</Text>
<Text>Total: {totalItems}</Text>
{paginatedLoading && <Spin size="small" />}
</Space>
</Col>
<Col>
<Space>
<Button size="small" icon={<ArrowLeft size={14} />} onClick={prevPage} disabled={!hasPrev}>
Previous
</Button>
<Button size="small" icon={<ArrowRight size={14} />} onClick={nextPage} disabled={!hasMore}>
{hasMore ? 'Load More' : 'No More'}
</Button>
<Button size="small" icon={<RefreshCw size={14} />} onClick={refreshPaginated}>
Refresh
</Button>
<Button size="small" onClick={resetPagination}>
Reset
</Button>
</Space>
</Col>
</Row>
{paginatedError ? (
<Alert message="usePaginatedQuery Error" description={paginatedError.message} type="error" showIcon />
) : (
<div style={{ maxHeight: 300, overflow: 'auto' }}>
{paginatedItems.map((item, index) => {
const typedItem = item as any
return (
<Card key={typedItem.id} size="small" style={{ marginBottom: 8 }}>
<Row justify="space-between" align="middle">
<Col span={18}>
<Space direction="vertical" size="small">
<Text strong>{typedItem.title}</Text>
<Space>
<Tag>{typedItem.type}</Tag>
<Tag color={typedItem.status === 'active' ? 'green' : 'orange'}>{typedItem.status}</Tag>
</Space>
</Space>
</Col>
<Col>
<Text type="secondary">#{index + 1}</Text>
</Col>
</Row>
</Card>
)
})}
</div>
)}
</Space>
</Card>
{/* useMutation Tests */}
<Row gutter={16}>
<Col span={12}>
<Card title="useMutation - Create Topic" size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<Input
placeholder="Topic Title"
value={newItemTitle}
onChange={(e) => setNewItemTitle(e.target.value)}
/>
<TextArea
placeholder="Topic Content (optional)"
value={newItemDescription}
onChange={(e) => setNewItemDescription(e.target.value)}
rows={3}
/>
<Button
type="primary"
icon={<Plus size={14} />}
onClick={handleCreateItem}
loading={createItemMutation.loading}
disabled={!newItemTitle.trim()}
block>
Create Topic
</Button>
{createItemMutation.error && (
<Alert
message="Creation Error"
description={createItemMutation.error.message}
type="error"
showIcon
closable
/>
)}
</Space>
</Card>
</Col>
<Col span={12}>
<Card title="useMutation - Update Topic" size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<Input placeholder="New Title" value={updateTitle} onChange={(e) => setUpdateTitle(e.target.value)} />
<TextArea
placeholder="New Content"
value={updateDescription}
onChange={(e) => setUpdateDescription(e.target.value)}
rows={2}
/>
<Space style={{ width: '100%' }}>
<Button
type="primary"
icon={<Edit size={14} />}
onClick={handleUpdateTopic}
loading={updateItemMutation.loading}
disabled={!selectedItemId || !updateTitle.trim()}>
Update
</Button>
<Button icon={<Zap size={14} />} onClick={handleOptimisticUpdate} disabled={!selectedItemId}>
Optimistic
</Button>
<Button
danger
icon={<Trash2 size={14} />}
onClick={handleDeleteTopic}
loading={deleteItemMutation.loading}
disabled={!selectedItemId}>
Delete
</Button>
</Space>
{(updateItemMutation.error || deleteItemMutation.error) && (
<Alert
message="Operation Error"
description={(updateItemMutation.error || deleteItemMutation.error)?.message}
type="error"
showIcon
closable
/>
)}
</Space>
</Card>
</Col>
</Row>
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
.ant-card {
border-radius: 8px;
}
.ant-table-small .ant-table-tbody > tr > td {
padding: 8px;
}
.ant-tag {
border-radius: 4px;
}
`
export default DataApiHookTests

View File

@ -0,0 +1,770 @@
import { loggerService } from '@renderer/services/LoggerService'
import {
Alert,
Button,
Card,
Col,
InputNumber,
message,
Progress,
Row,
Space,
Statistic,
Table,
Tag,
Typography
} from 'antd'
import { Activity, AlertTriangle, Cpu, Gauge, RefreshCw, StopCircle, Timer, TrendingUp, Zap } from 'lucide-react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { dataApiService } from '../../../data/DataApiService'
const { Text } = Typography
const logger = loggerService.withContext('DataApiStressTests')
interface StressTestConfig {
concurrentRequests: number
totalRequests: number
requestDelay: number
testDuration: number // in seconds
errorRate: number // percentage of requests that should fail
}
interface StressTestResult {
id: string
requestId: number
startTime: number
endTime?: number
duration?: number
status: 'pending' | 'success' | 'error' | 'timeout'
response?: any
error?: string
memoryBefore?: number
memoryAfter?: number
}
interface StressTestSummary {
totalRequests: number
completedRequests: number
successfulRequests: number
failedRequests: number
timeoutRequests: number
averageResponseTime: number
minResponseTime: number
maxResponseTime: number
requestsPerSecond: number
errorRate: number
memoryUsage: {
initial: number
peak: number
final: number
leaked: number
}
testDuration: number
}
const DataApiStressTests: React.FC = () => {
const [config, setConfig] = useState<StressTestConfig>({
concurrentRequests: 10,
totalRequests: 100,
requestDelay: 100,
testDuration: 60,
errorRate: 10
})
const [isRunning, setIsRunning] = useState(false)
const [currentTest, setCurrentTest] = useState<string>('')
const [results, setResults] = useState<StressTestResult[]>([])
const [summary, setSummary] = useState<StressTestSummary | null>(null)
const [progress, setProgress] = useState(0)
const [realtimeStats, setRealtimeStats] = useState({
rps: 0,
avgResponseTime: 0,
errorRate: 0,
memoryUsage: 0
})
// Test control
const abortControllerRef = useRef<AbortController | null>(null)
const testStartTimeRef = useRef<number>(0)
const completedCountRef = useRef<number>(0)
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Memory monitoring
const getMemoryUsage = () => {
if ((performance as any).memory) {
return (performance as any).memory.usedJSHeapSize
}
return 0
}
// Update realtime statistics
const updateRealtimeStats = useCallback(() => {
const completedResults = results.filter((r) => r.status !== 'pending')
const errorResults = completedResults.filter((r) => r.status === 'error')
if (completedResults.length === 0) return
const durations = completedResults.map((r) => r.duration || 0).filter((d) => d > 0)
const avgResponseTime = durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : 0
const elapsedTime = (Date.now() - testStartTimeRef.current) / 1000
const rps = elapsedTime > 0 ? completedResults.length / elapsedTime : 0
const errorRate = completedResults.length > 0 ? (errorResults.length / completedResults.length) * 100 : 0
setRealtimeStats({
rps: Math.round(rps * 100) / 100,
avgResponseTime: Math.round(avgResponseTime),
errorRate: Math.round(errorRate * 100) / 100,
memoryUsage: Math.round((getMemoryUsage() / 1024 / 1024) * 100) / 100 // MB
})
}, [results])
// Update statistics every second during testing
useEffect(() => {
if (isRunning) {
statsIntervalRef.current = setInterval(updateRealtimeStats, 1000)
} else if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current)
statsIntervalRef.current = null
}
return () => {
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current)
}
}
}, [isRunning, updateRealtimeStats])
const executeStressTest = async (
testName: string,
testFn: (requestId: number, signal: AbortSignal) => Promise<any>
) => {
setIsRunning(true)
setCurrentTest(testName)
setResults([])
setSummary(null)
setProgress(0)
completedCountRef.current = 0
testStartTimeRef.current = Date.now()
// Create abort controller
abortControllerRef.current = new AbortController()
const { signal } = abortControllerRef.current
const initialMemory = getMemoryUsage()
let peakMemory = initialMemory
logger.info(`Starting stress test: ${testName}`, config)
try {
const testResults: StressTestResult[] = []
// Initialize pending results
for (let i = 0; i < config.totalRequests; i++) {
testResults.push({
id: `request-${i}`,
requestId: i,
startTime: 0,
status: 'pending'
})
}
setResults([...testResults])
// Execute requests with controlled concurrency
const executeRequest = async (requestId: number): Promise<void> => {
if (signal.aborted) return
const startTime = Date.now()
const memoryBefore = getMemoryUsage()
// Update memory peak
peakMemory = Math.max(peakMemory, memoryBefore)
testResults[requestId].startTime = startTime
testResults[requestId].memoryBefore = memoryBefore
setResults([...testResults])
try {
const response = await testFn(requestId, signal)
const endTime = Date.now()
const duration = endTime - startTime
const memoryAfter = getMemoryUsage()
testResults[requestId] = {
...testResults[requestId],
endTime,
duration,
status: 'success',
response,
memoryAfter
}
completedCountRef.current++
setProgress((completedCountRef.current / config.totalRequests) * 100)
} catch (error) {
const endTime = Date.now()
const duration = endTime - startTime
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
testResults[requestId] = {
...testResults[requestId],
endTime,
duration,
status: errorMessage.includes('timeout') ? 'timeout' : 'error',
error: errorMessage,
memoryAfter: getMemoryUsage()
}
completedCountRef.current++
setProgress((completedCountRef.current / config.totalRequests) * 100)
}
setResults([...testResults])
}
// Control concurrency
let activeRequests = 0
let nextRequestId = 0
const processNextRequest = async () => {
if (nextRequestId >= config.totalRequests || signal.aborted) {
return
}
const requestId = nextRequestId++
activeRequests++
await executeRequest(requestId)
activeRequests--
// Add delay between requests if configured
if (config.requestDelay > 0 && !signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, config.requestDelay))
}
// Continue processing if we haven't reached limits
if (activeRequests < config.concurrentRequests && nextRequestId < config.totalRequests) {
processNextRequest()
}
}
// Start initial concurrent requests
const initialRequests = Math.min(config.concurrentRequests, config.totalRequests)
for (let i = 0; i < initialRequests; i++) {
processNextRequest()
}
// Wait for all requests to complete or timeout
const maxWaitTime = config.testDuration * 1000
const waitStartTime = Date.now()
while (completedCountRef.current < config.totalRequests && !signal.aborted) {
if (Date.now() - waitStartTime > maxWaitTime) {
logger.warn('Stress test timeout reached')
break
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Calculate final summary
const finalMemory = getMemoryUsage()
const completedResults = testResults.filter((r) => r.status !== 'pending')
const failedResults = completedResults.filter((r) => r.status === 'error')
const timeoutResults = completedResults.filter((r) => r.status === 'timeout')
const durations = completedResults.map((r) => r.duration || 0).filter((d) => d > 0)
const avgResponseTime = durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : 0
const minResponseTime = durations.length > 0 ? Math.min(...durations) : 0
const maxResponseTime = durations.length > 0 ? Math.max(...durations) : 0
const testDuration = (Date.now() - testStartTimeRef.current) / 1000
const requestsPerSecond = testDuration > 0 ? completedResults.length / testDuration : 0
const errorRate = completedResults.length > 0 ? (failedResults.length / completedResults.length) * 100 : 0
const testSummary: StressTestSummary = {
totalRequests: config.totalRequests,
completedRequests: completedResults.length,
successfulRequests: completedResults.filter((r) => r.status === 'success').length,
failedRequests: failedResults.length,
timeoutRequests: timeoutResults.length,
averageResponseTime: Math.round(avgResponseTime),
minResponseTime,
maxResponseTime,
requestsPerSecond: Math.round(requestsPerSecond * 100) / 100,
errorRate: Math.round(errorRate * 100) / 100,
memoryUsage: {
initial: Math.round((initialMemory / 1024 / 1024) * 100) / 100,
peak: Math.round((peakMemory / 1024 / 1024) * 100) / 100,
final: Math.round((finalMemory / 1024 / 1024) * 100) / 100,
leaked: Math.round(((finalMemory - initialMemory) / 1024 / 1024) * 100) / 100
},
testDuration: Math.round(testDuration * 100) / 100
}
setSummary(testSummary)
logger.info(`Stress test completed: ${testName}`, testSummary)
const successfulCount = completedResults.filter((r) => r.status === 'success').length
message.success(`Stress test completed! ${successfulCount}/${config.totalRequests} requests succeeded`)
} catch (error) {
logger.error('Stress test failed', error as Error)
message.error('Stress test failed')
} finally {
setIsRunning(false)
setCurrentTest('')
abortControllerRef.current = null
}
}
const runConcurrentRequestsTest = async () => {
await executeStressTest('Concurrent Requests Test', async (requestId, signal) => {
// Alternate between different endpoints to simulate real usage
const endpoints = ['/test/items', '/test/stats'] as const
const endpoint = endpoints[requestId % endpoints.length]
return await dataApiService.get(endpoint, { signal })
})
}
const runMemoryLeakTest = async () => {
await executeStressTest('Memory Leak Test', async (requestId, signal) => {
// Create and immediately cancel some requests to test cleanup
if (requestId % 5 === 0) {
const { signal: cancelSignal, cancel } = dataApiService.createAbortController()
const requestPromise = dataApiService.post('/test/slow', { body: { delay: 2000 }, signal: cancelSignal })
setTimeout(() => cancel(), 100)
try {
return await requestPromise
} catch (error) {
return { cancelled: true, error: error instanceof Error ? error.message : 'Unknown error' }
}
} else {
// Normal request
return await dataApiService.get('/test/items', { signal })
}
})
}
const runErrorHandlingTest = async () => {
await executeStressTest('Error Handling Stress Test', async (requestId, signal) => {
// Mix of successful and error requests
const errorTypes = ['network', 'server', 'timeout', 'notfound', 'validation']
const shouldError = Math.random() * 100 < config.errorRate
if (shouldError) {
const errorType = errorTypes[requestId % errorTypes.length]
try {
return await dataApiService.post('/test/error', { body: { errorType: errorType as any }, signal })
} catch (error) {
return { expectedError: true, error: error instanceof Error ? error.message : 'Unknown error' }
}
} else {
return await dataApiService.get('/test/items', { signal })
}
})
}
const runMixedOperationsTest = async () => {
await executeStressTest('Mixed Operations Stress Test', async (requestId, signal) => {
const operations = ['GET', 'POST', 'PUT', 'DELETE']
const operation = operations[requestId % operations.length]
switch (operation) {
case 'GET':
return await dataApiService.get('/test/items', { signal })
case 'POST':
return await dataApiService.post('/test/items', {
body: {
title: `Stress Test Topic ${requestId}`,
description: `Created during stress test #${requestId}`,
type: 'stress-test'
},
signal
})
case 'PUT':
// First get an item, then update it
try {
const items = await dataApiService.get('/test/items', { signal })
if ((items as any).items && (items as any).items.length > 0) {
const itemId = (items as any).items[0].id
return await dataApiService.put(`/test/items/${itemId}`, {
body: {
title: `Updated by stress test ${requestId}`
},
signal
})
}
} catch (error) {
return { updateFailed: true, error: error instanceof Error ? error.message : 'Unknown error' }
}
return { updateFailed: true, message: 'No items found to update' }
default:
return await dataApiService.get('/test/items', { signal })
}
})
}
const stopTest = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
message.info('Stress test stopped by user')
}
}
const resetTests = () => {
stopTest()
setResults([])
setSummary(null)
setProgress(0)
setRealtimeStats({ rps: 0, avgResponseTime: 0, errorRate: 0, memoryUsage: 0 })
message.info('Stress tests reset')
}
const resultColumns = [
{
title: 'Request ID',
dataIndex: 'requestId',
key: 'requestId',
width: 100
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const colorMap = {
success: 'green',
error: 'red',
timeout: 'orange',
pending: 'blue'
}
return <Tag color={colorMap[status] || 'default'}>{status.toUpperCase()}</Tag>
}
},
{
title: 'Duration (ms)',
dataIndex: 'duration',
key: 'duration',
render: (duration?: number) => (duration ? `${duration}ms` : '-')
},
{
title: 'Memory Usage (KB)',
key: 'memory',
render: (_: any, record: StressTestResult) => {
if (record.memoryBefore && record.memoryAfter) {
const diff = Math.round((record.memoryAfter - record.memoryBefore) / 1024)
return diff > 0 ? `+${diff}` : `${diff}`
}
return '-'
}
}
]
return (
<TestContainer>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Configuration Panel */}
<Card title="Stress Test Configuration" size="small">
<Row gutter={16}>
<Col span={6}>
<Text>Concurrent Requests:</Text>
<InputNumber
min={1}
max={50}
value={config.concurrentRequests}
onChange={(value) => setConfig((prev) => ({ ...prev, concurrentRequests: value || 10 }))}
style={{ width: '100%', marginTop: 4 }}
disabled={isRunning}
/>
</Col>
<Col span={6}>
<Text>Total Requests:</Text>
<InputNumber
min={10}
max={1000}
value={config.totalRequests}
onChange={(value) => setConfig((prev) => ({ ...prev, totalRequests: value || 100 }))}
style={{ width: '100%', marginTop: 4 }}
disabled={isRunning}
/>
</Col>
<Col span={6}>
<Text>Request Delay (ms):</Text>
<InputNumber
min={0}
max={5000}
value={config.requestDelay}
onChange={(value) => setConfig((prev) => ({ ...prev, requestDelay: value || 0 }))}
style={{ width: '100%', marginTop: 4 }}
disabled={isRunning}
/>
</Col>
<Col span={6}>
<Text>Error Rate (%):</Text>
<InputNumber
min={0}
max={100}
value={config.errorRate}
onChange={(value) => setConfig((prev) => ({ ...prev, errorRate: value || 0 }))}
style={{ width: '100%', marginTop: 4 }}
disabled={isRunning}
/>
</Col>
</Row>
</Card>
{/* Control Panel */}
<Row gutter={16}>
<Col span={6}>
<Button
type="primary"
icon={<Cpu size={16} />}
onClick={runConcurrentRequestsTest}
loading={isRunning && currentTest === 'Concurrent Requests Test'}
disabled={isRunning}
block>
Concurrent Test
</Button>
</Col>
<Col span={6}>
<Button
type="primary"
icon={<Activity size={16} />}
onClick={runMemoryLeakTest}
loading={isRunning && currentTest === 'Memory Leak Test'}
disabled={isRunning}
block>
Memory Test
</Button>
</Col>
<Col span={6}>
<Button
type="primary"
icon={<AlertTriangle size={16} />}
onClick={runErrorHandlingTest}
loading={isRunning && currentTest === 'Error Handling Stress Test'}
disabled={isRunning}
block>
Error Test
</Button>
</Col>
<Col span={6}>
<Button
type="primary"
icon={<Zap size={16} />}
onClick={runMixedOperationsTest}
loading={isRunning && currentTest === 'Mixed Operations Stress Test'}
disabled={isRunning}
block>
Mixed Test
</Button>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Space>
<Button danger icon={<StopCircle size={16} />} onClick={stopTest} disabled={!isRunning}>
Stop Test
</Button>
<Button icon={<RefreshCw size={16} />} onClick={resetTests} disabled={isRunning}>
Reset
</Button>
</Space>
</Col>
<Col span={12}>
<Text type="secondary" style={{ float: 'right' }}>
{isRunning && currentTest && `Running: ${currentTest}`}
</Text>
</Col>
</Row>
{/* Progress and Real-time Stats */}
{isRunning && (
<Card title="Test Progress" size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<Progress percent={Math.round(progress)} status={isRunning ? 'active' : 'success'} showInfo />
<Row gutter={16}>
<Col span={6}>
<Statistic
title="Requests/Second"
value={realtimeStats.rps}
precision={2}
prefix={<TrendingUp size={16} />}
/>
</Col>
<Col span={6}>
<Statistic
title="Avg Response Time"
value={realtimeStats.avgResponseTime}
suffix="ms"
prefix={<Timer size={16} />}
/>
</Col>
<Col span={6}>
<Statistic
title="Error Rate"
value={realtimeStats.errorRate}
suffix="%"
prefix={<AlertTriangle size={16} />}
valueStyle={{ color: realtimeStats.errorRate > 10 ? '#cf1322' : '#3f8600' }}
/>
</Col>
<Col span={6}>
<Statistic
title="Memory Usage"
value={realtimeStats.memoryUsage}
suffix="MB"
prefix={<Gauge size={16} />}
/>
</Col>
</Row>
</Space>
</Card>
)}
{/* Test Summary */}
{summary && (
<Card title="Test Summary" size="small">
<Row gutter={16}>
<Col span={8}>
<Card size="small" title="Request Statistics">
<Space direction="vertical">
<Text>
Total Requests: <strong>{summary.totalRequests}</strong>
</Text>
<Text>
Completed: <strong>{summary.completedRequests}</strong>
</Text>
<Text>
Successful: <strong style={{ color: '#3f8600' }}>{summary.successfulRequests}</strong>
</Text>
<Text>
Failed: <strong style={{ color: '#cf1322' }}>{summary.failedRequests}</strong>
</Text>
<Text>
Timeouts: <strong style={{ color: '#d48806' }}>{summary.timeoutRequests}</strong>
</Text>
</Space>
</Card>
</Col>
<Col span={8}>
<Card size="small" title="Performance Metrics">
<Space direction="vertical">
<Text>
Test Duration: <strong>{summary.testDuration}s</strong>
</Text>
<Text>
Requests/Second: <strong>{summary.requestsPerSecond}</strong>
</Text>
<Text>
Avg Response Time: <strong>{summary.averageResponseTime}ms</strong>
</Text>
<Text>
Min Response Time: <strong>{summary.minResponseTime}ms</strong>
</Text>
<Text>
Max Response Time: <strong>{summary.maxResponseTime}ms</strong>
</Text>
</Space>
</Card>
</Col>
<Col span={8}>
<Card size="small" title="Memory Usage">
<Space direction="vertical">
<Text>
Initial: <strong>{summary.memoryUsage.initial}MB</strong>
</Text>
<Text>
Peak: <strong>{summary.memoryUsage.peak}MB</strong>
</Text>
<Text>
Final: <strong>{summary.memoryUsage.final}MB</strong>
</Text>
<Text>
Memory Change:
<strong
style={{
color: summary.memoryUsage.leaked > 5 ? '#cf1322' : '#3f8600'
}}>
{summary.memoryUsage.leaked > 0 ? '+' : ''}
{summary.memoryUsage.leaked}MB
</strong>
</Text>
<Text>
Error Rate: <strong>{summary.errorRate}%</strong>
</Text>
</Space>
</Card>
</Col>
</Row>
</Card>
)}
{/* Results Table */}
{results.length > 0 && (
<Card
title={`Test Results (${results.filter((r) => r.status !== 'pending').length}/${results.length} completed)`}
size="small">
<Table
dataSource={results.slice(-50)} // Show last 50 results to avoid performance issues
columns={resultColumns}
rowKey="id"
size="small"
pagination={{ pageSize: 20 }}
scroll={{ y: 400 }}
/>
</Card>
)}
{/* Memory Usage Alert */}
{summary && summary.memoryUsage.leaked > 10 && (
<Alert
message="Potential Memory Leak Detected"
description={`Memory usage increased by ${summary.memoryUsage.leaked}MB during the test. This might indicate a memory leak.`}
type="warning"
showIcon
closable
/>
)}
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
.ant-statistic-title {
font-size: 12px;
}
.ant-statistic-content {
font-size: 16px;
}
.ant-progress-text {
font-size: 12px;
}
.ant-table-small .ant-table-thead > tr > th {
padding: 8px;
font-size: 12px;
}
.ant-table-small .ant-table-tbody > tr > td {
padding: 6px;
font-size: 12px;
}
`
export default DataApiStressTests

View File

@ -10292,6 +10292,7 @@ __metadata:
string-width: "npm:^7.2.0"
striptags: "npm:^3.2.0"
styled-components: "npm:^6.1.11"
swr: "npm:^2.3.6"
tar: "npm:^7.4.3"
tesseract.js: "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
tiny-pinyin: "npm:^1.3.2"
@ -23910,6 +23911,18 @@ __metadata:
languageName: node
linkType: hard
"swr@npm:^2.3.6":
version: 2.3.6
resolution: "swr@npm:2.3.6"
dependencies:
dequal: "npm:^2.0.3"
use-sync-external-store: "npm:^1.4.0"
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/9534f350982e36a3ae0a13da8c0f7da7011fc979e77f306e60c4e5db0f9b84f17172c44f973441ba56bb684b69b0d9838ab40011a6b6b3e32d0cd7f3d5405f99
languageName: node
linkType: hard
"symbol-tree@npm:^3.2.4":
version: 3.2.4
resolution: "symbol-tree@npm:3.2.4"