no bug - Correct some typos in the comments. a=typo-fix
[gecko.git] / services / settings / Database.sys.mjs
blobe8a89028bc845c7d1e0457c93f9bcc77a4d157b7
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 lazy = {};
7 ChromeUtils.defineESModuleGetters(lazy, {
8   AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
9   CommonUtils: "resource://services-common/utils.sys.mjs",
10   IDBHelpers: "resource://services-settings/IDBHelpers.sys.mjs",
11   ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
12   Utils: "resource://services-settings/Utils.sys.mjs",
13 });
15 ChromeUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log);
17 /**
18  * Database is a tiny wrapper with the objective
19  * of providing major kinto-offline-client collection API.
20  * (with the objective of getting rid of kinto-offline-client)
21  */
22 export class Database {
23   static destroy() {
24     return destroyIDB();
25   }
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 (lazy.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 (lazy.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 lazy.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 ? lazy.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) {
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           lazy.console.debug(
133             `${_cid} ${toDelete.length} to delete, ${toInsert.length} to insert`
134           );
135           // Delete local records for each tombstone.
136           lazy.IDBHelpers.bulkOperationHelper(
137             storeRecords,
138             {
139               reject: rejectTransaction,
140               completion() {
141                 // Overwrite all other data.
142                 lazy.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 lazy.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 lazy.IDBHelpers.IndexedDBError(
175         e,
176         "getLastModified()",
177         this.identifier
178       );
179     }
180     if (!entry) {
181       return null;
182     }
183     // Some distributions where released with a modified dump that did not
184     // contain timestamps for last_modified. Work around this here, and return
185     // the timestamp as zero, so that the entries should get updated.
186     if (isNaN(entry.value)) {
187       lazy.console.warn(`Local timestamp is NaN for ${this.identifier}`);
188       return 0;
189     }
190     return entry.value;
191   }
193   async getMetadata() {
194     let entry = null;
195     try {
196       await executeIDB(
197         "collections",
198         store => {
199           store.get(this.identifier).onsuccess = e => (entry = e.target.result);
200         },
201         { mode: "readonly" }
202       );
203     } catch (e) {
204       throw new lazy.IDBHelpers.IndexedDBError(
205         e,
206         "getMetadata()",
207         this.identifier
208       );
209     }
210     return entry ? entry.metadata : null;
211   }
213   async getAttachment(attachmentId) {
214     let entry = null;
215     try {
216       await executeIDB(
217         "attachments",
218         store => {
219           store.get([this.identifier, attachmentId]).onsuccess = e => {
220             entry = e.target.result;
221           };
222         },
223         { mode: "readonly" }
224       );
225     } catch (e) {
226       throw new lazy.IDBHelpers.IndexedDBError(
227         e,
228         "getAttachment()",
229         this.identifier
230       );
231     }
232     return entry ? entry.attachment : null;
233   }
235   async saveAttachment(attachmentId, attachment) {
236     try {
237       await executeIDB(
238         "attachments",
239         store => {
240           if (attachment) {
241             store.put({ cid: this.identifier, attachmentId, attachment });
242           } else {
243             store.delete([this.identifier, attachmentId]);
244           }
245         },
246         { desc: "saveAttachment(" + attachmentId + ") in " + this.identifier }
247       );
248     } catch (e) {
249       throw new lazy.IDBHelpers.IndexedDBError(
250         e,
251         "saveAttachment()",
252         this.identifier
253       );
254     }
255   }
257   /**
258    * Delete all attachments which don't match any record.
259    *
260    * Attachments are linked to records, except when a fixed `attachmentId` is used.
261    * A record can be updated or deleted, potentially by deleting a record and restoring an updated version
262    * of the record with the same ID. Potentially leaving orphaned attachments in the database.
263    * Since we run the pruning logic after syncing, any attachment without a
264    * matching record can be discarded as they will be unreachable forever.
265    *
266    * @param {Array<String>} excludeIds List of attachments IDs to exclude from pruning.
267    */
268   async pruneAttachments(excludeIds) {
269     const _cid = this.identifier;
270     let deletedCount = 0;
271     try {
272       await executeIDB(
273         ["attachments", "records"],
274         async (stores, rejectTransaction) => {
275           const [attachmentsStore, recordsStore] = stores;
277           // List all stored attachments.
278           // All keys â‰¥ [_cid, ..] && < [_cid, []]. See comment in `importChanges()`
279           const rangeAllKeys = IDBKeyRange.bound(
280             [_cid],
281             [_cid, []],
282             false,
283             true
284           );
285           const allAttachments = await new Promise((resolve, reject) => {
286             const request = attachmentsStore.getAll(rangeAllKeys);
287             request.onsuccess = e => resolve(e.target.result);
288             request.onerror = e => reject(e);
289           });
290           if (!allAttachments.length) {
291             lazy.console.debug(
292               `${this.identifier} No attachments in IDB cache. Nothing to do.`
293             );
294             return;
295           }
297           // List all stored records.
298           const allRecords = await new Promise((resolve, reject) => {
299             const rangeAllIndexed = IDBKeyRange.only(_cid);
300             const request = recordsStore.index("cid").getAll(rangeAllIndexed);
301             request.onsuccess = e => resolve(e.target.result);
302             request.onerror = e => reject(e);
303           });
305           // Compare known records IDs to those stored along the attachments.
306           const currentRecordsIDs = new Set(allRecords.map(r => r.id));
307           const attachmentsToDelete = allAttachments.reduce((acc, entry) => {
308             // Skip excluded attachments.
309             if (excludeIds.includes(entry.attachmentId)) {
310               return acc;
311             }
312             // Delete attachment if associated record does not exist.
313             if (!currentRecordsIDs.has(entry.attachment.record.id)) {
314               acc.push([_cid, entry.attachmentId]);
315             }
316             return acc;
317           }, []);
319           // Perform a bulk delete of all obsolete attachments.
320           lazy.console.debug(
321             `${this.identifier} Bulk delete ${attachmentsToDelete.length} obsolete attachments`
322           );
323           lazy.IDBHelpers.bulkOperationHelper(
324             attachmentsStore,
325             {
326               reject: rejectTransaction,
327             },
328             "delete",
329             attachmentsToDelete
330           );
331           deletedCount = attachmentsToDelete.length;
332         },
333         { desc: "pruneAttachments() in " + this.identifier }
334       );
335     } catch (e) {
336       throw new lazy.IDBHelpers.IndexedDBError(
337         e,
338         "pruneAttachments()",
339         this.identifier
340       );
341     }
342     return deletedCount;
343   }
345   async clear() {
346     try {
347       await this.importChanges(null, null, [], { clear: true });
348     } catch (e) {
349       throw new lazy.IDBHelpers.IndexedDBError(e, "clear()", this.identifier);
350     }
351   }
353   /*
354    * Methods used by unit tests.
355    */
357   async create(record) {
358     if (!("id" in record)) {
359       record = { ...record, id: lazy.CommonUtils.generateUUID() };
360     }
361     try {
362       await executeIDB(
363         "records",
364         store => {
365           store.add({ ...record, _cid: this.identifier });
366         },
367         { desc: "create() in " + this.identifier }
368       );
369     } catch (e) {
370       throw new lazy.IDBHelpers.IndexedDBError(e, "create()", this.identifier);
371     }
372     return record;
373   }
375   async update(record) {
376     try {
377       await executeIDB(
378         "records",
379         store => {
380           store.put({ ...record, _cid: this.identifier });
381         },
382         { desc: "update() in " + this.identifier }
383       );
384     } catch (e) {
385       throw new lazy.IDBHelpers.IndexedDBError(e, "update()", this.identifier);
386     }
387   }
389   async delete(recordId) {
390     try {
391       await executeIDB(
392         "records",
393         store => {
394           store.delete([this.identifier, recordId]); // [_cid, id]
395         },
396         { desc: "delete() in " + this.identifier }
397       );
398     } catch (e) {
399       throw new lazy.IDBHelpers.IndexedDBError(e, "delete()", this.identifier);
400     }
401   }
404 let gDB = null;
405 let gDBPromise = null;
408  * This function attempts to ensure `gDB` points to a valid database value.
409  * If gDB is already a database, it will do no-op (but this may take a
410  * microtask or two).
411  * If opening the database fails, it will throw an IndexedDBError.
412  */
413 async function openIDB() {
414   // We can be called multiple times in a race; always ensure that when
415   // we complete, `gDB` is no longer null, but avoid doing the actual
416   // IndexedDB work more than once.
417   if (!gDBPromise) {
418     // Open and initialize/upgrade if needed.
419     gDBPromise = lazy.IDBHelpers.openIDB();
420   }
421   let db = await gDBPromise;
422   if (!gDB) {
423     gDB = db;
424   }
427 const gPendingReadOnlyTransactions = new Set();
428 const gPendingWriteOperations = new Set();
430  * Helper to wrap some IDBObjectStore operations into a promise.
432  * @param {IDBDatabase} db
433  * @param {String|String[]} storeNames - either a string or an array of strings.
434  * @param {function} callback
435  * @param {Object} options
436  * @param {String} options.mode
437  * @param {String} options.desc   for shutdown tracking.
438  */
439 async function executeIDB(storeNames, callback, options = {}) {
440   if (!gDB) {
441     // Check if we're shutting down. Services.startup.shuttingDown will
442     // be true sooner, but is never true in xpcshell tests, so we check
443     // both that and a bool we set ourselves when `profile-before-change`
444     // starts.
445     if (gShutdownStarted || Services.startup.shuttingDown) {
446       throw new lazy.IDBHelpers.ShutdownError(
447         "The application is shutting down",
448         "execute()"
449       );
450     }
451     await openIDB();
452   } else {
453     // Even if we have a db, wait a tick to avoid making IndexedDB sad.
454     // We should be able to remove this once bug 1626935 is fixed.
455     await Promise.resolve();
456   }
458   // Check for shutdown again as we've await'd something...
459   if (!gDB && (gShutdownStarted || Services.startup.shuttingDown)) {
460     throw new lazy.IDBHelpers.ShutdownError(
461       "The application is shutting down",
462       "execute()"
463     );
464   }
466   // Start the actual transaction:
467   const { mode = "readwrite", desc = "" } = options;
468   let { promise, transaction } = lazy.IDBHelpers.executeIDB(
469     gDB,
470     storeNames,
471     mode,
472     callback,
473     desc
474   );
476   // We track all readonly transactions and abort them at shutdown.
477   // We track all readwrite ones and await their completion at shutdown
478   // (to avoid dataloss when writes fail).
479   // We use a `.finally()` clause for this; it'll run the function irrespective
480   // of whether the promise resolves or rejects, and the promise it returns
481   // will resolve/reject with the same value.
482   let finishedFn;
483   if (mode == "readonly") {
484     gPendingReadOnlyTransactions.add(transaction);
485     finishedFn = () => gPendingReadOnlyTransactions.delete(transaction);
486   } else {
487     let obj = { promise, desc };
488     gPendingWriteOperations.add(obj);
489     finishedFn = () => gPendingWriteOperations.delete(obj);
490   }
491   return promise.finally(finishedFn);
494 async function destroyIDB() {
495   if (gDB) {
496     if (gShutdownStarted || Services.startup.shuttingDown) {
497       throw new lazy.IDBHelpers.ShutdownError(
498         "The application is shutting down",
499         "destroyIDB()"
500       );
501     }
503     // This will return immediately; the actual close will happen once
504     // there are no more running transactions.
505     gDB.close();
506     const allTransactions = new Set([
507       ...gPendingWriteOperations,
508       ...gPendingReadOnlyTransactions,
509     ]);
510     for (let transaction of Array.from(allTransactions)) {
511       try {
512         transaction.abort();
513       } catch (ex) {
514         // Ignore errors to abort transactions, we'll destroy everything.
515       }
516     }
517   }
518   gDB = null;
519   gDBPromise = null;
520   return lazy.IDBHelpers.destroyIDB();
523 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
524   const last = arr.length - 1;
525   return arr.reduce((acc, cv, i) => {
526     if (i === last) {
527       return (acc[cv] = val);
528     } else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
529       return acc[cv];
530     }
531     return (acc[cv] = {});
532   }, nestedFiltersObj);
535 function transformSubObjectFilters(filtersObj) {
536   const transformedFilters = {};
537   for (const [key, val] of Object.entries(filtersObj)) {
538     const keysArr = key.split(".");
539     makeNestedObjectFromArr(keysArr, val, transformedFilters);
540   }
541   return transformedFilters;
544 // We need to expose this wrapper function so we can test
545 // shutdown handling.
546 Database._executeIDB = executeIDB;
548 let gShutdownStarted = false;
549 // Test-only helper to be able to test shutdown multiple times:
550 Database._cancelShutdown = () => {
551   gShutdownStarted = false;
554 let gShutdownBlocker = false;
555 Database._shutdownHandler = () => {
556   gShutdownStarted = true;
557   const NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR = 0x80660006;
558   // Duplicate the list (to avoid it being modified) and then
559   // abort all read-only transactions.
560   for (let transaction of Array.from(gPendingReadOnlyTransactions)) {
561     try {
562       transaction.abort();
563     } catch (ex) {
564       // Ensure we don't throw/break, because either way we're in shutdown.
566       // In particular, `transaction.abort` can throw if the transaction
567       // is complete, ie if we manage to get called in between the
568       // transaction completing, and our completion handler being called
569       // to remove the item from the set. We don't care about that.
570       if (ex.result != NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR) {
571         // Report any other errors:
572         console.error(ex);
573       }
574     }
575   }
576   if (gDB) {
577     // This will return immediately; the actual close will happen once
578     // there are no more running transactions.
579     gDB.close();
580     gDB = null;
581   }
582   gDBPromise = null;
583   return Promise.allSettled(
584     Array.from(gPendingWriteOperations).map(op => op.promise)
585   );
588 function ensureShutdownBlocker() {
589   if (gShutdownBlocker) {
590     return;
591   }
592   gShutdownBlocker = true;
593   lazy.AsyncShutdown.profileBeforeChange.addBlocker(
594     "RemoteSettingsClient - finish IDB access.",
595     Database._shutdownHandler,
596     {
597       fetchState() {
598         return Array.from(gPendingWriteOperations).map(op => op.desc);
599       },
600     }
601   );