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