dailyuse中数据同步的实现分析
对 dailyuse 本地优先、PowerSync 与 Store 持久化现状的分析报告
[!info] related notes
dailyuse中数据同步的实现分析
问题1
分析一下当前项目的本地优先做的怎么样, powersync 是不是被使用起来了,还有 store 有没有持久化基础数据,并被使用。 如果直接利用 powersync + store 实现本地优先 足够了吗,怎么保证 本地 和 服务端数据一致。 不需要修改代码,只需要分析并生成报告。使用中文
本地优先分析报告
1. PowerSync 使用现状
通过分析代码发现,项目已经集成了 PowerSync 以实现本地优先的数据库同步,但它的使用还处于基础搭建阶段,并未在业务层全面铺开。
- 平台集成已完成:
apps/web/src/platform/powersync.ts和apps/desktop/src/renderer/platform/powersync.ts中已经实现了 Web 端和桌面端的 PowerSync 集成。配置了PowerSyncDatabase,并通过WebPowerSyncConnector处理了fetchCredentials(获取 JWT token) 和uploadData(上传本地 CRUD 操作到后端/api/v1/powersync/crud)。 - Vue 插件支持已就绪:
packages/app-vue/src/plugins/powersync.ts提供了一个 Vue 插件,将 PowerSync 数据库实例注入到 Vue 组件树中。这允许在任何 Vue 组件或 composable 中使用@powersync/vue提供的useQuery,usePowerSync,useStatus钩子。 - 数据库 Schema 完整:
packages/database/src/powersync-schema.ts中定义了非常详细的客户端数据库 Schema (PowerSyncAppSchema),几乎涵盖了所有的领域实体(如 Account, Goal, Task, Schedule, Reminder, Editor, Repository 等)。 - 业务层采用率极低: 尽管基础设施已经就位,但在具体的业务模块(例如
packages/app-vue/src/modules/goal或task等)中,并没有发现使用useQuery去查询 PowerSync 数据库的痕迹。目前,像useGoal.ts这样的 composables 依然依赖于注入的 HTTP 客户端服务 (GoalClientService) 进行网络请求,然后将数据存入 Pinia store。
2. Store 数据持久化现状
Pinia store 的持久化已经配置,但目前仅用于非常有限的非核心基础数据。
- 持久化插件已安装: 项目使用了
pinia-plugin-persistedstate。 - 只持久化了极少部分状态: 检查各个模块的 Store(如
goalStore.ts,taskStore.ts,reminderStore.ts,notificationStore.ts)发现,persist: true或具体的pick配置非常保守:goalStore.ts: 只持久化了filterStatus和pagination。taskStore.ts: 只持久化了pagination。scheduleStore.ts: 只持久化了pagination。notificationStore.ts: 只持久化了pagination。governanceStore.ts: 只持久化了过滤条件和分页。authenticationStore.ts: 持久化了核心认证信息 (accessToken,refreshToken,currentIdentity)。userSettingStore.ts: 持久化了userSetting。
- 结论: Store 并没有被用来持久化核心业务数据(如具体的 goals, tasks 列表),仅仅是持久化了一些 UI 状态(如分页、过滤条件)和用户设置/认证令牌。
3. 直接利用 PowerSync + Store 实现本地优先足够了吗?
直接利用 PowerSync + Store 的组合可以作为实现“本地优先”架构的坚实基础,但还需要考虑如何正确地将两者结合,以及解决一些额外的挑战。这种组合是构建现代“本地优先”应用的经典且强大的模式:
- PowerSync 的职责(数据层 / Source of Truth):
- 作为本地的关系型数据库(SQLite),负责核心业务数据的高效存储、复杂关系查询(JOIN)、以及离线可用性。
- 天然的“响应式”(Reactive Queries):通过
useQuery,前端 UI 可以直接订阅 SQL 查询的结果。当后台同步或本地写操作导致数据变化时,UI 会自动重渲染,这天然就是一种乐观 UI (Optimistic UI)。 - 负责处理客户端与服务端的后台同步队列和网络重连机制。
- Store 的职责(视图状态层 / 瞬时状态):
- 负责管理不属于数据库的 UI 瞬时状态,如:当前激活的侧边栏、弹窗可见性、表单草稿状态、当前的过滤条件、选中的文件夹 ID 等。
- 通过
pinia-plugin-persistedstate持久化这些用户的偏好状态,以提升用户体验(如记住上次停留的页面或筛选器)。
架构重构方向(如何做到“足够”): 要真正发挥这套架构的威力,必须进行业务层的重构。现有的 useGoal.ts (等 Composables) 需要摒弃基于 HTTP 请求的 fetchGoals 逻辑和手动维护 store.goals 数组的做法。取而代之的是,在 Composable 中使用 useQuery('SELECT * FROM goals WHERE …') 订阅本地 PowerSync 数据库,直接驱动 Vue 视图。写操作(Create/Update/Delete)直接作用于本地 PowerSync 实例。
4. 如何保证本地和服务端数据的一致性
在使用 PowerSync 这类本地优先同步引擎时,数据一致性主要由其底层架构和后端服务的冲突解决策略来保证,前端只需要遵循其规范:
- 自动化的双向同步机制 (Sync Engine):
- 下行同步: PowerSync 服务端通过逻辑复制连接到主 PostgreSQL 数据库。客户端自动维护与服务端的连接,接收服务端的变更流并更新本地 SQLite。
- 上行同步: 所有的前端写操作首先记录在本地的 SQLite 中,然后放入同步队列,由 PowerSync SDK 自动批量上传到后端的
/api/v1/powersync/crud。
- 冲突解决策略(后端权威):
- PowerSync 采用的模式是:客户端操作按顺序上传,然后在服务端执行,服务端最终状态同步回客户端。
- 如果发生冲突(例如两个离线客户端同时修改了同一条数据),后端服务是单点事实来源。后端可以采用“最后写入胜出”或者其他业务合并逻辑来处理冲突。处理完毕后,正确的数据会被推送到所有客户端覆盖本地状态,达成状态收敛。
- 现存的隐患与保障建议(前端视角):
- 从当前
uploadData的实现来看,如果后端因为业务校验失败拒绝了某个操作抛出了异常,可能会导致整个同步队列卡住(Head-of-Line Blocking)。建议在后端的 CRUD 接口设计中,妥善处理这种不可恢复的错误,让它返回正常状态并单独通知前端,以确保队列能够顺畅清空。
- 从当前
问题2
如果前端数据获取都迁移到使用 powersync,是不是 store 中也不需要存储 实体数据,直接用 powersync 就行了。 还有 在 desktop 中,是不是powersync 是在 main 主进程的 中,和 web 端不一样
针对您的补充问题,我进行了进一步的源码分析,以下是解答:
解答2
1. 如果前端数据获取都迁移到使用 PowerSync,Store 中还需要存储实体数据吗?
答案是:不需要,Store 应当剥离核心实体数据,全面拥抱响应式查询。
这是“本地优先”架构范式转移的关键。具体原因如下:
-
单一事实来源 (Single Source of Truth): 如果同时在 PowerSync SQLite 数据库和 Pinia Store 中维护实体列表(如
goals: []),就会产生“状态同步”的难题。一旦引入“缓存的缓存”,代码复杂度会急剧上升且容易出现数据不一致。 -
天然的响应式查询 (Reactive Queries):
@powersync/vue提供的useQuery已经是响应式的。例如:// 这个 goals 自动就是一个 Vue Ref,当底层 SQLite 数据变化时会自动触发组件重渲染 const { data: goals } = useQuery('SELECT * FROM goals WHERE status = ?', [filterStatus.value]);无论是本地执行了 INSERT 操作,还是后台同步引擎拉取了云端的新数据,只要底层 SQL 查询结果发生改变,UI 就会自动更新。
-
Store 的新角色(退回本职工作): Store 不再负责持有和管理业务数据(如增删改查
goals数组的 actions 都可以删掉),而是专注于纯粹的视图状态 (UI State)。- 需要保留在 Store 中的: 分页参数、搜索关键词、当前选中的过滤标签(如
filterStatus)、弹窗是否打开、当前正在编辑的表单草稿等。
- 需要保留在 Store 中的: 分页参数、搜索关键词、当前选中的过滤标签(如
2. 在 Desktop 中,PowerSync 是运行在 Main 主进程中吗?和 Web 端有什么区别?
答案是:Desktop 采用了“双独立 PowerSync 实例”架构。主进程(Main)和渲染进程(Renderer)各自拥有一个独立的 PowerSync 数据库,以满足不同的系统级需求。
通过深入分析 apps/desktop 代码,我发现了如下设计:
A. 渲染进程 (UI 前端 - 和 Web 端极其相似)
- 运行位置:
apps/desktop/src/renderer/platform/powersync.ts - 引擎: 使用了与 Web 端完全相同的
@powersync/web(基于 wa-sqlite 和浏览器的 OPFS 存储)。注释明确写道:“the renderer IS a Chromium browser, so the same SDK as the web app works unchanged.” - 与 Web 的区别(网络代理): 渲染进程由于安全策略,没有直接的 API 访问权限和 JWT Access Token(Token 存放在主进程的安全存储
safeStorage中)。因此,当渲染进程的 PowerSync 需要拉取认证凭证或上传 CRUD 队列时,它不能像 Web 端那样直接发fetch请求,而是通过 IPC 桥接,调用主进程暴露的powersync:fetch-credentials和powersync:upload-crud接口来代发网络请求。
B. 主进程 (Node.js 后端服务)
- 运行位置:
apps/desktop/src/main/database/powersync.ts - 引擎: 使用了
@powersync/node(基于better-sqlite3),并将其持久化到本地文件系统的dailyuse-sync.sqlite中。 - 为什么主进程也需要一个? 因为桌面应用不仅仅是一个浏览器窗口。它需要在后台执行很多不依赖 UI 窗口的系统级任务,例如定时触发提醒 (Reminders)、系统托盘消息通知、或者全局快捷键响应等。如果只把数据放在渲染进程,一旦用户关闭了主窗口,后台服务就无法读取到最新的任务和提醒数据了。
总结桌面端架构:
- UI 展现层: 渲染进程用 OPFS 跑自己的 PowerSync,直接通过
useQuery驱动 Vue 视图。操作通过 IPC 发送给主进程同步到云端。 - 系统后台层: 主进程跑自己的 Node SQLite PowerSync,为了脱离 UI 窗口独立运行后台调度逻辑而保持数据同步。
这种分离设计非常巧妙,既保证了 UI 的响应式体验,又满足了桌面端常驻后台的系统级功能需求。
Mar 5 22:25
那 desktop 端 是 渲染进程 和 主进程用 powersync 同步, 主进程又和 api 后端用 powersync同步吗
关于桌面端 PowerSync 同步机制的解答
是的,桌面端的同步链路正是你所说的这样。主进程和渲染进程都各自与后端的 PowerSync 服务独立保持同步连接,但它们同步的数据来源和通道有所不同。
通过仔细查看代码,这个“双实例”架构的运转方式如下:
1. 主进程(Main Process)是怎么同步的?
主进程运行的是 @powersync/node,它拥有一个完整的、独立的数据库实体。
- 同步下行(数据拉取): 主进程的
PowerSyncDatabase会直接通过 WebSocket/HTTP 连接到后端的 PowerSync 服务(如localhost:8080),接收并应用实时数据流。 - 同步上行(数据提交): 主进程实现了自己的
NodePowerSyncConnector。当主进程发生数据变更时,它会拿着存在本地safeStorage里的 Access Token,直接发 HTTP 请求调用后端的/api/v1/powersync/crud接口来上传变更。
2. 渲染进程(Renderer Process / Web UI)是怎么同步的?
渲染进程运行的是 @powersync/web(也就是 wa-sqlite + 浏览器 OPFS)。
- 同步下行(数据拉取): 渲染进程的 PowerSync 同样会直接建立一个到后端 PowerSync 服务的独立连接来拉取数据流。下行数据同步是绕过主进程的,直接从云端同步到渲染进程的 SQLite 中。
- 同步上行(数据提交,区别在这里): 由于安全限制(比如没有 CORS 配置、没有存在内存里的 Access Token 等),渲染进程不能直接发网络请求。因此,在
DesktopRendererPowerSyncConnector中:- 获取 Token(
fetchCredentials):它会通过api.invoke('powersync:fetch-credentials')请求主进程帮忙要一个 PowerSync JWT Token。 - 上传操作(
uploadData):当渲染进程有写操作(比如用户点击了保存)时,它不会直接提交给后端 API,而是通过api.invoke('powersync:upload-crud', ops)把 CRUD 队列发给主进程。 - 主进程做代理: 主进程监听了这个 IPC 事件,它收到渲染进程发来的操作后,附带上正确的认证头,代为发送 POST 请求到后端的
/api/v1/powersync/crud。
- 获取 Token(
总结这套机制
- 渲染进程的 UI 和 主进程的后台服务各自维护了一个本地 SQLite。
- 它们各自独立地监听后端的 PowerSync 服务(下行)。
- 当 UI 产生修改时,UI 先更新自己的 SQLite,然后把修改通过 IPC 交给主进程,主进程再转发给云端 API。云端数据库更新后,PowerSync 服务会把更新同时推给主进程和渲染进程,从而实现最终一致性。
这种架构确保了 UI 层能够享受极速的响应式查询,而后台层能够完全脱离窗口进行独立的数据调度。
总结
web 端
现在应该是没有利用 posersync 和 store 功能(没有本地优先或者离线优先策略),直接所有数据都重新向 api 端发送请求,获取数据并渲染。