
Cherry Studio DataApi 系统详解从 Renderer 到 SQLite 的类型安全 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-studioDataApi 是 Cherry Studio 主进程与渲染进程之间用于业务数据读写的类型安全 IPC 通信体系它把 React 组件中的查询与变更直接映射为主进程内 Handler → Service → SQLiteDrizzle ORM的标准分层调用并提供 RESTful 语义、自动重试、按需取数与跨窗口数据变更通知。读完本文你将掌握 DataApi 的架构分层、使用边界、Renderer 端四大 Hook 的完整用法、Main 端 Handler/Service 的编写规范以及从 Schema 定义到错误处理的端到端实践。DataApi 的定位只服务业务数据DataApi 面向的数据必须同时满足以下特征见>// schemas/topic.ts export type TopicSchemas { /topics: { GET: { response: PaginatedResponseTopic } POST: { body: CreateTopicDto; response: Topic } } /topics/:id: { GET: { params: { id: string }; response: Topic } } }Schema 组合时经AssertValidSchemas做编译期校验定义于 types.ts只允许合法 HTTP 方法且每个端点必须声明response字段缺失即编译报错// schemas/apiSchemas.ts export type ApiSchemas AssertValidSchemasTopicSchemas MessageSchemas类型系统由此获得三项能力路径解析api.get(/topics/abc123)能自动映射到 Schema 路径/topics/:idTypeScript 知道返回Topic。Handler 穷尽性检查ApiImplementation类型保证 Schema 中每个端点都有对应 Handler缺失即编译错误。类型安全客户端ApiClient提供完整推导的方法——api.post(/topics, { body: { name: New } })的 body 被强类型为CreateTopicDto。Schema 文件组织规则Schema 文件按被操作/返回实体的领域组织而非 URL 前缀。父资源只起到作用域作用不决定归属文件路由返回实体归属文件/topics/:topicId/messagesMessagemessages.ts/topics/:topicId/treeTreeMessage 派生视图messages.ts/topics/:id/active-nodeActiveNodeResponseTopic 状态topics.ts当路由的 URL 父级与返回实体不一致时以实体为准。分页类型请求参数OffsetPaginationParamspage?、limit?、CursorPaginationParamscursor?、limit?cursor 是排他边界游标项本身不返回、SortParams、SearchParams。响应类型OffsetPaginationResponseTitems、total、page、CursorPaginationResponseTitems、nextCursor?、两者联合的PaginationResponseT用isOffsetPaginationResponse/isCursorPaginationResponse收窄。各分页 Hook 会把自己的路径泛型约束到匹配的分页形态——把 cursor 路径传给usePaginatedQuery或把 offset 路径传给useInfiniteQuery是编译期错误而不是静默的运行期挂起。完整的 offset vs cursor 选型与游标线格式key:id见 分页指南。Renderer 端四大 Hook 的完整用法React 端入口是data/hooks/useDataApi实现见 useDataApi.ts基于 SWR 提供缓存与自动重新校验。useQueryGET 请求import { useQuery } from data/hooks/useDataApi // 基础用法 const { data, isLoading, error } useQuery(/topics) // 带查询参数 const { data: messages } useQuery(/messages, { query: { topicId: abc123, page: 1, limit: 20 } }) // 路径参数从路径自动推断 const { data: topic } useQuery(/topics/abc123) // 条件取数 const { data } useQuery(/topics, { enabled: !!topicId }) // 手动刷新 const { data, mutate, refetch } useQuery(/topics) refetch() // 或 await mutate()useMutationPOST/PUT/PATCH/DELETEimport { useMutation } from data/hooks/useDataApi // 创建POST——返回 201 const { trigger: createTopic, isLoading } useMutation(POST, /topics) const newTopic await createTopic({ body: { name: New Topic } }) // 整体替换PUT const { trigger: replaceTopic } useMutation(PUT, /topics/abc123) await replaceTopic({ body: { name: Updated Name, description: ... } }) // 局部更新PATCH const { trigger: updateTopic } useMutation(PATCH, /topics/abc123) await updateTopic({ body: { name: New Name } }) // 删除Handler 返回 undefined 时自动推断 204 const { trigger: deleteTopic } useMutation(DELETE, /topics/abc123) await deleteTopic() // 成功后自动刷新其他查询 const { trigger } useMutation(POST, /topics, { refresh: [/topics], // 成功后刷新这些缓存键 onSuccess: (data) logger.info(Created:, data) })数据 Hook 返回的函数trigger、invalidate、refetch、nextPage、prevPage、reset等在重渲染间保持稳定身份与 SWR 自身的mutate/trigger行为一致——可以直接放进useCallback/useEffect依赖数组无需用 ref 包裹或从依赖中省略。useMutation的trigger通过 ref 读取 options因此内联的options对象不会导致其身份频繁变动。useInfiniteQuery游标无限滚动适用于加载更多式无限滚动 UI。Hook 暴露原始pages数组消费方用useInfiniteFlatItems推导扁平列表并显式选择与端点分页形态及容器布局匹配的顺序——永远不要假设页加载顺序等于条目显示顺序import { useInfiniteQuery, useInfiniteFlatItems } from data/hooks/useDataApi // 简单信息流第 0 页最新、页内倒序——页序与显示序一致 const { pages, hasNext, loadNext, isLoading } useInfiniteQuery(/feed) const items useInfiniteFlatItems(pages) // 分支遍历 column-reverse 聊天容器第 0 页最新、页内升序。 // reverseItems: true 翻转每页使扁平输出为最新在前直接喂给倒置布局 const { pages, hasNext, loadNext } useInfiniteQuery(/topics/:topicId/messages, { params: { topicId } }) const messages useInfiniteFlatItems(pages, { reverseItems: true }) const activeNodeId pages[0]?.activeNodeId ?? null // 顶层元数据无需强转 // 非 column-reverse 容器中按时间升序渲染翻转页序 const items useInfiniteFlatItems(pages, { reversePages: true })pages在 SWR 底层数据未变时跨重渲染引用稳定因此useInfiniteFlatItems(pages)可以跳过重复计算。usePaginatedQueryoffset 翻页适用于带上一页/下一页控件的逐页导航import { usePaginatedQuery } from data/hooks/useDataApi const { items, page, total, hasNext, hasPrev, nextPage, prevPage } usePaginatedQuery(/topics, { limit: 10 })分页 Hook 选型使用场景Hook无限滚动、聊天、信息流useInfiniteQuery翻页导航、表格usePaginatedQuery手动控制useQuery动态路径与缓存失效Hook 既接受具体路径id 已内联如/providers/abc123也接受模板路径:placeholders 独立params选项。两种形式产生逐字节一致的 SWR 缓存键因此一种形式读取、另一种形式刷新依然保持一致// 具体路径——id 在调用方稳定props、Hook 参数 const { data } useQuery(/providers/${providerId}) // 模板路径——单个 Hook 实例在生命周期内处理不同 id //侧边栏列表、命令面板、URL 处理器、循环中的行级操作 const { data } useQuery(/providers/:providerId, { params: { providerId } }) const { trigger } useMutation(DELETE, /providers/:providerId/api-keys/:keyId, { refresh: ({ args }) [ /providers/${args.params.providerId}, /providers/${args.params.providerId}/api-keys ] }) await trigger({ params: { providerId, keyId } })选择依据ProviderSettings providerId{id}这种 props 稳定的 id 用具体路径侧边栏删除任意 Provider、命令面板、URL 处理器等处理任意 id 的场景用模板路径.map()内的行级操作每个 Hook 绑定一行用具体路径。模板 useMutation 并发触发警告useSWRMutation按路径管理isMutating/error状态所以单个模板路径实例的所有 params 共享 loading 状态。在同一 Hook 实例上并发触发不同 id 会互相覆盖状态。正确做法是每行挂载一个绑定具体路径的 Hook。开发模式下params 变化的并发触发会打印警告。refresh 的三种形式与避坑refresh声明 mutation 成功后要失效的 SWR 缓存键静态路径精确匹配refresh: [/topics]仅失效该键用于确切知道受影响路径且与输入输出无关时。/*后缀前缀匹配refresh: ({ args }) [/providers,/providers/${args.params.providerId}/*]失效该资源下所有子路径。前缀中自动保留的尾部斜杠可防止/providers-archived这类兄弟路径的误匹配。/*的独特价值在于失效mutation 并不知道其 id 的子路径实例。函数形式动态键refresh: ({ args }) [...]或refresh: ({ result }) [...]键依赖触发参数或服务端响应时使用。必须避免的误用不要用/*做全缓存重置——[/*]或/m*这类短前缀在开发模式会抛错必须写完整路径段静态数组够用时不要用函数形式额外运行开销且掩盖意图不要对高基数列表用/*如/messages/*会重新校验所有打开窗口中的每个消息级查询应改用携带具体父 id 的函数形式/topics/${id}/messages同一模块内不要混用模板路径与辅助函数表达同一资源refresh只针对 DataApi 键——非 SQLite 数据Cache、Preference有自己的失效机制。Main 端实现Handler 与 Service编写 HandlerHandler 位于src/main/data/api/handlers/职责只有三条从请求提取参数、委托业务 Service、为 IPC 转换响应。每个模块的 Handler 记录必须标注HandlersForXxxSchemas类型这是该目录所有文件的标准形态不是可选约定它强制两条不变量路径被收窄到本模块 Schema 内拼写错误、跨模块泄漏均编译报错Schema 声明的每个path method都必须有 Handler新增端点而无对应 Handler 是编译错误。// handlers/topics.ts import { topicService } from data/services/TopicService import type { HandlersFor } from shared/data/api/types import type { TopicSchemas } from shared/data/api/schemas/topics export const topicHandlers: HandlersForTopicSchemas { /topics: { GET: ({ query }) { const { page 1, limit 20 } query ?? {} return topicService.list({ page, limit }) }, POST: ({ body }) { return topicService.create(body) } }, /topics/:id: { GET: ({ params }) topicService.getById(params.id), PUT: ({ params, body }) topicService.replace(params.id, body), PATCH: ({ params, body }) topicService.update(params.id, body), DELETE: ({ params }) { topicService.delete(params.id) } } }随后在handlers/apiHandlers.ts中合并注册export const allHandlers: ApiImplementation { ...topicHandlers, ...messageHandlers }编写 ServiceService 位于src/main/data/services/承担业务校验、事务协调、领域工作流与 Drizzle ORM 数据访问。作用域硬性限制DataApi Service 的领域工作流只能编排 SQLite 读写绝不允许出现 fs/网络/进程/外部服务副作用——即使旁边紧跟着合法的数据库写入、即使嵌套得再深也不行见 API 设计指南 —— 无非数据副作用硬规则。一个标准 Entity Service 的骨架注意getById先查后改、validateTopicData业务校验、错误统一走DataApiErrorFactory// services/TopicService.ts import { eq, desc, sql } from drizzle-orm import { application } from application import { topicTable } from data/db/schemas/topic import { DataApiErrorFactory } from shared/data/api/errors export class TopicService { private get db() { return application.get(DbService).getDb() } list(options: { page: number; limit: number }) { const { page, limit } options const offset (page - 1) * limit const items this.db.select().from(topicTable) .orderBy(desc(topicTable.updatedAt)) .limit(limit).offset(offset).all() const countResult this.db.select({ count: sqlnumbercount(*) }) .from(topicTable).all() return { items, total: countResult[0].count, page, limit } } getById(id: string) { const topic this.db.select().from(topicTable) .where(eq(topicTable.id, id)).limit(1).get() if (!topic) { throw DataApiErrorFactory.notFound(Topic, id) } return topic } create(data: CreateTopicDto) { this.validateTopicData(data) const topic this.db.insert(topicTable).values(data).returning().get() return topic } update(id: string, data: PartialUpdateTopicDto) { this.getById(id) // 不存在则抛错 const topic this.db.update(topicTable) .set(data).where(eq(topicTable.id, id)).returning().get() return topic } delete(id: string) { this.getById(id) // 不存在则抛错 this.db.delete(topicTable).where(eq(topicTable.id, id)).run() } private validateTopicData(data: CreateTopicDto) { if (!data.name?.trim()) { throw DataApiErrorFactory.validation({ name: [Name is required] }) } } } export const topicService new TopicService()上述list()展示的是offset 分页形态真实TopicService是游标分页listByCursor。两种服务端分页模式与选择依据见 分页指南。游标keyset列表的编解码必须复用services/utils/keysetCursor.ts中的decodeListCursor/encodeCursorkey:id线格式与keysetOrdering(keyCol, idCol, { major, tie })禁止手写游标编解码、keyset WHERE 元组或 ORDER BY。事务与跨 Service 表访问带事务的 Service 方法写法如下主库使用同步 better-sqlite3事务回调与其中的 Drizzle 调用必须保持同步返回 Promise 非法createTopicWithMessage(data: CreateTopicWithMessageDto) { return application.get(DbService).withWriteTx((tx) { const [topic] tx.insert(topicTable).values(data.topic).returning().all() const [message] tx.insert(messageTable).values({ ...data.message, topicId: topic.id }).returning().all() return { topic, message } }) }每张表有且仅有一个属主 Service按访问类型拆分规则写insert/update/delete他人拥有的表禁止。必须调用属主的方法事务写入时把tx作为第一个参数传入属主的变更方法接受PickDbType, delete | insert | ...类型的最小操作集作为首参。形状缺失时在属主上加方法批量需求加批量方法如purgeForEntitiesTx。读他人拥有的表当内联是更简单的路径时允许。跨表 JOIN 一次往返合并属主表是合理的只有读取需要属主已封装的业务逻辑时才走属主的读 API。为什么写要严格属主 Service 是表不变量的唯一真相源唯一索引、orderKey语义、软删除、审计时间戳并负责发出变更日志。外部写会把这些知识分散到每个调用方同时让日志叙事失声。例如ProviderService.delete应调用pinService.purgeForEntitiesTx(tx, model, ids)而不是直接tx.delete(pinTable).where(...)。事务方法命名约定tx必须是第一个参数、方法名以Tx结尾、参数类型用PickDbType, ...只声明所需最小操作。可选的非 Tx 包装方法薄db.transaction(...)仅在调用方需要拥有事务时提供。循环依赖用dataServiceRegistry解决当两个 Service 互相调用A→B 且 B→A时顶层import { bService } from ./BService会形成值级导入环。不要用调用点的await import(./BService)掩盖——那会把调用方传染成 async、把边界从静态工具面前隐藏。正确做法是兄弟模块底部自注册registerDataService(BService, bService)调用方在调用时getDataService(BService)解析。Registry 只以import type导入 Service因此在导入图中是汇点不会形成值环。只有真正成环的 Service 才进 registry其余保持直接导入单例。单元测试驱动跨 Service 路径时必须先副作用导入兄弟模块import data/services/BService使其自注册否则getDataService会抛 not registered yet。契约见 dataServiceRegistry.ts。行 → 实体映射rowToEntity每个 Entity Service 提供rowToEntity用nullsToUndefined来自services/utils/rowMappers.ts完成 SQLiteNULL→ TypeScriptundefined的翻译。标准骨架function rowToMcpServer(row: typeof mcpServerTable.$inferSelect): McpServer { const clean nullsToUndefined(row) return { ...clean, type: clean.type as McpServer[type], // 收窄枚举 createdAt: timestampToISO(row.createdAt), updatedAt: timestampToISO(row.updatedAt) } }判断规则领域字段类型是T | null→ 直接用row.x绕过clean因为nullsToUndefined会把顶层null收窄为undefined破坏T | null契约领域字段类型是T?或T→ 用clean.x或...clean。当存在字段重命名、计算/合并字段、敏感数据脱敏如apiKeys剥离、判别式联合按变体剥离字段等复杂场景时rowToEntity应手写而非依赖 spread。rowToEntity内出现row.x ?? 之类的兜底默认值是禁止的反模式——它的存在恰恰证明该列应该是带 DB DEFAULT 或$defaultFn的 NOT NULL。完整约定见>import { useDataChange } from data/hooks/useDataApi // 保守的列表收敛任何信号 → 重新拉取 const { refetch } useQuery(/topics) useDataChange(/topics, () refetch()) // 多端点每次通知合并为一次回调 useDataChange([/topics, /topics/latest], () refreshAll()) // 按 ID 收敛用 entityIds 过滤缺省 不声明 视为相关 useDataChange(/topics/:id, (effects) { if (effects.some((e) !e.entityIds || e.entityIds.includes(myId))) mutate() }) // 非 React 代码Service 上同样的设施返回取消订阅函数 const unsubscribe dataApiService.onDataChanged(/topics, (effects) { ... })语义要点由 Phase A 契约冻结精确端点匹配——无前缀/通配订阅effects 由endpoint 可选kindprojection/membership/orderdimensionentityIds组成一次业务操作 一次回调——同一条通知的所有匹配条目合并到一次调用不做跨通知聚合端点之下全是消费者策略——dimension/entityIds 过滤、收敛选择、对自身写入回声的幂等处理都由消费者负责发起窗口也会收到自己的信号提示只收窄、不豁免——省略dimension/entityIds表示未声明 → 假定相关绝不表示无影响尽力投递——只投给存活且持续订阅的渲染进程每窗口 FIFO。订阅注册之前含主进程引导期的变更不会被补发恢复手段是端点下一次变更、重新挂载或任何一次全新查询。错误处理体系错误体系集中在 errors.tsErrorCode枚举映射 HTTP 状态码DataApiError类带可重试性检测DataApiErrorFactory提供一致的错误创建。关键 APIimport { DataApiError, DataApiErrorFactory, ErrorCode } from shared/data/api/errors // 推荐工厂方法 throw DataApiErrorFactory.notFound(Topic, id) throw DataApiErrorFactory.validation({ name: [Name is required] }) throw DataApiErrorFactory.database(error, insert topic) throw DataApiErrorFactory.invalidOperation(delete root message, cascadetrue required) throw DataApiErrorFactory.conflict(Topic name already exists) throw DataApiErrorFactory.timeout(fetch topics, 3000) // 可重试性判断自动重试逻辑使用 if (error instanceof DataApiError error.isRetryable) { await retry(operation) } // 客户端错误 vs 服务端错误 if (error.isClientError) { /* 4xx请求本身的问题 */ } else if (error.isServerError) { /* 5xx服务端问题 */ }自动视为可重试的错误码SERVICE_UNAVAILABLE503、TIMEOUT504、RATE_LIMIT_EXCEEDED429、DATABASE_ERROR500、INTERNAL_SERVER_ERROR500、RESOURCE_LOCKED423。IPC 序列化边界错误经 IPC 传输时使用SerializedDataApiError结构code、message、status、details?、requestContext?堆栈不随 IPC 传输排障依赖主进程日志toJSON()/fromJSON()负责序列化与重建。SQLite 约束翻译Service 写库时SQLite 约束违规UNIQUE、FOREIGN KEY、CHECK、NOT NULL以DrizzleQueryError抛出真实错误埋在.cause链中。用withSqliteErrors来自src/main/data/db/sqliteErrors.ts统一翻译为DataApiErrorimport { defaultHandlersFor, withSqliteErrors } from data/db/sqliteErrors const [row] withSqliteErrors( () this.db.insert(tagTable).values(dto).returning().all(), defaultHandlersFor(Tag, dto.name) )defaultHandlersFor覆盖常见 CRUD 场景UNIQUE → 409、FK → 404、CHECK/NOT NULL → 422需要时可按种类展开覆盖未识别的错误原样重抛。Handler 状态码行为ApiServer自动推断成功状态码POST 恒为 201DELETE 在 Handler 返回undefined时为 204、返回数据时为 200GET/PUT/PATCH 恒为 200。需要自定义时返回{ data, status }如return { data: task, status: SuccessStatus.ACCEPTED }返回 202且status的类型被约束为合法的SuccessStatusCode传 999 会编译报错。使用SuccessStatus常量OK/CREATED/ACCEPTED/NO_CONTENT避免魔法数字。完整状态码语义表200/201/202/204/400/401/403/404/409/422/423/429/500/503/504见 api-design-guidelines.md。自动重试、超时与按需取数机制Renderer 端 DataApiService 是纯通信设施项目注释明确它是 API Client / Gateway类似 axios/fetch零业务逻辑自动重试默认maxRetries: 2、retryDelay: 1000ms、backoffMultiplier: 2指数退避是否重试由DataApiError.isRetryable决定4xx 客户端错误直接跳过可用configureRetry()覆盖默认值。请求超时默认 3 秒通过Promise.race与DataApiErrorFactory.timeout(path, 3000)实现过期请求自动取消stale request 自动清理。按需取数不维护自动缓存层每次useQuery都是即时拉取SWR 仅作去重与缓存键管理显式refresh/mutate控制失效。开发环境下DataApiDevtools记录每次请求的 start/success/error/retry 时间线便于排查。新增端点的完整步骤在现有代码库中新增一个 DataApi 端点遵循五步详见 contenteditable="false">【免费下载链接】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),仅供参考