Bug 1567238 - Refactor the bookmarks mirror merge triggers to do less work. r=tcsc,markh
authorLina Cambridge <lina@yakshaving.ninja>
Tue, 06 Aug 2019 23:48:03 +0000
changeset 486640 338b1c471939f0a45394b3e145cd22074477fe78
parent 486639 3b0f047710050088034bb83143f31478d99a516f
child 486641 663b94830a6b264984d92d005c8df56e0ddce232
push id36400
push useraciure@mozilla.com
push dateWed, 07 Aug 2019 04:33:53 +0000
treeherdermozilla-central@36592e14f6ce [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerstcsc, markh
bugs1567238
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1567238 - Refactor the bookmarks mirror merge triggers to do less work. r=tcsc,markh This commit reduces the number of database writes and table scans needed to merge synced bookmarks. * Remove `fetchNew{Local, Remote}Contents`. Fetching the tree already scans the table, so we can piggyback on it to fetch content info for deduping. * Store completion ops in temp tables to only update changed parts of the local tree, and remove the `mergeStates` table and views. * Replace the `itemsToMerge` view with an indexed `itemsToApply` temp table. * Replace the `updateGuidsAndSyncFlags` trigger with a `changeGuidOps` table and a `changeGuids` trigger. * Replace the `updateLocalItems` trigger with an `apply_remote_items` function to bulk upsert new and updated items. * Replace the `structureToMerge` view with an `applyNewLocalStructureOps` table that holds parents and positions for moved items, and an `applyNewLocalStructure` trigger to update them. * Remove tombstones for revived items, update change counters, and flag mirror items as merged directly in `update_local_items_in_places`, instead of indirecting through temp tables. * Don't mark items flagged for reupload as merged, since we'll write them back to the mirror after upload. * Use a scalar subquery instead of a join in the `localTags` view to look up the tags root ID. * Replace `relatedIdsToReupload` with a `Store::prepare` method that flags all bookmarks with keyword-URL mismatches for reupload. * Trigger frecency updates for origins once, not for every item. * Remove two extra scans on `itemsAdded` and `itemsChanged` when recording observer notifications for changed keywords. * Notify all `bookmark-added` listeners in a single batch. This also fixes some edge cases: * Update root positions correctly after deleting a non-syncable root or item. * Keyword-URL mismatches may reupload more items than before, but now ensure that all bookmarks with the same URL have the same keyword. * Only set items with deduped GUIDs to `SYNC_STATUS_NORMAL` after merging. * Bump the last modified time for modified items only. Differential Revision: https://phabricator.services.mozilla.com/D39889
Cargo.lock
services/sync/tests/unit/test_bookmark_engine.js
third_party/rust/dogear/.cargo-checksum.json
third_party/rust/dogear/Cargo.toml
third_party/rust/dogear/src/driver.rs
third_party/rust/dogear/src/guid.rs
third_party/rust/dogear/src/lib.rs
third_party/rust/dogear/src/merge.rs
third_party/rust/dogear/src/store.rs
third_party/rust/dogear/src/tests.rs
third_party/rust/dogear/src/tree.rs
toolkit/components/places/SyncedBookmarksMirror.jsm
toolkit/components/places/bookmark_sync/Cargo.toml
toolkit/components/places/bookmark_sync/src/driver.rs
toolkit/components/places/bookmark_sync/src/error.rs
toolkit/components/places/bookmark_sync/src/merger.rs
toolkit/components/places/bookmark_sync/src/store.rs
toolkit/components/places/mozISyncedBookmarksMirror.idl
toolkit/components/places/tests/sync/head_sync.js
toolkit/components/places/tests/sync/mirror_v5.sqlite
toolkit/components/places/tests/sync/test_bookmark_chunking.js
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_merge_conflicts.js
toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js
toolkit/components/places/tests/sync/test_bookmark_observer_recorder.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
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -380,17 +380,17 @@ dependencies = [
 ]
 
 [[package]]
 name = "bookmark_sync"
 version = "0.1.0"
 dependencies = [
  "atomic_refcell 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "cstr 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
- "dogear 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)",
+ "dogear 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
  "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
  "moz_task 0.1.0",
  "nserror 0.1.0",
  "nsstring 0.1.0",
  "storage 0.1.0",
  "storage_variant 0.1.0",
  "thin-vec 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -943,17 +943,17 @@ source = "registry+https://github.com/ru
 dependencies = [
  "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
  "redox_users 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "winapi 0.3.6 (git+https://github.com/froydnj/winapi-rs?branch=aarch64)",
 ]
 
 [[package]]
 name = "dogear"
-version = "0.2.6"
+version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 dependencies = [
  "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
  "smallbitvec 2.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
 [[package]]
 name = "dtoa"
@@ -3969,17 +3969,17 @@ dependencies = [
 "checksum darling_core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "6afc018370c3bff3eb51f89256a6bdb18b4fdcda72d577982a14954a7a0b402c"
 "checksum darling_macro 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c6d8dac1c6f1d29a41c4712b4400f878cb4fcc4c7628f298dd75038e024998d1"
 "checksum dbus 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)" = "b9e1b39f3f6aa3d4a1522c4f0f9f1e9e9167bd93740a8690874caa7cf8ce47d7"
 "checksum deflate 0.7.19 (registry+https://github.com/rust-lang/crates.io-index)" = "8a6abb26e16e8d419b5c78662aa9f82857c2386a073da266840e474d5055ec86"
 "checksum derive_more 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3f57d78cf3bd45270dad4e70c21ec77a960b36c7a841ff9db76aaa775a8fb871"
 "checksum devd-rs 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "0d009f166c0d9e9f9909dc751630b3a6411ab7f85a153d32d01deb364ffe52a7"
 "checksum digest 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "05f47366984d3ad862010e22c7ce81a7dbcaebbdfb37241a620f8b6596ee135c"
 "checksum dirs 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "88972de891f6118092b643d85a0b28e0678e0f948d7f879aa32f2d5aafe97d2a"
-"checksum dogear 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "c01a457f8d6689260111be60774bfb68e558b41bc89b866ebc3bbed60ba255cb"
+"checksum dogear 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "99e2ed5f8036ceb83d951f769651ff0e2be5ddb0c62da87d50d27c22086db01c"
 "checksum dtoa 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "09c3753c3db574d215cba4ea76018483895d7bff25a31b49ba45db21c48e50ab"
 "checksum dtoa-short 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "068d4026697c1a18f0b0bb8cfcad1b0c151b90d8edb9bf4c235ad68128920d1d"
 "checksum dwrote 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "0bd1369e02db5e9b842a9b67bce8a2fcc043beafb2ae8a799dd482d46ea1ff0d"
 "checksum either 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "18785c1ba806c258137c937e44ada9ee7e69a37e3c72077542cd2f069d78562a"
 "checksum encoding_c 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "769ecb8b33323998e482b218c0d13cd64c267609023b4b7ec3ee740714c318ee"
 "checksum encoding_rs 0.8.17 (registry+https://github.com/rust-lang/crates.io-index)" = "4155785c79f2f6701f185eb2e6b4caf0555ec03477cb4c70db67b465311620ed"
 "checksum enum-display-derive 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "53f76eb63c4bfc6fce5000f106254701b741fc9a65ee08445fde0ff39e583f1c"
 "checksum env_logger 0.5.6 (registry+https://github.com/rust-lang/crates.io-index)" = "0561146661ae44c579e993456bc76d11ce1e0c7d745e57b2fa7146b6e49fa2ad"
--- a/services/sync/tests/unit/test_bookmark_engine.js
+++ b/services/sync/tests/unit/test_bookmark_engine.js
@@ -263,19 +263,17 @@ add_bookmark_test(async function test_de
             },
           ],
           "Buffered engine should report parent-child disagreement"
         );
         deepEqual(
           engineData.steps.map(step => step.name),
           [
             "fetchLocalTree",
-            "fetchNewLocalContents",
             "fetchRemoteTree",
-            "fetchNewRemoteContents",
             "merge",
             "apply",
             "notifyObservers",
             "fetchLocalChangeRecords",
           ],
           "Buffered engine should report all merge steps"
         );
       }
--- a/third_party/rust/dogear/.cargo-checksum.json
+++ b/third_party/rust/dogear/.cargo-checksum.json
@@ -1,1 +1,1 @@
-{"files":{"CODE_OF_CONDUCT.md":"e85149c44f478f164f7d5f55f6e66c9b5ae236d4a11107d5e2a93fe71dd874b9","Cargo.toml":"04fe24f3f6bef773af26909e0b931bd1c82ab5f93a296b56cc88fdc059abba6d","LICENSE":"c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4","README.md":"303ea5ec53d4e86f2c321056e8158e31aa061353a99e52de3d76859d40919efc","src/driver.rs":"1223c848f8e381c52a8b112ad4a987cc4515862a9c5e021814d4bc6cc1fcf361","src/error.rs":"d4ef0cba5c7fc54959ed62da166f10435548d705e0a817eed449fb001fe4e21d","src/guid.rs":"790700aa07b1d1616d76476c48c9bfda6014350b4b028d4b7c05ac1b8f1c8870","src/lib.rs":"5f4f0065b560d9a55cc83499c2e4f44305a7332e1c959e5f2fc04e7d994b794a","src/merge.rs":"63b2f30fea4034d4ca37f835bf18e6344781cd0d5db0f0b56c49395ba3650ed7","src/store.rs":"b5e724fb8e6e9e2d42541ec88dd2a4ad8f7198c4abf3d591fddefd0dafb4a145","src/tests.rs":"9c5b5d3088bfbe18ea03c4ad178be9950c49b35a7c83df734ff9bc98690b9806","src/tree.rs":"23087add8c915b955b742eed51762b88f0c992e0bda95b5c32b89d7571bae7ef"},"package":"c01a457f8d6689260111be60774bfb68e558b41bc89b866ebc3bbed60ba255cb"}
\ No newline at end of file
+{"files":{"CODE_OF_CONDUCT.md":"e85149c44f478f164f7d5f55f6e66c9b5ae236d4a11107d5e2a93fe71dd874b9","Cargo.toml":"8a1067e5d4af68c738884e76256033d638b938c40789eefb0c078a79f5238890","LICENSE":"c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4","README.md":"303ea5ec53d4e86f2c321056e8158e31aa061353a99e52de3d76859d40919efc","src/driver.rs":"913de08a578b38902f4eb0d78147289897af5d3b1ecdad818c4ea6f83da6c714","src/error.rs":"d4ef0cba5c7fc54959ed62da166f10435548d705e0a817eed449fb001fe4e21d","src/guid.rs":"c82af64fba3ad87948a9b599241e48753d17587e8c642f610949163be3d499bf","src/lib.rs":"0606e69b235650bf404ae0b03a1e85c2063bb4b7147fa4d5e8ff2c128a757453","src/merge.rs":"1fcdc00dcf47743e72372ba85b00e5f48d0fc9b8586f9b3bd5c0b0604628d8b3","src/store.rs":"e5ce4c358b05b2483198b37c90d2f6c740c8e349c70abcee3ba914cb80f66aae","src/tests.rs":"51bc77eb989974f7594dfa0cdf7741f8e26f2956eebc34d1f71cbfd137512940","src/tree.rs":"fcdc9a5ae0e3f1b0d62d01bf1024db2a0aa8660e6839ada607a6a20e0fe2ad83"},"package":"99e2ed5f8036ceb83d951f769651ff0e2be5ddb0c62da87d50d27c22086db01c"}
\ No newline at end of file
--- a/third_party/rust/dogear/Cargo.toml
+++ b/third_party/rust/dogear/Cargo.toml
@@ -8,17 +8,17 @@
 # If you believe there's an error in this file please file an
 # issue against the rust-lang/cargo repository. If you're
 # editing this file be aware that the upstream Cargo.toml
 # will likely look very different (and much more reasonable)
 
 [package]
 edition = "2018"
 name = "dogear"
-version = "0.2.6"
+version = "0.3.0"
 authors = ["Lina Cambridge <lina@mozilla.com>"]
 exclude = ["/.travis/**", ".travis.yml", "/docs/**", "book.toml"]
 description = "A library for merging bookmark trees."
 readme = "README.md"
 license = "Apache-2.0"
 repository = "https://github.com/mozilla/dogear"
 [dependencies.log]
 version = "0.4"
--- a/third_party/rust/dogear/src/driver.rs
+++ b/third_party/rust/dogear/src/driver.rs
@@ -53,37 +53,29 @@ impl AbortSignal for DefaultAbortSignal 
     fn aborted(&self) -> bool {
         false
     }
 }
 
 /// A merge telemetry event.
 pub enum TelemetryEvent {
     FetchLocalTree(TreeStats),
-    FetchNewLocalContents(ContentsStats),
     FetchRemoteTree(TreeStats),
-    FetchNewRemoteContents(ContentsStats),
     Merge(Duration, StructureCounts),
     Apply(Duration),
 }
 
 /// Records the time taken to build a local or remote tree, number of items
 /// in the tree, and structure problem counts.
 pub struct TreeStats {
     pub time: Duration,
     pub items: usize,
     pub problems: ProblemCounts,
 }
 
-/// Records the number of and time taken to fetch local or remote contents.
-pub struct ContentsStats {
-    pub time: Duration,
-    pub items: usize,
-}
-
 /// A merge driver provides methods to customize merging behavior.
 pub trait Driver {
     /// Generates a new GUID for the given invalid GUID. This is used to fix up
     /// items with GUIDs that Places can't store (bug 1380606, bug 1313026).
     ///
     /// The default implementation returns an error, forbidding invalid GUIDs.
     ///
     /// Implementations of `Driver` can either use the `rand` and `base64`
@@ -152,65 +144,66 @@ pub fn log<D: Driver>(
                 .build(),
         );
     }
 }
 
 #[macro_export]
 macro_rules! error {
     ($driver:expr, $($args:tt)+) => {
-        if log::Level::Error <= $driver.max_log_level() {
-            $crate::driver::log(
+        if log::Level::Error <= $crate::Driver::max_log_level($driver) {
+            $crate::log(
                 $driver,
                 log::Level::Error,
                 format_args!($($args)+),
                 module_path!(),
                 file!(),
                 line!(),
             );
         }
     }
 }
 
+#[macro_export]
 macro_rules! warn {
     ($driver:expr, $($args:tt)+) => {
-        if log::Level::Warn <= $driver.max_log_level() {
-            $crate::driver::log(
+        if log::Level::Warn <= $crate::Driver::max_log_level($driver) {
+            $crate::log(
                 $driver,
                 log::Level::Warn,
                 format_args!($($args)+),
                 module_path!(),
                 file!(),
                 line!(),
             );
         }
     }
 }
 
 #[macro_export]
 macro_rules! debug {
     ($driver:expr, $($args:tt)+) => {
-        if log::Level::Debug <= $driver.max_log_level() {
-            $crate::driver::log(
+        if log::Level::Debug <= $crate::Driver::max_log_level($driver) {
+            $crate::log(
                 $driver,
                 log::Level::Debug,
                 format_args!($($args)+),
                 module_path!(),
                 file!(),
                 line!(),
             );
         }
     }
 }
 
 #[macro_export]
 macro_rules! trace {
     ($driver:expr, $($args:tt)+) => {
-        if log::Level::Trace <= $driver.max_log_level() {
-            $crate::driver::log(
+        if log::Level::Trace <= $crate::Driver::max_log_level($driver) {
+            $crate::log(
                 $driver,
                 log::Level::Trace,
                 format_args!($($args)+),
                 module_path!(),
                 file!(),
                 line!(),
             );
         }
--- a/third_party/rust/dogear/src/guid.rs
+++ b/third_party/rust/dogear/src/guid.rs
@@ -50,16 +50,19 @@ pub const TOOLBAR_GUID: Guid = Guid(Repr
 pub const MENU_GUID: Guid = Guid(Repr::Valid(*b"menu________"));
 
 /// The "Other Bookmarks" GUID, used to hold items without a parent.
 pub const UNFILED_GUID: Guid = Guid(Repr::Valid(*b"unfiled_____"));
 
 /// The mobile bookmarks GUID.
 pub const MOBILE_GUID: Guid = Guid(Repr::Valid(*b"mobile______"));
 
+/// The tags root GUID.
+pub const TAGS_GUID: Guid = Guid(Repr::Valid(*b"tags________"));
+
 const VALID_GUID_BYTES: [u8; 255] = [
     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,
     0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1,
     0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0,
     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
@@ -120,20 +123,25 @@ impl Guid {
         // these while respecting is_valid (and moreover, we assert that
         // `s.is_char_boundary(12)` in `Guid::from`).
         match self.0 {
             Repr::Valid(ref bytes) => str::from_utf8(bytes).unwrap(),
             Repr::Invalid(ref s) => s,
         }
     }
 
-    /// Indicates if the GUID is one of the four Places user content roots.
+    /// Indicates if the GUID is one of the five Places built-in roots,
+    /// including the user content roots and the tags root.
     #[inline]
-    pub fn is_user_content_root(&self) -> bool {
-        self == TOOLBAR_GUID || self == MENU_GUID || self == UNFILED_GUID || self == MOBILE_GUID
+    pub fn is_built_in_root(&self) -> bool {
+        self == TOOLBAR_GUID
+            || self == MENU_GUID
+            || self == UNFILED_GUID
+            || self == MOBILE_GUID
+            || self == TAGS_GUID
     }
 }
 
 impl IsValidGuid for Guid {
     #[inline]
     fn is_valid_guid(&self) -> bool {
         match self.0 {
             Repr::Valid(_) => true,
--- a/third_party/rust/dogear/src/lib.rs
+++ b/third_party/rust/dogear/src/lib.rs
@@ -15,27 +15,20 @@
 #![allow(unknown_lints)]
 #![warn(rust_2018_idioms)]
 
 #[macro_use]
 mod driver;
 mod error;
 mod guid;
 mod merge;
-#[macro_use]
 mod store;
 mod tree;
 
 #[cfg(test)]
 mod tests;
 
-pub use crate::driver::{
-    AbortSignal, ContentsStats, DefaultAbortSignal, DefaultDriver, Driver, TelemetryEvent,
-    TreeStats,
-};
-pub use crate::error::{Error, ErrorKind, Result};
-pub use crate::guid::{Guid, MENU_GUID, MOBILE_GUID, ROOT_GUID, TOOLBAR_GUID, UNFILED_GUID};
-pub use crate::merge::{Deletion, Merger, StructureCounts};
-pub use crate::store::Store;
-pub use crate::tree::{
-    Content, Item, Kind, MergeState, MergedDescendant, MergedNode, MergedRoot, ProblemCounts, Tree,
-    UploadReason, Validity,
-};
+pub use crate::driver::*;
+pub use crate::error::*;
+pub use crate::guid::*;
+pub use crate::merge::*;
+pub use crate::store::*;
+pub use crate::tree::*;
--- a/third_party/rust/dogear/src/merge.rs
+++ b/third_party/rust/dogear/src/merge.rs
@@ -9,23 +9,23 @@
 // Unless required by applicable law or agreed to in writing, software
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
 use std::{
     collections::{hash_map::Entry, HashMap, HashSet, VecDeque},
-    mem,
+    fmt, mem,
 };
 
 use crate::driver::{AbortSignal, DefaultAbortSignal, DefaultDriver, Driver};
 use crate::error::{ErrorKind, Result};
-use crate::guid::{Guid, IsValidGuid};
-use crate::tree::{Content, MergeState, MergedNode, MergedRoot, Node, Tree, Validity};
+use crate::guid::{Guid, IsValidGuid, TAGS_GUID};
+use crate::tree::{Content, MergeState, MergedNode, Node, Tree, Validity};
 
 /// Structure change types, used to indicate if a node on one side is moved
 /// or deleted on the other.
 #[derive(Eq, PartialEq)]
 enum StructureChange {
     /// Node not deleted, or doesn't exist, on the other side.
     Unchanged,
     /// Node moved on the other side.
@@ -69,16 +69,26 @@ pub struct Deletion<'t> {
 /// Indicates which side to take in case of a merge conflict.
 #[derive(Clone, Copy, Debug)]
 enum ConflictResolution {
     Local,
     Remote,
     Unchanged,
 }
 
+/// A hash key used to match dupes by content.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+enum DupeKey<'a> {
+    /// Matches a dupe by content only. Used for bookmarks, queries, folders,
+    /// and livemarks.
+    WithoutPosition(&'a Content),
+    /// Matches a dupe by content and position. Used for separators.
+    WithPosition(&'a Content, usize),
+}
+
 /// 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.
@@ -95,90 +105,64 @@ enum ConflictResolution {
 /// Fortunately, most of our users don't organize their bookmarks into deeply
 /// nested hierarchies, or make conflicting changes on multiple devices
 /// simultaneously. A simpler two-way tree merge strikes a good balance between
 /// correctness and complexity.
 pub struct Merger<'t, D = DefaultDriver, A = DefaultAbortSignal> {
     driver: &'t D,
     signal: &'t A,
     local_tree: &'t Tree,
-    new_local_contents: Option<&'t HashMap<Guid, Content>>,
     remote_tree: &'t Tree,
-    new_remote_contents: Option<&'t HashMap<Guid, Content>>,
     matching_dupes_by_local_parent_guid: HashMap<Guid, MatchingDupes<'t>>,
     merged_guids: HashSet<Guid>,
     delete_locally: HashSet<Guid>,
     delete_remotely: HashSet<Guid>,
     structure_counts: StructureCounts,
 }
 
-#[cfg(test)]
 impl<'t> Merger<'t, DefaultDriver, DefaultAbortSignal> {
     /// Creates a merger with the default merge driver.
     pub fn new(local_tree: &'t Tree, remote_tree: &'t Tree) -> Merger<'t> {
         Merger {
             driver: &DefaultDriver,
             signal: &DefaultAbortSignal,
             local_tree,
-            new_local_contents: None,
             remote_tree,
-            new_remote_contents: None,
             matching_dupes_by_local_parent_guid: HashMap::new(),
             merged_guids: HashSet::new(),
             delete_locally: HashSet::new(),
             delete_remotely: HashSet::new(),
             structure_counts: StructureCounts::default(),
         }
     }
-
-    /// Creates a merger with the default merge driver and contents.
-    pub fn with_contents(
-        local_tree: &'t Tree,
-        new_local_contents: &'t HashMap<Guid, Content>,
-        remote_tree: &'t Tree,
-        new_remote_contents: &'t HashMap<Guid, Content>,
-    ) -> Merger<'t> {
-        Merger::with_driver(
-            &DefaultDriver,
-            &DefaultAbortSignal,
-            local_tree,
-            new_local_contents,
-            remote_tree,
-            new_remote_contents,
-        )
-    }
 }
 
 impl<'t, D: Driver, A: AbortSignal> Merger<'t, D, A> {
     /// Creates a merger with the given merge driver and contents.
     pub fn with_driver(
         driver: &'t D,
         signal: &'t A,
         local_tree: &'t Tree,
-        new_local_contents: &'t HashMap<Guid, Content>,
         remote_tree: &'t Tree,
-        new_remote_contents: &'t HashMap<Guid, Content>,
     ) -> Merger<'t, D, A> {
         Merger {
             driver,
             signal,
             local_tree,
-            new_local_contents: Some(new_local_contents),
             remote_tree,
-            new_remote_contents: Some(new_remote_contents),
             matching_dupes_by_local_parent_guid: HashMap::new(),
             merged_guids: HashSet::new(),
             delete_locally: HashSet::new(),
             delete_remotely: HashSet::new(),
             structure_counts: StructureCounts::default(),
         }
     }
 
     /// Builds a merged tree from the local and remote trees.
-    pub fn merge(&mut self) -> Result<MergedRoot<'t>> {
+    pub fn merge(mut self) -> Result<MergedRoot<'t>> {
         let merged_root_node = {
             let local_root_node = self.local_tree.root();
             let remote_root_node = self.remote_tree.root();
             self.two_way_merge(local_root_node, remote_root_node)?
         };
 
         // Any remaining deletions on one side should be deleted on the other side.
         // This happens when the remote tree has tombstones for items that don't
@@ -194,82 +178,50 @@ impl<'t, D: Driver, A: AbortSignal> Merg
         for guid in self.remote_tree.deletions() {
             self.signal.err_if_aborted()?;
             if !self.mentions(guid) {
                 self.delete_locally.insert(guid.clone());
                 self.structure_counts.merged_deletions += 1;
             }
         }
 
-        Ok(MergedRoot::with_size(
-            merged_root_node,
-            self.structure_counts.merged_nodes,
-        ))
-    }
-
-    /// Checks if the merger merged all GUIDs in the given tree.
-    #[inline]
-    pub fn subsumes(&self, tree: &Tree) -> bool {
-        tree.guids().all(|guid| self.mentions(guid))
-    }
-
-    /// Returns an iterator for all accepted local and remote deletions.
-    #[inline]
-    pub fn deletions(&self) -> impl Iterator<Item = Deletion<'_>> {
-        self.local_deletions().chain(self.remote_deletions())
-    }
+        // The merged tree should know about all items mentioned in the local
+        // and remote trees. Otherwise, it's incomplete, and we can't apply it.
+        // This indicates a bug in the merger.
+        for guid in self.local_tree.guids() {
+            self.signal.err_if_aborted()?;
+            if !self.mentions(guid) {
+                return Err(ErrorKind::UnmergedLocalItems.into());
+            }
+        }
+        for guid in self.remote_tree.guids() {
+            self.signal.err_if_aborted()?;
+            if !self.mentions(guid) {
+                return Err(ErrorKind::UnmergedRemoteItems.into());
+            }
+        }
 
-    pub(crate) fn local_deletions(&self) -> impl Iterator<Item = Deletion<'_>> {
-        self.delete_locally.iter().filter_map(move |guid| {
-            if self.delete_remotely.contains(guid) {
-                None
-            } else {
-                let local_level = self
-                    .local_tree
-                    .node_for_guid(guid)
-                    .map_or(-1, |node| node.level());
-                // Items that should be deleted locally already have tombstones
-                // on the server, so we don't need to upload tombstones for
-                // these deletions.
-                Some(Deletion {
-                    guid,
-                    local_level,
-                    should_upload_tombstone: false,
-                })
-            }
-        })
-    }
-
-    pub(crate) fn remote_deletions(&self) -> impl Iterator<Item = Deletion<'_>> {
-        self.delete_remotely.iter().map(move |guid| {
-            let local_level = self
-                .local_tree
-                .node_for_guid(guid)
-                .map_or(-1, |node| node.level());
-            Deletion {
-                guid,
-                local_level,
-                should_upload_tombstone: true,
-            }
+        Ok(MergedRoot {
+            local_tree: self.local_tree,
+            remote_tree: self.remote_tree,
+            node: merged_root_node,
+            merged_guids: self.merged_guids,
+            delete_locally: self.delete_locally,
+            delete_remotely: self.delete_remotely,
+            structure_counts: self.structure_counts,
         })
     }
 
     #[inline]
     fn mentions(&self, guid: &Guid) -> bool {
         self.merged_guids.contains(guid)
             || self.delete_locally.contains(guid)
             || self.delete_remotely.contains(guid)
     }
 
-    /// Returns structure change counts for this merge.
-    #[inline]
-    pub fn counts(&self) -> &StructureCounts {
-        &self.structure_counts
-    }
-
     fn merge_local_only_node(&mut self, local_node: Node<'t>) -> Result<MergedNode<'t>> {
         trace!(self.driver, "Item {} only exists locally", local_node);
 
         self.merged_guids.insert(local_node.guid.clone());
 
         let merged_guid = if local_node.guid.is_valid_guid() {
             local_node.guid.clone()
         } else {
@@ -284,30 +236,32 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                     return Err(ErrorKind::DuplicateItem(new_guid).into());
                 }
                 self.merged_guids.insert(new_guid.clone());
             }
             new_guid
         };
 
         let mut merged_node = MergedNode::new(merged_guid, MergeState::LocalOnly(local_node));
-        if local_node.is_folder() {
-            // 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.
-            for local_child_node in local_node.children() {
-                self.signal.err_if_aborted()?;
-                self.merge_local_child_into_merged_node(
-                    &mut merged_node,
-                    local_node,
-                    None,
-                    local_child_node,
-                )?;
-            }
+        // 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.
+        for local_child_node in local_node.children() {
+            self.signal.err_if_aborted()?;
+            self.merge_local_child_into_merged_node(
+                &mut merged_node,
+                local_node,
+                None,
+                local_child_node,
+            )?;
+        }
+
+        if local_node.diverged() {
+            merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
         }
 
         Ok(merged_node)
     }
 
     fn merge_remote_only_node(&mut self, remote_node: Node<'t>) -> Result<MergedNode<'t>> {
         trace!(self.driver, "Item {} only exists remotely", remote_node);
 
@@ -329,38 +283,36 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                 self.merged_guids.insert(new_guid.clone());
                 // Upload tombstones for changed remote GUIDs.
                 self.delete_remotely.insert(remote_node.guid.clone());
                 self.structure_counts.merged_deletions += 1;
             }
             new_guid
         };
         let mut merged_node = MergedNode::new(merged_guid, MergeState::RemoteOnly(remote_node));
-        if remote_node.is_folder() {
-            // 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.
-            for remote_child_node in remote_node.children() {
-                self.signal.err_if_aborted()?;
-                self.merge_remote_child_into_merged_node(
-                    &mut merged_node,
-                    None,
-                    remote_node,
-                    remote_child_node,
-                )?;
-            }
+        // 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.
+        for remote_child_node in remote_node.children() {
+            self.signal.err_if_aborted()?;
+            self.merge_remote_child_into_merged_node(
+                &mut merged_node,
+                None,
+                remote_node,
+                remote_child_node,
+            )?;
         }
 
         if remote_node.diverged()
             || merged_node.remote_guid_changed()
             || remote_node.validity != Validity::Valid
         {
             // If the remote structure diverged, the merged item's GUID changed,
             // or the item isn't valid, flag it for reupload.
-            merged_node.merge_state = merged_node.merge_state.with_new_structure();
+            merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
         }
 
         Ok(merged_node)
     }
 
     /// Merges two nodes that exist locally and remotely.
     fn two_way_merge(
         &mut self,
@@ -443,17 +395,17 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                         &mut merged_node,
                         Some(local_node),
                         remote_node,
                         remote_child_node,
                     )?;
                 }
             }
 
-            ConflictResolution::Remote | ConflictResolution::Unchanged => {
+            ConflictResolution::Remote => {
                 for remote_child_node in remote_node.children() {
                     self.signal.err_if_aborted()?;
                     self.merge_remote_child_into_merged_node(
                         &mut merged_node,
                         Some(local_node),
                         remote_node,
                         remote_child_node,
                     )?;
@@ -463,56 +415,158 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                     self.merge_local_child_into_merged_node(
                         &mut merged_node,
                         local_node,
                         Some(remote_node),
                         local_child_node,
                     )?;
                 }
             }
+
+            ConflictResolution::Unchanged => {
+                // The children are the same, so we only need to merge one side.
+                for (local_child_node, remote_child_node) in
+                    local_node.children().zip(remote_node.children())
+                {
+                    self.signal.err_if_aborted()?;
+                    self.merge_unchanged_child_into_merged_node(
+                        &mut merged_node,
+                        local_node,
+                        local_child_node,
+                        remote_node,
+                        remote_child_node,
+                    )?;
+                }
+            }
+        }
+
+        if local_node.diverged() {
+            merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
         }
 
         if remote_node.diverged() || remote_node.validity != Validity::Valid {
             // Flag remotely diverged and invalid items for reupload.
-            merged_node.merge_state = merged_node.merge_state.with_new_structure();
+            merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
         }
 
         Ok(merged_node)
     }
 
+    /// Merges two nodes with the same parents and positions.
+    ///
+    /// Unlike items that have been moved, or exist only on one side, unchanged
+    /// children can be merged directly.
+    fn merge_unchanged_child_into_merged_node(
+        &mut self,
+        merged_node: &mut MergedNode<'t>,
+        local_parent_node: Node<'t>,
+        local_child_node: Node<'t>,
+        remote_parent_node: Node<'t>,
+        remote_child_node: Node<'t>,
+    ) -> Result<()> {
+        assert!(
+            !self.merged_guids.contains(&local_child_node.guid),
+            "Unchanged local child shouldn't have been merged"
+        );
+        assert!(
+            !self.merged_guids.contains(&remote_child_node.guid),
+            "Unchanged remote child shouldn't have been merged"
+        );
+
+        // Even though the child exists on both sides, it might still be
+        // non-syncable or invalid, so we need to check for structure
+        // changes.
+        let local_structure_change = self.check_for_local_structure_change_of_remote_node(
+            merged_node,
+            remote_parent_node,
+            remote_child_node,
+        )?;
+        let remote_structure_change = self.check_for_remote_structure_change_of_local_node(
+            merged_node,
+            local_parent_node,
+            local_child_node,
+        )?;
+        match (local_structure_change, remote_structure_change) {
+            (StructureChange::Deleted, StructureChange::Deleted) => {
+                // The child is deleted on both sides. We'll need to reupload
+                // and apply a new structure.
+                merged_node.merge_state = merged_node
+                    .merge_state
+                    .with_new_local_structure()
+                    .with_new_remote_structure();
+            }
+            (StructureChange::Deleted, _) => {
+                // The child is deleted locally, but not remotely, so we only
+                // need to reupload a new structure.
+                merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
+            }
+            (_, StructureChange::Deleted) => {
+                // The child is deleted remotely, so we only need to apply a
+                // new local structure.
+                merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
+            }
+            (_, _) => {
+                // The child exists on both sides, so merge it now. If the GUID
+                // changes because it's invalid, we'll need to reapply the
+                // child, and reupload the child and its parent.
+                let mut merged_child_node =
+                    self.two_way_merge(local_child_node, remote_child_node)?;
+                if merged_child_node.local_guid_changed() {
+                    merged_child_node.merge_state =
+                        merged_child_node.merge_state.with_new_local_structure();
+                }
+                if merged_node.remote_guid_changed() {
+                    // The merged parent's GUID changed; flag the child for
+                    // reupload with a new `parentid`.
+                    merged_child_node.merge_state =
+                        merged_child_node.merge_state.with_new_remote_structure();
+                }
+                if merged_child_node.remote_guid_changed() {
+                    // The merged child's GUID changed; flag the parent for
+                    // reupload with new `children`.
+                    merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
+                }
+                merged_node.merged_children.push(merged_child_node);
+                self.structure_counts.merged_nodes += 1;
+            }
+        }
+
+        Ok(())
+    }
+
     /// Merges a remote child node into a merged folder node. This handles the
     /// following cases:
     ///
     /// - The remote child is locally deleted. We recursively move all of its
     ///   descendants that don't exist locally to the merged folder.
     /// - The remote child doesn't exist locally, but has a content match in the
     ///   corresponding local folder. We dedupe the local child to the remote
     ///   child.
     /// - The remote child exists locally, but in a different folder. We compare
     ///   merge flags and timestamps to decide where to keep the child.
     /// - The remote child exists locally, and in the same folder. We merge the
     ///   local and remote children.
     ///
     /// This is the inverse of `merge_local_child_into_merged_node`.
-    ///
-    /// Returns `true` if the merged structure state changed because the remote
-    /// child was locally moved or deleted; `false` otherwise.
     fn merge_remote_child_into_merged_node(
         &mut self,
         merged_node: &mut MergedNode<'t>,
         local_parent_node: Option<Node<'t>>,
         remote_parent_node: Node<'t>,
         remote_child_node: Node<'t>,
     ) -> Result<()> {
         if self.merged_guids.contains(&remote_child_node.guid) {
             trace!(
                 self.driver,
                 "Remote child {} already seen in another folder and merged",
                 remote_child_node
             );
+            // Omitting a remote child that we already merged locally means we
+            // have a new remote structure.
+            merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
             return Ok(());
         }
 
         trace!(
             self.driver,
             "Merging remote child {} of {} into {}",
             remote_child_node,
             remote_parent_node,
@@ -528,17 +582,17 @@ impl<'t, D: Driver, A: AbortSignal> Merg
         if self.check_for_local_structure_change_of_remote_node(
             merged_node,
             remote_parent_node,
             remote_child_node,
         )? == StructureChange::Deleted
         {
             // Flag the merged parent for reupload, since we deleted the
             // remote child.
-            merged_node.merge_state = merged_node.merge_state.with_new_structure();
+            merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
             return Ok(());
         }
 
         // The remote child isn't locally deleted. Does it exist in the local tree?
         if let Some(local_child_node) = self.local_tree.node_for_guid(&remote_child_node.guid) {
             // The remote child exists in the local tree. Did it move?
             let local_parent_node = local_child_node
                 .parent()
@@ -559,27 +613,30 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                      deleted remotely",
                     remote_child_node,
                     remote_parent_node,
                     local_parent_node
                 );
 
                 let mut merged_child_node =
                     self.two_way_merge(local_child_node, remote_child_node)?;
+                merged_child_node.merge_state =
+                    merged_child_node.merge_state.with_new_local_structure();
                 if merged_node.remote_guid_changed() {
                     // If the parent's GUID changed, flag the child for reupload, so that
                     // its `parentid` is correct.
                     merged_child_node.merge_state =
-                        merged_child_node.merge_state.with_new_structure();
+                        merged_child_node.merge_state.with_new_remote_structure();
                 }
                 if merged_child_node.remote_guid_changed() {
                     // If the child's GUID changed, flag the parent for reupload, so that
                     // its `children` are correct.
-                    merged_node.merge_state = merged_node.merge_state.with_new_structure();
+                    merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
                 }
+                merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
                 merged_node.merged_children.push(merged_child_node);
                 self.structure_counts.merged_nodes += 1;
                 return Ok(());
             }
 
             match self.resolve_structure_conflict(
                 local_parent_node,
                 local_child_node,
@@ -598,44 +655,65 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                         local_parent_node,
                         remote_parent_node
                     );
 
                     // Flag the old parent for reupload, since we're moving
                     // the remote child. Note that, since we only flag the
                     // remote parent here, we don't need to handle
                     // reparenting and repositioning separately.
-                    merged_node.merge_state = merged_node.merge_state.with_new_structure();
+                    merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
                 }
 
                 ConflictResolution::Remote | ConflictResolution::Unchanged => {
                     // The remote move is newer, so we merge the remote
                     // child now and ignore the local move.
-                    trace!(
-                        self.driver,
-                        "Remote child {} moved locally to {} and remotely to {}; \
-                         keeping child in newer remote parent and position",
-                        remote_child_node,
-                        local_parent_node,
-                        remote_parent_node
-                    );
-
-                    let mut merged_child_node =
-                        self.two_way_merge(local_child_node, remote_child_node)?;
+                    let mut merged_child_node = if local_parent_node.guid != remote_parent_node.guid
+                    {
+                        trace!(
+                            self.driver,
+                            "Remote child {} reparented locally to {} and remotely to {}; \
+                             keeping child in newer remote parent",
+                            remote_child_node,
+                            local_parent_node,
+                            remote_parent_node
+                        );
+                        let mut merged_child_node =
+                            self.two_way_merge(local_child_node, remote_child_node)?;
+                        merged_child_node.merge_state =
+                            merged_child_node.merge_state.with_new_local_structure();
+                        merged_child_node
+                    } else {
+                        trace!(
+                            self.driver,
+                            "Remote child {} repositioned locally in {} and remotely in {}; \
+                             keeping child in newer remote position",
+                            remote_child_node,
+                            local_parent_node,
+                            remote_parent_node
+                        );
+                        self.two_way_merge(local_child_node, remote_child_node)?
+                    };
+                    if merged_child_node.local_guid_changed() {
+                        merged_child_node.merge_state =
+                            merged_child_node.merge_state.with_new_local_structure();
+                    }
                     if merged_node.remote_guid_changed() {
                         // The merged parent's GUID changed; flag the child for
                         // reupload with a new `parentid`.
                         merged_child_node.merge_state =
-                            merged_child_node.merge_state.with_new_structure();
+                            merged_child_node.merge_state.with_new_remote_structure();
                     }
                     if merged_child_node.remote_guid_changed() {
                         // The merged child's GUID changed; flag the parent for
                         // reupload with new `children`.
-                        merged_node.merge_state = merged_node.merge_state.with_new_structure();
+                        merged_node.merge_state =
+                            merged_node.merge_state.with_new_remote_structure();
                     }
+                    merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
                     merged_node.merged_children.push(merged_child_node);
                     self.structure_counts.merged_nodes += 1;
                 }
             }
 
             return Ok(());
         }
 
@@ -654,47 +732,53 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                 local_parent_node,
                 remote_parent_node,
                 remote_child_node,
             )? {
             self.two_way_merge(local_child_node_by_content, remote_child_node)
         } else {
             self.merge_remote_only_node(remote_child_node)
         }?;
+        if merged_child_node.local_guid_changed() {
+            merged_child_node.merge_state =
+                merged_child_node.merge_state.with_new_local_structure();
+        }
         if merged_node.remote_guid_changed() {
-            merged_child_node.merge_state = merged_child_node.merge_state.with_new_structure();
+            merged_child_node.merge_state =
+                merged_child_node.merge_state.with_new_remote_structure();
         }
         if merged_child_node.remote_guid_changed() {
-            merged_node.merge_state = merged_node.merge_state.with_new_structure();
+            merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
         }
+        merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
         merged_node.merged_children.push(merged_child_node);
         self.structure_counts.merged_nodes += 1;
         Ok(())
     }
 
     /// Merges a local child node into a merged folder node.
     ///
     /// This is the inverse of `merge_remote_child_into_merged_node`.
-    ///
-    /// Returns `true` if the merged structure state changed because the local
-    /// child doesn't exist remotely or was locally moved; `false` otherwise.
     fn merge_local_child_into_merged_node(
         &mut self,
         merged_node: &mut MergedNode<'t>,
         local_parent_node: Node<'t>,
         remote_parent_node: Option<Node<'t>>,
         local_child_node: Node<'t>,
     ) -> Result<()> {
         if self.merged_guids.contains(&local_child_node.guid) {
-            // We already merged the child when we walked another folder.
+            // We already merged the child when we walked another folder. Since
+            // a tree can't have duplicate GUIDs, we must have merged the remote
+            // child, so we have a new local structure.
             trace!(
                 self.driver,
                 "Local child {} already seen in another folder and merged",
                 local_child_node
             );
+            merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
             return Ok(());
         }
 
         trace!(
             self.driver,
             "Merging local child {} of {} into {}",
             local_child_node,
             local_parent_node,
@@ -706,16 +790,17 @@ impl<'t, D: Driver, A: AbortSignal> Merg
         if self.check_for_remote_structure_change_of_local_node(
             merged_node,
             local_parent_node,
             local_child_node,
         )? == StructureChange::Deleted
         {
             // Since we're merging local nodes, we don't need to flag the merged
             // parent for reupload.
+            merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
             return Ok(());
         }
 
         // At this point, we know the local child isn't deleted. See if it
         // exists in the remote tree.
         if let Some(remote_child_node) = self.remote_tree.node_for_guid(&local_child_node.guid) {
             // The local child exists remotely. It must have moved; otherwise, we
             // would have seen it when we walked the remote children.
@@ -747,18 +832,23 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                 //
                 // Reuploading the child isn't necessary for newer Desktops, which
                 // ignore the child's `parentid` and use the parent's `children`.
                 //
                 // However, older Desktop and Android use the child's `parentid` as
                 // canonical, while iOS is stricter and requires both to match.
                 let mut merged_child_node =
                     self.two_way_merge(local_child_node, remote_child_node)?;
-                merged_node.merge_state = merged_node.merge_state.with_new_structure();
-                merged_child_node.merge_state = merged_child_node.merge_state.with_new_structure();
+                if merged_child_node.local_guid_changed() {
+                    merged_child_node.merge_state =
+                        merged_child_node.merge_state.with_new_local_structure();
+                }
+                merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
+                merged_child_node.merge_state =
+                    merged_child_node.merge_state.with_new_remote_structure();
                 merged_node.merged_children.push(merged_child_node);
                 self.structure_counts.merged_nodes += 1;
                 return Ok(());
             }
 
             match self.resolve_structure_conflict(
                 local_parent_node,
                 local_child_node,
@@ -778,44 +868,55 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                             local_parent_node,
                             remote_parent_node
                         );
 
                         // Merge and flag both the new parent and child for
                         // reupload. See above for why.
                         let mut merged_child_node =
                             self.two_way_merge(local_child_node, remote_child_node)?;
-                        merged_node.merge_state = merged_node.merge_state.with_new_structure();
+                        if merged_child_node.local_guid_changed() {
+                            merged_child_node.merge_state =
+                                merged_child_node.merge_state.with_new_local_structure();
+                        }
+                        merged_node.merge_state =
+                            merged_node.merge_state.with_new_remote_structure();
                         merged_child_node.merge_state =
-                            merged_child_node.merge_state.with_new_structure();
+                            merged_child_node.merge_state.with_new_remote_structure();
                         merged_node.merged_children.push(merged_child_node);
                         self.structure_counts.merged_nodes += 1;
                     } else {
                         trace!(
                             self.driver,
                             "Local child {} repositioned locally in {} and remotely in {}; \
                              keeping child in newer local position",
                             local_child_node,
                             local_parent_node,
                             remote_parent_node
                         );
 
                         // For position changes in the same folder, we only need to
                         // merge and flag the parent for reupload...
                         let mut merged_child_node =
                             self.two_way_merge(local_child_node, remote_child_node)?;
-                        merged_node.merge_state = merged_node.merge_state.with_new_structure();
+                        if merged_child_node.local_guid_changed() {
+                            merged_child_node.merge_state =
+                                merged_child_node.merge_state.with_new_local_structure();
+                        }
+                        merged_node.merge_state =
+                            merged_node.merge_state.with_new_remote_structure();
                         if merged_node.remote_guid_changed() {
                             // ...Unless the merged parent's GUID also changed,
                             // in which case we also need to flag the
                             // repositioned child for reupload, so that its
                             // `parentid` is correct.
                             merged_child_node.merge_state =
-                                merged_child_node.merge_state.with_new_structure();
+                                merged_child_node.merge_state.with_new_remote_structure();
                         }
+
                         merged_node.merged_children.push(merged_child_node);
                         self.structure_counts.merged_nodes += 1;
                     }
                 }
 
                 ConflictResolution::Remote | ConflictResolution::Unchanged => {
                     // The remote move is newer, so we ignore the local
                     // move. We'll merge the local child later, when we
@@ -834,16 +935,17 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                             self.driver,
                             "Local child {} repositioned locally in {} and remotely in {}; \
                              keeping child in newer remote position",
                             local_child_node,
                             local_parent_node,
                             remote_parent_node
                         );
                     }
+                    merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
                 }
             }
 
             return Ok(());
         }
 
         // Local child is not a root, and doesn't exist remotely. Try to find a
         // content match in the containing folder, and dedupe the local item if
@@ -860,130 +962,176 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                 local_parent_node,
                 remote_parent_node,
                 local_child_node,
             )? {
             // The local child has a remote content match, so take the remote GUID
             // and merge.
             let mut merged_child_node =
                 self.two_way_merge(local_child_node, remote_child_node_by_content)?;
+            if merged_child_node.local_guid_changed() {
+                merged_child_node.merge_state =
+                    merged_child_node.merge_state.with_new_local_structure();
+            }
             if merged_node.remote_guid_changed() {
-                merged_child_node.merge_state = merged_child_node.merge_state.with_new_structure();
+                merged_child_node.merge_state =
+                    merged_child_node.merge_state.with_new_remote_structure();
             }
             if merged_child_node.remote_guid_changed() {
-                merged_node.merge_state = merged_node.merge_state.with_new_structure();
+                merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
             }
+            merged_node.merge_state = merged_node.merge_state.with_new_local_structure();
             merged_child_node
         } else {
             // The local child doesn't exist remotely, so flag the merged parent and
             // new child for upload, and walk its descendants.
             let mut merged_child_node = self.merge_local_only_node(local_child_node)?;
-            merged_node.merge_state = merged_node.merge_state.with_new_structure();
-            merged_child_node.merge_state = merged_child_node.merge_state.with_new_structure();
+            if merged_child_node.local_guid_changed() {
+                merged_child_node.merge_state =
+                    merged_child_node.merge_state.with_new_local_structure();
+            }
+            merged_node.merge_state = merged_node.merge_state.with_new_remote_structure();
+            merged_child_node.merge_state =
+                merged_child_node.merge_state.with_new_remote_structure();
             merged_child_node
         };
         merged_node.merged_children.push(merged_child_node);
         self.structure_counts.merged_nodes += 1;
         Ok(())
     }
 
     /// Determines which side to prefer, and which children to merge first,
     /// for an item that exists on both sides.
     fn resolve_value_conflict(
         &self,
         local_node: Node<'t>,
         remote_node: Node<'t>,
     ) -> (ConflictResolution, ConflictResolution) {
         if remote_node.is_root() {
-            // Don't reorder local roots.
-            return (ConflictResolution::Local, ConflictResolution::Local);
+            // Don't touch the Places root; it's not synced, anyway.
+            return (ConflictResolution::Unchanged, ConflictResolution::Local);
         }
 
         match (local_node.needs_merge, remote_node.needs_merge) {
             (true, true) => {
                 // The item changed locally and remotely.
-                let item = if local_node.is_user_content_root() {
+                let item = if local_node.is_built_in_root() {
                     // For roots, we always prefer the local side for item
                     // changes, like the title (bug 1432614).
                     ConflictResolution::Local
                 } else {
                     // For other items, we check the validity to decide
                     // which side to take.
-                    match remote_node.validity {
-                        Validity::Valid | Validity::Reupload => {
-                            // If the remote item is valid, or valid but needs
-                            // reupload, compare timestamps to decide which side is
-                            // newer.
+                    match (local_node.validity, remote_node.validity) {
+                        // If both are invalid, it doesn't matter which side
+                        // we pick; the item will be deleted, anyway.
+                        (Validity::Replace, Validity::Replace) => ConflictResolution::Unchanged,
+                        // If only one side is invalid, pick the other side.
+                        // This loses changes from that side, but we can't
+                        // apply or upload those changes, anyway.
+                        (Validity::Replace, _) => ConflictResolution::Remote,
+                        (_, Validity::Replace) => ConflictResolution::Local,
+                        (_, _) => {
+                            // Otherwise, the item is either valid, or valid
+                            // but needs to be reuploaded or reapplied, so
+                            // compare timestamps to decide which side is newer.
                             if local_node.age < remote_node.age {
                                 ConflictResolution::Local
                             } else {
                                 ConflictResolution::Remote
                             }
                         }
-                        // If the remote item must be replaced, take the local
-                        // side. This _loses remote changes_, but we can't
-                        // apply those changes, anyway.
-                        Validity::Replace => ConflictResolution::Local,
                     }
                 };
                 // For children, it's easier: we always use the newer side, even
                 // if we're taking local changes for the item.
-                let children = if local_node.age < remote_node.age {
+                let children = if local_node.has_matching_children(remote_node) {
+                    ConflictResolution::Unchanged
+                } else if local_node.age < remote_node.age {
                     // The local change is newer, so merge local children first,
                     // followed by remaining unmerged remote children.
                     ConflictResolution::Local
                 } else {
                     // The remote change is newer, so walk and merge remote
                     // children first, then remaining local children.
                     ConflictResolution::Remote
                 };
                 (item, children)
             }
 
             (true, false) => {
                 // The item changed locally, but not remotely. Prefer the local
                 // item, then merge local children first, followed by remote
                 // children.
-                (ConflictResolution::Local, ConflictResolution::Local)
+                let item = match local_node.validity {
+                    Validity::Valid | Validity::Reupload => ConflictResolution::Local,
+                    Validity::Replace => ConflictResolution::Remote,
+                };
+                let children = if local_node.has_matching_children(remote_node) {
+                    ConflictResolution::Unchanged
+                } else {
+                    ConflictResolution::Local
+                };
+                (item, children)
             }
 
             (false, true) => {
                 // The item changed remotely, but not locally.
-                let item = if local_node.is_user_content_root() {
+                let item = if local_node.is_built_in_root() {
                     // For roots, we ignore remote item changes.
                     ConflictResolution::Unchanged
                 } else {
                     match remote_node.validity {
                         Validity::Valid | Validity::Reupload => ConflictResolution::Remote,
                         // And, for invalid remote items, we must reupload the
                         // local side. This _loses remote changes_, but we can't
                         // apply those changes, anyway.
                         Validity::Replace => ConflictResolution::Local,
                     }
                 };
+                let children = if local_node.has_matching_children(remote_node) {
+                    ConflictResolution::Unchanged
+                } else {
+                    ConflictResolution::Remote
+                };
                 // For children, we always use the remote side.
-                (item, ConflictResolution::Remote)
+                (item, children)
             }
 
             (false, false) => {
-                // The item is unchanged on both sides.
-                (ConflictResolution::Unchanged, ConflictResolution::Unchanged)
+                let item = match (local_node.validity, remote_node.validity) {
+                    (Validity::Replace, Validity::Replace) => ConflictResolution::Unchanged,
+                    (_, Validity::Replace) => ConflictResolution::Local,
+                    (Validity::Replace, _) => ConflictResolution::Remote,
+                    (_, _) => ConflictResolution::Unchanged,
+                };
+                // If the child lists are identical, the structure is unchanged.
+                // Otherwise, the children differ even though the items aren't
+                // flagged as unmerged, so we prefer the newer side.
+                let children = if local_node.has_matching_children(remote_node) {
+                    ConflictResolution::Unchanged
+                } else if local_node.age < remote_node.age {
+                    ConflictResolution::Local
+                } else {
+                    ConflictResolution::Remote
+                };
+                (item, children)
             }
         }
     }
 
     /// Determines where to keep a child of a folder that exists on both sides.
     fn resolve_structure_conflict(
         &self,
         local_parent_node: Node<'t>,
         local_child_node: Node<'t>,
         remote_parent_node: Node<'t>,
         remote_child_node: Node<'t>,
     ) -> ConflictResolution {
-        if remote_child_node.is_user_content_root() {
+        if remote_child_node.is_built_in_root() {
             // Always use the local parent and position for roots.
             return ConflictResolution::Local;
         }
 
         match (
             local_parent_node.needs_merge,
             remote_parent_node.needs_merge,
         ) {
@@ -1018,24 +1166,35 @@ impl<'t, D: Driver, A: AbortSignal> Merg
         &mut self,
         merged_node: &mut MergedNode<'t>,
         remote_parent_node: Node<'t>,
         remote_node: Node<'t>,
     ) -> Result<StructureChange> {
         if !remote_node.is_syncable() {
             // If the remote node is known to be non-syncable, we unconditionally
             // delete it, even if it's syncable or moved locally.
+            trace!(
+                self.driver,
+                "Deleting non-syncable remote node {}",
+                remote_node
+            );
             return self.delete_remote_node(merged_node, remote_node);
         }
 
         if !self.local_tree.is_deleted(&remote_node.guid) {
             if let Some(local_node) = self.local_tree.node_for_guid(&remote_node.guid) {
                 if !local_node.is_syncable() {
                     // The remote node is syncable, but the local node is
                     // non-syncable. Unconditionally delete it.
+                    trace!(
+                        self.driver,
+                        "Remote node {} is syncable, but local node {} isn't; deleting",
+                        remote_node,
+                        local_node
+                    );
                     return self.delete_remote_node(merged_node, remote_node);
                 }
                 if local_node.validity == Validity::Replace
                     && remote_node.validity == Validity::Replace
                 {
                     // The nodes are invalid on both sides, so we can't apply
                     // or reupload a valid copy. Delete it.
                     return self.delete_remote_node(merged_node, remote_node);
@@ -1057,17 +1216,17 @@ impl<'t, D: Driver, A: AbortSignal> Merg
         }
 
         if remote_node.validity == Validity::Replace {
             // The remote node is invalid and deleted locally, so we can't
             // reupload a valid copy. Delete it.
             return self.delete_remote_node(merged_node, remote_node);
         }
 
-        if remote_node.is_user_content_root() {
+        if remote_node.is_built_in_root() {
             // If the remote node is a content root, don't delete it locally.
             return Ok(StructureChange::Unchanged);
         }
 
         if remote_node.needs_merge {
             if !remote_node.is_folder() {
                 // If a non-folder child is deleted locally and changed remotely, we
                 // ignore the local deletion and take the remote child.
@@ -1114,27 +1273,38 @@ impl<'t, D: Driver, A: AbortSignal> Merg
         &mut self,
         merged_node: &mut MergedNode<'t>,
         local_parent_node: Node<'t>,
         local_node: Node<'t>,
     ) -> Result<StructureChange> {
         if !local_node.is_syncable() {
             // If the local node is known to be non-syncable, we unconditionally
             // delete it, even if it's syncable or moved remotely.
+            trace!(
+                self.driver,
+                "Deleting non-syncable local node {}",
+                local_node
+            );
             return self.delete_local_node(merged_node, local_node);
         }
 
         if !self.remote_tree.is_deleted(&local_node.guid) {
             if let Some(remote_node) = self.remote_tree.node_for_guid(&local_node.guid) {
                 if !remote_node.is_syncable() {
                     // The local node is syncable, but the remote node is not.
                     // This can happen if we applied an orphaned left pane
                     // query in a previous sync, and later saw the left pane
                     // root on the server. Since we now have the complete
                     // subtree, we can remove it.
+                    trace!(
+                        self.driver,
+                        "Local node {} is syncable, but remote node {} isn't; deleting",
+                        local_node,
+                        remote_node
+                    );
                     return self.delete_local_node(merged_node, local_node);
                 }
                 if remote_node.validity == Validity::Replace
                     && local_node.validity == Validity::Replace
                 {
                     // The nodes are invalid on both sides, so we can't replace
                     // the local copy with a remote one. Delete it.
                     return self.delete_local_node(merged_node, local_node);
@@ -1159,17 +1329,17 @@ impl<'t, D: Driver, A: AbortSignal> Merg
         }
 
         if local_node.validity == Validity::Replace {
             // The local node is invalid and deleted remotely, so we can't
             // replace the local copy. Delete it.
             return self.delete_local_node(merged_node, local_node);
         }
 
-        if local_node.is_user_content_root() {
+        if local_node.is_built_in_root() {
             // If the local node is a content root, don't delete it remotely.
             return Ok(StructureChange::Unchanged);
         }
 
         // See `check_for_local_structure_change_of_remote_node` for an
         // explanation of how we decide to take or ignore a deletion.
         if local_node.needs_merge {
             if !local_node.is_folder() {
@@ -1243,19 +1413,24 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                     // Flag the new parent and moved remote orphan for reupload.
                     let mut merged_orphan_node = if let Some(local_child_node) =
                         self.local_tree.node_for_guid(&remote_child_node.guid)
                     {
                         self.two_way_merge(local_child_node, remote_child_node)
                     } else {
                         self.merge_remote_only_node(remote_child_node)
                     }?;
-                    merged_node.merge_state = merged_node.merge_state.with_new_structure();
-                    merged_orphan_node.merge_state =
-                        merged_orphan_node.merge_state.with_new_structure();
+                    merged_node.merge_state = merged_node
+                        .merge_state
+                        .with_new_local_structure()
+                        .with_new_remote_structure();
+                    merged_orphan_node.merge_state = merged_orphan_node
+                        .merge_state
+                        .with_new_local_structure()
+                        .with_new_remote_structure();
                     merged_node.merged_children.push(merged_orphan_node);
                     self.structure_counts.merged_nodes += 1;
                 }
             }
         }
         self.structure_counts.merged_deletions += 1;
         Ok(StructureChange::Deleted)
     }
@@ -1301,19 +1476,24 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                     // Flag the new parent and moved local orphan for reupload.
                     let mut merged_orphan_node = if let Some(remote_child_node) =
                         self.remote_tree.node_for_guid(&local_child_node.guid)
                     {
                         self.two_way_merge(local_child_node, remote_child_node)
                     } else {
                         self.merge_local_only_node(local_child_node)
                     }?;
-                    merged_node.merge_state = merged_node.merge_state.with_new_structure();
-                    merged_orphan_node.merge_state =
-                        merged_orphan_node.merge_state.with_new_structure();
+                    merged_node.merge_state = merged_node
+                        .merge_state
+                        .with_new_local_structure()
+                        .with_new_remote_structure();
+                    merged_orphan_node.merge_state = merged_orphan_node
+                        .merge_state
+                        .with_new_local_structure()
+                        .with_new_remote_structure();
                     merged_node.merged_children.push(merged_orphan_node);
                     self.structure_counts.merged_nodes += 1;
                 }
             }
         }
         self.structure_counts.merged_deletions += 1;
         Ok(StructureChange::Deleted)
     }
@@ -1338,27 +1518,24 @@ impl<'t, D: Driver, A: AbortSignal> Merg
     /// `matching_dupes_by_local_parent_guid`, so deduping all
     /// remaining children of the same folder, on both sides, only needs two
     /// O(1) map lookups per child.
     fn find_all_matching_dupes_in_folders(
         &self,
         local_parent_node: Node<'t>,
         remote_parent_node: Node<'t>,
     ) -> Result<MatchingDupes<'t>> {
-        let mut dupe_key_to_local_nodes: HashMap<&Content, VecDeque<_>> = HashMap::new();
+        let mut dupe_key_to_local_nodes: HashMap<DupeKey<'_>, VecDeque<_>> = HashMap::new();
 
-        for local_child_node in local_parent_node.children() {
+        for (local_position, local_child_node) in local_parent_node.children().enumerate() {
             self.signal.err_if_aborted()?;
-            if local_child_node.is_user_content_root() {
+            if local_child_node.is_built_in_root() {
                 continue;
             }
-            if let Some(local_child_content) = self
-                .new_local_contents
-                .and_then(|contents| contents.get(&local_child_node.guid))
-            {
+            if let Some(local_child_content) = local_child_node.content() {
                 if let Some(remote_child_node) =
                     self.remote_tree.node_for_guid(&local_child_node.guid)
                 {
                     trace!(
                         self.driver,
                         "Not deduping local child {}; already exists remotely as {}",
                         local_child_node,
                         remote_child_node
@@ -1371,52 +1548,61 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                         "Not deduping local child {}; deleted remotely",
                         local_child_node
                     );
                     continue;
                 }
                 // Store matching local children in an array, in case multiple children
                 // have the same dupe key (for example, a toolbar containing multiple
                 // empty folders, as in bug 1213369).
-                let local_nodes_for_key = dupe_key_to_local_nodes
-                    .entry(local_child_content)
-                    .or_default();
+                let dupe_key = match local_child_content {
+                    Content::Bookmark { .. } | Content::Folder { .. } => {
+                        DupeKey::WithoutPosition(local_child_content)
+                    }
+                    Content::Separator => {
+                        DupeKey::WithPosition(local_child_content, local_position)
+                    }
+                };
+                let local_nodes_for_key = dupe_key_to_local_nodes.entry(dupe_key).or_default();
                 local_nodes_for_key.push_back(local_child_node);
             } else {
                 trace!(
                     self.driver,
                     "Not deduping local child {}; already uploaded",
                     local_child_node
                 );
             }
         }
 
         let mut local_to_remote = HashMap::new();
         let mut remote_to_local = HashMap::new();
 
-        for remote_child_node in remote_parent_node.children() {
+        for (remote_position, remote_child_node) in remote_parent_node.children().enumerate() {
             self.signal.err_if_aborted()?;
             if remote_to_local.contains_key(&remote_child_node.guid) {
                 trace!(
                     self.driver,
                     "Not deduping remote child {}; already deduped",
                     remote_child_node
                 );
                 continue;
             }
             // Note that we don't need to check if the remote node is deleted
             // locally, because it wouldn't have local content entries if it
             // were.
-            if let Some(remote_child_content) = self
-                .new_remote_contents
-                .and_then(|contents| contents.get(&remote_child_node.guid))
-            {
-                if let Some(local_nodes_for_key) =
-                    dupe_key_to_local_nodes.get_mut(remote_child_content)
-                {
+            if let Some(remote_child_content) = remote_child_node.content() {
+                let dupe_key = match remote_child_content {
+                    Content::Bookmark { .. } | Content::Folder { .. } => {
+                        DupeKey::WithoutPosition(remote_child_content)
+                    }
+                    Content::Separator => {
+                        DupeKey::WithPosition(remote_child_content, remote_position)
+                    }
+                };
+                if let Some(local_nodes_for_key) = dupe_key_to_local_nodes.get_mut(&dupe_key) {
                     if let Some(local_child_node) = local_nodes_for_key.pop_front() {
                         trace!(
                             self.driver,
                             "Deduping local child {} to remote child {}",
                             local_child_node,
                             remote_child_node
                         );
                         local_to_remote.insert(local_child_node.guid.clone(), remote_child_node);
@@ -1562,8 +1748,352 @@ impl<'t, D: Driver, A: AbortSignal> Merg
                 "Merged node {} doesn't exist locally; no potential dupes for remote child {}",
                 merged_node,
                 remote_child_node
             );
             Ok(None)
         }
     }
 }
+
+/// The root of a merged tree, from which all merged nodes descend.
+#[derive(Debug)]
+pub struct MergedRoot<'t> {
+    local_tree: &'t Tree,
+    remote_tree: &'t Tree,
+    node: MergedNode<'t>,
+    merged_guids: HashSet<Guid>,
+    delete_locally: HashSet<Guid>,
+    delete_remotely: HashSet<Guid>,
+    structure_counts: StructureCounts,
+}
+
+impl<'t> MergedRoot<'t> {
+    /// Returns the root node.
+    #[inline]
+    pub fn node(&self) -> &MergedNode<'_> {
+        &self.node
+    }
+
+    /// Returns a sequence of completion operations, or "completion ops", to
+    /// apply to the local tree so that it matches the merged tree.
+    pub fn completion_ops(&self) -> CompletionOps<'_> {
+        let mut ops = CompletionOps::default();
+        accumulate(&mut ops, self.node(), 1, false);
+        for guid in self.delete_locally.iter() {
+            if self.local_tree.mentions(guid) && self.remote_tree.mentions(guid) {
+                // Only flag tombstones for items that exist in both trees,
+                // excluding ones for items that don't exist in the local
+                // tree. This can be removed once we persist tombstones in
+                // bug 1343103.
+                let flag_as_merged = FlagAsMerged(guid);
+                ops.flag_as_merged.push(flag_as_merged);
+            }
+        }
+        ops
+    }
+
+    /// Returns an iterator for all accepted local and remote deletions.
+    #[inline]
+    pub fn deletions(&self) -> impl Iterator<Item = Deletion<'_>> {
+        self.local_deletions().chain(self.remote_deletions())
+    }
+
+    /// Returns an iterator for all items that should be deleted from the
+    /// local tree.
+    pub fn local_deletions(&self) -> impl Iterator<Item = Deletion<'_>> {
+        self.delete_locally.iter().filter_map(move |guid| {
+            if self.delete_remotely.contains(guid) {
+                None
+            } else {
+                let local_level = self
+                    .local_tree
+                    .node_for_guid(guid)
+                    .map_or(-1, |node| node.level());
+                // Items that should be deleted locally already have tombstones
+                // on the server, so we don't need to upload tombstones for
+                // these deletions.
+                Some(Deletion {
+                    guid,
+                    local_level,
+                    should_upload_tombstone: false,
+                })
+            }
+        })
+    }
+
+    /// Returns an iterator for all items that should be deleted from the
+    /// remote tree.
+    pub fn remote_deletions(&self) -> impl Iterator<Item = Deletion<'_>> {
+        self.delete_remotely.iter().map(move |guid| {
+            let local_level = self
+                .local_tree
+                .node_for_guid(guid)
+                .map_or(-1, |node| node.level());
+            Deletion {
+                guid,
+                local_level,
+                should_upload_tombstone: true,
+            }
+        })
+    }
+
+    /// Returns structure change counts for this merged root.
+    #[inline]
+    pub fn counts(&self) -> &StructureCounts {
+        &self.structure_counts
+    }
+}
+
+/// Completion operations to apply to the local tree after a merge. These are
+/// represented as separate structs in `Vec`s instead of enums yielded from an
+/// iterator so that consumers can easily chunk them.
+#[derive(Clone, Debug, Default)]
+pub struct CompletionOps<'t> {
+    pub change_guids: Vec<ChangeGuid<'t>>,
+    pub apply_remote_items: Vec<ApplyRemoteItem<'t>>,
+    pub apply_new_local_structure: Vec<ApplyNewLocalStructure<'t>>,
+    pub flag_for_upload: Vec<FlagForUpload<'t>>,
+    pub skip_upload: Vec<SkipUpload<'t>>,
+    pub flag_as_merged: Vec<FlagAsMerged<'t>>,
+    pub upload: Vec<Upload<'t>>,
+}
+
+/// A completion op to change the local GUID to the merged GUID. This is used
+/// to dedupe new local items to remote ones, as well as to fix up invalid
+/// GUIDs.
+#[derive(Clone, Copy, Debug)]
+pub struct ChangeGuid<'t> {
+    /// The merged node to update.
+    pub merged_node: &'t MergedNode<'t>,
+    /// The level of the node in the merged tree. Desktop uses this to ensure
+    /// that GUID change observers are notified in level order (parents before
+    /// children).
+    pub level: usize,
+}
+
+impl<'t> ChangeGuid<'t> {
+    /// Returns the local node for this completion op. Panics if the local node
+    /// isn't set, as we should never emit a `ChangeGuid` op in that case.
+    #[inline]
+    pub fn local_node(&self) -> &'t Node<'t> {
+        self.merged_node
+            .merge_state
+            .local_node()
+            .expect("Can't change local GUID without local node")
+    }
+}
+
+impl<'t> fmt::Display for ChangeGuid<'t> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "Change {} to {}",
+            self.local_node().guid,
+            self.merged_node.guid
+        )
+    }
+}
+
+/// A completion op to insert a new remote item into the local tree, or apply
+/// synced changes to an existing item.
+#[derive(Clone, Copy, Debug)]
+pub struct ApplyRemoteItem<'t> {
+    pub merged_node: &'t MergedNode<'t>,
+    pub level: usize,
+}
+
+impl<'t> ApplyRemoteItem<'t> {
+    /// Returns the remote node for this completion op. Panics if the remote
+    /// node isn't set, as we should never emit an `ApplyRemoteItem` op in
+    /// that case.
+    #[inline]
+    pub fn remote_node(&self) -> &'t Node<'t> {
+        self.merged_node
+            .merge_state
+            .remote_node()
+            .expect("Can't apply remote item without remote node")
+    }
+}
+
+impl<'t> fmt::Display for ApplyRemoteItem<'t> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if self.merged_node.remote_guid_changed() {
+            write!(
+                f,
+                "Apply remote {} as {}",
+                self.remote_node().guid,
+                self.merged_node.guid
+            )
+        } else {
+            write!(f, "Apply remote {}", self.merged_node.guid)
+        }
+    }
+}
+
+/// A completion op to update the parent and position of a local item.
+#[derive(Clone, Copy, Debug)]
+pub struct ApplyNewLocalStructure<'t> {
+    pub merged_node: &'t MergedNode<'t>,
+    pub merged_parent_node: &'t MergedNode<'t>,
+    pub position: usize,
+    pub level: usize,
+}
+
+impl<'t> fmt::Display for ApplyNewLocalStructure<'t> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "Move {} into {} at {}",
+            self.merged_node.guid, self.merged_parent_node.guid, self.position
+        )
+    }
+}
+
+/// A completion op to flag a local item for upload.
+#[derive(Clone, Copy, Debug)]
+pub struct FlagForUpload<'t> {
+    pub merged_node: &'t MergedNode<'t>,
+}
+
+impl<'t> fmt::Display for FlagForUpload<'t> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "Flag {} for upload", self.merged_node.guid)
+    }
+}
+
+/// A completion op to skip uploading a local item after resolving merge
+/// conflicts.
+#[derive(Clone, Copy, Debug)]
+pub struct SkipUpload<'t> {
+    pub merged_node: &'t MergedNode<'t>,
+}
+
+impl<'t> fmt::Display for SkipUpload<'t> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "Don't upload {}", self.merged_node.guid)
+    }
+}
+
+/// A completion op to upload or reupload a merged item.
+#[derive(Clone, Copy, Debug)]
+pub struct Upload<'t> {
+    pub merged_node: &'t MergedNode<'t>,
+}
+
+impl<'t> fmt::Display for Upload<'t> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "Upload {}", self.merged_node.guid)
+    }
+}
+
+/// A completion op to flag a remote item as merged.
+#[derive(Clone, Copy, Debug)]
+pub struct FlagAsMerged<'t>(&'t Guid);
+
+impl<'t> FlagAsMerged<'t> {
+    /// Returns the remote GUID for the item to flag as merged.
+    #[inline]
+    pub fn guid(self) -> &'t Guid {
+        self.0
+    }
+}
+
+impl<'t> fmt::Display for FlagAsMerged<'t> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "Flag {} as merged", self.guid())
+    }
+}
+
+/// Recursively accumulates completion ops, starting at `merged_node` and
+/// drilling down into all its descendants.
+fn accumulate<'t>(
+    ops: &mut CompletionOps<'t>,
+    merged_node: &'t MergedNode<'t>,
+    level: usize,
+    is_tagging: bool,
+) {
+    for (position, merged_child_node) in merged_node.merged_children.iter().enumerate() {
+        let is_tagging = if merged_child_node.guid == TAGS_GUID {
+            true
+        } else {
+            is_tagging
+        };
+        if merged_child_node.merge_state.should_apply_item() {
+            let apply_remote_item = ApplyRemoteItem {
+                merged_node: merged_child_node,
+                level,
+            };
+            ops.apply_remote_items.push(apply_remote_item);
+        }
+        if merged_child_node.local_guid_changed() {
+            let change_guid = ChangeGuid {
+                merged_node: merged_child_node,
+                level,
+            };
+            ops.change_guids.push(change_guid);
+        }
+        let local_child_node = merged_node
+            .merge_state
+            .local_node()
+            .and_then(|local_parent_node| local_parent_node.child(position));
+        let merged_local_child_node = merged_child_node.merge_state.local_node();
+        if local_child_node
+            .and_then(|m| merged_local_child_node.map(|n| m.guid != n.guid))
+            .unwrap_or(true)
+        {
+            // As an optimization, we only emit ops to apply a new local
+            // structure for items that actually moved. For example, if the
+            // local children are (A B C D) and the merged children are
+            // (A D C B), only (B D) need new structure.
+            let apply_new_local_structure = ApplyNewLocalStructure {
+                merged_node: merged_child_node,
+                merged_parent_node: merged_node,
+                position,
+                level,
+            };
+            ops.apply_new_local_structure
+                .push(apply_new_local_structure);
+        }
+        let local_needs_merge = merged_child_node
+            .merge_state
+            .local_node()
+            .map(|node| node.needs_merge)
+            .unwrap_or(false);
+        let should_upload = merged_child_node.merge_state.should_upload();
+        match (local_needs_merge, should_upload) {
+            (false, true) => {
+                // Local item isn't flagged for upload, but should be.
+                let flag_for_upload = FlagForUpload {
+                    merged_node: merged_child_node,
+                };
+                ops.flag_for_upload.push(flag_for_upload);
+            }
+            (true, false) => {
+                // Local item flagged for upload when it doesn't need to be.
+                let skip_upload = SkipUpload {
+                    merged_node: merged_child_node,
+                };
+                ops.skip_upload.push(skip_upload);
+            }
+            _ => {}
+        }
+        if should_upload && !is_tagging {
+            // (Re)upload items. Ignore the tags root and its descendants:
+            // they're part of the local tree on Desktop (and will be removed
+            // in bug 424160), but aren't synced as part of the structure.
+            ops.upload.push(Upload {
+                merged_node: merged_child_node,
+            });
+        }
+        if let Some(remote_child_node) = merged_child_node.merge_state.remote_node() {
+            if remote_child_node.needs_merge && !should_upload {
+                // If the remote item was merged, and doesn't need to be
+                // reuploaded, flag it as merged in the remote tree. Note that
+                // we _don't_ emit this for locally revived items, or items with
+                // new remote structure.
+                let flag_as_merged = FlagAsMerged(&remote_child_node.guid);
+                ops.flag_as_merged.push(flag_as_merged);
+            }
+        }
+        accumulate(ops, merged_child_node, level + 1, is_tagging);
+    }
+}
--- a/third_party/rust/dogear/src/store.rs
+++ b/third_party/rust/dogear/src/store.rs
@@ -7,145 +7,100 @@
 //     http://www.apache.org/licenses/LICENSE-2.0
 //
 // Unless required by applicable law or agreed to in writing, software
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-use std::{collections::HashMap, time::Duration, time::Instant};
+use std::{time::Duration, time::Instant};
 
 use crate::driver::{
-    AbortSignal, ContentsStats, DefaultAbortSignal, DefaultDriver, Driver, TelemetryEvent,
-    TreeStats,
+    AbortSignal, DefaultAbortSignal, DefaultDriver, Driver, TelemetryEvent, TreeStats,
 };
-use crate::error::{Error, ErrorKind};
-use crate::guid::Guid;
-use crate::merge::{Deletion, Merger};
-use crate::tree::{Content, MergedRoot, Tree};
+use crate::error::Error;
+use crate::merge::{MergedRoot, Merger};
+use crate::tree::Tree;
 
 /// A store is the main interface to Dogear. It implements methods for building
 /// local and remote trees from a storage backend, fetching content info for
 /// matching items with similar contents, and persisting the merged tree.
 pub trait Store<E: From<Error>> {
     /// Builds a fully rooted, consistent tree from the items and tombstones in
     /// the local store.
     fn fetch_local_tree(&self) -> Result<Tree, E>;
 
-    /// Fetches content info for all new local items that haven't been uploaded
-    /// or merged yet. We'll try to dedupe them to remotely changed items with
-    /// similar contents and different GUIDs.
-    fn fetch_new_local_contents(&self) -> Result<HashMap<Guid, Content>, E>;
-
     /// Builds a fully rooted, consistent tree from the items and tombstones in
     /// the mirror.
     fn fetch_remote_tree(&self) -> Result<Tree, E>;
 
-    /// Fetches content info for all items in the mirror that changed since the
-    /// last sync and don't exist locally. We'll try to match new local items to
-    /// these.
-    fn fetch_new_remote_contents(&self) -> Result<HashMap<Guid, Content>, E>;
-
     /// Applies the merged root to the local store, and stages items for
     /// upload. On Desktop, this method inserts the merged tree into a temp
     /// table, updates Places, and inserts outgoing items into another
     /// temp table.
-    fn apply<'t>(
-        &mut self,
-        root: MergedRoot<'t>,
-        deletions: impl Iterator<Item = Deletion<'t>>,
-    ) -> Result<(), E>;
+    fn apply<'t>(&mut self, root: MergedRoot<'t>) -> Result<(), E>;
 
     /// Builds and applies a merged tree using the default merge driver.
     fn merge(&mut self) -> Result<(), E> {
         self.merge_with_driver(&DefaultDriver, &DefaultAbortSignal)
     }
 
     /// Builds a complete merged tree from the local and remote trees, resolves
     /// conflicts, dedupes local items, and applies the merged tree using the
     /// given driver.
     fn merge_with_driver(
         &mut self,
         driver: &impl Driver,
         signal: &impl AbortSignal,
     ) -> Result<(), E> {
         signal.err_if_aborted()?;
+        debug!(driver, "Building local tree");
         let (local_tree, time) = with_timing(|| self.fetch_local_tree())?;
         driver.record_telemetry_event(TelemetryEvent::FetchLocalTree(TreeStats {
             items: local_tree.size(),
             problems: local_tree.problems().counts(),
             time,
         }));
-        debug!(driver, "Built local tree from mirror\n{}", local_tree);
+        trace!(driver, "Built local tree from mirror\n{}", local_tree);
 
         signal.err_if_aborted()?;
-        let (new_local_contents, time) = with_timing(|| self.fetch_new_local_contents())?;
-        driver.record_telemetry_event(TelemetryEvent::FetchNewLocalContents(ContentsStats {
-            items: new_local_contents.len(),
-            time,
-        }));
-
-        signal.err_if_aborted()?;
+        debug!(driver, "Building remote tree");
         let (remote_tree, time) = with_timing(|| self.fetch_remote_tree())?;
         driver.record_telemetry_event(TelemetryEvent::FetchRemoteTree(TreeStats {
             items: remote_tree.size(),
             problems: remote_tree.problems().counts(),
             time,
         }));
-        debug!(driver, "Built remote tree from mirror\n{}", remote_tree);
+        trace!(driver, "Built remote tree from mirror\n{}", remote_tree);
 
         signal.err_if_aborted()?;
-        let (new_remote_contents, time) = with_timing(|| self.fetch_new_remote_contents())?;
-        driver.record_telemetry_event(TelemetryEvent::FetchNewRemoteContents(ContentsStats {
-            items: new_local_contents.len(),
-            time,
-        }));
-
-        let mut merger = Merger::with_driver(
-            driver,
-            signal,
-            &local_tree,
-            &new_local_contents,
-            &remote_tree,
-            &new_remote_contents,
-        );
+        debug!(driver, "Building merged tree");
+        let merger = Merger::with_driver(driver, signal, &local_tree, &remote_tree);
         let (merged_root, time) = with_timing(|| merger.merge())?;
-        driver.record_telemetry_event(TelemetryEvent::Merge(time, *merger.counts()));
-        debug!(
+        driver.record_telemetry_event(TelemetryEvent::Merge(time, *merged_root.counts()));
+        trace!(
             driver,
             "Built new merged tree\n{}\nDelete Locally: [{}]\nDelete Remotely: [{}]",
-            merged_root.to_ascii_string(),
-            merger
+            merged_root.node().to_ascii_string(),
+            merged_root
                 .local_deletions()
                 .map(|d| d.guid.as_str())
                 .collect::<Vec<_>>()
                 .join(", "),
-            merger
+            merged_root
                 .remote_deletions()
                 .map(|d| d.guid.as_str())
                 .collect::<Vec<_>>()
                 .join(", ")
         );
 
-        // The merged tree should know about all items mentioned in the local
-        // and remote trees. Otherwise, it's incomplete, and we can't apply it.
-        // This indicates a bug in the merger.
-
         signal.err_if_aborted()?;
-        if !merger.subsumes(&local_tree) {
-            Err(E::from(ErrorKind::UnmergedLocalItems.into()))?;
-        }
-
-        signal.err_if_aborted()?;
-        if !merger.subsumes(&remote_tree) {
-            Err(E::from(ErrorKind::UnmergedRemoteItems.into()))?;
-        }
-
-        let ((), time) = with_timing(|| self.apply(merged_root, merger.deletions()))?;
+        debug!(driver, "Applying merged tree");
+        let ((), time) = with_timing(|| self.apply(merged_root))?;
         driver.record_telemetry_event(TelemetryEvent::Apply(time));
 
         Ok(())
     }
 }
 
 fn with_timing<T, E>(run: impl FnOnce() -> Result<T, E>) -> Result<(T, Duration), E> {
     let now = Instant::now();
--- a/third_party/rust/dogear/src/tests.rs
+++ b/third_party/rust/dogear/src/tests.rs
@@ -9,30 +9,29 @@
 // Unless required by applicable law or agreed to in writing, software
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
 use std::{
     cell::Cell,
-    collections::HashMap,
     convert::{TryFrom, TryInto},
     sync::Once,
 };
 
 use env_logger;
 
 use crate::driver::{DefaultAbortSignal, Driver};
 use crate::error::{Error, ErrorKind, Result};
 use crate::guid::{Guid, ROOT_GUID, UNFILED_GUID};
 use crate::merge::{Merger, StructureCounts};
 use crate::tree::{
-    Builder, Content, DivergedParent, DivergedParentGuid, Item, Kind, Problem, ProblemCounts,
-    Problems, Tree, Validity,
+    self, Builder, Content, DivergedParent, DivergedParentGuid, Item, Kind, MergeState, Problem,
+    ProblemCounts, Problems, Tree, Validity,
 };
 
 #[derive(Debug)]
 struct Node {
     item: Item,
     children: Vec<Node>,
 }
 
@@ -50,23 +49,23 @@ impl Node {
 }
 
 impl TryFrom<Node> for Builder {
     type Error = Error;
 
     fn try_from(node: Node) -> Result<Builder> {
         fn inflate(b: &mut Builder, parent_guid: &Guid, node: Node) -> Result<()> {
             let guid = node.item.guid.clone();
-            b.item(node.item)
-                .map(|_| ())
-                .or_else(|err| match err.kind() {
-                    ErrorKind::DuplicateItem(_) => Ok(()),
-                    _ => Err(err),
-                })?;
-            b.parent_for(&guid).by_structure(&parent_guid)?;
+            if let Err(err) = b.item(node.item) {
+                match err.kind() {
+                    ErrorKind::DuplicateItem(_) => {}
+                    _ => return Err(err),
+                }
+            }
+            b.mutate(&guid).by_structure(&parent_guid)?;
             for child in node.children {
                 inflate(b, &guid, child)?;
             }
             Ok(())
         }
 
         let guid = node.item.guid.clone();
         let mut builder = Tree::with_root(node.item);
@@ -101,16 +100,105 @@ macro_rules! nodes {
         $({
             let child = nodes!($($children)*);
             node.children.push(child.into());
         })*
         node
     }};
 }
 
+/// The name of a merge state. These match `tree::MergeState`, but without the
+/// associated nodes to simplify comparisons. We also don't distinguish between
+/// `{Local, Remote}Only` and `{Local, Remote}`, since that doesn't matter for
+/// tests.
+#[derive(Debug)]
+enum MergeStateName {
+    Local,
+    LocalWithNewLocalStructure,
+    Remote,
+    RemoteWithNewRemoteStructure,
+    Unchanged,
+    UnchangedWithNewLocalStructure,
+}
+
+/// A merged node produced by the `merged_nodes!` macro. Can be compared to
+/// a `tree::MergedNode` using `assert_eq!`.
+#[derive(Debug)]
+struct MergedNode {
+    guid: Guid,
+    merge_state_name: MergeStateName,
+    children: Vec<MergedNode>,
+}
+
+impl MergedNode {
+    fn new(guid: Guid, merge_state_name: MergeStateName) -> MergedNode {
+        MergedNode {
+            guid,
+            merge_state_name,
+            children: Vec::new(),
+        }
+    }
+}
+
+impl<'t> PartialEq<tree::MergedNode<'t>> for MergedNode {
+    fn eq(&self, other: &tree::MergedNode<'t>) -> bool {
+        if self.guid != other.guid {
+            return false;
+        }
+        let merge_state_matches = match (&self.merge_state_name, other.merge_state) {
+            (MergeStateName::Local, MergeState::LocalOnly(_)) => true,
+            (
+                MergeStateName::LocalWithNewLocalStructure,
+                MergeState::LocalOnlyWithNewLocalStructure(_),
+            ) => true,
+            (MergeStateName::Remote, MergeState::RemoteOnly(_)) => true,
+            (
+                MergeStateName::RemoteWithNewRemoteStructure,
+                MergeState::RemoteOnlyWithNewRemoteStructure(_),
+            ) => true,
+            (MergeStateName::Local, MergeState::Local { .. }) => true,
+            (
+                MergeStateName::LocalWithNewLocalStructure,
+                MergeState::LocalWithNewLocalStructure { .. },
+            ) => true,
+            (MergeStateName::Remote, MergeState::Remote { .. }) => true,
+            (
+                MergeStateName::RemoteWithNewRemoteStructure,
+                MergeState::RemoteWithNewRemoteStructure { .. },
+            ) => true,
+            (MergeStateName::Unchanged, MergeState::Unchanged { .. }) => true,
+            (
+                MergeStateName::UnchangedWithNewLocalStructure,
+                MergeState::UnchangedWithNewLocalStructure { .. },
+            ) => true,
+            _ => false,
+        };
+        if !merge_state_matches {
+            return false;
+        }
+        self.children == other.merged_children
+    }
+}
+
+macro_rules! merged_nodes {
+    ($children:tt) => { merged_nodes!(ROOT_GUID, Local, $children) };
+    ($guid:expr, $state:ident) => {
+        MergedNode::new(Guid::from($guid), MergeStateName::$state)
+    };
+    ($guid:expr, $state:ident, { $(( $($children:tt)+ )),* }) => {{
+        #[allow(unused_mut)]
+        let mut node = merged_nodes!($guid, $state);
+        $({
+            let child = merged_nodes!($($children)*);
+            node.children.push(child);
+        })*
+        node
+    }};
+}
+
 fn before_each() {
     static ONCE: Once = Once::new();
     ONCE.call_once(|| {
         env_logger::init();
     });
 }
 
 #[test]
@@ -146,51 +234,46 @@ fn reparent_and_reposition() {
                 ("bookmarkFFFF", Bookmark[needs_merge = true]),
                 ("bookmarkEEEE", Bookmark[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!(ROOT_GUID, Folder[needs_merge = true], {
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkFFFF", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!(ROOT_GUID, LocalWithNewLocalStructure, {
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkFFFF", RemoteWithNewRemoteStructure)
         }),
-        ("unfiled_____", Folder, {
-            ("folderBBBBBB", Folder, {
-                ("bookmarkDDDD", Bookmark),
-                ("bookmarkAAAA", Bookmark),
-                ("bookmarkCCCC", Bookmark)
+        ("unfiled_____", Remote, {
+            ("folderBBBBBB", Remote, {
+                ("bookmarkDDDD", Remote),
+                ("bookmarkAAAA", Remote),
+                ("bookmarkCCCC", Remote)
             })
         }),
-        ("toolbar_____", Folder, {
-            ("folderAAAAAA", Folder[needs_merge = true], {
-                ("bookmarkEEEE", Bookmark)
+        ("toolbar_____", Remote, {
+            ("folderAAAAAA", LocalWithNewLocalStructure, {
+                ("bookmarkEEEE", Remote)
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 10,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 // 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.
 #[test]
 fn move_into_parent_sibling() {
     before_each();
 
@@ -210,42 +293,37 @@ fn move_into_parent_sibling() {
             ("folderCCCCCC", Folder[needs_merge = true], {
                 ("bookmarkBBBB", Bookmark[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("folderAAAAAA", Folder),
-            ("folderCCCCCC", Folder, {
-                ("bookmarkBBBB", Bookmark)
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
+            ("folderAAAAAA", Remote),
+            ("folderCCCCCC", Remote, {
+                ("bookmarkBBBB", Remote)
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 4,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn reorder_and_insert() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder, {
@@ -291,54 +369,51 @@ fn reorder_and_insert() {
             ("bookmarkFFFF", Bookmark),
             ("bookmarkDDDD", Bookmark),
             ("bookmarkEEEE", Bookmark)
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
             // The server has an older menu, so we should use the local order (C A B)
             // as the base, then append (I J).
-            ("bookmarkCCCC", Bookmark),
-            ("bookmarkAAAA", Bookmark),
-            ("bookmarkBBBB", Bookmark),
-            ("bookmarkIIII", Bookmark),
-            ("bookmarkJJJJ", Bookmark)
+            ("bookmarkCCCC", Unchanged),
+            ("bookmarkAAAA", Unchanged),
+            ("bookmarkBBBB", Unchanged),
+            ("bookmarkIIII", Remote),
+            ("bookmarkJJJJ", Remote)
         }),
-        ("toolbar_____", Folder[needs_merge = true, age = 5], {
+        ("toolbar_____", LocalWithNewLocalStructure, {
             // The server has a newer toolbar, so we should use the remote order (F D E)
-            // as the base, then append (G H).
-            ("bookmarkFFFF", Bookmark),
-            ("bookmarkDDDD", Bookmark),
-            ("bookmarkEEEE", Bookmark),
-            ("bookmarkGGGG", Bookmark[needs_merge = true]),
-            ("bookmarkHHHH", Bookmark[needs_merge = true])
+            // as the base, then append (G H). However, we always prefer the local state
+            // for roots, to avoid clobbering titles, so this is
+            // `LocalWithNewLocalStructure` instead of `RemoteWithNewRemoteStructure`.
+            ("bookmarkFFFF", Unchanged),
+            ("bookmarkDDDD", Unchanged),
+            ("bookmarkEEEE", Unchanged),
+            ("bookmarkGGGG", Local),
+            ("bookmarkHHHH", Local)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 12,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn unchanged_newer_changed_older() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder[age = 5], {
@@ -348,87 +423,82 @@ fn unchanged_newer_changed_older() {
         ("toolbar_____", Folder[age = 5], {
             ("folderCCCCCC", Folder[age = 5]),
             ("bookmarkDDDD", Bookmark[age = 5])
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         // Even though the local menu is newer (local = 5s, remote = 9s;
         // adding E updated the modified times of A and the menu), it's
         // not *changed* locally, so we should merge remote children first.
         ("menu________", Folder, {
             ("folderAAAAAA", Folder[needs_merge = true], {
                 ("bookmarkEEEE", Bookmark[needs_merge = true])
             }),
             ("bookmarkBBBB", Bookmark[age = 5])
         }),
         ("toolbar_____", Folder[needs_merge = true, age = 5], {
             ("bookmarkDDDD", Bookmark[age = 5])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    local_tree.note_deleted("folderCCCCCC".into());
+    local_tree_builder.deletion("folderCCCCCC".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let mut remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true, age = 5], {
             ("bookmarkBBBB", Bookmark[age = 5])
         }),
         // Even though the remote toolbar is newer (local = 15s, remote = 10s), it's
         // not changed remotely, so we should merge local children first.
         ("toolbar_____", Folder[age = 5], {
             ("folderCCCCCC", Folder[needs_merge = true], {
                 ("bookmarkFFFF", Bookmark[needs_merge = true])
             }),
             ("bookmarkDDDD", Bookmark[age = 5])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    remote_tree.note_deleted("folderAAAAAA".into());
+    remote_tree_builder.deletion("folderAAAAAA".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkBBBB", Bookmark[age = 5]),
-            ("bookmarkEEEE", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkBBBB", Unchanged),
+            ("bookmarkEEEE", LocalWithNewLocalStructure)
         }),
-        ("toolbar_____", Folder[needs_merge = true, age = 5], {
-            ("bookmarkDDDD", Bookmark[age = 5]),
-            ("bookmarkFFFF", Bookmark[needs_merge = true])
+        ("toolbar_____", LocalWithNewLocalStructure, {
+            ("bookmarkDDDD", Unchanged),
+            ("bookmarkFFFF", RemoteWithNewRemoteStructure)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec!["folderAAAAAA", "folderCCCCCC"];
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 1,
         local_revives: 0,
         remote_deletes: 1,
         dupes: 0,
         merged_nodes: 6,
         merged_deletions: 2,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn newer_local_moves() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder[age = 10], {
@@ -490,54 +560,49 @@ fn newer_local_moves() {
             ("folderHHHHHH", Folder[needs_merge = true, age = 5], {
                 ("bookmarkCCCC", Bookmark[needs_merge = true, age = 5])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("folderDDDDDD", Folder[needs_merge = true], {
-                ("bookmarkCCCC", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!({
+        ("menu________", Local, {
+            ("folderDDDDDD", Local, {
+                ("bookmarkCCCC", Local)
             })
         }),
-        ("toolbar_____", Folder[needs_merge = true], {
-            ("folderHHHHHH", Folder[needs_merge = true], {
-                ("bookmarkGGGG", Bookmark[needs_merge = true])
+        ("toolbar_____", Local, {
+            ("folderHHHHHH", Local, {
+                ("bookmarkGGGG", Local)
             }),
-            ("folderFFFFFF", Folder[needs_merge = true]),
-            ("bookmarkEEEE", Bookmark[age = 10])
+            ("folderFFFFFF", Local),
+            ("bookmarkEEEE", Unchanged)
         }),
-        ("unfiled_____", Folder[needs_merge = true], {
-            ("bookmarkAAAA", Bookmark[needs_merge = true])
+        ("unfiled_____", Local, {
+            ("bookmarkAAAA", Local)
         }),
-        ("mobile______", Folder[needs_merge = true], {
-            ("folderBBBBBB", Folder[needs_merge = true])
+        ("mobile______", Local, {
+            ("folderBBBBBB", Local)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 12,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn newer_remote_moves() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder[age = 10], {
@@ -599,54 +664,49 @@ fn newer_remote_moves() {
             ("folderHHHHHH", Folder[needs_merge = true], {
                 ("bookmarkCCCC", Bookmark[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true, age = 5], {
-            ("folderDDDDDD", Folder, {
-                ("bookmarkGGGG", Bookmark)
+    let expected_tree = merged_nodes!({
+        ("menu________", Local, {
+            ("folderDDDDDD", Remote, {
+                ("bookmarkGGGG", Remote)
             })
         }),
-        ("toolbar_____", Folder[needs_merge = true, age = 5], {
-            ("folderFFFFFF", Folder),
-            ("bookmarkEEEE", Bookmark[age = 10]),
-            ("folderHHHHHH", Folder, {
-                ("bookmarkCCCC", Bookmark)
+        ("toolbar_____", LocalWithNewLocalStructure, {
+            ("folderFFFFFF", Remote),
+            ("bookmarkEEEE", Unchanged),
+            ("folderHHHHHH", Remote, {
+                ("bookmarkCCCC", Remote)
             })
         }),
-        ("unfiled_____", Folder[needs_merge = true, age = 5], {
-            ("folderBBBBBB", Folder)
+        ("unfiled_____", LocalWithNewLocalStructure, {
+            ("folderBBBBBB", Remote)
         }),
-        ("mobile______", Folder[needs_merge = true, age = 5], {
-            ("bookmarkAAAA", Bookmark)
+        ("mobile______", LocalWithNewLocalStructure, {
+            ("bookmarkAAAA", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 12,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn value_structure_conflict() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder, {
@@ -685,45 +745,40 @@ fn value_structure_conflict() {
             ("folderDDDDDD", Folder[needs_merge = true, age = 5], {
                 ("bookmarkEEEE", Bookmark[needs_merge = true, age = 5])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder, {
-            ("folderAAAAAA", Folder[needs_merge = true, age = 10], {
-                ("bookmarkCCCC", Bookmark)
+    let expected_tree = merged_nodes!({
+        ("menu________", Unchanged, {
+            ("folderAAAAAA", Local, {
+                ("bookmarkCCCC", Unchanged)
             }),
-            ("folderDDDDDD", Folder[needs_merge = true, age = 5], {
-                ("bookmarkEEEE", Bookmark[age = 5]),
-                ("bookmarkBBBB", Bookmark[needs_merge = true])
+            ("folderDDDDDD", RemoteWithNewRemoteStructure, {
+                ("bookmarkEEEE", Remote),
+                ("bookmarkBBBB", Local)
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 6,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn complex_move_with_additions() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder, {
@@ -757,50 +812,45 @@ fn complex_move_with_additions() {
                 ("bookmarkBBBB", Bookmark),
                 ("bookmarkEEEE", Bookmark[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder, {
-            ("bookmarkCCCC", Bookmark)
+    let expected_tree = merged_nodes!(ROOT_GUID, LocalWithNewLocalStructure, {
+        ("menu________", UnchangedWithNewLocalStructure, {
+            ("bookmarkCCCC", Remote)
         }),
-        ("toolbar_____", Folder, {
-            ("folderAAAAAA", Folder[needs_merge = true], {
+        ("toolbar_____", Remote, {
+            ("folderAAAAAA", RemoteWithNewRemoteStructure, {
                 // 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).
-                ("bookmarkBBBB", Bookmark),
-                ("bookmarkEEEE", Bookmark),
-                ("bookmarkDDDD", Bookmark[needs_merge = true])
+                ("bookmarkBBBB", Unchanged),
+                ("bookmarkEEEE", Remote),
+                ("bookmarkDDDD", Local)
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 7,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn complex_orphaning() {
     before_each();
 
     let _shared_tree = nodes!({
         ("toolbar_____", Folder, {
@@ -815,96 +865,91 @@ fn complex_orphaning() {
                 })
             })
         })
     })
     .into_tree()
     .unwrap();
 
     // Locally: delete E, add B > F.
-    let mut local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("toolbar_____", Folder[needs_merge = false], {
             ("folderAAAAAA", Folder, {
                 ("folderBBBBBB", Folder[needs_merge = true], {
                     ("bookmarkFFFF", Bookmark[needs_merge = true])
                 })
             })
         }),
         ("menu________", Folder, {
             ("folderCCCCCC", Folder, {
                 ("folderDDDDDD", Folder[needs_merge = true])
             })
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    local_tree.note_deleted("folderEEEEEE".into());
+    local_tree_builder.deletion("folderEEEEEE".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
     // Remotely: delete B, add E > G.
-    let mut remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("toolbar_____", Folder, {
             ("folderAAAAAA", Folder[needs_merge = true])
         }),
         ("menu________", Folder, {
             ("folderCCCCCC", Folder, {
                 ("folderDDDDDD", Folder, {
                     ("folderEEEEEE", Folder[needs_merge = true], {
                         ("bookmarkGGGG", Bookmark[needs_merge = true])
                     })
                 })
             })
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    remote_tree.note_deleted("folderBBBBBB".into());
+    remote_tree_builder.deletion("folderBBBBBB".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("toolbar_____", Folder, {
-            ("folderAAAAAA", Folder[needs_merge = true], {
+    let expected_tree = merged_nodes!({
+        ("toolbar_____", Unchanged, {
+            ("folderAAAAAA", RemoteWithNewRemoteStructure, {
                 // B was deleted remotely, so F moved to A, the closest
                 // surviving parent.
-                ("bookmarkFFFF", Bookmark[needs_merge = true])
+                ("bookmarkFFFF", LocalWithNewLocalStructure)
             })
         }),
-        ("menu________", Folder, {
-            ("folderCCCCCC", Folder, {
-                ("folderDDDDDD", Folder[needs_merge = true], {
+        ("menu________", Unchanged, {
+            ("folderCCCCCC", Unchanged, {
+                ("folderDDDDDD", LocalWithNewLocalStructure, {
                     // E was deleted locally, so G moved to D.
-                    ("bookmarkGGGG", Bookmark[needs_merge = true])
+                    ("bookmarkGGGG", RemoteWithNewRemoteStructure)
                 })
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec!["folderBBBBBB", "folderEEEEEE"];
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 1,
         local_revives: 0,
         remote_deletes: 1,
         dupes: 0,
         merged_nodes: 7,
         merged_deletions: 2,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn locally_modified_remotely_deleted() {
     before_each();
 
     let _shared_tree = nodes!({
         ("toolbar_____", Folder, {
@@ -918,92 +963,87 @@ fn locally_modified_remotely_deleted() {
                     ("folderEEEEEE", Folder)
                 })
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("toolbar_____", Folder, {
             ("folderAAAAAA", Folder, {
                 ("folderBBBBBB", Folder[needs_merge = true], {
                     ("bookmarkFFFF", Bookmark[needs_merge = true])
                 })
             })
         }),
         ("menu________", Folder, {
             ("folderCCCCCC", Folder, {
                 ("folderDDDDDD", Folder[needs_merge = true])
             })
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    local_tree.note_deleted("folderEEEEEE".into());
+    local_tree_builder.deletion("folderEEEEEE".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let mut remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("toolbar_____", Folder, {
             ("folderAAAAAA", Folder[needs_merge = true])
         }),
         ("menu________", Folder, {
             ("folderCCCCCC", Folder, {
                 ("folderDDDDDD", Folder, {
                     ("folderEEEEEE", Folder[needs_merge = true], {
                         ("bookmarkGGGG", Bookmark[needs_merge = true])
                     })
                 })
             })
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    remote_tree.note_deleted("folderBBBBBB".into());
+    remote_tree_builder.deletion("folderBBBBBB".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("toolbar_____", Folder, {
-            ("folderAAAAAA", Folder[needs_merge = true], {
-                ("bookmarkFFFF", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!({
+        ("toolbar_____", Unchanged, {
+            ("folderAAAAAA", RemoteWithNewRemoteStructure, {
+                ("bookmarkFFFF", LocalWithNewLocalStructure)
             })
         }),
-        ("menu________", Folder, {
-            ("folderCCCCCC", Folder, {
-                ("folderDDDDDD", Folder[needs_merge = true], {
-                    ("bookmarkGGGG", Bookmark[needs_merge = true])
+        ("menu________", Unchanged, {
+            ("folderCCCCCC", Unchanged, {
+                ("folderDDDDDD", LocalWithNewLocalStructure, {
+                    ("bookmarkGGGG", RemoteWithNewRemoteStructure)
                 })
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec!["folderBBBBBB", "folderEEEEEE"];
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 1,
         local_revives: 0,
         remote_deletes: 1,
         dupes: 0,
         merged_nodes: 7,
         merged_deletions: 2,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn locally_deleted_remotely_modified() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder, {
@@ -1014,28 +1054,25 @@ fn locally_deleted_remotely_modified() {
                     ("bookmarkEEEE", Bookmark)
                 })
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut local_tree = nodes!({ ("menu________", Folder[needs_merge = true]) })
-        .into_tree()
-        .unwrap();
-    for guid in &[
-        "bookmarkAAAA",
-        "folderBBBBBB",
-        "bookmarkCCCC",
-        "folderDDDDDD",
-        "bookmarkEEEE",
-    ] {
-        local_tree.note_deleted(guid.to_owned().into());
-    }
+    let mut local_tree_builder =
+        Builder::try_from(nodes!({ ("menu________", Folder[needs_merge = true]) })).unwrap();
+    local_tree_builder
+        .deletion("bookmarkAAAA".into())
+        .deletion("folderBBBBBB".into())
+        .deletion("bookmarkCCCC".into())
+        .deletion("folderDDDDDD".into())
+        .deletion("bookmarkEEEE".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
     let remote_tree = nodes!({
         ("menu________", Folder, {
             ("bookmarkAAAA", Bookmark[needs_merge = true]),
             ("folderBBBBBB", Folder[needs_merge = true], {
                 ("bookmarkCCCC", Bookmark),
                 ("folderDDDDDD", Folder[needs_merge = true], {
                     ("bookmarkEEEE", Bookmark),
@@ -1043,94 +1080,84 @@ fn locally_deleted_remotely_modified() {
                 }),
                 ("bookmarkGGGG", Bookmark[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkAAAA", Bookmark),
-            ("bookmarkFFFF", Bookmark[needs_merge = true]),
-            ("bookmarkGGGG", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkAAAA", Remote),
+            ("bookmarkFFFF", RemoteWithNewRemoteStructure),
+            ("bookmarkGGGG", RemoteWithNewRemoteStructure)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec![
         "bookmarkCCCC",
         "bookmarkEEEE",
         "folderBBBBBB",
         "folderDDDDDD",
     ];
     let expected_telem = StructureCounts {
         remote_revives: 1,
         local_deletes: 2,
         local_revives: 0,
         remote_deletes: 0,
         dupes: 0,
         merged_nodes: 4,
         merged_deletions: 4,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn nonexistent_on_one_side() {
     before_each();
 
-    let mut local_tree = Tree::with_root(Item::new(ROOT_GUID, Kind::Folder))
-        .into_tree()
-        .unwrap();
     // A doesn't exist remotely.
-    local_tree.note_deleted("bookmarkAAAA".into());
+    let mut local_tree_builder = Tree::with_root(Item::new(ROOT_GUID, Kind::Folder));
+    local_tree_builder.deletion("bookmarkAAAA".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let mut remote_tree = Tree::with_root(Item::new(ROOT_GUID, Kind::Folder))
-        .into_tree()
-        .unwrap();
     // B doesn't exist locally.
-    remote_tree.note_deleted("bookmarkBBBB".into());
+    let mut remote_tree_builder = Tree::with_root(Item::new(ROOT_GUID, Kind::Folder));
+    remote_tree_builder.deletion("bookmarkBBBB".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
     let mut expected_root = Item::new(ROOT_GUID, Kind::Folder);
     expected_root.needs_merge = true;
-    let expected_tree = Tree::with_root(expected_root).into_tree().unwrap();
+    let expected_tree = merged_nodes!(ROOT_GUID, Unchanged, {});
     let expected_deletions = vec!["bookmarkAAAA", "bookmarkBBBB"];
     let expected_telem = StructureCounts {
         merged_deletions: 2,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn clear_folder_then_delete() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder, {
@@ -1142,82 +1169,77 @@ fn clear_folder_then_delete() {
                 ("bookmarkEEEE", Bookmark),
                 ("bookmarkFFFF", Bookmark)
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true], {
             ("folderAAAAAA", Folder, {
                 ("bookmarkBBBB", Bookmark),
                 ("bookmarkCCCC", Bookmark)
             }),
             ("bookmarkEEEE", Bookmark[needs_merge = true])
         }),
         ("mobile______", Folder[needs_merge = true], {
             ("bookmarkFFFF", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    local_tree.note_deleted("folderDDDDDD".into());
+    local_tree_builder.deletion("folderDDDDDD".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let mut remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true], {
             ("bookmarkBBBB", Bookmark[needs_merge = true]),
             ("folderDDDDDD", Folder, {
                 ("bookmarkEEEE", Bookmark),
                 ("bookmarkFFFF", Bookmark)
             })
         }),
         ("unfiled_____", Folder[needs_merge = true], {
             ("bookmarkCCCC", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    remote_tree.note_deleted("folderAAAAAA".into());
+    remote_tree_builder.deletion("folderAAAAAA".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!(ROOT_GUID, Folder[needs_merge = true], {
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkBBBB", Bookmark),
-            ("bookmarkEEEE", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!(ROOT_GUID, LocalWithNewLocalStructure, {
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkBBBB", Remote),
+            ("bookmarkEEEE", Local)
         }),
-        ("mobile______", Folder[needs_merge = true], {
-            ("bookmarkFFFF", Bookmark[needs_merge = true])
+        ("mobile______", Local, {
+            ("bookmarkFFFF", Local)
         }),
-        ("unfiled_____", Folder, {
-            ("bookmarkCCCC", Bookmark)
+        ("unfiled_____", Remote, {
+            ("bookmarkCCCC", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec!["folderAAAAAA", "folderDDDDDD"];
     let expected_telem = StructureCounts {
         merged_nodes: 7,
         merged_deletions: 2,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn newer_move_to_deleted() {
     before_each();
 
     let _shared_tree = nodes!({
         ("menu________", Folder, {
@@ -1227,194 +1249,172 @@ fn newer_move_to_deleted() {
             ("folderCCCCCC", Folder, {
                 ("bookmarkDDDD", Bookmark)
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true], {
             // A is younger locally. However, we should *not* revert
             // remotely moving B to the toolbar. (Locally, B exists in A,
             // but we deleted the now-empty A remotely).
             ("folderAAAAAA", Folder[needs_merge = true], {
                 ("bookmarkBBBB", Bookmark[age = 5]),
                 ("bookmarkEEEE", Bookmark[needs_merge = true])
             })
         }),
         ("toolbar_____", Folder[needs_merge = true], {
             ("bookmarkDDDD", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    local_tree.note_deleted("folderCCCCCC".into());
+    local_tree_builder.deletion("folderCCCCCC".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let mut remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true, age = 5], {
             // C is younger remotely. However, we should *not* revert
             // locally moving D to the toolbar. (Locally, D exists in C,
             // but we deleted the now-empty C locally).
             ("folderCCCCCC", Folder[needs_merge = true], {
                 ("bookmarkDDDD", Bookmark[age = 5]),
                 ("bookmarkFFFF", Bookmark[needs_merge = true])
             })
         }),
         ("toolbar_____", Folder[needs_merge = true, age = 5], {
             ("bookmarkBBBB", Bookmark[needs_merge = true, age = 5])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    remote_tree.note_deleted("folderAAAAAA".into());
+    remote_tree_builder.deletion("folderAAAAAA".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkEEEE", Bookmark[needs_merge = true]),
-            ("bookmarkFFFF", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkEEEE", LocalWithNewLocalStructure),
+            ("bookmarkFFFF", RemoteWithNewRemoteStructure)
         }),
-        ("toolbar_____", Folder[needs_merge = true], {
-            ("bookmarkDDDD", Bookmark[needs_merge = true]),
-            ("bookmarkBBBB", Bookmark[age = 5])
+        ("toolbar_____", LocalWithNewLocalStructure, {
+            ("bookmarkDDDD", Local),
+            ("bookmarkBBBB", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec!["folderAAAAAA", "folderCCCCCC"];
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 1,
         local_revives: 0,
         remote_deletes: 1,
         dupes: 0,
         merged_nodes: 6,
         merged_deletions: 2,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn deduping_local_newer() {
     before_each();
 
-    let local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true], {
             ("bookmarkAAA1", Bookmark[needs_merge = true]),
             ("bookmarkAAA2", Bookmark[needs_merge = true]),
             ("bookmarkAAA3", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_local_contents: HashMap<Guid, Content> = HashMap::new();
-    new_local_contents.insert(
-        "bookmarkAAA1".into(),
-        Content::Bookmark {
+    local_tree_builder
+        .mutate(&"bookmarkAAA1".into())
+        .content(Content::Bookmark {
             title: "A".into(),
             url_href: "http://example.com/a".into(),
-        },
-    );
-    new_local_contents.insert(
-        "bookmarkAAA2".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"bookmarkAAA2".into())
+        .content(Content::Bookmark {
             title: "A".into(),
             url_href: "http://example.com/a".into(),
-        },
-    );
-    new_local_contents.insert(
-        "bookmarkAAA3".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"bookmarkAAA3".into())
+        .content(Content::Bookmark {
             title: "A".into(),
             url_href: "http://example.com/a".into(),
-        },
-    );
+        });
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true, age = 5], {
             ("bookmarkAAAA", Bookmark[needs_merge = true, age = 5]),
             ("bookmarkAAA4", Bookmark[needs_merge = true, age = 5]),
             ("bookmarkAAA5", Bookmark)
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_remote_contents: HashMap<Guid, Content> = HashMap::new();
-    new_remote_contents.insert(
-        "bookmarkAAAA".into(),
-        Content::Bookmark {
+    remote_tree_builder
+        .mutate(&"bookmarkAAAA".into())
+        .content(Content::Bookmark {
             title: "A".into(),
             url_href: "http://example.com/a".into(),
-        },
-    );
-    new_remote_contents.insert(
-        "bookmarkAAA4".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"bookmarkAAA4".into())
+        .content(Content::Bookmark {
             title: "A".into(),
             url_href: "http://example.com/a".into(),
-        },
-    );
+        });
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::with_contents(
-        &local_tree,
-        &new_local_contents,
-        &remote_tree,
-        &new_remote_contents,
-    );
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkAAAA", Bookmark[needs_merge = true]),
-            ("bookmarkAAA4", Bookmark[needs_merge = true]),
-            ("bookmarkAAA3", Bookmark[needs_merge = true]),
-            ("bookmarkAAA5", Bookmark)
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkAAAA", LocalWithNewLocalStructure),
+            ("bookmarkAAA4", LocalWithNewLocalStructure),
+            ("bookmarkAAA3", Local),
+            ("bookmarkAAA5", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 0,
         local_revives: 0,
         remote_deletes: 0,
         dupes: 2,
         merged_nodes: 5,
         merged_deletions: 0,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn deduping_remote_newer() {
     before_each();
 
-    let local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true, age = 5], {
             // Shouldn't dedupe to `folderAAAAA1` because it's not in
             // `new_local_contents`.
             ("folderAAAAAA", Folder[needs_merge = true, age = 5], {
                 // Shouldn't dedupe to `bookmarkBBB1`. (bookmarkG111)
                 ("bookmarkBBBB", Bookmark[age = 10]),
                 // Not a candidate for `bookmarkCCC1` because the URLs are
                 // different. (bookmarkH111)
@@ -1432,293 +1432,277 @@ fn deduping_remote_newer() {
             // different. (separatorE11)
             ("separatorGGG", Separator[needs_merge = true, age = 5]),
             // Shouldn't dedupe to `bookmarkHHH1` because the parents are
             // different. (bookmarkC222)
             ("bookmarkHHHH", Bookmark[needs_merge = true, age = 5]),
             // Should dedupe to `queryIIIIII1`.
             ("queryIIIIIII", Query[needs_merge = true, age = 5])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_local_contents: HashMap<Guid, Content> = HashMap::new();
-    new_local_contents.insert(
-        "bookmarkCCCC".into(),
-        Content::Bookmark {
+    local_tree_builder
+        .mutate(&"bookmarkCCCC".into())
+        .content(Content::Bookmark {
             title: "C".into(),
             url_href: "http://example.com/c".into(),
-        },
-    );
-    new_local_contents.insert("folderDDDDDD".into(), Content::Folder { title: "D".into() });
-    new_local_contents.insert(
-        "bookmarkEEEE".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"folderDDDDDD".into())
+        .content(Content::Folder { title: "D".into() });
+    local_tree_builder
+        .mutate(&"bookmarkEEEE".into())
+        .content(Content::Bookmark {
             title: "E".into(),
             url_href: "http://example.com/e".into(),
-        },
-    );
-    new_local_contents.insert("separatorFFF".into(), Content::Separator { position: 1 });
-    new_local_contents.insert("separatorGGG".into(), Content::Separator { position: 2 });
-    new_local_contents.insert(
-        "bookmarkHHHH".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"separatorFFF".into())
+        .content(Content::Separator);
+    local_tree_builder
+        .mutate(&"separatorGGG".into())
+        .content(Content::Separator);
+    local_tree_builder
+        .mutate(&"bookmarkHHHH".into())
+        .content(Content::Bookmark {
             title: "H".into(),
             url_href: "http://example.com/h".into(),
-        },
-    );
-    new_local_contents.insert(
-        "queryIIIIIII".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"queryIIIIIII".into())
+        .content(Content::Bookmark {
             title: "I".into(),
             url_href: "place:maxResults=10&sort=8".into(),
-        },
-    );
+        });
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true], {
             ("folderAAAAAA", Folder[needs_merge = true], {
                 ("bookmarkBBBB", Bookmark[age = 10]),
                 ("bookmarkCCC1", Bookmark[needs_merge = true])
             }),
             ("folderDDDDD1", Folder[needs_merge = true], {
                 ("bookmarkEEE1", Bookmark[needs_merge = true]),
                 ("separatorFF1", Separator[needs_merge = true])
             }),
             ("separatorGG1", Separator[needs_merge = true]),
             ("bookmarkHHH1", Bookmark[needs_merge = true]),
             ("queryIIIIII1", Query[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_remote_contents: HashMap<Guid, Content> = HashMap::new();
-    new_remote_contents.insert(
-        "bookmarkCCC1".into(),
-        Content::Bookmark {
+    remote_tree_builder
+        .mutate(&"bookmarkCCC1".into())
+        .content(Content::Bookmark {
             title: "C".into(),
             url_href: "http://example.com/c1".into(),
-        },
-    );
-    new_remote_contents.insert("folderDDDDD1".into(), Content::Folder { title: "D".into() });
-    new_remote_contents.insert(
-        "bookmarkEEE1".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"folderDDDDD1".into())
+        .content(Content::Folder { title: "D".into() });
+    remote_tree_builder
+        .mutate(&"bookmarkEEE1".into())
+        .content(Content::Bookmark {
             title: "E".into(),
             url_href: "http://example.com/e".into(),
-        },
-    );
-    new_remote_contents.insert("separatorFF1".into(), Content::Separator { position: 1 });
-    new_remote_contents.insert("separatorGG1".into(), Content::Separator { position: 2 });
-    new_remote_contents.insert(
-        "bookmarkHHH1".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"separatorFF1".into())
+        .content(Content::Separator);
+    remote_tree_builder
+        .mutate(&"separatorGG1".into())
+        .content(Content::Separator);
+    remote_tree_builder
+        .mutate(&"bookmarkHHH1".into())
+        .content(Content::Bookmark {
             title: "H".into(),
             url_href: "http://example.com/h".into(),
-        },
-    );
-    new_remote_contents.insert(
-        "queryIIIIII1".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"queryIIIIII1".into())
+        .content(Content::Bookmark {
             title: "I".into(),
             url_href: "place:maxResults=10&sort=8".into(),
-        },
-    );
+        });
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::with_contents(
-        &local_tree,
-        &new_local_contents,
-        &remote_tree,
-        &new_remote_contents,
-    );
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true, age = 5], {
-            ("folderAAAAAA", Folder[needs_merge = true], {
-                ("bookmarkBBBB", Bookmark[age = 10]),
-                ("bookmarkCCC1", Bookmark),
-                ("bookmarkCCCC", Bookmark[needs_merge = true, age = 5])
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
+            ("folderAAAAAA", RemoteWithNewRemoteStructure, {
+                ("bookmarkBBBB", Unchanged),
+                ("bookmarkCCC1", Remote),
+                ("bookmarkCCCC", Local)
             }),
-            ("folderDDDDD1", Folder, {
-                ("bookmarkEEE1", Bookmark),
-                ("separatorFF1", Separator)
+            ("folderDDDDD1", Remote, {
+                ("bookmarkEEE1", Remote),
+                ("separatorFF1", Remote)
             }),
-            ("separatorGG1", Separator),
-            ("bookmarkHHH1", Bookmark),
-            ("queryIIIIII1", Query)
+            ("separatorGG1", Remote),
+            ("bookmarkHHH1", Remote),
+            ("queryIIIIII1", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 0,
         local_revives: 0,
         remote_deletes: 0,
         dupes: 6,
         merged_nodes: 11,
         merged_deletions: 0,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn complex_deduping() {
     before_each();
 
-    let local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true], {
             ("folderAAAAAA", Folder[needs_merge = true, age = 10], {
                 ("bookmarkBBBB", Bookmark[needs_merge = true, age = 10]),
                 ("bookmarkCCCC", Bookmark[needs_merge = true, age = 10])
             }),
             ("folderDDDDDD", Folder[needs_merge = true], {
                 ("bookmarkEEEE", Bookmark[needs_merge = true, age = 5])
             }),
             ("folderFFFFFF", Folder[needs_merge = true, age = 5], {
                 ("bookmarkGGGG", Bookmark[needs_merge = true, age = 5])
             })
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_local_contents: HashMap<Guid, Content> = HashMap::new();
-    new_local_contents.insert("folderAAAAAA".into(), Content::Folder { title: "A".into() });
-    new_local_contents.insert(
-        "bookmarkBBBB".into(),
-        Content::Bookmark {
+    local_tree_builder
+        .mutate(&"folderAAAAAA".into())
+        .content(Content::Folder { title: "A".into() });
+    local_tree_builder
+        .mutate(&"bookmarkBBBB".into())
+        .content(Content::Bookmark {
             title: "B".into(),
             url_href: "http://example.com/b".into(),
-        },
-    );
-    new_local_contents.insert(
-        "bookmarkCCCC".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"bookmarkCCCC".into())
+        .content(Content::Bookmark {
             title: "C".into(),
             url_href: "http://example.com/c".into(),
-        },
-    );
-    new_local_contents.insert("folderDDDDDD".into(), Content::Folder { title: "D".into() });
-    new_local_contents.insert(
-        "bookmarkEEEE".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"folderDDDDDD".into())
+        .content(Content::Folder { title: "D".into() });
+    local_tree_builder
+        .mutate(&"bookmarkEEEE".into())
+        .content(Content::Bookmark {
             title: "E".into(),
             url_href: "http://example.com/e".into(),
-        },
-    );
-    new_local_contents.insert("folderFFFFFF".into(), Content::Folder { title: "F".into() });
-    new_local_contents.insert(
-        "bookmarkGGGG".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"folderFFFFFF".into())
+        .content(Content::Folder { title: "F".into() });
+    local_tree_builder
+        .mutate(&"bookmarkGGGG".into())
+        .content(Content::Bookmark {
             title: "G".into(),
             url_href: "http://example.com/g".into(),
-        },
-    );
+        });
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder[needs_merge = true], {
             ("folderAAAAA1", Folder[needs_merge = true], {
                 ("bookmarkBBB1", Bookmark[needs_merge = true])
             }),
             ("folderDDDDD1", Folder[needs_merge = true, age = 5], {
                 ("bookmarkEEE1", Bookmark[needs_merge = true])
             }),
             ("folderFFFFF1", Folder[needs_merge = true], {
                 ("bookmarkGGG1", Bookmark[needs_merge = true, age = 5]),
                 ("bookmarkHHH1", Bookmark[needs_merge = true])
             })
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_remote_contents: HashMap<Guid, Content> = HashMap::new();
-    new_remote_contents.insert("folderAAAAA1".into(), Content::Folder { title: "A".into() });
-    new_remote_contents.insert(
-        "bookmarkBBB1".into(),
-        Content::Bookmark {
+    remote_tree_builder
+        .mutate(&"folderAAAAA1".into())
+        .content(Content::Folder { title: "A".into() });
+    remote_tree_builder
+        .mutate(&"bookmarkBBB1".into())
+        .content(Content::Bookmark {
             title: "B".into(),
             url_href: "http://example.com/b".into(),
-        },
-    );
-    new_remote_contents.insert("folderDDDDD1".into(), Content::Folder { title: "D".into() });
-    new_remote_contents.insert(
-        "bookmarkEEE1".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"folderDDDDD1".into())
+        .content(Content::Folder { title: "D".into() });
+    remote_tree_builder
+        .mutate(&"bookmarkEEE1".into())
+        .content(Content::Bookmark {
             title: "E".into(),
             url_href: "http://example.com/e".into(),
-        },
-    );
-    new_remote_contents.insert("folderFFFFF1".into(), Content::Folder { title: "F".into() });
-    new_remote_contents.insert(
-        "bookmarkGGG1".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"folderFFFFF1".into())
+        .content(Content::Folder { title: "F".into() });
+    remote_tree_builder
+        .mutate(&"bookmarkGGG1".into())
+        .content(Content::Bookmark {
             title: "G".into(),
             url_href: "http://example.com/g".into(),
-        },
-    );
-    new_remote_contents.insert(
-        "bookmarkHHH1".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"bookmarkHHH1".into())
+        .content(Content::Bookmark {
             title: "H".into(),
             url_href: "http://example.com/h".into(),
-        },
-    );
+        });
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::with_contents(
-        &local_tree,
-        &new_local_contents,
-        &remote_tree,
-        &new_remote_contents,
-    );
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("folderAAAAA1", Folder[needs_merge = true], {
-                ("bookmarkBBB1", Bookmark),
-                ("bookmarkCCCC", Bookmark[needs_merge = true, age = 10])
+    let expected_tree = merged_nodes!({
+        ("menu________", LocalWithNewLocalStructure, {
+            ("folderAAAAA1", RemoteWithNewRemoteStructure, {
+                ("bookmarkBBB1", Remote),
+                ("bookmarkCCCC", Local)
             }),
-            ("folderDDDDD1", Folder[needs_merge = true], {
-                ("bookmarkEEE1", Bookmark)
+            ("folderDDDDD1", LocalWithNewLocalStructure, {
+                ("bookmarkEEE1", Remote)
             }),
-            ("folderFFFFF1", Folder, {
-                ("bookmarkGGG1", Bookmark[age = 5]),
-                ("bookmarkHHH1", Bookmark)
+            ("folderFFFFF1", Remote, {
+                ("bookmarkGGG1", Remote),
+                ("bookmarkHHH1", Remote)
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 0,
         local_revives: 0,
         remote_deletes: 0,
         dupes: 6,
         merged_nodes: 9,
         merged_deletions: 0,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn left_pane_root() {
     before_each();
 
     let local_tree = Tree::with_root(Item::new(ROOT_GUID, Kind::Folder))
         .into_tree()
@@ -1730,43 +1714,38 @@ fn left_pane_root() {
             ("folderLEFTPF", Folder[needs_merge = true], {
                 ("folderLEFTPC", Query[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!(ROOT_GUID, Folder[needs_merge = true])
-        .into_tree()
-        .unwrap();
+    let expected_tree = merged_nodes!(ROOT_GUID, Local);
     let expected_deletions = vec![
         "folderLEFTPC",
         "folderLEFTPF",
         "folderLEFTPQ",
         "folderLEFTPR",
     ];
     let expected_telem = StructureCounts {
         merged_deletions: 4,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn livemarks() {
     before_each();
 
     let local_tree = nodes!({
         ("menu________", Folder, {
@@ -1789,50 +1768,45 @@ fn livemarks() {
         }),
         ("unfiled_____", Folder, {
             ("livemarkEEEE", Livemark)
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true]),
-        ("toolbar_____", Folder[needs_merge = true]),
-        ("unfiled_____", Folder[needs_merge = true])
-    })
-    .into_tree()
-    .unwrap();
+    let expected_tree = merged_nodes!(ROOT_GUID, LocalWithNewLocalStructure, {
+        ("menu________", LocalWithNewLocalStructure),
+        ("toolbar_____", LocalWithNewLocalStructure),
+        ("unfiled_____", RemoteWithNewRemoteStructure)
+    });
     let expected_deletions = vec![
         "livemarkAAAA",
         "livemarkBBBB",
         "livemarkCCCC",
         "livemarkDDDD",
         "livemarkEEEE",
     ];
     let expected_telem = StructureCounts {
         merged_nodes: 3,
         // A, B, and C are counted twice, since they exist on both sides.
         merged_deletions: 8,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn non_syncable_items() {
     before_each();
 
     let local_tree = nodes!({
         ("menu________", Folder[needs_merge = true], {
@@ -1889,32 +1863,28 @@ fn non_syncable_items() {
             ("folderLEFTPF", Folder[needs_merge = true], {
                 ("folderLEFTPC", Query[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!(ROOT_GUID, Folder[needs_merge = true], {
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkBBBB", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!(ROOT_GUID, LocalWithNewLocalStructure, {
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkBBBB", LocalWithNewLocalStructure)
         }),
-        ("unfiled_____", Folder[needs_merge = true], {
-            ("bookmarkJJJJ", Bookmark[needs_merge = true]),
-            ("bookmarkGGGG", Bookmark)
+        ("unfiled_____", LocalWithNewLocalStructure, {
+            ("bookmarkJJJJ", RemoteWithNewRemoteStructure),
+            ("bookmarkGGGG", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec![
         "bookmarkEEEE", // Non-syncable locally.
         "bookmarkFFFF", // Non-syncable locally.
         "bookmarkIIII", // Non-syncable remotely.
         "folderAAAAAA", // Non-syncable remotely.
         "folderDDDDDD", // Non-syncable locally.
         "folderLEFTPC", // Non-syncable remotely.
         "folderLEFTPF", // Non-syncable remotely.
@@ -1924,24 +1894,23 @@ fn non_syncable_items() {
         "rootHHHHHHHH", // Non-syncable remotely.
     ];
     let expected_telem = StructureCounts {
         merged_nodes: 5,
         merged_deletions: 16,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn applying_two_empty_folders_doesnt_smush() {
     before_each();
 
     let local_tree = Tree::with_root(Item::new(ROOT_GUID, Kind::Folder))
         .into_tree()
@@ -1951,217 +1920,179 @@ fn applying_two_empty_folders_doesnt_smu
         ("mobile______", Folder[needs_merge = true], {
             ("emptyempty01", Folder[needs_merge = true]),
             ("emptyempty02", Folder[needs_merge = true])
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("mobile______", Folder, {
-            ("emptyempty01", Folder),
-            ("emptyempty02", Folder)
+    let expected_tree = merged_nodes!(ROOT_GUID, UnchangedWithNewLocalStructure, {
+        ("mobile______", Remote, {
+            ("emptyempty01", Remote),
+            ("emptyempty02", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 3,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn applying_two_empty_folders_matches_only_one() {
     before_each();
 
-    let local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("mobile______", Folder[needs_merge = true], {
             ("emptyempty02", Folder[needs_merge = true]),
             ("emptyemptyL0", Folder[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_local_contents: HashMap<Guid, Content> = HashMap::new();
-    new_local_contents.insert(
-        "emptyempty02".into(),
-        Content::Folder {
+    local_tree_builder
+        .mutate(&"emptyempty02".into())
+        .content(Content::Folder {
             title: "Empty".into(),
-        },
-    );
-    new_local_contents.insert(
-        "emptyemptyL0".into(),
-        Content::Folder {
+        });
+    local_tree_builder
+        .mutate(&"emptyemptyL0".into())
+        .content(Content::Folder {
             title: "Empty".into(),
-        },
-    );
+        });
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("mobile______", Folder[needs_merge = true], {
             ("emptyempty01", Folder[needs_merge = true]),
             ("emptyempty02", Folder[needs_merge = true]),
             ("emptyempty03", Folder[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_remote_contents: HashMap<Guid, Content> = HashMap::new();
-    new_remote_contents.insert(
-        "emptyempty01".into(),
-        Content::Folder {
+    remote_tree_builder
+        .mutate(&"emptyempty01".into())
+        .content(Content::Folder {
             title: "Empty".into(),
-        },
-    );
-    new_remote_contents.insert(
-        "emptyempty02".into(),
-        Content::Folder {
-            title: "Empty".into(),
-        },
-    );
-    new_remote_contents.insert(
-        "emptyempty03".into(),
-        Content::Folder {
+        });
+    remote_tree_builder
+        .mutate(&"emptyempty02".into())
+        .content(Content::Folder {
             title: "Empty".into(),
-        },
-    );
+        });
+    remote_tree_builder
+        .mutate(&"emptyempty03".into())
+        .content(Content::Folder {
+            title: "Empty".into(),
+        });
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::with_contents(
-        &local_tree,
-        &new_local_contents,
-        &remote_tree,
-        &new_remote_contents,
-    );
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("mobile______", Folder[needs_merge = true], {
-            ("emptyempty01", Folder),
-            ("emptyempty02", Folder),
-            ("emptyempty03", Folder)
+    let expected_tree = merged_nodes!({
+        ("mobile______", LocalWithNewLocalStructure, {
+            ("emptyempty01", Remote),
+            ("emptyempty02", Remote),
+            ("emptyempty03", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 0,
         local_revives: 0,
         remote_deletes: 0,
         dupes: 1,
         merged_nodes: 4,
         merged_deletions: 0,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 // Bug 747699: we should follow the hierarchy when merging, instead of
 // deduping by parent title.
 #[test]
 fn deduping_ignores_parent_title() {
     before_each();
 
-    let local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("mobile______", Folder[needs_merge = true], {
             ("bookmarkAAA1", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_local_contents: HashMap<Guid, Content> = HashMap::new();
-    new_local_contents.insert(
-        "mobile______".into(),
-        Content::Folder {
+    local_tree_builder
+        .mutate(&"mobile______".into())
+        .content(Content::Folder {
             title: "Favoritos do celular".into(),
-        },
-    );
-    new_local_contents.insert(
-        "bookmarkAAA1".into(),
-        Content::Bookmark {
+        });
+    local_tree_builder
+        .mutate(&"bookmarkAAA1".into())
+        .content(Content::Bookmark {
             title: "A".into(),
             url_href: "http://example.com/a".into(),
-        },
-    );
+        });
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("mobile______", Folder[needs_merge = true], {
             ("bookmarkAAAA", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    let mut new_remote_contents: HashMap<Guid, Content> = HashMap::new();
-    new_remote_contents.insert(
-        "mobile______".into(),
-        Content::Folder {
+    remote_tree_builder
+        .mutate(&"mobile______".into())
+        .content(Content::Folder {
             title: "Mobile Bookmarks".into(),
-        },
-    );
-    new_remote_contents.insert(
-        "bookmarkAAAA".into(),
-        Content::Bookmark {
+        });
+    remote_tree_builder
+        .mutate(&"bookmarkAAAA".into())
+        .content(Content::Bookmark {
             title: "A".into(),
             url_href: "http://example.com/a".into(),
-        },
-    );
+        });
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::with_contents(
-        &local_tree,
-        &new_local_contents,
-        &remote_tree,
-        &new_remote_contents,
-    );
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("mobile______", Folder[needs_merge = true], {
-            ("bookmarkAAAA", Bookmark)
+    let expected_tree = merged_nodes!({
+        ("mobile______", LocalWithNewLocalStructure, {
+            ("bookmarkAAAA", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 0,
         local_revives: 0,
         remote_deletes: 0,
         dupes: 1,
         merged_nodes: 2,
         merged_deletions: 0,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn mismatched_compatible_bookmark_kinds() {
     before_each();
 
     let local_tree = nodes!({
         ("menu________", Folder[needs_merge = true], {
@@ -2176,40 +2107,35 @@ fn mismatched_compatible_bookmark_kinds(
         ("menu________", Folder[needs_merge = true], {
             ("queryAAAAAAA", Bookmark[needs_merge = true, age = 5]),
             ("bookmarkBBBB", Query[needs_merge = true])
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder[needs_merge = true], {
-            ("queryAAAAAAA", Query[needs_merge = true]),
-            ("bookmarkBBBB", Query)
+    let expected_tree = merged_nodes!({
+        ("menu________", Local, {
+            ("queryAAAAAAA", Local),
+            ("bookmarkBBBB", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 3,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn mismatched_incompatible_bookmark_kinds() {
     before_each();
 
     let local_tree = nodes!({
         ("menu________", Folder[needs_merge = true], {
@@ -2222,17 +2148,17 @@ fn mismatched_incompatible_bookmark_kind
     let remote_tree = nodes!({
         ("menu________", Folder[needs_merge = true], {
             ("bookmarkAAAA", Folder[needs_merge = true, age = 5])
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     match merger.merge() {
         Ok(_) => panic!("Should not merge trees with mismatched kinds"),
         Err(err) => {
             match err.kind() {
                 ErrorKind::MismatchedItemKind { .. } => {}
                 kind => panic!("Got {:?} merging trees with mismatched kinds", kind),
             };
         }
@@ -2267,77 +2193,63 @@ fn invalid_guids() {
         }),
         ("menu________", Folder[needs_merge = true], {
             ("shortGUID", Bookmark[needs_merge = true]),
             ("loooooongGUID", Bookmark[needs_merge = true])
         })
     })
     .into_tree()
     .unwrap();
-    let new_local_contents: HashMap<Guid, Content> = HashMap::new();
 
     let remote_tree = nodes!({
         ("toolbar_____", Folder[needs_merge = true, age = 5], {
             ("!@#$%^", Bookmark[needs_merge = true, age = 5]),
             ("shortGUID", Bookmark[needs_merge = true, age = 5]),
             ("", Bookmark[needs_merge = true, age = 5]),
             ("loooooongGUID", Bookmark[needs_merge = true, age = 5])
         }),
         ("menu________", Folder[needs_merge = true], {
             ("bookmarkAAAA", Bookmark[needs_merge = true]),
             ("bookmarkBBBB", Bookmark[needs_merge = true])
         })
     })
     .into_tree()
     .unwrap();
-    let new_remote_contents: HashMap<Guid, Content> = HashMap::new();
 
     let driver = GenerateNewGuid::default();
-    let mut merger = Merger::with_driver(
-        &driver,
-        &DefaultAbortSignal,
-        &local_tree,
-        &new_local_contents,
-        &remote_tree,
-        &new_remote_contents,
-    );
+    let merger = Merger::with_driver(&driver, &DefaultAbortSignal, &local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("toolbar_____", Folder[needs_merge = true, age = 5], {
-            ("item00000000", Bookmark[needs_merge = true, age = 5]),
-            ("item00000001", Bookmark[needs_merge = true, age = 5]),
-            ("item00000002", Bookmark[needs_merge = true, age = 5])
+    let expected_tree = merged_nodes!({
+        ("toolbar_____", LocalWithNewLocalStructure, {
+            ("item00000000", RemoteWithNewRemoteStructure),
+            ("item00000001", RemoteWithNewRemoteStructure),
+            ("item00000002", LocalWithNewLocalStructure)
         }),
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkAAAA", Bookmark),
-            ("bookmarkBBBB", Bookmark),
-            ("item00000003", Bookmark[needs_merge = true]),
-            ("item00000004", Bookmark[needs_merge = true])
+        ("menu________", LocalWithNewLocalStructure, {
+            ("bookmarkAAAA", Remote),
+            ("bookmarkBBBB", Remote),
+            ("item00000003", LocalWithNewLocalStructure),
+            ("item00000004", LocalWithNewLocalStructure)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec!["", "!@#$%^", "loooooongGUID", "shortGUID"];
     let expected_telem = StructureCounts {
         merged_nodes: 9,
         merged_deletions: 4,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let mut deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     deletions.sort();
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn multiple_parents() {
     before_each();
 
     let local_tree = Tree::with_root(Item::new(ROOT_GUID, Kind::Folder))
         .into_tree()
@@ -2360,49 +2272,44 @@ fn multiple_parents() {
                 ("bookmarkHHHH", Bookmark),
                 ("bookmarkDDDD", Bookmark)
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("toolbar_____", Folder[age = 5, needs_merge = true], {
-            ("bookmarkBBBB", Bookmark)
+    let expected_tree = merged_nodes!(ROOT_GUID, UnchangedWithNewLocalStructure, {
+        ("toolbar_____", RemoteWithNewRemoteStructure, {
+            ("bookmarkBBBB", Remote)
         }),
-        ("menu________", Folder[needs_merge = true], {
-            ("bookmarkGGGG", Bookmark),
-            ("bookmarkAAAA", Bookmark[needs_merge = true]),
-            ("folderCCCCCC", Folder[needs_merge = true], {
-                ("bookmarkDDDD", Bookmark[needs_merge = true]),
-                ("bookmarkEEEE", Bookmark),
-                ("bookmarkFFFF", Bookmark),
-                ("bookmarkHHHH", Bookmark)
+        ("menu________", RemoteWithNewRemoteStructure, {
+            ("bookmarkGGGG", Remote),
+            ("bookmarkAAAA", RemoteWithNewRemoteStructure),
+            ("folderCCCCCC", RemoteWithNewRemoteStructure, {
+                ("bookmarkDDDD", RemoteWithNewRemoteStructure),
+                ("bookmarkEEEE", Remote),
+                ("bookmarkFFFF", Remote),
+                ("bookmarkHHHH", Remote)
             })
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 10,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn reparent_orphans() {
     before_each();
 
     let local_tree = nodes!({
         ("toolbar_____", Folder, {
@@ -2445,105 +2352,97 @@ fn reparent_orphans() {
             age: 0,
             needs_merge: true,
             validity: Validity::Valid,
         })
         .and_then(|p| p.by_parent_guid("nonexistent".into()))
         .expect("Should insert orphan F");
     let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("toolbar_____", Folder[needs_merge = true], {
-            ("bookmarkBBBB", Bookmark),
-            ("bookmarkAAAA", Bookmark),
-            ("bookmarkEEEE", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!({
+        ("toolbar_____", LocalWithNewLocalStructure, {
+            ("bookmarkBBBB", Unchanged),
+            ("bookmarkAAAA", Unchanged),
+            ("bookmarkEEEE", RemoteWithNewRemoteStructure)
         }),
-        ("unfiled_____", Folder[needs_merge = true], {
-            ("bookmarkDDDD", Bookmark),
-            ("bookmarkCCCC", Bookmark),
-            ("bookmarkFFFF", Bookmark[needs_merge = true])
+        ("unfiled_____", LocalWithNewLocalStructure, {
+            ("bookmarkDDDD", Remote),
+            ("bookmarkCCCC", Unchanged),
+            ("bookmarkFFFF", RemoteWithNewRemoteStructure)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 8,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn deleted_user_content_roots() {
     before_each();
 
-    let mut local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("unfiled_____", Folder[needs_merge = true], {
             ("bookmarkAAAA", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    local_tree.note_deleted("mobile______".into());
-    local_tree.note_deleted("toolbar_____".into());
+    local_tree_builder
+        .deletion("mobile______".into())
+        .deletion("toolbar_____".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let mut remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("mobile______", Folder[needs_merge = true], {
             ("bookmarkBBBB", Bookmark[needs_merge = true])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    remote_tree.note_deleted("unfiled_____".into());
-    remote_tree.note_deleted("toolbar_____".into());
-
-    let mut merger = Merger::new(&local_tree, &remote_tree);
-    let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
+    remote_tree_builder
+        .deletion("unfiled_____".into())
+        .deletion("toolbar_____".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let expected_tree = nodes!({
-        ("unfiled_____", Folder[needs_merge = true], {
-            ("bookmarkAAAA", Bookmark[needs_merge = true])
+    let merger = Merger::new(&local_tree, &remote_tree);
+    let merged_root = merger.merge().unwrap();
+
+    let expected_tree = merged_nodes!(ROOT_GUID, LocalWithNewLocalStructure, {
+        ("unfiled_____", Local, {
+            ("bookmarkAAAA", Local)
         }),
-        ("mobile______", Folder, {
-            ("bookmarkBBBB", Bookmark)
+        ("mobile______", Remote, {
+            ("bookmarkBBBB", Remote)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         remote_revives: 0,
         local_deletes: 0,
         local_revives: 0,
         remote_deletes: 0,
         dupes: 0,
         merged_nodes: 4,
         merged_deletions: 1,
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
     // TODO(lina): Remove invalid tombstones from both sides.
-    let deletions = merger.deletions().map(|d| d.guid).collect::<Vec<_>>();
+    let deletions = merged_root.deletions().map(|d| d.guid).collect::<Vec<_>>();
     assert_eq!(deletions, vec![Into::<Guid>::into("toolbar_____")]);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn moved_user_content_roots() {
     before_each();
 
     let local_tree = nodes!({
         ("unfiled_____", Folder[needs_merge = true], {
@@ -2577,54 +2476,49 @@ fn moved_user_content_roots() {
             ("unfiled_____", Folder[needs_merge = true], {
                 ("bookmarkIIII", Bookmark[needs_merge = true])
             })
         })
     })
     .into_tree()
     .unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("unfiled_____", Folder[needs_merge = true], {
-            ("bookmarkIIII", Bookmark),
-            ("bookmarkAAAA", Bookmark[needs_merge = true])
+    let expected_tree = merged_nodes!(ROOT_GUID, LocalWithNewLocalStructure, {
+        ("unfiled_____", LocalWithNewLocalStructure, {
+            ("bookmarkIIII", Remote),
+            ("bookmarkAAAA", Local)
         }),
-        ("mobile______", Folder[needs_merge = true], {
-            ("bookmarkFFFF", Bookmark[needs_merge = true])
+        ("mobile______", Local, {
+            ("bookmarkFFFF", Local)
         }),
-        ("menu________", Folder[needs_merge = true], {
-           ("bookmarkHHHH", Bookmark),
-           ("bookmarkBBBB", Bookmark[needs_merge = true]),
-           ("folderCCCCCC", Folder[needs_merge = true], {
-               ("bookmarkDDDD", Bookmark[needs_merge = true])
+        ("menu________", LocalWithNewLocalStructure, {
+           ("bookmarkHHHH", Remote),
+           ("bookmarkBBBB", Local),
+           ("folderCCCCCC", LocalWithNewLocalStructure, {
+               ("bookmarkDDDD", Local)
            })
         }),
-        ("toolbar_____", Folder[needs_merge = true], {
-            ("bookmarkGGGG", Bookmark),
-            ("bookmarkEEEE", Bookmark)
+        ("toolbar_____", LocalWithNewLocalStructure, {
+            ("bookmarkGGGG", Remote),
+            ("bookmarkEEEE", Unchanged)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_telem = StructureCounts {
         merged_nodes: 13,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    assert_eq!(merger.deletions().count(), 0);
+    assert_eq!(merged_root.deletions().count(), 0);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
 }
 
 #[test]
 fn cycle() {
     before_each();
 
     // Try to create a cycle: move A into B, and B into the menu, but keep
     // B's parent by children as A.
@@ -2651,17 +2545,17 @@ fn cycle() {
         err => panic!("Wrong error kind for cycle: {:?}", err),
     }
 }
 
 #[test]
 fn reupload_replace() {
     before_each();
 
-    let mut local_tree = nodes!({
+    let mut local_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder, {
             ("bookmarkAAAA", Bookmark)
         }),
         ("toolbar_____", Folder, {
             ("folderBBBBBB", Folder, {
                 ("bookmarkCCCC", Bookmark[validity = Validity::Replace])
             }),
             ("folderDDDDDD", Folder, {
@@ -2669,22 +2563,22 @@ fn reupload_replace() {
             })
         }),
         ("unfiled_____", Folder),
         ("mobile______", Folder, {
             ("bookmarkFFFF", Bookmark[validity = Validity::Replace]),
             ("folderGGGGGG", Folder),
             ("bookmarkHHHH", Bookmark[validity = Validity::Replace])
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    local_tree.note_deleted("bookmarkIIII".into());
+    local_tree_builder.deletion("bookmarkIIII".into());
+    let local_tree = local_tree_builder.into_tree().unwrap();
 
-    let mut remote_tree = nodes!({
+    let mut remote_tree_builder = Builder::try_from(nodes!({
         ("menu________", Folder, {
             ("bookmarkAAAA", Bookmark[validity = Validity::Replace])
         }),
         ("toolbar_____", Folder, {
             ("bookmarkJJJJ", Bookmark[validity = Validity::Replace]),
             ("folderBBBBBB", Folder, {
                 ("bookmarkCCCC", Bookmark[validity = Validity::Replace])
             }),
@@ -2694,51 +2588,47 @@ fn reupload_replace() {
             ("bookmarkKKKK", Bookmark[validity = Validity::Reupload])
         }),
         ("mobile______", Folder, {
             ("bookmarkFFFF", Bookmark),
             ("folderGGGGGG", Folder, {
                 ("bookmarkIIII", Bookmark[validity = Validity::Replace])
             })
         })
-    })
-    .into_tree()
+    }))
     .unwrap();
-    remote_tree.note_deleted("bookmarkEEEE".into());
+    remote_tree_builder.deletion("bookmarkEEEE".into());
+    let remote_tree = remote_tree_builder.into_tree().unwrap();
 
-    let mut merger = Merger::new(&local_tree, &remote_tree);
+    let merger = Merger::new(&local_tree, &remote_tree);
     let merged_root = merger.merge().unwrap();
-    assert!(merger.subsumes(&local_tree));
-    assert!(merger.subsumes(&remote_tree));
 
-    let expected_tree = nodes!({
-        ("menu________", Folder, {
+    let expected_tree = merged_nodes!({
+        ("menu________", Unchanged, {
             // A is invalid remotely and valid locally, so replace.
-            ("bookmarkAAAA", Bookmark[needs_merge = true])
+            ("bookmarkAAAA", Local)
         }),
         // Toolbar has new children.
-        ("toolbar_____", Folder[needs_merge = true], {
+        ("toolbar_____", LocalWithNewLocalStructure, {
             // B has new children.
-            ("folderBBBBBB", Folder[needs_merge = true]),
-            ("folderDDDDDD", Folder)
+            ("folderBBBBBB", LocalWithNewLocalStructure),
+            ("folderDDDDDD", UnchangedWithNewLocalStructure)
         }),
-        ("unfiled_____", Folder, {
+        ("unfiled_____", UnchangedWithNewLocalStructure, {
             // K was flagged for reupload.
-           ("bookmarkKKKK", Bookmark[needs_merge = true])
+           ("bookmarkKKKK", RemoteWithNewRemoteStructure)
         }),
-        ("mobile______", Folder, {
+        ("mobile______", UnchangedWithNewLocalStructure, {
             // F is invalid locally, so replace with remote. This isn't
             // possible in Firefox Desktop or Rust Places, where the local
             // tree is always valid, but we handle it for symmetry.
-            ("bookmarkFFFF", Bookmark),
-            ("folderGGGGGG", Folder[needs_merge = true])
+            ("bookmarkFFFF", Remote),
+            ("folderGGGGGG", Local)
         })
-    })
-    .into_tree()
-    .unwrap();
+    });
     let expected_deletions = vec![
         // C is invalid on both sides, so we need to upload a tombstone.
         ("bookmarkCCCC", true),
         // E is invalid locally and deleted remotely, so doesn't need a
         // tombstone.
         ("bookmarkEEEE", false),
         // H is invalid locally and doesn't exist remotely, so doesn't need a
         // tombstone.
@@ -2752,27 +2642,128 @@ fn reupload_replace() {
         merged_nodes: 10,
         // C is double-counted: it's deleted on both sides, so
         // `merged_deletions` is 6, even though we only have 5 expected
         // deletions.
         merged_deletions: 6,
         ..StructureCounts::default()
     };
 
-    let merged_tree = merged_root.into_tree().unwrap();
-    assert_eq!(merged_tree, expected_tree);
+    assert_eq!(&expected_tree, merged_root.node());
 
-    let mut deletions = merger
+    let mut deletions = merged_root
         .deletions()
         .map(|d| (d.guid.as_ref(), d.should_upload_tombstone))
         .collect::<Vec<(&str, bool)>>();
     deletions.sort_by(|a, b| a.0.cmp(&b.0));
     assert_eq!(deletions, expected_deletions);
 
-    assert_eq!(merger.counts(), &expected_telem);
+    assert_eq!(merged_root.counts(), &expected_telem);
+}
+
+#[test]
+fn completion_ops() {
+    let local_tree = nodes!({
+        ("menu________", Folder, {
+            ("bookmarkAAAA", Bookmark),
+            ("bookmarkBBBB", Bookmark),
+            ("bookmarkCCCC", Bookmark),
+            ("bookmarkDDDD", Bookmark)
+        }),
+        ("toolbar_____", Folder, {
+            ("bookmarkEEEE", Bookmark)
+        }),
+        ("unfiled_____", Folder),
+        ("mobile______", Folder, {
+            ("bookmarkFFFF", Bookmark[needs_merge = true, age = 10])
+        })
+    })
+    .into_tree()
+    .unwrap();
+
+    let remote_tree = nodes!({
+        ("menu________", Folder[needs_merge = true], {
+            ("bookmarkAAAA", Bookmark),
+            ("bookmarkDDDD", Bookmark),
+            ("bookmarkCCCC", Bookmark),
+            ("bookmarkBBBB", Bookmark),
+            ("bookmarkEEEE", Bookmark[needs_merge = true])
+        }),
+        ("toolbar_____", Folder[needs_merge = true], {
+            ("bookmarkGGGG", Bookmark[needs_merge = true])
+        }),
+        ("unfiled_____", Folder[needs_merge = true], {
+            ("bookmarkHHHH", Bookmark[needs_merge = true])
+        }),
+        ("mobile______", Folder, {
+            ("bookmarkFFFF", Bookmark[needs_merge = true, age = 5])
+        })
+    })
+    .into_tree()
+    .unwrap();
+
+    let merger = Merger::new(&local_tree, &remote_tree);
+    let merged_root = merger.merge().unwrap();
+
+    let expected_tree = merged_nodes!({
+        ("menu________", UnchangedWithNewLocalStructure, {
+            ("bookmarkAAAA", Unchanged),
+            ("bookmarkDDDD", Unchanged),
+            ("bookmarkCCCC", Unchanged),
+            ("bookmarkBBBB", Unchanged),
+            ("bookmarkEEEE", Remote)
+        }),
+        ("toolbar_____", UnchangedWithNewLocalStructure, {
+            ("bookmarkGGGG", Remote)
+        }),
+        ("unfiled_____", UnchangedWithNewLocalStructure, {
+           ("bookmarkHHHH", Remote)
+        }),
+        ("mobile______", Unchanged, {
+            ("bookmarkFFFF", Remote)
+        })
+    });
+
+    assert_eq!(&expected_tree, merged_root.node());
+
+    let ops = merged_root.completion_ops();
+    assert!(ops.change_guids.is_empty());
+    assert_eq!(
+        ops.apply_remote_items
+            .iter()
+            .map(|op| op.to_string())
+            .collect::<Vec<String>>(),
+        &[
+            "Apply remote bookmarkEEEE",
+            "Apply remote bookmarkGGGG",
+            "Apply remote bookmarkHHHH",
+            "Apply remote bookmarkFFFF",
+        ]
+    );
+    assert_eq!(
+        ops.apply_new_local_structure
+            .iter()
+            .map(|op| op.to_string())
+            .collect::<Vec<String>>(),
+        &[
+            "Move bookmarkDDDD into menu________ at 1",
+            "Move bookmarkBBBB into menu________ at 3",
+            "Move bookmarkEEEE into menu________ at 4",
+            "Move bookmarkGGGG into toolbar_____ at 0",
+            "Move bookmarkHHHH into unfiled_____ at 0",
+        ]
+    );
+    assert!(ops.upload.is_empty());
+    assert_eq!(
+        ops.skip_upload
+            .iter()
+            .map(|op| op.to_string())
+            .collect::<Vec<String>>(),
+        &["Don't upload bookmarkFFFF",]
+    );
 }
 
 #[test]
 fn problems() {
     let mut problems = Problems::default();
 
     problems
         .note(&"bookmarkAAAA".into(), Problem::Orphan)
@@ -2810,44 +2801,53 @@ fn problems() {
                 child_guid: "bookmarkNNNN".into(),
             },
         )
         .note(
             &"folderMMMMMM".into(),
             Problem::MissingChild {
                 child_guid: "bookmarkOOOO".into(),
             },
+        )
+        .note(
+            &"bookmarkPPPP".into(),
+            Problem::DivergedParents(vec![
+                DivergedParentGuid::Deleted("folderQQQQQQ".into()).into()
+            ]),
         );
 
     let mut summary = problems.summarize().collect::<Vec<_>>();
     summary.sort_by(|a, b| a.guid().cmp(b.guid()));
     assert_eq!(
         summary
             .into_iter()
             .map(|s| s.to_string())
             .collect::<Vec<String>>(),
         &[
             "bookmarkAAAA is an orphan",
             "bookmarkBBBB is in children of folderCCCCCC and has parent folderDDDDDD",
             "bookmarkEEEE is in children of folderFFFFFF and has non-folder parent bookmarkGGGG",
             "bookmarkHHHH is in children of folderIIIIII, is in children of folderJJJJJJ, and has \
              nonexistent parent folderKKKKKK",
             "bookmarkLLLL has diverged parents",
+            "bookmarkPPPP has deleted parent folderQQQQQQ",
             "folderMMMMMM has nonexistent child bookmarkNNNN",
             "folderMMMMMM has nonexistent child bookmarkOOOO",
             "menu________ is a user content root, but is in children of unfiled_____",
             "toolbar_____ is a user content root",
         ]
     );
 
     assert_eq!(
         problems.counts(),
         ProblemCounts {
             orphans: 1,
             misparented_roots: 2,
             multiple_parents_by_children: 3,
+            deleted_parent_guids: 1,
             missing_parent_guids: 1,
             non_folder_parent_guids: 1,
-            parent_child_disagreements: 6,
+            parent_child_disagreements: 7,
+            deleted_children: 0,
             missing_children: 2,
         }
     );
 }
--- a/third_party/rust/dogear/src/tree.rs
+++ b/third_party/rust/dogear/src/tree.rs
@@ -49,19 +49,21 @@ impl Tree {
     /// Returns a builder for a rooted tree.
     pub fn with_root(root: Item) -> Builder {
         let mut entry_index_by_guid = HashMap::new();
         entry_index_by_guid.insert(root.guid.clone(), 0);
 
         Builder {
             entries: vec![BuilderEntry {
                 item: root,
+                content: None,
                 parent: BuilderEntryParent::Root,
                 children: Vec::new(),
             }],
+            deleted_guids: HashSet::new(),
             entry_index_by_guid,
             reparent_orphans_to: None,
         }
     }
 
     /// Returns the number of nodes in the tree.
     #[inline]
     pub fn size(&self) -> usize {
@@ -83,35 +85,34 @@ impl Tree {
     /// Indicates if the GUID is known to be deleted. If `Tree::node_for_guid`
     /// returns `None` and `Tree::is_deleted` returns `false`, the item doesn't
     /// exist in the tree at all.
     #[inline]
     pub fn is_deleted(&self, guid: &Guid) -> bool {
         self.deleted_guids.contains(guid)
     }
 
-    /// Notes a tombstone for a deleted item.
+    /// Indicates if the GUID is mentioned in the tree, either as a node or
+    /// a deletion.
     #[inline]
-    pub fn note_deleted(&mut self, guid: Guid) {
-        self.deleted_guids.insert(guid);
+    pub fn mentions(&self, guid: &Guid) -> bool {
+        self.entry_index_by_guid.contains_key(guid) || self.deleted_guids.contains(guid)
     }
 
     /// Returns an iterator for all node and tombstone GUIDs.
     pub fn guids(&self) -> impl Iterator<Item = &Guid> {
-        assert_eq!(self.entries.len(), self.entry_index_by_guid.len());
         self.entries
             .iter()
             .map(|entry| &entry.item.guid)
             .chain(self.deleted_guids.iter())
     }
 
     /// Returns the node for a given `guid`, or `None` if a node with the `guid`
     /// doesn't exist in the tree, or was deleted.
     pub fn node_for_guid(&self, guid: &Guid) -> Option<Node<'_>> {
-        assert_eq!(self.entries.len(), self.entry_index_by_guid.len());
         self.entry_index_by_guid
             .get(guid)
             .map(|&index| Node(self, &self.entries[index]))
     }
 
     /// Returns the structure divergences found when building the tree.
     #[inline]
     pub fn problems(&self) -> &Problems {
@@ -140,23 +141,16 @@ impl fmt::Display for Tree {
                 }
                 write!(f, "❗️ {}", summary)?;
             }
         }
         Ok(())
     }
 }
 
-#[cfg(test)]
-impl PartialEq for Tree {
-    fn eq(&self, other: &Tree) -> bool {
-        self.root() == other.root() && self.deletions().eq(other.deletions())
-    }
-}
-
 /// A tree builder builds a bookmark tree structure from a flat list of items
 /// and parent-child associations.
 ///
 /// # Tree structure
 ///
 /// In a well-formed tree:
 ///
 /// - Each item exists in exactly one folder. Two different folder's
@@ -236,92 +230,122 @@ impl PartialEq for Tree {
 ///    reparented to the root instead.
 ///
 /// The result is a well-formed tree structure that can be merged. The merger
 /// detects if the structure diverged, and flags affected items for reupload.
 #[derive(Debug)]
 pub struct Builder {
     entry_index_by_guid: HashMap<Guid, Index>,
     entries: Vec<BuilderEntry>,
+    deleted_guids: HashSet<Guid>,
     reparent_orphans_to: Option<Guid>,
 }
 
 impl Builder {
     /// Sets the default folder for reparented orphans. If not set, doesn't
     /// exist, or not a folder, orphans will be reparented to the root.
     #[inline]
     pub fn reparent_orphans_to(&mut self, guid: &Guid) -> &mut Builder {
         self.reparent_orphans_to = Some(guid.clone());
         self
     }
 
     /// Inserts an `item` into the tree. Returns an error if the item already
     /// exists.
-    pub fn item(&mut self, item: Item) -> Result<ParentBuilder<'_>> {
+    pub fn item(&mut self, item: Item) -> Result<ItemBuilder<'_>> {
         assert_eq!(self.entries.len(), self.entry_index_by_guid.len());
         if self.entry_index_by_guid.contains_key(&item.guid) {
             return Err(ErrorKind::DuplicateItem(item.guid.clone()).into());
         }
+        let entry_index = self.entries.len();
         self.entry_index_by_guid
-            .insert(item.guid.clone(), self.entries.len());
-        let entry_child = BuilderEntryChild::Exists(self.entries.len());
+            .insert(item.guid.clone(), entry_index);
         self.entries.push(BuilderEntry {
             item,
+            content: None,
             parent: BuilderEntryParent::None,
             children: Vec::new(),
         });
-        Ok(ParentBuilder(self, entry_child))
+        Ok(ItemBuilder(self, entry_index))
     }
 
     /// Sets parents for a `child_guid`. Depending on where the parent comes
     /// from, `child_guid` may not need to exist in the tree.
     pub fn parent_for(&mut self, child_guid: &Guid) -> ParentBuilder<'_> {
         assert_eq!(self.entries.len(), self.entry_index_by_guid.len());
         let entry_child = match self.entry_index_by_guid.get(child_guid) {
             Some(&child_index) => BuilderEntryChild::Exists(child_index),
             None => BuilderEntryChild::Missing(child_guid.clone()),
         };
         ParentBuilder(self, entry_child)
     }
 
+    /// Notes a tombstone for a deleted item, marking it as deleted in the
+    /// tree.
+    #[inline]
+    pub fn deletion(&mut self, guid: Guid) -> &mut Builder {
+        self.deleted_guids.insert(guid);
+        self
+    }
+
     /// Equivalent to using our implementation of`TryInto<Tree>::try_into`, but
     /// provided both for convenience when updating from previous versions of
     /// `dogear`, and for cases where a type hint would otherwise be needed to
     /// clarify the target type of the conversion.
     pub fn into_tree(self) -> Result<Tree> {
         self.try_into()
     }
+
+    /// Mutates content and structure for an existing item. This is only
+    /// exposed to tests.
+    #[cfg(test)]
+    pub fn mutate(&mut self, child_guid: &Guid) -> ItemBuilder<'_> {
+        assert_eq!(self.entries.len(), self.entry_index_by_guid.len());
+        match self.entry_index_by_guid.get(child_guid) {
+            Some(&child_index) => ItemBuilder(self, child_index),
+            None => panic!("Can't mutate nonexistent item {}", child_guid),
+        }
+    }
 }
 
 impl TryFrom<Builder> for Tree {
     type Error = Error;
     /// Builds a tree from all stored items and parent-child associations,
     /// resolving inconsistencies like orphans, multiple parents, and
     /// parent-child disagreements.
-    fn try_from(builder: Builder) -> Result<Tree> {
+    fn try_from(mut builder: Builder) -> Result<Tree> {
         let mut problems = Problems::default();
 
+        // The indices in this bit vector point to zombie entries, which exist
+        // in the tree, but are also flagged as deleted. We'll remove these
+        // zombies from the set of deleted GUIDs, and mark them as diverged for
+        // reupload.
+        let mut zombies = SmallBitVec::from_elem(builder.entries.len(), false);
+
         // First, resolve parents for all entries, and build a lookup table for
         // items without a position.
         let mut parents = Vec::with_capacity(builder.entries.len());
         let mut reparented_child_indices_by_parent: HashMap<Index, Vec<Index>> = HashMap::new();
         for (entry_index, entry) in builder.entries.iter().enumerate() {
             let r = ResolveParent::new(&builder, entry, &mut problems);
             let resolved_parent = r.resolve();
-            if let ResolvedParent::ByParentGuid(parent_index) = &resolved_parent {
+            if let ResolvedParent::ByParentGuid(parent_index) = resolved_parent {
                 // Reparented items are special: since they aren't mentioned in
                 // that parent's `children`, we don't know their positions. Note
                 // them for when we resolve children. We also clone the GUID,
                 // since we use it for sorting, but can't access it by
                 // reference once we call `builder.entries.into_iter()` below.
                 let reparented_child_indices = reparented_child_indices_by_parent
-                    .entry(*parent_index)
+                    .entry(parent_index)
                     .or_default();
                 reparented_child_indices.push(entry_index);
             }
+            if builder.deleted_guids.remove(&entry.item.guid) {
+                zombies.set(entry_index, true);
+            }
             parents.push(resolved_parent);
         }
 
         // If any parents form cycles, abort. We haven't seen cyclic trees in
         // the wild, and breaking cycles would add complexity.
         if let Some(index) = detect_cycles(&parents) {
             return Err(ErrorKind::Cycle(builder.entries[index].item.guid.clone()).into());
         }
@@ -346,135 +370,138 @@ impl TryFrom<Builder> for Tree {
                 ResolvedParent::ByChildren(index) | ResolvedParent::ByParentGuid(index) => {
                     // The entry has multiple parents, and we resolved one,
                     // so it's diverged.
                     divergence = Divergence::Diverged;
                     Some(*index)
                 }
             };
 
+            // If the entry is a zombie, mark it as diverged, so that the merger
+            // can remove the tombstone and reupload the item.
+            if zombies[entry_index] {
+                divergence = Divergence::Diverged;
+            }
+
             // Check if the entry's children exist and agree that this entry is
             // their parent.
             let mut child_indices = Vec::with_capacity(entry.children.len());
             for child in entry.children {
                 match child {
-                    BuilderEntryChild::Exists(child_index) => match &parents[child_index] {
-                        ResolvedParent::Root => {
-                            // The Places root can't be a child of another entry.
-                            unreachable!("A child can't be a top-level root");
+                    BuilderEntryChild::Exists(child_index) => {
+                        if zombies[entry_index] {
+                            // If the entry has a zombie child, mark it as
+                            // diverged.
+                            divergence = Divergence::Diverged;
                         }
-                        ResolvedParent::ByStructure(parent_index) => {
-                            // If the child has a valid parent by structure, it
-                            // must be the entry. If it's not, there's a bug
-                            // in `ResolveParent` or `BuilderEntry`.
-                            assert_eq!(*parent_index, entry_index);
-                            child_indices.push(child_index);
-                        }
-                        ResolvedParent::ByChildren(parent_index) => {
-                            // If the child has multiple parents, we may have
-                            // resolved a different one, so check if we decided
-                            // to keep the child in this entry.
-                            divergence = Divergence::Diverged;
-                            if *parent_index == entry_index {
+                        match &parents[child_index] {
+                            ResolvedParent::Root => {
+                                // The Places root can't be a child of another entry.
+                                unreachable!("A child can't be a top-level root");
+                            }
+                            ResolvedParent::ByStructure(parent_index) => {
+                                // If the child has a valid parent by structure, it
+                                // must be the entry. If it's not, there's a bug
+                                // in `ResolveParent` or `BuilderEntry`.
+                                assert_eq!(*parent_index, entry_index);
                                 child_indices.push(child_index);
                             }
+                            ResolvedParent::ByChildren(parent_index) => {
+                                // If the child has multiple parents, we may have
+                                // resolved a different one, so check if we decided
+                                // to keep the child in this entry.
+                                divergence = Divergence::Diverged;
+                                if *parent_index == entry_index {
+                                    child_indices.push(child_index);
+                                }
+                            }
+                            ResolvedParent::ByParentGuid(parent_index) => {
+                                // We should only ever prefer parents
+                                // `by_parent_guid` over parents `by_children` for
+                                // misparented user content roots. Otherwise,
+                                // there's a bug in `ResolveParent`.
+                                assert_eq!(*parent_index, 0);
+                                divergence = Divergence::Diverged;
+                            }
                         }
-                        ResolvedParent::ByParentGuid(parent_index) => {
-                            // We should only ever prefer parents
-                            // `by_parent_guid` over parents `by_children` for
-                            // misparented user content roots. Otherwise,
-                            // there's a bug in `ResolveParent`.
-                            assert_eq!(*parent_index, 0);
-                            divergence = Divergence::Diverged;
-                        }
-                    },
+                    }
                     BuilderEntryChild::Missing(child_guid) => {
-                        // If the entry's `children` mentions a GUID for which
-                        // we don't have an entry, note it as a problem, and
-                        // ignore the child.
+                        // If the entry's `children` mention a deleted or
+                        // nonexistent GUID, note it as a problem, and ignore
+                        // the child.
                         divergence = Divergence::Diverged;
-                        problems.note(
-                            &entry.item.guid,
+                        let problem = if builder.deleted_guids.remove(&child_guid) {
+                            Problem::DeletedChild {
+                                child_guid: child_guid.clone(),
+                            }
+                        } else {
                             Problem::MissingChild {
                                 child_guid: child_guid.clone(),
-                            },
-                        );
+                            }
+                        };
+                        problems.note(&entry.item.guid, problem);
                     }
                 }
             }
 
             // Reparented items don't appear in our `children`, so we move them
             // to the end, after existing children (rules 3-4).
             if let Some(reparented_child_indices) =
                 reparented_child_indices_by_parent.get(&entry_index)
             {
                 divergence = Divergence::Diverged;
                 child_indices.extend_from_slice(reparented_child_indices);
             }
 
             entries.push(TreeEntry {
                 item: entry.item,
+                content: entry.content,
                 parent_index,
                 child_indices,
                 divergence,
             });
         }
 
         // Now we have a consistent tree.
         Ok(Tree {
             entry_index_by_guid: builder.entry_index_by_guid,
             entries,
-            deleted_guids: HashSet::new(),
+            deleted_guids: builder.deleted_guids,
             problems,
         })
     }
 }
 
-/// Describes where an item's parent comes from.
-pub struct ParentBuilder<'b>(&'b mut Builder, BuilderEntryChild);
+/// Adds an item with content and structure to a tree builder.
+pub struct ItemBuilder<'b>(&'b mut Builder, Index);
 
-impl<'b> ParentBuilder<'b> {
-    /// Records a `parent_guid` from the item's parent's `children`. The
-    /// `parent_guid` must refer to an existing folder in the tree, but
-    /// the item itself doesn't need to exist. This handles folders with
-    /// missing children.
-    pub fn by_children(self, parent_guid: &Guid) -> Result<&'b mut Builder> {
-        let parent_index = match self.0.entry_index_by_guid.get(parent_guid) {
-            Some(&parent_index) if self.0.entries[parent_index].item.is_folder() => parent_index,
-            _ => {
-                return Err(ErrorKind::InvalidParent(
-                    self.child_guid().clone(),
-                    parent_guid.clone(),
-                )
-                .into());
-            }
-        };
-        if let BuilderEntryChild::Exists(child_index) = &self.1 {
-            self.0.entries[*child_index].parents_by(&[BuilderParentBy::Children(parent_index)])?;
-        }
-        self.0.entries[parent_index].children.push(self.1);
-        Ok(self.0)
+impl<'b> ItemBuilder<'b> {
+    /// Sets content info for an item that hasn't been uploaded or merged yet.
+    /// We'll try to dedupe local items with content info to remotely changed
+    /// items with similar contents and different GUIDs.
+    #[inline]
+    pub fn content<'c>(&'c mut self, content: Content) -> &'c mut ItemBuilder<'b> {
+        mem::replace(&mut self.0.entries[self.1].content, Some(content));
+        self
     }
 
-    /// Records a `parent_guid` from the item's `parentid`. The item must
-    /// exist in the tree, but the `parent_guid` doesn't need to exist,
-    /// or even refer to a folder. The builder will reparent items with
-    /// missing and non-folder `parentid`s to the default folder when it
-    /// builds the tree.
+    /// Records a `parent_guid` from the item's parent's `children`. See
+    /// `ParentBuilder::by_children`.
+    #[inline]
+    pub fn by_children(self, parent_guid: &Guid) -> Result<&'b mut Builder> {
+        let b = ParentBuilder(self.0, BuilderEntryChild::Exists(self.1));
+        b.by_children(parent_guid)
+    }
+
+    /// Records a `parent_guid` from the item's `parentid`. See
+    /// `ParentBuilder::by_parent_guid`.
+    #[inline]
     pub fn by_parent_guid(self, parent_guid: Guid) -> Result<&'b mut Builder> {
-        match &self.1 {
-            BuilderEntryChild::Exists(child_index) => {
-                self.0.entries[*child_index]
-                    .parents_by(&[BuilderParentBy::UnknownItem(parent_guid)])?;
-            }
-            BuilderEntryChild::Missing(child_guid) => {
-                return Err(ErrorKind::MissingItem(child_guid.clone()).into());
-            }
-        }
-        Ok(self.0)
+        let b = ParentBuilder(self.0, BuilderEntryChild::Exists(self.1));
+        b.by_parent_guid(parent_guid)
     }
 
     /// Records a `parent_guid` from a valid tree structure. This is for
     /// callers who already know their structure is consistent, like
     /// `Store::fetch_local_tree()` on Desktop, and
     /// `std::convert::TryInto<Tree>` in the tests.
     ///
     /// Both the item and `parent_guid` must exist, and the `parent_guid` must
@@ -499,43 +526,78 @@ impl<'b> ParentBuilder<'b> {
     /// ...But more convenient. It's also more efficient, because it avoids
     /// multiple lookups for the item and parent, as well as an extra heap
     /// allocation to store the parents.
     pub fn by_structure(self, parent_guid: &Guid) -> Result<&'b mut Builder> {
         let parent_index = match self.0.entry_index_by_guid.get(parent_guid) {
             Some(&parent_index) if self.0.entries[parent_index].item.is_folder() => parent_index,
             _ => {
                 return Err(ErrorKind::InvalidParent(
-                    self.child_guid().clone(),
+                    self.0.entries[self.1].item.guid.clone(),
                     parent_guid.clone(),
                 )
                 .into());
             }
         };
+        self.0.entries[self.1].parents_by(&[
+            BuilderParentBy::Children(parent_index),
+            BuilderParentBy::KnownItem(parent_index),
+        ])?;
+        self.0.entries[parent_index]
+            .children
+            .push(BuilderEntryChild::Exists(self.1));
+        Ok(self.0)
+    }
+}
+
+/// Adds structure for an existing item to a tree builder.
+pub struct ParentBuilder<'b>(&'b mut Builder, BuilderEntryChild);
+
+impl<'b> ParentBuilder<'b> {
+    /// Records a `parent_guid` from the item's parent's `children`. The
+    /// `parent_guid` must refer to an existing folder in the tree, but
+    /// the item itself doesn't need to exist. This handles folders with
+    /// missing children.
+    pub fn by_children(self, parent_guid: &Guid) -> Result<&'b mut Builder> {
+        let parent_index = match self.0.entry_index_by_guid.get(parent_guid) {
+            Some(&parent_index) if self.0.entries[parent_index].item.is_folder() => parent_index,
+            _ => {
+                let child_guid = match &self.1 {
+                    BuilderEntryChild::Exists(index) => &self.0.entries[*index].item.guid,
+                    BuilderEntryChild::Missing(guid) => guid,
+                };
+                return Err(
+                    ErrorKind::InvalidParent(child_guid.clone(), parent_guid.clone()).into(),
+                );
+            }
+        };
+        if let BuilderEntryChild::Exists(child_index) = &self.1 {
+            self.0.entries[*child_index].parents_by(&[BuilderParentBy::Children(parent_index)])?;
+        }
+        self.0.entries[parent_index].children.push(self.1);
+        Ok(self.0)
+    }
+
+    /// Records a `parent_guid` from the item's `parentid`. The item must
+    /// exist in the tree, but the `parent_guid` doesn't need to exist,
+    /// or even refer to a folder. The builder will reparent items with
+    /// missing and non-folder `parentid`s to the default folder when it
+    /// builds the tree.
+    pub fn by_parent_guid(self, parent_guid: Guid) -> Result<&'b mut Builder> {
         match &self.1 {
             BuilderEntryChild::Exists(child_index) => {
-                self.0.entries[*child_index].parents_by(&[
-                    BuilderParentBy::Children(parent_index),
-                    BuilderParentBy::KnownItem(parent_index),
-                ])?;
+                self.0.entries[*child_index]
+                    .parents_by(&[BuilderParentBy::UnknownItem(parent_guid)])?;
             }
             BuilderEntryChild::Missing(child_guid) => {
                 return Err(ErrorKind::MissingItem(child_guid.clone()).into());
             }
         }
-        self.0.entries[parent_index].children.push(self.1);
         Ok(self.0)
     }
-
-    fn child_guid(&self) -> &Guid {
-        match &self.1 {
-            BuilderEntryChild::Exists(index) => &self.0.entries[*index].item.guid,
-            BuilderEntryChild::Missing(guid) => guid,
-        }
-    }
 }
 
 /// An entry wraps a tree item with references to its parents and children,
 /// which index into the tree's `entries` vector. This indirection exists
 /// because Rust is more strict about ownership of parents and children.
 ///
 /// For example, we can't have entries own their children without sacrificing
 /// fast random lookup: we'd need to store references to the entries in the
@@ -552,26 +614,28 @@ impl<'b> ParentBuilder<'b> {
 /// would take one hash map lookup *per child*.
 ///
 /// Note that we always compare references to entries, instead of deriving
 /// `PartialEq`, because two entries with the same fields but in different
 /// trees should never compare equal.
 #[derive(Debug)]
 struct TreeEntry {
     item: Item,
+    content: Option<Content>,
     divergence: Divergence,
     parent_index: Option<Index>,
     child_indices: Vec<Index>,
 }
 
 /// A builder entry holds an item and its structure. It's the builder's analog
 /// of a `TreeEntry`.
 #[derive(Debug)]
 struct BuilderEntry {
     item: Item,
+    content: Option<Content>,
     parent: BuilderEntryParent,
     children: Vec<BuilderEntryChild>,
 }
 
 impl BuilderEntry {
     /// Adds `new_parents` for the entry.
     fn parents_by(&mut self, new_parents: &[BuilderParentBy]) -> Result<()> {
         let old_parent = mem::replace(&mut self.parent, BuilderEntryParent::None);
@@ -669,17 +733,17 @@ impl<'a> ResolveParent<'a> {
         ResolveParent {
             builder,
             entry,
             problems,
         }
     }
 
     fn resolve(self) -> ResolvedParent {
-        if self.entry.item.guid.is_user_content_root() {
+        if self.entry.item.guid.is_built_in_root() {
             self.user_content_root()
         } else {
             self.item()
         }
     }
 
     /// Returns the parent for this builder entry. This unifies parents
     /// `by_structure`, which are known to be consistent, and parents
@@ -873,17 +937,22 @@ impl<'a> PossibleParent<'a> {
         let entry = match self.parent_by {
             BuilderParentBy::Children(index) => {
                 return DivergedParent::ByChildren(self.builder.entries[*index].item.guid.clone());
             }
             BuilderParentBy::KnownItem(index) => &self.builder.entries[*index],
             BuilderParentBy::UnknownItem(guid) => {
                 match self.builder.entry_index_by_guid.get(guid) {
                     Some(index) => &self.builder.entries[*index],
-                    None => return DivergedParentGuid::Missing(guid.clone()).into(),
+                    None => {
+                        if self.builder.deleted_guids.contains(guid) {
+                            return DivergedParentGuid::Deleted(guid.clone()).into();
+                        }
+                        return DivergedParentGuid::Missing(guid.clone()).into();
+                    }
                 }
             }
         };
         if entry.item.is_folder() {
             DivergedParentGuid::Folder(entry.item.guid.clone()).into()
         } else {
             DivergedParentGuid::NonFolder(entry.item.guid.clone()).into()
         }
@@ -1036,31 +1105,39 @@ pub enum Problem {
     MisparentedRoot(Vec<DivergedParent>),
 
     /// The item has diverging parents. If the vector contains more than one
     /// `DivergedParent::ByChildren`, the item has multiple parents. If the
     /// vector contains a `DivergedParent::ByParentGuid`, with or without a
     /// `DivergedParent::ByChildren`, the item has a parent-child disagreement.
     DivergedParents(Vec<DivergedParent>),
 
-    /// The item is mentioned in a folder's `children`, but doesn't exist or is
-    /// deleted.
+    /// The item is mentioned in a folder's `children`, but doesn't exist.
     MissingChild { child_guid: Guid },
+
+    /// The item is mentioned in a folder's `children`, but is deleted.
+    DeletedChild { child_guid: Guid },
 }
 
 impl Problem {
     /// Returns count deltas for this problem.
     fn counts(&self) -> ProblemCounts {
         let (parents, deltas) = match self {
             Problem::Orphan => {
                 return ProblemCounts {
                     orphans: 1,
                     ..ProblemCounts::default()
                 }
             }
+            Problem::DeletedChild { .. } => {
+                return ProblemCounts {
+                    deleted_children: 1,
+                    ..ProblemCounts::default()
+                }
+            }
             Problem::MissingChild { .. } => {
                 return ProblemCounts {
                     missing_children: 1,
                     ..ProblemCounts::default()
                 }
             }
             // For misparented roots, or items with diverged parents, we need to
             // do a bit more work to determine all the problems. For example, a
@@ -1108,16 +1185,26 @@ impl Problem {
                         deltas
                     } else {
                         ProblemCounts {
                             non_folder_parent_guids: 1,
                             ..deltas
                         }
                     }
                 }
+                DivergedParentGuid::Deleted(_) => {
+                    if deltas.deleted_parent_guids > 0 {
+                        deltas
+                    } else {
+                        ProblemCounts {
+                            deleted_parent_guids: 1,
+                            ..deltas
+                        }
+                    }
+                }
                 DivergedParentGuid::Missing(_) => {
                     if deltas.missing_parent_guids > 0 {
                         deltas
                     } else {
                         ProblemCounts {
                             missing_parent_guids: 1,
                             ..deltas
                         }
@@ -1149,31 +1236,36 @@ impl fmt::Display for DivergedParent {
             DivergedParent::ByChildren(parent_guid) => {
                 write!(f, "is in children of {}", parent_guid)
             }
             DivergedParent::ByParentGuid(p) => match p {
                 DivergedParentGuid::Folder(parent_guid) => write!(f, "has parent {}", parent_guid),
                 DivergedParentGuid::NonFolder(parent_guid) => {
                     write!(f, "has non-folder parent {}", parent_guid)
                 }
+                DivergedParentGuid::Deleted(parent_guid) => {
+                    write!(f, "has deleted parent {}", parent_guid)
+                }
                 DivergedParentGuid::Missing(parent_guid) => {
                     write!(f, "has nonexistent parent {}", parent_guid)
                 }
             },
         }
     }
 }
 
 /// Describes an invalid `parentid`.
 #[derive(Clone, Debug, Eq, Hash, PartialEq)]
 pub enum DivergedParentGuid {
     /// Exists and is a folder.
     Folder(Guid),
     /// Exists, but isn't a folder.
     NonFolder(Guid),
+    /// Is explicitly deleted.
+    Deleted(Guid),
     /// Doesn't exist at all.
     Missing(Guid),
 }
 
 /// Records problems for all items in a tree.
 #[derive(Debug, Default)]
 pub struct Problems(HashMap<Guid, Vec<Problem>>);
 
@@ -1245,16 +1337,19 @@ impl<'a> fmt::Display for ProblemSummary
                     return write!(f, "{} has diverged parents", self.guid());
                 }
                 write!(f, "{} ", self.guid())?;
                 parents
             }
             Problem::MissingChild { child_guid } => {
                 return write!(f, "{} has nonexistent child {}", self.guid(), child_guid);
             }
+            Problem::DeletedChild { child_guid } => {
+                return write!(f, "{} has deleted child {}", self.guid(), child_guid);
+            }
         };
         match parents.as_slice() {
             [a] => write!(f, "{}", a)?,
             [a, b] => write!(f, "{} and {}", a, b)?,
             _ => {
                 for (i, parent) in parents.iter().enumerate() {
                     if i != 0 {
                         f.write_str(", ")?;
@@ -1277,58 +1372,93 @@ pub struct ProblemCounts {
     /// Number of items that aren't mentioned in any parent's `children` and
     /// don't have a `parentid`. These are very rare; it's likely that a
     /// problem child has at least a `parentid`.
     pub orphans: usize,
     /// Number of roots that aren't children of the Places root.
     pub misparented_roots: usize,
     /// Number of items with multiple, conflicting parents `by_children`.
     pub multiple_parents_by_children: usize,
+    /// Number of items whose `parentid` is deleted.
+    pub deleted_parent_guids: usize,
     /// Number of items whose `parentid` doesn't exist.
     pub missing_parent_guids: usize,
     /// Number of items whose `parentid` isn't a folder.
     pub non_folder_parent_guids: usize,
     /// Number of items whose `parentid`s disagree with their parents'
     /// `children`.
     pub parent_child_disagreements: usize,
+    /// Number of deleted items mentioned in all parents' `children`.
+    pub deleted_children: usize,
     /// Number of nonexistent items mentioned in all parents' `children`.
     pub missing_children: usize,
 }
 
 impl ProblemCounts {
     /// Adds two sets of counts together.
     pub fn add(&self, other: ProblemCounts) -> ProblemCounts {
         ProblemCounts {
             orphans: self.orphans + other.orphans,
             misparented_roots: self.misparented_roots + other.misparented_roots,
             multiple_parents_by_children: self.multiple_parents_by_children
                 + other.multiple_parents_by_children,
+            deleted_parent_guids: self.deleted_parent_guids + other.deleted_parent_guids,
             missing_parent_guids: self.missing_parent_guids + other.missing_parent_guids,
             non_folder_parent_guids: self.non_folder_parent_guids + other.non_folder_parent_guids,
             parent_child_disagreements: self.parent_child_disagreements
                 + other.parent_child_disagreements,
+            deleted_children: self.deleted_children + other.deleted_children,
             missing_children: self.missing_children + other.missing_children,
         }
     }
 }
 
 /// A node in a bookmark tree that knows its parent and children, and
 /// dereferences to its item.
 #[derive(Clone, Copy, Debug)]
 pub struct Node<'t>(&'t Tree, &'t TreeEntry);
 
 impl<'t> Node<'t> {
+    /// Returns content info for deduping this item, if available.
+    pub fn content(&self) -> Option<&'t Content> {
+        self.1.content.as_ref()
+    }
+
     /// Returns an iterator for all children of this node.
     pub fn children<'n>(&'n self) -> impl Iterator<Item = Node<'t>> + 'n {
         self.1
             .child_indices
             .iter()
             .map(move |&child_index| Node(self.0, &self.0.entries[child_index]))
     }
 
+    /// Returns the child at the given index, or `None` if the index is out of
+    /// bounds.
+    pub fn child(&self, index: usize) -> Option<Node<'_>> {
+        self.1
+            .child_indices
+            .get(index)
+            .map(|&child_index| Node(self.0, &self.0.entries[child_index]))
+    }
+
+    /// Returns `true` if this and `other` have the same child GUIDs.
+    pub fn has_matching_children<'u>(&self, other: Node<'u>) -> bool {
+        if self.1.child_indices.len() != other.1.child_indices.len() {
+            return false;
+        }
+        for (index, &child_index) in self.1.child_indices.iter().enumerate() {
+            let guid = &self.0.entries[child_index].item.guid;
+            let other_guid = &other.0.entries[other.1.child_indices[index]].item.guid;
+            if guid != other_guid {
+                return false;
+            }
+        }
+        true
+    }
+
     /// Returns the resolved parent of this node, or `None` if this is the
     /// root node.
     pub fn parent(&self) -> Option<Node<'_>> {
         self.1
             .parent_index
             .as_ref()
             .map(|&parent_index| Node(self.0, &self.0.entries[parent_index]))
     }
@@ -1338,33 +1468,35 @@ impl<'t> Node<'t> {
         if self.is_root() {
             return 0;
         }
         self.parent().map_or(-1, |parent| parent.level() + 1)
     }
 
     /// Indicates if this node is for a syncable item.
     ///
-    /// Syncable items descend from the four user content roots. Any
-    /// other roots and their descendants, like the left pane root,
+    /// Syncable items descend from the four user content roots. For historical
+    /// reasons, the Desktop tags root and its descendants are also marked as
+    /// syncable, even though they are not part of the synced tree structure.
+    /// Any other roots and their descendants, like the left pane root,
     /// left pane queries, and custom roots, are non-syncable.
     ///
     /// Newer Desktops should never reupload non-syncable items
     /// (bug 1274496), and should have removed them in Places
     /// migrations (bug 1310295). However, these items may be
     /// reparented locally to unfiled, in which case they're seen as
     /// syncable. If the remote tree has the missing parents
     /// and roots, we'll determine that the items are non-syncable
     /// when merging, remove them locally, and mark them for deletion
     /// remotely.
     pub fn is_syncable(&self) -> bool {
         if self.is_root() {
             return false;
         }
-        if self.is_user_content_root() {
+        if self.is_built_in_root() {
             return true;
         }
         match self.kind {
             // Exclude livemarks (bug 1477671).
             Kind::Livemark => false,
             // Exclude orphaned Places queries (bug 1433182).
             Kind::Query if self.diverged() => false,
             _ => self.parent().map_or(false, |parent| parent.is_syncable()),
@@ -1424,20 +1556,21 @@ impl<'t> Node<'t> {
     }
 
     /// Indicates if this node is the root node.
     #[inline]
     pub fn is_root(&self) -> bool {
         ptr::eq(self.1, &self.0.entries[0])
     }
 
-    /// Indicates if this node is a user content root.
+    /// Indicates if this node is a Places built-in root. Any other roots except
+    /// these are non-syncable.
     #[inline]
-    pub fn is_user_content_root(&self) -> bool {
-        self.1.item.guid.is_user_content_root()
+    pub fn is_built_in_root(&self) -> bool {
+        self.1.item.guid.is_built_in_root()
     }
 }
 
 impl<'t> Deref for Node<'t> {
     type Target = Item;
 
     fn deref(&self) -> &Item {
         &self.1.item
@@ -1445,35 +1578,16 @@ impl<'t> Deref for Node<'t> {
 }
 
 impl<'t> fmt::Display for Node<'t> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         self.1.item.fmt(f)
     }
 }
 
-#[cfg(test)]
-impl<'t> PartialEq for Node<'t> {
-    fn eq(&self, other: &Node<'_>) -> bool {
-        match (self.parent(), other.parent()) {
-            (Some(parent), Some(other_parent)) => {
-                if parent.1.item != other_parent.1.item {
-                    return false;
-                }
-            }
-            (Some(_), None) | (None, Some(_)) => return false,
-            (None, None) => {}
-        }
-        if self.1.item != other.1.item {
-            return false;
-        }
-        self.children().eq(other.children())
-    }
-}
-
 /// An item in a local or remote bookmark tree.
 #[derive(Debug, Eq, PartialEq)]
 pub struct Item {
     pub guid: Guid,
     pub kind: Kind,
     pub age: i64,
     pub needs_merge: bool,
     pub validity: Validity,
@@ -1561,127 +1675,60 @@ pub enum Validity {
 }
 
 impl fmt::Display for Validity {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         fmt::Debug::fmt(self, f)
     }
 }
 
-/// The root of a merged tree, from which all merged nodes descend.
-#[derive(Debug)]
-pub struct MergedRoot<'t> {
-    node: MergedNode<'t>,
-    size_hint: usize,
-}
-
-impl<'t> MergedRoot<'t> {
-    /// Returns a merged root for the given node. `size_hint` indicates the
-    /// size of the tree, excluding the root, and is used to avoid extra
-    /// allocations for the descendants.
-    pub(crate) fn with_size(node: MergedNode<'t>, size_hint: usize) -> MergedRoot<'_> {
-        MergedRoot { node, size_hint }
-    }
-
-    /// Returns the root node.
-    pub fn node(&self) -> &MergedNode<'_> {
-        &self.node
-    }
-
-    /// Returns a flattened `Vec` of the root node's descendants, excluding the
-    /// root node itself.
-    pub fn descendants(&self) -> Vec<MergedDescendant<'_>> {
-        fn accumulate<'t>(
-            results: &mut Vec<MergedDescendant<'t>>,
-            merged_node: &'t MergedNode<'t>,
-            level: usize,
-        ) {
-            results.reserve(merged_node.merged_children.len());
-            for (position, merged_child_node) in merged_node.merged_children.iter().enumerate() {
-                results.push(MergedDescendant {
-                    merged_parent_node: &merged_node,
-                    level: level + 1,
-                    position,
-                    merged_node: merged_child_node,
-                });
-                accumulate(results, merged_child_node, level + 1);
-            }
-        }
-        let mut results = Vec::with_capacity(self.size_hint);
-        accumulate(&mut results, &self.node, 0);
-        results
-    }
-
-    /// Returns an ASCII art representation of the root and its descendants,
-    /// similar to `Node::to_ascii_string`.
-    pub fn to_ascii_string(&self) -> String {
-        self.node.to_ascii_fragment("")
-    }
-
-    /// Lets us avoid needing to specify the target type in tests.
-    #[cfg(test)]
-    pub(crate) fn into_tree(self) -> Result<Tree> {
-        self.try_into()
-    }
-}
-
-#[cfg(test)]
-impl<'t> TryFrom<MergedRoot<'t>> for Tree {
-    type Error = Error;
-    fn try_from(merged_root: MergedRoot<'t>) -> Result<Tree> {
-        fn to_item(merged_node: &MergedNode<'_>) -> Item {
-            let node = merged_node.merge_state.node();
-            let mut item = Item::new(merged_node.guid.clone(), node.kind);
-            item.age = node.age;
-            item.needs_merge = merged_node.merge_state.upload_reason() != UploadReason::None;
-            item
-        }
-
-        let mut b = Tree::with_root(to_item(&merged_root.node));
-        for MergedDescendant {
-            merged_parent_node,
-            merged_node,
-            ..
-        } in merged_root.descendants()
-        {
-            b.item(to_item(merged_node))?
-                .by_structure(&merged_parent_node.guid)?;
-        }
-        b.try_into()
-    }
-}
-
 /// A merged bookmark node that indicates which side to prefer, and holds merged
 /// child nodes.
 #[derive(Debug)]
 pub struct MergedNode<'t> {
     pub guid: Guid,
     pub merge_state: MergeState<'t>,
     pub merged_children: Vec<MergedNode<'t>>,
 }
 
 impl<'t> MergedNode<'t> {
     /// Creates a merged node from the given merge state.
-    pub(crate) fn new(guid: Guid, merge_state: MergeState<'t>) -> MergedNode<'t> {
+    pub fn new(guid: Guid, merge_state: MergeState<'t>) -> MergedNode<'t> {
         MergedNode {
             guid,
             merge_state,
             merged_children: Vec::new(),
         }
     }
 
+    /// Indicates if the merged node exists locally and has a new GUID.
+    /// The merger uses this to flag deduped items and items with invalid
+    /// GUIDs with new local structure.
+    pub fn local_guid_changed(&self) -> bool {
+        self.merge_state
+            .local_node()
+            .map_or(false, |local_node| local_node.guid != self.guid)
+    }
+
     /// Indicates if the merged node exists remotely and has a new GUID. The
     /// merger uses this to flag parents and children of remote nodes with
     /// invalid GUIDs for reupload.
-    pub(crate) fn remote_guid_changed(&self) -> bool {
+    pub fn remote_guid_changed(&self) -> bool {
         self.merge_state
             .remote_node()
             .map_or(false, |remote_node| remote_node.guid != self.guid)
     }
 
+    /// Returns an ASCII art representation of the root and its descendants,
+    /// similar to `Node::to_ascii_string`.
+    #[inline]
+    pub fn to_ascii_string(&self) -> String {
+        self.to_ascii_fragment("")
+    }
+
     fn to_ascii_fragment(&self, prefix: &str) -> String {
         match self.merge_state.node().kind {
             Kind::Folder => {
                 let children_prefix = format!("{}| ", prefix);
                 let children = self
                     .merged_children
                     .iter()
                     .map(|n| n.to_ascii_fragment(&children_prefix))
@@ -1698,245 +1745,334 @@ impl<'t> MergedNode<'t> {
 }
 
 impl<'t> fmt::Display for MergedNode<'t> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         write!(f, "{} {}", self.guid, self.merge_state)
     }
 }
 
-/// A descendant holds a merged node, merged parent node, position in the
-/// merged parent, and level in the merged tree.
-#[derive(Clone, Copy, Debug)]
-pub struct MergedDescendant<'t> {
-    pub merged_parent_node: &'t MergedNode<'t>,
-    pub level: usize,
-    pub position: usize,
-    pub merged_node: &'t MergedNode<'t>,
-}
-
-/// The merge state indicates which node we should prefer, local or remote, when
+/// The merge state indicates which side we should prefer, local or remote, when
 /// resolving conflicts.
 #[derive(Clone, Copy, Debug)]
 pub enum MergeState<'t> {
     /// A local-only merge state means the item only exists locally, and should
     /// be uploaded.
     LocalOnly(Node<'t>),
 
+    /// Local-only with a new local structure means the item should be uploaded,
+    /// _and_ has new children (reparented or repositioned) locally.
+    LocalOnlyWithNewLocalStructure(Node<'t>),
+
     /// A remote-only merge state means the item only exists remotely, and
     /// should be applied.
     RemoteOnly(Node<'t>),
 
+    /// Remote-only with a new remote structure means the item should be
+    /// applied, _and_ has a new child list that should be uploaded.
+    RemoteOnlyWithNewRemoteStructure(Node<'t>),
+
     /// A local merge state means the item exists on both sides, and has newer
     /// local changes that should be uploaded.
     Local {
         local_node: Node<'t>,
         remote_node: Node<'t>,
     },
 
+    /// Local with a new local structure means the item has newer local changes
+    /// that should be uploaded, and new children locally.
+    LocalWithNewLocalStructure {
+        local_node: Node<'t>,
+        remote_node: Node<'t>,
+    },
+
     /// A remote merge state means the item exists on both sides, and has newer
     /// remote changes that should be applied.
     Remote {
         local_node: Node<'t>,
         remote_node: Node<'t>,
     },
 
-    /// A remote-only merge state with new structure means the item only exists
-    /// remotely, and has a new merged structure that should be reuploaded. We
-    /// use new structure states to resolve conflicts caused by moving local
-    /// items out of a remotely deleted folder, moving remote items out of a
-    /// locally deleted folder, or merging divergent items.
-    RemoteOnlyWithNewStructure(Node<'t>),
-
-    /// A remote merge state with new structure means the item exists on both
-    /// sides, has newer remote changes, and new structure that should be
-    /// reuploaded.
-    RemoteWithNewStructure {
+    /// Remote with a new remote structure means the item has newer remote
+    /// changes that should be applied, and a new child list that should be
+    /// uploaded.
+    RemoteWithNewRemoteStructure {
         local_node: Node<'t>,
         remote_node: Node<'t>,
     },
 
-    /// An unchanged merge state means the item didn't change on either side,
-    /// and doesn't need to be uploaded or applied.
+    /// An unchanged merge state means the item and its children are the
+    /// same on both sides, and don't need to be uploaded or applied.
     Unchanged {
         local_node: Node<'t>,
         remote_node: Node<'t>,
     },
-}
 
-/// The reason for uploading or reuploading a merged descendant.
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
-pub enum UploadReason {
-    /// The item doesn't need to be uploaded.
-    None,
-    /// The item was added locally since the last sync.
-    LocallyNew,
-    /// The item has newer local changes.
-    Merged,
-    /// The item didn't change locally, but has new structure. Reuploading
-    /// the same item with new structure on every sync may indicate a sync loop,
-    /// where two or more clients clash trying to fix up the remote tree.
-    NewStructure,
+    /// Unchanged with a new local structure means the item hasn't changed, but
+    /// its children have. The new children should be applied locally, but not
+    /// uploaded.
+    UnchangedWithNewLocalStructure {
+        local_node: Node<'t>,
+        remote_node: Node<'t>,
+    },
 }
 
 impl<'t> MergeState<'t> {
     /// Returns the local node for the item, or `None` if the item only exists
     /// remotely. The inverse of `remote_node()`.
     pub fn local_node(&self) -> Option<&Node<'t>> {
         match self {
             MergeState::LocalOnly(local_node)
+            | MergeState::LocalOnlyWithNewLocalStructure(local_node)
             | MergeState::Local { local_node, .. }
+            | MergeState::LocalWithNewLocalStructure { local_node, .. }
             | MergeState::Remote { local_node, .. }
-            | MergeState::RemoteWithNewStructure { local_node, .. }
-            | MergeState::Unchanged { local_node, .. } => Some(local_node),
-
-            MergeState::RemoteOnly(_) | MergeState::RemoteOnlyWithNewStructure(_) => None,
+            | MergeState::RemoteWithNewRemoteStructure { local_node, .. }
+            | MergeState::Unchanged { local_node, .. }
+            | MergeState::UnchangedWithNewLocalStructure { local_node, .. } => Some(local_node),
+            MergeState::RemoteOnly(_) | MergeState::RemoteOnlyWithNewRemoteStructure(_) => None,
         }
     }
 
     /// Returns the remote node for the item, or `None` if the node only exists
     /// locally. The inverse of `local_node()`.
     pub fn remote_node(&self) -> Option<&Node<'t>> {
         match self {
-            MergeState::RemoteOnly(remote_node)
-            | MergeState::Local { remote_node, .. }
+            MergeState::Local { remote_node, .. }
+            | MergeState::LocalWithNewLocalStructure { remote_node, .. }
+            | MergeState::RemoteOnly(remote_node)
+            | MergeState::RemoteOnlyWithNewRemoteStructure(remote_node)
             | MergeState::Remote { remote_node, .. }
-            | MergeState::RemoteOnlyWithNewStructure(remote_node)
-            | MergeState::RemoteWithNewStructure { remote_node, .. }
-            | MergeState::Unchanged { remote_node, .. } => Some(remote_node),
-
-            MergeState::LocalOnly(_) => None,
+            | MergeState::RemoteWithNewRemoteStructure { remote_node, .. }
+            | MergeState::Unchanged { remote_node, .. }
+            | MergeState::UnchangedWithNewLocalStructure { remote_node, .. } => Some(remote_node),
+            MergeState::LocalOnly(_) | MergeState::LocalOnlyWithNewLocalStructure(_) => None,
         }
     }
 
     /// Returns `true` if the remote item should be inserted into or updated
     /// in the local tree. This is not necessarily the inverse of
     /// `should_upload()`, as remote items with new structure should be both
     /// applied and reuploaded, and unchanged items should be neither.
-    pub fn should_apply(&self) -> bool {
+    pub fn should_apply_item(&self) -> bool {
         match self {
             MergeState::RemoteOnly(_)
+            | MergeState::RemoteOnlyWithNewRemoteStructure(_)
             | MergeState::Remote { .. }
-            | MergeState::RemoteOnlyWithNewStructure(_)
-            | MergeState::RemoteWithNewStructure { .. } => true,
+            | MergeState::RemoteWithNewRemoteStructure { .. } => true,
+            MergeState::LocalOnly(_)
+            | MergeState::LocalOnlyWithNewLocalStructure(_)
+            | MergeState::Local { .. }
+            | MergeState::LocalWithNewLocalStructure { .. }
+            | MergeState::Unchanged { .. }
+            | MergeState::UnchangedWithNewLocalStructure { .. } => false,
+        }
+    }
 
+    /// Returns `true` if the item has a new structure (parent or children)
+    /// that should be updated in the local tree.
+    pub fn should_apply_structure(&self) -> bool {
+        match self {
+            MergeState::LocalOnlyWithNewLocalStructure(_)
+            | MergeState::LocalWithNewLocalStructure { .. }
+            | MergeState::RemoteOnly(_)
+            | MergeState::RemoteOnlyWithNewRemoteStructure(_)
+            | MergeState::Remote { .. }
+            | MergeState::RemoteWithNewRemoteStructure { .. }
+            | MergeState::UnchangedWithNewLocalStructure { .. } => true,
             MergeState::LocalOnly(_) | MergeState::Local { .. } | MergeState::Unchanged { .. } => {
                 false
             }
         }
     }
 
-    /// Returns the reason for (re)uploading this node.
-    pub fn upload_reason(&self) -> UploadReason {
+    /// Returns `true` if the item should be flagged for (re)upload.
+    pub fn should_upload(&self) -> bool {
+        match self {
+            MergeState::LocalOnly(_)
+            | MergeState::LocalOnlyWithNewLocalStructure(_)
+            | MergeState::Local { .. }
+            | MergeState::LocalWithNewLocalStructure { .. }
+            | MergeState::RemoteOnlyWithNewRemoteStructure(_)
+            | MergeState::RemoteWithNewRemoteStructure { .. } => true,
+            MergeState::RemoteOnly(_)
+            | MergeState::Remote { .. }
+            | MergeState::Unchanged { .. }
+            | MergeState::UnchangedWithNewLocalStructure { .. } => false,
+        }
+    }
+
+    /// Returns a new merge state, indicating that the item has a new merged
+    /// structure that should be applied locally.
+    pub fn with_new_local_structure(self) -> MergeState<'t> {
         match self {
-            MergeState::LocalOnly(_) => UploadReason::LocallyNew,
-            MergeState::RemoteOnly(_) => UploadReason::None,
-            MergeState::Local { .. } => UploadReason::Merged,
-            MergeState::Remote { .. } => UploadReason::None,
-            MergeState::RemoteOnlyWithNewStructure(_) => {
-                // We're reuploading an item that only exists remotely, so it
-                // must have new structure. Otherwise, its merge state would
-                // be remote only, without new structure.
-                UploadReason::NewStructure
+            MergeState::LocalOnly(local_node) => {
+                MergeState::LocalOnlyWithNewLocalStructure(local_node)
+            }
+            MergeState::LocalOnlyWithNewLocalStructure(local_node) => {
+                MergeState::LocalOnlyWithNewLocalStructure(local_node)
+            }
+            MergeState::Local {
+                local_node,
+                remote_node,
+            } => MergeState::LocalWithNewLocalStructure {
+                local_node,
+                remote_node,
+            },
+            MergeState::LocalWithNewLocalStructure {
+                local_node,
+                remote_node,
+            } => MergeState::LocalWithNewLocalStructure {
+                local_node,
+                remote_node,
+            },
+            MergeState::RemoteOnly(remote_node) => MergeState::RemoteOnly(remote_node),
+            MergeState::RemoteOnlyWithNewRemoteStructure(local_node) => {
+                MergeState::RemoteOnlyWithNewRemoteStructure(local_node)
             }
-            MergeState::RemoteWithNewStructure { local_node, .. } => {
-                if local_node.needs_merge {
-                    // The item exists on both sides, and changed locally, so
-                    // we're uploading to resolve a merge conflict.
-                    UploadReason::Merged
-                } else {
-                    // The item exists on both sides, and didn't change locally,
-                    // so we must be uploading new structure to fix GUIDs or
-                    // divergences.
-                    UploadReason::NewStructure
+            MergeState::Remote {
+                local_node,
+                remote_node,
+            } => MergeState::Remote {
+                local_node,
+                remote_node,
+            },
+            MergeState::RemoteWithNewRemoteStructure {
+                local_node,
+                remote_node,
+            } => MergeState::RemoteWithNewRemoteStructure {
+                local_node,
+                remote_node,
+            },
+            MergeState::Unchanged {
+                local_node,
+                remote_node,
+            } => {
+                // Once the structure changes, it doesn't matter which side we
+                // pick; we'll need to reupload the item to the server, anyway.
+                MergeState::UnchangedWithNewLocalStructure {
+                    local_node,
+                    remote_node,
                 }
             }
-            MergeState::Unchanged { .. } => UploadReason::None,
+            MergeState::UnchangedWithNewLocalStructure {
+                local_node,
+                remote_node,
+            } => MergeState::UnchangedWithNewLocalStructure {
+                local_node,
+                remote_node,
+            },
         }
     }
 
     /// Returns a new merge state, indicating that the item has a new merged
     /// structure that should be reuploaded to the server.
-    pub(crate) fn with_new_structure(&self) -> MergeState<'t> {
-        match *self {
+    pub fn with_new_remote_structure(self) -> MergeState<'t> {
+        match self {
             MergeState::LocalOnly(local_node) => MergeState::LocalOnly(local_node),
-            MergeState::RemoteOnly(remote_node)
-            | MergeState::RemoteOnlyWithNewStructure(remote_node) => {
-                MergeState::RemoteOnlyWithNewStructure(remote_node)
+            MergeState::LocalOnlyWithNewLocalStructure(local_node) => {
+                MergeState::LocalOnlyWithNewLocalStructure(local_node)
             }
             MergeState::Local {
                 local_node,
                 remote_node,
             } => MergeState::Local {
                 local_node,
                 remote_node,
             },
+            MergeState::LocalWithNewLocalStructure {
+                local_node,
+                remote_node,
+            } => MergeState::LocalWithNewLocalStructure {
+                local_node,
+                remote_node,
+            },
+            MergeState::RemoteOnly(remote_node) => {
+                MergeState::RemoteOnlyWithNewRemoteStructure(remote_node)
+            }
+            MergeState::RemoteOnlyWithNewRemoteStructure(remote_node) => {
+                MergeState::RemoteOnlyWithNewRemoteStructure(remote_node)
+            }
             MergeState::Remote {
                 local_node,
                 remote_node,
-            }
-            | MergeState::RemoteWithNewStructure {
+            } => MergeState::RemoteWithNewRemoteStructure {
                 local_node,
                 remote_node,
-            } => MergeState::RemoteWithNewStructure {
+            },
+            MergeState::RemoteWithNewRemoteStructure {
+                local_node,
+                remote_node,
+            } => MergeState::RemoteWithNewRemoteStructure {
                 local_node,
                 remote_node,
             },
             MergeState::Unchanged {
                 local_node,
                 remote_node,
             } => {
                 // Once the structure changes, it doesn't matter which side we
                 // pick; we'll need to reupload the item to the server, anyway.
                 MergeState::Local {
                     local_node,
                     remote_node,
                 }
             }
+            MergeState::UnchangedWithNewLocalStructure {
+                local_node,
+                remote_node,
+            } => MergeState::LocalWithNewLocalStructure {
+                local_node,
+                remote_node,
+            },
         }
     }
 
     /// Returns the node from the preferred side. Unlike `local_node()` and
     /// `remote_node()`, this doesn't indicate which side, so it's only used
     /// for logging and `try_from()`.
     fn node(&self) -> &Node<'t> {
         match self {
-            MergeState::LocalOnly(local_node) | MergeState::Local { local_node, .. } => local_node,
-
+            MergeState::LocalOnly(local_node)
+            | MergeState::LocalOnlyWithNewLocalStructure(local_node)
+            | MergeState::Local { local_node, .. }
+            | MergeState::LocalWithNewLocalStructure { local_node, .. }
+            | MergeState::Unchanged { local_node, .. }
+            | MergeState::UnchangedWithNewLocalStructure { local_node, .. } => local_node,
             MergeState::RemoteOnly(remote_node)
+            | MergeState::RemoteOnlyWithNewRemoteStructure(remote_node)
             | MergeState::Remote { remote_node, .. }
-            | MergeState::RemoteOnlyWithNewStructure(remote_node)
-            | MergeState::RemoteWithNewStructure { remote_node, .. } => remote_node,
-
-            MergeState::Unchanged { local_node, .. } => local_node,
+            | MergeState::RemoteWithNewRemoteStructure { remote_node, .. } => remote_node,
         }
     }
 }
 
 impl<'t> fmt::Display for MergeState<'t> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.write_str(match self {
             MergeState::LocalOnly(_) | MergeState::Local { .. } => "(Local, Local)",
+            MergeState::LocalOnlyWithNewLocalStructure(_)
+            | MergeState::LocalWithNewLocalStructure { .. } => "(Local, New)",
 
             MergeState::RemoteOnly(_) | MergeState::Remote { .. } => "(Remote, Remote)",
-
-            MergeState::RemoteOnlyWithNewStructure(_)
-            | MergeState::RemoteWithNewStructure { .. } => "(Remote, New)",
+            MergeState::RemoteOnlyWithNewRemoteStructure(_)
+            | MergeState::RemoteWithNewRemoteStructure { .. } => "(Remote, New)",
 
             MergeState::Unchanged { .. } => "(Unchanged, Unchanged)",
+            MergeState::UnchangedWithNewLocalStructure { .. } => "(Unchanged, New)",
         })
     }
 }
 
 /// 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, with different
 /// GUIDs and similar content.
 ///
 /// - Bookmarks must have the same title and URL.
 /// - Queries must have the same title and query URL.
 /// - Folders and livemarks must have the same title.
 /// - Separators must have the same position within their parents.
 #[derive(Debug, Eq, Hash, PartialEq)]
 pub enum Content {
     Bookmark { title: String, url_href: String },
     Folder { title: String },
-    Separator { position: i64 },
+    Separator,
 }
--- a/toolkit/components/places/SyncedBookmarksMirror.jsm
+++ b/toolkit/components/places/SyncedBookmarksMirror.jsm
@@ -124,17 +124,17 @@ XPCOMUtils.defineLazyGetter(
 // 1375896).
 const DB_URL_LENGTH_MAX = 65536;
 const DB_TITLE_LENGTH_MAX = 4096;
 
 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 = 5;
+const MIRROR_SCHEMA_VERSION = 6;
 
 const DEFAULT_MAX_FRECENCIES_TO_RECALCULATE = 400;
 
 // Use a shared jankYielder in these functions
 XPCOMUtils.defineLazyGetter(this, "yieldState", () => Async.yieldState());
 
 /** Adapts a `Log.jsm` logger to a `mozISyncedBookmarksMirrorLogger`. */
 class MirrorLoggerAdapter {
@@ -238,19 +238,17 @@ class ProgressTracker {
   fetchState() {
     return { steps: this.steps };
   }
 }
 
 /** Merge steps for which we record progress. */
 ProgressTracker.STEPS = {
   FETCH_LOCAL_TREE: "fetchLocalTree",
-  FETCH_NEW_LOCAL_CONTENTS: "fetchNewLocalContents",
   FETCH_REMOTE_TREE: "fetchRemoteTree",
-  FETCH_NEW_REMOTE_CONTENTS: "fetchNewRemoteContents",
   MERGE: "merge",
   APPLY: "apply",
   NOTIFY_OBSERVERS: "notifyObservers",
   FETCH_LOCAL_CHANGE_RECORDS: "fetchLocalChangeRecords",
   FINALIZE: "finalize",
 };
 
 /**
@@ -673,23 +671,16 @@ class SyncedBookmarksMirror {
           onFetchLocalTree: (took, count, problems) => {
             this.progress.stepWithItemCount(
               ProgressTracker.STEPS.FETCH_LOCAL_TREE,
               took,
               count
             );
             // We don't record local tree problems in validation telemetry.
           },
-          onFetchNewLocalContents: (took, count) => {
-            this.progress.stepWithItemCount(
-              ProgressTracker.STEPS.FETCH_NEW_LOCAL_CONTENTS,
-              took,
-              count
-            );
-          },
           onFetchRemoteTree: (took, count, problemsBag) => {
             this.progress.stepWithItemCount(
               ProgressTracker.STEPS.FETCH_REMOTE_TREE,
               took,
               count
             );
             // Record validation telemetry for problems in the remote tree.
             let problems = bagToNamedCounts(problemsBag, [
@@ -697,23 +688,16 @@ class SyncedBookmarksMirror {
               "misparentedRoots",
               "multipleParents",
               "nonFolderParents",
               "parentChildDisagreements",
               "missingChildren",
             ]);
             this.recordValidationTelemetry(took, count, problems);
           },
-          onFetchNewRemoteContents: (took, count) => {
-            this.progress.stepWithItemCount(
-              ProgressTracker.STEPS.FETCH_NEW_REMOTE_CONTENTS,
-              took,
-              count
-            );
-          },
           onMerge: (took, countsBag) => {
             let counts = bagToNamedCounts(countsBag, [
               "items",
               "deletes",
               "dupes",
               "remoteRevives",
               "localDeletes",
               "localRevives",
@@ -825,17 +809,20 @@ class SyncedBookmarksMirror {
   /**
    * 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`);
+    let rows = await this.db.execute(`
+      SELECT guid FROM items
+      WHERE needsMerge
+      ORDER BY guid`);
     return rows.map(row => row.getResultByName("guid"));
   }
 
   async storeRemoteBookmark(record, { needsMerge }) {
     let guid = PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
 
     let url = validateURL(record.bmkUri);
     if (url) {
@@ -1489,16 +1476,21 @@ async function attachAndInitMirrorDataba
  */
 async function migrateMirrorSchema(db, currentSchemaVersion) {
   if (currentSchemaVersion < 5) {
     // The mirror was pref'd off by default for schema versions 1-4.
     throw new DatabaseCorruptError(
       `Can't migrate from schema version ${currentSchemaVersion}; too old`
     );
   }
+  if (currentSchemaVersion < 6) {
+    await db.execute(`CREATE INDEX mirror.itemURLs ON items(urlId)`);
+    await db.execute(`CREATE INDEX mirror.itemKeywords ON items(keyword)
+                      WHERE keyword NOT NULL`);
+  }
 }
 
 /**
  * Initializes a new mirror database, creating persistent tables, indexes, and
  * roots.
  *
  * @param {Sqlite.OpenedConnection} db
  *        The mirror database connection.
@@ -1558,16 +1550,21 @@ async function initializeMirrorDatabase(
   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.itemURLs ON items(urlId)`);
+
+  await db.execute(`CREATE INDEX mirror.itemKeywords ON items(keyword)
+                    WHERE keyword NOT NULL`);
+
   await createMirrorRoots(db);
 }
 
 /**
  * Sets up the syncable roots. All items in the mirror we apply will descend
  * from these roots - however, malformed records from the server which create
  * a different root *will* be created in the mirror - just not applied.
  *
@@ -1615,351 +1612,130 @@ async function createMirrorRoots(db) {
       { guid, parentGuid, position }
     );
   }
 }
 
 /**
  * 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 `itemsToMerge` and `structureToMerge`
- * 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) {
-  // Stores the value and structure states of all nodes in the merged tree.
-  await db.execute(`CREATE TEMP TABLE mergeStates(
-    mergedGuid TEXT PRIMARY KEY,
-    localGuid TEXT,
-    remoteGuid TEXT,
-    mergedParentGuid TEXT NOT NULL,
+  await db.execute(`CREATE TEMP TABLE changeGuidOps(
+    localGuid TEXT PRIMARY KEY,
+    mergedGuid TEXT UNIQUE NOT NULL,
+    syncStatus INTEGER,
     level INTEGER NOT NULL,
+    lastModifiedMicroseconds INTEGER NOT NULL
+  ) WITHOUT ROWID`);
+
+  await db.execute(`
+    CREATE TEMP TRIGGER changeGuids
+    AFTER DELETE ON changeGuidOps
+    BEGIN
+      /* Record item changed notifications for the updated GUIDs. */
+      INSERT INTO guidsChanged(itemId, oldGuid, level)
+      SELECT b.id, OLD.localGuid, OLD.level
+      FROM moz_bookmarks b
+      WHERE b.guid = OLD.localGuid;
+
+      UPDATE moz_bookmarks SET
+        guid = OLD.mergedGuid,
+        lastModified = OLD.lastModifiedMicroseconds,
+        syncStatus = IFNULL(OLD.syncStatus, syncStatus)
+      WHERE guid = OLD.localGuid;
+    END`);
+
+  await db.execute(`CREATE TEMP TABLE itemsToApply(
+    mergedGuid TEXT PRIMARY KEY,
+    localId INTEGER UNIQUE,
+    remoteId INTEGER UNIQUE NOT NULL,
+    remoteGuid TEXT UNIQUE NOT NULL,
+    newLevel INTEGER NOT NULL,
+    newType INTEGER NOT NULL,
+    localDateAddedMicroseconds INTEGER,
+    remoteDateAddedMicroseconds INTEGER NOT NULL,
+    lastModifiedMicroseconds INTEGER NOT NULL,
+    oldTitle TEXT,
+    newTitle TEXT,
+    oldPlaceId INTEGER,
+    newPlaceId INTEGER,
+    newKeyword TEXT
+  )`);
+
+  await db.execute(`CREATE INDEX existingItems ON itemsToApply(localId)
+                    WHERE localId NOT NULL`);
+
+  await db.execute(`CREATE INDEX oldPlaceIds ON itemsToApply(oldPlaceId)
+                    WHERE oldPlaceId NOT NULL`);
+
+  await db.execute(`CREATE INDEX newPlaceIds ON itemsToApply(newPlaceId)
+                    WHERE newPlaceId NOT NULL`);
+
+  await db.execute(`CREATE INDEX newKeywords ON itemsToApply(newKeyword)
+                    WHERE newKeyword NOT NULL`);
+
+  await db.execute(`CREATE TEMP TABLE applyNewLocalStructureOps(
+    mergedGuid TEXT PRIMARY KEY,
+    mergedParentGuid TEXT NOT NULL,
     position INTEGER NOT NULL,
-    useRemote BOOLEAN NOT NULL, /* Take the remote state when merging? */
-    shouldUpload BOOLEAN NOT NULL, /* Flag the item for upload? */
-    /* The node should exist on at least one side. */
-    CHECK(localGuid NOT NULL OR remoteGuid NOT NULL)
+    level INTEGER NOT NULL,
+    lastModifiedMicroseconds INTEGER NOT NULL
   ) WITHOUT ROWID`);
 
+  await db.execute(`
+    CREATE TEMP TRIGGER applyNewLocalStructure
+    AFTER DELETE ON applyNewLocalStructureOps
+    BEGIN
+      INSERT INTO itemsMoved(itemId, oldParentId, oldParentGuid, oldPosition,
+                             level)
+      SELECT b.id, p.id, p.guid, b.position, OLD.level
+      FROM moz_bookmarks b
+      JOIN moz_bookmarks p ON p.id = b.parent
+      WHERE b.guid = OLD.mergedGuid;
+
+      UPDATE moz_bookmarks SET
+        parent = (SELECT id FROM moz_bookmarks
+                  WHERE guid = OLD.mergedParentGuid),
+        position = OLD.position,
+        lastModified = OLD.lastModifiedMicroseconds
+      WHERE guid = OLD.mergedGuid;
+    END`);
+
   // Stages all items to delete locally and remotely. Items to delete locally
   // don't need tombstones: since we took the remote deletion, the tombstone
   // already exists on the server. Items to delete remotely, or non-syncable
   // items to delete on both sides, need tombstones.
   await db.execute(`CREATE TEMP TABLE itemsToRemove(
     guid TEXT PRIMARY KEY,
     localLevel INTEGER NOT NULL,
-    shouldUploadTombstone BOOLEAN NOT NULL
+    shouldUploadTombstone BOOLEAN NOT NULL,
+    dateRemovedMicroseconds INTEGER NOT NULL
   ) WITHOUT ROWID`);
 
-  await db.execute(`
-    CREATE TEMP TRIGGER noteItemRemoved
-    AFTER INSERT ON itemsToRemove
-    BEGIN
-      /* Note that we can't record item removed notifications in the
-         "removeLocalItems" trigger, because SQLite can delete rows in any
-         order, and might fire the trigger for a removed parent before its
-         children. */
-      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,
-             NEW.localLevel
-      FROM moz_bookmarks b
-      JOIN moz_bookmarks p ON p.id = b.parent
-      WHERE b.guid = NEW.guid;
-    END`);
-
-  // Removes items that are deleted on one or both sides from Places, and
-  // inserts new tombstones for non-syncable items to delete remotely.
-  await db.execute(`
-    CREATE TEMP TRIGGER removeLocalItems
-    AFTER DELETE ON itemsToRemove
-    BEGIN
-      /* Flag URL frecency for recalculation. */
-      UPDATE moz_places SET
-        frecency = -frecency
-      WHERE id = (SELECT fk FROM moz_bookmarks
-                  WHERE guid = OLD.guid) AND
-            frecency > 0;
-
-      /* Trigger frecency updates for all affected origins. */
-      DELETE FROM moz_updateoriginsupdate_temp;
-
-      /* Remove annos for the deleted items. This can be removed in bug
-         1460577. */
-      DELETE FROM moz_items_annos
-      WHERE item_id = (SELECT id FROM moz_bookmarks
-                       WHERE guid = OLD.guid);
-
-      /* Don't reupload tombstones for items that are already deleted on the
-         server. */
-      DELETE FROM moz_bookmarks_deleted
-      WHERE NOT OLD.shouldUploadTombstone AND
-            guid = OLD.guid;
-
-      /* Upload tombstones for non-syncable items. We can remove the
-         "shouldUploadTombstone" check and persist tombstones unconditionally
-         in bug 1343103. */
-      INSERT OR IGNORE INTO moz_bookmarks_deleted(guid, dateRemoved)
-      SELECT OLD.guid, STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000
-      WHERE OLD.shouldUploadTombstone;
-
-      /* Remove the item from Places. */
-      DELETE FROM moz_bookmarks
-      WHERE guid = OLD.guid;
-
-      /* Flag applied deletions as merged. */
-      UPDATE items SET
-        needsMerge = 0
-      WHERE needsMerge AND
-            guid = OLD.guid AND
-            /* Don't flag tombstones for items that don't exist in the local
-               tree. This can be removed once we persist tombstones in bug
-               1343103. */
-            (NOT isDeleted OR OLD.localLevel > -1);
-    END`);
-
-  // 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 itemsToMerge(localId, localGuid, remoteId, remoteGuid,
-                                  mergedGuid, useRemote, shouldUpload, newLevel,
-                                  newType,
-                                  newDateAddedMicroseconds,
-                                  newTitle, oldPlaceId, newPlaceId,
-                                  newKeyword) AS
-    SELECT b.id, b.guid, v.id, v.guid,
-           r.mergedGuid, r.useRemote, r.shouldUpload, r.level,
-           (CASE WHEN v.kind IN (${[
-             Ci.mozISyncedBookmarksMerger.KIND_BOOKMARK,
-             Ci.mozISyncedBookmarksMerger.KIND_QUERY,
-           ].join(",")}) THEN ${PlacesUtils.bookmarks.TYPE_BOOKMARK}
-                 WHEN v.kind IN (${[
-                   Ci.mozISyncedBookmarksMerger.KIND_FOLDER,
-                   Ci.mozISyncedBookmarksMerger.KIND_LIVEMARK,
-                 ].join(",")}) THEN ${PlacesUtils.bookmarks.TYPE_FOLDER}
-                 ELSE ${PlacesUtils.bookmarks.TYPE_SEPARATOR} END),
-           /* Take the older creation date. "b.dateAdded" is in microseconds;
-              "v.dateAdded" is in milliseconds. */
-           (CASE WHEN b.dateAdded / 1000 < v.dateAdded THEN b.dateAdded
-                 ELSE v.dateAdded * 1000 END),
-           v.title, h.id, (SELECT n.id FROM moz_places n
-                           WHERE n.url_hash = u.hash AND
-                                 n.url = u.url),
-           v.keyword
-    FROM mergeStates r
-    LEFT JOIN items v ON v.guid = r.remoteGuid
-    LEFT JOIN moz_bookmarks b ON b.guid = r.localGuid
-    LEFT JOIN moz_places h ON h.id = b.fk
-    LEFT JOIN urls u ON u.id = 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 `itemsToMerge`.
-  await db.execute(`
-    CREATE TEMP TRIGGER updateGuidsAndSyncFlags
-    INSTEAD OF DELETE ON itemsToMerge
-    BEGIN
-      UPDATE moz_bookmarks SET
-        /* We update GUIDs here, instead of in the "updateExistingLocalItems"
-           trigger, because deduped items where we're keeping the local value
-           state won't have "useRemote" set. */
-        guid = OLD.mergedGuid,
-        syncStatus = CASE WHEN OLD.useRemote
-                     THEN ${PlacesUtils.bookmarks.SYNC_STATUS.NORMAL}
-                     ELSE syncStatus
-                     END,
-        /* Flag updated local items and new structure for upload. */
-        syncChangeCounter = OLD.shouldUpload,
-        lastModified = STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000
-      WHERE id = OLD.localId;
-
-      /* Record item changed notifications for the updated GUIDs. */
-      INSERT INTO guidsChanged(itemId, oldGuid, level)
-      SELECT OLD.localId, OLD.localGuid, OLD.newLevel
-      WHERE OLD.localGuid <> OLD.mergedGuid;
-
-      /* Drop local tombstones for revived remote items. */
-      DELETE FROM moz_bookmarks_deleted
-      WHERE guid IN (OLD.localGuid, OLD.remoteGuid);
-
-      /* Flag the remote item as merged. */
-      UPDATE items SET
-        needsMerge = 0
-      WHERE needsMerge AND
-            guid IN (OLD.remoteGuid, OLD.localGuid);
-    END`);
-
-  await db.execute(`
-    CREATE TEMP TRIGGER updateLocalItems
-    INSTEAD OF DELETE ON itemsToMerge WHEN OLD.useRemote
-    BEGIN
-      /* Record an item added notification for the new item. */
-      INSERT INTO itemsAdded(guid, keywordChanged, level)
-      SELECT OLD.mergedGuid, OLD.newKeyword NOT NULL OR
-                             EXISTS(SELECT 1 FROM moz_keywords
-                                    WHERE place_id = OLD.newPlaceId OR
-                                          keyword = OLD.newKeyword),
-             OLD.newLevel
-      WHERE OLD.localId IS NULL;
-
-      /* Record an item changed notification for the existing item. */
-      INSERT INTO itemsChanged(itemId, oldTitle, oldPlaceId, keywordChanged,
-                               level)
-      SELECT id, title, OLD.oldPlaceId, OLD.newKeyword NOT NULL OR
-               EXISTS(SELECT 1 FROM moz_keywords
-                      WHERE place_id IN (OLD.oldPlaceId, OLD.newPlaceId) OR
-                            keyword = OLD.newKeyword),
-             OLD.newLevel
-      FROM moz_bookmarks
-      WHERE OLD.localId NOT NULL AND
-            id = OLD.localId;
-
-      /* 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 consistency (bug 1328737), we
-         reupload all items with the old URL, new URL, and new keyword. Note
-         that we intentionally use "k.place_id IN (...)" instead of
-         "b.fk = OLD.newPlaceId OR fk IN (...)" in the WHERE clause because we
-         only want to reupload items with keywords. */
-      INSERT OR IGNORE INTO relatedIdsToReupload(id)
-      SELECT b.id FROM moz_bookmarks b
-      JOIN moz_keywords k ON k.place_id = b.fk
-      WHERE (b.id <> OLD.localId OR OLD.localId IS NULL) AND (
-              k.place_id IN (OLD.oldPlaceId, OLD.newPlaceId) OR
-              k.keyword = OLD.newKeyword
-            );
-
-      /* Remove all keywords from the old and new URLs, and remove the new
-         keyword from all existing 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);
-
-      /* 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(id, guid, parent, position, type, fk, title,
-                                dateAdded, lastModified, syncStatus,
-                                syncChangeCounter)
-      VALUES(OLD.localId, OLD.mergedGuid, -1, -1, OLD.newType, OLD.newPlaceId,
-             OLD.newTitle, OLD.newDateAddedMicroseconds,
-             STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
-             ${PlacesUtils.bookmarks.SYNC_STATUS.NORMAL}, OLD.shouldUpload)
-      ON CONFLICT(id) DO UPDATE SET
-        title = excluded.title,
-        dateAdded = excluded.dateAdded,
-        lastModified = excluded.lastModified,
-        /* It's important that we update the URL *after* removing old keywords
-           and *before* inserting new ones, so that the above DELETEs select
-           the correct affected items. */
-        fk = excluded.fk;
-
-      /* Recalculate frecency. */
-      UPDATE moz_places SET
-        frecency = -frecency
-      WHERE OLD.oldPlaceId <> OLD.newPlaceId AND
-            id IN (OLD.oldPlaceId, OLD.newPlaceId) AND
-            frecency > 0;
-
-      /* Trigger frecency updates for all affected origins. */
-      DELETE FROM moz_updateoriginsupdate_temp;
-
-      /* Insert a new keyword for the new URL, if one is set. */
-      INSERT OR IGNORE INTO moz_keywords(keyword, place_id, post_data)
-      SELECT OLD.newKeyword, OLD.newPlaceId, ''
-      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;
-    END`);
-
-  // A view of the structure states 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 structureToMerge(localId, oldParentId, newParentId,
-                                      oldPosition, newPosition, newLevel) AS
-    SELECT b.id, b.parent, p.id, b.position, r.position, r.level
-    FROM moz_bookmarks b
-    JOIN mergeStates r ON r.mergedGuid = b.guid
-    JOIN moz_bookmarks p ON p.guid = r.mergedParentGuid
-    /* Don't reposition roots, since we never upload the Places root, and our
-       merged tree doesn't have a tags root. */
-    WHERE '${PlacesUtils.bookmarks.rootGuid}' NOT IN (r.mergedGuid,
-                                                      r.mergedParentGuid)`);
-
-  // Updates all parents and positions to reflect the merged tree.
-  await db.execute(`
-    CREATE TEMP TRIGGER updateLocalStructure
-    INSTEAD OF DELETE ON structureToMerge
-    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,
-                             level)
-      SELECT OLD.localId, OLD.oldParentId, p.guid, OLD.oldPosition,
-             OLD.newLevel
-      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 can be removed once
   // bug 424160 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
+                               tag, placeId, lastModifiedMicroseconds) AS
+    SELECT b.id, b.guid, p.id, p.guid, b.position, b.type,
+           p.title, b.fk, b.lastModified
     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}'`);
+          p.parent = (SELECT id FROM moz_bookmarks
+                      WHERE 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,
@@ -1993,18 +1769,18 @@ async function initializeTempMirrorEntit
                            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);
+             NEW.lastModifiedMicroseconds,
+             NEW.lastModifiedMicroseconds);
 
       /* 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
@@ -2017,32 +1793,34 @@ async function initializeTempMirrorEntit
                                           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}'),
+                    p.parent = (SELECT id FROM moz_bookmarks
+                                WHERE guid = '${
+                                  PlacesUtils.bookmarks.tagsGuid
+                                }')),
              ${PlacesUtils.bookmarks.TYPE_BOOKMARK}, NEW.placeId,
-             STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000,
-             STRFTIME('%s', 'now', 'localtime', 'utc') * 1000000
+             NEW.lastModifiedMicroseconds,
+             NEW.lastModifiedMicroseconds
       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}';
+            p.parent = (SELECT id FROM moz_bookmarks
+                        WHERE 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,
     keywordChanged BOOLEAN NOT NULL DEFAULT 0,
@@ -2081,40 +1859,16 @@ async function initializeTempMirrorEntit
     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 local IDs for items to upload even if they're not flagged as changed
-  // in Places. These are "weak" because we won't try to reupload the item on
-  // the next sync if the upload is interrupted or fails.
-  await db.execute(`CREATE TEMP TABLE idsToWeaklyUpload(
-    id INTEGER PRIMARY KEY
-  )`);
-
-  // Stores local IDs for items to reupload. Removing an
-  // ID from this table bumps its local change counter, so, unlike weak uploads,
-  // we *will* reupload the item on the next sync if the current sync fails.
-  // This is used to ensure that all bookmarks with the same URL have the same keyword (bug 1328737).
-  await db.execute(`CREATE TEMP TABLE relatedIdsToReupload(
-    id INTEGER PRIMARY KEY
-  )`);
-
-  await db.execute(`
-    CREATE TEMP TRIGGER reuploadIds
-    AFTER DELETE ON relatedIdsToReupload
-    BEGIN
-      UPDATE moz_bookmarks SET
-        syncChangeCounter = syncChangeCounter + 1
-      WHERE id = OLD.id;
-    END`);
-
   // Stores locally changed items staged for upload.
   await db.execute(`CREATE TEMP TABLE itemsToUpload(
     id INTEGER PRIMARY KEY,
     guid TEXT UNIQUE NOT NULL,
     syncChangeCounter INTEGER NOT NULL,
     isDeleted BOOLEAN NOT NULL DEFAULT 0,
     parentGuid TEXT,
     parentTitle TEXT,
@@ -2223,33 +1977,37 @@ function validateTag(rawTag) {
  */
 async function withTiming(name, func, recordTiming) {
   MirrorLog.debug(name);
 
   let startTime = Cu.now();
   let result = await func();
   let elapsedTime = Cu.now() - startTime;
 
-  MirrorLog.trace(`${name} took ${elapsedTime.toFixed(3)}ms`);
+  MirrorLog.debug(`${name} took ${elapsedTime.toFixed(3)}ms`);
   if (typeof recordTiming == "function") {
     recordTiming(elapsedTime, result);
   }
 
   return result;
 }
 
 /**
  * Fires bookmark and keyword observer notifications for all changes made during
  * the merge.
  */
 class BookmarkObserverRecorder {
   constructor(db, { maxFrecenciesToRecalculate }) {
     this.db = db;
     this.maxFrecenciesToRecalculate = maxFrecenciesToRecalculate;
-    this.bookmarkObserverNotifications = [];
+    this.placesEvents = [];
+    this.itemRemovedNotifications = [];
+    this.guidChangedArgs = [];
+    this.itemMovedArgs = [];
+    this.itemChangedArgs = [];
     this.shouldInvalidateKeywords = false;
   }
 
   /**
    * Fires observer notifications for all changed items, invalidates the
    * livemark cache if necessary, and recalculates frecencies for changed
    * URLs. This is called outside the merge transaction.
    */
@@ -2320,17 +2078,17 @@ class BookmarkObserverRecorder {
       },
       yieldState
     );
 
     MirrorLog.trace("Recording observer notifications for new items");
     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
+             p.guid AS parentGuid, n.isTagging, n.keywordChanged
       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
       ORDER BY n.level, p.id, b.position`);
     await Async.yieldingForEach(
       newItemRows,
       row => {
@@ -2342,16 +2100,19 @@ class BookmarkObserverRecorder {
           urlHref: row.getResultByName("url"),
           title: row.getResultByName("title"),
           dateAdded: row.getResultByName("dateAdded"),
           guid: row.getResultByName("guid"),
           parentGuid: row.getResultByName("parentGuid"),
           isTagging: row.getResultByName("isTagging"),
         };
         this.noteItemAdded(info);
+        if (row.getResultByName("keywordChanged")) {
+          this.shouldInvalidateKeywords = true;
+        }
       },
       yieldState
     );
 
     MirrorLog.trace("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,
@@ -2382,17 +2143,18 @@ class BookmarkObserverRecorder {
     );
 
     MirrorLog.trace("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
+             p.id AS parentId, p.guid AS parentGuid,
+             c.keywordChanged
       FROM itemsChanged c
       JOIN moz_bookmarks b ON b.id = c.itemId
       JOIN moz_bookmarks p ON p.id = b.parent
       LEFT JOIN moz_places h ON h.id = b.fk
       LEFT JOIN moz_places i ON i.id = c.oldPlaceId
       ORDER BY c.level, p.id, b.position`);
     await Async.yieldingForEach(
       changedItemRows,
@@ -2405,32 +2167,26 @@ class BookmarkObserverRecorder {
           newTitle: row.getResultByName("newTitle"),
           oldTitle: row.getResultByName("oldTitle"),
           newURLHref: row.getResultByName("newURL"),
           oldURLHref: row.getResultByName("oldURL"),
           parentId: row.getResultByName("parentId"),
           parentGuid: row.getResultByName("parentGuid"),
         };
         this.noteItemChanged(info);
+        if (row.getResultByName("keywordChanged")) {
+          this.shouldInvalidateKeywords = true;
+        }
       },
       yieldState
     );
-
-    MirrorLog.trace("Recording notifications for changed keywords");
-    let keywordsChangedRows = await this.db.execute(`
-      SELECT EXISTS(SELECT 1 FROM itemsAdded WHERE keywordChanged) OR
-             EXISTS(SELECT 1 FROM itemsChanged WHERE keywordChanged)
-             AS keywordsChanged`);
-    this.shouldInvalidateKeywords = !!keywordsChangedRows[0].getResultByName(
-      "keywordsChanged"
-    );
   }
 
   noteItemAdded(info) {
-    this.bookmarkObserverNotifications.push(
+    this.placesEvents.push(
       new PlacesBookmarkAddition({
         id: info.id,
         parentId: info.parentId,
         index: info.position,
         url: info.urlHref || "",
         title: info.title,
         dateAdded: info.dateAdded,
         guid: info.guid,
@@ -2439,100 +2195,83 @@ class BookmarkObserverRecorder {
         itemType: info.type,
         isTagging: info.isTagging,
       })
     );
   }
 
   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,
-      ],
-    });
+    this.guidChangedArgs.push([
+      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,
-        info.urlHref,
-      ],
-    });
+    this.itemMovedArgs.push([
+      info.id,
+      info.oldParentId,
+      info.oldPosition,
+      info.newParentId,
+      info.newPosition,
+      info.type,
+      info.guid,
+      info.oldParentGuid,
+      info.newParentGuid,
+      PlacesUtils.bookmarks.SOURCES.SYNC,
+      info.urlHref,
+    ]);
   }
 
   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,
-        ],
-      });
+      this.itemChangedArgs.push([
+        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,
-        ],
-      });
+      this.itemChangedArgs.push([
+        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",
+    this.itemRemovedNotifications.push({
       isTagging: info.isUntagging,
       args: [
         info.id,
         info.parentId,
         info.position,
         info.type,
         uri,
         info.guid,
@@ -2544,36 +2283,67 @@ class BookmarkObserverRecorder {
 
   async notifyBookmarkObservers() {
     MirrorLog.trace("Notifying bookmark observers");
     let observers = PlacesUtils.bookmarks.getObservers();
     for (let observer of observers) {
       this.notifyObserver(observer, "onBeginUpdateBatch");
     }
     await Async.yieldingForEach(
-      this.bookmarkObserverNotifications,
+      this.itemRemovedNotifications,
       info => {
-        if (info instanceof PlacesEvent) {
-          PlacesObservers.notifyListeners([info]);
-        } else {
-          for (let observer of observers) {
-            if (info.isTagging && observer.skipTags) {
-              return;
-            }
-            this.notifyObserver(observer, info.name, info.args);
-          }
-        }
+        this.notifyObserversWithInfo(observers, "onItemRemoved", info);
+      },
+      yieldState
+    );
+    await Async.yieldingForEach(
+      this.guidChangedArgs,
+      args => {
+        this.notifyObserversWithInfo(observers, "onItemChanged", {
+          isTagging: false,
+          args,
+        });
+      },
+      yieldState
+    );
+    PlacesObservers.notifyListeners(this.placesEvents);
+    await Async.yieldingForEach(
+      this.itemMovedArgs,
+      args => {
+        this.notifyObserversWithInfo(observers, "onItemMoved", {
+          isTagging: false,
+          args,
+        });
+      },
+      yieldState
+    );
+    await Async.yieldingForEach(
+      this.itemChangedArgs,
+      args => {
+        this.notifyObserversWithInfo(observers, "onItemChanged", {
+          isTagging: false,
+          args,
+        });
       },
       yieldState
     );
     for (let observer of observers) {
       this.notifyObserver(observer, "onEndUpdateBatch");
     }
   }
 
+  notifyObserversWithInfo(observers, name, info) {
+    for (let observer of observers) {
+      if (info.isTagging && observer.skipTags) {
+        return;
+      }
+      this.notifyObserver(observer, name, info.args);
+    }
+  }
+
   notifyObserver(observer, notification, args = []) {
     try {
       observer[notification](...args);
     } catch (ex) {
       MirrorLog.warn("Error notifying observer", ex);
     }
   }
 }
--- a/toolkit/components/places/bookmark_sync/Cargo.toml
+++ b/toolkit/components/places/bookmark_sync/Cargo.toml
@@ -1,17 +1,17 @@
 [package]
 name = "bookmark_sync"
 version = "0.1.0"
 authors = ["Lina Cambridge <lina@yakshaving.ninja>"]
 edition = "2018"
 
 [dependencies]
 atomic_refcell = "0.1"
-dogear = "0.2.6"
+dogear = "0.3.0"
 libc = "0.2"
 log = "0.4"
 cstr = "0.1"
 moz_task = { path = "../../../../xpcom/rust/moz_task" }
 nserror = { path = "../../../../xpcom/rust/nserror" }
 nsstring = { path = "../../../../xpcom/rust/nsstring" }
 storage = { path = "../../../../storage/rust" }
 storage_variant = { path = "../../../../storage/variant" }
--- a/toolkit/components/places/bookmark_sync/src/driver.rs
+++ b/toolkit/components/places/bookmark_sync/src/driver.rs
@@ -198,29 +198,23 @@ impl Task for RecordTelemetryEventTask {
         let _ = match &self.event {
             TelemetryEvent::FetchLocalTree(stats) => unsafe {
                 callback.OnFetchLocalTree(
                     as_millis(stats.time),
                     stats.items as i64,
                     problem_counts_to_bag(&stats.problems).bag().coerce(),
                 )
             },
-            TelemetryEvent::FetchNewLocalContents(stats) => unsafe {
-                callback.OnFetchNewLocalContents(as_millis(stats.time), stats.items as i64)
-            },
             TelemetryEvent::FetchRemoteTree(stats) => unsafe {
                 callback.OnFetchRemoteTree(
                     as_millis(stats.time),
                     stats.items as i64,
                     problem_counts_to_bag(&stats.problems).bag().coerce(),
                 )
             },
-            TelemetryEvent::FetchNewRemoteContents(stats) => unsafe {
-                callback.OnFetchNewRemoteContents(as_millis(stats.time), stats.items as i64)
-            },
             TelemetryEvent::Merge(time, counts) => unsafe {
                 callback.OnMerge(
                     as_millis(*time),
                     structure_counts_to_bag(counts).bag().coerce(),
                 )
             },
             TelemetryEvent::Apply(time) => unsafe { callback.OnApply(as_millis(*time)) },
         };
--- a/toolkit/components/places/bookmark_sync/src/error.rs
+++ b/toolkit/components/places/bookmark_sync/src/error.rs
@@ -13,16 +13,17 @@ pub type Result<T> = result::Result<T, E
 
 #[derive(Debug)]
 pub enum Error {
     Dogear(dogear::Error),
     Storage(storage::Error),
     InvalidLocalRoots,
     InvalidRemoteRoots,
     Nsresult(nsresult),
+    UnknownItemType(i64),
     UnknownItemKind(i64),
     MalformedString(Box<dyn error::Error + Send + Sync + 'static>),
     MergeConflict,
     UnknownItemValidity(i64),
     DidNotRun,
 }
 
 impl error::Error for Error {
@@ -66,17 +67,18 @@ impl From<Error> for nsresult {
                 dogear::ErrorKind::Abort => NS_ERROR_ABORT,
                 _ => NS_ERROR_FAILURE,
             },
             Error::InvalidLocalRoots | Error::InvalidRemoteRoots | Error::DidNotRun => {
                 NS_ERROR_UNEXPECTED
             }
             Error::Storage(err) => err.into(),
             Error::Nsresult(result) => result.clone(),
-            Error::UnknownItemKind(_)
+            Error::UnknownItemType(_)
+            | Error::UnknownItemKind(_)
             | Error::MalformedString(_)
             | Error::UnknownItemValidity(_) => NS_ERROR_INVALID_ARG,
             Error::MergeConflict => NS_ERROR_STORAGE_BUSY,
         }
     }
 }
 
 impl fmt::Display for Error {
@@ -84,17 +86,18 @@ impl fmt::Display for Error {
         match self {
             Error::Dogear(err) => err.fmt(f),
             Error::Storage(err) => err.fmt(f),
             Error::InvalidLocalRoots => f.write_str("The Places roots are invalid"),
             Error::InvalidRemoteRoots => {
                 f.write_str("The roots in the mirror database are invalid")
             }
             Error::Nsresult(result) => write!(f, "Operation failed with {}", result.error_name()),
-            Error::UnknownItemKind(kind) => write!(f, "Unknown item kind {} in database", kind),
+            Error::UnknownItemType(typ) => write!(f, "Unknown item type {} in Places", typ),
+            Error::UnknownItemKind(kind) => write!(f, "Unknown item kind {} in mirror", kind),
             Error::MalformedString(err) => err.fmt(f),
             Error::MergeConflict => f.write_str("Local tree changed during merge"),
             Error::UnknownItemValidity(validity) => {
                 write!(f, "Unknown item validity {} in database", validity)
             }
             Error::DidNotRun => write!(f, "Failed to run merge on storage thread"),
         }
     }
--- a/toolkit/components/places/bookmark_sync/src/merger.rs
+++ b/toolkit/components/places/bookmark_sync/src/merger.rs
@@ -207,26 +207,29 @@ impl MergeTask {
             result: AtomicRefCell::new(Err(error::Error::DidNotRun)),
         })
     }
 }
 
 impl Task for MergeTask {
     fn run(&self) {
         let mut db = self.db.clone();
+        let log = Logger::new(self.max_log_level, self.logger.clone());
+        let driver = Driver::new(log, self.progress.clone());
         let mut store = store::Store::new(
             &mut db,
+            &driver,
             &self.controller,
             self.local_time_millis,
             self.remote_time_millis,
             &self.weak_uploads,
         );
-        let log = Logger::new(self.max_log_level, self.logger.clone());
-        let driver = Driver::new(log, self.progress.clone());
-        *self.result.borrow_mut() = store.merge_with_driver(&driver, &*self.controller);
+        *self.result.borrow_mut() = store
+            .prepare()
+            .and_then(|_| store.merge_with_driver(&driver, &*self.controller));
     }
 
     fn done(&self) -> Result<(), nsresult> {
         let callback = self.callback.get().unwrap();
         match mem::replace(&mut *self.result.borrow_mut(), Err(error::Error::DidNotRun)) {
             Ok(()) => unsafe { callback.HandleSuccess() },
             Err(err) => {
                 let message = {
--- a/toolkit/components/places/bookmark_sync/src/store.rs
+++ b/toolkit/components/places/bookmark_sync/src/store.rs
@@ -1,384 +1,387 @@
 /* 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 std::{collections::HashMap, convert::TryFrom, fmt};
+use std::{convert::TryFrom, fmt, time::SystemTime};
 
 use dogear::{
-    AbortSignal, Content, Deletion, Guid, Item, Kind, MergedDescendant, MergedRoot, Tree,
-    UploadReason, Validity,
+    debug, AbortSignal, CompletionOps, Content, Deletion, Guid, Item, Kind, MergedRoot, Tree,
+    Upload, Validity,
 };
-use nsstring::{nsCString, nsString};
+use nsstring::nsString;
 use storage::{Conn, Step};
 use xpcom::interfaces::{mozISyncedBookmarksMerger, nsINavBookmarksService};
 
-use crate::driver::AbortController;
+use crate::driver::{AbortController, Driver};
 use crate::error::{Error, Result};
 
 pub const LMANNO_FEEDURI: &'static str = "livemark/feedURI";
 
-pub const LOCAL_ITEMS_SQL_FRAGMENT: &str = "
-  localItems(id, guid, parentId, parentGuid, position, type, title, parentTitle,
-             placeId, dateAdded, lastModified, syncChangeCounter, level) AS (
-  SELECT b.id, b.guid, 0, NULL, b.position, b.type, b.title, NULL,
-         b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, 0
-  FROM moz_bookmarks b
-  WHERE b.guid = 'root________'
-  UNION ALL
-  SELECT b.id, b.guid, s.id, s.guid, b.position, b.type, b.title, s.title,
-         b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, s.level + 1
-  FROM moz_bookmarks b
-  JOIN localItems s ON s.id = b.parent
-  WHERE b.guid <> 'tags________')";
+const SQLITE_MAX_VARIABLE_NUMBER: usize = 999;
 
 extern "C" {
     fn NS_NavBookmarksTotalSyncChanges() -> i64;
 }
 
 fn total_sync_changes() -> i64 {
     unsafe { NS_NavBookmarksTotalSyncChanges() }
 }
 
 pub struct Store<'s> {
     db: &'s mut Conn,
+    driver: &'s Driver,
     controller: &'s AbortController,
 
     /// The total Sync change count before merging. We store this before
     /// accessing Places, and compare the current and stored counts after
     /// opening our transaction. If they match, we can safely apply the
     /// tree. Otherwise, we bail and try merging again on the next sync.
     total_sync_changes: i64,
 
     local_time_millis: i64,
     remote_time_millis: i64,
     weak_uploads: &'s [nsString],
 }
 
 impl<'s> Store<'s> {
     pub fn new(
         db: &'s mut Conn,
+        driver: &'s Driver,
         controller: &'s AbortController,
         local_time_millis: i64,
         remote_time_millis: i64,
         weak_uploads: &'s [nsString],
     ) -> Store<'s> {
         Store {
             db,
+            driver,
             controller,
             total_sync_changes: total_sync_changes(),
             local_time_millis,
             remote_time_millis,
             weak_uploads,
         }
     }
 
+    /// Prepares the mirror database for a merge.
+    pub fn prepare(&self) -> Result<()> {
+        // 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. When a keyword changes, clients
+        // should reupload all bookmarks with the affected URL (see
+        // `PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL` and
+        // bug 1328737). Just in case, we flag any remote bookmarks that have
+        // different keywords for the same URL, or the same keyword for
+        // different URLs, for reupload.
+        self.db.exec(format!(
+            "UPDATE items SET
+               validity = {}
+             WHERE validity = {} AND (
+               urlId IN (
+                 /* Same URL, different keywords. `COUNT` ignores NULLs, so
+                    we need to count them separately. This handles cases where
+                    a keyword was removed from one, but not all bookmarks with
+                    the same URL. */
+                 SELECT urlId FROM items
+                 GROUP BY urlId
+                 HAVING COUNT(DISTINCT keyword) +
+                        COUNT(DISTINCT CASE WHEN keyword IS NULL
+                                       THEN 1 END) > 1
+               ) OR keyword IN (
+                 /* Different URLs, same keyword. Bookmarks with keywords but
+                    without URLs are already invalid, so we don't need to handle
+                    NULLs here. */
+                 SELECT keyword FROM items
+                 WHERE keyword NOT NULL
+                 GROUP BY keyword
+                 HAVING COUNT(DISTINCT urlId) > 1
+               )
+             )",
+            mozISyncedBookmarksMerger::VALIDITY_REUPLOAD,
+            mozISyncedBookmarksMerger::VALIDITY_VALID,
+        ))?;
+        Ok(())
+    }
+
     /// Creates a local tree item from a row in the `localItems` CTE.
-    fn local_row_to_item(&self, step: &Step) -> Result<Item> {
+    fn local_row_to_item(&self, step: &Step) -> Result<(Item, Option<Content>)> {
         let raw_guid: nsString = step.get_by_name("guid")?;
-        let raw_kind: i64 = step.get_by_name("kind")?;
+        let guid = Guid::from_utf16(&*raw_guid)?;
+
+        let raw_url_href: Option<nsString> = step.get_by_name("url")?;
+        let url_href = match raw_url_href {
+            Some(raw_url_href) => Some(String::from_utf16(&*raw_url_href)?),
+            None => None,
+        };
 
-        let guid = Guid::from_utf16(&*raw_guid)?;
-        let mut item = Item::new(guid, Kind::from_column(raw_kind)?);
+        let typ: i64 = step.get_by_name("type")?;
+        let kind = match typ {
+            nsINavBookmarksService::TYPE_BOOKMARK => match url_href.as_ref() {
+                Some(u) if u.starts_with("place:") => Kind::Query,
+                _ => Kind::Bookmark,
+            },
+            nsINavBookmarksService::TYPE_FOLDER => {
+                let is_livemark: i64 = step.get_by_name("isLivemark")?;
+                if is_livemark == 1 {
+                    Kind::Livemark
+                } else {
+                    Kind::Folder
+                }
+            }
+            nsINavBookmarksService::TYPE_SEPARATOR => Kind::Separator,
+            _ => return Err(Error::UnknownItemType(typ)),
+        };
+
+        let mut item = Item::new(guid, kind);
 
         let local_modified: i64 = step.get_by_name_or_default("localModified");
         item.age = (self.local_time_millis - local_modified).max(0);
 
         let sync_change_counter: i64 = step.get_by_name("syncChangeCounter")?;
         item.needs_merge = sync_change_counter > 0;
 
-        Ok(item)
+        let content = if item.guid == dogear::ROOT_GUID {
+            None
+        } else {
+            let sync_status: i64 = step.get_by_name("syncStatus")?;
+            match sync_status {
+                nsINavBookmarksService::SYNC_STATUS_NORMAL => None,
+                _ => match kind {
+                    Kind::Bookmark | Kind::Query => {
+                        let raw_title: nsString = step.get_by_name("title")?;
+                        let title = String::from_utf16(&*raw_title)?;
+                        url_href.map(|url_href| Content::Bookmark { title, url_href })
+                    }
+                    Kind::Folder | Kind::Livemark => {
+                        let raw_title: nsString = step.get_by_name("title")?;
+                        let title = String::from_utf16(&*raw_title)?;
+                        Some(Content::Folder { title })
+                    }
+                    Kind::Separator => Some(Content::Separator),
+                },
+            }
+        };
+
+        Ok((item, content))
     }
 
     /// Creates a remote tree item from a row in `mirror.items`.
-    fn remote_row_to_item(&self, step: &Step) -> Result<Item> {
+    fn remote_row_to_item(&self, step: &Step) -> Result<(Item, Option<Content>)> {
         let raw_guid: nsString = step.get_by_name("guid")?;
+        let guid = Guid::from_utf16(&*raw_guid)?;
+
         let raw_kind: i64 = step.get_by_name("kind")?;
+        let kind = Kind::from_column(raw_kind)?;
 
-        let guid = Guid::from_utf16(&*raw_guid)?;
-        let mut item = Item::new(guid, Kind::from_column(raw_kind)?);
+        let mut item = Item::new(guid, kind);
 
         let remote_modified: i64 = step.get_by_name("serverModified")?;
         item.age = (self.remote_time_millis - remote_modified).max(0);
 
         let needs_merge: i32 = step.get_by_name("needsMerge")?;
         item.needs_merge = needs_merge == 1;
 
         let raw_validity: i64 = step.get_by_name("validity")?;
         item.validity = Validity::from_column(raw_validity)?;
 
-        Ok(item)
+        let content = if item.guid == dogear::ROOT_GUID || !item.needs_merge {
+            None
+        } else {
+            match kind {
+                Kind::Bookmark | Kind::Query => {
+                    let raw_title: nsString = step.get_by_name("title")?;
+                    let title = String::from_utf16(&*raw_title)?;
+                    let raw_url_href: Option<nsString> = step.get_by_name("url")?;
+                    match raw_url_href {
+                        Some(raw_url_href) => {
+                            let url_href = String::from_utf16(&*raw_url_href)?;
+                            Some(Content::Bookmark { title, url_href })
+                        }
+                        None => None,
+                    }
+                }
+                Kind::Folder | Kind::Livemark => {
+                    let raw_title: nsString = step.get_by_name("title")?;
+                    let title = String::from_utf16(&*raw_title)?;
+                    Some(Content::Folder { title })
+                }
+                Kind::Separator => Some(Content::Separator),
+            }
+        };
+
+        Ok((item, content))
     }
 }
 
 impl<'s> dogear::Store<Error> for Store<'s> {
     /// Builds a fully rooted, consistent tree from the items and tombstones in
     /// Places.
     fn fetch_local_tree(&self) -> Result<Tree> {
         let mut items_statement = self.db.prepare(format!(
             "WITH RECURSIVE
-             {}
-             SELECT s.id, s.guid, s.parentGuid,
-                    /* Map Places item types to Sync record kinds. */
-                    (CASE s.type
-                       WHEN :bookmarkType THEN (
-                         CASE SUBSTR((SELECT h.url FROM moz_places h
-                                      WHERE h.id = s.placeId), 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
+             localItems(id, guid, parentId, parentGuid, position, type, title,
+                        parentTitle, placeId, dateAdded, lastModified,
+                        syncChangeCounter, syncStatus, level)
+             AS (
+               SELECT b.id, b.guid, 0, NULL, b.position, b.type, b.title,
+                      NULL, b.fk, b.dateAdded, b.lastModified,
+                      b.syncChangeCounter, b.syncStatus, 0
+               FROM moz_bookmarks b
+               WHERE b.guid = 'root________'
+               UNION ALL
+               SELECT b.id, b.guid, s.id, s.guid, b.position, b.type, b.title,
+                      s.title, b.fk, b.dateAdded, b.lastModified,
+                      b.syncChangeCounter, b.syncStatus, s.level + 1
+               FROM moz_bookmarks b
+               JOIN localItems s ON s.id = b.parent
+             )
+             SELECT s.guid, s.parentGuid, s.type,  s.syncChangeCounter,
+                    s.syncStatus, s.lastModified / 1000 AS localModified,
+                    IFNULL(s.title, '') AS title, s.position,
+                    (SELECT h.url FROM moz_places h
+                     WHERE h.id = s.placeId) AS url,
+                    EXISTS(SELECT 1 FROM moz_items_annos a
                            JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
                            WHERE a.item_id = s.id AND
-                                 n.name = :feedURLAnno
-                         ) THEN :livemarkKind
-                         ELSE :folderKind END)
-                       ELSE :separatorKind END) AS kind,
-                    s.lastModified / 1000 AS localModified, s.syncChangeCounter
+                                 n.name = '{}') AS isLivemark
              FROM localItems s
              ORDER BY s.level, s.parentId, s.position",
-            LOCAL_ITEMS_SQL_FRAGMENT
+            LMANNO_FEEDURI,
         ))?;
-        items_statement.bind_by_name("bookmarkType", nsINavBookmarksService::TYPE_BOOKMARK)?;
-        items_statement.bind_by_name("queryKind", mozISyncedBookmarksMerger::KIND_QUERY)?;
-        items_statement.bind_by_name("bookmarkKind", mozISyncedBookmarksMerger::KIND_BOOKMARK)?;
-        items_statement.bind_by_name("folderType", nsINavBookmarksService::TYPE_FOLDER)?;
-        items_statement.bind_by_name("feedURLAnno", nsCString::from(LMANNO_FEEDURI))?;
-        items_statement.bind_by_name("livemarkKind", mozISyncedBookmarksMerger::KIND_LIVEMARK)?;
-        items_statement.bind_by_name("folderKind", mozISyncedBookmarksMerger::KIND_FOLDER)?;
-        items_statement.bind_by_name("separatorKind", mozISyncedBookmarksMerger::KIND_SEPARATOR)?;
         let mut builder = match items_statement.step()? {
             // The first row is always the root.
-            Some(step) => Tree::with_root(self.local_row_to_item(&step)?),
-            None => return Err(Error::InvalidLocalRoots.into()),
+            Some(step) => {
+                let (item, _) = self.local_row_to_item(&step)?;
+                Tree::with_root(item)
+            }
+            None => return Err(Error::InvalidLocalRoots),
         };
         while let Some(step) = items_statement.step()? {
             // All subsequent rows are descendants.
             self.controller.err_if_aborted()?;
             let raw_parent_guid: nsString = step.get_by_name("parentGuid")?;
             let parent_guid = Guid::from_utf16(&*raw_parent_guid)?;
-            builder
-                .item(self.local_row_to_item(&step)?)?
-                .by_structure(&parent_guid)?;
+
+            let (item, content) = self.local_row_to_item(&step)?;
+            let mut p = builder.item(item)?;
+
+            if let Some(content) = content {
+                p.content(content);
+            }
+
+            p.by_structure(&parent_guid)?;
         }
 
-        let mut tree = Tree::try_from(builder)?;
-
         let mut deletions_statement = self.db.prepare("SELECT guid FROM moz_bookmarks_deleted")?;
         while let Some(step) = deletions_statement.step()? {
             self.controller.err_if_aborted()?;
             let raw_guid: nsString = step.get_by_name("guid")?;
             let guid = Guid::from_utf16(&*raw_guid)?;
-            tree.note_deleted(guid);
+            builder.deletion(guid);
         }
 
+        let tree = Tree::try_from(builder)?;
         Ok(tree)
     }
 
-    /// Fetches content info for all NEW and UNKNOWN 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.
-    fn fetch_new_local_contents(&self) -> Result<HashMap<Guid, Content>> {
-        let mut contents = HashMap::new();
-
-        let mut statement = self.db.prepare(
-            r#"SELECT b.guid, b.type, IFNULL(b.title, '') AS title, h.url,
-                      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"#,
-        )?;
-        statement.bind_by_name("rootGuid", nsCString::from(&*dogear::ROOT_GUID))?;
-        statement.bind_by_name("syncStatus", nsINavBookmarksService::SYNC_STATUS_NORMAL)?;
-        while let Some(step) = statement.step()? {
-            self.controller.err_if_aborted()?;
-            let typ: i64 = step.get_by_name("type")?;
-            let content = match typ {
-                nsINavBookmarksService::TYPE_BOOKMARK => {
-                    let raw_title: nsString = step.get_by_name("title")?;
-                    let title = String::from_utf16(&*raw_title)?;
-                    let raw_url_href: nsString = step.get_by_name("url")?;
-                    let url_href = String::from_utf16(&*raw_url_href)?;
-                    Content::Bookmark { title, url_href }
-                }
-                nsINavBookmarksService::TYPE_FOLDER => {
-                    let raw_title: nsString = step.get_by_name("title")?;
-                    let title = String::from_utf16(&*raw_title)?;
-                    Content::Folder { title }
-                }
-                nsINavBookmarksService::TYPE_SEPARATOR => {
-                    let position: i64 = step.get_by_name("position")?;
-                    Content::Separator { position }
-                }
-                _ => continue,
-            };
-
-            let raw_guid: nsString = step.get_by_name("guid")?;
-            let guid = Guid::from_utf16(&*raw_guid)?;
-
-            contents.insert(guid, content);
-        }
-
-        Ok(contents)
-    }
-
     /// Builds a fully rooted, consistent tree from the items and tombstones in the
     /// mirror.
     fn fetch_remote_tree(&self) -> Result<Tree> {
-        let mut root_statement = self.db.prepare(
+        let mut root_statement = self.db.prepare(format!(
             "SELECT guid, serverModified, kind, needsMerge, validity
              FROM items
-             WHERE NOT isDeleted AND
-                   guid = :rootGuid",
-        )?;
-        root_statement.bind_by_name("rootGuid", nsCString::from(&*dogear::ROOT_GUID))?;
+             WHERE guid = '{}'",
+            dogear::ROOT_GUID,
+        ))?;
         let mut builder = match root_statement.step()? {
-            Some(step) => Tree::with_root(self.remote_row_to_item(&step)?),
-            None => return Err(Error::InvalidRemoteRoots.into()),
+            Some(step) => {
+                let (item, _) = self.remote_row_to_item(&step)?;
+                Tree::with_root(item)
+            }
+            None => return Err(Error::InvalidRemoteRoots),
         };
         builder.reparent_orphans_to(&dogear::UNFILED_GUID);
 
-        let mut items_statement = self.db.prepare(
-            "SELECT guid, parentGuid, serverModified, kind, needsMerge, validity
-             FROM items
-             WHERE NOT isDeleted AND
-                   guid <> :rootGuid
-             ORDER BY guid",
-        )?;
-        items_statement.bind_by_name("rootGuid", nsCString::from(&*dogear::ROOT_GUID))?;
+        let mut items_statement = self.db.prepare(format!(
+            "SELECT v.guid, v.parentGuid, v.serverModified, v.kind,
+                    IFNULL(v.title, '') AS title, v.needsMerge, v.validity,
+                    v.isDeleted,
+                    (SELECT u.url FROM urls u
+                     WHERE u.id = v.urlId) AS url
+             FROM items v
+             WHERE v.guid <> '{}'
+             ORDER BY v.guid",
+            dogear::ROOT_GUID,
+        ))?;
         while let Some(step) = items_statement.step()? {
             self.controller.err_if_aborted()?;
-            let p = builder.item(self.remote_row_to_item(&step)?)?;
-            let raw_parent_guid: Option<nsString> = step.get_by_name("parentGuid")?;
-            if let Some(raw_parent_guid) = raw_parent_guid {
-                p.by_parent_guid(Guid::from_utf16(&*raw_parent_guid)?)?;
+
+            let is_deleted: i64 = step.get_by_name("isDeleted")?;
+            if is_deleted == 1 {
+                let raw_guid: nsString = step.get_by_name("guid")?;
+                let guid = Guid::from_utf16(&*raw_guid)?;
+                builder.deletion(guid);
+            } else {
+                let (item, content) = self.remote_row_to_item(&step)?;
+                let mut p = builder.item(item)?;
+                if let Some(content) = content {
+                    p.content(content);
+                }
+                let raw_parent_guid: Option<nsString> = step.get_by_name("parentGuid")?;
+                if let Some(raw_parent_guid) = raw_parent_guid {
+                    p.by_parent_guid(Guid::from_utf16(&*raw_parent_guid)?)?;
+                }
             }
         }
 
-        let mut structure_statement = self.db.prepare(
+        let mut structure_statement = self.db.prepare(format!(
             "SELECT guid, parentGuid FROM structure
-             WHERE guid <> :rootGuid
+             WHERE guid <> '{}'
              ORDER BY parentGuid, position",
-        )?;
-        structure_statement.bind_by_name("rootGuid", nsCString::from(&*dogear::ROOT_GUID))?;
+            dogear::ROOT_GUID,
+        ))?;
         while let Some(step) = structure_statement.step()? {
             self.controller.err_if_aborted()?;
             let raw_guid: nsString = step.get_by_name("guid")?;
             let guid = Guid::from_utf16(&*raw_guid)?;
 
             let raw_parent_guid: nsString = step.get_by_name("parentGuid")?;
             let parent_guid = Guid::from_utf16(&*raw_parent_guid)?;
 
             builder.parent_for(&guid).by_children(&parent_guid)?;
         }
 
-        let mut tree = Tree::try_from(builder)?;
-
-        let mut deletions_statement = self.db.prepare(
-            "SELECT guid FROM items
-             WHERE isDeleted AND
-                   needsMerge",
-        )?;
-        while let Some(step) = deletions_statement.step()? {
-            self.controller.err_if_aborted()?;
-            let raw_guid: nsString = step.get_by_name("guid")?;
-            let guid = Guid::from_utf16(&*raw_guid)?;
-            tree.note_deleted(guid);
-        }
-
+        let tree = Tree::try_from(builder)?;
         Ok(tree)
     }
 
-    /// Fetches content info for all items in the mirror that changed since the
-    /// last sync and don't exist locally.
-    fn fetch_new_remote_contents(&self) -> Result<HashMap<Guid, Content>> {
-        let mut contents = HashMap::new();
-
-        let mut statement = self.db.prepare(
-            r#"SELECT v.guid, v.kind, IFNULL(v.title, '') AS title, u.url,
-                      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"#,
-        )?;
-        statement.bind_by_name("unfiledGuid", nsCString::from(&*dogear::UNFILED_GUID))?;
-        statement.bind_by_name("rootGuid", nsCString::from(&*dogear::ROOT_GUID))?;
-        while let Some(step) = statement.step()? {
-            self.controller.err_if_aborted()?;
-            let kind: i64 = step.get_by_name("kind")?;
-            let content = match kind {
-                mozISyncedBookmarksMerger::KIND_BOOKMARK
-                | mozISyncedBookmarksMerger::KIND_QUERY => {
-                    let raw_title: nsString = step.get_by_name("title")?;
-                    let title = String::from_utf16(&*raw_title)?;
-
-                    let raw_url_href: nsString = step.get_by_name("url")?;
-                    let url_href = String::from_utf16(&*raw_url_href)?;
-
-                    Content::Bookmark { title, url_href }
-                }
-                mozISyncedBookmarksMerger::KIND_FOLDER
-                | mozISyncedBookmarksMerger::KIND_LIVEMARK => {
-                    let raw_title: nsString = step.get_by_name("title")?;
-                    let title = String::from_utf16(&*raw_title)?;
-                    Content::Folder { title }
-                }
-                mozISyncedBookmarksMerger::KIND_SEPARATOR => {
-                    let position: i64 = step.get_by_name("position")?;
-                    Content::Separator { position }
-                }
-                _ => continue,
-            };
-
-            let raw_guid: nsString = step.get_by_name("guid")?;
-            let guid = Guid::from_utf16(&*raw_guid)?;
-
-            contents.insert(guid, content);
-        }
-
-        Ok(contents)
-    }
-
-    fn apply<'t>(
-        &mut self,
-        root: MergedRoot<'t>,
-        deletions: impl Iterator<Item = Deletion<'t>>,
-    ) -> Result<()> {
+    fn apply<'t>(&mut self, root: MergedRoot<'t>) -> Result<()> {
         self.controller.err_if_aborted()?;
-        let descendants = root.descendants();
+        let ops = root.completion_ops();
 
         self.controller.err_if_aborted()?;
-        let deletions = deletions.collect::<Vec<_>>();
+        let deletions = root.deletions().collect::<Vec<_>>();
 
         // Apply the merged tree and stage outgoing items. This transaction
         // blocks writes from the main connection until it's committed, so we
         // try to do as little work as possible within it.
         let tx = self.db.transaction()?;
         if self.total_sync_changes != total_sync_changes() {
             return Err(Error::MergeConflict);
         }
 
         self.controller.err_if_aborted()?;
-        update_local_items_in_places(&tx, descendants, deletions)?;
+        debug!(self.driver, "Updating local items in Places");
+        update_local_items_in_places(&tx, &self.driver, &ops, deletions)?;
 
         self.controller.err_if_aborted()?;
-        stage_items_to_upload(&tx, &self.weak_uploads)?;
+        debug!(self.driver, "Staging items to upload");
+        stage_items_to_upload(&tx, &ops.upload, &self.weak_uploads)?;
 
         cleanup(&tx)?;
         tx.commit()?;
 
         Ok(())
     }
 }
 
@@ -391,118 +394,481 @@ impl<'s> dogear::Store<Error> for Store<
 ///
 /// 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.
 fn update_local_items_in_places<'t>(
     db: &Conn,
-    descendants: Vec<MergedDescendant<'t>>,
+    driver: &Driver,
+    ops: &CompletionOps<'t>,
     deletions: Vec<Deletion>,
 ) -> Result<()> {
-    for chunk in descendants.chunks(999 / 4) {
+    // Clean up any observer notifications left over from the last sync.
+    db.exec(
+        "DELETE FROM itemsAdded;
+         DELETE FROM guidsChanged;
+         DELETE FROM itemsChanged;
+         DELETE FROM itemsRemoved;
+         DELETE FROM itemsMoved;",
+    )?;
+
+    let now = rounded_now();
+
+    // Insert URLs for new remote items into the `moz_places` table. We need to
+    // do this before inserting new remote items, since we need Place IDs for
+    // both old and new URLs.
+    debug!(driver, "Inserting Places for new items");
+    for chunk in ops.apply_remote_items.chunks(SQLITE_MAX_VARIABLE_NUMBER) {
+        let mut statement = db.prepare(format!(
+            "INSERT OR IGNORE INTO moz_places(url, url_hash, rev_host, hidden,
+                                              frecency, guid)
+             SELECT u.url, u.hash, u.revHost, 0,
+                    (CASE v.kind WHEN {} THEN 0 ELSE -1 END),
+                    IFNULL((SELECT h.guid FROM moz_places h
+                            WHERE h.url_hash = u.hash AND
+                                  h.url = u.url), u.guid)
+             FROM items v
+             JOIN urls u ON u.id = v.urlId
+             WHERE v.guid IN ({})",
+            mozISyncedBookmarksMerger::KIND_QUERY,
+            repeat_sql_vars(chunk.len()),
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            let remote_guid = nsString::from(&*op.remote_node().guid);
+            statement.bind_by_index(index as u32, remote_guid)?;
+        }
+        statement.execute()?;
+    }
+
+    // Trigger frecency updates for all new origins.
+    debug!(driver, "Updating origins for new URLs");
+    db.exec("DELETE FROM moz_updateoriginsinsert_temp")?;
+
+    // Build a table of new and updated items.
+    debug!(driver, "Staging apply remote item ops");
+    for chunk in ops
+        .apply_remote_items
+        .chunks(SQLITE_MAX_VARIABLE_NUMBER / 3)
+    {
+        // CTEs in `WITH` clauses aren't indexed, so this query needs a full
+        // table scan on `ops`. But that's okay; a separate temp table for ops
+        // would also need a full scan. Note that we need both the local _and_
+        // remote GUIDs here, because we haven't changed the local GUIDs yet.
         let mut statement = db.prepare(format!(
-            "INSERT INTO mergeStates(localGuid, remoteGuid, mergedGuid, mergedParentGuid, level,
-                                     position, useRemote, shouldUpload)
+            "WITH
+             ops(mergedGuid, localGuid, remoteGuid, level) AS (VALUES {})
+             INSERT INTO itemsToApply(mergedGuid, localId, remoteId,
+                                      remoteGuid, newLevel,
+                                      newType,
+                                      localDateAddedMicroseconds,
+                                      remoteDateAddedMicroseconds,
+                                      lastModifiedMicroseconds,
+                                      oldTitle, newTitle, oldPlaceId,
+                                      newPlaceId,
+                                      newKeyword)
+             SELECT n.mergedGuid, b.id, v.id,
+                    v.guid, n.level,
+                    (CASE WHEN v.kind IN ({}, {}) THEN {}
+                          WHEN v.kind IN ({}, {}) THEN {}
+                          ELSE {}
+                     END),
+                    b.dateAdded,
+                    v.dateAdded * 1000,
+                    MAX(v.dateAdded * 1000, {}),
+                    b.title, v.title, b.fk,
+                    (SELECT h.id FROM moz_places h
+                     JOIN urls u ON u.hash = h.url_hash
+                     WHERE u.id = v.urlId AND
+                           u.url = h.url),
+                    v.keyword
+             FROM ops n
+             JOIN items v ON v.guid = n.remoteGuid
+             LEFT JOIN moz_bookmarks b ON b.guid = n.localGuid",
+            repeat_display(chunk.len(), ",", |index, f| {
+                let op = &chunk[index];
+                write!(f, "(?, ?, ?, {})", op.level)
+            }),
+            mozISyncedBookmarksMerger::KIND_BOOKMARK,
+            mozISyncedBookmarksMerger::KIND_QUERY,
+            nsINavBookmarksService::TYPE_BOOKMARK,
+            mozISyncedBookmarksMerger::KIND_FOLDER,
+            mozISyncedBookmarksMerger::KIND_LIVEMARK,
+            nsINavBookmarksService::TYPE_FOLDER,
+            nsINavBookmarksService::TYPE_SEPARATOR,
+            now,
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            let offset = (index * 3) as u32;
+
+            // In most cases, the merged and remote GUIDs are the same for new
+            // items. For updates, all three are typically the same. We could
+            // try to avoid binding duplicates, but that complicates chunking,
+            // and we don't expect many items to change after the first sync.
+            let merged_guid = nsString::from(&*op.merged_node.guid);
+            statement.bind_by_index(offset, merged_guid)?;
+
+            let local_guid = op
+                .merged_node
+                .merge_state
+                .local_node()
+                .map(|node| nsString::from(&*node.guid));
+            statement.bind_by_index(offset + 1, local_guid)?;
+
+            let remote_guid = nsString::from(&*op.remote_node().guid);
+            statement.bind_by_index(offset + 2, remote_guid)?;
+        }
+        statement.execute()?;
+    }
+
+    debug!(driver, "Staging change GUID ops");
+    for chunk in ops.change_guids.chunks(SQLITE_MAX_VARIABLE_NUMBER / 2) {
+        let mut statement = db.prepare(format!(
+            "INSERT INTO changeGuidOps(localGuid, mergedGuid, syncStatus, level,
+                                       lastModifiedMicroseconds)
+             VALUES {}",
+            repeat_display(chunk.len(), ",", |index, f| {
+                let op = &chunk[index];
+                // If only the local GUID changed, the item was deduped, so we
+                // can mark it as syncing. Otherwise, we changed an invalid
+                // GUID locally or remotely, so we leave its original sync
+                // status in place until we've uploaded it.
+                let sync_status = if op.merged_node.remote_guid_changed() {
+                    None
+                } else {
+                    Some(nsINavBookmarksService::SYNC_STATUS_NORMAL)
+                };
+                write!(
+                    f,
+                    "(?, ?, {}, {}, {})",
+                    NullableFragment(sync_status),
+                    op.level,
+                    now
+                )
+            })
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            let offset = (index * 2) as u32;
+
+            let local_guid = nsString::from(&*op.local_node().guid);
+            statement.bind_by_index(offset, local_guid)?;
+
+            let merged_guid = nsString::from(&*op.merged_node.guid);
+            statement.bind_by_index(offset + 1, merged_guid)?;
+        }
+        statement.execute()?;
+    }
+
+    debug!(driver, "Staging apply new local structure ops");
+    for chunk in ops
+        .apply_new_local_structure
+        .chunks(SQLITE_MAX_VARIABLE_NUMBER / 2)
+    {
+        let mut statement = db.prepare(format!(
+            "INSERT INTO applyNewLocalStructureOps(mergedGuid, mergedParentGuid,
+                                                   position, level,
+                                                   lastModifiedMicroseconds)
+             VALUES {}",
+            repeat_display(chunk.len(), ",", |index, f| {
+                let op = &chunk[index];
+                write!(f, "(?, ?, {}, {}, {})", op.position, op.level, now)
+            })
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            let offset = (index * 2) as u32;
+
+            let merged_guid = nsString::from(&*op.merged_node.guid);
+            statement.bind_by_index(offset, merged_guid)?;
+
+            let merged_parent_guid = nsString::from(&*op.merged_parent_node.guid);
+            statement.bind_by_index(offset + 1, merged_parent_guid)?;
+        }
+        statement.execute()?;
+    }
+
+    debug!(driver, "Staging deletions");
+    for chunk in deletions.chunks(SQLITE_MAX_VARIABLE_NUMBER) {
+        // Inserting into `itemsToRemove` fires the `noteItemRemoved` trigger,
+        // which records info for deleted item observer notifications. It's
+        // important we do this before updating the structure, so that the
+        // trigger captures the old parent and position.
+        let mut statement = db.prepare(format!(
+            "INSERT INTO itemsToRemove(guid, localLevel, shouldUploadTombstone,
+                                       dateRemovedMicroseconds)
              VALUES {}",
             repeat_display(chunk.len(), ",", |index, f| {
                 let d = &chunk[index];
                 write!(
                     f,
-                    "(?, ?, ?, ?, {}, {}, {}, {})",
-                    d.level,
-                    d.position,
-                    d.merged_node.merge_state.should_apply() as i8,
-                    (d.merged_node.merge_state.upload_reason() != UploadReason::None) as i8
-                )
-            })
-        ))?;
-        for (index, d) in chunk.iter().enumerate() {
-            let offset = (index * 4) as u32;
-
-            let local_guid = d
-                .merged_node
-                .merge_state
-                .local_node()
-                .map(|node| nsString::from(node.guid.as_str()));
-            let remote_guid = d
-                .merged_node
-                .merge_state
-                .remote_node()
-                .map(|node| nsString::from(node.guid.as_str()));
-            let merged_guid = nsString::from(d.merged_node.guid.as_str());
-            let merged_parent_guid = nsString::from(d.merged_parent_node.guid.as_str());
-
-            statement.bind_by_index(offset, local_guid)?;
-            statement.bind_by_index(offset + 1, remote_guid)?;
-            statement.bind_by_index(offset + 2, merged_guid)?;
-            statement.bind_by_index(offset + 3, merged_parent_guid)?;
-        }
-        statement.execute()?;
-    }
-
-    for chunk in deletions.chunks(999) {
-        // This fires the `noteItemRemoved` trigger, which records observer infos
-        // for deletions. It's important we do this before updating the structure,
-        // so that the trigger captures the old parent and position.
-        let mut statement = db.prepare(format!(
-            "INSERT INTO itemsToRemove(guid, localLevel, shouldUploadTombstone)
-             VALUES {}",
-            repeat_display(chunk.len(), ",", |index, f| {
-                let d = &chunk[index];
-                write!(
-                    f,
-                    "(?, {}, {})",
-                    d.local_level, d.should_upload_tombstone as i8
+                    "(?, {}, {}, {})",
+                    d.local_level, d.should_upload_tombstone as i8, now,
                 )
             })
         ))?;
         for (index, d) in chunk.iter().enumerate() {
             statement.bind_by_index(index as u32, nsString::from(d.guid.as_str()))?;
         }
         statement.execute()?;
     }
 
-    insert_new_urls_into_places(&db)?;
+    // Remove tombstones for revived items. We intentionally use three `IN`
+    // subqueries instead of one with `UNION ALL`s because SQLite's query
+    // planner can't unroll the latter into a multi-index OR.
+    debug!(driver, "Removing tombstones for revived items");
+    db.exec(
+        "DELETE FROM moz_bookmarks_deleted
+         WHERE guid IN (SELECT mergedGuid FROM itemsToApply) OR
+               guid IN (SELECT remoteGuid FROM itemsToApply) OR
+               guid IN (SELECT localGuid FROM changeGuidOps)",
+    )?;
+
+    debug!(driver, "Removing local items");
+    remove_local_items(&db)?;
 
-    // "Deleting" from `itemsToMerge` fires the `insertNewLocalItems` and
-    // `updateExistingLocalItems` triggers.
-    db.exec("DELETE FROM itemsToMerge")?;
+    // Fires the `changeGuids` trigger.
+    debug!(driver, "Changing GUIDs");
+    db.exec("DELETE FROM changeGuidOps")?;
+
+    debug!(driver, "Applying remote items");
+    apply_remote_items(&db)?;
+
+    // Trigger frecency updates for all affected origins.
+    debug!(driver, "Updating origins for changed URLs");
+    db.exec("DELETE FROM moz_updateoriginsupdate_temp")?;
+
+    // Fires the `applyNewLocalStructure` trigger.
+    debug!(driver, "Applying new local structure");
+    db.exec("DELETE FROM applyNewLocalStructureOps")?;
 
-    // "Deleting" from `structureToMerge` fires the `updateLocalStructure`
-    // trigger.
-    db.exec("DELETE FROM structureToMerge")?;
+    // Reset the change counter for items that we don't need to upload.
+    debug!(driver, "Applying skip upload ops");
+    for chunk in ops.skip_upload.chunks(SQLITE_MAX_VARIABLE_NUMBER) {
+        let mut statement = db.prepare(format!(
+            "UPDATE moz_bookmarks SET
+               syncChangeCounter = 0
+             WHERE guid IN ({})",
+            repeat_sql_vars(chunk.len()),
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            statement.bind_by_index(index as u32, nsString::from(&*op.merged_node.guid))?;
+        }
+        statement.execute()?;
+    }
 
-    db.exec("DELETE FROM itemsToRemove")?;
+    // Bump the counter for items that we should upload.
+    debug!(driver, "Applying flag for upload ops");
+    for chunk in ops.flag_for_upload.chunks(SQLITE_MAX_VARIABLE_NUMBER) {
+        let mut statement = db.prepare(format!(
+            "UPDATE moz_bookmarks SET
+               syncChangeCounter = 1
+             WHERE guid IN ({})",
+            repeat_sql_vars(chunk.len()),
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            statement.bind_by_index(index as u32, nsString::from(&*op.merged_node.guid))?;
+        }
+        statement.execute()?;
+    }
 
-    db.exec("DELETE FROM relatedIdsToReupload")?;
+    // Flag applied remote items as merged in the mirror.
+    debug!(driver, "Applying flag as merged ops");
+    for chunk in ops.flag_as_merged.chunks(SQLITE_MAX_VARIABLE_NUMBER) {
+        let mut statement = db.prepare(format!(
+            "UPDATE items SET
+               needsMerge = 0
+             WHERE guid IN ({})",
+            repeat_sql_vars(chunk.len()),
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            statement.bind_by_index(index as u32, nsString::from(op.guid().as_str()))?;
+        }
+        statement.execute()?;
+    }
 
     Ok(())
 }
 
-/// Inserts URLs for new remote items from the mirror's `urls` table into the
-/// `moz_places` table.
-fn insert_new_urls_into_places(db: &Conn) -> Result<()> {
-    let mut statement = db.prepare(
-        "INSERT OR IGNORE INTO moz_places(url, url_hash, rev_host, hidden,
-                                          frecency, guid)
-         SELECT u.url, u.hash, u.revHost, 0,
-                (CASE v.kind WHEN :queryKind THEN 0 ELSE -1 END),
-                IFNULL((SELECT h.guid FROM moz_places h
-                        WHERE h.url_hash = u.hash AND
-                              h.url = u.url), u.guid)
-         FROM items v
-         JOIN urls u ON u.id = v.urlId
-         JOIN mergeStates r ON r.remoteGuid = v.guid
-         WHERE r.useRemote",
+/// Upserts all new and updated items from the `itemsToApply` table into Places.
+fn apply_remote_items(db: &Conn) -> Result<()> {
+    // Record item added notifications for new items.
+    db.exec(
+        "INSERT INTO itemsAdded(guid, keywordChanged, level)
+         SELECT n.mergedGuid, n.newKeyword NOT NULL OR
+                              EXISTS(SELECT 1 FROM moz_keywords k
+                                     WHERE k.place_id = n.newPlaceId OR
+                                           k.keyword = n.newKeyword),
+                n.newLevel
+         FROM itemsToApply n
+         WHERE n.localId IS NULL",
+    )?;
+
+    // Record item changed notifications for existing items.
+    db.exec(
+        "INSERT INTO itemsChanged(itemId, oldTitle, oldPlaceId, keywordChanged,
+                                 level)
+        SELECT n.localId, n.oldTitle, n.oldPlaceId,
+               n.newKeyword NOT NULL OR EXISTS(
+                 SELECT 1 FROM moz_keywords k
+                 WHERE k.place_id IN (n.oldPlaceId, n.newPlaceId) OR
+                       k.keyword = n.newKeyword
+               ),
+               n.newLevel
+        FROM itemsToApply n
+        WHERE n.localId NOT NULL",
+    )?;
+
+    // Remove all keywords from old and new URLs, and remove new keywords from
+    // all existing URLs. The `NOT NULL` conditions are important; they ensure
+    // that SQLite uses our partial indexes, instead of a table scan.
+    db.exec(
+        "DELETE FROM moz_keywords
+         WHERE place_id IN (SELECT oldPlaceId FROM itemsToApply
+                            WHERE oldPlaceId NOT NULL) OR
+               place_id IN (SELECT newPlaceId FROM itemsToApply
+                            WHERE newPlaceId NOT NULL) OR
+               keyword IN (SELECT newKeyword FROM itemsToApply
+                           WHERE newKeyword NOT NULL)
+    ",
+    )?;
+
+    // Remove existing tags.
+    db.exec(
+        "DELETE FROM localTags
+         WHERE placeId IN (SELECT oldPlaceId FROM itemsToApply
+                           WHERE oldPlaceId NOT NULL) OR
+               placeId IN (SELECT newPlaceId FROM itemsToApply
+                           WHERE newPlaceId NOT NULL)",
     )?;
-    statement.bind_by_name("queryKind", mozISyncedBookmarksMerger::KIND_QUERY)?;
-    statement.execute()?;
-    db.exec("DELETE FROM moz_updateoriginsinsert_temp")?;
+
+    // Insert new items, using -1 as a placeholder for the parent ID and
+    // position. We'll update these later, when we apply the new local
+    // structure. This is a full table scan on `itemsToApply`. The no-op
+    // `WHERE` clause is necessary to avoid a parsing ambiguity.
+    db.exec(format!(
+        "INSERT INTO moz_bookmarks(id, guid, parent, position, type, fk, title,
+                                   dateAdded,
+                                   lastModified,
+                                   syncStatus, syncChangeCounter)
+         SELECT localId, mergedGuid, -1, -1, newType, newPlaceId, newTitle,
+                /* Pick the older of the local and remote date added. We'll
+                   weakly reupload any items with an older local date. */
+                MIN(IFNULL(localDateAddedMicroseconds,
+                           remoteDateAddedMicroseconds),
+                    remoteDateAddedMicroseconds),
+                /* The last modified date should always be newer than the date
+                   added, so we pick the newer of the two here. */
+                MAX(lastModifiedMicroseconds, remoteDateAddedMicroseconds),
+                {}, 0
+         FROM itemsToApply
+         WHERE 1
+         ON CONFLICT(id) DO UPDATE SET
+           title = excluded.title,
+           dateAdded = excluded.dateAdded,
+           lastModified = excluded.lastModified,
+           /* It's important that we update the URL *after* removing old keywords
+              and *before* inserting new ones, so that the above DELETEs select
+              the correct affected items. */
+           fk = excluded.fk",
+        nsINavBookmarksService::SYNC_STATUS_NORMAL
+    ))?;
+
+    // Flag frecencies for recalculation. This is a multi-index OR that uses the
+    // `oldPlacesIds` and `newPlaceIds` partial indexes, since `<>` is only true
+    // if both terms are not NULL. Without those constraints, the subqueries
+    // would scan `itemsToApply` twice. The `oldPlaceId <> newPlaceId` and
+    // `newPlaceId <> oldPlaceId` checks exclude items where the URL didn't
+    // change; we don't need to recalculate their frecencies.
+    db.exec(
+        "UPDATE moz_places SET
+           frecency = -frecency
+         WHERE frecency > 0 AND (
+           id IN (
+             SELECT oldPlaceId FROM itemsToApply
+             WHERE oldPlaceId <> newPlaceId
+           ) OR id IN (
+             SELECT newPlaceId FROM itemsToApply
+             WHERE newPlaceId <> oldPlaceId
+           )
+         )",
+    )?;
+
+    // Insert new keywords for new URLs.
+    db.exec(
+        "INSERT OR IGNORE INTO moz_keywords(keyword, place_id, post_data)
+         SELECT newKeyword, newPlaceId, ''
+         FROM itemsToApply
+         WHERE newKeyword NOT NULL",
+    )?;
+
+    // Insert new tags for new URLs.
+    db.exec(
+        "INSERT INTO localTags(tag, placeId, lastModifiedMicroseconds)
+         SELECT t.tag, n.newPlaceId, n.lastModifiedMicroseconds
+         FROM itemsToApply n
+         JOIN tags t ON t.itemId = n.remoteId",
+    )?;
+
+    Ok(())
+}
+
+/// Removes items that are deleted on one or both sides from Places, and inserts
+/// new tombstones for non-syncable items to delete remotely.
+fn remove_local_items(db: &Conn) -> Result<()> {
+    // Record observer notifications for removed items.
+    db.exec(
+        "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, d.localLevel
+         FROM itemsToRemove d
+         JOIN moz_bookmarks b ON b.guid = d.guid
+         JOIN moz_bookmarks p ON p.id = b.parent",
+    )?;
+
+    // Flag URL frecency for recalculation.
+    db.exec(
+        "UPDATE moz_places SET
+           frecency = -frecency
+         WHERE id IN (SELECT b.fk FROM moz_bookmarks b
+                      JOIN itemsToRemove d ON d.guid = b.guid) AND
+               frecency > 0",
+    )?;
+
+    // Remove annos for the deleted items. This can be removed in bug 1460577.
+    db.exec(
+        "DELETE FROM moz_items_annos
+         WHERE item_id = (SELECT b.id FROM moz_bookmarks b
+                          JOIN itemsToRemove d ON d.guid = b.guid)",
+    )?;
+
+    // Don't reupload tombstones for items that are already deleted on the
+    // server.
+    db.exec(
+        "DELETE FROM moz_bookmarks_deleted
+         WHERE guid IN (SELECT guid FROM itemsToRemove
+                        WHERE NOT shouldUploadTombstone)",
+    )?;
+
+    // Upload tombstones for non-syncable items. We can remove the
+    // `shouldUploadTombstone` check and persist tombstones unconditionally in
+    // bug 1343103.
+    db.exec(
+        "INSERT OR IGNORE INTO moz_bookmarks_deleted(guid, dateRemoved)
+         SELECT guid, dateRemovedMicroseconds
+         FROM itemsToRemove
+         WHERE shouldUploadTombstone",
+    )?;
+
+    // Remove the item from Places.
+    db.exec(
+        "DELETE FROM moz_bookmarks
+         WHERE guid IN (SELECT guid FROM itemsToRemove)",
+    )?;
+
     Ok(())
 }
 
 /// 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
 /// changes made during the sync don't cause us to upload inconsistent records.
 ///
 /// For an example of why we use a temporary table instead of reading directly
@@ -514,74 +880,70 @@ fn insert_new_urls_into_places(db: &Conn
 /// 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.
-fn stage_items_to_upload(db: &Conn, weak_upload: &[nsString]) -> Result<()> {
+fn stage_items_to_upload(db: &Conn, upload: &[Upload], weak_upload: &[nsString]) -> Result<()> {
+    // Clean up staged items left over from the last sync.
+    db.exec("DELETE FROM itemsToUpload")?;
+
     // Stage explicit weak uploads such as repair responses.
-    for chunk in weak_upload.chunks(999) {
+    for chunk in weak_upload.chunks(SQLITE_MAX_VARIABLE_NUMBER) {
         let mut statement = db.prepare(format!(
-            "INSERT INTO idsToWeaklyUpload(id)
-             SELECT id
-             FROM moz_bookmarks
-             WHERE guid IN ({})",
-            repeat_display(chunk.len(), ",", |_, f| f.write_str("?")),
+            "INSERT INTO itemsToUpload(id, guid, syncChangeCounter, parentGuid,
+                                       parentTitle, dateAdded, type, title,
+                                       placeId, isQuery, url, keyword, position,
+                                       tagFolderName)
+             {}
+             WHERE b.guid IN ({})",
+            UploadItemsFragment("b"),
+            repeat_sql_vars(chunk.len()),
         ))?;
         for (index, guid) in chunk.iter().enumerate() {
             statement.bind_by_index(index as u32, nsString::from(guid.as_ref()))?;
         }
         statement.execute()?;
     }
 
     // Stage remotely changed items with older local creation dates. These are
     // tracked "weakly": if the upload is interrupted or fails, we won't
     // reupload the record on the next sync.
-    db.exec(
-        r#"
-        INSERT OR IGNORE INTO idsToWeaklyUpload(id)
-        SELECT b.id
-        FROM moz_bookmarks b
-        JOIN mergeStates r ON r.mergedGuid = b.guid
-        JOIN items v ON v.guid = r.remoteGuid
-        WHERE r.useRemote AND
-              /* "b.dateAdded" is in microseconds; "v.dateAdded" is in
-                 milliseconds. */
-              b.dateAdded / 1000 < v.dateAdded"#,
-    )?;
+    db.exec(format!(
+        "INSERT OR IGNORE INTO itemsToUpload(id, guid, syncChangeCounter,
+                                             parentGuid, parentTitle, dateAdded,
+                                             type, title, placeId, isQuery, url,
+                                             keyword, position, tagFolderName)
+         {}
+         JOIN itemsToApply n ON n.mergedGuid = b.guid
+         WHERE n.localDateAddedMicroseconds < n.remoteDateAddedMicroseconds",
+        UploadItemsFragment("b"),
+    ))?;
 
     // Stage remaining locally changed items for upload.
-    db.exec(format!(
-        "
-        WITH RECURSIVE
-        {}
-        INSERT INTO itemsToUpload(id, guid, syncChangeCounter, parentGuid,
-                                  parentTitle, dateAdded, type, title, placeId,
-                                  isQuery, url, keyword, position, tagFolderName)
-        SELECT s.id, s.guid, s.syncChangeCounter, s.parentGuid, s.parentTitle,
-               s.dateAdded / 1000, s.type, s.title, s.placeId,
-               IFNULL(SUBSTR(h.url, 1, 6) = 'place:', 0) AS isQuery,
-               h.url,
-               (SELECT keyword FROM moz_keywords WHERE place_id = h.id),
-               s.position,
-               (SELECT get_query_param(substr(url, 7), 'tag')
-                WHERE substr(h.url, 1, 6) = 'place:')
-        FROM localItems s
-        LEFT JOIN moz_places h ON h.id = s.placeId
-        LEFT JOIN idsToWeaklyUpload w ON w.id = s.id
-        WHERE s.guid <> '{}' AND (
-          s.syncChangeCounter > 0 OR
-          w.id NOT NULL
-        )",
-        LOCAL_ITEMS_SQL_FRAGMENT,
-        dogear::ROOT_GUID,
-    ))?;
+    for chunk in upload.chunks(SQLITE_MAX_VARIABLE_NUMBER) {
+        let mut statement = db.prepare(format!(
+            "INSERT OR IGNORE INTO itemsToUpload(id, guid, syncChangeCounter,
+                                                 parentGuid, parentTitle,
+                                                 dateAdded, type, title,
+                                                 placeId, isQuery, url, keyword,
+                                                 position, tagFolderName)
+             {}
+             WHERE b.guid IN ({})",
+            UploadItemsFragment("b"),
+            repeat_sql_vars(chunk.len()),
+        ))?;
+        for (index, op) in chunk.iter().enumerate() {
+            statement.bind_by_index(index as u32, nsString::from(&*op.merged_node.guid))?;
+        }
+        statement.execute()?;
+    }
 
     // Record the child GUIDs of locally changed folders, which we use to
     // populate the `children` array in the record.
     db.exec(
         "
         INSERT INTO structureToUpload(guid, parentId, position)
         SELECT b.guid, b.parent, b.position
         FROM moz_bookmarks b
@@ -604,21 +966,29 @@ fn stage_items_to_upload(db: &Conn, weak
         INSERT OR IGNORE INTO itemsToUpload(guid, syncChangeCounter, isDeleted)
         SELECT guid, 1, 1 FROM moz_bookmarks_deleted",
     )?;
 
     Ok(())
 }
 
 fn cleanup(db: &Conn) -> Result<()> {
-    db.exec("DELETE FROM mergeStates")?;
-    db.exec("DELETE FROM idsToWeaklyUpload")?;
+    db.exec(
+        "DELETE FROM itemsToApply;
+         DELETE FROM itemsToRemove;",
+    )?;
     Ok(())
 }
 
+/// Formats a list of binding parameters for inclusion in a SQL list.
+#[inline]
+fn repeat_sql_vars(count: usize) -> impl fmt::Display {
+    repeat_display(count, ",", |_, f| write!(f, "?"))
+}
+
 /// Construct a `RepeatDisplay` that will repeatedly call `fmt_one` with a
 /// formatter `count` times, separated by `sep`. This is copied from the
 /// `sql_support` crate in `application-services`.
 #[inline]
 fn repeat_display<'a, F>(count: usize, sep: &'a str, fmt_one: F) -> RepeatDisplay<'a, F>
 where
     F: Fn(usize, &mut fmt::Formatter) -> fmt::Result,
 {
@@ -696,8 +1066,58 @@ impl Column<i64> for Validity {
     fn into_column(self) -> i64 {
         match self {
             Validity::Valid => mozISyncedBookmarksMerger::VALIDITY_VALID,
             Validity::Reupload => mozISyncedBookmarksMerger::VALIDITY_REUPLOAD,
             Validity::Replace => mozISyncedBookmarksMerger::VALIDITY_REPLACE,
         }
     }
 }
+
+/// Formats an optional value so that it can be included in a SQL statement.
+struct NullableFragment<T>(Option<T>);
+
+impl<T> fmt::Display for NullableFragment<T>
+where
+    T: fmt::Display,
+{
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match &self.0 {
+            Some(v) => v.fmt(f),
+            None => write!(f, "NULL"),
+        }
+    }
+}
+
+/// Formats a `SELECT` statement for staging local items in the `itemsToUpload`
+/// table.
+struct UploadItemsFragment(&'static str);
+
+impl fmt::Display for UploadItemsFragment {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "SELECT {0}.id, {0}.guid, {}.syncChangeCounter,
+                       p.guid AS parentGuid, p.title AS parentTitle,
+                       {0}.dateAdded / 1000 AS dateAdded, {0}.type, {0}.title,
+                       h.id AS placeId,
+                       IFNULL(substr(h.url, 1, 6) = 'place:', 0) AS isQuery,
+                       h.url,
+                       (SELECT keyword FROM moz_keywords WHERE place_id = h.id),
+                       {0}.position,
+                       (SELECT get_query_param(substr(url, 7), 'tag')
+                        WHERE substr(h.url, 1, 6) = 'place:') AS tagFolderName
+                FROM moz_bookmarks {0}
+                JOIN moz_bookmarks p ON p.id = {0}.parent
+                LEFT JOIN moz_places h ON h.id = {0}.fk",
+            self.0
+        )
+    }
+}
+
+/// Returns the current time, in microseconds, rounded to the nearest
+/// millisecond.
+fn rounded_now() -> u64 {
+    SystemTime::now()
+        .duration_since(SystemTime::UNIX_EPOCH)
+        .map(|d| (d.as_secs() as u64) * 1_000_000 + u64::from(d.subsec_millis()) * 1000)
+        .unwrap_or(0)
+}
--- a/toolkit/components/places/mozISyncedBookmarksMirror.idl
+++ b/toolkit/components/places/mozISyncedBookmarksMirror.idl
@@ -11,26 +11,20 @@ interface nsIPropertyBag;
 [scriptable, uuid(6239ffe3-6ffd-49ac-8b1d-958407395bf9)]
 interface mozISyncedBookmarksMirrorProgressListener : nsISupports {
   // Called after the merger fetches the local tree from Places, with the time
   // taken to build the tree, number of items in the tree, and a map of
   // structure problem counts for validation telemetry.
   void onFetchLocalTree(in long long took, in long long itemCount,
                         in nsIPropertyBag problems);
 
-  // Called after the merger fetches content info from Places for deduping.
-  void onFetchNewLocalContents(in long long took, in long long itemCount);
-
   // Called after the merger builds the remote tree from the mirror database.
   void onFetchRemoteTree(in long long took, in long long itemCount,
                          in nsIPropertyBag problems);
 
-  // Called after the merger fetches remote content info from the mirror.
-  void onFetchNewRemoteContents(in long long took, in long long itemCount);
-
   // Called after the merger builds the merged tree, including structure change
   // counts for event telemetry.
   void onMerge(in long long took, in nsIPropertyBag counts);
 
   // Called after the merger finishes applying the merged tree to Places and
   // staging outgoing items.
   void onApply(in long long took);
 };
--- a/toolkit/components/places/tests/sync/head_sync.js
+++ b/toolkit/components/places/tests/sync/head_sync.js
@@ -188,16 +188,26 @@ async function promiseManyDatesAdded(gui
   }
   return datesAdded;
 }
 
 async function fetchLocalTree(rootGuid) {
   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 =>
+        [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);
new file mode 100644
index 0000000000000000000000000000000000000000..2a798ae90869f39a14e066b7c95720c4348cefe7
GIT binary patch
literal 262144
zc%1Fk&u<)89RToM|F)&5{7~czC_|7Vr%CLl2=#{)2|L~<2G?%=LrG57u6G;{-rX^?
zvmq%}CjoJxNN_}o#Hm6;;!ohzLvN@;9Jp0N964~~2+YpL_Aa(tI3+@#k2JII_xrv#
zZ)P{%U5U~#*o~8BH4VO1dR!`(OWz5CQmHhSUlXNc-SkLbzMNm9rO!83ntSDqbJ;IT
z<B$GX%6|3!`&sy-U%dC1_ip4_000000000000000000000000000000000000Du?J
zjq$PR3+KyQQL7$4>?Dob)%IT4&J*p4o9mT@%}TJmx>UIv990EttKCd{b}!Ffe)5g6
z@#*vD%Rin;tF=a$R`0jF*Z8x(n+uC8m7rTPI~C-jG;A){^C34Yw<_zw>e^<oy0x+r
ztXJNutXEcVRyK+z?b)b)x#)0~Jcy;rN+qA-=EBC!g{8{XqIo`3uvxjgc{Flfdilwf
z(O!&i6ft&#?_OgAF&0be#dsXwJL}7DFRb4SzE`=|8~=VMuN|16SDyDit+=~ICl=eQ
zO2P-X<92#VwBH{YpT2OR{F84M(YDj1vy*m`<HYDd+(*T;g8}wtIjAOKE4_6T^@)A%
zwm*HR&)DIExE-ZY+&W`feLFULJo(k&aGuM-o#oBjYg>6JYj>8He!4p}K0Q5M{_sk%
z*dnIlHaxJ%UabGa)7e^Ges`<#AF<899Bl4|!Np!&QT<}Do5al^&C8N-Cr;{f!OQcf
zZQDTic9=W}lb{*bqunU12Wiv{uLe;oXhw}j)XoQL)!RoS^;*Au*!aZ3U#h&du(h%o
zywaPa6^8Zp+hKA)3>MecRw@gtXEuFMZAA4beRTGi*Lw}4_EMO?nfdHrG~|B%5jcCe
zYu5+%VJ8Wz#X+c7)6dy}ZtX(7{$H#;jnYQgx8M0Y)%U6BJGsTHdit?FKC~N^{ztzb
zKKfz)fcr+ThwYstIw)3fT-u1M_2t$^R1a&_<Z!=wRqbXqNf+aIKmXldtv35Z-OV4{
z*7{0cN%s^VmUNH(<>9gE8`I@d@tJM^ppjQ^S37B3<hP6Sy?wp-8OcA&6WuTKZn^ks
zJ((|Fjb{FB?OsCzFGjay_Hc#0qkrP9$PY><9QecxzJpWU$sRw?{~!PW0000000000
z00000000000000000000000000C@h+<=6NJzb<8e%6^kQ%2u+ke(>i!3jhEB00000
z00000000000000000000000000002M3+1(muastHMkgmH%A<#6&9K$kekO_I^e|m+
zL>D*yINbYrbNsO5<mgM~FBctaalGHGCi{!I{y};@diTYlz38>EzP^*CfxZj5v-&Pv
z8SQqQn8>4@IEpr|MUC)ew8K|MPB!kecJs#dQyLGQY@Eh%qgG8$X<R<pxWB<b<EgxI
zJo{NG`+N3T_J{2E+3&Kac@_Ww000000000000000000000000000000000000G^XC
z4bPN2t=*^**0;Mxx&>(*H)_?SC>VNacxEz*<8=F(Zi8lA%X{?lqeVg3>Krzh=oZxC
zc)wXq_7`*G{RIoTv8jCK@$A)7_Sfu_?6=v6Su=Yl&jJ7d00000000000000000000
z000000000000000z!&)X$jnSRiQ{zpnNDjrYJ~M7J@mD_cA~#FjpIhGniT01UHjAj
zI#Jt<Yx%&v^l*3J$${Fi)j3QTmHh>^INonoll{e95z=Xu3%PUoiiWbEm$Fat8vp<R
z00000000000000000000000000000000000yZ~Mw8Y|6wdvvC<*1Es(sJ(pa-tFDJ
zG(C88em;CyZ5}kjxt+K<UrR3C`grc6X1p^OHV&HAhvD4o<zCPG5AJM#w_3S=?bW;2
zPU%@qE?wMv`e}d9bKQv_Kj(!500000000000000000000000000000000000006*q
zHkDsP+22apKk^#@00000000000000000000000000000000000008{|n;IT1Czo!0
TJonMi<nZWZa_QpU(@*~m$-2Y~
--- a/toolkit/components/places/tests/sync/test_bookmark_chunking.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_chunking.js
@@ -78,17 +78,21 @@ add_task(async function test_merged_item
         bmkUri: "http://example.com/b",
       })
     );
   }
   await buf.store(shuffle(records));
 
   info("Apply remote");
   let changesToUpload = await buf.apply();
-  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+  deepEqual(
+    await buf.fetchUnmergedGuids(),
+    [PlacesUtils.bookmarks.unfiledGuid],
+    "Should leave unfiled with new remote structure unmerged"
+  );
 
   let localChildRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
     "toolbar"
   );
   deepEqual(
     localChildRecordIds,
     toolbarRecord.children,
     "Should apply all remote toolbar children"
@@ -96,16 +100,19 @@ add_task(async function test_merged_item
 
   let guidsToUpload = Object.keys(changesToUpload);
   deepEqual(
     guidsToUpload.sort(),
     ["unfiled", ...localGuids].sort(),
     "Should upload unfiled and all new local children"
   );
 
+  await storeChangesInMirror(buf, changesToUpload);
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
   await buf.finalize();
   await PlacesUtils.bookmarks.eraseEverything();
   await PlacesSyncUtils.bookmarks.reset();
 });
 
 add_task(async function test_deletion_chunking() {
   let buf = await openMirror("deletion_chunking");
 
--- a/toolkit/components/places/tests/sync/test_bookmark_corruption.js
+++ b/toolkit/components/places/tests/sync/test_bookmark_corruption.js
@@ -73,17 +73,28 @@ add_task(async function test_multiple_pa
     },
   ]);
 
   info("Apply remote");
   let changesToUpload = await buf.apply({
     localTimeSeconds: now / 1000,
     remoteTimeSeconds: now / 1000,
   });
-  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+  deepEqual(
+    await buf.fetchUnmergedGuids(),
+    [
+      "bookmarkAAAA",
+      "bookmarkBBBB",
+      PlacesUtils.bookmarks.menuGuid,
+      PlacesUtils.bookmarks.mobileGuid,
+      PlacesUtils.bookmarks.toolbarGuid,
+      PlacesUtils.bookmarks.unfiledGuid,
+    ],
+    "Should leave items with new remote structure unmerged"
+  );
 
   let datesAdded = await promiseManyDatesAdded([
     PlacesUtils.bookmarks.menuGuid,
     PlacesUtils.bookmarks.toolbarGuid,
     PlacesUtils.bookmarks.unfiledGuid,
     PlacesUtils.bookmarks.mobileGuid,
     "bookmarkAAAA",
     "bookmarkBBBB",
@@ -359,17 +370,28 @@ add_task(async function test_reupload_re
       type: "query",
       title: "D",
       bmkUri: "^&*()",
     },
   ]);
 
   info("Apply remote");
   let changesToUpload = await buf.apply();
-  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+  deepEqual(
+    await buf.fetchUnmergedGuids(),
+    [
+      "bookmarkAAAA",
+      "bookmarkEEEE",
+      "folderBBBBBB",
+      PlacesUtils.bookmarks.menuGuid,
+      "queryCCCCCCC",
+      "queryDDDDDDD",
+    ],
+    "Should leave invalid A, E, D; reuploaded C; B, menu with new remote structure unmerged"
+  );
 
   let datesAdded = await promiseManyDatesAdded([
     PlacesUtils.bookmarks.menuGuid,
     "bookmarkAAAA",
   ]);
   deepEqual(changesToUpload, {
     menu: {
       tombstone: false,
@@ -448,16 +470,19 @@ add_task(async function test_reupload_re
       synced: false,
       cleartext: {
         id: "bookmarkEEEE",
         deleted: true,
       },
     },
   });
 
+  await storeChangesInMirror(buf, changesToUpload);
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
   await buf.finalize();
   await PlacesUtils.bookmarks.eraseEverything();
   await PlacesSyncUtils.bookmarks.reset();
 });
 
 add_task(async function test_corrupt_local_roots() {
   let buf = await openMirror("corrupt_roots");
 
@@ -639,17 +664,25 @@ add_task(async function test_corrupt_rem
     },
     {
       id: "toolbar",
       deleted: true,
     },
   ]);
 
   let changesToUpload = await buf.apply();
-  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+  deepEqual(
+    await buf.fetchUnmergedGuids(),
+    [
+      PlacesUtils.bookmarks.menuGuid,
+      PlacesUtils.bookmarks.toolbarGuid,
+      PlacesUtils.bookmarks.unfiledGuid,
+    ],
+    "Should leave deleted roots unmerged"
+  );
 
   let datesAdded = await promiseManyDatesAdded([
     PlacesUtils.bookmarks.menuGuid,
     PlacesUtils.bookmarks.unfiledGuid,
     PlacesUtils.bookmarks.toolbarGuid,
   ]);
   deepEqual(
     changesToUpload,
@@ -753,16 +786,19 @@ add_task(async function test_corrupt_rem
           index: 4,
           title: MobileBookmarksTitle,
         },
       ],
     },
     "Should not corrupt local roots"
   );
 
+  await storeChangesInMirror(buf, changesToUpload);
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
   await buf.finalize();
   await PlacesUtils.bookmarks.eraseEverything();
   await PlacesSyncUtils.bookmarks.reset();
 });
 
 add_task(async function test_missing_children() {
   let buf = await openMirror("missing_childen");
 
@@ -790,17 +826,21 @@ add_task(async function test_missing_chi
           parentid: "menu",
           type: "bookmark",
           bmkUri: "http://example.com/c",
           title: "C",
         },
       ])
     );
     let changesToUpload = await buf.apply();
-    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    deepEqual(
+      await buf.fetchUnmergedGuids(),
+      [PlacesUtils.bookmarks.menuGuid],
+      "Should leave menu with new remote structure unmerged"
+    );
 
     let idsToUpload = inspectChangeRecords(changesToUpload);
     deepEqual(
       idsToUpload,
       {
         updated: ["menu"],
         deleted: [],
       },
@@ -845,17 +885,21 @@ add_task(async function test_missing_chi
           parentid: "menu",
           type: "bookmark",
           title: "E",
           bmkUri: "http://example.com/e",
         },
       ])
     );
     let changesToUpload = await buf.apply();
-    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    deepEqual(
+      await buf.fetchUnmergedGuids(),
+      ["bookmarkBBBB", "bookmarkEEEE"],
+      "Should leave B, E with new remote structure unmerged"
+    );
 
     let idsToUpload = inspectChangeRecords(changesToUpload);
     deepEqual(
       idsToUpload,
       {
         updated: ["bookmarkBBBB", "bookmarkEEEE", "menu"],
         deleted: [],
       },
@@ -904,17 +948,21 @@ add_task(async function test_missing_chi
         id: "bookmarkDDDD",
         parentid: "menu",
         type: "bookmark",
         title: "D",
         bmkUri: "http://example.com/d",
       },
     ]);
     let changesToUpload = await buf.apply();
-    deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+    deepEqual(
+      await buf.fetchUnmergedGuids(),
+      ["bookmarkDDDD"],
+      "Should leave D with new remote structure unmerged"
+    );
 
     let idsToUpload = inspectChangeRecords(changesToUpload);
     deepEqual(
       idsToUpload,
       {
         updated: ["bookmarkDDDD", "menu"],
         deleted: [],
       },
@@ -958,16 +1006,18 @@ add_task(async function test_missing_chi
           },
         ],
       },
       "Menu children should be (C B E D)"
     );
     await storeChangesInMirror(buf, changesToUpload);
   }
 
+  deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items");
+
   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");
 
@@ -1003,17 +1053,26 @@ add_task(async function test_new_orphan_
         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");
+    deepEqual(
+      await buf.fetchUnmergedGuids(),
+      [