tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

Database.sys.mjs (20970B)


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