Skip to content

Commit

Permalink
Wrap IDBFactory.open() as it can throw with SecurityError on Safari (#7)
Browse files Browse the repository at this point in the history
* Wrap database open

* Don't use DOMException only

* timeout even if no transaction exists (on open(), for example)

* Make name mandatory in createError

* Fix arg order
  • Loading branch information
zoontek authored Jun 8, 2023
1 parent e5f44f7 commit acd6ea4
Show file tree
Hide file tree
Showing 8 changed files with 94 additions and 93 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
Expand Down
19 changes: 11 additions & 8 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
39 changes: 26 additions & 13 deletions src/futurify.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
import { Future, Result } from "@swan-io/boxed";
import { rewriteError } from "./errors";
import { createError, rewriteError } from "./errors";

export const futurify = <T>(
request: IDBRequest<T>,
operationName: string,
timeout: number,
): Future<Result<T, DOMException>> =>
): Future<Result<T, Error>> =>
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<void, Error>(() => transaction.abort()).tapError(
(error) => resolve(Result.Error(error)),
);
}
}, timeout);

request.onsuccess = () => {
clearTimeout(timeoutId);
Expand All @@ -17,14 +40,4 @@ export const futurify = <T>(
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);
}
});
45 changes: 12 additions & 33 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <T extends string>(
keys: T[],
): Future<Result<Record<T, unknown>, DOMException>> => {
): Future<Result<Record<T, unknown>, 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;
Expand Down Expand Up @@ -79,24 +68,18 @@ export const openStore = (

setMany: (
object: Record<string, unknown>,
): Future<Result<undefined, DOMException>> => {
): Future<Result<undefined, Error>> => {
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)),
),
Expand All @@ -111,19 +94,15 @@ export const openStore = (
});
},

clear: (): Future<Result<undefined, DOMException>> => {
clear: (): Future<Result<undefined, Error>> => {
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();
Expand Down
45 changes: 23 additions & 22 deletions src/wrappers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Future, Result } from "@swan-io/boxed";
import { isDatabaseClosedError } from "./errors";
import { createError, isDatabaseClosedError } from "./errors";
import { futurify } from "./futurify";

/**
Expand All @@ -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<Result<IDBFactory, DOMException>> => {
export const getFactory = (): Future<Result<IDBFactory, Error>> => {
// 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"),
),
);
}
Expand Down Expand Up @@ -45,10 +45,7 @@ export const getFactory = (): Future<Result<IDBFactory, DOMException>> => {

resolve(
Result.Error(
new DOMException(
"Couldn't list IndexedDB databases",
"TimeoutError",
),
createError("TimeoutError", "Couldn't list IndexedDB databases"),
),
);
}
Expand All @@ -62,25 +59,30 @@ export const getFactory = (): Future<Result<IDBFactory, DOMException>> => {
export const openDatabase = (
databaseName: string,
storeName: string,
timeout: number,
): Future<Result<IDBDatabase, DOMException>> =>
getFactory().flatMapOk((factory) => {
const request = factory.open(databaseName);

request.onupgradeneeded = () => {
request.result.createObjectStore(storeName);
};
): Future<Result<IDBDatabase, Error>> =>
getFactory()
.flatMapOk((factory) =>
Future.value(
Result.fromExecution<IDBOpenDBRequest, Error>(() =>
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<Result<IDBObjectStore, DOMException>> =>
): Future<Result<IDBObjectStore, Error>> =>
Future.value(
Result.fromExecution<IDBObjectStore, DOMException>(() =>
Result.fromExecution<IDBObjectStore, Error>(() =>
database.transaction(storeName, transactionMode).objectStore(storeName),
),
);
Expand All @@ -90,12 +92,11 @@ export const getStore = (
databaseName: string,
storeName: string,
transactionMode: IDBTransactionMode,
timeout: number,
): Future<Result<IDBObjectStore, DOMException>> =>
): Future<Result<IDBObjectStore, Error>> =>
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),
),
);
14 changes: 7 additions & 7 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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);

Expand Down
7 changes: 4 additions & 3 deletions tests/inMemoryStore.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -16,15 +17,15 @@ 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(
Result.Ok({ A: true, B: undefined }),
);

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(
Expand All @@ -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(
Expand Down
Loading

0 comments on commit acd6ea4

Please sign in to comment.