From 5b54a8814d732b4f06c33166c0651a21a40615c6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:49:17 +0900 Subject: [PATCH 01/73] Create sync-research.md --- docs/sync-research.md | 434 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/sync-research.md diff --git a/docs/sync-research.md b/docs/sync-research.md new file mode 100644 index 000000000..cc4dc356f --- /dev/null +++ b/docs/sync-research.md @@ -0,0 +1,434 @@ +# 云同步一致性与生产兼容性研究 + +## 目标 + +本分支用于研究和修复 ScriptCat 云同步在多设备、多 provider、网络异常和并发修改场景下的正确性问题。 + +核心目标: + +1. 避免云端文件被静默覆盖。 +2. 避免失败同步污染本地 `file_digest` 或远端 `scriptcat-sync.json`。 +3. 保持现有生产用户云端数据兼容。 +4. 不把 per-file best-effort 同步退化成整轮 all-or-nothing。 +5. 将 provider 能力、同步策略、用户通知分层处理。 + +## 现有同步数据结构 + +### `.user.js` + +每个脚本的源码文件。 + +命名规则: + +```text +.user.js +```` + +### `.meta.json` + +每个脚本的同步元信息。 + +旧格式大致包含: + +```ts +type SyncMeta = { + uuid: string; + origin?: string; + downloadUrl?: string; + checkUpdateUrl?: string; + isDeleted?: boolean; +}; +``` + +其中 `isDeleted: true` 表示 tombstone 删除标记。 + +### `scriptcat-sync.json` + +保存脚本启用状态、排序和状态更新时间。 + +```ts +type ScriptcatSync = { + version: string; + status: { + scripts: { + [uuid: string]: { + enable: boolean; + sort: number; + updatetime: number; + } | undefined; + }; + }; +}; +``` + +### `file_digest` + +保存在本地 chrome storage 中,用于判断云端文件是否相对上轮同步有变化。 + +现有格式: + +```ts +type FileDigestMap = { + [filename: string]: string; +}; +``` + +生产兼容要求: + +* 旧用户只有 digest,没有 version。 +* 旧用户的云端文件没有 provider token。 +* 新逻辑必须能读旧格式。 +* 新逻辑不能要求用户手动迁移。 + +## 已确认问题 + +### 1. 静默覆盖 + +旧接口中 `fs.create(path)` 默认覆盖远端文件。多设备并发时,如果设备 A 和设备 B 基于同一旧快照修改同一脚本,后写入者会覆盖先写入者,且用户无提示。 + +### 2. 失败后状态污染 + +若上传、下载、删除中某个步骤失败,但后续仍更新 `file_digest` 或 `scriptcat-sync.json`,下轮同步会误以为失败操作已经成功,导致本地与云端状态永久偏离。 + +### 3. `pullScript` / `deleteCloudScript` 错误粒度不足 + +旧实现中某些错误只记录日志,不向调用方抛出。这样 `syncOnceInternal` 无法区分成功和失败,容易把失败任务当作成功推进状态。 + +### 4. orphan `.user.js` 风险 + +远端可能出现只有 `.user.js`、没有 `.meta.json` 的半上传状态。此时不能安装、删除或覆盖该脚本,应跳过本轮并保留远端 status。 + +### 5. provider 能力不一致 + +不同后端能力差异很大: + +* WebDAV / S3 / OneDrive:通常可用 ETag / If-Match。 +* Dropbox:有 content_hash,但冲突错误需要 typed error,不应靠字符串匹配。 +* Google Drive:可读取 file id、version、md5Checksum,但 create / update 的原子 CAS 能力有限。 +* Baidu:部分操作只能 preflight,不能假装 atomic。 +* Zip:本地备份用途,通常不参与云端 CAS。 + +## 设计原则 + +### 原则 1:保持 per-file best-effort + +不能因为一个脚本失败,让其他 99 个成功脚本全部不推进。 + +正确行为: + +* 成功文件更新自己的 digest。 +* 失败文件保留旧 digest。 +* 下轮只重试失败或有变化的文件。 +* 冲突只阻塞冲突文件。 + +### 原则 2:状态推进必须和真实成功绑定 + +只有真实成功完成的文件,才能推进对应 digest。 + +不能出现: + +* 写文件失败但更新 digest。 +* 删除失败但写 tombstone digest。 +* pull 失败但认为脚本已更新。 +* syncStatus 失败但覆盖远端 status。 + +### 原则 3:filesystem 不承担业务冲突策略 + +filesystem 层应该暴露: + +* 读取文件 +* 写入文件 +* 删除文件 +* 条件写入能力 +* typed error +* provider capabilities + +同步层负责: + +* 何时 push +* 何时 pull +* 何时认为 conflict +* 如何通知用户 +* 如何推进 digest +* 如何处理 tombstone + +### 原则 4:provider token 必须 opaque + +不要把 Google Drive 的 `fileId:version` 这类 provider 内部结构塞进通用 `version` 字段。 + +建议: + +```ts +interface FileInfo { + name: string; + path: string; + size: number; + digest: string; + createtime: number; + updatetime: number; + + version?: string; + providerToken?: unknown; +} +``` + +`providerToken` 只由 provider 自己解释。 + +### 原则 5:明确 atomic 与 best-effort + +接口必须区分: + +```ts +type FileSystemCapabilities = { + atomicCompareAndSwap: boolean; + createOnly: boolean; + conditionalDelete: boolean; + nativeDigest: boolean; +}; +``` + +不能把 preflight 实现伪装成 atomic CAS。 + +## 建议实现阶段 + +## Phase 1:研究文档和测试基线 + +提交: + +```text +docs/sync-research.md +``` + +内容: + +* 当前同步机制说明 +* PR #1439 分析 +* CodFrm review 整理 +* provider 能力矩阵 +* 生产兼容原则 +* 测试计划 + +不改 runtime 代码。 + +## Phase 2:修复错误传播和 per-file digest 推进 + +目标: + +* `pullScript` 失败时抛出错误。 +* `deleteCloudScript` 失败时抛出错误。 +* `syncOnceInternal` 收集每个任务结果。 +* 成功任务返回 digest patch。 +* 失败任务不推进 digest。 +* 不因单个任务失败阻塞其他成功任务。 + +伪代码: + +```ts +const results = await Promise.allSettled(tasks); + +const digestPatch: FileDigestMap = {}; +const failures: SyncFailure[] = []; + +for (const result of results) { + if (result.status === "fulfilled") { + Object.assign(digestPatch, result.value?.digestPatch); + } else { + failures.push(classifySyncFailure(result.reason)); + } +} + +await updateFileDigest(fs, digestPatch, { + preserveFailedFiles: true, +}); +``` + +## Phase 3:保护 `scriptcat-sync.json` + +目标: + +* 写入前重新读取远端 `scriptcat-sync.json`。 +* 保留 orphan uuid 的远端 status。 +* 本轮失败文件不得覆盖远端 status。 +* syncStatus 单项失败不导致全局状态污染。 + +建议: + +```ts +const latestSyncFile = await readLatestScriptcatSync(fs); +const merged = mergeStatus({ + base: latestSyncFile, + localChanges, + skippedUuids, + failedUuids, +}); +``` + +## Phase 4:引入 capabilities 和 typed error + +新增或扩展: + +```ts +type FileSystemErrorKind = + | "conflict" + | "stale_snapshot" + | "transient" + | "unsupported" + | "fatal"; + +class FileSystemError extends Error { + kind: FileSystemErrorKind; + status?: number; + retryAfter?: number; +} +``` + +provider 需要把 HTTP 409 / 412 / 429 / 5xx 等转换成 typed error。 + +## Phase 5:条件写入 + +新增: + +```ts +type FileCreateOptions = { + modifiedDate?: number; + expectedDigest?: string; + expectedVersion?: string; + createOnly?: boolean; + providerToken?: unknown; +}; + +type FileDeleteOptions = { + expectedDigest?: string; + expectedVersion?: string; + providerToken?: unknown; +}; +``` + +调用层根据远端 snapshot 生成条件写入参数。 + +## Phase 6:通知和重试 + +目标: + +* transient 错误自动 retry。 +* retry 后仍失败才通知。 +* 通知按 provider / sync round 节流。 +* 用户提示区分: + + * 正在重试 + * 同步冲突 + * 授权失效 + * 网络失败 + * provider 不支持条件写入 + +## Provider 注意事项 + +### WebDAV + +优先使用 ETag / `If-Match`。 + +### S3 + +优先使用 ETag / conditional request。 + +### OneDrive + +检查所有 `nothen=true` 调用点,避免行为改变导致调用者收到非预期响应。 + +### Google Drive + +不要把 `fileId:version` 放进通用 `version`。 + +可用方案: + +* `version` 保持 provider 原生 version。 +* `providerToken` 保存 `{ fileId, version }`。 +* createOnly 如果无法 atomic,应标记为 best-effort。 + +### Dropbox + +不要通过错误 message string 匹配冲突。 + +应在 request 层解析 Dropbox API error,转换成 typed `FileSystemError`. + +### Baidu + +只把明确的 file-exists errno 转换成 conflict。 + +不要把所有非零 errno 都当成 createOnly conflict。 + +### Zip + +保持简单实现,不需要云端 CAS 能力。 + +## 测试矩阵 + +### 同步任务级别 + +1. 99 个 push 成功,1 个 push 失败。 +2. 99 个 pull 成功,1 个 pull 失败。 +3. 删除 10 个脚本,其中 1 个删除失败。 +4. syncStatus 单个 enableScript 失败。 +5. `scriptcat-sync.json` 写入失败。 +6. `file_digest` 更新失败。 + +### 数据兼容 + +1. 旧 `.meta.json` 无 version。 +2. 旧 `file_digest` 只有 digest。 +3. 旧 `scriptcat-sync.json` 无新增字段。 +4. 云端只有 `.user.js`。 +5. 云端只有 `.meta.json` tombstone。 +6. 云端 `.user.js` 和 `.meta.json` 更新时间不一致。 + +### 并发冲突 + +1. 两台设备同时修改同一脚本。 +2. 一台删除,另一台修改。 +3. 一台新增,另一台同时新增同 uuid。 +4. 一台更新 status,另一台更新 code。 +5. 远端文件在 list 和 write 之间变化。 + +### provider + +1. WebDAV ETag mismatch。 +2. S3 ETag mismatch。 +3. OneDrive if-match mismatch。 +4. Dropbox conflict typed error。 +5. Google Drive best-effort preflight race。 +6. Baidu errno 分类。 + +## 暂不建议立即做的事情 + +1. 不建议一次性把所有 provider 都改成复杂 CAS。 +2. 不建议把 filesystem 变成业务冲突处理层。 +3. 不建议新增独立 `tombstone_digest`,除非有明确 GC。 +4. 不建议让任何单文件失败阻塞整轮同步。 +5. 不建议没有测试就修改 Google Drive / Baidu 的删除逻辑。 + +## 最小可接受修复 + +第一轮代码修复可以只做到: + +1. `pullScript` 和 `deleteCloudScript` 失败向上抛出。 +2. `syncOnceInternal` 按文件收集结果。 +3. 成功文件推进 digest,失败文件不推进。 +4. orphan `.user.js` 跳过且保留 status。 +5. 新增测试证明不会再出现 “99 成功 + 1 失败 => 100 个都重复/污染” 的问题。 + +## 验证要求 + +每次提交前记录: + +```text +repo: +branch: +commit: +changed files: +tests run: +tests failed: +tests skipped: +known risks: +``` + +如果测试无法运行,必须写明原因。 From b4fda8601e69a3f52d2a68ff15d824f98cf2dd82 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:22:52 +0900 Subject: [PATCH 02/73] =?UTF-8?q?=F0=9F=93=84=20docs(sync):=20document=20c?= =?UTF-8?q?loud=20sync=20correctness=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 1 + docs/sync-research.md | 544 +++++++++++++++++++++--------------------- 2 files changed, 270 insertions(+), 275 deletions(-) diff --git a/docs/README.md b/docs/README.md index 653ea9e25..363cee575 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ | [`DEVELOP.md`](./DEVELOP.md) | 开发规范:命令、目录结构、编码风格、UI/主题、测试机制、i18n、提交/PR 流程。**写代码前先读。** | | [`VERIFICATION.md`](./VERIFICATION.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例)。**验证改动是否真正跑通时读。** | | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 内部原理深入:多进程模型、消息传递、服务/数据层、GM API、脚本执行、构建管线。 | +| [`sync-research.md`](./sync-research.md) | 云同步一致性研究:现有同步架构、PR #1439 讨论整理、生产兼容原则、分阶段修复计划和测试矩阵。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | ## 翻译 / Translation diff --git a/docs/sync-research.md b/docs/sync-research.md index cc4dc356f..f48a4c67b 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -2,364 +2,340 @@ ## 目标 -本分支用于研究和修复 ScriptCat 云同步在多设备、多 provider、网络异常和并发修改场景下的正确性问题。 +本分支用于研究 ScriptCat 云同步在多设备、多 provider、网络异常、并发修改和旧数据兼容场景下的正确性问题。目标不是把 PR #1439 原样搬进来,而是在 `upstream/main` / `main` 现有同步语义上做可验证、可分阶段合并的修复。 核心目标: 1. 避免云端文件被静默覆盖。 2. 避免失败同步污染本地 `file_digest` 或远端 `scriptcat-sync.json`。 -3. 保持现有生产用户云端数据兼容。 +3. 保持现有生产用户云端数据兼容,不要求手动迁移。 4. 不把 per-file best-effort 同步退化成整轮 all-or-nothing。 -5. 将 provider 能力、同步策略、用户通知分层处理。 +5. 将 provider 能力、同步策略、错误分类和用户通知分层处理。 -## 现有同步数据结构 +## 当前同步架构 -### `.user.js` +入口在 `src/app/service/service_worker/synchronize.ts` 的 `SynchronizeService`。云同步由 `buildFileSystem()` 建立 provider 文件系统,然后通过 `syncOnce()` 进入 `cloud_sync_queue` 串行队列,最终执行 `syncOnceInternal()`。安装脚本和删除脚本的消息也复用同一个队列,避免和定时同步并发写云端。 -每个脚本的源码文件。 +### `syncOnceInternal` -命名规则: +当前流程: -```text -.user.js -```` +1. `fs.list()` 读取 `ScriptCat/sync` 目录。 +2. 按文件名把 `.user.js` 和 `.meta.json` 组装成 `uuidMap`。 +3. 从本地 DAO 读取脚本列表,生成 `scriptMap`。 +4. 如果存在 `scriptcat-sync.json`,读取其中的 `status.scripts` 作为云端状态。 +5. 遍历 `uuidMap` 决定每个 uuid 的动作: + - 本地脚本存在,云端只有 `.meta.json`:如果 meta 是 tombstone,则本地删除;否则删除无效 meta 并重新 push。 + - 本地脚本存在,云端 `.user.js` digest 与 `file_digest` 一致:跳过。 + - 本地脚本更新时间更晚,或云端缺 `.meta.json`:`pushScript()`。 + - 云端更新时间更晚:`pullScript()`。 + - 本地脚本不存在但云端 `.user.js` 和 `.meta.json` 都存在:`pullScript()` 安装。 + - 云端只有 `.user.js`、没有 `.meta.json`:跳过并记录 orphan uuid,避免误删或覆盖半上传文件。 +6. 剩余本地脚本全部 `pushScript()`。 +7. `Promise.allSettled()` 等待所有任务;fulfilled 且返回 digest patch 的任务合并到 `pushedFileDigestMap`。 +8. 如果开启 `syncStatus`,用本地状态和 `scriptcat-sync.json` 的 status 时间戳合并 enable/sort,然后直接 `fs.create("scriptcat-sync.json")` 覆盖写回。 +9. `updateFileDigest(fs, pushedFileDigestMap)` 重新 list 云端,把当前云端 digest 写入本地 `file_digest`;如果刚 push 的文件未出现在 list 里,用本地 md5 兜底。 -### `.meta.json` +重要现状:当前 `syncOnceInternal()` 仍保持 task 级 `Promise.allSettled()`,不会因为单个 push/pull Promise rejected 而停止所有任务;但 `pullScript()` 和 `deleteCloudScript()` 内部会 catch 后只记录日志,导致上层无法知道真实失败。 -每个脚本的同步元信息。 +### `pushScript` -旧格式大致包含: +`pushScript(fs, script)` 当前无条件覆盖写: -```ts -type SyncMeta = { - uuid: string; - origin?: string; - downloadUrl?: string; - checkUpdateUrl?: string; - isDeleted?: boolean; -}; -``` +1. 写 `${uuid}.user.js`,`modifiedDate` 使用脚本 `updatetime || createtime || Date.now()`。 +2. 从 `scriptCodeDAO` 读取源码并写入 `.user.js`。 +3. 写 `${uuid}.meta.json`,内容包含 `uuid`、`origin`、`downloadUrl`、`checkUpdateUrl`。 +4. 返回本地计算的 md5 digest patch,供 `updateFileDigest()` 在云端 list 暂时不可见时兜底。 -其中 `isDeleted: true` 表示 tombstone 删除标记。 +风险:`fs.create()` 的 provider 实现通常是覆盖写或先查再写,当前没有 create-only、expected digest、expected version,也没有原子 CAS。 -### `scriptcat-sync.json` +### `pullScript` -保存脚本启用状态、排序和状态更新时间。 +`pullScript(fs, file, status, existingScript?)` 当前流程: -```ts -type ScriptcatSync = { - version: string; - status: { - scripts: { - [uuid: string]: { - enable: boolean; - sort: number; - updatetime: number; - } | undefined; - }; - }; -}; -``` - -### `file_digest` - -保存在本地 chrome storage 中,用于判断云端文件是否相对上轮同步有变化。 - -现有格式: - -```ts -type FileDigestMap = { - [filename: string]: string; -}; -``` +1. 读取 `.user.js` 源码。 +2. 读取 `.meta.json` 并 `JSON.parse`。 +3. `prepareScriptByCode()` 解析脚本元信息。 +4. 如果有 `scriptcat-sync.json` status,则根据 status 更新时间决定 enable/sort。 +5. `script.installScript({ upsertBy: "sync" })` 写入本地。 +6. catch 所有异常,只记录日志,不向调用方抛出。 -生产兼容要求: - -* 旧用户只有 digest,没有 version。 -* 旧用户的云端文件没有 provider token。 -* 新逻辑必须能读旧格式。 -* 新逻辑不能要求用户手动迁移。 - -## 已确认问题 - -### 1. 静默覆盖 - -旧接口中 `fs.create(path)` 默认覆盖远端文件。多设备并发时,如果设备 A 和设备 B 基于同一旧快照修改同一脚本,后写入者会覆盖先写入者,且用户无提示。 +风险:下载、解析、安装失败都会被视为任务 fulfilled,后续可能继续写 `scriptcat-sync.json` 和 `file_digest`,造成状态污染。另一方面,不能简单把所有错误都抛成整轮失败,因为坏单文件不应卡死其他脚本。 -### 2. 失败后状态污染 +### `deleteCloudScript` -若上传、下载、删除中某个步骤失败,但后续仍更新 `file_digest` 或 `scriptcat-sync.json`,下轮同步会误以为失败操作已经成功,导致本地与云端状态永久偏离。 +`deleteCloudScript(fs, uuid, syncDelete)` 当前流程: -### 3. `pullScript` / `deleteCloudScript` 错误粒度不足 +1. 删除 `${uuid}.user.js`。 +2. 如果 `syncDelete` 为 true,写 `${uuid}.meta.json` tombstone:`{ uuid, isDeleted: true }`。 +3. 如果 `syncDelete` 为 false,删除 `${uuid}.meta.json`。 +4. catch 所有异常,只记录日志,不向调用方抛出。 -旧实现中某些错误只记录日志,不向调用方抛出。这样 `syncOnceInternal` 无法区分成功和失败,容易把失败任务当作成功推进状态。 +风险:删除失败、tombstone 写失败或 meta 删除失败都会被调用方当作成功;`scriptsDelete()` 随后仍会 `updateFileDigest()`。 -### 4. orphan `.user.js` 风险 +### `updateFileDigest` -远端可能出现只有 `.user.js`、没有 `.meta.json` 的半上传状态。此时不能安装、删除或覆盖该脚本,应跳过本轮并保留远端 status。 +`file_digest` 是本地 `ChromeStorage("sync")` 中的 digest cache。当前 `updateFileDigest()` 会: -### 5. provider 能力不一致 +1. `fs.list()` 读取云端当前文件。 +2. 用云端返回的 `file.digest` 生成完整新 map。 +3. 对刚 push 但 list 缺失的文件,用 `knownFileDigestMap` 的本地 md5 兜底。 +4. `storage.set("file_digest", newFileDigestMap)` 全量替换。 -不同后端能力差异很大: +当前实现已经避免在云端 list 返回 provider 原生 digest 时用本地 md5 覆盖它,这对 WebDAV/OneDrive/S3 的 ETag 和 Dropbox content_hash 很关键。 -* WebDAV / S3 / OneDrive:通常可用 ETag / If-Match。 -* Dropbox:有 content_hash,但冲突错误需要 typed error,不应靠字符串匹配。 -* Google Drive:可读取 file id、version、md5Checksum,但 create / update 的原子 CAS 能力有限。 -* Baidu:部分操作只能 preflight,不能假装 atomic。 -* Zip:本地备份用途,通常不参与云端 CAS。 +仍需注意:全量替换适合“完整 list 成功”场景;如果某个文件操作失败但 `updateFileDigest()` 仍基于新 list 全量写入,失败文件也可能被推进。 -## 设计原则 +### `scriptInstall` -### 原则 1:保持 per-file best-effort +非 sync 来源安装脚本时: -不能因为一个脚本失败,让其他 99 个成功脚本全部不推进。 +1. 读取云同步配置。 +2. 如果启用,进入 `cloud_sync_queue`。 +3. `buildFileSystem()`。 +4. `pushScript()`。 +5. `updateFileDigest(fs, pushedFileDigestMap)`。 -正确行为: +失败只记录日志。这个路径的本地安装已经成功,云端失败可由后续定时 sync 补偿;通知需要谨慎,避免 transient 失败造成噪声。 -* 成功文件更新自己的 digest。 -* 失败文件保留旧 digest。 -* 下轮只重试失败或有变化的文件。 -* 冲突只阻塞冲突文件。 +### `scriptsDelete` -### 原则 2:状态推进必须和真实成功绑定 +非 sync 来源删除脚本时: -只有真实成功完成的文件,才能推进对应 digest。 +1. 过滤 `deleteBy === "sync"` 的事件,避免同步拉取/删除造成回灌。 +2. 如果启用云同步,进入 `cloud_sync_queue`。 +3. `buildFileSystem()`。 +4. 顺序调用 `deleteCloudScript()`。 +5. `updateFileDigest(fs)`。 -不能出现: +当前 `deleteCloudScript()` 吞错,所以循环会继续;如果未来改为抛错,必须逐条 catch,否则批量删除中第 3 条失败会阻止后续 7 条处理。 -* 写文件失败但更新 digest。 -* 删除失败但写 tombstone digest。 -* pull 失败但认为脚本已更新。 -* syncStatus 失败但覆盖远端 status。 +## 同步文件语义 -### 原则 3:filesystem 不承担业务冲突策略 - -filesystem 层应该暴露: +### `.user.js` -* 读取文件 -* 写入文件 -* 删除文件 -* 条件写入能力 -* typed error -* provider capabilities +每个脚本的源码文件,命名为 `.user.js`。当前同步判断主要看它的 `digest` 和 `updatetime`。 -同步层负责: +### `.meta.json` -* 何时 push -* 何时 pull -* 何时认为 conflict -* 如何通知用户 -* 如何推进 digest -* 如何处理 tombstone +每个脚本的同步元信息,命名为 `.meta.json`。生产旧格式大致为: -### 原则 4:provider token 必须 opaque +```ts +type SyncMeta = { + uuid: string; + origin?: string; + downloadUrl?: string; + checkUpdateUrl?: string; + isDeleted?: boolean; +}; +``` -不要把 Google Drive 的 `fileId:version` 这类 provider 内部结构塞进通用 `version` 字段。 +旧用户云端可能只有这些字段。新增字段必须 optional,读取时必须容忍缺失。 -建议: +### tombstone 删除标记 -```ts -interface FileInfo { - name: string; - path: string; - size: number; - digest: string; - createtime: number; - updatetime: number; - - version?: string; - providerToken?: unknown; -} -``` +当 `syncDelete` 开启时,删除云端脚本不是简单移除所有文件,而是保留 `.meta.json` 且写入 `isDeleted: true`。其他设备看到“只有 meta 且 isDeleted=true”时会删除本地脚本并通知用户。 -`providerToken` 只由 provider 自己解释。 +不建议立即新增独立 `tombstone_digest`。它会引入生命周期、清理时机和最终一致性下误保留的问题;除非先定义 GC 规则,否则会增加状态面。 -### 原则 5:明确 atomic 与 best-effort +### `scriptcat-sync.json` -接口必须区分: +保存脚本启用状态、排序和更新时间: ```ts -type FileSystemCapabilities = { - atomicCompareAndSwap: boolean; - createOnly: boolean; - conditionalDelete: boolean; - nativeDigest: boolean; +type ScriptcatSync = { + version: string; + status: { + scripts: { + [uuid: string]: { + enable: boolean; + sort: number; + updatetime: number; + } | undefined; + }; + }; }; ``` -不能把 preflight 实现伪装成 atomic CAS。 +当前写回是覆盖式 `fs.create("scriptcat-sync.json")`。生产兼容要求: -## 建议实现阶段 +- 旧文件可能没有未来新增字段。 +- orphan `.user.js` 对应 uuid 的远端 status 必须保留,避免本机把另一台设备半上传状态覆盖掉。 +- 一轮同步中失败的 uuid 不应导致 status 被错误清空或推进。 +- 写回前应尽量基于最新远端状态合并,避免覆盖其他设备刚写入的 status。 -## Phase 1:研究文档和测试基线 +### `file_digest` -提交: +本地 digest cache,当前格式是: -```text -docs/sync-research.md +```ts +type FileDigestMap = { + [filename: string]: string; +}; ``` -内容: - -* 当前同步机制说明 -* PR #1439 分析 -* CodFrm review 整理 -* provider 能力矩阵 -* 生产兼容原则 -* 测试计划 - -不改 runtime 代码。 +生产兼容要求: -## Phase 2:修复错误传播和 per-file digest 推进 +- 旧记录只有 digest,没有 version/provider token。 +- 新逻辑必须能读取旧格式。 +- 新增结构时应支持 string 旧值和 object 新值并存。 +- digest 只能代表“上轮已确认成功同步的远端文件状态”,不能写入失败任务的结果。 -目标: +## Provider 实现差异 -* `pullScript` 失败时抛出错误。 -* `deleteCloudScript` 失败时抛出错误。 -* `syncOnceInternal` 收集每个任务结果。 -* 成功任务返回 digest patch。 -* 失败任务不推进 digest。 -* 不因单个任务失败阻塞其他成功任务。 +当前 `FileSystem` 接口只有 `create(path, { modifiedDate })` 和 `delete(path)`,没有 capabilities、conditional write/delete 或 opaque provider token。 -伪代码: +| Provider | `list()` digest | `create()` 当前语义 | `delete()` 当前语义 | 关键差异 | +| --- | --- | --- | --- | --- | +| WebDAV | `etag` | `putFileContents` 覆盖写 | 404 幂等成功 | 天然可扩展 `If-Match` / create-only,但当前未暴露 | +| S3 | 去引号 ETag | PUT 覆盖写,可写 `x-amz-meta-createtime` | DELETE 幂等,`NoSuchKey` 成功 | 可用 conditional request;当前未传条件头 | +| OneDrive | `eTag` | simple/upload session,conflictBehavior replace | raw Response 路径,404 成功,其他错误字符串化 | 有 typed request error,但 `nothen=true` 调用点仍需审计 | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 无通用原子 CAS;path cache 和同名文件会影响正确性;`nothen=true` 错误类型化不完整 | +| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,not_found 字符串判断为成功 | rev 未暴露;冲突/不存在多靠 message string | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 抛普通 Error | 可用 md5 preflight,但不是 atomic;errno 分类必须精确 | +| Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | -```ts -const results = await Promise.allSettled(tasks); - -const digestPatch: FileDigestMap = {}; -const failures: SyncFailure[] = []; - -for (const result of results) { - if (result.status === "fulfilled") { - Object.assign(digestPatch, result.value?.digestPatch); - } else { - failures.push(classifySyncFailure(result.reason)); - } -} - -await updateFileDigest(fs, digestPatch, { - preserveFailedFiles: true, -}); -``` +`LimiterFileSystem` 当前只对白名单读类操作的 429 自动重试:`verify/open/read/openDir/list/getDirUrl`。`create/createDir/write/delete` 遇到 429 不重试。这是保守的,避免重复非幂等写;后续若要支持写重试,应结合 create-only/CAS 或 provider 幂等能力。 -## Phase 3:保护 `scriptcat-sync.json` +## 已确认问题 -目标: +1. 静默覆盖:`fs.create()` 默认覆盖,两个设备基于旧快照修改同一文件时后写覆盖先写。 +2. 失败后状态污染:push/pull/delete 失败后仍可能写 `file_digest` 或 `scriptcat-sync.json`。 +3. `pullScript()` 吞错:下载、JSON parse、userscript parse、安装失败都无法被上层区分。 +4. `deleteCloudScript()` 吞错:删除或 tombstone 写失败后调用方仍会推进 digest。 +5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 +6. `scriptcat-sync.json` 覆盖写:可能覆盖其他设备刚更新的 enable/sort。 +7. provider 能力不一致:不能用同一个 `expectedVersion` 语义假装所有 provider 都支持 atomic CAS。 +8. 错误类型不完整:部分 provider 有 `FileSystemError`,部分仍抛普通 Error 或字符串。 +9. transient 写失败无有限 retry:429/5xx 在 write/delete 上当前直接失败。 +10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 -* 写入前重新读取远端 `scriptcat-sync.json`。 -* 保留 orphan uuid 的远端 status。 -* 本轮失败文件不得覆盖远端 status。 -* syncStatus 单项失败不导致全局状态污染。 +## PR #1439 分析 -建议: +PR #1439 试图修复多设备并发写入、删除、拉取/推送失败导致的静默覆盖和状态污染。方向上,它抓到了真实问题:写入应带前置条件,失败不能推进本地 digest,provider 错误应类型化,tombstone 和 orphan 需要明确语义。 -```ts -const latestSyncFile = await readLatestScriptcatSync(fs); -const merged = mergeStatus({ - base: latestSyncFile, - localChanges, - skippedUuids, - failedUuids, -}); -``` +### cyfung1031 的改动意图 -## Phase 4:引入 capabilities 和 typed error +- 给 `FileInfo` 增加 version 类 token。 +- 给 create/delete 增加 expected digest/version 和 createOnly。 +- provider 使用 ETag/rev/version/content_hash/md5 做条件写入或 preflight。 +- `syncOnceInternal()` 在失败时避免继续推进 `scriptcat-sync.json` 和 `file_digest`。 +- 处理 orphan `.user.js`、tombstone 收敛、Google Drive 重名、Baidu errno、OneDrive/Google raw response 等具体问题。 +- 增加同步失败通知和大量 provider/sync 测试。 -新增或扩展: +### CodFrm review 核心意见 -```ts -type FileSystemErrorKind = - | "conflict" - | "stale_snapshot" - | "transient" - | "unsupported" - | "fatal"; - -class FileSystemError extends Error { - kind: FileSystemErrorKind; - status?: number; - retryAfter?: number; -} -``` +CodFrm 的核心担忧是:PR 修了“报错”,但没有保留“消化错误”的粒度。旧代码的问题是 silent data loss,新代码的风险是 all-or-nothing 事务。 -provider 需要把 HTTP 409 / 412 / 429 / 5xx 等转换成 typed error。 +需要吸收的意见: -## Phase 5:条件写入 +- filesystem 包不应承担业务冲突策略;它应暴露原子能力、条件操作和 typed error,同步层决定业务冲突处理。 +- 99 个成功 + 1 个失败时,成功文件 digest 应推进,失败文件保留旧 digest,下轮只重试失败文件。 +- transient、conflict、fatal、unsupported 应分类,不能都当作整轮失败。 +- status sync 是 best-effort,单个 enable/sort 更新失败不应让整轮 sync 卡死。 +- `pullScript()` 不应吞掉真实失败,但坏 `.meta.json` 或坏 userscript 也不应卡死整个账号。 +- `deleteCloudScript()` 抛错后,`scriptsDelete()` 必须逐条 catch,不能中断后续 uuid。 +- 安装/删除事件触发的同步通知要节流,transient 失败可让定时同步兜底。 +- `updateFileDigest()` 的 list retry / known digest 兜底不能掩盖 provider 最终一致性和 digest 格式差异。 +- Google Drive 的 `fileId:version` 不应被包装成通用 version 语义。 +- `tombstone_digest` 会带来额外生命周期和 GC 问题。 -新增: +### Copilot / reviewer 指出的问题 -```ts -type FileCreateOptions = { - modifiedDate?: number; - expectedDigest?: string; - expectedVersion?: string; - createOnly?: boolean; - providerToken?: unknown; -}; +- `FileSystem.delete()` 签名改变后必须更新所有实现,例如 zip,否则 TypeScript 接口不匹配。 +- Baidu `createOnly` 不能把所有非 0 `errno` 都判为 conflict;只能匹配明确 file-exists / duplicate-name 错误。 +- Dropbox 不能靠 message string 长期判断冲突,应在 request 层转换 typed error。 +- OneDrive / Google Drive 的 `nothen=true` raw response 调用点必须全部审计,否则 typed error 不完整。 -type FileDeleteOptions = { - expectedDigest?: string; - expectedVersion?: string; - providerToken?: unknown; -}; -``` +### 正确方向 -调用层根据远端 snapshot 生成条件写入参数。 +- 保持旧数据可读,新增字段 optional。 +- 成功文件和失败文件分开推进状态。 +- provider 层暴露能力和原子操作,不写业务策略。 +- 能 atomic 的 provider 使用原生条件写;只能 preflight 的 provider 明确标记 best-effort。 +- orphan `.user.js` without `.meta.json` 跳过并保留远端 status。 +- `scriptcat-sync.json` 写回前合并远端最新状态,避免覆盖其他设备。 -## Phase 6:通知和重试 +### 可能过度设计或破坏语义的方向 -目标: +- 一次性改所有 provider、通知、tombstone digest、导出、alarm,改动面过大。 +- 任一文件失败就跳过全部 digest 更新,导致成功文件下轮重做甚至自冲突。 +- filesystem 层承载“同步冲突业务策略”,增加包职责。 +- 将 Google Drive `fileId:version` 暴露为通用 `version`。 +- 独立 `tombstone_digest` 未定义 GC 前就落地。 +- 每个 install/delete transient 失败都通知用户。 -* transient 错误自动 retry。 -* retry 后仍失败才通知。 -* 通知按 provider / sync round 节流。 -* 用户提示区分: +## 兼容生产数据的设计原则 - * 正在重试 - * 同步冲突 - * 授权失效 - * 网络失败 - * provider 不支持条件写入 +1. 读旧格式:`.meta.json`、`scriptcat-sync.json`、`file_digest` 必须容忍缺字段。 +2. 写新格式要 optional:新增字段不能成为读取旧数据的前提。 +3. per-file best-effort:成功文件推进自己的 digest,失败文件保留旧 digest。 +4. 状态推进绑定真实成功:失败任务不得写成功 digest,不得覆盖对应 status。 +5. `scriptcat-sync.json` 合并写:保留 orphan、失败 uuid 和远端较新的 status。 +6. provider token opaque:同步层只传回 provider 给出的 token,不解析 provider 内部结构。 +7. 明确 atomic vs best-effort:preflight 只能降低风险,不能宣称 CAS。 +8. transient 有限 retry:只在幂等或有条件保护的写路径开启。 +9. 通知节流:按 sync round 聚合,不逐文件弹。 -## Provider 注意事项 +## 分阶段实现计划 -### WebDAV +### Phase 1:研究文档和测试基线 -优先使用 ETag / `If-Match`。 +只更新 `docs/sync-research.md` 和文档索引。提交前不改 runtime 代码。 -### S3 +### Phase 2:最小测试 -优先使用 ETag / conditional request。 +先写失败测试,覆盖: -### OneDrive +- 99 个文件成功、1 个文件失败时,成功文件 digest 可推进,失败文件不推进。 +- `pullScript()` 失败不能写入成功 digest。 +- `deleteCloudScript()` 失败不能推进 digest。 +- orphan `.user.js` without `.meta.json` 跳过且保留 `scriptcat-sync.json` status。 +- syncStatus 单个 enable/sort 更新失败不能卡死整轮。 -检查所有 `nothen=true` 调用点,避免行为改变导致调用者收到非预期响应。 +### Phase 3:最小实现 -### Google Drive +不引入 provider CAS,先修同步层状态污染: -不要把 `fileId:version` 放进通用 `version`。 +- 让 `pullScript()` 对真实失败向上返回失败,但坏远端单文件只阻塞该 uuid。 +- 让 `deleteCloudScript()` 返回成功/失败结果;`scriptsDelete()` 逐条 catch 并继续。 +- `syncOnceInternal()` 记录成功 uuid 和失败 uuid。 +- `updateFileDigest()` 支持基于旧 digest map + 成功 patch 的局部推进,失败文件保留旧值。 +- 有失败时 `scriptcat-sync.json` 只写安全合并结果,或跳过会污染的 uuid。 -可用方案: +### Phase 4:capabilities 和 typed error -* `version` 保持 provider 原生 version。 -* `providerToken` 保存 `{ fileId, version }`。 -* createOnly 如果无法 atomic,应标记为 best-effort。 +新增最小能力描述,而不是把冲突策略塞进 filesystem: -### Dropbox +```ts +type FileSystemCapabilities = { + supportsAtomicCompareAndSwap: boolean; + supportsCreateOnly: boolean; + supportsConditionalDelete: boolean; +}; +``` -不要通过错误 message string 匹配冲突。 +错误分类建议: -应在 request 层解析 Dropbox API error,转换成 typed `FileSystemError`. +```ts +type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; +``` -### Baidu +### Phase 5:provider 条件操作 follow-up -只把明确的 file-exists errno 转换成 conflict。 +分 provider 小步提交: -不要把所有非零 errno 都当成 createOnly conflict。 +- WebDAV / S3 / OneDrive:优先用 If-Match / ETag。 +- Dropbox:request 层 typed error,rev 作为 opaque token。 +- Google Drive:providerToken 保存 fileId/version;preflight 明确 best-effort。 +- Baidu:只把明确 file-exists errno 判 conflict;md5 preflight 明确 best-effort。 +- Zip:保持简单,不参与云端 CAS。 -### Zip +### Phase 6:重试和通知 -保持简单实现,不需要云端 CAS 能力。 +- transient 429/5xx 有限 retry/backoff。 +- install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 +- 通知包含失败数量和首个错误类型,不逐文件弹。 ## 测试矩阵 @@ -367,15 +343,16 @@ type FileDeleteOptions = { 1. 99 个 push 成功,1 个 push 失败。 2. 99 个 pull 成功,1 个 pull 失败。 -3. 删除 10 个脚本,其中 1 个删除失败。 -4. syncStatus 单个 enableScript 失败。 +3. 删除 10 个脚本,其中 1 个删除失败,后续 9 个仍处理。 +4. syncStatus 单个 `enableScript` 或 sort 更新失败。 5. `scriptcat-sync.json` 写入失败。 6. `file_digest` 更新失败。 +7. install 触发 push transient 失败,不污染 digest。 ### 数据兼容 -1. 旧 `.meta.json` 无 version。 -2. 旧 `file_digest` 只有 digest。 +1. 旧 `.meta.json` 无新增字段。 +2. 旧 `file_digest` 只有 digest string。 3. 旧 `scriptcat-sync.json` 无新增字段。 4. 云端只有 `.user.js`。 5. 云端只有 `.meta.json` tombstone。 @@ -393,28 +370,45 @@ type FileDeleteOptions = { 1. WebDAV ETag mismatch。 2. S3 ETag mismatch。 -3. OneDrive if-match mismatch。 -4. Dropbox conflict typed error。 +3. OneDrive If-Match mismatch。 +4. Dropbox typed conflict。 5. Google Drive best-effort preflight race。 6. Baidu errno 分类。 - -## 暂不建议立即做的事情 - -1. 不建议一次性把所有 provider 都改成复杂 CAS。 -2. 不建议把 filesystem 变成业务冲突处理层。 -3. 不建议新增独立 `tombstone_digest`,除非有明确 GC。 -4. 不建议让任何单文件失败阻塞整轮同步。 -5. 不建议没有测试就修改 Google Drive / Baidu 的删除逻辑。 +7. Limiter 对 read 429 重试、write/delete 429 不重复非幂等操作。 + +## 风险清单 + +1. 把单文件失败升级为整轮失败,会让大批量同步重复执行并制造自冲突。 +2. 把失败文件 digest 写成成功,会永久隐藏失败。 +3. 覆盖式写 `scriptcat-sync.json` 会丢掉其他设备状态。 +4. provider 原生 digest 与本地 md5 混用会导致误判变更。 +5. Google Drive 同名文件和 path cache 会导致读写非预期文件。 +6. Baidu/Google Drive preflight 不是 atomic,不能承诺完全消除 TOCTOU。 +7. Dropbox 字符串匹配错误脆弱,API 文案变化会破坏分类。 +8. tombstone 状态若无 GC,会让删除收敛和 status 合并越来越复杂。 +9. 写路径 retry 若没有幂等保护,可能重复创建或覆盖。 +10. 过早改所有 provider 会让 PR 过大,难以 review 和回滚。 + +## 暂不建议实现的内容 + +1. 不建议一次性把 PR #1439 全量合并。 +2. 不建议把整个同步 round 改成 all-or-nothing。 +3. 不建议让 filesystem 包承担业务冲突策略。 +4. 不建议新增独立 `tombstone_digest`,除非同时设计 GC。 +5. 不建议把 Google Drive `fileId:version` 暴露成通用 version。 +6. 不建议在没有 typed error 前按字符串大规模分类冲突。 +7. 不建议没有测试就修改 Google Drive / Baidu 删除逻辑。 ## 最小可接受修复 -第一轮代码修复可以只做到: +第一轮 runtime 修复应只做到: -1. `pullScript` 和 `deleteCloudScript` 失败向上抛出。 -2. `syncOnceInternal` 按文件收集结果。 +1. `pullScript()` 和 `deleteCloudScript()` 的失败可被上层识别。 +2. `syncOnceInternal()` 按文件收集结果。 3. 成功文件推进 digest,失败文件不推进。 4. orphan `.user.js` 跳过且保留 status。 -5. 新增测试证明不会再出现 “99 成功 + 1 失败 => 100 个都重复/污染” 的问题。 +5. syncStatus 单项失败不阻塞整轮。 +6. 新增测试证明不会再出现“99 成功 + 1 失败导致 100 个都重复/污染”的问题。 ## 验证要求 @@ -431,4 +425,4 @@ tests skipped: known risks: ``` -如果测试无法运行,必须写明原因。 +如果测试无法运行,必须写明原因,不能写“已验证”。 From db2d563fc5742550971e1373864db59bfabef794 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:27:17 +0900 Subject: [PATCH 03/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20failed?= =?UTF-8?q?=20task=20digest=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 4fc2ba742..bc5e58ef1 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -445,6 +445,225 @@ console.log("ok");` }); }); + it("部分 push 失败时只推进成功文件 digest 并保留失败文件旧 digest", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "cloud-ok-new", + createtime: 1, + updatetime: 1, + }, + { + name: "bad.user.js", + path: "bad.user.js", + size: 1, + digest: "cloud-bad-new", + createtime: 1, + updatetime: 1, + }, + { + name: "bad.meta.json", + path: "bad.meta.json", + size: 1, + digest: "cloud-bad-meta-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + { + uuid: "bad", + name: "bad", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockImplementation(async (_fs, script: any) => { + if (script.uuid === "bad") { + throw new Error("push failed"); + } + return { + "ok.user.js": "ok-user-new", + "ok.meta.json": "ok-meta-new", + }; + }); + await (service as any).storage.set("file_digest", { + "bad.user.js": "bad-user-old", + "bad.meta.json": "bad-meta-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "ok.user.js": "cloud-ok-new", + "bad.user.js": "bad-user-old", + "bad.meta.json": "bad-meta-old", + "ok.meta.json": "ok-meta-new", + }); + }); + + it("pullScript 失败时不推进对应云端 digest", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "pull-uuid.user.js", + path: "pull-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 10, + }, + { + name: "pull-uuid.meta.json", + path: "pull-uuid.meta.json", + size: 1, + digest: "cloud-meta-new", + createtime: 1, + updatetime: 10, + }, + ]) + .mockResolvedValueOnce([ + { + name: "pull-uuid.user.js", + path: "pull-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 10, + }, + { + name: "pull-uuid.meta.json", + path: "pull-uuid.meta.json", + size: 1, + digest: "cloud-meta-new", + createtime: 1, + updatetime: 10, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockRejectedValue(new Error("read failed")), + }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + installScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + await (service as any).storage.set("file_digest", { + "pull-uuid.user.js": "user-old", + "pull-uuid.meta.json": "meta-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "pull-uuid.user.js": "user-old", + "pull-uuid.meta.json": "meta-old", + }); + }); + + it("批量删除单条云端删除失败时继续处理后续脚本并保留失败 digest", async () => { + const deleteCalls: string[] = []; + const fs = createFs({ + delete: vi.fn().mockImplementation(async (path: string) => { + deleteCalls.push(path); + if (path === "fail.user.js") { + throw new Error("delete failed"); + } + }), + list: vi.fn().mockResolvedValue([ + { + name: "fail.user.js", + path: "fail.user.js", + size: 1, + digest: "fail-user-new", + createtime: 1, + updatetime: 1, + }, + { + name: "fail.meta.json", + path: "fail.meta.json", + size: 1, + digest: "fail-meta-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true, syncDelete: false }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + await (service as any).storage.set("file_digest", { + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + + await service.scriptsDelete([{ uuid: "fail", deleteBy: "user" } as any, { uuid: "ok", deleteBy: "user" } as any]); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(deleteCalls).toEqual(["fail.user.js", "ok.user.js", "ok.meta.json"]); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + }); + it("passes script modifiedDate when pushing script and meta files", async () => { const writeMock = vi.fn().mockResolvedValue(undefined); const createMock = vi.fn().mockResolvedValue({ write: writeMock }); From 85c32dde968b68694f6cc1c845a98647ea1aaf03 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:27:59 +0900 Subject: [PATCH 04/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20preserve=20fa?= =?UTF-8?q?iled=20task=20digests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 80 +++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 371f19178..7f15866c8 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -73,6 +73,11 @@ type FileDigestMap = { [key: string]: string; }; +type SyncTask = { + promise: Promise; + preserveDigestFiles: string[]; +}; + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; function getScriptModifiedDate(script: PushScriptParam): number { @@ -404,20 +409,27 @@ export class SynchronizeService { } // 对比脚本列表和文件列表,进行同步 - const result: Promise[] = []; + const result: SyncTask[] = []; const updateScript: Map = new Map(); // 记录被跳过的孤儿云端脚本(仅 .user.js 无 .meta.json) // 避免本机回写 scriptcat-sync.json 时丢失对应 uuid 的云端 status const skippedOrphanUuids = new Set(); // 需要是同步操作,后续上传剩下的脚本 // 最后使用 Promise.allSettled 进行等待 + const addSyncTask = (uuid: string, promise: Promise, files?: string[]) => { + result.push({ + promise, + preserveDigestFiles: files || [`${uuid}.user.js`, `${uuid}.meta.json`], + }); + }; uuidMap.forEach((file, uuid) => { const script = scriptMap.get(uuid); if (script) { scriptMap.delete(uuid); // 脚本存在但是文件不存在,则读取.meta.json内容判断是否需要删除脚本 if (!file.script) { - result.push( + addSyncTask( + uuid, (async () => { // 读取meta文件 const meta = await fs.open(file.meta!); @@ -437,7 +449,8 @@ export class SynchronizeService { await fs.delete(file.meta!.name); return await this.pushScript(fs, script); } - })() + })(), + [file.meta!.name, `${uuid}.user.js`] ); return; } @@ -450,11 +463,11 @@ export class SynchronizeService { if (updatetime > file.script!.updatetime || !file.meta) { // 如果脚本更新时间大于文件更新时间 // 或者不存在.meta文件,则上传文件 - result.push(this.pushScript(fs, script)); + addSyncTask(uuid, this.pushScript(fs, script)); } else { // 如果脚本更新时间小于文件更新时间,则更新脚本 updateScript.set(uuid, true); - result.push(this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script)); + addSyncTask(uuid, this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script)); } return; } @@ -470,23 +483,29 @@ export class SynchronizeService { return; } updateScript.set(uuid, true); - result.push(this.pullScript(fs, file as SyncFiles, cloudStatus[uuid])); + addSyncTask(uuid, this.pullScript(fs, file as SyncFiles, cloudStatus[uuid])); } }); // 上传剩下的脚本 scriptMap.forEach((script) => { - result.push(this.pushScript(fs, script)); + addSyncTask(script.uuid, this.pushScript(fs, script)); }); // 忽略错误 - const syncResults = await Promise.allSettled(result); + const syncResults = await Promise.allSettled(result.map((item) => item.promise)); const pushedFileDigestMap: FileDigestMap = {}; - syncResults.forEach((ret) => { + const preserveDigestFiles = new Set(); + syncResults.forEach((ret, index) => { if (ret.status === "fulfilled" && ret.value) { Object.assign(pushedFileDigestMap, ret.value); + } else if (ret.status === "rejected") { + result[index].preserveDigestFiles.forEach((name) => preserveDigestFiles.add(name)); + this.logger.warn("sync task failed", Logger.E(ret.reason), { + files: result[index].preserveDigestFiles, + }); } }); // 同步状态 - if (syncConfig.syncStatus) { + if (syncConfig.syncStatus && preserveDigestFiles.size === 0) { const scriptlist = await this.scriptDAO.all(); await Promise.allSettled( scriptlist.map(async (script) => { @@ -546,28 +565,48 @@ export class SynchronizeService { const syncFile = await fs.create("scriptcat-sync.json", { modifiedDate }); await syncFile.write(JSON.stringify(scriptcatSync, null, 2)); this.logger.info("sync scriptcat-sync.json file success"); + } else if (syncConfig.syncStatus) { + this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { + failedFiles: [...preserveDigestFiles], + }); } // 重新获取文件列表,保存文件摘要 this.logger.info("update file digest"); - await this.updateFileDigest(fs, pushedFileDigestMap); + await this.updateFileDigest(fs, pushedFileDigestMap, preserveDigestFiles); this.logger.info("sync complete"); return; } - async updateFileDigest(fs: FileSystem, knownFileDigestMap: FileDigestMap = {}) { + async updateFileDigest( + fs: FileSystem, + knownFileDigestMap: FileDigestMap = {}, + preserveFileNames = new Set() + ) { + const oldFileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const newList = await fs.list(); const newFileDigestMap: FileDigestMap = {}; for (const file of newList) { + if (preserveFileNames.has(file.name)) { + if (oldFileDigestMap[file.name] !== undefined) { + newFileDigestMap[file.name] = oldFileDigestMap[file.name]; + } + continue; + } newFileDigestMap[file.name] = file.digest; } // 各后端 digest 格式不一(WebDAV/OneDrive/S3 是 etag、Dropbox 是 content_hash、Zip 为空, // 仅 GoogleDrive/Baidu 是 md5),只在云端列表暂时漏掉刚上传的文件时用本地 md5 兜底, // 不能覆盖 fs.list 已返回的原生 digest,否则下次同步比对会因格式不一致而误判 for (const name in knownFileDigestMap) { - if (!(name in newFileDigestMap)) { + if (!(name in newFileDigestMap) && !preserveFileNames.has(name)) { newFileDigestMap[name] = knownFileDigestMap[name]; } } + preserveFileNames.forEach((name) => { + if (!(name in newFileDigestMap) && oldFileDigestMap[name] !== undefined) { + newFileDigestMap[name] = oldFileDigestMap[name]; + } + }); await this.storage.set("file_digest", newFileDigestMap); return; } @@ -601,6 +640,7 @@ export class SynchronizeService { logger.info("delete success"); } catch (e) { logger.error("delete file error", Logger.E(e)); + throw e; } return; } @@ -681,6 +721,7 @@ export class SynchronizeService { logger.info("pull script success"); } catch (e) { logger.error("pull script error", Logger.E(e)); + throw e; } } @@ -753,10 +794,19 @@ export class SynchronizeService { if (config.enable) { stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { const fs = await this.buildFileSystem(config); + const preserveDigestFiles = new Set(); for (const { uuid } of items) { - await this.deleteCloudScript(fs, uuid, config.syncDelete); + try { + await this.deleteCloudScript(fs, uuid, config.syncDelete); + } catch (e) { + preserveDigestFiles.add(`${uuid}.user.js`); + preserveDigestFiles.add(`${uuid}.meta.json`); + this.logger.warn("delete cloud script item failed", Logger.E(e), { + uuid, + }); + } } - await this.updateFileDigest(fs); + await this.updateFileDigest(fs, {}, preserveDigestFiles); }).catch((e) => { this.logger.error("delete cloud script error", Logger.E(e)); }); From a6042cd8482a8f0aded6feec86e01841a7ec9630 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:33:19 +0900 Subject: [PATCH 05/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20status?= =?UTF-8?q?=20merge=20before=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index bc5e58ef1..3b35b023c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -187,6 +187,185 @@ describe("SynchronizeService", () => { expect(written.status.scripts.orphan).toEqual(orphanStatus); }); + it("写回 scriptcat-sync.json 前重新读取远端状态,避免覆盖其他设备更新", async () => { + const initialStatus = { enable: false, sort: 7, updatetime: 200 }; + const latestStatus = { enable: true, sort: 9, updatetime: 300 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const syncFile = { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }; + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "status-uuid.user.js", + path: "status-uuid.user.js", + size: 1, + digest: "script-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "status-uuid.meta.json", + path: "status-uuid.meta.json", + size: 1, + digest: "meta-digest", + createtime: 1, + updatetime: 1, + }, + syncFile, + ]), + open: vi + .fn() + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": initialStatus } }, + }) + ), + }) + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": latestStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const scriptDAO = { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "status-uuid", + name: "status", + updatetime: 100, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + }; + const service = new SynchronizeService( + {} as any, + {} as any, + { + enableScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + scriptDAO as any + ); + await (service as any).storage.set("file_digest", { + "status-uuid.user.js": "script-digest", + }); + + await service.syncOnce(syncConfig, fs); + + expect(fs.open).toHaveBeenCalledTimes(2); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts["status-uuid"]).toEqual(latestStatus); + }); + + it("写回 scriptcat-sync.json 时本地较新的状态仍覆盖远端旧状态", async () => { + const initialStatus = { enable: false, sort: 7, updatetime: 100 }; + const latestStatus = { enable: false, sort: 8, updatetime: 150 }; + const localStatus = { enable: true, sort: 1, updatetime: 200 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "status-uuid.user.js", + path: "status-uuid.user.js", + size: 1, + digest: "script-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "status-uuid.meta.json", + path: "status-uuid.meta.json", + size: 1, + digest: "meta-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi + .fn() + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": initialStatus } }, + }) + ), + }) + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": latestStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + enableScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "status-uuid", + name: "status", + updatetime: localStatus.updatetime, + createtime: 1, + status: 1, + sort: localStatus.sort, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + } as any + ); + await (service as any).storage.set("file_digest", { + "status-uuid.user.js": "script-digest", + }); + + await service.syncOnce(syncConfig, fs); + + expect(fs.open).toHaveBeenCalledTimes(2); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts["status-uuid"]).toEqual(localStatus); + }); + it("waits for installScript during pullScript", async () => { let releaseInstall!: () => void; const installGate = new Promise((resolve) => { From 7af330ac390e9a1b3857e781854c753cee62446c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:33:56 +0900 Subject: [PATCH 06/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20merge=20lates?= =?UTF-8?q?t=20status=20before=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 7f15866c8..d62cde61d 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -560,6 +560,14 @@ export class SynchronizeService { scriptcatSync.status.scripts[uuid] = status; } }); + if (file) { + const latestCloudStatus = await this.readScriptcatSyncStatus(fs, file); + scriptcatSync.status.scripts = this.mergeScriptcatSyncStatus( + cloudStatus, + latestCloudStatus, + scriptcatSync.status.scripts + ); + } // 保存脚本猫同步状态 const modifiedDate = Date.now(); const syncFile = await fs.create("scriptcat-sync.json", { modifiedDate }); @@ -577,6 +585,40 @@ export class SynchronizeService { return; } + private async readScriptcatSyncStatus(fs: FileSystem, file: FileInfo): Promise { + const cloudScriptCatSync = JSON.parse(await fs.open(file).then((f) => f.read("string"))) as Partial; + return cloudScriptCatSync.status?.scripts || {}; + } + + private mergeScriptcatSyncStatus( + initialStatus: ScriptcatSync["status"]["scripts"], + latestStatus: ScriptcatSync["status"]["scripts"], + candidateStatus: ScriptcatSync["status"]["scripts"] + ): ScriptcatSync["status"]["scripts"] { + const merged: ScriptcatSync["status"]["scripts"] = { ...latestStatus }; + for (const uuid of Object.keys(candidateStatus)) { + const candidate = candidateStatus[uuid]; + if (!candidate) { + continue; + } + const initial = initialStatus[uuid]; + const latest = latestStatus[uuid]; + const candidateOnlyPreservedInitial = + initial && + candidate.enable === initial.enable && + candidate.sort === initial.sort && + candidate.updatetime === initial.updatetime; + if (candidateOnlyPreservedInitial) { + merged[uuid] = latest || candidate; + continue; + } + if (!latest || candidate.updatetime >= latest.updatetime) { + merged[uuid] = candidate; + } + } + return merged; + } + async updateFileDigest( fs: FileSystem, knownFileDigestMap: FileDigestMap = {}, From 2cc67e4aa3cac82934f72b31632eebe71e320739 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:38:40 +0900 Subject: [PATCH 07/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20status?= =?UTF-8?q?=20sync=20best=20effort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 3b35b023c..96fc2ecb4 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -366,6 +366,92 @@ describe("SynchronizeService", () => { expect(written.status.scripts["status-uuid"]).toEqual(localStatus); }); + it("syncStatus 单个脚本排序更新失败时不阻塞整轮同步", async () => { + const cloudStatus = { enable: true, sort: 9, updatetime: 200 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "status-uuid.user.js", + path: "status-uuid.user.js", + size: 1, + digest: "script-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "status-uuid.meta.json", + path: "status-uuid.meta.json", + size: 1, + digest: "meta-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": cloudStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const scriptDAO = { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "status-uuid", + name: "status", + updatetime: 100, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + update: vi.fn().mockRejectedValue(new Error("sort update failed")), + }; + const service = new SynchronizeService( + {} as any, + {} as any, + { + enableScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + scriptDAO as any + ); + await (service as any).storage.set("file_digest", { + "status-uuid.user.js": "script-digest", + "status-uuid.meta.json": "meta-digest", + "scriptcat-sync.json": "sync-digest", + }); + + await service.syncOnce(syncConfig, fs); + + expect(scriptDAO.update).toHaveBeenCalledWith("status-uuid", { sort: cloudStatus.sort }); + expect(writeMock).toHaveBeenCalledTimes(1); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts["status-uuid"]).toEqual(cloudStatus); + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "status-uuid.user.js": "script-digest", + "status-uuid.meta.json": "meta-digest", + "scriptcat-sync.json": "sync-digest", + }); + }); + it("waits for installScript during pullScript", async () => { let releaseInstall!: () => void; const installGate = new Promise((resolve) => { From 83867028df997aad76a3baf6ed98cc352519964b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:39:45 +0900 Subject: [PATCH 08/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20legacy?= =?UTF-8?q?=20sync=20status=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 96fc2ecb4..ad822c228 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -187,6 +187,45 @@ describe("SynchronizeService", () => { expect(written.status.scripts.orphan).toEqual(orphanStatus); }); + it("兼容缺少 status.scripts 的旧版 scriptcat-sync.json", async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ version: "1.0.0" })), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + await service.syncOnce(syncConfig, fs); + + expect(writeMock).toHaveBeenCalledTimes(1); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts).toEqual({}); + }); + it("写回 scriptcat-sync.json 前重新读取远端状态,避免覆盖其他设备更新", async () => { const initialStatus = { enable: false, sort: 7, updatetime: 200 }; const latestStatus = { enable: true, sort: 9, updatetime: 300 }; From 0d14a0d1272161806df70d93d416a41ee9dbc7c2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:38 +0900 Subject: [PATCH 09/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20tolerate=20le?= =?UTF-8?q?gacy=20sync=20status=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index d62cde61d..cd95c20db 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -404,8 +404,10 @@ export class SynchronizeService { let cloudStatus: ScriptcatSync["status"]["scripts"] = {}; if (file) { // 如果有,则读取文件内容 - const cloudScriptCatSync = JSON.parse(await fs.open(file).then((f) => f.read("string"))) as ScriptcatSync; - cloudStatus = cloudScriptCatSync.status.scripts; + const cloudScriptCatSync = JSON.parse( + await fs.open(file).then((f) => f.read("string")) + ) as Partial; + cloudStatus = cloudScriptCatSync.status?.scripts || {}; } // 对比脚本列表和文件列表,进行同步 From 0da9e0ef32bd4d14dc4e6b0cfedb47b674f2c135 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:43:01 +0900 Subject: [PATCH 10/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20status?= =?UTF-8?q?=20write=20failure=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index ad822c228..edaed259a 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -837,6 +837,69 @@ console.log("ok");` }); }); + it("scriptcat-sync.json 写回失败时仍推进已成功脚本 digest", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "cloud-ok-user", + createtime: 1, + updatetime: 1, + }, + { + name: "ok.meta.json", + path: "ok.meta.json", + size: 1, + digest: "cloud-ok-meta", + createtime: 1, + updatetime: 1, + }, + ]), + create: vi.fn().mockResolvedValue({ + write: vi.fn().mockRejectedValue(new Error("status write failed")), + }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({ + "ok.user.js": "pushed-ok-user", + "ok.meta.json": "pushed-ok-meta", + }); + + await service.syncOnce(syncConfig, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "ok.user.js": "cloud-ok-user", + "ok.meta.json": "cloud-ok-meta", + }); + }); + it("pullScript 失败时不推进对应云端 digest", async () => { const fs = createFs({ list: vi From 5e5f39bd5357fe4a33d40aa2374a907ed0624c6e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:44:52 +0900 Subject: [PATCH 11/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20isolate=20sta?= =?UTF-8?q?tus=20file=20write=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 120 +++++++++--------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index cd95c20db..00e034673 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -508,73 +508,77 @@ export class SynchronizeService { }); // 同步状态 if (syncConfig.syncStatus && preserveDigestFiles.size === 0) { - const scriptlist = await this.scriptDAO.all(); - await Promise.allSettled( - scriptlist.map(async (script) => { - // 判断云端状态是否与本地状态一致 - const status = cloudStatus[script.uuid]; - const updatetime = script.updatetime || script.createtime; - if (!status) { - scriptcatSync.status.scripts[script.uuid] = { - enable: script.status === SCRIPT_STATUS_ENABLE, - sort: script.sort, - updatetime: updatetime, - }; - } else { - if (updateScript.has(script.uuid)) { - // 脚本已经更新过了,跳过状态同步 - scriptcatSync.status.scripts[script.uuid] = status; - return; - } - // 判断时间 - // 如果云端状态的更新时间小于本地状态的更新时间,则更新云端状态 - if (status.updatetime < updatetime) { + try { + const scriptlist = await this.scriptDAO.all(); + await Promise.allSettled( + scriptlist.map(async (script) => { + // 判断云端状态是否与本地状态一致 + const status = cloudStatus[script.uuid]; + const updatetime = script.updatetime || script.createtime; + if (!status) { scriptcatSync.status.scripts[script.uuid] = { enable: script.status === SCRIPT_STATUS_ENABLE, sort: script.sort, updatetime: updatetime, }; - return; - } - // 否则采用云端状态 - scriptcatSync.status.scripts[script.uuid] = status; - // 脚本顺序 - if (status.sort !== script.sort) { - await this.scriptDAO.update(script.uuid, { - sort: status.sort, - }); - } - // 脚本状态 - if (status.enable !== (script.status === SCRIPT_STATUS_ENABLE)) { - // 开启脚本 - await this.script.enableScript({ - uuid: script.uuid, - enable: status.enable, - }); + } else { + if (updateScript.has(script.uuid)) { + // 脚本已经更新过了,跳过状态同步 + scriptcatSync.status.scripts[script.uuid] = status; + return; + } + // 判断时间 + // 如果云端状态的更新时间小于本地状态的更新时间,则更新云端状态 + if (status.updatetime < updatetime) { + scriptcatSync.status.scripts[script.uuid] = { + enable: script.status === SCRIPT_STATUS_ENABLE, + sort: script.sort, + updatetime: updatetime, + }; + return; + } + // 否则采用云端状态 + scriptcatSync.status.scripts[script.uuid] = status; + // 脚本顺序 + if (status.sort !== script.sort) { + await this.scriptDAO.update(script.uuid, { + sort: status.sort, + }); + } + // 脚本状态 + if (status.enable !== (script.status === SCRIPT_STATUS_ENABLE)) { + // 开启脚本 + await this.script.enableScript({ + uuid: script.uuid, + enable: status.enable, + }); + } } + }) + ); + // 保留被跳过的 orphan uuid 的云端 status,避免覆盖另一台设备半上传的状态 + skippedOrphanUuids.forEach((uuid) => { + const status = cloudStatus[uuid]; + if (status) { + scriptcatSync.status.scripts[uuid] = status; } - }) - ); - // 保留被跳过的 orphan uuid 的云端 status,避免覆盖另一台设备半上传的状态 - skippedOrphanUuids.forEach((uuid) => { - const status = cloudStatus[uuid]; - if (status) { - scriptcatSync.status.scripts[uuid] = status; + }); + if (file) { + const latestCloudStatus = await this.readScriptcatSyncStatus(fs, file); + scriptcatSync.status.scripts = this.mergeScriptcatSyncStatus( + cloudStatus, + latestCloudStatus, + scriptcatSync.status.scripts + ); } - }); - if (file) { - const latestCloudStatus = await this.readScriptcatSyncStatus(fs, file); - scriptcatSync.status.scripts = this.mergeScriptcatSyncStatus( - cloudStatus, - latestCloudStatus, - scriptcatSync.status.scripts - ); + // 保存脚本猫同步状态 + const modifiedDate = Date.now(); + const syncFile = await fs.create("scriptcat-sync.json", { modifiedDate }); + await syncFile.write(JSON.stringify(scriptcatSync, null, 2)); + this.logger.info("sync scriptcat-sync.json file success"); + } catch (e) { + this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); } - // 保存脚本猫同步状态 - const modifiedDate = Date.now(); - const syncFile = await fs.create("scriptcat-sync.json", { modifiedDate }); - await syncFile.write(JSON.stringify(scriptcatSync, null, 2)); - this.logger.info("sync scriptcat-sync.json file success"); } else if (syncConfig.syncStatus) { this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { failedFiles: [...preserveDigestFiles], From 5d3fd072ea2404a10431df40e3610254b65fce7c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:47:23 +0900 Subject: [PATCH 12/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20corrupt?= =?UTF-8?q?=20status=20file=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index edaed259a..b213cb4c4 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -226,6 +226,84 @@ describe("SynchronizeService", () => { expect(written.status.scripts).toEqual({}); }); + it("scriptcat-sync.json 损坏时不阻塞脚本同步且不覆盖状态文件", async () => { + const createMock = vi.fn().mockResolvedValue({ + write: vi.fn().mockResolvedValue(undefined), + }); + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "cloud-ok-user", + createtime: 1, + updatetime: 1, + }, + { + name: "ok.meta.json", + path: "ok.meta.json", + size: 1, + digest: "cloud-ok-meta", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue("{"), + }), + create: createMock, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({ + "ok.user.js": "pushed-ok-user", + "ok.meta.json": "pushed-ok-meta", + }); + + await service.syncOnce(syncConfig, fs); + + expect(service.pushScript).toHaveBeenCalledTimes(1); + expect(createMock).not.toHaveBeenCalled(); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "ok.user.js": "cloud-ok-user", + "ok.meta.json": "cloud-ok-meta", + }); + }); + it("写回 scriptcat-sync.json 前重新读取远端状态,避免覆盖其他设备更新", async () => { const initialStatus = { enable: false, sort: 7, updatetime: 200 }; const latestStatus = { enable: true, sort: 9, updatetime: 300 }; From b6788e3fb10c27d4b14b5d8170f7e375e8cbbb04 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:48:54 +0900 Subject: [PATCH 13/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20isolate=20unr?= =?UTF-8?q?eadable=20status=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 00e034673..7695a2de2 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -402,12 +402,18 @@ export class SynchronizeService { }, } as ScriptcatSync; let cloudStatus: ScriptcatSync["status"]["scripts"] = {}; + let canWriteScriptcatSync = true; if (file) { - // 如果有,则读取文件内容 - const cloudScriptCatSync = JSON.parse( - await fs.open(file).then((f) => f.read("string")) - ) as Partial; - cloudStatus = cloudScriptCatSync.status?.scripts || {}; + try { + // 如果有,则读取文件内容 + const cloudScriptCatSync = JSON.parse( + await fs.open(file).then((f) => f.read("string")) + ) as Partial; + cloudStatus = cloudScriptCatSync.status?.scripts || {}; + } catch (e) { + canWriteScriptcatSync = false; + this.logger.warn("read scriptcat-sync.json file failed", Logger.E(e)); + } } // 对比脚本列表和文件列表,进行同步 @@ -507,7 +513,7 @@ export class SynchronizeService { } }); // 同步状态 - if (syncConfig.syncStatus && preserveDigestFiles.size === 0) { + if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && canWriteScriptcatSync) { try { const scriptlist = await this.scriptDAO.all(); await Promise.allSettled( @@ -579,6 +585,8 @@ export class SynchronizeService { } catch (e) { this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); } + } else if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && !canWriteScriptcatSync) { + this.logger.warn("skip scriptcat-sync.json write because cloud status could not be read"); } else if (syncConfig.syncStatus) { this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { failedFiles: [...preserveDigestFiles], From 841ab6302c2735edf5619948b40396b56692c22f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:50:50 +0900 Subject: [PATCH 14/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20typed=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 91 ++++++++++++++++++++- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 6bf549d41..72e21ee97 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FileSystemError } from "../error"; import DropboxFileSystem from "./dropbox"; describe("DropboxFileSystem", () => { @@ -6,10 +7,88 @@ describe("DropboxFileSystem", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("request should throw typed not found error", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path_lookup/not_found/...", + error: { ".tag": "path_lookup", path_lookup: { ".tag": "not_found" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/get_metadata")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + code: "path_lookup/not_found/...", + notFound: true, + conflict: false, + }); + }); + + it("request should throw typed conflict error", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path/conflict/folder/...", + error: { ".tag": "path", path: { ".tag": "conflict" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/create_folder_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + code: "path/conflict/folder/...", + conflict: true, + notFound: false, + }); + }); + + it("request should throw typed rate-limit error", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => JSON.stringify({ error_summary: "too_many_requests/..." }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/list_folder")).rejects.toMatchObject({ + provider: "dropbox", + status: 429, + code: "too_many_requests/...", + rateLimit: true, + retryable: true, + }); + }); + it("delete should be idempotent on path not found", async () => { const fs = new DropboxFileSystem("/", "token"); vi.spyOn(fs, "request").mockRejectedValue( - new Error('Dropbox API Error: 409 - {"error_summary":"path_lookup/not_found/..."}') + new FileSystemError({ + provider: "dropbox", + message: "not found", + status: 409, + code: "path_lookup/not_found/...", + notFound: true, + }) ); await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); @@ -18,7 +97,13 @@ describe("DropboxFileSystem", () => { it("exists should return false on path not found", async () => { const fs = new DropboxFileSystem("/", "token"); vi.spyOn(fs, "request").mockRejectedValue( - new Error('Dropbox API Error: 409 - {"error_summary":"path/not_found/..."}') + new FileSystemError({ + provider: "dropbox", + message: "not found", + status: 409, + code: "path/not_found/...", + notFound: true, + }) ); await expect(fs.exists("/missing.txt")).resolves.toBe(false); From 0322251611726004ca1ffeb97616f643c9d8c6e4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:53:08 +0900 Subject: [PATCH 15/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20use=20typed=20d?= =?UTF-8?q?ropbox=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.ts | 69 ++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 46c264322..1dd218143 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -1,12 +1,59 @@ import { AuthVerify } from "../auth"; +import { FileSystemError, isConflictError, isNotFoundError } from "../error"; import type FileSystem from "../filesystem"; import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import { DropboxFileReader, DropboxFileWriter } from "./rw"; -function isDropboxPathNotFound(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("path_lookup/not_found") || message.includes("path/not_found"); +type DropboxErrorBody = { + error_summary?: string; + error?: { + ".tag"?: string; + }; +}; + +function parseDropboxError(raw: unknown): { summary?: string; raw: unknown } { + let parsed = raw; + if (typeof raw === "string") { + try { + parsed = JSON.parse(raw); + } catch { + return { summary: raw, raw }; + } + } + + if (parsed && typeof parsed === "object") { + const body = parsed as DropboxErrorBody; + if (typeof body.error_summary === "string") { + return { summary: body.error_summary, raw: parsed }; + } + if (typeof body.error?.[".tag"] === "string") { + return { summary: body.error[".tag"], raw: parsed }; + } + } + + return { raw: parsed }; +} + +function toDropboxFileSystemError(status: number, raw: unknown): FileSystemError { + const { summary, raw: parsed } = parseDropboxError(raw); + const code = summary; + const notFound = summary?.includes("path_lookup/not_found") === true || summary?.includes("path/not_found") === true; + const conflict = !notFound && (status === 409 || summary?.includes("path/conflict") === true); + const rateLimit = status === 429; + const auth = status === 401 || summary?.includes("invalid_access_token") === true; + return new FileSystemError({ + provider: "dropbox", + message: `Dropbox API Error${status ? `: ${status}` : ""}${summary ? ` - ${summary}` : ""}`, + status, + code, + notFound, + conflict, + rateLimit, + auth, + retryable: rateLimit || status >= 500, + raw: parsed, + }); } export default class DropboxFileSystem implements FileSystem { @@ -69,7 +116,7 @@ export default class DropboxFileSystem implements FileSystem { this.pathCache.add(fullPath); } catch (error: any) { // 如果目录已存在,Dropbox 会返回错误,但这是正常情况 - if (error.message && error.message.includes("path/conflict")) { + if (isConflictError(error)) { // 目录已存在,不需要报错 this.pathCache.add(fullPath); return; @@ -107,7 +154,7 @@ export default class DropboxFileSystem implements FileSystem { } if (!response.ok) { const errorText = await response.text(); - throw new Error(`Dropbox API Error: ${response.status} - ${errorText}`); + throw toDropboxFileSystemError(response.status, errorText); } return response.json(); }) @@ -122,18 +169,18 @@ export default class DropboxFileSystem implements FileSystem { .then(async (retryResponse) => { if (!retryResponse.ok) { const errorText = await retryResponse.text(); - throw new Error(`Dropbox API Error: ${retryResponse.status} - ${errorText}`); + throw toDropboxFileSystemError(retryResponse.status, errorText); } return retryResponse.json(); }) .then((retryData) => { if (retryData.error) { - throw new Error(JSON.stringify(retryData)); + throw toDropboxFileSystemError(200, retryData); } return retryData; }); } - throw new Error(JSON.stringify(data)); + throw toDropboxFileSystemError(200, data); } return data; }); @@ -154,7 +201,7 @@ export default class DropboxFileSystem implements FileSystem { }), }); } catch (e: any) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return; } throw e; @@ -182,7 +229,7 @@ export default class DropboxFileSystem implements FileSystem { path: folderPath, }), }).catch((e) => { - if (e.message.includes("path/not_found")) { + if (isNotFoundError(e)) { return { entries: [], has_more: false }; // 返回空数组以避免后续错误 } throw e; @@ -247,7 +294,7 @@ export default class DropboxFileSystem implements FileSystem { }); return true; } catch (e) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return false; } throw e; From caf4c3b6872a6d8612adbecb229c4ee53c266c40 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:57:26 +0900 Subject: [PATCH 16/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20baidu=20e?= =?UTF-8?q?rrno=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/baidu/baidu.test.ts | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index 033aec0fa..bfc352988 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -52,4 +52,38 @@ describe("BaiduFileSystem", () => { `async=0&filelist=${encodeURIComponent(JSON.stringify(["/apps/ScriptCat/dir/file.user.js"]))}` ); }); + + it("创建目录遇到明确已存在 errno 时才标记为冲突", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: 31061, errmsg: "file already exists" }); + + await expect(fs.createDir("ScriptCat")).rejects.toMatchObject({ + provider: "baidu", + code: "31061", + conflict: true, + }); + }); + + it("创建目录遇到普通 errno 时不能误标记为冲突", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: 2, errmsg: "access denied" }); + + await expect(fs.createDir("ScriptCat")).rejects.toMatchObject({ + provider: "baidu", + code: "2", + conflict: false, + }); + }); + + it("写入预创建失败时保留普通 errno 的非冲突语义", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: 2, errmsg: "access denied" }); + const writer = await fs.create("dir/file.user.js"); + + await expect(writer.write("code")).rejects.toMatchObject({ + provider: "baidu", + code: "2", + conflict: false, + }); + }); }); From d0a89d54ab9cfb6f6c11efd65ebd20ee09091d34 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:00:30 +0900 Subject: [PATCH 17/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20classify=20baid?= =?UTF-8?q?u=20errno=20precisely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/baidu/baidu.ts | 9 +++++---- packages/filesystem/baidu/error.ts | 29 +++++++++++++++++++++++++++++ packages/filesystem/baidu/rw.ts | 7 ++++--- 3 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 packages/filesystem/baidu/error.ts diff --git a/packages/filesystem/baidu/baidu.ts b/packages/filesystem/baidu/baidu.ts index 9184de2f0..144d83481 100644 --- a/packages/filesystem/baidu/baidu.ts +++ b/packages/filesystem/baidu/baidu.ts @@ -2,6 +2,7 @@ import { AuthVerify } from "../auth"; import type FileSystem from "../filesystem"; import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; +import { createBaiduFileSystemError } from "./error"; import { BaiduFileReader, BaiduFileWriter } from "./rw"; export default class BaiduFileSystem implements FileSystem { @@ -52,7 +53,7 @@ export default class BaiduFileSystem implements FileSystem { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } } @@ -73,7 +74,7 @@ export default class BaiduFileSystem implements FileSystem { .then((data2) => data2.json()) .then((data2) => { if (data2.errno === 111 || data2.errno === -6) { - throw new Error(JSON.stringify(data2)); + throw createBaiduFileSystemError(data2); } return data2; }); @@ -95,7 +96,7 @@ export default class BaiduFileSystem implements FileSystem { } ).then((data) => { if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } return data; }); @@ -126,7 +127,7 @@ export default class BaiduFileSystem implements FileSystem { if (data.errno === -9) { break; } - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } if (!data.list || data.list.length === 0) { diff --git a/packages/filesystem/baidu/error.ts b/packages/filesystem/baidu/error.ts new file mode 100644 index 000000000..769c2ba14 --- /dev/null +++ b/packages/filesystem/baidu/error.ts @@ -0,0 +1,29 @@ +import { FileSystemError } from "../error"; + +export type BaiduErrorResponse = { + errno?: number; + errmsg?: string; + error_msg?: string; + [key: string]: unknown; +}; + +const BAIDU_FILE_EXISTS_ERRNOS = new Set([31061]); + +export function createBaiduFileSystemError(data: BaiduErrorResponse): FileSystemError { + const code = typeof data.errno === "number" ? String(data.errno) : undefined; + const message = + data.errmsg || data.error_msg || (code ? `Baidu request failed with errno ${code}` : "Baidu request failed"); + const conflict = typeof data.errno === "number" && BAIDU_FILE_EXISTS_ERRNOS.has(data.errno); + const auth = data.errno === 111 || data.errno === -6; + const notFound = data.errno === -9; + + return new FileSystemError({ + provider: "baidu", + message, + code, + conflict, + auth, + notFound, + raw: data, + }); +} diff --git a/packages/filesystem/baidu/rw.ts b/packages/filesystem/baidu/rw.ts index df2e54519..acec44e19 100644 --- a/packages/filesystem/baidu/rw.ts +++ b/packages/filesystem/baidu/rw.ts @@ -1,6 +1,7 @@ import type { FileInfo, FileReader, FileWriter } from "../filesystem"; import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto"; import type BaiduFileSystem from "./baidu"; +import { createBaiduFileSystemError } from "./error"; export class BaiduFileReader implements FileReader { file: FileInfo; @@ -80,7 +81,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } const uploadid = data.uploadid; const body = new FormData(); @@ -102,7 +103,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } // 创建文件 urlencoded = new URLSearchParams(); @@ -121,7 +122,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } } } From fa29bb3ff672af7247d9a5315ee2eba13b411b32 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:22:38 +0900 Subject: [PATCH 18/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20raw=20res?= =?UTF-8?q?ponse=20typed=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../googledrive/googledrive.test.ts | 51 +++++++++++++++++++ packages/filesystem/onedrive/onedrive.test.ts | 47 +++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 320f2cb55..1bf0635e2 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -49,6 +49,57 @@ describe("GoogleDriveFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); + it("删除文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: 429, + message: "Quota exceeded", + status: "RESOURCE_EXHAUSTED", + }, + }), + }) + ); + + await expect(fs.delete("limited.txt")).rejects.toMatchObject({ + provider: "googledrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + + it("读取文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: 429, + message: "Quota exceeded", + status: "RESOURCE_EXHAUSTED", + }, + }), + }) + ); + const reader = await fs.open({ name: "limited.txt", path: "/", size: 0, digest: "", createtime: 0, updatetime: 0 }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "googledrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + it("ensureDirExists should create missing nested directories and return final id", async () => { const fs = new GoogleDriveFileSystem("/", "token"); const findSpy = vi.spyOn(fs, "findFolderByName").mockResolvedValue(null); diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index a7400cad1..7af87d17e 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -95,6 +95,53 @@ describe("OneDriveFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); + it("删除文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new OneDriveFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: "TooManyRequests", + message: "Too many requests", + }, + }), + }) + ); + + await expect(fs.delete("limited.txt")).rejects.toMatchObject({ + provider: "onedrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + + it("读取文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new OneDriveFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: "TooManyRequests", + message: "Too many requests", + }, + }), + }) + ); + const reader = await fs.open({ name: "limited.txt", path: "/", size: 0, digest: "", createtime: 0, updatetime: 0 }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "onedrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + it("create should normalize double slashes in paths", async () => { const fs = new OneDriveFileSystem("/ScriptCat//sync", "token"); From 6a65acb8e07568d0c97952c26e53de5f104f7a55 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:24:23 +0900 Subject: [PATCH 19/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20raw=20pr?= =?UTF-8?q?ovider=20response=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/googledrive/googledrive.ts | 4 ++-- packages/filesystem/googledrive/rw.ts | 2 +- packages/filesystem/onedrive/onedrive.ts | 4 ++-- packages/filesystem/onedrive/rw.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index b5934f20e..b0fb91921 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -147,7 +147,7 @@ export default class GoogleDriveFileSystem implements FileSystem { }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -232,7 +232,7 @@ export default class GoogleDriveFileSystem implements FileSystem { return; } if (resp.status !== 204 && resp.status !== 200) { - throw new Error(await resp.text()); + throw await this.createResponseError(resp); } }); diff --git a/packages/filesystem/googledrive/rw.ts b/packages/filesystem/googledrive/rw.ts index a19a6828d..fef4d367d 100644 --- a/packages/filesystem/googledrive/rw.ts +++ b/packages/filesystem/googledrive/rw.ts @@ -28,7 +28,7 @@ export class GoogleDriveFileReader implements FileReader { ); if (data.status !== 200) { - return Promise.reject(await data.text()); + throw await this.fs.createResponseError(data); } switch (type) { diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 3a2bef2b0..60b3725bb 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -114,7 +114,7 @@ export default class OneDriveFileSystem implements FileSystem { }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -194,7 +194,7 @@ export default class OneDriveFileSystem implements FileSystem { return; } if (resp.status !== 204) { - throw new Error(await resp.text()); + throw await this.createResponseError(resp); } } diff --git a/packages/filesystem/onedrive/rw.ts b/packages/filesystem/onedrive/rw.ts index b0d33d55a..8a0dc0fa8 100644 --- a/packages/filesystem/onedrive/rw.ts +++ b/packages/filesystem/onedrive/rw.ts @@ -20,7 +20,7 @@ export class OneDriveFileReader implements FileReader { true ); if (data.status !== 200) { - throw new Error(await data.text()); + throw await this.fs.createResponseError(data); } switch (type) { case "string": From 071364442b9137c1aa397bfb119fc6d4ce386f10 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:30:37 +0900 Subject: [PATCH 20/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20typed=20t?= =?UTF-8?q?ransient=20retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.test.ts | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts index f76ed56f4..a79f953a7 100644 --- a/packages/filesystem/limiter.test.ts +++ b/packages/filesystem/limiter.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { FileSystemError } from "./error"; import type FileSystem from "./filesystem"; import type { FileInfo, FileReader, FileWriter } from "./filesystem"; import LimiterFileSystem from "./limiter"; @@ -54,6 +55,66 @@ describe("LimiterFileSystem", () => { expect(fs.list).toHaveBeenCalledTimes(2); }); + it("应当根据 typed rateLimit 错误重试 list", async () => { + vi.useFakeTimers(); + const fs = createFs(); + vi.mocked(fs.list) + .mockRejectedValueOnce( + new FileSystemError({ + provider: "dropbox", + message: "Quota exhausted", + status: 429, + rateLimit: true, + retryable: true, + }) + ) + .mockResolvedValueOnce([]); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.list(); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toEqual([]); + expect(fs.list).toHaveBeenCalledTimes(2); + }); + + it("应当根据 typed retryable 5xx 错误重试 list", async () => { + vi.useFakeTimers(); + const fs = createFs(); + vi.mocked(fs.list) + .mockRejectedValueOnce( + new FileSystemError({ + provider: "googledrive", + message: "Service unavailable", + status: 503, + retryable: true, + }) + ) + .mockResolvedValueOnce([]); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.list(); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toEqual([]); + expect(fs.list).toHaveBeenCalledTimes(2); + }); + + it("遇到 typed conflict 错误时不能重试 list", async () => { + const fs = createFs(); + const conflictError = new FileSystemError({ + provider: "onedrive", + message: "Conflict", + status: 409, + conflict: true, + }); + vi.mocked(fs.list).mockRejectedValueOnce(conflictError); + const limiter = new LimiterFileSystem(fs); + + await expect(limiter.list()).rejects.toBe(conflictError); + expect(fs.list).toHaveBeenCalledTimes(1); + }); + it("should retry verify on 429", async () => { vi.useFakeTimers(); const fs = createFs(); @@ -163,6 +224,26 @@ describe("LimiterFileSystem", () => { expect(write).toHaveBeenCalledTimes(1); }); + it("writer.write 遇到 typed retryable 错误时仍不能重试", async () => { + const fs = createFs(); + const write = vi.fn(async () => {}); + const retryableError = new FileSystemError({ + provider: "googledrive", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.create).mockResolvedValueOnce({ + write, + }); + write.mockRejectedValueOnce(retryableError); + const limiter = new LimiterFileSystem(fs); + const writer = await limiter.create(file.path); + + await expect(writer.write("content")).rejects.toBe(retryableError); + expect(write).toHaveBeenCalledTimes(1); + }); + it("should retry reader.read on 429", async () => { vi.useFakeTimers(); const fs = createFs(); From cd6f2d42e17b3cef5262e0a22b9ce9ca67090471 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:32:51 +0900 Subject: [PATCH 21/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20retry=20typed?= =?UTF-8?q?=20transient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 5dfb54b81..b2fbd5e10 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,5 +1,6 @@ import type FileSystem from "./filesystem"; import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; +import { FileSystemError } from "./error"; const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); @@ -57,10 +58,8 @@ export class RateLimiter { try { return await fn(); } catch (error) { - // 检查错误字符串中是否包含 429 - const errorStr = String(error).toLowerCase(); - if (this.shouldRetry429(op, ` ${errorStr} `) && i < 10) { - // 遇到 429 错误且未达到重试上限,采用指数退避策略延迟后继续重试 + if (this.shouldRetryTransient(op, error) && i < 10) { + // 遇到 transient 错误且未达到重试上限,采用指数退避策略延迟后继续重试 const delay = Math.min(2000 * Math.pow(2, i), 60000); await new Promise((resolve) => setTimeout(resolve, delay)); // 继续下一次循环重试 @@ -74,11 +73,15 @@ export class RateLimiter { throw new Error(`Max retries exceeded (op=${op})`); } - private shouldRetry429(op: string, errorStr: string): boolean { - return ( - ((errorStr.includes("429") && /[^a-z\d]429[^a-z\d]/.test(errorStr)) || errorStr.includes("too many requests")) && - RETRYABLE_429_OPS.has(op) - ); + private shouldRetryTransient(op: string, error: unknown): boolean { + if (!RETRYABLE_429_OPS.has(op)) { + return false; + } + if (error instanceof FileSystemError) { + return error.rateLimit || error.retryable; + } + const errorStr = ` ${String(error).toLowerCase()} `; + return (errorStr.includes("429") && /[^a-z\d]429[^a-z\d]/.test(errorStr)) || errorStr.includes("too many requests"); } } From 355e0a6b808781081bd54c05f2bd74afbc94a2a5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:36:32 +0900 Subject: [PATCH 22/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20delete?= =?UTF-8?q?=20notification=20throttling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index b213cb4c4..6af6e3ecb 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -713,6 +713,74 @@ console.log("ok");` expect(order).toEqual(["delete:start", "delete:end", "digest:list"]); }); + it("同一轮同步多个远端删除标记只发送一条删除通知", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "first.meta.json", + path: "first.meta.json", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + { + name: "second.meta.json", + path: "second.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([]), + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: file.name.replace(".meta.json", ""), isDeleted: true })), + })), + }); + const deleteScript = vi.fn().mockResolvedValue(undefined); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const service = new SynchronizeService( + {} as any, + {} as any, + { deleteScript } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "first", + name: "first", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + { + uuid: "second", + name: "second", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(deleteScript).toHaveBeenCalledTimes(2); + expect(notificationSpy).toHaveBeenCalledTimes(1); + }); + it("waits for pushScript before updating file digest", async () => { let releasePush!: () => void; const pushGate = new Promise((resolve) => { From b1ad94fe3ea9374b588269f9185e51bafc02d7d5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:37:56 +0900 Subject: [PATCH 23/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20throttle=20de?= =?UTF-8?q?lete=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 7695a2de2..d3c6afc30 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -422,6 +422,7 @@ export class SynchronizeService { // 记录被跳过的孤儿云端脚本(仅 .user.js 无 .meta.json) // 避免本机回写 scriptcat-sync.json 时丢失对应 uuid 的云端 status const skippedOrphanUuids = new Set(); + let hasNotifiedSyncDelete = false; // 需要是同步操作,后续上传剩下的脚本 // 最后使用 Promise.allSettled 进行等待 const addSyncTask = (uuid: string, promise: Promise, files?: string[]) => { @@ -446,12 +447,15 @@ export class SynchronizeService { if (metaObj.isDeleted) { // 删除脚本 await this.script.deleteScript(script.uuid, "sync"); - InfoNotification( - i18n.t("notification.script_sync_delete"), - i18n.t("notification.script_sync_delete_desc", { - scriptName: i18nName(script), - }) - ); + if (!hasNotifiedSyncDelete) { + hasNotifiedSyncDelete = true; + InfoNotification( + i18n.t("notification.script_sync_delete"), + i18n.t("notification.script_sync_delete_desc", { + scriptName: i18nName(script), + }) + ); + } } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); From b10703501ea5c95ef41ab75348efc762e2a6ae68 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:42:39 +0900 Subject: [PATCH 24/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20filesyste?= =?UTF-8?q?m=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/filesystem.test.ts | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/filesystem/filesystem.test.ts diff --git a/packages/filesystem/filesystem.test.ts b/packages/filesystem/filesystem.test.ts new file mode 100644 index 000000000..e367586fc --- /dev/null +++ b/packages/filesystem/filesystem.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import type FileSystem from "./filesystem"; +import * as filesystemModule from "./filesystem"; +import LimiterFileSystem from "./limiter"; + +const getFileSystemCapabilities = (filesystemModule as Record).getFileSystemCapabilities as ( + fs: FileSystem +) => unknown; + +function createFs(overrides: Partial = {}): FileSystem { + return { + verify: vi.fn(async () => {}), + open: vi.fn(async () => ({ read: vi.fn(async () => "") })), + openDir: vi.fn(async () => createFs()), + create: vi.fn(async () => ({ write: vi.fn(async () => {}) })), + createDir: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + list: vi.fn(async () => []), + getDirUrl: vi.fn(async () => ""), + ...overrides, + }; +} + +describe("FileSystem capabilities", () => { + it("未声明能力时应当默认不支持原子同步能力", () => { + const fs = createFs(); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + + it("应当合并 provider 显式声明的能力", () => { + const fs = createFs({ + capabilities: { + supportsCreateOnly: true, + }, + } as Partial); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: true, + supportsConditionalDelete: false, + }); + }); + + it("LimiterFileSystem 应当透传底层 provider 能力", () => { + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsConditionalDelete: true, + }, + } as Partial); + const limiter = new LimiterFileSystem(fs); + + expect(getFileSystemCapabilities(limiter)).toEqual({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: false, + supportsConditionalDelete: true, + }); + }); +}); From abbd80de3cdcd3d5939977c8da6a4c6f69a0d910 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:45:34 +0900 Subject: [PATCH 25/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20expose=20filesy?= =?UTF-8?q?stem=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/filesystem.test.ts | 10 +++------- packages/filesystem/filesystem.ts | 20 ++++++++++++++++++++ packages/filesystem/limiter.ts | 5 +++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/filesystem/filesystem.test.ts b/packages/filesystem/filesystem.test.ts index e367586fc..7bef88c5e 100644 --- a/packages/filesystem/filesystem.test.ts +++ b/packages/filesystem/filesystem.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type FileSystem from "./filesystem"; -import * as filesystemModule from "./filesystem"; +import { getFileSystemCapabilities } from "./filesystem"; import LimiterFileSystem from "./limiter"; -const getFileSystemCapabilities = (filesystemModule as Record).getFileSystemCapabilities as ( - fs: FileSystem -) => unknown; - function createFs(overrides: Partial = {}): FileSystem { return { verify: vi.fn(async () => {}), @@ -37,7 +33,7 @@ describe("FileSystem capabilities", () => { capabilities: { supportsCreateOnly: true, }, - } as Partial); + }); expect(getFileSystemCapabilities(fs)).toEqual({ supportsAtomicCompareAndSwap: false, @@ -52,7 +48,7 @@ describe("FileSystem capabilities", () => { supportsAtomicCompareAndSwap: true, supportsConditionalDelete: true, }, - } as Partial); + }); const limiter = new LimiterFileSystem(fs); expect(getFileSystemCapabilities(limiter)).toEqual({ diff --git a/packages/filesystem/filesystem.ts b/packages/filesystem/filesystem.ts index be40308f0..a96947f87 100644 --- a/packages/filesystem/filesystem.ts +++ b/packages/filesystem/filesystem.ts @@ -31,8 +31,28 @@ export type FileCreateOptions = { modifiedDate?: number; }; +export type FileSystemCapabilities = { + supportsAtomicCompareAndSwap: boolean; + supportsCreateOnly: boolean; + supportsConditionalDelete: boolean; +}; + +export const DEFAULT_FILE_SYSTEM_CAPABILITIES: FileSystemCapabilities = { + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, +}; + +export function getFileSystemCapabilities(fs: FileSystem): FileSystemCapabilities { + return { + ...DEFAULT_FILE_SYSTEM_CAPABILITIES, + ...fs.capabilities, + }; +} + // 文件读取 export default interface FileSystem { + readonly capabilities?: Partial; // 授权验证 verify(): Promise; // 打开文件 diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index b2fbd5e10..1108b896d 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,5 +1,6 @@ import type FileSystem from "./filesystem"; import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; +import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); @@ -97,6 +98,10 @@ export default class LimiterFileSystem implements FileSystem { this.limiter = limiter || new RateLimiter(); } + get capabilities(): FileSystemCapabilities { + return getFileSystemCapabilities(this.fs); + } + verify(): Promise { return this.limiter.execute(() => this.fs.verify(), "verify"); } From b26a2c55418c860242bdbc4e25f775eb02010772 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:48:09 +0900 Subject: [PATCH 26/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20raw=20read=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 72e21ee97..513b12611 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -79,6 +79,30 @@ describe("DropboxFileSystem", () => { }); }); + it("读取文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ + status: 429, + text: vi.fn().mockResolvedValue(JSON.stringify({ error_summary: "too_many_requests/..." })), + } as unknown as Response); + const reader = await fs.open({ + name: "limited.user.js", + path: "/", + size: 1, + digest: "digest", + createtime: 1, + updatetime: 1, + }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "dropbox", + status: 429, + code: "too_many_requests/...", + rateLimit: true, + retryable: true, + }); + }); + it("delete should be idempotent on path not found", async () => { const fs = new DropboxFileSystem("/", "token"); vi.spyOn(fs, "request").mockRejectedValue( From 90ba0623ded3ffeec56646a6d684bf01ebee89d2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:49:54 +0900 Subject: [PATCH 27/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20dropbox?= =?UTF-8?q?=20raw=20read=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.ts | 4 ++++ packages/filesystem/dropbox/rw.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 1dd218143..f36ae2e44 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -186,6 +186,10 @@ export default class DropboxFileSystem implements FileSystem { }); } + async createResponseError(resp: Response): Promise { + return toDropboxFileSystemError(resp.status, await resp.text()); + } + async delete(path: string): Promise { const fullPath = joinPath(this.path, path); diff --git a/packages/filesystem/dropbox/rw.ts b/packages/filesystem/dropbox/rw.ts index f63bfc2ea..076fc9e02 100644 --- a/packages/filesystem/dropbox/rw.ts +++ b/packages/filesystem/dropbox/rw.ts @@ -35,7 +35,7 @@ export class DropboxFileReader implements FileReader { ); if (data.status !== 200) { - return Promise.reject(await data.text()); + throw await this.fs.createResponseError(data); } switch (type) { From aa007acca9da5cc58fc006c0d009d508d9b3f969 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:04:20 +0900 Subject: [PATCH 28/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20s3=20tran?= =?UTF-8?q?sient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/s3.test.ts | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 8e52a51c7..7d624e5e7 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -155,6 +155,29 @@ describe("S3FileSystem", () => { expect(mockClient.request).toHaveBeenCalledWith("GET", "test-bucket", "data/hello.txt"); expect(content).toBe("file content"); }); + + it("读取文件遇到 503 时应当抛出 typed 可重试错误", async () => { + const fileInfo: FileInfo = { + name: "busy.user.js", + path: "/", + size: 50, + digest: "xyz", + createtime: 1000, + updatetime: 2000, + }; + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("ServiceUnavailable", "Please retry later", 503) + ); + + const reader = await fs.open(fileInfo); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "s3", + status: 503, + code: "ServiceUnavailable", + retryable: true, + }); + }); }); // ---- create ---- @@ -400,6 +423,20 @@ describe("S3FileSystem", () => { await expect(fs.list()).rejects.toThrow("Permission denied"); }); + + it("列目录遇到 429 时应当抛出 typed 限流错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("SlowDown", "Please reduce your request rate", 429) + ); + + await expect(fs.list()).rejects.toMatchObject({ + provider: "s3", + status: 429, + code: "SlowDown", + rateLimit: true, + retryable: true, + }); + }); }); // ---- openDir ---- From feaf735403ca98fc5af6cc5b6a2e61a4ce21930d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:06:43 +0900 Subject: [PATCH 29/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20s3=20tra?= =?UTF-8?q?nsient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/error.ts | 23 +++++++++++++++++++++++ packages/filesystem/s3/rw.ts | 25 +++++++++++++++++-------- packages/filesystem/s3/s3.ts | 5 +++-- 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 packages/filesystem/s3/error.ts diff --git a/packages/filesystem/s3/error.ts b/packages/filesystem/s3/error.ts new file mode 100644 index 000000000..78cbbb67b --- /dev/null +++ b/packages/filesystem/s3/error.ts @@ -0,0 +1,23 @@ +import { FileSystemError } from "../error"; +import { S3Error } from "./client"; + +export function createS3FileSystemError(error: unknown): unknown { + if (!(error instanceof S3Error)) { + return error; + } + + const rateLimit = error.statusCode === 429 || error.code === "SlowDown"; + + return new FileSystemError({ + provider: "s3", + message: error.message, + status: error.statusCode, + code: error.code, + auth: error.statusCode === 401 || error.statusCode === 403, + notFound: error.statusCode === 404 || error.code === "NoSuchKey" || error.code === "NoSuchBucket", + conflict: error.statusCode === 409 || error.statusCode === 412 || error.code === "PreconditionFailed", + rateLimit, + retryable: rateLimit || error.statusCode >= 500, + raw: error, + }); +} diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index 378fb18ad..dcfdc9c6b 100644 --- a/packages/filesystem/s3/rw.ts +++ b/packages/filesystem/s3/rw.ts @@ -1,5 +1,6 @@ import type { S3Client } from "./client"; import type { FileReader, FileWriter } from "../filesystem"; +import { createS3FileSystemError } from "./error"; /** * S3 文件读取器 @@ -25,11 +26,15 @@ export class S3FileReader implements FileReader { * @throws {S3Error} 文件不存在或读取失败 */ async read(type: "string" | "blob" = "blob"): Promise { - const response = await this.client.request("GET", this.bucket, this.key); - if (type === "string") { - return response.text(); + try { + const response = await this.client.request("GET", this.bucket, this.key); + if (type === "string") { + return response.text(); + } + return response.blob(); + } catch (error) { + throw createS3FileSystemError(error); } - return response.blob(); } } @@ -69,9 +74,13 @@ export class S3FileWriter implements FileWriter { headers["x-amz-meta-createtime"] = new Date(this.modifiedDate).toISOString(); } - await this.client.request("PUT", this.bucket, this.key, { - body: typeof body === "string" ? body : body, - headers, - }); + try { + await this.client.request("PUT", this.bucket, this.key, { + body: typeof body === "string" ? body : body, + headers, + }); + } catch (error) { + throw createS3FileSystemError(error); + } } } diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index 41ce89e6a..42559555d 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -6,6 +6,7 @@ import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../fil import { joinPath } from "../utils"; import { S3FileReader, S3FileWriter } from "./rw"; import { WarpTokenError } from "../error"; +import { createS3FileSystemError } from "./error"; // ---- ListObjectsV2 XML 解析 ---- @@ -190,7 +191,7 @@ export default class S3FileSystem implements FileSystem { if (error instanceof S3Error && error.code === "NoSuchKey") { return; } - throw error; + throw createS3FileSystemError(error); } } @@ -254,7 +255,7 @@ export default class S3FileSystem implements FileSystem { if (error instanceof S3Error && error.code === "AccessDenied") { throw new Error(`Permission denied. Check your IAM permissions for bucket: ${this.bucket}`); } - throw error; + throw createS3FileSystemError(error); } } From 089d8edb2b395d3c08a3a4ca0c39378060450cf1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:15:21 +0900 Subject: [PATCH 30/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20webdav=20?= =?UTF-8?q?transient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/webdav.test.ts | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 4d91a5edb..d5227e1d5 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -221,6 +221,29 @@ describe("WebDAVFileSystem", () => { }); }); + describe("open", () => { + it("读取文件遇到 503 时应当抛出 typed 可重试错误", async () => { + const err = new Error("Service Unavailable"); + (err as any).response = { status: 503 }; + (mockClient.getFileContents as ReturnType).mockRejectedValue(err); + const fs = createTestFS(mockClient); + const reader = await fs.open({ + name: "busy.user.js", + path: "/", + size: 1, + digest: "digest", + createtime: 1, + updatetime: 1, + }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "webdav", + status: 503, + retryable: true, + }); + }); + }); + describe("list", () => { it("应当列出文件并过滤目录", async () => { (mockClient.getDirectoryContents as ReturnType).mockResolvedValue([ @@ -270,6 +293,20 @@ describe("WebDAVFileSystem", () => { await expect(fs.list()).rejects.toThrow("Server Error"); }); + + it("列目录遇到 429 时应当抛出 typed 限流错误", async () => { + const err = new Error("Too Many Requests"); + (err as any).response = { status: 429 }; + (mockClient.getDirectoryContents as ReturnType).mockRejectedValue(err); + const fs = createTestFS(mockClient); + + await expect(fs.list()).rejects.toMatchObject({ + provider: "webdav", + status: 429, + rateLimit: true, + retryable: true, + }); + }); }); describe("getDirUrl", () => { From 3f239555426f5be560d0d35e0712f48a95d071b9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:17:41 +0900 Subject: [PATCH 31/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20webdav?= =?UTF-8?q?=20transient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/error.ts | 30 ++++++++++++++++++++++++++ packages/filesystem/webdav/rw.ts | 32 ++++++++++++++++++---------- packages/filesystem/webdav/webdav.ts | 7 +++--- 3 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 packages/filesystem/webdav/error.ts diff --git a/packages/filesystem/webdav/error.ts b/packages/filesystem/webdav/error.ts new file mode 100644 index 000000000..4c7a9a86c --- /dev/null +++ b/packages/filesystem/webdav/error.ts @@ -0,0 +1,30 @@ +import { FileSystemError } from "../error"; + +type WebDAVLikeError = { + message?: string; + response?: { + status?: number; + }; +}; + +export function createWebDAVFileSystemError(error: unknown): unknown { + const webdavError = error as WebDAVLikeError; + const status = webdavError?.response?.status; + if (typeof status !== "number") { + return error; + } + + const rateLimit = status === 429; + + return new FileSystemError({ + provider: "webdav", + message: webdavError.message || `WebDAV request failed with status ${status}`, + status, + auth: status === 401 || status === 403, + notFound: status === 404, + conflict: status === 409 || status === 412, + rateLimit, + retryable: rateLimit || status >= 500, + raw: error, + }); +} diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index c13afe2f4..15f77f755 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,5 +1,6 @@ import type { WebDAVClient } from "webdav"; import type { FileReader, FileWriter } from "../filesystem"; +import { createWebDAVFileSystemError } from "./error"; export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -12,17 +13,21 @@ export class WebDAVFileReader implements FileReader { } async read(type?: "string" | "blob"): Promise { - switch (type) { - case "string": - return await (this.client.getFileContents(this.path, { - format: "text", - }) as Promise); - default: { - const resp = (await this.client.getFileContents(this.path, { - format: "binary", - })) as ArrayBuffer; - return new Blob([resp]); + try { + switch (type) { + case "string": + return await (this.client.getFileContents(this.path, { + format: "text", + }) as Promise); + default: { + const resp = (await this.client.getFileContents(this.path, { + format: "binary", + })) as ArrayBuffer; + return new Blob([resp]); + } } + } catch (error) { + throw createWebDAVFileSystemError(error); } } } @@ -39,7 +44,12 @@ export class WebDAVFileWriter implements FileWriter { async write(content: string | Blob): Promise { const data = content instanceof Blob ? await content.arrayBuffer() : content; - const resp = await this.client.putFileContents(this.path, data); + let resp: boolean; + try { + resp = await this.client.putFileContents(this.path, data); + } catch (error) { + throw createWebDAVFileSystemError(error); + } if (!resp) { throw new Error("write error"); } diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 871097537..33cedada9 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -5,6 +5,7 @@ import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../fil import { joinPath } from "../utils"; import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; import { WarpTokenError } from "../error"; +import { createWebDAVFileSystemError } from "./error"; // 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) // 全局单次注册 @@ -87,7 +88,7 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 405 || e.message?.includes("405")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } @@ -98,7 +99,7 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 404 || e.message?.includes("404")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } @@ -108,7 +109,7 @@ export default class WebDAVFileSystem implements FileSystem { dir = (await this.client.getDirectoryContents(this.basePath)) as FileStat[]; } catch (e: any) { if (e.response?.status === 404) return [] as FileInfo[]; // 目录不存在视为空 - throw e; + throw createWebDAVFileSystemError(e); } const ret: FileInfo[] = []; for (const item of dir) { From dfd23f74f2278cc23f5e623e7816c3170b324d31 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:22:54 +0900 Subject: [PATCH 32/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20s3=20cond?= =?UTF-8?q?itional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/s3.test.ts | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 7d624e5e7..4fb3e6d94 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -47,6 +47,14 @@ describe("S3FileSystem", () => { fs = new S3FileSystem("test-bucket", mockClient); }); + it("应当声明支持原子条件写入和条件删除能力", () => { + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }); + }); + // ---- verify ---- describe("verify", () => { it("应当成功验证 bucket", async () => { @@ -226,6 +234,42 @@ describe("S3FileSystem", () => { ); }); + it("条件写入应当将 expectedDigest 转成 If-Match", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); + + const writer = await (fs as any).create("output.txt", { expectedDigest: "abc123" }); + await writer.write("hello world"); + + expect(mockClient.request).toHaveBeenCalledWith( + "PUT", + "test-bucket", + "output.txt", + expect.objectContaining({ + headers: expect.objectContaining({ + "if-match": '"abc123"', + }), + }) + ); + }); + + it("createOnly 写入应当使用 If-None-Match", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); + + const writer = await (fs as any).create("new.txt", { createOnly: true }); + await writer.write("hello world"); + + expect(mockClient.request).toHaveBeenCalledWith( + "PUT", + "test-bucket", + "new.txt", + expect.objectContaining({ + headers: expect.objectContaining({ + "if-none-match": "*", + }), + }) + ); + }); + it("normalizes double slashes in object keys", async () => { const subFs = new S3FileSystem("test-bucket", mockClient, "/ScriptCat//sync"); @@ -275,6 +319,23 @@ describe("S3FileSystem", () => { expect(mockClient.request).toHaveBeenCalledWith("DELETE", "test-bucket", "ScriptCat/sync/dir/file.user.js"); }); + + it("条件删除应当将 expectedDigest 转成 If-Match", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true, status: 204 })); + + await (fs as any).delete("test.txt", { expectedDigest: "abc123" }); + + expect(mockClient.request).toHaveBeenCalledWith( + "DELETE", + "test-bucket", + "test.txt", + expect.objectContaining({ + headers: { + "if-match": '"abc123"', + }, + }) + ); + }); }); // ---- list ---- From 889d08dec4f92a6f5cf41932a05f7802a4facb41 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:26:40 +0900 Subject: [PATCH 33/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20support=20s3=20?= =?UTF-8?q?conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/filesystem.ts | 8 +++++++- packages/filesystem/limiter.ts | 6 +++--- packages/filesystem/s3/rw.ts | 21 ++++++++++++++++++--- packages/filesystem/s3/s3.ts | 25 +++++++++++++++++++++---- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/filesystem/filesystem.ts b/packages/filesystem/filesystem.ts index a96947f87..5fa2c6259 100644 --- a/packages/filesystem/filesystem.ts +++ b/packages/filesystem/filesystem.ts @@ -29,6 +29,12 @@ export type FileReadWriter = FileReader & FileWriter; export type FileCreateOptions = { modifiedDate?: number; + expectedDigest?: string; + createOnly?: boolean; +}; + +export type FileDeleteOptions = { + expectedDigest?: string; }; export type FileSystemCapabilities = { @@ -64,7 +70,7 @@ export default interface FileSystem { // 创建目录 createDir(dir: string, opts?: FileCreateOptions): Promise; // 删除文件 - delete(path: string): Promise; + delete(path: string, opts?: FileDeleteOptions): Promise; // 文件列表 list(): Promise; // getDirUrl 获取目录的url diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 1108b896d..b9cff1ad0 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,5 +1,5 @@ import type FileSystem from "./filesystem"; -import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; +import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; @@ -135,8 +135,8 @@ export default class LimiterFileSystem implements FileSystem { return this.limiter.execute(() => this.fs.createDir(dir, opts), "createDir"); } - delete(path: string): Promise { - return this.limiter.execute(() => this.fs.delete(path), "delete"); + delete(path: string, opts?: FileDeleteOptions): Promise { + return this.limiter.execute(() => this.fs.delete(path, opts), "delete"); } list(): Promise { diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index dcfdc9c6b..87b11749d 100644 --- a/packages/filesystem/s3/rw.ts +++ b/packages/filesystem/s3/rw.ts @@ -1,7 +1,11 @@ import type { S3Client } from "./client"; -import type { FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { createS3FileSystemError } from "./error"; +function quoteETag(digest: string): string { + return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; +} + /** * S3 文件读取器 * 通过 GET 请求下载 S3 对象 @@ -51,11 +55,17 @@ export class S3FileWriter implements FileWriter { modifiedDate?: number; - constructor(client: S3Client, bucket: string, key: string, modifiedDate?: number) { + expectedDigest?: string; + + createOnly?: boolean; + + constructor(client: S3Client, bucket: string, key: string, opts?: FileCreateOptions) { this.client = client; this.bucket = bucket; this.key = key; - this.modifiedDate = modifiedDate; + this.modifiedDate = opts?.modifiedDate; + this.expectedDigest = opts?.expectedDigest; + this.createOnly = opts?.createOnly; } /** @@ -73,6 +83,11 @@ export class S3FileWriter implements FileWriter { // 历史兼容:S3 侧使用 createtime 元数据保存文件时间,实际来源是 FileCreateOptions.modifiedDate。 headers["x-amz-meta-createtime"] = new Date(this.modifiedDate).toISOString(); } + if (this.expectedDigest) { + headers["if-match"] = quoteETag(this.expectedDigest); + } else if (this.createOnly) { + headers["if-none-match"] = "*"; + } try { await this.client.request("PUT", this.bucket, this.key, { diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index 42559555d..fe9f76459 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -2,7 +2,7 @@ import { XMLParser } from "fast-xml-parser"; import { S3Client, S3Error } from "./client"; import type { S3ClientConfig } from "./client"; import type FileSystem from "../filesystem"; -import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import { S3FileReader, S3FileWriter } from "./rw"; import { WarpTokenError } from "../error"; @@ -26,6 +26,10 @@ const xmlParser = new XMLParser({ isArray: (name) => name === "Contents", }); +function quoteETag(digest: string): string { + return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; +} + /** 从 ListObjectsV2 XML 响应中解析对象列表 */ function parseListObjectsV2(xml: string): ListObjectsV2Result { const parsed = xmlParser.parse(xml); @@ -53,6 +57,12 @@ function parseListObjectsV2(xml: string): ListObjectsV2Result { * 使用原生 fetch + AWS Signature V4 签名,不依赖 @aws-sdk/client-s3 */ export default class S3FileSystem implements FileSystem { + readonly capabilities = { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }; + client: S3Client; bucket: string; @@ -168,7 +178,7 @@ export default class S3FileSystem implements FileSystem { * @returns 文件写入器 */ async create(path: string, opts?: FileCreateOptions): Promise { - return new S3FileWriter(this.client, this.bucket, joinPath(this.basePath, path).substring(1), opts?.modifiedDate); + return new S3FileWriter(this.client, this.bucket, joinPath(this.basePath, path).substring(1), opts); } /** @@ -183,9 +193,16 @@ export default class S3FileSystem implements FileSystem { * 此操作幂等——删除不存在的文件也会成功 * @param path 相对于当前 basePath 的文件路径 */ - async delete(path: string): Promise { + async delete(path: string, opts?: FileDeleteOptions): Promise { try { - await this.client.request("DELETE", this.bucket, joinPath(this.basePath, path).substring(1)); + const key = joinPath(this.basePath, path).substring(1); + if (opts?.expectedDigest) { + await this.client.request("DELETE", this.bucket, key, { + headers: { "if-match": quoteETag(opts.expectedDigest) }, + }); + return; + } + await this.client.request("DELETE", this.bucket, key); } catch (error: any) { // S3 delete 是幂等的,key 不存在时也视为成功 if (error instanceof S3Error && error.code === "NoSuchKey") { From 5ba7db86ada33af156a1f2ade5f4ee9976937c18 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:20:04 +0900 Subject: [PATCH 34/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20webdav=20?= =?UTF-8?q?conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/webdav.test.ts | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index d5227e1d5..b0447ef7e 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -32,6 +32,16 @@ describe("WebDAVFileSystem", () => { mockClient = createMockClient(); }); + it("应当声明支持原子条件写入和条件删除能力", () => { + const fs = createTestFS(mockClient); + + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }); + }); + describe("initWebDAVPatch", () => { it("应当通过 getPatcher 注册 fetch patch,设置 credentials 为 omit", () => { // fromCredentials 内部调用 initWebDAVPatch,验证 patcher 已注册 fetch @@ -206,6 +216,18 @@ describe("WebDAVFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); + + it("条件删除应当将 expectedDigest 转成 If-Match", async () => { + const fs = createTestFS(mockClient); + + await (fs as any).delete("test.txt", { expectedDigest: '"abc123"' }); + + expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt", { + headers: { + "If-Match": '"abc123"', + }, + }); + }); }); describe("create", () => { @@ -219,6 +241,38 @@ describe("WebDAVFileSystem", () => { expect((writer as any).path).toBe("/ScriptCat/sync/dir/file.user.js"); }); + + it("条件写入应当将 expectedDigest 转成 If-Match", async () => { + const fs = createTestFS(mockClient); + const writer = await (fs as any).create("test.txt", { expectedDigest: '"abc123"' }); + + await writer.write("content"); + + expect(mockClient.putFileContents).toHaveBeenCalledWith( + "/test.txt", + "content", + expect.objectContaining({ + headers: { + "If-Match": '"abc123"', + }, + }) + ); + }); + + it("createOnly 写入应当使用 WebDAV overwrite=false", async () => { + const fs = createTestFS(mockClient); + const writer = await (fs as any).create("new.txt", { createOnly: true }); + + await writer.write("content"); + + expect(mockClient.putFileContents).toHaveBeenCalledWith( + "/new.txt", + "content", + expect.objectContaining({ + overwrite: false, + }) + ); + }); }); describe("open", () => { From 8778b1923036f7aa8eb0e5524289c7e621af7d00 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:21:57 +0900 Subject: [PATCH 35/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20support=20webda?= =?UTF-8?q?v=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/rw.ts | 32 +++++++++++++++++++++++++--- packages/filesystem/webdav/webdav.ts | 23 +++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index 15f77f755..da717761d 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,7 +1,9 @@ import type { WebDAVClient } from "webdav"; -import type { FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { createWebDAVFileSystemError } from "./error"; +const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); + export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -37,16 +39,24 @@ export class WebDAVFileWriter implements FileWriter { path: string; - constructor(client: WebDAVClient, path: string) { + opts?: FileCreateOptions; + + constructor(client: WebDAVClient, path: string, opts?: FileCreateOptions) { this.client = client; this.path = path; + this.opts = opts; } async write(content: string | Blob): Promise { const data = content instanceof Blob ? await content.arrayBuffer() : content; let resp: boolean; try { - resp = await this.client.putFileContents(this.path, data); + const opts = this.buildWriteOptions(); + if (opts) { + resp = await this.client.putFileContents(this.path, data, opts); + } else { + resp = await this.client.putFileContents(this.path, data); + } } catch (error) { throw createWebDAVFileSystemError(error); } @@ -54,4 +64,20 @@ export class WebDAVFileWriter implements FileWriter { throw new Error("write error"); } } + + private buildWriteOptions() { + if (this.opts?.expectedDigest) { + return { + headers: { + "If-Match": quoteETag(this.opts.expectedDigest), + }, + }; + } + if (this.opts?.createOnly) { + return { + overwrite: false, + }; + } + return undefined; + } } diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 33cedada9..db8440954 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -6,6 +6,7 @@ import { joinPath } from "../utils"; import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; import { WarpTokenError } from "../error"; import { createWebDAVFileSystemError } from "./error"; +import type { FileDeleteOptions } from "../filesystem"; // 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) // 全局单次注册 @@ -24,7 +25,15 @@ const initWebDAVPatch = () => { }); }; +const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); + export default class WebDAVFileSystem implements FileSystem { + readonly capabilities = { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }; + client: WebDAVClient; url: string; @@ -76,8 +85,8 @@ export default class WebDAVFileSystem implements FileSystem { return WebDAVFileSystem.fromSameClient(this, joinPath(this.basePath, path)); } - async create(path: string, _opts?: FileCreateOptions): Promise { - return new WebDAVFileWriter(this.client, joinPath(this.basePath, path)); + async create(path: string, opts?: FileCreateOptions): Promise { + return new WebDAVFileWriter(this.client, joinPath(this.basePath, path), opts); } async createDir(path: string, _opts?: FileCreateOptions): Promise { @@ -92,8 +101,16 @@ export default class WebDAVFileSystem implements FileSystem { } } - async delete(path: string): Promise { + async delete(path: string, opts?: FileDeleteOptions): Promise { try { + if (opts?.expectedDigest) { + await this.client.deleteFile(joinPath(this.basePath, path), { + headers: { + "If-Match": quoteETag(opts.expectedDigest), + }, + }); + return; + } await this.client.deleteFile(joinPath(this.basePath, path)); } catch (e: any) { if (e.response?.status === 404 || e.message?.includes("404")) { From a52998c68fba3b4456581f79d0521bbc4dff4dc4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:25:33 +0900 Subject: [PATCH 36/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20onedrive?= =?UTF-8?q?=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/onedrive/onedrive.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index 7af87d17e..c5f4a06fa 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -28,6 +28,16 @@ describe("OneDriveFileSystem", () => { vi.stubGlobal("fetch", originalFetch); }); + it("应当声明支持原子条件写入和条件删除能力", () => { + const fs = new OneDriveFileSystem("/", "token"); + + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }); + }); + it("request should return retry result after token refresh", async () => { await localStorageDAO.saveValue("netdisk:token:onedrive", { accessToken: "expired-token", @@ -163,6 +173,24 @@ describe("OneDriveFileSystem", () => { ); }); + it("条件删除应当将 expectedDigest 转成 If-Match", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const request = vi.spyOn(fs, "request").mockResolvedValue({ status: 204 }); + + await (fs as any).delete("test.txt", { expectedDigest: "abc123" }); + + expect(request).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/drive/special/approot:/test.txt", + { + method: "DELETE", + headers: expect.objectContaining({ + "If-Match": "abc123", + }), + }, + true + ); + }); + it("createDir should create nested directories from root", async () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); @@ -399,4 +427,34 @@ describe("OneDriveFileSystem", () => { const headers = (requestSpy.mock.calls[1][1] as RequestInit).headers as Headers; expect(headers.get("Content-Range")).toBe("bytes 0-2/3"); }); + + it("条件写入应当将 expectedDigest 传给 upload session 的 If-Match", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi + .spyOn(fs, "request") + .mockResolvedValueOnce({ uploadUrl: "https://upload.example/session" }) + .mockResolvedValueOnce({}); + + const writer = await (fs as any).create("not-empty.txt", { expectedDigest: "abc123" }); + await writer.write("abc"); + + const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers; + expect(headers.get("If-Match")).toBe("abc123"); + }); + + it("createOnly 写入应当将 If-None-Match 传给 upload session 并使用 fail 语义", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi + .spyOn(fs, "request") + .mockResolvedValueOnce({ uploadUrl: "https://upload.example/session" }) + .mockResolvedValueOnce({}); + + const writer = await (fs as any).create("not-empty.txt", { createOnly: true }); + await writer.write("abc"); + + const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers; + const body = JSON.parse((requestSpy.mock.calls[0][1] as RequestInit).body as string); + expect(headers.get("If-None-Match")).toBe("*"); + expect(body.item["@microsoft.graph.conflictBehavior"]).toBe("fail"); + }); }); From 41c07ed3c50d6a0c918c669cec2ed942560dd075 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:28:06 +0900 Subject: [PATCH 37/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20support=20onedr?= =?UTF-8?q?ive=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/onedrive/onedrive.ts | 28 ++++++++++++------ packages/filesystem/onedrive/rw.ts | 36 ++++++++++++++++++++---- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 60b3725bb..e7529e1fc 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -1,11 +1,17 @@ import { AuthVerify } from "../auth"; import { FileSystemError } from "../error"; -import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem"; import type FileSystem from "../filesystem"; import { joinPath } from "../utils"; import { OneDriveFileReader, OneDriveFileWriter } from "./rw"; export default class OneDriveFileSystem implements FileSystem { + readonly capabilities = { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }; + accessToken?: string; path: string; @@ -32,8 +38,8 @@ export default class OneDriveFileSystem implements FileSystem { return new OneDriveFileSystem(joinPath(this.path, path), this.accessToken); } - async create(path: string, _opts?: FileCreateOptions): Promise { - return new OneDriveFileWriter(this, joinPath(this.path, path)); + async create(path: string, opts?: FileCreateOptions): Promise { + return new OneDriveFileWriter(this, joinPath(this.path, path), opts); } async createDir(dir: string, _opts?: FileCreateOptions): Promise { @@ -127,7 +133,7 @@ export default class OneDriveFileSystem implements FileSystem { request(url: string, config?: RequestInit, nothen?: boolean): Promise { config = config || {}; - const headers = config.headers || new Headers(); + const headers = new Headers(config.headers); if (!url.includes("uploadSession")) { headers.set(`Authorization`, `Bearer ${this.accessToken}`); } @@ -182,12 +188,18 @@ export default class OneDriveFileSystem implements FileSystem { }); } - async delete(path: string): Promise { + async delete(path: string, opts?: FileDeleteOptions): Promise { + const config: RequestInit = { + method: "DELETE", + }; + if (opts?.expectedDigest) { + config.headers = { + "If-Match": opts.expectedDigest, + }; + } const resp = await this.request( `https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath(this.path, path)}`, - { - method: "DELETE", - }, + config, true ); if (resp.status === 404) { diff --git a/packages/filesystem/onedrive/rw.ts b/packages/filesystem/onedrive/rw.ts index 8a0dc0fa8..7c6158b38 100644 --- a/packages/filesystem/onedrive/rw.ts +++ b/packages/filesystem/onedrive/rw.ts @@ -1,5 +1,5 @@ import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto"; -import type { FileInfo, FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import type OneDriveFileSystem from "./onedrive"; @@ -37,9 +37,12 @@ export class OneDriveFileWriter implements FileWriter { fs: OneDriveFileSystem; - constructor(fs: OneDriveFileSystem, path: string) { + opts?: FileCreateOptions; + + constructor(fs: OneDriveFileSystem, path: string, opts?: FileCreateOptions) { this.fs = fs; this.path = path; + this.opts = opts; } size(content: string | Blob) { @@ -60,21 +63,30 @@ export class OneDriveFileWriter implements FileWriter { // 预上传获取id const size = this.size(content); if (size === 0) { - return this.fs.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/content`, { + const config: RequestInit = { method: "PUT", body: content, - }); + }; + const writeHeaders = this.buildConditionalHeaders(); + if (writeHeaders) { + config.headers = writeHeaders; + } + return this.fs.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/content`, config); } let myHeaders = new Headers(); myHeaders.append("Content-Type", "application/json"); + const conditionHeaders = this.buildConditionalHeaders(); + if (conditionHeaders) { + conditionHeaders.forEach((value, key) => myHeaders.set(key, value)); + } const uploadUrl = await this.fs .request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/createUploadSession`, { method: "POST", headers: myHeaders, body: JSON.stringify({ item: { - "@microsoft.graph.conflictBehavior": "replace", + "@microsoft.graph.conflictBehavior": this.opts?.createOnly ? "fail" : "replace", // description: "description", // fileSystemInfo: { // "@odata.type": "microsoft.graph.fileSystemInfo", @@ -97,4 +109,18 @@ export class OneDriveFileWriter implements FileWriter { headers: myHeaders, }); } + + private buildConditionalHeaders(): Headers | undefined { + if (this.opts?.expectedDigest) { + const headers = new Headers(); + headers.set("If-Match", this.opts.expectedDigest); + return headers; + } + if (this.opts?.createOnly) { + const headers = new Headers(); + headers.set("If-None-Match", "*"); + return headers; + } + return undefined; + } } From a3ec5e1866790ac389fb84a9103c2c541f2fbf3f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:49:11 +0900 Subject: [PATCH 38/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20conditi?= =?UTF-8?q?onal=20push=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 6af6e3ecb..348815602 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -895,6 +895,176 @@ console.log("ok");` }); }); + it("push 已有云端文件时应当用旧 digest 作为 expectedDigest", async () => { + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + }, + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "push-uuid.user.js", + path: "push-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 1, + }, + { + name: "push-uuid.meta.json", + path: "push-uuid.meta.json", + size: 1, + digest: "cloud-meta-new", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + await (service as any).storage.set("file_digest", { + "push-uuid.user.js": "old-user-digest", + "push-uuid.meta.json": "old-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(fs.create).toHaveBeenCalledWith( + "push-uuid.user.js", + expect.objectContaining({ expectedDigest: "old-user-digest" }) + ); + expect(fs.create).toHaveBeenCalledWith( + "push-uuid.meta.json", + expect.objectContaining({ expectedDigest: "old-meta-digest" }) + ); + }); + + it("push 云端缺失文件时应当使用 createOnly,避免覆盖并发新增", async () => { + const script = { + uuid: "new-uuid", + name: "new", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsCreateOnly: true, + }, + list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(fs.create).toHaveBeenCalledWith("new-uuid.user.js", expect.objectContaining({ createOnly: true })); + expect(fs.create).toHaveBeenCalledWith("new-uuid.meta.json", expect.objectContaining({ createOnly: true })); + }); + + it("没有能力声明时 push 不应传条件写入参数", async () => { + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "push-uuid.user.js", + path: "push-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + await (service as any).storage.set("file_digest", { + "push-uuid.user.js": "old-user-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(fs.create).toHaveBeenCalledWith( + "push-uuid.user.js", + expect.not.objectContaining({ + expectedDigest: expect.anything(), + createOnly: expect.anything(), + }) + ); + }); + it("部分 push 失败时只推进成功文件 digest 并保留失败文件旧 digest", async () => { const fs = createFs({ list: vi From 4d4355fbe31bf47d4e19a33533826a4ea9b3931c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:51:25 +0900 Subject: [PATCH 39/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20use=20provide?= =?UTF-8?q?r=20conditional=20writes=20when=20safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index d3c6afc30..ab981ccf3 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -10,8 +10,9 @@ import { } from "@App/app/repo/scripts"; import BackupExport from "@App/pkg/backup/export"; import type { BackupData, ResourceBackup, ScriptBackupData, ScriptOptions, ValueStorage } from "@App/pkg/backup/struct"; -import type { FileInfo } from "@Packages/filesystem/filesystem"; +import type { FileCreateOptions, FileInfo } from "@Packages/filesystem/filesystem"; import type FileSystem from "@Packages/filesystem/filesystem"; +import { getFileSystemCapabilities } from "@Packages/filesystem/filesystem"; import ZipFileSystem from "@Packages/filesystem/zip/zip"; import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory"; import { isWarpTokenError } from "@Packages/filesystem/error"; @@ -73,6 +74,12 @@ type FileDigestMap = { [key: string]: string; }; +type PushScriptOptions = { + fileDigestMap?: FileDigestMap; + scriptFile?: FileInfo; + metaFile?: FileInfo; +}; + type SyncTask = { promise: Promise; preserveDigestFiles: string[]; @@ -459,7 +466,7 @@ export class SynchronizeService { } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); - return await this.pushScript(fs, script); + return await this.pushScript(fs, script, { fileDigestMap }); } })(), [file.meta!.name, `${uuid}.user.js`] @@ -475,7 +482,10 @@ export class SynchronizeService { if (updatetime > file.script!.updatetime || !file.meta) { // 如果脚本更新时间大于文件更新时间 // 或者不存在.meta文件,则上传文件 - addSyncTask(uuid, this.pushScript(fs, script)); + addSyncTask( + uuid, + this.pushScript(fs, script, { fileDigestMap, scriptFile: file.script, metaFile: file.meta }) + ); } else { // 如果脚本更新时间小于文件更新时间,则更新脚本 updateScript.set(uuid, true); @@ -500,7 +510,7 @@ export class SynchronizeService { }); // 上传剩下的脚本 scriptMap.forEach((script) => { - addSyncTask(script.uuid, this.pushScript(fs, script)); + addSyncTask(script.uuid, this.pushScript(fs, script, { fileDigestMap })); }); // 忽略错误 const syncResults = await Promise.allSettled(result.map((item) => item.promise)); @@ -706,7 +716,7 @@ export class SynchronizeService { } // 上传脚本 - async pushScript(fs: FileSystem, script: PushScriptParam): Promise { + async pushScript(fs: FileSystem, script: PushScriptParam, opts: PushScriptOptions = {}): Promise { const filename = `${script.uuid}.user.js`; const metaFilename = `${script.uuid}.meta.json`; const logger = this.logger.with({ @@ -716,12 +726,18 @@ export class SynchronizeService { }); try { const modifiedDate = getScriptModifiedDate(script); - const w = await fs.create(filename, { modifiedDate }); + const w = await fs.create( + filename, + this.buildPushCreateOptions(fs, filename, modifiedDate, opts.scriptFile, opts.fileDigestMap) + ); // 获取脚本代码 const code = await this.scriptCodeDAO.get(script.uuid); const scriptCode = code!.code; await w.write(scriptCode); - const meta = await fs.create(metaFilename, { modifiedDate }); + const meta = await fs.create( + metaFilename, + this.buildPushCreateOptions(fs, metaFilename, modifiedDate, opts.metaFile, opts.fileDigestMap) + ); const metaJson = JSON.stringify({ uuid: script.uuid, origin: script.origin, @@ -740,6 +756,28 @@ export class SynchronizeService { } } + private buildPushCreateOptions( + fs: FileSystem, + filename: string, + modifiedDate: number, + existingFile?: FileInfo, + fileDigestMap?: FileDigestMap + ): FileCreateOptions { + const capabilities = getFileSystemCapabilities(fs); + const createOptions: FileCreateOptions = { modifiedDate }; + if (!existingFile) { + if (capabilities.supportsCreateOnly) { + createOptions.createOnly = true; + } + return createOptions; + } + const expectedDigest = fileDigestMap?.[filename]; + if (expectedDigest && capabilities.supportsAtomicCompareAndSwap) { + createOptions.expectedDigest = expectedDigest; + } + return createOptions; + } + async pullScript(fs: FileSystem, file: SyncFiles, status: ScriptcatSyncStatus | undefined, existingScript?: Script) { const logger = this.logger.with({ uuid: existingScript?.uuid || "", From 03250cdf0e0707ee03266ce3198d2ed595b0e1f4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:15:52 +0900 Subject: [PATCH 40/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20conditi?= =?UTF-8?q?onal=20cloud=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 348815602..e683f6a4c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1347,6 +1347,68 @@ console.log("ok");` }); }); + it("deleteCloudScript 支持条件删除时应当传 expectedDigest", async () => { + const fs = createFs({ + capabilities: { + supportsConditionalDelete: true, + }, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + await (service as any).storage.set("file_digest", { + "delete-uuid.user.js": "old-user-digest", + "delete-uuid.meta.json": "old-meta-digest", + }); + + await service.deleteCloudScript(fs, "delete-uuid", false); + + expect(fs.delete).toHaveBeenCalledWith( + "delete-uuid.user.js", + expect.objectContaining({ expectedDigest: "old-user-digest" }) + ); + expect(fs.delete).toHaveBeenCalledWith( + "delete-uuid.meta.json", + expect.objectContaining({ expectedDigest: "old-meta-digest" }) + ); + }); + + it("deleteCloudScript 无条件删除能力时不应传 expectedDigest", async () => { + const fs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + await (service as any).storage.set("file_digest", { + "delete-uuid.user.js": "old-user-digest", + "delete-uuid.meta.json": "old-meta-digest", + }); + + await service.deleteCloudScript(fs, "delete-uuid", false); + + expect((fs.delete as any).mock.calls[0]).toEqual(["delete-uuid.user.js"]); + expect((fs.delete as any).mock.calls[1]).toEqual(["delete-uuid.meta.json"]); + }); + it("passes script modifiedDate when pushing script and meta files", async () => { const writeMock = vi.fn().mockResolvedValue(undefined); const createMock = vi.fn().mockResolvedValue({ write: writeMock }); From 1aa8b4baeb805507b17306124397ffd7d2ae2946 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:17:35 +0900 Subject: [PATCH 41/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20use=20provide?= =?UTF-8?q?r=20conditional=20delete=20when=20safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index ab981ccf3..2f363d8a3 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -684,16 +684,18 @@ export class SynchronizeService { // 删除云端脚本数据 async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) { const filename = `${uuid}.user.js`; + const metaFilename = `${uuid}.meta.json`; + const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const logger = this.logger.with({ uuid: uuid, file: filename, }); try { - await fs.delete(filename); + await this.deleteCloudFile(fs, filename, fileDigestMap); if (syncDelete) { // 留下一个.meta.json删除标记 const modifiedDate = Date.now(); - const meta = await fs.create(`${uuid}.meta.json`, { modifiedDate }); + const meta = await fs.create(metaFilename, { modifiedDate }); await meta.write( JSON.stringify({ uuid: uuid, @@ -705,7 +707,7 @@ export class SynchronizeService { ); } else { // 直接删除所有相关文件 - await fs.delete(`${uuid}.meta.json`); + await this.deleteCloudFile(fs, metaFilename, fileDigestMap); } logger.info("delete success"); } catch (e) { @@ -715,6 +717,26 @@ export class SynchronizeService { return; } + private buildDeleteOptions(fs: FileSystem, filename: string, fileDigestMap: FileDigestMap) { + if (!getFileSystemCapabilities(fs).supportsConditionalDelete) { + return undefined; + } + const expectedDigest = fileDigestMap[filename]; + if (!expectedDigest) { + return undefined; + } + return { expectedDigest }; + } + + private async deleteCloudFile(fs: FileSystem, filename: string, fileDigestMap: FileDigestMap) { + const opts = this.buildDeleteOptions(fs, filename, fileDigestMap); + if (opts) { + await fs.delete(filename, opts); + return; + } + await fs.delete(filename); + } + // 上传脚本 async pushScript(fs: FileSystem, script: PushScriptParam, opts: PushScriptOptions = {}): Promise { const filename = `${script.uuid}.user.js`; From 5aad551dd7b71ab23d1a698d266a1dc56f8cc911 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:20:04 +0900 Subject: [PATCH 42/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20webdav=20?= =?UTF-8?q?create-only=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/webdav.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index b0447ef7e..5ac6fadd5 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { WebDAVClient } from "webdav"; import { getPatcher } from "webdav"; import WebDAVFileSystem from "./webdav"; -import { WarpTokenError } from "../error"; +import { isConflictError, WarpTokenError } from "../error"; /** 创建 mock WebDAVClient */ function createMockClient(overrides?: Partial): WebDAVClient { @@ -273,6 +273,14 @@ describe("WebDAVFileSystem", () => { }) ); }); + + it("createOnly 冲突返回 false 时应当抛出 typed conflict 错误", async () => { + (mockClient.putFileContents as ReturnType).mockResolvedValue(false); + const fs = createTestFS(mockClient); + const writer = await (fs as any).create("exists.txt", { createOnly: true }); + + await expect(writer.write("content")).rejects.toSatisfy(isConflictError); + }); }); describe("open", () => { From e59ea1f91d0d1bda46ce739bf9a871284dc9b7fd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:22:22 +0900 Subject: [PATCH 43/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20webdav?= =?UTF-8?q?=20create-only=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/rw.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index da717761d..c3c2d43f9 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,5 +1,6 @@ import type { WebDAVClient } from "webdav"; import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import { FileSystemError } from "../error"; import { createWebDAVFileSystemError } from "./error"; const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); @@ -61,6 +62,14 @@ export class WebDAVFileWriter implements FileWriter { throw createWebDAVFileSystemError(error); } if (!resp) { + if (this.opts?.createOnly) { + throw new FileSystemError({ + provider: "webdav", + message: "WebDAV create-only write conflict", + status: 412, + conflict: true, + }); + } throw new Error("write error"); } } From c63bfb471f38317df0610114262c1ddafd94f14b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:26:12 +0900 Subject: [PATCH 44/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20status?= =?UTF-8?q?=20write=20after=20file=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index e683f6a4c..59e5f426a 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -569,6 +569,122 @@ describe("SynchronizeService", () => { }); }); + it("脚本文件同步失败时仍可回写其它脚本的 syncStatus 并保留失败脚本云端状态", async () => { + const failedCloudStatus = { enable: false, sort: 3, updatetime: 300 }; + const okCloudStatus = { enable: false, sort: 1, updatetime: 100 }; + const okLocalStatus = { enable: true, sort: 9, updatetime: 200 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "failed.user.js", + path: "failed.user.js", + size: 1, + digest: "failed-new-digest", + createtime: 1, + updatetime: 300, + }, + { + name: "failed.meta.json", + path: "failed.meta.json", + size: 1, + digest: "failed-meta-digest", + createtime: 1, + updatetime: 300, + }, + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "ok-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "ok.meta.json", + path: "ok.meta.json", + size: 1, + digest: "ok-meta-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockImplementation((file) => { + if (file.name === "scriptcat-sync.json") { + return Promise.resolve({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { + scripts: { + failed: failedCloudStatus, + ok: okCloudStatus, + }, + }, + }) + ), + }); + } + return Promise.reject(new Error("read failed")); + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "failed", + name: "failed", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + { + uuid: "ok", + name: "ok", + updatetime: okLocalStatus.updatetime, + createtime: 1, + status: 1, + sort: okLocalStatus.sort, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + } as any + ); + await (service as any).storage.set("file_digest", { + "failed.user.js": "failed-old-digest", + "failed.meta.json": "failed-meta-digest", + "ok.user.js": "ok-digest", + "ok.meta.json": "ok-meta-digest", + }); + + await service.syncOnce(syncConfig, fs); + + expect(writeMock).toHaveBeenCalledTimes(1); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts.failed).toEqual(failedCloudStatus); + expect(written.status.scripts.ok).toEqual(okLocalStatus); + }); + it("waits for installScript during pullScript", async () => { let releaseInstall!: () => void; const installGate = new Promise((resolve) => { From 1a2ab69c280d7974bb0fff6112e84cbac8fec661 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:26:45 +0900 Subject: [PATCH 45/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20preserve=20fa?= =?UTF-8?q?iled=20script=20status=20while=20syncing=20others?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 2f363d8a3..58f4b9d8b 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -81,6 +81,7 @@ type PushScriptOptions = { }; type SyncTask = { + uuid: string; promise: Promise; preserveDigestFiles: string[]; }; @@ -434,6 +435,7 @@ export class SynchronizeService { // 最后使用 Promise.allSettled 进行等待 const addSyncTask = (uuid: string, promise: Promise, files?: string[]) => { result.push({ + uuid, promise, preserveDigestFiles: files || [`${uuid}.user.js`, `${uuid}.meta.json`], }); @@ -516,10 +518,12 @@ export class SynchronizeService { const syncResults = await Promise.allSettled(result.map((item) => item.promise)); const pushedFileDigestMap: FileDigestMap = {}; const preserveDigestFiles = new Set(); + const failedSyncUuids = new Set(); syncResults.forEach((ret, index) => { if (ret.status === "fulfilled" && ret.value) { Object.assign(pushedFileDigestMap, ret.value); } else if (ret.status === "rejected") { + failedSyncUuids.add(result[index].uuid); result[index].preserveDigestFiles.forEach((name) => preserveDigestFiles.add(name)); this.logger.warn("sync task failed", Logger.E(ret.reason), { files: result[index].preserveDigestFiles, @@ -527,11 +531,15 @@ export class SynchronizeService { } }); // 同步状态 - if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && canWriteScriptcatSync) { + if (syncConfig.syncStatus && canWriteScriptcatSync) { try { const scriptlist = await this.scriptDAO.all(); await Promise.allSettled( scriptlist.map(async (script) => { + if (failedSyncUuids.has(script.uuid)) { + scriptcatSync.status.scripts[script.uuid] = cloudStatus[script.uuid]; + return; + } // 判断云端状态是否与本地状态一致 const status = cloudStatus[script.uuid]; const updatetime = script.updatetime || script.createtime; @@ -599,12 +607,8 @@ export class SynchronizeService { } catch (e) { this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); } - } else if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && !canWriteScriptcatSync) { + } else if (syncConfig.syncStatus && !canWriteScriptcatSync) { this.logger.warn("skip scriptcat-sync.json write because cloud status could not be read"); - } else if (syncConfig.syncStatus) { - this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { - failedFiles: [...preserveDigestFiles], - }); } // 重新获取文件列表,保存文件摘要 this.logger.info("update file digest"); From 4a8380cf8d19d8cac52dbc248ccb79fec36e1196 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:30:42 +0900 Subject: [PATCH 46/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20s3=20cond?= =?UTF-8?q?itional=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/s3.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 4fb3e6d94..8244bdbc1 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isConflictError } from "../error"; import S3FileSystem from "./s3"; import { S3Client, S3Error } from "./client"; import type { FileInfo } from "../filesystem"; @@ -270,6 +271,16 @@ describe("S3FileSystem", () => { ); }); + it("createOnly 写入遇到 412 时应当抛出 typed conflict 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("PreconditionFailed", "At least one precondition failed", 412) + ); + + const writer = await (fs as any).create("new.txt", { createOnly: true }); + + await expect(writer.write("hello world")).rejects.toSatisfy(isConflictError); + }); + it("normalizes double slashes in object keys", async () => { const subFs = new S3FileSystem("test-bucket", mockClient, "/ScriptCat//sync"); @@ -336,6 +347,14 @@ describe("S3FileSystem", () => { }) ); }); + + it("条件删除遇到 412 时应当抛出 typed conflict 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("PreconditionFailed", "At least one precondition failed", 412) + ); + + await expect((fs as any).delete("test.txt", { expectedDigest: "abc123" })).rejects.toSatisfy(isConflictError); + }); }); // ---- list ---- From 74c2ce209dea5ae335c605c8cc61537111bc527a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:32:56 +0900 Subject: [PATCH 47/73] docs(sync): update implemented provider status --- docs/sync-research.md | 45 +++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index f48a4c67b..34e25581e 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -183,16 +183,25 @@ type FileDigestMap = { ## Provider 实现差异 -当前 `FileSystem` 接口只有 `create(path, { modifiedDate })` 和 `delete(path)`,没有 capabilities、conditional write/delete 或 opaque provider token。 +当前 `FileSystem` 接口已经有最小 capabilities,并允许 `create()` / `delete()` 接收条件参数: + +- `supportsAtomicCompareAndSwap` +- `supportsCreateOnly` +- `supportsConditionalDelete` +- `FileCreateOptions.expectedDigest` +- `FileCreateOptions.createOnly` +- `FileDeleteOptions.expectedDigest` + +同步层只在 provider 显式声明能力时传条件参数;未声明能力的 provider 继续保持旧覆盖语义,避免把 best-effort preflight 伪装成 atomic CAS。 | Provider | `list()` digest | `create()` 当前语义 | `delete()` 当前语义 | 关键差异 | | --- | --- | --- | --- | --- | -| WebDAV | `etag` | `putFileContents` 覆盖写 | 404 幂等成功 | 天然可扩展 `If-Match` / create-only,但当前未暴露 | -| S3 | 去引号 ETag | PUT 覆盖写,可写 `x-amz-meta-createtime` | DELETE 幂等,`NoSuchKey` 成功 | 可用 conditional request;当前未传条件头 | -| OneDrive | `eTag` | simple/upload session,conflictBehavior replace | raw Response 路径,404 成功,其他错误字符串化 | 有 typed request error,但 `nothen=true` 调用点仍需审计 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 无通用原子 CAS;path cache 和同名文件会影响正确性;`nothen=true` 错误类型化不完整 | -| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,not_found 字符串判断为成功 | rev 未暴露;冲突/不存在多靠 message string | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 抛普通 Error | 可用 md5 preflight,但不是 atomic;errno 分类必须精确 | +| WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | +| S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | +| OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 未声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | +| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 未声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单读类操作的 429 自动重试:`verify/open/read/openDir/list/getDirUrl`。`create/createDir/write/delete` 遇到 429 不重试。这是保守的,避免重复非幂等写;后续若要支持写重试,应结合 create-only/CAS 或 provider 幂等能力。 @@ -200,13 +209,13 @@ type FileDigestMap = { ## 已确认问题 1. 静默覆盖:`fs.create()` 默认覆盖,两个设备基于旧快照修改同一文件时后写覆盖先写。 -2. 失败后状态污染:push/pull/delete 失败后仍可能写 `file_digest` 或 `scriptcat-sync.json`。 -3. `pullScript()` 吞错:下载、JSON parse、userscript parse、安装失败都无法被上层区分。 -4. `deleteCloudScript()` 吞错:删除或 tombstone 写失败后调用方仍会推进 digest。 +2. 失败后状态污染:本分支已修复主要路径,push/pull/delete 失败文件保留旧 `file_digest`;失败 uuid 的 status 回写会保留云端状态。 +3. `pullScript()` 吞错:本分支已改为让真实失败向上表现为单文件任务失败。 +4. `deleteCloudScript()` 吞错:本分支已改为删除或 tombstone 写失败时向上抛错,批量删除调用方逐条 catch。 5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 -6. `scriptcat-sync.json` 覆盖写:可能覆盖其他设备刚更新的 enable/sort。 -7. provider 能力不一致:不能用同一个 `expectedVersion` 语义假装所有 provider 都支持 atomic CAS。 -8. 错误类型不完整:部分 provider 有 `FileSystemError`,部分仍抛普通 Error 或字符串。 +6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 +7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 +8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;普通网络错误仍可能保持原始 Error。 9. transient 写失败无有限 retry:429/5xx 在 write/delete 上当前直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 @@ -321,14 +330,16 @@ type FileSystemCapabilities = { type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; ``` +本分支当前已实现 capabilities、provider typed error 的关键路径,以及 read 类 429/5xx 有限 retry;还没有把 `SyncErrorKind` 作为同步层显式类型落地。 + ### Phase 5:provider 条件操作 follow-up 分 provider 小步提交: -- WebDAV / S3 / OneDrive:优先用 If-Match / ETag。 -- Dropbox:request 层 typed error,rev 作为 opaque token。 -- Google Drive:providerToken 保存 fileId/version;preflight 明确 best-effort。 -- Baidu:只把明确 file-exists errno 判 conflict;md5 preflight 明确 best-effort。 +- WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 +- Dropbox:request 层 typed error 已落地;rev 作为 opaque token 尚未实现。 +- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。 +- Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 From ce0400c5470dd34d3b4b68b46033ad70cdd10d16 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:38:06 +0900 Subject: [PATCH 48/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20conflic?= =?UTF-8?q?t=20error=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 59e5f426a..2b08fccd2 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { SynchronizeService } from "./synchronize"; import { initTestEnv } from "@Tests/utils"; import type FileSystem from "@Packages/filesystem/filesystem"; +import { FileSystemError } from "@Packages/filesystem/error"; import type { CloudSyncConfig } from "@App/pkg/config/config"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { md5OfText } from "@App/pkg/utils/crypto"; @@ -1269,6 +1270,94 @@ console.log("ok");` }); }); + it("单文件同步遇到 typed conflict 时应在日志中标记 conflict 分类", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "cloud-ok-new", + createtime: 1, + updatetime: 1, + }, + { + name: "bad.user.js", + path: "bad.user.js", + size: 1, + digest: "cloud-bad-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + { + uuid: "bad", + name: "bad", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + } as any + ); + const warnSpy = vi.spyOn(service.logger, "warn"); + vi.spyOn(service, "pushScript").mockImplementation(async (_fs, script: any) => { + if (script.uuid === "bad") { + throw new FileSystemError({ + provider: "s3", + message: "Precondition failed", + status: 412, + conflict: true, + }); + } + return { "ok.user.js": "ok-user-new" }; + }); + await (service as any).storage.set("file_digest", { + "bad.user.js": "bad-user-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(warnSpy).toHaveBeenCalledWith( + "sync task failed", + expect.objectContaining({ error: "Precondition failed" }), + expect.objectContaining({ + errorKind: "conflict", + files: ["bad.user.js", "bad.meta.json"], + }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "ok.user.js": "cloud-ok-new", + "bad.user.js": "bad-user-old", + }); + }); + it("scriptcat-sync.json 写回失败时仍推进已成功脚本 digest", async () => { const fs = createFs({ list: vi From 2b4ab24cdd09aedda406ee47e4ff39f988a201b8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:38:40 +0900 Subject: [PATCH 49/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20classify=20pe?= =?UTF-8?q?r-file=20sync=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 58f4b9d8b..576d219a5 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -15,7 +15,7 @@ import type FileSystem from "@Packages/filesystem/filesystem"; import { getFileSystemCapabilities } from "@Packages/filesystem/filesystem"; import ZipFileSystem from "@Packages/filesystem/zip/zip"; import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory"; -import { isWarpTokenError } from "@Packages/filesystem/error"; +import { FileSystemError, isWarpTokenError } from "@Packages/filesystem/error"; import type { Group } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; import { type IMessageQueue } from "@Packages/message/message_queue"; @@ -86,6 +86,8 @@ type SyncTask = { preserveDigestFiles: string[]; }; +type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; function getScriptModifiedDate(script: PushScriptParam): number { @@ -526,6 +528,7 @@ export class SynchronizeService { failedSyncUuids.add(result[index].uuid); result[index].preserveDigestFiles.forEach((name) => preserveDigestFiles.add(name)); this.logger.warn("sync task failed", Logger.E(ret.reason), { + errorKind: this.classifySyncError(ret.reason), files: result[index].preserveDigestFiles, }); } @@ -617,6 +620,31 @@ export class SynchronizeService { return; } + private classifySyncError(error: unknown): SyncErrorKind { + if (error instanceof FileSystemError) { + if (error.conflict) { + return "conflict"; + } + if (error.rateLimit || error.retryable) { + return "transient"; + } + if (error.notFound) { + return "stale_snapshot"; + } + if (error.auth) { + return "fatal"; + } + return "fatal"; + } + if (isWarpTokenError(error)) { + return "fatal"; + } + if (error instanceof Error && /\bunsupported\b/i.test(error.message)) { + return "unsupported"; + } + return "fatal"; + } + private async readScriptcatSyncStatus(fs: FileSystem, file: FileInfo): Promise { const cloudScriptCatSync = JSON.parse(await fs.open(file).then((f) => f.read("string"))) as Partial; return cloudScriptCatSync.status?.scripts || {}; From 683f4b85a65b975bc376c4a75310acebaf8f1725 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:43:27 +0900 Subject: [PATCH 50/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20condition?= =?UTF-8?q?al=20write=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.test.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts index a79f953a7..04c8e0fbe 100644 --- a/packages/filesystem/limiter.test.ts +++ b/packages/filesystem/limiter.test.ts @@ -244,6 +244,73 @@ describe("LimiterFileSystem", () => { expect(write).toHaveBeenCalledTimes(1); }); + it("受 expectedDigest 保护的 writer.write 遇到 typed retryable 错误时应当重试", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const write = vi.fn(async () => {}); + const retryableError = new FileSystemError({ + provider: "s3", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.create).mockResolvedValueOnce({ + write, + }); + write.mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); + const limiter = new LimiterFileSystem(fs); + const writer = await limiter.create(file.path, { expectedDigest: "digest" }); + + const promise = writer.write("content"); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeUndefined(); + expect(write).toHaveBeenCalledTimes(2); + }); + + it("受 createOnly 保护的 writer.write 遇到 typed retryable 错误时应当重试", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const write = vi.fn(async () => {}); + const retryableError = new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.create).mockResolvedValueOnce({ + write, + }); + write.mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); + const limiter = new LimiterFileSystem(fs); + const writer = await limiter.create(file.path, { createOnly: true }); + + const promise = writer.write("content"); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeUndefined(); + expect(write).toHaveBeenCalledTimes(2); + }); + + it("受 expectedDigest 保护的 delete 遇到 typed retryable 错误时应当重试", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const retryableError = new FileSystemError({ + provider: "onedrive", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.delete).mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.delete(file.path, { expectedDigest: "digest" }); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeUndefined(); + expect(fs.delete).toHaveBeenCalledTimes(2); + }); + it("should retry reader.read on 429", async () => { vi.useFakeTimers(); const fs = createFs(); From 3eae169e5935d12ff79bf64a273e480b22386c30 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:44:01 +0900 Subject: [PATCH 51/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20retry=20protect?= =?UTF-8?q?ed=20conditional=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index b9cff1ad0..1016e1e75 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -3,7 +3,16 @@ import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWr import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; -const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); +const RETRYABLE_429_OPS = new Set([ + "verify", + "open", + "read", + "openDir", + "list", + "getDirUrl", + "conditionalWrite", + "conditionalDelete", +]); /** * 速率限制器 @@ -125,8 +134,9 @@ export default class LimiterFileSystem implements FileSystem { async create(path: string, opts?: FileCreateOptions): Promise { return this.limiter.execute(async () => { const writer = await this.fs.create(path, opts); + const writeOp = opts?.expectedDigest || opts?.createOnly ? "conditionalWrite" : "write"; return { - write: (content) => this.limiter.execute(() => writer.write(content), "write"), + write: (content) => this.limiter.execute(() => writer.write(content), writeOp), }; }, "create"); } @@ -136,7 +146,8 @@ export default class LimiterFileSystem implements FileSystem { } delete(path: string, opts?: FileDeleteOptions): Promise { - return this.limiter.execute(() => this.fs.delete(path, opts), "delete"); + const op = opts?.expectedDigest ? "conditionalDelete" : "delete"; + return this.limiter.execute(() => this.fs.delete(path, opts), op); } list(): Promise { From 0f61332b17acc79463e286a94314e6a59e299b9b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:46:22 +0900 Subject: [PATCH 52/73] docs(sync): reflect protected write retry --- docs/sync-research.md | 10 +++++----- packages/filesystem/limiter.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 34e25581e..ba346590e 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -204,7 +204,7 @@ type FileDigestMap = { | Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 未声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | -`LimiterFileSystem` 当前只对白名单读类操作的 429 自动重试:`verify/open/read/openDir/list/getDirUrl`。`create/createDir/write/delete` 遇到 429 不重试。这是保守的,避免重复非幂等写;后续若要支持写重试,应结合 create-only/CAS 或 provider 幂等能力。 +`LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 ## 已确认问题 @@ -216,7 +216,7 @@ type FileDigestMap = { 6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;普通网络错误仍可能保持原始 Error。 -9. transient 写失败无有限 retry:429/5xx 在 write/delete 上当前直接失败。 +9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 ## PR #1439 分析 @@ -330,7 +330,7 @@ type FileSystemCapabilities = { type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; ``` -本分支当前已实现 capabilities、provider typed error 的关键路径,以及 read 类 429/5xx 有限 retry;还没有把 `SyncErrorKind` 作为同步层显式类型落地。 +本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类,以及读类和受条件保护写/删的 transient 有限 retry。 ### Phase 5:provider 条件操作 follow-up @@ -344,7 +344,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" ### Phase 6:重试和通知 -- transient 429/5xx 有限 retry/backoff。 +- transient 429/5xx 有限 retry/backoff 已在 `LimiterFileSystem` 落地,范围限于读类操作和受条件保护的写/删。 - install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 - 通知包含失败数量和首个错误类型,不逐文件弹。 @@ -385,7 +385,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 4. Dropbox typed conflict。 5. Google Drive best-effort preflight race。 6. Baidu errno 分类。 -7. Limiter 对 read 429 重试、write/delete 429 不重复非幂等操作。 +7. Limiter 对 read 类 transient 错误重试;只对受条件保护的 write/delete transient 错误重试,普通 write/delete 不重复非幂等操作。 ## 风险清单 diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 1016e1e75..84847915f 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -3,7 +3,7 @@ import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWr import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; -const RETRYABLE_429_OPS = new Set([ +const RETRYABLE_TRANSIENT_OPS = new Set([ "verify", "open", "read", @@ -32,7 +32,7 @@ export class RateLimiter { /** * 执行限速操作 * @param fn 要执行的操作函数 - * @param op 操作类型,用于在遇到 429 时判断是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 + * @param op 操作类型,用于判断 transient 错误是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 * @returns 操作结果 */ async execute(fn: () => Promise, op = "unknown"): Promise { @@ -57,9 +57,9 @@ export class RateLimiter { } /** - * 执行操作并处理 429 错误重试 + * 执行操作并处理 transient 错误重试 * @param fn 要执行的操作函数 - * @param op 操作类型,用于判定该操作在遇到 429 时是否进入指数退避重试 + * @param op 操作类型,用于判定该操作在遇到 transient 错误时是否进入指数退避重试 * @returns 操作结果 */ private async executeWithRetry(fn: () => Promise, op: string): Promise { @@ -84,7 +84,7 @@ export class RateLimiter { } private shouldRetryTransient(op: string, error: unknown): boolean { - if (!RETRYABLE_429_OPS.has(op)) { + if (!RETRYABLE_TRANSIENT_OPS.has(op)) { return false; } if (error instanceof FileSystemError) { From b87a55c6b635bc4d170bbdebe5ac3551ef481a12 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:49:59 +0900 Subject: [PATCH 53/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20sync=20?= =?UTF-8?q?error=20kind=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 2b08fccd2..fef26030c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1358,6 +1358,98 @@ console.log("ok");` }); }); + it.each([ + { + title: "typed transient", + error: new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }), + expectedKind: "transient", + expectedMessage: "Service unavailable", + }, + { + title: "typed stale snapshot", + error: new FileSystemError({ + provider: "onedrive", + message: "File moved", + status: 404, + notFound: true, + }), + expectedKind: "stale_snapshot", + expectedMessage: "File moved", + }, + { + title: "unsupported", + error: new Error("unsupported conditional write"), + expectedKind: "unsupported", + expectedMessage: "unsupported conditional write", + }, + ])( + "单文件同步遇到 $title 错误时应在日志中标记 $expectedKind 分类", + async ({ error, expectedKind, expectedMessage }) => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "bad.user.js", + path: "bad.user.js", + size: 1, + digest: "cloud-bad-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "bad", + name: "bad", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + const warnSpy = vi.spyOn(service.logger, "warn"); + vi.spyOn(service, "pushScript").mockRejectedValue(error); + await (service as any).storage.set("file_digest", { + "bad.user.js": "bad-user-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(warnSpy).toHaveBeenCalledWith( + "sync task failed", + expect.objectContaining({ error: expectedMessage }), + expect.objectContaining({ + errorKind: expectedKind, + files: ["bad.user.js", "bad.meta.json"], + }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "bad.user.js": "bad-user-old", + }); + } + ); + it("scriptcat-sync.json 写回失败时仍推进已成功脚本 digest", async () => { const fs = createFs({ list: vi From 41b91104b021fd052ae10579dc973c577c3bb6d4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:51:49 +0900 Subject: [PATCH 54/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20install?= =?UTF-8?q?=20push=20transient=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index fef26030c..c8ecdbae2 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1935,6 +1935,55 @@ console.log("ok");` expect(order).toEqual(["sync:list", "sync:digest", "install:push", "install:digest"]); }); + it("scriptInstall 触发 push transient 失败时不污染 file_digest", async () => { + const fs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + const transientError = new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + vi.spyOn(service, "pushScript").mockRejectedValue(transientError); + const updateSpy = vi.spyOn(service, "updateFileDigest"); + const errorSpy = vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + await (service as any).storage.set("file_digest", { + "install-uuid.user.js": "old-user-digest", + "install-uuid.meta.json": "old-meta-digest", + }); + + await service.scriptInstall({ + script: { uuid: "install-uuid", name: "install" } as any, + upsertBy: "user", + } as any); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "push script on install error", + expect.objectContaining({ error: "Service unavailable" }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "install-uuid.user.js": "old-user-digest", + "install-uuid.meta.json": "old-meta-digest", + }); + }); + it("scriptsDelete enters cloud_sync queue and updates digest after deleting", async () => { let releaseSync!: () => void; const syncGate = new Promise((resolve) => { From 28c50a85fe95b078f8a70e4ce1a6197fad0cb00f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:54:01 +0900 Subject: [PATCH 55/73] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20classify=20qu?= =?UTF-8?q?eued=20sync=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.test.ts | 9 ++++++++- src/app/service/service_worker/synchronize.ts | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index c8ecdbae2..aca51a548 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1629,6 +1629,7 @@ console.log("ok");` } as any ); vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + const warnSpy = vi.spyOn(service.logger, "warn"); await (service as any).storage.set("file_digest", { "fail.user.js": "fail-user-old", "fail.meta.json": "fail-meta-old", @@ -1638,6 +1639,11 @@ console.log("ok");` await stackAsyncTask("cloud_sync_queue", async () => "barrier"); expect(deleteCalls).toEqual(["fail.user.js", "ok.user.js", "ok.meta.json"]); + expect(warnSpy).toHaveBeenCalledWith( + "delete cloud script item failed", + expect.objectContaining({ error: "delete failed" }), + expect.objectContaining({ uuid: "fail", errorKind: "fatal" }) + ); await expect((service as any).storage.get("file_digest")).resolves.toEqual({ "fail.user.js": "fail-user-old", "fail.meta.json": "fail-meta-old", @@ -1976,7 +1982,8 @@ console.log("ok");` expect(updateSpy).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith( "push script on install error", - expect.objectContaining({ error: "Service unavailable" }) + expect.objectContaining({ error: "Service unavailable" }), + expect.objectContaining({ errorKind: "transient" }) ); await expect((service as any).storage.get("file_digest")).resolves.toEqual({ "install-uuid.user.js": "old-user-digest", diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 576d219a5..c44d9aaee 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -929,7 +929,9 @@ export class SynchronizeService { const pushedFileDigestMap = await this.pushScript(fs, params.script); await this.updateFileDigest(fs, pushedFileDigestMap); }).catch((e) => { - this.logger.error("push script on install error", Logger.E(e)); + this.logger.error("push script on install error", Logger.E(e), { + errorKind: this.classifySyncError(e), + }); }); } } @@ -955,6 +957,7 @@ export class SynchronizeService { preserveDigestFiles.add(`${uuid}.meta.json`); this.logger.warn("delete cloud script item failed", Logger.E(e), { uuid, + errorKind: this.classifySyncError(e), }); } } From 70c6a5994352005bc2316d728547422b64fef913 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:55:19 +0900 Subject: [PATCH 56/73] docs(sync): record queued failure classification audit --- docs/sync-research.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index ba346590e..dd6e30699 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -330,7 +330,7 @@ type FileSystemCapabilities = { type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; ``` -本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类,以及读类和受条件保护写/删的 transient 有限 retry。 +本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类(包括 `syncOnce` per-file 失败、`scriptInstall` 排队 push 失败、`scriptsDelete` 单项删除失败),以及读类和受条件保护写/删的 transient 有限 retry。 ### Phase 5:provider 条件操作 follow-up @@ -338,7 +338,8 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 - Dropbox:request 层 typed error 已落地;rev 作为 opaque token 尚未实现。 -- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。 +- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 +- OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 - Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 From 0828472a6e9f149e1c08e2e8b2af347f80e9d743 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:07:27 +0900 Subject: [PATCH 57/73] docs(sync): refine remaining rollout plan --- docs/sync-research.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/sync-research.md b/docs/sync-research.md index dd6e30699..a5f6bec54 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -349,6 +349,43 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 - 通知包含失败数量和首个错误类型,不逐文件弹。 +## 剩余 rollout checklist + +后续提交应继续保持小步、可回滚,并按“测试先行、实现随后、文档同步”的顺序推进。 + +### 必须继续验证 + +1. queued delete 的 typed `transient` / `conflict` 分类:已覆盖 fatal,仍需确认 provider typed error 在 `scriptsDelete()` 单项失败路径中保持 per-item best-effort。 +2. provider token opaque:继续用测试确认 WebDAV/S3/OneDrive/Dropbox 的 digest/token 只作为 provider 回传值使用,不在同步层解析成通用 version。 +3. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 +4. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 + +### 可以做的小步 commit + +1. `✅ test(sync): cover queued delete typed failures` + - 只补 `scriptsDelete()` 单项 `FileSystemError` transient/conflict 测试。 + - 验证失败 uuid 保留旧 digest,后续 uuid 继续处理,日志含 `errorKind`。 +2. `🐛 fix(sync): preserve queued delete error kinds` + - 仅在测试暴露缺口时提交。 + - 不改变队列、digest、status 合并语义。 +3. `✅ test(fs): cover provider opaque tokens` + - 补 provider digest/token 不被同步层改写的回归测试。 + - 不引入 Google Drive `fileId:version`。 +4. `🐛 fix(fs): harden provider typed conflicts` + - 只修测试证明的 typed error 漏洞。 + - 优先 Dropbox raw error shape、OneDrive 409/412、WebDAV/S3 412。 +5. `docs(sync): document manual verification path` + - 写清真实扩展手工验证步骤和旧云目录样例。 + - 不增加新 runtime 行为。 + +### 暂不进入本轮 + +1. Dropbox `rev` CAS:需要独立设计,不能把 `content_hash` 当 rev。 +2. Google Drive / Baidu atomic CAS:没有原生条件写时只能 best-effort preflight。 +3. `tombstone_digest`:没有 GC 设计前不新增。 +4. 通知聚合 UI:先保留日志和定时同步兜底,避免扩大 UI / i18n 范围。 +5. 既有 React hooks lint warning:与同步修复无关。 + ## 测试矩阵 ### 同步任务级别 From 93bad78adad51972c37e058e49efb659f213cb92 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:09:20 +0900 Subject: [PATCH 58/73] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20queued?= =?UTF-8?q?=20delete=20typed=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index aca51a548..3fb1a7e1c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1650,6 +1650,97 @@ console.log("ok");` }); }); + it.each([ + { + title: "transient", + error: new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }), + expectedKind: "transient", + expectedMessage: "Service unavailable", + }, + { + title: "conflict", + error: new FileSystemError({ + provider: "s3", + message: "Precondition failed", + status: 412, + conflict: true, + }), + expectedKind: "conflict", + expectedMessage: "Precondition failed", + }, + ])( + "批量删除遇到 typed $title 失败时只阻塞对应脚本并标记 $expectedKind", + async ({ error, expectedKind, expectedMessage }) => { + const deleteCalls: string[] = []; + const fs = createFs({ + delete: vi.fn().mockImplementation(async (path: string) => { + deleteCalls.push(path); + if (path === "fail.user.js") { + throw error; + } + }), + list: vi.fn().mockResolvedValue([ + { + name: "fail.user.js", + path: "fail.user.js", + size: 1, + digest: "fail-user-new", + createtime: 1, + updatetime: 1, + }, + { + name: "fail.meta.json", + path: "fail.meta.json", + size: 1, + digest: "fail-meta-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true, syncDelete: false }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + const warnSpy = vi.spyOn(service.logger, "warn"); + await (service as any).storage.set("file_digest", { + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + + await service.scriptsDelete([{ uuid: "fail", deleteBy: "user" } as any, { uuid: "ok", deleteBy: "user" } as any]); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(deleteCalls).toEqual(["fail.user.js", "ok.user.js", "ok.meta.json"]); + expect(warnSpy).toHaveBeenCalledWith( + "delete cloud script item failed", + expect.objectContaining({ error: expectedMessage }), + expect.objectContaining({ uuid: "fail", errorKind: expectedKind }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + } + ); + it("deleteCloudScript 支持条件删除时应当传 expectedDigest", async () => { const fs = createFs({ capabilities: { From a8a14872941dddb5dcdc86635531ffb084672c36 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:11:36 +0900 Subject: [PATCH 59/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20opaque=20digest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 513b12611..21d5243b6 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -140,6 +140,31 @@ describe("DropboxFileSystem", () => { await expect(fs.exists("/test.txt")).rejects.toThrow("invalid_access_token"); }); + it("list should preserve Dropbox content_hash as opaque digest", async () => { + const fs = new DropboxFileSystem("/ScriptCat/sync", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ + entries: [ + { + ".tag": "file", + name: "script.user.js", + size: 10, + content_hash: "dropbox-content-hash", + client_modified: "2026-01-01T00:00:00Z", + server_modified: "2026-01-01T00:00:01Z", + }, + ], + has_more: false, + }); + + await expect(fs.list()).resolves.toMatchObject([ + { + name: "script.user.js", + path: "/sync", + digest: "dropbox-content-hash", + }, + ]); + }); + it("create should normalize double slashes after the Dropbox app root", async () => { const fs = new DropboxFileSystem("/ScriptCat//sync", "token"); From 0b3d1340395a59d7ba3d71bbe7a2f822042f0433 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:12:40 +0900 Subject: [PATCH 60/73] docs(sync): document manual verification path --- docs/sync-research.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/sync-research.md b/docs/sync-research.md index a5f6bec54..fb470c190 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -386,6 +386,33 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 4. 通知聚合 UI:先保留日志和定时同步兜底,避免扩大 UI / i18n 范围。 5. 既有 React hooks lint warning:与同步修复无关。 +## 手工验证路径 + +手工验证应遵循 [`VERIFICATION.md`](./VERIFICATION.md):先跑 cheap signals,再用 `e2e/scratch/` 一次性脚本加载真实 `dist/ext`,验证完删除 scratch 脚本。不要为了本同步修复新增永久 E2E。 + +建议按以下云目录夹具逐项验证: + +1. 旧格式完整脚本:云端只有 `.user.js` 和旧 `.meta.json`,无新增字段;预期可正常 pull/install,`file_digest` 写入 provider 原生 digest。 +2. 旧 `file_digest`:本地 storage 只有 `{ "uuid.user.js": "digest" }` string map;预期 push/delete 条件参数只在 provider 声明能力且旧 digest 存在时传递。 +3. 旧 `scriptcat-sync.json`:缺少未来字段或缺少 `status.scripts`;预期不崩溃,写回仍保留可读 status。 +4. orphan `.user.js`:云端只有 `.user.js`;预期跳过,不删除云端文件,不清空对应远端 status。 +5. 单文件 pull 失败:让一个 `.meta.json` 损坏或源码无法解析;预期只阻塞该 uuid,其他脚本继续同步,失败文件 digest 不推进。 +6. 单文件 delete 失败:让 provider 对某个 uuid 删除返回 412/429/5xx;预期后续 uuid 继续处理,失败 uuid digest 保留,日志含 `errorKind`。 +7. `scriptcat-sync.json` 并发状态:两端分别更新不同脚本 enable/sort;预期写回前合并远端最新状态,不覆盖另一端较新 status。 + +每次手工验证记录: + +```text +provider: +initial cloud files: +local scripts: +action: +observed logs: +file_digest after: +scriptcat-sync.json after: +result: +``` + ## 测试矩阵 ### 同步任务级别 From aedc3a8d075748a15de21f55ce489f3baa277259 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:16:59 +0900 Subject: [PATCH 61/73] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20classify=20drop?= =?UTF-8?q?box=20structured=20not=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 22 +++++++++++++++++++++ packages/filesystem/dropbox/dropbox.ts | 12 +++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 21d5243b6..35b287471 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -35,6 +35,28 @@ describe("DropboxFileSystem", () => { }); }); + it("request should classify structured path_lookup not_found without error_summary", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error: { ".tag": "path_lookup", path_lookup: { ".tag": "not_found" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/get_metadata")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + notFound: true, + conflict: false, + }); + }); + it("request should throw typed conflict error", async () => { const fs = new DropboxFileSystem("/", "token"); vi.stubGlobal( diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index f36ae2e44..538d07899 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -9,6 +9,12 @@ type DropboxErrorBody = { error_summary?: string; error?: { ".tag"?: string; + path_lookup?: { + ".tag"?: string; + }; + path?: { + ".tag"?: string; + }; }; }; @@ -27,6 +33,12 @@ function parseDropboxError(raw: unknown): { summary?: string; raw: unknown } { if (typeof body.error_summary === "string") { return { summary: body.error_summary, raw: parsed }; } + if (typeof body.error?.path_lookup?.[".tag"] === "string") { + return { summary: `path_lookup/${body.error.path_lookup[".tag"]}`, raw: parsed }; + } + if (typeof body.error?.path?.[".tag"] === "string") { + return { summary: `path/${body.error.path[".tag"]}`, raw: parsed }; + } if (typeof body.error?.[".tag"] === "string") { return { summary: body.error[".tag"], raw: parsed }; } From f96958dc2e5a1222686dc55b783753b9d2895e74 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:20:05 +0900 Subject: [PATCH 62/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20structured=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 35b287471..34011cb86 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -81,6 +81,28 @@ describe("DropboxFileSystem", () => { }); }); + it("request should classify structured path conflict without error_summary", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error: { ".tag": "path", path: { ".tag": "conflict" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/create_folder_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + conflict: true, + notFound: false, + }); + }); + it("request should throw typed rate-limit error", async () => { const fs = new DropboxFileSystem("/", "token"); vi.stubGlobal( From 7c1bd3748a7d31da2b6570846aa8359b86d7ef9f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:25:45 +0900 Subject: [PATCH 63/73] docs(sync): keep rollout checklist current --- docs/sync-research.md | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index fb470c190..690d8031a 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -337,7 +337,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 分 provider 小步提交: - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 -- Dropbox:request 层 typed error 已落地;rev 作为 opaque token 尚未实现。 +- Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 - Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 - Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 @@ -355,28 +355,21 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" ### 必须继续验证 -1. queued delete 的 typed `transient` / `conflict` 分类:已覆盖 fatal,仍需确认 provider typed error 在 `scriptsDelete()` 单项失败路径中保持 per-item best-effort。 -2. provider token opaque:继续用测试确认 WebDAV/S3/OneDrive/Dropbox 的 digest/token 只作为 provider 回传值使用,不在同步层解析成通用 version。 -3. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 -4. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 +1. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 +2. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 +3. Google Drive / Baidu best-effort 风险:仍需手工确认不会被误标成 atomic CAS。 ### 可以做的小步 commit -1. `✅ test(sync): cover queued delete typed failures` - - 只补 `scriptsDelete()` 单项 `FileSystemError` transient/conflict 测试。 - - 验证失败 uuid 保留旧 digest,后续 uuid 继续处理,日志含 `errorKind`。 -2. `🐛 fix(sync): preserve queued delete error kinds` - - 仅在测试暴露缺口时提交。 - - 不改变队列、digest、status 合并语义。 -3. `✅ test(fs): cover provider opaque tokens` - - 补 provider digest/token 不被同步层改写的回归测试。 - - 不引入 Google Drive `fileId:version`。 -4. `🐛 fix(fs): harden provider typed conflicts` - - 只修测试证明的 typed error 漏洞。 - - 优先 Dropbox raw error shape、OneDrive 409/412、WebDAV/S3 412。 -5. `docs(sync): document manual verification path` - - 写清真实扩展手工验证步骤和旧云目录样例。 - - 不增加新 runtime 行为。 +1. `docs(sync): keep rollout checklist current` + - 每完成一组测试/实现后同步文档状态。 + - 不增加 runtime 行为。 +2. `✅ test(fs): cover remaining provider typed gaps` + - 只补明确缺口;当前重点是手工审计中发现的 provider raw error shape。 + - 不为 Google Drive / Baidu 添加伪 atomic 测试。 +3. `docs(sync): record manual verification result` + - 真实扩展和真实 provider 验证后记录观察结果。 + - 若无法验证,明确写未验证原因。 ### 暂不进入本轮 From d1be55776df400ea0ef9c7745d0f4a592159aabb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:36:49 +0900 Subject: [PATCH 64/73] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20non-atomi?= =?UTF-8?q?c=20provider=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/baidu/baidu.test.ts | 20 ++++++++++++++++++- packages/filesystem/dropbox/dropbox.test.ts | 11 ++++++++++ .../googledrive/googledrive.test.ts | 11 ++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index bfc352988..2f52b9b2a 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,12 +1,26 @@ import { describe, expect, it, vi, afterEach } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { getFileSystemCapabilities } from "../filesystem"; import BaiduFileSystem from "./baidu"; +initTestEnv(); + describe("BaiduFileSystem", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + it("不应声明原子条件写入能力", () => { + const fs = new BaiduFileSystem("/apps", "token"); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + it("request should omit credentials without using global DNR rules", async () => { const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ errno: 0 }), @@ -15,7 +29,11 @@ describe("BaiduFileSystem", () => { // 监视 updateDynamicRules,确保不再依赖全局 DNR 规则 const updateDynamicRulesMock = vi.fn(); - (chrome as any).declarativeNetRequest.updateDynamicRules = updateDynamicRulesMock; + vi.stubGlobal("chrome", { + declarativeNetRequest: { + updateDynamicRules: updateDynamicRulesMock, + }, + }); const fs = new BaiduFileSystem("/apps", "token"); diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 34011cb86..a0fd65325 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FileSystemError } from "../error"; +import { getFileSystemCapabilities } from "../filesystem"; import DropboxFileSystem from "./dropbox"; describe("DropboxFileSystem", () => { @@ -11,6 +12,16 @@ describe("DropboxFileSystem", () => { vi.unstubAllGlobals(); }); + it("不应声明原子条件写入能力", () => { + const fs = new DropboxFileSystem("/", "token"); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + it("request should throw typed not found error", async () => { const fs = new DropboxFileSystem("/", "token"); vi.stubGlobal( diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 1bf0635e2..add9c9c0d 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error"; +import { getFileSystemCapabilities } from "../filesystem"; import { joinPath } from "../utils"; import GoogleDriveFileSystem from "./googledrive"; @@ -29,6 +30,16 @@ describe("GoogleDriveFileSystem", () => { vi.stubGlobal("fetch", originalFetch); }); + it("不应声明原子条件写入能力", () => { + const fs = new GoogleDriveFileSystem("/", "token"); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + it("delete should be idempotent when file id is missing", async () => { const fs = new GoogleDriveFileSystem("/", "token"); vi.spyOn(fs, "getFileId").mockResolvedValue(null); From 178eea1c20ebf24c4b58b7e2756c38fb27f2c787 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:39:00 +0900 Subject: [PATCH 65/73] docs(sync): record non-atomic provider coverage --- docs/sync-research.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 690d8031a..1b2dcd3d5 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -199,9 +199,9 @@ type FileDigestMap = { | WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | | S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | | OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 未声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | -| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 未声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | +| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 @@ -338,9 +338,9 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 - Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 -- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 +- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 +- Baidu:不声明 atomic 能力已有 capability 测试锁定;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 From 2264d9e76581d0f63018d4cbb8196a4c1b728811 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:44:50 +0900 Subject: [PATCH 66/73] fix(sync/fs): address remaining provider verification gaps --- packages/filesystem/baidu/baidu.test.ts | 17 +++++++++++++++++ packages/filesystem/baidu/rw.ts | 7 +++++-- .../filesystem/googledrive/googledrive.test.ts | 10 ++++++++++ packages/filesystem/googledrive/rw.ts | 9 +++++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index 2f52b9b2a..ed2386439 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { initTestEnv } from "@Tests/utils"; +import { isNotFoundError } from "../error"; import { getFileSystemCapabilities } from "../filesystem"; import BaiduFileSystem from "./baidu"; @@ -104,4 +105,20 @@ describe("BaiduFileSystem", () => { conflict: false, }); }); + + it("读取文件元数据缺失时应抛出 typed not found 错误", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: -9, errmsg: "file not found" }); + const reader = await fs.open({ + fsid: 123, + name: "missing.user.js", + path: "/apps", + size: 0, + digest: "", + createtime: 0, + updatetime: 0, + }); + + await expect(reader.read("string")).rejects.toSatisfy(isNotFoundError); + }); }); diff --git a/packages/filesystem/baidu/rw.ts b/packages/filesystem/baidu/rw.ts index acec44e19..6b3284982 100644 --- a/packages/filesystem/baidu/rw.ts +++ b/packages/filesystem/baidu/rw.ts @@ -20,8 +20,11 @@ export class BaiduFileReader implements FileReader { this.fs.accessToken }&fsids=[${this.file.fsid!}]&dlink=1` ); - if (!data.list.length) { - throw new Error("file not found"); + if (data.errno) { + throw createBaiduFileSystemError(data); + } + if (!data.list?.length) { + throw createBaiduFileSystemError({ errno: -9, errmsg: "file not found" }); } const resp = await fetch(`${data.list[0].dlink}&access_token=${this.fs.accessToken}`); switch (type) { diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index add9c9c0d..428c5f473 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -111,6 +111,16 @@ describe("GoogleDriveFileSystem", () => { }); }); + it("读取文件路径查找失败时应抛出 typed not found 错误", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue(null); + const requestSpy = vi.spyOn(fs, "request"); + const reader = await fs.open({ name: "missing.txt", path: "/", size: 0, digest: "", createtime: 0, updatetime: 0 }); + + await expect(reader.read("string")).rejects.toSatisfy(isNotFoundError); + expect(requestSpy).not.toHaveBeenCalled(); + }); + it("ensureDirExists should create missing nested directories and return final id", async () => { const fs = new GoogleDriveFileSystem("/", "token"); const findSpy = vi.spyOn(fs, "findFolderByName").mockResolvedValue(null); diff --git a/packages/filesystem/googledrive/rw.ts b/packages/filesystem/googledrive/rw.ts index fef4d367d..ad69fef72 100644 --- a/packages/filesystem/googledrive/rw.ts +++ b/packages/filesystem/googledrive/rw.ts @@ -1,4 +1,4 @@ -import { isNotFoundError } from "../error"; +import { FileSystemError, isNotFoundError } from "../error"; import type { FileInfo, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import type GoogleDriveFileSystem from "./googledrive"; @@ -17,7 +17,12 @@ export class GoogleDriveFileReader implements FileReader { // 首先获取文件ID const fileId = await this.fs.getFileId(joinPath(this.file.path, this.file.name)); if (!fileId) { - return Promise.reject(new Error(`File not found: ${this.file.name}`)); + throw new FileSystemError({ + provider: "googledrive", + message: `File not found: ${this.file.name}`, + status: 404, + notFound: true, + }); } // 获取文件内容 From 917a30c41d6d860c8c699e6eccdfd8aef1db9637 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:04:13 +0900 Subject: [PATCH 67/73] docs(sync): record provider gap closure --- docs/sync-research.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 1b2dcd3d5..8e06cacd8 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -199,9 +199,9 @@ type FileDigestMap = { | WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | | S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | | OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;reader path lookup miss 已转 typed notFound;path cache 和同名文件仍是主要风险 | | Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 @@ -215,7 +215,7 @@ type FileDigestMap = { 5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 -8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;普通网络错误仍可能保持原始 Error。 +8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss 和 Baidu filemetas miss 已补 typed notFound;普通网络错误仍可能保持原始 Error。 9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 @@ -338,9 +338,9 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 - Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 -- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 +- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;reader path lookup miss 已补 typed notFound。若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:不声明 atomic 能力已有 capability 测试锁定;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 +- Baidu:不声明 atomic 能力已有 capability 测试锁定;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 @@ -359,15 +359,18 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 2. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 3. Google Drive / Baidu best-effort 风险:仍需手工确认不会被误标成 atomic CAS。 -### 可以做的小步 commit +### 已完成的本地验证 commit -1. `docs(sync): keep rollout checklist current` - - 每完成一组测试/实现后同步文档状态。 - - 不增加 runtime 行为。 -2. `✅ test(fs): cover remaining provider typed gaps` - - 只补明确缺口;当前重点是手工审计中发现的 provider raw error shape。 - - 不为 Google Drive / Baidu 添加伪 atomic 测试。 -3. `docs(sync): record manual verification result` +1. `✅ test(fs): cover non-atomic provider capabilities` + - 锁定 Google Drive / Dropbox / Baidu 不声明 atomic/create-only/conditional delete。 +2. `fix(sync/fs): address remaining provider verification gaps` + - Google Drive reader path lookup miss 转 typed notFound。 + - Baidu reader filemetas errno/空列表转 typed notFound。 + - 不为 Google Drive / Baidu 添加伪 atomic 语义。 + +### 剩余可做 commit + +1. `docs(sync): record manual verification result` - 真实扩展和真实 provider 验证后记录观察结果。 - 若无法验证,明确写未验证原因。 From 39cf65b1f93d9c87adfbadd853036321292b1ae2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:05:38 +0900 Subject: [PATCH 68/73] docs(sync): record manual verification result --- docs/sync-research.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 8e06cacd8..a72ec9cf4 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -368,11 +368,11 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - Baidu reader filemetas errno/空列表转 typed notFound。 - 不为 Google Drive / Baidu 添加伪 atomic 语义。 -### 剩余可做 commit +### 已完成的验证记录 commit 1. `docs(sync): record manual verification result` - - 真实扩展和真实 provider 验证后记录观察结果。 - - 若无法验证,明确写未验证原因。 + - 已记录本地 unit/type/lint/build 验证结果。 + - 真实 provider 验证需要 OAuth/账号和云端夹具;未执行的路径明确列为未验证。 ### 暂不进入本轮 @@ -409,6 +409,30 @@ scriptcat-sync.json after: result: ``` +### 当前验证记录 + +本地可运行验证: + +- `pnpm test --run src/app/service/service_worker/synchronize.test.ts packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts packages/filesystem/dropbox/dropbox.test.ts packages/filesystem/onedrive/onedrive.test.ts packages/filesystem/s3/s3.test.ts packages/filesystem/webdav/webdav.test.ts packages/filesystem/limiter.test.ts packages/filesystem/filesystem.test.ts` + - 9 个 test files,189 个 tests 通过。 +- `pnpm test --run packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts` + - 修正 Baidu test fixture 类型后重跑,2 个 test files,27 个 tests 通过。 +- `pnpm run typecheck` + - 通过。 +- `pnpm run lint` + - 通过,仍有 12 个既有 React hooks warning,均不在同步修复范围。 +- `pnpm run build` + - production build 通过;Rspack 输出既有 bundle size、CSS order、Monaco dynamic require warnings。 + +尚未执行的真实 provider 验证: + +- WebDAV / S3 / OneDrive 真实 If-Match / ETag mismatch。 +- Dropbox 真实 structured conflict / rate-limit。 +- Google Drive 真实 path cache stale、同名文件和 best-effort race。 +- Baidu 真实 `filemetas` 缺失、errno 分类和覆盖写行为。 + +未执行原因:当前本地环境没有可用于 OAuth/云端账号的 provider 凭据,也没有预置云端夹具目录;不能把 mock/unit/build 结果宣称为真实云端验证。 + ## 测试矩阵 ### 同步任务级别 From 2cdf9289ab470a3abf232ca7e8d0d2c805ce902a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:12:30 +0900 Subject: [PATCH 69/73] docs(sync): record real provider verification --- docs/sync-research.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/sync-research.md b/docs/sync-research.md index a72ec9cf4..2b6bc162d 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -433,6 +433,19 @@ result: 未执行原因:当前本地环境没有可用于 OAuth/云端账号的 provider 凭据,也没有预置云端夹具目录;不能把 mock/unit/build 结果宣称为真实云端验证。 +### 真实 provider 验证记录 + +本轮未能执行真实云端验证。阻塞条件是本地环境没有可用的 provider OAuth 凭据、S3/WebDAV 测试账号、Dropbox/Google Drive/Baidu/OneDrive 授权会话,也没有预置的云端夹具目录。由于同步修复涉及生产数据兼容,不能用 mock 响应替代真实 provider 结论。 + +真实验证恢复条件: + +1. 每个 provider 准备独立测试目录,目录内只放一次性夹具数据。 +2. 预置旧格式 `.user.js`、`.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json`。 +3. 至少两端配置同一 provider,用于验证 `scriptcat-sync.json` 合并写和 per-file best-effort。 +4. 对 WebDAV/S3/OneDrive 准备 ETag/If-Match mismatch 场景。 +5. 对 Google Drive/Baidu 明确记录 best-effort 行为,不把 preflight 当 atomic CAS。 +6. 每次验证后保存 provider、初始云端文件、操作、日志、`file_digest`、`scriptcat-sync.json` 和结果。 + ## 测试矩阵 ### 同步任务级别 From 34d6c5f2ba4ada5396ef409bbb8f25f0a238ac91 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:14:58 +0900 Subject: [PATCH 70/73] fix(sync/fs): address real provider verification findings --- packages/filesystem/baidu/baidu.test.ts | 36 ++++++++++++++++++++++++- packages/filesystem/baidu/baidu.ts | 13 +++++++-- packages/filesystem/baidu/error.ts | 10 +++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index ed2386439..af36b4cf9 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { initTestEnv } from "@Tests/utils"; -import { isNotFoundError } from "../error"; +import { isNotFoundError, isRateLimitError } from "../error"; import { getFileSystemCapabilities } from "../filesystem"; import BaiduFileSystem from "./baidu"; @@ -24,6 +24,8 @@ describe("BaiduFileSystem", () => { it("request should omit credentials without using global DNR rules", async () => { const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, json: async () => ({ errno: 0 }), }); vi.stubGlobal("fetch", fetchMock); @@ -52,6 +54,38 @@ describe("BaiduFileSystem", () => { expect(updateDynamicRulesMock).not.toHaveBeenCalled(); }); + it("request 遇到 HTTP 429 时应抛出 typed rate-limit 错误", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + statusText: "Too Many Requests", + json: async () => ({ errno: 0, errmsg: "rate limited" }), + }); + vi.stubGlobal("fetch", fetchMock); + const fs = new BaiduFileSystem("/apps", "token"); + + await expect(fs.request("https://pan.baidu.com/rest/2.0/xpan/file?method=list")).rejects.toSatisfy( + isRateLimitError + ); + }); + + it("request 遇到 HTTP 5xx 时应抛出 typed retryable 错误", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + json: async () => ({ errno: 0, errmsg: "service unavailable" }), + }); + vi.stubGlobal("fetch", fetchMock); + const fs = new BaiduFileSystem("/apps", "token"); + + await expect(fs.request("https://pan.baidu.com/rest/2.0/xpan/file?method=list")).rejects.toMatchObject({ + provider: "baidu", + status: 503, + retryable: true, + }); + }); + it("create should normalize double slashes in paths", async () => { const fs = new BaiduFileSystem("/apps//ScriptCat", "token"); diff --git a/packages/filesystem/baidu/baidu.ts b/packages/filesystem/baidu/baidu.ts index 144d83481..4c3ddd8be 100644 --- a/packages/filesystem/baidu/baidu.ts +++ b/packages/filesystem/baidu/baidu.ts @@ -63,15 +63,24 @@ export default class BaiduFileSystem implements FileSystem { config.headers = headers; // 对百度网盘请求显式禁用 cookie,避免依赖全局 DNR 规则造成并发竞态 config.credentials = "omit"; + const parseResponse = async (response: Response) => { + const data = await response.json().catch(() => ({ + errmsg: response.statusText || `HTTP ${response.status}`, + })); + if (!response.ok) { + throw createBaiduFileSystemError({ ...data, httpStatus: response.status }); + } + return data; + }; return fetch(url, config) - .then((data) => data.json()) + .then(parseResponse) .then(async (data) => { if (data.errno === 111 || data.errno === -6) { const token = await AuthVerify("baidu", true); this.accessToken = token; url = url.replace(/access_token=[^&]+/, `access_token=${token}`); return fetch(url, config) - .then((data2) => data2.json()) + .then(parseResponse) .then((data2) => { if (data2.errno === 111 || data2.errno === -6) { throw createBaiduFileSystemError(data2); diff --git a/packages/filesystem/baidu/error.ts b/packages/filesystem/baidu/error.ts index 769c2ba14..d5cb49bc4 100644 --- a/packages/filesystem/baidu/error.ts +++ b/packages/filesystem/baidu/error.ts @@ -2,6 +2,7 @@ import { FileSystemError } from "../error"; export type BaiduErrorResponse = { errno?: number; + httpStatus?: number; errmsg?: string; error_msg?: string; [key: string]: unknown; @@ -11,19 +12,24 @@ const BAIDU_FILE_EXISTS_ERRNOS = new Set([31061]); export function createBaiduFileSystemError(data: BaiduErrorResponse): FileSystemError { const code = typeof data.errno === "number" ? String(data.errno) : undefined; + const status = data.httpStatus; const message = data.errmsg || data.error_msg || (code ? `Baidu request failed with errno ${code}` : "Baidu request failed"); const conflict = typeof data.errno === "number" && BAIDU_FILE_EXISTS_ERRNOS.has(data.errno); - const auth = data.errno === 111 || data.errno === -6; - const notFound = data.errno === -9; + const auth = data.errno === 111 || data.errno === -6 || status === 401; + const notFound = data.errno === -9 || status === 404; + const rateLimit = status === 429; return new FileSystemError({ provider: "baidu", message, + status, code, conflict, auth, notFound, + rateLimit, + retryable: rateLimit || (status !== undefined && status >= 500), raw: data, }); } From dec82aaf8d28f9529cdc235d5f0677d353b4cdf2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:16:00 +0900 Subject: [PATCH 71/73] docs(sync): finalize rollout status --- docs/sync-research.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 2b6bc162d..5288b6ba5 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -201,7 +201,7 @@ type FileDigestMap = { | OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | | Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;reader path lookup miss 已转 typed notFound;path cache 和同名文件仍是主要风险 | | Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;HTTP 429/5xx 已转 typed rateLimit/retryable;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 @@ -215,7 +215,7 @@ type FileDigestMap = { 5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 -8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss 和 Baidu filemetas miss 已补 typed notFound;普通网络错误仍可能保持原始 Error。 +8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss、Baidu filemetas miss 和 Baidu HTTP 429/5xx 已补 typed error;普通网络错误仍可能保持原始 Error。 9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 @@ -340,7 +340,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 - Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;reader path lookup miss 已补 typed notFound。若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:不声明 atomic 能力已有 capability 测试锁定;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 +- Baidu:不声明 atomic 能力已有 capability 测试锁定;HTTP 429/5xx 已补 typed rateLimit/retryable;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 @@ -373,6 +373,21 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 1. `docs(sync): record manual verification result` - 已记录本地 unit/type/lint/build 验证结果。 - 真实 provider 验证需要 OAuth/账号和云端夹具;未执行的路径明确列为未验证。 +2. `docs(sync): record real provider verification` + - 已记录真实 provider 验证未执行的阻塞条件和恢复条件。 + +### 已完成的 provider finding commit + +1. `fix(sync/fs): address real provider verification findings` + - Baidu HTTP 429 转 typed rateLimit。 + - Baidu HTTP 5xx 转 typed retryable。 + - 保持 Baidu 非 atomic 能力声明不变。 + +### Rollout 状态 + +本地可验证范围已完成:同步层 per-file best-effort、旧数据兼容、provider capabilities、关键 typed error、受保护写/删 retry、Baidu/Google Drive 非 atomic 锁定均已有测试或文档记录。 + +真实 provider rollout 仍需凭据环境:WebDAV/S3/OneDrive 的真实 ETag/If-Match mismatch、Dropbox structured conflict、Google Drive/Baidu best-effort race 和 OAuth 刷新路径仍必须在真实账号中验证后再宣称生产完成。 ### 暂不进入本轮 From be5e7940f8520a78cb37bae06f6514a913fa2bfa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:42:26 +0900 Subject: [PATCH 72/73] Delete sync-research.md --- docs/sync-research.md | 552 ------------------------------------------ 1 file changed, 552 deletions(-) delete mode 100644 docs/sync-research.md diff --git a/docs/sync-research.md b/docs/sync-research.md deleted file mode 100644 index 5288b6ba5..000000000 --- a/docs/sync-research.md +++ /dev/null @@ -1,552 +0,0 @@ -# 云同步一致性与生产兼容性研究 - -## 目标 - -本分支用于研究 ScriptCat 云同步在多设备、多 provider、网络异常、并发修改和旧数据兼容场景下的正确性问题。目标不是把 PR #1439 原样搬进来,而是在 `upstream/main` / `main` 现有同步语义上做可验证、可分阶段合并的修复。 - -核心目标: - -1. 避免云端文件被静默覆盖。 -2. 避免失败同步污染本地 `file_digest` 或远端 `scriptcat-sync.json`。 -3. 保持现有生产用户云端数据兼容,不要求手动迁移。 -4. 不把 per-file best-effort 同步退化成整轮 all-or-nothing。 -5. 将 provider 能力、同步策略、错误分类和用户通知分层处理。 - -## 当前同步架构 - -入口在 `src/app/service/service_worker/synchronize.ts` 的 `SynchronizeService`。云同步由 `buildFileSystem()` 建立 provider 文件系统,然后通过 `syncOnce()` 进入 `cloud_sync_queue` 串行队列,最终执行 `syncOnceInternal()`。安装脚本和删除脚本的消息也复用同一个队列,避免和定时同步并发写云端。 - -### `syncOnceInternal` - -当前流程: - -1. `fs.list()` 读取 `ScriptCat/sync` 目录。 -2. 按文件名把 `.user.js` 和 `.meta.json` 组装成 `uuidMap`。 -3. 从本地 DAO 读取脚本列表,生成 `scriptMap`。 -4. 如果存在 `scriptcat-sync.json`,读取其中的 `status.scripts` 作为云端状态。 -5. 遍历 `uuidMap` 决定每个 uuid 的动作: - - 本地脚本存在,云端只有 `.meta.json`:如果 meta 是 tombstone,则本地删除;否则删除无效 meta 并重新 push。 - - 本地脚本存在,云端 `.user.js` digest 与 `file_digest` 一致:跳过。 - - 本地脚本更新时间更晚,或云端缺 `.meta.json`:`pushScript()`。 - - 云端更新时间更晚:`pullScript()`。 - - 本地脚本不存在但云端 `.user.js` 和 `.meta.json` 都存在:`pullScript()` 安装。 - - 云端只有 `.user.js`、没有 `.meta.json`:跳过并记录 orphan uuid,避免误删或覆盖半上传文件。 -6. 剩余本地脚本全部 `pushScript()`。 -7. `Promise.allSettled()` 等待所有任务;fulfilled 且返回 digest patch 的任务合并到 `pushedFileDigestMap`。 -8. 如果开启 `syncStatus`,用本地状态和 `scriptcat-sync.json` 的 status 时间戳合并 enable/sort,然后直接 `fs.create("scriptcat-sync.json")` 覆盖写回。 -9. `updateFileDigest(fs, pushedFileDigestMap)` 重新 list 云端,把当前云端 digest 写入本地 `file_digest`;如果刚 push 的文件未出现在 list 里,用本地 md5 兜底。 - -重要现状:当前 `syncOnceInternal()` 仍保持 task 级 `Promise.allSettled()`,不会因为单个 push/pull Promise rejected 而停止所有任务;但 `pullScript()` 和 `deleteCloudScript()` 内部会 catch 后只记录日志,导致上层无法知道真实失败。 - -### `pushScript` - -`pushScript(fs, script)` 当前无条件覆盖写: - -1. 写 `${uuid}.user.js`,`modifiedDate` 使用脚本 `updatetime || createtime || Date.now()`。 -2. 从 `scriptCodeDAO` 读取源码并写入 `.user.js`。 -3. 写 `${uuid}.meta.json`,内容包含 `uuid`、`origin`、`downloadUrl`、`checkUpdateUrl`。 -4. 返回本地计算的 md5 digest patch,供 `updateFileDigest()` 在云端 list 暂时不可见时兜底。 - -风险:`fs.create()` 的 provider 实现通常是覆盖写或先查再写,当前没有 create-only、expected digest、expected version,也没有原子 CAS。 - -### `pullScript` - -`pullScript(fs, file, status, existingScript?)` 当前流程: - -1. 读取 `.user.js` 源码。 -2. 读取 `.meta.json` 并 `JSON.parse`。 -3. `prepareScriptByCode()` 解析脚本元信息。 -4. 如果有 `scriptcat-sync.json` status,则根据 status 更新时间决定 enable/sort。 -5. `script.installScript({ upsertBy: "sync" })` 写入本地。 -6. catch 所有异常,只记录日志,不向调用方抛出。 - -风险:下载、解析、安装失败都会被视为任务 fulfilled,后续可能继续写 `scriptcat-sync.json` 和 `file_digest`,造成状态污染。另一方面,不能简单把所有错误都抛成整轮失败,因为坏单文件不应卡死其他脚本。 - -### `deleteCloudScript` - -`deleteCloudScript(fs, uuid, syncDelete)` 当前流程: - -1. 删除 `${uuid}.user.js`。 -2. 如果 `syncDelete` 为 true,写 `${uuid}.meta.json` tombstone:`{ uuid, isDeleted: true }`。 -3. 如果 `syncDelete` 为 false,删除 `${uuid}.meta.json`。 -4. catch 所有异常,只记录日志,不向调用方抛出。 - -风险:删除失败、tombstone 写失败或 meta 删除失败都会被调用方当作成功;`scriptsDelete()` 随后仍会 `updateFileDigest()`。 - -### `updateFileDigest` - -`file_digest` 是本地 `ChromeStorage("sync")` 中的 digest cache。当前 `updateFileDigest()` 会: - -1. `fs.list()` 读取云端当前文件。 -2. 用云端返回的 `file.digest` 生成完整新 map。 -3. 对刚 push 但 list 缺失的文件,用 `knownFileDigestMap` 的本地 md5 兜底。 -4. `storage.set("file_digest", newFileDigestMap)` 全量替换。 - -当前实现已经避免在云端 list 返回 provider 原生 digest 时用本地 md5 覆盖它,这对 WebDAV/OneDrive/S3 的 ETag 和 Dropbox content_hash 很关键。 - -仍需注意:全量替换适合“完整 list 成功”场景;如果某个文件操作失败但 `updateFileDigest()` 仍基于新 list 全量写入,失败文件也可能被推进。 - -### `scriptInstall` - -非 sync 来源安装脚本时: - -1. 读取云同步配置。 -2. 如果启用,进入 `cloud_sync_queue`。 -3. `buildFileSystem()`。 -4. `pushScript()`。 -5. `updateFileDigest(fs, pushedFileDigestMap)`。 - -失败只记录日志。这个路径的本地安装已经成功,云端失败可由后续定时 sync 补偿;通知需要谨慎,避免 transient 失败造成噪声。 - -### `scriptsDelete` - -非 sync 来源删除脚本时: - -1. 过滤 `deleteBy === "sync"` 的事件,避免同步拉取/删除造成回灌。 -2. 如果启用云同步,进入 `cloud_sync_queue`。 -3. `buildFileSystem()`。 -4. 顺序调用 `deleteCloudScript()`。 -5. `updateFileDigest(fs)`。 - -当前 `deleteCloudScript()` 吞错,所以循环会继续;如果未来改为抛错,必须逐条 catch,否则批量删除中第 3 条失败会阻止后续 7 条处理。 - -## 同步文件语义 - -### `.user.js` - -每个脚本的源码文件,命名为 `.user.js`。当前同步判断主要看它的 `digest` 和 `updatetime`。 - -### `.meta.json` - -每个脚本的同步元信息,命名为 `.meta.json`。生产旧格式大致为: - -```ts -type SyncMeta = { - uuid: string; - origin?: string; - downloadUrl?: string; - checkUpdateUrl?: string; - isDeleted?: boolean; -}; -``` - -旧用户云端可能只有这些字段。新增字段必须 optional,读取时必须容忍缺失。 - -### tombstone 删除标记 - -当 `syncDelete` 开启时,删除云端脚本不是简单移除所有文件,而是保留 `.meta.json` 且写入 `isDeleted: true`。其他设备看到“只有 meta 且 isDeleted=true”时会删除本地脚本并通知用户。 - -不建议立即新增独立 `tombstone_digest`。它会引入生命周期、清理时机和最终一致性下误保留的问题;除非先定义 GC 规则,否则会增加状态面。 - -### `scriptcat-sync.json` - -保存脚本启用状态、排序和更新时间: - -```ts -type ScriptcatSync = { - version: string; - status: { - scripts: { - [uuid: string]: { - enable: boolean; - sort: number; - updatetime: number; - } | undefined; - }; - }; -}; -``` - -当前写回是覆盖式 `fs.create("scriptcat-sync.json")`。生产兼容要求: - -- 旧文件可能没有未来新增字段。 -- orphan `.user.js` 对应 uuid 的远端 status 必须保留,避免本机把另一台设备半上传状态覆盖掉。 -- 一轮同步中失败的 uuid 不应导致 status 被错误清空或推进。 -- 写回前应尽量基于最新远端状态合并,避免覆盖其他设备刚写入的 status。 - -### `file_digest` - -本地 digest cache,当前格式是: - -```ts -type FileDigestMap = { - [filename: string]: string; -}; -``` - -生产兼容要求: - -- 旧记录只有 digest,没有 version/provider token。 -- 新逻辑必须能读取旧格式。 -- 新增结构时应支持 string 旧值和 object 新值并存。 -- digest 只能代表“上轮已确认成功同步的远端文件状态”,不能写入失败任务的结果。 - -## Provider 实现差异 - -当前 `FileSystem` 接口已经有最小 capabilities,并允许 `create()` / `delete()` 接收条件参数: - -- `supportsAtomicCompareAndSwap` -- `supportsCreateOnly` -- `supportsConditionalDelete` -- `FileCreateOptions.expectedDigest` -- `FileCreateOptions.createOnly` -- `FileDeleteOptions.expectedDigest` - -同步层只在 provider 显式声明能力时传条件参数;未声明能力的 provider 继续保持旧覆盖语义,避免把 best-effort preflight 伪装成 atomic CAS。 - -| Provider | `list()` digest | `create()` 当前语义 | `delete()` 当前语义 | 关键差异 | -| --- | --- | --- | --- | --- | -| WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | -| S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | -| OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;reader path lookup miss 已转 typed notFound;path cache 和同名文件仍是主要风险 | -| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;HTTP 429/5xx 已转 typed rateLimit/retryable;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | -| Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | - -`LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 - -## 已确认问题 - -1. 静默覆盖:`fs.create()` 默认覆盖,两个设备基于旧快照修改同一文件时后写覆盖先写。 -2. 失败后状态污染:本分支已修复主要路径,push/pull/delete 失败文件保留旧 `file_digest`;失败 uuid 的 status 回写会保留云端状态。 -3. `pullScript()` 吞错:本分支已改为让真实失败向上表现为单文件任务失败。 -4. `deleteCloudScript()` 吞错:本分支已改为删除或 tombstone 写失败时向上抛错,批量删除调用方逐条 catch。 -5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 -6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 -7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 -8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss、Baidu filemetas miss 和 Baidu HTTP 429/5xx 已补 typed error;普通网络错误仍可能保持原始 Error。 -9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 -10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 - -## PR #1439 分析 - -PR #1439 试图修复多设备并发写入、删除、拉取/推送失败导致的静默覆盖和状态污染。方向上,它抓到了真实问题:写入应带前置条件,失败不能推进本地 digest,provider 错误应类型化,tombstone 和 orphan 需要明确语义。 - -### cyfung1031 的改动意图 - -- 给 `FileInfo` 增加 version 类 token。 -- 给 create/delete 增加 expected digest/version 和 createOnly。 -- provider 使用 ETag/rev/version/content_hash/md5 做条件写入或 preflight。 -- `syncOnceInternal()` 在失败时避免继续推进 `scriptcat-sync.json` 和 `file_digest`。 -- 处理 orphan `.user.js`、tombstone 收敛、Google Drive 重名、Baidu errno、OneDrive/Google raw response 等具体问题。 -- 增加同步失败通知和大量 provider/sync 测试。 - -### CodFrm review 核心意见 - -CodFrm 的核心担忧是:PR 修了“报错”,但没有保留“消化错误”的粒度。旧代码的问题是 silent data loss,新代码的风险是 all-or-nothing 事务。 - -需要吸收的意见: - -- filesystem 包不应承担业务冲突策略;它应暴露原子能力、条件操作和 typed error,同步层决定业务冲突处理。 -- 99 个成功 + 1 个失败时,成功文件 digest 应推进,失败文件保留旧 digest,下轮只重试失败文件。 -- transient、conflict、fatal、unsupported 应分类,不能都当作整轮失败。 -- status sync 是 best-effort,单个 enable/sort 更新失败不应让整轮 sync 卡死。 -- `pullScript()` 不应吞掉真实失败,但坏 `.meta.json` 或坏 userscript 也不应卡死整个账号。 -- `deleteCloudScript()` 抛错后,`scriptsDelete()` 必须逐条 catch,不能中断后续 uuid。 -- 安装/删除事件触发的同步通知要节流,transient 失败可让定时同步兜底。 -- `updateFileDigest()` 的 list retry / known digest 兜底不能掩盖 provider 最终一致性和 digest 格式差异。 -- Google Drive 的 `fileId:version` 不应被包装成通用 version 语义。 -- `tombstone_digest` 会带来额外生命周期和 GC 问题。 - -### Copilot / reviewer 指出的问题 - -- `FileSystem.delete()` 签名改变后必须更新所有实现,例如 zip,否则 TypeScript 接口不匹配。 -- Baidu `createOnly` 不能把所有非 0 `errno` 都判为 conflict;只能匹配明确 file-exists / duplicate-name 错误。 -- Dropbox 不能靠 message string 长期判断冲突,应在 request 层转换 typed error。 -- OneDrive / Google Drive 的 `nothen=true` raw response 调用点必须全部审计,否则 typed error 不完整。 - -### 正确方向 - -- 保持旧数据可读,新增字段 optional。 -- 成功文件和失败文件分开推进状态。 -- provider 层暴露能力和原子操作,不写业务策略。 -- 能 atomic 的 provider 使用原生条件写;只能 preflight 的 provider 明确标记 best-effort。 -- orphan `.user.js` without `.meta.json` 跳过并保留远端 status。 -- `scriptcat-sync.json` 写回前合并远端最新状态,避免覆盖其他设备。 - -### 可能过度设计或破坏语义的方向 - -- 一次性改所有 provider、通知、tombstone digest、导出、alarm,改动面过大。 -- 任一文件失败就跳过全部 digest 更新,导致成功文件下轮重做甚至自冲突。 -- filesystem 层承载“同步冲突业务策略”,增加包职责。 -- 将 Google Drive `fileId:version` 暴露为通用 `version`。 -- 独立 `tombstone_digest` 未定义 GC 前就落地。 -- 每个 install/delete transient 失败都通知用户。 - -## 兼容生产数据的设计原则 - -1. 读旧格式:`.meta.json`、`scriptcat-sync.json`、`file_digest` 必须容忍缺字段。 -2. 写新格式要 optional:新增字段不能成为读取旧数据的前提。 -3. per-file best-effort:成功文件推进自己的 digest,失败文件保留旧 digest。 -4. 状态推进绑定真实成功:失败任务不得写成功 digest,不得覆盖对应 status。 -5. `scriptcat-sync.json` 合并写:保留 orphan、失败 uuid 和远端较新的 status。 -6. provider token opaque:同步层只传回 provider 给出的 token,不解析 provider 内部结构。 -7. 明确 atomic vs best-effort:preflight 只能降低风险,不能宣称 CAS。 -8. transient 有限 retry:只在幂等或有条件保护的写路径开启。 -9. 通知节流:按 sync round 聚合,不逐文件弹。 - -## 分阶段实现计划 - -### Phase 1:研究文档和测试基线 - -只更新 `docs/sync-research.md` 和文档索引。提交前不改 runtime 代码。 - -### Phase 2:最小测试 - -先写失败测试,覆盖: - -- 99 个文件成功、1 个文件失败时,成功文件 digest 可推进,失败文件不推进。 -- `pullScript()` 失败不能写入成功 digest。 -- `deleteCloudScript()` 失败不能推进 digest。 -- orphan `.user.js` without `.meta.json` 跳过且保留 `scriptcat-sync.json` status。 -- syncStatus 单个 enable/sort 更新失败不能卡死整轮。 - -### Phase 3:最小实现 - -不引入 provider CAS,先修同步层状态污染: - -- 让 `pullScript()` 对真实失败向上返回失败,但坏远端单文件只阻塞该 uuid。 -- 让 `deleteCloudScript()` 返回成功/失败结果;`scriptsDelete()` 逐条 catch 并继续。 -- `syncOnceInternal()` 记录成功 uuid 和失败 uuid。 -- `updateFileDigest()` 支持基于旧 digest map + 成功 patch 的局部推进,失败文件保留旧值。 -- 有失败时 `scriptcat-sync.json` 只写安全合并结果,或跳过会污染的 uuid。 - -### Phase 4:capabilities 和 typed error - -新增最小能力描述,而不是把冲突策略塞进 filesystem: - -```ts -type FileSystemCapabilities = { - supportsAtomicCompareAndSwap: boolean; - supportsCreateOnly: boolean; - supportsConditionalDelete: boolean; -}; -``` - -错误分类建议: - -```ts -type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; -``` - -本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类(包括 `syncOnce` per-file 失败、`scriptInstall` 排队 push 失败、`scriptsDelete` 单项删除失败),以及读类和受条件保护写/删的 transient 有限 retry。 - -### Phase 5:provider 条件操作 follow-up - -分 provider 小步提交: - -- WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 -- Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 -- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;reader path lookup miss 已补 typed notFound。若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 -- OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:不声明 atomic 能力已有 capability 测试锁定;HTTP 429/5xx 已补 typed rateLimit/retryable;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 -- Zip:保持简单,不参与云端 CAS。 - -### Phase 6:重试和通知 - -- transient 429/5xx 有限 retry/backoff 已在 `LimiterFileSystem` 落地,范围限于读类操作和受条件保护的写/删。 -- install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 -- 通知包含失败数量和首个错误类型,不逐文件弹。 - -## 剩余 rollout checklist - -后续提交应继续保持小步、可回滚,并按“测试先行、实现随后、文档同步”的顺序推进。 - -### 必须继续验证 - -1. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 -2. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 -3. Google Drive / Baidu best-effort 风险:仍需手工确认不会被误标成 atomic CAS。 - -### 已完成的本地验证 commit - -1. `✅ test(fs): cover non-atomic provider capabilities` - - 锁定 Google Drive / Dropbox / Baidu 不声明 atomic/create-only/conditional delete。 -2. `fix(sync/fs): address remaining provider verification gaps` - - Google Drive reader path lookup miss 转 typed notFound。 - - Baidu reader filemetas errno/空列表转 typed notFound。 - - 不为 Google Drive / Baidu 添加伪 atomic 语义。 - -### 已完成的验证记录 commit - -1. `docs(sync): record manual verification result` - - 已记录本地 unit/type/lint/build 验证结果。 - - 真实 provider 验证需要 OAuth/账号和云端夹具;未执行的路径明确列为未验证。 -2. `docs(sync): record real provider verification` - - 已记录真实 provider 验证未执行的阻塞条件和恢复条件。 - -### 已完成的 provider finding commit - -1. `fix(sync/fs): address real provider verification findings` - - Baidu HTTP 429 转 typed rateLimit。 - - Baidu HTTP 5xx 转 typed retryable。 - - 保持 Baidu 非 atomic 能力声明不变。 - -### Rollout 状态 - -本地可验证范围已完成:同步层 per-file best-effort、旧数据兼容、provider capabilities、关键 typed error、受保护写/删 retry、Baidu/Google Drive 非 atomic 锁定均已有测试或文档记录。 - -真实 provider rollout 仍需凭据环境:WebDAV/S3/OneDrive 的真实 ETag/If-Match mismatch、Dropbox structured conflict、Google Drive/Baidu best-effort race 和 OAuth 刷新路径仍必须在真实账号中验证后再宣称生产完成。 - -### 暂不进入本轮 - -1. Dropbox `rev` CAS:需要独立设计,不能把 `content_hash` 当 rev。 -2. Google Drive / Baidu atomic CAS:没有原生条件写时只能 best-effort preflight。 -3. `tombstone_digest`:没有 GC 设计前不新增。 -4. 通知聚合 UI:先保留日志和定时同步兜底,避免扩大 UI / i18n 范围。 -5. 既有 React hooks lint warning:与同步修复无关。 - -## 手工验证路径 - -手工验证应遵循 [`VERIFICATION.md`](./VERIFICATION.md):先跑 cheap signals,再用 `e2e/scratch/` 一次性脚本加载真实 `dist/ext`,验证完删除 scratch 脚本。不要为了本同步修复新增永久 E2E。 - -建议按以下云目录夹具逐项验证: - -1. 旧格式完整脚本:云端只有 `.user.js` 和旧 `.meta.json`,无新增字段;预期可正常 pull/install,`file_digest` 写入 provider 原生 digest。 -2. 旧 `file_digest`:本地 storage 只有 `{ "uuid.user.js": "digest" }` string map;预期 push/delete 条件参数只在 provider 声明能力且旧 digest 存在时传递。 -3. 旧 `scriptcat-sync.json`:缺少未来字段或缺少 `status.scripts`;预期不崩溃,写回仍保留可读 status。 -4. orphan `.user.js`:云端只有 `.user.js`;预期跳过,不删除云端文件,不清空对应远端 status。 -5. 单文件 pull 失败:让一个 `.meta.json` 损坏或源码无法解析;预期只阻塞该 uuid,其他脚本继续同步,失败文件 digest 不推进。 -6. 单文件 delete 失败:让 provider 对某个 uuid 删除返回 412/429/5xx;预期后续 uuid 继续处理,失败 uuid digest 保留,日志含 `errorKind`。 -7. `scriptcat-sync.json` 并发状态:两端分别更新不同脚本 enable/sort;预期写回前合并远端最新状态,不覆盖另一端较新 status。 - -每次手工验证记录: - -```text -provider: -initial cloud files: -local scripts: -action: -observed logs: -file_digest after: -scriptcat-sync.json after: -result: -``` - -### 当前验证记录 - -本地可运行验证: - -- `pnpm test --run src/app/service/service_worker/synchronize.test.ts packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts packages/filesystem/dropbox/dropbox.test.ts packages/filesystem/onedrive/onedrive.test.ts packages/filesystem/s3/s3.test.ts packages/filesystem/webdav/webdav.test.ts packages/filesystem/limiter.test.ts packages/filesystem/filesystem.test.ts` - - 9 个 test files,189 个 tests 通过。 -- `pnpm test --run packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts` - - 修正 Baidu test fixture 类型后重跑,2 个 test files,27 个 tests 通过。 -- `pnpm run typecheck` - - 通过。 -- `pnpm run lint` - - 通过,仍有 12 个既有 React hooks warning,均不在同步修复范围。 -- `pnpm run build` - - production build 通过;Rspack 输出既有 bundle size、CSS order、Monaco dynamic require warnings。 - -尚未执行的真实 provider 验证: - -- WebDAV / S3 / OneDrive 真实 If-Match / ETag mismatch。 -- Dropbox 真实 structured conflict / rate-limit。 -- Google Drive 真实 path cache stale、同名文件和 best-effort race。 -- Baidu 真实 `filemetas` 缺失、errno 分类和覆盖写行为。 - -未执行原因:当前本地环境没有可用于 OAuth/云端账号的 provider 凭据,也没有预置云端夹具目录;不能把 mock/unit/build 结果宣称为真实云端验证。 - -### 真实 provider 验证记录 - -本轮未能执行真实云端验证。阻塞条件是本地环境没有可用的 provider OAuth 凭据、S3/WebDAV 测试账号、Dropbox/Google Drive/Baidu/OneDrive 授权会话,也没有预置的云端夹具目录。由于同步修复涉及生产数据兼容,不能用 mock 响应替代真实 provider 结论。 - -真实验证恢复条件: - -1. 每个 provider 准备独立测试目录,目录内只放一次性夹具数据。 -2. 预置旧格式 `.user.js`、`.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json`。 -3. 至少两端配置同一 provider,用于验证 `scriptcat-sync.json` 合并写和 per-file best-effort。 -4. 对 WebDAV/S3/OneDrive 准备 ETag/If-Match mismatch 场景。 -5. 对 Google Drive/Baidu 明确记录 best-effort 行为,不把 preflight 当 atomic CAS。 -6. 每次验证后保存 provider、初始云端文件、操作、日志、`file_digest`、`scriptcat-sync.json` 和结果。 - -## 测试矩阵 - -### 同步任务级别 - -1. 99 个 push 成功,1 个 push 失败。 -2. 99 个 pull 成功,1 个 pull 失败。 -3. 删除 10 个脚本,其中 1 个删除失败,后续 9 个仍处理。 -4. syncStatus 单个 `enableScript` 或 sort 更新失败。 -5. `scriptcat-sync.json` 写入失败。 -6. `file_digest` 更新失败。 -7. install 触发 push transient 失败,不污染 digest。 - -### 数据兼容 - -1. 旧 `.meta.json` 无新增字段。 -2. 旧 `file_digest` 只有 digest string。 -3. 旧 `scriptcat-sync.json` 无新增字段。 -4. 云端只有 `.user.js`。 -5. 云端只有 `.meta.json` tombstone。 -6. 云端 `.user.js` 和 `.meta.json` 更新时间不一致。 - -### 并发冲突 - -1. 两台设备同时修改同一脚本。 -2. 一台删除,另一台修改。 -3. 一台新增,另一台同时新增同 uuid。 -4. 一台更新 status,另一台更新 code。 -5. 远端文件在 list 和 write 之间变化。 - -### provider - -1. WebDAV ETag mismatch。 -2. S3 ETag mismatch。 -3. OneDrive If-Match mismatch。 -4. Dropbox typed conflict。 -5. Google Drive best-effort preflight race。 -6. Baidu errno 分类。 -7. Limiter 对 read 类 transient 错误重试;只对受条件保护的 write/delete transient 错误重试,普通 write/delete 不重复非幂等操作。 - -## 风险清单 - -1. 把单文件失败升级为整轮失败,会让大批量同步重复执行并制造自冲突。 -2. 把失败文件 digest 写成成功,会永久隐藏失败。 -3. 覆盖式写 `scriptcat-sync.json` 会丢掉其他设备状态。 -4. provider 原生 digest 与本地 md5 混用会导致误判变更。 -5. Google Drive 同名文件和 path cache 会导致读写非预期文件。 -6. Baidu/Google Drive preflight 不是 atomic,不能承诺完全消除 TOCTOU。 -7. Dropbox 字符串匹配错误脆弱,API 文案变化会破坏分类。 -8. tombstone 状态若无 GC,会让删除收敛和 status 合并越来越复杂。 -9. 写路径 retry 若没有幂等保护,可能重复创建或覆盖。 -10. 过早改所有 provider 会让 PR 过大,难以 review 和回滚。 - -## 暂不建议实现的内容 - -1. 不建议一次性把 PR #1439 全量合并。 -2. 不建议把整个同步 round 改成 all-or-nothing。 -3. 不建议让 filesystem 包承担业务冲突策略。 -4. 不建议新增独立 `tombstone_digest`,除非同时设计 GC。 -5. 不建议把 Google Drive `fileId:version` 暴露成通用 version。 -6. 不建议在没有 typed error 前按字符串大规模分类冲突。 -7. 不建议没有测试就修改 Google Drive / Baidu 删除逻辑。 - -## 最小可接受修复 - -第一轮 runtime 修复应只做到: - -1. `pullScript()` 和 `deleteCloudScript()` 的失败可被上层识别。 -2. `syncOnceInternal()` 按文件收集结果。 -3. 成功文件推进 digest,失败文件不推进。 -4. orphan `.user.js` 跳过且保留 status。 -5. syncStatus 单项失败不阻塞整轮。 -6. 新增测试证明不会再出现“99 成功 + 1 失败导致 100 个都重复/污染”的问题。 - -## 验证要求 - -每次提交前记录: - -```text -repo: -branch: -commit: -changed files: -tests run: -tests failed: -tests skipped: -known risks: -``` - -如果测试无法运行,必须写明原因,不能写“已验证”。 From eac7b8d9fe3c7106f895a12410fbd10dd687d78b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:55:42 +0900 Subject: [PATCH 73/73] =?UTF-8?q?=F0=9F=93=84=20docs(sync):=20document=20c?= =?UTF-8?q?loud=20sync=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CLOUD-SYNC.md | 374 ++++++++++++++++++++++++++++++++++++++++ docs/DOC-MAINTENANCE.md | 3 +- docs/README.md | 2 +- 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 docs/CLOUD-SYNC.md diff --git a/docs/CLOUD-SYNC.md b/docs/CLOUD-SYNC.md new file mode 100644 index 000000000..a1588ae8b --- /dev/null +++ b/docs/CLOUD-SYNC.md @@ -0,0 +1,374 @@ +# 云同步实现说明 + +本文是 ScriptCat 云同步的维护文档,描述当前分支上的实际实现。它面向需要修改或 review 同步逻辑的维护者,重点解释同步操作、状态文件、provider 差异、错误分类和生产数据兼容要求。 + +相关代码入口: + +- [`src/app/service/service_worker/synchronize.ts`](../src/app/service/service_worker/synchronize.ts):同步服务、队列、状态合并、digest 更新。 +- [`packages/filesystem/filesystem.ts`](../packages/filesystem/filesystem.ts):统一文件系统接口、条件操作参数、provider capability。 +- [`packages/filesystem/error.ts`](../packages/filesystem/error.ts):统一 typed provider error。 +- [`packages/filesystem/*`](../packages/filesystem/):各云盘 provider 实现。 + +## 维护目标 + +云同步的第一目标不是强事务,而是在浏览器扩展和多 provider 限制下做到“尽量正确且不破坏旧数据”。 + +必须保持的不变量: + +1. 单个脚本失败不能阻塞其他脚本同步。 +2. 成功脚本可以推进自己的 `file_digest`,失败脚本必须保留旧 digest。 +3. `scriptcat-sync.json` 写回前要合并远端较新状态,避免覆盖其他设备状态。 +4. provider 没有声明原子能力时,同步层不能传条件写删参数,也不能把 best-effort 行为描述成 CAS。 +5. 旧 `.user.js`、旧 `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须继续可读。 +6. filesystem 包只负责暴露能力、执行文件操作、抛 typed error;同步冲突策略属于 `SynchronizeService`。 + +## 同步目录和文件 + +同步目录由云同步配置决定,业务上使用 `ScriptCat/sync` 作为脚本同步目录。同步目录中主要有四类文件。 + +### `.user.js` + +脚本源码文件。同步层用文件名中的 `uuid` 和 `FileInfo.digest` 识别脚本及远端内容状态。 + +### `.meta.json` + +脚本元信息文件。当前读取时只要求兼容以下字段: + +```ts +type SyncMeta = { + uuid: string; + origin?: string; + downloadUrl?: string; + checkUpdateUrl?: string; + isDeleted?: boolean; +}; +``` + +`origin`、`downloadUrl`、`checkUpdateUrl` 是安装或更新时的辅助信息。新增字段必须保持 optional,读取旧文件时不能要求存在。 + +### tombstone 删除标记 + +当用户启用同步删除时,删除云端脚本不会简单移除所有文件,而是删除 `.user.js` 并写入 `.meta.json`: + +```json +{ + "uuid": "", + "isDeleted": true +} +``` + +其他设备看到“只有 `.meta.json` 且 `isDeleted: true`”时,会删除本地脚本。当前没有单独的 `tombstone_digest`,也没有 tombstone GC 机制;不要在没有生命周期设计前新增这类状态。 + +### `scriptcat-sync.json` + +保存脚本启用状态、排序和更新时间。当前结构: + +```ts +type ScriptcatSync = { + version: string; + status: { + scripts: { + [uuid: string]: { + enable: boolean; + sort: number; + updatetime: number; + } | undefined; + }; + }; +}; +``` + +兼容要求: + +- 文件可能不存在。 +- 文件可能缺少 `status` 或 `status.scripts`。 +- 文件可能损坏或无法 JSON parse。 +- 写回时必须尽量保留远端较新状态,尤其是本轮失败脚本和 orphan 脚本的状态。 + +## 本地 `file_digest` + +`file_digest` 存在 `ChromeStorage("sync")` 中,用于记录上一次确认同步成功的云端文件 digest。 + +当前格式: + +```ts +type FileDigestMap = { + [filename: string]: string; +}; +``` + +注意事项: + +- digest 是 provider 返回的 opaque token,不一定是 md5。 +- WebDAV、S3、OneDrive 使用 ETag 风格 digest。 +- Dropbox 使用 `content_hash`。 +- Google Drive、Baidu 接近 md5。 +- Zip 可能为空。 +- 不能用本地 md5 覆盖 provider 已返回的原生 digest。 +- 文件操作失败时,对应文件名必须保留旧 digest,不能写入“看起来成功”的新值。 + +`updateFileDigest()` 会重新 `fs.list()` 构造新 map;对于刚 push 但 provider list 暂时不可见的文件,才使用 `pushScript()` 返回的本地 md5 作为兜底。 + +## 同步入口和队列 + +`SynchronizeService` 使用 `SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"` 串行化同步任务。以下入口都会进入同一队列: + +- 配置启用后触发的 `syncOnce()`。 +- 定时同步,Chrome alarm 名称为 `cloudSync`,周期为 60 分钟。 +- 非 sync 来源安装脚本后的 `scriptInstall()`。 +- 非 sync 来源删除脚本后的 `scriptsDelete()`。 + +串行队列很重要:安装、删除和定时同步都可能写同一批云端文件,如果并发执行,会扩大覆盖和 digest 污染风险。 + +## `syncOnceInternal()` 流程 + +`syncOnceInternal(syncConfig, fs)` 是主同步流程: + +1. 调用 `fs.list()` 获取云端目录。 +2. 按文件名把 `.user.js` 和 `.meta.json` 组装成 `uuidMap`。 +3. 读取本地脚本列表,生成 `scriptMap`。 +4. 尝试读取 `scriptcat-sync.json`,失败时允许脚本同步继续,但本轮跳过 status 写回。 +5. 对每个云端 uuid 和本地脚本做决策。 +6. 用 `Promise.allSettled()` 等待所有文件任务,保持 per-file best-effort。 +7. 收集成功任务返回的 digest patch。 +8. 对失败任务记录 `failedSyncUuids` 和 `preserveDigestFiles`。 +9. 如果启用 `syncStatus`,合并本地状态、初始云端状态、写回前重新读取的最新云端状态。 +10. 调用 `updateFileDigest()`,成功文件推进 digest,失败文件保留旧 digest。 + +### 决策规则 + +本地脚本存在、云端脚本也存在: + +- 如果 `.user.js` digest 与 `file_digest` 一致,跳过。 +- 如果本地脚本更新时间更晚,push 本地脚本。 +- 如果云端更新时间更晚,pull 云端脚本。 +- 如果云端缺 `.meta.json`,push 本地脚本补齐 meta。 + +本地脚本存在、云端只有 `.meta.json`: + +- 如果 meta 是 tombstone,本地删除脚本。 +- 如果 meta 不是 tombstone,删除无效 meta 并重新 push 本地脚本。 + +本地脚本不存在、云端 `.user.js` 和 `.meta.json` 都存在: + +- pull 并安装云端脚本。 + +本地脚本不存在、云端只有 `.user.js`: + +- 视为 orphan cloud script,跳过。 +- 不删除、不覆盖、不清空对应远端 status。 + +遍历结束后,剩余只存在于本地的脚本会 push 到云端。 + +## push / pull / delete + +### `pushScript()` + +`pushScript()` 写两个文件: + +- `.user.js` +- `.meta.json` + +`modifiedDate` 使用 `script.updatetime || script.createtime || Date.now()`。 + +写入参数由 `buildPushCreateOptions()` 决定: + +- 云端文件不存在且 provider 支持 `supportsCreateOnly`:传 `createOnly: true`。 +- 云端文件存在且 provider 支持 `supportsAtomicCompareAndSwap`,并且 `file_digest` 中有旧 digest:传 `expectedDigest`。 +- provider 未声明能力时,只传 `modifiedDate`,保持旧覆盖语义。 + +`pushScript()` 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。 + +### `pullScript()` + +`pullScript()` 会读取源码和 meta: + +1. `fs.open(file.script).read("string")`。 +2. `fs.open(file.meta).read("string")`。 +3. `JSON.parse(meta)`。 +4. `prepareScriptByCode()` 解析脚本。 +5. 根据 `scriptcat-sync.json` status 调整 enable/sort。 +6. `script.installScript({ upsertBy: "sync" })` 写入本地。 + +真实失败会向上抛出,由 `syncOnceInternal()` 作为单文件失败处理。不要在 `pullScript()` 内吞掉错误,否则会重新引入 digest 污染。 + +### `deleteCloudScript()` + +删除云端脚本时: + +- 先删除 `.user.js`。 +- 如果 `syncDelete` 为 true,写 tombstone meta。 +- 如果 `syncDelete` 为 false,删除 `.meta.json`。 + +删除参数由 `buildDeleteOptions()` 决定: + +- provider 支持 `supportsConditionalDelete` 且 `file_digest` 有旧 digest 时,传 `expectedDigest`。 +- 否则走普通 delete。 + +失败会向上抛出。`scriptsDelete()` 必须逐条 catch,保证批量删除中一个 uuid 失败不影响后续 uuid。 + +## status 合并 + +`scriptcat-sync.json` 是 best-effort 状态同步,不是强事务。合并时遵守以下规则: + +1. 本轮文件同步失败的 uuid 保留云端原 status。 +2. 本轮刚 pull 的脚本保留云端 status,避免刚按云端更新后又写回本地旧状态。 +3. 本地状态更新时间更新时,候选写回本地 status。 +4. 云端状态更新时,应用云端 enable/sort 到本地。 +5. orphan uuid 的云端 status 保留。 +6. 写回前重新读取最新 `scriptcat-sync.json`,再用 `mergeScriptcatSyncStatus()` 合并,减少覆盖其他设备更新的概率。 + +如果初始读取 `scriptcat-sync.json` 失败,本轮不会写回 status 文件。 + +## 错误分类 + +provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映射: + +| 条件 | `SyncErrorKind` | 语义 | +| --- | --- | --- | +| `FileSystemError.conflict` | `conflict` | 条件写删失败或 provider 冲突 | +| `FileSystemError.rateLimit` 或 `retryable` | `transient` | 429、5xx、可重试错误 | +| `FileSystemError.notFound` | `stale_snapshot` | list 到操作之间远端消失或缓存过期 | +| `FileSystemError.auth` 或 `WarpTokenError` | `fatal` | 授权失败 | +| error message 包含 `unsupported` | `unsupported` | provider 不支持 | +| 其他 | `fatal` | 未分类错误 | + +错误分类主要用于日志、保留 digest、后续 retry 策略和 review 判断。它不是用户可见错误协议。 + +## retry 策略 + +`LimiterFileSystem` 对不同操作使用不同重试策略: + +- 会重试:`verify`、`open`、`read`、`openDir`、`list`、`getDirUrl`。 +- 会重试:带 `expectedDigest` 或 `createOnly` 的 `writer.write()`。 +- 会重试:带 `expectedDigest` 的 `delete()`。 +- 不重试:普通 `create`、`createDir`、普通 `writer.write()`、普通 `delete()`。 + +原因:没有条件保护的写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 + +## provider 差异 + +| Provider | digest 来源 | atomic CAS | create-only | conditional delete | 关键实现 | +| --- | --- | --- | --- | --- | --- | +| WebDAV | `etag` | 支持 | 支持 | 支持 | 写入用 `If-Match`;create-only 用 `overwrite: false`;删除用 `If-Match` | +| S3 | `ETag` 去引号 | 支持 | 支持 | 支持 | 写入用 `if-match`;create-only 用 `if-none-match: *`;删除用 `if-match` | +| OneDrive | `eTag` | 支持 | 支持 | 支持 | simple upload / upload session 带 `If-Match` 或 `If-None-Match`;删除带 `If-Match` | +| Google Drive | `md5Checksum` | 不支持 | 不支持 | 不支持 | 先按路径查 fileId,再 PATCH 或 POST;path cache 可能 stale | +| Dropbox | `content_hash` | 不支持 | 不支持 | 不支持 | 先 `exists()`,存在 overwrite,不存在 add;未暴露 rev CAS | +| Baidu | `md5` | 不支持 | 不支持 | 不支持 | precreate/upload/create,`rtype=3` 覆盖;HTTP 429/5xx typed | +| Zip | 空或 zip metadata | 不支持 | 不支持 | 不支持 | 备份用途,不参与云端 CAS | + +### WebDAV + +WebDAV 是原生条件能力 provider。维护时注意: + +- `capabilities` 三项均为 true。 +- ETag 写入和删除时需要保持引号,`quoteETag()` 会补齐。 +- create-only 依赖 `putFileContents(..., { overwrite: false })`。 +- `putFileContents()` 返回 false 时,在 create-only 场景转 typed conflict。 +- 删除 404 视为幂等成功。 + +### S3 + +S3 是原生条件能力 provider。维护时注意: + +- `capabilities` 三项均为 true。 +- list 返回的 `ETag` 会去掉引号作为 digest。 +- 写入 `expectedDigest` 时发送 `if-match`。 +- create-only 发送 `if-none-match: *`。 +- 删除 `expectedDigest` 时发送 `if-match`。 +- `PreconditionFailed` / 412 转 typed conflict。 +- `NoSuchKey` 删除视为成功。 + +### OneDrive + +OneDrive 是原生条件能力 provider。维护时注意: + +- `capabilities` 三项均为 true。 +- list 使用 `eTag` 作为 digest。 +- 空内容走 simple PUT,非空内容走 upload session。 +- 条件写会把 `If-Match` / `If-None-Match` 放到 simple PUT 或 upload session 创建请求。 +- upload session URL 不带 bearer token,request 层保留这个特殊路径。 +- read/delete 使用 raw `Response` 路径,需要手动转 typed error。 + +### Google Drive + +Google Drive 当前不声明 atomic 能力。维护时注意: + +- digest 来自 `md5Checksum`。 +- 目录和文件通过 appDataFolder + path cache 查 fileId。 +- 写入是“先查同名文件,再 PATCH 或 POST”,不是 atomic CAS。 +- 删除是“先查 fileId,再 DELETE”,不是 conditional delete。 +- reader path lookup miss 已转 typed notFound。 +- path cache stale 时 writer/list 会清缓存并重试一次。 +- 不要把 `fileId` 或 `fileId:version` 包装成通用 version 语义。 + +### Dropbox + +Dropbox 当前不声明 atomic 能力。维护时注意: + +- digest 来自 `content_hash`,必须当作 opaque provider digest。 +- 写入是 `exists()` 后 overwrite 或 add,存在 TOCTOU。 +- 当前没有使用 Dropbox rev CAS。 +- request 层已解析 `error_summary` 和 structured `path_lookup` / `path`。 +- raw download 429 会转 typed rateLimit。 +- 删除 not_found 视为幂等成功。 + +### Baidu + +Baidu 当前不声明 atomic 能力。维护时注意: + +- digest 来自 `md5`。 +- 写入流程是 precreate、upload、create,`rtype=3` 覆盖。 +- 只把明确 file-exists errno 判为 conflict。 +- HTTP 429 转 typed rateLimit。 +- HTTP 5xx 转 typed retryable。 +- `filemetas` errno 或空列表转 typed notFound。 +- request 显式 `credentials: "omit"`,不要重新依赖全局 DNR 规则。 +- Baidu 没有被声明为 create-only 或 CAS provider。 + +### Zip + +ZipFileSystem 主要服务备份/导出,不应接入云同步 CAS 语义。它不声明 capabilities。 + +## 生产兼容要求 + +改同步逻辑前必须检查: + +1. 旧云端目录只有 `.user.js` 和 `.meta.json` 时能否继续同步。 +2. 旧 `file_digest` 只有 string digest 时能否继续比较。 +3. 旧 `scriptcat-sync.json` 缺字段时是否会崩溃。 +4. 损坏 `scriptcat-sync.json` 是否会被本轮覆盖。 +5. orphan `.user.js` 是否仍被跳过而不是删除。 +6. 单文件失败是否只保留该文件旧 digest。 +7. provider 原生 digest 是否被本地 md5 覆盖。 +8. 非 atomic provider 是否被错误声明 capability。 + +## 不要做的事 + +- 不要把整个 sync round 改成 all-or-nothing。 +- 不要在 `pullScript()` 或 `deleteCloudScript()` 内 catch 后吞掉真实失败。 +- 不要让失败文件推进 digest。 +- 不要在无法读取远端 `scriptcat-sync.json` 时覆盖写回。 +- 不要把 Google Drive / Baidu 的 preflight 当 atomic CAS。 +- 不要把 Dropbox `content_hash` 当 rev。 +- 不要新增 `tombstone_digest`,除非同时定义 GC 和兼容策略。 +- 不要对普通无条件写入开启 transient retry。 + +## 测试重点 + +同步层测试在 [`src/app/service/service_worker/synchronize.test.ts`](../src/app/service/service_worker/synchronize.test.ts)。provider 测试在各自 `packages/filesystem/*/*.test.ts`。 + +修改同步逻辑时至少考虑以下场景: + +1. 多个文件中一个 push/pull/delete 失败,其他文件继续同步。 +2. 失败文件 digest 保留,成功文件 digest 推进。 +3. `scriptcat-sync.json` 写回失败不污染文件 digest。 +4. 损坏或旧格式 `scriptcat-sync.json` 不阻塞脚本同步。 +5. orphan `.user.js` 跳过并保留 status。 +6. provider conflict/transient/notFound 能映射到正确 `SyncErrorKind`。 +7. 条件写删只在 provider 声明能力时使用。 +8. Google Drive / Dropbox / Baidu 明确保持非 atomic capability。 + +真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 + diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index c1f434e15..0ba399b9d 100644 --- a/docs/DOC-MAINTENANCE.md +++ b/docs/DOC-MAINTENANCE.md @@ -29,6 +29,7 @@ Aspirational / feature-branch content belongs in that branch's docs, or is clear | [`DEVELOP.md`](./DEVELOP.md) | The concrete "how": commands, structure, style, testing, i18n, commit/PR. | | [`VERIFICATION.md`](./VERIFICATION.md) | Lightweight end-to-end functional verification — throwaway scratch scripts driving the real built extension. | | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | Deep internals: process model, message passing, service/data layers, GM API, execution, build. | +| [`CLOUD-SYNC.md`](./CLOUD-SYNC.md) | Cloud sync internals: sync files, digest/status semantics, provider differences, error classification, retry policy. | | [`translation/README.md`](./translation/README.md) | Translation / localization single source of truth. | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | This guide: doc-set organization rules + fact-check / anti-drift discipline. | | [`README.md`](./README.md) | The index that points to all of the above. | @@ -97,7 +98,7 @@ git ls-files eslint-rules/; git grep -l "require-last-error-check" -- eslint.con Link integrity — confirm every relative markdown link in the core docs resolves: ```bash -for doc in AGENTS.md docs/README.md docs/DEVELOP.md docs/VERIFICATION.md docs/ARCHITECTURE.md docs/DOC-MAINTENANCE.md docs/translation/README.md; do +for doc in AGENTS.md docs/README.md docs/DEVELOP.md docs/VERIFICATION.md docs/ARCHITECTURE.md docs/CLOUD-SYNC.md docs/DOC-MAINTENANCE.md docs/translation/README.md; do grep -oE '\]\(([^)]+)\)' "$doc" | sed -E 's/^\]\(|\)$//g' | grep -vE '^https?:|^#' | while read -r link; do target="$(dirname "$doc")/${link%%#*}" [ -e "$target" ] && echo "ok $doc → $link" || echo "BROKEN $doc → $link" diff --git a/docs/README.md b/docs/README.md index 363cee575..02d25d449 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ | [`DEVELOP.md`](./DEVELOP.md) | 开发规范:命令、目录结构、编码风格、UI/主题、测试机制、i18n、提交/PR 流程。**写代码前先读。** | | [`VERIFICATION.md`](./VERIFICATION.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例)。**验证改动是否真正跑通时读。** | | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 内部原理深入:多进程模型、消息传递、服务/数据层、GM API、脚本执行、构建管线。 | -| [`sync-research.md`](./sync-research.md) | 云同步一致性研究:现有同步架构、PR #1439 讨论整理、生产兼容原则、分阶段修复计划和测试矩阵。 | +| [`CLOUD-SYNC.md`](./CLOUD-SYNC.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | ## 翻译 / Translation