Cherry Studio Data API 类型系统全解析:从 Schema 定义到端到端类型安全的 IPC 数据层
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
本篇技术指南围绕 Cherry Studio 的 DataApi 类型系统展开,系统讲解src/shared/data/api/目录下的核心类型定义、路径解析、分页类型、错误处理与 Schema 组织约定,并结合仓库源码与真实 domain 示例(如topics、messages)说明如何新增一个领域 Schema。读完本文,你将掌握 DataApi 的端到端类型推导机制(客户端调用 → 路由 → handler 实现全链路类型约束)、offset/cursor 两种分页模式的使用边界,以及如何用DataApiErrorFactory构建结构化、可序列化、可重试判定的错误体系。
DataApi 类型系统定位
DataApi 是 Cherry Studio 在 Renderer 与 Main 进程之间提供类型安全 IPC 通信的数据层。它只服务于「业务数据」——即用户使用过程中累积、有独立数据库表、可无限增删改且丢失不可挽回的数据(对话主题 topic、消息 message、文件 file 等,数据量可能增长到 GB 级),而不是通用的 RPC 层。系统控制、外部服务集成、命令式操作、无数据库支撑的无状态查询等,应继续走传统 IPC handler(src/main/ipc.ts)或生命周期服务,具体边界见 API Design Guidelines —— DataApi Scope & Boundaries。
类型系统的全部基础设施位于 src/shared/data/api/,与分页、排序等跨领域约定文档(data-pagination-guide.md、data-ordering-guide.md)配套使用。
目录结构与文件职责
src/shared/data/api/ ├── types.ts # 核心请求/响应类型与 API 工具类型 ├── paths.ts # 路径模板字面量类型工具 ├── errors.ts # 错误处理:ErrorCode、DataApiError 类、工厂 └── schemas/ ├── apiSchemas.ts # Schema 组合(合并所有领域 schema) └── *.ts # 各领域独立 schema| 文件 | 职责 |
|---|---|
| types.ts | 核心类型(DataRequest、DataResponse、ApiClient)与 schema 工具类型(AssertValidSchemas、ApiImplementation、HandlersFor等) |
| paths.ts | 模板字面量类型:把/items/:id解析为/items/${string},并提供ConcreteApiPaths/TemplateApiPaths/ResponseForPath等路径推导工具 |
| errors.ts | ErrorCode枚举、DataApiError类、DataApiErrorFactory工厂、可重试错误配置 |
| schemas/apiSchemas.ts | 用交叉类型把所有领域 schema 组合成统一的ApiSchemas |
| schemas/*.ts | 各领域 API 定义与 DTO |
从源码结构看,types.ts还承担了「数据变更通知协议」类型的定义:GetMethodApiPaths、CollectionGetPaths、ScalarGetPaths与DataApiDataChangeEffect(见下文「数据变更通知的类型的类型约束」小节),它并非单纯的请求/响应类型文件。
Schema 文件组织:按「返回实体领域」划分
schema 文件按被操作或返回的实体的领域组织,而不是按 URL 前缀组织。路径中的父级资源(:topicId、:providerId)只起到限定作用域的作用,并不决定路由归属于哪个文件:
| 路由 | 返回实体 | 归属文件 |
|---|---|---|
/topics/:topicId/messages | Message | messages.ts |
/topics/:topicId/tree | Tree(Message 派生的视图) | messages.ts |
/topics/:id/active-node | ActiveNodeResponse(Topic 状态) | topics.ts |
当路由的 URL 父级与返回实体不一致时,以返回实体为准。例如 topics.ts 中ActiveNodeResponse定义在/topics/:id/active-node之下,但它的语义是 Topic 的状态(activeNodeId),因此归属topics.ts而非独立文件。
导入约定
DataApi 没有 barrel 文件(不提供聚合导出的 index),这要求开发者按来源精确导入:
基础设施类型直接从模块导入——核心类型、分页与查询参数在types;错误在errors;路径工具在paths:
import type { DataRequest, DataResponse, ApiClient, // 分页类型 OffsetPaginationParams, OffsetPaginationResponse, CursorPaginationParams, CursorPaginationResponse, PaginationResponse, // 查询参数类型 SortParams, SearchParams } from '@shared/data/api/types' // 分页类型守卫同样位于 types import { isOffsetPaginationResponse, isCursorPaginationResponse } from '@shared/data/api/types' import { ErrorCode, DataApiError, DataApiErrorFactory, isDataApiError, toDataApiError } from '@shared/data/api/errors'领域 DTO 直接从对应 schema 文件导入:
// Topic 领域 import type { Topic, CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topics' // Message 领域 import type { Message, CreateMessageDto } from '@shared/data/api/schemas/messages'这一约定在 schemas/apiSchemas.ts 的文件头注释中也有明确说明,且整个仓库的 domain schema 文件(如topics.ts、messages.ts)均遵循「实体 schema 与类型位于@shared/data/types/,API 层 schema 位于schemas/」的分层惯例。
分页类型:两种模式,端到端约束
DataApi 支持两种分页模式,查询参数可组合使用。本节是类型引用——关于模式选择(offset vs cursor)、cursor 排他性语义、实战示例与客户端派生,见 分页指南。
请求参数
| 类型 | 字段 | 适用场景 |
|---|---|---|
OffsetPaginationParams | page?、limit? | 传统翻页导航(page从 1 开始) |
CursorPaginationParams | cursor?、limit? | 无限滚动、实时 feed。cursor是排他性边界——cursor 所指条目本身不会被返回(见 分页指南 § Wire Contract) |
SortParams | sortBy?、sortOrder? | 排序(sortOrder为'asc'/'desc',按需组合) |
SearchParams | search? | 文本搜索(按需组合) |
在路由的query中用&组合,例如query?: OffsetPaginationParams & SortParams & SearchParams。
响应类型
| 类型 | 字段 | 说明 |
|---|---|---|
OffsetPaginationResponse<T> | items、total、page | 基于页码的结果 |
CursorPaginationResponse<T> | items、nextCursor? | 基于游标的结果;nextCursor缺失即无更多数据 |
PaginationResponse<T> | 两者的联合 | 两种模式均可接受时使用;用isOffsetPaginationResponse/isCursorPaginationResponse收窄 |
类型守卫与推断工具
types.ts提供两个运行时类型守卫与两个条件类型推断工具:
// 类型守卫:判断是 offset 还是 cursor 响应 export function isOffsetPaginationResponse<T>(response: PaginationResponse<T>): response is OffsetPaginationResponse<T> { return 'page' in response && 'total' in response } export function isCursorPaginationResponse<T>(response: PaginationResponse<T>): response is CursorPaginationResponse<T> { return !('page' in response) } // 条件类型:从响应类型反推分页模式与条目类型 export type InferPaginationMode<R> = R extends OffsetPaginationResponse<any> ? 'offset' : R extends CursorPaginationResponse<any> ? 'cursor' : never export type InferPaginationItem<R> = R extends OffsetPaginationResponse<infer T> ? T : R extends CursorPaginationResponse<infer T> ? T : never此外types.ts还定义了服务层的列表查询约定ListOptions(含sortBy白名单:'createdAt' | 'updatedAt' | 'name' | 'orderKey',search为对 name/description 的大小写不敏感LIKE %kw%匹配)。
模式是端点的固有属性,不可由调用方配置。一个端点要么是 offset 要么是 cursor,在 schema 中一次性声明;混用是编译期错误而非运行时挂起——usePaginatedQuery拒绝 cursor 路径、useInfiniteQuery拒绝 offset 路径(路径泛型通过OffsetPaginatedPath/CursorPaginatedPath约束,二者都由InferPaginationMode派生,见 useDataApi.ts)。
模式选择的工程建议(来自分页指南):任何无界增长、或「最新优先读取同时持续写入」的数据(消息、会话、翻译/绘画历史)优先用cursor——offset 的page * limit窗口在两次请求之间插入数据时会静默跳过或重复行;UI 需要离散翻页控件或精确总数时(assistants、MCP servers)优先用offset。cursor 响应也可以额外携带total(如知识库、文件列表)。
客户端派生公式
// OffsetPaginationResponse const pageCount = Math.ceil(total / limit) const hasNext = page * limit < total const hasPrev = page > 1 // CursorPaginationResponse const hasNext = nextCursor !== undefinedRenderer 侧使用usePaginatedQuery(offset)与useInfiniteQuery+useInfiniteFlatItems(cursor)时这些推导已内建;仅当直接调用DataApiService时才需要手工派生。每个分页 hook 都会把路径泛型约束到匹配的分页形状,cursor/offset 路径混用是编译期错误。
新增一个领域 Schema:三步完整流程
第一步:创建 schema 文件
以下示例来自 api-types.md 的示意(实际工程中字段原子 schema 通常定义在@shared/data/types/下并由领域文件复用,如 topics.ts 从../../types/topic导入TopicSchema、TopicNameSchema):
import * as z from 'zod' import type { OffsetPaginationParams, OffsetPaginationResponse, SearchParams, SortParams } from '../apiTypes' // 实际路径为 '@shared/data/api/types' // 字段原子(field atoms)——在实体、DTO、查询之间共享 export const TopicNameSchema = z.string().trim().min(1).max(128) // 实体 schema(z.strictObject 拒绝未知字段) export const TopicSchema = z.strictObject({ id: z.uuidv4(), name: TopicNameSchema, createdAt: z.iso.datetime() }) export type Topic = z.infer<typeof TopicSchema> // DTO —— 从实体白名单选取(见 api-design-guidelines.md 的 Zod Schema & DTO 约定) export const CreateTopicSchema = TopicSchema.pick({ name: true }) export type CreateTopicDto = z.infer<typeof CreateTopicSchema> // API Schema —— 校验由 index.ts 中的 AssertValidSchemas 完成 export type TopicSchemas = { '/topics': { GET: { query?: OffsetPaginationParams & SortParams & SearchParams response: OffsetPaginationResponse<Topic> // response 必填 } POST: { body: CreateTopicDto response: Topic } } '/topics/:id': { GET: { params: { id: string } response: Topic } } }组合级校验:schema 会在schemas/apiSchemas.ts的组合点通过AssertValidSchemas做编译期验证:
- 仅允许合法 HTTP 方法(GET、POST、PUT、DELETE、PATCH);
- 每个端点必须声明
response字段; - 非法 schema 会在组合点产生 TypeScript 编译错误。
AssertValidSchemas的实现机制(types.ts)由两个工具类型构成:ValidateMethods把非HttpMethod的方法映射为never类型;ValidateResponses把缺失response的端点映射为带错误提示的{ error: 'Endpoint X.Y is missing response field' }。二者相交后,任何一处违规都会让类型推导失败。
设计准则:新建 schema 前请先阅读 API Design Guidelines,确认路径命名、HTTP 方法与错误处理约定。
第二步:在 apiSchemas.ts 注册
schema 组合文件的唯一职责是把所有领域 schema 组合为ApiSchemas:
import type { TopicSchemas } from './topics' // AssertValidSchemas 提供兜底校验——即使某个 schema 文件忘了单独校验也能被发现 export type ApiSchemas = AssertValidSchemas<TopicSchemas & MessageSchemas>当前仓库已组合的领域包括(共 25 个):TopicSchemas、MessageSchemas、TemporaryChatSchemas、ModelSchemas、ProviderSchemas、PaintingsSchemas、TranslateSchemas、FileSchemas、McpServerSchemas、KnowledgeSchemas、MiniAppSchemas、NoteSchemas、AssistantSchemas、TagSchemas、PromptSchemas、GroupSchemas、PinSchemas、AgentSchemas、SkillSchemas、AgentSessionMessageSchemas、AgentSessionSchemas、AgentWorkspaceSchemas、AgentChannelSchemas、JobSchemas、SearchSchemas、AiUsageRecordSchemas。
第三步:在 handlers 目录实现处理器
在src/main/data/api/handlers/中实现对应端点。Handler 是薄层:提取参数、调用 service、转换响应,不允许包含业务逻辑(业务逻辑在src/main/data/services/层)。
类型安全特性
路径解析:模板字面量类型
paths.ts用模板字面量类型把具体路径映射回 schema 路径,使客户端能用「真实路径」调用并获得精确返回类型:
// 具体路径 '/topics/abc123' 映射到 schema 路径 '/topics/:id' api.get('/topics/abc123') // TypeScript 知道返回 Topic其核心是ResolvedPath递归类型:'/test/items/:id'→'/test/items/${string}','/topics/:id/messages'→'/topics/${string}/messages'。再由ResolvedPath对ApiSchemas的每个键做映射,得到所有合法具体路径的联合ConcreteApiPaths;TemplateApiPaths则是 schema 键本身(含:param占位符)的联合。ApiPath = ConcreteApiPaths | TemplateApiPaths是所有数据 hook(useQuery/useMutation/useInfiniteQuery/usePaginatedQuery)统一接受的路径类型:模板路径会触发params必填约束,具体路径则禁止传入params。
配套的类型提取工具ParamsForPath/QueryParamsForPath/BodyForPath/ResponseForPath通过SchemaKeyForPath(模板路径走快路径、具体路径走MatchApiPath反向匹配)定位 schema 键,再按方法提取对应字段类型——这正是ApiClient接口(types.ts)能对get/post/put/delete/patch分别推导 query、body、response 类型的底层机制。
穷尽式 Handler 检查
ApiImplementation类型要求所有 schema 端点都有 handler 实现——缺失任何端点都会导致编译错误:
// TypeScript 会在缺少任意端点时报错 const handlers: ApiImplementation = { '/topics': { GET: async () => { /* ... */ }, POST: async ({ body }) => { /* ... */ } } // 缺少 '/topics/:id' 会引发编译错误 }ApiHandler类型还根据 schema 声明自动决定params/query/body是必填还是可选(通过HasRequiredQuery/HasRequiredBody/HasRequiredParams三个辅助类型),handler 返回值可以是数据本身T(自动推断状态码)或{ data: T, status: SuccessStatusCode }(自定义状态码)。SuccessStatus常量定义了 200 / 201 / 202 / 204,配套isCustomStatusResult类型守卫判断 handler 是否返回了自定义状态码格式。
另外HandlersFor<Schemas>提供按模块(子 schema)划分的 handler 映射:给定 schema 子集(如TopicSchemas),产出必须穷尽实现该 schema 全部路径+方法的 handler 记录,同时把路径收窄到本模块自己的 schema,防止拼写错误与跨模块泄漏,并在该作用域内保持穷尽性保证。
类型安全客户端
ApiClient提供完全类型化的方法:
const topic = await api.get('/topics/123') // 返回 Topic const topics = await api.get('/topics', { query: { page: 1, limit: 20, search: 'hello' } }) // 返回 OffsetPaginationResponse<Topic> await api.post('/topics', { body: { name: 'New' } }) // body 被推导为 CreateTopicDto真实示例:topics 领域 schema 的部分端点
topics.ts 展示了真实的 schema 结构,例如:
GET /topics:cursor 分页 + 可选名称搜索(limit默认 50,最大 200),返回CursorPaginationResponse<Topic>;列表是服务端组合视图——置顶主题在前(关联pin表按pin.orderKey排序),未置顶的按topic.orderKey ASC, id ASC排序,cursor 编码了「分区 + 最后边界」以无缝跨分区翻页;DELETE /topics?ids=...:批量删除,全有或全无(任一 ID 无效则整体失败);/topics/latest:声明在/topics/:id之前,由服务端路由精确匹配,避免latest被误当成 topic id;/topics/:id/move、/topics/:id/active-node、/topics/:id/duplicate等动作端点,以及通过& OrderEndpoints<'/topics'>注入的重排序端点。
错误处理:类型安全 + 自动重试判定
错误系统提供类型安全的错误处理与自动重试能力,核心实现位于 errors.ts。
用法一览
import { DataApiError, DataApiErrorFactory, ErrorCode, isDataApiError, toDataApiError } from '@shared/data/api/errors' // 推荐用工厂创建错误 throw DataApiErrorFactory.notFound('Topic', id) throw DataApiErrorFactory.validation({ name: ['Name is required'] }) throw DataApiErrorFactory.timeout('fetch topics', 3000) throw DataApiErrorFactory.database(originalError, 'insert topic') // 或用类直接创建 throw new DataApiError( ErrorCode.NOT_FOUND, 'Topic not found', 404, { resource: 'Topic', id: 'abc123' } ) // 判断是否可重试(供自动重试逻辑使用) if (error instanceof DataApiError && error.isRetryable) { await retry(operation) } // 判断错误类型 if (error instanceof DataApiError) { if (error.isClientError) { // 4xx —— 请求本身的问题 } else if (error.isServerError) { // 5xx —— 服务端问题 } } // 把任意错误转换为 DataApiError const apiError = toDataApiError(unknownError, 'context') // 序列化供 IPC 传输(Main → Renderer) const serialized = apiError.toJSON() // 从 IPC 响应反序列化(Renderer) const reconstructed = DataApiError.fromJSON(response.error)ErrorCode 枚举与状态码映射
ErrorCode枚举共 16 个错误码,通过ERROR_STATUS_MAP映射到 HTTP 状态码,通过ERROR_MESSAGES提供默认消息:
| 类别 | 错误码 | HTTP 状态 | 说明 |
|---|---|---|---|
| 客户端错误 | BAD_REQUEST | 400 | 请求格式或参数非法 |
INVALID_OPERATION | 400 | 当前状态下操作非法(非校验错误) | |
UNAUTHORIZED | 401 | 未认证或凭证无效 | |
PERMISSION_DENIED | 403 | 已认证但权限不足 | |
NOT_FOUND | 404 | 资源不存在 | |
METHOD_NOT_ALLOWED | 405 | 端点不支持该 HTTP 方法 | |
CONFLICT | 409 | 资源冲突(重名、唯一约束违反) | |
VALIDATION_ERROR | 422 | 请求体未通过校验 | |
RATE_LIMIT_EXCEEDED | 429 | 请求过于频繁 | |
| 服务端错误 | INTERNAL_SERVER_ERROR | 500 | 未预期错误 |
DATABASE_ERROR | 500 | 数据库操作失败 | |
SERVICE_UNAVAILABLE | 503 | 服务暂时不可用 | |
TIMEOUT | 504 | 请求超时 | |
| 应用专用 | RESOURCE_LOCKED | 423 | 资源被其他操作临时锁定(可重试) |
CONCURRENT_MODIFICATION | 409 | 乐观锁冲突(多窗口编辑同一主题等) | |
DATA_INCONSISTENT | 409 | 数据完整性违反(不可重试,需排查修复) | |
MIGRATION_ERROR | 500 | 数据迁移失败 |
每个错误码还配有结构化 details 类型(ErrorDetailsMap映射):ValidationErrorDetails(字段级错误)、NotFoundErrorDetails、DatabaseErrorDetails、TimeoutErrorDetails、ResourceLockedErrorDetails、ConcurrentModificationErrorDetails等,DetailsForCode<T>会根据错误码推导出对应的 details 类型。
DataApiError 类
DataApiError<T extends ErrorCode>是带类型的错误类,提供:
isRetryable:基于RETRYABLE_ERROR_CODES配置判定;isClientError/isServerError:按状态码区间(4xx / 5xx)判定;toJSON():序列化为SerializedDataApiError供 IPC 传输(不含堆栈,主进程日志为准);- 静态方法
fromJSON()(从 IPC 响应重建)与fromError()(把普通 Error 包装,默认INTERNAL_SERVER_ERROR)。
DataApiErrorFactory
工厂类提供语义化的创建方法(比直接用类更推荐,类型更精确):create、validation、notFound、database、internal、permissionDenied、timeout、invalidOperation、conflict、dataInconsistent、resourceLocked、concurrentModification。例如notFound(resource, id)会生成"Topic with id 'abc123' not found"这样的消息并附带{ resource, id }详情。
可重试错误码
以下错误码被RETRYABLE_ERROR_CODES集合自动判定为可重试(临时性失败,重试可能成功):
SERVICE_UNAVAILABLE(503)TIMEOUT(504)RATE_LIMIT_EXCEEDED(429)DATABASE_ERROR(500)INTERNAL_SERVER_ERROR(500)RESOURCE_LOCKED(423)
对应的isRetryableErrorCode(code)函数可直接查询任意错误码是否可重试。
toDataApiError 的转换策略
toDataApiError(error, context)把任意未知错误归一化为DataApiError:
- 已是
DataApiError→ 原样返回; - 是序列化错误(
isSerializedDataApiError)→ 通过fromJSON重建; - 是 ZodError(通过
.name === 'ZodError'鸭子类型判断,避免引入 zod 依赖)→ 把issues转换为字段级fieldErrors,生成 422VALIDATION_ERROR; - 是普通
Error→ 包装为INTERNAL_SERVER_ERROR; - 其他未知值 → 生成带上下文信息的
INTERNAL_SERVER_ERROR。
序列化与反序列化
SerializedDataApiError结构(code/message/status/details/requestContext)承载在DataResponse.error字段中跨 IPC 传输;DataApiError.toJSON()负责序列化,DataApiError.fromJSON()负责重建,isSerializedDataApiError负责运行时判定。
数据变更通知的类型约束
从源码看,types.ts还承载了数据变更通知协议的类型化约束(GetMethodApiPaths、CollectionGetPaths、ScalarGetPaths、DataApiDataChangeEffect)。要点包括:
- 只有声明了
GET的模板路径才是合法的通知目标(GetMethodApiPaths)——纯POST路径(如/messages/:id/siblings)没有可收敛的读状态; - 集合路径(
CollectionGetPaths)由 GET 响应形状判定:裸数组或分页响应是集合,其余是标量(ScalarGetPaths);集合与标量的联合成员资格由快照类型测试(__tests__/dataChange.types.test.ts)钉住,schema 变更导致分类翻转时会呈现为可评审的 diff; DataApiDataChangeEffect用可判别联合让「非法状态不可表达」:标量端点无kind;集合端点有projection(行内容变化)、membership(按dimension维度的成员变化)、order(按dimension排序的位置变化)三种 kind。写入提交后主进程广播受影响的读模型,渲染端各自订阅并决定收敛动作,SQLite 始终是唯一事实来源——effect 不携带实体行、字段 diff、CRUD 动词或命令。
架构概览
Renderer Main ──────────────────────────────────────────────────── DataApiService ──IPC──► IpcAdapter ──► ApiServer │ │ │ ▼ ApiClient MiddlewareEngine (typed) │ ▼ Handlers (typed)- Renderer:通过类型安全的
ApiClient接口使用DataApiService; - IPC:请求经
IpcAdapter序列化(同时负责validateSender拒绝不可信发送方); - Main:
ApiServer按路径与方法路由请求,经MiddlewareEngine中间件管线处理; - 类型安全:从客户端调用到 handler 实现的端到端类型一致。
完整的分层职责(Handler → Service → SQLite + Drizzle ORM)与「Repository 模式强烈不推荐」的约定见 DataApi 系统总览;客户端用法见 DataApi in Renderer;服务端实现见 DataApi in Main;RESTful 约定见 API Design Guidelines;分页模式细节见 分页指南。
实践要点速查
- 新增领域:建 schema 文件(字段原子共享)→ 在 apiSchemas.ts 加入交叉组合 → 在 src/main/data/api/handlers/ 实现 handler,三步走,全程有编译期兜底(
AssertValidSchemas+ApiImplementation)。 - 导入规范:基础设施类型按模块导入(
types/errors/paths),领域 DTO 从 schema 文件直导,不依赖 barrel。 - 分页:端点模式固定不可配置,cursor 是排他性边界;列表 cursor 采用「警告并回退首页」策略,搜索 cursor 采用「422 抛出」策略(见 分页指南 § Full-Text Search Pagination),二者策略不可混用。
- 错误:优先用
DataApiErrorFactory;4xx 不自动重试,6 个可重试错误码由RETRYABLE_ERROR_CODES统一配置;跨 IPC 传输用toJSON()/fromJSON()。
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考