gecko-dev/services/settings/Database.jsm

570 строки
17 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["indexedDB"]);
XPCOMUtils.defineLazyModuleGetters(this, {
AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
CommonUtils: "resource://services-common/utils.js",
ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
});
var EXPORTED_SYMBOLS = ["Database"];
// IndexedDB name.
const DB_NAME = "remote-settings";
const DB_VERSION = 2;
/**
* Wrap IndexedDB errors to catch them more easily.
*/
class IndexedDBError extends Error {
constructor(error, method = "", identifier = "") {
super(`IndexedDB: ${identifier} ${method} ${error.message}`);
this.name = error.name;
this.stack = error.stack;
}
}
class ShutdownError extends IndexedDBError {
constructor(error, method = "", identifier = "") {
if (typeof error == "string") {
error = new Error(error);
}
super(error, method, identifier);
}
}
// We batch operations in order to reduce round-trip latency to the IndexedDB
// database thread. The trade-offs are that the more records in the batch, the
// more time we spend on this thread in structured serialization, and the
// greater the chance to jank PBackground and this thread when the responses
// come back. The initial choice of 250 was made targeting 2-3ms on a fast
// machine and 10-15ms on a slow machine.
// Every chunk waits for success before starting the next, and
// the final chunk's completion will fire transaction.oncomplete .
function bulkOperationHelper(store, operation, list, listIndex = 0) {
const CHUNK_LENGTH = 250;
const max = Math.min(listIndex + CHUNK_LENGTH, list.length);
let request;
for (; listIndex < max; listIndex++) {
request = store[operation](list[listIndex]);
}
if (listIndex < list.length) {
// On error, `transaction.onerror` is called.
request.onsuccess = bulkOperationHelper.bind(
null,
store,
operation,
list,
listIndex
);
}
// otherwise, we're done, and the transaction will complete on its own.
}
/**
* Database is a tiny wrapper with the objective
* of providing major kinto-offline-client collection API.
* (with the objective of getting rid of kinto-offline-client)
*/
class Database {
/* Expose the IDBError and ShutdownError class publicly */
static get IDBError() {
return IndexedDBError;
}
static get ShutdownError() {
return ShutdownError;
}
constructor(identifier) {
ensureShutdownBlocker();
this.identifier = identifier;
}
async list(options = {}) {
const { filters = {}, sort = "" } = options;
let results = [];
try {
await executeIDB(
"records",
store => {
// Fast-path the (very common) no-filters case
if (ObjectUtils.isEmpty(filters)) {
const range = IDBKeyRange.only(this.identifier);
const request = store.index("cid").getAll(range);
request.onsuccess = e => {
results = e.target.result;
};
return;
}
const request = store
.index("cid")
.openCursor(IDBKeyRange.only(this.identifier));
const objFilters = transformSubObjectFilters(filters);
request.onsuccess = event => {
const cursor = event.target.result;
if (cursor) {
const { value } = cursor;
if (filterObject(objFilters, value)) {
results.push(value);
}
cursor.continue();
}
};
},
{ mode: "readonly" }
);
} catch (e) {
throw new IndexedDBError(e, "list()", this.identifier);
}
// Remove IDB key field from results.
for (const result of results) {
delete result._cid;
}
return sort ? sortObjects(sort, results) : results;
}
async importBulk(toInsert) {
const _cid = this.identifier;
try {
await executeIDB(
"records",
store => {
bulkOperationHelper(
store,
"put",
toInsert.map(item => {
return Object.assign({ _cid }, item);
})
);
},
{ desc: "importBulk() in " + this.identifier }
);
} catch (e) {
throw new IndexedDBError(e, "importBulk()", this.identifier);
}
}
async deleteBulk(toDelete) {
const _cid = this.identifier;
try {
await executeIDB(
"records",
store => {
bulkOperationHelper(
store,
"delete",
toDelete.map(item => {
return [_cid, item.id];
})
);
},
{ desc: "deleteBulk() in " + this.identifier }
);
} catch (e) {
throw new IndexedDBError(e, "deleteBulk()", this.identifier);
}
}
async getLastModified() {
let entry = null;
try {
await executeIDB(
"timestamps",
store => {
store.get(this.identifier).onsuccess = e => (entry = e.target.result);
},
{ mode: "readonly" }
);
} catch (e) {
throw new IndexedDBError(e, "getLastModified()", this.identifier);
}
return entry ? entry.value : null;
}
async saveLastModified(lastModified) {
const value = parseInt(lastModified, 10) || null;
try {
await executeIDB(
"timestamps",
store => {
if (value === null) {
store.delete(this.identifier);
} else {
store.put({ cid: this.identifier, value });
}
},
{ desc: "saveLastModified() in " + this.identifier }
);
} catch (e) {
throw new IndexedDBError(e, "saveLastModified()", this.identifier);
}
return value;
}
async getMetadata() {
let entry = null;
try {
await executeIDB(
"collections",
store => {
store.get(this.identifier).onsuccess = e => (entry = e.target.result);
},
{ mode: "readonly" }
);
} catch (e) {
throw new IndexedDBError(e, "getMetadata()", this.identifier);
}
return entry ? entry.metadata : null;
}
async saveMetadata(metadata) {
try {
await executeIDB(
"collections",
store => store.put({ cid: this.identifier, metadata }),
{ desc: "saveMetadata() in " + this.identifier }
);
return metadata;
} catch (e) {
throw new IndexedDBError(e, "saveMetadata()", this.identifier);
}
}
async clear() {
try {
await this.saveLastModified(null);
await this.saveMetadata(null);
await executeIDB(
"records",
store => {
const range = IDBKeyRange.only(this.identifier);
const request = store.index("cid").openKeyCursor(range);
request.onsuccess = event => {
const cursor = event.target.result;
if (cursor) {
store.delete(cursor.primaryKey);
cursor.continue();
}
};
return request;
},
{ desc: "clear() in " + this.identifier }
);
} catch (e) {
throw new IndexedDBError(e, "clear()", this.identifier);
}
}
/*
* Methods used by unit tests.
*/
async create(record) {
if (!("id" in record)) {
record = { ...record, id: CommonUtils.generateUUID() };
}
try {
await executeIDB(
"records",
store => {
store.add({ ...record, _cid: this.identifier });
},
{ desc: "create() in " + this.identifier }
);
} catch (e) {
throw new IndexedDBError(e, "create()", this.identifier);
}
return record;
}
async update(record) {
try {
await executeIDB(
"records",
store => {
store.put({ ...record, _cid: this.identifier });
},
{ desc: "update() in " + this.identifier }
);
} catch (e) {
throw new IndexedDBError(e, "update()", this.identifier);
}
}
async delete(recordId) {
try {
await executeIDB(
"records",
store => {
store.delete([this.identifier, recordId]); // [_cid, id]
},
{ desc: "delete() in " + this.identifier }
);
} catch (e) {
throw new IndexedDBError(e, "delete()", this.identifier);
}
}
}
let gDB = null;
let gDBPromise = null;
/**
* This function attempts to ensure `gDB` points to a valid database value.
* If gDB is already a database, it will do no-op (but this may take a
* microtask or two).
* If opening the database fails, it will throw an IndexedDBError.
*/
async function openIDB(callback) {
// We can be called multiple times in a race; always ensure that when
// we complete, `gDB` is no longer null, but avoid doing the actual
// IndexedDB work more than once.
if (!gDBPromise) {
// Open and initialize/upgrade if needed.
gDBPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = event => {
// When an upgrade is needed, a transaction is started.
const transaction = event.target.transaction;
transaction.onabort = event => {
const error =
event.target.error ||
transaction.error ||
new DOMException("The operation has been aborted", "AbortError");
reject(new IndexedDBError(error, "open()"));
};
const db = event.target.result;
db.onerror = event => reject(new IndexedDBError(event.target.error));
if (event.oldVersion < 1) {
// Records store
const recordsStore = db.createObjectStore("records", {
keyPath: ["_cid", "id"],
});
// An index to obtain all the records in a collection.
recordsStore.createIndex("cid", "_cid");
// Last modified field
recordsStore.createIndex("last_modified", ["_cid", "last_modified"]);
// Timestamps store
db.createObjectStore("timestamps", {
keyPath: "cid",
});
}
if (event.oldVersion < 2) {
// Collections store
db.createObjectStore("collections", {
keyPath: "cid",
});
}
};
request.onerror = event =>
reject(new IndexedDBError(event.target.error, "open()"));
request.onsuccess = event => {
const db = event.target.result;
resolve(db);
};
});
}
let db = await gDBPromise;
if (!gDB) {
gDB = db;
}
}
const gPendingReadOnlyTransactions = new Set();
const gPendingWriteOperations = new Set();
/**
* Helper to wrap some IDBObjectStore operations into a promise.
*
* @param {IDBDatabase} db
* @param {String} storeName
* @param {function} callback
* @param {Object} options
* @param {String} options.mode
* @param {String} options.desc for shutdown tracking.
*/
async function executeIDB(storeName, callback, options = {}) {
if (!gDB) {
// Check if we're shutting down. Services.startup.shuttingDown will
// be true sooner, but is never true in xpcshell tests, so we check
// both that and a bool we set ourselves when `profile-before-change`
// starts.
if (gShutdownStarted || Services.startup.shuttingDown) {
throw new ShutdownError("The application is shutting down", "execute()");
}
await openIDB();
} else {
// Even if we have a db, wait a tick to avoid making IndexedDB sad.
// We should be able to remove this once bug 1626935 is fixed.
await Promise.resolve();
}
let db = gDB;
const { mode = "readwrite" } = options;
const transaction = db.transaction([storeName], mode);
let promise = new Promise((resolve, reject) => {
const store = transaction.objectStore(storeName);
let result;
try {
result = callback(store);
} catch (e) {
transaction.abort();
reject(new IndexedDBError(e, "execute()", storeName));
}
transaction.onerror = event =>
reject(new IndexedDBError(event.target.error, "execute()"));
transaction.oncomplete = event => resolve(result);
});
// We track all readonly transactions and abort them at shutdown.
// We track all the other ones and await their completion at
// shutdown (to avoid dataloss when writes fail).
if (mode == "readonly") {
gPendingReadOnlyTransactions.add(transaction);
promise
.finally(() => gPendingReadOnlyTransactions.delete(transaction))
// The finally clause creates another promise, which will also
// be in a "rejected" state if `promise` is rejected. To not
// upset our "uncaught promise rejection" handler, no-op catch
// rejections on the end of that chain:
.catch(() => {});
} else {
let obj = { promise, desc: options.desc };
gPendingWriteOperations.add(obj);
promise
.finally(() => gPendingWriteOperations.delete(obj))
// Same as above.
.catch(() => {});
}
return promise;
}
function _isUndefined(value) {
return typeof value === "undefined";
}
function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
const last = arr.length - 1;
return arr.reduce((acc, cv, i) => {
if (i === last) {
return (acc[cv] = val);
} else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
return acc[cv];
}
return (acc[cv] = {});
}, nestedFiltersObj);
}
function transformSubObjectFilters(filtersObj) {
const transformedFilters = {};
for (const [key, val] of Object.entries(filtersObj)) {
const keysArr = key.split(".");
makeNestedObjectFromArr(keysArr, val, transformedFilters);
}
return transformedFilters;
}
/**
* Test if a single object matches all given filters.
*
* @param {Object} filters The filters object.
* @param {Object} entry The object to filter.
* @return {Boolean}
*/
function filterObject(filters, entry) {
return Object.entries(filters).every(([filter, value]) => {
if (Array.isArray(value)) {
return value.some(candidate => candidate === entry[filter]);
} else if (typeof value === "object") {
return filterObject(value, entry[filter]);
} else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
console.error(`The property ${filter} does not exist`);
return false;
}
return entry[filter] === value;
});
}
/**
* Sorts records in a list according to a given ordering.
*
* @param {String} order The ordering, eg. `-last_modified`.
* @param {Array} list The collection to order.
* @return {Array}
*/
function sortObjects(order, list) {
const hasDash = order[0] === "-";
const field = hasDash ? order.slice(1) : order;
const direction = hasDash ? -1 : 1;
return list.slice().sort((a, b) => {
if (a[field] && _isUndefined(b[field])) {
return direction;
}
if (b[field] && _isUndefined(a[field])) {
return -direction;
}
if (_isUndefined(a[field]) && _isUndefined(b[field])) {
return 0;
}
return a[field] > b[field] ? direction : -direction;
});
}
let gShutdownBlocker = false;
let gShutdownStarted = false;
function ensureShutdownBlocker() {
if (gShutdownBlocker) {
return;
}
gShutdownBlocker = true;
AsyncShutdown.profileBeforeChange.addBlocker(
"RemoteSettingsClient - finish IDB access.",
() => {
gShutdownStarted = true;
const NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR = 0x80660006;
// Duplicate the list (to avoid it being modified) and then
// abort all read-only transactions.
for (let transaction of Array.from(gPendingReadOnlyTransactions)) {
try {
transaction.abort();
} catch (ex) {
// Ensure we don't throw/break, because either way we're in shutdown.
// In particular, `transaction.abort` can throw if the transaction
// is complete, ie if we manage to get called inbetween the
// transaction completing, and our completion handler being called
// to remove the item from the set. We don't care about that.
if (ex.result != NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR) {
// Report any other errors:
Cu.reportError(ex);
}
}
}
if (gDB) {
// This will return immediately; the actual close will happen once
// there are no more running transactions.
gDB.close();
gDB = null;
}
gDBPromise = null;
return Promise.allSettled(
Array.from(gPendingWriteOperations).map(op => op.promise)
);
},
{
fetchState() {
return Array.from(gPendingWriteOperations).map(op => op.desc);
},
}
);
}