Bug 1482608 - Remove the JS bookmark merger. r=markh
authorLina Cambridge <lina@yakshaving.ninja>
Mon, 25 Mar 2019 04:51:01 +0000
changeset 465888 4a692c812a3fe2f893d2a6e25b9490b38415c907
parent 465887 e7282f4449c83d2657edc29f8131b8b4985ab2ef
child 465889 d3edd959989d02ccc65e63001ada36d783936979
child 465902 1b6bf93953727decb8481cb58937ec3eefa5821a
push id112530
push useropoprus@mozilla.com
push dateMon, 25 Mar 2019 10:05:39 +0000
treeherdermozilla-inbound@d3edd959989d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh
bugs1482608
milestone68.0a1
first release with
nightly linux32
4a692c812a3f / 68.0a1 / 20190325095153 / files
nightly linux64
4a692c812a3f / 68.0a1 / 20190325095153 / files
nightly mac
4a692c812a3f / 68.0a1 / 20190325095153 / files
nightly win32
4a692c812a3f / 68.0a1 / 20190325095153 / files
nightly win64
4a692c812a3f / 68.0a1 / 20190325095153 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1482608 - Remove the JS bookmark merger. r=markh Differential Revision: https://phabricator.services.mozilla.com/D20078
toolkit/components/places/SyncedBookmarksMirror.jsm
--- a/toolkit/components/places/SyncedBookmarksMirror.jsm
+++ b/toolkit/components/places/SyncedBookmarksMirror.jsm
@@ -487,17 +487,17 @@ class SyncedBookmarksMirror {
     let hasChanges = weakUpload.length > 0 || (await this.hasChanges());
     if (!hasChanges) {
       MirrorLog.debug("No changes detected in both mirror and Places");
       await observersToNotify.updateFrecencies();
       return {};
     }
 
     if (!(await this.validLocalRoots())) {
-      throw new SyncedBookmarksMirror.ConsistencyError(
+      throw new SyncedBookmarksMirror.MergeError(
         "Local tree has misparented root");
     }
 
     // The flow ID is used to correlate telemetry events for each sync.
     let flowID = PlacesUtils.history.makeGuid();
 
     let changeRecords;
     try {
@@ -1242,27 +1242,18 @@ this.SyncedBookmarksMirror = SyncedBookm
 
 /** Key names for the key-value `meta` table. */
 SyncedBookmarksMirror.META_KEY = {
   LAST_MODIFIED: "collection/lastModified",
   SYNC_ID: "collection/syncId",
 };
 
 /**
- * An error thrown when the merge can't proceed because the local or remote
- * tree is inconsistent.
+ * An error thrown when the merge failed for an unexpected reason.
  */
-class ConsistencyError extends Error {
-  constructor(message) {
-    super(message);
-    this.name = "ConsistencyError";
-  }
-}
-SyncedBookmarksMirror.ConsistencyError = ConsistencyError;
-
 class MergeError extends Error {
   constructor(message) {
     super(message);
     this.name = "MergeError";
   }
 }
 SyncedBookmarksMirror.MergeError = MergeError;
 
@@ -2074,1840 +2065,16 @@ async function withTiming(name, func, re
 
   MirrorLog.trace(`${name} took ${elapsedTime.toFixed(3)}ms`);
   recordTiming(elapsedTime, result);
 
   return result;
 }
 
 /**
- * Content info for an item in the local or remote tree. This is used to dedupe
- * NEW local items to remote items that don't exist locally. See `makeDupeKey`
- * for how we determine if two items are dupes.
- */
-class BookmarkContent {
-  constructor(title, urlHref, position) {
-    this.title = title;
-    this.urlHref = urlHref;
-    this.position = position;
-  }
-
-  static fromRow(row) {
-    let title = row.getResultByName("title");
-    let urlHref = row.getResultByName("url");
-    let position = row.getResultByName("position");
-    return new BookmarkContent(title, urlHref, position);
-  }
-}
-
-/**
- * Builds a lookup key for a node and its content. This is used to match nodes
- * with different GUIDs and similar content.
- *
- * - Bookmarks must have the same title and URL.
- * - Queries must have the same title and query URL.
- * - Folders and livemarks must have the same title.
- * - Separators must have the same position within their parents.
- *
- * @param  {BookmarkNode} node
- *         A local or remote node.
- * @param  {BookmarkContent} content
- *         Content info for the node.
- * @return {String}
- *         A map key that represents the node and its content.
- */
-function makeDupeKey(node, content) {
-  switch (node.kind) {
-    case SyncedBookmarksMirror.KIND.QUERY:
-      // Fallthrough, treat the same as a bookmark.
-    case SyncedBookmarksMirror.KIND.BOOKMARK:
-      // We use `JSON.stringify([...])` instead of `[...].join(",")` to avoid
-      // escaping the `,` in titles and URLs.
-      return JSON.stringify([node.kind, content.title, content.urlHref]);
-
-    case SyncedBookmarksMirror.KIND.FOLDER:
-    case SyncedBookmarksMirror.KIND.LIVEMARK:
-      return JSON.stringify([node.kind, content.title]);
-
-    case SyncedBookmarksMirror.KIND.SEPARATOR:
-      return JSON.stringify([node.kind, content.position]);
-  }
-  return JSON.stringify([node.guid]);
-}
-
-/**
- * The merge state indicates which node we should prefer when reconciling
- * with Places. Recall that a merged node may point to a local node, remote
- * node, or both.
- */
-class BookmarkMergeState {
-  constructor(value, structure = value) {
-    this.value = value;
-    this.structure = structure;
-  }
-
-  /**
-   * Takes an existing value state, and a new structure state. We use the new
-   * merge state to resolve conflicts caused by moving local items out of a
-   * remotely deleted folder, or remote items out of a locally deleted folder.
-   *
-   * Applying a new merged node bumps its local change counter, so that the
-   * merged structure is reuploaded to the server.
-   *
-   * @param  {BookmarkMergeState} oldState
-   *         The existing merge state.
-   * @return {BookmarkMergeState}
-   *         The new merge state.
-   */
-  static new(oldState) {
-    if (oldState.structure == BookmarkMergeState.TYPE.NEW) {
-      return oldState;
-    }
-    return new BookmarkMergeState(oldState.value, BookmarkMergeState.TYPE.NEW);
-  }
-
-  /**
-   * Returns a representation of the value ("V") and structure ("S") state
-   * for logging. "L" is "local", "R" is "remote", and "+" is "new". We use
-   * compact notation here to reduce noise in trace logs, which log the
-   * merge state of every node in the tree.
-   *
-   * @return {String}
-   */
-  toString() {
-    return `(${this.valueToString()}; ${this.structureToString()})`;
-  }
-
-  valueToString() {
-    switch (this.value) {
-      case BookmarkMergeState.TYPE.LOCAL:
-        return "Value: Local";
-      case BookmarkMergeState.TYPE.REMOTE:
-        return "Value: Remote";
-    }
-    return "Value: ?";
-  }
-
-  structureToString() {
-    switch (this.structure) {
-      case BookmarkMergeState.TYPE.LOCAL:
-        return "Structure: Local";
-      case BookmarkMergeState.TYPE.REMOTE:
-        return "Structure: Remote";
-      case BookmarkMergeState.TYPE.NEW:
-        return "Structure: New";
-    }
-    return "Structure: ?";
-  }
-
-  toJSON() {
-    return this.toString();
-  }
-}
-
-BookmarkMergeState.TYPE = {
-  LOCAL: 1,
-  REMOTE: 2,
-  NEW: 3,
-};
-
-/**
- * A local merge state means no changes: we keep the local value and structure
- * state. This could mean that the item doesn't exist on the server yet, or that
- * it has newer local changes that we should upload.
- *
- * It's an error for a merged node to have a local merge state without a local
- * node. Deciding the value state for the merged node asserts this.
- */
-BookmarkMergeState.local = new BookmarkMergeState(
-  BookmarkMergeState.TYPE.LOCAL);
-
-/**
- * A remote merge state means we should update Places with new value and
- * structure state from the mirror. The item might not exist locally yet, or
- * might have newer remote changes that we should apply.
- *
- * As with local, a merged node can't have a remote merge state without a
- * remote node.
- */
-BookmarkMergeState.remote = new BookmarkMergeState(
-  BookmarkMergeState.TYPE.REMOTE);
-
-/**
- * A node in a local or remote bookmark tree. Nodes are lightweight: they carry
- * enough information for the merger to resolve trivial conflicts without
- * querying the mirror or Places for the complete value state.
- */
-class BookmarkNode {
-  constructor(guid, kind, { age = 0, needsMerge = false, level = 0,
-                            isSyncable = true } = {}) {
-    this.guid = guid;
-    this.kind = kind;
-    this.age = age;
-    this.needsMerge = needsMerge;
-    this.level = level;
-    this.isSyncable = isSyncable;
-    this.children = [];
-  }
-
-  // Creates a virtual folder node for the Places root.
-  static root() {
-    let guid = PlacesUtils.bookmarks.rootGuid;
-    return new BookmarkNode(guid, SyncedBookmarksMirror.KIND.FOLDER);
-  }
-
-  /**
-   * Creates a bookmark node from a Places row.
-   *
-   * @param  {mozIStorageRow} row
-   *         The Places row containing the node info.
-   * @param  {Number} localTimeSeconds
-   *         The current local time, in seconds, used to calculate the
-   *         item's age.
-   * @return {BookmarkNode}
-   *         A bookmark node for the local item.
-   */
-  static fromLocalRow(row, localTimeSeconds) {
-    let guid = row.getResultByName("guid");
-
-    // Note that this doesn't account for local clock skew. `localModified`
-    // is in milliseconds.
-    let localModified = row.getResultByName("localModified");
-    let age = Math.max(localTimeSeconds - localModified / 1000, 0) || 0;
-
-    let kind = row.getResultByName("kind");
-    let level = row.getResultByName("level");
-    let isSyncable = !!row.getResultByName("isSyncable");
-
-    let syncChangeCounter = row.getResultByName("syncChangeCounter");
-    let needsMerge = syncChangeCounter > 0;
-
-    return new BookmarkNode(guid, kind, { age, needsMerge, level, isSyncable });
-  }
-
-  /**
-   * Creates a bookmark node from a mirror row.
-   *
-   * @param  {mozIStorageRow} row
-   *         The mirror row containing the node info.
-   * @param  {Number} remoteTimeSeconds
-   *         The current server time, in seconds, used to calculate the
-   *         item's age.
-   * @return {BookmarkNode}
-   *         A bookmark node for the remote item.
-   */
-  static fromRemoteRow(row, remoteTimeSeconds) {
-    let guid = row.getResultByName("guid");
-
-    // `serverModified` is in milliseconds.
-    let serverModified = row.getResultByName("serverModified");
-    let age = Math.max(remoteTimeSeconds - serverModified / 1000, 0) || 0;
-
-    let kind = row.getResultByName("kind");
-    let needsMerge = !!row.getResultByName("needsMerge");
-
-    return new BookmarkNode(guid, kind, { age, needsMerge });
-  }
-
-  isFolder() {
-    return this.kind == SyncedBookmarksMirror.KIND.FOLDER;
-  }
-
-  newerThan(otherNode) {
-    return this.age < otherNode.age;
-  }
-
-  /**
-   * Checks if remoteNode has a kind that's compatible with this *local* node.
-   * - Nodes with the same kind are always compatible.
-   * - Local folders are compatible with remote livemarks, but not vice-versa
-   *   (ie, remote folders are *not* compatible with local livemarks)
-   * - Bookmarks and queries are always compatible.
-   *
-   * @return {Boolean}
-   */
-  hasCompatibleKind(remoteNode) {
-    if (this.kind == remoteNode.kind) {
-      return true;
-    }
-    // bookmarks and queries are interchangable as simply changing the URL
-    // can cause it to flip kinds - and webextensions are able to change the
-    // URL of any bookmark.
-    if ((this.kind == SyncedBookmarksMirror.KIND.BOOKMARK &&
-         remoteNode.kind == SyncedBookmarksMirror.KIND.QUERY) ||
-        (this.kind == SyncedBookmarksMirror.KIND.QUERY &&
-         remoteNode.kind == SyncedBookmarksMirror.KIND.BOOKMARK)) {
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Generates a human-readable, ASCII art representation of the node and its
-   * descendants. This is useful for visualizing the tree structure in trace
-   * logs.
-   *
-   * @return {String}
-   */
-  toASCIITreeString(prefix = "") {
-    if (!this.isFolder()) {
-      return prefix + "- " + this.toString();
-    }
-    return prefix + "+ " + this.toString() + "\n" + this.children.map(childNode =>
-      childNode.toASCIITreeString(`${prefix}| `)
-    ).join("\n");
-  }
-
-  /**
-   * Returns a representation of the node for logging. This should be compact,
-   * because the merger logs every local and remote node when trace logging is
-   * enabled.
-   *
-   * @return {String} A string in the form of
-   *         "bookmarkAAAA (Bookmark; Age = 1.234s; Unmerged)", where "Bookmark"
-   *         is the kind, "Age = 1.234s" indicates the age in seconds, and
-   *         "Unmerged" (which may not be present) indicates that the node
-   *         needs to be merged.
-   */
-  toString() {
-    let info = `${this.kindToString()}; Age = ${this.age.toFixed(3)}s`;
-    if (this.needsMerge) {
-      info += "; Unmerged";
-    }
-    return `${this.guid} (${info})`;
-  }
-
-  kindToString() {
-    switch (this.kind) {
-      case SyncedBookmarksMirror.KIND.BOOKMARK:
-        return "Bookmark";
-      case SyncedBookmarksMirror.KIND.QUERY:
-        return "Query";
-      case SyncedBookmarksMirror.KIND.FOLDER:
-        return "Folder";
-      case SyncedBookmarksMirror.KIND.LIVEMARK:
-        return "Livemark";
-      case SyncedBookmarksMirror.KIND.SEPARATOR:
-        return "Separator";
-    }
-    return "Unknown";
-  }
-
-  // Used by `Log.jsm`.
-  toJSON() {
-    return this.toString();
-  }
-}
-
-/**
- * A complete, rooted tree with tombstones.
- */
-class BookmarkTree {
-  constructor(root) {
-    this.root = root;
-    this.byGuid = new Map([[this.root.guid, this.root]]);
-    this.parentNodeByChildNode = new Map([[this.root, null]]);
-    this.deletedGuids = new Set();
-  }
-
-  isDeleted(guid) {
-    return this.deletedGuids.has(guid);
-  }
-
-  nodeForGuid(guid) {
-    return this.byGuid.get(guid);
-  }
-
-  parentNodeFor(childNode) {
-    return this.parentNodeByChildNode.get(childNode);
-  }
-
-  /**
-   * Inserts a node into the tree. The node must not already exist in the tree,
-   * and the node's parent must be a folder.
-   */
-  insert(parentGuid, node) {
-    if (this.byGuid.has(node.guid)) {
-      let existingNode = this.byGuid.get(node.guid);
-      MirrorLog.error("Can't replace existing node ${existingNode} with node " +
-                      "${node}", { existingNode, node });
-      throw new TypeError("Node already exists in tree");
-    }
-    let parentNode = this.byGuid.get(parentGuid);
-    if (!parentNode) {
-      MirrorLog.error("Missing parent ${parentGuid} for node ${node}",
-                      { parentGuid, node });
-      throw new TypeError("Can't insert node into nonexistent parent");
-    }
-    if (!parentNode.isFolder()) {
-      MirrorLog.error("Non-folder parent ${parentNode} for node ${node}",
-                      { parentNode, node });
-      throw new TypeError("Can't insert node into non-folder");
-    }
-
-    parentNode.children.push(node);
-    this.byGuid.set(node.guid, node);
-    this.parentNodeByChildNode.set(node, parentNode);
-  }
-
-  noteDeleted(guid) {
-    this.deletedGuids.add(guid);
-  }
-
-  * guids() {
-    for (let [guid] of this.byGuid) {
-      yield guid;
-    }
-    for (let guid of this.deletedGuids) {
-      yield guid;
-    }
-  }
-
-  /**
-   * Generates an ASCII art representation of the complete tree.
-   *
-   * @return {String}
-   */
-  toASCIITreeString() {
-    return `${this.root.toASCIITreeString()}\nDeleted: [${
-            Array.from(this.deletedGuids).join(", ")}]`;
-  }
-}
-
-/**
- * A node in a merged bookmark tree. Holds the local node, remote node,
- * merged children, and a merge state indicating which side to prefer.
- */
-class MergedBookmarkNode {
-  constructor(guid, localNode, remoteNode, mergeState) {
-    this.guid = guid;
-    this.localNode = localNode;
-    this.remoteNode = remoteNode;
-    this.mergeState = mergeState;
-    this.mergedChildren = [];
-  }
-
-  /**
-   * Indicates whether to prefer the remote side when applying the merged tree.
-   *
-   * @return {Boolean}
-   */
-  useRemote() {
-    switch (this.mergeState.value) {
-      case BookmarkMergeState.TYPE.LOCAL:
-        if (!this.localNode) {
-          // Should never happen. See the comment for
-          // `BookmarkMergeState.local`.
-          throw new TypeError(
-            "Can't have local value state without local node");
-        }
-        return false;
-
-      case BookmarkMergeState.TYPE.REMOTE:
-        if (!this.remoteNode) {
-          // Should never happen. See the comment for
-          // `BookmarkMergeState.remote`.
-          throw new TypeError(
-            "Can't have remote value state without remote node");
-        }
-        if (this.localNode) {
-          // If the item exists locally and remotely, check if the remote node
-          // changed.
-          return this.remoteNode.needsMerge;
-        }
-        // Otherwise, the item only exists remotely, so take the remote state
-        // unconditionally.
-        return true;
-    }
-    throw new TypeError("Unexpected value state");
-  }
-
-  /**
-   * Indicates whether the merged item should be uploaded to the server.
-   *
-   * @return {Boolean}
-   */
-  shouldUpload() {
-    switch (this.mergeState.structure) {
-      case BookmarkMergeState.TYPE.LOCAL:
-        if (!this.localNode) {
-          // Should never happen. See the comment for
-          // `BookmarkMergeState.local`.
-          throw new TypeError(
-            "Can't have local structure state without local node");
-        }
-        if (this.remoteNode) {
-          // If the item exists locally and remotely, check if the local node
-          // changed.
-          return this.localNode.needsMerge;
-        }
-        // Otherwise, the item only exists locally, so upload the local state
-        // unconditionally.
-        return true;
-
-      case BookmarkMergeState.TYPE.REMOTE:
-        if (!this.remoteNode) {
-          // Should never happen. See the comment for
-          // `BookmarkMergeState.remote`.
-          throw new TypeError(
-            "Can't have remote structure state without remote node");
-        }
-        return false;
-
-      case BookmarkMergeState.TYPE.NEW:
-        return true;
-    }
-    throw new TypeError("Unexpected structure state");
-  }
-
-  /**
-   * Yields the decided value and structure states of the merged node's
-   * descendants. We use these as binding parameters to populate the temporary
-   * `mergeStates` table when applying the merged tree to Places.
-   */
-  * mergeStatesParams(level = 0) {
-    for (let position = 0; position < this.mergedChildren.length; ++position) {
-      let mergedChild = this.mergedChildren[position];
-      let mergeStateParam = {
-        localGuid: mergedChild.localNode ? mergedChild.localNode.guid :
-                   mergedChild.guid,
-        // The merged GUID is different than the local GUID if we deduped a
-        // NEW local item to a remote item.
-        mergedGuid: mergedChild.guid,
-        parentGuid: this.guid,
-        level,
-        position,
-        // SQLite represents Booleans as 0/1.
-        useRemote: mergedChild.useRemote() ? 1 : 0,
-        shouldUpload: mergedChild.shouldUpload() ? 1 : 0,
-      };
-      yield mergeStateParam;
-      yield* mergedChild.mergeStatesParams(level + 1);
-    }
-  }
-
-  /**
-   * Generates an ASCII art representation of the merged node and its
-   * descendants. This is similar to the format generated by
-   * `BookmarkNode#toASCIITreeString`, but logs value and structure states for
-   * merged children.
-   *
-   * @return {String}
-   */
-  toASCIITreeString(prefix = "") {
-    if (!this.mergedChildren.length) {
-      return prefix + "- " + this.toString();
-    }
-    return prefix + "+ " + this.toString() + "\n" + this.mergedChildren.map(
-      mergedChildNode => mergedChildNode.toASCIITreeString(`${prefix}| `)
-    ).join("\n");
-  }
-
-  /**
-   * Returns a representation of the merged node for logging.
-   *
-   * @return {String}
-   *         A string in the form of "bookmarkAAAA (V: R, S: R)", where
-   *         "V" is the value state and "R" is the structure state.
-   */
-  toString() {
-    return `${this.guid} ${this.mergeState.toString()}`;
-  }
-
-  toJSON() {
-    return this.toString();
-  }
-}
-
-/**
- * A two-way merger that produces a complete merged tree from a complete local
- * tree and a complete remote tree with changes since the last sync.
- *
- * This is ported almost directly from iOS. On iOS, the `ThreeWayMerger` takes a
- * complete "mirror" tree with the server state after the last sync, and two
- * incomplete trees with local and remote changes to the mirror: "local" and
- * "mirror", respectively. Overlaying buffer onto mirror yields the current
- * server tree; overlaying local onto mirror yields the complete local tree.
- *
- * On Desktop, our `localTree` is the union of iOS's mirror and local, and our
- * `remoteTree` is the union of iOS's mirror and buffer. Mapping the iOS
- * concepts to Desktop:
- *
- * - "Mirror" is approximately all `moz_bookmarks` where `syncChangeCounter = 0`
- *   and `items` where `needsMerge = 0`. This is approximate because Desktop
- *   doesn't store the shared parent for changed items.
- * - "Local" is all `moz_bookmarks` where `syncChangeCounter > 0`.
- * - "Buffer" is all `items` where `needsMerge = 1`.
- *
- * Since we don't store the shared parent, we can only do two-way merges. Also,
- * our merger doesn't distinguish between structure and value changes, since we
- * don't record that state in Places. The change counter notes *that* a bookmark
- * changed, but not *how*. This means we might choose the wrong side when
- * resolving merge conflicts, while iOS will do the right thing.
- *
- * Fortunately, most of our users don't organize their bookmarks into deeply
- * nested hierarchies, or make conflicting changes on multiple devices
- * simultaneously. Changing Places to record structure and value changes would
- * require significant changes to the storage schema. A simpler two-way tree
- * merge strikes a good balance between correctness and complexity.
- */
-class BookmarkMerger {
-  constructor(localTree, newLocalContents, remoteTree, newRemoteContents) {
-    this.localTree = localTree;
-    this.newLocalContents = newLocalContents;
-    this.remoteTree = remoteTree;
-    this.newRemoteContents = newRemoteContents;
-    this.matchingDupesByLocalParentNode = new Map();
-    this.mergedGuids = new Set();
-    this.deleteLocally = new Set();
-    this.deleteRemotely = new Set();
-    this.structureCounts = {
-      remoteRevives: 0, // Remote non-folder change wins over local deletion.
-      localDeletes: 0, // Local folder deletion wins over remote change.
-      localRevives: 0, // Local non-folder change wins over remote deletion.
-      remoteDeletes: 0, // Remote folder deletion wins over local change.
-    };
-    this.dupeCount = 0;
-  }
-
-  async merge() {
-    let localRoot = this.localTree.nodeForGuid(PlacesUtils.bookmarks.rootGuid);
-    let remoteRoot = this.remoteTree.nodeForGuid(PlacesUtils.bookmarks.rootGuid);
-    let mergedRoot = await this.mergeNode(PlacesUtils.bookmarks.rootGuid, localRoot,
-                                          remoteRoot);
-
-    // Any remaining deletions on one side should be deleted on the other side.
-    // This happens when the remote tree has tombstones for items that don't
-    // exist in Places, or Places has tombstones for items that aren't on the
-    // server.
-    for await (let guid of yieldingIterator(this.localTree.deletedGuids)) {
-      if (!this.mentions(guid)) {
-        this.deleteRemotely.add(guid);
-      }
-    }
-    for await (let guid of yieldingIterator(this.remoteTree.deletedGuids)) {
-      if (!this.mentions(guid)) {
-        this.deleteLocally.add(guid);
-      }
-    }
-    return mergedRoot;
-  }
-
-  async subsumes(tree) {
-    for await (let guid of yieldingIterator(tree.guids())) {
-      if (!this.mentions(guid)) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  mentions(guid) {
-    return this.mergedGuids.has(guid) || this.deleteLocally.has(guid) ||
-           this.deleteRemotely.has(guid);
-  }
-
-  * deletions() {
-    // Items that should be deleted locally already have tombstones on the
-    // server, so we don't need to upload tombstones for these deletions.
-    for (let guid of this.deleteLocally) {
-      if (this.deleteRemotely.has(guid)) {
-        continue;
-      }
-      let localNode = this.localTree.nodeForGuid(guid);
-      yield { guid, localLevel: localNode ? localNode.level : -1,
-              shouldUploadTombstone: false };
-    }
-
-    // Items that should be deleted remotely, or on both sides, need tombstones.
-    for (let guid of this.deleteRemotely) {
-      let localNode = this.localTree.nodeForGuid(guid);
-      yield { guid, localLevel: localNode ? localNode.level : -1,
-              shouldUploadTombstone: true };
-    }
-  }
-
-  /**
-   * Merges two nodes, recursively walking folders.
-   *
-   * @param  {String} guid
-   *         The GUID to use for the merged node.
-   * @param  {BookmarkNode?} localNode
-   *         The local node. May be `null` if the node only exists remotely.
-   * @param  {BookmarkNode?} remoteNode
-   *         The remote node. May be `null` if the node only exists locally.
-   * @return {MergedBookmarkNode}
-   *         The merged node, with merged folder children.
-   */
-  async mergeNode(mergedGuid, localNode, remoteNode) {
-    await maybeYield();
-    this.mergedGuids.add(mergedGuid);
-
-    if (localNode) {
-      if (localNode.guid != mergedGuid) {
-        // We deduped a NEW local item to a remote item.
-        this.mergedGuids.add(localNode.guid);
-      }
-
-      if (remoteNode) {
-        MirrorLog.trace("Item ${mergedGuid} exists locally as ${localNode} " +
-                        "and remotely as ${remoteNode}; merging",
-                        { mergedGuid, localNode, remoteNode });
-        let mergedNode = await this.twoWayMerge(mergedGuid, localNode, remoteNode);
-        return mergedNode;
-      }
-
-      MirrorLog.trace("Item ${mergedGuid} only exists locally as " +
-                      "${localNode}; taking local state", { mergedGuid,
-                                                            localNode });
-      let mergedNode = new MergedBookmarkNode(mergedGuid, localNode, null,
-                                              BookmarkMergeState.local);
-      if (localNode.isFolder()) {
-        // The local folder doesn't exist remotely, but its children might, so
-        // we still need to recursively walk and merge them. This method will
-        // change the merge state from local to new if any children were moved
-        // or deleted.
-        await this.mergeChildListsIntoMergedNode(mergedNode, localNode,
-                                                 /* remoteNode */ null);
-      }
-      return mergedNode;
-    }
-
-    if (remoteNode) {
-      MirrorLog.trace("Item ${mergedGuid} only exists remotely as " +
-                      "${remoteNode}; taking remote state", { mergedGuid,
-                                                              remoteNode });
-      let mergedNode = new MergedBookmarkNode(mergedGuid, null, remoteNode,
-                                              BookmarkMergeState.remote);
-      if (remoteNode.isFolder()) {
-        // As above, a remote folder's children might still exist locally, so we
-        // need to merge them and update the merge state from remote to new if
-        // any children were moved or deleted.
-        await this.mergeChildListsIntoMergedNode(mergedNode, /* localNode */ null,
-                                                 remoteNode);
-      }
-      return mergedNode;
-    }
-
-    // Should never happen. We need to have at least one node for a two-way
-    // merge.
-    throw new TypeError("Can't merge two nonexistent nodes");
-  }
-
-  /**
-   * Merges two nodes that exist locally and remotely.
-   *
-   * @param  {String} mergedGuid
-   *         The GUID to use for the merged node.
-   * @param  {BookmarkNode} localNode
-   *         The existing local node.
-   * @param  {BookmarkNode} remoteNode
-   *         The existing remote node.
-   * @return {MergedBookmarkNode}
-   *         The merged node, with merged folder children.
-   */
-  async twoWayMerge(mergedGuid, localNode, remoteNode) {
-    let mergeState = this.resolveTwoWayValueConflict(mergedGuid, localNode,
-                                                     remoteNode);
-    MirrorLog.trace("Merge state for ${mergedGuid} is ${mergeState}",
-                    { mergedGuid, mergeState });
-
-    let mergedNode = new MergedBookmarkNode(mergedGuid, localNode, remoteNode,
-                                            mergeState);
-
-    if (!localNode.hasCompatibleKind(remoteNode)) {
-      MirrorLog.error("Merging local ${localNode} and remote ${remoteNode} " +
-                      "with different kinds", { localNode, remoteNode });
-      throw new SyncedBookmarksMirror.ConsistencyError(
-        "Can't merge different item kinds");
-    }
-
-    if (localNode.isFolder()) {
-      if (remoteNode.isFolder()) {
-        // Merging two folders, so we need to walk their children to handle
-        // structure changes.
-        MirrorLog.trace("Merging folders ${localNode} and ${remoteNode}",
-                        { localNode, remoteNode });
-        await this.mergeChildListsIntoMergedNode(mergedNode, localNode, remoteNode);
-        return mergedNode;
-      }
-      // Otherwise it must be a livemark, so fall through.
-    }
-    // Otherwise are compatible kinds of non-folder, so there's no need to
-    // walk children - just return the merged node.
-    MirrorLog.trace("Merging non-folders ${localNode} and ${remoteNode}",
-                    { localNode, remoteNode });
-    return mergedNode;
-  }
-
-  /**
-   * Determines the merge state for a node that exists locally and remotely.
-   *
-   * @param  {String} mergedGuid
-   *         The GUID of the merged node. This is the same as the remote GUID,
-   *         and usually the same as the local GUID. The local GUID may be
-   *         different if we're deduping a local item to a remote item.
-   * @param  {String} localNode
-   *         The local bookmark node.
-   * @param  {BookmarkNode} remoteNode
-   *         The remote bookmark node.
-   * @return {BookmarkMergeState}
-   *         The two-way merge state.
-   */
-  resolveTwoWayValueConflict(mergedGuid, localNode, remoteNode) {
-    if (PlacesUtils.bookmarks.userContentRoots.includes(mergedGuid)) {
-      // Don't update root titles or other properties.
-      return BookmarkMergeState.local;
-    }
-    if (localNode.needsMerge && remoteNode.needsMerge) {
-      // The item changed locally and remotely. Use the timestamp to decide
-      // which is newer.
-      let valueState = localNode.newerThan(remoteNode) ?
-                       BookmarkMergeState.local :
-                       BookmarkMergeState.remote;
-      return valueState;
-    }
-    if (remoteNode.needsMerge) {
-      // The item changed remotely since the last sync, but not locally. Take
-      // the remote state.
-      return BookmarkMergeState.remote;
-    }
-    // The item changed locally, or is unchanged on both sides. Keep the local
-    // state.
-    return BookmarkMergeState.local;
-  }
-
-  /**
-   * Merges a remote child node into a merged folder node. This handles the
-   * following cases:
-   *
-   * - The remote child is locally deleted. We recursively move all of its
-   *   descendants that don't exist locally to the merged folder.
-   * - The remote child doesn't exist locally, but has a content match in the
-   *   corresponding local folder. We dedupe the local child to the remote
-   *   child.
-   * - The remote child exists locally, but in a different folder. We compare
-   *   merge flags and timestamps to decide where to keep the child.
-   * - The remote child exists locally, and in the same folder. We merge the
-   *   local and remote children.
-   *
-   * This is the inverse of `mergeLocalChildIntoMergedNode`.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node.
-   * @param  {BookmarkNode} remoteParentNode
-   *         The remote folder node.
-   * @param  {BookmarkNode} remoteChildNode
-   *         The remote child node.
-   */
-  async mergeRemoteChildIntoMergedNode(mergedNode, remoteParentNode,
-                                       remoteChildNode) {
-    if (this.mergedGuids.has(remoteChildNode.guid)) {
-      MirrorLog.trace("Remote child ${remoteChildNode} already seen in " +
-                      "another folder and merged", { remoteChildNode });
-      return;
-    }
-
-    MirrorLog.trace("Merging remote child ${remoteChildNode} of " +
-                    "${remoteParentNode} into ${mergedNode}",
-                    { remoteChildNode, remoteParentNode, mergedNode });
-
-    if (PlacesUtils.bookmarks.userContentRoots.includes(remoteChildNode.guid)) {
-      // Remote child is a root. We always prefer local roots, since remote
-      // roots might be misparented, and we checked that the local roots were
-      // correct before merging. We can just bail here: if the root is parented
-      // correctly, we won't reupload anything, since we never upload the Places
-      // root; if not, we'll flag the wrong parent for reupload.
-      MirrorLog.trace("Ignoring remote root ${remoteChildNode} in " +
-                      "${remoteParentNode}", { remoteChildNode,
-                                               remoteParentNode });
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      return;
-    }
-
-    // Make sure the remote child isn't locally deleted.
-    let structureChange = await this.checkForLocalStructureChangeOfRemoteNode(
-      mergedNode, remoteParentNode, remoteChildNode);
-    if (structureChange == BookmarkMerger.STRUCTURE.DELETED) {
-      // If the remote child is locally deleted, we need to move all descendants
-      // that aren't also remotely deleted to the merged node. This handles the
-      // case where a user deletes a folder on this device, and adds a bookmark
-      // to the same folder on another device. We want to keep the folder
-      // deleted, but we also don't want to lose the new bookmark, so we move
-      // the bookmark to the deleted folder's parent.
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      return;
-    }
-
-    // The remote child isn't locally deleted. Does it exist in the local tree?
-    let localChildNode = this.localTree.nodeForGuid(remoteChildNode.guid);
-    if (!localChildNode) {
-      // Remote child is not a root, and doesn't exist locally. Try to find a
-      // content match in the containing folder, and dedupe the local item if
-      // we can.
-      MirrorLog.trace("Remote child ${remoteChildNode} doesn't exist " +
-                      "locally; looking for local content match",
-                      { remoteChildNode });
-
-      let localChildNodeByContent = await this.findLocalNodeMatchingRemoteNode(
-        mergedNode, remoteChildNode);
-
-      let mergedChildNode = await this.mergeNode(remoteChildNode.guid,
-                                                 localChildNodeByContent,
-                                                 remoteChildNode);
-      mergedNode.mergedChildren.push(mergedChildNode);
-      return;
-    }
-
-    // Otherwise, the remote child exists in the local tree. Did it move?
-    let localParentNode = this.localTree.parentNodeFor(localChildNode);
-    if (!localParentNode) {
-      // Should never happen. If a node in the local tree doesn't have a parent,
-      // we built the tree incorrectly.
-      MirrorLog.error("Remote child ${remoteChildNode} exists locally as " +
-                      "${localChildNode} without local parent",
-                      { remoteChildNode, localChildNode });
-      throw new TypeError(
-        "Can't merge existing remote child without local parent");
-    }
-
-    MirrorLog.trace("Remote child ${remoteChildNode} exists locally in " +
-                    "${localParentNode} and remotely in ${remoteParentNode}",
-                    { remoteChildNode, localParentNode, remoteParentNode });
-
-    if (this.remoteTree.isDeleted(localParentNode.guid)) {
-      MirrorLog.trace("Unconditionally taking remote move for " +
-                      "${remoteChildNode} to ${remoteParentNode} because " +
-                      "local parent ${localParentNode} is deleted remotely",
-                      { remoteChildNode, remoteParentNode, localParentNode });
-
-      let mergedChildNode = await this.mergeNode(localChildNode.guid,
-                                                 localChildNode, remoteChildNode);
-      mergedNode.mergedChildren.push(mergedChildNode);
-      return;
-    }
-
-    if (localParentNode.needsMerge) {
-      if (remoteParentNode.needsMerge) {
-        MirrorLog.trace("Local ${localParentNode} and remote " +
-                        "${remoteParentNode} parents changed; comparing " +
-                        "modified times to decide parent for remote child " +
-                        "${remoteChildNode}",
-                        { localParentNode, remoteParentNode, remoteChildNode });
-
-        let latestLocalAge = Math.min(localChildNode.age,
-                                      localParentNode.age);
-        let latestRemoteAge = Math.min(remoteChildNode.age,
-                                       remoteParentNode.age);
-
-        if (latestRemoteAge > latestLocalAge) {
-          // Local move is younger, so we ignore the remote move. We'll
-          // merge the child later, when we walk its new local parent.
-          MirrorLog.trace("Ignoring older remote move for ${remoteChildNode} " +
-                          "to ${remoteParentNode} at ${latestRemoteAge}; " +
-                          "local move to ${localParentNode} at " +
-                          "${latestLocalAge} is newer",
-                          { remoteChildNode, remoteParentNode, latestRemoteAge,
-                            localParentNode, latestLocalAge });
-
-          // Flag the old parent for reupload, since we're moving the
-          // remote child. Note that, since we only flag the remote parent here,
-          // we don't need to handle reparenting and repositioning separately.
-          mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-          return;
-        }
-
-        // Otherwise, the remote move is younger, so we ignore the local move
-        // and merge the child now.
-        MirrorLog.trace("Taking newer remote move for ${remoteChildNode} to " +
-                        "${remoteParentNode} at ${latestRemoteAge}; local " +
-                        "move to ${localParentNode} at ${latestLocalAge} is " +
-                        "older", { remoteChildNode, remoteParentNode,
-                                   latestRemoteAge, localParentNode,
-                                   latestLocalAge });
-
-        let mergedChildNode = await this.mergeNode(remoteChildNode.guid,
-                                                   localChildNode, remoteChildNode);
-        mergedNode.mergedChildren.push(mergedChildNode);
-        return;
-      }
-
-      MirrorLog.trace("Remote parent unchanged; keeping remote child " +
-                      "${remoteChildNode} in ${localParentNode}",
-                      { remoteChildNode, localParentNode });
-
-      // Only flag the parent of the remote child for reupload.
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      return;
-    }
-
-    MirrorLog.trace("Local parent unchanged; keeping remote child " +
-                    "${remoteChildNode} in ${remoteParentNode}",
-                    { remoteChildNode, remoteParentNode });
-
-    let mergedChildNode = await this.mergeNode(remoteChildNode.guid, localChildNode,
-                                               remoteChildNode);
-    mergedNode.mergedChildren.push(mergedChildNode);
-  }
-
-  /**
-   * Merges a local child node into a merged folder node.
-   *
-   * This is the inverse of `mergeRemoteChildIntoMergedNode`.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node.
-   * @param  {BookmarkNode} localParentNode
-   *         The local folder node.
-   * @param  {BookmarkNode} localChildNode
-   *         The local child node.
-   */
-  async mergeLocalChildIntoMergedNode(mergedNode, localParentNode, localChildNode) {
-    if (this.mergedGuids.has(localChildNode.guid)) {
-      // We already merged the child when we walked another folder.
-      MirrorLog.trace("Local child ${localChildNode} already seen in " +
-                      "another folder and merged", { localChildNode });
-      return;
-    }
-
-    MirrorLog.trace("Merging local child ${localChildNode} of " +
-                    "${localParentNode} into ${mergedNode}",
-                    { localChildNode, localParentNode, mergedNode });
-
-    if (PlacesUtils.bookmarks.userContentRoots.includes(localChildNode.guid)) {
-      // Local child is a root, which may or may not exist remotely. We know
-      // local roots are parented correctly, so we merge them unconditionally.
-      // Places maintenance also bumps the change counter when fixing incorrect
-      // parents, so we'll flag the merged root node for reupload.
-      let remoteChildNode = this.remoteTree.nodeForGuid(localChildNode.guid);
-      if (remoteChildNode) {
-        let remoteParentNode = this.remoteTree.parentNodeFor(remoteChildNode);
-        if (!remoteParentNode) {
-          // Should never happen. If a node in the remote tree doesn't have a
-          // parent, we built the tree incorrectly.
-          MirrorLog.error("Local child ${localChildNode} exists remotely as " +
-                          "${remoteChildNode} without remote parent",
-                          { localChildNode, remoteChildNode });
-          throw new TypeError(
-            "Can't merge existing local syncable root without remote Places root");
-        }
-        if (localParentNode.guid != remoteParentNode.guid) {
-          let mergedRootNode = await this.mergeNode(localChildNode.guid,
-                                                    localChildNode, remoteChildNode);
-          mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-          mergedRootNode.mergeState = BookmarkMergeState.new(mergedRootNode.mergeState);
-          mergedNode.mergedChildren.push(mergedRootNode);
-          return;
-        }
-        let mergedRootNode = await this.mergeNode(localChildNode.guid,
-                                                  localChildNode, remoteChildNode);
-        mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-        mergedNode.mergedChildren.push(mergedRootNode);
-        return;
-      }
-
-      let mergedRootNode = await this.mergeNode(localChildNode.guid,
-                                                localChildNode, /* remoteNode */ null);
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      mergedRootNode.mergeState = BookmarkMergeState.new(mergedRootNode.mergeState);
-      mergedNode.mergedChildren.push(mergedRootNode);
-      return;
-    }
-
-    // Now, we know we haven't seen the local child before, and it's not in
-    // this folder on the server. Check if the child is remotely deleted.
-    let structureChange = await this.checkForRemoteStructureChangeOfLocalNode(
-      mergedNode, localParentNode, localChildNode);
-    if (structureChange == BookmarkMerger.STRUCTURE.DELETED) {
-      // If the child is remotely deleted, we need to move any new local
-      // descendants to the merged node, just as we did for new remote
-      // descendants of locally deleted children.
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      return;
-    }
-
-    // At this point, we know the local child isn't deleted. See if it
-    // exists in the remote tree.
-    let remoteChildNode = this.remoteTree.nodeForGuid(localChildNode.guid);
-    if (!remoteChildNode) {
-      // Local child is not a root, and doesn't exist remotely. Try to find a
-      // content match in the containing folder, and dedupe the local item if
-      // we can.
-      MirrorLog.trace("Local child ${localChildNode} doesn't exist " +
-                      "remotely; looking for remote content match",
-                      { localChildNode });
-
-      let remoteChildNodeByContent = await this.findRemoteNodeMatchingLocalNode(
-        mergedNode, localChildNode);
-
-      if (remoteChildNodeByContent) {
-        // The local child has a remote content match, so take the remote GUID
-        // and merge.
-        let mergedChildNode = await this.mergeNode(
-          remoteChildNodeByContent.guid, localChildNode,
-          remoteChildNodeByContent);
-        mergedNode.mergedChildren.push(mergedChildNode);
-        return;
-      }
-
-      // The local child doesn't exist remotely, so flag the merged parent and
-      // new child for upload, and walk its descendants.
-      let mergedChildNode = await this.mergeNode(localChildNode.guid, localChildNode,
-                                                 /* remoteChildNode */ null);
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      mergedChildNode.mergeState = BookmarkMergeState.new(mergedChildNode.mergeState);
-      mergedNode.mergedChildren.push(mergedChildNode);
-      return;
-    }
-
-    // The local child exists remotely. It must have moved; otherwise, we
-    // would have seen it when we walked the remote children.
-    let remoteParentNode = this.remoteTree.parentNodeFor(remoteChildNode);
-    if (!remoteParentNode) {
-      // Should never happen. If a node in the remote tree doesn't have a
-      // parent, we built the tree incorrectly.
-      MirrorLog.error("Local child ${localChildNode} exists remotely as " +
-                      "${remoteChildNode} without remote parent",
-                      { localChildNode, remoteChildNode });
-      throw new TypeError(
-        "Can't merge existing local child without remote parent");
-    }
-
-    MirrorLog.trace("Local child ${localChildNode} exists locally in " +
-                    "${localParentNode} and remotely in ${remoteParentNode}",
-                    { localChildNode, localParentNode, remoteParentNode });
-
-    if (this.localTree.isDeleted(remoteParentNode.guid)) {
-      MirrorLog.trace("Unconditionally taking local move for " +
-                      "${localChildNode} to ${localParentNode} because " +
-                      "remote parent ${remoteParentNode} is deleted locally",
-                      { localChildNode, localParentNode, remoteParentNode });
-
-      let mergedChildNode = await this.mergeNode(localChildNode.guid,
-                                                 localChildNode, remoteChildNode);
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      mergedChildNode.mergeState = BookmarkMergeState.new(mergedChildNode.mergeState);
-      mergedNode.mergedChildren.push(mergedChildNode);
-      return;
-    }
-
-    if (localParentNode.needsMerge) {
-      if (remoteParentNode.needsMerge) {
-        // If both parents changed, compare timestamps to decide where
-        // to keep the local child.
-        let latestLocalAge = Math.min(localChildNode.age,
-                                      localParentNode.age);
-        let latestRemoteAge = Math.min(remoteChildNode.age,
-                                       remoteParentNode.age);
-
-        // Did the child move to a different folder?
-        if (localParentNode.guid != remoteParentNode.guid) {
-          if (latestRemoteAge <= latestLocalAge) {
-            MirrorLog.trace("Local child ${localChildNode} reparented " +
-                            "locally to ${localParentNode} at " +
-                            "${latestLocalAge} and remotely to " +
-                            "${remoteParentNode} at ${latestRemoteAge}; " +
-                            "keeping child in newer remote parent",
-                            { localChildNode, localParentNode, latestLocalAge,
-                              remoteParentNode, latestRemoteAge });
-            return;
-          }
-
-          MirrorLog.trace("Local child ${localChildNode} reparented " +
-                          "locally to ${localParentNode} at " +
-                          "${latestLocalAge} and remotely to " +
-                          "${remoteParentNode} at ${latestRemoteAge}; " +
-                          "keeping child in newer local parent",
-                          { localChildNode, localParentNode, latestLocalAge,
-                            remoteParentNode, latestRemoteAge });
-
-          let mergedChildNode = await this.mergeNode(localChildNode.guid,
-                                                     localChildNode, remoteChildNode);
-          mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-          mergedChildNode.mergeState = BookmarkMergeState.new(mergedChildNode.mergeState);
-          mergedNode.mergedChildren.push(mergedChildNode);
-          return;
-        }
-
-        // Otherwise, the child was repositioned in the same folder.
-        // Compare timestamps to decide the child's new position.
-        if (latestRemoteAge <= latestLocalAge) {
-          MirrorLog.trace("Local child ${localChildNode} repositioned " +
-                          "locally in ${localParentNode} at " +
-                          "${latestLocalAge} and remotely in " +
-                          "${remoteParentNode} at ${latestRemoteAge}; " +
-                          "keeping child in newer remote position",
-                          { localChildNode, localParentNode, latestLocalAge,
-                            remoteParentNode, latestRemoteAge });
-          return;
-        }
-
-        MirrorLog.trace("Local child ${localChildNode} repositioned " +
-                        "locally in ${localParentNode} at " +
-                        "${latestLocalAge} and remotely in " +
-                        "${remoteParentNode} at ${latestRemoteAge}; " +
-                        "keeping child in newer local position",
-                        { localChildNode, localParentNode, latestLocalAge,
-                          remoteParentNode, latestRemoteAge });
-
-        let mergedChildNode = await this.mergeNode(localChildNode.guid,
-                                                   localChildNode, remoteChildNode);
-        mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-        mergedNode.mergedChildren.push(mergedChildNode);
-        return;
-      }
-
-      // If only the local parent changed, keep the local child in its
-      // new parent.
-      if (localParentNode.guid != remoteParentNode.guid) {
-        MirrorLog.trace("Local child ${localChildNode} reparented locally to " +
-                        "${localParentNode}", { localChildNode,
-                                                localParentNode });
-
-        // Merge and flag both the new parent and child for
-        // reupload.
-        let mergedChildNode = await this.mergeNode(localChildNode.guid, localChildNode,
-                                                   remoteChildNode);
-        mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-        mergedChildNode.mergeState = BookmarkMergeState.new(mergedChildNode.mergeState);
-        mergedNode.mergedChildren.push(mergedChildNode);
-        return;
-      }
-
-      // Otherwise, the child was repositioned locally.
-      MirrorLog.trace("Local child ${localChildNode} repositioned locally in " +
-                      "${localParentNode}", { localChildNode,
-                                              localParentNode });
-
-      // Only flag the parent of the repositioned local child for
-      // reupload.
-      let mergedChildNode = await this.mergeNode(localChildNode.guid,
-                                                 localChildNode, remoteChildNode);
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      mergedNode.mergedChildren.push(mergedChildNode);
-      return;
-    }
-
-    MirrorLog.trace("Local child ${localChildNode} unchanged locally; " +
-                    "keeping child in remote parent ${remoteParentNode}",
-                    { localChildNode, remoteParentNode });
-  }
-
-  /**
-   * Recursively merges the children of a local folder node and a matching
-   * remote folder node.
-   *
-   * @param {MergedBookmarkNode} mergedNode
-   *        The merged folder node. This method mutates the merged node to
-   *        append local and remote children, and sets a new merge state
-   *        state if needed.
-   * @param {BookmarkNode?} localNode
-   *        The local folder node. May be `null` if the folder only exists
-   *        remotely.
-   * @param {BookmarkNode?} remoteNode
-   *        The remote folder node. May be `null` if the folder only exists
-   *        locally.
-   */
-  async mergeChildListsIntoMergedNode(mergedNode, localNode, remoteNode) {
-    if (localNode && remoteNode) {
-      if (localNode.needsMerge && remoteNode.needsMerge) {
-        // The folder exists locally and remotely, and changed on both sides.
-        // Compare timestamps to determine which children to merge first,
-        // followed by remaining unmerged children on the other side.
-        if (localNode.newerThan(remoteNode)) {
-          await this.mergeLocalChildrenIntoMergedNode(mergedNode, localNode);
-          await this.mergeRemoteChildrenIntoMergedNode(mergedNode, remoteNode);
-        } else {
-          await this.mergeRemoteChildrenIntoMergedNode(mergedNode, remoteNode);
-          await this.mergeLocalChildrenIntoMergedNode(mergedNode, localNode);
-        }
-        return;
-      }
-
-      if (remoteNode.needsMerge) {
-        // The folder exists locally and remotely, and only changed remotely.
-        // Merge remote children first, followed by remaining local children.
-        await this.mergeRemoteChildrenIntoMergedNode(mergedNode, remoteNode);
-        await this.mergeLocalChildrenIntoMergedNode(mergedNode, localNode);
-        return;
-      }
-
-      // The folder exists locally and remotely, and only changed locally, or
-      // is unchanged on both sides. Merge local first, then remote.
-      await this.mergeLocalChildrenIntoMergedNode(mergedNode, localNode);
-      await this.mergeRemoteChildrenIntoMergedNode(mergedNode, remoteNode);
-      return;
-    }
-
-    if (localNode) {
-      // The folder only exists locally, so no remote children to merge.
-      await this.mergeLocalChildrenIntoMergedNode(mergedNode, localNode);
-      return;
-    }
-
-    if (remoteNode) {
-      // The folder only exists remotely, so no local children to merge.
-      await this.mergeRemoteChildrenIntoMergedNode(mergedNode, remoteNode);
-      return;
-    }
-
-    // Should never happen.
-    throw new TypeError("Can't merge children for two nonexistent nodes");
-  }
-
-  /**
-   * Recursively merges the children of a remote folder node.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node. This method mutates the merged node to
-   *         append remote children.
-   * @param  {BookmarkNode} remoteNode
-   *         The remote folder node.
-   */
-  async mergeRemoteChildrenIntoMergedNode(mergedNode, remoteNode) {
-    MirrorLog.trace("Merging remote children of ${remoteNode} into " +
-                    "${mergedNode}", { remoteNode, mergedNode });
-
-    for await (let remoteChildNode of yieldingIterator(remoteNode.children)) {
-      await this.mergeRemoteChildIntoMergedNode(
-        mergedNode, remoteNode, remoteChildNode);
-    }
-  }
-
-  /**
-   * Recursively merges the children of a local folder node.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node. This method mutates the merged node to
-   *         append local children.
-   * @param  {BookmarkNode} localNode
-   *         The local folder node.
-   */
-  async mergeLocalChildrenIntoMergedNode(mergedNode, localNode) {
-    MirrorLog.trace("Merging local children of ${localNode} into " +
-                    "${mergedNode}", { localNode, mergedNode });
-
-    for await (let localChildNode of yieldingIterator(localNode.children)) {
-      await this.mergeLocalChildIntoMergedNode(
-        mergedNode, localNode, localChildNode);
-    }
-  }
-
-  /**
-   * Checks if a remote node is locally moved or deleted, and reparents any
-   * descendants that aren't also remotely deleted to the merged node.
-   *
-   * This is the inverse of `checkForRemoteStructureChangeOfLocalNode`.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node to hold relocated remote orphans.
-   * @param  {BookmarkNode} remoteParentNode
-   *         The remote parent of the potentially deleted child node.
-   * @param  {BookmarkNode} remoteNode
-   *         The remote potentially deleted child node.
-   * @return {BookmarkMerger.STRUCTURE}
-   *         A structure change type: `UNCHANGED` if the remote node is not
-   *         deleted or doesn't exist locally, `MOVED` if the node is moved
-   *         locally, or `DELETED` if the node is deleted locally.
-   */
-  async checkForLocalStructureChangeOfRemoteNode(mergedNode, remoteParentNode,
-                                                 remoteNode) {
-    if (PlacesUtils.bookmarks.userContentRoots.includes(remoteNode.guid)) {
-      // Should never happen. We should have seen and ignored remote roots.
-      throw new TypeError(
-        "Shouldn't check remote syncable root for structure changes");
-    }
-
-    if (!remoteNode.isSyncable) {
-      // If the remote node is known to be non-syncable, we unconditionally
-      // delete it from the server, even if it's syncable locally.
-      this.deleteRemotely.add(remoteNode.guid);
-      if (remoteNode.isFolder()) {
-        // If the remote node is a folder, we also need to walk its descendants
-        // and reparent any syncable descendants, and descendants that only
-        // exist remotely, to the merged node.
-        await this.relocateRemoteOrphansToMergedNode(mergedNode, remoteNode);
-      }
-      return BookmarkMerger.STRUCTURE.DELETED;
-    }
-
-    if (!this.localTree.isDeleted(remoteNode.guid)) {
-      let localNode = this.localTree.nodeForGuid(remoteNode.guid);
-      if (!localNode) {
-        return BookmarkMerger.STRUCTURE.UNCHANGED;
-      }
-      if (!localNode.isSyncable) {
-        // The remote node is syncable, but the local node is non-syncable.
-        // This is unlikely now that Places no longer supports custom roots,
-        // but, for consistency, we unconditionally delete the node from the
-        // server.
-        this.deleteRemotely.add(remoteNode.guid);
-        if (remoteNode.isFolder()) {
-          await this.relocateRemoteOrphansToMergedNode(mergedNode, remoteNode);
-        }
-        return BookmarkMerger.STRUCTURE.DELETED;
-      }
-      let localParentNode = this.localTree.parentNodeFor(localNode);
-      if (!localParentNode) {
-        // Should never happen. If a node in the local tree doesn't have a
-        // parent, we built the tree incorrectly.
-        throw new TypeError(
-          "Can't check for structure changes without local parent");
-      }
-      if (localParentNode.guid != remoteParentNode.guid) {
-        return BookmarkMerger.STRUCTURE.MOVED;
-      }
-      return BookmarkMerger.STRUCTURE.UNCHANGED;
-    }
-
-    if (remoteNode.needsMerge) {
-      if (!remoteNode.isFolder()) {
-        // If a non-folder child is deleted locally and changed remotely, we
-        // ignore the local deletion and take the remote child.
-        MirrorLog.trace("Remote non-folder ${remoteNode} deleted locally " +
-                        "and changed remotely; taking remote change",
-                        { remoteNode });
-        this.structureCounts.remoteRevives++;
-        return BookmarkMerger.STRUCTURE.UNCHANGED;
-      }
-      // For folders, we always take the local deletion and relocate remotely
-      // changed grandchildren to the merged node. We could use the mirror to
-      // revive the child folder, but it's easier to relocate orphaned
-      // grandchildren than to partially revive the child folder.
-      MirrorLog.trace("Remote folder ${remoteNode} deleted locally " +
-                      "and changed remotely; taking local deletion",
-                      { remoteNode });
-      this.structureCounts.localDeletes++;
-    } else {
-      MirrorLog.trace("Remote node ${remoteNode} deleted locally and not " +
-                      "changed remotely; taking local deletion",
-                      { remoteNode });
-    }
-
-    // Take the local deletion and relocate any new remote descendants to the
-    // merged node.
-    this.deleteRemotely.add(remoteNode.guid);
-    if (remoteNode.isFolder()) {
-      await this.relocateRemoteOrphansToMergedNode(mergedNode, remoteNode);
-    }
-    return BookmarkMerger.STRUCTURE.DELETED;
-  }
-
-  /**
-   * Checks if a local node is remotely moved or deleted, and reparents any
-   * descendants that aren't also locally deleted to the merged node.
-   *
-   * This is the inverse of `checkForLocalStructureChangeOfRemoteNode`.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node to hold relocated local orphans.
-   * @param  {BookmarkNode} localParentNode
-   *         The local parent of the potentially deleted child node.
-   * @param  {BookmarkNode} localNode
-   *         The local potentially deleted child node.
-   * @return {BookmarkMerger.STRUCTURE}
-   *         A structure change type: `UNCHANGED` if the local node is not
-   *         deleted or doesn't exist remotely, `MOVED` if the node is moved
-   *         remotely, or `DELETED` if the node is deleted remotely.
-   */
-  async checkForRemoteStructureChangeOfLocalNode(mergedNode, localParentNode,
-                                                 localNode) {
-    if (PlacesUtils.bookmarks.userContentRoots.includes(localNode.guid)) {
-      // Should never happen. We should have merged local roots unconditionally.
-      throw new TypeError(
-        "Shouldn't check local syncable root for structure changes");
-    }
-
-    if (!localNode.isSyncable) {
-      // If the local node is known to be non-syncable, we unconditionally
-      // delete it from Places, even if it's syncable remotely. This is
-      // unlikely now that Places no longer supports custom roots.
-      this.deleteLocally.add(localNode.guid);
-      if (localNode.isFolder()) {
-        await this.relocateLocalOrphansToMergedNode(mergedNode, localNode);
-      }
-      return BookmarkMerger.STRUCTURE.DELETED;
-    }
-
-    if (!this.remoteTree.isDeleted(localNode.guid)) {
-      let remoteNode = this.remoteTree.nodeForGuid(localNode.guid);
-      if (!remoteNode) {
-        return BookmarkMerger.STRUCTURE.UNCHANGED;
-      }
-      if (!remoteNode.isSyncable) {
-        // The local node is syncable, but the remote node is non-syncable.
-        // This can happen if we applied an orphaned left pane query in a
-        // previous sync, and later saw the left pane root on the server.
-        // Since we now have the complete subtree, we can remove the item from
-        // Places.
-        this.deleteLocally.add(localNode.guid);
-        if (remoteNode.isFolder()) {
-          await this.relocateLocalOrphansToMergedNode(mergedNode, localNode);
-        }
-        return BookmarkMerger.STRUCTURE.DELETED;
-      }
-      let remoteParentNode = this.remoteTree.parentNodeFor(remoteNode);
-      if (!remoteParentNode) {
-        // Should never happen. If a node in the remote tree doesn't have a
-        // parent, we built the tree incorrectly.
-        throw new TypeError(
-          "Can't check for structure changes without remote parent");
-      }
-      if (remoteParentNode.guid != localParentNode.guid) {
-        return BookmarkMerger.STRUCTURE.MOVED;
-      }
-      return BookmarkMerger.STRUCTURE.UNCHANGED;
-    }
-
-    if (localNode.needsMerge) {
-      if (!localNode.isFolder()) {
-        MirrorLog.trace("Local non-folder ${localNode} deleted remotely and " +
-                        "changed locally; taking local change", { localNode });
-        this.structureCounts.localRevives++;
-        return BookmarkMerger.STRUCTURE.UNCHANGED;
-      }
-      MirrorLog.trace("Local folder ${localNode} deleted remotely and " +
-                      "changed locally; taking remote deletion", { localNode });
-      this.structureCounts.remoteDeletes++;
-    } else {
-      MirrorLog.trace("Local node ${localNode} deleted remotely and not " +
-                      "changed locally; taking remote deletion", { localNode });
-    }
-
-    // Take the remote deletion and relocate any new local descendants to the
-    // merged node.
-    this.deleteLocally.add(localNode.guid);
-    if (localNode.isFolder()) {
-      await this.relocateLocalOrphansToMergedNode(mergedNode, localNode);
-    }
-    return BookmarkMerger.STRUCTURE.DELETED;
-  }
-
-  /**
-   * Takes a local deletion for a remote node by marking the node as deleted,
-   * and relocating all remote descendants that aren't also locally deleted to
-   * the closest surviving ancestor. We do this to avoid data loss if the
-   * user adds a bookmark to a folder on another device, and deletes that
-   * folder locally.
-   *
-   * This is the inverse of `relocateLocalOrphansToMergedNode`.
-   *
-   * @param {MergedBookmarkNode} mergedNode
-   *        The closest surviving ancestor, to hold relocated remote orphans.
-   * @param {BookmarkNode} remoteNode
-   *        The locally deleted remote node.
-   */
-  async relocateRemoteOrphansToMergedNode(mergedNode, remoteNode) {
-    let remoteOrphanNodes = [];
-    for await (let remoteChildNode of yieldingIterator(remoteNode.children)) {
-      if (this.mergedGuids.has(remoteChildNode.guid)) {
-        MirrorLog.trace("Remote child ${remoteChildNode} can't be an orphan; " +
-                        "already merged", { remoteChildNode });
-        continue;
-      }
-      let structureChange = await this.checkForLocalStructureChangeOfRemoteNode(
-        mergedNode, remoteNode, remoteChildNode);
-      if (structureChange == BookmarkMerger.STRUCTURE.MOVED ||
-          structureChange == BookmarkMerger.STRUCTURE.DELETED) {
-        // The remote child is already moved or deleted locally, so we should
-        // ignore it instead of treating it as a remote orphan.
-        continue;
-      }
-      remoteOrphanNodes.push(remoteChildNode);
-    }
-
-    let mergedOrphanNodes = [];
-    for await (let remoteOrphanNode of yieldingIterator(remoteOrphanNodes)) {
-      let localOrphanNode = this.localTree.nodeForGuid(remoteOrphanNode.guid);
-      let mergedOrphanNode = await this.mergeNode(remoteOrphanNode.guid,
-                                                  localOrphanNode, remoteOrphanNode);
-      mergedOrphanNodes.push(mergedOrphanNode);
-    }
-
-    MirrorLog.trace("Relocating remote orphans ${mergedOrphanNodes} to " +
-                    "${mergedNode}", { mergedOrphanNodes, mergedNode });
-    for await (let mergedOrphanNode of yieldingIterator(mergedOrphanNodes)) {
-      // Flag the new parent and moved orphans for reupload.
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      mergedOrphanNode.mergeState = BookmarkMergeState.new(mergedOrphanNode.mergeState);
-      mergedNode.mergedChildren.push(mergedOrphanNode);
-    }
-  }
-
-  /**
-   * Takes a remote deletion for a local node by marking the node as deleted,
-   * and relocating all local descendants that aren't also remotely deleted to
-   * the closest surviving ancestor.
-   *
-   * This is the inverse of `relocateRemoteOrphansToMergedNode`.
-   *
-   * @param {MergedBookmarkNode} mergedNode
-   *        The closest surviving ancestor, to hold relocated local orphans.
-   * @param {BookmarkNode} localNode
-   *        The remotely deleted local node.
-   */
-  async relocateLocalOrphansToMergedNode(mergedNode, localNode) {
-    let localOrphanNodes = [];
-    for await (let localChildNode of yieldingIterator(localNode.children)) {
-      if (this.mergedGuids.has(localChildNode.guid)) {
-        MirrorLog.trace("Local child ${localChildNode} can't be an orphan; " +
-                        "already merged", { localChildNode });
-        continue;
-      }
-      let structureChange = await this.checkForRemoteStructureChangeOfLocalNode(
-        mergedNode, localNode, localChildNode);
-      if (structureChange == BookmarkMerger.STRUCTURE.MOVED ||
-          structureChange == BookmarkMerger.STRUCTURE.DELETED) {
-        // The local child is already moved or deleted remotely, so we should
-        // ignore it instead of treating it as a local orphan.
-        continue;
-      }
-      localOrphanNodes.push(localChildNode);
-    }
-
-    let mergedOrphanNodes = [];
-    for await (let localOrphanNode of yieldingIterator(localOrphanNodes)) {
-      let remoteOrphanNode = this.remoteTree.nodeForGuid(localOrphanNode.guid);
-      let mergedNode = await this.mergeNode(localOrphanNode.guid,
-                                            localOrphanNode, remoteOrphanNode);
-      mergedOrphanNodes.push(mergedNode);
-    }
-
-    MirrorLog.trace("Relocating local orphans ${mergedOrphanNodes} to " +
-                    "${mergedNode}", { mergedOrphanNodes, mergedNode });
-
-    for await (let mergedOrphanNode of yieldingIterator(mergedOrphanNodes)) {
-      mergedNode.mergeState = BookmarkMergeState.new(mergedNode.mergeState);
-      mergedOrphanNode.mergeState = BookmarkMergeState.new(mergedOrphanNode.mergeState);
-      mergedNode.mergedChildren.push(mergedOrphanNode);
-    }
-  }
-
-  /**
-   * Finds all children of a local folder with similar content as children of
-   * the corresponding remote folder. This is used to dedupe local items that
-   * haven't been uploaded yet, to remote items that don't exist locally.
-   *
-   * Recall that we match items by GUID as we walk down the tree. If a GUID on
-   * one side doesn't exist on the other, we fall back to a content match in
-   * the same folder.
-   *
-   * This method is called the first time that `findRemoteNodeMatchingLocalNode`
-   * merges a local child that doesn't exist remotely, and the first time that
-   * `findLocalNodeMatchingRemoteNode` merges a remote child that doesn't exist
-   * locally.
-   *
-   * Finding all possible dupes is O(m + n) in the worst case, where `m` is the
-   * number of local children, and `n` is the number of remote children. We
-   * cache matches in `matchingDupesByLocalParentNode`, so deduping all
-   * remaining children of the same folder, on both sides, only needs two O(1)
-   * map lookups per child.
-   *
-   * @param   {BookmarkNode} localParentNode
-   *          The local folder containing children to dedupe.
-   * @param   {BookmarkNode} remoteParentNode
-   *          The corresponding remote folder.
-   * @returns {Map.<BookmarkNode, BookmarkNode>}
-   *          A bidirectional map of local children to remote children, and
-   *          remote children to local children.
-   *          `findRemoteNodeMatchingLocalNode` looks up matching remote
-   *          children by local node. `findLocalNodeMatchingRemoteNode` looks up
-   *          local children by remote node.
-   */
-  async findAllMatchingDupesInFolders(localParentNode, remoteParentNode) {
-    let matches = new Map();
-    let dupeKeyToLocalNodes = new Map();
-
-    for await (let localChildNode of yieldingIterator(localParentNode.children)) {
-      let localChildContent = this.newLocalContents.get(localChildNode.guid);
-      if (!localChildContent) {
-        MirrorLog.trace("Not deduping local child ${localChildNode}; already " +
-                        "uploaded", { localChildNode });
-        continue;
-      }
-      let remoteChildNodeByGuid = this.remoteTree.nodeForGuid(
-        localChildNode.guid);
-      if (remoteChildNodeByGuid) {
-        MirrorLog.trace("Not deduping local child ${localChildNode}; already " +
-                        "exists remotely as ${remoteChildNodeByGuid}",
-                        { localChildNode, remoteChildNodeByGuid });
-        continue;
-      }
-      if (this.remoteTree.isDeleted(localChildNode.guid)) {
-        MirrorLog.trace("Not deduping local child ${localChildNode}; deleted " +
-                        "remotely", { localChildNode });
-        continue;
-      }
-      let dupeKey = makeDupeKey(localChildNode, localChildContent);
-      let localNodesForKey = dupeKeyToLocalNodes.get(dupeKey);
-      if (localNodesForKey) {
-        // Store matching local children in an array, in case multiple children
-        // have the same dupe key (for example, a toolbar containing multiple
-        // empty folders, as in bug 1213369).
-        localNodesForKey.push(localChildNode);
-      } else {
-        dupeKeyToLocalNodes.set(dupeKey, [localChildNode]);
-      }
-    }
-
-    for await (let remoteChildNode of yieldingIterator(remoteParentNode.children)) {
-      if (matches.has(remoteChildNode)) {
-        MirrorLog.trace("Not deduping remote child ${remoteChildNode}; " +
-                        "already deduped", { remoteChildNode });
-        continue;
-      }
-      let remoteChildContent = this.newRemoteContents.get(
-        remoteChildNode.guid);
-      if (!remoteChildContent) {
-        MirrorLog.trace("Not deduping remote child ${remoteChildNode}; " +
-                        "already merged", { remoteChildNode });
-        continue;
-      }
-      let dupeKey = makeDupeKey(remoteChildNode, remoteChildContent);
-      let localNodesForKey = dupeKeyToLocalNodes.get(dupeKey);
-      if (!localNodesForKey) {
-        MirrorLog.trace("Not deduping remote child ${remoteChildNode}; no " +
-                        "local content matches", { remoteChildNode });
-        continue;
-      }
-      let localChildNode = localNodesForKey.shift();
-      if (!localChildNode) {
-        MirrorLog.trace("Not deduping remote child ${remoteChildNode}; no " +
-                        "remaining local content matches", { remoteChildNode });
-        continue;
-      }
-      MirrorLog.trace("Deduping local child ${localChildNode} to remote " +
-                      "child ${remoteChildNode}", { localChildNode,
-                                                    remoteChildNode });
-      matches.set(localChildNode, remoteChildNode);
-      matches.set(remoteChildNode, localChildNode);
-    }
-    return matches;
-  }
-
-  /**
-   * Finds a remote node with a different GUID that matches the content of a
-   * local node. This is the inverse of `findLocalNodeMatchingRemoteNode`.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node.
-   * @param  {BookmarkNode} localChildNode
-   *         The NEW local child node.
-   * @return {BookmarkNode?}
-   *         A matching unmerged remote child node, or `null` if there are no
-   *         matching remote items.
-   */
-  async findRemoteNodeMatchingLocalNode(mergedNode, localChildNode) {
-    let remoteParentNode = mergedNode.remoteNode;
-    if (!remoteParentNode) {
-      MirrorLog.trace("Merged node ${mergedNode} doesn't exist remotely; no " +
-                      "potential dupes for local child ${localChildNode}",
-                      { mergedNode, localChildNode });
-      return null;
-    }
-    let localParentNode = mergedNode.localNode;
-    if (!localParentNode) {
-      // Should never happen. Trying to find a remote content match for a
-      // child of a folder that doesn't exist locally is a coding error.
-      throw new TypeError(
-        "Can't find remote content match without local parent");
-    }
-    let matches = this.matchingDupesByLocalParentNode.get(localParentNode);
-    if (!matches) {
-      MirrorLog.trace("First local child ${localChildNode} doesn't exist " +
-                      "remotely; finding all matching dupes in local " +
-                      "${localParentNode} and remote ${remoteParentNode}",
-                      { localChildNode, localParentNode, remoteParentNode });
-      matches = await this.findAllMatchingDupesInFolders(localParentNode,
-                                                         remoteParentNode);
-      this.matchingDupesByLocalParentNode.set(localParentNode, matches);
-    }
-    let newRemoteNode = matches.get(localChildNode);
-    if (!newRemoteNode) {
-      return null;
-    }
-    this.dupeCount++;
-    return newRemoteNode;
-  }
-
-  /**
-   * Finds a local node with a different GUID that matches the content of a
-   * remote node. This is the inverse of `findRemoteNodeMatchingLocalNode`.
-   *
-   * @param  {MergedBookmarkNode} mergedNode
-   *         The merged folder node.
-   * @param  {BookmarkNode} remoteChildNode
-   *         The unmerged remote child node.
-   * @return {BookmarkNode?}
-   *         A matching NEW local child node, or `null` if there are no matching
-   *         local items.
-   */
-  async findLocalNodeMatchingRemoteNode(mergedNode, remoteChildNode) {
-    let localParentNode = mergedNode.localNode;
-    if (!localParentNode) {
-      MirrorLog.trace("Merged node ${mergedNode} doesn't exist locally; no " +
-                      "potential dupes for remote child ${remoteChildNode}",
-                      { mergedNode, remoteChildNode });
-      return null;
-    }
-    let remoteParentNode = mergedNode.remoteNode;
-    if (!remoteParentNode) {
-      // Should never happen. Trying to find a local content match for a
-      // child of a folder that doesn't exist remotely is a coding error.
-      throw new TypeError(
-        "Can't find local content match without remote parent");
-    }
-    let matches = this.matchingDupesByLocalParentNode.get(localParentNode);
-    if (!matches) {
-      MirrorLog.trace("First remote child ${remoteChildNode} doesn't exist " +
-                      "locally; finding all matching dupes in local " +
-                      "${localParentNode} and remote ${remoteParentNode}",
-                      { remoteChildNode, localParentNode, remoteParentNode });
-      matches = await this.findAllMatchingDupesInFolders(localParentNode,
-                                                         remoteParentNode);
-      this.matchingDupesByLocalParentNode.set(localParentNode, matches);
-    }
-    let newLocalNode = matches.get(remoteChildNode);
-    if (!newLocalNode) {
-      return null;
-    }
-    this.dupeCount++;
-    return newLocalNode;
-  }
-
-  /**
-   * Returns an array of local and remote deletions for logging.
-   *
-   * @return {String[]}
-   */
-  deletionsToStrings() {
-    let infos = [];
-    if (this.deleteLocally.size) {
-      infos.push("Delete Locally: " + Array.from(this.deleteLocally).join(
-        ", "));
-    }
-    if (this.deleteRemotely.size) {
-      infos.push("Delete Remotely: " + Array.from(this.deleteRemotely).join(
-        ", "));
-    }
-    return infos;
-  }
-}
-
-/**
- * Structure change types, used to indicate if a node on one side is moved
- * or deleted on the other.
- */
-BookmarkMerger.STRUCTURE = {
-  UNCHANGED: 1,
-  MOVED: 2,
-  DELETED: 3,
-};
-
-/**
  * Fires bookmark and keyword observer notifications for all changes made during
  * the merge.
  */
 class BookmarkObserverRecorder {
   constructor(db, { maxFrecenciesToRecalculate }) {
     this.db = db;
     this.maxFrecenciesToRecalculate = maxFrecenciesToRecalculate;
     this.bookmarkObserverNotifications = [];