diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index e1fd76b79..de7b4fe4b 100644 --- a/docs/DOC-MAINTENANCE.md +++ b/docs/DOC-MAINTENANCE.md @@ -30,6 +30,7 @@ Aspirational / feature-branch content belongs in that branch's docs, or is clear | [`design.md`](./design.md) | The design system: theme mechanism, shadcn component selection, new-page recipe; tokens split to [`references/design-tokens.md`](./references/design-tokens.md), component palette to [`references/design-components.md`](./references/design-components.md), layout/motion/state/a11y patterns to [`references/design-patterns.md`](./references/design-patterns.md). | | [`verification.md`](./verification.md) | Lightweight end-to-end functional verification — throwaway scratch scripts driving the real built extension; report template split to [`references/verification-report-template.md`](./references/verification-report-template.md), debugging FAQ to [`references/verification-debugging.md`](./references/verification-debugging.md). | | [`architecture.md`](./architecture.md) | Deep internals: process model, message passing; subsystem deep-dives split to [`references/architecture-services.md`](./references/architecture-services.md), [`references/architecture-data.md`](./references/architecture-data.md), [`references/architecture-gm-api.md`](./references/architecture-gm-api.md), [`references/architecture-execution.md`](./references/architecture-execution.md), [`references/architecture-build.md`](./references/architecture-build.md). | +| [`cloud-sync.md`](./cloud-sync.md) | Cloud sync internals: sync files, digest/status semantics, provider differences, error classification, retry policy. | | [`translation.md`](./translation.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. | @@ -98,7 +99,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/DOC-MAINTENANCE.md docs/develop.md docs/references/develop-testing.md docs/design.md docs/references/design-tokens.md docs/references/design-components.md docs/references/design-patterns.md docs/architecture.md docs/references/architecture-services.md docs/references/architecture-data.md docs/references/architecture-gm-api.md docs/references/architecture-execution.md docs/references/architecture-build.md docs/verification.md docs/references/verification-debugging.md docs/references/verification-report-template.md docs/translation.md; do +for doc in AGENTS.md docs/README.md docs/DOC-MAINTENANCE.md docs/develop.md docs/references/develop-testing.md docs/design.md docs/references/design-tokens.md docs/references/design-components.md docs/references/design-patterns.md docs/architecture.md docs/references/architecture-services.md docs/references/architecture-data.md docs/references/architecture-gm-api.md docs/references/architecture-execution.md docs/references/architecture-build.md docs/verification.md docs/references/verification-debugging.md docs/references/verification-report-template.md docs/cloud-sync.md docs/translation.md; do # the sed pipeline drops fenced code blocks and inline code spans first, so illustrative sample # links inside ```md snippets or `single-backtick` text (e.g. references/verification-report-template.md's # screenshot/resource examples, verification.md's "Evidence location" spans) aren't false-flagged as broken diff --git a/docs/README.md b/docs/README.md index f1a280e41..6b3d3f473 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ | [`design.md`](./design.md) | 设计系统参考:主题机制、shadcn 组件选型、新建页面配方总览;令牌完整值拆到 [`references/design-tokens.md`](./references/design-tokens.md),组件清单拆到 [`references/design-components.md`](./references/design-components.md),布局/响应式/动效/状态/无障碍范式拆到 [`references/design-patterns.md`](./references/design-patterns.md)。**做页面/对话框/区块前先读。** | | [`verification.md`](./verification.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例);报告模板拆到 [`references/verification-report-template.md`](./references/verification-report-template.md),调试 FAQ 拆到 [`references/verification-debugging.md`](./references/verification-debugging.md)。**验证改动是否真正跑通时读。** | | [`architecture.md`](./architecture.md) | 内部原理总览:多进程模型、消息传递;各子系统深入拆到 [`references/architecture-services.md`](./references/architecture-services.md)(服务层)、[`references/architecture-data.md`](./references/architecture-data.md)(数据层)、[`references/architecture-gm-api.md`](./references/architecture-gm-api.md)(GM API)、[`references/architecture-execution.md`](./references/architecture-execution.md)(脚本执行)、[`references/architecture-build.md`](./references/architecture-build.md)(构建管线)。 | +| [`cloud-sync.md`](./cloud-sync.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | ## 翻译 / Translation diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md new file mode 100644 index 000000000..773428988 --- /dev/null +++ b/docs/cloud-sync.md @@ -0,0 +1,429 @@ +# 云同步实现说明 + +本文是 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`。 +7. 本地与云端都改过的脚本(真冲突)不自动覆盖任何一端:跳过、保留基线、聚合通知用户。 + +功能范围:云同步只同步脚本源码、来源 metadata(`.meta.json`)、启用状态与排序(`scriptcat-sync.json`)。GM storage 值和 `@require`/`@resource` 资源缓存**不**参与云同步(它们只在完整备份/导出路径处理),属于有意取舍,扩展需另行设计(隐私、容量、二进制资源、多端 merge)。 + +## 同步目录和文件 + +同步目录由云同步配置决定,业务上使用 `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,不能写入“看起来成功”的新值。 + +digest 更新有两条路径,区别在于对账范围: + +- `updateFileDigest()`(`syncOnce` 用)重新 `fs.list()` 全量构造新 map。`syncOnce` 已逐文件对账整份云端列表,可以安全全量盖章。 +- `updateFileDigestForUuids()`(`scriptInstall` / `scriptsDelete` 队列用)只更新本次涉及 uuid 的文件。队列路径没有对账整份云端列表,若也全量盖章,会把他端已更新、本机尚未 pull 的文件误标成已同步,导致下一轮 `syncOnce` 早退漏 pull。 + +两条路径都遵守同一套规则:云端仍在则记录 `fs.list()` 返回的原生 digest;刚 push 但 provider list 暂时不可见时才用 `pushScript()` 返回的本地 md5 兜底;云端已删除则移除记录;失败文件保留旧 digest,不写入“看起来成功”的新值。 + +## `sync_content_md5`(本地内容基线) + +另有一份独立的 `sync_content_md5`(同存 `ChromeStorage("sync")`,格式同 `FileDigestMap`)记录本机每次成功推送**或拉取**的内容 md5,即"上次同步成功时的本地内容基线"。它有两个用途: + +1. **方向判定(L4 修复)**:云端 digest 相对 `file_digest` 已变时,用本地当前内容 md5 与基线比较判断"本地是否也改过",取代跨时钟域的墙钟比较(本地毫秒 updatetime vs 服务端整秒 mtime 在同一秒内会误判方向,导致 push 覆盖较新的云端内容)。 +2. **自我 412 收敛**(见下文 `pushScript()` 一节):判定云端冲突文件是否为本机上次所写。 + +它与 `file_digest` 用途不同:`file_digest` 存 provider 原生 digest 检测云端变化,`sync_content_md5` 存本地内容 md5 检测本地变化,二者不可混用。 + +`sync_content_md5` 随 `file_digest` 生命周期收敛,不会只增不删:`updateFileDigest()` 全量对账后清理 `file_digest` 之外的条目;`updateFileDigestForUuids()` 只清理本次确认已从云端删除的目标文件(队列路径未全量对账,不能全局清理)。 + +## 同步入口和队列 + +`SynchronizeService` 使用 `SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"` 串行化同步任务。以下入口都会进入同一队列: + +- 配置启用后触发的 `syncOnce()`。 +- 定时同步,Chrome alarm 名称为 `cloudSync`,周期为 60 分钟。 +- 非 sync 来源安装脚本后的 `scriptInstall()`。push 失败时按 `PushScriptPartialError` 只保留失败文件的旧 digest,已成功文件推进到云端最新值。 +- 非 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`;push 部分失败时(`PushScriptPartialError`),已成功写入云端的文件不保留旧 digest,让其推进到云端最新值,避免过期 digest 造成后续 CAS 永久冲突。 +9. 如果启用 `syncStatus`,合并本地状态、初始云端状态、写回前重新读取的最新云端状态。 +10. 调用 `updateFileDigest()`,成功文件推进 digest,失败文件保留旧 digest。 + +### 决策规则 + +本地脚本存在、云端脚本也存在: + +- 云端缺 `.meta.json`(上一轮分片上传残留):push 本地脚本补齐 meta。 +- 云端 digest 与 `file_digest` 一致(云端自上次同步未变): + - 本地更新时间不比云端新:跳过。 + - 本地更新时间更晚:补偿 push——云端 digest 检测不到本地编辑,队列 push 失败后也靠这里兜底。 +- 云端 digest 与 `file_digest` 不一致(云端自上次同步已变,或本机无记录),由 `decideDirectionOnRemoteChange()` 决定方向。**不比较本地毫秒时钟与服务端整秒 mtime**(对端更新落在同一秒内时"本地时间戳更大"是误报,会 push 覆盖较新的云端内容,即 L4 同秒竞态): + - 本地内容 md5 == `sync_content_md5` 基线(本地未改):pull。 + - 本地内容已改,但与云端当前内容一致(两台设备做了同样编辑,或本机记账失败后云端实为本机所写):直接采用云端 digest 收敛基线,不产生写操作(adopt)。 + - 本地与云端都改了(真冲突):抛 `SyncBothChangedConflictError`,本轮跳过该脚本(沿用失败路径保留旧 digest 与云端 status),并聚合通知用户(见下)。 + - 无基线(升级前旧数据/从未同步成功):退回旧的时间比较规则(时间更晚一方胜出)。 +- 冲突通知:一轮同步只发一条通知,列出所有冲突脚本名;同一批脚本持续冲突时后续轮次不重复通知(集合变化后重新通知)。冲突脚本会一直停走,直到某一端内容与另一端一致(自动收敛)或某一端被删除/重装。 + +注意:所有 provider 目前都不声明条件写删能力(见「provider 差异」),push 是无条件覆盖写。`list → 决策 → write` 之间存在 TOCTOU 窗口:若对端恰好在这几秒内写入同一文件,后写者胜(last-writer-wins),同步层无法察觉。内容基线把误覆盖收窄到"真·同时编辑同一脚本"这一罕见场景;需要原子保护时必须由 provider 侧 CAS 支持(后续按 endpoint 探测能力的方案)。 + +本地脚本存在、云端只有 `.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()` 决定: + +- syncOnce 场景(`hasListSnapshot: true`,文件存在性来自本轮 `fs.list()`): + - 云端文件存在且 provider 支持 `supportsAtomicCompareAndSwap`,并且 `file_digest` 中有旧 digest:传 `expectedDigest` 做 CAS 冲突检测。 + - 云端文件确认不存在:传 `createOnly: true`(支持时)。此时不得回退 `file_digest`——本地残留 digest 必然过期,对不存在的文件做 If-Match 必然 412 且永不自愈。 +- scriptInstall 队列场景(无 list 快照):`file_digest` 有记录(脚本已同步过)→ 传 `expectedDigest` 做 CAS 覆盖;无记录(首次上传)→ 传 `createOnly: true`。 +- provider 未声明能力时,只传 `modifiedDate`,保持旧覆盖语义。 + +当前所有 provider 的 capabilities 均为 false(见「provider 差异」),因此生产路径实际只传 `modifiedDate`;条件参数逻辑保留并有单测覆盖,供后续"按 endpoint 探测能力"方案回开时使用。 + +`pushScript()` 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。 + +#### 自我 412 收敛 + +`pushScript()` 捕获到 `.user.js` 写入 conflict、且本轮尚未重试(`selfHealRetried` 未置位)时,调用 `rebaseSelfWrittenConflict()`:读 `sync_content_md5` 取本机上次同步的内容 md5,再 `fs.list()` + 读云端当前内容比对。 + +- 命中(云端内容 == 本机上次所写):以云端当前原生 digest 为新基准,用 `selfHealRetried: true` 重推一次;该标记防止二次冲突时无限重试。 +- 未命中(云端内容 != 本机记录,即真·他端并发编辑):返回 null,维持停在 conflict,绝不覆盖他端。 + +这解决「本机写入成功但 digest 记账失败后再次编辑,拿过期 digest 对自己写的云端内容做 If-Match 而永久 412」的自我死锁;只在识别到云端就是自己所写时才重推,不破坏对真·并发编辑的保守停走语义。 + +### `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", updatetime: file.script.updatetime })` 写入本地——本地 updatetime 必须采用云端文件时间(与 push 对称),否则下一轮会把刚拉下来的内容误判为本地编辑触发补偿 push,在 etag 型 provider 上形成双设备永久 pull/push 振荡。 +7. 成功后把拉取内容的 md5 记入 `sync_content_md5` 基线,供下轮云端再变时判定本地是否也改过。 + +真实失败会向上抛出,由 `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(500/502/503/504)等可重试错误 | +| `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()`。 + +原因:没有条件保护的写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 + +typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501(如后端不支持条件请求)、505、507 等属于永久失败,不标记可重试,避免 limiter 空转退避。 + +## provider 差异 + +| Provider | digest 来源 | atomic CAS | create-only | conditional delete | 关键实现 | +| --- | --- | --- | --- | --- | --- | +| WebDAV | `etag` | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(`If-Match` / `overwrite: false`)但同步层不会触发 | +| S3 | `ETag` 去引号 | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(`if-match` / `if-none-match: *`)但同步层不会触发 | +| OneDrive | `eTag` | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(simple PUT / upload session `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 / S3 / OneDrive 不再声明条件能力**(#1504 真实服务端实测结论): + +- WebDAV:两台真实服务端行为相反且都不可用——坚果云对**正确 etag** 的 `If-Match` 也恒 412(CAS 路径完全写不进),Alist 类完全忽略 `If-Match`/`If-None-Match`(条件头是静默 no-op,无任何防覆盖)。没有客户端改动能同时适配两者。 +- S3:MinIO 实测仅 `If-Match` CAS 真原子;`If-None-Match: *` 被忽略(视版本),DeleteObject 本无条件删除语义。 +- OneDrive(Graph):三项实测都生效,但恰恰因为它合规,历史上 `scriptInstall` 的 create-only 误用会 409/412 卡死每次编辑——能力按 provider 类型硬编码的假设整体不成立。 + +结论:capability 不能按协议类型硬编码,只能按具体账号/endpoint 实测探测。在探测机制落地前,所有 provider 保守声明为不支持,同步层回落无条件读写 + 内容基线方向判定 + 冲突停走通知(最终一致,不会卡死)。provider 侧条件头映射代码与单测保留,供后续回开。 + +### WebDAV + +维护时注意: + +- `capabilities` 三项均为 false(原因见上表说明)。 +- 条件头映射代码保留:ETag 写入和删除时 `quoteETag()` 补齐引号(坚果云 PROPFIND 的 etag 不带引号且只认不带引号的 `If-Match`,是能力关闭的原因之一)。 +- create-only 依赖 `putFileContents(..., { overwrite: false })`。 +- `putFileContents()` 返回 false 时,在 create-only 场景转 typed conflict。 +- 删除 404 视为幂等成功。 + +### S3 + +维护时注意: + +- `capabilities` 三项均为 false(原因见上表说明)。 +- list 返回的 `ETag` 会去掉引号作为 digest。 +- 条件头映射代码保留:写入 `expectedDigest` 时发送 `if-match`;create-only 发送 `if-none-match: *`;删除 `expectedDigest` 时发送 `if-match`。 +- `PreconditionFailed` / 412 转 typed conflict。 +- `NoSuchKey` 删除视为成功。 + +### OneDrive + +维护时注意: + +- `capabilities` 三项均为 false(原因见上表说明)。 +- 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`。 +- 只有 `path/conflict` / `path_write/conflict` 判 conflict;其余 409(无写权限、空间不足等)保留原错误语义,不能被 createDir 当"目录已存在"吞掉。 +- 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,瞬时 5xx(500/502/503/504)转 typed retryable。 +- 2xx 非 JSON 响应(如代理返回 HTML)会报错,不能当作成功——否则 list 会被判空触发全量覆盖。 +- `filemetas` 空列表强制转 typed notFound(errno -9);服务端返回的其他 errno 走通用 errno 分类,不一定是 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 声明能力时使用;当前所有 provider capabilities 均为 false,不得发出任何条件头。 +8. 云端已变、本地内容基线未变时必须 pull——即使本地 updatetime 大于云端 mtime(同秒竞态 L4)。 +9. 本地与云端都变(真冲突)时不 push 不 pull,保留旧 digest 与云端 status,一轮只发一条聚合通知,同一批冲突不重复通知。 +10. 云端已变、本地也变但内容与云端一致时,收敛基线且不产生写操作。 +11. 无内容基线(升级前旧数据)时退回时间比较规则。 +12. 自我 412 收敛:本机 digest 记账丢失后再次编辑,识别云端为自己所写则重推;真·并发编辑维持停在 conflict。 +13. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 + +真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 + diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index 033aec0fa..fac43f71f 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,21 +1,42 @@ import { describe, expect, it, vi, afterEach } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { isNotFoundError, isRateLimitError } from "../error"; +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({ + ok: true, + status: 200, json: async () => ({ errno: 0 }), }); vi.stubGlobal("fetch", fetchMock); // 监视 updateDynamicRules,确保不再依赖全局 DNR 规则 const updateDynamicRulesMock = vi.fn(); - (chrome as any).declarativeNetRequest.updateDynamicRules = updateDynamicRulesMock; + vi.stubGlobal("chrome", { + declarativeNetRequest: { + updateDynamicRules: updateDynamicRulesMock, + }, + }); const fs = new BaiduFileSystem("/apps", "token"); @@ -33,6 +54,73 @@ 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("request 遇到 HTTP 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 501, + statusText: "Not Implemented", + json: async () => ({ errno: 0, errmsg: "not implemented" }), + }); + 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: 501, + retryable: false, + }); + }); + + it("request 遇到 2xx 非 JSON 响应时应拒绝而不能当作成功", async () => { + // 代理/网关可能返回 200 + HTML 错误页;若被当成功,list() 会把云端判空并触发全量覆盖 + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => { + throw new SyntaxError("Unexpected token '<'"); + }, + }); + 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.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("create should normalize double slashes in paths", async () => { const fs = new BaiduFileSystem("/apps//ScriptCat", "token"); @@ -52,4 +140,54 @@ 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, + }); + }); + + 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/baidu.ts b/packages/filesystem/baidu/baidu.ts index 9184de2f0..dfdea8718 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); } } @@ -62,18 +63,29 @@ export default class BaiduFileSystem implements FileSystem { config.headers = headers; // 对百度网盘请求显式禁用 cookie,避免依赖全局 DNR 规则造成并发竞态 config.credentials = "omit"; + const parseResponse = async (response: Response) => { + if (!response.ok) { + // 失败响应可能没有 JSON body,容忍解析失败以保留 HTTP 状态分类 + const data = await response.json().catch(() => ({ + errmsg: response.statusText || `HTTP ${response.status}`, + })); + throw createBaiduFileSystemError({ ...data, httpStatus: response.status }); + } + // 2xx 非 JSON(如代理返回的 HTML 页)必须报错,否则 list 会被判空导致全量覆盖云端 + return response.json(); + }; 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 new Error(JSON.stringify(data2)); + throw createBaiduFileSystemError(data2); } return data2; }); @@ -95,7 +107,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 +138,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..ff8d3b3ec --- /dev/null +++ b/packages/filesystem/baidu/error.ts @@ -0,0 +1,37 @@ +import { FileSystemError } from "../error"; + +export type BaiduErrorResponse = { + errno?: number; + httpStatus?: 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 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 || status === 401; + const notFound = data.errno === -9 || status === 404; + const rateLimit = status === 429; + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + const transient = status !== undefined && [500, 502, 503, 504].includes(status); + + return new FileSystemError({ + provider: "baidu", + message, + status, + code, + conflict, + auth, + notFound, + rateLimit, + retryable: rateLimit || transient, + raw: data, + }); +} diff --git a/packages/filesystem/baidu/rw.ts b/packages/filesystem/baidu/rw.ts index df2e54519..6b3284982 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; @@ -19,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) { @@ -80,7 +84,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 +106,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } // 创建文件 urlencoded = new URLSearchParams(); @@ -121,7 +125,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } } } diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 6bf549d41..54411c980 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -1,4 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FileSystemError } from "../error"; +import { getFileSystemCapabilities } from "../filesystem"; import DropboxFileSystem from "./dropbox"; describe("DropboxFileSystem", () => { @@ -6,10 +8,232 @@ describe("DropboxFileSystem", () => { vi.clearAllMocks(); }); + afterEach(() => { + 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( + "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 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( + "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 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("非冲突类 409(如无写权限)不应误判为 conflict", async () => { + // Dropbox 用 HTTP 409 承载所有结构化错误;若一律判 conflict, + // createDir 会把无写权限/空间不足当"目录已存在"静默吞掉 + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path/no_write_permission/..", + error: { ".tag": "path", path: { ".tag": "no_write_permission" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/create_folder_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + conflict: false, + notFound: false, + }); + }); + + it("delete 的 path_write/conflict 应判为 conflict", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path_write/conflict/file/..", + error: { ".tag": "path_write" }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/delete_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + conflict: true, + }); + }); + + it("request 遇到 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 501, + text: async () => "Not Implemented", + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/list_folder")).rejects.toMatchObject({ + provider: "dropbox", + status: 501, + retryable: 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("读取文件遇到 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( - 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 +242,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); @@ -31,6 +261,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"); diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 46c264322..a914f60ff 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -1,12 +1,75 @@ 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; + path_lookup?: { + ".tag"?: string; + }; + path?: { + ".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?.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 }; + } + } + + 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; + // Dropbox 用 HTTP 409 承载所有结构化错误(无写权限/空间不足等),只有明确 conflict 语义才算冲突 + const conflict = + !notFound && (summary?.includes("path/conflict") === true || summary?.includes("path_write/conflict") === true); + const rateLimit = status === 429; + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + const transient = [500, 502, 503, 504].includes(status); + 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 || transient, + raw: parsed, + }); } export default class DropboxFileSystem implements FileSystem { @@ -69,7 +132,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 +170,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,23 +185,27 @@ 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; }); } + async createResponseError(resp: Response): Promise { + return toDropboxFileSystemError(resp.status, await resp.text()); + } + async delete(path: string): Promise { const fullPath = joinPath(this.path, path); @@ -154,7 +221,7 @@ export default class DropboxFileSystem implements FileSystem { }), }); } catch (e: any) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return; } throw e; @@ -182,7 +249,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 +314,7 @@ export default class DropboxFileSystem implements FileSystem { }); return true; } catch (e) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return false; } throw e; 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) { diff --git a/packages/filesystem/filesystem.test.ts b/packages/filesystem/filesystem.test.ts new file mode 100644 index 000000000..7bef88c5e --- /dev/null +++ b/packages/filesystem/filesystem.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import type FileSystem from "./filesystem"; +import { getFileSystemCapabilities } from "./filesystem"; +import LimiterFileSystem from "./limiter"; + +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, + }, + }); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: true, + supportsConditionalDelete: false, + }); + }); + + it("LimiterFileSystem 应当透传底层 provider 能力", () => { + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsConditionalDelete: true, + }, + }); + const limiter = new LimiterFileSystem(fs); + + expect(getFileSystemCapabilities(limiter)).toEqual({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: false, + supportsConditionalDelete: true, + }); + }); +}); diff --git a/packages/filesystem/filesystem.ts b/packages/filesystem/filesystem.ts index be40308f0..5fa2c6259 100644 --- a/packages/filesystem/filesystem.ts +++ b/packages/filesystem/filesystem.ts @@ -29,10 +29,36 @@ export type FileReadWriter = FileReader & FileWriter; export type FileCreateOptions = { modifiedDate?: number; + expectedDigest?: string; + createOnly?: boolean; }; +export type FileDeleteOptions = { + expectedDigest?: string; +}; + +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; // 打开文件 @@ -44,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/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 320f2cb55..f036512e7 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); @@ -49,6 +60,92 @@ 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("删除文件遇到 501 响应时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 501, + text: JSON.stringify({ + error: { + code: 501, + message: "Not implemented", + status: "UNIMPLEMENTED", + }, + }), + }) + ); + + await expect(fs.delete("stub.txt")).rejects.toMatchObject({ + provider: "googledrive", + status: 501, + retryable: false, + }); + }); + + 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("读取文件路径查找失败时应抛出 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/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index b5934f20e..f1a1cb5f2 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -142,12 +142,13 @@ export default class GoogleDriveFileSystem implements FileSystem { notFound: googleStatus === 404, conflict: googleStatus === 409 || googleStatus === 412, rateLimit: googleStatus === 429, - retryable: googleStatus === 429 || (googleStatus !== undefined && googleStatus >= 500), + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + retryable: googleStatus === 429 || (googleStatus !== undefined && [500, 502, 503, 504].includes(googleStatus)), raw, }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -232,7 +233,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..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, + }); } // 获取文件内容 @@ -28,7 +33,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/limiter.test.ts b/packages/filesystem/limiter.test.ts index f76ed56f4..65937f4c6 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,95 @@ 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("受 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); + // 守卫 opts 透传:若 limiter 丢掉 expectedDigest,条件删除会退化成无条件删除 + expect(fs.delete).toHaveBeenCalledWith(file.path, { expectedDigest: "digest" }); + }); + it("should retry reader.read on 429", async () => { vi.useFakeTimers(); const fs = createFs(); diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 5dfb54b81..84847915f 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,7 +1,18 @@ import type FileSystem from "./filesystem"; -import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; - -const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); +import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; +import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; +import { FileSystemError } from "./error"; + +const RETRYABLE_TRANSIENT_OPS = new Set([ + "verify", + "open", + "read", + "openDir", + "list", + "getDirUrl", + "conditionalWrite", + "conditionalDelete", +]); /** * 速率限制器 @@ -21,7 +32,7 @@ export class RateLimiter { /** * 执行限速操作 * @param fn 要执行的操作函数 - * @param op 操作类型,用于在遇到 429 时判断是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 + * @param op 操作类型,用于判断 transient 错误是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 * @returns 操作结果 */ async execute(fn: () => Promise, op = "unknown"): Promise { @@ -46,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 { @@ -57,10 +68,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 +83,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_TRANSIENT_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"); } } @@ -94,6 +107,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"); } @@ -117,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"); } @@ -127,8 +145,9 @@ 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 { + const op = opts?.expectedDigest ? "conditionalDelete" : "delete"; + return this.limiter.execute(() => this.fs.delete(path, opts), op); } list(): Promise { diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index a7400cad1..340993af6 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("不应声明条件写删能力:与其余 provider 一致回落无条件读写,避免同步层行为分裂", () => { + const fs = new OneDriveFileSystem("/", "token"); + + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + it("request should return retry result after token refresh", async () => { await localStorageDAO.saveValue("netdisk:token:onedrive", { accessToken: "expired-token", @@ -95,6 +105,76 @@ 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("删除文件遇到 501 响应时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fs = new OneDriveFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 501, + text: JSON.stringify({ + error: { + code: "notImplemented", + message: "Not implemented", + }, + }), + }) + ); + + await expect(fs.delete("stub.txt")).rejects.toMatchObject({ + provider: "onedrive", + status: 501, + retryable: false, + }); + }); + it("create should normalize double slashes in paths", async () => { const fs = new OneDriveFileSystem("/ScriptCat//sync", "token"); @@ -116,6 +196,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({}); @@ -334,6 +432,30 @@ describe("OneDriveFileSystem", () => { expect((requestSpy.mock.calls[0][1] as RequestInit).body).toBe(emptyBlob); }); + it("空内容 simple PUT 条件写入应携带 HTTP 引号形式的 If-Match", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); + + const writer = await fs.create("empty.txt", { expectedDigest: "abc123" }); + await writer.write(""); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const config = requestSpy.mock.calls[0][1] as RequestInit; + expect((config.headers as Headers).get("If-Match")).toBe('"abc123"'); + }); + + it("空内容 simple PUT create-only 应携带 If-None-Match: *", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); + + const writer = await fs.create("empty.txt", { createOnly: true }); + await writer.write(""); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const config = requestSpy.mock.calls[0][1] as RequestInit; + expect((config.headers as Headers).get("If-None-Match")).toBe("*"); + }); + it("writer should keep upload session for non-empty content", async () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi @@ -352,4 +474,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"); + }); }); diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 3a2bef2b0..dcff8bf65 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -1,11 +1,20 @@ 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"; +import { quoteETag } from "./utils"; export default class OneDriveFileSystem implements FileSystem { + // Graph 实测三项条件原语都生效,但同步层统一走无条件读写 + 最终一致(#1504 结论), + // 避免同一套同步逻辑在不同 provider 上行为分裂;待按 endpoint 探测能力的后续 PR 再回开 + readonly capabilities = { + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }; + accessToken?: string; path: string; @@ -32,8 +41,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 { @@ -99,6 +108,8 @@ export default class OneDriveFileSystem implements FileSystem { code === "itemAlreadyExists" || code === "PreconditionFailed"; const rateLimit = status === 429; + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + const transient = status !== undefined && [500, 502, 503, 504].includes(status); return new FileSystemError({ provider: "onedrive", @@ -109,12 +120,12 @@ export default class OneDriveFileSystem implements FileSystem { notFound, conflict, rateLimit, - retryable: rateLimit || (status !== undefined && status >= 500), + retryable: rateLimit || transient, raw, }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -127,7 +138,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,19 +193,25 @@ 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": quoteETag(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) { 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..16f2e6353 100644 --- a/packages/filesystem/onedrive/rw.ts +++ b/packages/filesystem/onedrive/rw.ts @@ -1,7 +1,8 @@ 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"; +import { quoteETag } from "./utils"; export class OneDriveFileReader implements FileReader { file: FileInfo; @@ -20,7 +21,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": @@ -37,9 +38,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 +64,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 +110,18 @@ export class OneDriveFileWriter implements FileWriter { headers: myHeaders, }); } + + private buildConditionalHeaders(): Headers | undefined { + if (this.opts?.expectedDigest) { + const headers = new Headers(); + headers.set("If-Match", quoteETag(this.opts.expectedDigest)); + return headers; + } + if (this.opts?.createOnly) { + const headers = new Headers(); + headers.set("If-None-Match", "*"); + return headers; + } + return undefined; + } } diff --git a/packages/filesystem/onedrive/utils.ts b/packages/filesystem/onedrive/utils.ts new file mode 100644 index 000000000..2028bdc15 --- /dev/null +++ b/packages/filesystem/onedrive/utils.ts @@ -0,0 +1 @@ +export const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); diff --git a/packages/filesystem/s3/error.ts b/packages/filesystem/s3/error.ts new file mode 100644 index 000000000..e8be345f9 --- /dev/null +++ b/packages/filesystem/s3/error.ts @@ -0,0 +1,25 @@ +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"; + // 只重试瞬时 5xx;501/505/507 等属于永久失败,重试只会空转退避 + const transient = [500, 502, 503, 504].includes(error.statusCode); + + 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 || transient, + raw: error, + }); +} diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index 378fb18ad..2cc6c9d6e 100644 --- a/packages/filesystem/s3/rw.ts +++ b/packages/filesystem/s3/rw.ts @@ -1,5 +1,7 @@ import type { S3Client } from "./client"; -import type { FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import { createS3FileSystemError } from "./error"; +import { quoteETag } from "./utils"; /** * S3 文件读取器 @@ -25,11 +27,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(); } } @@ -46,11 +52,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; } /** @@ -68,10 +80,19 @@ 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"] = "*"; + } - 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.test.ts b/packages/filesystem/s3/s3.test.ts index 8e52a51c7..e8f477986 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"; @@ -47,6 +48,14 @@ describe("S3FileSystem", () => { fs = new S3FileSystem("test-bucket", mockClient); }); + it("不应声明条件写删能力:MinIO 实测忽略 If-None-Match:*,DeleteObject 无条件删除语义", () => { + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + // ---- verify ---- describe("verify", () => { it("应当成功验证 bucket", async () => { @@ -155,6 +164,52 @@ 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, + }); + }); + + it("读取文件遇到 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败(如后端不支持条件请求),重试只会空转退避 + const fileInfo: FileInfo = { + name: "busy.user.js", + path: "/", + size: 50, + digest: "xyz", + createtime: 1000, + updatetime: 2000, + }; + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("NotImplemented", "A header you provided implies functionality that is not implemented", 501) + ); + + const reader = await fs.open(fileInfo); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "s3", + status: 501, + retryable: false, + }); + }); }); // ---- create ---- @@ -203,6 +258,52 @@ 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("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"); @@ -252,6 +353,31 @@ 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"', + }, + }) + ); + }); + + 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 ---- @@ -400,6 +526,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 ---- diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index 41ce89e6a..78046189b 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -2,10 +2,12 @@ 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"; +import { createS3FileSystemError } from "./error"; +import { quoteETag } from "./utils"; // ---- ListObjectsV2 XML 解析 ---- @@ -52,6 +54,14 @@ function parseListObjectsV2(xml: string): ListObjectsV2Result { * 使用原生 fetch + AWS Signature V4 签名,不依赖 @aws-sdk/client-s3 */ export default class S3FileSystem implements FileSystem { + // S3 兼容服务端对条件写删的支持因实现/版本而异(MinIO 实测忽略 If-None-Match:*, + // DeleteObject 本无 If-Match 语义),统一保守声明为不支持,回落无条件读写 + 最终一致(#1504 实测结论) + readonly capabilities = { + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }; + client: S3Client; bucket: string; @@ -167,7 +177,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); } /** @@ -182,15 +192,22 @@ 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") { return; } - throw error; + throw createS3FileSystemError(error); } } @@ -254,7 +271,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); } } diff --git a/packages/filesystem/s3/utils.ts b/packages/filesystem/s3/utils.ts new file mode 100644 index 000000000..641ec18f6 --- /dev/null +++ b/packages/filesystem/s3/utils.ts @@ -0,0 +1,3 @@ +export function quoteETag(digest: string): string { + return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; +} diff --git a/packages/filesystem/webdav/error.ts b/packages/filesystem/webdav/error.ts new file mode 100644 index 000000000..8db517429 --- /dev/null +++ b/packages/filesystem/webdav/error.ts @@ -0,0 +1,32 @@ +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; + // 只重试瞬时 5xx;501/505/507 等属于永久失败,重试只会空转退避 + const transient = [500, 502, 503, 504].includes(status); + + 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 || transient, + raw: error, + }); +} diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index c13afe2f4..337b70a6c 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,5 +1,8 @@ import type { WebDAVClient } from "webdav"; -import type { FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import { FileSystemError } from "../error"; +import { createWebDAVFileSystemError } from "./error"; +import { quoteETag } from "./utils"; export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -12,17 +15,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); } } } @@ -32,16 +39,58 @@ 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; - const resp = await this.client.putFileContents(this.path, data); + let resp: boolean; + try { + 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); + } if (!resp) { - throw new Error("write error"); + if (this.opts?.createOnly) { + throw new FileSystemError({ + provider: "webdav", + message: "WebDAV create-only write conflict", + status: 412, + conflict: true, + }); + } + throw new FileSystemError({ + provider: "webdav", + message: "WebDAV write failed", + status: 500, + retryable: false, + }); + } + } + + 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/utils.ts b/packages/filesystem/webdav/utils.ts new file mode 100644 index 000000000..2028bdc15 --- /dev/null +++ b/packages/filesystem/webdav/utils.ts @@ -0,0 +1 @@ +export const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 4d91a5edb..140d9b6e0 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 { @@ -32,6 +32,16 @@ describe("WebDAVFileSystem", () => { mockClient = createMockClient(); }); + it("不应声明条件写删能力:真实服务端行为不一致(坚果云恒 412、Alist 类忽略 If-Match)", () => { + const fs = createTestFS(mockClient); + + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + 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,91 @@ 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, + }) + ); + }); + + 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", () => { + 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, + }); + }); + + it("读取文件遇到 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const err = new Error("Not Implemented"); + (err as any).response = { status: 501 }; + (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: 501, + retryable: false, + }); + }); }); describe("list", () => { @@ -270,6 +377,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", () => { diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 871097537..ae5793d68 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -5,6 +5,9 @@ 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"; +import type { FileDeleteOptions } from "../filesystem"; +import { quoteETag } from "./utils"; // 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) // 全局单次注册 @@ -24,6 +27,15 @@ const initWebDAVPatch = () => { }; export default class WebDAVFileSystem implements FileSystem { + // 真实 WebDAV 服务端对条件头的兑现极不一致且方向相反(坚果云对正确 etag 的 If-Match 也恒 412, + // Alist 类完全忽略 If-Match/If-None-Match),任何一项都不能依赖,保守声明为不支持, + // 回落无条件读写 + 最终一致(#1504 实测结论) + readonly capabilities = { + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }; + client: WebDAVClient; url: string; @@ -75,8 +87,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 { @@ -87,18 +99,26 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 405 || e.message?.includes("405")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } - 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")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } @@ -108,7 +128,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) { diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index d45d18e9a..eca7a2236 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"; @@ -320,176 +321,2674 @@ describe("SynchronizeService", () => { expect(written.status.scripts.orphan).toEqual(orphanStatus); }); - it("waits for installScript during pullScript", async () => { - let releaseInstall!: () => void; - const installGate = new Promise((resolve) => { - releaseInstall = resolve; - }); - const installScript = vi.fn().mockImplementation(() => installGate); + it("兼容缺少 status.scripts 的旧版 scriptcat-sync.json", async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); const fs = createFs({ - open: vi.fn().mockImplementation(async (file) => ({ - read: vi.fn().mockResolvedValue( - file.name.endsWith(".user.js") - ? `// ==UserScript== -// @name Pull Test -// @namespace sync-test -// @match https://example.com/* -// ==/UserScript== -console.log("ok");` - : JSON.stringify({ uuid: "pull-uuid" }) - ), - })), + 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, - { - installScript, - } as any, + {} as any, {} as any, {} as any, {} as any, {} as any, { scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), } as any ); - let settled = false; - const pullPromise = service - .pullScript( - fs, - { - script: { - name: "pull-uuid.user.js", - path: "pull-uuid.user.js", + 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 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: "d1", + digest: "sync-digest", createtime: 1, updatetime: 1, }, - meta: { - name: "pull-uuid.meta.json", - path: "pull-uuid.meta.json", + ]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", size: 1, - digest: "d2", + digest: "cloud-ok-user", createtime: 1, updatetime: 1, }, - }, - undefined - ) - .then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); + { + 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", + }); - releaseInstall(); - await pullPromise; + await service.syncOnce(syncConfig, fs); - expect(installScript).toHaveBeenCalledTimes(1); - expect(settled).toBe(true); + 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("waits for deleteScript before updating file digest", async () => { - let releaseDelete!: () => void; - const deleteGate = new Promise((resolve) => { - releaseDelete = resolve; + 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 order: string[] = []; - const deleteScript = vi.fn().mockImplementation(async () => { - order.push("delete:start"); - await deleteGate; - order.push("delete:end"); + 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", }); - // fs.list 第二次调用对应 updateFileDigest,这是 syncOnce 的最后一步 - const fsList = vi - .fn() - .mockImplementationOnce(async () => [ + + 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: "del-uuid.meta.json", - path: "del-uuid.meta.json", + name: "status-uuid.user.js", + path: "status-uuid.user.js", size: 1, - digest: "d1", + digest: "script-digest", + createtime: 1, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, + }, + { + name: "status-uuid.meta.json", + path: "status-uuid.meta.json", + size: 1, + digest: "meta-digest", createtime: 1, updatetime: 1, }, - ]) - .mockImplementationOnce(async () => { - order.push("digest:list"); - return []; - }); + { + 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("写回 scriptcat-sync.json 时远端已删除的 uuid 不应被复活", async () => { + const initialStatus = { enable: true, sort: 1, updatetime: 100 }; + const writeMock = vi.fn().mockResolvedValue(undefined); const fs = createFs({ - list: fsList, - open: vi.fn().mockResolvedValue({ - read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "del-uuid", isDeleted: true })), - }), + list: vi.fn().mockResolvedValue([ + { + name: "kept-uuid.user.js", + path: "kept-uuid.user.js", + size: 1, + digest: "d", + createtime: 1, + updatetime: 1, + }, + { + name: "kept-uuid.meta.json", + path: "kept-uuid.meta.json", + size: 1, + digest: "d", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-d", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi + .fn() + // initial read: both uuids present + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { + scripts: { + "kept-uuid": initialStatus, + "deleted-uuid": initialStatus, + }, + }, + }) + ), + }) + // latest re-read: deleted-uuid removed by another device + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "kept-uuid": initialStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), }); const service = new SynchronizeService( {} as any, {} as any, - { deleteScript } as any, + { enableScript: vi.fn().mockResolvedValue(undefined) } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "kept-uuid", + name: "kept", + updatetime: 100, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + } as any + ); + + await service.syncOnce(syncConfig, fs); + + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts).not.toHaveProperty("deleted-uuid"); + expect(written.status.scripts).toHaveProperty("kept-uuid"); + }); + + 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, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, + }, + { + 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("脚本文件同步失败时仍可回写其它脚本的 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, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, + }, + { + 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) => { + releaseInstall = resolve; + }); + const installScript = vi.fn().mockImplementation(() => installGate); + const fs = createFs({ + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue( + file.name.endsWith(".user.js") + ? `// ==UserScript== +// @name Pull Test +// @namespace sync-test +// @match https://example.com/* +// ==/UserScript== +console.log("ok");` + : JSON.stringify({ uuid: "pull-uuid" }) + ), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + installScript, + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + } as any + ); + + let settled = false; + const pullPromise = service + .pullScript( + fs, + { + script: { + name: "pull-uuid.user.js", + path: "pull-uuid.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + meta: { + name: "pull-uuid.meta.json", + path: "pull-uuid.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + }, + undefined + ) + .then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + releaseInstall(); + await pullPromise; + + expect(installScript).toHaveBeenCalledTimes(1); + expect(settled).toBe(true); + }); + + it("pull 安装时本地 updatetime 应采用云端文件时间,避免下一轮误判本地较新而补偿 push", async () => { + // prepareScriptByCode 会把 updatetime 置为 Date.now()(必然大于云端文件 mtime)。 + // 若不用云端时间覆盖,下一轮 syncOnce 会把刚拉下来的内容当"本地编辑过"再推回云端; + // 在 etag 型 provider(WebDAV/OneDrive)上相同内容重写也会换 etag, + // 两台设备会陷入 pull→push→etag 变化→对端 pull→push 的永久振荡。 + const installScript = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue( + file.name.endsWith(".user.js") + ? `// ==UserScript== +// @name Pull Time Test +// @namespace sync-test +// @match https://example.com/* +// ==/UserScript== +console.log("ok");` + : JSON.stringify({ uuid: "pull-time-uuid" }) + ), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + installScript, + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + } as any + ); + + await service.pullScript( + fs, + { + script: { + name: "pull-time-uuid.user.js", + path: "pull-time-uuid.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 12345, + }, + meta: { + name: "pull-time-uuid.meta.json", + path: "pull-time-uuid.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + }, + undefined + ); + + expect(installScript).toHaveBeenCalledTimes(1); + expect(installScript).toHaveBeenCalledWith(expect.objectContaining({ updatetime: 12345 })); + }); + + it("waits for deleteScript before updating file digest", async () => { + let releaseDelete!: () => void; + const deleteGate = new Promise((resolve) => { + releaseDelete = resolve; + }); + const order: string[] = []; + const deleteScript = vi.fn().mockImplementation(async () => { + order.push("delete:start"); + await deleteGate; + order.push("delete:end"); + }); + // fs.list 第二次调用对应 updateFileDigest,这是 syncOnce 的最后一步 + const fsList = vi + .fn() + .mockImplementationOnce(async () => [ + { + name: "del-uuid.meta.json", + path: "del-uuid.meta.json", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + ]) + .mockImplementationOnce(async () => { + order.push("digest:list"); + return []; + }); + const fs = createFs({ + list: fsList, + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "del-uuid", isDeleted: true })), + }), + }); + 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: "del-uuid", + name: "del", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + + const promise = service.syncOnce(syncConfig, fs); + await flushMicrotasks(); + + // delete 已经开始但没结束,updateFileDigest 还没被调用 + expect(order).toEqual(["delete:start"]); + + releaseDelete(); + await promise; + + // delete 必须在 updateFileDigest 之前完成 + 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) => { + releasePush = resolve; + }); + const order: string[] = []; + // pushScript 内部第一步是 fs.create(uuid.user.js),gate 在这里就能拦住整个 push + const fsCreate = vi.fn().mockImplementation(async (filename: string) => { + if (filename === "push-uuid.user.js") { + order.push("push:start"); + await pushGate; + order.push("push:end"); + } + return { write: vi.fn().mockResolvedValue(undefined) }; + }); + const fsList = vi + .fn() + .mockImplementationOnce(async () => []) + .mockImplementationOnce(async () => { + order.push("digest:list"); + return []; + }); + const fs = createFs({ + list: fsList, + create: fsCreate, + }); + 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([ + { + uuid: "push-uuid", + name: "push", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + + const promise = service.syncOnce(syncConfig, fs); + await flushMicrotasks(); + + // push 已经开始但没结束,updateFileDigest 还没被调用 + expect(order).toEqual(["push:start"]); + + releasePush(); + await promise; + + // push 必须在 updateFileDigest 之前完成 + expect(order).toContain("push:end"); + expect(order).toContain("digest:list"); + expect(order.indexOf("push:end")).toBeLessThan(order.indexOf("digest:list")); + }); + + it("keeps pushed script digest when cloud list is stale after upload", async () => { + const scriptCode = "// code"; + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + 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: scriptCode }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const metaJson = JSON.stringify({ + uuid: script.uuid, + origin: script.origin, + downloadUrl: script.downloadUrl, + checkUpdateUrl: script.checkUpdateUrl, + }); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "push-uuid.user.js": md5OfText(scriptCode), + "push-uuid.meta.json": md5OfText(metaJson), + }); + }); + + 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("CAS 冲突时云端内容是本机上次所写则重挂基准重推,收敛自我 412", async () => { + const script = { + uuid: "sh-uuid", + name: "sh", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 20, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const written: { name: string; content: string }[] = []; + const conflictError = new FileSystemError({ + provider: "webdav", + message: "precondition failed", + status: 412, + conflict: true, + }); + const fs = createFs({ + capabilities: { supportsAtomicCompareAndSwap: true }, + // 首次用过期基准 d0 做 If-Match → 412(自己上次写入把云端推到了 e1,但 digest 记账失败) + create: vi.fn().mockImplementation(async (name: string, opts: { expectedDigest?: string }) => { + if (name === "sh-uuid.user.js" && opts?.expectedDigest === "d0-stale") { + throw conflictError; + } + return { + write: vi.fn().mockImplementation(async (content: string) => { + written.push({ name, content }); + }), + }; + }), + list: vi.fn().mockResolvedValue([ + { name: "sh-uuid.user.js", path: "sh-uuid.user.js", size: 1, digest: "e1-cloud", createtime: 1, updatetime: 5 }, + { + name: "sh-uuid.meta.json", + path: "sh-uuid.meta.json", + size: 1, + digest: "m1-cloud", + createtime: 1, + updatetime: 5, + }, + ]), + open: vi.fn().mockImplementation(async (fileInfo: { name: string }) => ({ + read: vi.fn().mockResolvedValue(fileInfo.name === "sh-uuid.user.js" ? "// cloud-edit-1" : "{}"), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// local-edit-2" }) }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + // 本机上次成功写入云端的内容是 edit-1(digest 记账失败 → file_digest 仍停在 d0) + await (service as any).storage.set("sync_content_md5", { + "sh-uuid.user.js": md5OfText("// cloud-edit-1"), + }); + + const result = await service.pushScript(fs as any, script as any, { + fileDigestMap: { "sh-uuid.user.js": "d0-stale", "sh-uuid.meta.json": "m0-stale" }, + }); + + // 首次以过期基准 CAS + expect(fs.create).toHaveBeenCalledWith("sh-uuid.user.js", expect.objectContaining({ expectedDigest: "d0-stale" })); + // 识别为自我所写后以云端当前 digest 重挂基准重推 + expect(fs.create).toHaveBeenCalledWith("sh-uuid.user.js", expect.objectContaining({ expectedDigest: "e1-cloud" })); + // 重推的是本机当前内容 edit-2 + expect(written).toContainEqual({ name: "sh-uuid.user.js", content: "// local-edit-2" }); + expect(result["sh-uuid.user.js"]).toBe(md5OfText("// local-edit-2")); + }); + + it("CAS 冲突时云端内容非本机所写则维持停在冲突,不重推覆盖他端", async () => { + const script = { + uuid: "cf-uuid", + name: "cf", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 20, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const conflictError = new FileSystemError({ + provider: "webdav", + message: "precondition failed", + status: 412, + conflict: true, + }); + const create = vi.fn().mockImplementation(async (name: string, opts: { expectedDigest?: string }) => { + if (name === "cf-uuid.user.js" && opts?.expectedDigest === "d0-stale") { + throw conflictError; + } + return { write: vi.fn().mockResolvedValue(undefined) }; + }); + const fs = createFs({ + capabilities: { supportsAtomicCompareAndSwap: true }, + create, + list: vi.fn().mockResolvedValue([ + { name: "cf-uuid.user.js", path: "cf-uuid.user.js", size: 1, digest: "eB-cloud", createtime: 1, updatetime: 9 }, + { + name: "cf-uuid.meta.json", + path: "cf-uuid.meta.json", + size: 1, + digest: "mB-cloud", + createtime: 1, + updatetime: 9, + }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// other-device"), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// local" }) }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + await (service as any).storage.set("sync_content_md5", { + "cf-uuid.user.js": md5OfText("// my-previous-push"), + }); + + await expect( + service.pushScript(fs as any, script as any, { + fileDigestMap: { "cf-uuid.user.js": "d0-stale", "cf-uuid.meta.json": "m0-stale" }, + }) + ).rejects.toThrow(); + + // 云端内容不是本机所写 → 不应以其它 digest 重推覆盖他端 + const userDigests = create.mock.calls + .filter((c: any[]) => c[0] === "cf-uuid.user.js") + .map((c: any[]) => c[1]?.expectedDigest); + expect(userDigests).toEqual(["d0-stale"]); + }); + + it("updateFileDigest 全量对账时应清理云端已删除文件的 sync_content_md5,避免只增不删", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValue([ + { name: "keep.user.js", path: "keep.user.js", size: 1, digest: "d-keep", createtime: 1, updatetime: 1 }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: { get: vi.fn() }, all: vi.fn().mockResolvedValue([]) } as any + ); + await (service as any).storage.set("sync_content_md5", { + "keep.user.js": "md5-keep", + // 云端已删除(不在 list 快照中),其 sync_content_md5 条目应被清理 + "gone.user.js": "md5-gone", + }); + + await service.updateFileDigest(fs as any); + + await expect((service as any).storage.get("sync_content_md5")).resolves.toEqual({ + "keep.user.js": "md5-keep", + }); + }); + + it("updateFileDigestForUuids 删除本次目标文件时仅清理目标 sync_content_md5,不误删无关条目", async () => { + // 队列路径未对账整份云端列表,只能按本次目标 uuid 清理,不能全量盖章误删他端窗口内的记录 + const fs = createFs({ + list: vi.fn().mockResolvedValue([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: { get: vi.fn() }, all: vi.fn() } as any + ); + await (service as any).storage.set("sync_content_md5", { + "del-uuid.user.js": "md5-user", + "del-uuid.meta.json": "md5-meta", + // 非本次目标且不在 list:不应被队列路径清理 + "other.user.js": "md5-other", + }); + + await service.updateFileDigestForUuids(fs as any, ["del-uuid"]); + + await expect((service as any).storage.get("sync_content_md5")).resolves.toEqual({ + "other.user.js": "md5-other", + }); + }); + + 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("删除无效 meta 后重传:本轮已确认不存在的文件应走 createOnly 而非过期 digest CAS", async () => { + // 云端只有普通 .meta.json(无 .user.js):删除无效 meta 后重传两个文件。 + // 本轮 list 已确认 .user.js 不存在、meta 刚被我们删除,若再拿本地记录的过期 digest + // 对不存在的文件做 If-Match 必然 412,且失败保留旧 digest 后永不自愈。 + const script = { + uuid: "remeta-uuid", + name: "remeta", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }, + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "remeta-uuid.meta.json", + path: "remeta-uuid.meta.json", + size: 1, + digest: "cloud-meta", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "remeta-uuid", origin: "origin" })), + }), + } as unknown as Partial); + 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", { + "remeta-uuid.user.js": "stale-user-digest", + "remeta-uuid.meta.json": "stale-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const createCalls = vi.mocked(fs.create).mock.calls; + const userCall = createCalls.find((c) => c[0] === "remeta-uuid.user.js"); + const metaCall = createCalls.find((c) => c[0] === "remeta-uuid.meta.json"); + expect(userCall?.[1]).toMatchObject({ createOnly: true }); + expect(userCall?.[1]?.expectedDigest).toBeUndefined(); + expect(metaCall?.[1]).toMatchObject({ createOnly: true }); + expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); + }); + + it("云端缺 .meta.json 的补传:meta 走 createOnly,.user.js 仍用记录 digest 做 CAS", async () => { + const script = { + uuid: "remeta2-uuid", + name: "remeta2", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + }, + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "remeta2-uuid.user.js", + path: "remeta2-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", { + "remeta2-uuid.user.js": "old-user-digest", + "remeta2-uuid.meta.json": "stale-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const createCalls = vi.mocked(fs.create).mock.calls; + const userCall = createCalls.find((c) => c[0] === "remeta2-uuid.user.js"); + const metaCall = createCalls.find((c) => c[0] === "remeta2-uuid.meta.json"); + expect(userCall?.[1]).toMatchObject({ expectedDigest: "old-user-digest" }); + expect(metaCall?.[1]).toMatchObject({ createOnly: true }); + expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); + }); + + it("云端已确认不存在的新脚本:即便本地残留过期 digest 记录也应走 createOnly", async () => { + const script = { + uuid: "fresh-uuid", + name: "fresh", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + 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 as any).storage.set("file_digest", { + "fresh-uuid.user.js": "stale-user-digest", + "fresh-uuid.meta.json": "stale-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const createCalls = vi.mocked(fs.create).mock.calls; + const userCall = createCalls.find((c) => c[0] === "fresh-uuid.user.js"); + const metaCall = createCalls.find((c) => c[0] === "fresh-uuid.meta.json"); + expect(userCall?.[1]).toMatchObject({ createOnly: true }); + expect(userCall?.[1]?.expectedDigest).toBeUndefined(); + expect(metaCall?.[1]).toMatchObject({ createOnly: true }); + expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); + }); + + 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("单文件同步遇到 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.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 + .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 + .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); + 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: "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", + }); + }); + + 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: { + 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 }); + const fs = createFs({ + create: createMock, + }); + 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([]), + } as any + ); + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 1234, + createtime: 1000, + status: 1, + sort: 0, + metadata: {}, + }; + + await service.pushScript(fs, script as any); + + expect(createMock.mock.calls[0]).toEqual(["push-uuid.user.js", { modifiedDate: 1234 }]); + expect(createMock.mock.calls[1]).toEqual(["push-uuid.meta.json", { modifiedDate: 1234 }]); + }); + + it("uses Date.now as modifiedDate when writing scriptcat-sync.json", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(9876); + const createMock = vi.fn().mockResolvedValue({ + write: vi.fn().mockResolvedValue(undefined), + }); + const fs = createFs({ + 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([]), + } as any + ); + + try { + await service.syncOnce(syncConfig, fs); + + expect(createMock).toHaveBeenCalledWith("scriptcat-sync.json", { + modifiedDate: 9876, + }); + } finally { + nowSpy.mockRestore(); + } + }); + + it("uses Date.now as modifiedDate when writing delete tombstone meta", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(6789); + const createMock = vi.fn().mockResolvedValue({ + write: vi.fn().mockResolvedValue(undefined), + }); + const fs = createFs({ + 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([]), + } as any + ); + + try { + await service.deleteCloudScript(fs, "delete-uuid", true); + + expect(createMock).toHaveBeenCalledWith("delete-uuid.meta.json", { + modifiedDate: 6789, + }); + } finally { + nowSpy.mockRestore(); + } + }); + + it("preserves cloud-native digest and does not overwrite with pushed md5", async () => { + // 各后端 digest 格式不一致(webdav/onedrive 是 etag、dropbox 是 content_hash 等), + // 上传后再次 list 已经能拿到原生 digest 时,必须保留它,不能被本地 md5 覆盖, + // 否则下次同步比对会因格式不一致而把未变动的脚本判定为已变动并触发不必要的拉取/推送 + const scriptCode = "// code"; + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const cloudListAfterPush = [ + { name: "push-uuid.user.js", digest: "etag-user-js", updatetime: 1 }, + { name: "push-uuid.meta.json", digest: "etag-meta-json", updatetime: 1 }, + ]; + const fs = createFs({ + list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(cloudListAfterPush), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: scriptCode }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "push-uuid.user.js": "etag-user-js", + "push-uuid.meta.json": "etag-meta-json", + }); + }); + + it("scriptInstall enters cloud_sync queue and updates digest after push", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const order: string[] = []; + + // 第一个占用队列的 syncOnce,gate 在 updateFileDigest 阶段 + const syncFs = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("sync:list"); + return []; + }) + .mockImplementationOnce(async () => { + order.push("sync:digest"); + await syncGate; + return []; + }), + }); + + const installFs = 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 + ); + + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(installFs); + vi.spyOn(service, "pushScript").mockImplementation(async () => { + order.push("install:push"); + return {}; + }); + const realUpdateDigest = service.updateFileDigest.bind(service); + vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { + await realUpdateDigest(fs); + }); + vi.spyOn(service, "updateFileDigestForUuids").mockImplementation(async () => { + order.push("install:digest"); + }); + + const syncPromise = service.syncOnce(syncConfig, syncFs); + await flushMicrotasks(); + expect(order).toEqual(["sync:list", "sync:digest"]); + + await service.scriptInstall({ + script: { uuid: "u1", name: "t" } as any, + upsertBy: "user", + } as any); + await flushMicrotasks(); + + // syncOnce 还没释放,install 的 pushScript 不能跑 + expect(order).toEqual(["sync:list", "sync:digest"]); + + releaseSync(); + await syncPromise; + + // 在同一队列上排一个 barrier,barrier 完成意味着 install 任务也已完成 + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + 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" }), + expect.objectContaining({ errorKind: "transient" }) + ); + 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("scriptInstall 不应把他端已更新但本机未 pull 的文件标成已同步(避免漏 pull)", async () => { + const installFs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "install-uuid.user.js", + path: "install-uuid.user.js", + size: 1, + digest: "install-user-etag", + createtime: 1, + updatetime: 1, + }, + { + name: "install-uuid.meta.json", + path: "install-uuid.meta.json", + size: 1, + digest: "install-meta-etag", + createtime: 1, + updatetime: 1, + }, + // 他端已把 other-uuid 更新到 d2/m2,本机尚未 pull + { + name: "other-uuid.user.js", + path: "other-uuid.user.js", + size: 1, + digest: "other-user-d2", + createtime: 1, + updatetime: 2, + }, + { + name: "other-uuid.meta.json", + path: "other-uuid.meta.json", + size: 1, + digest: "other-meta-m2", + createtime: 1, + updatetime: 2, + }, + ]), + }); + 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 + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(installFs); + vi.spyOn(service, "pushScript").mockResolvedValue({ + "install-uuid.user.js": "install-user-md5", + "install-uuid.meta.json": "install-meta-md5", + }); + // 本机上次同步 other-uuid 记录的是旧 d1/m1 + await (service as any).storage.set("file_digest", { + "other-uuid.user.js": "other-user-d1", + "other-uuid.meta.json": "other-meta-m1", + }); + + await service.scriptInstall({ + script: { uuid: "install-uuid", name: "install" } as any, + upsertBy: "user", + } as any); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + const digest = (await (service as any).storage.get("file_digest")) as Record; + // 本次推送的文件应记录云端原生 digest + expect(digest["install-uuid.user.js"]).toBe("install-user-etag"); + expect(digest["install-uuid.meta.json"]).toBe("install-meta-etag"); + // 他端未 pull 的更新不能被标成已同步,否则下轮 syncOnce 会早退漏 pull + expect(digest["other-uuid.user.js"]).toBe("other-user-d1"); + expect(digest["other-uuid.meta.json"]).toBe("other-meta-m1"); + }); + + it("scriptsDelete enters cloud_sync queue and updates digest after deleting", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const order: string[] = []; + + const syncFs = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("sync:list"); + return []; + }) + .mockImplementationOnce(async () => { + order.push("sync:digest"); + await syncGate; + return []; + }), + }); + + const deleteFs = 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 + ); + + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(deleteFs); + vi.spyOn(service, "deleteCloudScript").mockImplementation(async (_fs: any, uuid: string) => { + order.push(`delete:${uuid}`); + }); + const realUpdateDigest = service.updateFileDigest.bind(service); + vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { + await realUpdateDigest(fs); + }); + vi.spyOn(service, "updateFileDigestForUuids").mockImplementation(async () => { + order.push("delete:digest"); + }); + + const syncPromise = service.syncOnce(syncConfig, syncFs); + await flushMicrotasks(); + expect(order).toEqual(["sync:list", "sync:digest"]); + + await service.scriptsDelete([ + { uuid: "from-user", deleteBy: "user" } as any, + { uuid: "from-sync", deleteBy: "sync" } as any, + ]); + await flushMicrotasks(); + + // syncOnce 还没释放,delete 任务一步都不能跑 + expect(order).toEqual(["sync:list", "sync:digest"]); + + releaseSync(); + await syncPromise; + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + // deleteBy === "sync" 的不应触发云端删除;并且 digest 必须在删除全部完成后才更新 + expect(order).toEqual(["sync:list", "sync:digest", "delete:from-user", "delete:digest"]); + }); + + it("scriptsDelete skips enqueue when all entries are deleteBy=sync", async () => { + 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([ - { - uuid: "del-uuid", - name: "del", - updatetime: 1, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }, - ]), + all: vi.fn().mockResolvedValue([]), } as any ); - const promise = service.syncOnce(syncConfig, fs); + const buildSpy = vi.spyOn(service as any, "buildFileSystem"); + const getCloudSyncSpy = (service as any).systemConfig.getCloudSync as ReturnType; + + await service.scriptsDelete([{ uuid: "a", deleteBy: "sync" } as any, { uuid: "b", deleteBy: "sync" } as any]); await flushMicrotasks(); - // delete 已经开始但没结束,updateFileDigest 还没被调用 - expect(order).toEqual(["delete:start"]); + // 全部来源是 sync(来自 syncOnce 内部的 deleteScript 回灌),应直接 return + // 不应去读取云同步配置,也不应建立任何文件系统连接 + expect(getCloudSyncSpy).not.toHaveBeenCalled(); + expect(buildSpy).not.toHaveBeenCalled(); + }); - releaseDelete(); - await promise; + it("cloudSyncConfigChange swallows buildFileSystem error", async () => { + 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 + ); - // delete 必须在 updateFileDigest 之前完成 - expect(order).toEqual(["delete:start", "delete:end", "digest:list"]); + const buildErr = new Error("build fs failed"); + vi.spyOn(service as any, "buildFileSystem").mockRejectedValue(buildErr); + const errorSpy = vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + + try { + service.cloudSyncConfigChange({ ...syncConfig, enable: true }); + await flushMicrotasks(); + + expect(errorSpy).toHaveBeenCalledWith("cloud sync config change error", expect.anything()); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + } }); - it("waits for pushScript before updating file digest", async () => { - let releasePush!: () => void; - const pushGate = new Promise((resolve) => { - releasePush = resolve; - }); - const order: string[] = []; - // pushScript 内部第一步是 fs.create(uuid.user.js),gate 在这里就能拦住整个 push - const fsCreate = vi.fn().mockImplementation(async (filename: string) => { - if (filename === "push-uuid.user.js") { - order.push("push:start"); - await pushGate; - order.push("push:end"); - } - return { write: vi.fn().mockResolvedValue(undefined) }; - }); - const fsList = vi - .fn() - .mockImplementationOnce(async () => []) - .mockImplementationOnce(async () => { - order.push("digest:list"); - return []; - }); + it("scriptInstall 对已同步过的脚本改用 CAS 而非 create-only 上传", async () => { + // 编辑一个已经同步到云端的脚本会以 upsertBy=user 再次触发 installScript。 + // 云端文件已存在,此时若仍用 create-only 上传必然 412 冲突,本地编辑永远上不了云。 + // 正确行为:凭本地已存的云端 digest 做 CAS 覆盖(expectedDigest),不带 createOnly。 + const createCalls: Array<{ name: string; opts: any }> = []; const fs = createFs({ - list: fsList, - create: fsCreate, - }); + capabilities: { supportsCreateOnly: true, supportsAtomicCompareAndSwap: true }, + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockImplementation(async (name: string, opts: any) => { + createCalls.push({ name, opts }); + return { write: vi.fn().mockResolvedValue(undefined) }; + }), + } as unknown as Partial); const service = new SynchronizeService( {} as any, {} as any, @@ -497,56 +2996,49 @@ console.log("ok");` {} as any, {} as any, {} as any, - {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, { scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }), }, - all: vi.fn().mockResolvedValue([ - { - uuid: "push-uuid", - name: "push", - updatetime: 1, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }, - ]), + all: vi.fn().mockResolvedValue([]), } as any ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + // 该脚本此前已同步过,本地记录了它的云端 digest + await (service as any).storage.set("file_digest", { + "u1.user.js": "etag-user", + "u1.meta.json": "etag-meta", + }); - const promise = service.syncOnce(syncConfig, fs); - await flushMicrotasks(); - - // push 已经开始但没结束,updateFileDigest 还没被调用 - expect(order).toEqual(["push:start"]); - - releasePush(); - await promise; + await service.scriptInstall({ + script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "" } as any, + upsertBy: "user", + } as any); + // 在同一队列排一个 barrier,barrier 完成意味着 install 任务已完成 + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); - // push 必须在 updateFileDigest 之前完成 - expect(order).toContain("push:end"); - expect(order).toContain("digest:list"); - expect(order.indexOf("push:end")).toBeLessThan(order.indexOf("digest:list")); + const userJs = createCalls.find((c) => c.name === "u1.user.js"); + const metaJson = createCalls.find((c) => c.name === "u1.meta.json"); + expect(userJs?.opts?.createOnly).toBeUndefined(); + expect(userJs?.opts?.expectedDigest).toBe("etag-user"); + expect(metaJson?.opts?.createOnly).toBeUndefined(); + expect(metaJson?.opts?.expectedDigest).toBe("etag-meta"); }); - it("keeps pushed script digest when cloud list is stale after upload", async () => { - const scriptCode = "// code"; - const script = { - uuid: "push-uuid", - name: "push", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 1, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; + it("本地编辑后即便云端 digest 未变也应推送而非因 digest 相等被跳过", async () => { + // 云端 digest 只反映云端侧变化,检测不到本地编辑。若脚本本地更新时间已比云端新, + // 即便 digest 相等也必须上传,否则本地改动永远上不了云(#1)。 const fs = createFs({ - list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([]), + list: vi + .fn() + .mockResolvedValueOnce([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu1", createtime: 1, updatetime: 5 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm1", createtime: 1, updatetime: 5 }, + ]) + .mockResolvedValueOnce([]), }); const service = new SynchronizeService( {} as any, @@ -557,33 +3049,32 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: { - get: vi.fn().mockResolvedValue({ code: scriptCode }), - }, - all: vi.fn().mockResolvedValue([script]), + scriptCodeDAO: {}, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 20, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - const metaJson = JSON.stringify({ - uuid: script.uuid, - origin: script.origin, - downloadUrl: script.downloadUrl, - checkUpdateUrl: script.checkUpdateUrl, - }); - await expect((service as any).storage.get("file_digest")).resolves.toEqual({ - "push-uuid.user.js": md5OfText(scriptCode), - "push-uuid.meta.json": md5OfText(metaJson), - }); + expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" }), expect.anything()); }); - it("passes script modifiedDate when pushing script and meta files", async () => { - const writeMock = vi.fn().mockResolvedValue(undefined); - const createMock = vi.fn().mockResolvedValue({ write: writeMock }); + it("pushScript 在 .meta.json 写入失败时带出已成功写入的 .user.js", async () => { + // 分两次写 .user.js / .meta.json,前者成功后者失败时要让调用方知道 .user.js 已写成功, + // 才能只保留失败文件的旧 digest、推进成功文件的 digest,避免永久 CAS 冲突(#3)。 const fs = createFs({ - create: createMock, - }); + create: vi.fn().mockImplementation(async (name: string) => ({ + write: vi.fn().mockImplementation(async () => { + if (name === "u1.meta.json") throw new Error("meta write failed"); + }), + })), + } as unknown as Partial); const service = new SynchronizeService( {} as any, {} as any, @@ -593,39 +3084,230 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: { - get: vi.fn().mockResolvedValue({ code: "// code" }), - }, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, } as any ); const script = { - uuid: "push-uuid", - name: "push", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 1234, - createtime: 1000, + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 5, + createtime: 1, status: 1, sort: 0, metadata: {}, }; - await service.pushScript(fs, script as any); + await expect(service.pushScript(fs, script as any)).rejects.toMatchObject({ + writtenFiles: ["u1.user.js"], + }); + }); - expect(createMock.mock.calls[0]).toEqual(["push-uuid.user.js", { modifiedDate: 1234 }]); - expect(createMock.mock.calls[1]).toEqual(["push-uuid.meta.json", { modifiedDate: 1234 }]); + it("新脚本 .meta.json 失败:应推进已成功的 .user.js digest,并在下一轮补传 .meta.json", async () => { + let failMeta = true; + let seq = 0; + const cloud = new Map(); + const cloudFs = createFs({ + capabilities: { supportsCreateOnly: true }, + list: vi.fn(async () => + Array.from(cloud, ([name, f]) => ({ + name, + path: name, + size: 1, + digest: f.digest, + createtime: 1, + updatetime: f.updatetime, + })) + ), + create: vi.fn(async (name: string, opts: any) => ({ + write: vi.fn(async (content: string) => { + const existing = cloud.get(name); + if (opts?.createOnly && existing) { + throw new FileSystemError({ provider: "webdav", message: "createOnly", status: 412, conflict: true }); + } + if (failMeta && name === "u1.meta.json") { + throw new FileSystemError({ provider: "webdav", message: "meta fail", status: 500, retryable: true }); + } + cloud.set(name, { + digest: `d${++seq}`, + content, + updatetime: opts?.modifiedDate ?? existing?.updatetime ?? 0, + }); + }), + })), + } as unknown as Partial); + 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([ + { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 5, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + + // 第一轮:.user.js 成功、.meta.json 失败 + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(cloud.has("u1.user.js")).toBe(true); + expect(cloud.has("u1.meta.json")).toBe(false); + const dig1 = (await (service as any).storage.get("file_digest")) as Record; + expect(dig1["u1.user.js"]).toBeDefined(); // 推进已成功文件的 digest + expect(dig1["u1.meta.json"]).toBeUndefined(); // 失败文件不记录 + + // 第二轮:meta 故障恢复,应补传 .meta.json(不因 .user.js digest 相等被当作无变动跳过) + failMeta = false; + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(cloud.has("u1.meta.json")).toBe(true); }); - it("uses Date.now as modifiedDate when writing scriptcat-sync.json", async () => { - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(9876); - const createMock = vi.fn().mockResolvedValue({ - write: vi.fn().mockResolvedValue(undefined), - }); - const fs = createFs({ - create: createMock, - }); + it("编辑已同步脚本时 .meta.json 失败,下一轮同步自愈而不陷入永久 CAS 冲突", async () => { + // 端到端复现 #2+#3+#1:编辑已同步脚本 → scriptInstall 推送,.user.js 成功、.meta.json 失败。 + // 已成功的 .user.js digest 必须被推进,否则下一轮 syncOnce 拿过期 digest 做 CAS 永久 412。 + let failMeta = true; + const writes: string[] = []; + let seq = 0; + const cloud = new Map([ + ["u1.user.js", { digest: "cu1", content: "// v0", updatetime: 5 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5 }], + ]); + const conflict = () => + new FileSystemError({ provider: "webdav", message: "CAS conflict", status: 412, conflict: true }); + const cloudFs = createFs({ + capabilities: { supportsCreateOnly: true, supportsAtomicCompareAndSwap: true }, + list: vi.fn(async () => + Array.from(cloud, ([name, f]) => ({ + name, + path: name, + size: 1, + digest: f.digest, + createtime: 1, + updatetime: f.updatetime, + })) + ), + create: vi.fn(async (name: string, opts: any) => ({ + write: vi.fn(async (content: string) => { + const existing = cloud.get(name); + if (opts?.createOnly && existing) throw conflict(); + if (opts?.expectedDigest !== undefined && (!existing || existing.digest !== opts.expectedDigest)) { + throw conflict(); + } + if (failMeta && name === "u1.meta.json") throw conflict(); + cloud.set(name, { + digest: `d${++seq}`, + content, + updatetime: opts?.modifiedDate ?? existing?.updatetime ?? 0, + }); + writes.push(name); + }), + })), + } as unknown as Partial); + + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, syncStatus: false, enable: true }), + } as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v1" }) }, + all: vi.fn().mockResolvedValue([ + { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 20, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(cloudFs); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + + // 第一轮:编辑触发 scriptInstall,.user.js 成功、.meta.json 失败 + await service.scriptInstall({ + script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "", updatetime: 10 } as any, + upsertBy: "user", + } as any); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(writes).toContain("u1.user.js"); + expect(cloud.get("u1.meta.json")!.digest).toBe("cm1"); + + // 第二轮:meta 故障恢复,syncOnce 应自愈(成功文件 digest 已推进,CAS 不再永久冲突) + failMeta = false; + writes.length = 0; + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + + expect(writes).toContain("u1.user.js"); + expect(writes).toContain("u1.meta.json"); + }); + + it("本地编辑经 syncOnce 真实上云,且推进 digest 后下一轮不再重复推送", async () => { + // 端到端验证 #1:脚本已同步(云端 digest 与本地记录一致),随后本地编辑(updatetime 变新、 + // 但云端文件未变故 digest 仍相等)。syncOnce 必须真正把新内容 CAS 覆盖上云; + // 且推进 digest/更新时间后,下一轮应回到稳态不再重复推送。 + let seq = 0; + const cloud = new Map([ + ["u1.user.js", { digest: "cu1", content: "// v1", updatetime: 5 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5 }], + ]); + const writes: string[] = []; + const cloudFs = createFs({ + capabilities: { supportsAtomicCompareAndSwap: true }, + list: vi.fn(async () => + Array.from(cloud, ([name, f]) => ({ + name, + path: name, + size: 1, + digest: f.digest, + createtime: 1, + updatetime: f.updatetime, + })) + ), + create: vi.fn(async (name: string, opts: any) => ({ + write: vi.fn(async (content: string) => { + const existing = cloud.get(name); + if (opts?.expectedDigest !== undefined && (!existing || existing.digest !== opts.expectedDigest)) { + throw new FileSystemError({ provider: "webdav", message: "CAS conflict", status: 412, conflict: true }); + } + cloud.set(name, { + digest: `d${++seq}`, + content, + updatetime: opts?.modifiedDate ?? existing?.updatetime ?? 0, + }); + writes.push(name); + }), + })), + } as unknown as Partial); const service = new SynchronizeService( {} as any, {} as any, @@ -635,29 +3317,44 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v2" }) }, + all: vi.fn().mockResolvedValue([ + { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 20, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); - try { - await service.syncOnce(syncConfig, fs); + // 本地编辑(updatetime 20 > 云端 5,digest 仍相等)→ 应真正把 // v2 覆盖上云 + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(cloud.get("u1.user.js")!.content).toBe("// v2"); - expect(createMock).toHaveBeenCalledWith("scriptcat-sync.json", { - modifiedDate: 9876, - }); - } finally { - nowSpy.mockRestore(); - } + // 下一轮:digest 与更新时间已推进,稳态不再重复推送 + writes.length = 0; + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(writes).toEqual([]); }); - it("uses Date.now as modifiedDate when writing delete tombstone meta", async () => { - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(6789); - const createMock = vi.fn().mockResolvedValue({ - write: vi.fn().mockResolvedValue(undefined), - }); + it("云端已变而本地内容未变时,即使本地时间戳更大也应 pull 而非 push(同秒竞态 L4)", async () => { + // 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端整秒时钟,两者不可比: + // 对端更新落在同一秒时,毫秒余数会让本地"看起来更新"而误走 push。 + // 方向判定应基于内容基线:云端 digest 已变、本地内容自上次同步未变 → pull。 const fs = createFs({ - create: createMock, + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), }); const service = new SynchronizeService( {} as any, @@ -668,46 +3365,36 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// code") }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - try { - await service.deleteCloudScript(fs, "delete-uuid", true); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - expect(createMock).toHaveBeenCalledWith("delete-uuid.meta.json", { - modifiedDate: 6789, - }); - } finally { - nowSpy.mockRestore(); - } + expect(pullSpy).toHaveBeenCalledWith(fs, expect.anything(), undefined, expect.objectContaining({ uuid: "u1" })); + expect(pushSpy).not.toHaveBeenCalled(); }); - it("preserves cloud-native digest and does not overwrite with pushed md5", async () => { - // 各后端 digest 格式不一致(webdav/onedrive 是 etag、dropbox 是 content_hash 等), - // 上传后再次 list 已经能拿到原生 digest 时,必须保留它,不能被本地 md5 覆盖, - // 否则下次同步比对会因格式不一致而把未变动的脚本判定为已变动并触发不必要的拉取/推送 - const scriptCode = "// code"; - const script = { - uuid: "push-uuid", - name: "push", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 1, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const cloudListAfterPush = [ - { name: "push-uuid.user.js", digest: "etag-user-js", updatetime: 1 }, - { name: "push-uuid.meta.json", digest: "etag-meta-json", updatetime: 1 }, - ]; + it("本地与云端都已修改时不自动覆盖任何一端:保留旧 digest 并通知一次", async () => { const fs = createFs({ - list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(cloudListAfterPush), + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// cloud edit"), + })), }); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); const service = new SynchronizeService( {} as any, {} as any, @@ -717,44 +3404,42 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: { - get: vi.fn().mockResolvedValue({ code: scriptCode }), - }, - all: vi.fn().mockResolvedValue([script]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// local edit" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// base") }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - await expect((service as any).storage.get("file_digest")).resolves.toEqual({ - "push-uuid.user.js": "etag-user-js", - "push-uuid.meta.json": "etag-meta-json", + expect(pushSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(notificationSpy).toHaveBeenCalledTimes(1); + // 冲突文件保留旧 digest,下一轮仍能识别云端变化 + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "u1.user.js": "cu1", + "u1.meta.json": "cm1", }); }); - it("scriptInstall enters cloud_sync queue and updates digest after push", async () => { - let releaseSync!: () => void; - const syncGate = new Promise((resolve) => { - releaseSync = resolve; - }); - const order: string[] = []; - - // 第一个占用队列的 syncOnce,gate 在 updateFileDigest 阶段 - const syncFs = createFs({ - list: vi - .fn() - .mockImplementationOnce(async () => { - order.push("sync:list"); - return []; - }) - .mockImplementationOnce(async () => { - order.push("sync:digest"); - await syncGate; - return []; - }), + it("同一批冲突脚本多轮同步只通知一次,冲突消失后重置", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// cloud edit"), + })), }); - - const installFs = createFs(); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); const service = new SynchronizeService( {} as any, {} as any, @@ -762,73 +3447,40 @@ console.log("ok");` {} as any, {} as any, {} as any, + {} as any, { - getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), - } as any, - { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// local edit" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// base") }); + vi.spyOn(service, "pushScript").mockResolvedValue({}); + vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(installFs); - vi.spyOn(service, "pushScript").mockImplementation(async () => { - order.push("install:push"); - return {}; - }); - const realUpdateDigest = service.updateFileDigest.bind(service); - vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { - if (fs === installFs) { - order.push("install:digest"); - return; - } - await realUpdateDigest(fs); - }); - - const syncPromise = service.syncOnce(syncConfig, syncFs); - await flushMicrotasks(); - expect(order).toEqual(["sync:list", "sync:digest"]); - - await service.scriptInstall({ - script: { uuid: "u1", name: "t" } as any, - upsertBy: "user", - } as any); - await flushMicrotasks(); - - // syncOnce 还没释放,install 的 pushScript 不能跑 - expect(order).toEqual(["sync:list", "sync:digest"]); - - releaseSync(); - await syncPromise; - - // 在同一队列上排一个 barrier,barrier 完成意味着 install 任务也已完成 - await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - expect(order).toEqual(["sync:list", "sync:digest", "install:push", "install:digest"]); + expect(notificationSpy).toHaveBeenCalledTimes(1); }); - it("scriptsDelete enters cloud_sync queue and updates digest after deleting", async () => { - let releaseSync!: () => void; - const syncGate = new Promise((resolve) => { - releaseSync = resolve; - }); - const order: string[] = []; - - const syncFs = createFs({ - list: vi - .fn() - .mockImplementationOnce(async () => { - order.push("sync:list"); - return []; - }) - .mockImplementationOnce(async () => { - order.push("sync:digest"); - await syncGate; - return []; - }), + it("云端已变但内容与本地一致时直接收敛基线,不 push 不 pull 不通知", async () => { + // 两台设备做了同样的编辑(或本机记账失败后云端内容实为本机所写): + // 内容一致只是基线过期,采用云端 digest 收敛即可,不算冲突 + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// same edit"), + })), }); - - const deleteFs = createFs(); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); const service = new SynchronizeService( {} as any, {} as any, @@ -836,50 +3488,43 @@ console.log("ok");` {} as any, {} as any, {} as any, + {} as any, { - getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), - } as any, - { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// same edit" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// base") }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(deleteFs); - vi.spyOn(service, "deleteCloudScript").mockImplementation(async (_fs: any, uuid: string) => { - order.push(`delete:${uuid}`); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(notificationSpy).not.toHaveBeenCalled(); + // digest 与内容基线一并推进,下一轮回到稳态 + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "u1.user.js": "cu2", + "u1.meta.json": "cm2", }); - const realUpdateDigest = service.updateFileDigest.bind(service); - vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { - if (fs === deleteFs) { - order.push("delete:digest"); - return; - } - await realUpdateDigest(fs); + await expect((service as any).storage.get("sync_content_md5")).resolves.toMatchObject({ + "u1.user.js": md5OfText("// same edit"), }); - - const syncPromise = service.syncOnce(syncConfig, syncFs); - await flushMicrotasks(); - expect(order).toEqual(["sync:list", "sync:digest"]); - - await service.scriptsDelete([ - { uuid: "from-user", deleteBy: "user" } as any, - { uuid: "from-sync", deleteBy: "sync" } as any, - ]); - await flushMicrotasks(); - - // syncOnce 还没释放,delete 任务一步都不能跑 - expect(order).toEqual(["sync:list", "sync:digest"]); - - releaseSync(); - await syncPromise; - await stackAsyncTask("cloud_sync_queue", async () => "barrier"); - - // deleteBy === "sync" 的不应触发云端删除;并且 digest 必须在删除全部完成后才更新 - expect(order).toEqual(["sync:list", "sync:digest", "delete:from-user", "delete:digest"]); }); - it("scriptsDelete skips enqueue when all entries are deleteBy=sync", async () => { + it("无本地内容基线时退回时间比较决定方向(升级兼容)", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5 }, + ]), + }); const service = new SynchronizeService( {} as any, {} as any, @@ -887,56 +3532,77 @@ console.log("ok");` {} as any, {} as any, {} as any, + {} as any, { - getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), - } as any, - { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 20, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - const buildSpy = vi.spyOn(service as any, "buildFileSystem"); - const getCloudSyncSpy = (service as any).systemConfig.getCloudSync as ReturnType; - - await service.scriptsDelete([{ uuid: "a", deleteBy: "sync" } as any, { uuid: "b", deleteBy: "sync" } as any]); - await flushMicrotasks(); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - // 全部来源是 sync(来自 syncOnce 内部的 deleteScript 回灌),应直接 return - // 不应去读取云同步配置,也不应建立任何文件系统连接 - expect(getCloudSyncSpy).not.toHaveBeenCalled(); - expect(buildSpy).not.toHaveBeenCalled(); + expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" }), expect.anything()); + expect(pullSpy).not.toHaveBeenCalled(); }); - it("cloudSyncConfigChange swallows buildFileSystem error", async () => { + it("pull 成功后记录内容基线,供下轮云端再变时判定本地是否也改过", async () => { + const installScript = vi.fn().mockResolvedValue(undefined); + const pulledCode = `// ==UserScript== +// @name Baseline Test +// @namespace sync-test +// @match https://example.com/* +// ==/UserScript== +console.log("ok");`; + const pulledMeta = JSON.stringify({ uuid: "baseline-uuid" }); + const fs = createFs({ + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue(file.name.endsWith(".user.js") ? pulledCode : pulledMeta), + })), + }); const service = new SynchronizeService( {} as any, {} as any, + { installScript } as any, {} as any, {} as any, {} as any, {} as any, - {} as any, - { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), - } as any + { scriptCodeDAO: {} } as any ); - const buildErr = new Error("build fs failed"); - vi.spyOn(service as any, "buildFileSystem").mockRejectedValue(buildErr); - const errorSpy = vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); - const unhandled = vi.fn(); - process.on("unhandledRejection", unhandled); - - try { - service.cloudSyncConfigChange({ ...syncConfig, enable: true }); - await flushMicrotasks(); + await service.pullScript( + fs, + { + script: { + name: "baseline-uuid.user.js", + path: "baseline-uuid.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 12345, + }, + meta: { + name: "baseline-uuid.meta.json", + path: "baseline-uuid.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + }, + undefined + ); - expect(errorSpy).toHaveBeenCalledWith("cloud sync config change error", expect.anything()); - expect(unhandled).not.toHaveBeenCalled(); - } finally { - process.off("unhandledRejection", unhandled); - } + await expect((service as any).storage.get("sync_content_md5")).resolves.toMatchObject({ + "baseline-uuid.user.js": md5OfText(pulledCode), + "baseline-uuid.meta.json": md5OfText(pulledMeta), + }); }); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index d53c4be53..4e94e6b14 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -10,11 +10,12 @@ 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"; +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"; @@ -82,6 +83,49 @@ type FileDigestMap = { [key: string]: string; }; +type PushScriptOptions = { + fileDigestMap?: FileDigestMap; + scriptFile?: FileInfo; + metaFile?: FileInfo; + // syncOnce 场景为 true:scriptFile/metaFile 反映本轮 fs.list() 快照,undefined 表示已确认云端不存在 + hasListSnapshot?: boolean; + // 自我 412 收敛的重推标记,避免二次冲突时无限重试 + selfHealRetried?: boolean; +}; + +type SyncTask = { + uuid: string; + promise: Promise; + preserveDigestFiles: string[]; +}; + +type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; + +// pushScript 分两次写 .user.js / .meta.json,前者成功后者失败时抛出本错误, +// 带出已成功写入的文件名,让调用方只保留真正失败文件的旧 digest、推进成功文件的 digest, +// 避免成功文件的 digest 永久变旧后与云端 CAS 永远不匹配(永久 412)。 +class PushScriptPartialError extends Error { + constructor( + readonly originalError: unknown, + readonly writtenFiles: string[] + ) { + super(originalError instanceof Error ? originalError.message : String(originalError)); + this.name = "PushScriptPartialError"; + } +} + +// 本地与云端在上次同步后都发生了修改(真冲突):不自动覆盖任何一端, +// 本轮跳过该脚本(沿用失败路径保留旧 digest 与云端状态),并聚合通知用户手动处理 +class SyncBothChangedConflictError extends Error { + constructor( + readonly uuid: string, + readonly scriptName: string + ) { + super(`sync conflict: both local and cloud changed for ${uuid}`); + this.name = "SyncBothChangedConflictError"; + } +} + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; function getScriptModifiedDate(script: PushScriptParam): number { @@ -95,6 +139,9 @@ export class SynchronizeService { storage: ChromeStorage = new ChromeStorage("sync", false); + // 上一轮已通知过的冲突脚本集合(uuid 排序拼接),冲突集合未变化时不重复通知 + private lastNotifiedConflictKey = ""; + constructor( private msgSender: MessageSend, private group: Group, @@ -417,6 +464,8 @@ export class SynchronizeService { const uuidMap = new Map>(); // 储存文件摘要,用于检测文件是否有变化 const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; + // 上次同步成功时的本地内容基线(md5),用于云端已变时判断本地是否也变过(方向判定不依赖跨时钟时间比较) + const syncedContentMd5Map = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; for (const file of list) { if (file.name.endsWith(".user.js")) { @@ -455,27 +504,44 @@ 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 ScriptcatSync; - 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)); + } } // 对比脚本列表和文件列表,进行同步 - const result: Promise[] = []; + const result: SyncTask[] = []; const updateScript: Map = new Map(); // 记录被跳过的孤儿云端脚本(仅 .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[]) => { + result.push({ + uuid, + 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!); @@ -484,36 +550,82 @@ export class SynchronizeService { if (metaObj.isDeleted) { // 删除脚本 await this.script.deleteScript(script.uuid, "sync"); - InfoNotification( - i18n.t("settings:notification.script_sync_delete"), - i18n.t("settings:notification.script_sync_delete_desc", { - scriptName: i18nName(script), - }) - ); + if (!hasNotifiedSyncDelete) { + hasNotifiedSyncDelete = true; + InfoNotification( + i18n.t("settings:notification.script_sync_delete"), + i18n.t("settings:notification.script_sync_delete_desc", { + scriptName: i18nName(script), + }) + ); + } } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); - return await this.pushScript(fs, script); + return await this.pushScript(fs, script, { fileDigestMap, hasListSnapshot: true }); } - })() + })(), + [file.meta!.name, `${uuid}.user.js`] ); return; } - // 过滤掉无变动的文件 - if (fileDigestMap[file.script!.name] === file.script!.digest) { + const updatetime = script.updatetime || script.createtime; + // 云端缺 .meta.json(上一轮分片上传残留):无论方向判定如何都需补传修复 + if (!file.meta) { + addSyncTask( + uuid, + this.pushScript(fs, script, { + fileDigestMap, + scriptFile: file.script, + metaFile: file.meta, + hasListSnapshot: true, + }) + ); return; } - const updatetime = script.updatetime || script.createtime; - // 对比脚本更新时间和文件更新时间 - if (updatetime > file.script!.updatetime || !file.meta) { - // 如果脚本更新时间大于文件更新时间 - // 或者不存在.meta文件,则上传文件 - result.push(this.pushScript(fs, script)); - } else { - // 如果脚本更新时间小于文件更新时间,则更新脚本 - updateScript.set(uuid, true); - result.push(this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script)); + if (fileDigestMap[file.script!.name] === file.script!.digest) { + // 云端自上次同步未变:本地更新时间不比云端新则无事可做; + // 否则本地编辑过(digest 相等只反映云端未变),需补偿上传(#1,队列 push 失败后的兜底) + if (updatetime <= file.script!.updatetime) { + return; + } + addSyncTask( + uuid, + this.pushScript(fs, script, { + fileDigestMap, + scriptFile: file.script, + metaFile: file.meta, + hasListSnapshot: true, + }) + ); + return; } + // 云端自上次同步已变(或本机无记录)。本地毫秒时钟与服务端整秒 mtime 属于两个时钟域, + // 对端更新落在同一秒内时"本地时间戳更大"是误报(L4 同秒竞态), + // 方向判定优先用本地内容基线:本地内容自上次同步未变 → pull;双方都变 → 冲突,不自动覆盖任何一端 + addSyncTask( + uuid, + (async () => { + const direction = await this.decideDirectionOnRemoteChange(fs, file.script!, script, syncedContentMd5Map); + if (direction.action === "pull") { + updateScript.set(uuid, true); + return await this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script); + } + if (direction.action === "push") { + return await this.pushScript(fs, script, { + fileDigestMap, + scriptFile: file.script, + metaFile: file.meta, + hasListSnapshot: true, + }); + } + if (direction.action === "adopt") { + // 两端内容一致只是基线过期:返回内容 md5 让 updateFileDigest 推进基线,不产生写操作 + return direction.digestMap; + } + throw new SyncBothChangedConflictError(uuid, i18nName(script)); + })() + ); return; } // 如果脚本不存在,但文件存在,则安装脚本 @@ -528,121 +640,302 @@ 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, { fileDigestMap, hasListSnapshot: true })); }); // 忽略错误 - 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(); + const failedSyncUuids = new Set(); + const conflictScripts: SyncBothChangedConflictError[] = []; + 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); + if (ret.reason instanceof SyncBothChangedConflictError) { + conflictScripts.push(ret.reason); + } + // 分片上传时已成功写入云端的文件(如 .user.js 成功、.meta.json 失败)不保留旧 digest, + // 让 updateFileDigest 记录其云端最新 digest,避免成功文件 digest 永久变旧、后续 CAS 永久冲突。 + const writtenFiles = ret.reason instanceof PushScriptPartialError ? ret.reason.writtenFiles : []; + result[index].preserveDigestFiles.forEach((name) => { + if (!writtenFiles.includes(name)) { + preserveDigestFiles.add(name); + } + }); + this.logger.warn("sync task failed", Logger.E(ret.reason), { + errorKind: this.classifySyncError(ret.reason), + files: result[index].preserveDigestFiles, + }); } }); + // 冲突通知:一轮只发一条;同一批脚本持续冲突时不重复轰炸,集合变化或冲突消失后重置 + const conflictKey = conflictScripts + .map((c) => c.uuid) + .sort() + .join(","); + if (conflictScripts.length && conflictKey !== this.lastNotifiedConflictKey) { + InfoNotification( + i18n.t("settings:notification.script_sync_conflict"), + i18n.t("settings:notification.script_sync_conflict_desc", { + scriptNames: conflictScripts.map((c) => c.scriptName).join(", "), + }) + ); + } + this.lastNotifiedConflictKey = conflictKey; // 同步状态 - if (syncConfig.syncStatus) { - 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; + 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; } - // 判断时间 - // 如果云端状态的更新时间小于本地状态的更新时间,则更新云端状态 - if (status.updatetime < updatetime) { + // 判断云端状态是否与本地状态一致 + 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 + ); } - }); - // 保存脚本猫同步状态 - 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"); + // 保存脚本猫同步状态 + 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)); + } + } else if (syncConfig.syncStatus && !canWriteScriptcatSync) { + this.logger.warn("skip scriptcat-sync.json write because cloud status could not be read"); } // 重新获取文件列表,保存文件摘要 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 = {}) { + private classifySyncError(error: unknown): SyncErrorKind { + if (error instanceof PushScriptPartialError) { + return this.classifySyncError(error.originalError); + } + if (error instanceof SyncBothChangedConflictError) { + return "conflict"; + } + 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 || {}; + } + + 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) { + // Defer to remote: if another device deleted this uuid, respect the deletion + if (latest !== undefined) { + merged[uuid] = latest; + } + continue; + } + if (!latest || candidate.updatetime >= latest.updatetime) { + merged[uuid] = candidate; + } + } + return merged; + } + + async updateFileDigest( + fs: FileSystem, + knownFileDigestMap: FileDigestMap = {}, + preserveFileNames = new Set() + ) { + // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 + await this.recordSyncedContentMd5(knownFileDigestMap); + 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); + // syncOnce 已全量对账整份云端列表,可安全清理 file_digest 之外的 sync_content_md5,避免只增不删 + await this.pruneSyncedContentMd5((name) => !(name in newFileDigestMap)); + return; + } + + // 队列路径(scriptInstall/scriptsDelete)只推送/删除了指定 uuid 的文件,只能更新这些文件的 digest 记录。 + // 不能像 syncOnce 那样全量盖章:syncOnce 已逐文件对账整份云端列表,而队列路径没有——全量盖章会把 + // 他端已更新但本机尚未 pull 的文件也标成已同步,导致下轮 syncOnce 早退漏 pull。 + async updateFileDigestForUuids( + fs: FileSystem, + uuids: string[], + knownFileDigestMap: FileDigestMap = {}, + preserveFileNames = new Set() + ) { + // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 + await this.recordSyncedContentMd5(knownFileDigestMap); + const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; + const targetFiles = new Set(uuids.flatMap((uuid) => [`${uuid}.user.js`, `${uuid}.meta.json`])); + const newList = await fs.list(); + const listedDigest = new Map(newList.map((file) => [file.name, file.digest])); + // 本次确认已从云端删除的目标文件,其 sync_content_md5 一并清理(仅限本次目标,队列路径未全量对账不能全局清理) + const deletedTargetFiles = new Set(); + for (const name of targetFiles) { + if (preserveFileNames.has(name)) { + // 失败文件保留旧 digest,下轮重试 + continue; + } + if (listedDigest.has(name)) { + // 云端仍在:记录原生 digest(各后端 etag/content_hash/md5 格式不一,须用 list 的原生值而非本地 md5) + fileDigestMap[name] = listedDigest.get(name)!; + } else if (name in knownFileDigestMap) { + // 云端列表暂时漏掉刚上传的文件(最终一致性延迟):用本地 md5 兜底 + fileDigestMap[name] = knownFileDigestMap[name]; + } else { + // 云端已不存在(删除):移除记录 + delete fileDigestMap[name]; + deletedTargetFiles.add(name); + } + } + await this.storage.set("file_digest", fileDigestMap); + await this.pruneSyncedContentMd5((name) => deletedTargetFiles.has(name)); return; } // 删除云端脚本数据 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, @@ -654,17 +947,38 @@ export class SynchronizeService { ); } else { // 直接删除所有相关文件 - await fs.delete(`${uuid}.meta.json`); + await this.deleteCloudFile(fs, metaFilename, fileDigestMap); } logger.info("delete success"); } catch (e) { logger.error("delete file error", Logger.E(e)); + throw e; } 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): 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({ @@ -672,14 +986,23 @@ export class SynchronizeService { name: script.name, file: filename, }); + // 记录本次已成功写入云端的文件,供部分失败时区分「成功文件」与「失败文件」 + const writtenFiles: string[] = []; 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) + ); // 获取脚本代码 const code = await this.scriptCodeDAO.get(script.uuid); const scriptCode = code!.code; await w.write(scriptCode); - const meta = await fs.create(metaFilename, { modifiedDate }); + writtenFiles.push(filename); + const meta = await fs.create( + metaFilename, + this.buildPushCreateOptions(fs, metaFilename, modifiedDate, opts.metaFile, opts) + ); const metaJson = JSON.stringify({ uuid: script.uuid, origin: script.origin, @@ -687,17 +1010,166 @@ export class SynchronizeService { checkUpdateUrl: script.checkUpdateUrl, }); await meta.write(metaJson); + writtenFiles.push(metaFilename); logger.info("push script success"); return { [filename]: md5OfText(scriptCode), [metaFilename]: md5OfText(metaJson), }; } catch (e) { + // 自我 412 收敛:本地记录的云端 digest 记账失败(上次 fs.list 抛错 / SW 被杀)后再次编辑, + // 会拿过期 digest 对「自己上次写入的云端内容」做 If-Match,陷入永久 412。识别到冲突文件的 + // 云端内容正是本机上次成功写入时,以云端当前 digest 为新基准重推一次收敛;真·他端并发编辑 + // (内容不符)则维持停在冲突,绝不覆盖他端。 + if (!opts.selfHealRetried && !writtenFiles.includes(filename) && this.classifySyncError(e) === "conflict") { + const rebased = await this.rebaseSelfWrittenConflict(fs, script, opts.fileDigestMap); + if (rebased) { + logger.warn("self-heal 412 conflict by rebasing on own last push"); + return await this.pushScript(fs, script, { + fileDigestMap: rebased, + hasListSnapshot: false, + selfHealRetried: true, + }); + } + } logger.error("push script error", Logger.E(e)); - throw e; + throw new PushScriptPartialError(e, writtenFiles); + } + } + + // CAS 冲突时判断冲突文件的云端内容是否为本机上次成功写入: + // 是 → 返回以云端当前原生 digest 为新基准的 fileDigestMap(供重推);否 → 返回 null(真·他端并发编辑)。 + private async rebaseSelfWrittenConflict( + fs: FileSystem, + script: PushScriptParam, + fileDigestMap: FileDigestMap = {} + ): Promise { + const filename = `${script.uuid}.user.js`; + const metaFilename = `${script.uuid}.meta.json`; + try { + const syncedContentMd5 = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; + const recordedMd5 = syncedContentMd5[filename]; + if (!recordedMd5) { + return null; + } + const list = await fs.list(); + const cloudFiles = new Map(list.map((file) => [file.name, file])); + const cloudScript = cloudFiles.get(filename); + if (!cloudScript) { + return null; + } + const cloudCode = (await fs.open(cloudScript).then((r) => r.read("string"))) as string; + if (md5OfText(cloudCode) !== recordedMd5) { + // 云端内容不是本机上次写入 → 真·他端并发编辑,维持停在冲突 + return null; + } + const rebased: FileDigestMap = { ...fileDigestMap }; + for (const name of [filename, metaFilename]) { + const info = cloudFiles.get(name); + if (info) { + rebased[name] = info.digest; + } + } + return rebased; + } catch (e) { + this.logger.warn("rebase self-written conflict failed", Logger.E(e), { uuid: script.uuid }); + return null; } } + // 落库本次成功推送/拉取内容的 md5(键为文件名):作为本地内容基线供方向判定(L4), + // 也供 CAS 冲突时识别云端是否为本机所写。 + // 在 updateFileDigest* 内部、fs.list 之前调用,确保即便随后 list 抛错也已持久化。 + private async recordSyncedContentMd5(syncedMd5Map: FileDigestMap) { + const names = Object.keys(syncedMd5Map); + if (!names.length) { + return; + } + const stored = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; + for (const name of names) { + stored[name] = syncedMd5Map[name]; + } + await this.storage.set("sync_content_md5", stored); + } + + // 随 file_digest 生命周期收敛 sync_content_md5,避免只增不删。shouldRemove 决定某文件名是否移除。 + private async pruneSyncedContentMd5(shouldRemove: (name: string) => boolean) { + const stored = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; + let changed = false; + for (const name of Object.keys(stored)) { + if (shouldRemove(name)) { + delete stored[name]; + changed = true; + } + } + if (changed) { + await this.storage.set("sync_content_md5", stored); + } + } + + private buildPushCreateOptions( + fs: FileSystem, + filename: string, + modifiedDate: number, + existingFile: FileInfo | undefined, + opts: PushScriptOptions + ): FileCreateOptions { + const capabilities = getFileSystemCapabilities(fs); + const createOptions: FileCreateOptions = { modifiedDate }; + // CAS 的 If-Match 基准取本地记录的云端 digest(脚本上次同步/安装成功时的版本), + // 这样其他设备改动过云端文件时能正确检测冲突而不误覆盖。 + // 但本轮 list 快照已确认云端不存在的文件(existingFile 为 undefined 且有快照)例外: + // 本地残留的 digest 必然过期,对不存在的文件做 If-Match 必然 412, + // 且失败保留旧 digest 后永不自愈,只能走 create-only。 + // 无快照的场景(scriptInstall 队列)才允许用本地记录 digest 做 CAS—— + // 已同步过的脚本编辑后再次上传必须 CAS 覆盖,否则 create-only 撞上已存在文件必然 412。 + const expectedDigest = + existingFile === undefined && opts.hasListSnapshot ? undefined : opts.fileDigestMap?.[filename]; + if (existingFile === undefined && expectedDigest === undefined) { + if (capabilities.supportsCreateOnly) { + createOptions.createOnly = true; + } + return createOptions; + } + if (expectedDigest && capabilities.supportsAtomicCompareAndSwap) { + createOptions.expectedDigest = expectedDigest; + } + return createOptions; + } + + // 云端 digest 相对上次同步已变时决定同步方向。 + // 不比较本地毫秒时钟与服务端整秒 mtime(同秒竞态会误判方向,L4): + // 用上次同步的本地内容基线判断本地是否也变过,双方都变才算冲突; + // 无基线(升级前旧数据/从未同步成功)时退回旧的时间比较规则。 + private async decideDirectionOnRemoteChange( + fs: FileSystem, + cloudFile: FileInfo, + script: Script, + syncedContentMd5Map: FileDigestMap + ): Promise<{ action: "push" | "pull" | "conflict" } | { action: "adopt"; digestMap: FileDigestMap }> { + const baselineMd5 = syncedContentMd5Map[cloudFile.name]; + if (baselineMd5 === undefined) { + const updatetime = script.updatetime || script.createtime; + return { action: updatetime > cloudFile.updatetime ? "push" : "pull" }; + } + const code = await this.scriptCodeDAO.get(script.uuid); + const localMd5 = code ? md5OfText(code.code) : undefined; + if (localMd5 === baselineMd5) { + return { action: "pull" }; + } + // 本地也变了:读取云端内容确认是否真冲突。两端内容一致(两台设备做了同样的编辑, + // 或本机记账失败后云端内容实为本机所写)只是基线过期,直接采用云端 digest 收敛 + try { + const cloudCode = (await fs.open(cloudFile).then((r) => r.read("string"))) as string; + if (localMd5 !== undefined && md5OfText(cloudCode) === localMd5) { + return { action: "adopt", digestMap: { [cloudFile.name]: localMd5 } }; + } + } catch (e) { + this.logger.warn("read cloud content for conflict check failed", Logger.E(e), { uuid: script.uuid }); + } + return { action: "conflict" }; + } + async pullScript(fs: FileSystem, file: SyncFiles, status: ScriptcatSyncStatus | undefined, existingScript?: Script) { const logger = this.logger.with({ uuid: existingScript?.uuid || "", @@ -735,10 +1207,20 @@ export class SynchronizeService { script, code, upsertBy: "sync", + // 本地 updatetime 采用云端文件时间(与 push 把 modifiedDate 设为本地 updatetime 对称)。 + // 若放任 prepareScriptByCode 的 Date.now(),下一轮会把刚拉下来的内容误判为 + // "本地编辑过"而补偿 push;etag 型 provider 重写会换 etag,两台设备将永久 pull/push 振荡 + updatetime: file.script.updatetime, + }); + // 记录拉取内容基线:下轮云端再变时用它判断本地是否也改过(方向判定不依赖跨时钟时间比较) + await this.recordSyncedContentMd5({ + [file.script.name]: md5OfText(code), + [file.meta.name]: md5OfText(metaJson), }); logger.info("pull script success"); } catch (e) { logger.error("pull script error", Logger.E(e)); + throw e; } } @@ -791,10 +1273,30 @@ export class SynchronizeService { if (config.enable) { stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { const fs = await this.buildFileSystem(config); - const pushedFileDigestMap = await this.pushScript(fs, params.script); - await this.updateFileDigest(fs, pushedFileDigestMap); + // 已同步过的脚本本地记录了其云端 digest,据此做 CAS 覆盖;没有记录才是首次上传走 create-only。 + // 缺了它,编辑已存在的云端脚本会因 create-only 而 412 冲突,本地改动永远上不了云。 + const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; + const script = params.script; + try { + const pushedFileDigestMap = await this.pushScript(fs, script, { fileDigestMap }); + await this.updateFileDigestForUuids(fs, [script.uuid], pushedFileDigestMap); + } catch (e) { + // 部分成功(如 .user.js 成功、.meta.json 失败)时,已写成功文件的云端 digest 已更新, + // 必须记录其最新 digest、只保留失败文件的旧值,否则下一轮同步会拿过期 digest 做 CAS 永久 412。 + // 全部失败(无文件写入成功)则不动 file_digest,保持"失败不污染"。 + const writtenFiles = e instanceof PushScriptPartialError ? e.writtenFiles : []; + if (writtenFiles.length > 0) { + const preserve = new Set( + [`${script.uuid}.user.js`, `${script.uuid}.meta.json`].filter((name) => !writtenFiles.includes(name)) + ); + await this.updateFileDigestForUuids(fs, [script.uuid], {}, preserve); + } + throw e; + } }).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), + }); }); } } @@ -811,10 +1313,25 @@ 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, + errorKind: this.classifySyncError(e), + }); + } } - await this.updateFileDigest(fs); + await this.updateFileDigestForUuids( + fs, + items.map((item) => item.uuid), + {}, + preserveDigestFiles + ); }).catch((e) => { this.logger.error("delete cloud script error", Logger.E(e)); }); diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 466ef7e2e..8afa805ef 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "Skript-Löschsynchronisation", "script_sync_delete_desc": "Skript {{scriptName}} wurde gelöscht", + "script_sync_conflict": "Cloud-Synchronisierungskonflikt", + "script_sync_conflict_desc": "Skript {{scriptNames}} wurde sowohl lokal als auch in der Cloud geändert. Die Synchronisierung dieses Skripts ist pausiert, bitte lösen Sie den Konflikt manuell", "subscribe_update": "Abonnement {{subscribeName}} wurde aktualisiert", "subscribe_update_desc": "Neue Skripte: {{newScripts}}\nGelöschte Skripte: {{deletedScripts}}" }, diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index cedcce7f0..f6985c066 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "Script Sync Delete", "script_sync_delete_desc": "Script {{scriptName}} has been deleted", + "script_sync_conflict": "Cloud Sync Conflict", + "script_sync_conflict_desc": "Both the local and cloud versions of {{scriptNames}} have been modified. Sync for the script is paused; please resolve the conflict manually", "subscribe_update": "Subscribe {{subscribeName}} has been updated", "subscribe_update_desc": "New scripts: {{newScripts}}\nDeleted scripts: {{deletedScripts}}" }, diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index af4d89eca..62191731e 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "スクリプト同期削除", "script_sync_delete_desc": "スクリプト {{scriptName}} が削除されました", + "script_sync_conflict": "クラウド同期の競合", + "script_sync_conflict_desc": "スクリプト {{scriptNames}} はローカルとクラウドの両方で変更されています。同期を一時停止しました。手動で解決してください", "subscribe_update": "サブスクリプション {{subscribeName}} が更新されました", "subscribe_update_desc": "新しいスクリプト: {{newScripts}}\n削除されたスクリプト: {{deletedScripts}}" }, diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 5229765ff..2f3988314 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "Синхронизация удаления скрипта", "script_sync_delete_desc": "Скрипт {{scriptName}} был удален", + "script_sync_conflict": "Конфликт облачной синхронизации", + "script_sync_conflict_desc": "Скрипт {{scriptNames}} изменен и локально, и в облаке. Синхронизация этого скрипта приостановлена, разрешите конфликт вручную", "subscribe_update": "Подписка {{subscribeName}} была обновлена", "subscribe_update_desc": "Новые скрипты: {{newScripts}}\nУдаленные скрипты: {{deletedScripts}}" }, diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index bd0b075ed..b8a5c0d22 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "Betik Eşitleme Silme İşlemi", "script_sync_delete_desc": "{{scriptName}} betiği silindi", + "script_sync_conflict": "Bulut Eşitleme Çakışması", + "script_sync_conflict_desc": "{{scriptNames}} betiği hem yerelde hem bulutta değiştirildi. Bu betiğin eşitlemesi duraklatıldı, lütfen çakışmayı elle çözün", "subscribe_update": "{{subscribeName}} aboneliği güncellendi", "subscribe_update_desc": "Yeni betikler: {{newScripts}}\nSilinen betikler: {{deletedScripts}}" }, diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index 053c1b176..253093283 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "Đồng bộ xóa script", "script_sync_delete_desc": "Script {{scriptName}} đã bị xóa", + "script_sync_conflict": "Xung đột đồng bộ đám mây", + "script_sync_conflict_desc": "Script {{scriptNames}} đã thay đổi cả trên máy và trên đám mây. Đã tạm dừng đồng bộ script này, vui lòng xử lý thủ công", "subscribe_update": "Đăng ký {{subscribeName}} đã được cập nhật", "subscribe_update_desc": "Script mới: {{newScripts}}\nScript đã xóa: {{deletedScripts}}" }, diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index b4740e22d..e3fbd1bd2 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "脚本删除同步", "script_sync_delete_desc": "脚本 {{scriptName}} 已被删除", + "script_sync_conflict": "云同步冲突", + "script_sync_conflict_desc": "脚本 {{scriptNames}} 的本地与云端版本均已修改,已暂停同步该脚本,请手动处理冲突", "subscribe_update": "订阅 {{subscribeName}} 已更新", "subscribe_update_desc": "新增脚本:{{newScripts}}\n删除脚本:{{deletedScripts}}" }, diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index e3196d38f..145dea1e4 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "腳本刪除同步", "script_sync_delete_desc": "腳本 {{scriptName}} 已被刪除", + "script_sync_conflict": "雲端同步衝突", + "script_sync_conflict_desc": "腳本 {{scriptNames}} 的本機與雲端版本皆已修改,已暫停同步該腳本,請手動處理衝突", "subscribe_update": "訂閱 {{subscribeName}} 已更新", "subscribe_update_desc": "新增腳本:{{newScripts}}\n刪除腳本:{{deletedScripts}}" },