Bug 1871127 - Add tsconfig, basic types, and fix or ignore remaining type errors...
[gecko.git] / toolkit / components / extensions / ExtensionStorageIDB.sys.mjs
blob26df3eacdb91473e9b6596f1e39752b2fb15c836
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
6 import { IndexedDB } from "resource://gre/modules/IndexedDB.sys.mjs";
8 const lazy = {};
10 ChromeUtils.defineESModuleGetters(lazy, {
11   ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs",
12   ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
13   getTrimmedString: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
14 });
16 // The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB
17 // storage used by the browser.storage.local API is not directly accessible from the extension code,
18 // it is defined and reserved as "userContextIdInternal.webextStorageLocal" in ContextualIdentityService.sys.mjs).
19 const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0;
21 const IDB_NAME = "webExtensions-storage-local";
22 const IDB_DATA_STORENAME = "storage-local-data";
23 const IDB_VERSION = 1;
24 const IDB_MIGRATE_RESULT_HISTOGRAM =
25   "WEBEXT_STORAGE_LOCAL_IDB_MIGRATE_RESULT_COUNT";
27 // Whether or not the installed extensions should be migrated to the storage.local IndexedDB backend.
28 const BACKEND_ENABLED_PREF =
29   "extensions.webextensions.ExtensionStorageIDB.enabled";
30 const IDB_MIGRATED_PREF_BRANCH =
31   "extensions.webextensions.ExtensionStorageIDB.migrated";
33 class DataMigrationAbortedError extends Error {
34   get name() {
35     return "DataMigrationAbortedError";
36   }
39 var ErrorsTelemetry = {
40   initialized: false,
42   lazyInit() {
43     if (this.initialized) {
44       return;
45     }
46     this.initialized = true;
48     // Ensure that these telemetry events category is enabled.
49     Services.telemetry.setEventRecordingEnabled("extensions.data", true);
51     this.resultHistogram = Services.telemetry.getHistogramById(
52       IDB_MIGRATE_RESULT_HISTOGRAM
53     );
54   },
56   /**
57    * Get the DOMException error name for a given error object.
58    *
59    * @param {Error | undefined} error
60    *        The Error object to convert into a string, or undefined if there was no error.
61    *
62    * @returns {string | undefined}
63    *          The DOMException error name (sliced to a maximum of 80 chars),
64    *          "OtherError" if the error object is not a DOMException instance,
65    *          or `undefined` if there wasn't an error.
66    */
67   getErrorName(error) {
68     if (!error) {
69       return undefined;
70     }
72     if (
73       DOMException.isInstance(error) ||
74       error instanceof DataMigrationAbortedError
75     ) {
76       if (error.name.length > 80) {
77         return lazy.getTrimmedString(error.name);
78       }
80       return error.name;
81     }
83     return "OtherError";
84   },
86   /**
87    * Record telemetry related to a data migration result.
88    *
89    * @param {object} telemetryData
90    * @param {string} telemetryData.backend
91    *        The backend selected ("JSONFile" or "IndexedDB").
92    * @param {boolean} [telemetryData.dataMigrated]
93    *        Old extension data has been migrated successfully.
94    * @param {string} telemetryData.extensionId
95    *        The id of the extension migrated.
96    * @param {Error | undefined} telemetryData.error
97    *        The error raised during the data migration, if any.
98    * @param {boolean} [telemetryData.hasJSONFile]
99    *        The extension has an existing JSONFile to migrate.
100    * @param {boolean} [telemetryData.hasOldData]
101    *        The extension's JSONFile wasn't empty.
102    * @param {string} telemetryData.histogramCategory
103    *        The histogram category for the result ("success" or "failure").
104    */
105   recordDataMigrationResult(telemetryData) {
106     try {
107       const {
108         backend,
109         dataMigrated,
110         extensionId,
111         error,
112         hasJSONFile,
113         hasOldData,
114         histogramCategory,
115       } = telemetryData;
117       this.lazyInit();
118       this.resultHistogram.add(histogramCategory);
120       const extra = { backend };
122       if (dataMigrated != null) {
123         extra.data_migrated = dataMigrated ? "y" : "n";
124       }
126       if (hasJSONFile != null) {
127         extra.has_jsonfile = hasJSONFile ? "y" : "n";
128       }
130       if (hasOldData != null) {
131         extra.has_olddata = hasOldData ? "y" : "n";
132       }
134       if (error) {
135         extra.error_name = this.getErrorName(error);
136       }
138       let addon_id = lazy.getTrimmedString(extensionId);
139       Services.telemetry.recordEvent(
140         "extensions.data",
141         "migrateResult",
142         "storageLocal",
143         addon_id,
144         extra
145       );
146       Glean.extensionsData.migrateResult.record({
147         addon_id,
148         backend: extra.backend,
149         data_migrated: extra.data_migrated,
150         has_jsonfile: extra.has_jsonfile,
151         has_olddata: extra.has_olddata,
152         error_name: extra.error_name,
153       });
154     } catch (err) {
155       // Report any telemetry error on the browser console, but
156       // we treat it as a non-fatal error and we don't re-throw
157       // it to the caller.
158       Cu.reportError(err);
159     }
160   },
162   /**
163    * Record telemetry related to the unexpected errors raised while executing
164    * a storage.local API call.
165    *
166    * @param {object} options
167    * @param {string} options.extensionId
168    *        The id of the extension migrated.
169    * @param {string} options.storageMethod
170    *        The storage.local API method being run.
171    * @param {Error}  options.error
172    *        The unexpected error raised during the API call.
173    */
174   recordStorageLocalError({ extensionId, storageMethod, error }) {
175     this.lazyInit();
176     let addon_id = lazy.getTrimmedString(extensionId);
177     let error_name = this.getErrorName(error);
179     Services.telemetry.recordEvent(
180       "extensions.data",
181       "storageLocalError",
182       storageMethod,
183       addon_id,
184       { error_name }
185     );
186     Glean.extensionsData.storageLocalError.record({
187       addon_id,
188       method: storageMethod,
189       error_name,
190     });
191   },
194 class ExtensionStorageLocalIDB extends IndexedDB {
195   onupgradeneeded(event) {
196     if (event.oldVersion < 1) {
197       this.createObjectStore(IDB_DATA_STORENAME);
198     }
199   }
201   static openForPrincipal(storagePrincipal) {
202     // The db is opened using an extension principal isolated in a reserved user context id.
203     return super.openForPrincipal(storagePrincipal, IDB_NAME, IDB_VERSION);
204   }
206   async isEmpty() {
207     const cursor = await this.objectStore(
208       IDB_DATA_STORENAME,
209       "readonly"
210     ).openKeyCursor();
211     return cursor.done;
212   }
214   /**
215    * Asynchronously sets the values of the given storage items.
216    *
217    * @param {object} items
218    *        The storage items to set. For each property in the object,
219    *        the storage value for that property is set to its value in
220    *        said object. Any values which are StructuredCloneHolder
221    *        instances are deserialized before being stored.
222    * @param {object}  options
223    * @param {callback} [options.serialize]
224    *        Set to a function which will be used to serialize the values into
225    *        a StructuredCloneHolder object (if appropriate) and being sent
226    *        across the processes (it is also used to detect data cloning errors
227    *        and raise an appropriate error to the caller).
228    *
229    * @returns {Promise<null|object>}
230    *        Return a promise which resolves to the computed "changes" object
231    *        or null.
232    */
233   async set(items, { serialize } = {}) {
234     const changes = {};
235     let changed = false;
237     // Explicitly create a transaction, so that we can explicitly abort it
238     // as soon as one of the put requests fails.
239     const transaction = this.transaction(IDB_DATA_STORENAME, "readwrite");
240     const objectStore = transaction.objectStore(IDB_DATA_STORENAME);
241     const transactionCompleted = transaction.promiseComplete();
243     if (!serialize) {
244       serialize = (name, anonymizedName, value) => value;
245     }
247     for (let key of Object.keys(items)) {
248       try {
249         let oldValue = await objectStore.get(key);
251         await objectStore.put(items[key], key);
253         changes[key] = {
254           oldValue:
255             oldValue && serialize(`old/${key}`, `old/<anonymized>`, oldValue),
256           newValue: serialize(`new/${key}`, `new/<anonymized>`, items[key]),
257         };
258         changed = true;
259       } catch (err) {
260         transactionCompleted.catch(err => {
261           // We ignore this rejection because we are explicitly aborting the transaction,
262           // the transaction.error will be null, and we throw the original error below.
263         });
264         transaction.abort();
266         throw err;
267       }
268     }
270     await transactionCompleted;
272     return changed ? changes : null;
273   }
275   /**
276    * Asynchronously retrieves the values for the given storage items.
277    *
278    * @param {Array<string>|object|null} [keysOrItems]
279    *        The storage items to get. If an array, the value of each key
280    *        in the array is returned. If null, the values of all items
281    *        are returned. If an object, the value for each key in the
282    *        object is returned, or that key's value if the item is not
283    *        set.
284    * @returns {Promise<object>}
285    *        An object which has a property for each requested key,
286    *        containing that key's value as stored in the IndexedDB
287    *        storage.
288    */
289   async get(keysOrItems) {
290     let keys;
291     let defaultValues;
293     if (typeof keysOrItems === "string") {
294       keys = [keysOrItems];
295     } else if (Array.isArray(keysOrItems)) {
296       keys = keysOrItems;
297     } else if (keysOrItems && typeof keysOrItems === "object") {
298       keys = Object.keys(keysOrItems);
299       defaultValues = keysOrItems;
300     }
302     const result = {};
304     // Retrieve all the stored data using a cursor when browser.storage.local.get()
305     // has been called with no keys.
306     if (keys == null) {
307       const cursor = await this.objectStore(
308         IDB_DATA_STORENAME,
309         "readonly"
310       ).openCursor();
311       while (!cursor.done) {
312         result[cursor.key] = cursor.value;
313         await cursor.continue();
314       }
315     } else {
316       const objectStore = this.objectStore(IDB_DATA_STORENAME);
317       for (let key of keys) {
318         const storedValue = await objectStore.get(key);
319         if (storedValue === undefined) {
320           if (defaultValues && defaultValues[key] !== undefined) {
321             result[key] = defaultValues[key];
322           }
323         } else {
324           result[key] = storedValue;
325         }
326       }
327     }
329     return result;
330   }
332   /**
333    * Asynchronously removes the given storage items.
334    *
335    * @param {string|Array<string>} keys
336    *        A string key of a list of storage items keys to remove.
337    * @returns {Promise<object>}
338    *          Returns an object which contains applied changes.
339    */
340   async remove(keys) {
341     // Ensure that keys is an array of strings.
342     keys = [].concat(keys);
344     if (keys.length === 0) {
345       // Early exit if there is nothing to remove.
346       return null;
347     }
349     const changes = {};
350     let changed = false;
352     const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite");
354     let promises = [];
356     for (let key of keys) {
357       promises.push(
358         objectStore.getKey(key).then(async foundKey => {
359           if (foundKey === key) {
360             changed = true;
361             changes[key] = { oldValue: await objectStore.get(key) };
362             return objectStore.delete(key);
363           }
364         })
365       );
366     }
368     await Promise.all(promises);
370     return changed ? changes : null;
371   }
373   /**
374    * Asynchronously clears all storage entries.
375    *
376    * @returns {Promise<object>}
377    *          Returns an object which contains applied changes.
378    */
379   async clear() {
380     const changes = {};
381     let changed = false;
383     const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite");
385     const cursor = await objectStore.openCursor();
386     while (!cursor.done) {
387       changes[cursor.key] = { oldValue: cursor.value };
388       changed = true;
389       await cursor.continue();
390     }
392     await objectStore.clear();
394     return changed ? changes : null;
395   }
399  * Migrate the data stored in the JSONFile backend to the IDB Backend.
401  * Returns a promise which is resolved once the data migration has been
402  * completed and the new IDB backend can be enabled.
403  * Rejects if the data has been read successfully from the JSONFile backend
404  * but it failed to be saved in the new IDB backend.
406  * This method is called only from the main process (where the file
407  * can be opened).
409  * @param {Extension} extension
410  *        The extension to migrate to the new IDB backend.
411  * @param {nsIPrincipal} storagePrincipal
412  *        The "internally reserved" extension storagePrincipal to be used to create
413  *        the ExtensionStorageLocalIDB instance.
414  */
415 async function migrateJSONFileData(extension, storagePrincipal) {
416   let oldStoragePath;
417   let oldStorageExists;
418   let idbConn;
419   let jsonFile;
420   let hasEmptyIDB;
421   let nonFatalError;
422   let dataMigrateCompleted = false;
423   let hasOldData = false;
425   function abortIfShuttingDown() {
426     if (extension.hasShutdown || Services.startup.shuttingDown) {
427       throw new DataMigrationAbortedError("extension or app is shutting down");
428     }
429   }
431   if (ExtensionStorageIDB.isMigratedExtension(extension)) {
432     return;
433   }
435   try {
436     abortIfShuttingDown();
437     idbConn = await ExtensionStorageIDB.open(
438       storagePrincipal,
439       extension.hasPermission("unlimitedStorage")
440     );
441     abortIfShuttingDown();
443     hasEmptyIDB = await idbConn.isEmpty();
445     if (!hasEmptyIDB) {
446       // If the IDB backend is enabled and there is data already stored in the IDB backend,
447       // there is no "going back": any data that has not been migrated will be still on disk
448       // but it is not going to be migrated anymore, it could be eventually used to allow
449       // a user to manually retrieve the old data file).
450       ExtensionStorageIDB.setMigratedExtensionPref(extension, true);
451       return;
452     }
453   } catch (err) {
454     extension.logWarning(
455       `storage.local data migration cancelled, unable to open IDB connection: ${err.message}::${err.stack}`
456     );
458     ErrorsTelemetry.recordDataMigrationResult({
459       backend: "JSONFile",
460       extensionId: extension.id,
461       error: err,
462       histogramCategory: "failure",
463     });
465     throw err;
466   }
468   try {
469     abortIfShuttingDown();
471     oldStoragePath = lazy.ExtensionStorage.getStorageFile(extension.id);
472     oldStorageExists = await IOUtils.exists(oldStoragePath).catch(fileErr => {
473       // If we can't access the oldStoragePath here, then extension is also going to be unable to
474       // access it, and so we log the error but we don't stop the extension from switching to
475       // the IndexedDB backend.
476       extension.logWarning(
477         `Unable to access extension storage.local data file: ${fileErr.message}::${fileErr.stack}`
478       );
479       return false;
480     });
482     // Migrate any data stored in the JSONFile backend (if any), and remove the old data file
483     // if the migration has been completed successfully.
484     if (oldStorageExists) {
485       // Do not load the old JSON file content if shutting down is already in progress.
486       abortIfShuttingDown();
488       Services.console.logStringMessage(
489         `Migrating storage.local data for ${extension.policy.debugName}...`
490       );
492       jsonFile = await lazy.ExtensionStorage.getFile(extension.id);
494       abortIfShuttingDown();
496       const data = {};
497       for (let [key, value] of jsonFile.data.entries()) {
498         data[key] = value;
499         hasOldData = true;
500       }
502       await idbConn.set(data);
503       Services.console.logStringMessage(
504         `storage.local data successfully migrated to IDB Backend for ${extension.policy.debugName}.`
505       );
506     }
508     dataMigrateCompleted = true;
509   } catch (err) {
510     extension.logWarning(
511       `Error on migrating storage.local data file: ${err.message}::${err.stack}`
512     );
514     if (oldStorageExists && !dataMigrateCompleted) {
515       ErrorsTelemetry.recordDataMigrationResult({
516         backend: "JSONFile",
517         dataMigrated: dataMigrateCompleted,
518         extensionId: extension.id,
519         error: err,
520         hasJSONFile: oldStorageExists,
521         hasOldData,
522         histogramCategory: "failure",
523       });
525       // If the data failed to be stored into the IndexedDB backend, then we clear the IndexedDB
526       // backend to allow the extension to retry the migration on its next startup, and reject
527       // the data migration promise explicitly (which would prevent the new backend
528       // from being enabled for this session).
529       await new Promise(resolve => {
530         let req = Services.qms.clearStoragesForPrincipal(storagePrincipal);
531         req.callback = resolve;
532       });
534       throw err;
535     }
537     // This error is not preventing the extension from switching to the IndexedDB backend,
538     // but we may still want to know that it has been triggered and include it into the
539     // telemetry data collected for the extension.
540     nonFatalError = err;
541   } finally {
542     // Clear the jsonFilePromise cached by the ExtensionStorage.
543     await lazy.ExtensionStorage.clearCachedFile(extension.id).catch(err => {
544       extension.logWarning(err.message);
545     });
546   }
548   // If the IDB backend has been enabled, rename the old storage.local data file, but
549   // do not prevent the extension from switching to the IndexedDB backend if it fails.
550   if (oldStorageExists && dataMigrateCompleted) {
551     try {
552       // Only migrate the file when it actually exists (e.g. the file name is not going to exist
553       // when it is corrupted, because JSONFile internally rename it to `.corrupt`.
554       if (await IOUtils.exists(oldStoragePath)) {
555         const uniquePath = await IOUtils.createUniqueFile(
556           PathUtils.parent(oldStoragePath),
557           `${PathUtils.filename(oldStoragePath)}.migrated`
558         );
559         await IOUtils.move(oldStoragePath, uniquePath);
560       }
561     } catch (err) {
562       nonFatalError = err;
563       extension.logWarning(err.message);
564     }
565   }
567   ExtensionStorageIDB.setMigratedExtensionPref(extension, true);
569   ErrorsTelemetry.recordDataMigrationResult({
570     backend: "IndexedDB",
571     dataMigrated: dataMigrateCompleted,
572     extensionId: extension.id,
573     error: nonFatalError,
574     hasJSONFile: oldStorageExists,
575     hasOldData,
576     histogramCategory: "success",
577   });
581  * This ExtensionStorage class implements a backend for the storage.local API which
582  * uses IndexedDB to store the data.
583  */
584 export var ExtensionStorageIDB = {
585   BACKEND_ENABLED_PREF,
586   IDB_MIGRATED_PREF_BRANCH,
587   IDB_MIGRATE_RESULT_HISTOGRAM,
589   // Map<extension-id, Set<Function>>
590   listeners: new Map(),
592   // Keep track if the IDB backend has been selected or not for a running extension
593   // (the selected backend should never change while the extension is running, even if the
594   // related preference has been changed in the meantime):
595   //
596   //   WeakMap<extension -> Promise<boolean>
597   selectedBackendPromises: new WeakMap(),
599   init() {
600     XPCOMUtils.defineLazyPreferenceGetter(
601       this,
602       "isBackendEnabled",
603       BACKEND_ENABLED_PREF,
604       false
605     );
606   },
608   isMigratedExtension(extension) {
609     return Services.prefs.getBoolPref(
610       `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`,
611       false
612     );
613   },
615   setMigratedExtensionPref(extension, val) {
616     Services.prefs.setBoolPref(
617       `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`,
618       !!val
619     );
620   },
622   clearMigratedExtensionPref(extensionId) {
623     Services.prefs.clearUserPref(`${IDB_MIGRATED_PREF_BRANCH}.${extensionId}`);
624   },
626   getStoragePrincipal(extension) {
627     return extension.createPrincipal(extension.baseURI, {
628       userContextId: WEBEXT_STORAGE_USER_CONTEXT_ID,
629     });
630   },
632   /**
633    * Select the preferred backend and return a promise which is resolved once the
634    * selected backend is ready to be used (e.g. if the extension is switching from
635    * the old JSONFile storage to the new IDB backend, any previously stored data will
636    * be migrated to the backend before the promise is resolved).
637    *
638    * This method is called from both the main and child (content or extension) processes:
639    * - an extension child context will call this method lazily, when the browser.storage.local
640    *   is being used for the first time, and it will result into asking the main process
641    *   to call the same method in the main process
642    * - on the main process side, it will check if the new IDB backend can be used (and if it can,
643    *   it will migrate any existing data into the new backend, which needs to happen in the
644    *   main process where the file can directly be accessed)
645    *
646    * The result will be cached while the extension is still running, and so an extension
647    * child context is going to ask the main process only once per child process, and on the
648    * main process side the backend selection and data migration will happen only once.
649    *
650    * @param {import("ExtensionPageChild.sys.mjs").ExtensionBaseContextChild} context
651    *        The extension context that is selecting the storage backend.
652    *
653    * @returns {Promise<object>}
654    *          Returns a promise which resolves to an object which provides a
655    *          `backendEnabled` boolean property, and if it is true the extension should use
656    *          the IDB backend and the object also includes a `storagePrincipal` property
657    *          of type nsIPrincipal, otherwise `backendEnabled` will be false when the
658    *          extension should use the old JSONFile backend (e.g. because the IDB backend has
659    *          not been enabled from the preference).
660    */
661   selectBackend(context) {
662     const { extension } = context;
664     if (!this.selectedBackendPromises.has(extension)) {
665       let promise;
667       if (context.childManager) {
668         return context.childManager
669           .callParentAsyncFunction("storage.local.IDBBackend.selectBackend", [])
670           .then(parentResult => {
671             let result;
673             if (!parentResult.backendEnabled) {
674               result = { backendEnabled: false };
675             } else {
676               result = {
677                 ...parentResult,
678                 // In the child process, we need to deserialize the storagePrincipal
679                 // from the StructuredCloneHolder used to send it across the processes.
680                 storagePrincipal: parentResult.storagePrincipal.deserialize(
681                   this,
682                   true
683                 ),
684               };
685             }
687             // Cache the result once we know that it has been resolved. The promise returned by
688             // context.childManager.callParentAsyncFunction will be dead when context.cloneScope
689             // is destroyed. To keep a promise alive in the cache, we wrap the result in an
690             // independent promise.
691             this.selectedBackendPromises.set(
692               extension,
693               Promise.resolve(result)
694             );
696             return result;
697           });
698       }
700       // If migrating to the IDB backend is not enabled by the preference, then we
701       // don't need to migrate any data and the new backend is not enabled.
702       if (!this.isBackendEnabled) {
703         promise = Promise.resolve({ backendEnabled: false });
704       } else {
705         // In the main process, lazily create a storagePrincipal isolated in a
706         // reserved user context id (its purpose is ensuring that the IndexedDB storage used
707         // by the browser.storage.local API is not directly accessible from the extension code).
708         const storagePrincipal = this.getStoragePrincipal(extension);
710         // Serialize the nsIPrincipal object into a StructuredCloneHolder related to the privileged
711         // js global, ready to be sent to the child processes.
712         const serializedPrincipal = new StructuredCloneHolder(
713           "ExtensionStorageIDB/selectBackend/serializedPrincipal",
714           null,
715           storagePrincipal,
716           this
717         );
719         promise = migrateJSONFileData(extension, storagePrincipal)
720           .then(() => {
721             extension.setSharedData("storageIDBBackend", true);
722             extension.setSharedData("storageIDBPrincipal", storagePrincipal);
723             Services.ppmm.sharedData.flush();
724             return {
725               backendEnabled: true,
726               storagePrincipal: serializedPrincipal,
727             };
728           })
729           .catch(err => {
730             // If the data migration promise is rejected, the old data has been read
731             // successfully from the old JSONFile backend but it failed to be saved
732             // into the IndexedDB backend (which is likely unrelated to the kind of
733             // data stored and more likely a general issue with the IndexedDB backend)
734             // In this case we keep the JSONFile backend enabled for this session
735             // and we will retry to migrate to the IDB Backend the next time the
736             // extension is being started.
737             // TODO Bug 1465129: This should be a very unlikely scenario, some telemetry
738             // data about it may be useful.
739             extension.logWarning(
740               "JSONFile backend is being kept enabled by an unexpected " +
741                 `IDBBackend failure: ${err.message}::${err.stack}`
742             );
743             extension.setSharedData("storageIDBBackend", false);
744             Services.ppmm.sharedData.flush();
746             return { backendEnabled: false };
747           });
748       }
750       this.selectedBackendPromises.set(extension, promise);
751     }
753     return this.selectedBackendPromises.get(extension);
754   },
756   persist(storagePrincipal) {
757     return new Promise((resolve, reject) => {
758       const request = Services.qms.persist(storagePrincipal);
759       request.callback = () => {
760         if (request.resultCode === Cr.NS_OK) {
761           resolve();
762         } else {
763           reject(
764             new Error(
765               `Failed to persist storage for principal: ${storagePrincipal.originNoSuffix}`
766             )
767           );
768         }
769       };
770     });
771   },
773   /**
774    * Open a connection to the IDB storage.local db for a given extension.
775    * given extension.
776    *
777    * @param {nsIPrincipal} storagePrincipal
778    *        The "internally reserved" extension storagePrincipal to be used to create
779    *        the ExtensionStorageLocalIDB instance.
780    * @param {boolean} persisted
781    *        A boolean which indicates if the storage should be set into persistent mode.
782    *
783    * @returns {Promise<ExtensionStorageLocalIDB>}
784    *          Return a promise which resolves to the opened IDB connection.
785    */
786   open(storagePrincipal, persisted) {
787     if (!storagePrincipal) {
788       return Promise.reject(new Error("Unexpected empty principal"));
789     }
790     let setPersistentMode = persisted
791       ? this.persist(storagePrincipal)
792       : Promise.resolve();
793     return setPersistentMode.then(() =>
794       ExtensionStorageLocalIDB.openForPrincipal(storagePrincipal)
795     );
796   },
798   /**
799    * Ensure that an error originated from the ExtensionStorageIDB methods is normalized
800    * into an ExtensionError (e.g. DataCloneError and QuotaExceededError instances raised
801    * from the internal IndexedDB operations have to be converted into an ExtensionError
802    * to be accessible to the extension code).
803    *
804    * @param {object} params
805    * @param {Error|ExtensionError|DOMException} params.error
806    *        The error object to normalize.
807    * @param {string} params.extensionId
808    *        The id of the extension that was executing the storage.local method.
809    * @param {string} params.storageMethod
810    *        The storage method being executed when the error has been thrown
811    *        (used to keep track of the unexpected error incidence in telemetry).
812    *
813    * @returns {ExtensionError}
814    *          Return an ExtensionError error instance.
815    */
816   normalizeStorageError({ error, extensionId, storageMethod }) {
817     const { ExtensionError } = lazy.ExtensionUtils;
819     if (error instanceof ExtensionError) {
820       // @ts-ignore (will go away after `lazy` is properly typed)
821       return error;
822     }
824     let errorMessage;
826     if (DOMException.isInstance(error)) {
827       switch (error.name) {
828         case "DataCloneError":
829           errorMessage = String(error);
830           break;
831         case "QuotaExceededError":
832           errorMessage = `${error.name}: storage.local API call exceeded its quota limitations.`;
833           break;
834       }
835     }
837     if (!errorMessage) {
838       Cu.reportError(error);
840       errorMessage = "An unexpected error occurred";
842       ErrorsTelemetry.recordStorageLocalError({
843         error,
844         extensionId,
845         storageMethod,
846       });
847     }
849     return new ExtensionError(errorMessage);
850   },
852   addOnChangedListener(extensionId, listener) {
853     let listeners = this.listeners.get(extensionId) || new Set();
854     listeners.add(listener);
855     this.listeners.set(extensionId, listeners);
856   },
858   removeOnChangedListener(extensionId, listener) {
859     let listeners = this.listeners.get(extensionId);
860     listeners.delete(listener);
861   },
863   notifyListeners(extensionId, changes) {
864     let listeners = this.listeners.get(extensionId);
865     if (listeners) {
866       for (let listener of listeners) {
867         listener(changes);
868       }
869     }
870   },
872   hasListeners(extensionId) {
873     let listeners = this.listeners.get(extensionId);
874     return listeners && listeners.size > 0;
875   },
878 ExtensionStorageIDB.init();