Skip to content

Commit 3cb5600

Browse files
CodFrmcyfung1031
andauthored
🐛 修复多设备同步脚本排序错乱 (#1690)
* 🐛 修复多设备同步脚本排序错乱 * 🐛 修正排序同步的时序与 pending 清理 --------- Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
1 parent 310a16b commit 3cb5600

6 files changed

Lines changed: 512 additions & 91 deletions

File tree

docs/cloud-sync.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,16 @@ type PendingSyncOp = { op: "delete"; syncDelete: boolean } | { op: "push" };
255255

256256
`scriptcat-sync.json` 是 best-effort 状态同步,不是强事务。合并时遵守以下规则:
257257

258+
拖动排序和置顶不修改脚本内容的 `updatetime`。位置实际变化的脚本会在本地
259+
`pending_sort_status` 中记录同一次操作的 `sort``sortUpdatetime`;位置未变化的脚本不写入。
260+
该 pending 状态保存在扩展本地存储中,Service Worker 重启后仍可继续同步,并且只有在
261+
`scriptcat-sync.json` 成功写入对应或更新的排序时钟后才清除。
262+
263+
`scriptcat-sync.json` 中的 `sortUpdatetime` 是可选字段。存在该字段时,`enable` 继续由
264+
`updatetime` 决定,`sort` 则由 `sortUpdatetime` 决定,两个维度独立合并,避免一次启停覆盖
265+
另一台设备更新的顺序。旧文件双方都没有 `sortUpdatetime` 时,继续沿用整条 status 的
266+
`updatetime` LWW 规则;只有一侧具备新字段时,缺失侧以其 `updatetime` 作为排序时钟兼容读取。
267+
258268
1. 本轮文件同步失败的 uuid 保留云端原 status。
259269
2. 本轮刚 pull 的脚本保留云端 status,避免刚按云端更新后又写回本地旧状态。
260270
3. 本地状态更新时间更新时,候选写回本地 status。
@@ -451,5 +461,8 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name
451461
14. push 部分失败(`.user.js` 成功、`.meta.json` 失败)后,用生产形态的安装消息(不带 `updatetime`)验证下一轮仍会补传 `.meta.json`
452462
15. 删除部分失败(tombstone 未写 / `.meta.json` 残留)后,下一轮(含 SW 重启)自动完成剩余步骤;删除全失败后不得把脚本拉回本地。
453463
16. 源码未变但云端 `.meta.json` digest 变化时,必须读取采用而不是盖章跳过。
464+
17. 拖动排序不得修改脚本内容 `updatetime`,只为位置变化的脚本登记统一且单调推进的
465+
`sortUpdatetime`;下一轮同步应让较新的本地排序覆盖旧云端排序,同时保留较新的启用状态。
466+
18. 排序 pending 在 Service Worker 重启后仍应存在;状态文件写入失败或某个 pending 尚未写入时不得误清。
454467

455468
真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。

src/app/service/queue.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export type TInstallScript = { script: TInstallScriptParams; update: boolean; up
2222

2323
export type TDeleteScript = { uuid: string; storageName: string; type: SCRIPT_TYPE; deleteBy?: InstallSource };
2424

25-
export type TSortedScript = { uuid: string; sort: number };
25+
export const CLOUD_SYNC_QUEUE_KEY = "cloud_sync_queue";
26+
27+
export type TSortedScript = { uuid: string; sort: number; sortUpdatetime?: number };
2628

2729
export type TInstallSubscribe = { subscribe: Subscribe };
2830

src/app/service/service_worker/script.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ import { SystemConfig } from "@App/pkg/config/config";
1313
import EventEmitter from "eventemitter3";
1414
import type { ValueService } from "./value";
1515
import type { ResourceService } from "./resource";
16-
import type { TDeleteScript, TInstallScript } from "@App/app/service/queue";
16+
import type { TDeleteScript, TInstallScript, TSortedScript } from "@App/app/service/queue";
17+
import { CLOUD_SYNC_QUEUE_KEY } from "@App/app/service/queue";
1718
import { createMockOPFS } from "@App/app/repo/test-helpers";
1819
import type { Group } from "@Packages/message/server";
1920
import type { IMessageQueue } from "@Packages/message/message_queue";
2021
import type { MessageSend } from "@Packages/message/types";
2122
import { ScriptClient } from "./client";
2223
import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata";
2324
import { BatchUpdateListActionCode } from "./types";
25+
import { stackAsyncTask } from "@App/pkg/utils/async_queue";
2426

2527
initTestEnv();
2628

@@ -108,6 +110,140 @@ describe("ScriptService.purgeScripts —— 彻底删除", () => {
108110
});
109111
});
110112

113+
describe("ScriptService.sortScript", () => {
114+
beforeEach(async () => {
115+
await resetActiveScriptData();
116+
});
117+
118+
it("拖动排序只更新位置变化的脚本并发布排序更新时间", async () => {
119+
const { service, scriptDAO, mq } = buildService();
120+
await scriptDAO.save(makeScript({ uuid: "first", sort: 0, updatetime: 100 }));
121+
await scriptDAO.save(makeScript({ uuid: "second", sort: 1, updatetime: 1_000 }));
122+
const sorted: TSortedScript[][] = [];
123+
mq.subscribe<TSortedScript[]>("sortedScripts", (value) => void sorted.push(value));
124+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
125+
126+
try {
127+
await service.sortScript({ before: ["first", "second"], after: ["second", "first"] });
128+
} finally {
129+
now.mockRestore();
130+
}
131+
132+
await expect(scriptDAO.get("first")).resolves.toMatchObject({ sort: 1, updatetime: 100 });
133+
await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 0, updatetime: 1_000 });
134+
expect(sorted[0]).toEqual([
135+
{ uuid: "second", sort: 0, sortUpdatetime: 1_000 },
136+
{ uuid: "first", sort: 1, sortUpdatetime: 1_000 },
137+
]);
138+
});
139+
140+
it("拖动部分列表时不写入位置未变化的脚本", async () => {
141+
const { service, scriptDAO } = buildService();
142+
for (let index = 0; index < 4; index += 1) {
143+
await scriptDAO.save(makeScript({ uuid: `script-${index}`, sort: index, updatetime: 100 + index }));
144+
}
145+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
146+
147+
try {
148+
await service.sortScript({
149+
before: ["script-0", "script-1", "script-2", "script-3"],
150+
after: ["script-1", "script-0", "script-2", "script-3"],
151+
});
152+
} finally {
153+
now.mockRestore();
154+
}
155+
156+
await expect(scriptDAO.get("script-1")).resolves.toMatchObject({ sort: 0, updatetime: 101 });
157+
await expect(scriptDAO.get("script-0")).resolves.toMatchObject({ sort: 1, updatetime: 100 });
158+
await expect(scriptDAO.get("script-2")).resolves.toMatchObject({ sort: 2, updatetime: 102 });
159+
await expect(scriptDAO.get("script-3")).resolves.toMatchObject({ sort: 3, updatetime: 103 });
160+
});
161+
162+
it("全量同步进行时排序 mutation 不应穿插执行", async () => {
163+
const { service, scriptDAO } = buildService();
164+
await scriptDAO.save(makeScript({ uuid: "first", sort: 0 }));
165+
await scriptDAO.save(makeScript({ uuid: "second", sort: 1 }));
166+
const allSpy = vi.spyOn(scriptDAO, "all");
167+
let releaseSync!: () => void;
168+
const syncGate = new Promise<void>((resolve) => {
169+
releaseSync = resolve;
170+
});
171+
const syncPromise = stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, () => syncGate);
172+
let sortResolved = false;
173+
const sortPromise = service.sortScript({ before: ["first", "second"], after: ["second", "first"] }).then(() => {
174+
sortResolved = true;
175+
});
176+
177+
await Promise.resolve();
178+
expect(allSpy).not.toHaveBeenCalled();
179+
expect(sortResolved).toBe(false);
180+
181+
releaseSync();
182+
await Promise.all([syncPromise, sortPromise]);
183+
expect(allSpy).toHaveBeenCalledTimes(1);
184+
await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 0 });
185+
});
186+
});
187+
188+
describe("ScriptService.getAllScripts", () => {
189+
beforeEach(async () => {
190+
await resetActiveScriptData();
191+
});
192+
193+
it("规范化旧排序时只登记位置变化的脚本", async () => {
194+
const { service, scriptDAO, mq } = buildService();
195+
await scriptDAO.save(makeScript({ uuid: "first", sort: -1, updatetime: 100 }));
196+
await scriptDAO.save(makeScript({ uuid: "second", sort: 1, updatetime: 200 }));
197+
const sorted: TSortedScript[][] = [];
198+
mq.subscribe<TSortedScript[]>("sortedScripts", (value) => void sorted.push(value));
199+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
200+
201+
try {
202+
await service.getAllScripts();
203+
} finally {
204+
now.mockRestore();
205+
}
206+
207+
await expect(scriptDAO.get("first")).resolves.toMatchObject({ sort: 0, updatetime: 100 });
208+
await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 1, updatetime: 200 });
209+
expect(sorted[0]).toEqual([
210+
{ uuid: "first", sort: 0, sortUpdatetime: 1_000 },
211+
{ uuid: "second", sort: 1 },
212+
]);
213+
});
214+
});
215+
216+
describe("ScriptService.pinToTop", () => {
217+
beforeEach(async () => {
218+
await resetActiveScriptData();
219+
});
220+
221+
it("置顶只更新位置变化的脚本并发布同一个排序更新时间", async () => {
222+
const { service, scriptDAO, mq } = buildService();
223+
await scriptDAO.save(makeScript({ uuid: "first", sort: 0, updatetime: 100 }));
224+
await scriptDAO.save(makeScript({ uuid: "second", sort: 1, updatetime: 200 }));
225+
await scriptDAO.save(makeScript({ uuid: "third", sort: 2, updatetime: 300 }));
226+
const sorted: TSortedScript[][] = [];
227+
mq.subscribe<TSortedScript[]>("sortedScripts", (value) => void sorted.push(value));
228+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
229+
230+
try {
231+
await service.pinToTop(["second"]);
232+
} finally {
233+
now.mockRestore();
234+
}
235+
236+
await expect(scriptDAO.get("first")).resolves.toMatchObject({ sort: 1, updatetime: 100 });
237+
await expect(scriptDAO.get("second")).resolves.toMatchObject({ sort: 0, updatetime: 200 });
238+
await expect(scriptDAO.get("third")).resolves.toMatchObject({ sort: 2, updatetime: 300 });
239+
expect(sorted[0]).toEqual([
240+
{ uuid: "second", sort: 0, sortUpdatetime: 1_000 },
241+
{ uuid: "first", sort: 1, sortUpdatetime: 1_000 },
242+
{ uuid: "third", sort: 2 },
243+
]);
244+
});
245+
});
246+
111247
describe("ScriptService.deleteScripts —— 进回收站", () => {
112248
beforeEach(async () => {
113249
await resetActiveScriptData();

src/app/service/service_worker/script.ts

Lines changed: 90 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
TSortedScript,
3636
TInstallScriptParams,
3737
} from "../queue";
38+
import { CLOUD_SYNC_QUEUE_KEY } from "../queue";
3839
import { buildScriptRunResourceBasic, selfMetadataUpdate } from "./utils";
3940
import {
4041
BatchUpdateListActionCode,
@@ -1522,16 +1523,29 @@ export class ScriptService {
15221523
}
15231524

15241525
async getAllScripts() {
1525-
// 获取数据并排序
1526-
const scripts = await this.scriptDAO.all();
1527-
scripts.sort((a, b) => a.sort - b.sort);
1528-
for (let i = 0; i < scripts.length; i += 1) {
1529-
if (scripts[i].sort !== i) {
1530-
this.scriptDAO.update(scripts[i].uuid, { sort: i });
1531-
scripts[i].sort = i;
1526+
return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => {
1527+
// 获取数据并排序
1528+
const scripts = await this.scriptDAO.all();
1529+
scripts.sort((a, b) => a.sort - b.sort);
1530+
const batchUpdate: Record<string, Partial<Script>> = {};
1531+
const changed = new Set<string>();
1532+
for (let i = 0; i < scripts.length; i += 1) {
1533+
if (scripts[i].sort !== i) {
1534+
batchUpdate[scripts[i].uuid] = { sort: i };
1535+
scripts[i].sort = i;
1536+
changed.add(scripts[i].uuid);
1537+
}
15321538
}
1533-
}
1534-
return scripts;
1539+
if (changed.size) {
1540+
await this.scriptDAO.updates(batchUpdate);
1541+
const sortUpdatetime = Date.now();
1542+
this.mq.publish<TSortedScript[]>(
1543+
"sortedScripts",
1544+
scripts.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) }))
1545+
);
1546+
}
1547+
return scripts;
1548+
});
15351549
}
15361550

15371551
async getScriptAndCode(uuid: string) {
@@ -1540,72 +1554,82 @@ export class ScriptService {
15401554

15411555
// 脚本排序,after为排序后的uuid列表
15421556
async sortScript({ after }: { before: string[]; after: string[] }) {
1543-
const daoAll = await this.scriptDAO.all();
1544-
const scripts = daoAll.sort((a, b) => a.sort - b.sort);
1545-
const sortingMap: Map<string, number> = new Map(after.map((uuid, index) => [uuid, index]));
1546-
1547-
// 排序 scripts 并更新 sort 字段
1548-
const batchUpdate: Record<string, Partial<Script>> = {};
1549-
1550-
const newList = (
1551-
await Promise.all(
1552-
scripts.map(async (script) => {
1553-
const newSort = sortingMap.get(script.uuid);
1554-
if (newSort !== undefined && script.sort !== newSort) {
1555-
batchUpdate[script.uuid] = { sort: newSort };
1556-
script.sort = newSort;
1557-
}
1558-
return script;
1559-
})
1560-
)
1561-
).sort((a, b) => a.sort - b.sort);
1557+
return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => {
1558+
const daoAll = await this.scriptDAO.all();
1559+
const scripts = daoAll.sort((a, b) => a.sort - b.sort);
1560+
const sortingMap: Map<string, number> = new Map(after.map((uuid, index) => [uuid, index]));
1561+
1562+
// 排序 scripts 并更新 sort 字段
1563+
const batchUpdate: Record<string, Partial<Script>> = {};
1564+
const sortUpdatetime = Date.now();
1565+
const changed = new Set<string>();
1566+
1567+
const newList = (
1568+
await Promise.all(
1569+
scripts.map(async (script) => {
1570+
const newSort = sortingMap.get(script.uuid);
1571+
if (newSort !== undefined && script.sort !== newSort) {
1572+
batchUpdate[script.uuid] = { sort: newSort };
1573+
script.sort = newSort;
1574+
changed.add(script.uuid);
1575+
}
1576+
return script;
1577+
})
1578+
)
1579+
).sort((a, b) => a.sort - b.sort);
15621580

1563-
await this.scriptDAO.updates(batchUpdate);
1581+
await this.scriptDAO.updates(batchUpdate);
15641582

1565-
this.mq.publish<TSortedScript[]>(
1566-
"sortedScripts",
1567-
newList.map(({ uuid, sort }) => ({ uuid, sort }))
1568-
);
1583+
this.mq.publish<TSortedScript[]>(
1584+
"sortedScripts",
1585+
newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) }))
1586+
);
1587+
});
15691588
}
15701589

15711590
// 将指定 uuid 列表的脚本置顶,其他脚本排序不变
15721591
async pinToTop(uuids: string[]) {
1573-
const daoAll = await this.scriptDAO.all();
1574-
const sortingMap: Map<string, number> = new Map(uuids.map((uuid, index) => [uuid, index]));
1575-
// 排序 scripts 并更新 sort 字段
1576-
const scripts = daoAll.sort((a, b) => {
1577-
// 将 sortingMap 中有的 uuid 放在前面,其他的放在后面,且保持原有顺序
1578-
const aIndex = sortingMap.get(a.uuid);
1579-
const bIndex = sortingMap.get(b.uuid);
1580-
if (aIndex !== undefined && bIndex !== undefined) {
1581-
return aIndex - bIndex;
1582-
} else if (aIndex !== undefined) {
1583-
return -1;
1584-
} else if (bIndex !== undefined) {
1585-
return 1;
1586-
} else {
1587-
return a.sort - b.sort;
1588-
}
1589-
});
1592+
return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => {
1593+
const daoAll = await this.scriptDAO.all();
1594+
const sortingMap: Map<string, number> = new Map(uuids.map((uuid, index) => [uuid, index]));
1595+
// 排序 scripts 并更新 sort 字段
1596+
const scripts = daoAll.sort((a, b) => {
1597+
// 将 sortingMap 中有的 uuid 放在前面,其他的放在后面,且保持原有顺序
1598+
const aIndex = sortingMap.get(a.uuid);
1599+
const bIndex = sortingMap.get(b.uuid);
1600+
if (aIndex !== undefined && bIndex !== undefined) {
1601+
return aIndex - bIndex;
1602+
} else if (aIndex !== undefined) {
1603+
return -1;
1604+
} else if (bIndex !== undefined) {
1605+
return 1;
1606+
} else {
1607+
return a.sort - b.sort;
1608+
}
1609+
});
15901610

1591-
const batchUpdate: Record<string, Partial<Script>> = {};
1611+
const batchUpdate: Record<string, Partial<Script>> = {};
1612+
const sortUpdatetime = Date.now();
1613+
const changed = new Set<string>();
15921614

1593-
const newList = await Promise.all(
1594-
scripts.map(async (script, index) => {
1595-
const newSort = index;
1596-
if (script.sort !== newSort) {
1597-
batchUpdate[script.uuid] = { sort: newSort };
1598-
script.sort = newSort;
1599-
}
1600-
return script;
1601-
})
1602-
);
1603-
await this.scriptDAO.updates(batchUpdate);
1615+
const newList = await Promise.all(
1616+
scripts.map(async (script, index) => {
1617+
const newSort = index;
1618+
if (script.sort !== newSort) {
1619+
batchUpdate[script.uuid] = { sort: newSort };
1620+
script.sort = newSort;
1621+
changed.add(script.uuid);
1622+
}
1623+
return script;
1624+
})
1625+
);
1626+
await this.scriptDAO.updates(batchUpdate);
16041627

1605-
this.mq.publish<TSortedScript[]>(
1606-
"sortedScripts",
1607-
newList.map(({ uuid, sort }) => ({ uuid, sort }))
1608-
);
1628+
this.mq.publish<TSortedScript[]>(
1629+
"sortedScripts",
1630+
newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) }))
1631+
);
1632+
});
16091633
}
16101634

16111635
importByUrl(url: string) {

0 commit comments

Comments
 (0)