From 8f17ada7baccd3ac686b6a67d91998a31b03dca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 24 Aug 2026 17:31:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=90=8C=E6=AD=A5=E8=84=9A=E6=9C=AC=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E9=94=99=E4=B9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 13 ++ src/app/service/queue.ts | 2 +- src/app/service/service_worker/script.test.ts | 111 +++++++++++++++++- src/app/service/service_worker/script.ts | 23 +++- .../service_worker/synchronize.test.ts | 70 +++++++++++ src/app/service/service_worker/synchronize.ts | 104 +++++++++++++--- 6 files changed, 300 insertions(+), 23 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 91f06d165..af6645433 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -255,6 +255,16 @@ type PendingSyncOp = { op: "delete"; syncDelete: boolean } | { op: "push" }; `scriptcat-sync.json` 是 best-effort 状态同步,不是强事务。合并时遵守以下规则: +拖动排序和置顶不修改脚本内容的 `updatetime`。位置实际变化的脚本会在本地 +`pending_sort_status` 中记录同一次操作的 `sort` 和 `sortUpdatetime`;位置未变化的脚本不写入。 +该 pending 状态保存在扩展本地存储中,Service Worker 重启后仍可继续同步,并且只有在 +`scriptcat-sync.json` 成功写入对应或更新的排序时钟后才清除。 + +`scriptcat-sync.json` 中的 `sortUpdatetime` 是可选字段。存在该字段时,`enable` 继续由 +`updatetime` 决定,`sort` 则由 `sortUpdatetime` 决定,两个维度独立合并,避免一次启停覆盖 +另一台设备更新的顺序。旧文件双方都没有 `sortUpdatetime` 时,继续沿用整条 status 的 +`updatetime` LWW 规则;只有一侧具备新字段时,缺失侧以其 `updatetime` 作为排序时钟兼容读取。 + 1. 本轮文件同步失败的 uuid 保留云端原 status。 2. 本轮刚 pull 的脚本保留云端 status,避免刚按云端更新后又写回本地旧状态。 3. 本地状态更新时间更新时,候选写回本地 status。 @@ -451,5 +461,8 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name 14. push 部分失败(`.user.js` 成功、`.meta.json` 失败)后,用生产形态的安装消息(不带 `updatetime`)验证下一轮仍会补传 `.meta.json`。 15. 删除部分失败(tombstone 未写 / `.meta.json` 残留)后,下一轮(含 SW 重启)自动完成剩余步骤;删除全失败后不得把脚本拉回本地。 16. 源码未变但云端 `.meta.json` digest 变化时,必须读取采用而不是盖章跳过。 +17. 拖动排序不得修改脚本内容 `updatetime`,只为位置变化的脚本登记统一且单调推进的 + `sortUpdatetime`;下一轮同步应让较新的本地排序覆盖旧云端排序,同时保留较新的启用状态。 +18. 排序 pending 在 Service Worker 重启后仍应存在;状态文件写入失败或某个 pending 尚未写入时不得误清。 真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index cb3070714..dd1253528 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -22,7 +22,7 @@ export type TInstallScript = { script: TInstallScriptParams; update: boolean; up export type TDeleteScript = { uuid: string; storageName: string; type: SCRIPT_TYPE; deleteBy?: InstallSource }; -export type TSortedScript = { uuid: string; sort: number }; +export type TSortedScript = { uuid: string; sort: number; sortUpdatetime?: number }; export type TInstallSubscribe = { subscribe: Subscribe }; diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index 6ba0f4c6b..42ac7626a 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -13,7 +13,7 @@ import { SystemConfig } from "@App/pkg/config/config"; import EventEmitter from "eventemitter3"; import type { ValueService } from "./value"; import type { ResourceService } from "./resource"; -import type { TDeleteScript, TInstallScript } from "@App/app/service/queue"; +import type { TDeleteScript, TInstallScript, TSortedScript } from "@App/app/service/queue"; import { createMockOPFS } from "@App/app/repo/test-helpers"; import type { Group } from "@Packages/message/server"; import type { IMessageQueue } from "@Packages/message/message_queue"; @@ -108,6 +108,115 @@ describe("ScriptService.purgeScripts —— 彻底删除", () => { }); }); +describe("ScriptService.sortScript", () => { + beforeEach(async () => { + await resetActiveScriptData(); + }); + + it("拖动排序只更新位置变化的脚本并发布排序更新时间", async () => { + const { service, scriptDAO, mq } = buildService(); + await scriptDAO.save(makeScript({ uuid: "first", sort: 0, updatetime: 100 })); + await scriptDAO.save(makeScript({ uuid: "second", sort: 1, updatetime: 1_000 })); + const sorted: TSortedScript[][] = []; + mq.subscribe("sortedScripts", (value) => void sorted.push(value)); + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + + try { + await service.sortScript({ before: ["first", "second"], after: ["second", "first"] }); + } finally { + now.mockRestore(); + } + + await expect(scriptDAO.get("first")).resolves.toMatchObject({ sort: 1, updatetime: 100 }); + await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 0, updatetime: 1_000 }); + expect(sorted[0]).toEqual([ + { uuid: "second", sort: 0, sortUpdatetime: 1_000 }, + { uuid: "first", sort: 1, sortUpdatetime: 1_000 }, + ]); + }); + + it("拖动部分列表时不写入位置未变化的脚本", async () => { + const { service, scriptDAO } = buildService(); + for (let index = 0; index < 4; index += 1) { + await scriptDAO.save(makeScript({ uuid: `script-${index}`, sort: index, updatetime: 100 + index })); + } + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + + try { + await service.sortScript({ + before: ["script-0", "script-1", "script-2", "script-3"], + after: ["script-1", "script-0", "script-2", "script-3"], + }); + } finally { + now.mockRestore(); + } + + await expect(scriptDAO.get("script-1")).resolves.toMatchObject({ sort: 0, updatetime: 101 }); + await expect(scriptDAO.get("script-0")).resolves.toMatchObject({ sort: 1, updatetime: 100 }); + await expect(scriptDAO.get("script-2")).resolves.toMatchObject({ sort: 2, updatetime: 102 }); + await expect(scriptDAO.get("script-3")).resolves.toMatchObject({ sort: 3, updatetime: 103 }); + }); +}); + +describe("ScriptService.getAllScripts", () => { + beforeEach(async () => { + await resetActiveScriptData(); + }); + + it("规范化旧排序时只登记位置变化的脚本", async () => { + const { service, scriptDAO, mq } = buildService(); + await scriptDAO.save(makeScript({ uuid: "first", sort: -1, updatetime: 100 })); + await scriptDAO.save(makeScript({ uuid: "second", sort: 1, updatetime: 200 })); + const sorted: TSortedScript[][] = []; + mq.subscribe("sortedScripts", (value) => void sorted.push(value)); + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + + try { + await service.getAllScripts(); + } finally { + now.mockRestore(); + } + + await expect(scriptDAO.get("first")).resolves.toMatchObject({ sort: 0, updatetime: 100 }); + await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 1, updatetime: 200 }); + expect(sorted[0]).toEqual([ + { uuid: "first", sort: 0, sortUpdatetime: 1_000 }, + { uuid: "second", sort: 1 }, + ]); + }); +}); + +describe("ScriptService.pinToTop", () => { + beforeEach(async () => { + await resetActiveScriptData(); + }); + + it("置顶只更新位置变化的脚本并发布同一个排序更新时间", async () => { + const { service, scriptDAO, mq } = buildService(); + await scriptDAO.save(makeScript({ uuid: "first", sort: 0, updatetime: 100 })); + await scriptDAO.save(makeScript({ uuid: "second", sort: 1, updatetime: 200 })); + await scriptDAO.save(makeScript({ uuid: "third", sort: 2, updatetime: 300 })); + const sorted: TSortedScript[][] = []; + mq.subscribe("sortedScripts", (value) => void sorted.push(value)); + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + + try { + await service.pinToTop(["second"]); + } finally { + now.mockRestore(); + } + + await expect(scriptDAO.get("first")).resolves.toMatchObject({ sort: 1, updatetime: 100 }); + await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 0, updatetime: 200 }); + await expect(scriptDAO.get("third")).resolves.toMatchObject({ sort: 2, updatetime: 300 }); + expect(sorted[0]).toEqual([ + { uuid: "second", sort: 0, sortUpdatetime: 1_000 }, + { uuid: "first", sort: 1, sortUpdatetime: 1_000 }, + { uuid: "third", sort: 2 }, + ]); + }); +}); + describe("ScriptService.deleteScripts —— 进回收站", () => { beforeEach(async () => { await resetActiveScriptData(); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 64bc34431..225d48bd8 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -1525,12 +1525,23 @@ export class ScriptService { // 获取数据并排序 const scripts = await this.scriptDAO.all(); scripts.sort((a, b) => a.sort - b.sort); + const batchUpdate: Record> = {}; + const changed = new Set(); for (let i = 0; i < scripts.length; i += 1) { if (scripts[i].sort !== i) { - this.scriptDAO.update(scripts[i].uuid, { sort: i }); + batchUpdate[scripts[i].uuid] = { sort: i }; scripts[i].sort = i; + changed.add(scripts[i].uuid); } } + if (changed.size) { + await this.scriptDAO.updates(batchUpdate); + const sortUpdatetime = Date.now(); + this.mq.publish( + "sortedScripts", + scripts.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) + ); + } return scripts; } @@ -1546,6 +1557,8 @@ export class ScriptService { // 排序 scripts 并更新 sort 字段 const batchUpdate: Record> = {}; + const sortUpdatetime = Date.now(); + const changed = new Set(); const newList = ( await Promise.all( @@ -1554,6 +1567,7 @@ export class ScriptService { if (newSort !== undefined && script.sort !== newSort) { batchUpdate[script.uuid] = { sort: newSort }; script.sort = newSort; + changed.add(script.uuid); } return script; }) @@ -1564,7 +1578,7 @@ export class ScriptService { this.mq.publish( "sortedScripts", - newList.map(({ uuid, sort }) => ({ uuid, sort })) + newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) ); } @@ -1589,6 +1603,8 @@ export class ScriptService { }); const batchUpdate: Record> = {}; + const sortUpdatetime = Date.now(); + const changed = new Set(); const newList = await Promise.all( scripts.map(async (script, index) => { @@ -1596,6 +1612,7 @@ export class ScriptService { if (script.sort !== newSort) { batchUpdate[script.uuid] = { sort: newSort }; script.sort = newSort; + changed.add(script.uuid); } return script; }) @@ -1604,7 +1621,7 @@ export class ScriptService { this.mq.publish( "sortedScripts", - newList.map(({ uuid, sort }) => ({ uuid, sort })) + newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) ); } diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 6f781c874..f20df59df 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -624,6 +624,76 @@ describe("SynchronizeService", () => { expect(written.status.scripts["status-uuid"]).toEqual(localStatus); }); + it("排序待同步状态应独立覆盖旧排序且保留远端较新的启用状态", async () => { + const cloudStatus = { enable: false, sort: 8, updatetime: 300, sortUpdatetime: 100 }; + 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([syncFile]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ version: "1.0.0", status: { scripts: { u1: cloudStatus } } })), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const scriptDAO = { + scriptCodeDAO: {}, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 200, createtime: 1, status: 1, sort: 1, metadata: {} }, + ]), + get: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }; + const recordingService = new SynchronizeService( + {} as any, + {} as any, + { enableScript: vi.fn().mockResolvedValue(undefined) } as any, + {} as any, + {} as any, + {} as any, + {} as any, + scriptDAO as any + ); + const now = vi.spyOn(Date, "now").mockReturnValue(200); + try { + await recordingService.scriptsSorted([{ uuid: "u1", sort: 1, sortUpdatetime: 200 }]); + } finally { + now.mockRestore(); + } + + // 用新实例模拟 MV3 Service Worker 被回收后重新启动。 + 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 + ); + vi.spyOn(service, "pushScript").mockResolvedValue({}); + + await service.syncOnce(syncConfig, fs); + + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts.u1).toEqual({ + enable: false, + sort: 1, + updatetime: 300, + sortUpdatetime: 200, + }); + await expect((service as any).storage.get("pending_sort_status")).resolves.toEqual({}); + }); + it("写回 scriptcat-sync.json 时远端已删除的 uuid 不应被复活", async () => { const initialStatus = { enable: true, sort: 1, updatetime: 100 }; const writeMock = vi.fn().mockResolvedValue(undefined); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index a500d50f3..eaa7a42aa 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -46,6 +46,7 @@ import { InfoNotification } from "./utils"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { md5OfText } from "@App/pkg/utils/crypto"; import { startDownload } from "./download"; +import type { TSortedScript } from "../queue"; // type SynchronizeTarget = "local"; @@ -75,8 +76,11 @@ type ScriptcatSyncStatus = { enable: boolean; sort: number; updatetime: number; // 更新时间 + sortUpdatetime?: number; }; +type PendingSortStatus = { [uuid: string]: { sort: number; sortUpdatetime: number } | undefined }; + type PushScriptParam = TInstallScriptParams & Partial>; export type LocalBackupExport = { @@ -134,6 +138,7 @@ type PendingSyncOps = { [uuid: string]: PendingSyncOp }; const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; const PENDING_SYNC_OPS_KEY = "pending_sync_ops"; +const PENDING_SORT_STATUS_KEY = "pending_sort_status"; const LAST_NOTIFIED_CONFLICT_KEY = "last_notified_sync_conflicts"; function isEquivalentConfigValue(left: unknown, right: unknown): boolean { @@ -513,6 +518,7 @@ export class SynchronizeService { // 重放上一轮未完成的操作(半途失败的删除、欠写的 .meta.json)。 // 必须在主流程对账前先落地,否则「本地无脚本 + 云端有 .user.js」会把删到一半的脚本拉回本地 const pendingOps = await this.getPendingSyncOps(); + const pendingSortStatus = await this.getPendingSortStatus(); const pendingFailedUuids = new Set(); { const pendingUuids = Object.keys(pendingOps); @@ -873,6 +879,9 @@ export class SynchronizeService { enable: script.status === SCRIPT_STATUS_ENABLE, sort: script.sort, updatetime: updatetime, + ...(pendingSortStatus[script.uuid] + ? { sortUpdatetime: pendingSortStatus[script.uuid]!.sortUpdatetime } + : {}), }; } else { if (updateScript.has(script.uuid)) { @@ -880,26 +889,29 @@ export class SynchronizeService { 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) { + const localEnableWins = status.updatetime < updatetime; + const pendingSort = pendingSortStatus[script.uuid]; + const cloudSortUpdatetime = status.sortUpdatetime ?? status.updatetime; + const localSortWins = pendingSort + ? pendingSort.sortUpdatetime >= cloudSortUpdatetime + : status.sortUpdatetime === undefined && status.updatetime < updatetime; + const nextStatus: ScriptcatSyncStatus = { + enable: localEnableWins ? script.status === SCRIPT_STATUS_ENABLE : status.enable, + sort: localSortWins ? script.sort : status.sort, + updatetime: localEnableWins ? updatetime : status.updatetime, + ...(localSortWins && pendingSort + ? { sortUpdatetime: pendingSort.sortUpdatetime } + : status.sortUpdatetime !== undefined + ? { sortUpdatetime: status.sortUpdatetime } + : {}), + }; + scriptcatSync.status.scripts[script.uuid] = nextStatus; + if (!localSortWins && status.sort !== script.sort) { await this.scriptDAO.update(script.uuid, { sort: status.sort, }); } - // 脚本状态 - if (status.enable !== (script.status === SCRIPT_STATUS_ENABLE)) { + if (!localEnableWins && status.enable !== (script.status === SCRIPT_STATUS_ENABLE)) { // 开启脚本 await this.script.enableScript({ uuid: script.uuid, @@ -928,6 +940,16 @@ export class SynchronizeService { const modifiedDate = Date.now(); const syncFile = await fs.create("scriptcat-sync.json", { modifiedDate }); await syncFile.write(JSON.stringify(scriptcatSync, null, 2)); + const remainingPendingSortStatus: PendingSortStatus = {}; + for (const [uuid, pending] of Object.entries(pendingSortStatus)) { + if (!pending) continue; + const writtenStatus = scriptcatSync.status.scripts[uuid]; + const writtenSortTime = writtenStatus?.sortUpdatetime ?? writtenStatus?.updatetime ?? 0; + if (writtenSortTime < pending.sortUpdatetime) { + remainingPendingSortStatus[uuid] = pending; + } + } + await this.setPendingSortStatus(remainingPendingSortStatus); this.logger.info("sync scriptcat-sync.json file success"); } catch (e) { this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); @@ -997,7 +1019,8 @@ export class SynchronizeService { initial && candidate.enable === initial.enable && candidate.sort === initial.sort && - candidate.updatetime === initial.updatetime; + candidate.updatetime === initial.updatetime && + candidate.sortUpdatetime === initial.sortUpdatetime; if (candidateOnlyPreservedInitial) { // Defer to remote: if another device deleted this uuid, respect the deletion if (latest !== undefined) { @@ -1005,9 +1028,26 @@ export class SynchronizeService { } continue; } - if (!latest || candidate.updatetime >= latest.updatetime) { + if (!latest) { merged[uuid] = candidate; + continue; } + if (candidate.sortUpdatetime === undefined && latest.sortUpdatetime === undefined) { + if (candidate.updatetime >= latest.updatetime) { + merged[uuid] = candidate; + } + continue; + } + const enableWinner = candidate.updatetime >= latest.updatetime ? candidate : latest; + const candidateSortTime = candidate.sortUpdatetime ?? candidate.updatetime; + const latestSortTime = latest.sortUpdatetime ?? latest.updatetime; + const sortWinner = candidateSortTime >= latestSortTime ? candidate : latest; + merged[uuid] = { + enable: enableWinner.enable, + sort: sortWinner.sort, + updatetime: enableWinner.updatetime, + ...(sortWinner.sortUpdatetime !== undefined ? { sortUpdatetime: sortWinner.sortUpdatetime } : {}), + }; } return merged; } @@ -1097,6 +1137,33 @@ export class SynchronizeService { await this.storage.set(PENDING_SYNC_OPS_KEY, ops); } + private async getPendingSortStatus(): Promise { + return ((await this.storage.get(PENDING_SORT_STATUS_KEY)) as PendingSortStatus) || {}; + } + + private async setPendingSortStatus(status: PendingSortStatus) { + await this.storage.set(PENDING_SORT_STATUS_KEY, status); + } + + async scriptsSorted(items: TSortedScript[]) { + const changed = items.filter( + (item): item is TSortedScript & { sortUpdatetime: number } => item.sortUpdatetime !== undefined + ); + if (!changed.length) return; + await stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + const pending = await this.getPendingSortStatus(); + const latest = Math.max( + Date.now(), + ...Object.values(pending).map((item) => (item?.sortUpdatetime || 0) + 1), + ...changed.map((item) => item.sortUpdatetime) + ); + for (const item of changed) { + pending[item.uuid] = { sort: item.sort, sortUpdatetime: latest }; + } + await this.setPendingSortStatus(pending); + }); + } + // 删除云端脚本数据 async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) { const filename = `${uuid}.user.js`; @@ -1483,5 +1550,6 @@ export class SynchronizeService { // 监听脚本变化, 进行同步 this.mq.subscribe("installScript", this.scriptInstall.bind(this)); this.mq.subscribe("trashScripts", this.scriptsDelete.bind(this)); + this.mq.subscribe("sortedScripts", this.scriptsSorted.bind(this)); } } From 6d5bb976f87c6640a68baee89be1510019afd4ca Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:52:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E6=AD=A3=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=90=8C=E6=AD=A5=E7=9A=84=E6=97=B6=E5=BA=8F=E4=B8=8E?= =?UTF-8?q?=20pending=20=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/queue.ts | 2 + src/app/service/service_worker/script.test.ts | 27 +++ src/app/service/service_worker/script.ts | 171 +++++++++--------- .../service_worker/synchronize.test.ts | 104 +++++++++++ src/app/service/service_worker/synchronize.ts | 18 +- 5 files changed, 233 insertions(+), 89 deletions(-) diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index dd1253528..99070e730 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -22,6 +22,8 @@ export type TInstallScript = { script: TInstallScriptParams; update: boolean; up export type TDeleteScript = { uuid: string; storageName: string; type: SCRIPT_TYPE; deleteBy?: InstallSource }; +export const CLOUD_SYNC_QUEUE_KEY = "cloud_sync_queue"; + export type TSortedScript = { uuid: string; sort: number; sortUpdatetime?: number }; export type TInstallSubscribe = { subscribe: Subscribe }; diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index 42ac7626a..84b7ba909 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -14,6 +14,7 @@ import EventEmitter from "eventemitter3"; import type { ValueService } from "./value"; import type { ResourceService } from "./resource"; import type { TDeleteScript, TInstallScript, TSortedScript } from "@App/app/service/queue"; +import { CLOUD_SYNC_QUEUE_KEY } from "@App/app/service/queue"; import { createMockOPFS } from "@App/app/repo/test-helpers"; import type { Group } from "@Packages/message/server"; import type { IMessageQueue } from "@Packages/message/message_queue"; @@ -21,6 +22,7 @@ import type { MessageSend } from "@Packages/message/types"; import { ScriptClient } from "./client"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { BatchUpdateListActionCode } from "./types"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; initTestEnv(); @@ -156,6 +158,31 @@ describe("ScriptService.sortScript", () => { await expect(scriptDAO.get("script-2")).resolves.toMatchObject({ sort: 2, updatetime: 102 }); await expect(scriptDAO.get("script-3")).resolves.toMatchObject({ sort: 3, updatetime: 103 }); }); + + it("全量同步进行时排序 mutation 不应穿插执行", async () => { + const { service, scriptDAO } = buildService(); + await scriptDAO.save(makeScript({ uuid: "first", sort: 0 })); + await scriptDAO.save(makeScript({ uuid: "second", sort: 1 })); + const allSpy = vi.spyOn(scriptDAO, "all"); + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const syncPromise = stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, () => syncGate); + let sortResolved = false; + const sortPromise = service.sortScript({ before: ["first", "second"], after: ["second", "first"] }).then(() => { + sortResolved = true; + }); + + await Promise.resolve(); + expect(allSpy).not.toHaveBeenCalled(); + expect(sortResolved).toBe(false); + + releaseSync(); + await Promise.all([syncPromise, sortPromise]); + expect(allSpy).toHaveBeenCalledTimes(1); + await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 0 }); + }); }); describe("ScriptService.getAllScripts", () => { diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 225d48bd8..19a5b9fc2 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -35,6 +35,7 @@ import type { TSortedScript, TInstallScriptParams, } from "../queue"; +import { CLOUD_SYNC_QUEUE_KEY } from "../queue"; import { buildScriptRunResourceBasic, selfMetadataUpdate } from "./utils"; import { BatchUpdateListActionCode, @@ -1522,27 +1523,29 @@ export class ScriptService { } async getAllScripts() { - // 获取数据并排序 - const scripts = await this.scriptDAO.all(); - scripts.sort((a, b) => a.sort - b.sort); - const batchUpdate: Record> = {}; - const changed = new Set(); - for (let i = 0; i < scripts.length; i += 1) { - if (scripts[i].sort !== i) { - batchUpdate[scripts[i].uuid] = { sort: i }; - scripts[i].sort = i; - changed.add(scripts[i].uuid); + return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => { + // 获取数据并排序 + const scripts = await this.scriptDAO.all(); + scripts.sort((a, b) => a.sort - b.sort); + const batchUpdate: Record> = {}; + const changed = new Set(); + for (let i = 0; i < scripts.length; i += 1) { + if (scripts[i].sort !== i) { + batchUpdate[scripts[i].uuid] = { sort: i }; + scripts[i].sort = i; + changed.add(scripts[i].uuid); + } } - } - if (changed.size) { - await this.scriptDAO.updates(batchUpdate); - const sortUpdatetime = Date.now(); - this.mq.publish( - "sortedScripts", - scripts.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) - ); - } - return scripts; + if (changed.size) { + await this.scriptDAO.updates(batchUpdate); + const sortUpdatetime = Date.now(); + this.mq.publish( + "sortedScripts", + scripts.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) + ); + } + return scripts; + }); } async getScriptAndCode(uuid: string) { @@ -1551,78 +1554,82 @@ export class ScriptService { // 脚本排序,after为排序后的uuid列表 async sortScript({ after }: { before: string[]; after: string[] }) { - const daoAll = await this.scriptDAO.all(); - const scripts = daoAll.sort((a, b) => a.sort - b.sort); - const sortingMap: Map = new Map(after.map((uuid, index) => [uuid, index])); + return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => { + const daoAll = await this.scriptDAO.all(); + const scripts = daoAll.sort((a, b) => a.sort - b.sort); + const sortingMap: Map = new Map(after.map((uuid, index) => [uuid, index])); + + // 排序 scripts 并更新 sort 字段 + const batchUpdate: Record> = {}; + const sortUpdatetime = Date.now(); + const changed = new Set(); + + const newList = ( + await Promise.all( + scripts.map(async (script) => { + const newSort = sortingMap.get(script.uuid); + if (newSort !== undefined && script.sort !== newSort) { + batchUpdate[script.uuid] = { sort: newSort }; + script.sort = newSort; + changed.add(script.uuid); + } + return script; + }) + ) + ).sort((a, b) => a.sort - b.sort); - // 排序 scripts 并更新 sort 字段 - const batchUpdate: Record> = {}; - const sortUpdatetime = Date.now(); - const changed = new Set(); + await this.scriptDAO.updates(batchUpdate); - const newList = ( - await Promise.all( - scripts.map(async (script) => { - const newSort = sortingMap.get(script.uuid); - if (newSort !== undefined && script.sort !== newSort) { + this.mq.publish( + "sortedScripts", + newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) + ); + }); + } + + // 将指定 uuid 列表的脚本置顶,其他脚本排序不变 + async pinToTop(uuids: string[]) { + return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => { + const daoAll = await this.scriptDAO.all(); + const sortingMap: Map = new Map(uuids.map((uuid, index) => [uuid, index])); + // 排序 scripts 并更新 sort 字段 + const scripts = daoAll.sort((a, b) => { + // 将 sortingMap 中有的 uuid 放在前面,其他的放在后面,且保持原有顺序 + const aIndex = sortingMap.get(a.uuid); + const bIndex = sortingMap.get(b.uuid); + if (aIndex !== undefined && bIndex !== undefined) { + return aIndex - bIndex; + } else if (aIndex !== undefined) { + return -1; + } else if (bIndex !== undefined) { + return 1; + } else { + return a.sort - b.sort; + } + }); + + const batchUpdate: Record> = {}; + const sortUpdatetime = Date.now(); + const changed = new Set(); + + const newList = await Promise.all( + scripts.map(async (script, index) => { + const newSort = index; + if (script.sort !== newSort) { batchUpdate[script.uuid] = { sort: newSort }; script.sort = newSort; changed.add(script.uuid); } return script; }) - ) - ).sort((a, b) => a.sort - b.sort); - - await this.scriptDAO.updates(batchUpdate); - - this.mq.publish( - "sortedScripts", - newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) - ); - } + ); + await this.scriptDAO.updates(batchUpdate); - // 将指定 uuid 列表的脚本置顶,其他脚本排序不变 - async pinToTop(uuids: string[]) { - const daoAll = await this.scriptDAO.all(); - const sortingMap: Map = new Map(uuids.map((uuid, index) => [uuid, index])); - // 排序 scripts 并更新 sort 字段 - const scripts = daoAll.sort((a, b) => { - // 将 sortingMap 中有的 uuid 放在前面,其他的放在后面,且保持原有顺序 - const aIndex = sortingMap.get(a.uuid); - const bIndex = sortingMap.get(b.uuid); - if (aIndex !== undefined && bIndex !== undefined) { - return aIndex - bIndex; - } else if (aIndex !== undefined) { - return -1; - } else if (bIndex !== undefined) { - return 1; - } else { - return a.sort - b.sort; - } + this.mq.publish( + "sortedScripts", + newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) + ); }); - - const batchUpdate: Record> = {}; - const sortUpdatetime = Date.now(); - const changed = new Set(); - - const newList = await Promise.all( - scripts.map(async (script, index) => { - const newSort = index; - if (script.sort !== newSort) { - batchUpdate[script.uuid] = { sort: newSort }; - script.sort = newSort; - changed.add(script.uuid); - } - return script; - }) - ); - await this.scriptDAO.updates(batchUpdate); - - this.mq.publish( - "sortedScripts", - newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) })) - ); } importByUrl(url: string) { diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index f20df59df..79d2bcd20 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 type { FileInfo } from "@Packages/filesystem/filesystem"; import { FileSystemError } from "@Packages/filesystem/error"; import type { CloudSyncConfig, SystemConfig } from "@App/pkg/config/config"; import type { ScriptDAO } from "@App/app/repo/scripts"; @@ -694,6 +695,109 @@ describe("SynchronizeService", () => { await expect((service as any).storage.get("pending_sort_status")).resolves.toEqual({}); }); + it("脚本删除后不应永久保留无主的排序待同步状态", async () => { + 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([syncFile]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ version: "1.0.0", status: { scripts: {} } })), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + await service.scriptsSorted([{ uuid: "deleted", sort: 0, sortUpdatetime: 200 }]); + await service.syncOnce(syncConfig, fs); + + await expect((service as any).storage.get("pending_sort_status")).resolves.toEqual({}); + }); + + it("失败脚本的同钟不同排序不得误清除排序待同步状态", async () => { + 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: "u1.meta.json", + path: "u1.meta.json", + size: 1, + digest: "meta-digest", + createtime: 1, + updatetime: 1, + }, + syncFile, + ]), + open: vi.fn().mockImplementation(async (file: FileInfo) => ({ + read: vi.fn().mockResolvedValue( + file.name === "u1.meta.json" + ? JSON.stringify({ uuid: "u1" }) + : JSON.stringify({ + version: "1.0.0", + status: { scripts: { u1: { enable: true, sort: 8, updatetime: 300, sortUpdatetime: 200 } } }, + }) + ), + })), + 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: "u1", name: "t", updatetime: 300, createtime: 1, status: 1, sort: 1, metadata: {} }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockRejectedValue(new Error("push failed")); + const now = vi.spyOn(Date, "now").mockReturnValue(200); + + try { + await service.scriptsSorted([{ uuid: "u1", sort: 1, sortUpdatetime: 200 }]); + await service.syncOnce(syncConfig, fs); + } finally { + now.mockRestore(); + } + + await expect((service as any).storage.get("pending_sort_status")).resolves.toEqual({ + u1: { sort: 1, sortUpdatetime: 200 }, + }); + }); + it("写回 scriptcat-sync.json 时远端已删除的 uuid 不应被复活", async () => { const initialStatus = { enable: true, sort: 1, updatetime: 100 }; const writeMock = vi.fn().mockResolvedValue(undefined); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index eaa7a42aa..c63f8684c 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -29,7 +29,7 @@ import { CLOUD_SYNC_STATE_KEY, DEFAULT_CLOUD_SYNC_STATE, } from "@App/pkg/config/config"; -import type { TDeleteScript, TInstallScript, TInstallScriptParams } from "../queue"; +import { CLOUD_SYNC_QUEUE_KEY, type TDeleteScript, type TInstallScript, type TInstallScriptParams } from "../queue"; import { errorMsg, makeBlobURL } from "@App/pkg/utils/utils"; import { t } from "i18next"; import ChromeStorage from "@App/pkg/config/chrome_storage"; @@ -136,7 +136,6 @@ class SyncBothChangedConflictError extends Error { type PendingSyncOp = { op: "delete"; syncDelete: boolean } | { op: "push" }; type PendingSyncOps = { [uuid: string]: PendingSyncOp }; -const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; const PENDING_SYNC_OPS_KEY = "pending_sync_ops"; const PENDING_SORT_STATUS_KEY = "pending_sort_status"; const LAST_NOTIFIED_CONFLICT_KEY = "last_notified_sync_conflicts"; @@ -489,7 +488,7 @@ export class SynchronizeService { // 同步一次 async syncOnce(syncConfig: CloudSyncConfig, fs: FileSystem) { - return stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => { // 设备本地同步状态:开始置 syncing,结束写入计数/时间或错误,供设置页状态条展示。 // 读旧值与写 syncing 不能 await 在 syncOnceInternal 之前,否则存储 I/O 会推迟内部起始时序(见测试的微任务门控)。 const prevStatePromise = this.storage.get(CLOUD_SYNC_STATE_KEY).then(async (prev) => { @@ -865,6 +864,7 @@ export class SynchronizeService { if (syncConfig.syncStatus && canWriteScriptcatSync) { try { const scriptlist = await this.scriptDAO.all(); + const activeScriptUuids = new Set(scriptlist.map((script) => script.uuid)); await Promise.allSettled( scriptlist.map(async (script) => { if (failedSyncUuids.has(script.uuid)) { @@ -943,9 +943,13 @@ export class SynchronizeService { const remainingPendingSortStatus: PendingSortStatus = {}; for (const [uuid, pending] of Object.entries(pendingSortStatus)) { if (!pending) continue; + if (!activeScriptUuids.has(uuid)) continue; const writtenStatus = scriptcatSync.status.scripts[uuid]; const writtenSortTime = writtenStatus?.sortUpdatetime ?? writtenStatus?.updatetime ?? 0; - if (writtenSortTime < pending.sortUpdatetime) { + const sortWasWritten = + writtenSortTime > pending.sortUpdatetime || + (writtenSortTime === pending.sortUpdatetime && writtenStatus?.sort === pending.sort); + if (!sortWasWritten) { remainingPendingSortStatus[uuid] = pending; } } @@ -1150,7 +1154,7 @@ export class SynchronizeService { (item): item is TSortedScript & { sortUpdatetime: number } => item.sortUpdatetime !== undefined ); if (!changed.length) return; - await stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + await stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => { const pending = await this.getPendingSortStatus(); const latest = Math.max( Date.now(), @@ -1441,7 +1445,7 @@ export class SynchronizeService { // 判断是否开启了同步 const config = await this.systemConfig.getCloudSync(); if (config.enable) { - stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => { const fs = await this.buildFileSystem(config); const script = params.script; try { @@ -1484,7 +1488,7 @@ export class SynchronizeService { // 判断是否开启了同步 const config = await this.systemConfig.getCloudSync(); if (config.enable) { - stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => { const fs = await this.buildFileSystem(config); // 写前登记删除意图:两步删除(删 .user.js + 写 tombstone/删 .meta.json)中途失败 // 或 SW 中途重启后,由 syncOnce 开头按登记重放,全部步骤成功才清除