fix(sync): 修复云同步多设备冲突下的静默覆盖与状态污染问题#1439
Conversation
|
我总感觉要被你改炸了。。。。。 filesystem这个包应该只专注于文件读写,冲突之类不要由这个包去处理,违背单一职责了 |
每个 Provider 的冲突处理不一样呀 也是 fs.write(...) fs.delete(...) 有冲突的话要报错 |
There was a problem hiding this comment.
Pull request overview
本 PR 聚焦修复云同步在多设备并发与失败场景下的“静默覆盖”和“本地状态污染”问题:通过为各云端 provider 引入/统一条件写入与条件删除(version/digest 预期值),在检测到远端变化或写入失败时停止推进 scriptcat-sync.json 与本地 digest 状态,并补充冲突/失败通知与测试覆盖。
Changes:
- 扩展 filesystem 抽象:
FileInfo.version、FileCreateOptions/FileDeleteOptions条件参数、FileSystemError.unsupported及冲突/不支持的错误构造 helper;新增条件 Header 构造工具函数并复用。 - 多 provider(S3/WebDAV/OneDrive/Dropbox/Google Drive/Baidu)补齐条件写入/删除或 best-effort preflight 校验,并统一冲突错误类型化。
- 云同步流程增强:写入/删除带前置条件;失败/冲突时不推进
scriptcat-sync.json与 digest;tombstone 收敛与导出失败处理;新增通知文案与大量单测。
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/locales/zh-TW/translation.json | 新增同步失败/冲突通知文案(繁中) |
| src/locales/zh-CN/translation.json | 新增同步失败/冲突通知文案(简中) |
| src/locales/vi-VN/translation.json | 新增同步失败/冲突通知文案(越南语) |
| src/locales/ru-RU/translation.json | 新增同步失败/冲突通知文案(俄语) |
| src/locales/ja-JP/translation.json | 新增同步失败/冲突通知文案(日语) |
| src/locales/en-US/translation.json | 新增同步失败/冲突通知文案(英语) |
| src/locales/de-DE/translation.json | 新增同步失败/冲突通知文案(德语) |
| src/app/service/service_worker/synchronize.ts | 云同步核心逻辑:条件写入/删除、失败不推进状态、tombstone 收敛、导出失败处理、通知 |
| src/app/service/service_worker/synchronize.test.ts | 覆盖同步失败不推进、tombstone 优先、条件写入、清理策略等单测 |
| src/app/service/service_worker/index.ts | cloudSync alarm 链路增加 catch,避免未处理 rejection |
| packages/filesystem/filesystem.ts | 扩展 filesystem 通用接口(version、create/delete options、delete 签名) |
| packages/filesystem/error.ts | 增加 unsupported 标识与冲突/不支持条件写入的错误 helper |
| packages/filesystem/utils.ts | 新增 buildConditionalHeaders/buildExpectedHeaders 统一生成条件请求头 |
| packages/filesystem/utils.test.ts | 新增 filesystem utils 条件 header 行为测试 |
| packages/filesystem/limiter.ts | limiter 透传 FileDeleteOptions,保留条件删除语义 |
| packages/filesystem/limiter.test.ts | 覆盖 limiter.delete 透传 options 的测试 |
| packages/filesystem/s3/s3.ts | S3:list 暴露 version;PUT/DELETE 支持条件头;冲突类型化;删除幂等 |
| packages/filesystem/s3/rw.ts | S3 writer:复用条件 header 并将 409/412 转冲突错误 |
| packages/filesystem/s3/s3.test.ts | 覆盖 S3 条件 PUT/DELETE、list 行为与无额外 HEAD 请求 |
| packages/filesystem/webdav/webdav.ts | WebDAV:delete 支持 If-Match;list 暴露 version;冲突类型化;幂等删除 |
| packages/filesystem/webdav/rw.ts | WebDAV writer:复用条件 header + overwrite=false 实现 createOnly;冲突类型化 |
| packages/filesystem/webdav/webdav.test.ts | 覆盖 WebDAV 条件写入/删除与冲突转换测试 |
| packages/filesystem/onedrive/onedrive.ts | OneDrive:delete 支持 If-Match;非 2xx 抛 typed error;list 暴露 version |
| packages/filesystem/onedrive/rw.ts | OneDrive writer:simple/upload session 支持条件写入与 createOnly 行为 |
| packages/filesystem/onedrive/onedrive.test.ts | 覆盖 OneDrive 条件删除、条件写入、createDir 前缀处理、list version |
| packages/filesystem/dropbox/dropbox.ts | Dropbox:delete 支持条件 preflight 校验;list 暴露 rev 为 version |
| packages/filesystem/dropbox/rw.ts | Dropbox writer:createOnly/add、expectedVersion/update、冲突类型化与不支持场景 |
| packages/filesystem/dropbox/dropbox.test.ts | 覆盖 Dropbox 条件删除 preflight、writer mode、unsupported conditional write |
| packages/filesystem/googledrive/googledrive.ts | Google Drive:list 暴露 opaque version;delete/update best-effort version/digest 校验;重名冲突检测 |
| packages/filesystem/googledrive/rw.ts | Google Drive writer:createOnly 冲突处理与重复名回滚;更新前 version preflight |
| packages/filesystem/googledrive/googledrive.test.ts | 覆盖 Google Drive 版本 token 暴露、删除/写入 preflight、重名检测与回滚 |
| packages/filesystem/baidu/baidu.ts | Baidu:expectedVersion 明确 unsupported;expectedDigest/delete preflight;删除幂等化 |
| packages/filesystem/baidu/rw.ts | Baidu writer:createOnly(rtype=0)与 expectedDigest preflight;冲突类型化 |
| packages/filesystem/baidu/baidu.test.ts | 覆盖 Baidu createOnly/expectedDigest/delete 幂等与 unsupported 行为 |
| create(path: string, opts?: FileCreateOptions): Promise<FileWriter>; | ||
| // 创建目录 | ||
| createDir(dir: string, opts?: FileCreateOptions): Promise<void>; | ||
| // 删除文件 | ||
| delete(path: string): Promise<void>; | ||
| delete(path: string, opts?: FileDeleteOptions): Promise<void>; | ||
| // 文件列表 |
| private throwCreateOnlyConflict(data: any): void { | ||
| if (!this.opts?.createOnly) { | ||
| return; | ||
| } | ||
| throw fileConflictError("baidu", `File already exists or createOnly write was rejected: ${this.path}`, { | ||
| status: 409, | ||
| code: String(data.errno), | ||
| raw: data, | ||
| }); | ||
| } |
Code reviewFound 1 issue:
scriptcat/packages/filesystem/baidu/rw.ts Lines 128 to 147 in ce1a8cb 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
There was a problem hiding this comment.
从设计角度的 review,最近这些云同步的处理,都只看重了错误处理,却没有考虑消化错误,容错率低,我都有点不敢发布了
方向
修复云同步多设备并发下的静默覆盖,整体方向正确。FileCreateOptions.{expectedDigest, expectedVersion, createOnly} + FileDeleteOptions + FileSystemError.{conflict, unsupported} 是干净的领域模型,tombstone 优先、失败不推进 file_digest / scriptcat-sync.json 也是正确性的核心改进。
必须修复
-
OneDrive / Google Drive
request(nothen=true)行为变化未充分覆盖
两处都把nothen=true路径从"返回 Response 让调用方判状态"改成了"!resp.ok直接 throw typed error"。这是底层行为变更,但 PR 只针对delete()调整了调用点。请补测覆盖所有nothen=true的调用方,避免静默回归。 -
Dropbox 冲突识别用字符串匹配过于脆弱
message.includes("409") || message.includes("conflict") || message.includes("incorrect_offset")
将来 Dropbox SDK 文案变更会让冲突识别静默失效。建议在
fs.request层就把 4xx 转成 typedFileSystemError,上层只判error.conflict。 -
getScriptBackupData(uuids)写法
先Promise.allSettled再filter/map三次,可以简化成Promise.all+ try/catch 收集失败,或一次 reduce 完成分类。
设计层建议
-
expectedVersion语义在不同 provider 间不齐- S3 / OneDrive / WebDAV:服务端原子
If-Match(强 CAS) - Dropbox / Google Drive / Baidu:preflight,仍存在 TOCTOU 窗口
接口上看不出区别,调用方拿到
expectedVersion容易误以为是强一致。注释里只在 provider 内部点了。建议:- 在
FileSystem.capabilities()之类的能力查询里暴露atomicCompareAndSwap: boolean - 或在
FileSystemError上把conflict(服务端 CAS 命中)与staleSnapshot(preflight 命中)分开,让上层做差异化处理(best-effort provider 可以加自动重试)
- S3 / OneDrive / WebDAV:服务端原子
-
Google Drive 的
version字段被编码成fileId:versionversion: item.version ? `${item.id}:${item.version}` : item.id,
list/delete/writer 各自实现
parseGoogleDriveVersion再 split。这让version在不同 provider 间含义不同(其他都是 etag/rev/hash),违反"opaque token 跨 provider 透明"的契约。建议在FileInfo上加providerToken?: unknown,或 Google Drive 内部维护独立 path→id 缓存。 -
tombstone_digest第二存储引入了独立生命周期- 隐式 invariant:仅当
file_digest[name]也匹配时才有效,但类型不强制 - "其他 task 失败时仍允许写"是个例外口子,与整体"失败不推进状态"原则相反
- 永远不为完全删除的脚本做 GC(只在同名 meta 再次出现时清理),长期累积
建议合并进
file_digest的值({digest, isTombstone}),或加 TTL/round 计数清理。 - 隐式 invariant:仅当
-
updateFileDigest的 list-retry 把 provider 一致性问题暴露给上层
更干净的做法是FileWriter.write()返回该文件最终的FileInfo(含远端 digest/version),避免上传后再 list-retry。 -
SCRIPTCAT_SYNC_FILENAME写入用第一轮 list 的快照
整个 syncOnce 过程可能持续数秒到分钟,期间另一台设备可能已经写过 sync.json。CAS provider 没问题,best-effort provider 仍是 TOCTOU。值得在pushScript进入 finalize 前补一次 list。 -
OneDriveFileWriter.createConditionalHeaders返回逻辑return base || Object.keys(conditionalHeaders).length > 0 ? headers : undefined;
优先级容易看错,建议改成显式 if。
副作用清单(用户可感)
| 行为变化 | 风险 |
|---|---|
任一文件失败即停止 file_digest 推进 |
单次 5xx/限流会让整轮 sync 状态不前进,PR 没加自动重试 |
| 新增"同步失败/冲突"通知 | 之前静默吞掉的失败现在弹窗,预期用户投诉增加,文案让用户"检查日志"对最终用户不友好 |
| S3 list 不再 per-object HEAD | 失去 x-amz-meta-createtime,回退到 LastModified;性能大幅改善 |
deleteCloudScript / pullScript 不再吞异常 |
install/delete/pull 失败会触发用户通知,量上升 |
scriptcat-sync.json 写入失败会通知 |
多设备首轮并发用户会频繁看到冲突通知,直到一方让步 |
Google Drive createOnly 是 post-create 检测 |
两台设备同时新建同名时可能双方都先写成功,再各自 reject 删除自己的,极端竞态有数据丢失风险 |
OneDrive delete 200/202 现在也视为成功 |
旧代码只接受 204,新代码任意 resp.ok,行为变化 |
tombstone_digest 长期累积无 GC |
storage IO 慢慢变重 |
建议补充
- 自动重试:transient 失败(429/5xx)按指数退避重试,不直接通知用户
- 通知节流:同一错误 5 分钟内只弹一次
- 失败文案优化(不要让最终用户"检查日志")
整体评价:方向对、修复了真实问题,但抽象语义未对齐、用户感知通知可能从"静默"变"嘈杂"。建议优先补 typed error / 自动重试 / 通知节流。
There was a problem hiding this comment.
补一轮 inline review,重点指出 synchronize.ts 在错误处理上只考虑了「报错」,没考虑「消化」——容错颗粒度从 per-file best-effort 变成了 all-or-nothing 事务,对大批量同步是显著退化。
容错率对比(同 synchronize.ts)
| 场景 | 旧代码 | 新代码 | 容错变化 |
|---|---|---|---|
| 单 pushScript 429 / 网络抖动 | 其他脚本继续,成功的 digest 推进,下轮只重试失败那个 | 整轮停摆,所有 digest 不推进,下轮 100 个全部重做 | ❌ 显著降低 |
| 单 pullScript 失败(JSON 损坏等) | 静默 log,其他脚本继续 | 抛错→整轮停摆,单条坏数据卡死整账号 | ❌ 显著降低 |
| 批量删除中单条失败 | 循环继续,其他 uuid 仍被处理 | 抛错→循环中断,后续 uuid 完全跳过 | ❌ 降低 |
| status sync 单条失败 | Promise.allSettled 忽略 rejected,继续写 sync.json |
任一失败即 return,sync.json 不写 | ❌ 降低 |
| 99 成功 + 1 失败 | 99 推进 digest,1 待重试 | 100 全不推进,且下轮 push 因为远端 version 已变更容易再冲突 | ❌ 严重降低 |
| install 触发的 push 瞬时失败 | 静默 log,下次 syncOnce 自然补上 | 弹「同步失败」通知 | ❌ 用户感知噪声 |
| delete 触发的循环失败 | 静默 log | 弹通知但未说明后续未删 | ❌ 信息不全 |
| 整体 sync 失败 | 静默 log | 弹通知 + 不推进任何状态 | ❌ 嘈杂 + 脆弱 |
核心问题(一句话)
旧代码是「整体 best-effort」(最多就是一轮 sync 啥也没做)
新代码是「整体事务」(任一失败 = 整轮回滚)
应该的颗粒度是「per-file」——成功的 file 推进自己的 digest,失败的保留旧 digest 待下轮自动重试
旧代码确实也有问题
公平地说,旧代码的「容错」部分是用 silent data loss 换的——pullScript 静默吞错会让本地版本和云端不一致而没有任何提示。PR 修对了这部分。问题不是「该不该报错」,而是「报错的颗粒度过粗」。
建议
- 把错误按 transient(429/5xx/网络) / conflict / fatal 分类
- transient 走 backoff 重试(
LimiterFileSystem已有,但只覆盖 read 类操作,建议扩展到 write/delete) - conflict 才真正中止该文件,但保留其他成功文件的 digest
- 通知做节流,并区分「重试中」「最终失败」两种状态
下面是 synchronize.ts 具体行号的 inline 评论。
| this.logger.warn(`sync task #${idx} failed`, Logger.E(ret.reason)); | ||
| }); | ||
| this.notifySyncFailed(hasConflict, rejected.length); | ||
| return; |
There was a problem hiding this comment.
容错率下降的核心点。
旧代码这里是:
const syncResults = await Promise.allSettled(result);
syncResults.forEach((ret) => {
if (ret.status === "fulfilled" && ret.value) {
Object.assign(pushedFileDigestMap, ret.value);
}
});
// 继续往下走 status sync / sync.json / updateFileDigest新代码任一 rejected 即 return,99 个成功 push 的 digest 全部不记录。
后果:
- 下一轮 sync 重新 list,发现 99 个文件 digest 与本地缓存不一致 → 又触发对比 → push 重做
- 重做时远端
version已经变了(自己刚写过)→ 触发 conflict - 用户感知是「同步反复失败」
建议:保留旧的 pushedFileDigestMap 累积逻辑,把「成功 file 的 digest 推进」和「整轮 status / sync.json 写入」解耦——前者无脑推进,后者根据 rejected 数量决定是否跳过。这样 transient 失败下,至少成功的 99 个文件不会下轮重做。
| const rejectedStatus = statusResults.filter((ret) => ret.status === "rejected"); | ||
| if (rejectedStatus.length) { | ||
| this.notifySyncFailed(false, rejectedStatus.length); | ||
| return; |
There was a problem hiding this comment.
status sync 也一刀切退出。
旧代码这里是 await Promise.allSettled(...) 后不检查 rejected,继续写 scriptcat-sync.json。这是有意为之——status sync 是 best-effort,单条 enable/sort 失败不应阻止全局状态写回。
新代码改成「任一失败即 return」,会让单条 enableScript 失败(例如某个脚本被禁用过程中触发了边界错误)阻止 sync.json 写入,进而下轮所有脚本都要重做 status 同步。
建议:保持 best-effort 语义,或只在多数失败时才中止。
| logger.info("delete success"); | ||
| } catch (e) { | ||
| logger.error("delete file error", Logger.E(e)); | ||
| throw e; |
There was a problem hiding this comment.
deleteCloudScript 从「吞错」改为「抛错」。
旧代码:
} catch (e) {
logger.error("delete file error", Logger.E(e));
// 没有 throw
}配合 scriptsDelete 里的 for (const { uuid } of items) 循环——旧代码单条删除失败循环继续,新代码一条失败立刻中断后续所有 uuid 的删除。
问题:用户一次性删除 10 个脚本,第 3 个网络抖动 → 后 7 个都不会被推到云端,但本地已经删了,下次也不会重试(因为 mq 事件已经消费)。数据持久错配。
建议:循环内单条用 try/catch 收集失败,循环结束后统一上报;或者把删除事件持久化到队列里支持重试。
| logger.info("pull script success"); | ||
| } catch (e) { | ||
| logger.error("pull script error", Logger.E(e)); | ||
| throw e; |
There was a problem hiding this comment.
pullScript 从「吞错」改为「抛错」。
旧代码这里是静默 log,新代码 throw 后冒泡到上面 syncOnceInternal 的 rejected.length 检查 → 整轮 sync 退出。
实际场景:云端某个脚本 meta JSON 被外部工具改坏了 → JSON.parse 抛错 → 整个账号的同步永远停在那条坏数据上,直到用户人工去云盘修复。
建议:区分错误类型。JSON.parse 失败、prepareScriptByCode 失败属于「该文件本身坏了」,应该跳过该文件而不是阻止整轮;只有 fs 层的 conflict / auth / network 才该冒泡。
| logger.warn("cleanup newly created script after meta write failure failed", Logger.E(cleanupError)); | ||
| }); | ||
| } | ||
| throw e; |
There was a problem hiding this comment.
push 失败时清理孤儿 .user.js 用 expectedDigest: scriptDigest(本地 MD5),但部分 provider(OneDrive/WebDAV/S3 = etag, Dropbox = content_hash)远端 digest 不是 MD5。
注释已经承认「清理可能失败,只会留下 orphan」,但 orphan 的代价被低估了:
- 下一轮 sync 看到 orphan
.user.js(无 meta)会触发 skip orphan 分支 - 这台设备的本地 script 还在 →
scriptMap命中 → 走 push 分支 - push 时
remoteFiles.script命中 orphan → 用 orphan 的 digest 作为expectedDigest
这条路径最终能收敛,但中间会让其他设备误判为「另一台正在半上传」,导致其他设备的 sync.json 写回时保留旧 status。
建议:cleanup 这里改成无 digest 守卫的窄场景删除(仅在「本次新建且无远端快照」时),或者 writer 写完后返回远端真实 digest。
| await this.updateFileDigest(fs, pushedFileDigestMap); | ||
| }).catch((e) => { | ||
| this.logger.error("push script on install error", Logger.E(e)); | ||
| this.notifySyncFailed(isConflictError(e), 1); |
There was a problem hiding this comment.
install 触发的云推送失败 → 弹通知。
旧代码是 this.logger.error(...) 静默。
场景:用户装一个脚本,本地装好了,云端 push 因为 429 / 网络抖动失败 → 用户看到「同步失败」通知。但用户的本地操作完全成功,下次 syncOnce 会自动补上 push。这种情况通知就是噪声。
建议:transient 失败这里加 1-2 次重试,仍失败再降级通知;或者干脆不通知,让定时 syncOnce 自然兜底。
| await this.updateFileDigest(fs); | ||
| }).catch((e) => { | ||
| this.logger.error("delete cloud script error", Logger.E(e)); | ||
| this.notifySyncFailed(isConflictError(e), 1); |
There was a problem hiding this comment.
同上,delete 触发的云推送失败也弹通知。
这条路径比 install 更敏感:用户删 10 个脚本,第 3 个失败 → 后 7 个完全没被处理(因为 deleteCloudScript 现在 throw,for 循环中断)→ 但只弹一条「同步失败」通知,没说后面 7 个没删。
建议:循环内逐条 catch,最后聚合失败列表通知;或把 delete 任务写到持久队列做重试。
| async updateFileDigest(fs: FileSystem, knownFileDigestMap: FileDigestMap = {}) { | ||
| const newList = await fs.list(); | ||
| const newFileDigestMap: FileDigestMap = {}; | ||
| let newList = await fs.list(); |
There was a problem hiding this comment.
updateFileDigest list-retry 一次的启发式补丁。
这是把 provider 的最终一致性问题向上抛给同步层处理,而不是在 provider 层解决。更干净的设计是:FileWriter.write() 返回该文件最终的 FileInfo(含远端 digest 和 version),调用方直接用,不必上传后再 round-trip list。
现状代价:每轮 sync 都做这个判断(some + 嵌套 some,O(n²)),脚本多时是无谓开销。
| if (rejected.length) { | ||
| const hasConflict = rejected.some((ret) => isConflictError(ret.reason)); | ||
| rejected.forEach((ret, idx) => { | ||
| this.logger.warn(`sync task #${idx} failed`, Logger.E(ret.reason)); |
There was a problem hiding this comment.
logger.warn 逐条记每个失败,但用户只看到一条聚合通知 notifySyncFailed。失败原因(429? auth? conflict?)只在 log 里——而扩展用户基本不会去看 service worker console。
建议:通知里至少带 task 数 + 首个错误类型(conflict/network/auth),让用户能粗略判断是不是该立刻处理。
0308d03
补充测试:
|
概要
本 PR 修复云同步在多设备并发写入、删除、拉取/推送失败时可能出现的静默覆盖、状态污染和错误推进本地同步状态问题。
核心目标是:云同步写入脚本、元数据和
scriptcat-sync.json时,不再无条件基于过期远端状态覆盖;而是根据各 provider 能力使用version/digest/rev/ ETag 等信息建立写入前置检查或条件写入。若远端已被其他设备修改,当前设备会识别为冲突或失败,并停止继续更新scriptcat-sync.json与本地file_digest,避免把错误状态写回本地或云端。本 PR 同时补齐 provider 条件写入/删除能力、删除幂等性、请求错误类型化、Google Drive 重名保护、tombstone 删除收敛、同步失败通知、选中脚本导出失败处理、Service Worker alarm 错误处理以及相关测试。
主要改动
1. 扩展 filesystem 通用接口
FileInfo新增version?: stringFileCreateOptions新增:expectedDigestexpectedVersioncreateOnlyFileDeleteOptions:expectedDigestexpectedVersionFileSystem.delete()支持传入FileDeleteOptionsFileSystemError新增unsupportedfileConflictError()unsupportedConditionalWriteError()conflict/unsupported标识写入冲突和 provider 不支持的场景LimiterFileSystem.delete()会透传FileDeleteOptions,确保 limiter 包装后仍保留条件删除语义2. 条件 header 生成逻辑复用
buildConditionalHeaders(opts?: FileCreateOptions)buildExpectedHeaders(opts?: FileDeleteOptions)If-None-Match: *:用于createOnlyIf-Match:用于expectedVersion/expectedDigestIf-None-Match,继续使用 webdav client 的overwrite: false处理 create-only 语义3. Provider 写入/删除前置条件与冲突处理
S3
create()透传完整FileCreateOptionsdelete()支持FileDeleteOptionslist()暴露:digest: 去除引号后的 ETagversion: provider 原始 ETagPUT写入支持:If-None-Match: *用于createOnlyIf-Match用于expectedVersion/expectedDigestDELETE支持If-Match409/412统一转换为fileConflictError("s3", ...)delete()继续保持不存在时幂等成功list()不再为每个对象额外发送HEAD读取 metadata createtime,避免目录列表产生额外请求;创建时间使用对象LastModifiedWebDAV
create()透传FileCreateOptionsdelete()支持FileDeleteOptionslist()将 ETag 暴露为digest/versionIf-Match条件更新createOnly创建保护overwrite: false实现DELETE支持If-Match409/412统一转换为fileConflictError("webdav", ...)delete()对 404 保持幂等成功Dropbox
create()透传FileCreateOptionsdelete()支持FileDeleteOptionslist()将 Dropboxrev暴露为versionoverwritemode,不再先做 metadataexists()preflightexpectedVersion时使用 DropboxupdatemodecreateOnly时使用addmodeexpectedDigest但没有expectedVersion时通过unsupportedConditionalWriteError()明确报unsupported_conditional_writeincorrect_offset以及已类型化的FileSystemError(conflict: true)会统一转换 / 保持为 Dropbox 冲突错误rev/content_hash的 best-effort preflightGoogle Drive
create()透传FileCreateOptionsdelete()支持FileDeleteOptionslist()请求version字段,并暴露 opaque version token:fileId:versiondelete()遇到 typed not-found 时保持幂等成功,并清理 stale path cacheversion/md5Checksum的 best-effort preflightfindFileInDirectory()改为:findFilesInDirectory()fileConflictError("googledrive", ...)expectedVersion解析出fileId和versionversion412 versionMismatchgenerateIdsIf-None-Match: *createOnly会在创建后再次检查同名文件OneDrive
create()透传FileCreateOptionsdelete()支持FileDeleteOptionslist()将 eTag 暴露为:digestversiondelete()对 typed not-found 保持幂等成功DELETE支持If-MatchIf-None-Match: *用于createOnlyIf-Match用于expectedVersion/expectedDigestfailreplaceBaidu
expectedVersion明确标记为不支持,并通过unsupportedConditionalWriteError()抛出unsupported_conditional_writeexpectedDigest通过写入前list()做 best-effort preflightcreateOnly:rtype=0,要求百度服务端拒绝覆盖fileConflictError("baidu", ...)expectedDigest通过 preflight 后仍使用默认覆盖语义rtype=3delete()支持基于expectedDigest的 best-effort preflightdelete()对文件不存在 errno 保持幂等成功4. 云同步写入正确性改进
getWriteOptions(modifiedDate, remoteFile)createOnlyversion:使用expectedVersionversion但有digest:使用expectedDigestgetDeleteOptions(remoteFile)versionversion时回退到digestpushScript()现在接收远端脚本 / meta 文件信息,并分别带写入前置条件:${uuid}.user.js${uuid}.meta.jsoncreateOnlyversion/digest作为写入前置条件scriptcat-sync.json写入也改为使用远端version/digest前置条件list()远端状态,再带前置条件写入list()远端状态,再带前置条件删除 / 写 tombstone5. 同步失败时避免污染本地状态
syncOnceInternal()会检查 push / pull / status sync 的 rejected taskscriptcat-sync.jsonfile_digestscriptcat-sync.json写入失败时会被捕获:pullScript()失败后不再静默吞掉异常,而是继续抛出,让上层停止状态推进6. digest cache 与 tombstone digest 处理
FileDigestMapupdateFileDigest()会先读取云端列表fs.list()结果中,会再重试一次 liststorage.setfile_digest或scriptcat-sync.json的成功状态7. tombstone 删除收敛与 orphan 状态处理
pullScript()现在会先读取 meta.user.js.user.js和 tombstone.meta.json时,会优先处理 tombstone,避免残留脚本长期无法收敛.user.js、没有.meta.json时跳过本轮处理,避免另一台设备半上传时被误删expectedDigest8. 选中脚本备份导出失败处理
9. 同步失败通知与文案
notifySyncFailed(hasConflict: boolean, rejectedCount: number)notification.script_sync_failednotification.script_sync_failed_descnotification.script_sync_conflict_descde-DEen-USja-JPru-RUvi-VNzh-CNzh-TW10. Service Worker alarm 错误处理
cloudSyncalarm 调用链增加.catch()测试覆盖
本 PR 补充了各 provider、filesystem utils、limiter、备份导出和同步流程的单元测试,覆盖:
version暴露buildConditionalHeaders()行为:createOnly优先级高于 expected tokenexpectedVersion优先于expectedDigestexpectedDigest时生成If-MatchbuildExpectedHeaders()行为FileSystemError(conflict: true)unsupportedLimiterFileSystem.delete()透传FileDeleteOptionsversion保留原始 ETag,digest保留去引号 ETagoverwrite: falsertype=0expectedDigest成功时仍使用rtype=3pushScript()对新建文件使用createOnlypushScript()对已有文件传递远端expectedVersion/expectedDigestscriptcat-sync.json使用 create-only 或 expectedVersion / expectedDigest 条件写入scriptcat-sync.json写入失败时通知并跳过 digest 更新.user.js删除收敛.user.jswithout meta 的跳过逻辑cloudSyncalarm 异常捕获解决的问题
scriptcat-sync.json.user.js长期无法收敛