@@ -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,
0 commit comments