Bug 1686495 [wpt PR 27132] - Add tests for proposed WebDriver Shadow DOM support...
[gecko.git] / services / settings / Database.jsm
blob5654e2bf119e27b07c32e08663397f139fa2e538
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 const { XPCOMUtils } = ChromeUtils.import(
6   "resource://gre/modules/XPCOMUtils.jsm"
7 );
8 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10 XPCOMUtils.defineLazyModuleGetters(this, {
11   AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
12   IDBHelpers: "resource://services-settings/IDBHelpers.jsm",
13   Utils: "resource://services-settings/Utils.jsm",
14   CommonUtils: "resource://services-common/utils.js",
15   ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
16 });
17 XPCOMUtils.defineLazyGetter(this, "console", () => Utils.log);
19 var EXPORTED_SYMBOLS = ["Database"];
21 /**
22  * Database is a tiny wrapper with the objective
23  * of providing major kinto-offline-client collection API.
24  * (with the objective of getting rid of kinto-offline-client)
25  */
26 class Database {
27   constructor(identifier) {
28     ensureShutdownBlocker();
29     this.identifier = identifier;
30   }
32   async list(options = {}) {
33     const { filters = {}, order = "" } = options;
34     let results = [];
35     try {
36       await executeIDB(
37         "records",
38         (store, rejectTransaction) => {
39           // Fast-path the (very common) no-filters case
40           if (ObjectUtils.isEmpty(filters)) {
41             const range = IDBKeyRange.only(this.identifier);
42             const request = store.index("cid").getAll(range);
43             request.onsuccess = e => {
44               results = e.target.result;
45             };
46             return;
47           }
48           const request = store
49             .index("cid")
50             .openCursor(IDBKeyRange.only(this.identifier));
51           const objFilters = transformSubObjectFilters(filters);
52           request.onsuccess = event => {
53             try {
54               const cursor = event.target.result;
55               if (cursor) {
56                 const { value } = cursor;
57                 if (Utils.filterObject(objFilters, value)) {
58                   results.push(value);
59                 }
60                 cursor.continue();
61               }
62             } catch (ex) {
63               rejectTransaction(ex);
64             }
65           };
66         },
67         { mode: "readonly" }
68       );
69     } catch (e) {
70       throw new IDBHelpers.IndexedDBError(e, "list()", this.identifier);
71     }
72     // Remove IDB key field from results.
73     for (const result of results) {
74       delete result._cid;
75     }
76     return order ? Utils.sortObjects(order, results) : results;
77   }
79   async importChanges(metadata, timestamp, records = [], options = {}) {
80     const { clear = false } = options;
81     const _cid = this.identifier;
82     try {
83       await executeIDB(
84         ["collections", "timestamps", "records"],
85         (stores, rejectTransaction) => {
86           const [storeMetadata, storeTimestamps, storeRecords] = stores;
88           if (clear) {
89             // Our index is over the _cid and id fields. We want to remove
90             // all of the items in the collection for which the object was
91             // created, ie with _cid == this.identifier.
92             // We would like to just tell IndexedDB:
93             // store.index(IDBKeyRange.only(this.identifier)).delete();
94             // to delete all records matching the first part of the 2-part key.
95             // Unfortunately such an API does not exist.
96             // While we could iterate over the index with a cursor, we'd do
97             // a roundtrip to PBackground for each item. Once you have 1000
98             // items, the result is very slow because of all the overhead of
99             // jumping between threads and serializing/deserializing.
100             // So instead, we tell the store to delete everything between
101             // "our" _cid identifier, and what would be the next identifier
102             // (via lexicographical sorting). Unfortunately there does not
103             // seem to be a way to specify bounds for all items that share
104             // the same first part of the key using just that first part, hence
105             // the use of the hypothetical [] for the second part of the end of
106             // the bounds.
107             storeRecords.delete(
108               IDBKeyRange.bound([_cid], [_cid, []], false, true)
109             );
110           }
112           // Store or erase metadata.
113           if (metadata === null) {
114             storeMetadata.delete(_cid);
115           } else if (metadata) {
116             storeMetadata.put({ cid: _cid, metadata });
117           }
118           // Store or erase timestamp.
119           if (timestamp === null) {
120             storeTimestamps.delete(_cid);
121           } else if (timestamp) {
122             storeTimestamps.put({ cid: _cid, value: timestamp });
123           }
125           if (records.length == 0) {
126             return;
127           }
129           // Separate tombstones from creations/updates.
130           const toDelete = records.filter(r => r.deleted);
131           const toInsert = records.filter(r => !r.deleted);
132           console.debug(
133             `${_cid} ${toDelete.length} to delete, ${toInsert.length} to insert`
134           );
135           // Delete local records for each tombstone.
136           IDBHelpers.bulkOperationHelper(
137             storeRecords,
138             {
139               reject: rejectTransaction,
140               completion() {
141                 // Overwrite all other data.
142                 IDBHelpers.bulkOperationHelper(
143                   storeRecords,
144                   {
145                     reject: rejectTransaction,
146                   },
147                   "put",
148                   toInsert.map(item => ({ ...item, _cid }))
149                 );
150               },
151             },
152             "delete",
153             toDelete.map(item => [_cid, item.id])
154           );
155         },
156         { desc: "importChanges() in " + _cid }
157       );
158     } catch (e) {
159       throw new IDBHelpers.IndexedDBError(e, "importChanges()", _cid);
160     }
161   }
163   async getLastModified() {
164     let entry = null;
165     try {
166       await executeIDB(
167         "timestamps",
168         store => {
169           store.get(this.identifier).onsuccess = e => (entry = e.target.result);
170         },
171         { mode: "readonly" }
172       );
173     } catch (e) {
174       throw new IDBHelpers.IndexedDBError(
175         e,
176         "getLastModified()",
177         this.identifier
178       );
179     }
180     return entry ? entry.value : null;
181   }
183   async getMetadata() {
184     let entry = null;
185     try {
186       await executeIDB(
187         "collections",
188         store => {
189           store.get(this.identifier).onsuccess = e => (entry = e.target.result);
190         },
191         { mode: "readonly" }
192       );
193     } catch (e) {
194       throw new IDBHelpers.IndexedDBError(e, "getMetadata()", this.identifier);
195     }
196     return entry ? entry.metadata : null;
197   }
199   async getAttachment(attachmentId) {
200     let entry = null;
201     try {
202       await executeIDB(
203         "attachments",
204         store => {
205           store.get([this.identifier, attachmentId]).onsuccess = e => {
206             entry = e.target.result;
207           };
208         },
209         { mode: "readonly" }
210       );
211     } catch (e) {
212       throw new IDBHelpers.IndexedDBError(
213         e,
214         "getAttachment()",
215         this.identifier
216       );
217     }
218     return entry ? entry.attachment : null;
219   }
221   async saveAttachment(attachmentId, attachment) {
222     try {
223       await executeIDB(
224         "attachments",
225         store => {
226           if (attachment) {
227             store.put({ cid: this.identifier, attachmentId, attachment });
228           } else {
229             store.delete([this.identifier, attachmentId]);
230           }
231         },
232         { desc: "saveAttachment(" + attachmentId + ") in " + this.identifier }
233       );
234     } catch (e) {
235       throw new IDBHelpers.IndexedDBError(
236         e,
237         "saveAttachment()",
238         this.identifier
239       );
240     }
241   }
243   async clear() {
244     try {
245       await this.importChanges(null, null, [], { clear: true });
246     } catch (e) {
247       throw new IDBHelpers.IndexedDBError(e, "clear()", this.identifier);
248     }
249   }
251   /*
252    * Methods used by unit tests.
253    */
255   async create(record) {
256     if (!("id" in record)) {
257       record = { ...record, id: CommonUtils.generateUUID() };
258     }
259     try {
260       await executeIDB(
261         "records",
262         store => {
263           store.add({ ...record, _cid: this.identifier });
264         },
265         { desc: "create() in " + this.identifier }
266       );
267     } catch (e) {
268       throw new IDBHelpers.IndexedDBError(e, "create()", this.identifier);
269     }
270     return record;
271   }
273   async update(record) {
274     try {
275       await executeIDB(
276         "records",
277         store => {
278           store.put({ ...record, _cid: this.identifier });
279         },
280         { desc: "update() in " + this.identifier }
281       );
282     } catch (e) {
283       throw new IDBHelpers.IndexedDBError(e, "update()", this.identifier);
284     }
285   }
287   async delete(recordId) {
288     try {
289       await executeIDB(
290         "records",
291         store => {
292           store.delete([this.identifier, recordId]); // [_cid, id]
293         },
294         { desc: "delete() in " + this.identifier }
295       );
296     } catch (e) {
297       throw new IDBHelpers.IndexedDBError(e, "delete()", this.identifier);
298     }
299   }
302 let gDB = null;
303 let gDBPromise = null;
306  * This function attempts to ensure `gDB` points to a valid database value.
307  * If gDB is already a database, it will do no-op (but this may take a
308  * microtask or two).
309  * If opening the database fails, it will throw an IndexedDBError.
310  */
311 async function openIDB() {
312   // We can be called multiple times in a race; always ensure that when
313   // we complete, `gDB` is no longer null, but avoid doing the actual
314   // IndexedDB work more than once.
315   if (!gDBPromise) {
316     // Open and initialize/upgrade if needed.
317     gDBPromise = IDBHelpers.openIDB();
318   }
319   let db = await gDBPromise;
320   if (!gDB) {
321     gDB = db;
322   }
325 const gPendingReadOnlyTransactions = new Set();
326 const gPendingWriteOperations = new Set();
328  * Helper to wrap some IDBObjectStore operations into a promise.
330  * @param {IDBDatabase} db
331  * @param {String|String[]} storeNames - either a string or an array of strings.
332  * @param {function} callback
333  * @param {Object} options
334  * @param {String} options.mode
335  * @param {String} options.desc   for shutdown tracking.
336  */
337 async function executeIDB(storeNames, callback, options = {}) {
338   if (!gDB) {
339     // Check if we're shutting down. Services.startup.shuttingDown will
340     // be true sooner, but is never true in xpcshell tests, so we check
341     // both that and a bool we set ourselves when `profile-before-change`
342     // starts.
343     if (gShutdownStarted || Services.startup.shuttingDown) {
344       throw new IDBHelpers.ShutdownError(
345         "The application is shutting down",
346         "execute()"
347       );
348     }
349     await openIDB();
350   } else {
351     // Even if we have a db, wait a tick to avoid making IndexedDB sad.
352     // We should be able to remove this once bug 1626935 is fixed.
353     await Promise.resolve();
354   }
356   // Check for shutdown again as we've await'd something...
357   if (!gDB && (gShutdownStarted || Services.startup.shuttingDown)) {
358     throw new IDBHelpers.ShutdownError(
359       "The application is shutting down",
360       "execute()"
361     );
362   }
364   // Start the actual transaction:
365   const { mode = "readwrite", desc = "" } = options;
366   let { promise, transaction } = IDBHelpers.executeIDB(
367     gDB,
368     storeNames,
369     mode,
370     callback,
371     desc
372   );
374   // We track all readonly transactions and abort them at shutdown.
375   // We track all readwrite ones and await their completion at shutdown
376   // (to avoid dataloss when writes fail).
377   // We use a `.finally()` clause for this; it'll run the function irrespective
378   // of whether the promise resolves or rejects, and the promise it returns
379   // will resolve/reject with the same value.
380   let finishedFn;
381   if (mode == "readonly") {
382     gPendingReadOnlyTransactions.add(transaction);
383     finishedFn = () => gPendingReadOnlyTransactions.delete(transaction);
384   } else {
385     let obj = { promise, desc };
386     gPendingWriteOperations.add(obj);
387     finishedFn = () => gPendingWriteOperations.delete(obj);
388   }
389   return promise.finally(finishedFn);
392 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
393   const last = arr.length - 1;
394   return arr.reduce((acc, cv, i) => {
395     if (i === last) {
396       return (acc[cv] = val);
397     } else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
398       return acc[cv];
399     }
400     return (acc[cv] = {});
401   }, nestedFiltersObj);
404 function transformSubObjectFilters(filtersObj) {
405   const transformedFilters = {};
406   for (const [key, val] of Object.entries(filtersObj)) {
407     const keysArr = key.split(".");
408     makeNestedObjectFromArr(keysArr, val, transformedFilters);
409   }
410   return transformedFilters;
413 // We need to expose this wrapper function so we can test
414 // shutdown handling.
415 Database._executeIDB = executeIDB;
417 let gShutdownStarted = false;
418 // Test-only helper to be able to test shutdown multiple times:
419 Database._cancelShutdown = () => {
420   gShutdownStarted = false;
423 let gShutdownBlocker = false;
424 Database._shutdownHandler = () => {
425   gShutdownStarted = true;
426   const NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR = 0x80660006;
427   // Duplicate the list (to avoid it being modified) and then
428   // abort all read-only transactions.
429   for (let transaction of Array.from(gPendingReadOnlyTransactions)) {
430     try {
431       transaction.abort();
432     } catch (ex) {
433       // Ensure we don't throw/break, because either way we're in shutdown.
435       // In particular, `transaction.abort` can throw if the transaction
436       // is complete, ie if we manage to get called inbetween the
437       // transaction completing, and our completion handler being called
438       // to remove the item from the set. We don't care about that.
439       if (ex.result != NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR) {
440         // Report any other errors:
441         Cu.reportError(ex);
442       }
443     }
444   }
445   if (gDB) {
446     // This will return immediately; the actual close will happen once
447     // there are no more running transactions.
448     gDB.close();
449     gDB = null;
450   }
451   gDBPromise = null;
452   return Promise.allSettled(
453     Array.from(gPendingWriteOperations).map(op => op.promise)
454   );
457 function ensureShutdownBlocker() {
458   if (gShutdownBlocker) {
459     return;
460   }
461   gShutdownBlocker = true;
462   AsyncShutdown.profileBeforeChange.addBlocker(
463     "RemoteSettingsClient - finish IDB access.",
464     Database._shutdownHandler,
465     {
466       fetchState() {
467         return Array.from(gPendingWriteOperations).map(op => op.desc);
468       },
469     }
470   );