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"
11 ChromeUtils.defineESModuleGetters(lazy, {
12 AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
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",
21 XPCOMUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log);
23 var EXPORTED_SYMBOLS = ["Database"];
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)
35 constructor(identifier) {
36 ensureShutdownBlocker();
37 this.identifier = identifier;
40 async list(options = {}) {
41 const { filters = {}, order = "" } = options;
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;
58 .openCursor(IDBKeyRange.only(this.identifier));
59 const objFilters = transformSubObjectFilters(filters);
60 request.onsuccess = event => {
62 const cursor = event.target.result;
64 const { value } = cursor;
65 if (lazy.Utils.filterObject(objFilters, value)) {
71 rejectTransaction(ex);
78 throw new lazy.IDBHelpers.IndexedDBError(e, "list()", this.identifier);
80 // Remove IDB key field from results.
81 for (const result of results) {
84 return order ? lazy.Utils.sortObjects(order, results) : results;
87 async importChanges(metadata, timestamp, records = [], options = {}) {
88 const { clear = false } = options;
89 const _cid = this.identifier;
92 ["collections", "timestamps", "records"],
93 (stores, rejectTransaction) => {
94 const [storeMetadata, storeTimestamps, storeRecords] = stores;
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
116 IDBKeyRange.bound([_cid], [_cid, []], false, true)
120 // Store or erase metadata.
121 if (metadata === null) {
122 storeMetadata.delete(_cid);
123 } else if (metadata) {
124 storeMetadata.put({ cid: _cid, metadata });
126 // Store or erase timestamp.
127 if (timestamp === null) {
128 storeTimestamps.delete(_cid);
129 } else if (timestamp) {
130 storeTimestamps.put({ cid: _cid, value: timestamp });
133 if (!records.length) {
137 // Separate tombstones from creations/updates.
138 const toDelete = records.filter(r => r.deleted);
139 const toInsert = records.filter(r => !r.deleted);
141 `${_cid} ${toDelete.length} to delete, ${toInsert.length} to insert`
143 // Delete local records for each tombstone.
144 lazy.IDBHelpers.bulkOperationHelper(
147 reject: rejectTransaction,
149 // Overwrite all other data.
150 lazy.IDBHelpers.bulkOperationHelper(
153 reject: rejectTransaction,
156 toInsert.map(item => ({ ...item, _cid }))
161 toDelete.map(item => [_cid, item.id])
164 { desc: "importChanges() in " + _cid }
167 throw new lazy.IDBHelpers.IndexedDBError(e, "importChanges()", _cid);
171 async getLastModified() {
177 store.get(this.identifier).onsuccess = e => (entry = e.target.result);
182 throw new lazy.IDBHelpers.IndexedDBError(
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}`);
201 async getMetadata() {
207 store.get(this.identifier).onsuccess = e => (entry = e.target.result);
212 throw new lazy.IDBHelpers.IndexedDBError(
218 return entry ? entry.metadata : null;
221 async getAttachment(attachmentId) {
227 store.get([this.identifier, attachmentId]).onsuccess = e => {
228 entry = e.target.result;
234 throw new lazy.IDBHelpers.IndexedDBError(
240 return entry ? entry.attachment : null;
243 async saveAttachment(attachmentId, attachment) {
249 store.put({ cid: this.identifier, attachmentId, attachment });
251 store.delete([this.identifier, attachmentId]);
254 { desc: "saveAttachment(" + attachmentId + ") in " + this.identifier }
257 throw new lazy.IDBHelpers.IndexedDBError(
266 * Delete all attachments which don't match any record.
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.
274 * @param {Array<String>} excludeIds List of attachments IDs to exclude from pruning.
276 async pruneAttachments(excludeIds) {
277 const _cid = this.identifier;
278 let deletedCount = 0;
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(
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);
298 if (!allAttachments.length) {
300 `${this.identifier} No attachments in IDB cache. Nothing to do.`
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);
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)) {
322 // Delete attachment if associated record does not exist.
323 if (!currentRecordsIDs.has(entry.attachment.record.id)) {
324 acc.push([_cid, entry.attachmentId]);
329 // Perform a bulk delete of all obsolete attachments.
331 `${this.identifier} Bulk delete ${attachmentsToDelete.length} obsolete attachments`
333 lazy.IDBHelpers.bulkOperationHelper(
336 reject: rejectTransaction,
341 deletedCount = attachmentsToDelete.length;
343 { desc: "pruneAttachments() in " + this.identifier }
346 throw new lazy.IDBHelpers.IndexedDBError(
348 "pruneAttachments()",
357 await this.importChanges(null, null, [], { clear: true });
359 throw new lazy.IDBHelpers.IndexedDBError(e, "clear()", this.identifier);
364 * Methods used by unit tests.
367 async create(record) {
368 if (!("id" in record)) {
369 record = { ...record, id: lazy.CommonUtils.generateUUID() };
375 store.add({ ...record, _cid: this.identifier });
377 { desc: "create() in " + this.identifier }
380 throw new lazy.IDBHelpers.IndexedDBError(e, "create()", this.identifier);
385 async update(record) {
390 store.put({ ...record, _cid: this.identifier });
392 { desc: "update() in " + this.identifier }
395 throw new lazy.IDBHelpers.IndexedDBError(e, "update()", this.identifier);
399 async delete(recordId) {
404 store.delete([this.identifier, recordId]); // [_cid, id]
406 { desc: "delete() in " + this.identifier }
409 throw new lazy.IDBHelpers.IndexedDBError(e, "delete()", this.identifier);
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
421 * If opening the database fails, it will throw an IndexedDBError.
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.
428 // Open and initialize/upgrade if needed.
429 gDBPromise = lazy.IDBHelpers.openIDB();
431 let db = await gDBPromise;
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.
449 async function executeIDB(storeNames, callback, options = {}) {
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`
455 if (gShutdownStarted || Services.startup.shuttingDown) {
456 throw new lazy.IDBHelpers.ShutdownError(
457 "The application is shutting down",
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();
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",
476 // Start the actual transaction:
477 const { mode = "readwrite", desc = "" } = options;
478 let { promise, transaction } = lazy.IDBHelpers.executeIDB(
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.
493 if (mode == "readonly") {
494 gPendingReadOnlyTransactions.add(transaction);
495 finishedFn = () => gPendingReadOnlyTransactions.delete(transaction);
497 let obj = { promise, desc };
498 gPendingWriteOperations.add(obj);
499 finishedFn = () => gPendingWriteOperations.delete(obj);
501 return promise.finally(finishedFn);
504 async function destroyIDB() {
506 if (gShutdownStarted || Services.startup.shuttingDown) {
507 throw new lazy.IDBHelpers.ShutdownError(
508 "The application is shutting down",
513 // This will return immediately; the actual close will happen once
514 // there are no more running transactions.
516 const allTransactions = new Set([
517 ...gPendingWriteOperations,
518 ...gPendingReadOnlyTransactions,
520 for (let transaction of Array.from(allTransactions)) {
524 // Ignore errors to abort transactions, we'll destroy everything.
530 return lazy.IDBHelpers.destroyIDB();
533 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
534 const last = arr.length - 1;
535 return arr.reduce((acc, cv, i) => {
537 return (acc[cv] = val);
538 } else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
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);
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)) {
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:
587 // This will return immediately; the actual close will happen once
588 // there are no more running transactions.
593 return Promise.allSettled(
594 Array.from(gPendingWriteOperations).map(op => op.promise)
598 function ensureShutdownBlocker() {
599 if (gShutdownBlocker) {
602 gShutdownBlocker = true;
603 lazy.AsyncShutdown.profileBeforeChange.addBlocker(
604 "RemoteSettingsClient - finish IDB access.",
605 Database._shutdownHandler,
608 return Array.from(gPendingWriteOperations).map(op => op.desc);