feat(任务): 即时同步 —— 订阅本地库变更, 跨端改动无需切页自动刷新
Docker Build and Push / build-image (push) Failing after 1m48s
Docker Build and Push / build-image (push) Failing after 1m48s
现象: 多端同时打开同一项目时, 一端改完, 另一端必须切到别的页面再切回来才更新。 根因: 不是同步没到, 而是 UI 从未订阅本地库变化 —— PowerSync 的下行推送 (PG 逻辑复制 → sync service → 客户端本地 SQLite) 本身正常, 但任务模块的读全是命令式 db().getAll() (api/task.js), 读完不再看; lib/powersync/react.jsx 虽然导出了 useQuery/useStatus, 全项目一次都没用过; TaskPage/ProjectView 只在 useEffect 里 load() 一次, 连 useRefreshOnVisible 都没接 (只有打卡与宏观页面在用)。「切面板能刷新」纯粹是因为组件重新挂载又 load 了一次。 实现 (docs/V0.2/离线编辑模块/即时同步方案.md, 方案 C): - 新增 lib/powersync/useDbSignal.js: 用 db.watch() 的表级过滤 (tables) + 节流 (throttleMs) + AbortSignal, 订阅本地库变更; 跳过订阅时的首次通知, 避免与 挂载时的 load() 重复。 (初版设计是手写 count/sum/max(updatedAt) 的「信号 SQL」, 实施时发现 SDK 原生 支持 tables 过滤, 更可靠, 已改为表过滤并在文档中记录变更。) - api/task.js 新增 TASK_TABLES / PROJECT_TABLES 常量 (表名须与客户端 schema 一致)。 - ProjectView: 订阅任务域 (Task/BoardColumn/TaskMember/TaskTag) → 重算; TaskPage: 订阅项目域 (Project/ProjectMember) → 重算。 - 不打断操作: BoardView/ListView 上报 onDragActiveChange, 拖拽期间收到变更只记 pending, 松手后补一次刷新, 避免卡片在手指下跳位。 - 回前台兜底: 两页都接 useRefreshOnVisible (WebView 后台可能节流订阅回调)。 验证 (真实 Windows 外壳 + CDP, 服务端直接改库模拟另一端): - 服务端改任务标题后, 页面在**无任何操作**的情况下自动更新 (三次复测均成功); - 改回原标题后同样自动还原; 测试数据已恢复。 已知瓶颈 (不在 UI 层, 另见文档 §7.0): - 实测端到端延迟约 13~14s, 未达 ≤1s 目标; - CDP Network 观察客户端: 无 WebSocket, 仅 /sync/stream 长连接; - NPM (baishe nginx-app-1, proxy_host/5.conf) 未关闭 proxy_buffering, 流式响应被 nginx 缓冲后按批下发, 与观测到的固定批次延迟吻合; - 修复属运维配置 (proxy_buffering off 等), 需单独评估后执行。
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// 本地库变更订阅 (任务模块即时同步, 见 docs/V0.2/离线编辑模块/即时同步方案.md)
|
||||
//
|
||||
// 作用: 当指定表在本地 SQLite 里发生变化时回调 —— 变化可能来自
|
||||
// 1) 本端写入 (乐观更新之后)
|
||||
// 2) PowerSync 下行推送 (其它端 / 服务端改动)
|
||||
// 两者最终都落到本地库, 所以订阅本地库即可覆盖跨端场景, 且离线时同样有效。
|
||||
//
|
||||
// 为什么不用手写"信号 SQL"求和: SDK 的 watch 已支持按表名过滤 (tables)
|
||||
// + 节流 (throttleMs), 由 SDK 判断是否真的变化, 比自己做 count/max 汇总可靠。
|
||||
//
|
||||
// 用法:
|
||||
// useDbSignal(['Task', 'BoardColumn'], () => load())
|
||||
// useDbSignal(TASK_TABLES, onChange, { throttleMs: 300, enabled: someFlag })
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getPowersyncDb } from './db.js'
|
||||
|
||||
// 无意义的占位查询: 只借 watch 的"变更通知"能力, 不关心结果
|
||||
const SIGNAL_SQL = 'SELECT 1 AS s'
|
||||
|
||||
export default function useDbSignal(tables, onChange, options = {}) {
|
||||
const { throttleMs = 250, enabled = true } = options
|
||||
|
||||
// 回调走 ref, 避免调用方每次渲染都重新订阅
|
||||
const cbRef = useRef(onChange)
|
||||
cbRef.current = onChange
|
||||
|
||||
// 表名数组每帧都是新引用, 用内容做依赖
|
||||
const tablesKey = Array.isArray(tables) ? tables.join(',') : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !tablesKey) return undefined
|
||||
|
||||
const db = getPowersyncDb()
|
||||
const ac = new AbortController()
|
||||
let disposed = false
|
||||
// watch 订阅成功后会立刻给出一次当前结果, 而调用方挂载时已经主动 load 过,
|
||||
// 这里跳过首次通知, 避免重复请求
|
||||
let first = true
|
||||
|
||||
try {
|
||||
db.watch(
|
||||
SIGNAL_SQL,
|
||||
[],
|
||||
{
|
||||
onResult: () => {
|
||||
if (disposed) return
|
||||
if (first) { first = false; return }
|
||||
cbRef.current?.()
|
||||
},
|
||||
onError: (e) => {
|
||||
if (!disposed) console.warn('[db-signal] watch 出错:', e)
|
||||
},
|
||||
},
|
||||
{ tables: tablesKey.split(','), throttleMs, signal: ac.signal },
|
||||
)
|
||||
} catch (e) {
|
||||
// 订阅失败不应影响页面主流程 (退化为"手动刷新")
|
||||
console.warn('[db-signal] 订阅失败:', e)
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
ac.abort()
|
||||
}
|
||||
}, [tablesKey, throttleMs, enabled])
|
||||
}
|
||||
Reference in New Issue
Block a user