Bug 1305563 - Add a bookmark mirror and two-way merger for synced bookmarks. r?markh,rnewman,tcsc,mak draft
authorKit Cambridge <kit@yakshaving.ninja>
Sat, 06 Jan 2018 07:44:33 -0800
changeset 747956 fead51b240528adeb7b9a109a75173fbba3555d4
parent 747955 0d6c2cd7903e5f63f2d6d69118f4aa17d7ecdcbc
child 747957 6c1e77b5c4d0d3f2cc0e8adc6ce6fd463fa6e138
push id97039
push userbmo:kit@mozilla.com
push dateSat, 27 Jan 2018 02:58:34 +0000
reviewersmarkh, rnewman, tcsc, mak
bugs1305563
milestone60.0a1
Bug 1305563 - Add a bookmark mirror and two-way merger for synced bookmarks. r?markh,rnewman,tcsc,mak The mirror matches the complete bookmark tree stored on the Sync server, stores new bookmarks changed on the server since the last sync, merges the local and remote trees, applies the resulting merged tree back to Places, fires observer notifications for all items changed in the merge, and stages locally changed bookmarks for upload. MozReview-Commit-ID: MbeFQUargt
services/sync/modules/engines/bookmarks.js
toolkit/components/places/PlacesSyncUtils.jsm
toolkit/components/places/SyncedBookmarksMirror.jsm
toolkit/components/places/moz.build
toolkit/components/places/tests/PlacesTestUtils.jsm
toolkit/components/places/tests/moz.build
toolkit/components/places/tests/sync/head_sync.js
toolkit/components/places/tests/sync/livemark.xml
toolkit/components/places/tests/sync/test_bookmark_corruption.js
toolkit/components/places/tests/sync/test_bookmark_deduping.js
toolkit/components/places/tests/sync/test_bookmark_deletion.js
toolkit/components/places/tests/sync/test_bookmark_kinds.js
toolkit/components/places/tests/sync/test_bookmark_structure_changes.js
toolkit/components/places/tests/sync/test_bookmark_value_changes.js
toolkit/components/places/tests/sync/xpcshell.ini
tools/lint/eslint/modules.json
--- a/services/sync/modules/engines/bookmarks.js
+++ b/services/sync/modules/engines/bookmarks.js
@@ -123,17 +123,17 @@ PlacesItem.prototype = {
   toSyncBookmark() {
     let result = {
       kind: this.type,
       recordId: this.id,
       parentRecordId: this.parentid,
     };
     let dateAdded = PlacesSyncUtils.bookmarks.ratchetTimestampBackwards(
       this.dateAdded, +this.modified * 1000);
-    if (dateAdded !== undefined) {
+    if (dateAdded > 0) {
       result.dateAdded = dateAdded;
     }
     return result;
   },
 
   // Populates the record from a Sync bookmark object returned from
   // `PlacesSyncUtils.bookmarks.fetch`.
   fromSyncBookmark(item) {
--- a/toolkit/components/places/PlacesSyncUtils.jsm
+++ b/toolkit/components/places/PlacesSyncUtils.jsm
@@ -559,29 +559,36 @@ const BookmarkSyncUtils = PlacesSyncUtil
    *        A changeset containing sync change records, as returned by
    *        `pullChanges`.
    * @return {Promise} resolved once all records have been updated.
    */
   pushChanges(changeRecords) {
     return PlacesUtils.withConnectionWrapper(
       "BookmarkSyncUtils: pushChanges", async function(db) {
         let skippedCount = 0;
+        let weakCount = 0;
         let updateParams = [];
 
         for (let recordId in changeRecords) {
           // Validate change records to catch coding errors.
           let changeRecord = validateChangeRecord(
             "BookmarkSyncUtils: pushChanges",
             changeRecords[recordId], {
               tombstone: { required: true },
               counter: { required: true },
               synced: { required: true },
             }
           );
 
+          // Skip weakly uploaded records.
+          if (!changeRecord.counter) {
+            weakCount++;
+            continue;
+          }
+
           // Sync sets the `synced` flag for reconciled or successfully
           // uploaded items. If upload failed, ignore the change; we'll
           // try again on the next sync.
           if (!changeRecord.synced) {
             skippedCount++;
             continue;
           }
 
@@ -612,17 +619,18 @@ const BookmarkSyncUtils = PlacesSyncUtil
             await db.executeCached(`
               DELETE FROM moz_bookmarks_deleted
               WHERE guid = :guid`,
               deleteParams);
           });
         }
 
         BookmarkSyncLog.debug(`pushChanges: Processed change records`,
-                              { skipped: skippedCount,
+                              { weak: weakCount,
+                                skipped: skippedCount,
                                 updated: updateParams.length });
       }
     );
   },
 
   /**
    * Removes items from the database. Sync buffers incoming tombstones, and
    * calls this method to apply them at the end of each sync. Deletion
@@ -1019,24 +1027,24 @@ const BookmarkSyncUtils = PlacesSyncUtil
       WHERE type = :type AND
             fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND
                   url = :url)`,
       { syncChangeDelta, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
         url: url.href });
   },
 
   /**
-   * Returns `undefined` if no sensible timestamp could be found.
+   * Returns `0` if no sensible timestamp could be found.
    * Otherwise, returns the earliest sensible timestamp between `existingMillis`
    * and `serverMillis`.
    */
   ratchetTimestampBackwards(existingMillis, serverMillis, lowerBound = BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP) {
     const possible = [+existingMillis, +serverMillis].filter(n => !isNaN(n) && n > lowerBound);
     if (!possible.length) {
-      return undefined;
+      return 0;
     }
     return Math.min(...possible);
   },
 
   /**
    * Rebuilds the left pane query for the mobile root under "All Bookmarks" if
    * necessary. Record calls this method at the end of each bookmark record. This
    * code should eventually move to `PlacesUIUtils#maybeRebuildLeftPane`; see
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/SyncedBookmarksMirror.jsm
@@ -0,0 +1,3909 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This file implements a mirror and two-way merger for synced bookmarks. The
+ * mirror matches the complete tree stored on the Sync server, and stages new
+ * bookmarks changed on the server since the last sync. The merger walks the
+ * local tree in Places and the mirrored remote tree, produces a new merged
+ * tree, then updates the local tree to reflect the merged tree.
+ *
+ * Let's start with an overview of the different classes, and how they fit
+ * together.
+ *
+ * - `SyncedBookmarksMirror` sets up the database, validates and upserts new
+ *   incoming records, attaches to Places, and applies the changed records.
+ *   During application, we fetch the local and remote bookmark trees, merge
+ *   them, and update Places to match. Merging and application happen in a
+ *   single transaction, so applying the merged tree won't collide with local
+ *   changes. A failure at this point aborts the merge and leaves Places
+ *   unchanged.
+ *
+ * - A `BookmarkTree` is a fully rooted tree that also notes deletions. A
+ *   `BookmarkNode` represents a local item in Places, or a remote item in the
+ *   mirror.
+ *
+ * - A `MergedBookmarkNode` holds a local node, a remote node, and a
+ *   `MergeState` that indicates which node to prefer when updating Places and
+ *   the server to match the merged tree.
+ *
+ * - `BookmarkObserverRecorder` records all changes made to Places during the
+ *   merge, then dispatches `nsINavBookmarkObserver` notifications. Places uses
+ *   these notifications to update the UI and internal caches. We can't dispatch
+ *   during the merge because observers won't see the changes until the merge
+ *   transaction commits and the database is consistent again.
+ *
+ * - After application, we flag all applied incoming items as merged, create
+ *   Sync records for the locally new and updated items in Places, and upload
+ *   the records to the server. At this point, all outgoing items are flagged as
+ *   changed in Places, so the next sync can resume cleanly if the upload is
+ *   interrupted or fails.
+ *
+ * - Once upload succeeds, we update the mirror with the uploaded records, so
+ *   that the mirror matches the server again. An interruption or error here
+ *   will leave the uploaded items flagged as changed in Places, so we'll merge
+ *   them again on the next sync. This is redundant work, but shouldn't cause
+ *   issues.
+ */
+
+this.EXPORTED_SYMBOLS = ["SyncedBookmarksMirror"];
+
+const { utils: Cu, interfaces: Ci } = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+  Log: "resource://gre/modules/Log.jsm",
+  OS: "resource://gre/modules/osfile.jsm",
+  PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.jsm",
+  PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
+  Sqlite: "resource://gre/modules/Sqlite.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "MirrorLog", () =>
+  Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror")
+);
+
+// These can be removed once they're exposed in a central location (bug
+// 1375896).
+const DB_URL_LENGTH_MAX = 65536;
+const DB_TITLE_LENGTH_MAX = 4096;
+const DB_DESCRIPTION_LENGTH_MAX = 256;
+
+const SQLITE_MAX_VARIABLE_NUMBER = 999;
+
+// The current mirror database schema version. Bump for migrations, then add
+// migration code to `migrateMirrorSchema`.
+const MIRROR_SCHEMA_VERSION = 1;
+
+/**
+ * A mirror maintains a copy of the complete tree as stored on the Sync server.
+ * It is persistent.
+ *
+ * The mirror schema is a hybrid of how Sync and Places represent bookmarks.
+ * The `items` table contains item attributes (title, kind, description,
+ * URL, etc.), while the `structure` table stores parent-child relationships and
+ * position. This is similar to how iOS encodes "value" and "structure" state,
+ * though we handle these differently when merging. See `BookmarkMerger` for
+ * details.
+ *
+ * There's no guarantee that the remote state is consistent. We might be missing
+ * parents or children, or a bookmark and its parent might disagree about where
+ * it belongs. This means we need a strategy to handle missing parents and
+ * children.
+ *
+ * We treat the `children` of the last parent we see as canonical, and ignore
+ * the child's `parentid` entirely. We also ignore missing children, and
+ * temporarily reparent bookmarks with missing parents to "unfiled". When we
+ * eventually see the missing items, either during a later sync or as part of
+ * repair, we'll fill in the mirror's gaps and fix up the local tree.
+ *
+ * During merging, we won't intentionally try to fix inconsistencies on the
+ * server, and opt to build as complete a tree as we can from the remote state,
+ * even if we diverge from what's in the mirror. See bug 1433512 for context.
+ *
+ * If a sync is interrupted, we resume downloading from the server collection
+ * last modified time, or the server last modified time of the most recent
+ * record if newer. New incoming records always replace existing records in the
+ * mirror.
+ *
+ * We delete the mirror database on client reset, including when the sync ID
+ * changes on the server, and when the user is node reassigned, disables the
+ * bookmarks engine, or signs out.
+ */
+class SyncedBookmarksMirror {
+  constructor(db, { recordTelemetryEvent, finalizeAt =
+                      AsyncShutdown.profileBeforeChange } = {}) {
+    this.db = db;
+    this.recordTelemetryEvent = recordTelemetryEvent;
+
+    // Automatically close the database connection on shutdown.
+    this.finalizeAt = finalizeAt;
+    this.finalizeBound = () => this.finalize();
+    this.finalizeAt.addBlocker("SyncedBookmarksMirror: finalize",
+                               this.finalizeBound);
+  }
+
+  /**
+   * Sets up the mirror database connection and upgrades the mirror to the
+   * newest schema version. Automatically recreates the mirror if it's corrupt;
+   * throws on failure.
+   *
+   * @param  {String} options.path
+   *         The full path to the mirror database file.
+   * @param  {Function} options.recordTelemetryEvent
+   *         A function with the signature `(object: String, method: String,
+   *         value: String?, extra: Object?)`, used to emit telemetry events.
+   * @param  {AsyncShutdown.Barrier} [options.finalizeAt]
+   *         A shutdown phase, barrier, or barrier client that should
+   *         automatically finalize the mirror when triggered. Exposed for
+   *         testing.
+   * @return {SyncedBookmarksMirror}
+   *         A mirror ready for use.
+   */
+  static async open(options) {
+    let db = await Sqlite.cloneStorageConnection({
+      connection: PlacesUtils.history.DBConnection,
+      readOnly: false,
+    });
+    try {
+      try {
+        await db.execute(`ATTACH :mirrorPath AS mirror`,
+                         { mirrorPath: options.path });
+      } catch (ex) {
+        if (ex.errors && isDatabaseCorrupt(ex.errors[0])) {
+          MirrorLog.warn("Error attaching mirror to Places; removing and " +
+                         "recreating mirror", ex);
+          options.recordTelemetryEvent("mirror", "open", "error",
+                                       { why: "corrupt" });
+          await OS.File.remove(options.path);
+          await db.execute(`ATTACH :mirrorPath AS mirror`,
+                           { mirrorPath: options.path });
+        } else {
+          MirrorLog.warn("Unrecoverable error attaching mirror to Places", ex);
+          throw ex;
+        }
+      }
+      await db.execute(`PRAGMA foreign_keys = ON`);
+      await migrateMirrorSchema(db);
+      await initializeTempMirrorEntities(db);
+    } catch (ex) {
+      options.recordTelemetryEvent("mirror", "open", "error",
+                                   { why: "initialize" });
+      await db.close();
+      throw ex;
+    }
+    return new SyncedBookmarksMirror(db, options);
+  }
+
+  /**
+   * Returns the newer of the bookmarks collection last modified time, or the
+   * server modified time of the newest record. The bookmarks engine uses this
+   * timestamp as the "high water mark" for all downloaded records. Each sync
+   * downloads and stores records that are strictly newer than this time.
+   *
+   * @return {Number}
+   *         The high water mark time, in seconds.
+   */
+  async getCollectionHighWaterMark() {
+    // The first case, where we have records with server modified times newer
+    // than the collection last modified time, occurs when a sync is interrupted
+    // before we call `setCollectionLastModified`. We subtract one second, the
+    // maximum time precision guaranteed by the server, so that we don't miss
+    // other records with the same time as the newest one we downloaded.
+    let rows = await this.db.execute(`
+      SELECT MAX(
+        IFNULL((SELECT MAX(serverModified) - 1000 FROM items), 0),
+        IFNULL((SELECT CAST(value AS INTEGER) FROM meta
+                WHERE key = :modifiedKey), 0)
+      ) AS highWaterMark`,
+      { modifiedKey: SyncedBookmarksMirror.META.MODIFIED });
+    let highWaterMark = rows[0].getResultByName("highWaterMark");
+    return highWaterMark / 1000;
+  }
+
+  /**
+   * Updates the bookmarks collection last modified time. Note that this may
+   * be newer than the modified time of the most recent record.
+   *
+   * @param {Number|String} lastModifiedSeconds
+   *        The collection last modified time, in seconds.
+   */
+  async setCollectionLastModified(lastModifiedSeconds) {
+    let lastModified = lastModifiedSeconds * 1000;
+    if (!Number.isFinite(lastModified)) {
+      throw new TypeError("Invalid collection last modified time");
+    }
+    await this.db.executeBeforeShutdown(
+      "SyncedBookmarksMirror: setCollectionLastModified",
+      db => db.execute(`
+        REPLACE INTO meta(key, value)
+        VALUES(:modifiedKey, :lastModified)`,
+        { modifiedKey: SyncedBookmarksMirror.META.MODIFIED, lastModified })
+    );
+  }
+
+  /**
+   * Stores incoming or uploaded Sync records in the mirror. Rejects if any
+   * records are invalid.
+   *
+   * @param {PlacesItem[]} records
+   *        Sync records to store in the mirror.
+   * @param {Boolean} [options.needsMerge]
+   *        Indicates if the records were changed remotely since the last sync,
+   *        and should be merged into the local tree. This option is set to
+   *       `true` for incoming records, and `false` for successfully uploaded
+   *        records. Tests can also pass `false` to set up an existing mirror.
+   */
+  async store(records, { needsMerge = true } = {}) {
+    let options = { needsMerge };
+    await this.db.executeBeforeShutdown(
+      "SyncedBookmarksMirror: store",
+      db => db.executeTransaction(async () => {
+        for (let record of records) {
+          switch (record.type) {
+            case "bookmark":
+              MirrorLog.trace("Storing bookmark in mirror", record.cleartext);
+              await this.storeRemoteBookmark(record, options);
+              continue;
+
+            case "query":
+              MirrorLog.trace("Storing query in mirror", record.cleartext);
+              await this.storeRemoteQuery(record, options);
+              continue;
+
+            case "folder":
+              MirrorLog.trace("Storing folder in mirror", record.cleartext);
+              await this.storeRemoteFolder(record, options);
+              continue;
+
+            case "livemark":
+              MirrorLog.trace("Storing livemark in mirror", record.cleartext);
+              await this.storeRemoteLivemark(record, options);
+              continue;
+
+            case "separator":
+              MirrorLog.trace("Storing separator in mirror", record.cleartext);
+              await this.storeRemoteSeparator(record, options);
+              continue;
+
+            default:
+              if (record.deleted) {
+                MirrorLog.trace("Storing tombstone in mirror",
+                                record.cleartext);
+                await this.storeRemoteTombstone(record, options);
+                continue;
+              }
+          }
+          MirrorLog.warn("Ignoring record with unknown type", record.type);
+          this.recordTelemetryEvent("mirror", "ignore", "unknown",
+                                    { why: "kind" });
+        }
+      })
+    );
+  }
+
+  /**
+   * Builds a complete merged tree from the local and remote trees, resolves
+   * value and structure conflicts, dedupes local items, applies the merged
+   * tree back to Places, and notifies observers about the changes.
+   *
+   * Merging and application happen in a transaction, meaning code that uses the
+   * main Places connection, including the UI, will fail to write to the
+   * database until the transaction commits. Asynchronous consumers will retry
+   * on `SQLITE_BUSY`; synchronous consumers will fail after waiting for 100ms.
+   * See bug 1305563, comment 122 for details.
+   *
+   * @param  {Number} [options.localTimeSeconds]
+   *         The current local time, in seconds.
+   * @param  {Number} [options.remoteTimeSeconds]
+   *         The current server time, in seconds.
+   * @return {Object.<String, BookmarkChangeRecord>}
+   *         A changeset containing locally changed and reconciled records to
+   *         upload to the server, and to store in the mirror once upload
+   *         succeeds.
+   */
+  async apply({ localTimeSeconds = Date.now() / 1000,
+                remoteTimeSeconds = 0 } = {}) {
+    // We intentionally don't use `executeBeforeShutdown` in this function,
+    // since merging can take a while for large trees, and we don't want to
+    // block shutdown. Since all new items are in the mirror, we'll just try
+    // to merge again on the next sync.
+    let { missingParents, missingChildren } = await this.fetchRemoteOrphans();
+    if (missingParents.length) {
+      MirrorLog.debug("Temporarily reparenting remote items with missing " +
+                      "parents to unfiled", missingParents);
+      this.recordTelemetryEvent("mirror", "orphans", "parents",
+                                { count: String(missingParents.length) });
+    }
+    if (missingChildren.length) {
+      MirrorLog.debug("Remote tree missing items", missingChildren);
+      this.recordTelemetryEvent("mirror", "orphans", "children",
+                                { count: String(missingChildren.length) });
+    }
+
+    // It's safe to build the remote tree outside the transaction because
+    // `fetchRemoteTree` doesn't join to Places, only Sync writes to the
+    // mirror, and we're holding the Sync lock at this point.
+    MirrorLog.debug("Building remote tree from mirror");
+    let remoteTree = await this.fetchRemoteTree(remoteTimeSeconds);
+    MirrorLog.trace("Built remote tree from mirror", remoteTree);
+
+    let observersToNotify = new BookmarkObserverRecorder(this.db);
+
+    let changeRecords = await this.db.executeTransaction(async () => {
+      MirrorLog.debug("Building local tree from Places");
+      let localTree = await this.fetchLocalTree(localTimeSeconds);
+      MirrorLog.trace("Built local tree from Places", localTree);
+
+      MirrorLog.debug("Fetching content info for new mirror items");
+      let newRemoteContents = await this.fetchNewRemoteContents();
+
+      MirrorLog.debug("Fetching content info for new Places items");
+      let newLocalContents = await this.fetchNewLocalContents();
+
+      MirrorLog.debug("Building complete merged tree");
+      let merger = new BookmarkMerger(localTree, newLocalContents,
+                                      remoteTree, newRemoteContents);
+      let mergedRoot = merger.merge();
+      for (let { value, extra } of merger.telemetryEvents) {
+        this.recordTelemetryEvent("mirror", "merge", value, extra);
+      }
+
+      if (MirrorLog.level <= Log.Level.Trace) {
+        let newTreeRoot = mergedRoot.toBookmarkNode();
+        MirrorLog.trace("Built new merged tree", newTreeRoot);
+      }
+
+      // The merged tree should know about all items mentioned in the local
+      // and remote trees. Otherwise, it's incomplete, and we'll corrupt
+      // Places or lose data on the server if we try to apply it.
+      if (!merger.subsumes(localTree)) {
+        throw new SyncedBookmarksMirror.ConsistencyError(
+          "Merged tree doesn't mention all items from local tree");
+      }
+      if (!merger.subsumes(remoteTree)) {
+        throw new SyncedBookmarksMirror.ConsistencyError(
+          "Merged tree doesn't mention all items from remote tree");
+      }
+
+      MirrorLog.debug("Applying merged tree");
+      let localDeletions = Array.from(merger.deleteLocally).map(guid =>
+        ({ guid, level: localTree.levelForGuid(guid) })
+      );
+      let remoteDeletions = Array.from(merger.deleteRemotely);
+      await this.updateLocalItemsInPlaces(mergedRoot, localDeletions,
+                                          remoteDeletions);
+
+      // At this point, the database is consistent, and we can fetch info to
+      // pass to observers. Note that we can't fetch observer info in the
+      // triggers above, because the structure might not be complete yet. An
+      // incomplete structure might cause us to miss or record wrong parents and
+      // positions.
+
+      MirrorLog.debug("Recording observer notifications");
+      await this.noteObserverChanges(observersToNotify);
+
+      MirrorLog.debug("Staging locally changed items for upload");
+      await this.stageItemsToUpload();
+
+      MirrorLog.debug("Fetching records for local items to upload");
+      let changeRecords = await this.fetchLocalChangeRecords();
+
+      await this.db.execute(`DELETE FROM mergeStates`);
+      await this.db.execute(`DELETE FROM itemsAdded`);
+      await this.db.execute(`DELETE FROM guidsChanged`);
+      await this.db.execute(`DELETE FROM itemsChanged`);
+      await this.db.execute(`DELETE FROM itemsRemoved`);
+      await this.db.execute(`DELETE FROM itemsMoved`);
+      await this.db.execute(`DELETE FROM annosChanged`);
+      await this.db.execute(`DELETE FROM keywordsChanged`);
+      await this.db.execute(`DELETE FROM itemsToUpload`);
+
+      return changeRecords;
+    }, this.db.TRANSACTION_IMMEDIATE);
+
+    MirrorLog.debug("Replaying recorded observer notifications");
+    try {
+      await observersToNotify.notifyAll();
+    } catch (ex) {
+      MirrorLog.error("Error notifying Places observers", ex);
+    }
+
+    return changeRecords;
+  }
+
+  /**
+   * Discards the mirror contents. This is called when the user is node
+   * reassigned, disables the bookmarks engine, or signs out.
+   */
+  async reset() {
+    await this.db.executeBeforeShutdown(
+      "SyncedBookmarksMirror: reset",
+      async function(db) {
+        await db.executeTransaction(async function() {
+          await db.execute(`DELETE FROM meta`);
+          await db.execute(`DELETE FROM items`);
+          await db.execute(`DELETE FROM urls`);
+
+          // Since we need to reset the modified times for the syncable roots,
+          // we simply delete and recreate them.
+          await createMirrorRoots(db);
+        });
+      }
+    );
+  }
+
+  /**
+   * Fetches the GUIDs of all items in the remote tree that need to be merged
+   * into the local tree.
+   *
+   * @return {String[]}
+   *         Remotely changed GUIDs that need to be merged into Places.
+   */
+  async fetchUnmergedGuids() {
+    let rows = await this.db.execute(`SELECT guid FROM items WHERE needsMerge`);
+    return rows.map(row => row.getResultByName("guid"));
+  }
+
+  async storeRemoteBookmark(record, { needsMerge }) {
+    let guid = validateGuid(record.id);
+    if (!guid) {
+      MirrorLog.warn("Ignoring bookmark with invalid ID", record.id);
+      this.recordTelemetryEvent("mirror", "ignore", "bookmark",
+                                { why: "id" });
+      return;
+    }
+
+    let url = validateURL(record.bmkUri);
+    if (!url) {
+      MirrorLog.trace("Ignoring bookmark ${guid} with invalid URL ${url}",
+                      { guid, url: record.bmkUri });
+      this.recordTelemetryEvent("mirror", "ignore", "bookmark",
+                                { why: "url" });
+      return;
+    }
+
+    await this.maybeStoreRemoteURL(url);
+
+    let serverModified = determineServerModified(record);
+    let dateAdded = determineDateAdded(record);
+    let title = validateTitle(record.title);
+    let keyword = validateKeyword(record.keyword);
+    let description = validateDescription(record.description);
+    let loadInSidebar = record.loadInSidebar === true ? "1" : null;
+
+    await this.db.executeCached(`
+      REPLACE INTO items(guid, serverModified, needsMerge, kind,
+                         dateAdded, title, keyword,
+                         urlId, description, loadInSidebar)
+      VALUES(:guid, :serverModified, :needsMerge, :kind,
+             :dateAdded, NULLIF(:title, ""), :keyword,
+             (SELECT id FROM urls
+              WHERE hash = hash(:url) AND
+                    url = :url),
+             :description, :loadInSidebar)`,
+      { guid, serverModified, needsMerge,
+        kind: SyncedBookmarksMirror.KIND.BOOKMARK, dateAdded, title, keyword,
+        url: url.href, description, loadInSidebar });
+
+    let tags = record.tags;
+    if (tags && Array.isArray(tags)) {
+      for (let rawTag of tags) {
+        let tag = validateTag(rawTag);
+        if (!tag) {
+          continue;
+        }
+        await this.db.executeCached(`
+          INSERT INTO tags(itemId, tag)
+          SELECT id, :tag FROM items
+          WHERE guid = :guid`,
+          { tag, guid });
+      }
+    }
+  }
+
+  async storeRemoteQuery(record, { needsMerge }) {
+    let guid = validateGuid(record.id);
+    if (!guid) {
+      MirrorLog.warn("Ignoring query with invalid ID", record.id);
+      this.recordTelemetryEvent("mirror", "ignore", "query",
+                                { why: "id" });
+      return;
+    }
+
+    let url = validateURL(record.bmkUri);
+    if (!url) {
+      MirrorLog.trace("Ignoring query ${guid} with invalid URL ${url}",
+                      { guid, url: record.bmkUri });
+      this.recordTelemetryEvent("mirror", "ignore", "query",
+                                { why: "url" });
+      return;
+    }
+
+    await this.maybeStoreRemoteURL(url);
+
+    let serverModified = determineServerModified(record);
+    let dateAdded = determineDateAdded(record);
+    let title = validateTitle(record.title);
+    let tagFolderName = validateTag(record.folderName);
+    let description = validateDescription(record.description);
+    let smartBookmarkName = typeof record.queryId == "string" ?
+                            record.queryId : null;
+
+    await this.db.executeCached(`
+      REPLACE INTO items(guid, serverModified, needsMerge, kind,
+                         dateAdded, title, tagFolderName,
+                         urlId, description, smartBookmarkName)
+      VALUES(:guid, :serverModified, :needsMerge, :kind,
+             :dateAdded, NULLIF(:title, ""), :tagFolderName,
+             (SELECT id FROM urls
+              WHERE hash = hash(:url) AND
+                    url = :url),
+             :description, :smartBookmarkName)`,
+      { guid, serverModified, needsMerge,
+        kind: SyncedBookmarksMirror.KIND.QUERY, dateAdded, title, tagFolderName,
+        url: url.href, description, smartBookmarkName });
+  }
+
+  async storeRemoteFolder(record, { needsMerge }) {
+    let guid = validateGuid(record.id);
+    if (!guid) {
+      MirrorLog.warn("Ignoring folder with invalid ID", record.id);
+      this.recordTelemetryEvent("mirror", "ignore", "folder",
+                                { why: "id" });
+      return;
+    }
+    if (guid == PlacesUtils.bookmarks.rootGuid) {
+      // The Places root shouldn't be synced at all.
+      MirrorLog.warn("Ignoring Places root record", record.cleartext);
+      this.recordTelemetryEvent("mirror", "ignore", "folder",
+                                { why: "root" });
+      return;
+    }
+
+    let serverModified = determineServerModified(record);
+    let dateAdded = determineDateAdded(record);
+    let title = validateTitle(record.title);
+    let description = validateDescription(record.description);
+
+    await this.db.executeCached(`
+      REPLACE INTO items(guid, serverModified, needsMerge, kind,
+                         dateAdded, title, description)
+      VALUES(:guid, :serverModified, :needsMerge, :kind,
+             :dateAdded, NULLIF(:title, ""),
+             :description)`,
+      { guid, serverModified, needsMerge, kind: SyncedBookmarksMirror.KIND.FOLDER,
+        dateAdded, title, description });
+
+    let children = record.children;
+    if (children && Array.isArray(children)) {
+      for (let position = 0; position < children.length; ++position) {
+        let childRecordId = children[position];
+        let childGuid = validateGuid(childRecordId);
+        if (!childGuid) {
+          MirrorLog.warn("Ignoring child of folder ${parentGuid} with " +
+                         "invalid ID ${childRecordId}", { parentGuid: guid,
+                                                          childRecordId });
+          this.recordTelemetryEvent("mirror", "ignore", "child",
+                                    { why: "id" });
+          continue;
+        }
+        await this.db.executeCached(`
+          REPLACE INTO structure(guid, parentGuid, position)
+          VALUES(:childGuid, :parentGuid, :position)`,
+          { childGuid, parentGuid: guid, position });
+      }
+    }
+  }
+
+  async storeRemoteLivemark(record, { needsMerge }) {
+    let guid = validateGuid(record.id);
+    if (!guid) {
+      MirrorLog.warn("Ignoring livemark with invalid ID", record.id);
+      this.recordTelemetryEvent("mirror", "ignore", "livemark",
+                                { why: "id" });
+      return;
+    }
+
+    let feedURL = validateURL(record.feedUri);
+    if (!feedURL) {
+      MirrorLog.trace("Ignoring livemark ${guid} with invalid feed URL ${url}",
+                      { guid, url: record.feedUri });
+      this.recordTelemetryEvent("mirror", "ignore", "livemark",
+                                { why: "feed" });
+      return;
+    }
+
+    let serverModified = determineServerModified(record);
+    let dateAdded = determineDateAdded(record);
+    let title = validateTitle(record.title);
+    let description = validateDescription(record.description);
+    let siteURL = validateURL(record.siteUri);
+
+    await this.db.executeCached(`
+      REPLACE INTO items(guid, serverModified, needsMerge, kind, dateAdded,
+                         title, description, feedURL, siteURL)
+      VALUES(:guid, :serverModified, :needsMerge, :kind, :dateAdded,
+             NULLIF(:title, ""), :description, :feedURL, :siteURL)`,
+      { guid, serverModified, needsMerge,
+        kind: SyncedBookmarksMirror.KIND.LIVEMARK,
+        dateAdded, title, description, feedURL: feedURL.href,
+        siteURL: siteURL ? siteURL.href : null });
+  }
+
+  async storeRemoteSeparator(record, { needsMerge }) {
+    let guid = validateGuid(record.id);
+    if (!guid) {
+      MirrorLog.warn("Ignoring separator with invalid ID", record.id);
+      this.recordTelemetryEvent("mirror", "ignore", "separator",
+                                { why: "id" });
+      return;
+    }
+
+    let serverModified = determineServerModified(record);
+    let dateAdded = determineDateAdded(record);
+
+    await this.db.executeCached(`
+      REPLACE INTO items(guid, serverModified, needsMerge, kind,
+                         dateAdded)
+      VALUES(:guid, :serverModified, :needsMerge, :kind,
+             :dateAdded)`,
+      { guid, serverModified, needsMerge, kind: SyncedBookmarksMirror.KIND.SEPARATOR,
+        dateAdded });
+  }
+
+  async storeRemoteTombstone(record, { needsMerge }) {
+    let guid = validateGuid(record.id);
+    if (!guid) {
+      MirrorLog.warn("Ignoring tombstone with invalid ID", record.id);
+      this.recordTelemetryEvent("mirror", "ignore", "tombstone",
+                                { why: "id" });
+      return;
+    }
+
+    if (PlacesUtils.bookmarks.userContentRoots.includes(guid)) {
+      MirrorLog.warn("Ignoring tombstone for syncable root", guid);
+      this.recordTelemetryEvent("mirror", "ignore", "tombstone",
+                                { why: "root" });
+      return;
+    }
+
+    await this.db.executeCached(`
+      REPLACE INTO items(guid, serverModified, needsMerge, isDeleted)
+      VALUES(:guid, :serverModified, :needsMerge, 1)`,
+      { guid, serverModified: determineServerModified(record), needsMerge });
+  }
+
+  async maybeStoreRemoteURL(url) {
+    await this.db.executeCached(`
+      INSERT OR IGNORE INTO urls(guid, url, hash, revHost)
+      VALUES(IFNULL((SELECT guid FROM urls
+                     WHERE hash = hash(:url) AND
+                                  url = :url),
+                    GENERATE_GUID()), :url, hash(:url), :revHost)`,
+      { url: url.href, revHost: PlacesUtils.getReversedHost(url) });
+  }
+
+  async fetchRemoteOrphans() {
+    let infos = {
+      missingParents: [],
+      missingChildren: [],
+    };
+
+    let orphanRows = await this.db.execute(`
+      SELECT v.guid AS guid, 1 AS missingParent, 0 AS missingChild
+      FROM items v
+      LEFT JOIN structure s ON s.guid = v.guid
+      WHERE NOT v.isDeleted AND
+            s.guid IS NULL
+      UNION ALL
+      SELECT s.guid AS guid, 0 AS missingParent, 1 AS missingChild
+      FROM structure s
+      LEFT JOIN items v ON v.guid = s.guid
+      WHERE v.guid IS NULL`);
+
+    for (let row of orphanRows) {
+      let guid = row.getResultByName("guid");
+      let missingParent = row.getResultByName("missingParent");
+      if (missingParent) {
+        infos.missingParents.push(guid);
+      }
+      let missingChild = row.getResultByName("missingChild");
+      if (missingChild) {
+        infos.missingChildren.push(guid);
+      }
+    }
+
+    return infos;
+  }
+
+  /**
+   * Builds a fully rooted, consistent tree from the items and tombstones in the
+   * mirror.
+   *
+   * @param  {Number} remoteTimeSeconds
+   *         The current server time, in seconds.
+   * @return {BookmarkTree}
+   *         The remote bookmark tree.
+   */
+  async fetchRemoteTree(remoteTimeSeconds) {
+    let remoteTree = new BookmarkTree(BookmarkNode.root());
+    let startTime = Cu.now();
+
+    // First, build a flat mapping of parents to children. The `LEFT JOIN`
+    // includes items orphaned by an interrupted upload on another device.
+    // We keep the orphans in "unfiled" until the other device returns and
+    // uploads the missing parent.
+    let itemRows = await this.db.execute(`
+      SELECT v.guid, IFNULL(s.parentGuid, :unfiledGuid) AS parentGuid,
+             IFNULL(s.position, -1) AS position, v.serverModified, v.kind,
+             v.needsMerge
+      FROM items v
+      LEFT JOIN structure s ON s.guid = v.guid
+      WHERE NOT v.isDeleted AND
+            v.guid <> :rootGuid
+      ORDER BY parentGuid, position = -1, position, v.guid`,
+      { rootGuid: PlacesUtils.bookmarks.rootGuid,
+        unfiledGuid: PlacesUtils.bookmarks.unfiledGuid });
+
+    let pseudoTree = new Map();
+    for (let row of itemRows) {
+      let parentGuid = row.getResultByName("parentGuid");
+      let node = BookmarkNode.fromRemoteRow(row, remoteTimeSeconds);
+      if (pseudoTree.has(parentGuid)) {
+        let nodes = pseudoTree.get(parentGuid);
+        nodes.push(node);
+      } else {
+        pseudoTree.set(parentGuid, [node]);
+      }
+    }
+
+    // Second, build a complete tree from the pseudo-tree. We could do these
+    // two steps in SQL, but it's extremely inefficient. An equivalent
+    // recursive query, with joins in the base and recursive clauses, takes
+    // 10 seconds for a mirror with 5k items. Building the pseudo-tree and
+    // the pseudo-tree and recursing in JS takes 30ms for 5k items.
+    inflateTree(remoteTree, pseudoTree, PlacesUtils.bookmarks.rootGuid);
+
+    // Note tombstones for remotely deleted items.
+    let tombstoneRows = await this.db.execute(`
+      SELECT guid FROM items
+      WHERE isDeleted AND
+            needsMerge`);
+
+    for (let row of tombstoneRows) {
+      let guid = row.getResultByName("guid");
+      remoteTree.noteDeleted(guid);
+    }
+
+    let elapsedTime = Cu.now() - startTime;
+    let totalRows = itemRows.length + tombstoneRows.length;
+    this.recordTelemetryEvent("mirror", "fetch", "remoteTree",
+                              { time: String(elapsedTime),
+                                count: String(totalRows) });
+
+    return remoteTree;
+  }
+
+  /**
+   * Fetches content info for all items in the mirror that changed since the
+   * last sync and don't exist locally.
+   *
+   * @return {Map.<String, BookmarkContent>}
+   *         Changed items in the mirror that don't exist in Places, keyed by
+   *         their GUIDs.
+   */
+  async fetchNewRemoteContents() {
+    let newRemoteContents = new Map();
+    let startTime = Cu.now();
+
+    let rows = await this.db.execute(`
+      SELECT v.guid, IFNULL(v.title, "") AS title, u.url, v.smartBookmarkName,
+             IFNULL(s.position, -1) AS position
+      FROM items v
+      LEFT JOIN urls u ON u.id = v.urlId
+      LEFT JOIN structure s ON s.guid = v.guid
+      LEFT JOIN moz_bookmarks b ON b.guid = v.guid
+      WHERE NOT v.isDeleted AND
+            v.needsMerge AND
+            b.guid IS NULL AND
+            IFNULL(s.parentGuid, :unfiledGuid) <> :rootGuid`,
+      { unfiledGuid: PlacesUtils.bookmarks.unfiledGuid,
+        rootGuid: PlacesUtils.bookmarks.rootGuid });
+
+    for (let row of rows) {
+      let guid = row.getResultByName("guid");
+      let content = BookmarkContent.fromRow(row);
+      newRemoteContents.set(guid, content);
+    }
+
+    let elapsedTime = Cu.now() - startTime;
+    this.recordTelemetryEvent("mirror", "fetch", "newRemoteContents",
+                              { time: String(elapsedTime),
+                                count: String(rows.length) });
+
+    return newRemoteContents;
+  }
+
+  /**
+   * Builds a fully rooted, consistent tree from the items and tombstones in
+   * Places.
+   *
+   * @param  {Number} localTimeSeconds
+   *         The current local time, in seconds.
+   * @return {BookmarkTree}
+   *         The local bookmark tree.
+   */
+  async fetchLocalTree(localTimeSeconds) {
+    let localTree = new BookmarkTree(BookmarkNode.root());
+    let startTime = Cu.now();
+
+    // This unsightly query collects all descendants and maps their Places types
+    // to the Sync record kinds. We start with the roots, and work our way down.
+    // The list of roots in `syncedItems` should be updated if we add new
+    // syncable roots to Places.
+    let itemRows = await this.db.execute(`
+      WITH RECURSIVE
+      syncedItems(id, level) AS (
+        SELECT b.id, 0 AS level FROM moz_bookmarks b
+        WHERE b.guid IN (:menuGuid, :toolbarGuid, :unfiledGuid, :mobileGuid)
+        UNION ALL
+        SELECT b.id, s.level + 1 AS level FROM moz_bookmarks b
+        JOIN syncedItems s ON s.id = b.parent
+      )
+      SELECT b.id, b.guid, p.guid AS parentGuid,
+             /* Map Places item types to Sync record kinds. */
+             (CASE b.type
+                WHEN :bookmarkType THEN (
+                  CASE SUBSTR((SELECT h.url FROM moz_places h
+                               WHERE h.id = b.fk), 1, 6)
+                  /* Queries are bookmarks with a "place:" URL scheme. */
+                  WHEN 'place:' THEN :queryKind
+                  ELSE :bookmarkKind END)
+                WHEN :folderType THEN (
+                  CASE WHEN EXISTS(
+                    /* Livemarks are folders with a feed URL annotation. */
+                    SELECT 1 FROM moz_items_annos a
+                    JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+                    WHERE a.item_id = b.id AND
+                          n.name = :feedURLAnno
+                  ) THEN :livemarkKind
+                  ELSE :folderKind END)
+                ELSE :separatorKind END) AS kind,
+             b.lastModified, b.syncChangeCounter
+      FROM moz_bookmarks b
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN syncedItems s ON s.id = b.id
+      ORDER BY s.level, b.parent, b.position`,
+      { menuGuid: PlacesUtils.bookmarks.menuGuid,
+        toolbarGuid: PlacesUtils.bookmarks.toolbarGuid,
+        unfiledGuid: PlacesUtils.bookmarks.unfiledGuid,
+        mobileGuid: PlacesUtils.bookmarks.mobileGuid,
+        bookmarkType: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        queryKind: SyncedBookmarksMirror.KIND.QUERY,
+        bookmarkKind: SyncedBookmarksMirror.KIND.BOOKMARK,
+        folderType: PlacesUtils.bookmarks.TYPE_FOLDER,
+        feedURLAnno: PlacesUtils.LMANNO_FEEDURI,
+        livemarkKind: SyncedBookmarksMirror.KIND.LIVEMARK,
+        folderKind: SyncedBookmarksMirror.KIND.FOLDER,
+        separatorKind: SyncedBookmarksMirror.KIND.SEPARATOR });
+
+    for (let row of itemRows) {
+      let parentGuid = row.getResultByName("parentGuid");
+      let node = BookmarkNode.fromLocalRow(row, localTimeSeconds);
+      localTree.insert(parentGuid, node);
+    }
+
+    // Note tombstones for locally deleted items.
+    let tombstoneRows = await this.db.execute(`
+      SELECT guid FROM moz_bookmarks_deleted`);
+
+    for (let row of tombstoneRows) {
+      let guid = row.getResultByName("guid");
+      localTree.noteDeleted(guid);
+    }
+
+    let elapsedTime = Cu.now() - startTime;
+    let totalRows = itemRows.length + tombstoneRows.length;
+    this.recordTelemetryEvent("mirror", "fetch", "localTree",
+                              { time: String(elapsedTime),
+                                count: String(totalRows) });
+
+    return localTree;
+  }
+
+  /**
+   * Fetches content info for all NEW local items that don't exist in the
+   * mirror. We'll try to dedupe them to changed items with similar contents and
+   * different GUIDs in the mirror.
+   *
+   * @return {Map.<String, BookmarkContent>}
+   *         New items in Places that don't exist in the mirror, keyed by their
+   *         GUIDs.
+   */
+  async fetchNewLocalContents() {
+    let newLocalContents = new Map();
+    let startTime = Cu.now();
+
+    let rows = await this.db.execute(`
+      SELECT b.guid, IFNULL(b.title, "") AS title, h.url,
+             (SELECT a.content FROM moz_items_annos a
+              JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+              WHERE a.item_id = b.id AND
+                    n.name = :smartBookmarkAnno) AS smartBookmarkName,
+             b.position
+      FROM moz_bookmarks b
+      JOIN moz_bookmarks p ON p.id = b.parent
+      LEFT JOIN moz_places h ON h.id = b.fk
+      LEFT JOIN items v ON v.guid = b.guid
+      WHERE v.guid IS NULL AND
+            p.guid <> :rootGuid AND
+            b.syncStatus <> :syncStatus`,
+      { smartBookmarkAnno: PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO,
+        rootGuid: PlacesUtils.bookmarks.rootGuid,
+        syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL });
+
+    for (let row of rows) {
+      let guid = row.getResultByName("guid");
+      let content = BookmarkContent.fromRow(row);
+      newLocalContents.set(guid, content);
+    }
+
+    let elapsedTime = Cu.now() - startTime;
+    this.recordTelemetryEvent("mirror", "fetch", "newLocalContents",
+                              { time: String(elapsedTime),
+                                count: String(rows.length) });
+
+    return newLocalContents;
+  }
+
+  /**
+   * Builds a temporary table with the merge states of all nodes in the merged
+   * tree, rewrites tag queries, and updates Places to match the merged tree.
+   *
+   * Conceptually, we examine the merge state of each item, and either keep the
+   * complete local state, take the complete remote state, or apply a new
+   * structure state and flag the item for reupload.
+   *
+   * Note that we update Places and flag items *before* upload, while iOS
+   * updates the mirror *after* a successful upload. This simplifies our
+   * implementation, though we lose idempotent merges. If upload is interrupted,
+   * the next sync won't distinguish between new merge states from the previous
+   * sync, and local changes. Since this is how Desktop behaved before
+   * structured application, that's OK. In the future, we can make this more
+   * like iOS.
+   *
+   * @param {MergedBookmarkNode} mergedRoot
+   *        The root of the merged bookmark tree.
+   * @param {Object[]} localDeletions
+   *        `{ guid, level }` tuples for items to remove from Places and flag as
+   *        merged.
+   * @param {String[]} remoteDeletions
+   *        Remotely deleted GUIDs that should be flagged as merged.
+   */
+  async updateLocalItemsInPlaces(mergedRoot, localDeletions, remoteDeletions) {
+    MirrorLog.debug("Setting up merge states table");
+    let mergeStatesParams = Array.from(mergedRoot.mergeStatesParams());
+    if (mergeStatesParams.length) {
+      await this.db.execute(`
+        INSERT INTO mergeStates(localGuid, mergedGuid, parentGuid, level,
+                                position, valueState, structureState)
+        VALUES(IFNULL(:localGuid, :mergedGuid), :mergedGuid, :parentGuid,
+               :level, :position, :valueState, :structureState)`,
+        mergeStatesParams);
+    }
+
+    MirrorLog.debug("Rewriting tag queries in mirror");
+    await this.rewriteRemoteTagQueries();
+
+    MirrorLog.debug("Inserting new URLs into Places");
+    await this.db.execute(`
+      INSERT OR IGNORE INTO moz_places(url, url_hash, rev_host, hidden,
+                                       frecency, guid)
+      SELECT u.url, u.hash, u.revHost, 0,
+             (CASE SUBSTR(u.url, 1, 6) WHEN 'place:' THEN 0 ELSE -1 END),
+             IFNULL(h.guid, u.guid)
+      FROM items v
+      JOIN urls u ON u.id = v.urlId
+      LEFT JOIN moz_places h ON h.url_hash = u.hash AND
+                                h.url = u.url
+      JOIN mergeStates r ON r.mergedGuid = v.guid
+      WHERE r.valueState = :valueState`,
+      { valueState: BookmarkMergeState.TYPE.REMOTE });
+    await this.db.execute(`DELETE FROM moz_updatehostsinsert_temp`);
+
+    // Deleting from `newRemoteItems` fires the `insertNewLocalItems` and
+    // `updateExistingLocalItems` triggers.
+    MirrorLog.debug("Updating value states for local bookmarks");
+    await this.db.execute(`DELETE FROM newRemoteItems`);
+
+    // Update the structure. The mirror stores structure info in a separate
+    // table, like iOS, while Places stores structure info on children. We don't
+    // check the parent's merge state here because our merged tree might
+    // diverge from the server if we're missing children, or moved children
+    // without parents to "unfiled". In that case, we *don't* want to reupload
+    // the new local structure to the server.
+    MirrorLog.debug("Updating structure states for local bookmarks");
+    await this.db.execute(`DELETE FROM newRemoteStructure`);
+
+    MirrorLog.debug("Removing remotely deleted items from Places");
+    for (let chunk of PlacesSyncUtils.chunkArray(localDeletions,
+      SQLITE_MAX_VARIABLE_NUMBER)) {
+
+      let guids = chunk.map(({ guid }) => guid);
+
+      // Record item removed notifications.
+      await this.db.execute(`
+        WITH
+        guidsWithLevelsToDelete(guid, level) AS (
+          VALUES ${chunk.map(({ level }) => `(?, ${level})`).join(",")}
+        )
+        INSERT INTO itemsRemoved(itemId, parentId, position, type, placeId,
+                                 guid, parentGuid, level)
+        SELECT b.id, b.parent, b.position, b.type, b.fk, b.guid, p.guid,
+               o.level
+        FROM moz_bookmarks b
+        JOIN moz_bookmarks p ON p.id = b.parent
+        JOIN guidsWithLevelsToDelete o ON o.guid = b.guid`,
+        guids);
+
+      let guidsParams = new Array(guids.length).fill("?").join(",");
+
+      // Recalculate frecencies.
+      await this.db.execute(`
+        UPDATE moz_places SET
+          frecency = -1
+        WHERE id IN (SELECT fk FROM moz_bookmarks
+                     WHERE guid IN (${guidsParams}))`,
+        guids);
+
+      // Remove annos for the deleted items.
+      await this.db.execute(`
+        DELETE FROM moz_items_annos
+        WHERE item_id = (SELECT id FROM moz_bookmarks
+                         WHERE guid IN (${guidsParams}))`,
+        guids);
+
+      // Remove any local tombstones for deleted items.
+      await this.db.execute(`
+        DELETE FROM moz_bookmarks_deleted
+        WHERE guid IN (${guidsParams})`,
+        guids);
+
+      await this.db.execute(`
+        DELETE FROM moz_bookmarks WHERE guid IN (${guidsParams})`,
+        guids);
+
+      // Flag locally deleted items as merged.
+      await this.db.execute(`
+        UPDATE items SET
+          needsMerge = 0
+        WHERE needsMerge AND
+              guid IN (${guidsParams})`,
+        guids);
+    }
+
+    MirrorLog.debug("Flagging remotely deleted items as merged");
+    for (let chunk of PlacesSyncUtils.chunkArray(remoteDeletions,
+      SQLITE_MAX_VARIABLE_NUMBER)) {
+
+      await this.db.execute(`
+        UPDATE items SET
+          needsMerge = 0
+        WHERE needsMerge AND
+              guid IN (${new Array(chunk.length).fill("?").join(",")})`,
+        chunk);
+    }
+  }
+
+  /**
+   * Creates local tag folders mentioned in remotely changed tag queries, then
+   * rewrites the query URLs in the mirror to point to the new local folders.
+   *
+   * This can be removed once bug 1293445 lands.
+   */
+  async rewriteRemoteTagQueries() {
+    // Create local tag folders that don't already exist. This fires the
+    // `tagLocalPlace` trigger.
+    await this.db.execute(`
+      INSERT INTO localTags(tag)
+      SELECT v.tagFolderName FROM items v
+      JOIN mergeStates r ON r.mergedGuid = v.guid
+      WHERE r.valueState = :valueState AND
+            v.tagFolderName NOT NULL`,
+      { valueState: BookmarkMergeState.TYPE.REMOTE });
+
+    let queryRows = await this.db.execute(`
+      SELECT u.id AS urlId, u.url, b.id AS newTagFolderId FROM urls u
+      JOIN items v ON v.urlId = u.id
+      JOIN mergeStates r ON r.mergedGuid = v.guid
+      JOIN moz_bookmarks b ON b.title = v.tagFolderName
+      JOIN moz_bookmarks p ON p.id = b.parent
+      WHERE p.guid = :tagsGuid AND
+            r.valueState = :valueState AND
+            v.kind = :queryKind AND
+            v.tagFolderName NOT NULL`,
+      { tagsGuid: PlacesUtils.bookmarks.tagsGuid,
+        valueState: BookmarkMergeState.TYPE.REMOTE,
+        queryKind: SyncedBookmarksMirror.KIND.QUERY });
+
+    let urlsParams = [];
+    for (let row of queryRows) {
+      let url = new URL(row.getResultByName("url"));
+      let tagQueryParams = new URLSearchParams(url.pathname);
+      let type = Number(tagQueryParams.get("type"));
+      if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
+        continue;
+      }
+
+      // Rewrite the query URL to point to the new folder.
+      let newTagFolderId = row.getResultByName("newTagFolderId");
+      tagQueryParams.set("folder", newTagFolderId);
+
+      let newURLHref = url.protocol + tagQueryParams;
+      urlsParams.push({
+        urlId: row.getResultByName("urlId"),
+        url: newURLHref,
+      });
+    }
+
+    if (urlsParams.length) {
+      await this.db.execute(`
+        UPDATE urls SET
+          url = :url,
+          hash = hash(:url)
+        WHERE id = :urlId`,
+        urlsParams);
+    }
+  }
+
+  /**
+   * Records Places observer notifications for removed, added, moved, and
+   * changed items.
+   *
+   * @param {BookmarkObserverRecorder} observersToNotify
+   */
+  async noteObserverChanges(observersToNotify) {
+    MirrorLog.debug("Recording observer notifications for removed items");
+    // `ORDER BY v.level DESC` sorts deleted children before parents, to ensure
+    // that we update caches in the correct order (bug 1297941). We also order
+    // by parent and position so that the notifications are well-ordered for
+    // tests.
+    let removedItemRows = await this.db.execute(`
+      SELECT v.itemId AS id, v.parentId, v.parentGuid, v.position, v.type,
+             h.url, v.guid, v.isUntagging
+      FROM itemsRemoved v
+      LEFT JOIN moz_places h ON h.id = v.placeId
+      ORDER BY v.level DESC, v.parentId, v.position`);
+    for (let row of removedItemRows) {
+      let info = {
+        id: row.getResultByName("id"),
+        parentId: row.getResultByName("parentId"),
+        position: row.getResultByName("position"),
+        type: row.getResultByName("type"),
+        urlHref: row.getResultByName("url"),
+        guid: row.getResultByName("guid"),
+        parentGuid: row.getResultByName("parentGuid"),
+        isUntagging: row.getResultByName("isUntagging"),
+      };
+      observersToNotify.noteItemRemoved(info);
+    }
+
+    MirrorLog.debug("Recording observer notifications for changed GUIDs");
+    let changedGuidRows = await this.db.execute(`
+      SELECT b.id, b.lastModified, b.type, b.guid AS newGuid,
+             c.oldGuid, p.id AS parentId, p.guid AS parentGuid
+      FROM guidsChanged c
+      JOIN moz_bookmarks b ON b.id = c.itemId
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN mergeStates r ON r.mergedGuid = b.guid
+      ORDER BY r.level, p.id, b.position`);
+    for (let row of changedGuidRows) {
+      let info = {
+        id: row.getResultByName("id"),
+        lastModified: row.getResultByName("lastModified"),
+        type: row.getResultByName("type"),
+        newGuid: row.getResultByName("newGuid"),
+        oldGuid: row.getResultByName("oldGuid"),
+        parentId: row.getResultByName("parentId"),
+        parentGuid: row.getResultByName("parentGuid"),
+      };
+      observersToNotify.noteGuidChanged(info);
+    }
+
+    MirrorLog.debug("Recording observer notifications for new items");
+    // We `LEFT JOIN` to `mergeStates` because `itemsAdded` may include tag
+    // folders and entries, which are not part of the merged tree structure, and
+    // so don't exist in `mergeStates`.
+    let newItemRows = await this.db.execute(`
+      SELECT b.id, p.id AS parentId, b.position, b.type, h.url,
+             IFNULL(b.title, "") AS title, b.dateAdded, b.guid,
+             p.guid AS parentGuid, n.isTagging
+      FROM itemsAdded n
+      JOIN moz_bookmarks b ON b.guid = n.guid
+      JOIN moz_bookmarks p ON p.id = b.parent
+      LEFT JOIN moz_places h ON h.id = b.fk
+      LEFT JOIN mergeStates r ON r.mergedGuid = b.guid
+      ORDER BY r.level, p.id, b.position`);
+    for (let row of newItemRows) {
+      let info = {
+        id: row.getResultByName("id"),
+        parentId: row.getResultByName("parentId"),
+        position: row.getResultByName("position"),
+        type: row.getResultByName("type"),
+        urlHref: row.getResultByName("url"),
+        title: row.getResultByName("title"),
+        dateAdded: row.getResultByName("dateAdded"),
+        guid: row.getResultByName("guid"),
+        parentGuid: row.getResultByName("parentGuid"),
+        isTagging: row.getResultByName("isTagging"),
+      };
+      observersToNotify.noteItemAdded(info);
+    }
+
+    MirrorLog.debug("Recording observer notifications for moved items");
+    let movedItemRows = await this.db.execute(`
+      SELECT b.id, b.guid, b.type, p.id AS newParentId, c.oldParentId,
+             p.guid AS newParentGuid, c.oldParentGuid,
+             b.position AS newPosition, c.oldPosition
+      FROM itemsMoved c
+      JOIN moz_bookmarks b ON b.id = c.itemId
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN mergeStates r ON r.mergedGuid = b.guid
+      ORDER BY r.level, newParentId, newPosition`);
+    for (let row of movedItemRows) {
+      let info = {
+        id: row.getResultByName("id"),
+        guid: row.getResultByName("guid"),
+        type: row.getResultByName("type"),
+        newParentId: row.getResultByName("newParentId"),
+        oldParentId: row.getResultByName("oldParentId"),
+        newParentGuid: row.getResultByName("newParentGuid"),
+        oldParentGuid: row.getResultByName("oldParentGuid"),
+        newPosition: row.getResultByName("newPosition"),
+        oldPosition: row.getResultByName("oldPosition"),
+      };
+      observersToNotify.noteItemMoved(info);
+    }
+
+    MirrorLog.debug("Recording observer notifications for changed items");
+    let changedItemRows = await this.db.execute(`
+      SELECT b.id, b.guid, b.lastModified, b.type,
+             IFNULL(b.title, "") AS newTitle,
+             IFNULL(c.oldTitle, "") AS oldTitle,
+             h.url AS newURL, i.url AS oldURL,
+             p.id AS parentId, p.guid AS parentGuid
+      FROM itemsChanged c
+      JOIN moz_bookmarks b ON b.id = c.itemId
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN mergeStates r ON r.mergedGuid = b.guid
+      LEFT JOIN moz_places h ON h.id = b.fk
+      LEFT JOIN moz_places i ON i.id = c.oldPlaceId
+      ORDER BY r.level, p.id, b.position`);
+    for (let row of changedItemRows) {
+      let info = {
+        id: row.getResultByName("id"),
+        guid: row.getResultByName("guid"),
+        lastModified: row.getResultByName("lastModified"),
+        type: row.getResultByName("type"),
+        newTitle: row.getResultByName("newTitle"),
+        oldTitle: row.getResultByName("oldTitle"),
+        newURLHref: row.getResultByName("newURL"),
+        oldURLHref: row.getResultByName("oldURL"),
+        parentId: row.getResultByName("parentId"),
+        parentGuid: row.getResultByName("parentGuid"),
+      };
+      observersToNotify.noteItemChanged(info);
+    }
+
+    MirrorLog.debug("Recording observer notifications for changed annos");
+    let annoRows = await this.db.execute(`
+      SELECT itemId, annoName, wasRemoved FROM annosChanged
+      ORDER BY itemId`);
+    for (let row of annoRows) {
+      let id = row.getResultByName("itemId");
+      let name = row.getResultByName("annoName");
+      if (row.getResultByName("wasRemoved")) {
+        observersToNotify.noteAnnoRemoved(id, name);
+      } else {
+        observersToNotify.noteAnnoSet(id, name);
+      }
+    }
+
+    MirrorLog.debug("Recording notifications for changed keywords");
+    // `ORDER BY k.ROWID` replays additions and deletions for the same keyword
+    // or URL in order.
+    let changedKeywordRows = await this.db.execute(`
+      SELECT b.id, IFNULL(k.keyword, "") AS keyword, b.lastModified, b.type,
+             p.id AS parentId, b.guid, p.guid AS parentGuid, h.url
+      FROM keywordsChanged k
+      JOIN moz_bookmarks b ON b.id = k.itemId
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN moz_places h ON h.id = k.placeId
+      ORDER BY k.ROWID`);
+    for (let row of changedKeywordRows) {
+      let info = {
+        id: row.getResultByName("id"),
+        keyword: row.getResultByName("keyword"),
+        lastModified: row.getResultByName("lastModified"),
+        type: row.getResultByName("type"),
+        parentId: row.getResultByName("parentId"),
+        guid: row.getResultByName("guid"),
+        parentGuid: row.getResultByName("parentGuid"),
+        urlHref: row.getResultByName("url"),
+      };
+      observersToNotify.noteKeywordChanged(info);
+    }
+  }
+
+  /**
+   * Stores a snapshot of all locally changed items in a temporary table for
+   * upload. This is called from within the merge transaction, to ensure that
+   * structure changes made during the sync don't cause us to upload an
+   * inconsistent tree.
+   *
+   * For an example of why we use a temporary table instead of reading directly
+   * from Places, consider a user adding a bookmark, then changing its parent
+   * folder. We first add the bookmark to the default folder, bump the change
+   * counter of the new bookmark and the default folder, then trigger a sync.
+   * Depending on how quickly the user picks the new parent, we might upload
+   * a record for the default folder, commit the move, then upload the bookmark.
+   * We'll still upload the new parent on the next sync, but, in the meantime,
+   * we've introduced a parent-child disagreement. This can also happen if the
+   * user moves many items between folders.
+   *
+   * Conceptually, `itemsToUpload` is a transient "view" of locally changed
+   * items. The change counter in Places is the persistent record of items that
+   * we need to upload, so, if upload is interrupted or fails, we'll stage the
+   * items again on the next sync.
+   */
+  async stageItemsToUpload() {
+    // Stage all locally changed items for upload, along with any remotely
+    // changed records with older local creation dates. These are tracked
+    // "weakly", in the in-memory table only. If the upload is interrupted
+    // or fails, we won't reupload the record on the next sync.
+    await this.db.execute(`
+      WITH RECURSIVE
+      syncedItems(id, level) AS (
+        SELECT b.id, 0 AS level FROM moz_bookmarks b
+        WHERE b.guid IN (:menuGuid, :toolbarGuid, :unfiledGuid, :mobileGuid)
+        UNION ALL
+        SELECT b.id, s.level + 1 AS level FROM moz_bookmarks b
+        JOIN syncedItems s ON s.id = b.parent
+      ),
+      annos(itemId, name, content) AS (
+        SELECT a.item_id, n.name, a.content FROM moz_items_annos a
+        JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+      )
+      INSERT INTO itemsToUpload(guid, syncChangeCounter, parentGuid,
+                                parentTitle, dateAdded, type, title, isQuery,
+                                url, tags, description, loadInSidebar,
+                                smartBookmarkName, keyword, feedURL, siteURL,
+                                position)
+      SELECT b.guid, b.syncChangeCounter, p.guid, p.title, b.dateAdded, b.type,
+             b.title, IFNULL(SUBSTR(h.url, 1, 6) = 'place:', 0), h.url,
+             (SELECT GROUP_CONCAT(t.title, ',') FROM moz_bookmarks e
+              JOIN moz_bookmarks t ON t.id = e.parent
+              JOIN moz_bookmarks r ON r.id = t.parent
+              WHERE r.guid = :tagsGuid AND
+                    e.fk = h.id),
+             (SELECT content FROM annos WHERE itemId = b.id AND
+                                              name = :descriptionAnno),
+             IFNULL((SELECT content FROM annos WHERE itemId = b.id AND
+                                                     name = :sidebarAnno), 0),
+             (SELECT content FROM annos WHERE itemId = b.id AND
+                                              name = :smartBookmarkAnno),
+             (SELECT keyword FROM moz_keywords WHERE place_id = h.id),
+             (SELECT content FROM annos WHERE itemId = b.id AND
+                                              name = :feedURLAnno),
+             (SELECT content FROM annos WHERE itemId = b.id AND
+                                              name = :siteURLAnno),
+             b.position
+      FROM moz_bookmarks b
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN syncedItems s ON s.id = b.id
+      LEFT JOIN moz_places h ON h.id = b.fk
+      JOIN mergeStates r ON r.mergedGuid = b.guid
+      LEFT JOIN items v ON v.guid = r.mergedGuid
+      WHERE b.syncChangeCounter >= 1 OR
+            (r.valueState = :valueState AND
+              b.dateAdded < v.dateAdded)`,
+      { menuGuid: PlacesUtils.bookmarks.menuGuid,
+        toolbarGuid: PlacesUtils.bookmarks.toolbarGuid,
+        unfiledGuid: PlacesUtils.bookmarks.unfiledGuid,
+        mobileGuid: PlacesUtils.bookmarks.mobileGuid,
+        tagsGuid: PlacesUtils.bookmarks.tagsGuid,
+        descriptionAnno: PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO,
+        sidebarAnno: PlacesSyncUtils.bookmarks.SIDEBAR_ANNO,
+        smartBookmarkAnno: PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO,
+        feedURLAnno: PlacesUtils.LMANNO_FEEDURI,
+        siteURLAnno: PlacesUtils.LMANNO_SITEURI,
+        valueState: BookmarkMergeState.TYPE.REMOTE });
+
+    // Record tag folder names for tag queries. Parsing query URLs one by one
+    // is inefficient, but queries aren't common today, and we can remove this
+    // logic entirely once bug 1293445 lands.
+    let queryRows = await this.db.execute(`
+      SELECT guid, url FROM itemsToUpload
+      WHERE isQuery`);
+
+    let tagFolderNameParams = [];
+    for (let row of queryRows) {
+      let url = new URL(row.getResultByName("url"));
+      let tagQueryParams = new URLSearchParams(url.pathname);
+      let type = Number(tagQueryParams.get("type"));
+      if (type == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
+        continue;
+      }
+      let tagFolderId = Number(tagQueryParams.get("folder"));
+      tagFolderNameParams.push({
+        guid: row.getResultByName("guid"),
+        tagFolderId,
+        folderType: PlacesUtils.bookmarks.TYPE_FOLDER,
+      });
+    }
+
+    if (tagFolderNameParams.length) {
+      await this.db.execute(`
+        UPDATE itemsToUpload SET
+          tagFolderName = (SELECT b.title FROM moz_bookmarks b
+                           WHERE b.id = :tagFolderId AND
+                                 b.type = :folderType)
+        WHERE guid = :guid`);
+    }
+
+    // Record the child GUIDs of locally changed folders, which we use to
+    // populate the `children` array in the record.
+    await this.db.execute(`
+      INSERT INTO structureToUpload(guid, parentGuid, position)
+      SELECT b.guid, p.guid, b.position FROM moz_bookmarks b
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN itemsToUpload o ON o.guid = p.guid`);
+
+    // Finally, stage tombstones for deleted items. Ignore conflicts if we have
+    // tombstones for undeleted items; Places Maintenance should clean these up.
+    await this.db.execute(`
+      INSERT OR IGNORE INTO itemsToUpload(guid, syncChangeCounter, isDeleted,
+                                          dateAdded)
+      SELECT guid, 1, 1, dateRemoved FROM moz_bookmarks_deleted`);
+  }
+
+  /**
+   * Inflates Sync records for all staged outgoing items.
+   *
+   * @return {Object.<String, BookmarkChangeRecord>}
+   *         A changeset containing Sync record cleartexts for outgoing items
+   *         and tombstones, keyed by their Sync record IDs.
+   */
+  async fetchLocalChangeRecords() {
+    let changeRecords = {};
+
+    let itemRows = await this.db.execute(`
+      SELECT syncChangeCounter, guid, isDeleted, type, isQuery,
+             smartBookmarkName, IFNULL(tagFolderName, "") AS tagFolderName,
+             loadInSidebar, keyword, tags, url, IFNULL(title, "") AS title,
+             description, feedURL, siteURL, position, parentGuid,
+             IFNULL(parentTitle, "") AS parentTitle, dateAdded
+      FROM itemsToUpload`);
+
+    for (let row of itemRows) {
+      let syncChangeCounter = row.getResultByName("syncChangeCounter");
+
+      let guid = row.getResultByName("guid");
+      let recordId = PlacesSyncUtils.bookmarks.guidToRecordId(guid);
+
+      // Tombstones don't carry additional properties.
+      let isDeleted = row.getResultByName("isDeleted");
+      if (isDeleted) {
+        changeRecords[recordId] = new BookmarkChangeRecord(syncChangeCounter, {
+          id: recordId,
+          deleted: true,
+        });
+        continue;
+      }
+
+      let parentGuid = row.getResultByName("parentGuid");
+      let parentRecordId = PlacesSyncUtils.bookmarks.guidToRecordId(parentGuid);
+      let dateAdded = PlacesUtils.toDate(
+        row.getResultByName("dateAdded")).getTime();
+
+      let type = row.getResultByName("type");
+      switch (type) {
+        case PlacesUtils.bookmarks.TYPE_BOOKMARK: {
+          let isQuery = row.getResultByName("isQuery");
+          if (isQuery) {
+            let queryCleartext = {
+              id: recordId,
+              type: "query",
+              // We ignore `parentid` and use the parent's `children`, but older
+              // Desktops and Android use `parentid` as the canonical parent.
+              // iOS is stricter and requires both `children` and `parentid` to
+              // match.
+              parentid: parentRecordId,
+              // Older Desktops use `hasDupe` and `parentName` for deduping.
+              hasDupe: false,
+              parentName: row.getResultByName("parentTitle"),
+              dateAdded,
+              bmkUri: row.getResultByName("url"),
+              title: row.getResultByName("title"),
+              queryId: row.getResultByName("smartBookmarkName"),
+              folderName: row.getResultByName("tagFolderName"),
+            };
+            let description = row.getResultByName("description");
+            if (description) {
+              queryCleartext.description = description;
+            }
+            changeRecords[recordId] = new BookmarkChangeRecord(
+              syncChangeCounter, queryCleartext);
+            continue;
+          }
+
+          let bookmarkCleartext = {
+            id: recordId,
+            type: "bookmark",
+            parentid: parentRecordId,
+            hasDupe: false,
+            parentName: row.getResultByName("parentTitle"),
+            dateAdded,
+            bmkUri: row.getResultByName("url"),
+            title: row.getResultByName("title"),
+          };
+          let description = row.getResultByName("description");
+          if (description) {
+            bookmarkCleartext.description = description;
+          }
+          let loadInSidebar = row.getResultByName("loadInSidebar");
+          if (loadInSidebar) {
+            bookmarkCleartext.loadInSidebar = true;
+          }
+          let keyword = row.getResultByName("keyword");
+          if (keyword) {
+            bookmarkCleartext.keyword = keyword;
+          }
+          let tags = row.getResultByName("tags");
+          if (tags) {
+            bookmarkCleartext.tags = tags.split(",");
+          }
+          changeRecords[recordId] = new BookmarkChangeRecord(
+            syncChangeCounter, bookmarkCleartext);
+          continue;
+        }
+
+        case PlacesUtils.bookmarks.TYPE_FOLDER: {
+          let feedURLHref = row.getResultByName("feedURL");
+          if (feedURLHref) {
+            // Places stores livemarks as folders with feed and site URL annos.
+            // See bug 1072833 for discussion about changing them to queries.
+            let livemarkCleartext = {
+              id: recordId,
+              type: "livemark",
+              parentid: parentRecordId,
+              hasDupe: false,
+              parentName: row.getResultByName("parentTitle"),
+              dateAdded,
+              title: row.getResultByName("title"),
+              feedUri: feedURLHref,
+            };
+            let description = row.getResultByName("description");
+            if (description) {
+              livemarkCleartext.description = description;
+            }
+            let siteURLHref = row.getResultByName("siteURL");
+            if (siteURLHref) {
+              livemarkCleartext.siteUri = siteURLHref;
+            }
+            changeRecords[recordId] = new BookmarkChangeRecord(
+              syncChangeCounter, livemarkCleartext);
+            continue;
+          }
+
+          let folderCleartext = {
+            id: recordId,
+            type: "folder",
+            parentid: parentRecordId,
+            hasDupe: false,
+            parentName: row.getResultByName("parentTitle"),
+            dateAdded,
+            title: row.getResultByName("title"),
+          };
+          let description = row.getResultByName("description");
+          if (description) {
+            folderCleartext.description = description;
+          }
+          let childGuidRows = await this.db.executeCached(`
+            SELECT guid FROM structureToUpload
+            WHERE parentGuid = :guid
+            ORDER BY position`,
+            { guid });
+          folderCleartext.children = childGuidRows.map(row => {
+            let childGuid = row.getResultByName("guid");
+            return PlacesSyncUtils.bookmarks.guidToRecordId(childGuid);
+          });
+          changeRecords[recordId] = new BookmarkChangeRecord(
+            syncChangeCounter, folderCleartext);
+          continue;
+        }
+
+        case PlacesUtils.bookmarks.TYPE_SEPARATOR: {
+          let separatorCleartext = {
+            id: recordId,
+            type: "separator",
+            parentid: parentRecordId,
+            hasDupe: false,
+            parentName: row.getResultByName("parentTitle"),
+            dateAdded,
+            // Older Desktops use `pos` for deduping.
+            pos: row.getResultByName("position"),
+          };
+          changeRecords[recordId] = new BookmarkChangeRecord(
+            syncChangeCounter, separatorCleartext);
+          continue;
+        }
+
+        default:
+          throw new TypeError("Can't create record for unknown Places item");
+      }
+    }
+
+    return changeRecords;
+  }
+
+  /**
+   * Closes the mirror database connection. This is called automatically on
+   * shutdown, but may also be called explicitly when the mirror is no longer
+   * needed.
+   */
+  finalize() {
+    if (!this.finalizePromise) {
+      this.finalizePromise = (async () => {
+        await this.db.close();
+        this.finalizeAt.removeBlocker(this.finalizeBound);
+      })();
+    }
+    return this.finalizePromise;
+  }
+}
+
+this.SyncedBookmarksMirror = SyncedBookmarksMirror;
+
+/** Synced item kinds. Each corresponds to a Sync record type. */
+SyncedBookmarksMirror.KIND = {
+  BOOKMARK: 1,
+  QUERY: 2,
+  FOLDER: 3,
+  LIVEMARK: 4,
+  SEPARATOR: 5,
+};
+
+/** Valid key types for the key-value `meta` table. */
+SyncedBookmarksMirror.META = {
+  MODIFIED: 1,
+};
+
+/**
+ * An error thrown when the merge can't proceed because the local or remote
+ * tree is inconsistent.
+ */
+SyncedBookmarksMirror.ConsistencyError =
+  class ConsistencyError extends Error {};
+
+// Indicates if the mirror should be replaced because the database file is
+// corrupt.
+function isDatabaseCorrupt(error) {
+  return error instanceof Ci.mozIStorageError &&
+         (error.result == Ci.mozIStorageError.CORRUPT ||
+          error.result == Ci.mozIStorageError.NOTADB);
+}
+
+/**
+ * Migrates the mirror database schema to the latest version.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ *        The mirror database connection.
+ */
+function migrateMirrorSchema(db) {
+  return db.executeTransaction(async function() {
+    let currentSchemaVersion = await db.getSchemaVersion("mirror");
+    if (currentSchemaVersion < 1) {
+      await initializeMirrorDatabase(db);
+    }
+    // Downgrading from a newer profile to an older profile rolls back the
+    // schema version, but leaves all new columns in place. We'll run the
+    // migration logic again on the next upgrade.
+    await db.setSchemaVersion(MIRROR_SCHEMA_VERSION, "mirror");
+  });
+}
+
+/**
+ * Initializes a new mirror database, creating persistent tables, indexes, and
+ * roots.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ *        The mirror database connection.
+ */
+async function initializeMirrorDatabase(db) {
+  // Key-value metadata table. Currently stores just the server collection
+  // last modified time.
+  await db.execute(`CREATE TABLE mirror.meta(
+    key INTEGER PRIMARY KEY,
+    value NOT NULL
+    CHECK(key = ${SyncedBookmarksMirror.META.MODIFIED})
+  )`);
+
+  await db.execute(`CREATE TABLE mirror.items(
+    id INTEGER PRIMARY KEY,
+    guid TEXT UNIQUE NOT NULL,
+    /* The server modified time, in milliseconds. */
+    serverModified INTEGER NOT NULL DEFAULT 0,
+    needsMerge BOOLEAN NOT NULL DEFAULT 0,
+    isDeleted BOOLEAN NOT NULL DEFAULT 0,
+    kind INTEGER NOT NULL DEFAULT -1,
+    /* The creation date, in microseconds. */
+    dateAdded INTEGER NOT NULL DEFAULT 0,
+    title TEXT,
+    urlId INTEGER REFERENCES urls(id)
+                  ON DELETE SET NULL,
+    keyword TEXT,
+    tagFolderName TEXT,
+    description TEXT,
+    loadInSidebar BOOLEAN,
+    smartBookmarkName TEXT,
+    feedURL TEXT,
+    siteURL TEXT,
+    /* Only bookmarks and queries must have URLs. */
+    CHECK(CASE WHEN kind IN (${[
+                      SyncedBookmarksMirror.KIND.BOOKMARK,
+                      SyncedBookmarksMirror.KIND.QUERY,
+                    ].join(",")}) THEN urlId NOT NULL
+               ELSE urlId IS NULL END)
+  )`);
+
+  await db.execute(`CREATE TABLE mirror.structure(
+    guid TEXT NOT NULL PRIMARY KEY,
+    parentGuid TEXT NOT NULL REFERENCES items(guid)
+                             ON DELETE CASCADE,
+    position INTEGER NOT NULL
+  ) WITHOUT ROWID`);
+
+  await db.execute(`CREATE TABLE mirror.urls(
+    id INTEGER PRIMARY KEY,
+    guid TEXT NOT NULL,
+    url TEXT NOT NULL,
+    hash INTEGER NOT NULL,
+    revHost TEXT NOT NULL
+  )`);
+
+  await db.execute(`CREATE TABLE mirror.tags(
+    itemId INTEGER NOT NULL REFERENCES items(id)
+                            ON DELETE CASCADE,
+    tag TEXT NOT NULL
+  )`);
+
+  await db.execute(`CREATE INDEX mirror.urlHashes ON urls(hash)`);
+
+  await db.execute(`CREATE INDEX mirror.locations ON structure(
+    parentGuid,
+    position
+  )`);
+
+  await createMirrorRoots(db);
+}
+
+/**
+ * Sets up the syncable roots. All items in the mirror should descend from these
+ * roots. If we ever add new syncable roots to Places, this function should also
+ * be updated to create them in the mirror.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ *        The mirror database connection.
+ */
+async function createMirrorRoots(db) {
+  const syncableRoots = [{
+    guid: PlacesUtils.bookmarks.rootGuid,
+    // The Places root is its own parent, to satisfy the foreign key and
+    // `NOT NULL` constraints on `structure`.
+    parentGuid: PlacesUtils.bookmarks.rootGuid,
+    position: -1,
+  }, {
+    guid: PlacesUtils.bookmarks.menuGuid,
+    parentGuid: PlacesUtils.bookmarks.rootGuid,
+    position: 0,
+  }, {
+    guid: PlacesUtils.bookmarks.toolbarGuid,
+    parentGuid: PlacesUtils.bookmarks.rootGuid,
+    position: 1,
+  }, {
+    guid: PlacesUtils.bookmarks.unfiledGuid,
+    parentGuid: PlacesUtils.bookmarks.rootGuid,
+    position: 2,
+  }, {
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    parentGuid: PlacesUtils.bookmarks.rootGuid,
+    position: 3,
+  }];
+  for (let info of syncableRoots) {
+    await db.executeCached(`
+      INSERT INTO items(guid, kind)
+      VALUES(:guid, :kind)`,
+      { guid: info.guid, kind: SyncedBookmarksMirror.KIND.FOLDER });
+
+    await db.executeCached(`
+      INSERT INTO structure(guid, parentGuid, position)
+      VALUES(:guid, :parentGuid, :position)`,
+      info);
+  }
+}
+
+/**
+ * Creates temporary tables, views, and triggers to apply the mirror to Places.
+ *
+ * The bulk of the logic to apply all remotely changed items is defined in
+ * `INSTEAD OF DELETE` triggers on the `newRemoteItems` and `newRemoteStructure`
+ * views. When we execute `DELETE FROM newRemote{Items, Structure}`, SQLite
+ * fires the triggers for each row in the view. This is equivalent to, but more
+ * efficient than, issuing `SELECT * FROM newRemote{Items, Structure}`,
+ * followed by separate `INSERT` and `UPDATE` statements.
+ *
+ * Using triggers to execute all these statements avoids the overhead of passing
+ * results between the storage and main threads, and wrapping each result row in
+ * a `mozStorageRow` object.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ *        The mirror database connection.
+ */
+async function initializeTempMirrorEntities(db) {
+  // Columns in `items` that correspond to annos stored in `moz_items_annos`.
+  // We use this table to build SQL fragments for the `insertNewLocalItems` and
+  // `updateExistingLocalItems` triggers below.
+  const syncedAnnoTriggers = [{
+    annoName: PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO,
+    columnName: "description",
+    type: PlacesUtils.annotations.TYPE_STRING,
+  }, {
+    annoName: PlacesSyncUtils.bookmarks.SIDEBAR_ANNO,
+    columnName: "loadInSidebar",
+    type: PlacesUtils.annotations.TYPE_INT32,
+  }, {
+    annoName: PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO,
+    columnName: "smartBookmarkName",
+    type: PlacesUtils.annotations.TYPE_STRING,
+  }, {
+    annoName: PlacesUtils.LMANNO_FEEDURI,
+    columnName: "feedURL",
+    type: PlacesUtils.annotations.TYPE_STRING,
+  }, {
+    annoName: PlacesUtils.LMANNO_SITEURI,
+    columnName: "siteURL",
+    type: PlacesUtils.annotations.TYPE_STRING,
+  }];
+
+  // Stores the value and structure states of all nodes in the merged tree.
+  await db.execute(`CREATE TEMP TABLE mergeStates(
+    localGuid TEXT NOT NULL,
+    mergedGuid TEXT NOT NULL,
+    parentGuid TEXT NOT NULL,
+    level INTEGER NOT NULL,
+    position INTEGER NOT NULL,
+    valueState INTEGER NOT NULL,
+    structureState INTEGER NOT NULL,
+    PRIMARY KEY(localGuid, mergedGuid)
+  ) WITHOUT ROWID`);
+
+  // A view of the value states for all bookmarks in the mirror. We use triggers
+  // on this view to update Places. Note that we can't just `REPLACE INTO
+  // moz_bookmarks`, because `REPLACE` doesn't fire the `AFTER DELETE` triggers
+  // that Places uses to maintain schema coherency.
+  await db.execute(`
+    CREATE TEMP VIEW newRemoteItems(localId, remoteId, localGuid, mergedGuid,
+                                    needsUpdate, type, dateAdded, title,
+                                    oldPlaceId, newPlaceId, newKeyword,
+                                    description, loadInSidebar,
+                                    smartBookmarkName, feedURL, siteURL,
+                                    syncChangeCounter) AS
+    SELECT b.id, v.id, r.localGuid, r.mergedGuid,
+           r.valueState = ${BookmarkMergeState.TYPE.REMOTE},
+           (CASE WHEN v.kind IN (${[
+                        SyncedBookmarksMirror.KIND.BOOKMARK,
+                        SyncedBookmarksMirror.KIND.QUERY,
+                      ].join(",")}) THEN ${PlacesUtils.bookmarks.TYPE_BOOKMARK}
+                 WHEN v.kind IN (${[
+                        SyncedBookmarksMirror.KIND.FOLDER,
+                        SyncedBookmarksMirror.KIND.LIVEMARK,
+                      ].join(",")}) THEN ${PlacesUtils.bookmarks.TYPE_FOLDER}
+                 ELSE ${PlacesUtils.bookmarks.TYPE_SEPARATOR} END),
+           (CASE WHEN b.dateAdded < v.dateAdded THEN b.dateAdded
+                 ELSE v.dateAdded END),
+           v.title, h.id, u.newPlaceId, v.keyword, v.description,
+           v.loadInSidebar, v.smartBookmarkName, v.feedURL, v.siteURL,
+           (CASE r.structureState WHEN ${BookmarkMergeState.TYPE.REMOTE} THEN 0
+            ELSE 1 END)
+    FROM items v
+    JOIN mergeStates r ON r.mergedGuid = v.guid
+    LEFT JOIN moz_bookmarks b ON b.guid = r.localGuid
+    LEFT JOIN moz_places h ON h.id = b.fk
+    LEFT JOIN (
+      SELECT h.id AS newPlaceId, u.id AS urlId
+      FROM urls u
+      JOIN moz_places h ON h.url_hash = u.hash AND
+                           h.url = u.url
+    ) u ON u.urlId = v.urlId
+    WHERE r.mergedGuid <> '${PlacesUtils.bookmarks.rootGuid}'`);
+
+  // Changes local GUIDs to remote GUIDs, drops local tombstones for revived
+  // remote items, and flags remote items as merged. In the trigger body, `OLD`
+  // refers to the row for the unmerged item in `newRemoteItems`.
+  await db.execute(`
+    CREATE TEMP TRIGGER mergeGuids
+    INSTEAD OF DELETE ON newRemoteItems
+    BEGIN
+      /* We update GUIDs here, instead of in the "updateExistingLocalItems"
+         trigger, because deduped items where we're keeping the local value
+         state won't have "needsMerge" set. */
+      UPDATE moz_bookmarks SET
+        guid = OLD.mergedGuid
+      WHERE OLD.localGuid <> OLD.mergedGuid AND
+            guid = OLD.localGuid;
+
+      /* Record item changed notifications for the updated GUIDs. */
+      INSERT INTO guidsChanged(itemId, oldGuid)
+      SELECT OLD.localId, OLD.localGuid
+      WHERE OLD.localGuid <> OLD.mergedGuid;
+
+      DELETE FROM moz_bookmarks_deleted WHERE guid = OLD.mergedGuid;
+
+      /* Flag the remote item as merged. */
+      UPDATE items SET
+        needsMerge = 0
+      WHERE needsMerge AND
+            guid = OLD.mergedGuid;
+    END`);
+
+  // Inserts items from the mirror that don't exist locally.
+  await db.execute(`
+    CREATE TEMP TRIGGER insertNewLocalItems
+    INSTEAD OF DELETE ON newRemoteItems WHEN OLD.localId IS NULL
+    BEGIN
+      /* Sync associates keywords with bookmarks, and doesn't sync POST data;
+         Places associates keywords with (URL, POST data) pairs, and multiple
+         bookmarks may have the same URL. For simplicity, we bump the change
+         counter for all local bookmarks with the remote URL (bug 1328737),
+         then remove all local keywords from remote URLs, and the remote keyword
+         from local URLs. */
+      UPDATE moz_bookmarks SET
+        syncChangeCounter = syncChangeCounter + 1
+      WHERE fk IN (
+        /* We intentionally use "place_id = OLD.newPlaceId" in the subquery,
+           instead of "fk = OLD.newPlaceId OR fk IN (...)" in the WHERE clause
+           above, because we only want to bump the counter if the URL has
+           keywords. */
+        SELECT place_id FROM moz_keywords
+        WHERE place_id = OLD.newPlaceId OR
+              keyword = OLD.newKeyword);
+
+      /* Record item changed notifications for existing items with the new
+         keyword and URL. */
+      INSERT INTO keywordsChanged(itemId, placeId, keyword)
+      SELECT b.id, b.fk, NULL FROM moz_bookmarks b
+      JOIN moz_keywords k ON k.place_id = b.fk
+      WHERE k.place_id = OLD.newPlaceId OR
+            k.keyword = OLD.newKeyword;
+
+      /* Remove the new keyword from existing items, and all keywords from the
+         new URL. */
+      DELETE FROM moz_keywords WHERE place_id = OLD.newPlaceId OR
+                                     keyword = OLD.newKeyword;
+
+      /* Remove existing tags for the new URL. */
+      DELETE FROM localTags WHERE placeId = OLD.newPlaceId;
+
+      /* Insert the new item, using "-1" as the placeholder parent and
+         position. We'll update these later, in the "updateLocalStructure"
+         trigger. */
+      INSERT INTO moz_bookmarks(guid, parent, position, type, fk, title,
+                                dateAdded, lastModified, syncStatus,
+                                syncChangeCounter)
+      VALUES(OLD.mergedGuid, -1, -1, OLD.type, OLD.newPlaceId, OLD.title,
+             OLD.dateAdded, STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
+             ${PlacesUtils.bookmarks.SYNC_STATUS.NORMAL},
+             OLD.syncChangeCounter);
+
+      /* Record an item added notification for the new item. */
+      INSERT INTO itemsAdded(guid)
+      VALUES(OLD.mergedGuid);
+
+      /* Insert new keywords after the item, so that "noteKeywordAdded" can find
+         the new item by Place ID. */
+      INSERT INTO moz_keywords(keyword, place_id)
+      SELECT OLD.newKeyword, OLD.newPlaceId
+      WHERE OLD.newKeyword NOT NULL;
+
+      /* Record item changed notifications for the new keyword. */
+      INSERT INTO keywordsChanged(itemId, placeId, keyword)
+      SELECT b.id, OLD.newPlaceId, OLD.newKeyword FROM moz_bookmarks b
+      WHERE b.guid = OLD.mergedGuid AND
+            OLD.newKeyword NOT NULL;
+
+      /* Insert new tags for the URL. */
+      INSERT INTO localTags(tag, placeId)
+      SELECT t.tag, OLD.newPlaceId FROM tags t
+      WHERE t.itemId = OLD.remoteId;
+
+      /* Insert new synced annos. These are almost identical to the statements
+         for updates, except we need an additional subquery to fetch the new
+         item's ID. We can also skip removing existing annos. */
+      INSERT OR IGNORE INTO moz_anno_attributes(name)
+      VALUES ${syncedAnnoTriggers.map(annoTrigger =>
+        `('${annoTrigger.annoName}')`
+      ).join(",")};
+
+      ${syncedAnnoTriggers.map(annoTrigger => `
+        INSERT INTO moz_items_annos(item_id, anno_attribute_id, content, flags,
+                                    expiration, type, lastModified, dateAdded)
+        SELECT (SELECT id FROM moz_bookmarks
+                WHERE guid = OLD.mergedGuid),
+               (SELECT id FROM moz_anno_attributes
+                WHERE name = '${annoTrigger.annoName}'),
+               OLD.${annoTrigger.columnName}, 0,
+               ${PlacesUtils.annotations.EXPIRE_NEVER}, ${annoTrigger.type},
+               STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
+               STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000
+        WHERE OLD.${annoTrigger.columnName} NOT NULL;
+
+        /* Record an anno set notification for the new synced anno. */
+        REPLACE INTO annosChanged(itemId, annoName, wasRemoved)
+        SELECT b.id, '${annoTrigger.annoName}', 0 FROM moz_bookmarks b
+        WHERE b.guid = OLD.mergedGuid AND
+              OLD.${annoTrigger.columnName} NOT NULL;
+      `).join("")}
+    END`);
+
+  // Updates existing items with new values from the mirror.
+  await db.execute(`
+    CREATE TEMP TRIGGER updateExistingLocalItems
+    INSTEAD OF DELETE ON newRemoteItems WHEN OLD.needsUpdate AND
+                                             OLD.localId NOT NULL
+    BEGIN
+      /* Record item changed notifications for the title and URL. */
+      INSERT INTO itemsChanged(itemId, oldTitle, oldPlaceId)
+      SELECT id, title, OLD.oldPlaceId FROM moz_bookmarks
+      WHERE id = OLD.localId;
+
+      UPDATE moz_bookmarks SET
+        title = OLD.title,
+        dateAdded = OLD.dateAdded,
+        lastModified = STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
+        syncStatus = ${PlacesUtils.bookmarks.SYNC_STATUS.NORMAL},
+        syncChangeCounter = OLD.syncChangeCounter
+      WHERE id = OLD.localId;
+
+      /* Bump the change counter for items with the old URL, new URL, and new
+         keyword. */
+      UPDATE moz_bookmarks SET
+        syncChangeCounter = syncChangeCounter + 1
+      WHERE fk IN (SELECT place_id FROM moz_keywords
+                   WHERE place_id IN (OLD.oldPlaceId, OLD.newPlaceId) OR
+                         keyword = OLD.newKeyword);
+
+      /* Record change observer notifications for items with the old URL, new
+         URL, and new keyword. This is identical to the subquery above; we just
+         query the item ID instead of updating by the Place ID. */
+      INSERT INTO keywordsChanged(itemId, placeId, keyword)
+      SELECT b.id, b.fk, NULL FROM moz_bookmarks b
+      JOIN moz_keywords k ON k.place_id = b.fk
+      WHERE k.place_id IN (OLD.oldPlaceId, OLD.newPlaceId) OR
+            k.keyword = OLD.newKeyword;
+
+      /* Remove the new keyword from existing items, and all keywords from the
+         old and new URLs. */
+      DELETE FROM moz_keywords WHERE place_id IN (OLD.oldPlaceId,
+                                                  OLD.newPlaceId) OR
+                                     keyword = OLD.newKeyword;
+
+      /* Remove existing tags. */
+      DELETE FROM localTags WHERE placeId IN (OLD.oldPlaceId, OLD.newPlaceId);
+
+      /* Update the URL and recalculate frecency. It's important we do this
+         *after* removing old keywords and *before* inserting new ones, so that
+         the above statements select the correct affected items. */
+      UPDATE moz_bookmarks SET
+        fk = OLD.newPlaceId
+      WHERE OLD.oldPlaceId <> OLD.newPlaceId AND
+            id = OLD.localId;
+
+      UPDATE moz_places SET
+        frecency = -1
+      WHERE OLD.oldPlaceId <> OLD.newPlaceId AND
+            id IN (OLD.oldPlaceId, OLD.newPlaceId);
+
+      /* Insert a new keyword for the new URL, if one is set. */
+      INSERT INTO moz_keywords(keyword, place_id)
+      SELECT OLD.newKeyword, OLD.newPlaceId
+      WHERE OLD.newKeyword NOT NULL;
+
+      /* Record an item changed notification for the new keyword. */
+      INSERT INTO keywordsChanged(itemId, placeId, keyword)
+      SELECT OLD.localId, OLD.newPlaceId, OLD.newKeyword
+      WHERE OLD.newKeyword NOT NULL;
+
+      /* Insert new tags for the new URL. */
+      INSERT INTO localTags(tag, placeId)
+      SELECT t.tag, OLD.newPlaceId FROM tags t
+      WHERE t.itemId = OLD.remoteId;
+
+      /* Record anno removed notifications for the synced annos. */
+      REPLACE INTO annosChanged(itemId, annoName, wasRemoved)
+      SELECT a.item_id, n.name, 1 FROM moz_items_annos a
+      JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+      WHERE item_id = OLD.localId AND
+            anno_attribute_id IN (SELECT id FROM moz_anno_attributes
+                                  WHERE name IN (${syncedAnnoTriggers.map(
+                                    annoTrigger => `'${annoTrigger.annoName}'`
+                                  ).join(",")}));
+
+      /* Remove existing synced annos. */
+      DELETE FROM moz_items_annos
+      WHERE item_id = OLD.localId AND
+            anno_attribute_id IN (SELECT id FROM moz_anno_attributes
+                                  WHERE name IN (${syncedAnnoTriggers.map(
+                                    annoTrigger => `'${annoTrigger.annoName}'`
+                                  ).join(",")}));
+
+      /* Insert new synced annos. */
+      INSERT OR IGNORE INTO moz_anno_attributes(name)
+      VALUES ${syncedAnnoTriggers.map(annoTrigger =>
+        `('${annoTrigger.annoName}')`
+      ).join(",")};
+
+      ${syncedAnnoTriggers.map(annoTrigger => `
+        INSERT INTO moz_items_annos(item_id, anno_attribute_id, content, flags,
+                                    expiration, type, lastModified, dateAdded)
+        SELECT OLD.localId, (SELECT id FROM moz_anno_attributes
+                             WHERE name = '${annoTrigger.annoName}'),
+               OLD.${annoTrigger.columnName}, 0,
+               ${PlacesUtils.annotations.EXPIRE_NEVER}, ${annoTrigger.type},
+               STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
+               STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000
+         WHERE OLD.${annoTrigger.columnName} NOT NULL;
+
+         /* Record an anno set notification for the new synced anno. */
+         REPLACE INTO annosChanged(itemId, annoName, wasRemoved)
+         SELECT OLD.localId, '${annoTrigger.annoName}', 0
+         WHERE OLD.${annoTrigger.columnName} NOT NULL;
+      `).join("")}
+    END`);
+
+  // A view of the new structure state for all items in the merged tree. The
+  // mirror stores structure info in a separate table, like iOS, while Places
+  // stores structure info on children. Unlike iOS, we can't simply check the
+  // parent's merge state to know if its children changed. This is because our
+  // merged tree might diverge from the mirror if we're missing children, or if
+  // we temporarily reparented children without parents to "unfiled". In that
+  // case, we want to keep syncing, but *don't* want to reupload the new local
+  // structure to the server.
+  await db.execute(`
+    CREATE TEMP VIEW newRemoteStructure(localId, oldParentId, newParentId,
+                                        oldPosition, newPosition) AS
+    SELECT b.id, b.parent, p.id, b.position, r.position
+    FROM moz_bookmarks b
+    JOIN mergeStates r ON r.mergedGuid = b.guid
+    JOIN moz_bookmarks p ON p.guid = r.parentGuid
+    WHERE r.parentGuid <> '${PlacesUtils.bookmarks.rootGuid}'`);
+
+  // Updates all parents and positions to reflect the merged tree.
+  await db.execute(`
+    CREATE TEMP TRIGGER updateLocalStructure
+    INSTEAD OF DELETE ON newRemoteStructure
+    BEGIN
+      UPDATE moz_bookmarks SET
+        parent = OLD.newParentId
+      WHERE id = OLD.localId AND
+            parent <> OLD.newParentId;
+
+      UPDATE moz_bookmarks SET
+        position = OLD.newPosition
+      WHERE id = OLD.localId AND
+            position <> OLD.newPosition;
+
+      /* Record observer notifications for moved items. We ignore items that
+         didn't move, and items with placeholder parents and positions of "-1",
+         since they're new. */
+      INSERT INTO itemsMoved(itemId, oldParentId, oldParentGuid, oldPosition)
+      SELECT OLD.localId, OLD.oldParentId, p.guid, OLD.oldPosition
+      FROM moz_bookmarks p
+      WHERE p.id = OLD.oldParentId AND
+            -1 NOT IN (OLD.oldParentId, OLD.oldPosition) AND
+            (OLD.oldParentId <> OLD.newParentId OR
+             OLD.oldPosition <> OLD.newPosition);
+    END`);
+
+  // A view of local bookmark tags. Tags, like keywords, are associated with
+  // URLs, so two bookmarks with the same URL should have the same tags. Unlike
+  // keywords, one tag may be associated with many different URLs. Tags are also
+  // different because they're implemented as bookmarks under the hood. Each tag
+  // is stored as a folder under the tags root, and tagged URLs are stored as
+  // untitled bookmarks under these folders. This complexity, along with tag
+  // query rewriting, can be removed once bug 1293445 lands.
+  await db.execute(`
+    CREATE TEMP VIEW localTags(tagEntryId, tagEntryGuid, tagFolderId,
+                               tagFolderGuid, tagEntryPosition, tagEntryType,
+                               tag, placeId) AS
+    SELECT b.id, b.guid, p.id, p.guid, b.position, b.type, p.title, b.fk
+    FROM moz_bookmarks b
+    JOIN moz_bookmarks p ON p.id = b.parent
+    JOIN moz_bookmarks r ON r.id = p.parent
+    WHERE b.type = ${PlacesUtils.bookmarks.TYPE_BOOKMARK} AND
+          r.guid = '${PlacesUtils.bookmarks.tagsGuid}'`);
+
+  // Untags a URL by removing its tag entry.
+  await db.execute(`
+    CREATE TEMP TRIGGER untagLocalPlace
+    INSTEAD OF DELETE ON localTags
+    BEGIN
+      /* Record an item removed notification for the tag entry. */
+      INSERT INTO itemsRemoved(itemId, parentId, position, type, placeId, guid,
+                               parentGuid, isUntagging)
+      VALUES(OLD.tagEntryId, OLD.tagFolderId, OLD.tagEntryPosition,
+             OLD.tagEntryType, OLD.placeId, OLD.tagEntryGuid,
+             OLD.tagFolderGuid, 1);
+
+      DELETE FROM moz_bookmarks WHERE id = OLD.tagEntryId;
+
+      /* Fix the positions of the sibling tag entries. */
+      UPDATE moz_bookmarks SET
+        position = position - 1
+      WHERE parent = OLD.tagFolderId AND
+            position > OLD.tagEntryPosition;
+    END`);
+
+  // Tags a URL by creating a tag folder if it doesn't exist, then inserting a
+  // tag entry for the URL into the tag folder. `NEW.placeId` can be NULL, in
+  // which case we'll just create the tag folder.
+  await db.execute(`
+    CREATE TEMP TRIGGER tagLocalPlace
+    INSTEAD OF INSERT ON localTags
+    BEGIN
+      /* Ensure the tag folder exists. */
+      INSERT OR IGNORE INTO moz_bookmarks(guid, parent, position, type, title,
+                                          dateAdded, lastModified)
+      VALUES(IFNULL((SELECT b.guid FROM moz_bookmarks b
+                     JOIN moz_bookmarks p ON p.id = b.parent
+                     WHERE b.title = NEW.tag AND
+                           p.guid = '${PlacesUtils.bookmarks.tagsGuid}'),
+                    GENERATE_GUID()),
+             (SELECT id FROM moz_bookmarks
+              WHERE guid = '${PlacesUtils.bookmarks.tagsGuid}'),
+             (SELECT COUNT(*) FROM moz_bookmarks b
+              JOIN moz_bookmarks p ON p.id = b.parent
+              WHERE p.guid = '${PlacesUtils.bookmarks.tagsGuid}'),
+             ${PlacesUtils.bookmarks.TYPE_FOLDER}, NEW.tag,
+             STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
+             STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000);
+
+      /* Record an item added notification if we created a tag folder.
+         "CHANGES()" returns the number of rows affected by the INSERT above:
+         1 if we created the folder, or 0 if the folder already existed. */
+      INSERT INTO itemsAdded(guid, isTagging)
+      SELECT b.guid, 1 FROM moz_bookmarks b
+      JOIN moz_bookmarks p ON p.id = b.parent
+      WHERE CHANGES() > 0 AND
+            b.title = NEW.tag AND
+            p.guid = '${PlacesUtils.bookmarks.tagsGuid}';
+
+      /* Add a tag entry for the URL under the tag folder. Omitting the place
+         ID creates a tag folder without tagging the URL. */
+      INSERT OR IGNORE INTO moz_bookmarks(guid, parent, position, type, fk,
+                                          dateAdded, lastModified)
+      SELECT GENERATE_GUID(),
+             (SELECT b.id FROM moz_bookmarks b
+              JOIN moz_bookmarks p ON p.id = b.parent
+              WHERE p.guid = '${PlacesUtils.bookmarks.tagsGuid}' AND
+                    b.title = NEW.tag),
+             (SELECT COUNT(*) FROM moz_bookmarks b
+              JOIN moz_bookmarks p ON p.id = b.parent
+              JOIN moz_bookmarks r ON r.id = p.parent
+              WHERE p.title = NEW.tag AND
+                    r.guid = '${PlacesUtils.bookmarks.tagsGuid}'),
+             ${PlacesUtils.bookmarks.TYPE_BOOKMARK}, NEW.placeId,
+             STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
+             STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000
+        WHERE NEW.placeId NOT NULL;
+
+      /* Record an item added notification for the tag entry. */
+      INSERT INTO itemsAdded(guid, isTagging)
+      SELECT b.guid, 1 FROM moz_bookmarks b
+      JOIN moz_bookmarks p ON p.id = b.parent
+      JOIN moz_bookmarks r ON r.id = p.parent
+      WHERE b.fk = NEW.placeId AND
+            p.title = NEW.tag AND
+            r.guid = '${PlacesUtils.bookmarks.tagsGuid}';
+    END`);
+
+  // Stores properties to pass to `onItem{Added, Changed, Moved, Removed}`
+  // bookmark observers for new, updated, moved, and deleted items.
+  await db.execute(`CREATE TEMP TABLE itemsAdded(
+    guid TEXT PRIMARY KEY,
+    isTagging BOOLEAN NOT NULL DEFAULT 0
+  ) WITHOUT ROWID`);
+
+  await db.execute(`CREATE TEMP TABLE guidsChanged(
+    itemId INTEGER NOT NULL,
+    oldGuid TEXT NOT NULL,
+    PRIMARY KEY(itemId, oldGuid)
+  ) WITHOUT ROWID`);
+
+  await db.execute(`CREATE TEMP TABLE itemsChanged(
+    itemId INTEGER PRIMARY KEY,
+    oldTitle TEXT,
+    oldPlaceId INTEGER
+  )`);
+
+  await db.execute(`CREATE TEMP TABLE itemsMoved(
+    itemId INTEGER PRIMARY KEY,
+    oldParentId INTEGER NOT NULL,
+    oldParentGuid TEXT NOT NULL,
+    oldPosition INTEGER NOT NULL
+  )`);
+
+  await db.execute(`CREATE TEMP TABLE itemsRemoved(
+    guid TEXT PRIMARY KEY,
+    itemId INTEGER NOT NULL,
+    parentId INTEGER NOT NULL,
+    position INTEGER NOT NULL,
+    type INTEGER NOT NULL,
+    placeId INTEGER,
+    parentGuid TEXT NOT NULL,
+    /* We record the original level of the removed item in the tree so that we
+       can notify children before parents. */
+    level INTEGER NOT NULL DEFAULT -1,
+    isUntagging BOOLEAN NOT NULL DEFAULT 0
+  ) WITHOUT ROWID`);
+
+  // Stores properties to pass to `onItemAnnotation{Set, Removed}` anno
+  // observers.
+  await db.execute(`CREATE TEMP TABLE annosChanged(
+    itemId INTEGER NOT NULL,
+    annoName TEXT NOT NULL,
+    wasRemoved BOOLEAN NOT NULL,
+    PRIMARY KEY(itemId, annoName, wasRemoved)
+  ) WITHOUT ROWID`);
+
+  // Stores properties to pass to `onItemChanged` observers for new and removed
+  // keywords. A NULL keyword means we're removing all keywords from a Place.
+  // Note that an item may appear multiple times in this table, so `itemId` is
+  // not a primary key.
+  await db.execute(`CREATE TEMP TABLE keywordsChanged(
+    itemId INTEGER NOT NULL,
+    placeId INTEGER NOT NULL,
+    keyword TEXT
+  )`);
+
+  // Stores locally changed items staged for upload. See `stageItemsToUpload`
+  // for an explanation of why these tables exists.
+  await db.execute(`CREATE TEMP TABLE itemsToUpload(
+    guid TEXT PRIMARY KEY,
+    syncChangeCounter INTEGER NOT NULL,
+    isDeleted BOOLEAN NOT NULL DEFAULT 0,
+    parentGuid TEXT,
+    parentTitle TEXT,
+    dateAdded INTEGER,
+    type INTEGER,
+    title TEXT,
+    isQuery BOOLEAN NOT NULL DEFAULT 0,
+    url TEXT,
+    tags TEXT,
+    description TEXT,
+    loadInSidebar BOOLEAN,
+    smartBookmarkName TEXT,
+    tagFolderName TEXT,
+    keyword TEXT,
+    feedURL TEXT,
+    siteURL TEXT,
+    position INTEGER
+  ) WITHOUT ROWID`);
+
+  await db.execute(`CREATE TEMP TABLE structureToUpload(
+    guid TEXT PRIMARY KEY,
+    parentGuid TEXT NOT NULL REFERENCES itemsToUpload(guid)
+                             ON DELETE CASCADE,
+    position INTEGER NOT NULL
+  ) WITHOUT ROWID`);
+}
+
+// Converts a Sync record ID to a Places GUID. Returns `null` if the ID is
+// invalid.
+function validateGuid(recordId) {
+  let guid = PlacesSyncUtils.bookmarks.recordIdToGuid(recordId);
+  return PlacesUtils.isValidGuid(guid) ? guid : null;
+}
+
+// Converts a Sync record's last modified time to milliseconds.
+function determineServerModified(record) {
+  return Math.max(record.modified * 1000, 0) || 0;
+}
+
+// Determines a Sync record's creation date.
+function determineDateAdded(record) {
+  let serverModified = determineServerModified(record);
+  let dateAdded = PlacesSyncUtils.bookmarks.ratchetTimestampBackwards(
+    record.dateAdded, serverModified);
+  return dateAdded ? PlacesUtils.toPRTime(new Date(dateAdded)) : 0;
+}
+
+function validateTitle(rawTitle) {
+  if (typeof rawTitle != "string" || !rawTitle) {
+    return null;
+  }
+  return rawTitle.slice(0, DB_TITLE_LENGTH_MAX);
+}
+
+function validateURL(rawURL) {
+  if (typeof rawURL != "string" || rawURL.length > DB_URL_LENGTH_MAX) {
+    return null;
+  }
+  let url = null;
+  try {
+    url = new URL(rawURL);
+  } catch (ex) {}
+  return url;
+}
+
+function validateDescription(rawDescription) {
+  if (typeof rawDescription != "string" || !rawDescription) {
+    return null;
+  }
+  return rawDescription.slice(0, DB_DESCRIPTION_LENGTH_MAX);
+}
+
+function validateKeyword(rawKeyword) {
+  if (typeof rawKeyword != "string") {
+    return null;
+  }
+  let keyword = rawKeyword.trim();
+  // Drop empty keywords.
+  return keyword ? keyword.toLowerCase() : null;
+}
+
+function validateTag(rawTag) {
+  if (typeof rawTag != "string") {
+    return null;
+  }
+  let tag = rawTag.trim();
+  if (!tag || tag.length > Ci.nsITaggingService.MAX_TAG_LENGTH) {
+    // Drop empty and oversized tags.
+    return null;
+  }
+  return tag;
+}
+
+// Recursively inflates a bookmark tree from a pseudo-tree that maps
+// parents to children.
+function inflateTree(tree, pseudoTree, parentGuid) {
+  let nodes = pseudoTree.get(parentGuid);
+  if (nodes) {
+    for (let node of nodes) {
+      tree.insert(parentGuid, node);
+      inflateTree(tree, pseudoTree, node.guid);
+    }
+  }
+}
+
+/**
+ * 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 `contentsMatch`
+ * for how we determine if two items are dupes.
+ */
+class BookmarkContent {
+  constructor(title, urlHref, smartBookmarkName, position) {
+    this.title = title;
+    this.url = urlHref ? new URL(urlHref) : null;
+    this.smartBookmarkName = smartBookmarkName;
+    this.position = position;
+  }
+
+  static fromRow(row) {
+    let title = row.getResultByName("title");
+    let urlHref = row.getResultByName("url");
+    let smartBookmarkName = row.getResultByName("smartBookmarkName");
+    let position = row.getResultByName("position");
+    return new BookmarkContent(title, urlHref, smartBookmarkName, position);
+  }
+
+  hasSameURL(otherContent) {
+    return !!this.url == !!otherContent.url &&
+           this.url.href == otherContent.url.href;
+  }
+}
+
+/**
+ * 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(type, newStructureNode = null) {
+    this.type = type;
+    this.newStructureNode = newStructureNode;
+  }
+
+  /**
+   * Takes an existing value state, and a new node for the 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 value state.
+   * @param  {BookmarkNode} newStructureNode
+   *         A node to use for the new structure state.
+   * @return {BookmarkMergeState}
+   *         The new merge state.
+   */
+  static new(oldState, newStructureNode) {
+    return new BookmarkMergeState(oldState.type, newStructureNode);
+  }
+
+  // Returns the structure state type: `LOCAL`, `REMOTE`, or `NEW`.
+  structure() {
+    return this.newStructureNode ? BookmarkMergeState.TYPE.NEW : this.type;
+  }
+
+  // Returns the value state type: `LOCAL` or `REMOTE`.
+  value() {
+    return this.type;
+  }
+}
+
+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, age, kind, needsMerge = false) {
+    this.guid = guid;
+    this.kind = kind;
+    this.age = age;
+    this.needsMerge = needsMerge;
+    this.children = [];
+  }
+
+  // Creates a virtual folder node for the Places root.
+  static root() {
+    let guid = PlacesUtils.bookmarks.rootGuid;
+    return new BookmarkNode(guid, 0, 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 *microseconds*.
+    let localModified = row.getResultByName("lastModified");
+    let age = Math.max(localTimeSeconds - localModified / 1000000, 0) || 0;
+
+    let kind = row.getResultByName("kind");
+
+    let syncChangeCounter = row.getResultByName("syncChangeCounter");
+    let needsMerge = syncChangeCounter > 0;
+
+    return new BookmarkNode(guid, age, kind, needsMerge);
+  }
+
+  /**
+   * 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, age, kind, needsMerge);
+  }
+
+  isRoot() {
+    return this.guid == PlacesUtils.bookmarks.rootGuid ||
+           PlacesUtils.bookmarks.userContentRoots.includes(this.guid);
+  }
+
+  isFolder() {
+    return this.kind == SyncedBookmarksMirror.KIND.FOLDER;
+  }
+
+  newerThan(otherNode) {
+    return this.age < otherNode.age;
+  }
+
+  * descendants() {
+    for (let node of this.children) {
+      yield node;
+      if (node.isFolder()) {
+        yield* node.descendants();
+      }
+    }
+  }
+}
+
+/**
+ * A complete, rooted tree with tombstones.
+ */
+class BookmarkTree {
+  constructor(root) {
+    this.byGuid = new Map();
+    this.infosByNode = new WeakMap();
+    this.deletedGuids = new Set();
+
+    this.root = root;
+    this.byGuid.set(this.root.guid, this.root);
+  }
+
+  isDeleted(guid) {
+    return this.deletedGuids.has(guid);
+  }
+
+  nodeForGuid(guid) {
+    return this.byGuid.get(guid);
+  }
+
+  parentNodeFor(childNode) {
+    let info = this.infosByNode.get(childNode);
+    return info ? info.parentNode : null;
+  }
+
+  levelForGuid(guid) {
+    let node = this.byGuid.get(guid);
+    if (!node) {
+      return -1;
+    }
+    let info = this.infosByNode.get(node);
+    return info ? info.level : -1;
+  }
+
+  /**
+   * 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);
+
+    let parentInfo = this.infosByNode.get(parentNode);
+    let level = parentInfo ? parentInfo.level + 1 : 0;
+    this.infosByNode.set(node, { parentNode, level });
+  }
+
+  noteDeleted(guid) {
+    this.deletedGuids.add(guid);
+  }
+
+  * guids() {
+    for (let [guid, node] of this.byGuid) {
+      if (node == this.root) {
+        continue;
+      }
+      yield guid;
+    }
+    for (let guid of this.deletedGuids) {
+      yield guid;
+    }
+  }
+
+  toJSON() {
+    let deleted = Array.from(this.deletedGuids);
+    return { root: this.root, deleted };
+  }
+}
+
+/**
+ * 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 = [];
+  }
+
+  /**
+   * 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 : null,
+        // 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,
+        valueState: mergedChild.mergeState.value(),
+        structureState: mergedChild.mergeState.structure(),
+      };
+      yield mergeStateParam;
+      yield* mergedChild.mergeStatesParams(level + 1);
+    }
+  }
+
+  /**
+   * Creates a bookmark node from this merged node.
+   *
+   * @return {BookmarkNode}
+   *         A node containing the decided value and structure state.
+   */
+  toBookmarkNode() {
+    if (MergedBookmarkNode.cachedBookmarkNodes.has(this)) {
+      return MergedBookmarkNode.cachedBookmarkNodes.get(this);
+    }
+
+    let decidedValueNode = this.decidedValue();
+    let decidedStructureState = this.mergeState.structure();
+    let needsMerge = decidedStructureState == BookmarkMergeState.TYPE.NEW ||
+                     (decidedStructureState == BookmarkMergeState.TYPE.LOCAL &&
+                      decidedValueNode.needsMerge);
+
+    let newNode = new BookmarkNode(this.guid, decidedValueNode.age,
+                                   decidedValueNode.kind, needsMerge);
+    MergedBookmarkNode.cachedBookmarkNodes.set(this, newNode);
+
+    if (newNode.isFolder()) {
+      for (let mergedChildNode of this.mergedChildren) {
+        newNode.children.push(mergedChildNode.toBookmarkNode());
+      }
+    }
+
+    return newNode;
+  }
+
+  /**
+   * Decides the value state for the merged node. Note that you can't walk the
+   * decided node's children: since the value node doesn't include structure
+   * changes from the other side, you'll depart from the merged tree. You'll
+   * want to use `toBookmarkNode` instead, which returns a node with the
+   * decided value *and* structure.
+   *
+   * @return {BookmarkNode}
+   *         The local or remote node containing the decided value state.
+   */
+  decidedValue() {
+    let valueState = this.mergeState.value();
+    switch (valueState) {
+      case BookmarkMergeState.TYPE.LOCAL:
+        if (!this.localNode) {
+          MirrorLog.error("Merged node ${guid} has local value state, but " +
+                          "no local node", this);
+          throw new TypeError(
+            "Can't take local value state without local node");
+        }
+        return this.localNode;
+
+      case BookmarkMergeState.TYPE.REMOTE:
+        if (!this.remoteNode) {
+          MirrorLog.error("Merged node ${guid} has remote value state, but " +
+                          "no remote node", this);
+          throw new TypeError(
+            "Can't take remote value state without remote node");
+        }
+        return this.remoteNode;
+    }
+    MirrorLog.error("Merged node ${guid} has unknown value state ${valueState}",
+                    { guid: this.guid, valueState });
+    throw new TypeError("Can't take unknown value state");
+  }
+}
+
+// Caches bookmark nodes containing the decided value and structure.
+MergedBookmarkNode.cachedBookmarkNodes = new WeakMap();
+
+/**
+ * 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.mergedGuids = new Set();
+    this.deleteLocally = new Set();
+    this.deleteRemotely = new Set();
+    this.telemetryEvents = [];
+  }
+
+  merge() {
+    let localRoot = this.localTree.nodeForGuid(PlacesUtils.bookmarks.rootGuid);
+    let remoteRoot = this.remoteTree.nodeForGuid(PlacesUtils.bookmarks.rootGuid);
+    let mergedRoot = this.mergeNode(PlacesUtils.bookmarks.rootGuid, localRoot,
+                                    remoteRoot);
+    return mergedRoot;
+  }
+
+  subsumes(tree) {
+    for (let guid of tree.guids()) {
+      if (!this.mergedGuids.has(guid) && !this.deleteLocally.has(guid) &&
+          !this.deleteRemotely.has(guid)) {
+        return false;
+      }
+    }
+    return 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.
+   */
+  mergeNode(mergedGuid, localNode, remoteNode) {
+    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 = 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.
+        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.
+        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.
+   */
+  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.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 });
+        this.mergeChildListsIntoMergedNode(mergedNode, localNode, remoteNode);
+        return mergedNode;
+      }
+
+      if (remoteNode.kind == SyncedBookmarksMirror.KIND.LIVEMARK) {
+        // We allow merging local folders and remote livemarks because Places
+        // stores livemarks as empty folders with feed and site URL annotations.
+        // The livemarks service first inserts the folder, and *then* sets
+        // annotations. Since this isn't wrapped in a transaction, we might sync
+        // before the annotations are set, and upload a folder record instead
+        // of a livemark record (bug 632287), then replace the folder with a
+        // livemark on the next sync.
+        MirrorLog.trace("Merging local folder ${localNode} and remote " +
+                        "livemark ${remoteNode}", { localNode, remoteNode });
+        this.telemetryEvents.push({
+          value: "kind",
+          extra: { local: "folder", remote: "folder" },
+        });
+        return mergedNode;
+      }
+
+      MirrorLog.error("Merging local folder ${localNode} and remote " +
+                      "non-folder ${remoteNode}", { localNode, remoteNode });
+      throw new SyncedBookmarksMirror.ConsistencyError(
+        "Can't merge folder and non-folder");
+    }
+
+    if (localNode.kind == remoteNode.kind) {
+      // Merging two non-folders, so no need to walk children.
+      MirrorLog.trace("Merging non-folders ${localNode} and ${remoteNode}",
+                      { localNode, remoteNode });
+      return mergedNode;
+    }
+
+    MirrorLog.error("Merging local ${localNode} and remote ${remoteNode} " +
+                    "with different kinds", { localNode, remoteNode });
+    throw new SyncedBookmarksMirror.ConsistencyError(
+      "Can't merge different item kinds");
+  }
+
+  /**
+   * 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 (!remoteNode.needsMerge) {
+      // The node wasn't changed remotely since the last sync. Keep the local
+      // state.
+      return BookmarkMergeState.local;
+    }
+    if (!localNode.needsMerge) {
+      // The node was changed remotely, but not locally. Take the remote state.
+      return BookmarkMergeState.remote;
+    }
+    // At this point, we know the item changed locally and remotely. We could
+    // query storage to determine if the value state is the same, as iOS does.
+    // However, that's an expensive check that requires joining `moz_bookmarks`,
+    // `moz_items_annos`, and `moz_places` to the mirror. It's unlikely that
+    // the value state is identical, so we skip the value check and use the
+    // timestamp to decide which node is newer.
+    let valueState = localNode.newerThan(remoteNode) ?
+                     BookmarkMergeState.local :
+                     BookmarkMergeState.remote;
+    return valueState;
+  }
+
+  /**
+   * Merges a remote child node into a merged folder node.
+   *
+   * @param  {MergedBookmarkNode} mergedNode
+   *         The merged folder node.
+   * @param  {BookmarkNode} remoteParentNode
+   *         The remote folder node.
+   * @param  {BookmarkNode} remoteChildNode
+   *         The remote child node.
+   * @return {Boolean}
+   *         `true` if the merged structure state changed because the remote
+   *         child was locally moved or deleted; `false` otherwise.
+   */
+  mergeRemoteChildIntoMergedNode(mergedNode, remoteParentNode,
+                                 remoteChildNode) {
+    if (this.mergedGuids.has(remoteChildNode.guid)) {
+      MirrorLog.trace("Remote child ${remoteChildNode} already seen in " +
+                      "another folder and merged", { remoteChildNode });
+      return false;
+    }
+
+    MirrorLog.trace("Merging remote child ${remoteChildNode} of " +
+                    "${remoteParentNode} into ${mergedNode}",
+                    { remoteChildNode, remoteParentNode, mergedNode });
+
+    // Make sure the remote child isn't locally deleted. If it is, 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.
+    let locallyDeleted = this.checkForLocalDeletionOfRemoteNode(mergedNode,
+      remoteChildNode);
+    if (locallyDeleted) {
+      return true;
+    }
+
+    // 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 doesn't exist locally, either. 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 content match",
+                      { remoteChildNode });
+
+      let localChildNodeByContent = this.findLocalNodeMatchingRemoteNode(
+        mergedNode, remoteChildNode);
+
+      let mergedChildNode = this.mergeNode(remoteChildNode.guid,
+                                           localChildNodeByContent,
+                                           remoteChildNode);
+      mergedNode.mergedChildren.push(mergedChildNode);
+      return false;
+    }
+
+    // Otherwise, the remote child exists in the local tree. Did it move?
+    let localParentNode = this.localTree.parentNodeFor(localChildNode);
+    if (!localParentNode) {
+      // Should never happen. The local tree must be complete.
+      MirrorLog.error("Remote child ${remoteChildNode} exists locally as " +
+                      "${localChildNode} without local parent",
+                      { remoteChildNode, localChildNode });
+      throw new SyncedBookmarksMirror.ConsistencyError(
+        "Local child node is orphan");
+    }
+
+    MirrorLog.trace("Remote child ${remoteChildNode} exists locally in " +
+                    "${localParentNode} and remotely in ${remoteParentNode}",
+                    { remoteChildNode, localParentNode, remoteParentNode });
+
+    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 (latestLocalAge < latestRemoteAge) {
+          // 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 });
+          return true;
+        }
+
+        // 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 = this.mergeNode(remoteChildNode.guid,
+                                             localChildNode, remoteChildNode);
+        mergedNode.mergedChildren.push(mergedChildNode);
+        return false;
+      }
+
+      MirrorLog.trace("Remote parent unchanged; keeping remote child " +
+                      "${remoteChildNode} in ${localParentNode}",
+                      { remoteChildNode, localParentNode });
+      return true;
+    }
+
+    MirrorLog.trace("Local parent unchanged; keeping remote child " +
+                    "${remoteChildNode} in ${remoteParentNode}",
+                    { remoteChildNode, remoteParentNode });
+
+    let mergedChildNode = this.mergeNode(remoteChildNode.guid, localChildNode,
+                                         remoteChildNode);
+    mergedNode.mergedChildren.push(mergedChildNode);
+    return false;
+  }
+
+  /**
+   * Merges a local child node into a merged folder node.
+   *
+   * @param  {MergedBookmarkNode} mergedNode
+   *         The merged folder node.
+   * @param  {BookmarkNode} localParentNode
+   *         The local folder node.
+   * @param  {BookmarkNode} localChildNode
+   *         The local child node.
+   * @return {Boolean}
+   *         `true` if the merged structure state changed because the local
+   *         child doesn't exist remotely or was locally moved; `false`
+   *         otherwise.
+   */
+  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 false;
+    }
+
+    MirrorLog.trace("Merging local child ${localChildNode} of " +
+                    "${localParentNode} into ${mergedNode}",
+                    { localChildNode, localParentNode, mergedNode });
+
+    // 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.
+    // If so, we need to move any new local descendants to the merged node,
+    // just as we did for new remote descendants of locally deleted parents.
+    let remotelyDeleted = this.checkForRemoteDeletionOfLocalNode(mergedNode,
+      localChildNode);
+    if (remotelyDeleted) {
+      return true;
+    }
+
+    // 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) {
+      // The local child doesn't exist remotely, but we still need to walk
+      // its children.
+      let mergedChildNode = this.mergeNode(localChildNode.guid, localChildNode,
+                                           /* remoteChildNode */ null);
+      mergedNode.mergedChildren.push(mergedChildNode);
+      return true;
+    }
+
+    // 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. The remote tree must be complete.
+      MirrorLog.error("Local child ${localChildNode} exists remotely as " +
+                      "${remoteChildNode} without remote parent",
+                      { localChildNode, remoteChildNode });
+      throw new SyncedBookmarksMirror.ConsistencyError(
+        "Remote child node is orphan");
+    }
+
+    MirrorLog.trace("Local child ${localChildNode} exists locally in " +
+                    "${localParentNode} and remotely in ${remoteParentNode}",
+                    { localChildNode, localParentNode, remoteParentNode });
+
+    if (localParentNode.needsMerge) {
+      if (remoteParentNode.needsMerge) {
+        MirrorLog.trace("Local ${localParentNode} and remote " +
+                        "${remoteParentNode} parents changed; comparing " +
+                        "modified times to decide parent for local child " +
+                        "${localChildNode}", { localParentNode,
+                                               remoteParentNode,
+                                               localChildNode });
+
+        let latestLocalAge = Math.min(localChildNode.age,
+                                      localParentNode.age);
+        let latestRemoteAge = Math.min(remoteChildNode.age,
+                                       remoteParentNode.age);
+
+        if (latestRemoteAge <= latestLocalAge) {
+          MirrorLog.trace("Ignoring older local move for ${localChildNode} " +
+                          "to ${localParentNode} at ${latestLocalAge}; " +
+                          "remote move to ${remoteParentNode} at " +
+                          "${latestRemoteAge} is newer",
+                          { localChildNode, localParentNode, latestLocalAge,
+                            remoteParentNode, latestRemoteAge });
+          return false;
+        }
+
+        MirrorLog.trace("Taking newer local move for ${localChildNode} to " +
+                        "${localParentNode} at ${latestLocalAge}; remote " +
+                        "move to ${remoteParentNode} at ${latestRemoteAge} " +
+                        "is older", { localChildNode, localParentNode,
+                                      latestLocalAge, remoteParentNode,
+                                      latestRemoteAge });
+
+        let mergedChildNode = this.mergeNode(localChildNode.guid,
+                                             localChildNode, remoteChildNode);
+        mergedNode.mergedChildren.push(mergedChildNode);
+        return true;
+      }
+
+      MirrorLog.trace("Remote parent unchanged; keeping local child " +
+                      "${localChildNode} in local parent ${localParentNode}",
+                      { localChildNode, localParentNode });
+
+      let mergedChildNode = this.mergeNode(localChildNode.guid, localChildNode,
+                                           remoteChildNode);
+      mergedNode.mergedChildren.push(mergedChildNode);
+      return true;
+    }
+
+    MirrorLog.trace("Local parent unchanged; keeping local child " +
+                    "${localChildNode} in remote parent ${remoteParentNode}",
+                    { localChildNode, remoteParentNode });
+    return false;
+  }
+
+  /**
+   * Recursively merges the children of a local folder node and a matching
+   * remote folder node.
+   *
+   * @param {MergedBookmarkNode} mergedNode
+   *        The merged folder state. This method mutates the merged node to
+   *        append merged children, and change the node's merge state to new
+   *        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.
+   */
+  mergeChildListsIntoMergedNode(mergedNode, localNode, remoteNode) {
+    let mergeStateChanged = false;
+
+    // Walk and merge remote children first.
+    MirrorLog.trace("Merging remote children of ${remoteNode} into " +
+                    "${mergedNode}", { remoteNode, mergedNode });
+    if (remoteNode) {
+      for (let remoteChildNode of remoteNode.children) {
+        let remoteChildrenChanged = this.mergeRemoteChildIntoMergedNode(
+          mergedNode, remoteNode, remoteChildNode);
+        if (remoteChildrenChanged) {
+          mergeStateChanged = true;
+        }
+      }
+    }
+
+    // Now walk and merge any local children that we haven't already merged.
+    MirrorLog.trace("Merging local children of ${localNode} into " +
+                    "${mergedNode}", { localNode, mergedNode });
+    if (localNode) {
+      for (let localChildNode of localNode.children) {
+        let remoteChildrenChanged = this.mergeLocalChildIntoMergedNode(
+          mergedNode, localNode, localChildNode);
+        if (remoteChildrenChanged) {
+          mergeStateChanged = true;
+        }
+      }
+    }
+
+    // Update the merge state if we moved children orphaned on one side by a
+    // deletion on the other side, if we kept newer locally moved children,
+    // or if the child order changed. We already updated the merge state of the
+    // orphans, but we also need to flag the containing folder so that it's
+    // reuploaded to the server along with the new children.
+    if (mergeStateChanged) {
+      let newStructureNode = mergedNode.toBookmarkNode();
+      let newMergeState = BookmarkMergeState.new(mergedNode.mergeState,
+                                                 newStructureNode);
+      MirrorLog.trace("Merge state for ${mergedNode} has new structure " +
+                      "${newMergeState}", { mergedNode, newMergeState });
+      this.telemetryEvents.push({
+        value: "structure",
+        extra: { type: "new" },
+      });
+      mergedNode.mergeState = newMergeState;
+    }
+  }
+
+  /**
+   * Walks a locally deleted remote node's children, reparenting any children
+   * that aren't also deleted remotely to the merged node. Returns `true` if
+   * `remoteNode` is deleted locally; `false` if `remoteNode` is not deleted or
+   * doesn't exist locally.
+   *
+   * This is the inverse of `checkForRemoteDeletionOfLocalNode`.
+   */
+  checkForLocalDeletionOfRemoteNode(mergedNode, remoteNode) {
+    if (!this.localTree.isDeleted(remoteNode.guid)) {
+      return false;
+    }
+
+    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.telemetryEvents.push({
+          value: "structure",
+          extra: { type: "delete", kind: "item", prefer: "remote" },
+        });
+        return false;
+      }
+      // 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.telemetryEvents.push({
+        value: "structure",
+        extra: { type: "delete", kind: "folder", prefer: "local" },
+      });
+    } else {
+      MirrorLog.trace("Remote node ${remoteNode} deleted locally and not " +
+                       "changed remotely; taking local deletion",
+                       { remoteNode });
+    }
+
+    this.deleteRemotely.add(remoteNode.guid);
+
+    let mergedOrphanNodes = this.processRemoteOrphansForNode(mergedNode,
+                                                             remoteNode);
+    this.relocateOrphansTo(mergedNode, mergedOrphanNodes);
+    MirrorLog.trace("Relocating remote orphans ${mergedOrphanNodes} to " +
+                    "${mergedNode}", { mergedOrphanNodes, mergedNode });
+
+    return true;
+  }
+
+  /**
+   * Walks a remotely deleted local node's children, reparenting any children
+   * that aren't also deleted locally to the merged node. Returns `true` if
+   * `localNode` is deleted remotely; `false` if `localNode` is not deleted or
+   * doesn't exist locally.
+   *
+   * This is the inverse of `checkForLocalDeletionOfRemoteNode`.
+   */
+  checkForRemoteDeletionOfLocalNode(mergedNode, localNode) {
+    if (!this.remoteTree.isDeleted(localNode.guid)) {
+      return false;
+    }
+
+    if (localNode.needsMerge) {
+      if (!localNode.isFolder()) {
+        MirrorLog.trace("Local non-folder ${localNode} deleted remotely and " +
+                        "changed locally; taking local change", { localNode });
+        this.telemetryEvents.push({
+          value: "structure",
+          extra: { type: "delete", kind: "item", prefer: "local" },
+        });
+        return false;
+      }
+      MirrorLog.trace("Local folder ${localNode} deleted remotely and " +
+                      "changed locally; taking remote deletion", { localNode });
+      this.telemetryEvents.push({
+        value: "structure",
+        extra: { type: "delete", kind: "folder", prefer: "remote" },
+      });
+    } else {
+      MirrorLog.trace("Local node ${localNode} deleted remotely and not " +
+                      "changed locally; taking remote deletion", { localNode });
+    }
+
+    MirrorLog.trace("Local node ${localNode} deleted remotely; taking remote " +
+                    "deletion", { localNode });
+
+    this.deleteLocally.add(localNode.guid);
+
+    let mergedOrphanNodes = this.processLocalOrphansForNode(mergedNode,
+                                                            localNode);
+    this.relocateOrphansTo(mergedNode, mergedOrphanNodes);
+    MirrorLog.trace("Relocating local orphans ${mergedOrphanNodes} to " +
+                    "${mergedNode}", { mergedOrphanNodes, mergedNode });
+
+    return true;
+  }
+
+  /**
+   * Recursively merges all remote children of a locally deleted folder that
+   * haven't also been deleted remotely. This can happen if the user adds a
+   * bookmark to a folder on another device, and deletes that folder locally.
+   * This is the inverse of `processLocalOrphansForNode`.
+   */
+  processRemoteOrphansForNode(mergedNode, remoteNode) {
+    let remoteOrphanNodes = [];
+
+    for (let remoteChildNode of remoteNode.children) {
+      let locallyDeleted = this.checkForLocalDeletionOfRemoteNode(mergedNode,
+        remoteChildNode);
+      if (locallyDeleted) {
+        // The remote child doesn't exist locally, or is also deleted locally,
+        // so we can safely delete its parent.
+        continue;
+      }
+      remoteOrphanNodes.push(remoteChildNode);
+    }
+
+    let mergedOrphanNodes = [];
+    for (let remoteOrphanNode of remoteOrphanNodes) {
+      let localOrphanNode = this.localTree.nodeForGuid(remoteOrphanNode.guid);
+      let mergedOrphanNode = this.mergeNode(remoteOrphanNode.guid,
+                                            localOrphanNode, remoteOrphanNode);
+      mergedOrphanNodes.push(mergedOrphanNode);
+    }
+
+    return mergedOrphanNodes;
+  }
+
+  /**
+   * Recursively merges all local children of a remotely deleted folder that
+   * haven't also been deleted locally. This is the inverse of
+   * `processRemoteOrphansForNode`.
+   */
+  processLocalOrphansForNode(mergedNode, localNode) {
+    if (!localNode.isFolder()) {
+      // The local node isn't a folder, so it won't have orphans.
+      return [];
+    }
+
+    let localOrphanNodes = [];
+    for (let localChildNode of localNode.children) {
+      let remotelyDeleted = this.checkForRemoteDeletionOfLocalNode(mergedNode,
+        localChildNode);
+      if (remotelyDeleted) {
+        // The local child doesn't exist or is also deleted on the server, so we
+        // can safely delete its parent without orphaning any local children.
+        continue;
+      }
+      localOrphanNodes.push(localChildNode);
+    }
+
+    let mergedOrphanNodes = [];
+    for (let localOrphanNode of localOrphanNodes) {
+      let remoteOrphanNode = this.remoteTree.nodeForGuid(localOrphanNode.guid);
+      let mergedNode = this.mergeNode(localOrphanNode.guid,
+                                      localOrphanNode, remoteOrphanNode);
+      mergedOrphanNodes.push(mergedNode);
+    }
+
+    return mergedOrphanNodes;
+  }
+
+  /**
+   * Moves a list of merged orphan nodes to the closest surviving ancestor.
+   * Changes the merge state of the moved orphans to new, so that we reupload
+   * them along with their new parent on the next sync.
+   *
+   * @param {MergedBookmarkNode} mergedNode
+   * @param {MergedBookmarkNode[]} mergedOrphanNodes
+   */
+  relocateOrphansTo(mergedNode, mergedOrphanNodes) {
+    for (let mergedOrphanNode of mergedOrphanNodes) {
+      let newStructureNode = mergedOrphanNode.toBookmarkNode();
+      let newMergeState = BookmarkMergeState.new(mergedOrphanNode.mergeState,
+                                                 newStructureNode);
+      mergedOrphanNode.mergeState = newMergeState;
+      mergedNode.mergedChildren.push(mergedOrphanNode);
+    }
+  }
+
+  /**
+   * Finds a local node with a different GUID that matches the content of a
+   * remote node. This is used to dedupe local items that haven't been uploaded
+   * to remote items that don't exist locally.
+   *
+   * @param  {MergedBookmarkNode} mergedNode
+   *         The merged folder node.
+   * @param  {BookmarkNode} remoteChildNode
+   *         The remote child node.
+   * @return {BookmarkNode?}
+   *         A matching local child node, or `null` if there are no matching
+   *         local items.
+   */
+  findLocalNodeMatchingRemoteNode(mergedNode, remoteChildNode) {
+    let localParentNode = mergedNode.localNode;
+    if (!localParentNode) {
+      MirrorLog.trace("Merged node ${mergedNode} doesn't exist locally; no " +
+                      "potential dupes for ${remoteChildNode}",
+                      { mergedNode, remoteChildNode });
+      return null;
+    }
+    let remoteChildContent = this.newRemoteContents.get(remoteChildNode.guid);
+    if (!remoteChildContent) {
+      // The node doesn't exist locally, but it's also flagged as merged in the
+      // mirror.
+      return null;
+    }
+    let newLocalNode = null;
+    for (let localChildNode of localParentNode.children) {
+      if (this.mergedGuids.has(localChildNode.guid)) {
+        MirrorLog.trace("Not deduping ${localChildNode}; already seen in " +
+                        "another folder", { localChildNode });
+        continue;
+      }
+      if (!this.newLocalContents.has(localChildNode.guid)) {
+        MirrorLog.trace("Not deduping ${localChildNode}; already uploaded",
+                        { localChildNode });
+        continue;
+      }
+      let remoteCandidate = this.remoteTree.nodeForGuid(localChildNode.guid);
+      if (remoteCandidate) {
+        MirrorLog.trace("Not deduping ${localChildNode}; already exists " +
+                        "remotely", { localChildNode });
+        continue;
+      }
+      if (this.remoteTree.isDeleted(localChildNode.guid)) {
+        MirrorLog.trace("Not deduping ${localChildNode}; deleted on server",
+                        { localChildNode });
+        continue;
+      }
+      let localChildContent = this.newLocalContents.get(localChildNode.guid);
+      if (!contentsMatch(localChildNode, localChildContent, remoteChildNode,
+                         remoteChildContent)) {
+        MirrorLog.trace("${localChildNode} is not a dupe of ${remoteChildNode}",
+                        { localChildNode, remoteChildNode });
+        continue;
+      }
+      this.telemetryEvents.push({ value: "dupe" });
+      newLocalNode = localChildNode;
+      break;
+    }
+    return newLocalNode;
+  }
+}
+
+/**
+ * Determines if two new local and remote nodes are of the same kind, and have
+ * similar contents.
+ *
+ * - Bookmarks must have the same title and URL.
+ * - Smart bookmarks must have the same smart bookmark name. Other 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} localNode
+ * @param  {BookmarkContent} localContent
+ * @param  {BookmarkNode} remoteNode
+ * @param  {BookmarkContent} remoteContent
+ * @return {Boolean}
+ */
+function contentsMatch(localNode, localContent, remoteNode, remoteContent) {
+  if (localNode.kind != remoteNode.kind) {
+    return false;
+  }
+  switch (localNode.kind) {
+    case SyncedBookmarksMirror.KIND.BOOKMARK:
+      return localContent.title == remoteContent.title &&
+             localContent.hasSameURL(remoteContent);
+
+    case SyncedBookmarksMirror.KIND.QUERY:
+      if (localContent.smartBookmarkName || remoteContent.smartBookmarkName) {
+        return localContent.smartBookmarkName ==
+               remoteContent.smartBookmarkName;
+      }
+      return localContent.title == remoteContent.title &&
+             localContent.hasSameURL(remoteContent);
+
+    case SyncedBookmarksMirror.KIND.FOLDER:
+    case SyncedBookmarksMirror.KIND.LIVEMARK:
+      return localContent.title == remoteContent.title;
+
+    case SyncedBookmarksMirror.KIND.SEPARATOR:
+      return localContent.position == remoteContent.position;
+  }
+  return false;
+}
+
+/**
+ * Records bookmark, annotation, and keyword observer notifications for all
+ * changes made during the merge, then fires the notifications after the merge
+ * is done.
+ *
+ * Recording bookmark changes and deletions is somewhat expensive, because we
+ * need to fetch all observer infos before writing. Making this more efficient
+ * is tracked in bug 1340498.
+ *
+ * Annotation observers don't require the extra context, so they're cheap to
+ * record and fire.
+ */
+class BookmarkObserverRecorder {
+  constructor(db) {
+    this.db = db;
+    this.bookmarkObserverNotifications = [];
+    this.annoObserverNotifications = [];
+    this.shouldInvalidateLivemarks = false;
+  }
+
+  /**
+   * Fires all recorded observer notifications, invalidates the livemark cache
+   * if necessary, and recalculates frecencies for changed URLs. This is called
+   * outside the merge transaction.
+   */
+  async notifyAll() {
+    this.notifyBookmarkObservers();
+    this.notifyAnnoObservers();
+    if (this.shouldInvalidateLivemarks) {
+      await PlacesUtils.livemarks.invalidateCachedLivemarks();
+    }
+    await this.updateFrecencies();
+  }
+
+  async updateFrecencies() {
+    await this.db.execute(`
+      UPDATE moz_places SET
+        frecency = CALCULATE_FRECENCY(id)
+      WHERE frecency = -1`);
+  }
+
+  noteItemAdded(info) {
+    let uri = info.urlHref ? Services.io.newURI(info.urlHref) : null;
+    this.bookmarkObserverNotifications.push({
+      name: "onItemAdded",
+      isTagging: info.isTagging,
+      args: [info.id, info.parentId, info.position, info.type, uri, info.title,
+        info.dateAdded, info.guid, info.parentGuid,
+        PlacesUtils.bookmarks.SOURCES.SYNC],
+    });
+  }
+
+  noteGuidChanged(info) {
+    PlacesUtils.invalidateCachedGuidFor(info.id);
+    this.bookmarkObserverNotifications.push({
+      name: "onItemChanged",
+      isTagging: false,
+      args: [info.id, "guid", /* isAnnotationProperty */ false, info.newGuid,
+        info.lastModified, info.type, info.parentId, info.newGuid,
+        info.parentGuid, info.oldGuid, PlacesUtils.bookmarks.SOURCES.SYNC],
+    });
+  }
+
+  noteItemMoved(info) {
+    this.bookmarkObserverNotifications.push({
+      name: "onItemMoved",
+      isTagging: false,
+      args: [info.id, info.oldParentId, info.oldPosition, info.newParentId,
+        info.newPosition, info.type, info.guid, info.oldParentGuid,
+        info.newParentGuid, PlacesUtils.bookmarks.SOURCES.SYNC],
+    });
+  }
+
+  noteItemChanged(info) {
+    if (info.oldTitle != info.newTitle) {
+      this.bookmarkObserverNotifications.push({
+        name: "onItemChanged",
+        isTagging: false,
+        args: [info.id, "title", /* isAnnotationProperty */ false,
+          info.newTitle, info.lastModified, info.type, info.parentId,
+          info.guid, info.parentGuid, info.oldTitle,
+          PlacesUtils.bookmarks.SOURCES.SYNC],
+      });
+    }
+    if (info.oldURLHref != info.newURLHref) {
+      this.bookmarkObserverNotifications.push({
+        name: "onItemChanged",
+        isTagging: false,
+        args: [info.id, "uri", /* isAnnotationProperty */ false,
+          info.newURLHref, info.lastModified, info.type, info.parentId,
+          info.guid, info.parentGuid, info.oldURLHref,
+          PlacesUtils.bookmarks.SOURCES.SYNC],
+      });
+    }
+  }
+
+  noteItemRemoved(info) {
+    let uri = info.urlHref ? Services.io.newURI(info.urlHref) : null;
+    this.bookmarkObserverNotifications.push({
+      name: "onItemRemoved",
+      isTagging: info.isUntagging,
+      args: [info.id, info.parentId, info.position, info.type, uri, info.guid,
+        info.parentGuid, PlacesUtils.bookmarks.SOURCES.SYNC],
+    });
+  }
+
+  noteKeywordChanged(info) {
+    this.bookmarkObserverNotifications.push({
+      name: "onItemChanged",
+      isTagging: false,
+      args: [info.id, "keyword", /* isAnnotationProperty */ false, info.keyword,
+        info.lastModified, info.type, info.parentId, info.guid, info.parentGuid,
+      /* oldValue */ info.urlHref, PlacesUtils.bookmarks.SOURCES.SYNC],
+    });
+  }
+
+  noteAnnoSet(id, name) {
+    if (isLivemarkAnno(name)) {
+      this.shouldInvalidateLivemarks = true;
+    }
+    this.annoObserverNotifications.push({
+      name: "onItemAnnotationSet",
+      args: [id, name, PlacesUtils.bookmarks.SOURCES.SYNC],
+    });
+  }
+
+  noteAnnoRemoved(id, name) {
+    if (isLivemarkAnno(name)) {
+      this.shouldInvalidateLivemarks = true;
+    }
+    this.annoObserverNotifications.push({
+      name: "onItemAnnotationRemoved",
+      args: [id, name, PlacesUtils.bookmarks.SOURCES.SYNC],
+    });
+  }
+
+  notifyBookmarkObservers() {
+    MirrorLog.debug("Notifying bookmark observers");
+    let observers = PlacesUtils.bookmarks.getObservers();
+    for (let observer of observers) {
+      this.notifyObserver(observer, "onBeginUpdateBatch");
+      for (let info of this.bookmarkObserverNotifications) {
+        if (info.isTagging && observer.skipTags) {
+          continue;
+        }
+        this.notifyObserver(observer, info.name, info.args);
+      }
+      this.notifyObserver(observer, "onEndUpdateBatch");
+    }
+  }
+
+  notifyAnnoObservers() {
+    MirrorLog.debug("Notifying anno observers");
+    let observers = PlacesUtils.annotations.getObservers();
+    for (let observer of observers) {
+      for (let { name, args } of this.annoObserverNotifications) {
+        this.notifyObserver(observer, name, args);
+      }
+    }
+  }
+
+  notifyObserver(observer, notification, args = []) {
+    try {
+      observer[notification](...args);
+    } catch (ex) {
+      MirrorLog.warn("Error notifying observer", ex);
+    }
+  }
+}
+
+function isLivemarkAnno(name) {
+  return name == PlacesUtils.LMANNO_FEEDURI ||
+         name == PlacesUtils.LMANNO_SITEURI;
+}
+
+/**
+ * Holds Sync metadata and the cleartext for a locally changed record. The
+ * bookmarks engine inflates a Sync record from the cleartext, and updates the
+ * `synced` property for successfully uploaded items.
+ *
+ * At the end of the sync, the engine writes the uploaded cleartext back to the
+ * mirror, and passes the updated change record as part of the changeset to
+ * `PlacesSyncUtils.bookmarks.pushChanges`.
+ */
+class BookmarkChangeRecord {
+  constructor(syncChangeCounter, cleartext) {
+    this.tombstone = cleartext.deleted === true;
+    this.counter = syncChangeCounter;
+    this.cleartext = cleartext;
+    this.synced = false;
+  }
+}
+
+// In conclusion, this is why bookmark syncing is hard.
--- a/toolkit/components/places/moz.build
+++ b/toolkit/components/places/moz.build
@@ -69,16 +69,17 @@ if CONFIG['MOZ_PLACES']:
         'History.jsm',
         'PlacesBackups.jsm',
         'PlacesDBUtils.jsm',
         'PlacesRemoteTabsAutocompleteProvider.jsm',
         'PlacesSearchAutocompleteProvider.jsm',
         'PlacesSyncUtils.jsm',
         'PlacesTransactions.jsm',
         'PlacesUtils.jsm',
+        'SyncedBookmarksMirror.jsm',
     ]
 
     EXTRA_COMPONENTS += [
         'ColorAnalyzer.js',
         'nsLivemarkService.js',
         'nsPlacesExpiration.js',
         'nsTaggingService.js',
         'PageIconProtocolHandler.js',
--- a/toolkit/components/places/tests/PlacesTestUtils.jsm
+++ b/toolkit/components/places/tests/PlacesTestUtils.jsm
@@ -351,9 +351,47 @@ this.PlacesTestUtils = Object.freeze({
             return false;
           }
           return () => false;
         }
       });
       PlacesUtils[type].addObserver(proxifiedObserver);
     });
   },
+
+  /**
+   * A debugging helper that dumps the contents of an SQLite table.
+   *
+   * @param {Sqlite.OpenedConnection} db
+   *        The mirror database connection.
+   * @param {String} table
+   *        The table name.
+   */
+  async dumpTable(db, table) {
+    let rows = await db.execute(`SELECT * FROM ${table}`);
+    dump(`Table ${table} contains ${rows.length} rows\n`);
+
+    let results = [];
+    for (let row of rows) {
+      let numColumns = row.numEntries;
+      let rowValues = [];
+      for (let i = 0; i < numColumns; ++i) {
+        switch (row.getTypeOfIndex(i)) {
+          case Ci.mozIStorageValueArray.VALUE_TYPE_NULL:
+            rowValues.push("NULL");
+            break;
+          case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER:
+            rowValues.push(row.getInt64(i));
+            break;
+          case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT:
+            rowValues.push(row.getDouble(i));
+            break;
+          case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT:
+            rowValues.push(JSON.stringify(row.getString(i)));
+            break;
+        }
+      }
+      results.push(rowValues.join("\t"));
+    }
+    results.push("\n");
+    dump(results.join("\n"));
+  },
 });
--- a/toolkit/components/places/tests/moz.build
+++ b/toolkit/components/places/tests/moz.build
@@ -13,16 +13,17 @@ TESTING_JS_MODULES += [
 XPCSHELL_TESTS_MANIFESTS += [
     'bookmarks/xpcshell.ini',
     'expiration/xpcshell.ini',
     'favicons/xpcshell.ini',
     'history/xpcshell.ini',
     'legacy/xpcshell.ini',
     'migration/xpcshell.ini',
     'queries/xpcshell.ini',
+    'sync/xpcshell.ini',
     'unifiedcomplete/xpcshell.ini',
     'unit/xpcshell.ini',
 ]
 
 BROWSER_CHROME_MANIFESTS += ['browser/browser.ini']
 MOCHITEST_CHROME_MANIFESTS += [
     'chrome/chrome.ini',
 ]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/head_sync.js
@@ -0,0 +1,190 @@
+const { utils: Cu, interfaces: Ci, classes: Cc, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/ObjectUtils.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+Cu.import("resource://gre/modules/PlacesSyncUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/SyncedBookmarksMirror.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+Cu.import("resource://testing-common/PlacesTestUtils.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+function run_test() {
+  let bufLog = Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror");
+  bufLog.level = Log.Level.Error;
+
+  let sqliteLog = Log.repository.getLogger("Sqlite");
+  sqliteLog.level = Log.Level.Error;
+
+  let formatter = new Log.BasicFormatter();
+  let appender = new Log.DumpAppender(formatter);
+  appender.level = Log.Level.All;
+
+  for (let log of [bufLog, sqliteLog]) {
+    log.addAppender(appender);
+  }
+
+  do_get_profile();
+  run_next_test();
+}
+
+function inspectChangeRecords(changeRecords) {
+  let results = { updated: [], deleted: [] };
+  for (let [id, record] of Object.entries(changeRecords)) {
+    (record.tombstone ? results.deleted : results.updated).push(id);
+  }
+  results.updated.sort();
+  results.deleted.sort();
+  return results;
+}
+
+async function assertLocalTree(rootGuid, expected, message) {
+  function bookmarkNodeToInfo(node) {
+    let { guid, index, title, typeCode: type } = node;
+    let itemInfo = { guid, index, title, type };
+    if (node.annos) {
+      let syncableAnnos = node.annos.filter(anno => [
+        PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO,
+        PlacesSyncUtils.bookmarks.SIDEBAR_ANNO,
+        PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO,
+        PlacesUtils.LMANNO_FEEDURI,
+        PlacesUtils.LMANNO_SITEURI,
+      ].includes(anno.name));
+      if (syncableAnnos.length) {
+        itemInfo.annos = syncableAnnos;
+      }
+    }
+    if (node.uri) {
+      itemInfo.url = node.uri;
+    }
+    if (node.keyword) {
+      itemInfo.keyword = node.keyword;
+    }
+    if (node.children) {
+      itemInfo.children = node.children.map(bookmarkNodeToInfo);
+    }
+    return itemInfo;
+  }
+  let root = await PlacesUtils.promiseBookmarksTree(rootGuid);
+  let actual = bookmarkNodeToInfo(root);
+  if (!ObjectUtils.deepEqual(actual, expected)) {
+    info(`Expected structure for ${rootGuid}: ${JSON.stringify(expected)}`);
+    info(`Actual structure for ${rootGuid}: ${JSON.stringify(actual)}`);
+    throw new Assert.constructor.AssertionError({ actual, expected, message });
+  }
+}
+
+function makeLivemarkServer() {
+  let server = new HttpServer();
+  server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml"));
+  server.start(-1);
+  return {
+    server,
+    get site() {
+      let { identity } = server;
+      let host = identity.primaryHost.includes(":") ?
+        `[${identity.primaryHost}]` : identity.primaryHost;
+      return `${identity.primaryScheme}://${host}:${identity.primaryPort}`;
+    },
+    stopServer() {
+      return new Promise(resolve => server.stop(resolve));
+    },
+  };
+}
+
+function shuffle(array) {
+  let results = [];
+  for (let i = 0; i < array.length; ++i) {
+    let randomIndex = Math.floor(Math.random() * (i + 1));
+    results[i] = results[randomIndex];
+    results[randomIndex] = array[i];
+  }
+  return results;
+}
+
+async function fetchAllKeywords(info) {
+  let entries = [];
+  await PlacesUtils.keywords.fetch(info, entry => entries.push(entry));
+  return entries;
+}
+
+async function openMirror(name) {
+  let path = OS.Path.join(OS.Constants.Path.profileDir, `${name}_buf.sqlite`);
+  let buf = await SyncedBookmarksMirror.open({
+    path,
+    recordTelemetryEvent() {},
+  });
+  return buf;
+}
+
+function BookmarkObserver({ ignoreDates = true, skipTags = false } = {}) {
+  this.notifications = [];
+  this.ignoreDates = ignoreDates;
+  this.skipTags = skipTags;
+}
+
+BookmarkObserver.prototype = {
+  onBeginUpdateBatch() {},
+  onEndUpdateBatch() {},
+  onItemAdded(itemId, parentId, index, type, uri, title, dateAdded, guid,
+              parentGuid, source) {
+    let urlHref = uri ? uri.spec : null;
+    let params = { itemId, parentId, index, type, urlHref, title, guid,
+                   parentGuid, source };
+    if (!this.ignoreDates) {
+      params.dateAdded = dateAdded;
+    }
+    this.notifications.push({ name: "onItemAdded", params });
+  },
+  onItemRemoved(itemId, parentId, index, type, uri, guid, parentGuid, source) {
+    let urlHref = uri ? uri.spec : null;
+    this.notifications.push({
+      name: "onItemRemoved",
+      params: { itemId, parentId, index, type, urlHref, guid, parentGuid,
+                 source },
+    });
+  },
+  onItemChanged(itemId, property, isAnnoProperty, newValue, lastModified, type,
+                parentId, guid, parentGuid, oldValue, source) {
+    let params = { itemId, property, isAnnoProperty, newValue, type, parentId,
+                   guid, parentGuid, oldValue, source };
+    if (!this.ignoreDates) {
+      params.lastModified = lastModified;
+    }
+    this.notifications.push({ name: "onItemChanged", params });
+  },
+  onItemVisited() {},
+  onItemMoved(itemId, oldParentId, oldIndex, newParentId, newIndex, type, guid,
+              oldParentGuid, newParentGuid, source) {
+    this.notifications.push({
+      name: "onItemMoved",
+      params: { itemId, oldParentId, oldIndex, newParentId, newIndex, type,
+                guid, oldParentGuid, newParentGuid, source },
+    });
+  },
+
+  QueryInterface: XPCOMUtils.generateQI([
+    Ci.nsINavBookmarkObserver,
+  ]),
+
+  check(expectedNotifications) {
+    PlacesUtils.bookmarks.removeObserver(this);
+    if (!ObjectUtils.deepEqual(this.notifications, expectedNotifications)) {
+      info(`Expected notifications: ${JSON.stringify(expectedNotifications)}`);
+      info(`Actual notifications: ${JSON.stringify(this.notifications)}`);
+      throw new Assert.constructor.AssertionError({
+        actual: this.notifications,
+        expected: expectedNotifications,
+      });
+    }
+  },
+};
+
+function expectBookmarkChangeNotifications(options) {
+  let observer = new BookmarkObserver(options);
+  PlacesUtils.bookmarks.addObserver(observer);
+  return observer;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/livemark.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <title>Livemark Feed</title>
+  <link href="https://example.com/"/>
+  <updated>2016-08-09T19:51:45.147Z</updated>
+  <author>
+    <name>John Doe</name>
+  </author>
+  <id>urn:uuid:e7947414-6ee0-4009-ae75-8b0ad3c6894b</id>
+  <entry>
+    <title>Some awesome article</title>
+    <link href="https://example.com/some-article"/>
+    <id>urn:uuid:d72ce019-0a56-4a0b-ac03-f66117d78141</id>
+    <updated>2016-08-09T19:57:22.178Z</updated>
+    <summary>My great article summary.</summary>
+  </entry>
+</feed>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_corruption.js
@@ -0,0 +1,1000 @@
+add_task(async function test_missing_children() {
+  let buf = await openMirror("missing_childen");
+
+  info("Set up empty mirror");
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make remote changes: A > ([B] C [D E])");
+  {
+    await buf.store(shuffle([{
+      id: "menu",
+      type: "folder",
+      title: "Bookmarks Menu",
+      children: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD",
+                 "bookmarkEEEE"],
+    }, {
+      id: "bookmarkCCCC",
+      type: "bookmark",
+      bmkUri: "http://example.com/c",
+      title: "C",
+    }]));
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload menu with missing children (B D E)");
+    await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "C",
+        url: "http://example.com/c",
+      }],
+    }, "Menu children should be (C)");
+    let { missingChildren } = await buf.fetchRemoteOrphans();
+    deepEqual(missingChildren.sort(), ["bookmarkBBBB", "bookmarkDDDD",
+      "bookmarkEEEE"], "Should report (B D E) as missing");
+  }
+
+  info("Add (B E) to remote");
+  {
+    await buf.store(shuffle([{
+      id: "bookmarkBBBB",
+      type: "bookmark",
+      title: "B",
+      bmkUri: "http://example.com/b",
+    }, {
+      id: "bookmarkEEEE",
+      type: "bookmark",
+      title: "E",
+      bmkUri: "http://example.com/e",
+    }]));
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload menu with missing child D");
+    await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "bookmarkBBBB",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "B",
+        url: "http://example.com/b",
+      }, {
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 1,
+        title: "C",
+        url: "http://example.com/c",
+      }, {
+        guid: "bookmarkEEEE",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 2,
+        title: "E",
+        url: "http://example.com/e",
+      }],
+    }, "Menu children should be (B C E)");
+    let { missingChildren } = await buf.fetchRemoteOrphans();
+    deepEqual(missingChildren, ["bookmarkDDDD"],
+      "Should report (D) as missing");
+  }
+
+  info("Add D to remote");
+  {
+    await buf.store([{
+      id: "bookmarkDDDD",
+      type: "bookmark",
+      title: "D",
+      bmkUri: "http://example.com/d",
+    }]);
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload complete menu");
+    await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "bookmarkBBBB",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "B",
+        url: "http://example.com/b",
+      }, {
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 1,
+        title: "C",
+        url: "http://example.com/c",
+      }, {
+        guid: "bookmarkDDDD",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 2,
+        title: "D",
+        url: "http://example.com/d",
+      }, {
+        guid: "bookmarkEEEE",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 3,
+        title: "E",
+        url: "http://example.com/e",
+      }],
+    }, "Menu children should be (B C D E)");
+    let { missingChildren } = await buf.fetchRemoteOrphans();
+    deepEqual(missingChildren, [], "Should not report any missing children");
+  }
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_new_orphan_without_local_parent() {
+  let buf = await openMirror("new_orphan_without_local_parent");
+
+  info("Set up empty mirror");
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  // A doesn't exist locally, so we move the bookmarks into "unfiled" without
+  // reuploading. When the partial uploader returns and uploads A, we'll
+  // move the bookmarks to the correct folder.
+  info("Make remote changes: [A] > (B C D)");
+  await buf.store(shuffle([{
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B (remote)",
+    bmkUri: "http://example.com/b-remote",
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C (remote)",
+    bmkUri: "http://example.com/c-remote",
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D (remote)",
+    bmkUri: "http://example.com/d-remote",
+  }]));
+
+  info("Apply remote with (B C D)");
+  {
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload orphans (B C D)");
+  }
+
+  await assertLocalTree(PlacesUtils.bookmarks.unfiledGuid, {
+    guid: PlacesUtils.bookmarks.unfiledGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 3,
+    title: "Other Bookmarks",
+    children: [{
+      guid: "bookmarkBBBB",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 0,
+      title: "B (remote)",
+      url: "http://example.com/b-remote",
+    }, {
+      guid: "bookmarkCCCC",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 1,
+      title: "C (remote)",
+      url: "http://example.com/c-remote",
+    }, {
+      guid: "bookmarkDDDD",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 2,
+      title: "D (remote)",
+      url: "http://example.com/d-remote",
+    }],
+  }, "Should move (B C D) to unfiled");
+
+  // A is an orphan because we don't have E locally, but we should move
+  // (B C D) into A.
+  info("Add [E] > A to remote");
+  await buf.store([{
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkDDDD", "bookmarkCCCC", "bookmarkBBBB"],
+  }]);
+
+  info("Apply remote with A");
+  {
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload orphan A");
+  }
+
+  await assertLocalTree(PlacesUtils.bookmarks.unfiledGuid, {
+    guid: PlacesUtils.bookmarks.unfiledGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 3,
+    title: "Other Bookmarks",
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "A",
+      children: [{
+        guid: "bookmarkDDDD",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "D (remote)",
+        url: "http://example.com/d-remote",
+      }, {
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 1,
+        title: "C (remote)",
+        url: "http://example.com/c-remote",
+      }, {
+        guid: "bookmarkBBBB",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 2,
+        title: "B (remote)",
+        url: "http://example.com/b-remote",
+      }],
+    }],
+  }, "Should move (D C B) into A");
+
+  info("Add E to remote");
+  await buf.store([{
+    id: "folderEEEEEE",
+    type: "folder",
+    title: "E",
+    children: ["folderAAAAAA"],
+  }]);
+
+  info("Apply remote with E");
+  {
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload orphan E");
+  }
+
+  // E is still in unfiled because we don't have a record for the menu.
+  await assertLocalTree(PlacesUtils.bookmarks.unfiledGuid, {
+    guid: PlacesUtils.bookmarks.unfiledGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 3,
+    title: "Other Bookmarks",
+    children: [{
+      guid: "folderEEEEEE",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "E",
+      children: [{
+        guid: "folderAAAAAA",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "A",
+        children: [{
+          guid: "bookmarkDDDD",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "D (remote)",
+          url: "http://example.com/d-remote",
+        }, {
+          guid: "bookmarkCCCC",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 1,
+          title: "C (remote)",
+          url: "http://example.com/c-remote",
+        }, {
+          guid: "bookmarkBBBB",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 2,
+          title: "B (remote)",
+          url: "http://example.com/b-remote",
+        }],
+      }],
+    }],
+  }, "Should move A into E");
+
+  info("Add Menu > E to remote");
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderEEEEEE"],
+  }]);
+
+  info("Apply remote with menu");
+  {
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload after forming complete tree");
+  }
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "folderEEEEEE",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "E",
+        children: [{
+          guid: "folderAAAAAA",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 0,
+          title: "A",
+          children: [{
+            guid: "bookmarkDDDD",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 0,
+            title: "D (remote)",
+            url: "http://example.com/d-remote",
+          }, {
+            guid: "bookmarkCCCC",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 1,
+            title: "C (remote)",
+            url: "http://example.com/c-remote",
+          }, {
+            guid: "bookmarkBBBB",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 2,
+            title: "B (remote)",
+            url: "http://example.com/b-remote",
+          }],
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should form complete tree after applying E");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move_into_orphaned() {
+  let buf = await openMirror("move_into_orphaned");
+
+  info("Set up mirror: Menu > (A B (C > (D (E > F))))");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkAAAA",
+      url: "http://example.com/a",
+      title: "A",
+    }, {
+      guid: "bookmarkBBBB",
+      url: "http://example.com/b",
+      title: "B",
+    }, {
+      guid: "folderCCCCCC",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "C",
+      children: [{
+        guid: "bookmarkDDDD",
+        title: "D",
+        url: "http://example.com/d",
+      }, {
+        guid: "folderEEEEEE",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "E",
+        children: [{
+          guid: "bookmarkFFFF",
+          title: "F",
+          url: "http://example.com/f",
+        }],
+      }],
+    }],
+  });
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "bookmarkBBBB", "folderCCCCCC"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+  }, {
+    id: "folderCCCCCC",
+    type: "folder",
+    title: "C",
+    children: ["bookmarkDDDD", "folderEEEEEE"],
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D",
+    bmkUri: "http://example.com/d",
+  }, {
+    id: "folderEEEEEE",
+    type: "folder",
+    title: "E",
+    children: ["bookmarkFFFF"],
+  }, {
+    id: "bookmarkFFFF",
+    type: "bookmark",
+    title: "F",
+    bmkUri: "http://example.com/f",
+  }], { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local changes: delete D, add E > I");
+  await PlacesUtils.bookmarks.remove("bookmarkDDDD");
+  await PlacesUtils.bookmarks.insert({
+    guid: "bookmarkIIII",
+    parentGuid: "folderEEEEEE",
+    title: "I (local)",
+    url: "http://example.com/i",
+  });
+
+  // G doesn't exist on the server.
+  info("Make remote changes: ([G] > A (C > (D H E))), (C > H)");
+  await buf.store(shuffle([{
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+  }, {
+    id: "folderCCCCCC",
+    type: "folder",
+    title: "C",
+    children: ["bookmarkDDDD", "bookmarkHHHH", "folderEEEEEE"],
+  }, {
+    id: "bookmarkHHHH",
+    type: "bookmark",
+    title: "H (remote)",
+    bmkUri: "http://example.com/h-remote",
+  }]));
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkIIII", "folderCCCCCC", "folderEEEEEE"],
+    deleted: ["bookmarkDDDD"],
+  }, "Should upload records for (I C E); tombstone for D");
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        // A remains in its original place, since we don't use the `parentid`,
+        // and we don't have a record for G.
+        guid: "bookmarkAAAA",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "A",
+        url: "http://example.com/a",
+      }, {
+        guid: "bookmarkBBBB",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 1,
+        title: "B",
+        url: "http://example.com/b",
+      }, {
+        // C exists on the server, so we take its children and order. D was
+        // deleted locally, and doesn't exist remotely. C is also a child of
+        // G, but we don't have a record for it on the server.
+        guid: "folderCCCCCC",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 2,
+        title: "C",
+        children: [{
+          guid: "bookmarkHHHH",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "H (remote)",
+          url: "http://example.com/h-remote",
+        }, {
+          guid: "folderEEEEEE",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 1,
+          title: "E",
+          children: [{
+            guid: "bookmarkFFFF",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 0,
+            title: "F",
+            url: "http://example.com/f",
+          }, {
+            guid: "bookmarkIIII",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 1,
+            title: "I (local)",
+            url: "http://example.com/i",
+          }],
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should treat local tree as canonical if server is missing new parent");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_new_orphan_with_local_parent() {
+  let buf = await openMirror("new_orphan_with_local_parent");
+
+  info("Set up mirror: A > (B D)");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "bookmarkBBBB",
+        url: "http://example.com/b",
+        title: "B",
+      }, {
+        guid: "bookmarkEEEE",
+        url: "http://example.com/e",
+        title: "E",
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkBBBB", "bookmarkEEEE"],
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  // Simulate a partial write by another device that uploaded only B and C. A
+  // exists locally, so we can move B and C into the correct folder, but not
+  // the correct positions.
+  info("Set up remote with orphans: [A] > (C D)");
+  await buf.store([{
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D (remote)",
+    bmkUri: "http://example.com/d-remote",
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C (remote)",
+    bmkUri: "http://example.com/c-remote",
+  }]);
+
+  info("Apply remote with (C D)");
+  {
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload orphans (C D)");
+  }
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "folderAAAAAA",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "A",
+        children: [{
+          guid: "bookmarkBBBB",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "B",
+          url: "http://example.com/b",
+        }, {
+          guid: "bookmarkEEEE",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 1,
+          title: "E",
+          url: "http://example.com/e",
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+      children: [{
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "C (remote)",
+        url: "http://example.com/c-remote",
+      }, {
+        guid: "bookmarkDDDD",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 1,
+        title: "D (remote)",
+        url: "http://example.com/d-remote",
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should move (C D) to unfiled");
+
+  // The partial uploader returns and uploads A.
+  info("Add A to remote");
+  await buf.store([{
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkCCCC", "bookmarkDDDD", "bookmarkEEEE", "bookmarkBBBB"],
+  }]);
+
+  info("Apply remote with A");
+  {
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    let idsToUpload = inspectChangeRecords(changesToUpload);
+    deepEqual(idsToUpload, {
+      updated: [],
+      deleted: [],
+    }, "Should not reupload orphan A");
+  }
+
+  await assertLocalTree("folderAAAAAA", {
+    guid: "folderAAAAAA",
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "A",
+    children: [{
+      guid: "bookmarkCCCC",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 0,
+      title: "C (remote)",
+      url: "http://example.com/c-remote",
+    }, {
+      guid: "bookmarkDDDD",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 1,
+      title: "D (remote)",
+      url: "http://example.com/d-remote",
+    }, {
+      guid: "bookmarkEEEE",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 2,
+      title: "E",
+      url: "http://example.com/e",
+    }, {
+      guid: "bookmarkBBBB",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 3,
+      title: "B",
+      url: "http://example.com/b",
+    }],
+  }, "Should update child positions once A exists in mirror");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_tombstone_as_child() {
+  // TODO (Bug 1433180): Add a folder that mentions a tombstone in its
+  // `children`.
+});
+
+add_task(async function test_left_pane_root() {
+  // TODO (Bug 1433182): Add a left pane root to the mirror.
+});
+
+add_task(async function test_partial_cycle() {
+  let buf = await openMirror("partial_cycle");
+
+  info("Set up mirror: Menu > A > B > C");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "folderBBBBBB",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "B",
+        children: [{
+          guid: "bookmarkCCCC",
+          url: "http://example.com/c",
+          title: "C",
+        }],
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["folderBBBBBB"],
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B",
+    children: ["bookmarkCCCC"],
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  // Try to create a cycle: move A into B, and B into the menu, but don't upload
+  // a record for the menu. B is still a child of A locally. Since we ignore the
+  // `parentid`, we'll move (B A) into unfiled.
+  info("Make remote changes: A > C");
+  await buf.store([{
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A (remote)",
+    children: ["bookmarkCCCC"],
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B (remote)",
+    children: ["folderAAAAAA"],
+  }]);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, { updated: [], deleted: [] },
+    "Should not mark any local items for upload");
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+      children: [{
+        guid: "folderBBBBBB",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "B (remote)",
+        children: [{
+          guid: "folderAAAAAA",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 0,
+          title: "A (remote)",
+          children: [{
+            guid: "bookmarkCCCC",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 0,
+            title: "C",
+            url: "http://example.com/c",
+          }],
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should move A and B to unfiled");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_complete_cycle() {
+  let buf = await openMirror("complete_cycle");
+
+  info("Set up empty mirror");
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  // This test is order-dependent. We shouldn't recurse infinitely, but,
+  // depending on the order of the records, we might ignore the circular
+  // subtree because there's nothing linking it back to the rest of the
+  // tree.
+  info("Make remote changes: Menu > A > B > C > A");
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["folderBBBBBB"],
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B",
+    children: ["folderCCCCCC"],
+  }, {
+    id: "folderCCCCCC",
+    type: "folder",
+    title: "C",
+    children: ["folderDDDDDD"],
+  }, {
+    id: "folderDDDDDD",
+    type: "folder",
+    title: "D",
+    children: ["folderAAAAAA"],
+  }]);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual((await buf.fetchUnmergedGuids()).sort(), ["folderAAAAAA",
+    "folderBBBBBB", "folderCCCCCC", "folderDDDDDD"],
+    "Should leave items in circular subtree unmerged");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, { updated: [], deleted: [] },
+    "Should not mark any local items for upload");
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should not be confused into creating a cycle");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_deduping.js
@@ -0,0 +1,444 @@
+add_task(async function test_duping() {
+  let buf = await openMirror("duping");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      // Shouldn't dupe to `folderA11111` because its sync status is "NORMAL".
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        // Shouldn't dupe to `bookmarkG111`.
+        guid: "bookmarkGGGG",
+        url: "http://example.com/g",
+        title: "G",
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkGGGG"],
+  }, {
+    id: "bookmarkGGGG",
+    type: "bookmark",
+    title: "G",
+    url: "http://example.com/g",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Insert local dupes");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      // Should dupe to `folderB11111`.
+      guid: "folderBBBBBB",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "B",
+      children: [{
+        // Should dupe to `bookmarkC222`.
+        guid: "bookmarkC111",
+        url: "http://example.com/c",
+        title: "C",
+      }, {
+        // Should dupe to `separatorF11` because the positions are the same.
+        guid: "separatorFFF",
+        type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+      }],
+    }, {
+      // Shouldn't dupe to `separatorE11`, because the positions are different.
+      guid: "separatorEEE",
+      type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+    }, {
+      // Shouldn't dupe to `bookmarkC222` because the parents are different.
+      guid: "bookmarkCCCC",
+      url: "http://example.com/c",
+      title: "C",
+    }, {
+      // Should dupe to `queryD111111`.
+      guid: "queryDDDDDDD",
+      url: "place:sort=8&maxResults=10",
+      title: "Most Visited",
+      annos: [{
+        name: PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO,
+        value: "MostVisited",
+      }],
+    }],
+  });
+  // Not a candidate for `bookmarkH111` because we didn't dupe `folderAAAAAA`.
+  await PlacesUtils.bookmarks.insert({
+    parentGuid: "folderAAAAAA",
+    guid: "bookmarkHHHH",
+    url: "http://example.com/h",
+    title: "H",
+  });
+
+  info("Make remote changes");
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA", "folderB11111", "folderA11111",
+               "separatorE11", "queryD111111"],
+  }, {
+    id: "folderB11111",
+    type: "folder",
+    title: "B",
+    children: ["bookmarkC222", "separatorF11"],
+  }, {
+    id: "bookmarkC222",
+    type: "bookmark",
+    bmkUri: "http://example.com/c",
+    title: "C",
+  }, {
+    id: "separatorF11",
+    type: "separator",
+  }, {
+    id: "folderA11111",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkG111"],
+  }, {
+    id: "bookmarkG111",
+    type: "bookmark",
+    bmkUri: "http://example.com/g",
+    title: "G",
+  }, {
+    id: "separatorE11",
+    type: "separator",
+  }, {
+    id: "queryD111111",
+    type: "query",
+    bmkUri: "place:maxResults=10&sort=8",
+    title: "Most Visited",
+    queryId: "MostVisited",
+  }]));
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkCCCC", "bookmarkHHHH", "folderAAAAAA", "menu",
+              "separatorEEE"],
+    deleted: [],
+  }, "Should not upload deduped local records");
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "folderAAAAAA",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "A",
+        children: [{
+          guid: "bookmarkGGGG",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "G",
+          url: "http://example.com/g",
+        }, {
+          guid: "bookmarkHHHH",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 1,
+          title: "H",
+          url: "http://example.com/h",
+        }],
+      }, {
+        guid: "folderB11111",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 1,
+        title: "B",
+        children: [{
+          guid: "bookmarkC222",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "C",
+          url: "http://example.com/c",
+        }, {
+          guid: "separatorF11",
+          type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+          index: 1,
+          title: "",
+        }],
+      }, {
+        guid: "folderA11111",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 2,
+        title: "A",
+        children: [{
+          guid: "bookmarkG111",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "G",
+          url: "http://example.com/g",
+        }],
+      }, {
+        guid: "separatorE11",
+        type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+        index: 3,
+        title: "",
+      }, {
+        guid: "queryD111111",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 4,
+        title: "Most Visited",
+        url: "place:maxResults=10&sort=8",
+        annos: [{
+          name: PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO,
+          flags: 0,
+          expires: PlacesUtils.annotations.EXPIRE_NEVER,
+          value: "MostVisited",
+        }],
+      }, {
+        guid: "separatorEEE",
+        type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+        index: 5,
+        title: "",
+      }, {
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 6,
+        title: "C",
+        url: "http://example.com/c",
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should dedupe matching NEW bookmarks");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_applying_two_empty_folders_doesnt_smush() {
+  let buf = await openMirror("applying_two_empty_folders_doesnt_smush");
+
+  info("Set up empty mirror");
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make remote changes");
+  await buf.store(shuffle([{
+    id: "mobile",
+    type: "folder",
+    title: "mobile",
+    children: ["emptyempty01", "emptyempty02"],
+  }, {
+    id: "emptyempty01",
+    type: "folder",
+    title: "Empty",
+  }, {
+    id: "emptyempty02",
+    type: "folder",
+    title: "Empty",
+  }]));
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: [],
+    deleted: [],
+  }, "Should not upload records for remote-only value changes");
+
+  await assertLocalTree(PlacesUtils.bookmarks.mobileGuid, {
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 4,
+    title: "mobile",
+    children: [{
+      guid: "emptyempty01",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Empty",
+    }, {
+      guid: "emptyempty02",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Empty",
+    }],
+  }, "Should not smush 1 and 2");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_applying_two_empty_folders_matches_only_one() {
+  let buf = await openMirror("applying_two_empty_folders_doesnt_smush");
+
+  info("Set up empty mirror");
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local changes");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    children: [{
+      guid: "emptyempty02",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "Empty",
+    }, {
+      guid: "emptyemptyL0",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "Empty",
+    }],
+  });
+
+  info("Make remote changes");
+  await buf.store(shuffle([{
+    id: "mobile",
+    type: "folder",
+    title: "mobile",
+    children: ["emptyempty01", "emptyempty02", "emptyempty03"],
+  }, {
+    id: "emptyempty01",
+    type: "folder",
+    title: "Empty",
+  }, {
+    id: "emptyempty02",
+    type: "folder",
+    title: "Empty",
+  }, {
+    id: "emptyempty03",
+    type: "folder",
+    title: "Empty",
+  }]));
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: [],
+    deleted: [],
+  }, "Should not upload records after applying empty folders");
+
+  await assertLocalTree(PlacesUtils.bookmarks.mobileGuid, {
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 4,
+    title: "mobile",
+    children: [{
+      guid: "emptyempty01",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Empty",
+    }, {
+      guid: "emptyempty02",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Empty",
+    }, {
+      guid: "emptyempty03",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 2,
+      title: "Empty",
+    }],
+  }, "Should apply 1 and dedupe L0 to 3");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+// Bug 747699.
+add_task(async function test_duping_mobile_bookmarks() {
+  let buf = await openMirror("duping_mobile_bookmarks");
+
+  info("Set up empty mirror with localized mobile root title");
+  let mobileInfo = await PlacesUtils.bookmarks.fetch(
+    PlacesUtils.bookmarks.mobileGuid);
+  await PlacesUtils.bookmarks.update({
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    title: "Favoritos do celular",
+  });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local changes");
+  await PlacesUtils.bookmarks.insert({
+    guid: "bookmarkAAA1",
+    parentGuid: PlacesUtils.bookmarks.mobileGuid,
+    title: "A",
+    url: "http://example.com/a",
+  });
+
+  info("Make remote changes");
+  await buf.store(shuffle([{
+    id: "mobile",
+    type: "folder",
+    title: "Mobile Bookmarks",
+    children: ["bookmarkAAAA"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+  }]));
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: [],
+    deleted: [],
+  }, "Should not upload records after applying deduped mobile bookmark");
+
+  await assertLocalTree(PlacesUtils.bookmarks.mobileGuid, {
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 4,
+    title: "Mobile Bookmarks",
+    children: [{
+      guid: "bookmarkAAAA",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 0,
+      title: "A",
+      url: "http://example.com/a",
+    }],
+  }, "Should dedupe A1 to A with different parent title");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  // Restore the original mobile root title.
+  await PlacesUtils.bookmarks.update({
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    title: mobileInfo.title,
+  });
+  await PlacesSyncUtils.bookmarks.reset();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_deletion.js
@@ -0,0 +1,587 @@
+add_task(async function test_complex_orphaning() {
+  let buf = await openMirror("complex_orphaning");
+
+  // On iOS, the mirror exists as a separate table. On Desktop, we have a
+  // shadow mirror of synced local bookmarks without new changes.
+  info("Set up mirror: ((Toolbar > A > B) (Menu > G > C > D))");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.toolbarGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "folderBBBBBB",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "B",
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "toolbar",
+    type: "folder",
+    title: "Bookmarks Toolbar",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["folderBBBBBB"],
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B",
+  }]), { needsMerge: false });
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderGGGGGG",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "G",
+      children: [{
+        guid: "folderCCCCCC",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "C",
+        children: [{
+          guid: "folderDDDDDD",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          title: "D",
+        }],
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderGGGGGG"],
+  }, {
+    id: "folderGGGGGG",
+    type: "folder",
+    title: "G",
+    children: ["folderCCCCCC"],
+  }, {
+    id: "folderCCCCCC",
+    type: "folder",
+    title: "C",
+    children: ["folderDDDDDD"],
+  }, {
+    id: "folderDDDDDD",
+    type: "folder",
+    title: "D",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local changes: delete D, add B > E");
+  await PlacesUtils.bookmarks.remove("folderDDDDDD");
+  await PlacesUtils.bookmarks.insert({
+    guid: "bookmarkEEEE",
+    parentGuid: "folderBBBBBB",
+    title: "E",
+    url: "http://example.com/e",
+  });
+
+  info("Make remote changes: delete B, add D > F");
+  await buf.store(shuffle([{
+    id: "folderBBBBBB",
+    deleted: true,
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+  }, {
+    id: "folderDDDDDD",
+    type: "folder",
+    children: ["bookmarkFFFF"],
+  }, {
+    id: "bookmarkFFFF",
+    type: "bookmark",
+    title: "F",
+    bmkUri: "http://example.com/f",
+  }]));
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkEEEE", "bookmarkFFFF", "folderAAAAAA", "folderCCCCCC"],
+    deleted: ["folderDDDDDD"],
+  }, "Should upload new records for (A > E), (C > F); tombstone for D");
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "folderGGGGGG",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "G",
+        children: [{
+          guid: "folderCCCCCC",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 0,
+          title: "C",
+          children: [{
+            // D was deleted, so F moved to C, the closest surviving parent.
+            guid: "bookmarkFFFF",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 0,
+            title: "F",
+            url: "http://example.com/f",
+          }],
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+      children: [{
+        guid: "folderAAAAAA",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "A",
+        children: [{
+          // B was deleted, so E moved to A.
+          guid: "bookmarkEEEE",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "E",
+          url: "http://example.com/e",
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should move orphans to closest surviving parent");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_locally_modified_remotely_deleted() {
+  let buf = await openMirror("locally_modified_remotely_deleted");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkAAAA",
+      title: "A",
+      url: "http://example.com/a",
+    }, {
+      guid: "folderBBBBBB",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "B",
+      children: [{
+        guid: "bookmarkCCCC",
+        title: "C",
+        url: "http://example.com/c",
+      }, {
+        guid: "folderDDDDDD",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "D",
+        children: [{
+          guid: "bookmarkEEEE",
+          title: "E",
+          url: "http://example.com/e",
+        }],
+      }],
+    }],
+  });
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "folderBBBBBB"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B",
+    children: ["bookmarkCCCC", "folderDDDDDD"],
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }, {
+    id: "folderDDDDDD",
+    type: "folder",
+    title: "D",
+    children: ["bookmarkEEEE"],
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+  }], { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local changes: change A; B > ((D > F) G)");
+  await PlacesUtils.bookmarks.update({
+    guid: "bookmarkAAAA",
+    title: "A (local)",
+    url: "http://example.com/a-local",
+  });
+  await PlacesUtils.bookmarks.insert({
+    guid: "bookmarkFFFF",
+    parentGuid: "folderDDDDDD",
+    title: "F (local)",
+    url: "http://example.com/f-local",
+  });
+  await PlacesUtils.bookmarks.insert({
+    guid: "bookmarkGGGG",
+    parentGuid: "folderBBBBBB",
+    title: "G (local)",
+    url: "http://example.com/g-local",
+  });
+
+  info("Make remote changes: delete A, B");
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: [],
+  }, {
+    id: "bookmarkAAAA",
+    deleted: true,
+  }, {
+    id: "folderBBBBBB",
+    deleted: true,
+  }, {
+    id: "bookmarkCCCC",
+    deleted: true,
+  }, {
+    id: "folderDDDDDD",
+    deleted: true,
+  }, {
+    id: "bookmarkEEEE",
+    deleted: true,
+  }]);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkAAAA", "bookmarkFFFF", "bookmarkGGGG", "menu"],
+    deleted: [],
+  }, "Should upload A, relocated local orphans, and menu");
+
+  await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+    guid: PlacesUtils.bookmarks.menuGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "Bookmarks Menu",
+    children: [{
+      guid: "bookmarkAAAA",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 0,
+      title: "A (local)",
+      url: "http://example.com/a-local",
+    }, {
+      guid: "bookmarkFFFF",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 1,
+      title: "F (local)",
+      url: "http://example.com/f-local",
+    }, {
+      guid: "bookmarkGGGG",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 2,
+      title: "G (local)",
+      url: "http://example.com/g-local",
+    }],
+  }, "Should restore A and relocate (F G) to menu");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_locally_deleted_remotely_modified() {
+  let buf = await openMirror("locally_deleted_remotely_modified");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkAAAA",
+      title: "A",
+      url: "http://example.com/a",
+    }, {
+      guid: "folderBBBBBB",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "B",
+      children: [{
+        guid: "bookmarkCCCC",
+        title: "C",
+        url: "http://example.com/c",
+      }, {
+        guid: "folderDDDDDD",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "D",
+        children: [{
+          guid: "bookmarkEEEE",
+          title: "E",
+          url: "http://example.com/e",
+        }],
+      }],
+    }],
+  });
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "folderBBBBBB"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B",
+    children: ["bookmarkCCCC", "folderDDDDDD"],
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }, {
+    id: "folderDDDDDD",
+    type: "folder",
+    title: "D",
+    children: ["bookmarkEEEE"],
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+  }], { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local changes: delete A, B");
+  await PlacesUtils.bookmarks.remove("bookmarkAAAA");
+  await PlacesUtils.bookmarks.remove("folderBBBBBB");
+
+  info("Make remote changes: change A; B > ((D > F) G)");
+  await buf.store([{
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A (remote)",
+    bmkUri: "http://example.com/a-remote",
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B (remote)",
+    children: ["bookmarkCCCC", "folderDDDDDD", "bookmarkGGGG"],
+  }, {
+    id: "folderDDDDDD",
+    type: "folder",
+    title: "D",
+    children: ["bookmarkEEEE", "bookmarkFFFF"],
+  }, {
+    id: "bookmarkFFFF",
+    type: "bookmark",
+    title: "F (remote)",
+    bmkUri: "http://example.com/f-remote",
+  }, {
+    id: "bookmarkGGGG",
+    type: "bookmark",
+    title: "G (remote)",
+    bmkUri: "http://example.com/g-remote",
+  }]);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkFFFF", "bookmarkGGGG", "menu"],
+    deleted: ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"],
+  }, "Should upload relocated remote orphans and menu");
+
+  await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+    guid: PlacesUtils.bookmarks.menuGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "Bookmarks Menu",
+    children: [{
+      guid: "bookmarkAAAA",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 0,
+      title: "A (remote)",
+      url: "http://example.com/a-remote",
+    }, {
+      guid: "bookmarkFFFF",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 1,
+      title: "F (remote)",
+      url: "http://example.com/f-remote",
+    }, {
+      guid: "bookmarkGGGG",
+      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+      index: 2,
+      title: "G (remote)",
+      url: "http://example.com/g-remote",
+    }],
+  }, "Should restore A and relocate (F G) to menu");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move_to_new_then_delete() {
+  let buf = await openMirror("move_to_new_then_delete");
+
+  info("Set up mirror: A > B > (C D)");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "folderBBBBBB",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "B",
+        children: [{
+          guid: "bookmarkCCCC",
+          url: "http://example.com/c",
+          title: "C",
+        }, {
+          guid: "bookmarkDDDD",
+          url: "http://example.com/d",
+          title: "D",
+        }],
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["folderBBBBBB"],
+  }, {
+    id: "folderBBBBBB",
+    type: "folder",
+    title: "B",
+    children: ["bookmarkCCCC", "bookmarkDDDD"],
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D",
+    bmkUri: "http://example.com/d",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local changes: E > A, delete E");
+  await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+    guid: "folderEEEEEE",
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    title: "E",
+  });
+  await PlacesUtils.bookmarks.update({
+    guid: "folderAAAAAA",
+    parentGuid: "folderEEEEEE",
+    index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+  });
+  // E isn't synced, so we shouldn't upload a tombstone.
+  await PlacesUtils.bookmarks.remove("folderEEEEEE");
+
+  info("Make remote changes");
+  await buf.store([{
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C (remote)",
+    bmkUri: "http://example.com/c-remote",
+  }]);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkCCCC", "menu", "toolbar"],
+    deleted: ["bookmarkDDDD", "folderAAAAAA", "folderBBBBBB"],
+  }, "Should upload records for Menu > C, Toolbar");
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "C (remote)",
+        url: "http://example.com/c-remote",
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should move C to closest surviving parent");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_kinds.js
@@ -0,0 +1,287 @@
+add_task(async function test_livemarks() {
+  let { site, stopServer } = makeLivemarkServer();
+
+  try {
+    let buf = await openMirror("livemarks");
+
+    info("Set up mirror");
+    await PlacesUtils.bookmarks.insertTree({
+      guid: PlacesUtils.bookmarks.menuGuid,
+      children: [{
+        guid: "livemarkAAAA",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "A",
+        annos: [{
+          name: PlacesUtils.LMANNO_FEEDURI,
+          value: site + "/feed/a",
+        }],
+      }],
+    });
+    await buf.store(shuffle([{
+      id: "menu",
+      type: "folder",
+      title: "Bookmarks Menu",
+      children: ["livemarkAAAA"],
+    }, {
+      id: "livemarkAAAA",
+      type: "livemark",
+      title: "A",
+      feedUri: site + "/feed/a",
+    }]), { needsMerge: false });
+    await PlacesTestUtils.markBookmarksAsSynced();
+
+    info("Make local changes");
+    await PlacesUtils.livemarks.addLivemark({
+      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+      guid: "livemarkBBBB",
+      title: "B",
+      feedURI: Services.io.newURI(site + "/feed/b-local"),
+      siteURI: Services.io.newURI(site + "/site/b-local"),
+    });
+    let livemarkD = await PlacesUtils.livemarks.addLivemark({
+      parentGuid: PlacesUtils.bookmarks.menuGuid,
+      guid: "livemarkDDDD",
+      title: "D",
+      feedURI: Services.io.newURI(site + "/feed/d"),
+      siteURI: Services.io.newURI(site + "/site/d"),
+    });
+
+    info("Make remote changes");
+    await buf.store(shuffle([{
+      id: "livemarkAAAA",
+      type: "livemark",
+      title: "A (remote)",
+      feedUri: site + "/feed/a-remote",
+    }, {
+      id: "toolbar",
+      type: "folder",
+      title: "Bookmarks Toolbar",
+      children: ["livemarkCCCC", "livemarkB111"],
+    }, {
+      id: "unfiled",
+      type: "folder",
+      title: "Other Bookmarks",
+      children: ["livemarkEEEE"],
+    }, {
+      id: "livemarkCCCC",
+      type: "livemark",
+      title: "C (remote)",
+      feedUri: site + "/feed/c-remote",
+    }, {
+      id: "livemarkB111",
+      type: "livemark",
+      title: "B",
+      feedUri: site + "/feed/b-remote",
+    }, {
+      id: "livemarkEEEE",
+      type: "livemark",
+      title: "E",
+      feedUri: site + "/feed/e",
+      siteUri: site + "/site/e",
+    }]));
+
+    info("Apply remote");
+    let changesToUpload = await buf.apply();
+    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+    let menuInfo = await PlacesUtils.bookmarks.fetch(
+      PlacesUtils.bookmarks.menuGuid);
+    deepEqual(changesToUpload, {
+      livemarkDDDD: {
+        tombstone: false,
+        counter: 1,
+        synced: false,
+        cleartext: {
+          id: "livemarkDDDD",
+          type: "livemark",
+          parentid: "menu",
+          hasDupe: false,
+          parentName: "Bookmarks Menu",
+          dateAdded: PlacesUtils.toDate(livemarkD.dateAdded).getTime(),
+          title: "D",
+          feedUri: site + "/feed/d",
+          siteUri: site + "/site/d",
+        },
+      },
+      menu: {
+        tombstone: false,
+        counter: 1,
+        synced: false,
+        cleartext: {
+          id: "menu",
+          type: "folder",
+          parentid: "places",
+          hasDupe: false,
+          parentName: "",
+          dateAdded: menuInfo.dateAdded.getTime(),
+          title: menuInfo.title,
+          children: ["livemarkAAAA", "livemarkDDDD"],
+        },
+      },
+    }, "Should upload new local livemark A");
+
+    await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+      guid: PlacesUtils.bookmarks.rootGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "",
+      children: [{
+        guid: PlacesUtils.bookmarks.menuGuid,
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "Bookmarks Menu",
+        children: [{
+          guid: "livemarkAAAA",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 0,
+          title: "A (remote)",
+          annos: [{
+            name: PlacesUtils.LMANNO_FEEDURI,
+            flags: 0,
+            expires: PlacesUtils.annotations.EXPIRE_NEVER,
+            value: site + "/feed/a-remote",
+          }],
+        }, {
+          guid: "livemarkDDDD",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 1,
+          title: "D",
+          annos: [{
+            name: PlacesUtils.LMANNO_FEEDURI,
+            flags: 0,
+            expires: PlacesUtils.annotations.EXPIRE_NEVER,
+            value: site + "/feed/d",
+          }, {
+            name: PlacesUtils.LMANNO_SITEURI,
+            flags: 0,
+            expires: PlacesUtils.annotations.EXPIRE_NEVER,
+            value: site + "/site/d",
+          }],
+        }],
+      }, {
+        guid: PlacesUtils.bookmarks.toolbarGuid,
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 1,
+        title: "Bookmarks Toolbar",
+        children: [{
+          guid: "livemarkCCCC",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 0,
+          title: "C (remote)",
+          annos: [{
+            name: PlacesUtils.LMANNO_FEEDURI,
+            flags: 0,
+            expires: PlacesUtils.annotations.EXPIRE_NEVER,
+            value: site + "/feed/c-remote",
+          }],
+        }, {
+          guid: "livemarkB111",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 1,
+          title: "B",
+          annos: [{
+            name: PlacesUtils.LMANNO_FEEDURI,
+            flags: 0,
+            expires: PlacesUtils.annotations.EXPIRE_NEVER,
+            value: site + "/feed/b-remote",
+          }],
+        }],
+      }, {
+        guid: PlacesUtils.bookmarks.unfiledGuid,
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 3,
+        title: "Other Bookmarks",
+        children: [{
+          guid: "livemarkEEEE",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 0,
+          title: "E",
+          annos: [{
+            name: PlacesUtils.LMANNO_FEEDURI,
+            flags: 0,
+            expires: PlacesUtils.annotations.EXPIRE_NEVER,
+            value: site + "/feed/e",
+          }, {
+            name: PlacesUtils.LMANNO_SITEURI,
+            flags: 0,
+            expires: PlacesUtils.annotations.EXPIRE_NEVER,
+            value: site + "/site/e",
+          }],
+        }],
+      }, {
+        guid: PlacesUtils.bookmarks.mobileGuid,
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 4,
+        title: "mobile",
+      }],
+    }, "Should apply and dedupe livemarks");
+
+    let cLivemark = await PlacesUtils.livemarks.getLivemark({
+      guid: "livemarkCCCC",
+    });
+    equal(cLivemark.title, "C (remote)", "Should set livemark C title");
+    ok(cLivemark.feedURI.equals(Services.io.newURI(site + "/feed/c-remote")),
+      "Should set livemark C feed URL");
+
+    let bLivemark = await PlacesUtils.livemarks.getLivemark({
+      guid: "livemarkB111",
+    });
+    ok(bLivemark.feedURI.equals(Services.io.newURI(site + "/feed/b-remote")),
+      "Should set deduped livemark B feed URL");
+    strictEqual(bLivemark.siteURI, null,
+      "Should remove deduped livemark B site URL");
+
+    await buf.finalize();
+  } finally {
+    await stopServer();
+  }
+
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+// Bug 632287.
+add_task(async function test_mismatched_types() {
+  let buf = await openMirror("mismatched_types");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "l1nZZXfB8nC7",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "Innerst i Sneglehode",
+    }],
+  });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make remote changes");
+  await buf.store([{
+    "id": "l1nZZXfB8nC7",
+    "type": "livemark",
+    "siteUri": "http://sneglehode.wordpress.com/",
+    "feedUri": "http://sneglehode.wordpress.com/feed/",
+    "parentName": "Bookmarks Toolbar",
+    "title": "Innerst i Sneglehode",
+    "description": null,
+    "children":
+      ["HCRq40Rnxhrd", "YeyWCV1RVsYw", "GCceVZMhvMbP", "sYi2hevdArlF",
+       "vjbZlPlSyGY8", "UtjUhVyrpeG6", "rVq8WMG2wfZI", "Lx0tcy43ZKhZ",
+       "oT74WwV8_j4P", "IztsItWVSo3-"],
+    "parentid": "toolbar"
+  }]);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: [],
+    deleted: [],
+  }, "Should not reupload merged livemark");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js
@@ -0,0 +1,742 @@
+add_task(async function test_value_structure_conflict() {
+  let buf = await openMirror("value_structure_conflict");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "bookmarkBBBB",
+        url: "http://example.com/b",
+        title: "B",
+      }, {
+        guid: "bookmarkCCCC",
+        url: "http://example.com/c",
+        title: "C",
+      }],
+    }, {
+      guid: "folderDDDDDD",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "D",
+      children: [{
+        guid: "bookmarkEEEE",
+        url: "http://example.com/e",
+        title: "E",
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA", "folderDDDDDD"],
+    modified: Date.now() / 1000 - 60,
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkBBBB", "bookmarkCCCC"],
+    modified: Date.now() / 1000 - 60,
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+    modified: Date.now() / 1000 - 60,
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+    modified: Date.now() / 1000 - 60,
+  }, {
+    id: "folderDDDDDD",
+    type: "folder",
+    title: "D",
+    children: ["bookmarkEEEE"],
+    modified: Date.now() / 1000 - 60,
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+    modified: Date.now() / 1000 - 60,
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local value change");
+  await PlacesUtils.bookmarks.update({
+    guid: "folderAAAAAA",
+    title: "A (local)",
+  });
+
+  info("Make local structure change");
+  await PlacesUtils.bookmarks.update({
+    guid: "bookmarkBBBB",
+    parentGuid: "folderDDDDDD",
+    index: 0,
+  });
+
+  info("Make remote value change");
+  await buf.store([{
+    id: "folderDDDDDD",
+    type: "folder",
+    title: "D (remote)",
+    children: ["bookmarkEEEE"],
+    modified: Date.now() / 1000 + 60,
+  }]);
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications();
+  let changesToUpload = await buf.apply({
+    remoteTimeSeconds: Date.now() / 1000,
+  });
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkBBBB", "folderAAAAAA", "folderDDDDDD"],
+    deleted: [],
+  }, "Should upload records for merged and new local items");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["folderAAAAAA",
+    "bookmarkEEEE", "bookmarkBBBB", "folderDDDDDD"]);
+  observer.check([{
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkEEEE"),
+              oldParentId: localItemIds.get("folderDDDDDD"),
+              oldIndex: 1, newParentId: localItemIds.get("folderDDDDDD"),
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkEEEE",
+              oldParentGuid: "folderDDDDDD",
+              newParentGuid: "folderDDDDDD",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkBBBB"),
+              oldParentId: localItemIds.get("folderDDDDDD"),
+              oldIndex: 0, newParentId: localItemIds.get("folderDDDDDD"),
+              newIndex: 1, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkBBBB",
+              oldParentGuid: "folderDDDDDD",
+              newParentGuid: "folderDDDDDD",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("folderDDDDDD"), property: "title",
+              isAnnoProperty: false, newValue: "D (remote)",
+              type: PlacesUtils.bookmarks.TYPE_FOLDER,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "folderDDDDDD",
+              parentGuid: PlacesUtils.bookmarks.menuGuid, oldValue: "D",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }]);
+
+  await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+    guid: PlacesUtils.bookmarks.menuGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "Bookmarks Menu",
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "A (local)",
+      children: [{
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "C",
+        url: "http://example.com/c",
+      }],
+    }, {
+      guid: "folderDDDDDD",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "D (remote)",
+      children: [{
+        guid: "bookmarkEEEE",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "E",
+        url: "http://example.com/e",
+      }, {
+        guid: "bookmarkBBBB",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 1,
+        title: "B",
+        url: "http://example.com/b",
+      }],
+    }],
+  }, "Should reconcile structure and value changes");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move() {
+  let buf = await openMirror("move");
+
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "devFolder___",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "Dev",
+      children: [{
+        guid: "mdnBmk______",
+        title: "MDN",
+        url: "https://developer.mozilla.org",
+      }, {
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        guid: "mozFolder___",
+        title: "Mozilla",
+        children: [{
+          guid: "fxBmk_______",
+          title: "Get Firefox!",
+          url: "http://getfirefox.com/",
+        }, {
+          guid: "nightlyBmk__",
+          title: "Nightly",
+          url: "https://nightly.mozilla.org",
+        }],
+      }, {
+        guid: "wmBmk_______",
+        title: "Webmaker",
+        url: "https://webmaker.org",
+      }],
+    }, {
+      guid: "bzBmk_______",
+      title: "Bugzilla",
+      url: "https://bugzilla.mozilla.org",
+    }]
+  });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  await buf.store(shuffle([{
+    id: "unfiled",
+    type: "folder",
+    title: "Other Bookmarks",
+    children: ["mozFolder___"],
+  }, {
+    id: "toolbar",
+    type: "folder",
+    title: "Bookmarks Toolbar",
+    children: ["devFolder___"],
+  }, {
+    id: "devFolder___",
+    // Moving to toolbar.
+    type: "folder",
+    title: "Dev",
+    children: ["bzBmk_______", "wmBmk_______"],
+  }, {
+    // Moving to "Mozilla".
+    id: "mdnBmk______",
+    type: "bookmark",
+    title: "MDN",
+    bmkUri: "https://developer.mozilla.org",
+  }, {
+    // Rearranging children and moving to unfiled.
+    id: "mozFolder___",
+    type: "folder",
+    title: "Mozilla",
+    children: ["nightlyBmk__", "mdnBmk______", "fxBmk_______"],
+  }, {
+    id: "fxBmk_______",
+    type: "bookmark",
+    title: "Get Firefox!",
+    bmkUri: "http://getfirefox.com/",
+  }, {
+    id: "nightlyBmk__",
+    type: "bookmark",
+    title: "Nightly",
+    bmkUri: "https://nightly.mozilla.org",
+  }, {
+    id: "wmBmk_______",
+    type: "bookmark",
+    title: "Webmaker",
+    bmkUri: "https://webmaker.org",
+  }, {
+    id: "bzBmk_______",
+    type: "bookmark",
+    title: "Bugzilla",
+    bmkUri: "https://bugzilla.mozilla.org",
+  }]));
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications();
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: [],
+    deleted: [],
+  }, "Should not upload records for remotely moved items");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["devFolder___",
+    "mozFolder___", "bzBmk_______", "wmBmk_______", "nightlyBmk__",
+    "mdnBmk______", "fxBmk_______"]);
+  observer.check([{
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("devFolder___"),
+              oldParentId: PlacesUtils.bookmarksMenuFolderId,
+              oldIndex: 0, newParentId: PlacesUtils.toolbarFolderId,
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_FOLDER,
+              guid: "devFolder___",
+              oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+              newParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("mozFolder___"),
+              oldParentId: localItemIds.get("devFolder___"),
+              oldIndex: 1, newParentId: PlacesUtils.unfiledBookmarksFolderId,
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_FOLDER,
+              guid: "mozFolder___",
+              oldParentGuid: "devFolder___",
+              newParentGuid: PlacesUtils.bookmarks.unfiledGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bzBmk_______"),
+              oldParentId: PlacesUtils.bookmarksMenuFolderId,
+              oldIndex: 1, newParentId: localItemIds.get("devFolder___"),
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bzBmk_______",
+              oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+              newParentGuid: "devFolder___",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("wmBmk_______"),
+              oldParentId: localItemIds.get("devFolder___"),
+              oldIndex: 2, newParentId: localItemIds.get("devFolder___"),
+              newIndex: 1, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "wmBmk_______",
+              oldParentGuid: "devFolder___",
+              newParentGuid: "devFolder___",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("nightlyBmk__"),
+              oldParentId: localItemIds.get("mozFolder___"),
+              oldIndex: 1, newParentId: localItemIds.get("mozFolder___"),
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "nightlyBmk__",
+              oldParentGuid: "mozFolder___",
+              newParentGuid: "mozFolder___",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("mdnBmk______"),
+              oldParentId: localItemIds.get("devFolder___"),
+              oldIndex: 0, newParentId: localItemIds.get("mozFolder___"),
+              newIndex: 1, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "mdnBmk______",
+              oldParentGuid: "devFolder___",
+              newParentGuid: "mozFolder___",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("fxBmk_______"),
+              oldParentId: localItemIds.get("mozFolder___"),
+              oldIndex: 0, newParentId: localItemIds.get("mozFolder___"),
+              newIndex: 2, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "fxBmk_______",
+              oldParentGuid: "mozFolder___",
+              newParentGuid: "mozFolder___",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }]);
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+      children: [{
+        guid: "devFolder___",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "Dev",
+        children: [{
+          guid: "bzBmk_______",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "Bugzilla",
+          url: "https://bugzilla.mozilla.org/",
+        }, {
+          guid: "wmBmk_______",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 1,
+          title: "Webmaker",
+          url: "https://webmaker.org/",
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+      children: [{
+        guid: "mozFolder___",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "Mozilla",
+        children: [{
+          guid: "nightlyBmk__",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "Nightly",
+          url: "https://nightly.mozilla.org/",
+        }, {
+          guid: "mdnBmk______",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 1,
+          title: "MDN",
+          url: "https://developer.mozilla.org/",
+        }, {
+          guid: "fxBmk_______",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 2,
+          title: "Get Firefox!",
+          url: "http://getfirefox.com/",
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should move and reorder bookmarks to match remote");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_move_into_parent_sibling() {
+  // This test moves a bookmark that exists locally into a new folder that only
+  // exists remotely, and is a later sibling of the local parent. This ensures
+  // we set up the local structure before applying structure changes.
+  let buf = await openMirror("move_into_parent_sibling");
+
+  info("Set up mirror: Menu > A > B");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "bookmarkBBBB",
+        url: "http://example.com/b",
+        title: "B",
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkBBBB"],
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make remote changes: Menu > (A (B > C))");
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA", "folderCCCCCC"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+  }, {
+    id: "folderCCCCCC",
+    type: "folder",
+    title: "C",
+    children: ["bookmarkBBBB"],
+  }]);
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications();
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: [],
+    deleted: [],
+  }, "Should not upload records for remote-only structure changes");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["folderCCCCCC",
+    "bookmarkBBBB", "folderAAAAAA"]);
+  observer.check([{
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("folderCCCCCC"),
+              parentId: PlacesUtils.bookmarksMenuFolderId, index: 1,
+              type: PlacesUtils.bookmarks.TYPE_FOLDER,
+              urlHref: null, title: "C",
+              guid: "folderCCCCCC",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkBBBB"),
+              oldParentId: localItemIds.get("folderAAAAAA"),
+              oldIndex: 0, newParentId: localItemIds.get("folderCCCCCC"),
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkBBBB",
+              oldParentGuid: "folderAAAAAA",
+              newParentGuid: "folderCCCCCC",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }]);
+
+  await assertLocalTree(PlacesUtils.bookmarks.menuGuid, {
+    guid: PlacesUtils.bookmarks.menuGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "Bookmarks Menu",
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "A",
+    }, {
+      guid: "folderCCCCCC",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "C",
+      children: [{
+        guid: "bookmarkBBBB",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "B",
+        url: "http://example.com/b",
+      }],
+    }],
+  }, "Should set up local structure correctly");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_complex_move_with_additions() {
+  let buf = await openMirror("complex_move_with_additions");
+
+  info("Set up mirror: Menu > A > (B C)");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "bookmarkBBBB",
+        url: "http://example.com/b",
+        title: "B",
+      }, {
+        guid: "bookmarkCCCC",
+        url: "http://example.com/c",
+        title: "C",
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkBBBB", "bookmarkCCCC"],
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make local change: Menu > A > (B C D)");
+  await PlacesUtils.bookmarks.insert({
+    guid: "bookmarkDDDD",
+    parentGuid: "folderAAAAAA",
+    title: "D (local)",
+    url: "http://example.com/d-local",
+  });
+
+  info("Make remote change: ((Menu > C) (Toolbar > A > (B E)))");
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkCCCC"],
+  }, {
+    id: "toolbar",
+    type: "folder",
+    title: "Bookmarks Toolbar",
+    children: ["folderAAAAAA"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkBBBB", "bookmarkEEEE"],
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+  }]));
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications();
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkDDDD", "folderAAAAAA"],
+    deleted: [],
+  }, "Should upload new records for (A D)");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["bookmarkEEEE",
+    "folderAAAAAA", "bookmarkCCCC"]);
+  observer.check([{
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("bookmarkEEEE"),
+              parentId: localItemIds.get("folderAAAAAA"), index: 1,
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              urlHref: "http://example.com/e", title: "E",
+              guid: "bookmarkEEEE",
+              parentGuid: "folderAAAAAA",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkCCCC"),
+              oldParentId: localItemIds.get("folderAAAAAA"),
+              oldIndex: 1, newParentId: PlacesUtils.bookmarksMenuFolderId,
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkCCCC",
+              oldParentGuid: "folderAAAAAA",
+              newParentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("folderAAAAAA"),
+              oldParentId: PlacesUtils.bookmarksMenuFolderId,
+              oldIndex: 0, newParentId: PlacesUtils.toolbarFolderId,
+              newIndex: 0, type: PlacesUtils.bookmarks.TYPE_FOLDER,
+              guid: "folderAAAAAA",
+              oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+              newParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }]);
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "bookmarkCCCC",
+        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+        index: 0,
+        title: "C",
+        url: "http://example.com/c",
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+      children: [{
+        // We can guarantee child order (B E D), since we always walk remote
+        // children first, and the remote folder A record is newer than the
+        // local folder. If the local folder were newer, the order would be
+        // (D B E).
+        guid: "folderAAAAAA",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "A",
+        children: [{
+          guid: "bookmarkBBBB",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "B",
+          url: "http://example.com/b",
+        }, {
+          guid: "bookmarkEEEE",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 1,
+          title: "E",
+          url: "http://example.com/e",
+        }, {
+          guid: "bookmarkDDDD",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 2,
+          title: "D (local)",
+          url: "http://example.com/d-local",
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should take remote order and preserve local children");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_value_changes.js
@@ -0,0 +1,1202 @@
+add_task(async function test_value_combo() {
+  let buf = await openMirror("value_combo");
+
+  info("Set up mirror with existing bookmark to update");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "mozBmk______",
+      url: "https://mozilla.org",
+      title: "Mozilla",
+      tags: ["moz", "dot", "org"],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["mozBmk______"],
+  }, {
+    id: "mozBmk______",
+    type: "bookmark",
+    title: "Mozilla",
+    bmkUri: "https://mozilla.org",
+    tags: ["moz", "dot", "org"],
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Insert new local bookmark to upload");
+  let [bzBmk] = await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.toolbarGuid,
+    children: [{
+      guid: "bzBmk_______",
+      url: "https://bugzilla.mozilla.org",
+      title: "Bugzilla",
+      tags: ["new", "tag"],
+    }],
+  });
+
+  info("Insert remote bookmarks and folder to apply");
+  await buf.store(shuffle([{
+    id: "mozBmk______",
+    type: "bookmark",
+    title: "Mozilla home page",
+    bmkUri: "https://mozilla.org",
+    tags: ["browsers"],
+  }, {
+    id: "toolbar",
+    type: "folder",
+    title: "Bookmarks Toolbar",
+    children: ["fxBmk_______", "tFolder_____"],
+  }, {
+    id: "fxBmk_______",
+    type: "bookmark",
+    title: "Get Firefox",
+    bmkUri: "http://getfirefox.com",
+    tags: ["taggy", "browsers"],
+  }, {
+    id: "tFolder_____",
+    type: "folder",
+    title: "Mail",
+    children: ["tbBmk_______"],
+  }, {
+    id: "tbBmk_______",
+    type: "bookmark",
+    title: "Get Thunderbird",
+    bmkUri: "http://getthunderbird.com",
+    keyword: "tb",
+  }]));
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications({ skipTags: true });
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  deepEqual(changesToUpload, {
+    bzBmk_______: {
+      tombstone: false,
+      counter: 3,
+      synced: false,
+      cleartext: {
+        id: "bzBmk_______",
+        type: "bookmark",
+        parentid: "toolbar",
+        hasDupe: false,
+        parentName: "Bookmarks Toolbar",
+        dateAdded: bzBmk.dateAdded.getTime(),
+        bmkUri: "https://bugzilla.mozilla.org/",
+        title: "Bugzilla",
+        tags: ["new", "tag"],
+      },
+    },
+    toolbar: {
+      tombstone: false,
+      counter: 1,
+      synced: false,
+      cleartext: {
+        id: "toolbar",
+        type: "folder",
+        parentid: "places",
+        hasDupe: false,
+        parentName: "",
+        dateAdded: 0,
+        title: "Bookmarks Toolbar",
+        children: ["fxBmk_______", "tFolder_____", "bzBmk_______"],
+      },
+    },
+  }, "Should upload new local bookmarks and parents");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["fxBmk_______",
+    "tFolder_____", "tbBmk_______", "bzBmk_______", "mozBmk______"]);
+  observer.check([{
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("fxBmk_______"),
+              parentId: PlacesUtils.toolbarFolderId, index: 0,
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              urlHref: "http://getfirefox.com/", title: "Get Firefox",
+              guid: "fxBmk_______",
+              parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("tFolder_____"),
+              parentId: PlacesUtils.toolbarFolderId,
+              index: 1, type: PlacesUtils.bookmarks.TYPE_FOLDER,
+              urlHref: null, title: "Mail", guid: "tFolder_____",
+              parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("tbBmk_______"),
+              parentId: localItemIds.get("tFolder_____"), index: 0,
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              urlHref: "http://getthunderbird.com/", title: "Get Thunderbird",
+              guid: "tbBmk_______", parentGuid: "tFolder_____",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bzBmk_______"),
+              oldParentId: PlacesUtils.toolbarFolderId,
+              oldIndex: 0, newParentId: PlacesUtils.toolbarFolderId,
+              newIndex: 2, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bzBmk_______",
+              oldParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+              newParentGuid: PlacesUtils.bookmarks.toolbarGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("mozBmk______"), property: "title",
+              isAnnoProperty: false, newValue: "Mozilla home page",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "mozBmk______",
+              parentGuid: PlacesUtils.bookmarks.menuGuid, oldValue: "Mozilla",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("tbBmk_______"), property: "keyword",
+              isAnnoProperty: false, newValue: "tb",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: localItemIds.get("tFolder_____"), guid: "tbBmk_______",
+              parentGuid: "tFolder_____",
+              oldValue: "http://getthunderbird.com/",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }]);
+
+  let fxBmk = await PlacesUtils.bookmarks.fetch("fxBmk_______");
+  ok(fxBmk, "New Firefox bookmark should exist");
+  equal(fxBmk.parentGuid, PlacesUtils.bookmarks.toolbarGuid,
+    "Should add Firefox bookmark to toolbar");
+  let fxTags = PlacesUtils.tagging.getTagsForURI(
+    Services.io.newURI("http://getfirefox.com"));
+  deepEqual(fxTags.sort(), ["browsers", "taggy"],
+    "Should tag new Firefox bookmark");
+
+  let folder = await PlacesUtils.bookmarks.fetch("tFolder_____");
+  ok(folder, "New folder should exist");
+  equal(folder.parentGuid, PlacesUtils.bookmarks.toolbarGuid,
+    "Should add new folder to toolbar");
+
+  let tbBmk = await PlacesUtils.bookmarks.fetch("tbBmk_______");
+  ok(tbBmk, "Should insert Thunderbird child bookmark");
+  equal(tbBmk.parentGuid, folder.guid,
+    "Should add Thunderbird bookmark to new folder");
+  let keywordInfo = await PlacesUtils.keywords.fetch("tb");
+  equal(keywordInfo.url.href, "http://getthunderbird.com/",
+    "Should set keyword for Thunderbird bookmark");
+
+  let updatedBmk = await PlacesUtils.bookmarks.fetch("mozBmk______");
+  equal(updatedBmk.title, "Mozilla home page",
+    "Should rename Mozilla bookmark");
+  equal(updatedBmk.parentGuid, PlacesUtils.bookmarks.menuGuid,
+    "Should not move Mozilla bookmark");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_value_only_changes() {
+  let buf = await openMirror("value_only_changes");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "folderAAAAAA",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "A",
+      children: [{
+        guid: "bookmarkBBBB",
+        url: "http://example.com/b",
+        title: "B",
+      }, {
+        guid: "bookmarkCCCC",
+        url: "http://example.com/c",
+        title: "C",
+      }, {
+        guid: "folderJJJJJJ",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "J",
+        children: [{
+          guid: "bookmarkKKKK",
+          url: "http://example.com/k",
+          title: "K",
+        }],
+      }, {
+        guid: "bookmarkDDDD",
+        url: "http://example.com/d",
+        title: "D",
+      }, {
+        guid: "bookmarkEEEE",
+        url: "http://example.com/e",
+        title: "E",
+      }],
+    }, {
+      guid: "folderFFFFFF",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "F",
+      children: [{
+        guid: "bookmarkGGGG",
+        url: "http://example.com/g",
+        title: "G",
+      }, {
+        guid: "folderHHHHHH",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        title: "H",
+        children: [{
+          guid: "bookmarkIIII",
+          url: "http://example.com/i",
+          title: "I",
+        }],
+      }],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["folderAAAAAA", "folderFFFFFF"],
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    title: "A",
+    children: ["bookmarkBBBB", "bookmarkCCCC", "folderJJJJJJ", "bookmarkDDDD",
+               "bookmarkEEEE"],
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }, {
+    id: "folderJJJJJJ",
+    type: "folder",
+    title: "J",
+    children: ["bookmarkKKKK"],
+  }, {
+    id: "bookmarkKKKK",
+    type: "bookmark",
+    title: "K",
+    bmkUri: "http://example.com/k",
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D",
+    bmkUri: "http://example.com/d",
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+  }, {
+    id: "folderFFFFFF",
+    type: "folder",
+    title: "F",
+    children: ["bookmarkGGGG", "folderHHHHHH"],
+  }, {
+    id: "bookmarkGGGG",
+    type: "bookmark",
+    title: "G",
+    bmkUri: "http://example.com/g",
+  }, {
+    id: "folderHHHHHH",
+    type: "folder",
+    title: "H",
+    children: ["bookmarkIIII"],
+  }, {
+    id: "bookmarkIIII",
+    type: "bookmark",
+    title: "I",
+    bmkUri: "http://example.com/i",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make remote changes");
+  await buf.store(shuffle([{
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C (remote)",
+    bmkUri: "http://example.com/c-remote",
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E (remote)",
+    bmkUri: "http://example.com/e-remote",
+  }, {
+    id: "bookmarkIIII",
+    type: "bookmark",
+    title: "I (remote)",
+    bmkUri: "http://example.com/i-remote",
+  }, {
+    id: "folderFFFFFF",
+    type: "folder",
+    title: "F (remote)",
+    children: ["bookmarkGGGG", "folderHHHHHH"],
+  }]));
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: [],
+    deleted: [],
+  }, "Should not upload records for remote-only value changes");
+
+  await assertLocalTree(PlacesUtils.bookmarks.rootGuid, {
+    guid: PlacesUtils.bookmarks.rootGuid,
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    index: 0,
+    title: "",
+    children: [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 0,
+      title: "Bookmarks Menu",
+      children: [{
+        guid: "folderAAAAAA",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 0,
+        title: "A",
+        children: [{
+          guid: "bookmarkBBBB",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "B",
+          url: "http://example.com/b",
+        }, {
+          guid: "bookmarkCCCC",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 1,
+          title: "C (remote)",
+          url: "http://example.com/c-remote",
+        }, {
+          guid: "folderJJJJJJ",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 2,
+          title: "J",
+          children: [{
+            guid: "bookmarkKKKK",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 0,
+            title: "K",
+            url: "http://example.com/k",
+          }],
+        }, {
+          guid: "bookmarkDDDD",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 3,
+          title: "D",
+          url: "http://example.com/d",
+        }, {
+          guid: "bookmarkEEEE",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 4,
+          title: "E (remote)",
+          url: "http://example.com/e-remote",
+        }],
+      }, {
+        guid: "folderFFFFFF",
+        type: PlacesUtils.bookmarks.TYPE_FOLDER,
+        index: 1,
+        title: "F (remote)",
+        children: [{
+          guid: "bookmarkGGGG",
+          type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+          index: 0,
+          title: "G",
+          url: "http://example.com/g",
+        }, {
+          guid: "folderHHHHHH",
+          type: PlacesUtils.bookmarks.TYPE_FOLDER,
+          index: 1,
+          title: "H",
+          children: [{
+            guid: "bookmarkIIII",
+            type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+            index: 0,
+            title: "I (remote)",
+            url: "http://example.com/i-remote",
+          }],
+        }],
+      }],
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 1,
+      title: "Bookmarks Toolbar",
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 3,
+      title: "Other Bookmarks",
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      index: 4,
+      title: "mobile",
+    }],
+  }, "Should not change structure for value-only changes");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_keywords() {
+  let buf = await openMirror("keywords");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkAAAA",
+      title: "A",
+      url: "http://example.com/a",
+      keyword: "one",
+    }, {
+      guid: "bookmarkBBBB",
+      title: "B",
+      url: "http://example.com/b",
+      keyword: "two",
+    }, {
+      guid: "bookmarkCCCC",
+      title: "C",
+      url: "http://example.com/c",
+    }, {
+      guid: "bookmarkDDDD",
+      title: "D",
+      url: "http://example.com/d",
+      keyword: "three",
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+    keyword: "one",
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+    keyword: "two",
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D",
+    bmkUri: "http://example.com/d",
+    keyword: "three",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Change keywords remotely");
+  await buf.store(shuffle([{
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+    keyword: "two",
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+  }]));
+
+  info("Change keywords locally");
+  await PlacesUtils.keywords.insert({
+    keyword: "four",
+    url: "http://example.com/c",
+  });
+  await PlacesUtils.keywords.remove("three");
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications();
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkAAAA", "bookmarkCCCC", "bookmarkDDDD"],
+    deleted: [],
+  }, "Should reupload all local records with changed keywords");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["bookmarkAAAA",
+    "bookmarkBBBB"]);
+  observer.check([{
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkAAAA"), property: "keyword",
+              isAnnoProperty: false, newValue: "",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkAAAA",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              oldValue: "http://example.com/a",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkBBBB"), property: "keyword",
+              isAnnoProperty: false, newValue: "",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkBBBB",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              oldValue: "http://example.com/b",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkAAAA"), property: "keyword",
+              isAnnoProperty: false, newValue: "two",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkAAAA",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              oldValue: "http://example.com/a",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }]);
+
+  let entryForOne = await PlacesUtils.keywords.fetch("one");
+  ok(!entryForOne, "Should remove existing keyword from A");
+
+  let entriesForTwo = await fetchAllKeywords("two");
+  deepEqual(entriesForTwo.map(entry => ({
+    keyword: entry.keyword,
+    url: entry.url.href,
+  })), [{
+    keyword: "two",
+    url: "http://example.com/a",
+  }], "Should move keyword for B to A");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_keywords_complex() {
+  let buf = await openMirror("keywords_complex");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkBBBB",
+      title: "B",
+      url: "http://example.com/b",
+      keyword: "four",
+    }, {
+      guid: "bookmarkCCCC",
+      title: "C",
+      url: "http://example.com/c",
+      keyword: "five",
+    }, {
+      guid: "bookmarkDDDD",
+      title: "D",
+      url: "http://example.com/d",
+    }, {
+      guid: "bookmarkEEEE",
+      title: "E",
+      url: "http://example.com/e",
+      keyword: "three",
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD", "bookmarkEEEE"],
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+    keyword: "four",
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+    keyword: "five",
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D",
+    bmkUri: "http://example.com/d",
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+    keyword: "three",
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make remote changes");
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "bookmarkAAA1", "bookmarkBBBB", "bookmarkCCCC",
+               "bookmarkDDDD", "bookmarkEEEE"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+    keyword: "one",
+  }, {
+    id: "bookmarkAAA1",
+    type: "bookmark",
+    title: "A (copy)",
+    bmkUri: "http://example.com/a",
+    keyword: "two",
+  }, {
+    id: "bookmarkBBB1",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C (remote)",
+    bmkUri: "http://example.com/c-remote",
+    keyword: "six",
+  }]));
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications();
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  let expectedIdsToUpload = {
+    updated: ["bookmarkBBBB", "bookmarkCCCC"],
+    deleted: [],
+  };
+
+  // We'll take the keyword of either "bookmarkAAAA" or "bookmarkAAA1",
+  // depending on which we see first, and reupload the other.
+  let entriesForOne = await fetchAllKeywords("one");
+  let entriesForTwo = await fetchAllKeywords("two");
+  if (entriesForOne.length) {
+    expectedIdsToUpload.updated.push("bookmarkAAA1");
+    ok(!entriesForTwo.length, "Should drop conflicting keyword from A1");
+    deepEqual(entriesForOne.map(keyword => keyword.url.href),
+      ["http://example.com/a"], "Should use A keyword for A and A1");
+  } else {
+    expectedIdsToUpload.updated.push("bookmarkAAAA");
+    ok(!entriesForOne.length, "Should drop conflicting keyword from A");
+    deepEqual(entriesForTwo.map(keyword => keyword.url.href),
+      ["http://example.com/a"], "Should use A1 keyword for A and A1");
+  }
+  expectedIdsToUpload.updated.sort();
+  deepEqual(idsToUpload, expectedIdsToUpload,
+    "Should reupload all local records with corrected keywords");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["bookmarkAAAA",
+    "bookmarkAAA1", "bookmarkBBB1", "bookmarkBBBB", "bookmarkCCCC",
+    "bookmarkDDDD", "bookmarkEEEE"]);
+  let expectedNotifications = [{
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("bookmarkAAAA"),
+              parentId: PlacesUtils.bookmarksMenuFolderId, index: 0,
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              urlHref: "http://example.com/a", title: "A",
+              guid: "bookmarkAAAA",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("bookmarkAAA1"),
+              parentId: PlacesUtils.bookmarksMenuFolderId, index: 1,
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              urlHref: "http://example.com/a", title: "A (copy)",
+              guid: "bookmarkAAA1",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemAdded",
+    params: { itemId: localItemIds.get("bookmarkBBB1"),
+              parentId: PlacesUtils.unfiledBookmarksFolderId, index: 0,
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              urlHref: "http://example.com/b", title: "B",
+              guid: "bookmarkBBB1",
+              parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    // These `onItemMoved` notifications aren't necessary: we only moved
+    // (B C D E) to accomodate (A A1 B1), and Places doesn't usually fire move
+    // notifications for repositioned siblings. However, detecting and filtering
+    // these out complicates `noteObserverChanges`, so, for simplicity, we
+    // record and fire the extra notifications.
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkBBBB"),
+              oldParentId: PlacesUtils.bookmarksMenuFolderId,
+              oldIndex: 0, newParentId: PlacesUtils.bookmarksMenuFolderId,
+              newIndex: 2, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkBBBB",
+              oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+              newParentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkCCCC"),
+              oldParentId: PlacesUtils.bookmarksMenuFolderId,
+              oldIndex: 1, newParentId: PlacesUtils.bookmarksMenuFolderId,
+              newIndex: 3, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkCCCC",
+              oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+              newParentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkDDDD"),
+              oldParentId: PlacesUtils.bookmarksMenuFolderId,
+              oldIndex: 2, newParentId: PlacesUtils.bookmarksMenuFolderId,
+              newIndex: 4, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkDDDD",
+              oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+              newParentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemMoved",
+    params: { itemId: localItemIds.get("bookmarkEEEE"),
+              oldParentId: PlacesUtils.bookmarksMenuFolderId,
+              oldIndex: 3, newParentId: PlacesUtils.bookmarksMenuFolderId,
+              newIndex: 5, type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              guid: "bookmarkEEEE",
+              oldParentGuid: PlacesUtils.bookmarks.menuGuid,
+              newParentGuid: PlacesUtils.bookmarks.menuGuid,
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkCCCC"), property: "title",
+              isAnnoProperty: false, newValue: "C (remote)",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkCCCC",
+              parentGuid: PlacesUtils.bookmarks.menuGuid, oldValue: "C",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkCCCC"), property: "uri",
+              isAnnoProperty: false, newValue: "http://example.com/c-remote",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkCCCC",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              oldValue: "http://example.com/c",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }];
+  if (entriesForOne.length) {
+    expectedNotifications.push({
+      name: "onItemChanged",
+      params: { itemId: localItemIds.get("bookmarkAAA1"), property: "keyword",
+                isAnnoProperty: false, newValue: "two",
+                type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkAAA1",
+                parentGuid: PlacesUtils.bookmarks.menuGuid,
+                oldValue: "http://example.com/a",
+                source: PlacesUtils.bookmarks.SOURCES.SYNC },
+    }, {
+      name: "onItemChanged",
+      params: { itemId: localItemIds.get("bookmarkAAA1"), property: "keyword",
+                isAnnoProperty: false, newValue: "",
+                type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkAAA1",
+                parentGuid: PlacesUtils.bookmarks.menuGuid,
+                oldValue: "http://example.com/a",
+                source: PlacesUtils.bookmarks.SOURCES.SYNC },
+    }, {
+      name: "onItemChanged",
+      params: { itemId: localItemIds.get("bookmarkAAAA"), property: "keyword",
+                isAnnoProperty: false, newValue: "one",
+                type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkAAAA",
+                parentGuid: PlacesUtils.bookmarks.menuGuid,
+                oldValue: "http://example.com/a",
+                source: PlacesUtils.bookmarks.SOURCES.SYNC },
+    });
+  } else {
+    // TODO(kitcambridge): This never happens, even in chaos mode.
+  }
+  expectedNotifications.push({
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkBBBB"), property: "keyword",
+              isAnnoProperty: false, newValue: "",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkBBBB",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              oldValue: "http://example.com/b",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkCCCC"), property: "keyword",
+              isAnnoProperty: false, newValue: "",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkCCCC",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              oldValue: "http://example.com/c",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkCCCC"), property: "keyword",
+              isAnnoProperty: false, newValue: "six",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkCCCC",
+              parentGuid: PlacesUtils.bookmarks.menuGuid,
+              oldValue: "http://example.com/c-remote",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  });
+  observer.check(expectedNotifications);
+
+  let entriesForFour = await fetchAllKeywords("four");
+  ok(!entriesForFour.length, "Should remove all keywords for B");
+
+  let entriesForOldC = await fetchAllKeywords({
+    url: "http://example.com/c",
+  });
+  ok(!entriesForOldC.length, "Should remove all keywords from old C URL");
+  let entriesForNewC = await fetchAllKeywords({
+    url: "http://example.com/c-remote",
+  });
+  deepEqual(entriesForNewC.map(entry => entry.keyword), ["six"],
+    "Should add new keyword to new C URL");
+
+  let entriesForD = await fetchAllKeywords("http://example.com/d");
+  ok(!entriesForD.length, "Should not add keywords to D");
+
+  let entriesForThree = await fetchAllKeywords("three");
+  deepEqual(entriesForThree.map(keyword => keyword.url.href),
+    ["http://example.com/e"], "Should not change keywords for E");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_tags() {
+  let buf = await openMirror("tags");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkAAAA",
+      title: "A",
+      url: "http://example.com/a",
+      tags: ["one", "two", "three", "four"],
+    }, {
+      guid: "bookmarkBBBB",
+      title: "B",
+      url: "http://example.com/b",
+      tags: ["five", "six"],
+    }, {
+      guid: "bookmarkCCCC",
+      title: "C",
+      url: "http://example.com/c",
+    }, {
+      guid: "bookmarkDDDD",
+      title: "D",
+      url: "http://example.com/d",
+      tags: ["seven", "eight", "nine"],
+    }],
+  });
+  await buf.store(shuffle([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+    tags: ["one", "two", "three", "four"],
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+    tags: ["five", "six"],
+  }, {
+    id: "bookmarkCCCC",
+    type: "bookmark",
+    title: "C",
+    bmkUri: "http://example.com/c",
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D",
+    bmkUri: "http://example.com/d",
+    tags: ["seven", "eight", "nine"],
+  }]), { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Change tags remotely");
+  await buf.store(shuffle([{
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+    tags: ["one", "two", "ten"],
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    bmkUri: "http://example.com/b",
+    tags: [],
+  }]));
+
+  info("Change tags locally");
+  PlacesUtils.tagging.tagURI(Services.io.newURI(
+    "http://example.com/c"), ["eleven", "twelve"]);
+  PlacesUtils.tagging.untagURI(Services.io.newURI(
+    "http://example.com/d"), null);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkCCCC", "bookmarkDDDD"],
+    deleted: [],
+  }, "Should upload local records with new tags");
+
+  deepEqual(changesToUpload.bookmarkCCCC.cleartext.tags.sort(),
+    ["eleven", "twelve"], "Should upload record with new tags for C");
+  ok(!changesToUpload.bookmarkDDDD.cleartext.tags,
+    "Should upload record for D with tags removed");
+
+  let tagsForA = PlacesUtils.tagging.getTagsForURI(
+    Services.io.newURI("http://example.com/a"));
+  deepEqual(tagsForA.sort(), ["one", "ten", "two"], "Should change tags for A");
+
+  let tagsForB = PlacesUtils.tagging.getTagsForURI(
+    Services.io.newURI("http://example.com/b"));
+  deepEqual(tagsForB, [], "Should remove all tags from B");
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_rewrite_tag_queries() {
+  let buf = await openMirror("rewrite_tag_queries");
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkAAAA",
+      title: "A",
+      url: "http://example.com/a",
+    }, {
+      guid: "bookmarkDDDD",
+      title: "D",
+      url: "http://example.com/d",
+      tags: ["kitty"],
+    }],
+  });
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "bookmarkDDDD"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    bmkUri: "http://example.com/a",
+  }, {
+    id: "bookmarkDDDD",
+    type: "bookmark",
+    title: "D",
+    bmkUri: "http://example.com/d",
+    tags: ["kitty"],
+  }], { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Add tag queries for new and existing tags");
+  await buf.store([{
+    id: "toolbar",
+    type: "folder",
+    title: "Bookmarks Toolbar",
+    children: ["queryBBBBBBB", "queryCCCCCCC", "bookmarkEEEE"],
+  }, {
+    id: "queryBBBBBBB",
+    type: "query",
+    title: "Tagged stuff",
+    bmkUri: "place:type=7&folder=999",
+    folderName: "taggy",
+  }, {
+    id: "queryCCCCCCC",
+    type: "query",
+    title: "Cats",
+    bmkUri: "place:type=7&folder=888",
+    folderName: "kitty",
+  }, {
+    id: "bookmarkEEEE",
+    type: "bookmark",
+    title: "E",
+    bmkUri: "http://example.com/e",
+    tags: ["taggy"],
+  }]);
+
+  info("Apply remote");
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  deepEqual(changesToUpload, {}, "Should not reupload any local records");
+
+  let urisWithTaggy = PlacesUtils.tagging.getURIsForTag("taggy");
+  deepEqual(urisWithTaggy.map(uri => uri.spec).sort(), ["http://example.com/e"],
+    "Should insert bookmark with new tag");
+
+  let urisWithKitty = PlacesUtils.tagging.getURIsForTag("kitty");
+  deepEqual(urisWithKitty.map(uri => uri.spec).sort(), ["http://example.com/d"],
+    "Should retain existing tag");
+
+  let { root: toolbarContainer } = PlacesUtils.getFolderContents(
+    PlacesUtils.toolbarFolderId, false, true);
+  equal(toolbarContainer.childCount, 3,
+    "Should add queries and bookmark to toolbar");
+
+  let containerForB = PlacesUtils.asContainer(toolbarContainer.getChild(0));
+  containerForB.containerOpen = true;
+  for (let i = 0; i < containerForB.childCount; ++i) {
+    let child = containerForB.getChild(i);
+    equal(child.uri, "http://example.com/e",
+      `Rewritten tag query B should have tagged child node at ${i}`);
+  }
+  containerForB.containerOpen = false;
+
+  let containerForC = PlacesUtils.asContainer(toolbarContainer.getChild(1));
+  containerForC.containerOpen = true;
+  for (let i = 0; i < containerForC.childCount; ++i) {
+    let child = containerForC.getChild(i);
+    equal(child.uri, "http://example.com/d",
+      `Rewritten tag query C should have tagged child node at ${i}`);
+  }
+  containerForC.containerOpen = false;
+
+  toolbarContainer.containerOpen = false;
+
+  await buf.finalize();
+  await PlacesUtils.bookmarks.eraseEverything();
+  await PlacesSyncUtils.bookmarks.reset();
+});
+
+add_task(async function test_date_added() {
+  let buf = await openMirror("date_added");
+
+  let aDateAdded = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000);
+  let bDateAdded = new Date();
+
+  info("Set up mirror");
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      guid: "bookmarkAAAA",
+      dateAdded: aDateAdded,
+      title: "A",
+      url: "http://example.com/a",
+    }, {
+      guid: "bookmarkBBBB",
+      dateAdded: bDateAdded,
+      title: "B",
+      url: "http://example.com/b",
+    }],
+  });
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    title: "Bookmarks Menu",
+    children: ["bookmarkAAAA", "bookmarkBBBB"],
+  }, {
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A",
+    dateAdded: aDateAdded.getTime(),
+    bmkUri: "http://example.com/a",
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B",
+    dateAdded: bDateAdded.getTime(),
+    bmkUri: "http://example.com/b",
+  }], { needsMerge: false });
+  await PlacesTestUtils.markBookmarksAsSynced();
+
+  info("Make remote changes");
+  let bNewDateAdded = new Date(bDateAdded.getTime() - 1 * 60 * 60 * 1000);
+  await buf.store([{
+    id: "bookmarkAAAA",
+    type: "bookmark",
+    title: "A (remote)",
+    dateAdded: Date.now(),
+    bmkUri: "http://example.com/a",
+  }, {
+    id: "bookmarkBBBB",
+    type: "bookmark",
+    title: "B (remote)",
+    dateAdded: bNewDateAdded.getTime(),
+    bmkUri: "http://example.com/b",
+  }]);
+
+  info("Apply remote");
+  let observer = expectBookmarkChangeNotifications();
+  let changesToUpload = await buf.apply();
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
+  let idsToUpload = inspectChangeRecords(changesToUpload);
+  deepEqual(idsToUpload, {
+    updated: ["bookmarkAAAA"],
+    deleted: []
+  }, "Should flag A for weak reupload");
+
+  let localItemIds = await PlacesUtils.promiseManyItemIds(["bookmarkAAAA",
+    "bookmarkBBBB"]);
+  observer.check([{
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkAAAA"), property: "title",
+              isAnnoProperty: false, newValue: "A (remote)",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkAAAA",
+              parentGuid: PlacesUtils.bookmarks.menuGuid, oldValue: "A",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }, {
+    name: "onItemChanged",
+    params: { itemId: localItemIds.get("bookmarkBBBB"), property: "title",
+              isAnnoProperty: false, newValue: "B (remote)",
+              type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+              parentId: PlacesUtils.bookmarksMenuFolderId, guid: "bookmarkBBBB",
+              parentGuid: PlacesUtils.bookmarks.menuGuid, oldValue: "B",
+              source: PlacesUtils.bookmarks.SOURCES.SYNC },
+  }]);
+
+  let changeCounter = changesToUpload.bookmarkAAAA.counter;
+  strictEqual(changeCounter, 0, "Should not bump change counter for A");
+
+  let aInfo = await PlacesUtils.bookmarks.fetch("bookmarkAAAA");
+  equal(aInfo.title, "A (remote)", "Should change local title for A");
+  deepEqual(aInfo.dateAdded, aDateAdded,
+    "Should not change date added for A to newer remote date");
+
+  let bInfo = await PlacesUtils.bookmarks.fetch("bookmarkBBBB");
+  equal(bInfo.title, "B (remote)", "Should change local title for B");
+  deepEqual(bInfo.dateAdded, bNewDateAdded,
+    "Should take older date added for B");
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/xpcshell.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+head = head_sync.js
+support-files =
+  livemark.xml
+
+[test_bookmark_corruption.js]
+[test_bookmark_deduping.js]
+[test_bookmark_deletion.js]
+[test_bookmark_kinds.js]
+[test_bookmark_structure_changes.js]
+[test_bookmark_value_changes.js]
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -188,16 +188,17 @@
   "status.js": ["Status"],
   "storageserver.js": ["ServerBSO", "StorageServerCallback", "StorageServerCollection", "StorageServer", "storageServerForUsers"],
   "StructuredLog.jsm": ["StructuredLogger", "StructuredFormatter"],
   "StyleEditorUtil.jsm": ["getString", "assert", "log", "text", "wire", "showFilePicker"],
   "subprocess_common.jsm": ["BaseProcess", "PromiseWorker", "SubprocessConstants"],
   "subprocess_unix.jsm": ["SubprocessImpl"],
   "subprocess_win.jsm": ["SubprocessImpl"],
   "sync.jsm": ["Authentication"],
+  "SyncedBookmarksMirror.jsm": ["SyncedBookmarksMirror"],
   "tabs.js": ["TabEngine", "TabSetRecord"],
   "tabs.jsm": ["BrowserTabs"],
   "tcpsocket_test.jsm": ["createSocket", "createServer", "enablePrefsAndPermissions", "socketCompartmentInstanceOfArrayBuffer"],
   "telemetry.js": ["SyncTelemetry"],
   "test.jsm": ["Foo"],
   "test2.jsm": ["Bar"],
   "test_bug883784.jsm": ["Test"],
   "Timer.jsm": ["setTimeout", "setTimeoutWithTarget", "clearTimeout", "setInterval", "setIntervalWithTarget", "clearInterval"],