Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/cloud-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down Expand Up @@ -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 结果宣称为真实云端验证。
4 changes: 3 additions & 1 deletion src/app/service/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ 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 const CLOUD_SYNC_QUEUE_KEY = "cloud_sync_queue";

export type TSortedScript = { uuid: string; sort: number; sortUpdatetime?: number };

export type TInstallSubscribe = { subscribe: Subscribe };

Expand Down
138 changes: 137 additions & 1 deletion src/app/service/service_worker/script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ 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 { 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";
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();

Expand Down Expand Up @@ -108,6 +110,140 @@ 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<TSortedScript[]>("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 });
});

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<void>((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", () => {
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<TSortedScript[]>("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<TSortedScript[]>("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();
Expand Down
156 changes: 90 additions & 66 deletions src/app/service/service_worker/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
TSortedScript,
TInstallScriptParams,
} from "../queue";
import { CLOUD_SYNC_QUEUE_KEY } from "../queue";
import { buildScriptRunResourceBasic, selfMetadataUpdate } from "./utils";
import {
BatchUpdateListActionCode,
Expand Down Expand Up @@ -1522,16 +1523,29 @@ export class ScriptService {
}

async getAllScripts() {
// 获取数据并排序
const scripts = await this.scriptDAO.all();
scripts.sort((a, b) => a.sort - b.sort);
for (let i = 0; i < scripts.length; i += 1) {
if (scripts[i].sort !== i) {
this.scriptDAO.update(scripts[i].uuid, { sort: i });
scripts[i].sort = i;
return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => {
// 获取数据并排序
const scripts = await this.scriptDAO.all();
scripts.sort((a, b) => a.sort - b.sort);
const batchUpdate: Record<string, Partial<Script>> = {};
const changed = new Set<string>();
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 scripts;
if (changed.size) {
await this.scriptDAO.updates(batchUpdate);
const sortUpdatetime = Date.now();
this.mq.publish<TSortedScript[]>(
"sortedScripts",
scripts.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) }))
);
}
return scripts;
});
}

async getScriptAndCode(uuid: string) {
Expand All @@ -1540,72 +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<string, number> = new Map(after.map((uuid, index) => [uuid, index]));

// 排序 scripts 并更新 sort 字段
const batchUpdate: Record<string, Partial<Script>> = {};

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;
}
return script;
})
)
).sort((a, b) => a.sort - b.sort);
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<string, number> = new Map(after.map((uuid, index) => [uuid, index]));

// 排序 scripts 并更新 sort 字段
const batchUpdate: Record<string, Partial<Script>> = {};
const sortUpdatetime = Date.now();
const changed = new Set<string>();

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);

await this.scriptDAO.updates(batchUpdate);
await this.scriptDAO.updates(batchUpdate);

this.mq.publish<TSortedScript[]>(
"sortedScripts",
newList.map(({ uuid, sort }) => ({ uuid, sort }))
);
this.mq.publish<TSortedScript[]>(
"sortedScripts",
newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) }))
);
});
}

// 将指定 uuid 列表的脚本置顶,其他脚本排序不变
async pinToTop(uuids: string[]) {
const daoAll = await this.scriptDAO.all();
const sortingMap: Map<string, number> = 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;
}
});
return stackAsyncTask(CLOUD_SYNC_QUEUE_KEY, async () => {
const daoAll = await this.scriptDAO.all();
const sortingMap: Map<string, number> = 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<string, Partial<Script>> = {};
const batchUpdate: Record<string, Partial<Script>> = {};
const sortUpdatetime = Date.now();
const changed = new Set<string>();

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;
}
return script;
})
);
await this.scriptDAO.updates(batchUpdate);
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<TSortedScript[]>(
"sortedScripts",
newList.map(({ uuid, sort }) => ({ uuid, sort }))
);
this.mq.publish<TSortedScript[]>(
"sortedScripts",
newList.map(({ uuid, sort }) => ({ uuid, sort, ...(changed.has(uuid) ? { sortUpdatetime } : {}) }))
);
});
}

importByUrl(url: string) {
Expand Down
Loading
Loading