Bug 1811871 - Check collapsed attribute rather than computed opacity value to tell...
[gecko.git] / services / settings / Database.jsm
blob5ee84c108fd7a62ab4339c03a084a0f420cc1ce9
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.importESModule(
6   "resource://gre/modules/XPCOMUtils.sys.mjs"
7 );
9 const lazy = {};
11 ChromeUtils.defineESModuleGetters(lazy, {
12   AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
13 });
15 XPCOMUtils.defineLazyModuleGetters(lazy, {
16   IDBHelpers: "resource://services-settings/IDBHelpers.jsm",
17   Utils: "resource://services-settings/Utils.jsm",
18   CommonUtils: "resource://services-common/utils.js",
19   ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
20 });
21 XPCOMUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log);
23 var EXPORTED_SYMBOLS = ["Database"];
25 /**
26  * Database is a tiny wrapper with the objective
27  * of providing major kinto-offline-client collection API.
28  * (with the objective of getting rid of kinto-offline-client)
29  */
30 class Database {
31   static destroy() {
32     return destroyIDB();
33   }
35   constructor(identifier) {
36     ensureShutdownBlocker();
37     this.identifier = identifier;
38   }
40   async list(options = {}) {
41     const { filters = {}, order = "" } = options;
42     let results = [];
43     try {
44       await executeIDB(
45         "records",
46         (store, rejectTransaction) => {
47           // Fast-path the (very common) no-filters case
48           if (lazy.ObjectUtils.isEmpty(filters)) {
49             const range = IDBKeyRange.only(this.identifier);
50             const request = store.index("cid").getAll(range);
51             request.onsuccess = e => {
52               results = e.target.result;
53             };
54             return;
55           }
56           const request = store
57             .index("cid")
58             .openCursor(IDBKeyRange.only(this.identifier));
59           const objFilters = transformSubObjectFilters(filters);
60           request.onsuccess = event => {
61             try {
62               const cursor = event.target.result;
63               if (cursor) {
64                 const { value } = cursor;
65                 if (lazy.Utils.filterObject(objFilters, value)) {
66                   results.push(value);
67                 }
68                 cursor.continue();
69               }
70             } catch (ex) {
71               rejectTransaction(ex);
72             }
73           };
74         },
75         { mode: "readonly" }
76       );
77     } catch (e) {
78       throw new lazy.IDBHelpers.IndexedDBError(e, "list()", this.identifier);
79     }
80     // Remove IDB key field from results.
81     for (const result of results) {
82       delete result._cid;
83     }
84     return order ? lazy.Utils.sortObjects(order, results) : results;
85   }
87   async importChanges(metadata, timestamp, records = [], options = {}) {
88     const { clear = false } = options;
89     const _cid = this.identifier;
90     try {
91       await executeIDB(
92         ["collections", "timestamps", "records"],
93         (stores, rejectTransaction) => {
94           const [storeMetadata, storeTimestamps, storeRecords] = stores;
96           if (clear) {
97             // Our index is over the _cid and id fields. We want to remove
98             // all of the items in the collection for which the object was
99             // created, ie with _cid == this.identifier.
100             // We would like to just tell IndexedDB:
101             // store.index(IDBKeyRange.only(this.identifier)).delete();
102             // to delete all records matching the first part of the 2-part key.
103             // Unfortunately such an API does not exist.
104             // While we could iterate over the index with a cursor, we'd do
105             // a roundtrip to PBackground for each item. Once you have 1000
106             // items, the result is very slow because of all the overhead of
107             // jumping between threads and serializing/deserializing.
108             // So instead, we tell the store to delete everything between
109             // "our" _cid identifier, and what would be the next identifier
110             // (via lexicographical sorting). Unfortunately there does not
111             // seem to be a way to specify bounds for all items that share
112             // the same first part of the key using just that first part, hence
113             // the use of the hypothetical [] for the second part of the end of
114             // the bounds.
115             storeRecords.delete(
116               IDBKeyRange.bound([_cid], [_cid, []], false, true)
117             );
118           }
120           // Store or erase metadata.
121           if (metadata === null) {
122             storeMetadata.delete(_cid);
123           } else if (metadata) {
124             storeMetadata.put({ cid: _cid, metadata });
125           }
126           // Store or erase timestamp.
127           if (timestamp === null) {
128             storeTimestamps.delete(_cid);
129           } else if (timestamp) {
130             storeTimestamps.put({ cid: _cid, value: timestamp });
131           }
133           if (!records.length) {
134             return;
135           }
137           // Separate tombstones from creations/updates.
138           const toDelete = records.filter(r => r.deleted);
139           const toInsert = records.filter(r => !r.deleted);
140           lazy.console.debug(
141             `${_cid} ${toDelete.length} to delete, ${toInsert.length} to insert`
142           );
143           // Delete local records for each tombstone.
144           lazy.IDBHelpers.bulkOperationHelper(
145             storeRecords,
146             {
147               reject: rejectTransaction,
148               completion() {
149                 // Overwrite all other data.
150                 lazy.IDBHelpers.bulkOperationHelper(
151                   storeRecords,
152                   {
153                     reject: rejectTransaction,
154                   },
155                   "put",
156                   toInsert.map(item => ({ ...item, _cid }))
157                 );
158               },
159             },
160             "delete",
161             toDelete.map(item => [_cid, item.id])
162           );
163         },
164         { desc: "importChanges() in " + _cid }
165       );
166     } catch (e) {
167       throw new lazy.IDBHelpers.IndexedDBError(e, "importChanges()", _cid);
168     }
169   }
171   async getLastModified() {
172     let entry = null;
173     try {
174       await executeIDB(
175         "timestamps",
176         store => {
177           store.get(this.identifier).onsuccess = e => (entry = e.target.result);
178         },
179         { mode: "readonly" }
180       );
181     } catch (e) {
182       throw new lazy.IDBHelpers.IndexedDBError(
183         e,
184         "getLastModified()",
185         this.identifier
186       );
187     }
188     if (!entry) {
189       return null;
190     }
191     // Some distributions where released with a modified dump that did not
192     // contain timestamps for last_modified. Work around this here, and return
193     // the timestamp as zero, so that the entries should get updated.
194     if (isNaN(entry.value)) {
195       lazy.console.warn(`Local timestamp is NaN for ${this.identifier}`);
196       return 0;
197     }
198     return entry.value;
199   }
201   async getMetadata() {
202     let entry = null;
203     try {
204       await executeIDB(
205         "collections",
206         store => {
207           store.get(this.identifier).onsuccess = e => (entry = e.target.result);
208         },
209         { mode: "readonly" }
210       );
211     } catch (e) {
212       throw new lazy.IDBHelpers.IndexedDBError(
213         e,
214         "getMetadata()",
215         this.identifier
216       );
217     }
218     return entry ? entry.metadata : null;
219   }
221   async getAttachment(attachmentId) {
222     let entry = null;
223     try {
224       await executeIDB(
225         "attachments",
226         store => {
227           store.get([this.identifier, attachmentId]).onsuccess = e => {
228             entry = e.target.result;
229           };
230         },
231         { mode: "readonly" }
232       );
233     } catch (e) {
234       throw new lazy.IDBHelpers.IndexedDBError(
235         e,
236         "getAttachment()",
237         this.identifier
238       );
239     }
240     return entry ? entry.attachment : null;
241   }
243   async saveAttachment(attachmentId, attachment) {
244     try {
245       await executeIDB(
246         "attachments",
247         store => {
248           if (attachment) {
249             store.put({ cid: this.identifier, attachmentId, attachment });
250           } else {
251             store.delete([this.identifier, attachmentId]);
252           }
253         },
254         { desc: "saveAttachment(" + attachmentId + ") in " + this.identifier }
255       );
256     } catch (e) {
257       throw new lazy.IDBHelpers.IndexedDBError(
258         e,
259         "saveAttachment()",
260         this.identifier
261       );
262     }
263   }
265   /**
266    * Delete all attachments which don't match any record.
267    *
268    * Attachments are linked to records, except when a fixed `attachmentId` is used.
269    * A record can be updated or deleted, potentially by deleting a record and restoring an updated version
270    * of the record with the same ID. Potentially leaving orphaned attachments in the database.
271    * Since we run the pruning logic after syncing, any attachment without a
272    * matching record can be discarded as they will be unreachable forever.
273    *
274    * @param {Array<String>} excludeIds List of attachments IDs to exclude from pruning.
275    */
276   async pruneAttachments(excludeIds) {
277     const _cid = this.identifier;
278     let deletedCount = 0;
279     try {
280       await executeIDB(
281         ["attachments", "records"],
282         async (stores, rejectTransaction) => {
283           const [attachmentsStore, recordsStore] = stores;
285           // List all stored attachments.
286           // All keys â‰¥ [_cid, ..] && < [_cid, []]. See comment in `importChanges()`
287           const rangeAllKeys = IDBKeyRange.bound(
288             [_cid],
289             [_cid, []],
290             false,
291             true
292           );
293           const allAttachments = await new Promise((resolve, reject) => {
294             const request = attachmentsStore.getAll(rangeAllKeys);
295             request.onsuccess = e => resolve(e.target.result);
296             request.onerror = e => reject(e);
297           });
298           if (!allAttachments.length) {
299             lazy.console.debug(
300               `${this.identifier} No attachments in IDB cache. Nothing to do.`
301             );
302             return;
303           }
305           // List all stored records.
306           const allRecords = await new Promise((resolve, reject) => {
307             const rangeAllIndexed = IDBKeyRange.only(_cid);
308             const request = recordsStore.index("cid").getAll(rangeAllIndexed);
309             request.onsuccess = e => resolve(e.target.result);
310             request.onerror = e => reject(e);
311           });
313           console.error("allRecords", allRecords);
315           // Compare known records IDs to those stored along the attachments.
316           const currentRecordsIDs = new Set(allRecords.map(r => r.id));
317           const attachmentsToDelete = allAttachments.reduce((acc, entry) => {
318             // Skip excluded attachments.
319             if (excludeIds.includes(entry.attachmentId)) {
320               return acc;
321             }
322             // Delete attachment if associated record does not exist.
323             if (!currentRecordsIDs.has(entry.attachment.record.id)) {
324               acc.push([_cid, entry.attachmentId]);
325             }
326             return acc;
327           }, []);
329           // Perform a bulk delete of all obsolete attachments.
330           lazy.console.debug(
331             `${this.identifier} Bulk delete ${attachmentsToDelete.length} obsolete attachments`
332           );
333           lazy.IDBHelpers.bulkOperationHelper(
334             attachmentsStore,
335             {
336               reject: rejectTransaction,
337             },
338             "delete",
339             attachmentsToDelete
340           );
341           deletedCount = attachmentsToDelete.length;
342         },
343         { desc: "pruneAttachments() in " + this.identifier }
344       );
345     } catch (e) {
346       throw new lazy.IDBHelpers.IndexedDBError(
347         e,
348         "pruneAttachments()",
349         this.identifier
350       );
351     }
352     return deletedCount;
353   }
355   async clear() {
356     try {
357       await this.importChanges(null, null, [], { clear: true });
358     } catch (e) {
359       throw new lazy.IDBHelpers.IndexedDBError(e, "clear()", this.identifier);
360     }
361   }
363   /*
364    * Methods used by unit tests.
365    */
367   async create(record) {
368     if (!("id" in record)) {
369       record = { ...record, id: lazy.CommonUtils.generateUUID() };
370     }
371     try {
372       await executeIDB(
373         "records",
374         store => {
375           store.add({ ...record, _cid: this.identifier });
376         },
377         { desc: "create() in " + this.identifier }
378       );
379     } catch (e) {
380       throw new lazy.IDBHelpers.IndexedDBError(e, "create()", this.identifier);
381     }
382     return record;
383   }
385   async update(record) {
386     try {
387       await executeIDB(
388         "records",
389         store => {
390           store.put({ ...record, _cid: this.identifier });
391         },
392         { desc: "update() in " + this.identifier }
393       );
394     } catch (e) {
395       throw new lazy.IDBHelpers.IndexedDBError(e, "update()", this.identifier);
396     }
397   }
399   async delete(recordId) {
400     try {
401       await executeIDB(
402         "records",
403         store => {
404           store.delete([this.identifier, recordId]); // [_cid, id]
405         },
406         { desc: "delete() in " + this.identifier }
407       );
408     } catch (e) {
409       throw new lazy.IDBHelpers.IndexedDBError(e, "delete()", this.identifier);
410     }
411   }
414 let gDB = null;
415 let gDBPromise = null;
418  * This function attempts to ensure `gDB` points to a valid database value.
419  * If gDB is already a database, it will do no-op (but this may take a
420  * microtask or two).
421  * If opening the database fails, it will throw an IndexedDBError.
422  */
423 async function openIDB() {
424   // We can be called multiple times in a race; always ensure that when
425   // we complete, `gDB` is no longer null, but avoid doing the actual
426   // IndexedDB work more than once.
427   if (!gDBPromise) {
428     // Open and initialize/upgrade if needed.
429     gDBPromise = lazy.IDBHelpers.openIDB();
430   }
431   let db = await gDBPromise;
432   if (!gDB) {
433     gDB = db;
434   }
437 const gPendingReadOnlyTransactions = new Set();
438 const gPendingWriteOperations = new Set();
440  * Helper to wrap some IDBObjectStore operations into a promise.
442  * @param {IDBDatabase} db
443  * @param {String|String[]} storeNames - either a string or an array of strings.
444  * @param {function} callback
445  * @param {Object} options
446  * @param {String} options.mode
447  * @param {String} options.desc   for shutdown tracking.
448  */
449 async function executeIDB(storeNames, callback, options = {}) {
450   if (!gDB) {
451     // Check if we're shutting down. Services.startup.shuttingDown will
452     // be true sooner, but is never true in xpcshell tests, so we check
453     // both that and a bool we set ourselves when `profile-before-change`
454     // starts.
455     if (gShutdownStarted || Services.startup.shuttingDown) {
456       throw new lazy.IDBHelpers.ShutdownError(
457         "The application is shutting down",
458         "execute()"
459       );
460     }
461     await openIDB();
462   } else {
463     // Even if we have a db, wait a tick to avoid making IndexedDB sad.
464     // We should be able to remove this once bug 1626935 is fixed.
465     await Promise.resolve();
466   }
468   // Check for shutdown again as we've await'd something...
469   if (!gDB && (gShutdownStarted || Services.startup.shuttingDown)) {
470     throw new lazy.IDBHelpers.ShutdownError(
471       "The application is shutting down",
472       "execute()"
473     );
474   }
476   // Start the actual transaction:
477   const { mode = "readwrite", desc = "" } = options;
478   let { promise, transaction } = lazy.IDBHelpers.executeIDB(
479     gDB,
480     storeNames,
481     mode,
482     callback,
483     desc
484   );
486   // We track all readonly transactions and abort them at shutdown.
487   // We track all readwrite ones and await their completion at shutdown
488   // (to avoid dataloss when writes fail).
489   // We use a `.finally()` clause for this; it'll run the function irrespective
490   // of whether the promise resolves or rejects, and the promise it returns
491   // will resolve/reject with the same value.
492   let finishedFn;
493   if (mode == "readonly") {
494     gPendingReadOnlyTransactions.add(transaction);
495     finishedFn = () => gPendingReadOnlyTransactions.delete(transaction);
496   } else {
497     let obj = { promise, desc };
498     gPendingWriteOperations.add(obj);
499     finishedFn = () => gPendingWriteOperations.delete(obj);
500   }
501   return promise.finally(finishedFn);
504 async function destroyIDB() {
505   if (gDB) {
506     if (gShutdownStarted || Services.startup.shuttingDown) {
507       throw new lazy.IDBHelpers.ShutdownError(
508         "The application is shutting down",
509         "destroyIDB()"
510       );
511     }
513     // This will return immediately; the actual close will happen once
514     // there are no more running transactions.
515     gDB.close();
516     const allTransactions = new Set([
517       ...gPendingWriteOperations,
518       ...gPendingReadOnlyTransactions,
519     ]);
520     for (let transaction of Array.from(allTransactions)) {
521       try {
522         transaction.abort();
523       } catch (ex) {
524         // Ignore errors to abort transactions, we'll destroy everything.
525       }
526     }
527   }
528   gDB = null;
529   gDBPromise = null;
530   return lazy.IDBHelpers.destroyIDB();
533 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
534   const last = arr.length - 1;
535   return arr.reduce((acc, cv, i) => {
536     if (i === last) {
537       return (acc[cv] = val);
538     } else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
539       return acc[cv];
540     }
541     return (acc[cv] = {});
542   }, nestedFiltersObj);
545 function transformSubObjectFilters(filtersObj) {
546   const transformedFilters = {};
547   for (const [key, val] of Object.entries(filtersObj)) {
548     const keysArr = key.split(".");
549     makeNestedObjectFromArr(keysArr, val, transformedFilters);
550   }
551   return transformedFilters;
554 // We need to expose this wrapper function so we can test
555 // shutdown handling.
556 Database._executeIDB = executeIDB;
558 let gShutdownStarted = false;
559 // Test-only helper to be able to test shutdown multiple times:
560 Database._cancelShutdown = () => {
561   gShutdownStarted = false;
564 let gShutdownBlocker = false;
565 Database._shutdownHandler = () => {
566   gShutdownStarted = true;
567   const NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR = 0x80660006;
568   // Duplicate the list (to avoid it being modified) and then
569   // abort all read-only transactions.
570   for (let transaction of Array.from(gPendingReadOnlyTransactions)) {
571     try {
572       transaction.abort();
573     } catch (ex) {
574       // Ensure we don't throw/break, because either way we're in shutdown.
576       // In particular, `transaction.abort` can throw if the transaction
577       // is complete, ie if we manage to get called in between the
578       // transaction completing, and our completion handler being called
579       // to remove the item from the set. We don't care about that.
580       if (ex.result != NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR) {
581         // Report any other errors:
582         console.error(ex);
583       }
584     }
585   }
586   if (gDB) {
587     // This will return immediately; the actual close will happen once
588     // there are no more running transactions.
589     gDB.close();
590     gDB = null;
591   }
592   gDBPromise = null;
593   return Promise.allSettled(
594     Array.from(gPendingWriteOperations).map(op => op.promise)
595   );
598 function ensureShutdownBlocker() {
599   if (gShutdownBlocker) {
600     return;
601   }
602   gShutdownBlocker = true;
603   lazy.AsyncShutdown.profileBeforeChange.addBlocker(
604     "RemoteSettingsClient - finish IDB access.",
605     Database._shutdownHandler,
606     {
607       fetchState() {
608         return Array.from(gPendingWriteOperations).map(op => op.desc);
609       },
610     }
611   );