@@ -2,12 +2,50 @@ import * as IDB from 'idb-keyval';
22import type { UseStore } from 'idb-keyval' ;
33import * as Logger from '../../../Logger' ;
44
5+ const HEAL_ATTEMPTS_MAX = 3 ;
6+
7+ /**
8+ * Detects the Chromium-specific IDB backing store corruption error.
9+ * Fires when LevelDB files backing IndexedDB are corrupted and Chrome's
10+ * internal recovery (RepairDB -> delete -> recreate) also fails.
11+ */
12+ function isBackingStoreError ( error : unknown ) : boolean {
13+ return ( error instanceof Error || error instanceof DOMException ) && ( error as Error ) . message . includes ( 'Internal error opening backing store' ) ;
14+ }
15+
16+ /**
17+ * Detects Safari/WebKit IDB connection termination errors.
18+ * Fires when Safari kills the IDB server process for backgrounded tabs.
19+ * WebKit bugs: https://bugs.webkit.org/show_bug.cgi?id=197050, https://bugs.webkit.org/show_bug.cgi?id=201483
20+ */
21+ function isConnectionLostError ( error : unknown ) : boolean {
22+ if ( ! ( error instanceof Error || error instanceof DOMException ) ) return false ;
23+ const msg = ( error as Error ) . message . toLowerCase ( ) ;
24+ return msg . includes ( 'connection to indexed database server lost' ) || msg . includes ( 'connection is closing' ) ;
25+ }
26+
27+ function isInvalidStateError ( error : unknown ) : boolean {
28+ return ( error instanceof Error || error instanceof DOMException ) && ( error as 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 ) : string {
37+ if ( isBackingStoreError ( error ) ) return 'backing store' ;
38+ if ( isConnectionLostError ( error ) ) return 'connection lost' ;
39+ return 'unknown' ;
40+ }
41+
542// This is a copy of the createStore function from idb-keyval, we need a custom implementation
643// because we need to create the database manually in order to ensure that the store exists before we use it.
744// If the store does not exist, idb-keyval will throw an error
845// source: https://github.com/jakearchibald/idb-keyval/blob/9d19315b4a83897df1e0193dccdc29f78466a0f3/src/index.ts#L12
946function createStore ( dbName : string , storeName : string ) : UseStore {
1047 let dbp : Promise < IDBDatabase > | undefined ;
48+ let healAttemptsRemaining = HEAL_ATTEMPTS_MAX ;
1149
1250 const attachHandlers = ( db : IDBDatabase ) => {
1351 // Browsers may close idle IDB connections at any time, especially Safari.
@@ -30,18 +68,27 @@ function createStore(dbName: string, storeName: string): UseStore {
3068 } ;
3169 } ;
3270
71+ // Cache the open promise and attach handlers + rejection cleanup.
72+ // On rejection, clears dbp so the next operation retries with a fresh indexedDB.open()
73+ // instead of returning the same rejected promise.
74+ // Guard: only clear if dbp hasn't been replaced by a concurrent heal/retry.
75+ function cacheOpenPromise ( openPromise : Promise < IDBDatabase > ) {
76+ dbp = openPromise ;
77+ const currentPromise = openPromise ;
78+ openPromise . then ( attachHandlers , ( ) => {
79+ if ( dbp !== currentPromise ) {
80+ return ;
81+ }
82+ dbp = undefined ;
83+ } ) ;
84+ return openPromise ;
85+ }
86+
3387 const getDB = ( ) => {
3488 if ( dbp ) return dbp ;
3589 const request = indexedDB . open ( dbName ) ;
3690 request . onupgradeneeded = ( ) => request . result . createObjectStore ( storeName ) ;
37- dbp = IDB . promisifyRequest ( request ) ;
38-
39- dbp . then (
40- attachHandlers ,
41- // eslint-disable-next-line @typescript-eslint/no-empty-function
42- ( ) => { } ,
43- ) ;
44- return dbp ;
91+ return cacheOpenPromise ( IDB . promisifyRequest ( request ) ) ;
4592 } ;
4693
4794 // Ensures the store exists in the DB. If missing, bumps the version to trigger
@@ -66,10 +113,7 @@ function createStore(dbName: string, storeName: string): UseStore {
66113 updatedDatabase . createObjectStore ( storeName ) ;
67114 } ;
68115
69- dbp = IDB . promisifyRequest ( request ) ;
70- // eslint-disable-next-line @typescript-eslint/no-empty-function
71- dbp . then ( attachHandlers , ( ) => { } ) ;
72- return dbp ;
116+ return cacheOpenPromise ( IDB . promisifyRequest ( request ) ) ;
73117 } ;
74118
75119 function executeTransaction < T > ( txMode : IDBTransactionMode , callback : ( store : IDBObjectStore ) => T | PromiseLike < T > ) : Promise < T > {
@@ -78,23 +122,64 @@ function createStore(dbName: string, storeName: string): UseStore {
78122 . then ( ( db ) => callback ( db . transaction ( storeName , txMode ) . objectStore ( storeName ) ) ) ;
79123 }
80124
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.
125+ function resetHealBudget < T > ( result : T ) : T {
126+ healAttemptsRemaining = HEAL_ATTEMPTS_MAX ;
127+ return result ;
128+ }
129+
130+ // Handles three recoverable error classes:
131+ // 1. InvalidStateError — connection closed between getDB() resolving and db.transaction().
132+ // Retry once with a fresh connection. No budget limit (transient, always worth one reopen).
133+ // 2. Backing store corruption (Chromium UnknownError) — drop cached connection and reopen.
134+ // 3. Connection lost (Safari UnknownError) — IDB server terminated for backgrounded tabs.
135+ // Both 2 and 3 share a heal budget (3 attempts, reset on success).
136+ // Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts
137+ // Note: concurrent store() calls share the budget. Under overlapping failures each caller
138+ // decrements independently, so the budget may drain faster than one-per-incident. This is
139+ // acceptable — same as Dexie's approach — and the budget resets on any success.
83140 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- } ) ;
141+ executeTransaction ( txMode , callback )
142+ . then ( resetHealBudget )
143+ . catch ( ( error ) => {
144+ if ( isInvalidStateError ( error ) ) {
145+ Logger . logInfo ( 'IDB InvalidStateError — dropping cached connection and retrying' , {
146+ dbName,
147+ storeName,
148+ txMode,
149+ errorMessage : error instanceof Error ? error . message : String ( error ) ,
150+ } ) ;
151+ dbp = undefined ;
152+ return executeTransaction ( txMode , callback ) . then ( resetHealBudget ) ;
153+ }
154+
155+ if ( isBudgetedHealError ( error ) && healAttemptsRemaining > 0 ) {
156+ healAttemptsRemaining -- ;
157+ const label = getBudgetedHealErrorLabel ( error ) ;
158+ Logger . logInfo ( `IDB heal: ${ label } error detected — dropping cached connection and reopening (${ healAttemptsRemaining } attempts left)` , {
159+ dbName,
160+ storeName,
161+ } ) ;
162+ dbp = undefined ;
163+ return executeTransaction ( txMode , callback ) . then ( ( result ) => {
164+ Logger . logInfo ( `IDB heal: successfully recovered after ${ label } error` , { dbName, storeName} ) ;
165+ return resetHealBudget ( result ) ;
166+ } ) ;
167+ }
168+
169+ if ( isBudgetedHealError ( error ) ) {
170+ Logger . logAlert ( `IDB heal: ${ getBudgetedHealErrorLabel ( error ) } error — heal budget exhausted, giving up` , {
171+ dbName,
172+ storeName,
173+ } ) ;
174+ } else {
175+ Logger . logAlert ( 'IDB error is not recoverable, giving up' , {
176+ dbName,
177+ storeName,
178+ errorMessage : error instanceof Error ? error . message : String ( error ) ,
179+ } ) ;
180+ }
181+ throw error ;
182+ } ) ;
98183}
99184
100185export default createStore ;
0 commit comments