Bug 1426556 - Store the bookmarks sync ID in the mirror. r=markh draft
authorKit Cambridge <kit@yakshaving.ninja>
Fri, 23 Mar 2018 17:39:41 -0700
changeset 772865 556d5406ce449b45208e3370f05da915024ce0d1
parent 772310 b99844d179cacf74a5d39ad23429be91e989c331
push id104070
push userbmo:kit@mozilla.com
push dateTue, 27 Mar 2018 03:07:39 +0000
reviewersmarkh
bugs1426556
milestone61.0a1
Bug 1426556 - Store the bookmarks sync ID in the mirror. r=markh MozReview-Commit-ID: iozvhyCfle
services/sync/modules/engines.js
services/sync/modules/engines/bookmarks.js
services/sync/modules/engines/history.js
services/sync/tests/unit/test_bookmark_engine.js
toolkit/components/places/PlacesSyncUtils.jsm
toolkit/components/places/SyncedBookmarksMirror.jsm
toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js
toolkit/components/places/tests/sync/xpcshell.ini
--- a/services/sync/modules/engines.js
+++ b/services/sync/modules/engines.js
@@ -874,17 +874,18 @@ SyncEngine.prototype = {
    *         replace the sync ID in `meta/global` with the assigned ID.
    */
   async ensureCurrentSyncID(newSyncID) {
     let existingSyncID = this._syncID;
     if (existingSyncID == newSyncID) {
       return existingSyncID;
     }
     this._log.debug("Engine syncIDs: " + [newSyncID, existingSyncID]);
-    this.setSyncIDPref(newSyncID);
+    Svc.Prefs.set(this.name + ".syncID", newSyncID);
+    Svc.Prefs.set(this.name + ".lastSync", "0");
     return newSyncID;
   },
 
   /**
    * Resets the local sync ID for the engine, wipes the server, and resets all
    * local Sync state to start over as a first sync.
    *
    * @return the new sync ID.
@@ -897,23 +898,17 @@ SyncEngine.prototype = {
 
   /**
    * Resets the local sync ID for the engine, signaling that we're starting over
    * as a first sync.
    *
    * @return the new sync ID.
    */
   async resetLocalSyncID() {
-    return this.setSyncIDPref(Utils.makeGUID());
-  },
-
-  setSyncIDPref(syncID) {
-    Svc.Prefs.set(this.name + ".syncID", syncID);
-    Svc.Prefs.set(this.name + ".lastSync", "0");
-    return syncID;
+    return this.ensureCurrentSyncID(Utils.makeGUID());
   },
 
   /*
    * lastSync is a timestamp in server time.
    */
   async getLastSync() {
     return this._lastSync;
   },
--- a/services/sync/modules/engines/bookmarks.js
+++ b/services/sync/modules/engines/bookmarks.js
@@ -329,41 +329,47 @@ BaseBookmarksEngine.prototype = {
     let shouldWipeRemote = await PlacesSyncUtils.bookmarks.shouldWipeRemote();
     if (!shouldWipeRemote) {
       // Migrate the bookmarks sync ID and last sync time from prefs, to avoid
       // triggering a full sync on upgrade. This can be removed in bug 1443021.
       let existingSyncID = await super.getSyncID();
       if (existingSyncID) {
         this._log.debug("Migrating existing sync ID ${existingSyncID} from " +
                         "prefs", { existingSyncID });
-        await PlacesSyncUtils.bookmarks.ensureCurrentSyncId(existingSyncID);
+        await this._ensureCurrentSyncID(existingSyncID);
       }
       if (migrateLastSync) {
         let existingLastSync = await super.getLastSync();
         if (existingLastSync) {
           this._log.debug("Migrating existing last sync time " +
                           "${existingLastSync} from prefs",
                           { existingLastSync });
           await PlacesSyncUtils.bookmarks.setLastSync(existingLastSync);
         }
       }
     }
     this._migratedSyncMetadata = true;
   },
 
+  // Exposed so that the buffered engine can override to store the sync ID in
+  // the mirror.
+  _ensureCurrentSyncID(newSyncID) {
+    return PlacesSyncUtils.bookmarks.ensureCurrentSyncId(newSyncID);
+  },
+
   async ensureCurrentSyncID(newSyncID) {
     let shouldWipeRemote = await PlacesSyncUtils.bookmarks.shouldWipeRemote();
     if (!shouldWipeRemote) {
       this._log.debug("Checking if server sync ID ${newSyncID} matches " +
                       "existing", { newSyncID });
-      await PlacesSyncUtils.bookmarks.ensureCurrentSyncId(newSyncID);
+      await this._ensureCurrentSyncID(newSyncID);
       // Update the sync ID in prefs to allow downgrading to older Firefox
       // releases that don't store Sync metadata in Places. This can be removed
       // in bug 1443021.
-      super.setSyncIDPref(newSyncID);
+      await super.ensureCurrentSyncID(newSyncID);
       return newSyncID;
     }
     // We didn't take the new sync ID because we need to wipe the server
     // and other clients after a restore. Send the command, wipe the
     // server, and reset our sync ID to reupload everything.
     this._log.debug("Ignoring server sync ID ${newSyncID} after restore; " +
                     "wiping server and resetting sync ID", { newSyncID });
     await this.service.clientsEngine.sendCommand("wipeEngine", [this.name],
@@ -375,24 +381,20 @@ BaseBookmarksEngine.prototype = {
   async resetSyncID() {
     await this._deleteServerCollection();
     return this.resetLocalSyncID();
   },
 
   async resetLocalSyncID() {
     let newSyncID = await PlacesSyncUtils.bookmarks.resetSyncId();
     this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID });
-    super.setSyncIDPref(newSyncID); // Remove in bug 1443021.
+    await super.ensureCurrentSyncID(newSyncID); // Remove in bug 1443021.
     return newSyncID;
   },
 
-  setSyncIDPref(syncID) {
-    throw new Error("Use ensureCurrentSyncID or resetLocalSyncID");
-  },
-
   async _syncFinish() {
     await SyncEngine.prototype._syncFinish.call(this);
     await PlacesSyncUtils.bookmarks.ensureMobileQuery();
   },
 
   async _createRecord(id) {
     if (this._modified.isTombstone(id)) {
       // If we already know a changed item is a tombstone, just create the
@@ -417,16 +419,20 @@ BaseBookmarksEngine.prototype = {
     let changes = this._modified.changes;
     await PlacesSyncUtils.bookmarks.pushChanges(changes);
   },
 
   _deleteId(id) {
     this._noteDeletedId(id);
   },
 
+  // The bookmarks engine rarely calls this method directly, except in tests or
+  // when handling a `reset{All, Engine}` command from another client. We
+  // usually reset local Sync metadata on a sync ID mismatch, which both engines
+  // override with logic that lives in Places and the mirror.
   async _resetClient() {
     await super._resetClient();
     await PlacesSyncUtils.bookmarks.reset();
   },
 
   // Cleans up the Places root, reading list items (ignored in bug 762118,
   // removed in bug 1155684), and pinned sites.
   _shouldDeleteRemotely(incomingItem) {
@@ -777,24 +783,30 @@ BufferedBookmarksEngine.prototype = {
 
   async _syncStartup() {
     await this._migrateSyncMetadata({
       migrateLastSync: false,
     });
     await super._syncStartup();
   },
 
+  async _ensureCurrentSyncID(newSyncID) {
+    await super._ensureCurrentSyncID(newSyncID);
+    let buf = await this._store.ensureOpenMirror();
+    await buf.ensureCurrentSyncId(newSyncID);
+  },
+
   async getSyncID() {
     return PlacesSyncUtils.bookmarks.getSyncId();
   },
 
   async resetLocalSyncID() {
+    let newSyncID = await super.resetLocalSyncID();
     let buf = await this._store.ensureOpenMirror();
-    await buf.reset();
-    let newSyncID = await super.resetLocalSyncID();
+    await buf.ensureCurrentSyncId(newSyncID);
     return newSyncID;
   },
 
   async getLastSync() {
     let mirror = await this._store.ensureOpenMirror();
     return mirror.getCollectionHighWaterMark();
   },
 
--- a/services/sync/modules/engines/history.js
+++ b/services/sync/modules/engines/history.js
@@ -70,40 +70,36 @@ HistoryEngine.prototype = {
   async getSyncID() {
     return PlacesSyncUtils.history.getSyncId();
   },
 
   async ensureCurrentSyncID(newSyncID) {
     this._log.debug("Checking if server sync ID ${newSyncID} matches existing",
                     { newSyncID });
     await PlacesSyncUtils.history.ensureCurrentSyncId(newSyncID);
-    super.setSyncIDPref(newSyncID); // Remove in bug 1443021.
+    await super.ensureCurrentSyncID(newSyncID); // Remove in bug 1443021.
     return newSyncID;
   },
 
   async resetSyncID() {
     // First, delete the collection on the server. It's fine if we're
     // interrupted here: on the next sync, we'll detect that our old sync ID is
     // now stale, and start over as a first sync.
     await this._deleteServerCollection();
     // Then, reset our local sync ID.
     return this.resetLocalSyncID();
   },
 
   async resetLocalSyncID() {
     let newSyncID = await PlacesSyncUtils.history.resetSyncId();
     this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID });
-    await super.setSyncIDPref(newSyncID); // Remove in bug 1443021.
+    await super.ensureCurrentSyncID(newSyncID); // Remove in bug 1443021.
     return newSyncID;
   },
 
-  setSyncIDPref(syncID) {
-    throw new Error("Use ensureCurrentSyncID or resetLocalSyncID");
-  },
-
   async getLastSync() {
     let lastSync = await PlacesSyncUtils.history.getLastSync();
     return lastSync;
   },
 
   async setLastSync(lastSync) {
     await PlacesSyncUtils.history.setLastSync(lastSync);
     await super.setLastSync(lastSync); // Remove in bug 1443021.
--- a/services/sync/tests/unit/test_bookmark_engine.js
+++ b/services/sync/tests/unit/test_bookmark_engine.js
@@ -1,13 +1,14 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 ChromeUtils.import("resource://gre/modules/BookmarkHTMLUtils.jsm");
 ChromeUtils.import("resource://gre/modules/BookmarkJSONUtils.jsm");
+ChromeUtils.import("resource://gre/modules/SyncedBookmarksMirror.jsm");
 ChromeUtils.import("resource://gre/modules/Log.jsm");
 ChromeUtils.import("resource://gre/modules/osfile.jsm");
 ChromeUtils.import("resource://services-common/utils.js");
 ChromeUtils.import("resource://services-sync/constants.js");
 ChromeUtils.import("resource://services-sync/engines.js");
 ChromeUtils.import("resource://services-sync/engines/bookmarks.js");
 ChromeUtils.import("resource://services-sync/service.js");
 ChromeUtils.import("resource://services-sync/util.js");
@@ -1093,8 +1094,109 @@ add_task(async function test_buffered_mi
 
   equal(Svc.Prefs.get(`${bufferedEngine.name}.syncID`), newSyncID,
     "Changing buffered engine sync ID should update prefs");
   strictEqual(Svc.Prefs.get(`${bufferedEngine.name}.lastSync`), "0",
     "Changing buffered engine sync ID should clear last sync pref");
 
   await bufferedEngine.wipeClient();
 });
+
+// The buffered engine stores the sync ID and last sync time in three places:
+// prefs, Places, and the mirror. We can remove the prefs entirely in bug
+// 1443021, and drop the last sync time from Places once we remove the legacy
+// engine. This test ensures we keep them in sync (^_^), and handle mismatches
+// in case the user copies Places or the mirror between accounts. See
+// bug 1199077, comment 84 for the gory details.
+add_task(async function test_mirror_syncID() {
+  let bufferedEngine = new BufferedBookmarksEngine(Service);
+  await bufferedEngine.initialize();
+  let buf = await bufferedEngine._store.ensureOpenMirror();
+
+  info("Places and mirror don't have sync IDs");
+
+  let syncID = await bufferedEngine.resetLocalSyncID();
+
+  equal(Svc.Prefs.get(`${bufferedEngine.name}.syncID`), syncID,
+    "Should reset sync ID in prefs");
+  strictEqual(Svc.Prefs.get(`${bufferedEngine.name}.lastSync`), "0",
+    "Should reset last sync in prefs");
+
+  equal(await PlacesSyncUtils.bookmarks.getSyncId(), syncID,
+    "Should reset sync ID in Places");
+  strictEqual(await PlacesSyncUtils.bookmarks.getLastSync(), 0,
+    "Should reset last sync in Places");
+
+  equal(await buf.getSyncId(), syncID, "Should reset sync ID in mirror");
+  strictEqual(await buf.getCollectionHighWaterMark(), 0,
+    "Should reset high water mark in mirror");
+
+  info("Places and mirror have matching sync ID");
+
+  await bufferedEngine.setLastSync(123.45);
+  await bufferedEngine.ensureCurrentSyncID(syncID);
+
+  equal(Svc.Prefs.get(`${bufferedEngine.name}.syncID`), syncID,
+    "Should keep sync ID in prefs if Places and mirror match");
+  strictEqual(Svc.Prefs.get(`${bufferedEngine.name}.lastSync`), "123.45",
+    "Should keep last sync in prefs if Places and mirror match");
+
+  equal(await PlacesSyncUtils.bookmarks.getSyncId(), syncID,
+    "Should keep sync ID in Places if Places and mirror match");
+  strictEqual(await PlacesSyncUtils.bookmarks.getLastSync(), 123.45,
+    "Should keep last sync in Places if Places and mirror match");
+
+  equal(await buf.getSyncId(), syncID, "Should keep sync ID in mirror");
+  equal(await buf.getCollectionHighWaterMark(), 123.45,
+    "Should keep high water mark in mirror");
+
+  info("Places and mirror have different sync IDs");
+
+  // Directly update the sync ID in the mirror, without resetting.
+  await buf.db.execute(`UPDATE meta SET value = :value WHERE key = :key`,
+                       { key: SyncedBookmarksMirror.META_KEY.SYNC_ID,
+                         value: "syncIdAAAAAA" });
+  await bufferedEngine.ensureCurrentSyncID(syncID);
+
+  equal(Svc.Prefs.get(`${bufferedEngine.name}.syncID`), syncID,
+    "Should keep sync ID in prefs if Places and mirror don't match");
+  strictEqual(Svc.Prefs.get(`${bufferedEngine.name}.lastSync`), "123.45",
+    "Should keep last sync in prefs if Places and mirror don't match");
+
+  equal(await PlacesSyncUtils.bookmarks.getSyncId(), syncID,
+    "Should keep existing sync ID in Places on mirror sync ID mismatch");
+  strictEqual(await PlacesSyncUtils.bookmarks.getLastSync(), 123.45,
+    "Should keep existing last sync in Places on mirror sync ID mismatch");
+
+  equal(await buf.getSyncId(), syncID,
+    "Should reset mismatched sync ID in mirror");
+  strictEqual(await buf.getCollectionHighWaterMark(), 0,
+    "Should reset high water mark on mirror sync ID mismatch");
+
+  info("Places has sync ID; mirror missing sync ID");
+  await buf.reset();
+
+  equal(await bufferedEngine.ensureCurrentSyncID(syncID), syncID,
+    "Should not assign new sync ID if Places has sync ID; mirror missing");
+  equal(await buf.getSyncId(), syncID,
+    "Should set sync ID in mirror to match Places");
+
+  info("Places missing sync ID; mirror has sync ID");
+
+  await buf.setCollectionLastModified(123.45);
+  await PlacesSyncUtils.bookmarks.reset();
+  let newSyncID = await bufferedEngine.ensureCurrentSyncID("syncIdBBBBBB");
+
+  equal(Svc.Prefs.get(`${bufferedEngine.name}.syncID`), newSyncID,
+    "Should set new sync ID in prefs");
+  strictEqual(Svc.Prefs.get(`${bufferedEngine.name}.lastSync`), "0",
+    "Should reset last sync in prefs on sync ID change");
+
+  equal(await PlacesSyncUtils.bookmarks.getSyncId(), newSyncID,
+    "Should set new sync ID in Places");
+  equal(await buf.getSyncId(), newSyncID,
+    "Should update new sync ID in mirror");
+
+  strictEqual(await buf.getCollectionHighWaterMark(), 0,
+    "Should reset high water mark on sync ID change in Places");
+
+  await bufferedEngine.wipeClient();
+});
--- a/toolkit/components/places/PlacesSyncUtils.jsm
+++ b/toolkit/components/places/PlacesSyncUtils.jsm
@@ -122,23 +122,23 @@ const HistorySyncUtils = PlacesSyncUtils
     }
     await PlacesUtils.withConnectionWrapper(
       "HistorySyncUtils: ensureCurrentSyncId",
       async function(db) {
         let existingSyncId = await PlacesUtils.metadata.getWithConnection(
           db, HistorySyncUtils.SYNC_ID_META_KEY);
 
         if (existingSyncId == newSyncId) {
-          HistorySyncLog.debug("History sync ID up-to-date",
+          HistorySyncLog.trace("History sync ID up-to-date",
                                { existingSyncId });
           return;
         }
 
-        HistorySyncLog.debug("History sync ID changed; resetting metadata",
-                             { existingSyncId, newSyncId });
+        HistorySyncLog.info("History sync ID changed; resetting metadata",
+                            { existingSyncId, newSyncId });
         await db.executeTransaction(function() {
           return setHistorySyncId(db, newSyncId);
         });
       }
     );
   },
 
   /**
@@ -470,33 +470,33 @@ const BookmarkSyncUtils = PlacesSyncUtil
       "BookmarkSyncUtils: ensureCurrentSyncId",
       async function(db) {
         let existingSyncId = await PlacesUtils.metadata.getWithConnection(
           db, BookmarkSyncUtils.SYNC_ID_META_KEY);
 
         // If we don't have a sync ID, take the server's without resetting
         // sync statuses.
         if (!existingSyncId) {
-          BookmarkSyncLog.debug("Taking new bookmarks sync ID", { newSyncId });
+          BookmarkSyncLog.info("Taking new bookmarks sync ID", { newSyncId });
           await db.executeTransaction(() => setBookmarksSyncId(db, newSyncId));
           return;
         }
 
         // If the existing sync ID matches the server, great!
         if (existingSyncId == newSyncId) {
-          BookmarkSyncLog.debug("Bookmarks sync ID up-to-date",
+          BookmarkSyncLog.trace("Bookmarks sync ID up-to-date",
                                 { existingSyncId });
           return;
         }
 
         // Otherwise, we have a sync ID, but it doesn't match, so we were likely
         // node reassigned. Take the server's sync ID and reset all items to
         // "UNKNOWN" so that we can merge.
-        BookmarkSyncLog.debug("Bookmarks sync ID changed; resetting sync " +
-                              "statuses", { existingSyncId, newSyncId });
+        BookmarkSyncLog.info("Bookmarks sync ID changed; resetting sync " +
+                             "statuses", { existingSyncId, newSyncId });
         await db.executeTransaction(async function() {
           await setBookmarksSyncId(db, newSyncId);
           await resetAllSyncStatuses(db,
             PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN);
         });
       }
     );
   },
--- a/toolkit/components/places/SyncedBookmarksMirror.jsm
+++ b/toolkit/components/places/SyncedBookmarksMirror.jsm
@@ -204,23 +204,23 @@ class SyncedBookmarksMirror {
    *         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(`
+    let rows = await this.db.executeCached(`
       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 });
+      { modifiedKey: SyncedBookmarksMirror.META_KEY.LAST_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.
    *
@@ -229,20 +229,73 @@ class SyncedBookmarksMirror {
    */
   async setCollectionLastModified(lastModifiedSeconds) {
     let lastModified = Math.floor(lastModifiedSeconds * 1000);
     if (!Number.isInteger(lastModified)) {
       throw new TypeError("Invalid collection last modified time");
     }
     await this.db.executeBeforeShutdown(
       "SyncedBookmarksMirror: setCollectionLastModified",
-      db => db.execute(`
+      db => db.executeCached(`
         REPLACE INTO meta(key, value)
         VALUES(:modifiedKey, :lastModified)`,
-        { modifiedKey: SyncedBookmarksMirror.META.MODIFIED, lastModified })
+        { modifiedKey: SyncedBookmarksMirror.META_KEY.LAST_MODIFIED,
+          lastModified })
+    );
+  }
+
+  /**
+   * Returns the bookmarks collection sync ID. This corresponds to
+   * `PlacesSyncUtils.bookmarks.getSyncId`.
+   *
+   * @return {String}
+   *         The sync ID, or `""` if one isn't set.
+   */
+  async getSyncId() {
+    let rows = await this.db.executeCached(`
+      SELECT value FROM meta WHERE key = :syncIdKey`,
+      { syncIdKey: SyncedBookmarksMirror.META_KEY.SYNC_ID });
+    return rows.length ? rows[0].getResultByName("value") : "";
+  }
+
+  /**
+   * Ensures that the sync ID in the mirror is up-to-date with the server and
+   * Places, and discards the mirror on mismatch.
+   *
+   * The bookmarks engine store the same sync ID in Places and the mirror to
+   * "tie" the two together. This allows Sync to do the right thing if the
+   * database files are copied between profiles connected to different accounts.
+   *
+   * See `PlacesSyncUtils.bookmarks.ensureCurrentSyncId` for an explanation of
+   * how Places handles sync ID mismatches.
+   *
+   * @param {String} newSyncId
+   *        The server's sync ID.
+   */
+  async ensureCurrentSyncId(newSyncId) {
+    if (!newSyncId || typeof newSyncId != "string") {
+      throw new TypeError("Invalid new bookmarks sync ID");
+    }
+    let existingSyncId = await this.getSyncId();
+    if (existingSyncId == newSyncId) {
+      MirrorLog.trace("Sync ID up-to-date in mirror", { existingSyncId });
+      return;
+    }
+    MirrorLog.info("Sync ID changed from ${existingSyncId} to " +
+                   "${newSyncId}; resetting mirror",
+                   { existingSyncId, newSyncId });
+    await this.db.executeBeforeShutdown(
+      "SyncedBookmarksMirror: ensureCurrentSyncId",
+      db => db.executeTransaction(async function() {
+        await resetMirror(db);
+        await db.execute(`
+          REPLACE INTO meta(key, value)
+          VALUES(:syncIdKey, :newSyncId)`,
+          { syncIdKey: SyncedBookmarksMirror.META_KEY.SYNC_ID, newSyncId });
+      })
     );
   }
 
   /**
    * Stores incoming or uploaded Sync records in the mirror. Rejects if any
    * records are invalid.
    *
    * @param {PlacesItem[]} records
@@ -475,28 +528,17 @@ class SyncedBookmarksMirror {
 
   /**
    * 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 structure`);
-          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);
-        });
-      }
+      db => db.executeTransaction(() => resetMirror(db))
     );
   }
 
   /**
    * Fetches the GUIDs of all items in the remote tree that need to be merged
    * into the local tree.
    *
    * @return {String[]}
@@ -1893,19 +1935,20 @@ this.SyncedBookmarksMirror = SyncedBookm
 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,
+/** Key names for the key-value `meta` table. */
+SyncedBookmarksMirror.META_KEY = {
+  LAST_MODIFIED: "collection/lastModified",
+  SYNC_ID: "collection/syncId",
 };
 
 /**
  * An error thrown when the merge can't proceed because the local or remote
  * tree is inconsistent.
  */
 SyncedBookmarksMirror.ConsistencyError =
   class ConsistencyError extends Error {};
@@ -1938,22 +1981,21 @@ async function migrateMirrorSchema(db) {
 /**
  * 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.
+  // Key-value metadata table. Stores the server collection last modified time
+  // and sync ID.
   await db.execute(`CREATE TABLE mirror.meta(
-    key INTEGER PRIMARY KEY,
+    key TEXT 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,
@@ -2614,16 +2656,27 @@ async function initializeTempMirrorEntit
   await db.execute(`CREATE TEMP TABLE structureToUpload(
     guid TEXT PRIMARY KEY,
     parentId INTEGER NOT NULL REFERENCES itemsToUpload(id)
                               ON DELETE CASCADE,
     position INTEGER NOT NULL
   ) WITHOUT ROWID`);
 }
 
+async function resetMirror(db) {
+  await db.execute(`DELETE FROM meta`);
+  await db.execute(`DELETE FROM structure`);
+  await db.execute(`DELETE FROM items`);
+  await db.execute(`DELETE FROM urls`);
+
+  // Since we need to reset the modified times and merge flags for the syncable
+  // roots, we simply delete and recreate them.
+  await createMirrorRoots(db);
+}
+
 // 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.
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_highWaterMark() {
+  let buf = await openMirror("highWaterMark");
+
+  strictEqual(await buf.getCollectionHighWaterMark(), 0,
+    "High water mark should be 0 without items");
+
+  await buf.setCollectionLastModified(123.45);
+  equal(await buf.getCollectionHighWaterMark(), 123.45,
+    "High water mark should be last modified time without items");
+
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    children: [],
+    modified: 50,
+  }, {
+    id: "toolbar",
+    type: "folder",
+    children: [],
+    modified: 123.95,
+  }]);
+  equal(await buf.getCollectionHighWaterMark(), 123.45,
+    "High water mark should be last modified time if items are older");
+
+  await buf.store([{
+    id: "unfiled",
+    type: "folder",
+    children: [],
+    modified: 125.45,
+  }]);
+  equal(await buf.getCollectionHighWaterMark(), 124.45,
+    "High water mark should be modified time - 1s of newest record if exists");
+
+  await buf.finalize();
+});
+
+add_task(async function test_ensureCurrentSyncId() {
+  let buf = await openMirror("ensureCurrentSyncId");
+
+  await buf.ensureCurrentSyncId("syncIdAAAAAA");
+  equal(await buf.getCollectionHighWaterMark(), 0,
+    "High water mark should be 0 after setting sync ID");
+
+  info("Insert items and set collection last modified");
+  await buf.store([{
+    id: "menu",
+    type: "folder",
+    children: ["folderAAAAAA"],
+    modified: 125.45,
+  }, {
+    id: "folderAAAAAA",
+    type: "folder",
+    children: [],
+  }], { needsMerge: false });
+  await buf.setCollectionLastModified(123.45);
+
+  info("Set matching sync ID");
+  await buf.ensureCurrentSyncId("syncIdAAAAAA");
+  {
+    equal(await buf.getSyncId(), "syncIdAAAAAA",
+      "Should return existing sync ID");
+    strictEqual(await buf.getCollectionHighWaterMark(), 124.45,
+      "Different sync ID should reset high water mark");
+
+    let itemRows = await buf.db.execute(`
+      SELECT guid, needsMerge FROM items
+      ORDER BY guid`);
+    let itemInfos = itemRows.map(row => ({
+      guid: row.getResultByName("guid"),
+      needsMerge: !!row.getResultByName("needsMerge"),
+    }));
+    deepEqual(itemInfos, [{
+      guid: "folderAAAAAA",
+      needsMerge: false,
+    }, {
+      guid: PlacesUtils.bookmarks.menuGuid,
+      needsMerge: false,
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      needsMerge: true,
+    }, {
+      guid: PlacesUtils.bookmarks.rootGuid,
+      needsMerge: false,
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      needsMerge: true,
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      needsMerge: true,
+    }], "Matching sync ID should not reset items");
+  }
+
+  info("Set different sync ID");
+  await buf.ensureCurrentSyncId("syncIdBBBBBB");
+  {
+    equal(await buf.getSyncId(), "syncIdBBBBBB",
+      "Should replace existing sync ID");
+    strictEqual(await buf.getCollectionHighWaterMark(), 0,
+      "Different sync ID should reset high water mark");
+
+    let itemRows = await buf.db.execute(`
+      SELECT guid, needsMerge FROM items
+      ORDER BY guid`);
+    let itemInfos = itemRows.map(row => ({
+      guid: row.getResultByName("guid"),
+      needsMerge: !!row.getResultByName("needsMerge"),
+    }));
+    deepEqual(itemInfos, [{
+      guid: PlacesUtils.bookmarks.menuGuid,
+      needsMerge: true,
+    }, {
+      guid: PlacesUtils.bookmarks.mobileGuid,
+      needsMerge: true,
+    }, {
+      guid: PlacesUtils.bookmarks.rootGuid,
+      needsMerge: false,
+    }, {
+      guid: PlacesUtils.bookmarks.toolbarGuid,
+      needsMerge: true,
+    }, {
+      guid: PlacesUtils.bookmarks.unfiledGuid,
+      needsMerge: true,
+    }], "Different sync ID should reset items");
+  }
+});
--- a/toolkit/components/places/tests/sync/xpcshell.ini
+++ b/toolkit/components/places/tests/sync/xpcshell.ini
@@ -6,12 +6,13 @@ support-files =
   sync_utils_bookmarks.json
 
 [test_bookmark_corruption.js]
 [test_bookmark_deduping.js]
 [test_bookmark_deletion.js]
 [test_bookmark_explicit_weakupload.js]
 [test_bookmark_haschanges.js]
 [test_bookmark_kinds.js]
+[test_bookmark_mirror_meta.js]
 [test_bookmark_structure_changes.js]
 [test_bookmark_validation.js]
 [test_bookmark_value_changes.js]
 [test_sync_utils.js]