diff --git a/package.json b/package.json index 22343a2..7f3e75e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@swan-io/indexed-db", - "version": "0.1.1", + "version": "0.2.0", "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 5ec935f..58e651a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -57,3 +57,7 @@ export const rewriteError = (error: DOMException | null): DOMException => { return error; }; + +export const isDatabaseClosedError = (error: DOMException) => + 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/factory.ts b/src/factory.ts deleted file mode 100644 index 999f345..0000000 --- a/src/factory.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Future, Result } from "@swan-io/boxed"; - -/** - * Safari has a horrible bug where IndexedDB requests can hang forever. - * We resolve this future with error after 100ms if it seems to happen. - * @see https://bugs.webkit.org/show_bug.cgi?id=226547 - * @see https://github.com/jakearchibald/safari-14-idb-fix - */ -export const getIndexedDBFactory = (): Future< - Result -> => { - // 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"), - ), - ); - } - - const isSafari = - !navigator.userAgentData && - /Safari\//.test(navigator.userAgent) && - !/Chrom(e|ium)\//.test(navigator.userAgent); - - // No point putting other browsers or older versions of Safari through this mess. - if (!isSafari || !("databases" in indexedDB)) { - return Future.value(Result.Ok(indexedDB)); - } - - let intervalId: NodeJS.Timer; - let remainingAttempts = 10; - - return Future.make((resolve) => { - const tryToAccessIndexedDB = () => { - remainingAttempts = remainingAttempts - 1; - - if (remainingAttempts > 0) { - indexedDB.databases().finally(() => { - clearInterval(intervalId); - resolve(Result.Ok(indexedDB)); - }); - } else { - clearInterval(intervalId); - - resolve( - Result.Error( - new DOMException( - "Couldn't list IndexedDB databases", - "TimeoutError", - ), - ), - ); - } - }; - - intervalId = setInterval(tryToAccessIndexedDB, 100); - tryToAccessIndexedDB(); - }); -}; diff --git a/src/futurify.ts b/src/futurify.ts index 7962a0a..ccbd123 100644 --- a/src/futurify.ts +++ b/src/futurify.ts @@ -1,9 +1,8 @@ import { Future, Result } from "@swan-io/boxed"; import { rewriteError } from "./errors"; -export const futurifyRequest = ( +export const futurify = ( request: IDBRequest, - operationName: string, timeout: number, ): Future> => Future.make((resolve) => { @@ -20,66 +19,12 @@ export const futurifyRequest = ( }; if (transaction != null) { - const resolveAfterAbort = () => - resolve( - Result.Error( - new DOMException( - `${operationName} IndexedDB request timed out`, - "TimeoutError", - ), - ), - ); - timeoutId = setTimeout(() => { - Result.fromExecution(() => { - transaction.abort(); - }).tapError(() => { - resolveAfterAbort(); - }); + 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); - - transaction.onabort = () => { - clearTimeout(timeoutId); - resolveAfterAbort(); - }; } }); - -export const futurifyTransaction = ( - transaction: IDBTransaction, - operationName: string, - timeout: number, -): Future> => - Future.make((resolve) => { - transaction.oncomplete = () => { - clearTimeout(timeoutId); - resolve(Result.Ok(undefined)); - }; - transaction.onerror = () => { - clearTimeout(timeoutId); - resolve(Result.Error(rewriteError(transaction.error))); - }; - - const resolveAfterAbort = () => - resolve( - Result.Error( - new DOMException( - `${operationName} IndexedDB transaction timed out`, - "TimeoutError", - ), - ), - ); - - const timeoutId = setTimeout(() => { - Result.fromExecution(() => { - transaction.abort(); - }).tapError(() => { - resolveAfterAbort(); - }); - }, timeout); - - transaction.onabort = () => { - clearTimeout(timeoutId); - resolveAfterAbort(); - }; - }); diff --git a/src/index.ts b/src/index.ts index c836feb..e825ce0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ import { Dict, Future, Result } from "@swan-io/boxed"; -import { rewriteError } from "./errors"; -import { getIndexedDBFactory } from "./factory"; -import { futurifyRequest, futurifyTransaction } from "./futurify"; +import { futurify } from "./futurify"; import { retry, zipToObject } from "./helpers"; import { getInMemoryStore } from "./inMemoryStore"; +import { getStore, openDatabase } from "./wrappers"; export const openStore = ( databaseName: string, @@ -20,57 +19,52 @@ export const openStore = ( transactionTimeout = 500, } = options; - const inMemoryStore = getInMemoryStore(databaseName, storeName); - - const databaseFuture = getIndexedDBFactory().flatMapOk((factory) => { - const request = factory.open(databaseName); - - request.onupgradeneeded = () => { - request.result.createObjectStore(storeName); - }; + const databaseFuture = openDatabase( + databaseName, + storeName, + transactionTimeout, + ); - return futurifyRequest(request, "openDatabase", transactionTimeout); - }); - - const getObjectStore = ( - transactionMode: IDBTransactionMode, - ): Future> => - databaseFuture.mapOkToResult((database) => - Result.fromExecution(() => - database.transaction(storeName, transactionMode).objectStore(storeName), - ).mapError((error) => - rewriteError(error instanceof DOMException ? error : null), - ), - ); + const inMemoryStore = getInMemoryStore(databaseName, storeName); return { getMany: ( keys: T[], ): Future, DOMException>> => { return retry(transactionRetries, () => - getObjectStore("readonly").flatMapOk((store) => - Future.all( - keys.map((key) => - futurifyRequest(store.get(key), "getMany", transactionTimeout) - .mapOk((value: unknown) => { - if (!enableInMemoryFallback) { - return value; - } - if (typeof value === "undefined") { - return inMemoryStore.get(key); - } - - inMemoryStore.set(key, value); - return value; - }) - .mapErrorToResult((error) => - enableInMemoryFallback - ? Result.Ok(inMemoryStore.get(key)) - : Result.Error(error), - ), + databaseFuture + .flatMapOk((database) => + getStore( + database, + databaseName, + storeName, + "readonly", + transactionTimeout, ), - ).map((results) => Result.all(results)), - ), + ) + .flatMapOk((store) => + Future.all( + keys.map((key) => + futurify(store.get(key), transactionTimeout) + .mapOk((value: unknown) => { + if (!enableInMemoryFallback) { + return value; + } + if (typeof value === "undefined") { + return inMemoryStore.get(key); + } + + inMemoryStore.set(key, value); + return value; + }) + .mapErrorToResult((error) => + enableInMemoryFallback + ? Result.Ok(inMemoryStore.get(key)) + : Result.Error(error), + ), + ), + ).map((results) => Result.all(results)), + ), ) .mapOk((values) => zipToObject(keys, values)) .mapErrorToResult((error) => { @@ -85,39 +79,51 @@ export const openStore = ( setMany: ( object: Record, - ): Future> => { + ): Future> => { const entries = Dict.entries(object); return retry(transactionRetries, () => - getObjectStore("readwrite").flatMapOk((store) => { - entries.forEach(([key, value]) => store.put(value, key)); - - return futurifyTransaction( - store.transaction, - "setMany", - transactionTimeout, - ); - }), - ).tap(() => { - if (enableInMemoryFallback) { - entries.forEach(([key, value]) => { - inMemoryStore.set(key, value); - }); - } - }); + databaseFuture + .flatMapOk((database) => + getStore( + database, + databaseName, + storeName, + "readwrite", + transactionTimeout, + ), + ) + .flatMapOk((store) => + Future.all( + entries.map(([key, value]) => + futurify(store.put(value, key), transactionTimeout), + ), + ).map((results) => Result.all(results)), + ), + ) + .mapOk(() => undefined) + .tap(() => { + if (enableInMemoryFallback) { + entries.forEach(([key, value]) => { + inMemoryStore.set(key, value); + }); + } + }); }, - clear: (): Future> => { + clear: (): Future> => { return retry(transactionRetries, () => - getObjectStore("readwrite").flatMapOk((store) => { - store.clear(); - - return futurifyTransaction( - store.transaction, - "clear", - transactionTimeout, - ); - }), + databaseFuture + .flatMapOk((database) => + getStore( + database, + databaseName, + storeName, + "readwrite", + transactionTimeout, + ), + ) + .flatMapOk((store) => futurify(store.clear(), transactionTimeout)), ).tapOk(() => { if (enableInMemoryFallback) { inMemoryStore.clear(); diff --git a/src/wrappers.ts b/src/wrappers.ts new file mode 100644 index 0000000..50c2344 --- /dev/null +++ b/src/wrappers.ts @@ -0,0 +1,101 @@ +import { Future, Result } from "@swan-io/boxed"; +import { isDatabaseClosedError } from "./errors"; +import { futurify } from "./futurify"; + +/** + * Safari has a horrible bug where IndexedDB requests can hang forever. + * We resolve this future with error after 100ms if it seems to happen. + * @see https://bugs.webkit.org/show_bug.cgi?id=226547 + * @see https://github.com/jakearchibald/safari-14-idb-fix + */ +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"), + ), + ); + } + + const isSafari = + !navigator.userAgentData && + /Safari\//.test(navigator.userAgent) && + !/Chrom(e|ium)\//.test(navigator.userAgent); + + // No point putting other browsers or older versions of Safari through this mess. + if (!isSafari || !("databases" in indexedDB)) { + return Future.value(Result.Ok(indexedDB)); + } + + let intervalId: NodeJS.Timer; + let remainingAttempts = 10; + + return Future.make((resolve) => { + const accessIndexedDB = () => { + remainingAttempts = remainingAttempts - 1; + + if (remainingAttempts > 0) { + indexedDB.databases().finally(() => { + clearInterval(intervalId); + resolve(Result.Ok(indexedDB)); + }); + } else { + clearInterval(intervalId); + + resolve( + Result.Error( + new DOMException( + "Couldn't list IndexedDB databases", + "TimeoutError", + ), + ), + ); + } + }; + + intervalId = setInterval(accessIndexedDB, 100); + accessIndexedDB(); + }); +}; + +export const openDatabase = ( + databaseName: string, + storeName: string, + timeout: number, +): Future> => + getFactory().flatMapOk((factory) => { + const request = factory.open(databaseName); + + request.onupgradeneeded = () => { + request.result.createObjectStore(storeName); + }; + + return futurify(request, timeout); + }); + +const getStoreRaw = ( + database: IDBDatabase, + storeName: string, + transactionMode: IDBTransactionMode, +): Future> => + Future.value( + Result.fromExecution(() => + database.transaction(storeName, transactionMode).objectStore(storeName), + ), + ); + +export const getStore = ( + database: IDBDatabase, + databaseName: string, + storeName: string, + transactionMode: IDBTransactionMode, + timeout: number, +): Future> => + getStoreRaw(database, storeName, transactionMode).flatMapError((error) => + !isDatabaseClosedError(error) + ? Future.value(Result.Error(error)) + : openDatabase(databaseName, storeName, timeout).flatMapOk((database) => + getStoreRaw(database, storeName, transactionMode), + ), + ); diff --git a/tests/noFailures.test.ts b/tests/noFailures.test.ts index 6215bce..fd98122 100644 --- a/tests/noFailures.test.ts +++ b/tests/noFailures.test.ts @@ -9,7 +9,7 @@ afterEach(async () => { }); test( - "happy path with no failures", + "Happy path with no failures", async () => { expect(await store.setMany({ A: true })).toStrictEqual( Result.Ok(undefined), diff --git a/tests/safari.test.ts b/tests/safari.test.ts index 247126e..a5fc257 100644 --- a/tests/safari.test.ts +++ b/tests/safari.test.ts @@ -1,7 +1,7 @@ import { Result } from "@swan-io/boxed"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { rewriteError } from "../src/errors"; -import { getIndexedDBFactory } from "../src/factory"; +import { getFactory } from "../src/wrappers"; const userAgents = { "12.2": @@ -57,8 +57,8 @@ describe("Safari 14.6 (unresponsive)", () => { vi.unstubAllGlobals(); }); - test("getIndexedDBFactory resolve with error if indexedDB.databases hangs forever", async () => { - const result = await getIndexedDBFactory(); + test("getFactory resolve with error if indexedDB.databases hangs forever", async () => { + const result = await getFactory(); expect(result).toStrictEqual( Result.Error(new DOMException("Couldn't list IndexedDB databases")), @@ -88,8 +88,8 @@ describe("Safari 14.6 (unresponsive at first)", () => { vi.unstubAllGlobals(); }); - test("getIndexedDBFactory resolve with ok if indexedDB.databases resolve after a while", async () => { - const result = await getIndexedDBFactory(); + test("getFactory resolve with ok if indexedDB.databases resolve after a while", async () => { + const result = await getFactory(); expect(result).toStrictEqual(Result.Ok(indexedDB)); }); }); @@ -105,8 +105,8 @@ describe("Safari 14.6 (responsive)", () => { vi.unstubAllGlobals(); }); - test("getIndexedDBFactory resolve with ok if indexedDB.databases resolve immediately", async () => { - const result = await getIndexedDBFactory(); + test("getFactory resolve with ok if indexedDB.databases resolve immediately", async () => { + const result = await getFactory(); expect(result).toStrictEqual(Result.Ok(indexedDB)); }); }); @@ -133,8 +133,8 @@ describe("Safari 14.6 (responsive, but too late)", () => { vi.unstubAllGlobals(); }); - test("getIndexedDBFactory resolve with error if indexedDB.databases hangs for too long", async () => { - const result = await getIndexedDBFactory(); + test("getFactory resolve with error if indexedDB.databases hangs for too long", async () => { + const result = await getFactory(); expect(result).toStrictEqual( Result.Error(new DOMException("Couldn't list IndexedDB databases")),