Skip to content

Commit b191085

Browse files
authored
Merge pull request Expensify#796 from callstack-internal/fix/handle-aborted-transactions
Unify how merge and set handles AbortErrors
2 parents 14be5b8 + d757016 commit b191085

2 files changed

Lines changed: 102 additions & 5 deletions

File tree

lib/storage/providers/IDBKeyValProvider/index.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ import type {StorageKeyValuePair} from '../types';
99
const DB_NAME = 'OnyxDB';
1010
const STORE_NAME = 'keyvaluepairs';
1111

12+
/**
13+
* Awaits an IndexedDB write transaction. idb-keyval's promisifyRequest rejects with
14+
* `transaction.error`, which is `null` for an abort not caused by its own request
15+
* (connection close / versionchange / a sibling transaction aborting). Normalize that
16+
* `null` into a tagged AbortError.
17+
*/
18+
function promisifyWriteTransaction(transaction: IDBTransaction): Promise<void> {
19+
return IDB.promisifyRequest(transaction).catch((error) => {
20+
throw error ?? new DOMException('IDB write transaction aborted without an error', 'AbortError');
21+
});
22+
}
23+
1224
const provider: StorageProvider<UseStore | undefined> = {
1325
// We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
1426
// which might not be available in certain environments that load the bundle (e.g. electron main process).
@@ -38,7 +50,13 @@ const provider: StorageProvider<UseStore | undefined> = {
3850
return provider.removeItem(key);
3951
}
4052

41-
return IDB.set(key, value, provider.store);
53+
// Drive the write through the manual store transaction so promisifyWriteTransaction can
54+
// normalize a null abort error — idb-keyval's IDB.set() awaits the raw transaction and
55+
// would propagate the unclassifiable "Error: null".
56+
return provider.store('readwrite', (store) => {
57+
store.put(value, key);
58+
return promisifyWriteTransaction(store.transaction);
59+
});
4260
},
4361
multiGet(keysParam) {
4462
if (!provider.store) {
@@ -71,7 +89,7 @@ const provider: StorageProvider<UseStore | undefined> = {
7189
}
7290
}
7391

74-
return IDB.promisifyRequest(store.transaction);
92+
return promisifyWriteTransaction(store.transaction);
7593
});
7694
});
7795
},
@@ -93,7 +111,7 @@ const provider: StorageProvider<UseStore | undefined> = {
93111
}
94112
}
95113

96-
return IDB.promisifyRequest(store.transaction);
114+
return promisifyWriteTransaction(store.transaction);
97115
});
98116
},
99117
clear() {
@@ -133,14 +151,22 @@ const provider: StorageProvider<UseStore | undefined> = {
133151
throw new Error('Store not initialized!');
134152
}
135153

136-
return IDB.del(key, provider.store);
154+
return provider.store('readwrite', (store) => {
155+
store.delete(key);
156+
return promisifyWriteTransaction(store.transaction);
157+
});
137158
},
138159
removeItems(keysParam) {
139160
if (!provider.store) {
140161
throw new Error('Store not initialized!');
141162
}
142163

143-
return IDB.delMany(keysParam, provider.store);
164+
return provider.store('readwrite', (store) => {
165+
for (const key of keysParam) {
166+
store.delete(key);
167+
}
168+
return promisifyWriteTransaction(store.transaction);
169+
});
144170
},
145171
getDatabaseSize() {
146172
if (!provider.store) {

tests/unit/storage/providers/IDBKeyvalProviderTest.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,77 @@ describe('IDBKeyValProvider', () => {
174174
});
175175
});
176176

177+
describe('write-error normalization (aborted transactions)', () => {
178+
// A write transaction aborted by something other than its own request (connection close,
179+
// versionchange, a sibling transaction) leaves `transaction.error === null`, which idb-keyval
180+
// rejects with as-is. Every write path must instead reject with a real Error so the failure
181+
// can be classified and retried.
182+
function abortTransactionOnPut() {
183+
const originalPut = IDBObjectStore.prototype.put;
184+
jest.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function put(this: IDBObjectStore, ...args: Parameters<IDBObjectStore['put']>) {
185+
const request = originalPut.apply(this, args);
186+
this.transaction.abort();
187+
return request;
188+
});
189+
}
190+
191+
function abortTransactionOnDelete() {
192+
const originalDelete = IDBObjectStore.prototype.delete;
193+
jest.spyOn(IDBObjectStore.prototype, 'delete').mockImplementation(function del(this: IDBObjectStore, ...args: Parameters<IDBObjectStore['delete']>) {
194+
const request = originalDelete.apply(this, args);
195+
this.transaction.abort();
196+
return request;
197+
});
198+
}
199+
200+
function expectAbortError(error: unknown) {
201+
expect(error).not.toBeNull();
202+
expect(error).toBeInstanceOf(DOMException);
203+
expect((error as DOMException).name).toBe('AbortError');
204+
expect((error as DOMException).message.length).toBeGreaterThan(0);
205+
}
206+
207+
afterEach(() => {
208+
jest.restoreAllMocks();
209+
});
210+
211+
it('should reject setItem with a tagged AbortError, never null', async () => {
212+
abortTransactionOnPut();
213+
const error = await IDBKeyValProvider.setItem(ONYXKEYS.TEST_KEY, 'value').catch((e: unknown) => e);
214+
expectAbortError(error);
215+
});
216+
217+
it('should reject multiSet with a tagged AbortError, never null', async () => {
218+
abortTransactionOnPut();
219+
const error = await IDBKeyValProvider.multiSet([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e);
220+
expectAbortError(error);
221+
});
222+
223+
it('should reject multiMerge with a tagged AbortError, never null', async () => {
224+
abortTransactionOnPut();
225+
const error = await IDBKeyValProvider.multiMerge([[ONYXKEYS.TEST_KEY, 'value']]).catch((e: unknown) => e);
226+
expectAbortError(error);
227+
});
228+
229+
it('should reject setItem(null) with a tagged AbortError, never null', async () => {
230+
abortTransactionOnDelete();
231+
const error = await IDBKeyValProvider.setItem(ONYXKEYS.TEST_KEY, null).catch((e: unknown) => e);
232+
expectAbortError(error);
233+
});
234+
235+
it('should reject removeItem with a tagged AbortError, never null', async () => {
236+
abortTransactionOnDelete();
237+
const error = await IDBKeyValProvider.removeItem(ONYXKEYS.TEST_KEY).catch((e: unknown) => e);
238+
expectAbortError(error);
239+
});
240+
241+
it('should reject removeItems with a tagged AbortError, never null', async () => {
242+
abortTransactionOnDelete();
243+
const error = await IDBKeyValProvider.removeItems([ONYXKEYS.TEST_KEY]).catch((e: unknown) => e);
244+
expectAbortError(error);
245+
});
246+
});
247+
177248
describe('mergeItem', () => {
178249
it('should merge all the supported kinds of data correctly', async () => {
179250
await IDB.set(ONYXKEYS.TEST_KEY, 'value', IDBKeyValProvider.store);

0 commit comments

Comments
 (0)