feat(transfer): persist reusable transfer tasks
This commit is contained in:
1 parent
1a10db4c18
commit
05361eb40d
26 files changed
+2628
-241
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function installIndexedDb(transactionOutcome: "complete" | "abort") {
|
||||
const transaction: Record<string, any> = {
|
||||
error: null,
|
||||
oncomplete: null,
|
||||
onabort: null,
|
||||
onerror: null,
|
||||
};
|
||||
const store = {
|
||||
put(_value: unknown, key: IDBValidKey) {
|
||||
const request: Record<string, any> = { result: undefined, error: null, onsuccess: null, onerror: null };
|
||||
queueMicrotask(() => {
|
||||
request.result = key;
|
||||
request.onsuccess?.();
|
||||
queueMicrotask(() => {
|
||||
if (transactionOutcome === "complete") {
|
||||
transaction.oncomplete?.();
|
||||
} else {
|
||||
transaction.error = new Error("transaction aborted");
|
||||
transaction.onabort?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
return request as IDBRequest<IDBValidKey>;
|
||||
},
|
||||
};
|
||||
transaction.objectStore = () => store;
|
||||
const database = {
|
||||
transaction: () => transaction as IDBTransaction,
|
||||
};
|
||||
const indexedDB = {
|
||||
open() {
|
||||
const request: Record<string, any> = { result: database, error: null, onsuccess: null, onerror: null, onblocked: null, onupgradeneeded: null };
|
||||
queueMicrotask(() => request.onsuccess?.());
|
||||
return request as IDBOpenDBRequest;
|
||||
},
|
||||
};
|
||||
vi.stubGlobal("indexedDB", indexedDB);
|
||||
}
|
||||
|
||||
function installLocalStorage(setItem: (key: string, value: string) => void) {
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(setItem),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("saveBrowserAppState", () => {
|
||||
it("waits for transaction commit and falls back after an IndexedDB abort", async () => {
|
||||
installIndexedDb("abort");
|
||||
installLocalStorage(() => undefined);
|
||||
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
|
||||
|
||||
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).resolves.toBeUndefined();
|
||||
|
||||
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith("dbx-app-state:transfer_task_library", JSON.stringify({ version: 1 }));
|
||||
});
|
||||
|
||||
it("throws when localStorage quota prevents the fallback write", async () => {
|
||||
vi.stubGlobal("indexedDB", undefined);
|
||||
installLocalStorage(() => {
|
||||
throw new Error("quota exceeded");
|
||||
});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
|
||||
|
||||
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).rejects.toThrow("Failed to persist browser app state: transfer_task_library");
|
||||
});
|
||||
|
||||
it("throws when both IndexedDB and localStorage fail", async () => {
|
||||
installIndexedDb("abort");
|
||||
installLocalStorage(() => {
|
||||
throw new Error("storage disabled");
|
||||
});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
|
||||
|
||||
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).rejects.toThrow("Failed to persist browser app state: transfer_task_library");
|
||||
});
|
||||
});
|
||||
@@ -300,6 +300,8 @@ export const loadOpenTabsState = forward("loadOpenTabsState");
|
||||
export const saveOpenTabsState = forward("saveOpenTabsState");
|
||||
export const loadSavedSqlEditorPositions = forward("loadSavedSqlEditorPositions");
|
||||
export const saveSavedSqlEditorPositions = forward("saveSavedSqlEditorPositions");
|
||||
export const loadTransferTaskLibrary = forward("loadTransferTaskLibrary");
|
||||
export const saveTransferTaskLibrary = forward("saveTransferTaskLibrary");
|
||||
export const webdavSyncTest = forward("webdavSyncTest");
|
||||
export const webdavPasswordStatus = forward("webdavPasswordStatus");
|
||||
export const saveWebdavSavedPassword = forward("saveWebdavSavedPassword");
|
||||
|
||||
@@ -16,6 +16,14 @@ function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
});
|
||||
}
|
||||
|
||||
function transactionToPromise(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
|
||||
});
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase | null> | undefined;
|
||||
|
||||
function openDb(): Promise<IDBDatabase | null> {
|
||||
@@ -44,7 +52,13 @@ async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStor
|
||||
const db = await openDb();
|
||||
if (!db) return null;
|
||||
try {
|
||||
return await requestToPromise(run(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME)));
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
const request = requestToPromise(run(transaction.objectStore(STORE_NAME)));
|
||||
if (mode === "readwrite") {
|
||||
const [result] = await Promise.all([request, transactionToPromise(transaction)]);
|
||||
return result;
|
||||
}
|
||||
return await request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -66,5 +80,6 @@ export async function loadBrowserAppState(key: string): Promise<unknown | null>
|
||||
export async function saveBrowserAppState(key: string, value: unknown): Promise<void> {
|
||||
const result = await withStore("readwrite", (store) => store.put(value, key));
|
||||
if (result !== null) return;
|
||||
safeLocalStorageSet(fallbackKey(key), JSON.stringify(value));
|
||||
if (safeLocalStorageSet(fallbackKey(key), JSON.stringify(value))) return;
|
||||
throw new Error(`Failed to persist browser app state: ${key}`);
|
||||
}
|
||||
@@ -1678,6 +1678,14 @@ export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise
|
||||
await saveBrowserAppState("saved_sql_editor_positions", positions);
|
||||
}
|
||||
|
||||
export async function loadTransferTaskLibrary(): Promise<unknown | null> {
|
||||
return loadBrowserAppState("transfer_task_library");
|
||||
}
|
||||
|
||||
export async function saveTransferTaskLibrary(library: unknown): Promise<void> {
|
||||
await saveBrowserAppState("transfer_task_library", library);
|
||||
}
|
||||
|
||||
export async function completeAppClose(_action: "quit" | "hide"): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@ export function safeLocalStorageGet(key: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function safeLocalStorageSet(key: string, value: string) {
|
||||
export function safeLocalStorageSet(key: string, value: string): boolean {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(key, value);
|
||||
const storage = globalThis.localStorage;
|
||||
if (!storage) return false;
|
||||
storage.setItem(key, value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
storageWarn("set", key, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -613,6 +613,14 @@ export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise
|
||||
return invoke("save_saved_sql_editor_positions", { positions });
|
||||
}
|
||||
|
||||
export async function loadTransferTaskLibrary(): Promise<unknown | null> {
|
||||
return invoke("load_transfer_task_library");
|
||||
}
|
||||
|
||||
export async function saveTransferTaskLibrary(library: unknown): Promise<void> {
|
||||
return invoke("save_transfer_task_library", { library });
|
||||
}
|
||||
|
||||
export async function completeAppClose(action: "quit" | "hide"): Promise<void> {
|
||||
return invoke("complete_app_close", { action });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user