tor-browser

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

bookmarks.sys.mjs (29529B)


      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 import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
      6 import {
      7  Changeset,
      8  Store,
      9  SyncEngine,
     10  Tracker,
     11 } from "resource://services-sync/engines.sys.mjs";
     12 import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
     13 import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
     14 
     15 const lazy = {};
     16 
     17 ChromeUtils.defineESModuleGetters(lazy, {
     18  Async: "resource://services-common/async.sys.mjs",
     19  Observers: "resource://services-common/observers.sys.mjs",
     20  PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs",
     21  PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs",
     22  PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs",
     23  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     24  Resource: "resource://services-sync/resource.sys.mjs",
     25  SyncedBookmarksMirror: "resource://gre/modules/SyncedBookmarksMirror.sys.mjs",
     26 });
     27 
     28 const PLACES_MAINTENANCE_INTERVAL_SECONDS = 4 * 60 * 60; // 4 hours.
     29 
     30 const FOLDER_SORTINDEX = 1000000;
     31 
     32 // Roots that should be deleted from the server, instead of applied locally.
     33 // This matches `AndroidBrowserBookmarksRepositorySession::forbiddenGUID`,
     34 // but allows tags because we don't want to reparent tag folders or tag items
     35 // to "unfiled".
     36 const FORBIDDEN_INCOMING_IDS = ["pinned", "places", "readinglist"];
     37 
     38 // Items with these parents should be deleted from the server. We allow
     39 // children of the Places root, to avoid orphaning left pane queries and other
     40 // descendants of custom roots.
     41 const FORBIDDEN_INCOMING_PARENT_IDS = ["pinned", "readinglist"];
     42 
     43 // The tracker ignores changes made by import and restore, to avoid bumping the
     44 // score and triggering syncs during the process, as well as changes made by
     45 // Sync.
     46 ChromeUtils.defineLazyGetter(lazy, "IGNORED_SOURCES", () => [
     47  lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
     48  lazy.PlacesUtils.bookmarks.SOURCES.IMPORT,
     49  lazy.PlacesUtils.bookmarks.SOURCES.RESTORE,
     50  lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
     51  lazy.PlacesUtils.bookmarks.SOURCES.SYNC_REPARENT_REMOVED_FOLDER_CHILDREN,
     52 ]);
     53 
     54 // The validation telemetry version for the engine. Version 1 is collected
     55 // by `bookmark_validator.js`, and checks value as well as structure
     56 // differences. Version 2 is collected by the engine as part of building the
     57 // remote tree, and checks structure differences only.
     58 const BOOKMARK_VALIDATOR_VERSION = 2;
     59 
     60 // The maximum time that the engine should wait before aborting a bookmark
     61 // merge.
     62 const BOOKMARK_APPLY_TIMEOUT_MS = 5 * 60 * 60 * 1000; // 5 minutes
     63 
     64 // The default frecency value to use when not known.
     65 const FRECENCY_UNKNOWN = -1;
     66 
     67 // Returns the constructor for a bookmark record type.
     68 function getTypeObject(type) {
     69  switch (type) {
     70    case "bookmark":
     71      return Bookmark;
     72    case "query":
     73      return BookmarkQuery;
     74    case "folder":
     75      return BookmarkFolder;
     76    case "livemark":
     77      return Livemark;
     78    case "separator":
     79      return BookmarkSeparator;
     80    case "item":
     81      return PlacesItem;
     82  }
     83  return null;
     84 }
     85 
     86 export function PlacesItem(collection, id, type) {
     87  CryptoWrapper.call(this, collection, id);
     88  this.type = type || "item";
     89 }
     90 
     91 PlacesItem.prototype = {
     92  async decrypt(keyBundle) {
     93    // Do the normal CryptoWrapper decrypt, but change types before returning
     94    let clear = await CryptoWrapper.prototype.decrypt.call(this, keyBundle);
     95 
     96    // Convert the abstract places item to the actual object type
     97    if (!this.deleted) {
     98      Object.setPrototypeOf(this, this.getTypeObject(this.type).prototype);
     99    }
    100 
    101    return clear;
    102  },
    103 
    104  getTypeObject: function PlacesItem_getTypeObject(type) {
    105    let recordObj = getTypeObject(type);
    106    if (!recordObj) {
    107      throw new Error("Unknown places item object type: " + type);
    108    }
    109    return recordObj;
    110  },
    111 
    112  _logName: "Sync.Record.PlacesItem",
    113 
    114  // Converts the record to a Sync bookmark object that can be passed to
    115  // `PlacesSyncUtils.bookmarks.{insert, update}`.
    116  toSyncBookmark() {
    117    let result = {
    118      kind: this.type,
    119      recordId: this.id,
    120      parentRecordId: this.parentid,
    121    };
    122    let dateAdded = lazy.PlacesSyncUtils.bookmarks.ratchetTimestampBackwards(
    123      this.dateAdded,
    124      +this.modified * 1000
    125    );
    126    if (dateAdded > 0) {
    127      result.dateAdded = dateAdded;
    128    }
    129    return result;
    130  },
    131 
    132  // Populates the record from a Sync bookmark object returned from
    133  // `PlacesSyncUtils.bookmarks.fetch`.
    134  fromSyncBookmark(item) {
    135    this.parentid = item.parentRecordId;
    136    this.parentName = item.parentTitle;
    137    if (item.dateAdded) {
    138      this.dateAdded = item.dateAdded;
    139    }
    140  },
    141 };
    142 
    143 Object.setPrototypeOf(PlacesItem.prototype, CryptoWrapper.prototype);
    144 
    145 Utils.deferGetSet(PlacesItem, "cleartext", [
    146  "hasDupe",
    147  "parentid",
    148  "parentName",
    149  "type",
    150  "dateAdded",
    151 ]);
    152 
    153 export function Bookmark(collection, id, type) {
    154  PlacesItem.call(this, collection, id, type || "bookmark");
    155 }
    156 
    157 Bookmark.prototype = {
    158  _logName: "Sync.Record.Bookmark",
    159 
    160  toSyncBookmark() {
    161    let info = PlacesItem.prototype.toSyncBookmark.call(this);
    162    info.title = this.title;
    163    info.url = this.bmkUri;
    164    info.description = this.description;
    165    info.tags = this.tags;
    166    info.keyword = this.keyword;
    167    return info;
    168  },
    169 
    170  fromSyncBookmark(item) {
    171    PlacesItem.prototype.fromSyncBookmark.call(this, item);
    172    this.title = item.title;
    173    this.bmkUri = item.url.href;
    174    this.description = item.description;
    175    this.tags = item.tags;
    176    this.keyword = item.keyword;
    177  },
    178 };
    179 
    180 Object.setPrototypeOf(Bookmark.prototype, PlacesItem.prototype);
    181 
    182 Utils.deferGetSet(Bookmark, "cleartext", [
    183  "title",
    184  "bmkUri",
    185  "description",
    186  "tags",
    187  "keyword",
    188 ]);
    189 
    190 export function BookmarkQuery(collection, id) {
    191  Bookmark.call(this, collection, id, "query");
    192 }
    193 
    194 BookmarkQuery.prototype = {
    195  _logName: "Sync.Record.BookmarkQuery",
    196 
    197  toSyncBookmark() {
    198    let info = Bookmark.prototype.toSyncBookmark.call(this);
    199    info.folder = this.folderName || undefined; // empty string -> undefined
    200    info.query = this.queryId;
    201    return info;
    202  },
    203 
    204  fromSyncBookmark(item) {
    205    Bookmark.prototype.fromSyncBookmark.call(this, item);
    206    this.folderName = item.folder || undefined; // empty string -> undefined
    207    this.queryId = item.query;
    208  },
    209 };
    210 
    211 Object.setPrototypeOf(BookmarkQuery.prototype, Bookmark.prototype);
    212 
    213 Utils.deferGetSet(BookmarkQuery, "cleartext", ["folderName", "queryId"]);
    214 
    215 export function BookmarkFolder(collection, id, type) {
    216  PlacesItem.call(this, collection, id, type || "folder");
    217 }
    218 
    219 BookmarkFolder.prototype = {
    220  _logName: "Sync.Record.Folder",
    221 
    222  toSyncBookmark() {
    223    let info = PlacesItem.prototype.toSyncBookmark.call(this);
    224    info.description = this.description;
    225    info.title = this.title;
    226    return info;
    227  },
    228 
    229  fromSyncBookmark(item) {
    230    PlacesItem.prototype.fromSyncBookmark.call(this, item);
    231    this.title = item.title;
    232    this.description = item.description;
    233    this.children = item.childRecordIds;
    234  },
    235 };
    236 
    237 Object.setPrototypeOf(BookmarkFolder.prototype, PlacesItem.prototype);
    238 
    239 Utils.deferGetSet(BookmarkFolder, "cleartext", [
    240  "description",
    241  "title",
    242  "children",
    243 ]);
    244 
    245 export function Livemark(collection, id) {
    246  BookmarkFolder.call(this, collection, id, "livemark");
    247 }
    248 
    249 Livemark.prototype = {
    250  _logName: "Sync.Record.Livemark",
    251 
    252  toSyncBookmark() {
    253    let info = BookmarkFolder.prototype.toSyncBookmark.call(this);
    254    info.feed = this.feedUri;
    255    info.site = this.siteUri;
    256    return info;
    257  },
    258 
    259  fromSyncBookmark(item) {
    260    BookmarkFolder.prototype.fromSyncBookmark.call(this, item);
    261    this.feedUri = item.feed.href;
    262    if (item.site) {
    263      this.siteUri = item.site.href;
    264    }
    265  },
    266 };
    267 
    268 Object.setPrototypeOf(Livemark.prototype, BookmarkFolder.prototype);
    269 
    270 Utils.deferGetSet(Livemark, "cleartext", ["siteUri", "feedUri"]);
    271 
    272 export function BookmarkSeparator(collection, id) {
    273  PlacesItem.call(this, collection, id, "separator");
    274 }
    275 
    276 BookmarkSeparator.prototype = {
    277  _logName: "Sync.Record.Separator",
    278 
    279  fromSyncBookmark(item) {
    280    PlacesItem.prototype.fromSyncBookmark.call(this, item);
    281    this.pos = item.index;
    282  },
    283 };
    284 
    285 Object.setPrototypeOf(BookmarkSeparator.prototype, PlacesItem.prototype);
    286 
    287 Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos");
    288 
    289 /**
    290 * The bookmarks engine uses a different store that stages downloaded bookmarks
    291 * in a separate database, instead of writing directly to Places. The buffer
    292 * handles reconciliation, so we stub out `_reconcile`, and wait to pull changes
    293 * until we're ready to upload.
    294 */
    295 export function BookmarksEngine(service) {
    296  SyncEngine.call(this, "Bookmarks", service);
    297 }
    298 
    299 BookmarksEngine.prototype = {
    300  _recordObj: PlacesItem,
    301  _trackerObj: BookmarksTracker,
    302  _storeObj: BookmarksStore,
    303  version: 2,
    304  // Used to override the engine name in telemetry, so that we can distinguish
    305  // this engine from the old, now removed non-buffered engine.
    306  overrideTelemetryName: "bookmarks-buffered",
    307 
    308  // Needed to ensure we don't miss items when resuming a sync that failed or
    309  // aborted early.
    310  _defaultSort: "oldest",
    311 
    312  syncPriority: 4,
    313  allowSkippedRecord: false,
    314 
    315  async _ensureCurrentSyncID(newSyncID) {
    316    await lazy.PlacesSyncUtils.bookmarks.ensureCurrentSyncId(newSyncID);
    317    let buf = await this._store.ensureOpenMirror();
    318    await buf.ensureCurrentSyncId(newSyncID);
    319  },
    320 
    321  async ensureCurrentSyncID(newSyncID) {
    322    let shouldWipeRemote =
    323      await lazy.PlacesSyncUtils.bookmarks.shouldWipeRemote();
    324    if (!shouldWipeRemote) {
    325      this._log.debug(
    326        "Checking if server sync ID ${newSyncID} matches existing",
    327        { newSyncID }
    328      );
    329      await this._ensureCurrentSyncID(newSyncID);
    330      return newSyncID;
    331    }
    332    // We didn't take the new sync ID because we need to wipe the server
    333    // and other clients after a restore. Send the command, wipe the
    334    // server, and reset our sync ID to reupload everything.
    335    this._log.debug(
    336      "Ignoring server sync ID ${newSyncID} after restore; " +
    337        "wiping server and resetting sync ID",
    338      { newSyncID }
    339    );
    340    await this.service.clientsEngine.sendCommand(
    341      "wipeEngine",
    342      [this.name],
    343      null,
    344      { reason: "bookmark-restore" }
    345    );
    346    let assignedSyncID = await this.resetSyncID();
    347    return assignedSyncID;
    348  },
    349 
    350  async getSyncID() {
    351    return lazy.PlacesSyncUtils.bookmarks.getSyncId();
    352  },
    353 
    354  async resetSyncID() {
    355    await this._deleteServerCollection();
    356    return this.resetLocalSyncID();
    357  },
    358 
    359  async resetLocalSyncID() {
    360    let newSyncID = await lazy.PlacesSyncUtils.bookmarks.resetSyncId();
    361    this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID });
    362    let buf = await this._store.ensureOpenMirror();
    363    await buf.ensureCurrentSyncId(newSyncID);
    364    return newSyncID;
    365  },
    366 
    367  async getLastSync() {
    368    let mirror = await this._store.ensureOpenMirror();
    369    return mirror.getCollectionHighWaterMark();
    370  },
    371 
    372  async setLastSync(lastSync) {
    373    let mirror = await this._store.ensureOpenMirror();
    374    await mirror.setCollectionLastModified(lastSync);
    375    // Update the last sync time in Places so that reverting to the original
    376    // bookmarks engine doesn't download records we've already applied.
    377    await lazy.PlacesSyncUtils.bookmarks.setLastSync(lastSync);
    378  },
    379 
    380  async _syncStartup() {
    381    await super._syncStartup();
    382 
    383    try {
    384      // For first syncs, back up the user's bookmarks.
    385      let lastSync = await this.getLastSync();
    386      if (!lastSync) {
    387        this._log.debug("Bookmarks backup starting");
    388        await lazy.PlacesBackups.create(null, true);
    389        this._log.debug("Bookmarks backup done");
    390      }
    391    } catch (ex) {
    392      // Failure to create a backup is somewhat bad, but probably not bad
    393      // enough to prevent syncing of bookmarks - so just log the error and
    394      // continue.
    395      this._log.warn(
    396        "Error while backing up bookmarks, but continuing with sync",
    397        ex
    398      );
    399    }
    400  },
    401 
    402  async _sync() {
    403    try {
    404      await super._sync();
    405      if (this._ranMaintenanceOnLastSync) {
    406        // If the last sync failed, we ran maintenance, and this sync succeeded,
    407        // maintenance likely fixed the issue.
    408        this._ranMaintenanceOnLastSync = false;
    409        Glean.sync.maintenanceFixBookmarks.record();
    410        this.service.recordTelemetryEvent("maintenance", "fix", "bookmarks");
    411      }
    412    } catch (ex) {
    413      if (
    414        lazy.Async.isShutdownException(ex) ||
    415        ex.status > 0 ||
    416        ex.name == "InterruptedError"
    417      ) {
    418        // Don't run maintenance on shutdown or HTTP errors, or if we aborted
    419        // the sync because the user changed their bookmarks during merging.
    420        throw ex;
    421      }
    422      if (ex.name == "MergeConflictError") {
    423        this._log.warn(
    424          "Bookmark syncing ran into a merge conflict error...will retry later"
    425        );
    426        return;
    427      }
    428      // Run Places maintenance periodically to try to recover from corruption
    429      // that might have caused the sync to fail. We cap the interval because
    430      // persistent failures likely indicate a problem that won't be fixed by
    431      // running maintenance after every failed sync.
    432      let elapsedSinceMaintenance =
    433        Date.now() / 1000 -
    434        Services.prefs.getIntPref("places.database.lastMaintenance", 0);
    435      if (elapsedSinceMaintenance >= PLACES_MAINTENANCE_INTERVAL_SECONDS) {
    436        this._log.error(
    437          "Bookmark sync failed, ${elapsedSinceMaintenance}s " +
    438            "elapsed since last run; running Places maintenance",
    439          { elapsedSinceMaintenance }
    440        );
    441        await lazy.PlacesDBUtils.maintenanceOnIdle();
    442        this._ranMaintenanceOnLastSync = true;
    443        Glean.sync.maintenanceRunBookmarks.record();
    444        this.service.recordTelemetryEvent("maintenance", "run", "bookmarks");
    445      } else {
    446        this._ranMaintenanceOnLastSync = false;
    447      }
    448      throw ex;
    449    }
    450  },
    451 
    452  async _syncFinish() {
    453    await SyncEngine.prototype._syncFinish.call(this);
    454    await lazy.PlacesSyncUtils.bookmarks.ensureMobileQuery();
    455  },
    456 
    457  async pullAllChanges() {
    458    return this.pullNewChanges();
    459  },
    460 
    461  async trackRemainingChanges() {
    462    let changes = this._modified.changes;
    463    await lazy.PlacesSyncUtils.bookmarks.pushChanges(changes);
    464  },
    465 
    466  _deleteId(id) {
    467    this._noteDeletedId(id);
    468  },
    469 
    470  // The bookmarks engine rarely calls this method directly, except in tests or
    471  // when handling a `reset{All, Engine}` command from another client. We
    472  // usually reset local Sync metadata on a sync ID mismatch, which both engines
    473  // override with logic that lives in Places and the mirror.
    474  async _resetClient() {
    475    await super._resetClient();
    476    await lazy.PlacesSyncUtils.bookmarks.reset();
    477    let buf = await this._store.ensureOpenMirror();
    478    await buf.reset();
    479  },
    480 
    481  // Cleans up the Places root, reading list items (ignored in bug 762118,
    482  // removed in bug 1155684), and pinned sites.
    483  _shouldDeleteRemotely(incomingItem) {
    484    return (
    485      FORBIDDEN_INCOMING_IDS.includes(incomingItem.id) ||
    486      FORBIDDEN_INCOMING_PARENT_IDS.includes(incomingItem.parentid)
    487    );
    488  },
    489 
    490  emptyChangeset() {
    491    return new BookmarksChangeset();
    492  },
    493 
    494  async _apply() {
    495    let buf = await this._store.ensureOpenMirror();
    496    let watchdog = this._newWatchdog();
    497    watchdog.start(BOOKMARK_APPLY_TIMEOUT_MS);
    498 
    499    try {
    500      let recordsToUpload = await buf.apply({
    501        remoteTimeSeconds: lazy.Resource.serverTime,
    502        signal: watchdog.signal,
    503      });
    504      this._modified.replace(recordsToUpload);
    505    } finally {
    506      watchdog.stop();
    507      if (watchdog.abortReason) {
    508        this._log.warn(`Aborting bookmark merge: ${watchdog.abortReason}`);
    509      }
    510    }
    511  },
    512 
    513  async _processIncoming(newitems) {
    514    await super._processIncoming(newitems);
    515    await this._apply();
    516  },
    517 
    518  async _reconcile() {
    519    return true;
    520  },
    521 
    522  async _createRecord(id) {
    523    let record = await this._doCreateRecord(id);
    524    if (!record.deleted) {
    525      // Set hasDupe on all (non-deleted) records since we don't use it and we
    526      // want to minimize the risk of older clients corrupting records. Note
    527      // that the SyncedBookmarksMirror sets it for all records that it created,
    528      // but we would like to ensure that weakly uploaded records are marked as
    529      // hasDupe as well.
    530      record.hasDupe = true;
    531    }
    532    return record;
    533  },
    534 
    535  async _doCreateRecord(id) {
    536    let change = this._modified.changes[id];
    537    if (!change) {
    538      this._log.error(
    539        "Creating record for item ${id} not in strong changeset",
    540        { id }
    541      );
    542      throw new TypeError("Can't create record for unchanged item");
    543    }
    544    let record = this._recordFromCleartext(id, change.cleartext);
    545    record.sortindex = await this._store._calculateIndex(record);
    546    return record;
    547  },
    548 
    549  _recordFromCleartext(id, cleartext) {
    550    let recordObj = getTypeObject(cleartext.type);
    551    if (!recordObj) {
    552      this._log.warn(
    553        "Creating record for item ${id} with unknown type ${type}",
    554        { id, type: cleartext.type }
    555      );
    556      recordObj = PlacesItem;
    557    }
    558    let record = new recordObj(this.name, id);
    559    record.cleartext = cleartext;
    560    return record;
    561  },
    562 
    563  async pullChanges() {
    564    return {};
    565  },
    566 
    567  /**
    568   * Writes successfully uploaded records back to the mirror, so that the
    569   * mirror matches the server. We update the mirror before updating Places,
    570   * which has implications for interrupted syncs.
    571   *
    572   * 1. Sync interrupted during upload; server doesn't support atomic uploads.
    573   *    We'll download and reapply everything that we uploaded before the
    574   *    interruption. All locally changed items retain their change counters.
    575   * 2. Sync interrupted during upload; atomic uploads enabled. The server
    576   *    discards the batch. All changed local items retain their change
    577   *    counters, so the next sync resumes cleanly.
    578   * 3. Sync interrupted during upload; outgoing records can't fit in a single
    579   *    batch. We'll download and reapply all records through the most recent
    580   *    committed batch. This is a variation of (1).
    581   * 4. Sync interrupted after we update the mirror, but before cleanup. The
    582   *    mirror matches the server, but locally changed items retain their change
    583   *    counters. Reuploading them on the next sync should be idempotent, though
    584   *    unnecessary. If another client makes a conflicting remote change before
    585   *    we sync again, we may incorrectly prefer the local state.
    586   * 5. Sync completes successfully. We'll update the mirror, and reset the
    587   *    change counters for all items.
    588   */
    589  async _onRecordsWritten(succeeded, failed, serverModifiedTime) {
    590    let records = [];
    591    for (let id of succeeded) {
    592      let change = this._modified.changes[id];
    593      if (!change) {
    594        // TODO (Bug 1433178): Write weakly uploaded records back to the mirror.
    595        this._log.info("Uploaded record not in strong changeset", id);
    596        continue;
    597      }
    598      if (!change.synced) {
    599        this._log.info("Record in strong changeset not uploaded", id);
    600        continue;
    601      }
    602      let cleartext = change.cleartext;
    603      if (!cleartext) {
    604        this._log.error(
    605          "Missing Sync record cleartext for ${id} in ${change}",
    606          { id, change }
    607        );
    608        throw new TypeError("Missing cleartext for uploaded Sync record");
    609      }
    610      let record = this._recordFromCleartext(id, cleartext);
    611      record.modified = serverModifiedTime;
    612      records.push(record);
    613    }
    614    let buf = await this._store.ensureOpenMirror();
    615    await buf.store(records, { needsMerge: false });
    616  },
    617 
    618  async finalize() {
    619    await super.finalize();
    620    await this._store.finalize();
    621  },
    622 };
    623 
    624 Object.setPrototypeOf(BookmarksEngine.prototype, SyncEngine.prototype);
    625 
    626 /**
    627 * The bookmarks store delegates to the mirror for staging and applying
    628 * records. Most `Store` methods intentionally remain abstract, so you can't use
    629 * this store to create or update bookmarks in Places. All changes must go
    630 * through the mirror, which takes care of merging and producing a valid tree.
    631 */
    632 function BookmarksStore(name, engine) {
    633  Store.call(this, name, engine);
    634 }
    635 
    636 BookmarksStore.prototype = {
    637  _openMirrorPromise: null,
    638 
    639  // For tests.
    640  _batchChunkSize: 500,
    641 
    642  // Create a record starting from the weave id (places guid)
    643  async createRecord(id, collection) {
    644    let item = await lazy.PlacesSyncUtils.bookmarks.fetch(id);
    645    if (!item) {
    646      // deleted item
    647      let record = new PlacesItem(collection, id);
    648      record.deleted = true;
    649      return record;
    650    }
    651 
    652    let recordObj = getTypeObject(item.kind);
    653    if (!recordObj) {
    654      this._log.warn("Unknown item type, cannot serialize: " + item.kind);
    655      recordObj = PlacesItem;
    656    }
    657    let record = new recordObj(collection, id);
    658    record.fromSyncBookmark(item);
    659 
    660    record.sortindex = await this._calculateIndex(record);
    661 
    662    return record;
    663  },
    664 
    665  async _calculateIndex(record) {
    666    // Ensure folders have a very high sort index so they're not synced last.
    667    if (record.type == "folder") {
    668      return FOLDER_SORTINDEX;
    669    }
    670 
    671    // For anything directly under the toolbar, give it a boost of more than an
    672    // unvisited bookmark
    673    let index = 0;
    674    if (record.parentid == "toolbar") {
    675      index += 150;
    676    }
    677 
    678    // Add in the bookmark's frecency if we have something.
    679    if (record.bmkUri != null) {
    680      let frecency = FRECENCY_UNKNOWN;
    681      try {
    682        frecency = await lazy.PlacesSyncUtils.history.fetchURLFrecency(
    683          record.bmkUri
    684        );
    685      } catch (ex) {
    686        this._log.warn(
    687          `Failed to fetch frecency for ${record.id}; assuming default`,
    688          ex
    689        );
    690        this._log.trace("Record {id} has invalid URL ${bmkUri}", record);
    691      }
    692      if (frecency != FRECENCY_UNKNOWN) {
    693        index += frecency;
    694      }
    695    }
    696 
    697    return index;
    698  },
    699 
    700  async wipe() {
    701    // Save a backup before clearing out all bookmarks.
    702    await lazy.PlacesBackups.create(null, true);
    703    await lazy.PlacesSyncUtils.bookmarks.wipe();
    704  },
    705 
    706  ensureOpenMirror() {
    707    if (!this._openMirrorPromise) {
    708      this._openMirrorPromise = this._openMirror().catch(err => {
    709        // We may have failed to open the mirror temporarily; for example, if
    710        // the database is locked. Clear the promise so that subsequent
    711        // `ensureOpenMirror` calls can try to open the mirror again.
    712        this._openMirrorPromise = null;
    713        throw err;
    714      });
    715    }
    716    return this._openMirrorPromise;
    717  },
    718 
    719  async _openMirror() {
    720    let mirrorPath = PathUtils.join(
    721      PathUtils.profileDir,
    722      "weave",
    723      "bookmarks.sqlite"
    724    );
    725    await IOUtils.makeDirectory(PathUtils.parent(mirrorPath), {
    726      createAncestors: true,
    727    });
    728 
    729    return lazy.SyncedBookmarksMirror.open({
    730      path: mirrorPath,
    731      recordStepTelemetry: (name, took, counts) => {
    732        lazy.Observers.notify(
    733          "weave:engine:sync:step",
    734          {
    735            name,
    736            took,
    737            counts,
    738          },
    739          this.name
    740        );
    741      },
    742      recordValidationTelemetry: (took, checked, problems) => {
    743        lazy.Observers.notify(
    744          "weave:engine:validate:finish",
    745          {
    746            version: BOOKMARK_VALIDATOR_VERSION,
    747            took,
    748            checked,
    749            problems,
    750          },
    751          this.name
    752        );
    753      },
    754    });
    755  },
    756 
    757  async applyIncomingBatch(records) {
    758    let buf = await this.ensureOpenMirror();
    759    for (let chunk of lazy.PlacesUtils.chunkArray(
    760      records,
    761      this._batchChunkSize
    762    )) {
    763      await buf.store(chunk);
    764    }
    765    // Array of failed records.
    766    return [];
    767  },
    768 
    769  async applyIncoming(record) {
    770    let buf = await this.ensureOpenMirror();
    771    await buf.store([record]);
    772  },
    773 
    774  async finalize() {
    775    if (!this._openMirrorPromise) {
    776      return;
    777    }
    778    let buf = await this._openMirrorPromise;
    779    await buf.finalize();
    780  },
    781 };
    782 
    783 Object.setPrototypeOf(BookmarksStore.prototype, Store.prototype);
    784 
    785 // The bookmarks tracker is a special flower. Instead of listening for changes
    786 // via observer notifications, it queries Places for the set of items that have
    787 // changed since the last sync. Because it's a "pull-based" tracker, it ignores
    788 // all concepts of "add a changed ID." However, it still registers an observer
    789 // to bump the score, so that changed bookmarks are synced immediately.
    790 function BookmarksTracker(name, engine) {
    791  Tracker.call(this, name, engine);
    792 }
    793 BookmarksTracker.prototype = {
    794  onStart() {
    795    this._placesListener = new PlacesWeakCallbackWrapper(
    796      this.handlePlacesEvents.bind(this)
    797    );
    798    lazy.PlacesUtils.observers.addListener(
    799      [
    800        "bookmark-added",
    801        "bookmark-removed",
    802        "bookmark-moved",
    803        "bookmark-guid-changed",
    804        "bookmark-keyword-changed",
    805        "bookmark-tags-changed",
    806        "bookmark-time-changed",
    807        "bookmark-title-changed",
    808        "bookmark-url-changed",
    809      ],
    810      this._placesListener
    811    );
    812    Svc.Obs.add("bookmarks-restore-begin", this);
    813    Svc.Obs.add("bookmarks-restore-success", this);
    814    Svc.Obs.add("bookmarks-restore-failed", this);
    815  },
    816 
    817  onStop() {
    818    lazy.PlacesUtils.observers.removeListener(
    819      [
    820        "bookmark-added",
    821        "bookmark-removed",
    822        "bookmark-moved",
    823        "bookmark-guid-changed",
    824        "bookmark-keyword-changed",
    825        "bookmark-tags-changed",
    826        "bookmark-time-changed",
    827        "bookmark-title-changed",
    828        "bookmark-url-changed",
    829      ],
    830      this._placesListener
    831    );
    832    Svc.Obs.remove("bookmarks-restore-begin", this);
    833    Svc.Obs.remove("bookmarks-restore-success", this);
    834    Svc.Obs.remove("bookmarks-restore-failed", this);
    835  },
    836 
    837  async getChangedIDs() {
    838    return lazy.PlacesSyncUtils.bookmarks.pullChanges();
    839  },
    840 
    841  observe(subject, topic, data) {
    842    switch (topic) {
    843      case "bookmarks-restore-begin":
    844        this._log.debug("Ignoring changes from importing bookmarks.");
    845        break;
    846      case "bookmarks-restore-success":
    847        this._log.debug("Tracking all items on successful import.");
    848 
    849        if (data == "json") {
    850          this._log.debug(
    851            "Restore succeeded: wiping server and other clients."
    852          );
    853          // Trigger an immediate sync. `ensureCurrentSyncID` will notice we
    854          // restored, wipe the server and other clients, reset the sync ID, and
    855          // upload the restored tree.
    856          this.score += SCORE_INCREMENT_XLARGE;
    857        } else {
    858          // "html", "html-initial", or "json-append"
    859          this._log.debug("Import succeeded.");
    860        }
    861        break;
    862      case "bookmarks-restore-failed":
    863        this._log.debug("Tracking all items on failed import.");
    864        break;
    865    }
    866  },
    867 
    868  QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),
    869 
    870  /* Every add/remove/change will trigger a sync for MULTI_DEVICE */
    871  _upScore: function BMT__upScore() {
    872    this.score += SCORE_INCREMENT_XLARGE;
    873  },
    874 
    875  handlePlacesEvents(events) {
    876    for (let event of events) {
    877      switch (event.type) {
    878        case "bookmark-added":
    879        case "bookmark-removed":
    880        case "bookmark-moved":
    881        case "bookmark-keyword-changed":
    882        case "bookmark-tags-changed":
    883        case "bookmark-time-changed":
    884        case "bookmark-title-changed":
    885        case "bookmark-url-changed":
    886          if (lazy.IGNORED_SOURCES.includes(event.source)) {
    887            continue;
    888          }
    889 
    890          this._log.trace(`'${event.type}': ${event.id}`);
    891          this._upScore();
    892          break;
    893        case "bookmark-guid-changed":
    894          if (event.source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC) {
    895            this._log.warn(
    896              "The source of bookmark-guid-changed event shoud be sync."
    897            );
    898            continue;
    899          }
    900 
    901          this._log.trace(`'${event.type}': ${event.id}`);
    902          this._upScore();
    903          break;
    904        case "purge-caches":
    905          this._log.trace("purge-caches");
    906          this._upScore();
    907          break;
    908      }
    909    }
    910  },
    911 };
    912 
    913 Object.setPrototypeOf(BookmarksTracker.prototype, Tracker.prototype);
    914 
    915 /**
    916 * A changeset that stores extra metadata in a change record for each ID. The
    917 * engine updates this metadata when uploading Sync records, and writes it back
    918 * to Places in `BookmarksEngine#trackRemainingChanges`.
    919 *
    920 * The `synced` property on a change record means its corresponding item has
    921 * been uploaded, and we should pretend it doesn't exist in the changeset.
    922 */
    923 class BookmarksChangeset extends Changeset {
    924  // Only `_reconcile` calls `getModifiedTimestamp` and `has`, and the engine
    925  // does its own reconciliation.
    926  getModifiedTimestamp() {
    927    throw new Error("Don't use timestamps to resolve bookmark conflicts");
    928  }
    929 
    930  has() {
    931    throw new Error("Don't use the changeset to resolve bookmark conflicts");
    932  }
    933 
    934  delete(id) {
    935    let change = this.changes[id];
    936    if (change) {
    937      // Mark the change as synced without removing it from the set. We do this
    938      // so that we can update Places in `trackRemainingChanges`.
    939      change.synced = true;
    940    }
    941  }
    942 
    943  ids() {
    944    let results = new Set();
    945    for (let id in this.changes) {
    946      if (!this.changes[id].synced) {
    947        results.add(id);
    948      }
    949    }
    950    return [...results];
    951  }
    952 }