Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / services / settings / IDBHelpers.sys.mjs
blob6f2ef6937dcf5ccebe9d4b64acd4068ed7b859fe
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 file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const DB_NAME = "remote-settings";
6 const DB_VERSION = 3;
8 /**
9  * Wrap IndexedDB errors to catch them more easily.
10  */
11 class IndexedDBError extends Error {
12   constructor(error, method = "", identifier = "") {
13     if (typeof error == "string") {
14       error = new Error(error);
15     }
16     super(`IndexedDB: ${identifier} ${method} ${error && error.message}`);
17     this.name = error.name;
18     this.stack = error.stack;
19   }
22 class ShutdownError extends IndexedDBError {
23   constructor(error, method = "", identifier = "") {
24     super(error, method, identifier);
25   }
28 // We batch operations in order to reduce round-trip latency to the IndexedDB
29 // database thread. The trade-offs are that the more records in the batch, the
30 // more time we spend on this thread in structured serialization, and the
31 // greater the chance to jank PBackground and this thread when the responses
32 // come back. The initial choice of 250 was made targeting 2-3ms on a fast
33 // machine and 10-15ms on a slow machine.
34 // Every chunk waits for success before starting the next, and
35 // the final chunk's completion will fire transaction.oncomplete .
36 function bulkOperationHelper(
37   store,
38   { reject, completion },
39   operation,
40   list,
41   listIndex = 0
42 ) {
43   try {
44     const CHUNK_LENGTH = 250;
45     const max = Math.min(listIndex + CHUNK_LENGTH, list.length);
46     let request;
47     for (; listIndex < max; listIndex++) {
48       request = store[operation](list[listIndex]);
49     }
50     if (listIndex < list.length) {
51       // On error, `transaction.onerror` is called.
52       request.onsuccess = bulkOperationHelper.bind(
53         null,
54         store,
55         { reject, completion },
56         operation,
57         list,
58         listIndex
59       );
60     } else if (completion) {
61       completion();
62     }
63     // otherwise, we're done, and the transaction will complete on its own.
64   } catch (e) {
65     // The executeIDB callsite has a try... catch, but it will not catch
66     // errors in subsequent bulkOperationHelper calls chained through
67     // request.onsuccess above. We do want to catch those, so we have to
68     // feed them through manually. We cannot use an async function with
69     // promises, because if we wait a microtask after onsuccess fires to
70     // put more requests on the transaction, the transaction will auto-commit
71     // before we can add more requests.
72     reject(e);
73   }
76 /**
77  * Helper to wrap some IDBObjectStore operations into a promise.
78  *
79  * @param {IDBDatabase} db
80  * @param {String|String[]} storeNames - either a string or an array of strings.
81  * @param {String} mode
82  * @param {function} callback
83  * @param {String} description of the operation for error handling purposes.
84  */
85 function executeIDB(db, storeNames, mode, callback, desc) {
86   if (!Array.isArray(storeNames)) {
87     storeNames = [storeNames];
88   }
89   const transaction = db.transaction(storeNames, mode);
90   let promise = new Promise((resolve, reject) => {
91     let stores = storeNames.map(name => transaction.objectStore(name));
92     let result;
93     let rejectWrapper = e => {
94       reject(new IndexedDBError(e, desc || "execute()", storeNames.join(", ")));
95       try {
96         transaction.abort();
97       } catch (ex) {
98         console.error(ex);
99       }
100     };
101     // Add all the handlers before using the stores.
102     transaction.onerror = event =>
103       reject(new IndexedDBError(event.target.error, desc || "execute()"));
104     transaction.onabort = event =>
105       reject(
106         new IndexedDBError(
107           event.target.error || transaction.error || "IDBTransaction aborted",
108           desc || "execute()"
109         )
110       );
111     transaction.oncomplete = event => resolve(result);
112     // Simplify access to a single datastore:
113     if (stores.length == 1) {
114       stores = stores[0];
115     }
116     try {
117       // Although this looks sync, once the callback places requests
118       // on the datastore, it can independently keep the transaction alive and
119       // keep adding requests. Even once we exit this try.. catch, we may
120       // therefore experience errors which should abort the transaction.
121       // This is why we pass the rejection handler - then the consumer can
122       // continue to ensure that errors are handled appropriately.
123       // In theory, exceptions thrown from onsuccess handlers should also
124       // cause IndexedDB to abort the transaction, so this is a belt-and-braces
125       // approach.
126       result = callback(stores, rejectWrapper);
127     } catch (e) {
128       rejectWrapper(e);
129     }
130   });
131   return { promise, transaction };
135  * Helper to wrap indexedDB.open() into a promise.
136  */
137 async function openIDB(allowUpgrades = true) {
138   return new Promise((resolve, reject) => {
139     const request = indexedDB.open(DB_NAME, DB_VERSION);
140     request.onupgradeneeded = event => {
141       if (!allowUpgrades) {
142         reject(
143           new Error(
144             `IndexedDB: Error accessing ${DB_NAME} IDB at version ${DB_VERSION}`
145           )
146         );
147         return;
148       }
149       // When an upgrade is needed, a transaction is started.
150       const transaction = event.target.transaction;
151       transaction.onabort = event => {
152         const error =
153           event.target.error ||
154           transaction.error ||
155           new DOMException("The operation has been aborted", "AbortError");
156         reject(new IndexedDBError(error, "open()"));
157       };
159       const db = event.target.result;
160       db.onerror = event => reject(new IndexedDBError(event.target.error));
162       if (event.oldVersion < 1) {
163         // Records store
164         const recordsStore = db.createObjectStore("records", {
165           keyPath: ["_cid", "id"],
166         });
167         // An index to obtain all the records in a collection.
168         recordsStore.createIndex("cid", "_cid");
169         // Last modified field
170         recordsStore.createIndex("last_modified", ["_cid", "last_modified"]);
171         // Timestamps store
172         db.createObjectStore("timestamps", {
173           keyPath: "cid",
174         });
175       }
176       if (event.oldVersion < 2) {
177         // Collections store
178         db.createObjectStore("collections", {
179           keyPath: "cid",
180         });
181       }
182       if (event.oldVersion < 3) {
183         // Attachment store
184         db.createObjectStore("attachments", {
185           keyPath: ["cid", "attachmentId"],
186         });
187       }
188     };
189     request.onerror = event => reject(new IndexedDBError(event.target.error));
190     request.onsuccess = event => {
191       const db = event.target.result;
192       resolve(db);
193     };
194   });
197 function destroyIDB() {
198   const request = indexedDB.deleteDatabase(DB_NAME);
199   return new Promise((resolve, reject) => {
200     request.onerror = event => reject(new IndexedDBError(event.target.error));
201     request.onsuccess = () => resolve();
202   });
205 export var IDBHelpers = {
206   bulkOperationHelper,
207   executeIDB,
208   openIDB,
209   destroyIDB,
210   IndexedDBError,
211   ShutdownError,