本文是 ScriptCat 云同步的维护文档,描述当前分支上的实际实现。它面向需要修改或 review 同步逻辑的维护者,重点解释同步操作、状态文件、provider 差异、错误分类和生产数据兼容要求。
相关代码入口:
src/app/service/service_worker/synchronize.ts:同步服务、队列、状态合并、digest 更新。packages/filesystem/filesystem.ts:统一文件系统接口。packages/filesystem/error.ts:统一 typed provider error。packages/filesystem/*:各云盘 provider 实现。
云同步的第一目标不是强事务,而是在浏览器扩展和多 provider 限制下做到“尽量正确且不破坏旧数据”。
必须保持的不变量:
- 单个脚本失败不能阻塞其他脚本同步。
- 成功脚本可以推进自己的
file_digest,失败脚本必须保留旧 digest。 scriptcat-sync.json写回前要合并远端较新状态,避免覆盖其他设备状态。- provider 写入使用普通覆盖语义;同步层必须诚实记录无法检测的并发覆盖窗口。
- 旧
.user.js、旧.meta.json、旧file_digeststring map、缺字段scriptcat-sync.json必须继续可读。 - filesystem 包只负责执行文件操作、抛 typed error;同步冲突策略属于
SynchronizeService。 - 本地与云端都改过的脚本(真冲突)不自动覆盖任何一端:跳过、保留基线、聚合通知用户。
file_digest只是文件快照,表达不了「删除做到一半」「.meta.json还欠一次写入」这类未完成意图;此类意图必须持久化到pending_sync_ops并在下一轮开头重放,不能指望方向判定自动再生成重试任务。
功能范围:云同步只同步脚本源码、来源 metadata(.meta.json)、启用状态与排序(scriptcat-sync.json)。GM storage 值和 @require/@resource 资源缓存不参与云同步(它们只在完整备份/导出路径处理),属于有意取舍,扩展需另行设计(隐私、容量、二进制资源、多端 merge)。
同步目录由云同步配置决定,业务上使用 ScriptCat/sync 作为脚本同步目录。同步目录中主要有四类文件。
脚本源码文件。同步层用文件名中的 uuid 和 FileInfo.digest 识别脚本及远端内容状态。
脚本元信息文件。当前读取时只要求兼容以下字段:
type SyncMeta = {
uuid: string;
origin?: string;
downloadUrl?: string;
checkUpdateUrl?: string;
isDeleted?: boolean;
};origin、downloadUrl、checkUpdateUrl 是安装或更新时的辅助信息。新增字段必须保持 optional,读取旧文件时不能要求存在。
当用户启用同步删除时,删除云端脚本不会简单移除所有文件,而是删除 <uuid>.user.js 并写入 <uuid>.meta.json:
{
"uuid": "<uuid>",
"isDeleted": true
}其他设备看到“只有 .meta.json 且 isDeleted: true”时,会删除本地脚本。当前没有单独的 tombstone_digest,也没有 tombstone GC 机制;不要在没有生命周期设计前新增这类状态。
保存脚本启用状态、排序和更新时间。当前结构:
type ScriptcatSync = {
version: string;
status: {
scripts: {
[uuid: string]: {
enable: boolean;
sort: number;
updatetime: number;
} | undefined;
};
};
};兼容要求:
- 文件可能不存在。
- 文件可能缺少
status或status.scripts。 - 文件可能损坏或无法 JSON parse。
- 写回时必须尽量保留远端较新状态,尤其是本轮失败脚本和 orphan 脚本的状态。
file_digest 存在 ChromeStorage("sync") 中,用于记录上一次确认同步成功的云端文件 digest。
当前格式:
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(同存 ChromeStorage("sync"),格式同 FileDigestMap)记录本机每次成功推送或拉取的内容 md5,即"上次同步成功时的本地内容基线"。它用于云端变化时判断本地内容是否也发生变化:
- 方向判定(L4 修复):云端 digest 相对
file_digest已变时,用本地当前内容 md5 与基线比较判断"本地是否也改过",取代跨时钟域的墙钟比较(本地毫秒 updatetime vs 服务端整秒 mtime 在同一秒内会误判方向,导致 push 覆盖较新的云端内容)。
它与 file_digest 用途不同:file_digest 存 provider 原生 digest 检测云端变化,sync_content_md5 存本地内容 md5 检测本地变化,二者不可混用。
sync_content_md5 随 file_digest 生命周期收敛,不会只增不删:updateFileDigest() 全量对账后清理 file_digest 之外的条目;updateFileDigestForUuids() 只清理本次确认已从云端删除的目标文件(队列路径未全量对账,不能全局清理)。
同存 ChromeStorage("sync"),按 uuid 登记尚未完成的多步操作:
type PendingSyncOp = { op: "delete"; syncDelete: boolean } | { op: "push" };写入时机:
scriptsDelete()写前登记op: "delete"(含当时的syncDelete意图):两步删除(删.user.js+ 写 tombstone / 删.meta.json)中途失败或 SW 中途重启后,登记仍在;该 uuid 全部步骤成功才清除。- push 部分成功(
.user.js成功、.meta.json失败)时登记op: "push"(队列路径与syncOnce内部 push 任务都登记)。原因:部分成功后.user.jsdigest 已推进,且生产安装消息不带时间字段(云端 mtime 是 push 时刻的Date.now(),本地时间必然不比它新),下一轮「digest 相等 + 本地时间不比云端新」会跳过整个 uuid,失败的.meta.json不会再获得重试。
重放时机:syncOnceInternal() 开头、主流程对账之前逐条重放:
delete:直接重放deleteCloudScript()(已幂等,见下)。若不先重放,「本地无脚本 + 云端有.user.js」会把删到一半的脚本拉回本地;「本地无脚本 + 云端只剩.meta.json」则不命中任何决策分支,tombstone 永远欠写。push:盲覆盖写,重放前先校验云端.user.jsdigest 仍等于本机file_digest记录(即云端仍是本机上次写入的内容);已被他端改写或删除则丢弃登记,交回主流程方向判定,避免覆盖对端更新。本地脚本已删则丢弃登记。- 重放成功的 uuid 经
updateFileDigestForUuids()推进 digest 与内容基线;重放仍失败的 uuid 保留登记、计入failedSyncUuids,且本轮主流程跳过该 uuid(防止在半完成状态上误拉/误推)。
SynchronizeService 使用 SYNC_SERVICE_TASK_KEY = "cloud_sync_queue" 串行化同步任务。以下入口都会进入同一队列:
- 配置启用后触发的
syncOnce()。 - 定时同步,Chrome alarm 名称为
cloudSync,周期为 60 分钟。 - 非 sync 来源安装脚本后的
scriptInstall()。push 失败时按PushScriptPartialError只保留失败文件的旧 digest,已成功文件推进到云端最新值;部分成功还会登记pending_sync_ops的 push 意图(见上)。 - 非 sync 来源删除脚本后的
scriptsDelete()。执行前写前登记删除意图,成功后清除。
串行队列很重要:安装、删除和定时同步都可能写同一批云端文件,如果并发执行,会扩大覆盖和 digest 污染风险。
syncOnceInternal(syncConfig, fs) 是主同步流程:
- 重放
pending_sync_ops中未完成的删除/push(见上),重放失败的 uuid 本轮跳过。 - 调用
fs.list()获取云端目录。 - 按文件名把
<uuid>.user.js和<uuid>.meta.json组装成uuidMap。 - 读取本地脚本列表,生成
scriptMap。 - 尝试读取
scriptcat-sync.json,失败时允许脚本同步继续,但本轮跳过 status 写回。 - 对每个云端 uuid 和本地脚本做决策。
- 用
Promise.allSettled()等待所有文件任务,保持 per-file best-effort。 - 收集成功任务返回的 digest patch。
- 对失败任务记录
failedSyncUuids和preserveDigestFiles;push 部分失败时(PushScriptPartialError),已成功写入云端的文件不保留旧 digest,让下一轮只重试真正失败的文件。 - 如果启用
syncStatus,合并本地状态、初始云端状态、写回前重新读取的最新云端状态。 - 调用
updateFileDigest(),成功文件推进 digest,失败文件保留旧 digest。
本地脚本存在、云端脚本也存在:
- 云端缺
.meta.json(上一轮分片上传残留):push 本地脚本补齐 meta。 - 云端 digest 与
file_digest一致(云端自上次同步未变):- 本地更新时间不比云端新(整秒对齐比较):再联合检查
.meta.jsondigest——.user.js未变不代表 meta 未变。meta digest 与记录一致才跳过;不一致(他端只改了 meta 字段)则读取云端 meta 并采用其origin/downloadUrl/checkUpdateUrl(tombstone +.user.js仍在视为他端部分推送残留,等对端补完再判),采用成功后推进内容基线。若直接跳过,收尾的全量盖章会把未处理的 meta 标成已同步,之后永不再处理。meta 无 digest 记录(从未成功同步)时不判定,交由收尾盖章建立基线。 - 本地更新时间更晚:补偿 push——云端 digest 检测不到本地编辑,队列 push 失败后也靠这里兜底。这里仍是跨时钟域比较(本地是客户端毫秒时钟,云端 mtime 在 WebDAV 等服务端仅整秒精度),比较前两侧都截断到整秒:同秒内的毫秒余数不触发补偿,避免每次编辑上云后(服务端写入时间与编辑同秒)多一轮冗余覆盖 push。
- 本地更新时间不比云端新(整秒对齐比较):再联合检查
- 云端 digest 与
file_digest不一致(云端自上次同步已变,或本机无记录),由decideDirectionOnRemoteChange()决定方向。不比较本地毫秒时钟与服务端整秒 mtime(对端更新落在同一秒内时"本地时间戳更大"是误报,会 push 覆盖较新的云端内容,即 L4 同秒竞态):- 本地内容 md5 ==
sync_content_md5基线(本地未改):pull。 - 本地内容已改,但与云端当前内容一致(两台设备做了同样编辑,或本机记账失败后云端实为本机所写):直接采用云端 digest 收敛基线,不产生写操作(adopt)。
- 本地与云端都改了(真冲突):抛
SyncBothChangedConflictError,本轮跳过该脚本(沿用失败路径保留旧 digest 与云端 status),并聚合通知用户(见下)。 - 无基线(升级前旧数据/从未同步成功):退回旧的时间比较规则(整秒对齐后时间更晚一方胜出,同秒判 pull)。
- 本地内容 md5 ==
- 冲突通知:一轮同步只发一条通知,列出所有冲突脚本名;同一批脚本持续冲突时后续轮次不重复通知(集合变化后重新通知)。冲突脚本会一直停走,直到某一端内容与另一端一致(自动收敛)或某一端被删除/重装。
注意:push 是普通覆盖写。list → 决策 → write 之间存在 TOCTOU 窗口:若对端恰好在这几秒内写入同一文件,后写者胜(last-writer-wins),同步层无法察觉。内容基线只能减少基于旧快照做出错误方向判断的概率,不能消除请求之间的并发窗口。
本地脚本存在、云端只有 .meta.json:
- 如果 meta 是 tombstone,本地删除脚本。
- 如果 meta 不是 tombstone,删除无效 meta 并重新 push 本地脚本。
本地脚本不存在、云端 .user.js 和 .meta.json 都存在:
- pull 并安装云端脚本。
本地脚本不存在、云端只有 .user.js:
- 视为 orphan cloud script,跳过。
- 不删除、不覆盖、不清空对应远端 status。
本地脚本不存在、云端只有 .meta.json:
- tombstone:保留(删除标记须长期可见)。
- 非 tombstone:他端分片删除的残留,清理掉——否则其他仍持有该脚本的设备会把它当「无效 meta」删除后重新上传,等于撤销删除。
- 以 digest 变化为门:digest 与记录一致时不重复读取(tombstone 首轮读取盖章后不再产生 I/O)。
遍历结束后,剩余只存在于本地的脚本会 push 到云端。
pushScript() 写两个文件:
<uuid>.user.js<uuid>.meta.json
modifiedDate 使用 script.updatetime || script.createtime || Date.now()。
写入使用 provider 的普通覆盖语义,不附加条件请求参数。
pushScript() 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。
pullScript() 会读取源码和 meta:
fs.open(file.script).read("string")。fs.open(file.meta).read("string")。JSON.parse(meta)。prepareScriptByCode()解析脚本。- 根据
scriptcat-sync.jsonstatus 调整 enable/sort。 script.installScript({ upsertBy: "sync", updatetime: file.script.updatetime })写入本地——本地 updatetime 必须采用云端文件时间(与 push 对称),否则下一轮会把刚拉下来的内容误判为本地编辑触发补偿 push,在 etag 型 provider 上形成双设备永久 pull/push 振荡。- 成功后把拉取内容的 md5 记入
sync_content_md5基线,供下轮云端再变时判定本地是否也改过。
真实失败会向上抛出,由 syncOnceInternal() 作为单文件失败处理。不要在 pullScript() 内吞掉错误,否则会重新引入 digest 污染。
删除云端脚本时:
- 先删除
<uuid>.user.js。 - 如果
syncDelete为 true,写 tombstone meta。 - 如果
syncDelete为 false,删除<uuid>.meta.json。
两步删除对 typed notFound 幂等:目标文件已不存在即视为达成目的(部分 provider 如 Google Drive 对缺失文件抛 notFound,重放半途失败的删除不能被它中断)。其余失败会向上抛出。scriptsDelete() 必须逐条 catch,保证批量删除中一个 uuid 失败不影响后续 uuid;未完成的 uuid 依赖 pending_sync_ops 登记由下一轮重放。
scriptcat-sync.json 是 best-effort 状态同步,不是强事务。合并时遵守以下规则:
- 本轮文件同步失败的 uuid 保留云端原 status。
- 本轮刚 pull 的脚本保留云端 status,避免刚按云端更新后又写回本地旧状态。
- 本地状态更新时间更新时,候选写回本地 status。
- 云端状态更新时,应用云端 enable/sort 到本地。
- orphan uuid 的云端 status 保留。
- 写回前重新读取最新
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 |
授权失败 |
| 其他 | fatal |
未分类错误 |
错误分类主要用于日志、保留 digest、后续 retry 策略和 review 判断。它不是用户可见错误协议。
LimiterFileSystem 对不同操作使用不同重试策略:
- 会重试:
verify、open、read、openDir、list、getDirUrl。 - 不重试:
create、createDir、writer.write()、delete()。
原因:写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。
typed retryable 只覆盖瞬时 5xx(500/502/503/504)。501、505、507 等属于永久失败,不标记可重试,避免 limiter 空转退避。
| Provider | digest 来源 | 写入方式 | 关键实现 |
|---|---|---|---|
| WebDAV | etag |
普通覆盖写入 | putFileContents() |
| S3 | ETag 去引号 |
普通覆盖写入 | PUT Object |
| OneDrive | eTag |
普通覆盖写入 | simple PUT / upload session |
| Google Drive | md5Checksum |
普通覆盖写入 | 先按路径查 fileId,再 PATCH 或 POST;path cache 可能 stale |
| Dropbox | content_hash |
普通覆盖写入 | 先 exists(),存在 overwrite,不存在 add |
| Baidu | md5 |
普通覆盖写入 | precreate/upload/create,rtype=3 覆盖;HTTP 429/5xx typed |
| Zip | 空 | 普通覆盖写入 | 备份用途 |
维护时注意:
putFileContents()返回 false 时转为写入失败。- 删除 404 视为幂等成功。
维护时注意:
- list 返回的
ETag会去掉引号作为 digest。 NoSuchKey删除视为成功。
维护时注意:
- list 使用
eTag作为 digest。 - 空内容走 simple PUT,非空内容走 upload session。
- upload session URL 不带 bearer token,request 层保留这个特殊路径。
- read/delete 使用 raw
Response路径,需要手动转 typed error。
Google Drive 维护时注意:
- digest 来自
md5Checksum。 - 目录和文件通过 appDataFolder + path cache 查 fileId。
- 写入是“先查同名文件,再 PATCH 或 POST”。
- 删除是“先查 fileId,再 DELETE”。
- reader path lookup miss 已转 typed notFound。
- path cache stale 时 writer/list 会清缓存并重试一次。
Dropbox 维护时注意:
- digest 来自
content_hash,必须当作 opaque provider digest。 - 写入是
exists()后 overwrite 或 add,存在 TOCTOU。 - request 层已解析
error_summary和 structuredpath_lookup/path。 - 只有
path/conflict/path_write/conflict判 conflict;其余 409(无写权限、空间不足等)保留原错误语义,不能被 createDir 当"目录已存在"吞掉。 - raw download 429 会转 typed rateLimit。
- 删除 not_found 视为幂等成功。
Baidu 维护时注意:
- 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 规则。
ZipFileSystem 主要服务备份/导出。
云同步在 best-effort、last-writer-wins 下可能静默覆盖或停走脚本。为让用户可感知、可回溯,增加了三处可见性(不改变同步语义,只增加提示与记录)。
syncOnce() 每轮把设备本地同步状态写入 ChromeStorage("sync")(即 chrome.storage.local,物理键 sync_cloud_sync_state):
type CloudSyncState = {
syncing: boolean;
lastSyncAt: number; // ms,从未同步为 0
error?: string; // 最近一次失败原因(如账号验证失败)
counts: { total: number; overwrite: number; conflict: number; failed: number };
};- 开始置
syncing:true,结束写counts/lastSyncAt,异常写error。注意:读旧值与写syncing不能 await 在syncOnceInternal之前,否则存储 I/O 会推迟内部起始,打乱测试的微任务门控。 - 设置页「脚本同步」卡片顶部状态条(
SyncSection.tsx+syncStatus.ts)读取并订阅chrome.storage.onChanged实时展示四态:正常 / 同步中 / 冲突(琥珀警示,已暂停需处理)/ 失败。覆盖(overwrite)不进入警示,属信息级:它是已发生、无需用户处理的事件,仅在「正常」态以中性信息行「N 个脚本被覆盖,可查看日志」+ 日志深链呈现(sync_state_overwrite_info)。单文件 best-effort 失败通过counts.failed显示为失败,不能回落成“同步正常”。 - 状态条只在已保存且启用同步时展示(
SyncSection.tsx用savedEnable门控,非未保存草稿draft.enable):仅勾选「启用脚本同步至」但尚未保存不会立刻出现「同步正常」。 立即同步按钮经SynchronizeClient.cloudSyncOnce()→ SWgroup.on("cloudSyncOnce")→ 用已保存配置跑一次syncOnce(未启用则不触发)。构建文件系统失败发生在syncOnce()之前,因此cloudSyncOnce()会单独写入error状态并向 UI 抛出,由设置页显示 toast。
decideDirectionOnRemoteChange() 的无内容基线兜底分支(baselineMd5 === undefined)只能按跨时钟域墙钟比较(整秒对齐)pull/push,可能覆盖未知改动。该分支返回 { action, unverified: true },调用点在写入成功后据此打警告日志(失败轮次不登记也不通知,避免谎报覆盖、且不让去重键把下一轮真覆盖静默掉):
this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name });日志经现有 LoggerDAO 落 IndexedDB(service: "synchronize")。日志 message 保持稳定英文标识(与既有同步日志一致),人类可读文案由状态条与通知承载。
本轮有冲突时聚合一条 InfoNotification(冲突脚本已暂停走,需用户处理):已通知集合(LAST_NOTIFIED_CONFLICT_KEY)存入设备本地 sync storage,MV3 Service Worker 重启后同一批冲突不重复通知;集合变化或冲突消失时重置。
覆盖(overwrite)不再弹桌面通知:它是已发生、无需用户处理的信息级事件,只由 overwrite 日志与设置页状态条信息行 + 「查看日志」深链承载,避免首次同步/升级后无基线场景批量弹“N 个脚本被覆盖”的惊扰通知。
「查看日志」深链的 ?query 载荷 [{key,value}] 由 Logger 页 parseInitialQueries 解析(状态条侧见 syncStatus.ts 的 syncLogHref):只有纯覆盖状态才预过滤到 service=synchronize 且 action=overwrite;存在冲突或失败时只过滤 service=synchronize,避免把对应失败日志隐藏掉。状态文案使用中性“发生覆盖”,具体是本地覆盖云端还是云端覆盖本地以日志的 direction 标签为准。
- 日志目前没有自动清理机制(
LogCleanCycle设置与LoggerDAO.deleteBefore接口存在,但没有调用点执行清理),覆盖日志可长期回溯。 overwrite只覆盖「无基线兜底」这一可检测的静默覆盖;纯 TOCTOU last-writer-wins(见上文 push 一节)客户端无法察觉,不在本轮可见性范围内。
改同步逻辑前必须检查:
- 旧云端目录只有
.user.js和.meta.json时能否继续同步。 - 旧
file_digest只有 string digest 时能否继续比较。 - 旧
scriptcat-sync.json缺字段时是否会崩溃。 - 损坏
scriptcat-sync.json是否会被本轮覆盖。 - orphan
.user.js是否仍被跳过而不是删除。 - 单文件失败是否只保留该文件旧 digest。
- provider 原生 digest 是否被本地 md5 覆盖。
- 不要把整个 sync round 改成 all-or-nothing。
- 不要在
pullScript()或deleteCloudScript()内 catch 后吞掉真实失败。 - 不要让失败文件推进 digest。
- 不要在无法读取远端
scriptcat-sync.json时覆盖写回。 - 不要把 Dropbox
content_hash当 rev。 - 不要新增
tombstone_digest,除非同时定义 GC 和兼容策略。 - 不要对普通无条件写入开启 transient retry。
同步层测试在 src/app/service/service_worker/synchronize.test.ts。provider 测试在各自 packages/filesystem/*/*.test.ts。
修改同步逻辑时至少考虑以下场景:
- 多个文件中一个 push/pull/delete 失败,其他文件继续同步。
- 失败文件 digest 保留,成功文件 digest 推进。
scriptcat-sync.json写回失败不污染文件 digest。- 损坏或旧格式
scriptcat-sync.json不阻塞脚本同步。 - orphan
.user.js跳过并保留 status。 - provider conflict/transient/notFound 能映射到正确
SyncErrorKind。 - 云端已变、本地内容基线未变时必须 pull——即使本地 updatetime 大于云端 mtime(同秒竞态 L4)。
- 本地与云端都变(真冲突)时不 push 不 pull,保留旧 digest 与云端 status,一轮只发一条聚合通知,同一批冲突不重复通知。
- 云端已变、本地也变但内容与云端一致时,收敛基线且不产生写操作。
- 无内容基线(升级前旧数据)时退回整秒对齐的时间比较规则,同秒判 pull。
- 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。
- 同步失败计数必须在设置页显示为失败,不能显示为同步正常。
- 冲突或失败状态的日志深链不能被覆盖过滤条件隐藏。
- push 部分失败(
.user.js成功、.meta.json失败)后,用生产形态的安装消息(不带updatetime)验证下一轮仍会补传.meta.json。 - 删除部分失败(tombstone 未写 /
.meta.json残留)后,下一轮(含 SW 重启)自动完成剩余步骤;删除全失败后不得把脚本拉回本地。 - 源码未变但云端
.meta.jsondigest 变化时,必须读取采用而不是盖章跳过。
真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。