Skip to content

Commit bcb948f

Browse files
authored
Merge pull request #748 from Krishna2323/krishna2323/issue/84192
fix the database connection is closing issue.
2 parents adbf777 + f02de7b commit bcb948f

File tree

2 files changed

+297
-11
lines changed

2 files changed

+297
-11
lines changed
Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,43 @@
11
import * as IDB from 'idb-keyval';
22
import type {UseStore} from 'idb-keyval';
3-
import {logInfo} from '../../../Logger';
3+
import * as Logger from '../../../Logger';
44

55
// This is a copy of the createStore function from idb-keyval, we need a custom implementation
66
// because we need to create the database manually in order to ensure that the store exists before we use it.
77
// If the store does not exist, idb-keyval will throw an error
88
// source: https://github.com/jakearchibald/idb-keyval/blob/9d19315b4a83897df1e0193dccdc29f78466a0f3/src/index.ts#L12
99
function createStore(dbName: string, storeName: string): UseStore {
1010
let dbp: Promise<IDBDatabase> | undefined;
11+
12+
const attachHandlers = (db: IDBDatabase) => {
13+
// Browsers may close idle IDB connections at any time, especially Safari.
14+
// We clear the cached promise so the next operation opens a fresh connection.
15+
// https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/close_event
16+
// eslint-disable-next-line no-param-reassign
17+
db.onclose = () => {
18+
Logger.logInfo('IDB connection closed by browser', {dbName, storeName});
19+
dbp = undefined;
20+
};
21+
22+
// When another tab triggers a DB version upgrade, we must close the connection
23+
// to unblock the upgrade; otherwise the other tab's open request hangs indefinitely.
24+
// https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/versionchange_event
25+
// eslint-disable-next-line no-param-reassign
26+
db.onversionchange = () => {
27+
Logger.logInfo('IDB connection closing due to version change', {dbName, storeName});
28+
db.close();
29+
dbp = undefined;
30+
};
31+
};
32+
1133
const getDB = () => {
1234
if (dbp) return dbp;
1335
const request = indexedDB.open(dbName);
1436
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
1537
dbp = IDB.promisifyRequest(request);
1638

1739
dbp.then(
18-
(db) => {
19-
// It seems like Safari sometimes likes to just close the connection.
20-
// It's supposed to fire this event when that happens. Let's hope it does!
21-
// eslint-disable-next-line no-param-reassign
22-
db.onclose = () => (dbp = undefined);
23-
},
40+
attachHandlers,
2441
// eslint-disable-next-line @typescript-eslint/no-empty-function
2542
() => {},
2643
);
@@ -34,7 +51,7 @@ function createStore(dbName: string, storeName: string): UseStore {
3451
return db;
3552
}
3653

37-
logInfo(`Store ${storeName} does not exist in database ${dbName}.`);
54+
Logger.logInfo(`Store ${storeName} does not exist in database ${dbName}.`);
3855
const nextVersion = db.version + 1;
3956
db.close();
4057

@@ -45,18 +62,39 @@ function createStore(dbName: string, storeName: string): UseStore {
4562
return;
4663
}
4764

48-
logInfo(`Creating store ${storeName} in database ${dbName}.`);
65+
Logger.logInfo(`Creating store ${storeName} in database ${dbName}.`);
4966
updatedDatabase.createObjectStore(storeName);
5067
};
5168

5269
dbp = IDB.promisifyRequest(request);
70+
// eslint-disable-next-line @typescript-eslint/no-empty-function
71+
dbp.then(attachHandlers, () => {});
5372
return dbp;
5473
};
5574

56-
return (txMode, callback) =>
57-
getDB()
75+
function executeTransaction<T>(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike<T>): Promise<T> {
76+
return getDB()
5877
.then(verifyStoreExists)
5978
.then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
79+
}
80+
81+
// If the connection was closed between getDB() resolving and db.transaction() executing,
82+
// the transaction throws InvalidStateError. We catch it and retry once with a fresh connection.
83+
return (txMode, callback) =>
84+
executeTransaction(txMode, callback).catch((error) => {
85+
if (error instanceof DOMException && error.name === 'InvalidStateError') {
86+
Logger.logAlert('IDB InvalidStateError, retrying with fresh connection', {
87+
dbName,
88+
storeName,
89+
txMode,
90+
errorMessage: error.message,
91+
});
92+
dbp = undefined;
93+
// Retry only once — this call is not wrapped, so if it also fails the error propagates normally.
94+
return executeTransaction(txMode, callback);
95+
}
96+
throw error;
97+
});
6098
}
6199

62100
export default createStore;
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import * as IDB from 'idb-keyval';
2+
import createStore from '../../../../lib/storage/providers/IDBKeyValProvider/createStore';
3+
import * as Logger from '../../../../lib/Logger';
4+
5+
const STORE_NAME = 'teststore';
6+
let testDbCounter = 0;
7+
8+
function uniqueDBName() {
9+
testDbCounter += 1;
10+
return `TestCreateStoreDB_${testDbCounter}`;
11+
}
12+
13+
/**
14+
* Captures the internal IDBDatabase instance used by a store by intercepting
15+
* the first db.transaction() call.
16+
*/
17+
async function captureDB(store: ReturnType<typeof createStore>): Promise<IDBDatabase | undefined> {
18+
const captured: {db?: IDBDatabase} = {};
19+
const original = IDBDatabase.prototype.transaction;
20+
const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
21+
captured.db = this;
22+
spy.mockRestore();
23+
return original.apply(this, args);
24+
});
25+
await store('readonly', (s) => IDB.promisifyRequest(s.getAllKeys()));
26+
return captured.db;
27+
}
28+
29+
describe('createStore', () => {
30+
let logAlertSpy: jest.SpyInstance;
31+
let logInfoSpy: jest.SpyInstance;
32+
33+
beforeEach(() => {
34+
logAlertSpy = jest.spyOn(Logger, 'logAlert');
35+
logInfoSpy = jest.spyOn(Logger, 'logInfo');
36+
});
37+
38+
afterEach(() => {
39+
jest.restoreAllMocks();
40+
});
41+
42+
describe('InvalidStateError retry', () => {
43+
it('should retry once and succeed when db.transaction throws InvalidStateError', async () => {
44+
const store = createStore(uniqueDBName(), STORE_NAME);
45+
46+
await store('readwrite', (s) => {
47+
s.put('initial', 'key1');
48+
return IDB.promisifyRequest(s.transaction);
49+
});
50+
51+
const original = IDBDatabase.prototype.transaction;
52+
let callCount = 0;
53+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
54+
callCount += 1;
55+
if (callCount === 1) {
56+
throw new DOMException('The database connection is closing.', 'InvalidStateError');
57+
}
58+
return original.apply(this, args);
59+
});
60+
61+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
62+
63+
expect(result).toBe('initial');
64+
expect(callCount).toBe(2);
65+
});
66+
67+
it('should propagate InvalidStateError if retry also fails', async () => {
68+
const store = createStore(uniqueDBName(), STORE_NAME);
69+
70+
await store('readwrite', (s) => {
71+
s.put('value', 'key1');
72+
return IDB.promisifyRequest(s.transaction);
73+
});
74+
75+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => {
76+
throw new DOMException('The database connection is closing.', 'InvalidStateError');
77+
});
78+
79+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(DOMException);
80+
expect(logAlertSpy).toHaveBeenCalledTimes(1);
81+
});
82+
83+
it('should not retry on non-InvalidStateError DOMException', async () => {
84+
const store = createStore(uniqueDBName(), STORE_NAME);
85+
86+
await store('readwrite', (s) => {
87+
s.put('value', 'key1');
88+
return IDB.promisifyRequest(s.transaction);
89+
});
90+
91+
let callCount = 0;
92+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => {
93+
callCount += 1;
94+
throw new DOMException('Not found', 'NotFoundError');
95+
});
96+
97+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(DOMException);
98+
expect(callCount).toBe(1);
99+
expect(logAlertSpy).not.toHaveBeenCalled();
100+
});
101+
102+
it('should not retry on non-DOMException errors', async () => {
103+
const store = createStore(uniqueDBName(), STORE_NAME);
104+
105+
await store('readwrite', (s) => {
106+
s.put('value', 'key1');
107+
return IDB.promisifyRequest(s.transaction);
108+
});
109+
110+
let callCount = 0;
111+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => {
112+
callCount += 1;
113+
throw new TypeError('Something went wrong');
114+
});
115+
116+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow(TypeError);
117+
expect(callCount).toBe(1);
118+
expect(logAlertSpy).not.toHaveBeenCalled();
119+
});
120+
121+
it('should preserve data integrity after a successful retry', async () => {
122+
const store = createStore(uniqueDBName(), STORE_NAME);
123+
124+
await store('readwrite', (s) => {
125+
s.put('existing', 'key0');
126+
return IDB.promisifyRequest(s.transaction);
127+
});
128+
129+
const original = IDBDatabase.prototype.transaction;
130+
let callCount = 0;
131+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
132+
callCount += 1;
133+
if (callCount === 1) {
134+
throw new DOMException('The database connection is closing.', 'InvalidStateError');
135+
}
136+
return original.apply(this, args);
137+
});
138+
139+
await store('readwrite', (s) => {
140+
s.put('retried_value', 'key1');
141+
return IDB.promisifyRequest(s.transaction);
142+
});
143+
144+
jest.restoreAllMocks();
145+
logAlertSpy = jest.spyOn(Logger, 'logAlert');
146+
147+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
148+
expect(result).toBe('retried_value');
149+
});
150+
});
151+
152+
describe('diagnostic logging', () => {
153+
it('should log alert with all diagnostic fields on retry', async () => {
154+
const dbName = uniqueDBName();
155+
const store = createStore(dbName, STORE_NAME);
156+
157+
await store('readwrite', (s) => {
158+
s.put('value', 'key1');
159+
return IDB.promisifyRequest(s.transaction);
160+
});
161+
162+
const original = IDBDatabase.prototype.transaction;
163+
let callCount = 0;
164+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
165+
callCount += 1;
166+
if (callCount === 1) {
167+
throw new DOMException('The database connection is closing.', 'InvalidStateError');
168+
}
169+
return original.apply(this, args);
170+
});
171+
172+
await store('readwrite', (s) => {
173+
s.put('value2', 'key2');
174+
return IDB.promisifyRequest(s.transaction);
175+
});
176+
177+
expect(logAlertSpy).toHaveBeenCalledWith('IDB InvalidStateError, retrying with fresh connection', {
178+
dbName,
179+
storeName: STORE_NAME,
180+
txMode: 'readwrite',
181+
errorMessage: 'The database connection is closing.',
182+
});
183+
});
184+
});
185+
186+
describe('onclose handler', () => {
187+
it('should log info when browser closes the connection', async () => {
188+
const dbName = uniqueDBName();
189+
const store = createStore(dbName, STORE_NAME);
190+
191+
const db = await captureDB(store);
192+
expect(db).toBeDefined();
193+
db?.onclose?.call(db, new Event('close'));
194+
195+
expect(logInfoSpy).toHaveBeenCalledWith('IDB connection closed by browser', {dbName, storeName: STORE_NAME});
196+
});
197+
198+
it('should recover with a fresh connection after browser close', async () => {
199+
const store = createStore(uniqueDBName(), STORE_NAME);
200+
201+
await store('readwrite', (s) => {
202+
s.put('value', 'key1');
203+
return IDB.promisifyRequest(s.transaction);
204+
});
205+
206+
const db = await captureDB(store);
207+
expect(db).toBeDefined();
208+
db?.onclose?.call(db, new Event('close'));
209+
210+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
211+
expect(result).toBe('value');
212+
});
213+
});
214+
215+
describe('onversionchange handler', () => {
216+
it('should close connection and log when versionchange fires', async () => {
217+
const dbName = uniqueDBName();
218+
const store = createStore(dbName, STORE_NAME);
219+
220+
const db = await captureDB(store);
221+
expect(db).toBeDefined();
222+
const closeSpy = jest.spyOn(db!, 'close');
223+
224+
// @ts-expect-error -- our handler ignores the event argument
225+
db?.onversionchange?.call(db, new Event('versionchange'));
226+
227+
expect(closeSpy).toHaveBeenCalled();
228+
expect(logInfoSpy).toHaveBeenCalledWith('IDB connection closing due to version change', {dbName, storeName: STORE_NAME});
229+
});
230+
231+
it('should recover with a fresh connection after versionchange', async () => {
232+
const store = createStore(uniqueDBName(), STORE_NAME);
233+
234+
await store('readwrite', (s) => {
235+
s.put('value', 'key1');
236+
return IDB.promisifyRequest(s.transaction);
237+
});
238+
239+
const db = await captureDB(store);
240+
expect(db).toBeDefined();
241+
// @ts-expect-error -- our handler ignores the event argument
242+
db?.onversionchange?.call(db, new Event('versionchange'));
243+
244+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
245+
expect(result).toBe('value');
246+
});
247+
});
248+
});

0 commit comments

Comments
 (0)