同步流程与代码位置
- 本文记录当前手动或定时同步从渲染进程/主进程调度器进入托管远端、执行同步状态机,直到完成或等待重试的完整调用链。
- 桌面端仅提供托管同步 Beta;协议测试使用
test/helpers/memory-sync-remote.js中的内存远端替身。
图中每个步骤都带有稳定编号和源码文件。编号后的代码索引提供可点击链接;移动或重构同步代码时,应同时更新本页的节点位置与索引。
1. 托管同步入口
flowchart TD
S01["S01 用户点击“立即同步”<br/>src/renderer/runtime/sync/library.js"]
S02["S02 预加载层调用 sync:run<br/>src/preload.js:48"]
S03["S03 主进程取得当前笔记库并延迟加载 SyncManager<br/>src/main/sync-handlers.js:12-23"]
S04["S04 读取托管配置和安全凭据<br/>src/main/sync/manager.js"]
S05["S05 取得或初始化托管 SyncClient<br/>src/main/sync/manager.js"]
S07["S07 合并并发触发,进入同一 SyncClient 状态机<br/>src/main/sync/client/transfer.js"]
S01 --> S02 --> S03 --> S04 --> S05 --> S07
| 步骤 | 代码位置 | 职责 |
|---|---|---|
| S01 | runSync 与点击绑定 |
锁定按钮、显示“同步中”,等待主进程结果。 |
| S02 | markdownAPI.runSync |
通过受限预加载 API 调用 sync:run IPC。 |
| S03 | registerSyncHandlers |
校验当前已有笔记库,并在首次同步请求时按需创建 SyncManager。 |
| S04 | SyncManager.run |
读取托管配置和安全凭据;失败时额外上报有界错误摘要。 |
| S05 | SyncManager.hostedClient |
按服务地址和账号隔离 .mynote 内的客户端状态;空目录可发现唯一远端库。 |
| S07 | SyncClient.sync |
合并同一客户端上的并发同步请求,避免状态机重入。 |
2. 客户端同步状态机
flowchart TD
C01["C01 初始化身份、基线、队列和 cursor;状态改为 syncing<br/>src/main/sync/client/local.js, src/main/sync/client/transfer.js"]
C02{"C02 是否为无基线的新副本?<br/>src/main/sync/client/transfer.js"}
C03["C03 先拉取远端最终快照,防止空目录生成误删除<br/>src/main/sync/client/transfer.js"]
C04["C04 开始最多两轮收敛<br/>src/main/sync/client/transfer.js"]
C05["C05 扫描文件;校验未跟踪路径的远端身份后持久化本地操作队列<br/>src/main/sync/client/local.js"]
C06["C06 按队首顺序幂等上传,逐项落盘结果<br/>src/main/sync/client/transfer.js"]
C07["C07 按 cursor 拉取压缩快照、应用每个 fileId 的最终状态<br/>src/main/sync/client/transfer.js"]
C08{"C08 两轮是否完成?<br/>src/main/sync/client/transfer.js"}
C09["C09 标记 idle,记录成功时间并保存状态<br/>src/main/sync/client/transfer.js"]
C10["C10 返回状态;界面刷新笔记库和托管详情<br/>src/main/sync/client/transfer.js, src/renderer/runtime/sync/library.js"]
E01["E01 标记 error,保留 queue/cursor 并保存错误<br/>src/main/sync/client/transfer.js"]
E02["E02 尝试上报有界错误摘要<br/>src/main/sync/manager.js"]
E03["E03 界面显示错误并保留“重试”入口<br/>src/renderer/runtime/sync/library.js"]
C01 --> C02
C02 -->|是| C03 --> C04
C02 -->|否| C04
C04 --> C05 --> C06 --> C07 --> C08
C08 -->|未完成| C05
C08 -->|已完成| C09 --> C10
C03 -.异常.-> E01
C05 -.异常.-> E01
C06 -.异常.-> E01
C07 -.异常.-> E01
E01 --> E02 --> E03
| 步骤 | 代码位置 | 职责 |
|---|---|---|
| C01 | SyncClient.initialize、runSync 初始化、SyncStateStore |
在仓库 .mynote 内建立 libraryId、deviceId 和账号命名空间状态文件,加载或创建 files、queue、cursor。状态路径不依赖仓库绝对路径。 |
| C02 | runSync 首次拉取条件 |
仅当 cursor、队列和文件基线都为空时判定为无基线副本。 |
| C03 | pullChanges |
新副本先应用远端最终快照,再把磁盘缺失解释为本地删除。 |
| C04 | runSync 双轮循环 |
最多执行两轮,使拉取阶段生成的干净合并操作或其他冲突副本能在同次同步继续收敛。 |
| C05 | scanFiles、remotePathOwners、ManagedPathStore、MyNoteIgnorePolicy、decodeContent、reconcileLocal |
先读取并同步根目录 .mynoteignore,再按 gitignore 语义扫描 Markdown 与图片;用户规则只拦截未管理的新路径,已进入同步基线或仓库管理清单的文件继续扫描,.app 和 .mynote 则始终排除。图片使用线性、规范化的 Base64 解码校验后按原始字节计算哈希。对基线未跟踪的路径先读取远端压缩头状态:同路径同内容直接采用远端文件身份,不上传或生成冲突副本;不同内容按远端胜出规则保留双方。随后识别其余新增、修改、唯一同内容重命名和删除,并先持久化操作队列。 |
| C05a | SyncClient.setProgress、sync:progress IPC |
扫描输出已发现文件数;上传和下载输出当前项、总数与相对路径,通过主进程事件更新同步中心,不额外轮询远端。下载总数是压缩快照中的最终文件数。进度区域位于同步操作按钮下方;渲染时将动态路径限制在固定高度区域,并按 120ms 合并高频更新,避免布局持续重排。 |
| C06 | pushQueue |
只处理队首;远端返回并保存处理结果后才移除该操作。 |
| C07 | pullChanges |
单次拉取服务端最终快照,按 fileId 再压缩并应用;逐项保存文件基线,整批完成后一次性推进 cursor。 |
| C08 | runSync 双轮循环 |
第一轮结束后再收敛一次,第二轮结束后退出。 |
| C09 | runSync 成功分支 |
保存 idle、lastSyncedAt 和完成日志。 |
| C10 | SyncClient.pendingChanges、renderer.renderPendingChanges、isPendingPullVersion |
待推送数来自已显示的本地待同步列表,待拉取数直接统计版本历史中蓝色的待拉取条目;任一方非零时高亮摘要。 |
| E01 | runSync 异常分支 |
主同步循环失败时保存 error 和错误信息;未确认的队首及已保存 cursor 保持不变。 |
| E01a | SyncStateStore.load |
新进程加载到遗留的 syncing 时,将其持久化恢复为“需要重试”;文件基线、队列和 cursor 原样保留。 |
| E02 | SyncManager.run 异常分支 |
尽力上报不超过约定长度的同步错误摘要;上报失败不遮蔽原始错误。 |
| E03 | renderer.runSync 异常分支 |
展示失败原因和“重试”,并重新读取持久化同步状态。 |
3. 上传、冲突与托管远端
flowchart TD
U01["U01 读取持久化队首操作<br/>src/main/sync/client/transfer.js"]
U04["U04 托管适配器发送鉴权 HTTP 请求<br/>src/main/sync/hosted-remote.js:31-68, 92-94"]
U05["U05 HTTP 路由鉴权并转交托管存储<br/>server/hosted-sync/http-server.js:137-142"]
U06["U06 托管存储检查幂等、权限、基线、路径和配额并提交 revision<br/>server/hosted-sync/store/index.js"]
U07{"U07 远端是否接受?<br/>src/main/sync/client/transfer.js"}
U08["U08 更新文件基线,移除队首并原子保存<br/>src/main/sync/client/transfer.js"]
U09["U09 尝试 Markdown 三方合并;失败时保留相邻版本或冲突副本<br/>src/main/sync/client/files.js"]
U10["U10 必要时把被远端删除或替换的文件移入同步回收目录<br/>src/main/sync/client/files.js"]
U01 --> U04 --> U05 --> U06 --> U07
U07 -->|accepted| U08
U07 -->|conflict| U09 --> U10 --> U08
| 步骤 | 代码位置 | 职责 |
|---|---|---|
| U01 | SyncClient.pushQueue |
读取持久化队列的第一项,保持严格顺序。 |
| U04 | HostedSyncRemote.request、applyOperation |
添加 bearer token、请求 ID 和超时控制,并调用 operations 接口。 |
| U05 | 托管 operations 路由 | 验证会话与 URL/正文中的库 ID,把操作交给账号隔离的存储层。 |
| U06 | HostedSyncStore.applyOperation |
串行提交操作,检查设备/账号/基线/路径/配额;目标路径和正文哈希已与当前有效文件头相同的 upsert(包括另一端先提交相同修改后形成的过期基线)直接成功收敛,只持久化幂等结果,不写新 revision;其余有效操作写对象、文件头、版本和幂等结果。 |
| U07 | SyncClient.pushQueue 结果分支 |
区分 accepted 与 conflict,冲突不视为可覆盖错误。 |
| U08 | SyncClient.pushQueue 队列提交 |
更新文件基线后移除已处理队首,并原子保存状态。 |
| U09 | resolvePushConflict |
新文件上传与远端同路径同内容时直接采用远端身份,作为预检后的竞态兜底,不生成 revision 或冲突副本。其余同路径 Markdown 使用本地共同祖先、持久化队列正文和服务端当前正文进行保守三方合并;合并正文已等于远端正文时直接采用已落盘的远端头,不重复写盘或上传,并且只在存在对应路径时清理临时冲突文件。其他干净结果才以新 operationId 和远端 revision 重基上传;重叠修改保留相邻的 .base/.local/.remote,其他冲突继续使用冲突副本和同步回收。 |
| U10 | moveToSyncTrash |
将受远端删除/替换影响的现有文件移入 .mynote/sync-trash/。 |
4. 拉取与本地应用
flowchart TD
D01["D01 使用当前 cursor 请求压缩快照<br/>src/main/sync/client/transfer.js"]
D04["D04 托管适配器调用 changes 接口<br/>src/main/sync/hosted-remote.js:95-97"]
D05["D05 HTTP 路由解析 cursor 并转交存储<br/>server/hosted-sync/http-server.js"]
D06["D06 托管存储从 files 头表返回每个 fileId 的最终状态<br/>server/hosted-sync/store/index.js"]
D07["D07 校验 compacted 响应并按 fileId 防御性再压缩<br/>src/main/sync/client/transfer.js"]
D08{"D08 是否存在正文偏离、路径占用或远端删除?<br/>src/main/sync/client/files.js"}
D09["D09 覆盖前创建冲突副本或移入同步回收目录<br/>src/main/sync/client/files.js"]
D10["D10 捕获本地身份快照并原子写入最终正文/墓碑<br/>src/main/sync/client/files.js"]
D11["D11 逐项保存基线;整批完成后提交目标 cursor<br/>src/main/sync/client/transfer.js"]
D01 --> D04 --> D05 --> D06 --> D07
D07 --> D08
D08 -->|是| D09 --> D10
D08 -->|否| D10
D10 --> D11 --> D13["返回主状态机<br/>src/main/sync/client/transfer.js"]
| 步骤 | 代码位置 | 职责 |
|---|---|---|
| D01 | SyncClient.pullChanges |
以已持久化 cursor 为起点请求一次最终快照。 |
| D04 | HostedSyncRemote.pull |
将库 ID 和 cursor 编码到托管 changes 请求。 |
| D05 | 托管 changes 路由 | 从查询参数解析 cursor,并传入已认证账号上下文。 |
| D06 | HostedSyncStore.pull |
从 files 头表选择最新 revision 超过 cursor 的记录;每个 fileId 只还原最终正文,版本历史表不变。 |
| D07 | pullChanges 校验与压缩 |
要求 compacted: true、hasMore: false 和目标 cursor,再按 fileId 保留最高 revision;旧分页 v1 响应会被拒绝。 |
| D08 | applyRemoteFile |
比较磁盘正文与本地基线,检查路径占用,并识别需要迁移现有文件的远端墓碑。 |
| D09 | tryAutoMerge、preserveConflict、moveToSyncTrash |
本地与基线偏离时,用共同基线、本地当前正文和远端最终正文只合并一次;不适用时保留冲突正文或可恢复的被删文件。 |
| D10 | assertSafeTarget、writeFile、applyRemoteFile |
写盘前捕获所有受影响 fileId 的本地正文,拒绝目录穿越和符号链接,并用捕获内容处理重命名路径循环。 |
| D11 | pullChanges cursor 提交 |
每个最终文件落盘后保存其基线但保持旧 cursor;全部成功后才提交响应目标 cursor。中断重试会跳过已达到最终 revision 的文件。 |
| D13 | pullChanges 返回 |
回到首次拉取或双轮收敛流程的调用点。 |
5. 关键持久化边界
- 本地操作先进入
queue并保存,再开始上传:reconcileLocal。 - 上传结果先更新文件基线,再移除队首并保存:
pushQueue。 - 拉取快照逐项安全写盘并保存文件基线,整批成功后再推进 cursor:
pullChanges。 - 所有同步状态通过原子替换保存:
SyncStateStore.save、atomicWriteBuffer。 - 主同步循环内的异常会保存错误状态,但不会主动清空未确认队列:
runSync异常分支。
因此重试的恢复点是“最后一次成功保存的队首/文件基线/cursor”,而不是界面内存中的临时进度;服务端再通过 operationId 幂等结果防止提交成功但响应丢失时产生重复 revision。
6. 远端仓库列表、克隆与删除
flowchart LR
L01["同步中心读取当前账号仓库列表"] --> L02["用户选择仓库与克隆位置"]
L02 --> L03["主进程验证目录与仓库归属;非空位置创建命名子目录"]
L03 --> L04["写入 .mynote/library.json"]
L04 --> L05["从 cursor 0 执行首次同步"]
L05 --> L06["打开克隆目录作为当前工作区"]
| 职责 | 代码位置 |
|---|---|
按 名称(完整 ID) 或完整 ID 渲染仓库、当前 Markdown 文件数,处理改名、克隆、永久删除按钮和结果 |
refreshHostedDetails |
| 从仓库详情修改本地根目录名称,并刷新标签、最近仓库及详情 | renameCurrentWorkspace → workspace:rename-current → WorkspaceManager.renameCurrentWorkspace |
| 选择克隆位置并切换到实际克隆目录 | sync:clone-library IPC |
| 校验目录、为非空位置创建名称/ID 子目录、写入仓库身份并首次同步 | SyncManager.cloneLibrary |
| 返回账号仓库名称、revision 与当前未删除 Markdown 文件数 | HostedSyncStore.libraries |
| 鉴权修改仓库名称并写审计事件 | HostedSyncStore.renameLibrary |
| 首次操作使用本地显示名称,删除时清理仓库历史与独占对象并保留删除墓碑 | SyncClient.operation、HostedSyncStore.deleteLibrary |
| 读取当前仓库本地身份、状态并匹配远端摘要;远端列表无对应 ID 时停止初始化同步客户端 | SyncManager.libraryDetails |
| 从侧边栏展示当前仓库完整详情,并区分本地目录缺失与当前远端命名空间无对应仓库 | openLibraryDetails |
| 确认本地根目录缺失后清除当前索引与持久化记录 | WorkspaceManager.forget、WorkspaceStateStore.removeWorkspace |
| 返回含动作和设备名的版本摘要,并按 revision 鉴权读取前后正文 | HostedSyncStore.versions、HostedSyncStore.versionDetails |
| 点击版本时按需加载详情;逐行 diff 默认只保留变化前后 3 行,并可切换完整显示或文本自动换行;二进制附件只显示大小 | renderVersionDetails、buildLineDiff |
| “整体恢复”按全局 revision 读取完整只读快照,在仓库操作锁内校验并覆盖本地同步工作树、清空旧队列,再以当前远端头作为新比较基线 | HostedSyncStore.versionSnapshot、SyncClient.runVersionHardRestore |
| 整体恢复后仅在全局自动同步开启时立即同步;仓库安全模式开启时仍使用安全自动同步,关闭全局自动同步则远端保持不变 | SyncManager.restoreVersionHard、sync:restore-version-hard |
7. 自动同步调度
flowchart LR
A01["同步中心保存启用状态与秒数"] --> A02["主进程重建定时器"]
A02 --> A03["合并当前仓库与后台同步仓库并去重"]
A03 --> A04{"全局开关开启、存在目标、有效登录且未在同步?"}
A04 -->|是| A05{"该仓库开启安全模式?"}
A04 -->|否| A06["跳过本次触发"]
A05 -->|否| A07["执行完整同步"]
A05 -->|是| A10{"仅单向变化?"}
A10 -->|是| A09["执行安全推送或安全拉取"]
A10 -->|否| A06
A07 --> A02
A09 --> A02
A06 --> A02
7.1 配置与目标
- 配置由
SyncConfigStore保存到系统应用数据目录下的sync-config-v1.json。 autoSyncIntervalSeconds为null时禁用;启用时默认 60 秒,允许 1–86400 的整数秒。- 全局自动同步开启时,当前打开的仓库始终参与定时同步,不受仓库级开关影响。
- “切换仓库后仍自动同步”由
WorkspaceStateStore保存为本机路径列表,默认关闭,但其选择不依赖全局开关。 - 全局关闭期间偏好仍可配置,只是不执行定时调度。
- 手动同步不检查该列表。
7.2 安全自动同步
- “安全的自动同步”使用独立的仓库路径列表,不依赖全局开关或“切换仓库后仍自动同步”。
- 当前仓库由全局定时器选中时也遵循该设置。
- 切换离开的仓库只有在“切换仓库后仍自动同步”开启、成为后台目标后才有机会执行。
- 选择安全模式后,
SyncClient.safeSync先用同一 cursor 检查远端最终快照,并以只读扫描检查本地待推送内容。 - 仅本地变化时安全推送,仅远端变化时安全拉取,两侧都有变化则跳过。
- 预检后的上传版本冲突会保留持久化队列。
- 拉取期间发现本地变化会在生成冲突副本前停止,因此安全模式不会自动进入冲突解决。
7.3 调度实现
AutoSyncScheduler只维护一个主进程定时器,对当前仓库和已启用后台同步的仓库去重后顺序执行,并以运行锁避免上一轮未结束时重入。- 一个目标失败时记录空结果并继续其余目标。
registerSyncHandlers在应用启动时读取全局设置,在sync:auto-config保存后立即重建定时器,并在每次触发时合并当前仓库与仓库级后台目标。- 退出时通过
src/main.js停止定时器。 - 定时触发对任何未选择安全模式的目标仓库使用
SyncManager.runIfAuthenticated,对任何选择安全模式的目标仓库使用runSafelyIfAuthenticated,不区分当前或后台位置。 - 未登录时,两种定时同步均不发请求。
- 手动“立即同步”始终使用完整同步与既有冲突恢复逻辑。
8. 模块职责与依赖方向
同步代码按“入口与调度 → 应用编排 → 仓库状态机 → 协议/持久化/网络”分层。上层可以组合下层,下层不得依赖 Electron 界面或工作区状态:
| 模块 | 主要职责 | 不负责的内容 |
|---|---|---|
sync-handlers.js |
注册 IPC、读取仓库级后台同步目标、按需加载同步引擎、管理自动同步定时器生命周期 | 文件对比、账号状态、HTTP 协议 |
manager.js |
组合配置、凭据、账号命名空间、远端适配器和仓库客户端;提供登录、仓库发现、克隆、版本、导出等应用操作 | 文件扫描、冲突落盘、cursor 推进 |
client/index.js |
组合并导出单个本地副本的 SyncClient 公共类 |
具体扫描、文件冲突或传输实现 |
client/local.js |
初始化仓库身份,扫描文件并持久化本地操作队列 | 网络传输、远端文件落盘 |
client/files.js |
安全读写同步文件,执行三方合并、冲突副本和同步回收 | 队列循环、账号和服务地址 |
client/transfer.js |
上传、拉取、整体恢复和同步运行状态;定义崩溃恢复提交点 | 登录、服务地址选择、定时器、服务端存储 |
client/helpers.js |
保存同步身份、安全元数据目录和公共文件基线辅助逻辑 | 同步流程编排 |
conflict-tool.js |
校验外部合并程序和参数模板,不经 shell 展开 %local/%remote/%base/%merged 并分离启动 GUI 工具 |
冲突判定、自动合并、删除临时版本 |
protocol.js |
定义 v1 上传操作结构,规范化相对路径,决定文本/二进制编码,验证大小和 SHA-256 | 拉取快照结构、网络发送、磁盘状态、冲突策略 |
state-store.js |
加载、校验、迁移默认字段并原子保存单副本检查点;保留有界诊断日志,界面可按默认关闭的“详细输出”展开结构化详情 | 解释队列或 cursor 的业务语义 |
hosted-remote.js |
校验服务地址,添加鉴权/请求 ID/超时,将资源方法映射到 HTTP API | 自动重试、幂等决策、凭据持久化 |
config-store.js |
保存非敏感服务地址、邮箱和自动同步间隔 | token、密码或账号会话 |
credential-store.js |
使用 Electron safeStorage 加密账号、token、设备身份和过期时间;不安全后端失败关闭 |
普通偏好设置、服务端密码验证 |
auto-sync-scheduler.js |
维护唯一计时器,目标去重并顺序执行,隔离单仓库失败,跳过无目标或重入的周期触发 | 判断登录有效性或执行同步 |
clone-target.js |
生成跨平台安全且不覆盖现有路径的克隆目录名 | 远端仓库鉴权或首次拉取 |
export.js |
将服务端快照写入全新目录,逐项校验路径并生成可移植仓库身份 | 在线同步或增量合并 |
https-policy.js |
解析严格的 HTTPS 环境策略 | TLS 终止或证书部署 |
依赖注入边界也用于测试:SyncClient 只要求远端实现 applyOperation() 和 pull();AutoSyncScheduler 注入计时器和执行回调;HostedSyncRemote 注入 fetchImpl。因此协议故障、定时器竞态和 HTTP 错误可以分别测试,不需要启动完整界面。
9. 本地持久化数据模型
9.1 仓库身份
<笔记库>/.mynote/library.json 随仓库移动或复制,用来说明“这是哪个逻辑仓库”:
{
"protocolVersion": 1,
"libraryId": "稳定的仓库 UUID",
"formatVersion": 1
}
身份文件存在但损坏时客户端会停止,不会生成新 ID 覆盖它;只有文件确实不存在时才创建身份。这避免损坏被误解释成一个全新远端仓库。
9.2 账号与服务命名空间
- 托管状态位于
<笔记库>/.mynote/sync-hosted-v1/<namespaceKey>/state.json。 namespaceKey来自规范化服务地址和服务端账号 ID 的 SHA-256 前 16 位,只用于本地隔离,不承担鉴权或加密作用。- 切换服务地址会清除旧凭据与内存客户端,旧状态文件仍保留在原命名空间中。
- 设备 ID 独立于登录 token 持久化;重新登录相同环境(包括远程退出当前设备后再次登录)会在密码验证成功后复用并重新激活该 ID,因此可继续使用原状态文件和 cursor,不会重新全量遍历远端。
9.3 同步检查点
state.json 的核心字段如下:
| 字段 | 含义 | 更新时机 |
|---|---|---|
libraryId / deviceId / protocolVersion |
检查状态文件是否属于当前副本与协议 | 首次创建;加载时严格匹配 |
files[fileId] |
最近已接受的文件基线:路径、哈希、revision、删除状态和编码 | 上传被接受/冲突被解析,或远端变更成功落盘后 |
queue[] |
尚未获得持久化处理结果的本地操作,按 FIFO 排列 | 完整扫描结果先整体加入;每个队首处理并保存后再移除 |
cursor |
已完整应用到本地的压缩快照目标 revision | 快照内各文件基线保存完成后一次性推进 |
status / lastError / lastSyncedAt |
界面状态、最近错误和最近成功时间 | 同步开始、失败或完整双轮成功时 |
logs[] |
按发生时间保存的诊断记录 | 关键状态转换;最多保留 200 条 |
files只保存基线元数据。- 可自动合并且不超过 2 MB 的 Markdown 共同祖先按正文哈希保存在账号命名空间的
merge-bases/,不放入有界的state.json。 - 用户可读的 Markdown 和图片仍以工作区磁盘文件为准。
queue中的upsert会保存扫描当时的正文和哈希,保证文件随后再次变化时,已经排队的操作仍表示同一份待确认内容。
10. 崩溃恢复与并发不变量
维护同步逻辑时必须保持以下顺序;颠倒其中任意一项都可能造成静默跳过或重复提交:
- 本地扫描产生的操作必须先写入
queue并原子保存,之后才能发出 HTTP 请求。 - 上传只能处理持久化队首;远端结果更新基线后,才能移除队首并原子保存。
- 同一
operationId的重试必须保持相同操作内容;服务端负责返回第一次提交的幂等结果。 - 拉取必须先捕获受影响身份的本地正文,逐项安全应用最终状态并保存文件基线;整份压缩快照完成后才能更新
cursor。 - Markdown 三方合并必须先写出 Base/Local/Remote;干净合并的新操作持久化后才能删除临时文件并发出请求。其余冲突必须先保留本地正文,再应用远端当前文件、路径拥有者或墓碑。
- 没有任何基线的新副本必须先从 cursor 0 拉取,不能先把空目录解释为一组删除。
- 同一客户端的手动和定时触发必须合并到一个运行中的 Promise;定时器也必须跳过尚未结束的上一轮。
- 网络、遥测或界面错误不得清空队列、回退 cursor,或覆盖原始同步异常。
- 如果进程在上传响应后、保存队列前退出,重启后会重发相同
operationId,服务端返回之前的结果。 - 如果进程在压缩快照部分写入后、保存目标 cursor 前退出,重启后会重新请求同一快照,并依据已保存文件基线跳过完成项。
- 两种窗口都允许重复工作,但不允许丢失未确认工作。
11. 修改与排障指南
11.1 修改协议或支持新文件类型
- 当前
/v1/libraries/:id/changes已按产品决定原地切换为不兼容的compacted: true单快照响应;客户端和服务端必须成套升级。后续再次改变该契约时,应重新明确是否继续接受不兼容部署。 - 文件类型、编码、大小和哈希规则集中在
protocol.js;客户端和服务端必须共享同一组验收用例。 - 新增持久化字段应提供安全默认值,并验证旧
state.json、损坏状态隔离和首次拉取行为。 - 冲突策略变化至少回归双设备离线编辑、重命名/删除并发、路径占用、提交后断网、乱序/重复拉取和符号链接逃逸。
11.2 常见状态定位
| 现象 | 优先检查 | 说明 |
|---|---|---|
| 长期显示“同步中” | state.json.status、主进程异常、HTTP 超时 |
正常请求有超时;进程退出后下次加载会把遗留 syncing 恢复为“需要重试”,并保留队列与 cursor |
pendingCount 不下降 |
queue[0].operationId、服务端幂等结果、最近错误日志 |
不要手工删除队首;先确认服务端是否已提交但响应丢失 |
| cursor 不推进 | changes 响应的 compacted/目标 cursor、目标路径安全检查、磁盘写入错误 |
cursor 只在整份压缩快照成功落盘后推进 |
| 文件树出现同步冲突标记 | 原文件旁的 .base/.local/.remote、基线哈希、远端 revision |
可直接修改原文件或右键启动已配置的外部工具;Beyond Compare(macOS)应选择应用包内的 Contents/MacOS/bcomp,而不是 BCompare;完成后标记已解决,三个临时版本不会上传 |
| 出现普通冲突副本 | 文件类型、删除/重命名、目标路径是否被其他文件占用 | 不适用三方文本合并时仍使用原有数据保护机制,并在下一轮作为新文件上传 |
文件进入 sync-trash |
对应墓碑或远端重命名/替换记录 | 目录内容可恢复,不应在同步流程中直接物理删除 |
| 登录后显示未认证 | 配置 endpoint 与加密凭据 endpoint 是否一致、safeStorage 后端、服务端 401 |
只有明确 401 或切换 endpoint 会清除本地会话 |
| 空目录未自动采用远端库 | 账号是否存在多个有数据的库、目录是否真的无受支持文件 | 多候选时必须显式克隆,客户端不会猜测 |
相关最小验证:
npm run lint
node --test test/sync-*.test.js test/auto-sync-scheduler.test.js test/clone-target.test.js
node --test test/hosted-sync-http.test.js
涉及主进程 IPC 或打包边界时还应执行完整 npm run validate。
