diff --git a/package.json b/package.json index 7f3e75e..43895b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@swan-io/indexed-db", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "description": "A resilient, Future-based key-value store for IndexedDB", "author": "Mathieu Acthernoene ", diff --git a/src/errors.ts b/src/errors.ts index 58e651a..ff6e8e9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -11,11 +11,14 @@ const iOSVersion = Lazy(() => { : -1; }); -const deriveError = ( - originalError: DOMException, - newMessage: string, -): DOMException => { - const newError = new DOMException(newMessage, originalError.name); +export const createError = (name: string, message: string): Error => { + const error = new Error(message); + error.name = name; + return error; +}; + +const deriveError = (originalError: Error, newMessage: string): Error => { + const newError = createError(originalError.name, newMessage); if (originalError.stack != null) { newError.stack = originalError.stack; @@ -24,9 +27,9 @@ const deriveError = ( return newError; }; -export const rewriteError = (error: DOMException | null): DOMException => { +export const rewriteError = (error: Error | null): Error => { if (error == null) { - return new DOMException("Unknown IndexedDB error", "UnknownError"); + return createError("UnknownError", "Unknown IndexedDB error"); } // https://github.com/firebase/firebase-js-sdk/blob/firebase%409.20.0/packages/firestore/src/local/simple_db.ts#L915 @@ -58,6 +61,6 @@ export const rewriteError = (error: DOMException | null): DOMException => { return error; }; -export const isDatabaseClosedError = (error: DOMException) => +export const isDatabaseClosedError = (error: Error) => error.message.indexOf("The database connection is closing") >= 0 || error.message.indexOf("Can't start a transaction on a closed database") >= 0; diff --git a/src/futurify.ts b/src/futurify.ts index ccbd123..b1ee43b 100644 --- a/src/futurify.ts +++ b/src/futurify.ts @@ -1,13 +1,36 @@ import { Future, Result } from "@swan-io/boxed"; -import { rewriteError } from "./errors"; +import { createError, rewriteError } from "./errors"; export const futurify = ( request: IDBRequest, + operationName: string, timeout: number, -): Future> => +): Future> => Future.make((resolve) => { const transaction = request.transaction; - let timeoutId: NodeJS.Timeout | undefined; + + const timeoutId = setTimeout(() => { + if (request.readyState === "done") { + return; // request has already been aborted + } + + if (transaction == null) { + resolve( + Result.Error( + createError( + "TimeoutError", + `${operationName} IndexedDB request timed out`, + ), + ), + ); + } else { + // Throws if the transaction has already been committed or aborted. + // Triggers onerror listener with an AbortError DOMException. + Result.fromExecution(() => transaction.abort()).tapError( + (error) => resolve(Result.Error(error)), + ); + } + }, timeout); request.onsuccess = () => { clearTimeout(timeoutId); @@ -17,14 +40,4 @@ export const futurify = ( clearTimeout(timeoutId); resolve(Result.Error(rewriteError(request.error))); }; - - if (transaction != null) { - timeoutId = setTimeout(() => { - if (request.readyState !== "done") { - // Throws if the transaction has already been committed or aborted. - // Triggers onerror listener with an AbortError DOMException. - Result.fromExecution(() => transaction.abort()); - } - }, timeout); - } }); diff --git a/src/index.ts b/src/index.ts index e825ce0..d3d736f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,33 +19,22 @@ export const openStore = ( transactionTimeout = 500, } = options; - const databaseFuture = openDatabase( - databaseName, - storeName, - transactionTimeout, - ); - + const databaseFuture = openDatabase(databaseName, storeName); const inMemoryStore = getInMemoryStore(databaseName, storeName); return { getMany: ( keys: T[], - ): Future, DOMException>> => { + ): Future, Error>> => { return retry(transactionRetries, () => databaseFuture .flatMapOk((database) => - getStore( - database, - databaseName, - storeName, - "readonly", - transactionTimeout, - ), + getStore(database, databaseName, storeName, "readonly"), ) .flatMapOk((store) => Future.all( keys.map((key) => - futurify(store.get(key), transactionTimeout) + futurify(store.get(key), "getMany", transactionTimeout) .mapOk((value: unknown) => { if (!enableInMemoryFallback) { return value; @@ -79,24 +68,18 @@ export const openStore = ( setMany: ( object: Record, - ): Future> => { + ): Future> => { const entries = Dict.entries(object); return retry(transactionRetries, () => databaseFuture .flatMapOk((database) => - getStore( - database, - databaseName, - storeName, - "readwrite", - transactionTimeout, - ), + getStore(database, databaseName, storeName, "readwrite"), ) .flatMapOk((store) => Future.all( entries.map(([key, value]) => - futurify(store.put(value, key), transactionTimeout), + futurify(store.put(value, key), "setMany", transactionTimeout), ), ).map((results) => Result.all(results)), ), @@ -111,19 +94,15 @@ export const openStore = ( }); }, - clear: (): Future> => { + clear: (): Future> => { return retry(transactionRetries, () => databaseFuture .flatMapOk((database) => - getStore( - database, - databaseName, - storeName, - "readwrite", - transactionTimeout, - ), + getStore(database, databaseName, storeName, "readwrite"), ) - .flatMapOk((store) => futurify(store.clear(), transactionTimeout)), + .flatMapOk((store) => + futurify(store.clear(), "clear", transactionTimeout), + ), ).tapOk(() => { if (enableInMemoryFallback) { inMemoryStore.clear(); diff --git a/src/wrappers.ts b/src/wrappers.ts index 50c2344..6cca1ee 100644 --- a/src/wrappers.ts +++ b/src/wrappers.ts @@ -1,5 +1,5 @@ import { Future, Result } from "@swan-io/boxed"; -import { isDatabaseClosedError } from "./errors"; +import { createError, isDatabaseClosedError } from "./errors"; import { futurify } from "./futurify"; /** @@ -8,12 +8,12 @@ import { futurify } from "./futurify"; * @see https://bugs.webkit.org/show_bug.cgi?id=226547 * @see https://github.com/jakearchibald/safari-14-idb-fix */ -export const getFactory = (): Future> => { +export const getFactory = (): Future> => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (indexedDB == null) { return Future.value( Result.Error( - new DOMException("indexedDB global doesn't exist", "UnknownError"), + createError("UnknownError", "indexedDB global doesn't exist"), ), ); } @@ -45,10 +45,7 @@ export const getFactory = (): Future> => { resolve( Result.Error( - new DOMException( - "Couldn't list IndexedDB databases", - "TimeoutError", - ), + createError("TimeoutError", "Couldn't list IndexedDB databases"), ), ); } @@ -62,25 +59,30 @@ export const getFactory = (): Future> => { export const openDatabase = ( databaseName: string, storeName: string, - timeout: number, -): Future> => - getFactory().flatMapOk((factory) => { - const request = factory.open(databaseName); - - request.onupgradeneeded = () => { - request.result.createObjectStore(storeName); - }; +): Future> => + getFactory() + .flatMapOk((factory) => + Future.value( + Result.fromExecution(() => + factory.open(databaseName), + ), + ), + ) + .flatMapOk((request) => { + request.onupgradeneeded = () => { + request.result.createObjectStore(storeName); + }; - return futurify(request, timeout); - }); + return futurify(request, "openDatabase", 1000); + }); const getStoreRaw = ( database: IDBDatabase, storeName: string, transactionMode: IDBTransactionMode, -): Future> => +): Future> => Future.value( - Result.fromExecution(() => + Result.fromExecution(() => database.transaction(storeName, transactionMode).objectStore(storeName), ), ); @@ -90,12 +92,11 @@ export const getStore = ( databaseName: string, storeName: string, transactionMode: IDBTransactionMode, - timeout: number, -): Future> => +): Future> => getStoreRaw(database, storeName, transactionMode).flatMapError((error) => !isDatabaseClosedError(error) ? Future.value(Result.Error(error)) - : openDatabase(databaseName, storeName, timeout).flatMapOk((database) => + : openDatabase(databaseName, storeName).flatMapOk((database) => getStoreRaw(database, storeName, transactionMode), ), ); diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 3f25ee1..4acfaf0 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -1,19 +1,19 @@ import { expect, test } from "vitest"; -import { rewriteError } from "../src/errors"; +import { createError, rewriteError } from "../src/errors"; test("rewriteError returns an unknown Error if null is passed", () => { const rewrittenError = rewriteError(null); - expect(rewrittenError).toBeInstanceOf(DOMException); + expect(rewrittenError).toBeInstanceOf(Error); expect(rewrittenError.name).toBe("UnknownError"); expect(rewrittenError.message).toBe("Unknown IndexedDB error"); }); test("rewriteError add context in case of InvalidStateError", () => { - const error = new DOMException("NO_INITIAL_MESSAGE", "InvalidStateError"); + const error = createError("InvalidStateError", "NO_INITIAL_MESSAGE"); const rewrittenError = rewriteError(error); - expect(rewrittenError).toBeInstanceOf(DOMException); + expect(rewrittenError).toBeInstanceOf(Error); expect(rewrittenError.name).toBe("InvalidStateError"); expect(error.stack).toStrictEqual(rewrittenError.stack); @@ -26,14 +26,14 @@ test("rewriteError add context in case of InvalidStateError", () => { }); test("rewriteError does nothing in case it seems to be an iOS 12.x error, but the platform doesn't match", () => { - const error = new DOMException( - "An internal error was encountered in the Indexed Database server", + const error = createError( "UnknownError", + "An internal error was encountered in the Indexed Database server", ); const rewrittenError = rewriteError(error); - expect(rewrittenError).toBeInstanceOf(DOMException); + expect(rewrittenError).toBeInstanceOf(Error); expect(rewrittenError.name).toBe("UnknownError"); expect(error.stack).toStrictEqual(rewrittenError.stack); diff --git a/tests/inMemoryStore.test.ts b/tests/inMemoryStore.test.ts index 67f930c..8dbd5b0 100644 --- a/tests/inMemoryStore.test.ts +++ b/tests/inMemoryStore.test.ts @@ -1,6 +1,7 @@ import { Result } from "@swan-io/boxed"; import { afterAll, beforeAll, expect, test, vi } from "vitest"; import { openStore } from "../src"; +import { createError } from "../src/errors"; beforeAll(() => { vi.stubGlobal("indexedDB", undefined); @@ -16,7 +17,7 @@ test("API stays usable thanks to in-memory store", async () => { }); expect(await store.setMany({ A: true })).toStrictEqual( - Result.Error(new DOMException("indexedDB global doesn't exist")), + Result.Error(createError("UnknownError", "indexedDB global doesn't exist")), ); expect(await store.getMany(["A", "B"])).toStrictEqual( @@ -24,7 +25,7 @@ test("API stays usable thanks to in-memory store", async () => { ); expect(await store.setMany({ B: true })).toStrictEqual( - Result.Error(new DOMException("indexedDB global doesn't exist")), + Result.Error(createError("UnknownError", "indexedDB global doesn't exist")), ); expect(await store.getMany(["A", "B"])).toStrictEqual( @@ -33,7 +34,7 @@ test("API stays usable thanks to in-memory store", async () => { // in-memory store will not be wiped if indexedDB clear failed expect(await store.clear()).toStrictEqual( - Result.Error(new DOMException("indexedDB global doesn't exist")), + Result.Error(createError("UnknownError", "indexedDB global doesn't exist")), ); expect(await store.getMany(["A", "B"])).toStrictEqual( diff --git a/tests/safari.test.ts b/tests/safari.test.ts index a5fc257..1279f62 100644 --- a/tests/safari.test.ts +++ b/tests/safari.test.ts @@ -1,6 +1,6 @@ import { Result } from "@swan-io/boxed"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; -import { rewriteError } from "../src/errors"; +import { createError, rewriteError } from "../src/errors"; import { getFactory } from "../src/wrappers"; const userAgents = { @@ -22,14 +22,14 @@ describe("Safari 12.2", () => { }); test("rewriteError add context in case of an unknown iOS 12.x error", () => { - const ios12Error = new DOMException( - "An internal error was encountered in the Indexed Database server", + const ios12Error = createError( "UnknownError", + "An internal error was encountered in the Indexed Database server", ); const rewrittenError = rewriteError(ios12Error); - expect(rewrittenError).toBeInstanceOf(DOMException); + expect(rewrittenError).toBeInstanceOf(Error); expect(rewrittenError.name).toBe("UnknownError"); expect(rewrittenError.stack).toStrictEqual(ios12Error.stack); @@ -61,7 +61,9 @@ describe("Safari 14.6 (unresponsive)", () => { const result = await getFactory(); expect(result).toStrictEqual( - Result.Error(new DOMException("Couldn't list IndexedDB databases")), + Result.Error( + createError("TimeoutError", "Couldn't list IndexedDB databases"), + ), ); }); }); @@ -137,7 +139,9 @@ describe("Safari 14.6 (responsive, but too late)", () => { const result = await getFactory(); expect(result).toStrictEqual( - Result.Error(new DOMException("Couldn't list IndexedDB databases")), + Result.Error( + createError("TimeoutError", "Couldn't list IndexedDB databases"), + ), ); }); });