Skip to content

Commit 1f14374

Browse files
leshniakclaude
andcommitted
refactor: DRY error classification and promise cleanup in createStore
Extract module-level helpers: isInvalidStateError, isBudgetedHealError, getBudgetedHealErrorLabel. Extract cacheOpenPromise to deduplicate rejected-promise cleanup in getDB and verifyStoreExists. Pure refactor — no behavior change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7633f32 commit 1f14374

2 files changed

Lines changed: 40 additions & 34 deletions

File tree

lib/storage/providers/IDBKeyValProvider/createStore.ts

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ function isConnectionLostError(error: unknown): boolean {
2424
return msg.includes('connection to indexed database server lost') || msg.includes('connection is closing');
2525
}
2626

27+
function isInvalidStateError(error: unknown): boolean {
28+
return error instanceof Error && error.name === 'InvalidStateError';
29+
}
30+
31+
/** Errors that trigger a budgeted heal-and-retry in store(). */
32+
function isBudgetedHealError(error: unknown): boolean {
33+
return isBackingStoreError(error) || isConnectionLostError(error);
34+
}
35+
36+
function getBudgetedHealErrorLabel(error: unknown): 'backing store' | 'connection lost' {
37+
return isBackingStoreError(error) ? 'backing store' : 'connection lost';
38+
}
39+
2740
// This is a copy of the createStore function from idb-keyval, we need a custom implementation
2841
// because we need to create the database manually in order to ensure that the store exists before we use it.
2942
// If the store does not exist, idb-keyval will throw an error
@@ -53,23 +66,27 @@ function createStore(dbName: string, storeName: string): UseStore {
5366
};
5467
};
5568

56-
const getDB = () => {
57-
if (dbp) return dbp;
58-
const request = indexedDB.open(dbName);
59-
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
60-
dbp = IDB.promisifyRequest(request);
61-
62-
const currentPromise = dbp;
63-
dbp.then(attachHandlers, () => {
64-
// Clear the cached rejected promise so the next operation retries
65-
// with a fresh indexedDB.open() instead of returning the same rejection.
66-
// Guard: only clear if dbp hasn't been replaced by a concurrent heal/retry.
69+
// Cache the open promise and attach handlers + rejection cleanup.
70+
// On rejection, clears dbp so the next operation retries with a fresh indexedDB.open()
71+
// instead of returning the same rejected promise.
72+
// Guard: only clear if dbp hasn't been replaced by a concurrent heal/retry.
73+
function cacheOpenPromise(openPromise: Promise<IDBDatabase>) {
74+
dbp = openPromise;
75+
const currentPromise = openPromise;
76+
openPromise.then(attachHandlers, () => {
6777
if (dbp !== currentPromise) {
6878
return;
6979
}
7080
dbp = undefined;
7181
});
72-
return dbp;
82+
return openPromise;
83+
}
84+
85+
const getDB = () => {
86+
if (dbp) return dbp;
87+
const request = indexedDB.open(dbName);
88+
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
89+
return cacheOpenPromise(IDB.promisifyRequest(request));
7390
};
7491

7592
// Ensures the store exists in the DB. If missing, bumps the version to trigger
@@ -94,15 +111,7 @@ function createStore(dbName: string, storeName: string): UseStore {
94111
updatedDatabase.createObjectStore(storeName);
95112
};
96113

97-
dbp = IDB.promisifyRequest(request);
98-
const currentPromise = dbp;
99-
dbp.then(attachHandlers, () => {
100-
if (dbp !== currentPromise) {
101-
return;
102-
}
103-
dbp = undefined;
104-
});
105-
return dbp;
114+
return cacheOpenPromise(IDB.promisifyRequest(request));
106115
};
107116

108117
function executeTransaction<T>(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike<T>): Promise<T> {
@@ -116,11 +125,12 @@ function createStore(dbName: string, storeName: string): UseStore {
116125
return result;
117126
}
118127

119-
// Handles two recoverable error classes:
128+
// Handles three recoverable error classes:
120129
// 1. InvalidStateError — connection closed between getDB() resolving and db.transaction().
121-
// Retry once with a fresh connection.
122-
// 2. Backing store corruption (Chromium UnknownError) — drop cached connection and reopen the IDB connection.
123-
// Bounded by a shared heal budget (3 attempts, reset on success).
130+
// Retry once with a fresh connection. No budget limit (transient, always worth one reopen).
131+
// 2. Backing store corruption (Chromium UnknownError) — drop cached connection and reopen.
132+
// 3. Connection lost (Safari UnknownError) — IDB server terminated for backgrounded tabs.
133+
// Both 2 and 3 share a heal budget (3 attempts, reset on success).
124134
// Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts
125135
// Note: concurrent store() calls share the budget. Under overlapping failures each caller
126136
// decrements independently, so the budget may drain faster than one-per-incident. This is
@@ -129,21 +139,20 @@ function createStore(dbName: string, storeName: string): UseStore {
129139
executeTransaction(txMode, callback)
130140
.then(resetHealBudget)
131141
.catch((error) => {
132-
if (error instanceof Error && error.name === 'InvalidStateError') {
142+
if (isInvalidStateError(error)) {
133143
Logger.logAlert('IDB InvalidStateError, retrying with fresh connection', {
134144
dbName,
135145
storeName,
136146
txMode,
137-
errorMessage: error.message,
147+
errorMessage: error instanceof Error ? error.message : String(error),
138148
});
139149
dbp = undefined;
140150
return executeTransaction(txMode, callback).then(resetHealBudget);
141151
}
142152

143-
if ((isBackingStoreError(error) || isConnectionLostError(error)) && healAttemptsRemaining > 0) {
153+
if (isBudgetedHealError(error) && healAttemptsRemaining > 0) {
144154
healAttemptsRemaining--;
145-
const errorType = isBackingStoreError(error) ? 'backing store' : 'connection lost';
146-
Logger.logInfo(`IDB heal: ${errorType} error, attempting drop cached connection and reopen`, {
155+
Logger.logInfo(`IDB heal: ${getBudgetedHealErrorLabel(error)} error, attempting drop cached connection and reopen`, {
147156
dbName,
148157
storeName,
149158
healAttemptsRemaining,

tests/unit/storage/providers/createStoreTest.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -418,10 +418,7 @@ describe('createStore', () => {
418418

419419
describe('connection lost healing', () => {
420420
function connectionLostError() {
421-
return new DOMException(
422-
'Connection to Indexed Database server lost. Refresh the page to try again',
423-
'UnknownError',
424-
);
421+
return new DOMException('Connection to Indexed Database server lost. Refresh the page to try again', 'UnknownError');
425422
}
426423

427424
it('should heal by dropping cached connection and reopening', async () => {

0 commit comments

Comments
 (0)