IDBHelpers.sys.mjs (7260B)
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/. */ 4 5 const DB_NAME = "remote-settings"; 6 const DB_VERSION = 3; 7 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 } 20 } 21 22 class ShutdownError extends IndexedDBError { 23 constructor(error, method = "", identifier = "") { 24 super(error, method, identifier); 25 } 26 } 27 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 } 74 } 75 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 = () => 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 }; 132 } 133 134 /** 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 }; 158 159 const db = event.target.result; 160 db.onerror = event => reject(new IndexedDBError(event.target.error)); 161 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 // Clear existing stores for a fresh start 184 transaction.objectStore("records").clear(); 185 transaction.objectStore("timestamps").clear(); 186 transaction.objectStore("collections").clear(); 187 // Attachment store 188 db.createObjectStore("attachments", { 189 keyPath: ["cid", "attachmentId"], 190 }); 191 } 192 }; 193 request.onerror = event => reject(new IndexedDBError(event.target.error)); 194 request.onsuccess = event => { 195 const db = event.target.result; 196 resolve(db); 197 }; 198 }); 199 } 200 201 function destroyIDB() { 202 const request = indexedDB.deleteDatabase(DB_NAME); 203 return new Promise((resolve, reject) => { 204 request.onerror = event => reject(new IndexedDBError(event.target.error)); 205 request.onsuccess = () => resolve(); 206 }); 207 } 208 209 export var IDBHelpers = { 210 bulkOperationHelper, 211 executeIDB, 212 openIDB, 213 destroyIDB, 214 IndexedDBError, 215 ShutdownError, 216 };