Skip to content

-#1438

Closed
cyfung1031 wants to merge 1 commit into
mainfrom
fix/sync/015b
Closed

-#1438
cyfung1031 wants to merge 1 commit into
mainfrom
fix/sync/015b

Conversation

@cyfung1031
Copy link
Copy Markdown
Collaborator

概要

本 PR 修复云同步在多设备并发写入时可能出现的静默覆盖、状态污染和错误推进本地同步状态问题。

核心目标是:云同步写入脚本、元数据和 scriptcat-sync.json 时,不再无条件基于过期远端状态覆盖;而是根据各 provider 能力使用 version / digest / rev / ETag 等信息建立写入前置检查或条件写入。若远端已被其他设备修改,当前设备会识别为冲突或失败,并停止继续更新 scriptcat-sync.json 与本地 file_digest,避免把错误状态写回本地或云端。

本 PR 同时补齐 provider 条件写入能力、删除幂等性、请求错误类型化、Google Drive 重名保护、同步失败通知、认证并发去重、选中脚本导出失败处理以及相关测试。

主要改动

1. 扩展 filesystem 通用接口

  • FileInfo 新增 version?: string
    • 用于承载 provider-specific 写入前置 token,例如 ETag、rev、version、opaque id/version token。
  • FileCreateOptions 新增:
    • expectedDigest
    • expectedVersion
    • createOnly
  • 移除 FileCreateOptions.overwrite
  • FileSystemError 新增 unsupported
  • 新增错误构造 helper:
    • fileConflictError()
    • unsupportedConditionalWriteError()
  • 移除未使用的 isUnsupportedError()
  • 统一使用 conflict / unsupported 标识写入冲突和 provider 不支持的场景

2. 条件写入 header 生成逻辑复用

  • 新增 buildConditionalHeaders(opts?: FileCreateOptions)
  • 统一生成:
    • If-None-Match: *:用于 createOnly
    • If-Match:用于 expectedVersion / expectedDigest
  • S3、OneDrive、WebDAV 复用该工具函数,减少重复条件 header 逻辑
  • WebDAV 会删除 If-None-Match,继续使用 webdav client 的 overwrite: false 处理 create-only 语义
  • 新增 buildConditionalHeaders() 单元测试,覆盖:
    • createOnly 优先级高于 expected token
    • expectedVersion 优先于 expectedDigest
    • 仅有 expectedDigest 时生成 If-Match
    • 无条件时不生成 header

3. 认证流程并发去重

  • AuthVerify() 新增 authTokenPromises
  • 当 token 不存在或缺少 access token 时,同一网盘类型的并发认证请求会复用同一个 promise
  • 首次授权场景下:
    • 只打开一次授权页
    • 只请求一次 token
    • 只保存一次 token
  • 避免多个同步任务同时触发重复授权 / token 获取请求

4. Provider 写入前置条件与冲突处理

S3

  • create() 透传完整 FileCreateOptions
  • list() 暴露:
    • digest: 去除引号后的 ETag
    • version: provider 原始 ETag
  • PUT 写入支持:
    • If-None-Match: * 用于 createOnly
    • If-Match 用于 expectedVersion / expectedDigest
  • 将 S3 409 / 412 统一转换为 fileConflictError("s3", ...)
  • list() 不再为每个对象额外发送 HEAD 读取 metadata createtime,避免目录列表产生额外请求;创建时间使用对象 LastModified

WebDAV

  • create() 透传 FileCreateOptions
  • list() 将 ETag 暴露为 version
  • 写入支持:
    • If-Match 条件更新
    • createOnly 创建保护
  • create-only 通过 webdav client 的 overwrite: false 实现
  • 将 WebDAV 409 / 412 统一转换为 fileConflictError("webdav", ...)

Dropbox

  • create() 透传 FileCreateOptions
  • list() 将 Dropbox rev 暴露为 version
  • 写入支持:
    • 普通写入直接使用 overwrite mode,不再先做 metadata exists() preflight
    • expectedVersion 时使用 Dropbox update mode
    • createOnly 时使用 add mode
  • expectedDigest 但没有 expectedVersion 时通过 unsupportedConditionalWriteError() 明确报 unsupported_conditional_write
  • 上传冲突、409、incorrect_offset 以及已类型化的 FileSystemError(conflict: true) 会统一转换 / 保持为 Dropbox 冲突错误

Google Drive

  • create() 透传 FileCreateOptions
  • list() 请求 version 字段,并暴露 opaque version token:
    • fileId:version
  • raw response 路径在非 2xx 时改为抛 typed request error
  • delete() 遇到 typed not-found 时保持幂等成功,并清理 stale path cache
  • findFileInDirectory() 改为:
    • 基于 findFilesInDirectory()
    • 检测到同名重复文件时抛 fileConflictError("googledrive", ...)
  • 更新已有文件时:
    • expectedVersion 解析出 fileIdversion
    • 更新前显式读取当前 Google Drive 文件 version
    • 若当前 version 与期望 version 不一致,则抛 412 versionMismatch
    • 该校验是 best-effort preflight,用于尽早发现 stale local state;Google Drive writer 当前没有原子 compare-and-swap update 路径
  • 新建文件时:
    • 不再调用 generateIds
    • 不再设置 If-None-Match: *
    • createOnly 会在创建后再次检查同名文件
    • 若发现并发重名,best-effort 删除刚创建的文件并抛冲突

OneDrive

  • create() 透传 FileCreateOptions
  • list() 将 eTag 暴露为:
    • digest
    • version
  • raw response 路径在非 2xx 时改为抛 typed request error
  • delete() 对 typed not-found 保持幂等成功
  • simple upload 和 upload session 均支持条件写入:
    • If-None-Match: * 用于 createOnly
    • If-Match 用于 expectedVersion / expectedDigest
  • upload session 的 conflict behavior:
    • create-only 时使用 fail
    • 默认覆盖时继续使用 replace
  • 移除 writer 中未使用的 md5 helper

Baidu

  • expectedVersion 明确标记为不支持,并通过 unsupportedConditionalWriteError() 抛出 unsupported_conditional_write
  • expectedDigest 通过写入前 list() 做 best-effort preflight
    • 该检查只能在上传前发现本地状态过期
    • Baidu 不暴露原子 compare-and-swap upload 能力
  • createOnly
    • 写入前检查目标是否已存在
    • 上传参数使用 rtype=0,要求百度服务端拒绝覆盖
    • 服务端返回文件已存在类错误时转换为 fileConflictError("baidu", ...)
  • 普通 expectedDigest 通过 preflight 后仍使用默认覆盖语义 rtype=3
  • delete() 对文件不存在 errno 保持幂等成功

5. 云同步写入正确性改进

  • 新增 getWriteOptions(modifiedDate, remoteFile)
    • 远端文件不存在:使用 createOnly
    • 远端文件存在且有 version:使用 expectedVersion
    • version 但有 digest:使用 expectedDigest
  • pushScript() 现在接收远端脚本 / meta 文件信息,并分别带写入前置条件:
    • ${uuid}.user.js
    • ${uuid}.meta.json
  • 新建远端脚本文件时使用 createOnly
  • 更新已有远端脚本文件时使用远端 version / digest 作为写入前置条件
  • scriptcat-sync.json 写入也改为使用远端 version / digest 前置条件
  • install 事件触发的云端推送会先 list() 远端状态,再带前置条件写入
  • 删除云端脚本失败时不再吞掉异常,会向调用方抛出,便于通知用户并阻止错误状态推进

6. 同步失败时避免污染本地状态

  • syncOnceInternal() 会检查 push / pull / status sync 的 rejected task
  • 只要有失败:
    • 不写入或继续推进 scriptcat-sync.json
    • 不更新本地 file_digest
    • 触发同步失败通知
  • scriptcat-sync.json 写入失败时会被捕获:
    • 冲突错误显示冲突通知
    • 普通失败显示同步失败通知
    • 不再继续更新 digest cache
  • pullScript() 失败后不再静默吞掉异常,而是继续抛出,让上层停止状态推进
  • status 同步失败时也会停止后续 digest 更新
  • install/delete 触发的云同步失败会通知用户

7. digest cache 与 tombstone digest 处理

  • 统一使用 FileDigestMap
  • updateFileDigest() 会先读取云端列表
  • 如果刚上传的文件暂时没有出现在 fs.list() 结果中,会再重试一次 list
  • 如果文件仍未出现在云端列表中,才使用本次 push 返回的 known digest 作为兜底
  • 如果 provider 已经返回该文件,则保留 provider 返回的云端 digest
  • 不再因为本地 md5 与 S3/WebDAV/OneDrive 等 provider 原生 digest 格式不同而覆盖云端 digest,避免下次同步误判
  • tombstone digest cache 会批量写入,避免旧记录较多时频繁 storage.set
  • tombstone digest cache 即使在后续同步任务失败时也允许写入
    • 它只是“某个 meta digest 已确认是 tombstone”的辅助事实
    • 不会推进 file_digestscriptcat-sync.json 的成功状态
    • 用于帮助下一轮继续收敛残留删除

8. 选中脚本备份导出失败处理

  • 当用户明确选择一组脚本 uuid 导出时,如果其中任意脚本缺失或导出失败,不再静默跳过
  • 会先收集并记录所有失败项,然后让本次导出整体失败
  • 避免生成不完整备份而用户无感
  • 未指定 uuid 的普通全量导出行为不受该逻辑影响

9. 同步失败通知与文案

  • 新增 notifySyncFailed(hasConflict: boolean, rejectedCount: number)
  • 新增 / 更新多语言通知文案:
    • notification.script_sync_failed
    • notification.script_sync_failed_desc
    • notification.script_sync_conflict_desc
  • 覆盖语言:
    • de-DE
    • en-US
    • ja-JP
    • ru-RU
    • vi-VN
    • zh-CN
    • zh-TW

10. Service Worker alarm 错误处理

  • cloudSync alarm 调用链增加 .catch()
  • 避免构建 filesystem 或执行同步过程中的异常变成未处理 promise rejection

测试覆盖

本 PR 补充了各 provider、认证流程、filesystem utils、备份导出和同步流程的单元测试,覆盖:

  • provider version 暴露
  • 条件写入 header / mode / conflict behavior
  • buildConditionalHeaders() 行为
  • create-only 写入保护
  • 条件写入冲突转换为 FileSystemError(conflict: true)
  • 不支持条件写入时返回 unsupported
  • 删除文件不存在时保持幂等
  • 初始授权并发验证只打开一次授权页、请求一次 token、保存一次 token
  • token refresh 并发复用
  • Google Drive stale cache 清理
  • Google Drive 同名重复检测与 create-only best-effort 删除
  • Google Drive 更新前 best-effort version 校验
  • Google Drive create-only 不再生成 file id
  • S3 version 保留原始 ETag,digest 保留去引号 ETag
  • S3 list 不为每个对象额外 HEAD 读取 metadata
  • Dropbox 普通写入直接使用 overwrite mode,不再 metadata preflight
  • Dropbox 已类型化 conflict 错误识别
  • Baidu best-effort expectedDigest 成功时仍使用 rtype=3
  • pushScript() 对新建文件使用 createOnly
  • pushScript() 对已有文件传递远端 expectedVersion
  • 脚本/meta 写入失败时不继续推进状态和 digest
  • scriptcat-sync.json 使用 create-only 或 expectedVersion 条件写入
  • scriptcat-sync.json 写入失败时通知并跳过 digest 更新
  • push / pull / status sync 失败时跳过 status 写入和 digest cache 更新
  • digest cache list retry 与 known digest 兜底逻辑
  • tombstone digest cache 的写入语义
  • 选中脚本导出时缺失脚本会导致导出失败
  • install/delete 触发的云同步失败通知路径

解决的问题

  • 避免多设备同步时 last-write-wins 静默覆盖其他设备的新改动
  • 避免远端冲突后仍写入或推进 scriptcat-sync.json
  • 避免冲突、pull 失败、status sync 失败或 sync status 写入失败后错误更新本地 digest cache
  • 避免脚本文件与 meta 文件部分写入失败后继续推进本地同步状态
  • 避免 Google Drive 同名文件导致错误更新或状态错乱
  • 减少 S3 list 的额外 HEAD 请求开销
  • 减少 Dropbox 普通写入前不必要的 metadata 查询
  • 避免首次授权并发时重复打开授权页、重复请求 token 或重复保存 token
  • 避免用户选择导出指定脚本时,因为部分 uuid 缺失而静默生成不完整备份
  • 明确 tombstone digest cache 与成功同步状态的边界,帮助后续同步继续收敛残留删除
  • 收口 conflict / unsupported 条件写入错误构造,减少 provider 实现重复代码
  • 补齐 Discussion Sync-related Coding Issue #1237 中剩余云同步正确性问题:
    • 条件写入 / 前置检查防止静默覆盖
    • delete 幂等性补齐
    • OneDrive / Google Drive raw response 错误类型化
    • sync 冲突时不推进本地 digest cache
    • install 触发的云推送也使用远端状态作为写入前置条件

@cyfung1031 cyfung1031 closed this May 11, 2026
@cyfung1031 cyfung1031 changed the title fix(sync): 修复云同步多设备冲突下的静默覆盖与状态污染问题 - May 11, 2026
@CodFrm CodFrm deleted the fix/sync/015b branch May 11, 2026 01:53
@CodFrm CodFrm restored the fix/sync/015b branch May 11, 2026 01:55
@CodFrm CodFrm deleted the fix/sync/015b branch May 11, 2026 01:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant