diff --git a/src/app/migrate.ts b/src/app/migrate.ts index 63244e79e..1f02a2500 100644 --- a/src/app/migrate.ts +++ b/src/app/migrate.ts @@ -267,25 +267,43 @@ export function migrateChromeStorage() { ); }, }, + { + version: 2, + upgrade: async () => { + const scriptCodeDAO = new ScriptCodeDAO(); + // 从 chrome.storage.local 读取所有旧代码 + const scriptCodes = await scriptCodeDAO.all(); + // 仅写入 OPFS,避免写放大(不通过 save() 回写 chrome.storage.local) + await Promise.all(scriptCodes.map(async (scriptCode) => scriptCodeDAO.saveToOPFS(scriptCode))); + }, + }, ]; const localstorageDAO = new LocalStorageDAO(); localstorageDAO.get("migrations").then(async (item) => { - const migrations = item?.value || []; + const migrations: number[] = item?.value || []; for (let i = 0; i < migrationList.length; i++) { const m = migrationList[i]; - if (!migrations.includes(m.version)) { - try { - await m.upgrade(); - migrations.push(m.version); - } catch (e) { - throw new Error(`Chrome storage migration v${m.version} failed: ${e}`); - } + if (migrations.includes(m.version)) { + continue; + } + // 保证顺序:前一个版本必须已完成 + if (i > 0 && !migrations.includes(migrationList[i - 1].version)) { + throw new Error( + `Chrome storage migration v${m.version} skipped: v${migrationList[i - 1].version} not completed` + ); + } + try { + await m.upgrade(); + migrations.push(m.version); + // 每步成功后立即持久化,避免 SW 挂起导致进度丢失 + await localstorageDAO.save({ + key: "migrations", + value: migrations, + }); + } catch (e) { + throw new Error(`Chrome storage migration v${m.version} failed: ${e}`); } } - localstorageDAO.save({ - key: "migrations", - value: migrations, - }); }); } diff --git a/src/app/repo/scripts.test.ts b/src/app/repo/scripts.test.ts index 54f040456..657e67812 100644 --- a/src/app/repo/scripts.test.ts +++ b/src/app/repo/scripts.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { ScriptDAO, + ScriptCodeDAO, type Script, SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, @@ -152,3 +153,71 @@ describe("ScriptDAO.searchExistingScript", () => { expect(found[0]).toBeUndefined(); }); }); + +describe("ScriptCodeDAO", () => { + let dao: ScriptCodeDAO; + + beforeEach(async () => { + await chrome.storage.local.clear(); + (globalThis as any).__clearOPFSMock?.(); + dao = new ScriptCodeDAO(); + }); + + it("save 后 get 应该从 OPFS 读取", async () => { + await dao.save({ uuid: "test-1", code: "alert('hello');" }); + const result = await dao.get("test-1"); + expect(result).toBeDefined(); + expect(result!.code).toBe("alert('hello');"); + }); + + it("get 不存在的脚本应返回 undefined", async () => { + const result = await dao.get("nonexistent"); + expect(result).toBeUndefined(); + }); + + it("OPFS 不存在时应 fallback 到 chrome.storage.local", async () => { + await chrome.storage.local.set({ "scriptCode:old-script": { uuid: "old-script", code: "old code" } }); + const result = await dao.get("old-script"); + expect(result).toBeDefined(); + expect(result!.code).toBe("old code"); + }); + + it("fallback 读取后应懒迁移到 OPFS", async () => { + await chrome.storage.local.set({ "scriptCode:lazy": { uuid: "lazy", code: "lazy code" } }); + await dao.get("lazy"); + // 等待懒迁移的异步写入完成 + await new Promise((resolve) => setTimeout(resolve, 0)); + await chrome.storage.local.clear(); + const dao2 = new ScriptCodeDAO(); + const result = await dao2.get("lazy"); + expect(result).toBeDefined(); + expect(result!.code).toBe("lazy code"); + }); + + it("delete 应同时删除 OPFS 和 chrome.storage.local", async () => { + await dao.save({ uuid: "del-test", code: "to delete" }); + await dao.delete("del-test"); + const result = await dao.get("del-test"); + expect(result).toBeUndefined(); + }); + + it("gets 应批量获取", async () => { + await dao.save({ uuid: "a", code: "code-a" }); + await dao.save({ uuid: "b", code: "code-b" }); + const results = await dao.gets(["a", "b", "c"]); + expect(results[0]?.code).toBe("code-a"); + expect(results[1]?.code).toBe("code-b"); + expect(results[2]).toBeUndefined(); + }); + + it("saveToOPFS 仅写 OPFS 不写 chrome.storage.local", async () => { + await dao.saveToOPFS({ uuid: "opfs-only", code: "opfs code" }); + const storageResult = await new Promise((resolve) => { + chrome.storage.local.get("scriptCode:opfs-only", resolve); + }); + expect(storageResult["scriptCode:opfs-only"]).toBeUndefined(); + const result = await dao.get("opfs-only"); + expect(result).toBeDefined(); + expect(result!.code).toBe("opfs code"); + }); +}); diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 6fcc2b86f..0ea510495 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -78,7 +78,7 @@ export interface Script { checktime: number; // 脚本检查更新时间戳 lastruntime?: number; // 脚本最后一次运行时间戳 nextruntime?: number; // 脚本下一次运行时间戳 - ignoreVersion?: string; // 忽略單一版本的更新檢查 + ignoreVersion?: string; // 忽略单一版本的更新检查 } // 分开存储脚本代码 @@ -147,33 +147,18 @@ export type TClientPageLoadInfo = | { ok: false }; export class ScriptDAO extends Repo