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"
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",
17 XPCOMUtils.defineLazyGetter(this, "console", () => Utils.log);
19 var EXPORTED_SYMBOLS = ["Database"];
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)
27 constructor(identifier) {
28 ensureShutdownBlocker();
29 this.identifier = identifier;
32 async list(options = {}) {
33 const { filters = {}, order = "" } = options;
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;
50 .openCursor(IDBKeyRange.only(this.identifier));
51 const objFilters = transformSubObjectFilters(filters);
52 request.onsuccess = event => {
54 const cursor = event.target.result;
56 const { value } = cursor;
57 if (Utils.filterObject(objFilters, value)) {
63 rejectTransaction(ex);
70 throw new IDBHelpers.IndexedDBError(e, "list()", this.identifier);
72 // Remove IDB key field from results.
73 for (const result of results) {
76 return order ? Utils.sortObjects(order, results) : results;
79 async importChanges(metadata, timestamp, records = [], options = {}) {
80 const { clear = false } = options;
81 const _cid = this.identifier;
84 ["collections", "timestamps", "records"],
85 (stores, rejectTransaction) => {
86 const [storeMetadata, storeTimestamps, storeRecords] = stores;
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
108 IDBKeyRange.bound([_cid], [_cid, []], false, true)
112 // Store or erase metadata.
113 if (metadata === null) {
114 storeMetadata.delete(_cid);
115 } else if (metadata) {
116 storeMetadata.put({ cid: _cid, metadata });
118 // Store or erase timestamp.
119 if (timestamp === null) {
120 storeTimestamps.delete(_cid);
121 } else if (timestamp) {
122 storeTimestamps.put({ cid: _cid, value: timestamp });
125 if (records.length == 0) {
129 // Separate tombstones from creations/updates.
130 const toDelete = records.filter(r => r.deleted);
131 const toInsert = records.filter(r => !r.deleted);
133 `${_cid} ${toDelete.length} to delete, ${toInsert.length} to insert`
135 // Delete local records for each tombstone.
136 IDBHelpers.bulkOperationHelper(
139 reject: rejectTransaction,
141 // Overwrite all other data.
142 IDBHelpers.bulkOperationHelper(
145 reject: rejectTransaction,
148 toInsert.map(item => ({ ...item, _cid }))
153 toDelete.map(item => [_cid, item.id])
156 { desc: "importChanges() in " + _cid }
159 throw new IDBHelpers.IndexedDBError(e, "importChanges()", _cid);
163 async getLastModified() {
169 store.get(this.identifier).onsuccess = e => (entry = e.target.result);
174 throw new IDBHelpers.IndexedDBError(
180 return entry ? entry.value : null;
183 async getMetadata() {
189 store.get(this.identifier).onsuccess = e => (entry = e.target.result);
194 throw new IDBHelpers.IndexedDBError(e, "getMetadata()", this.identifier);
196 return entry ? entry.metadata : null;
199 async getAttachment(attachmentId) {
205 store.get([this.identifier, attachmentId]).onsuccess = e => {
206 entry = e.target.result;
212 throw new IDBHelpers.IndexedDBError(
218 return entry ? entry.attachment : null;
221 async saveAttachment(attachmentId, attachment) {
227 store.put({ cid: this.identifier, attachmentId, attachment });
229 store.delete([this.identifier, attachmentId]);
232 { desc: "saveAttachment(" + attachmentId + ") in " + this.identifier }
235 throw new IDBHelpers.IndexedDBError(
245 await this.importChanges(null, null, [], { clear: true });
247 throw new IDBHelpers.IndexedDBError(e, "clear()", this.identifier);
252 * Methods used by unit tests.
255 async create(record) {
256 if (!("id" in record)) {
257 record = { ...record, id: CommonUtils.generateUUID() };
263 store.add({ ...record, _cid: this.identifier });
265 { desc: "create() in " + this.identifier }
268 throw new IDBHelpers.IndexedDBError(e, "create()", this.identifier);
273 async update(record) {
278 store.put({ ...record, _cid: this.identifier });
280 { desc: "update() in " + this.identifier }
283 throw new IDBHelpers.IndexedDBError(e, "update()", this.identifier);
287 async delete(recordId) {
292 store.delete([this.identifier, recordId]); // [_cid, id]
294 { desc: "delete() in " + this.identifier }
297 throw new IDBHelpers.IndexedDBError(e, "delete()", this.identifier);
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
309 * If opening the database fails, it will throw an IndexedDBError.
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.
316 // Open and initialize/upgrade if needed.
317 gDBPromise = IDBHelpers.openIDB();
319 let db = await gDBPromise;
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.
337 async function executeIDB(storeNames, callback, options = {}) {
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`
343 if (gShutdownStarted || Services.startup.shuttingDown) {
344 throw new IDBHelpers.ShutdownError(
345 "The application is shutting down",
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();
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",
364 // Start the actual transaction:
365 const { mode = "readwrite", desc = "" } = options;
366 let { promise, transaction } = IDBHelpers.executeIDB(
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.
381 if (mode == "readonly") {
382 gPendingReadOnlyTransactions.add(transaction);
383 finishedFn = () => gPendingReadOnlyTransactions.delete(transaction);
385 let obj = { promise, desc };
386 gPendingWriteOperations.add(obj);
387 finishedFn = () => gPendingWriteOperations.delete(obj);
389 return promise.finally(finishedFn);
392 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
393 const last = arr.length - 1;
394 return arr.reduce((acc, cv, i) => {
396 return (acc[cv] = val);
397 } else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
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);
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)) {
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:
446 // This will return immediately; the actual close will happen once
447 // there are no more running transactions.
452 return Promise.allSettled(
453 Array.from(gPendingWriteOperations).map(op => op.promise)
457 function ensureShutdownBlocker() {
458 if (gShutdownBlocker) {
461 gShutdownBlocker = true;
462 AsyncShutdown.profileBeforeChange.addBlocker(
463 "RemoteSettingsClient - finish IDB access.",
464 Database._shutdownHandler,
467 return Array.from(gPendingWriteOperations).map(op => op.desc);