dailyuse中数据同步的实现分析

对 dailyuse 本地优先、PowerSync 与 Store 持久化现状的分析报告

#tech / dev / desktop #resource / electron #type / howto #status / evergreen

[!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 提供的 useQueryusePowerSyncuseStatus 钩子。
  • 数据库 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.tstaskStore.tsreminderStore.tsnotificationStore.ts)发现,persist: true 或具体的 pick 配置非常保守:
    • goalStore.ts: 只持久化了 filterStatus 和 pagination
    • taskStore.ts: 只持久化了 pagination
    • scheduleStore.ts: 只持久化了 pagination
    • notificationStore.ts: 只持久化了 pagination
    • governanceStore.ts: 只持久化了过滤条件和分页。
    • authenticationStore.ts: 持久化了核心认证信息 (accessTokenrefreshTokencurrentIdentity)。
    • 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 这类本地优先同步引擎时,数据一致性主要由其底层架构和后端服务的冲突解决策略来保证,前端只需要遵循其规范:

  1. 自动化的双向同步机制 (Sync Engine):
    • 下行同步: PowerSync 服务端通过逻辑复制连接到主 PostgreSQL 数据库。客户端自动维护与服务端的连接,接收服务端的变更流并更新本地 SQLite。
    • 上行同步: 所有的前端写操作首先记录在本地的 SQLite 中,然后放入同步队列,由 PowerSync SDK 自动批量上传到后端的 /api/v1/powersync/crud
  2. 冲突解决策略(后端权威):
    • PowerSync 采用的模式是:客户端操作按顺序上传,然后在服务端执行,服务端最终状态同步回客户端
    • 如果发生冲突(例如两个离线客户端同时修改了同一条数据),后端服务是单点事实来源。后端可以采用“最后写入胜出”或者其他业务合并逻辑来处理冲突。处理完毕后,正确的数据会被推送到所有客户端覆盖本地状态,达成状态收敛。
  3. 现存的隐患与保障建议(前端视角):
    • 从当前 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)、弹窗是否打开、当前正在编辑的表单草稿等。

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

总结这套机制

  • 渲染进程的 UI 和 主进程的后台服务各自维护了一个本地 SQLite。
  • 它们各自独立地监听后端的 PowerSync 服务(下行)。
  • 当 UI 产生修改时,UI 先更新自己的 SQLite,然后把修改通过 IPC 交给主进程,主进程再转发给云端 API。云端数据库更新后,PowerSync 服务会把更新同时推给主进程和渲染进程,从而实现最终一致性。

这种架构确保了 UI 层能够享受极速的响应式查询,而后台层能够完全脱离窗口进行独立的数据调度。

总结

web 端

现在应该是没有利用 posersync 和 store 功能(没有本地优先或者离线优先策略),直接所有数据都重新向 api 端发送请求,获取数据并渲染。

desktop 端

创建于 2026/3/6 更新于 2026/5/27