places/bookmark_sync/
engine.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use super::incoming::IncomingApplicator;
6use super::record::{
7    BookmarkItemRecord, BookmarkRecord, BookmarkRecordId, FolderRecord, QueryRecord,
8    SeparatorRecord,
9};
10use super::{SyncedBookmarkKind, SyncedBookmarkValidity};
11use crate::db::{GlobalChangeCounterTracker, PlacesDb, SharedPlacesDb};
12use crate::error::*;
13use crate::frecency::{calculate_frecency, DEFAULT_FRECENCY_SETTINGS};
14use crate::storage::{
15    bookmarks::{
16        bookmark_sync::{create_synced_bookmark_roots, reset},
17        BookmarkRootGuid,
18    },
19    delete_pending_temp_tables, get_meta, put_meta,
20};
21use crate::types::{BookmarkType, SyncStatus, UnknownFields};
22use dogear::{
23    self, AbortSignal, CompletionOps, Content, Item, MergedRoot, TelemetryEvent, Tree, UploadItem,
24    UploadTombstone,
25};
26use interrupt_support::SqlInterruptScope;
27use rusqlite::ErrorCode;
28use rusqlite::Row;
29use sql_support::ConnExt;
30use std::cell::RefCell;
31use std::collections::HashMap;
32use std::fmt;
33use std::sync::Arc;
34use sync15::bso::{IncomingBso, OutgoingBso};
35use sync15::engine::{CollSyncIds, CollectionRequest, EngineSyncAssociation, SyncEngine};
36use sync15::{telemetry, CollectionName, ServerTimestamp};
37use sync_guid::Guid as SyncGuid;
38use types::Timestamp;
39pub const LAST_SYNC_META_KEY: &str = "bookmarks_last_sync_time";
40// Note that all engines in this crate should use a *different* meta key
41// for the global sync ID, because engines are reset individually.
42pub const GLOBAL_SYNCID_META_KEY: &str = "bookmarks_global_sync_id";
43pub const COLLECTION_SYNCID_META_KEY: &str = "bookmarks_sync_id";
44pub const COLLECTION_NAME: &str = "bookmarks";
45
46/// The maximum number of URLs for which to recalculate frecencies at once.
47/// This is a trade-off between write efficiency and transaction time: higher
48/// maximums mean fewer write statements, but longer transactions, possibly
49/// blocking writes from other connections.
50const MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK: usize = 400;
51
52/// Adapts an interruptee to a Dogear abort signal.
53struct MergeInterruptee<'a>(&'a SqlInterruptScope);
54
55impl AbortSignal for MergeInterruptee<'_> {
56    #[inline]
57    fn aborted(&self) -> bool {
58        self.0.was_interrupted()
59    }
60}
61
62fn stage_incoming(
63    db: &PlacesDb,
64    scope: &SqlInterruptScope,
65    inbound: Vec<IncomingBso>,
66    incoming_telemetry: &mut telemetry::EngineIncoming,
67) -> Result<()> {
68    let mut tx = db.begin_transaction()?;
69
70    let applicator = IncomingApplicator::new(db);
71
72    for incoming in inbound {
73        applicator.apply_bso(incoming)?;
74        incoming_telemetry.applied(1);
75        if tx.should_commit() {
76            // Trigger frecency updates for all new origins.
77            debug!("Updating origins for new synced URLs since last commit");
78            delete_pending_temp_tables(db)?;
79        }
80        tx.maybe_commit()?;
81        scope.err_if_interrupted()?;
82    }
83
84    debug!("Updating origins for new synced URLs in last chunk");
85    delete_pending_temp_tables(db)?;
86
87    tx.commit()?;
88    Ok(())
89}
90
91fn db_has_changes(db: &PlacesDb) -> Result<bool> {
92    // In the first subquery, we check incoming items with needsMerge = true
93    // except the tombstones who don't correspond to any local bookmark because
94    // we don't store them yet, hence never "merged" (see bug 1343103).
95    let sql = format!(
96        "SELECT
97            EXISTS (
98                SELECT 1
99                FROM moz_bookmarks_synced v
100                LEFT JOIN moz_bookmarks b ON v.guid = b.guid
101                WHERE v.needsMerge AND
102                (NOT v.isDeleted OR b.guid NOT NULL)
103            ) OR EXISTS (
104                WITH RECURSIVE
105                {}
106                SELECT 1
107                FROM localItems
108                WHERE syncChangeCounter > 0
109            ) OR EXISTS (
110                SELECT 1
111                FROM moz_bookmarks_deleted
112            )
113         AS hasChanges",
114        LocalItemsFragment("localItems")
115    );
116    Ok(db
117        .try_query_row(
118            &sql,
119            [],
120            |row| -> rusqlite::Result<_> { row.get::<_, bool>(0) },
121            false,
122        )?
123        .unwrap_or(false))
124}
125
126/// Builds a temporary table with the merge states of all nodes in the merged
127/// tree, then updates the local tree to match the merged tree.
128///
129/// Conceptually, we examine the merge state of each item, and either leave the
130/// item unchanged, upload the local side, apply the remote side, or apply and
131/// then reupload the remote side with a new structure.
132fn update_local_items_in_places(
133    db: &PlacesDb,
134    scope: &SqlInterruptScope,
135    now: Timestamp,
136    ops: &CompletionOps<'_>,
137) -> Result<()> {
138    // Build a table of new and updated items.
139    debug!(
140        "Staging apply {} remote item ops",
141        ops.apply_remote_items.len()
142    );
143    sql_support::each_sized_chunk(
144        &ops.apply_remote_items,
145        sql_support::default_max_variable_number() / 3,
146        |chunk, _| -> Result<()> {
147            // CTEs in `WITH` clauses aren't indexed, so this query needs a
148            // full table scan on `ops`. But that's okay; a separate temp
149            // table for ops would also need a full scan. Note that we need
150            // both the local _and_ remote GUIDs here, because we haven't
151            // changed the local GUIDs yet.
152            let sql = format!(
153                "WITH ops(mergedGuid, localGuid, remoteGuid, remoteType,
154                          level) AS (
155                     VALUES {ops}
156                 )
157                 INSERT INTO itemsToApply(mergedGuid, localId, remoteId,
158                                          remoteGuid, newLevel, newKind,
159                                          localDateAdded, remoteDateAdded,
160                                          lastModified, oldTitle, newTitle,
161                                          oldPlaceId, newPlaceId,
162                                          newKeyword)
163                 SELECT n.mergedGuid, b.id, v.id,
164                        v.guid, n.level, n.remoteType,
165                        b.dateAdded, v.dateAdded,
166                        v.serverModified, b.title, v.title,
167                        b.fk, v.placeId,
168                        v.keyword
169                 FROM ops n
170                 JOIN moz_bookmarks_synced v ON v.guid = n.remoteGuid
171                 LEFT JOIN moz_bookmarks b ON b.guid = n.localGuid",
172                ops = sql_support::repeat_display(chunk.len(), ",", |index, f| {
173                    let op = &chunk[index];
174                    write!(
175                        f,
176                        "(?, ?, ?, {}, {})",
177                        SyncedBookmarkKind::from(op.remote_node().kind) as u8,
178                        op.level
179                    )
180                }),
181            );
182
183            // We can't avoid allocating here, since we're binding four
184            // parameters per descendant. Rust's `SliceConcatExt::concat`
185            // is semantically equivalent, but requires a second allocation,
186            // which we _can_ avoid by writing this out.
187            let mut params = Vec::with_capacity(chunk.len() * 3);
188            for op in chunk.iter() {
189                scope.err_if_interrupted()?;
190
191                let merged_guid = op.merged_node.guid.as_str();
192                params.push(Some(merged_guid));
193
194                let local_guid = op
195                    .merged_node
196                    .merge_state
197                    .local_node()
198                    .map(|node| node.guid.as_str());
199                params.push(local_guid);
200
201                let remote_guid = op.remote_node().guid.as_str();
202                params.push(Some(remote_guid));
203            }
204
205            db.execute(&sql, rusqlite::params_from_iter(params))?;
206            Ok(())
207        },
208    )?;
209
210    debug!("Staging {} change GUID ops", ops.change_guids.len());
211    sql_support::each_sized_chunk(
212        &ops.change_guids,
213        sql_support::default_max_variable_number() / 2,
214        |chunk, _| -> Result<()> {
215            let sql = format!(
216                "INSERT INTO changeGuidOps(localGuid, mergedGuid,
217                                           syncStatus, level, lastModified)
218                 VALUES {}",
219                sql_support::repeat_display(chunk.len(), ",", |index, f| {
220                    let op = &chunk[index];
221                    // If only the local GUID changed, the item was deduped, so we
222                    // can mark it as syncing. Otherwise, we changed an invalid
223                    // GUID locally or remotely, so we leave its original sync
224                    // status in place until we've uploaded it.
225                    let sync_status = if op.merged_node.remote_guid_changed() {
226                        None
227                    } else {
228                        Some(SyncStatus::Normal as u8)
229                    };
230                    write!(
231                        f,
232                        "(?, ?, {}, {}, {})",
233                        NullableFragment(sync_status),
234                        op.level,
235                        now
236                    )
237                }),
238            );
239
240            let mut params = Vec::with_capacity(chunk.len() * 2);
241            for op in chunk.iter() {
242                scope.err_if_interrupted()?;
243
244                let local_guid = op.local_node().guid.as_str();
245                params.push(local_guid);
246
247                let merged_guid = op.merged_node.guid.as_str();
248                params.push(merged_guid);
249            }
250
251            db.execute(&sql, rusqlite::params_from_iter(params))?;
252            Ok(())
253        },
254    )?;
255
256    debug!(
257        "Staging apply {} new local structure ops",
258        ops.apply_new_local_structure.len()
259    );
260    sql_support::each_sized_chunk(
261        &ops.apply_new_local_structure,
262        sql_support::default_max_variable_number() / 2,
263        |chunk, _| -> Result<()> {
264            let sql = format!(
265                "INSERT INTO applyNewLocalStructureOps(
266                     mergedGuid, mergedParentGuid, position, level
267                 )
268                 VALUES {}",
269                sql_support::repeat_display(chunk.len(), ",", |index, f| {
270                    let op = &chunk[index];
271                    write!(f, "(?, ?, {}, {})", op.position, op.level)
272                }),
273            );
274
275            let mut params = Vec::with_capacity(chunk.len() * 2);
276            for op in chunk.iter() {
277                scope.err_if_interrupted()?;
278
279                let merged_guid = op.merged_node.guid.as_str();
280                params.push(merged_guid);
281
282                let merged_parent_guid = op.merged_parent_node.guid.as_str();
283                params.push(merged_parent_guid);
284            }
285
286            db.execute(&sql, rusqlite::params_from_iter(params))?;
287            Ok(())
288        },
289    )?;
290
291    debug!(
292        "Removing {} tombstones for revived items",
293        ops.delete_local_tombstones.len()
294    );
295    sql_support::each_chunk_mapped(
296        &ops.delete_local_tombstones,
297        |op| op.guid().as_str(),
298        |chunk, _| -> Result<()> {
299            scope.err_if_interrupted()?;
300            db.execute(
301                &format!(
302                    "DELETE FROM moz_bookmarks_deleted
303                     WHERE guid IN ({})",
304                    sql_support::repeat_sql_vars(chunk.len())
305                ),
306                rusqlite::params_from_iter(chunk),
307            )?;
308            Ok(())
309        },
310    )?;
311
312    debug!(
313        "Inserting {} new tombstones for non-syncable and invalid items",
314        ops.insert_local_tombstones.len()
315    );
316    sql_support::each_chunk_mapped(
317        &ops.insert_local_tombstones,
318        |op| op.remote_node().guid.as_str().to_owned(),
319        |chunk, _| -> Result<()> {
320            scope.err_if_interrupted()?;
321            db.execute(
322                &format!(
323                    "INSERT INTO moz_bookmarks_deleted(guid, dateRemoved)
324                     VALUES {}",
325                    sql_support::repeat_display(chunk.len(), ",", |_, f| write!(f, "(?, {})", now)),
326                ),
327                rusqlite::params_from_iter(chunk),
328            )?;
329            Ok(())
330        },
331    )?;
332
333    debug!(
334        "Flag frecencies for {} removed bookmark URLs as stale",
335        ops.delete_local_items.len()
336    );
337    sql_support::each_chunk_mapped(
338        &ops.delete_local_items,
339        |op| op.local_node().guid.as_str().to_owned(),
340        |chunk, _| -> Result<()> {
341            scope.err_if_interrupted()?;
342            db.execute(
343                &format!(
344                    "REPLACE INTO moz_places_stale_frecencies(
345                         place_id, stale_at
346                     )
347                     SELECT b.fk, {now}
348                     FROM moz_bookmarks b
349                     WHERE b.guid IN ({vars})
350                     AND b.fk NOT NULL",
351                    now = now,
352                    vars = sql_support::repeat_sql_vars(chunk.len())
353                ),
354                rusqlite::params_from_iter(chunk),
355            )?;
356            Ok(())
357        },
358    )?;
359
360    debug!(
361        "Removing {} deleted items from Places",
362        ops.delete_local_items.len()
363    );
364    sql_support::each_chunk_mapped(
365        &ops.delete_local_items,
366        |op| op.local_node().guid.as_str().to_owned(),
367        |chunk, _| -> Result<()> {
368            scope.err_if_interrupted()?;
369            db.execute(
370                &format!(
371                    "DELETE FROM moz_bookmarks
372                     WHERE guid IN ({})",
373                    sql_support::repeat_sql_vars(chunk.len())
374                ),
375                rusqlite::params_from_iter(chunk),
376            )?;
377            Ok(())
378        },
379    )?;
380
381    debug!("Changing GUIDs");
382    scope.err_if_interrupted()?;
383    db.execute_batch("DELETE FROM changeGuidOps")?;
384
385    debug!("Applying remote items");
386    apply_remote_items(db, scope, now)?;
387
388    // Fires the `applyNewLocalStructure` trigger.
389    debug!("Applying new local structure");
390    scope.err_if_interrupted()?;
391    db.execute_batch("DELETE FROM applyNewLocalStructureOps")?;
392
393    // Similar to the check in apply_remote_items, however we do a post check
394    // to see if dogear was unable to fix up the issue
395    let orphaned_count: i64 = db.query_row(
396        "WITH RECURSIVE orphans(id) AS (
397           SELECT b.id
398           FROM moz_bookmarks b
399           WHERE b.parent IS NOT NULL
400             AND NOT EXISTS (
401               SELECT 1 FROM moz_bookmarks p WHERE p.id = b.parent
402             )
403           UNION
404           SELECT c.id
405           FROM moz_bookmarks c
406           JOIN orphans o ON c.parent = o.id
407         )
408         SELECT COUNT(*) FROM orphans;",
409        [],
410        |row| row.get(0),
411    )?;
412
413    if orphaned_count > 0 {
414        warn!("Found {} orphaned bookmarks after sync", orphaned_count);
415        error_support::report_error!(
416            "places-sync-bookmarks-orphaned",
417            "found local orphaned bookmarks after we applied new local structure ops: {}",
418            orphaned_count,
419        );
420    }
421
422    debug!(
423        "Resetting change counters for {} items that shouldn't be uploaded",
424        ops.set_local_merged.len()
425    );
426    sql_support::each_chunk_mapped(
427        &ops.set_local_merged,
428        |op| op.merged_node.guid.as_str(),
429        |chunk, _| -> Result<()> {
430            scope.err_if_interrupted()?;
431            db.execute(
432                &format!(
433                    "UPDATE moz_bookmarks SET
434                         syncChangeCounter = 0
435                     WHERE guid IN ({})",
436                    sql_support::repeat_sql_vars(chunk.len()),
437                ),
438                rusqlite::params_from_iter(chunk),
439            )?;
440            Ok(())
441        },
442    )?;
443
444    debug!(
445        "Bumping change counters for {} items that should be uploaded",
446        ops.set_local_unmerged.len()
447    );
448    sql_support::each_chunk_mapped(
449        &ops.set_local_unmerged,
450        |op| op.merged_node.guid.as_str(),
451        |chunk, _| -> Result<()> {
452            scope.err_if_interrupted()?;
453            db.execute(
454                &format!(
455                    "UPDATE moz_bookmarks SET
456                         syncChangeCounter = 1
457                     WHERE guid IN ({})",
458                    sql_support::repeat_sql_vars(chunk.len()),
459                ),
460                rusqlite::params_from_iter(chunk),
461            )?;
462            Ok(())
463        },
464    )?;
465
466    debug!(
467        "Flagging applied {} remote items as merged",
468        ops.set_remote_merged.len()
469    );
470    sql_support::each_chunk_mapped(
471        &ops.set_remote_merged,
472        |op| op.guid().as_str(),
473        |chunk, _| -> Result<()> {
474            scope.err_if_interrupted()?;
475            db.execute(
476                &format!(
477                    "UPDATE moz_bookmarks_synced SET
478                         needsMerge = 0
479                     WHERE guid IN ({})",
480                    sql_support::repeat_sql_vars(chunk.len()),
481                ),
482                rusqlite::params_from_iter(chunk),
483            )?;
484            Ok(())
485        },
486    )?;
487
488    Ok(())
489}
490
491fn apply_remote_items(db: &PlacesDb, scope: &SqlInterruptScope, now: Timestamp) -> Result<()> {
492    // Remove all keywords from old and new URLs, and remove new keywords
493    // from all existing URLs. The `NOT NULL` conditions are important; they
494    // ensure that SQLite uses our partial indexes on `itemsToApply`,
495    // instead of a table scan.
496    debug!("Removing old keywords");
497    scope.err_if_interrupted()?;
498    db.execute_batch(
499        "DELETE FROM moz_keywords
500         WHERE place_id IN (SELECT oldPlaceId FROM itemsToApply
501                            WHERE oldPlaceId NOT NULL) OR
502               place_id IN (SELECT newPlaceId FROM itemsToApply
503                            WHERE newPlaceId NOT NULL) OR
504               keyword IN (SELECT newKeyword FROM itemsToApply
505                           WHERE newKeyword NOT NULL)",
506    )?;
507
508    debug!("Removing old tags");
509    scope.err_if_interrupted()?;
510    db.execute_batch(
511        "DELETE FROM moz_tags_relation
512         WHERE place_id IN (SELECT oldPlaceId FROM itemsToApply
513                            WHERE oldPlaceId NOT NULL) OR
514               place_id IN (SELECT newPlaceId FROM itemsToApply
515                            WHERE newPlaceId NOT NULL)",
516    )?;
517
518    // Due to bug 1935797, we try to add additional logging on what exact
519    // guids are colliding as it could shed light on what's going on
520    debug!("Checking for potential GUID collisions before upserting items");
521    let collision_check_sql = "
522        SELECT ia.localId, ia.mergedGuid, ia.remoteGuid, b.id, b.guid
523        FROM itemsToApply ia
524        JOIN moz_bookmarks b ON ia.mergedGuid = b.guid
525        WHERE (ia.localId IS NULL OR ia.localId != b.id)
526    ";
527
528    let potential_collisions: Vec<(Option<i64>, String, String, i64, String)> = db
529        .prepare(collision_check_sql)?
530        .query_map([], |row| {
531            let ia_local_id: Option<i64> = row.get(0)?;
532            let ia_merged_guid: String = row.get(1)?;
533            let ia_remote_guid: String = row.get(2)?;
534            let bmk_id: i64 = row.get(3)?;
535            let bmk_guid: String = row.get(4)?;
536            Ok((
537                ia_local_id,
538                ia_merged_guid,
539                ia_remote_guid,
540                bmk_id,
541                bmk_guid,
542            ))
543        })?
544        .filter_map(|entry| entry.ok())
545        .collect();
546
547    if !potential_collisions.is_empty() {
548        // Log details about the collisions
549        for (ia_local_id, ia_merged_guid, ia_remote_guid, bmk_id, bmk_guid) in &potential_collisions
550        {
551            error_support::breadcrumb!(
552                "Found GUID collision: ia_localId={:?}, ia_mergedGuid={}, ia_remoteGuid={}, mb_id={}, mb_guid={}",
553                ia_local_id,
554                ia_merged_guid,
555                ia_remote_guid,
556                bmk_id,
557                bmk_guid
558            );
559        }
560    }
561
562    // Due to bug 1935797, we need to check if any users have any
563    // undetected orphaned bookmarks and report them
564    let orphaned_count: i64 = db.query_row(
565        "WITH RECURSIVE orphans(id) AS (
566           SELECT b.id
567           FROM moz_bookmarks b
568           WHERE b.parent IS NOT NULL
569             AND NOT EXISTS (
570               SELECT 1 FROM moz_bookmarks p WHERE p.id = b.parent
571             )
572           UNION
573           SELECT c.id
574           FROM moz_bookmarks c
575           JOIN orphans o ON c.parent = o.id
576         )
577         SELECT COUNT(*) FROM orphans;",
578        [],
579        |row| row.get(0),
580    )?;
581
582    if orphaned_count > 0 {
583        warn!("Found {} orphaned bookmarks during sync", orphaned_count);
584        error_support::breadcrumb!(
585            "places-sync-bookmarks-orphaned: found local orphans before upsert {}",
586            orphaned_count
587        );
588    }
589
590    // Insert and update items, temporarily using the Places root for new
591    // items' parent IDs, and -1 for positions. We'll fix these up later,
592    // when we apply the new local structure. This `INSERT` is a full table
593    // scan on `itemsToApply`. The no-op `WHERE` clause is necessary to
594    // avoid a parsing ambiguity.
595    debug!("Upserting new items");
596    let upsert_sql = format!(
597        "INSERT INTO moz_bookmarks(id, guid, parent,
598                                   position, type, fk, title,
599                                   dateAdded,
600                                   lastModified,
601                                   syncStatus, syncChangeCounter)
602         SELECT localId, mergedGuid, (SELECT id FROM moz_bookmarks
603                                      WHERE guid = '{root_guid}'),
604                -1, {type_fragment}, newPlaceId, newTitle,
605                /* Pick the older of the local and remote date added. We'll
606                   weakly reupload any items with an older local date. */
607                MIN(IFNULL(localDateAdded, remoteDateAdded), remoteDateAdded),
608                /* The last modified date should always be newer than the date
609                   added, so we pick the newer of the two here. */
610                MAX(lastModified, remoteDateAdded),
611                {sync_status}, 0
612         FROM itemsToApply
613         WHERE 1
614         ON CONFLICT(id) DO UPDATE SET
615           title = excluded.title,
616           dateAdded = excluded.dateAdded,
617           lastModified = excluded.lastModified,
618           fk = excluded.fk,
619           syncStatus = {sync_status}
620       /* Due to bug 1935797, we found scenarios where users had bookmarks with GUIDs that matched
621        * incoming records BUT for one reason or another dogear doesn't believe it exists locally
622        * This handles the case where we try to insert a new bookmark with a GUID that already exists,
623        * updating the existing record instead of failing with a constraint violation.
624        * Usually the above conflict will catch most of these scenarios and there's no issue of
625        * any dupes being added here since users that hit this before would've just failed the bookmark sync
626        */
627        ON CONFLICT(guid) DO UPDATE SET
628           title = excluded.title,
629           dateAdded = excluded.dateAdded,
630           lastModified = excluded.lastModified,
631           fk = excluded.fk,
632           syncStatus = {sync_status}",
633        root_guid = BookmarkRootGuid::Root.as_guid().as_str(),
634        type_fragment = ItemTypeFragment("newKind"),
635        sync_status = SyncStatus::Normal as u8,
636    );
637
638    scope.err_if_interrupted()?;
639    let result = db.execute_batch(&upsert_sql);
640
641    // In trying to debug bug 1935797 - relaxing the trigger caused a spike on
642    // guid collisions, we want to report on this during the upsert to see
643    // if we can discern any obvious signs
644    if let Err(rusqlite::Error::SqliteFailure(e, _)) = &result {
645        if e.code == ErrorCode::ConstraintViolation {
646            error_support::report_error!(
647                "places-sync-bookmarks-constraint-violation",
648                "Hit a constraint violation {:?}",
649                result
650            );
651        }
652    }
653    // Return the original result
654    result?;
655
656    debug!("Flagging frecencies for recalculation");
657    scope.err_if_interrupted()?;
658    db.execute_batch(&format!(
659        "REPLACE INTO moz_places_stale_frecencies(place_id, stale_at)
660         SELECT oldPlaceId, {now} FROM itemsToApply
661         WHERE newKind = {bookmark_kind} AND (
662                   oldPlaceId IS NULL <> newPlaceId IS NULL OR
663                   oldPlaceId <> newPlaceId
664               )
665         UNION ALL
666         SELECT newPlaceId, {now} FROM itemsToApply
667         WHERE newKind = {bookmark_kind} AND (
668                   newPlaceId IS NULL <> oldPlaceId IS NULL OR
669                   newPlaceId <> oldPlaceId
670               )",
671        now = now,
672        bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
673    ))?;
674
675    debug!("Inserting new keywords for new URLs");
676    scope.err_if_interrupted()?;
677    db.execute_batch(
678        "INSERT OR IGNORE INTO moz_keywords(keyword, place_id)
679         SELECT newKeyword, newPlaceId
680         FROM itemsToApply
681         WHERE newKeyword NOT NULL",
682    )?;
683
684    debug!("Inserting new tags for new URLs");
685    scope.err_if_interrupted()?;
686    db.execute_batch(
687        "INSERT OR IGNORE INTO moz_tags_relation(tag_id, place_id)
688         SELECT r.tagId, n.newPlaceId
689         FROM itemsToApply n
690         JOIN moz_bookmarks_synced_tag_relation r ON r.itemId = n.remoteId",
691    )?;
692
693    Ok(())
694}
695
696/// Stores a snapshot of all locally changed items in a temporary table for
697/// upload. This is called from within the merge transaction, to ensure that
698/// changes made during the sync don't cause us to upload inconsistent
699/// records.
700///
701/// Conceptually, `itemsToUpload` is a transient "view" of locally changed
702/// items. The local change counter is the persistent record of items that
703/// we need to upload, so, if upload is interrupted or fails, we'll stage
704/// the items again on the next sync.
705fn stage_items_to_upload(
706    db: &PlacesDb,
707    scope: &SqlInterruptScope,
708    upload_items: &[UploadItem<'_>],
709    upload_tombstones: &[UploadTombstone<'_>],
710) -> Result<()> {
711    debug!("Cleaning up staged items left from last sync");
712    scope.err_if_interrupted()?;
713    db.execute_batch("DELETE FROM itemsToUpload")?;
714
715    // Stage remotely changed items with older local creation dates. These are
716    // tracked "weakly": if the upload is interrupted or fails, we won't
717    // reupload the record on the next sync.
718    debug!("Staging items with older local dates added");
719    scope.err_if_interrupted()?;
720    db.execute_batch(&format!(
721        "INSERT OR IGNORE INTO itemsToUpload(id, guid, syncChangeCounter,
722                                             parentGuid, parentTitle, dateAdded,
723                                             kind, title, placeId, url,
724                                             keyword, position)
725         {}
726         JOIN itemsToApply n ON n.mergedGuid = b.guid
727         WHERE n.localDateAdded < n.remoteDateAdded",
728        UploadItemsFragment("b")
729    ))?;
730
731    debug!(
732        "Staging {} remaining locally changed items for upload",
733        upload_items.len()
734    );
735    sql_support::each_chunk_mapped(
736        upload_items,
737        |op| op.merged_node.guid.as_str(),
738        |chunk, _| -> Result<()> {
739            let sql = format!(
740                "INSERT OR IGNORE INTO itemsToUpload(id, guid, syncChangeCounter,
741                                                  parentGuid, parentTitle,
742                                                  dateAdded, kind, title,
743                                                  placeId, url, keyword,
744                                                  position)
745                 {upload_items_fragment}
746                 WHERE b.guid IN ({vars})",
747                vars = sql_support::repeat_sql_vars(chunk.len()),
748                upload_items_fragment = UploadItemsFragment("b")
749            );
750
751            db.execute(&sql, rusqlite::params_from_iter(chunk))?;
752            Ok(())
753        },
754    )?;
755
756    // Record the child GUIDs of locally changed folders, which we use to
757    // populate the `children` array in the record.
758    debug!("Staging structure to upload");
759    scope.err_if_interrupted()?;
760    db.execute_batch(
761        "INSERT INTO structureToUpload(guid, parentId, position)
762         SELECT b.guid, b.parent, b.position
763         FROM moz_bookmarks b
764         JOIN itemsToUpload o ON o.id = b.parent",
765    )?;
766
767    // Stage tags for outgoing bookmarks.
768    debug!("Staging tags to upload");
769    scope.err_if_interrupted()?;
770    db.execute_batch(
771        "INSERT INTO tagsToUpload(id, tag)
772         SELECT o.id, t.tag
773         FROM itemsToUpload o
774         JOIN moz_tags_relation r ON r.place_id = o.placeId
775         JOIN moz_tags t ON t.id = r.tag_id",
776    )?;
777
778    // Finally, stage tombstones for deleted items.
779    debug!("Staging {} tombstones to upload", upload_tombstones.len());
780    sql_support::each_chunk_mapped(
781        upload_tombstones,
782        |op| op.guid().as_str(),
783        |chunk, _| -> Result<()> {
784            scope.err_if_interrupted()?;
785            db.execute(
786                &format!(
787                    "INSERT OR IGNORE INTO itemsToUpload(
788                     guid, syncChangeCounter, isDeleted
789                 )
790                 VALUES {}",
791                    sql_support::repeat_display(chunk.len(), ",", |_, f| write!(f, "(?, 1, 1)")),
792                ),
793                rusqlite::params_from_iter(chunk),
794            )?;
795            Ok(())
796        },
797    )?;
798
799    Ok(())
800}
801
802/// Inflates Sync records for all staged outgoing items.
803fn fetch_outgoing_records(db: &PlacesDb, scope: &SqlInterruptScope) -> Result<Vec<OutgoingBso>> {
804    let mut changes = Vec::new();
805    let mut child_record_ids_by_local_parent_id: HashMap<i64, Vec<BookmarkRecordId>> =
806        HashMap::new();
807    let mut tags_by_local_id: HashMap<i64, Vec<String>> = HashMap::new();
808
809    let mut stmt = db.prepare(
810        "SELECT parentId, guid FROM structureToUpload
811         ORDER BY parentId, position",
812    )?;
813    let mut results = stmt.query([])?;
814    while let Some(row) = results.next()? {
815        scope.err_if_interrupted()?;
816        let local_parent_id = row.get::<_, i64>("parentId")?;
817        let child_guid = row.get::<_, SyncGuid>("guid")?;
818        let child_record_ids = child_record_ids_by_local_parent_id
819            .entry(local_parent_id)
820            .or_default();
821        child_record_ids.push(child_guid.into());
822    }
823
824    let mut stmt = db.prepare("SELECT id, tag FROM tagsToUpload")?;
825    let mut results = stmt.query([])?;
826    while let Some(row) = results.next()? {
827        scope.err_if_interrupted()?;
828        let local_id = row.get::<_, i64>("id")?;
829        let tag = row.get::<_, String>("tag")?;
830        let tags = tags_by_local_id.entry(local_id).or_default();
831        tags.push(tag);
832    }
833
834    let mut stmt = db.prepare(
835        "SELECT i.id, i.syncChangeCounter, i.guid, i.isDeleted, i.kind, i.keyword,
836                i.url, IFNULL(i.title, '') AS title, i.position, i.parentGuid,
837                IFNULL(i.parentTitle, '') AS parentTitle, i.dateAdded, m.unknownFields
838         FROM itemsToUpload i
839         LEFT JOIN moz_bookmarks_synced m ON i.guid == m.guid
840         ",
841    )?;
842    let mut results = stmt.query([])?;
843    while let Some(row) = results.next()? {
844        scope.err_if_interrupted()?;
845        let guid = row.get::<_, SyncGuid>("guid")?;
846        let is_deleted = row.get::<_, bool>("isDeleted")?;
847        if is_deleted {
848            changes.push(OutgoingBso::new_tombstone(
849                BookmarkRecordId::from(guid).as_guid().clone().into(),
850            ));
851            continue;
852        }
853        let parent_guid = row.get::<_, SyncGuid>("parentGuid")?;
854        let parent_title = row.get::<_, String>("parentTitle")?;
855        let date_added = row.get::<_, i64>("dateAdded")?;
856        let unknown_fields = match row.get::<_, Option<String>>("unknownFields")? {
857            None => UnknownFields::new(),
858            Some(s) => serde_json::from_str(&s)?,
859        };
860        let record: BookmarkItemRecord = match SyncedBookmarkKind::from_u8(row.get("kind")?)? {
861            SyncedBookmarkKind::Bookmark => {
862                let local_id = row.get::<_, i64>("id")?;
863                let title = row.get::<_, String>("title")?;
864                let url = row.get::<_, String>("url")?;
865                BookmarkRecord {
866                    record_id: guid.into(),
867                    parent_record_id: Some(parent_guid.into()),
868                    parent_title: Some(parent_title),
869                    date_added: Some(date_added),
870                    has_dupe: true,
871                    title: Some(title),
872                    url: Some(url),
873                    keyword: row.get::<_, Option<String>>("keyword")?,
874                    tags: tags_by_local_id.remove(&local_id).unwrap_or_default(),
875                    unknown_fields,
876                }
877                .into()
878            }
879            SyncedBookmarkKind::Query => {
880                let title = row.get::<_, String>("title")?;
881                let url = row.get::<_, String>("url")?;
882                QueryRecord {
883                    record_id: guid.into(),
884                    parent_record_id: Some(parent_guid.into()),
885                    parent_title: Some(parent_title),
886                    date_added: Some(date_added),
887                    has_dupe: true,
888                    title: Some(title),
889                    url: Some(url),
890                    tag_folder_name: None,
891                    unknown_fields,
892                }
893                .into()
894            }
895            SyncedBookmarkKind::Folder => {
896                let title = row.get::<_, String>("title")?;
897                let local_id = row.get::<_, i64>("id")?;
898                let children = child_record_ids_by_local_parent_id
899                    .remove(&local_id)
900                    .unwrap_or_default();
901                FolderRecord {
902                    record_id: guid.into(),
903                    parent_record_id: Some(parent_guid.into()),
904                    parent_title: Some(parent_title),
905                    date_added: Some(date_added),
906                    has_dupe: true,
907                    title: Some(title),
908                    children,
909                    unknown_fields,
910                }
911                .into()
912            }
913            SyncedBookmarkKind::Livemark => continue,
914            SyncedBookmarkKind::Separator => {
915                let position = row.get::<_, i64>("position")?;
916                SeparatorRecord {
917                    record_id: guid.into(),
918                    parent_record_id: Some(parent_guid.into()),
919                    parent_title: Some(parent_title),
920                    date_added: Some(date_added),
921                    has_dupe: true,
922                    position: Some(position),
923                    unknown_fields,
924                }
925                .into()
926            }
927        };
928        changes.push(OutgoingBso::from_content_with_id(record)?);
929    }
930
931    Ok(changes)
932}
933
934/// Decrements the change counter, updates the sync status, and cleans up
935/// tombstones for successfully synced items. Sync calls this method at the
936/// end of each bookmark sync.
937fn push_synced_items(
938    db: &PlacesDb,
939    scope: &SqlInterruptScope,
940    uploaded_at: ServerTimestamp,
941    records_synced: Vec<SyncGuid>,
942) -> Result<()> {
943    // Flag all successfully synced records as uploaded. This `UPDATE` fires
944    // the `pushUploadedChanges` trigger, which updates local change
945    // counters and writes the items back to the synced bookmarks table.
946    let mut tx = db.begin_transaction()?;
947
948    let guids = records_synced
949        .into_iter()
950        .map(|id| BookmarkRecordId::from_payload_id(id).into())
951        .collect::<Vec<SyncGuid>>();
952    sql_support::each_chunk(&guids, |chunk, _| -> Result<()> {
953        db.execute(
954            &format!(
955                "UPDATE itemsToUpload SET
956                     uploadedAt = {uploaded_at}
957                     WHERE guid IN ({values})",
958                uploaded_at = uploaded_at.as_millis(),
959                values = sql_support::repeat_sql_values(chunk.len())
960            ),
961            rusqlite::params_from_iter(chunk),
962        )?;
963        tx.maybe_commit()?;
964        scope.err_if_interrupted()?;
965        Ok(())
966    })?;
967
968    // Fast-forward the last sync time, so that we don't download the
969    // records we just uploaded on the next sync.
970    put_meta(db, LAST_SYNC_META_KEY, &uploaded_at.as_millis())?;
971
972    // Clean up.
973    db.execute_batch("DELETE FROM itemsToUpload")?;
974    tx.commit()?;
975
976    Ok(())
977}
978
979pub(crate) fn update_frecencies(db: &PlacesDb, scope: &SqlInterruptScope) -> Result<()> {
980    let mut tx = db.begin_transaction()?;
981
982    let mut frecencies = Vec::with_capacity(MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK);
983    loop {
984        let sql = format!(
985            "SELECT place_id FROM moz_places_stale_frecencies
986             ORDER BY stale_at DESC
987             LIMIT {}",
988            MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK
989        );
990        let mut stmt = db.prepare_maybe_cached(&sql, true)?;
991        let mut results = stmt.query([])?;
992        while let Some(row) = results.next()? {
993            let place_id = row.get("place_id")?;
994            // Frecency recalculation runs several statements, so check to
995            // make sure we aren't interrupted before each calculation.
996            scope.err_if_interrupted()?;
997            let frecency =
998                calculate_frecency(db, &DEFAULT_FRECENCY_SETTINGS, place_id, Some(false))?;
999            frecencies.push((place_id, frecency));
1000        }
1001        if frecencies.is_empty() {
1002            break;
1003        }
1004
1005        // Update all frecencies in one fell swoop...
1006        db.execute_batch(&format!(
1007            "WITH frecencies(id, frecency) AS (
1008               VALUES {}
1009             )
1010             UPDATE moz_places SET
1011               frecency = (SELECT frecency FROM frecencies f
1012                           WHERE f.id = id)
1013             WHERE id IN (SELECT f.id FROM frecencies f)",
1014            sql_support::repeat_display(frecencies.len(), ",", |index, f| {
1015                let (id, frecency) = frecencies[index];
1016                write!(f, "({}, {})", id, frecency)
1017            })
1018        ))?;
1019        tx.maybe_commit()?;
1020        scope.err_if_interrupted()?;
1021
1022        // ...And remove them from the stale table.
1023        db.execute_batch(&format!(
1024            "DELETE FROM moz_places_stale_frecencies
1025             WHERE place_id IN ({})",
1026            sql_support::repeat_display(frecencies.len(), ",", |index, f| {
1027                let (id, _) = frecencies[index];
1028                write!(f, "{}", id)
1029            })
1030        ))?;
1031        tx.maybe_commit()?;
1032        scope.err_if_interrupted()?;
1033
1034        // If the query returned fewer URLs than the maximum, we're done.
1035        // Otherwise, we might have more, so clear the ones we just
1036        // recalculated and fetch the next chunk.
1037        if frecencies.len() < MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK {
1038            break;
1039        }
1040        frecencies.clear();
1041    }
1042
1043    tx.commit()?;
1044
1045    Ok(())
1046}
1047
1048// Short-lived struct that's constructed each sync
1049pub struct BookmarksSyncEngine {
1050    db: Arc<SharedPlacesDb>,
1051    // Pub so that it can be used by the PlacesApi methods.  Once all syncing goes through the
1052    // `SyncManager` we should be able to make this private.
1053    pub(crate) scope: SqlInterruptScope,
1054}
1055
1056impl BookmarksSyncEngine {
1057    pub fn new(db: Arc<SharedPlacesDb>) -> Result<Self> {
1058        Ok(Self {
1059            scope: db.begin_interrupt_scope()?,
1060            db,
1061        })
1062    }
1063}
1064
1065impl SyncEngine for BookmarksSyncEngine {
1066    #[inline]
1067    fn collection_name(&self) -> CollectionName {
1068        COLLECTION_NAME.into()
1069    }
1070
1071    fn stage_incoming(
1072        &self,
1073        inbound: Vec<IncomingBso>,
1074        telem: &mut telemetry::Engine,
1075    ) -> anyhow::Result<()> {
1076        let conn = self.db.lock();
1077        // Stage all incoming items.
1078        let mut incoming_telemetry = telemetry::EngineIncoming::new();
1079        stage_incoming(&conn, &self.scope, inbound, &mut incoming_telemetry)?;
1080        telem.incoming(incoming_telemetry);
1081        Ok(())
1082    }
1083
1084    fn apply(
1085        &self,
1086        timestamp: ServerTimestamp,
1087        telem: &mut telemetry::Engine,
1088    ) -> anyhow::Result<Vec<OutgoingBso>> {
1089        let conn = self.db.lock();
1090        // write the timestamp now, so if we are interrupted merging or
1091        // creating outgoing changesets we don't need to re-apply the same
1092        // records.
1093        put_meta(&conn, LAST_SYNC_META_KEY, &timestamp.as_millis())?;
1094
1095        // Merge.
1096        let mut merger = Merger::with_telemetry(&conn, &self.scope, timestamp, telem);
1097        merger.merge()?;
1098        Ok(fetch_outgoing_records(&conn, &self.scope)?)
1099    }
1100
1101    fn set_uploaded(
1102        &self,
1103        new_timestamp: ServerTimestamp,
1104        ids: Vec<SyncGuid>,
1105    ) -> anyhow::Result<()> {
1106        let conn = self.db.lock();
1107        push_synced_items(&conn, &self.scope, new_timestamp, ids)?;
1108        Ok(update_frecencies(&conn, &self.scope)?)
1109    }
1110
1111    fn sync_finished(&self) -> anyhow::Result<()> {
1112        let conn = self.db.lock();
1113        conn.pragma_update(None, "wal_checkpoint", "PASSIVE")?;
1114        Ok(())
1115    }
1116
1117    fn get_collection_request(
1118        &self,
1119        server_timestamp: ServerTimestamp,
1120    ) -> anyhow::Result<Option<CollectionRequest>> {
1121        let conn = self.db.lock();
1122        let since =
1123            ServerTimestamp(get_meta::<i64>(&conn, LAST_SYNC_META_KEY)?.unwrap_or_default());
1124        Ok(if since == server_timestamp {
1125            None
1126        } else {
1127            Some(
1128                CollectionRequest::new(self.collection_name())
1129                    .full()
1130                    .newer_than(since),
1131            )
1132        })
1133    }
1134
1135    fn get_sync_assoc(&self) -> anyhow::Result<EngineSyncAssociation> {
1136        let conn = self.db.lock();
1137        let global = get_meta(&conn, GLOBAL_SYNCID_META_KEY)?;
1138        let coll = get_meta(&conn, COLLECTION_SYNCID_META_KEY)?;
1139        Ok(if let (Some(global), Some(coll)) = (global, coll) {
1140            EngineSyncAssociation::Connected(CollSyncIds { global, coll })
1141        } else {
1142            EngineSyncAssociation::Disconnected
1143        })
1144    }
1145
1146    fn reset(&self, assoc: &EngineSyncAssociation) -> anyhow::Result<()> {
1147        let conn = self.db.lock();
1148        reset(&conn, assoc)?;
1149        Ok(())
1150    }
1151
1152    /// Erases all local items. Unlike `reset`, this keeps all synced items
1153    /// until the next sync, when they will be replaced with tombstones. This
1154    /// also preserves the sync ID and last sync time.
1155    ///
1156    /// Conceptually, the next sync will merge an empty local tree, and a full
1157    /// remote tree.
1158    fn wipe(&self) -> anyhow::Result<()> {
1159        let conn = self.db.lock();
1160        let tx = conn.begin_transaction()?;
1161        let sql = format!(
1162            "INSERT INTO moz_bookmarks_deleted(guid, dateRemoved)
1163             SELECT guid, now()
1164             FROM moz_bookmarks
1165             WHERE guid NOT IN {roots} AND
1166                   syncStatus = {sync_status};
1167
1168             UPDATE moz_bookmarks SET
1169               syncChangeCounter = syncChangeCounter + 1
1170             WHERE guid IN {roots};
1171
1172             DELETE FROM moz_bookmarks
1173             WHERE guid NOT IN {roots};",
1174            roots = RootsFragment(&[
1175                BookmarkRootGuid::Root,
1176                BookmarkRootGuid::Menu,
1177                BookmarkRootGuid::Mobile,
1178                BookmarkRootGuid::Toolbar,
1179                BookmarkRootGuid::Unfiled
1180            ]),
1181            sync_status = SyncStatus::Normal as u8
1182        );
1183        conn.execute_batch(&sql)?;
1184        create_synced_bookmark_roots(&conn)?;
1185        tx.commit()?;
1186        Ok(())
1187    }
1188}
1189
1190#[derive(Default)]
1191struct Driver {
1192    validation: RefCell<telemetry::Validation>,
1193}
1194
1195impl dogear::Driver for Driver {
1196    fn generate_new_guid(&self, _invalid_guid: &dogear::Guid) -> dogear::Result<dogear::Guid> {
1197        Ok(SyncGuid::random().as_str().into())
1198    }
1199
1200    fn record_telemetry_event(&self, event: TelemetryEvent) {
1201        // Record validation telemetry for remote trees.
1202        if let TelemetryEvent::FetchRemoteTree(stats) = event {
1203            self.validation
1204                .borrow_mut()
1205                .problem("orphans", stats.problems.orphans)
1206                .problem("misparentedRoots", stats.problems.misparented_roots)
1207                .problem(
1208                    "multipleParents",
1209                    stats.problems.multiple_parents_by_children,
1210                )
1211                .problem("missingParents", stats.problems.missing_parent_guids)
1212                .problem("nonFolderParents", stats.problems.non_folder_parent_guids)
1213                .problem(
1214                    "parentChildDisagreements",
1215                    stats.problems.parent_child_disagreements,
1216                )
1217                .problem("missingChildren", stats.problems.missing_children);
1218        }
1219    }
1220}
1221
1222// The "merger", which is just a thin wrapper for dogear.
1223pub(crate) struct Merger<'a> {
1224    db: &'a PlacesDb,
1225    scope: &'a SqlInterruptScope,
1226    remote_time: ServerTimestamp,
1227    local_time: Timestamp,
1228    // Used for where the merger is not the one which should be managing the
1229    // transaction, e.g. in the case of bookmarks import. The only impact this has
1230    // is on the `apply()` function. Always false unless the caller explicitly
1231    // turns it on, to avoid accidentally enabling unintentionally.
1232    external_transaction: bool,
1233    telem: Option<&'a mut telemetry::Engine>,
1234    // Allows us to abort applying the result of the merge if the local tree
1235    // changed since we fetched it.
1236    global_change_tracker: GlobalChangeCounterTracker,
1237}
1238
1239impl<'a> Merger<'a> {
1240    #[cfg(test)]
1241    pub(crate) fn new(
1242        db: &'a PlacesDb,
1243        scope: &'a SqlInterruptScope,
1244        remote_time: ServerTimestamp,
1245    ) -> Self {
1246        Self {
1247            db,
1248            scope,
1249            remote_time,
1250            local_time: Timestamp::now(),
1251            external_transaction: false,
1252            telem: None,
1253            global_change_tracker: db.global_bookmark_change_tracker(),
1254        }
1255    }
1256
1257    pub(crate) fn with_telemetry(
1258        db: &'a PlacesDb,
1259        scope: &'a SqlInterruptScope,
1260        remote_time: ServerTimestamp,
1261        telem: &'a mut telemetry::Engine,
1262    ) -> Self {
1263        Self {
1264            db,
1265            scope,
1266            remote_time,
1267            local_time: Timestamp::now(),
1268            external_transaction: false,
1269            telem: Some(telem),
1270            global_change_tracker: db.global_bookmark_change_tracker(),
1271        }
1272    }
1273
1274    #[cfg(test)]
1275    fn with_localtime(
1276        db: &'a PlacesDb,
1277        scope: &'a SqlInterruptScope,
1278        remote_time: ServerTimestamp,
1279        local_time: Timestamp,
1280    ) -> Self {
1281        Self {
1282            db,
1283            scope,
1284            remote_time,
1285            local_time,
1286            external_transaction: false,
1287            telem: None,
1288            global_change_tracker: db.global_bookmark_change_tracker(),
1289        }
1290    }
1291
1292    pub(crate) fn merge(&mut self) -> Result<()> {
1293        use dogear::Store;
1294        if !db_has_changes(self.db)? {
1295            return Ok(());
1296        }
1297        // Merge and stage outgoing items via dogear.
1298        let driver = Driver::default();
1299        self.prepare()?;
1300        let result = self.merge_with_driver(&driver, &MergeInterruptee(self.scope));
1301        debug!("merge completed: {:?}", result);
1302
1303        // Record telemetry in all cases, even if the merge fails.
1304        if let Some(ref mut telem) = self.telem {
1305            telem.validation(driver.validation.into_inner());
1306        }
1307        result
1308    }
1309
1310    /// Prepares synced bookmarks for merging.
1311    fn prepare(&self) -> Result<()> {
1312        // Sync and Fennec associate keywords with bookmarks, and don't sync
1313        // POST data; Rust Places associates them with URLs, and also doesn't
1314        // support POST data; Desktop associates keywords with (URL, POST data)
1315        // pairs, and multiple bookmarks may have the same URL.
1316        //
1317        // When a keyword changes, clients should reupload all bookmarks with
1318        // the affected URL (bug 1328737). Just in case, we flag any synced
1319        // bookmarks that have different keywords for the same URL, or the same
1320        // keyword for different URLs, for reupload.
1321        self.scope.err_if_interrupted()?;
1322        debug!("Flagging bookmarks with mismatched keywords for reupload");
1323        let sql = format!(
1324            "UPDATE moz_bookmarks_synced SET
1325               validity = {reupload}
1326             WHERE validity = {valid} AND (
1327               placeId IN (
1328                 /* Same URL, different keywords. `COUNT` ignores NULLs, so
1329                    we need to count them separately. This handles cases where
1330                    a keyword was removed from one, but not all bookmarks with
1331                    the same URL. */
1332                 SELECT placeId FROM moz_bookmarks_synced
1333                 GROUP BY placeId
1334                 HAVING COUNT(DISTINCT keyword) +
1335                        COUNT(DISTINCT CASE WHEN keyword IS NULL
1336                                       THEN 1 END) > 1
1337               ) OR keyword IN (
1338                 /* Different URLs, same keyword. Bookmarks with keywords but
1339                    without URLs are already invalid, so we don't need to handle
1340                    NULLs here. */
1341                 SELECT keyword FROM moz_bookmarks_synced
1342                 WHERE keyword NOT NULL
1343                 GROUP BY keyword
1344                 HAVING COUNT(DISTINCT placeId) > 1
1345               )
1346             )",
1347            reupload = SyncedBookmarkValidity::Reupload as u8,
1348            valid = SyncedBookmarkValidity::Valid as u8,
1349        );
1350        self.db.execute_batch(&sql)?;
1351
1352        // Like keywords, Sync associates tags with bookmarks, but Places
1353        // associates them with URLs. This means multiple bookmarks with the
1354        // same URL should have the same tags. In practice, different tags for
1355        // bookmarks with the same URL are some of the most common validation
1356        // errors we see.
1357        //
1358        // Unlike keywords, the relationship between URLs and tags in many-many:
1359        // multiple URLs can have the same tag, and a URL can have multiple
1360        // tags. So, to find mismatches, we need to compare the tags for each
1361        // URL with the tags for each item.
1362        //
1363        // We could fetch both lists of tags, sort them, and then compare them.
1364        // But there's a trick here: we're only interested in whether the tags
1365        // _match_, not the tags themselves. So we sum the tag IDs!
1366        //
1367        // This has two advantages: we don't have to sort IDs, since addition is
1368        // commutative, and we can compare two integers much more efficiently
1369        // than two string lists! If a bookmark has mismatched tags, the sum of
1370        // its tag IDs in `tagsByItemId` won't match the sum in `tagsByPlaceId`,
1371        // and we'll flag the item for reupload.
1372        self.scope.err_if_interrupted()?;
1373        debug!("Flagging bookmarks with mismatched tags for reupload");
1374        let sql = format!(
1375            "WITH
1376             tagsByPlaceId(placeId, tagIds) AS (
1377                 /* For multiple bookmarks with the same URL, each group will
1378                    have one tag per bookmark. So, if bookmarks A1, A2, and A3
1379                    have the same URL A with tag T, T will be in the group three
1380                    times. But we only want to count each tag once per URL, so
1381                    we use `SUM(DISTINCT)`. */
1382                 SELECT v.placeId, SUM(DISTINCT t.tagId)
1383                 FROM moz_bookmarks_synced v
1384                 JOIN moz_bookmarks_synced_tag_relation t ON t.itemId = v.id
1385                 WHERE v.placeId NOT NULL
1386                 GROUP BY v.placeId
1387             ),
1388             tagsByItemId(itemId, tagIds) AS (
1389                 /* But here, we can use a plain `SUM`, since we're grouping by
1390                    item ID, and an item can't have duplicate tags thanks to the
1391                    primary key on the relation table. */
1392                 SELECT t.itemId, SUM(t.tagId)
1393                 FROM moz_bookmarks_synced_tag_relation t
1394                 GROUP BY t.itemId
1395             )
1396             UPDATE moz_bookmarks_synced SET
1397                 validity = {reupload}
1398             WHERE validity = {valid} AND id IN (
1399                 SELECT v.id FROM moz_bookmarks_synced v
1400                 JOIN tagsByPlaceId u ON v.placeId = u.placeId
1401                 /* This left join is important: if A1 has tags and A2 doesn't,
1402                    we want to flag A2 for reupload. */
1403                 LEFT JOIN tagsByItemId t ON t.itemId = v.id
1404                 /* Unlike `<>`, `IS NOT` compares NULLs. */
1405                 WHERE t.tagIds IS NOT u.tagIds
1406             )",
1407            reupload = SyncedBookmarkValidity::Reupload as u8,
1408            valid = SyncedBookmarkValidity::Valid as u8,
1409        );
1410        self.db.execute_batch(&sql)?;
1411
1412        Ok(())
1413    }
1414
1415    /// Creates a local tree item from a row in the `localItems` CTE.
1416    fn local_row_to_item(&self, row: &Row<'_>) -> Result<(Item, Option<Content>)> {
1417        let guid = row.get::<_, SyncGuid>("guid")?;
1418        let url_href = row.get::<_, Option<String>>("url")?;
1419        let kind = match row.get::<_, BookmarkType>("type")? {
1420            BookmarkType::Bookmark => match url_href.as_ref() {
1421                Some(u) if u.starts_with("place:") => SyncedBookmarkKind::Query,
1422                _ => SyncedBookmarkKind::Bookmark,
1423            },
1424            BookmarkType::Folder => SyncedBookmarkKind::Folder,
1425            BookmarkType::Separator => SyncedBookmarkKind::Separator,
1426        };
1427        let mut item = Item::new(guid.as_str().into(), kind.into());
1428        // Note that this doesn't account for local clock skew.
1429        let age = self
1430            .local_time
1431            .duration_since(row.get::<_, Timestamp>("localModified")?)
1432            .unwrap_or_default();
1433        item.age = age.as_secs() as i64 * 1000 + i64::from(age.subsec_millis());
1434        item.needs_merge = row.get::<_, u32>("syncChangeCounter")? > 0;
1435
1436        let content = if item.guid == dogear::ROOT_GUID {
1437            None
1438        } else {
1439            match row.get::<_, SyncStatus>("syncStatus")? {
1440                SyncStatus::Normal => None,
1441                _ => match kind {
1442                    SyncedBookmarkKind::Bookmark | SyncedBookmarkKind::Query => {
1443                        let title = row.get::<_, String>("title")?;
1444                        url_href.map(|url_href| Content::Bookmark { title, url_href })
1445                    }
1446                    SyncedBookmarkKind::Folder | SyncedBookmarkKind::Livemark => {
1447                        let title = row.get::<_, String>("title")?;
1448                        Some(Content::Folder { title })
1449                    }
1450                    SyncedBookmarkKind::Separator => Some(Content::Separator),
1451                },
1452            }
1453        };
1454
1455        Ok((item, content))
1456    }
1457
1458    /// Creates a remote tree item from a row in `moz_bookmarks_synced`.
1459    fn remote_row_to_item(&self, row: &Row<'_>) -> Result<(Item, Option<Content>)> {
1460        let guid = row.get::<_, SyncGuid>("guid")?;
1461        let kind = SyncedBookmarkKind::from_u8(row.get("kind")?)?;
1462        let mut item = Item::new(guid.as_str().into(), kind.into());
1463        // note that serverModified in this table is an int with ms, which isn't
1464        // the format of a ServerTimestamp - so we convert it into a number
1465        // of seconds before creating a ServerTimestamp and doing duration_since.
1466        let age = self
1467            .remote_time
1468            .duration_since(ServerTimestamp(row.get::<_, i64>("serverModified")?))
1469            .unwrap_or_default();
1470        item.age = age.as_secs() as i64 * 1000 + i64::from(age.subsec_millis());
1471        item.needs_merge = row.get("needsMerge")?;
1472        item.validity = SyncedBookmarkValidity::from_u8(row.get("validity")?)?.into();
1473
1474        let content = if item.guid == dogear::ROOT_GUID || !item.needs_merge {
1475            None
1476        } else {
1477            match kind {
1478                SyncedBookmarkKind::Bookmark | SyncedBookmarkKind::Query => {
1479                    let title = row.get::<_, String>("title")?;
1480                    let url_href = row.get::<_, Option<String>>("url")?;
1481                    url_href.map(|url_href| Content::Bookmark { title, url_href })
1482                }
1483                SyncedBookmarkKind::Folder | SyncedBookmarkKind::Livemark => {
1484                    let title = row.get::<_, String>("title")?;
1485                    Some(Content::Folder { title })
1486                }
1487                SyncedBookmarkKind::Separator => Some(Content::Separator),
1488            }
1489        };
1490
1491        Ok((item, content))
1492    }
1493}
1494
1495impl dogear::Store for Merger<'_> {
1496    type Ok = ();
1497    type Error = Error;
1498
1499    /// Builds a fully rooted, consistent tree from all local items and
1500    /// tombstones.
1501    fn fetch_local_tree(&self) -> Result<Tree> {
1502        let mut stmt = self.db.prepare(&format!(
1503            "SELECT guid, type, syncChangeCounter, syncStatus,
1504                    lastModified AS localModified,
1505                    NULL AS url
1506             FROM moz_bookmarks
1507             WHERE guid = '{root_guid}'",
1508            root_guid = BookmarkRootGuid::Root.as_guid().as_str(),
1509        ))?;
1510        let mut results = stmt.query([])?;
1511        let mut builder = match results.next()? {
1512            Some(row) => {
1513                let (item, _) = self.local_row_to_item(row)?;
1514                Tree::with_root(item)
1515            }
1516            None => return Err(Error::Corruption(Corruption::InvalidLocalRoots)),
1517        };
1518
1519        // Add items and contents to the builder, keeping track of their
1520        // structure in a separate map. We can't call `p.by_structure(...)`
1521        // after adding the item, because this query might return rows for
1522        // children before their parents. This approach also lets us scan
1523        // `moz_bookmarks` once, using the index on `(b.parent, b.position)`
1524        // to avoid a temp B-tree for the `ORDER BY`.
1525        let mut child_guids_by_parent_guid: HashMap<SyncGuid, Vec<dogear::Guid>> = HashMap::new();
1526        let mut stmt = self.db.prepare(&format!(
1527            "SELECT b.guid, p.guid AS parentGuid, b.type, b.syncChangeCounter,
1528                    b.syncStatus, b.lastModified AS localModified,
1529                    IFNULL(b.title, '') AS title,
1530                    {url_fragment} AS url
1531             FROM moz_bookmarks b
1532             JOIN moz_bookmarks p ON p.id = b.parent
1533             WHERE b.guid <> '{root_guid}'
1534             ORDER BY b.parent, b.position",
1535            url_fragment = UrlOrPlaceIdFragment::PlaceId("b.fk"),
1536            root_guid = BookmarkRootGuid::Root.as_guid().as_str(),
1537        ))?;
1538        let mut results = stmt.query([])?;
1539
1540        while let Some(row) = results.next()? {
1541            self.scope.err_if_interrupted()?;
1542
1543            let (item, content) = self.local_row_to_item(row)?;
1544
1545            let parent_guid = row.get::<_, SyncGuid>("parentGuid")?;
1546            child_guids_by_parent_guid
1547                .entry(parent_guid)
1548                .or_default()
1549                .push(item.guid.clone());
1550
1551            let mut p = builder.item(item)?;
1552            if let Some(content) = content {
1553                p.content(content);
1554            }
1555        }
1556
1557        // At this point, we've added entries for all items to the tree, so
1558        // we can add their structure info.
1559        for (parent_guid, child_guids) in &child_guids_by_parent_guid {
1560            for child_guid in child_guids {
1561                self.scope.err_if_interrupted()?;
1562                builder
1563                    .parent_for(child_guid)
1564                    .by_structure(&parent_guid.as_str().into())?;
1565            }
1566        }
1567
1568        // Note tombstones for locally deleted items.
1569        let mut stmt = self.db.prepare("SELECT guid FROM moz_bookmarks_deleted")?;
1570        let mut results = stmt.query([])?;
1571        while let Some(row) = results.next()? {
1572            self.scope.err_if_interrupted()?;
1573            let guid = row.get::<_, SyncGuid>("guid")?;
1574            builder.deletion(guid.as_str().into());
1575        }
1576
1577        let tree = Tree::try_from(builder)?;
1578        Ok(tree)
1579    }
1580
1581    /// Builds a fully rooted tree from all synced items and tombstones.
1582    fn fetch_remote_tree(&self) -> Result<Tree> {
1583        // Unlike the local tree, items and structure are stored separately, so
1584        // we use three separate statements to fetch the root, its descendants,
1585        // and their structure.
1586        let sql = format!(
1587            "SELECT guid, serverModified, kind, needsMerge, validity
1588             FROM moz_bookmarks_synced
1589             WHERE NOT isDeleted AND
1590                   guid = '{root_guid}'",
1591            root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1592        );
1593        let mut builder = self
1594            .db
1595            .try_query_row(
1596                &sql,
1597                [],
1598                |row| -> Result<_> {
1599                    let (root, _) = self.remote_row_to_item(row)?;
1600                    Ok(Tree::with_root(root))
1601                },
1602                false,
1603            )?
1604            .ok_or(Error::Corruption(Corruption::InvalidSyncedRoots))?;
1605        builder.reparent_orphans_to(&dogear::UNFILED_GUID);
1606
1607        let sql = format!(
1608            "SELECT v.guid, v.parentGuid, v.serverModified, v.kind,
1609                    IFNULL(v.title, '') AS title, v.needsMerge, v.validity,
1610                    v.isDeleted, {url_fragment} AS url
1611             FROM moz_bookmarks_synced v
1612             WHERE v.guid <> '{root_guid}'
1613             ORDER BY v.guid",
1614            url_fragment = UrlOrPlaceIdFragment::PlaceId("v.placeId"),
1615            root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1616        );
1617        let mut stmt = self.db.prepare(&sql)?;
1618        let mut results = stmt.query([])?;
1619        while let Some(row) = results.next()? {
1620            self.scope.err_if_interrupted()?;
1621
1622            let is_deleted = row.get::<_, bool>("isDeleted")?;
1623            if is_deleted {
1624                let needs_merge = row.get::<_, bool>("needsMerge")?;
1625                if !needs_merge {
1626                    // Ignore already-merged tombstones. These aren't persisted
1627                    // locally, so merging them is a no-op.
1628                    continue;
1629                }
1630                let guid = row.get::<_, SyncGuid>("guid")?;
1631                builder.deletion(guid.as_str().into());
1632            } else {
1633                let (item, content) = self.remote_row_to_item(row)?;
1634                let mut p = builder.item(item)?;
1635                if let Some(content) = content {
1636                    p.content(content);
1637                }
1638                if let Some(parent_guid) = row.get::<_, Option<SyncGuid>>("parentGuid")? {
1639                    p.by_parent_guid(parent_guid.as_str().into())?;
1640                }
1641            }
1642        }
1643
1644        let sql = format!(
1645            "SELECT guid, parentGuid FROM moz_bookmarks_synced_structure
1646             WHERE guid <> '{root_guid}'
1647             ORDER BY parentGuid, position",
1648            root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1649        );
1650        let mut stmt = self.db.prepare(&sql)?;
1651        let mut results = stmt.query([])?;
1652        while let Some(row) = results.next()? {
1653            self.scope.err_if_interrupted()?;
1654            let guid = row.get::<_, SyncGuid>("guid")?;
1655            let parent_guid = row.get::<_, SyncGuid>("parentGuid")?;
1656            builder
1657                .parent_for(&guid.as_str().into())
1658                .by_children(&parent_guid.as_str().into())?;
1659        }
1660
1661        let tree = Tree::try_from(builder)?;
1662        Ok(tree)
1663    }
1664
1665    fn apply(&mut self, root: MergedRoot<'_>) -> Result<()> {
1666        let ops = root.completion_ops_with_signal(&MergeInterruptee(self.scope))?;
1667
1668        if ops.is_empty() {
1669            // If we don't have any items to apply, upload, or delete,
1670            // no need to open a transaction at all.
1671            return Ok(());
1672        }
1673
1674        let tx = if !self.external_transaction {
1675            Some(self.db.begin_transaction()?)
1676        } else {
1677            None
1678        };
1679
1680        // If the local tree has changed since we started the merge, we abort
1681        // in the expectation it will succeed next time.
1682        if self.global_change_tracker.changed() {
1683            info!("Aborting update of local items as local tree changed while merging");
1684            if let Some(tx) = tx {
1685                tx.rollback()?;
1686            }
1687            return Ok(());
1688        }
1689
1690        debug!("Updating local items in Places");
1691        update_local_items_in_places(self.db, self.scope, self.local_time, &ops)?;
1692
1693        debug!(
1694            "Staging {} items and {} tombstones to upload",
1695            ops.upload_items.len(),
1696            ops.upload_tombstones.len()
1697        );
1698        stage_items_to_upload(
1699            self.db,
1700            self.scope,
1701            &ops.upload_items,
1702            &ops.upload_tombstones,
1703        )?;
1704
1705        self.db.execute_batch("DELETE FROM itemsToApply;")?;
1706        if let Some(tx) = tx {
1707            tx.commit()?;
1708        }
1709        Ok(())
1710    }
1711}
1712
1713/// A helper that formats an optional value so that it can be included in a SQL
1714/// statement. `None` values become SQL `NULL`s.
1715struct NullableFragment<T>(Option<T>);
1716
1717impl<T> fmt::Display for NullableFragment<T>
1718where
1719    T: fmt::Display,
1720{
1721    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1722        match &self.0 {
1723            Some(v) => v.fmt(f),
1724            None => write!(f, "NULL"),
1725        }
1726    }
1727}
1728
1729/// A helper that interpolates a SQL `CASE` expression for converting a synced
1730/// item kind to a local item type. The expression evaluates to `NULL` if the
1731/// kind is unknown.
1732struct ItemTypeFragment(&'static str);
1733
1734impl fmt::Display for ItemTypeFragment {
1735    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1736        write!(
1737            f,
1738            "(CASE WHEN {col} IN ({bookmark_kind}, {query_kind})
1739                        THEN {bookmark_type}
1740                   WHEN {col} IN ({folder_kind}, {livemark_kind})
1741                        THEN {folder_type}
1742                   WHEN {col} = {separator_kind}
1743                        THEN {separator_type}
1744              END)",
1745            col = self.0,
1746            bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
1747            query_kind = SyncedBookmarkKind::Query as u8,
1748            bookmark_type = BookmarkType::Bookmark as u8,
1749            folder_kind = SyncedBookmarkKind::Folder as u8,
1750            livemark_kind = SyncedBookmarkKind::Livemark as u8,
1751            folder_type = BookmarkType::Folder as u8,
1752            separator_kind = SyncedBookmarkKind::Separator as u8,
1753            separator_type = BookmarkType::Separator as u8,
1754        )
1755    }
1756}
1757
1758/// Formats a `SELECT` statement for staging local items in the `itemsToUpload`
1759/// table.
1760struct UploadItemsFragment(&'static str);
1761
1762impl fmt::Display for UploadItemsFragment {
1763    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1764        write!(
1765            f,
1766            "SELECT {alias}.id, {alias}.guid, {alias}.syncChangeCounter,
1767                    p.guid AS parentGuid, p.title AS parentTitle,
1768                    {alias}.dateAdded, {kind_fragment} AS kind,
1769                    {alias}.title, h.id AS placeId, h.url,
1770                    (SELECT k.keyword FROM moz_keywords k
1771                     WHERE k.place_id = h.id) AS keyword,
1772                    {alias}.position
1773                FROM moz_bookmarks {alias}
1774                JOIN moz_bookmarks p ON p.id = {alias}.parent
1775                LEFT JOIN moz_places h ON h.id = {alias}.fk",
1776            alias = self.0,
1777            kind_fragment = item_kind_fragment(self.0, "type", UrlOrPlaceIdFragment::Url("h.url")),
1778        )
1779    }
1780}
1781
1782/// A helper that interpolates a named SQL common table expression (CTE) for
1783/// local items. The CTE may be included in a `WITH RECURSIVE` clause.
1784struct LocalItemsFragment<'a>(&'a str);
1785
1786impl fmt::Display for LocalItemsFragment<'_> {
1787    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1788        write!(
1789            f,
1790            "{name}(id, guid, parentId, parentGuid, position, type, title, parentTitle,
1791                    placeId, dateAdded, lastModified, syncChangeCounter, level) AS (
1792             SELECT b.id, b.guid, 0, NULL, b.position, b.type, b.title, NULL,
1793                    b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, 0
1794             FROM moz_bookmarks b
1795             WHERE b.guid = '{root_guid}'
1796             UNION ALL
1797             SELECT b.id, b.guid, s.id, s.guid, b.position, b.type, b.title, s.title,
1798                    b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, s.level + 1
1799             FROM moz_bookmarks b
1800             JOIN {name} s ON s.id = b.parent)",
1801            name = self.0,
1802            root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1803        )
1804    }
1805}
1806
1807fn item_kind_fragment(
1808    table_name: &'static str,
1809    type_column_name: &'static str,
1810    url_or_place_id_fragment: UrlOrPlaceIdFragment,
1811) -> ItemKindFragment {
1812    ItemKindFragment {
1813        table_name,
1814        type_column_name,
1815        url_or_place_id_fragment,
1816    }
1817}
1818
1819/// A helper that interpolates a SQL `CASE` expression for converting a local
1820/// item type to a synced item kind. The expression evaluates to `NULL` if the
1821/// type is unknown.
1822struct ItemKindFragment {
1823    /// The name of the Places bookmarks table.
1824    table_name: &'static str,
1825    /// The name of the column containing the Places item type.
1826    type_column_name: &'static str,
1827    /// The column containing the item's URL or Place ID.
1828    url_or_place_id_fragment: UrlOrPlaceIdFragment,
1829}
1830
1831impl fmt::Display for ItemKindFragment {
1832    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1833        write!(
1834            f,
1835            "(CASE {table_name}.{type_column_name}
1836              WHEN {bookmark_type} THEN (
1837                  CASE substr({url}, 1, 6)
1838                  /* Queries are bookmarks with a 'place:' URL scheme. */
1839                  WHEN 'place:' THEN {query_kind}
1840                  ELSE {bookmark_kind}
1841                  END
1842              )
1843              WHEN {folder_type} THEN {folder_kind}
1844              WHEN {separator_type} THEN {separator_kind}
1845              END)",
1846            table_name = self.table_name,
1847            type_column_name = self.type_column_name,
1848            bookmark_type = BookmarkType::Bookmark as u8,
1849            url = self.url_or_place_id_fragment,
1850            query_kind = SyncedBookmarkKind::Query as u8,
1851            bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
1852            folder_type = BookmarkType::Folder as u8,
1853            folder_kind = SyncedBookmarkKind::Folder as u8,
1854            separator_type = BookmarkType::Separator as u8,
1855            separator_kind = SyncedBookmarkKind::Separator as u8,
1856        )
1857    }
1858}
1859
1860/// A helper that interpolates a SQL expression for querying a local item's
1861/// URL. Note that the `&'static str` for each variant specifies the _name of
1862/// the column_ containing the URL or ID, not the URL or ID itself.
1863enum UrlOrPlaceIdFragment {
1864    /// The name of the column containing the URL. This avoids a subquery if
1865    /// a column for the URL already exists in the query.
1866    Url(&'static str),
1867    /// The name of the column containing the Place ID. This writes out a
1868    /// subquery to look up the URL.
1869    PlaceId(&'static str),
1870}
1871
1872impl fmt::Display for UrlOrPlaceIdFragment {
1873    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1874        match self {
1875            UrlOrPlaceIdFragment::Url(s) => write!(f, "{}", s),
1876            UrlOrPlaceIdFragment::PlaceId(s) => {
1877                write!(f, "(SELECT h.url FROM moz_places h WHERE h.id = {})", s)
1878            }
1879        }
1880    }
1881}
1882
1883/// A helper that interpolates a SQL list containing the given bookmark
1884/// root GUIDs.
1885struct RootsFragment<'a>(&'a [BookmarkRootGuid]);
1886
1887impl fmt::Display for RootsFragment<'_> {
1888    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1889        f.write_str("(")?;
1890        for (i, guid) in self.0.iter().enumerate() {
1891            if i != 0 {
1892                f.write_str(",")?;
1893            }
1894            write!(f, "'{}'", guid.as_str())?;
1895        }
1896        f.write_str(")")
1897    }
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902    use super::*;
1903    use crate::api::places_api::{test::new_mem_api, ConnectionType, PlacesApi};
1904    use crate::bookmark_sync::tests::SyncedBookmarkItem;
1905    use crate::db::PlacesDb;
1906    use crate::storage::{
1907        bookmarks::{
1908            get_raw_bookmark, insert_bookmark, update_bookmark, BookmarkPosition,
1909            InsertableBookmark, UpdatableBookmark, USER_CONTENT_ROOTS,
1910        },
1911        history::frecency_stale_at,
1912        tags,
1913    };
1914    use crate::tests::{
1915        assert_json_tree as assert_local_json_tree, insert_json_tree as insert_local_json_tree,
1916    };
1917    use dogear::{Store as DogearStore, Validity};
1918    use rusqlite::{Error as RusqlError, ErrorCode};
1919    use serde_json::{json, Value};
1920    use std::{
1921        borrow::Cow,
1922        time::{Duration, SystemTime},
1923    };
1924    use sync15::bso::{IncomingBso, IncomingKind};
1925    use sync15::engine::CollSyncIds;
1926    use sync_guid::Guid;
1927    use url::Url;
1928
1929    // A helper type to simplify writing table-driven tests with synced items.
1930    struct ExpectedSyncedItem<'a>(SyncGuid, Cow<'a, SyncedBookmarkItem>);
1931
1932    impl<'a> ExpectedSyncedItem<'a> {
1933        fn new(
1934            guid: impl Into<SyncGuid>,
1935            expected: &'a SyncedBookmarkItem,
1936        ) -> ExpectedSyncedItem<'a> {
1937            ExpectedSyncedItem(guid.into(), Cow::Borrowed(expected))
1938        }
1939
1940        fn with_properties(
1941            guid: impl Into<SyncGuid>,
1942            expected: &'a SyncedBookmarkItem,
1943            f: impl FnOnce(&mut SyncedBookmarkItem) -> &mut SyncedBookmarkItem + 'static,
1944        ) -> ExpectedSyncedItem<'a> {
1945            let mut expected = expected.clone();
1946            f(&mut expected);
1947            ExpectedSyncedItem(guid.into(), Cow::Owned(expected))
1948        }
1949
1950        fn check(&self, conn: &PlacesDb) -> Result<()> {
1951            let actual =
1952                SyncedBookmarkItem::get(conn, &self.0)?.expect("Expected synced item should exist");
1953            assert_eq!(&actual, &*self.1);
1954            Ok(())
1955        }
1956    }
1957
1958    fn create_sync_engine(api: &PlacesApi) -> BookmarksSyncEngine {
1959        BookmarksSyncEngine::new(api.get_sync_connection().unwrap()).unwrap()
1960    }
1961
1962    fn engine_apply_incoming(
1963        engine: &BookmarksSyncEngine,
1964        incoming: Vec<IncomingBso>,
1965    ) -> Vec<OutgoingBso> {
1966        let mut telem = telemetry::Engine::new(engine.collection_name());
1967        engine
1968            .stage_incoming(incoming, &mut telem)
1969            .expect("Should stage incoming");
1970        engine
1971            .apply(ServerTimestamp(0), &mut telem)
1972            .expect("Should apply")
1973    }
1974
1975    // Applies the incoming records, and also "finishes" the sync by pretending
1976    // we uploaded the outgoing items and marks them as uploaded.
1977    // Returns the GUIDs of the outgoing items.
1978    fn apply_incoming(
1979        api: &PlacesApi,
1980        remote_time: ServerTimestamp,
1981        records_json: Value,
1982    ) -> Vec<Guid> {
1983        // suck records into the engine.
1984        let engine = create_sync_engine(api);
1985
1986        let incoming = match records_json {
1987            Value::Array(records) => records
1988                .into_iter()
1989                .map(|record| IncomingBso::from_test_content_ts(record, remote_time))
1990                .collect(),
1991            Value::Object(_) => {
1992                vec![IncomingBso::from_test_content_ts(records_json, remote_time)]
1993            }
1994            _ => panic!("unexpected json value"),
1995        };
1996
1997        engine_apply_incoming(&engine, incoming);
1998
1999        let sync_db = api.get_sync_connection().unwrap();
2000        let syncer = sync_db.lock();
2001        let mut stmt = syncer
2002            .prepare("SELECT guid FROM itemsToUpload")
2003            .expect("Should prepare statement to fetch uploaded GUIDs");
2004        let uploaded_guids: Vec<Guid> = stmt
2005            .query_and_then([], |row| -> rusqlite::Result<_> { row.get::<_, Guid>(0) })
2006            .expect("Should fetch uploaded GUIDs")
2007            .map(std::result::Result::unwrap)
2008            .collect();
2009
2010        push_synced_items(&syncer, &engine.scope, remote_time, uploaded_guids.clone())
2011            .expect("Should push synced changes back to the engine");
2012        uploaded_guids
2013    }
2014
2015    fn assert_incoming_creates_local_tree(
2016        api: &PlacesApi,
2017        records_json: Value,
2018        local_folder: &SyncGuid,
2019        local_tree: Value,
2020    ) {
2021        apply_incoming(api, ServerTimestamp(0), records_json);
2022        assert_local_json_tree(
2023            &api.get_sync_connection().unwrap().lock(),
2024            local_folder,
2025            local_tree,
2026        );
2027    }
2028
2029    #[test]
2030    fn test_fetch_remote_tree() -> Result<()> {
2031        let records = vec![
2032            json!({
2033                "id": "qqVTRWhLBOu3",
2034                "type": "bookmark",
2035                "parentid": "unfiled",
2036                "parentName": "Unfiled Bookmarks",
2037                "dateAdded": 1_381_542_355_843u64,
2038                "title": "The title",
2039                "bmkUri": "https://example.com",
2040                "tags": [],
2041            }),
2042            json!({
2043                "id": "unfiled",
2044                "type": "folder",
2045                "parentid": "places",
2046                "parentName": "",
2047                "dateAdded": 0,
2048                "title": "Unfiled Bookmarks",
2049                "children": ["qqVTRWhLBOu3"],
2050                "tags": [],
2051            }),
2052        ];
2053
2054        let api = new_mem_api();
2055        let db = api.get_sync_connection().unwrap();
2056        let conn = db.lock();
2057
2058        // suck records into the database.
2059        let interrupt_scope = conn.begin_interrupt_scope()?;
2060
2061        let incoming = records
2062            .into_iter()
2063            .map(IncomingBso::from_test_content)
2064            .collect();
2065
2066        stage_incoming(
2067            &conn,
2068            &interrupt_scope,
2069            incoming,
2070            &mut telemetry::EngineIncoming::new(),
2071        )
2072        .expect("Should apply incoming and stage outgoing records");
2073
2074        let merger = Merger::new(&conn, &interrupt_scope, ServerTimestamp(0));
2075
2076        let tree = merger.fetch_remote_tree()?;
2077
2078        // should be each user root, plus the real root, plus the bookmark we added.
2079        assert_eq!(tree.guids().count(), USER_CONTENT_ROOTS.len() + 2);
2080
2081        let node = tree
2082            .node_for_guid(&"qqVTRWhLBOu3".into())
2083            .expect("should exist");
2084        assert!(node.needs_merge);
2085        assert_eq!(node.validity, Validity::Valid);
2086        assert_eq!(node.level(), 2);
2087        assert!(node.is_syncable());
2088
2089        let node = tree
2090            .node_for_guid(&BookmarkRootGuid::Unfiled.as_guid().as_str().into())
2091            .expect("should exist");
2092        assert!(node.needs_merge);
2093        assert_eq!(node.validity, Validity::Valid);
2094        assert_eq!(node.level(), 1);
2095        assert!(node.is_syncable());
2096
2097        let node = tree
2098            .node_for_guid(&BookmarkRootGuid::Menu.as_guid().as_str().into())
2099            .expect("should exist");
2100        assert!(!node.needs_merge);
2101        assert_eq!(node.validity, Validity::Valid);
2102        assert_eq!(node.level(), 1);
2103        assert!(node.is_syncable());
2104
2105        let node = tree
2106            .node_for_guid(&BookmarkRootGuid::Root.as_guid().as_str().into())
2107            .expect("should exist");
2108        assert_eq!(node.validity, Validity::Valid);
2109        assert_eq!(node.level(), 0);
2110        assert!(!node.is_syncable());
2111
2112        // We should have changes.
2113        assert!(db_has_changes(&conn).unwrap());
2114        Ok(())
2115    }
2116
2117    #[test]
2118    fn test_fetch_local_tree() -> Result<()> {
2119        let now = SystemTime::now();
2120        let previously_ts: Timestamp = (now - Duration::new(10, 0)).into();
2121        let api = new_mem_api();
2122        let writer = api.open_connection(ConnectionType::ReadWrite)?;
2123        let sync_db = api.get_sync_connection().unwrap();
2124        let syncer = sync_db.lock();
2125
2126        writer
2127            .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
2128            .expect("should work");
2129
2130        insert_local_json_tree(
2131            &writer,
2132            json!({
2133                "guid": &BookmarkRootGuid::Unfiled.as_guid(),
2134                "children": [
2135                    {
2136                        "guid": "bookmark1___",
2137                        "title": "the bookmark",
2138                        "url": "https://www.example.com/",
2139                        "last_modified": previously_ts,
2140                        "date_added": previously_ts,
2141                    },
2142                ]
2143            }),
2144        );
2145
2146        let interrupt_scope = syncer.begin_interrupt_scope()?;
2147        let merger =
2148            Merger::with_localtime(&syncer, &interrupt_scope, ServerTimestamp(0), now.into());
2149
2150        let tree = merger.fetch_local_tree()?;
2151
2152        // should be each user root, plus the real root, plus the bookmark we added.
2153        assert_eq!(tree.guids().count(), USER_CONTENT_ROOTS.len() + 2);
2154
2155        let node = tree
2156            .node_for_guid(&"bookmark1___".into())
2157            .expect("should exist");
2158        assert!(node.needs_merge);
2159        assert_eq!(node.level(), 2);
2160        assert!(node.is_syncable());
2161        assert_eq!(node.age, 10000);
2162
2163        let node = tree
2164            .node_for_guid(&BookmarkRootGuid::Unfiled.as_guid().as_str().into())
2165            .expect("should exist");
2166        assert!(node.needs_merge);
2167        assert_eq!(node.level(), 1);
2168        assert!(node.is_syncable());
2169
2170        let node = tree
2171            .node_for_guid(&BookmarkRootGuid::Menu.as_guid().as_str().into())
2172            .expect("should exist");
2173        assert!(!node.needs_merge);
2174        assert_eq!(node.level(), 1);
2175        assert!(node.is_syncable());
2176
2177        let node = tree
2178            .node_for_guid(&BookmarkRootGuid::Root.as_guid().as_str().into())
2179            .expect("should exist");
2180        assert!(!node.needs_merge);
2181        assert_eq!(node.level(), 0);
2182        assert!(!node.is_syncable());
2183        // hard to know the exact age of the root, but we know the max.
2184        let max_dur = SystemTime::now().duration_since(now).unwrap();
2185        let max_age = max_dur.as_secs() as i64 * 1000 + i64::from(max_dur.subsec_millis());
2186        assert!(node.age <= max_age);
2187
2188        // We should have changes.
2189        assert!(db_has_changes(&syncer).unwrap());
2190        Ok(())
2191    }
2192
2193    #[test]
2194    fn test_apply_bookmark() {
2195        let api = new_mem_api();
2196        assert_incoming_creates_local_tree(
2197            &api,
2198            json!([{
2199                "id": "bookmark1___",
2200                "type": "bookmark",
2201                "parentid": "unfiled",
2202                "parentName": "Unfiled Bookmarks",
2203                "dateAdded": 1_381_542_355_843u64,
2204                "title": "Some bookmark",
2205                "bmkUri": "http://example.com",
2206            },
2207            {
2208                "id": "unfiled",
2209                "type": "folder",
2210                "parentid": "places",
2211                "dateAdded": 1_381_542_355_843u64,
2212                "title": "Unfiled",
2213                "children": ["bookmark1___"],
2214            }]),
2215            &BookmarkRootGuid::Unfiled.as_guid(),
2216            json!({"children" : [{"guid": "bookmark1___", "url": "http://example.com"}]}),
2217        );
2218        let reader = api
2219            .open_connection(ConnectionType::ReadOnly)
2220            .expect("Should open read-only connection");
2221        assert!(
2222            frecency_stale_at(&reader, &Url::parse("http://example.com").unwrap())
2223                .expect("Should check stale frecency")
2224                .is_some(),
2225            "Should mark frecency for bookmark URL as stale"
2226        );
2227
2228        let writer = api
2229            .open_connection(ConnectionType::ReadWrite)
2230            .expect("Should open read-write connection");
2231        insert_local_json_tree(
2232            &writer,
2233            json!({
2234                "guid": &BookmarkRootGuid::Menu.as_guid(),
2235                "children": [
2236                    {
2237                        "guid": "bookmark2___",
2238                        "title": "2",
2239                        "url": "http://example.com/2",
2240                    }
2241                ],
2242            }),
2243        );
2244        assert_incoming_creates_local_tree(
2245            &api,
2246            json!([{
2247                "id": "menu",
2248                "type": "folder",
2249                "parentid": "places",
2250                "parentName": "",
2251                "dateAdded": 0,
2252                "title": "menu",
2253                "children": ["bookmark2___"],
2254            }, {
2255                "id": "bookmark2___",
2256                "type": "bookmark",
2257                "parentid": "menu",
2258                "parentName": "menu",
2259                "dateAdded": 1_381_542_355_843u64,
2260                "title": "2",
2261                "bmkUri": "http://example.com/2-remote",
2262            }]),
2263            &BookmarkRootGuid::Menu.as_guid(),
2264            json!({"children" : [{"guid": "bookmark2___", "url": "http://example.com/2-remote"}]}),
2265        );
2266        assert!(
2267            frecency_stale_at(&reader, &Url::parse("http://example.com/2").unwrap())
2268                .expect("Should check stale frecency for old URL")
2269                .is_some(),
2270            "Should mark frecency for old URL as stale"
2271        );
2272        assert!(
2273            frecency_stale_at(&reader, &Url::parse("http://example.com/2-remote").unwrap())
2274                .expect("Should check stale frecency for new URL")
2275                .is_some(),
2276            "Should mark frecency for new URL as stale"
2277        );
2278
2279        let sync_db = api.get_sync_connection().unwrap();
2280        let syncer = sync_db.lock();
2281        let interrupt_scope = syncer.begin_interrupt_scope().unwrap();
2282
2283        update_frecencies(&syncer, &interrupt_scope).expect("Should update frecencies");
2284
2285        assert!(
2286            frecency_stale_at(&reader, &Url::parse("http://example.com").unwrap())
2287                .expect("Should check stale frecency")
2288                .is_none(),
2289            "Should recalculate frecency for first bookmark"
2290        );
2291        assert!(
2292            frecency_stale_at(&reader, &Url::parse("http://example.com/2").unwrap())
2293                .expect("Should check stale frecency for old URL")
2294                .is_none(),
2295            "Should recalculate frecency for old URL"
2296        );
2297        assert!(
2298            frecency_stale_at(&reader, &Url::parse("http://example.com/2-remote").unwrap())
2299                .expect("Should check stale frecency for new URL")
2300                .is_none(),
2301            "Should recalculate frecency for new URL"
2302        );
2303    }
2304
2305    #[test]
2306    fn test_apply_complex_bookmark_tags() -> Result<()> {
2307        let api = new_mem_api();
2308        let writer = api.open_connection(ConnectionType::ReadWrite)?;
2309
2310        // Insert two local bookmarks with the same URL A (so they'll have
2311        // identical tags) and a third with a different URL B, but one same
2312        // tag as A.
2313        let local_bookmarks = vec![
2314            InsertableBookmark {
2315                parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2316                position: BookmarkPosition::Append,
2317                date_added: None,
2318                last_modified: None,
2319                guid: Some("bookmarkAAA1".into()),
2320                url: Url::parse("http://example.com/a").unwrap(),
2321                title: Some("A1".into()),
2322            }
2323            .into(),
2324            InsertableBookmark {
2325                parent_guid: BookmarkRootGuid::Menu.as_guid(),
2326                position: BookmarkPosition::Append,
2327                date_added: None,
2328                last_modified: None,
2329                guid: Some("bookmarkAAA2".into()),
2330                url: Url::parse("http://example.com/a").unwrap(),
2331                title: Some("A2".into()),
2332            }
2333            .into(),
2334            InsertableBookmark {
2335                parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2336                position: BookmarkPosition::Append,
2337                date_added: None,
2338                last_modified: None,
2339                guid: Some("bookmarkBBBB".into()),
2340                url: Url::parse("http://example.com/b").unwrap(),
2341                title: Some("B".into()),
2342            }
2343            .into(),
2344        ];
2345        let local_tags = &[
2346            ("http://example.com/a", vec!["one", "two"]),
2347            (
2348                "http://example.com/b",
2349                // Local duplicate tags should be ignored.
2350                vec!["two", "three", "three", "four"],
2351            ),
2352        ];
2353        for bm in local_bookmarks.into_iter() {
2354            insert_bookmark(&writer, bm)?;
2355        }
2356        for (url, tags) in local_tags {
2357            let url = Url::parse(url)?;
2358            for t in tags.iter() {
2359                tags::tag_url(&writer, &url, t)?;
2360            }
2361        }
2362
2363        // Now for some fun server data. Only B, C, and F2 have problems;
2364        // D and E are fine, and shouldn't be reuploaded.
2365        let remote_records = json!([{
2366            // Change B's tags on the server, and duplicate `two` for good
2367            // measure. We should reupload B with only one `two` tag.
2368            "id": "bookmarkBBBB",
2369            "type": "bookmark",
2370            "parentid": "unfiled",
2371            "parentName": "Unfiled",
2372            "dateAdded": 1_381_542_355_843u64,
2373            "title": "B",
2374            "bmkUri": "http://example.com/b",
2375            "tags": ["two", "two", "three", "eight"],
2376        }, {
2377            // C is an example of bad data on the server: bookmarks with the
2378            // same URL should have the same tags, but C1/C2 have different tags
2379            // than C3. We should reupload all of them.
2380            "id": "bookmarkCCC1",
2381            "type": "bookmark",
2382            "parentid": "unfiled",
2383            "parentName": "Unfiled",
2384            "dateAdded": 1_381_542_355_843u64,
2385            "title": "C1",
2386            "bmkUri": "http://example.com/c",
2387            "tags": ["four", "five", "six"],
2388        }, {
2389            "id": "bookmarkCCC2",
2390            "type": "bookmark",
2391            "parentid": "menu",
2392            "parentName": "Menu",
2393            "dateAdded": 1_381_542_355_843u64,
2394            "title": "C2",
2395            "bmkUri": "http://example.com/c",
2396            "tags": ["four", "five", "six"],
2397        }, {
2398            "id": "bookmarkCCC3",
2399            "type": "bookmark",
2400            "parentid": "menu",
2401            "parentName": "Menu",
2402            "dateAdded": 1_381_542_355_843u64,
2403            "title": "C3",
2404            "bmkUri": "http://example.com/c",
2405            "tags": ["six", "six", "seven"],
2406        }, {
2407            // D has the same tags as C1/2, but a different URL. This is
2408            // perfectly fine, since URLs and tags are many-many! D also
2409            // isn't duplicated, so it'll be filtered out by the
2410            // `HAVING COUNT(*) > 1` clause.
2411            "id": "bookmarkDDDD",
2412            "type": "bookmark",
2413            "parentid": "unfiled",
2414            "parentName": "Unfiled",
2415            "dateAdded": 1_381_542_355_843u64,
2416            "title": "D",
2417            "bmkUri": "http://example.com/d",
2418            "tags": ["four", "five", "six"],
2419        }, {
2420            // E1 and E2 have the same URLs and the same tags, so we shouldn't
2421            // reupload either.
2422            "id": "bookmarkEEE1",
2423            "type": "bookmark",
2424            "parentid": "toolbar",
2425            "parentName": "Toolbar",
2426            "dateAdded": 1_381_542_355_843u64,
2427            "title": "E1",
2428            "bmkUri": "http://example.com/e",
2429            "tags": ["nine", "ten", "eleven"],
2430        }, {
2431            "id": "bookmarkEEE2",
2432            "type": "bookmark",
2433            "parentid": "mobile",
2434            "parentName": "Mobile",
2435            "dateAdded": 1_381_542_355_843u64,
2436            "title": "E2",
2437            "bmkUri": "http://example.com/e",
2438            "tags": ["nine", "ten", "eleven"],
2439        }, {
2440            // F1 and F2 have mismatched tags, but with a twist: F2 doesn't
2441            // have _any_ tags! We should only reupload F2.
2442            "id": "bookmarkFFF1",
2443            "type": "bookmark",
2444            "parentid": "toolbar",
2445            "parentName": "Toolbar",
2446            "dateAdded": 1_381_542_355_843u64,
2447            "title": "F1",
2448            "bmkUri": "http://example.com/f",
2449            "tags": ["twelve"],
2450        }, {
2451            "id": "bookmarkFFF2",
2452            "type": "bookmark",
2453            "parentid": "mobile",
2454            "parentName": "Mobile",
2455            "dateAdded": 1_381_542_355_843u64,
2456            "title": "F2",
2457            "bmkUri": "http://example.com/f",
2458        }, {
2459            "id": "unfiled",
2460            "type": "folder",
2461            "parentid": "places",
2462            "dateAdded": 1_381_542_355_843u64,
2463            "title": "Unfiled",
2464            "children": ["bookmarkBBBB", "bookmarkCCC1", "bookmarkDDDD"],
2465        }, {
2466            "id": "menu",
2467            "type": "folder",
2468            "parentid": "places",
2469            "dateAdded": 1_381_542_355_843u64,
2470            "title": "Menu",
2471            "children": ["bookmarkCCC2", "bookmarkCCC3"],
2472        }, {
2473            "id": "toolbar",
2474            "type": "folder",
2475            "parentid": "places",
2476            "dateAdded": 1_381_542_355_843u64,
2477            "title": "Toolbar",
2478            "children": ["bookmarkEEE1", "bookmarkFFF1"],
2479        }, {
2480            "id": "mobile",
2481            "type": "folder",
2482            "parentid": "places",
2483            "dateAdded": 1_381_542_355_843u64,
2484            "title": "Mobile",
2485            "children": ["bookmarkEEE2", "bookmarkFFF2"],
2486        }]);
2487
2488        // Boilerplate to apply incoming records, since we want to check
2489        // outgoing record contents.
2490        let engine = create_sync_engine(&api);
2491        let incoming = if let Value::Array(records) = remote_records {
2492            records
2493                .into_iter()
2494                .map(IncomingBso::from_test_content)
2495                .collect()
2496        } else {
2497            unreachable!("JSON records must be an array");
2498        };
2499        let mut outgoing = engine_apply_incoming(&engine, incoming);
2500        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
2501
2502        // Verify that we applied all incoming records correctly.
2503        assert_local_json_tree(
2504            &writer,
2505            &BookmarkRootGuid::Root.as_guid(),
2506            json!({
2507                "guid": &BookmarkRootGuid::Root.as_guid(),
2508                "children": [{
2509                    "guid": &BookmarkRootGuid::Menu.as_guid(),
2510                    "children": [{
2511                        "guid": "bookmarkCCC2",
2512                        "title": "C2",
2513                        "url": "http://example.com/c",
2514                    }, {
2515                        "guid": "bookmarkCCC3",
2516                        "title": "C3",
2517                        "url": "http://example.com/c",
2518                    }, {
2519                        "guid": "bookmarkAAA2",
2520                        "title": "A2",
2521                        "url": "http://example.com/a",
2522                    }],
2523                }, {
2524                    "guid": &BookmarkRootGuid::Toolbar.as_guid(),
2525                    "children": [{
2526                        "guid": "bookmarkEEE1",
2527                        "title": "E1",
2528                        "url": "http://example.com/e",
2529                    }, {
2530                        "guid": "bookmarkFFF1",
2531                        "title": "F1",
2532                        "url": "http://example.com/f",
2533                    }],
2534                }, {
2535                    "guid": &BookmarkRootGuid::Unfiled.as_guid(),
2536                    "children": [{
2537                        "guid": "bookmarkBBBB",
2538                        "title": "B",
2539                        "url": "http://example.com/b",
2540                    }, {
2541                        "guid": "bookmarkCCC1",
2542                        "title": "C1",
2543                        "url": "http://example.com/c",
2544                    }, {
2545                        "guid": "bookmarkDDDD",
2546                        "title": "D",
2547                        "url": "http://example.com/d",
2548                    }, {
2549                        "guid": "bookmarkAAA1",
2550                        "title": "A1",
2551                        "url": "http://example.com/a",
2552                    }],
2553                }, {
2554                    "guid": &BookmarkRootGuid::Mobile.as_guid(),
2555                    "children": [{
2556                        "guid": "bookmarkEEE2",
2557                        "title": "E2",
2558                        "url": "http://example.com/e",
2559                    }, {
2560                        "guid": "bookmarkFFF2",
2561                        "title": "F2",
2562                        "url": "http://example.com/f",
2563                    }],
2564                }],
2565            }),
2566        );
2567        // And verify our local tags are correct, too.
2568        let expected_local_tags = &[
2569            ("http://example.com/a", vec!["one", "two"]),
2570            ("http://example.com/b", vec!["eight", "three", "two"]),
2571            ("http://example.com/c", vec!["five", "four", "seven", "six"]),
2572            ("http://example.com/d", vec!["five", "four", "six"]),
2573            ("http://example.com/e", vec!["eleven", "nine", "ten"]),
2574            ("http://example.com/f", vec!["twelve"]),
2575        ];
2576        for (href, expected) in expected_local_tags {
2577            let mut actual = tags::get_tags_for_url(&writer, &Url::parse(href).unwrap())?;
2578            actual.sort();
2579            assert_eq!(&actual, expected);
2580        }
2581
2582        let expected_outgoing_ids = &[
2583            "bookmarkAAA1", // A is new locally.
2584            "bookmarkAAA2",
2585            "bookmarkBBBB", // B has a duplicate tag.
2586            "bookmarkCCC1", // C has mismatched tags.
2587            "bookmarkCCC2",
2588            "bookmarkCCC3",
2589            "bookmarkFFF2", // F2 is missing tags.
2590            "menu",         // Roots always get uploaded on the first sync.
2591            "mobile",
2592            "toolbar",
2593            "unfiled",
2594        ];
2595        assert_eq!(
2596            outgoing
2597                .iter()
2598                .map(|p| p.envelope.id.as_str())
2599                .collect::<Vec<_>>(),
2600            expected_outgoing_ids,
2601            "Should upload new bookmarks and fix up tags",
2602        );
2603
2604        // Now push the records back to the engine, so we can check what we're
2605        // uploading.
2606        engine
2607            .set_uploaded(
2608                ServerTimestamp(0),
2609                expected_outgoing_ids.iter().map(SyncGuid::from).collect(),
2610            )
2611            .expect("Should push synced changes back to the engine");
2612        engine.sync_finished().expect("should work");
2613
2614        // A and C should have the same URL and tags, and should be valid now.
2615        // Because the builder methods take a `&mut SyncedBookmarkItem`, and we
2616        // want to hang on to our base items for cloning later, we can't use
2617        // one-liners to create them.
2618        let mut synced_item_for_a = SyncedBookmarkItem::new();
2619        synced_item_for_a
2620            .validity(SyncedBookmarkValidity::Valid)
2621            .kind(SyncedBookmarkKind::Bookmark)
2622            .url(Some("http://example.com/a"))
2623            .tags(["one", "two"].iter().map(|&tag| tag.into()).collect());
2624        let mut synced_item_for_b = SyncedBookmarkItem::new();
2625        synced_item_for_b
2626            .validity(SyncedBookmarkValidity::Valid)
2627            .kind(SyncedBookmarkKind::Bookmark)
2628            .url(Some("http://example.com/b"))
2629            .tags(
2630                ["eight", "three", "two"]
2631                    .iter()
2632                    .map(|&tag| tag.into())
2633                    .collect(),
2634            )
2635            .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2636            .title(Some("B"));
2637        let mut synced_item_for_c = SyncedBookmarkItem::new();
2638        synced_item_for_c
2639            .validity(SyncedBookmarkValidity::Valid)
2640            .kind(SyncedBookmarkKind::Bookmark)
2641            .url(Some("http://example.com/c"))
2642            .tags(
2643                ["five", "four", "seven", "six"]
2644                    .iter()
2645                    .map(|&tag| tag.into())
2646                    .collect(),
2647            );
2648        let mut synced_item_for_f = SyncedBookmarkItem::new();
2649        synced_item_for_f
2650            .validity(SyncedBookmarkValidity::Valid)
2651            .kind(SyncedBookmarkKind::Bookmark)
2652            .url(Some("http://example.com/f"))
2653            .tags(vec!["twelve".into()]);
2654        // A table-driven test to clean up some of the boilerplate. We clone
2655        // the base item for each test, and pass it to the boxed closure to set
2656        // additional properties.
2657        let expected_synced_items = &[
2658            ExpectedSyncedItem::with_properties("bookmarkAAA1", &synced_item_for_a, |a| {
2659                a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2660                    .title(Some("A1"))
2661            }),
2662            ExpectedSyncedItem::with_properties("bookmarkAAA2", &synced_item_for_a, |a| {
2663                a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2664                    .title(Some("A2"))
2665            }),
2666            ExpectedSyncedItem::new("bookmarkBBBB", &synced_item_for_b),
2667            ExpectedSyncedItem::with_properties("bookmarkCCC1", &synced_item_for_c, |c| {
2668                c.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2669                    .title(Some("C1"))
2670            }),
2671            ExpectedSyncedItem::with_properties("bookmarkCCC2", &synced_item_for_c, |c| {
2672                c.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2673                    .title(Some("C2"))
2674            }),
2675            ExpectedSyncedItem::with_properties("bookmarkCCC3", &synced_item_for_c, |c| {
2676                c.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2677                    .title(Some("C3"))
2678            }),
2679            ExpectedSyncedItem::with_properties(
2680                // We didn't reupload F1, but let's make sure it's still valid.
2681                "bookmarkFFF1",
2682                &synced_item_for_f,
2683                |f| {
2684                    f.parent_guid(Some(&BookmarkRootGuid::Toolbar.as_guid()))
2685                        .title(Some("F1"))
2686                },
2687            ),
2688            ExpectedSyncedItem::with_properties("bookmarkFFF2", &synced_item_for_f, |f| {
2689                f.parent_guid(Some(&BookmarkRootGuid::Mobile.as_guid()))
2690                    .title(Some("F2"))
2691            }),
2692        ];
2693        for item in expected_synced_items {
2694            item.check(&writer)?;
2695        }
2696
2697        Ok(())
2698    }
2699
2700    #[test]
2701    fn test_apply_bookmark_tags() -> Result<()> {
2702        let api = new_mem_api();
2703        let writer = api.open_connection(ConnectionType::ReadWrite)?;
2704
2705        // Insert local item with tagged URL.
2706        insert_bookmark(
2707            &writer,
2708            InsertableBookmark {
2709                parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2710                position: BookmarkPosition::Append,
2711                date_added: None,
2712                last_modified: None,
2713                guid: Some("bookmarkAAAA".into()),
2714                url: Url::parse("http://example.com/a").unwrap(),
2715                title: Some("A".into()),
2716            }
2717            .into(),
2718        )?;
2719        tags::tag_url(&writer, &Url::parse("http://example.com/a").unwrap(), "one")?;
2720
2721        let mut tags_for_a =
2722            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/a").unwrap())?;
2723        tags_for_a.sort();
2724        assert_eq!(tags_for_a, vec!["one".to_owned()]);
2725
2726        assert_incoming_creates_local_tree(
2727            &api,
2728            json!([{
2729                "id": "bookmarkBBBB",
2730                "type": "bookmark",
2731                "parentid": "unfiled",
2732                "parentName": "Unfiled",
2733                "dateAdded": 1_381_542_355_843u64,
2734                "title": "B",
2735                "bmkUri": "http://example.com/b",
2736                "tags": ["one", "two"],
2737            }, {
2738                "id": "bookmarkCCCC",
2739                "type": "bookmark",
2740                "parentid": "unfiled",
2741                "parentName": "Unfiled",
2742                "dateAdded": 1_381_542_355_843u64,
2743                "title": "C",
2744                "bmkUri": "http://example.com/c",
2745                "tags": ["three"],
2746            }, {
2747                "id": "unfiled",
2748                "type": "folder",
2749                "parentid": "places",
2750                "dateAdded": 1_381_542_355_843u64,
2751                "title": "Unfiled",
2752                "children": ["bookmarkBBBB", "bookmarkCCCC"],
2753            }]),
2754            &BookmarkRootGuid::Unfiled.as_guid(),
2755            json!({"children" : [
2756                  {"guid": "bookmarkBBBB", "url": "http://example.com/b"},
2757                  {"guid": "bookmarkCCCC", "url": "http://example.com/c"},
2758                  {"guid": "bookmarkAAAA", "url": "http://example.com/a"},
2759            ]}),
2760        );
2761
2762        let mut tags_for_a =
2763            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/a").unwrap())?;
2764        tags_for_a.sort();
2765        assert_eq!(tags_for_a, vec!["one".to_owned()]);
2766
2767        let mut tags_for_b =
2768            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/b").unwrap())?;
2769        tags_for_b.sort();
2770        assert_eq!(tags_for_b, vec!["one".to_owned(), "two".to_owned()]);
2771
2772        let mut tags_for_c =
2773            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/c").unwrap())?;
2774        tags_for_c.sort();
2775        assert_eq!(tags_for_c, vec!["three".to_owned()]);
2776
2777        let synced_item_for_a = SyncedBookmarkItem::get(&writer, &"bookmarkAAAA".into())
2778            .expect("Should fetch A")
2779            .expect("A should exist");
2780        assert_eq!(
2781            synced_item_for_a,
2782            *SyncedBookmarkItem::new()
2783                .validity(SyncedBookmarkValidity::Valid)
2784                .kind(SyncedBookmarkKind::Bookmark)
2785                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2786                .title(Some("A"))
2787                .url(Some("http://example.com/a"))
2788                .tags(vec!["one".into()])
2789        );
2790
2791        let synced_item_for_b = SyncedBookmarkItem::get(&writer, &"bookmarkBBBB".into())
2792            .expect("Should fetch B")
2793            .expect("B should exist");
2794        assert_eq!(
2795            synced_item_for_b,
2796            *SyncedBookmarkItem::new()
2797                .validity(SyncedBookmarkValidity::Valid)
2798                .kind(SyncedBookmarkKind::Bookmark)
2799                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2800                .title(Some("B"))
2801                .url(Some("http://example.com/b"))
2802                .tags(vec!["one".into(), "two".into()])
2803        );
2804
2805        Ok(())
2806    }
2807
2808    #[test]
2809    fn test_apply_bookmark_keyword() -> Result<()> {
2810        let api = new_mem_api();
2811
2812        let records = json!([{
2813            "id": "bookmarkAAAA",
2814            "type": "bookmark",
2815            "parentid": "unfiled",
2816            "parentName": "Unfiled",
2817            "dateAdded": 1_381_542_355_843u64,
2818            "title": "A",
2819            "bmkUri": "http://example.com/a?b=c&d=%s",
2820            "keyword": "ex",
2821        },
2822        {
2823            "id": "unfiled",
2824            "type": "folder",
2825            "parentid": "places",
2826            "dateAdded": 1_381_542_355_843u64,
2827            "title": "Unfiled",
2828            "children": ["bookmarkAAAA"],
2829        }]);
2830
2831        let db_mutex = api.get_sync_connection().unwrap();
2832        let db = db_mutex.lock();
2833        let tx = db.begin_transaction()?;
2834        let applicator = IncomingApplicator::new(&db);
2835
2836        if let Value::Array(records) = records {
2837            for record in records {
2838                applicator.apply_bso(IncomingBso::from_test_content(record))?;
2839            }
2840        } else {
2841            unreachable!("JSON records must be an array");
2842        }
2843
2844        tx.commit()?;
2845
2846        // Flag the bookmark with the keyword for reupload, so that we can
2847        // ensure the keyword is round-tripped correctly.
2848        db.execute(
2849            "UPDATE moz_bookmarks_synced SET
2850                 validity = :validity
2851             WHERE guid = :guid",
2852            rusqlite::named_params! {
2853                ":validity": SyncedBookmarkValidity::Reupload,
2854                ":guid": SyncGuid::from("bookmarkAAAA"),
2855            },
2856        )?;
2857
2858        let interrupt_scope = db.begin_interrupt_scope()?;
2859
2860        let mut merger = Merger::new(&db, &interrupt_scope, ServerTimestamp(0));
2861        merger.merge()?;
2862
2863        assert_local_json_tree(
2864            &db,
2865            &BookmarkRootGuid::Unfiled.as_guid(),
2866            json!({"children" : [{"guid": "bookmarkAAAA", "url": "http://example.com/a?b=c&d=%s"}]}),
2867        );
2868
2869        let outgoing = fetch_outgoing_records(&db, &interrupt_scope)?;
2870        let record_for_a = outgoing
2871            .iter()
2872            .find(|payload| payload.envelope.id == "bookmarkAAAA")
2873            .expect("Should reupload A");
2874        let bk = record_for_a.to_test_incoming_t::<BookmarkRecord>();
2875        assert_eq!(bk.url.unwrap(), "http://example.com/a?b=c&d=%s");
2876        assert_eq!(bk.keyword.unwrap(), "ex");
2877
2878        Ok(())
2879    }
2880
2881    #[test]
2882    fn test_apply_query() {
2883        // should we add some more query variations here?
2884        let api = new_mem_api();
2885        assert_incoming_creates_local_tree(
2886            &api,
2887            json!([{
2888                "id": "query1______",
2889                "type": "query",
2890                "parentid": "unfiled",
2891                "parentName": "Unfiled Bookmarks",
2892                "dateAdded": 1_381_542_355_843u64,
2893                "title": "Some query",
2894                "bmkUri": "place:tag=foo",
2895            },
2896            {
2897                "id": "unfiled",
2898                "type": "folder",
2899                "parentid": "places",
2900                "dateAdded": 1_381_542_355_843u64,
2901                "title": "Unfiled",
2902                "children": ["query1______"],
2903            }]),
2904            &BookmarkRootGuid::Unfiled.as_guid(),
2905            json!({"children" : [{"guid": "query1______", "url": "place:tag=foo"}]}),
2906        );
2907        let reader = api
2908            .open_connection(ConnectionType::ReadOnly)
2909            .expect("Should open read-only connection");
2910        assert!(
2911            frecency_stale_at(&reader, &Url::parse("place:tag=foo").unwrap())
2912                .expect("Should check stale frecency")
2913                .is_none(),
2914            "Should not mark frecency for queries as stale"
2915        );
2916    }
2917
2918    #[test]
2919    fn test_apply() -> Result<()> {
2920        let api = new_mem_api();
2921        let writer = api.open_connection(ConnectionType::ReadWrite)?;
2922        let db = api.get_sync_connection().unwrap();
2923        let syncer = db.lock();
2924
2925        syncer
2926            .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
2927            .expect("should work");
2928
2929        insert_local_json_tree(
2930            &writer,
2931            json!({
2932                "guid": &BookmarkRootGuid::Unfiled.as_guid(),
2933                "children": [
2934                    {
2935                        "guid": "bookmarkAAAA",
2936                        "title": "A",
2937                        "url": "http://example.com/a",
2938                    },
2939                    {
2940                        "guid": "bookmarkBBBB",
2941                        "title": "B",
2942                        "url": "http://example.com/b",
2943                    },
2944                ]
2945            }),
2946        );
2947        tags::tag_url(
2948            &writer,
2949            &Url::parse("http://example.com/a").expect("Should parse URL for A"),
2950            "baz",
2951        )
2952        .expect("Should tag A");
2953
2954        let records = vec![
2955            json!({
2956                "id": "bookmarkCCCC",
2957                "type": "bookmark",
2958                "parentid": "menu",
2959                "parentName": "menu",
2960                "dateAdded": 1_552_183_116_885u64,
2961                "title": "C",
2962                "bmkUri": "http://example.com/c",
2963                "tags": ["foo", "bar"],
2964            }),
2965            json!({
2966                "id": "menu",
2967                "type": "folder",
2968                "parentid": "places",
2969                "parentName": "",
2970                "dateAdded": 0,
2971                "title": "menu",
2972                "children": ["bookmarkCCCC"],
2973            }),
2974        ];
2975
2976        // Drop the sync connection to avoid a deadlock when the sync engine locks the mutex
2977        drop(syncer);
2978        let engine = create_sync_engine(&api);
2979
2980        let incoming = records
2981            .into_iter()
2982            .map(IncomingBso::from_test_content)
2983            .collect();
2984
2985        let mut outgoing = engine_apply_incoming(&engine, incoming);
2986        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
2987        assert_eq!(
2988            outgoing
2989                .iter()
2990                .map(|p| p.envelope.id.as_str())
2991                .collect::<Vec<_>>(),
2992            vec!["bookmarkAAAA", "bookmarkBBBB", "unfiled",]
2993        );
2994        let record_for_a = outgoing
2995            .iter()
2996            .find(|p| p.envelope.id == "bookmarkAAAA")
2997            .expect("Should upload A");
2998        let content_for_a = record_for_a.to_test_incoming_t::<BookmarkRecord>();
2999        assert_eq!(content_for_a.tags, vec!["baz".to_string()]);
3000
3001        assert_local_json_tree(
3002            &writer,
3003            &BookmarkRootGuid::Root.as_guid(),
3004            json!({
3005                "guid": &BookmarkRootGuid::Root.as_guid(),
3006                "children": [
3007                    {
3008                        "guid": &BookmarkRootGuid::Menu.as_guid(),
3009                        "children": [
3010                            {
3011                                "guid": "bookmarkCCCC",
3012                                "title": "C",
3013                                "url": "http://example.com/c",
3014                                "date_added": Timestamp(1_552_183_116_885),
3015                            },
3016                        ],
3017                    },
3018                    {
3019                        "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3020                        "children": [],
3021                    },
3022                    {
3023                        "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3024                        "children": [
3025                            {
3026                                "guid": "bookmarkAAAA",
3027                                "title": "A",
3028                                "url": "http://example.com/a",
3029                            },
3030                            {
3031                                "guid": "bookmarkBBBB",
3032                                "title": "B",
3033                                "url": "http://example.com/b",
3034                            },
3035                        ],
3036                    },
3037                    {
3038                        "guid": &BookmarkRootGuid::Mobile.as_guid(),
3039                        "children": [],
3040                    },
3041                ],
3042            }),
3043        );
3044
3045        // We haven't finished the sync yet, so all local change counts for
3046        // items to upload should still be > 0.
3047        let guid_for_a: SyncGuid = "bookmarkAAAA".into();
3048        let info_for_a = get_raw_bookmark(&writer, &guid_for_a)
3049            .expect("Should fetch info for A")
3050            .unwrap();
3051        assert_eq!(info_for_a._sync_change_counter, 2);
3052        let info_for_unfiled = get_raw_bookmark(&writer, &BookmarkRootGuid::Unfiled.as_guid())
3053            .expect("Should fetch info for unfiled")
3054            .unwrap();
3055        assert_eq!(info_for_unfiled._sync_change_counter, 2);
3056
3057        engine
3058            .set_uploaded(
3059                ServerTimestamp(0),
3060                vec![
3061                    "bookmarkAAAA".into(),
3062                    "bookmarkBBBB".into(),
3063                    "unfiled".into(),
3064                ],
3065            )
3066            .expect("Should push synced changes back to the engine");
3067        engine.sync_finished().expect("finish always works");
3068
3069        let info_for_a = get_raw_bookmark(&writer, &guid_for_a)
3070            .expect("Should fetch info for A")
3071            .unwrap();
3072        assert_eq!(info_for_a._sync_change_counter, 0);
3073        let info_for_unfiled = get_raw_bookmark(&writer, &BookmarkRootGuid::Unfiled.as_guid())
3074            .expect("Should fetch info for unfiled")
3075            .unwrap();
3076        assert_eq!(info_for_unfiled._sync_change_counter, 0);
3077
3078        let mut tags_for_c = tags::get_tags_for_url(
3079            &writer,
3080            &Url::parse("http://example.com/c").expect("Should parse URL for C"),
3081        )
3082        .expect("Should return tags for C");
3083        tags_for_c.sort();
3084        assert_eq!(tags_for_c, &["bar", "foo"]);
3085
3086        Ok(())
3087    }
3088
3089    #[test]
3090    fn test_apply_invalid_url() -> Result<()> {
3091        let api = new_mem_api();
3092        let db = api.get_sync_connection().unwrap();
3093        let syncer = db.lock();
3094
3095        syncer
3096            .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
3097            .expect("should work");
3098
3099        let records = vec![
3100            json!({
3101                "id": "bookmarkXXXX",
3102                "type": "bookmark",
3103                "parentid": "menu",
3104                "parentName": "menu",
3105                "dateAdded": 1_552_183_116_885u64,
3106                "title": "Invalid",
3107                "bmkUri": "invalid url",
3108            }),
3109            json!({
3110                "id": "menu",
3111                "type": "folder",
3112                "parentid": "places",
3113                "parentName": "",
3114                "dateAdded": 0,
3115                "title": "menu",
3116                "children": ["bookmarkXXXX"],
3117            }),
3118        ];
3119
3120        // Drop the sync connection to avoid a deadlock when the sync engine locks the mutex
3121        drop(syncer);
3122        let engine = create_sync_engine(&api);
3123
3124        let incoming = records
3125            .into_iter()
3126            .map(IncomingBso::from_test_content)
3127            .collect();
3128
3129        let mut outgoing = engine_apply_incoming(&engine, incoming);
3130        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3131        assert_eq!(
3132            outgoing
3133                .iter()
3134                .map(|p| p.envelope.id.as_str())
3135                .collect::<Vec<_>>(),
3136            vec!["bookmarkXXXX", "menu",]
3137        );
3138
3139        let record_for_invalid = outgoing
3140            .iter()
3141            .find(|p| p.envelope.id == "bookmarkXXXX")
3142            .expect("Should re-upload the invalid record");
3143
3144        assert!(
3145            matches!(
3146                record_for_invalid
3147                    .to_test_incoming()
3148                    .into_content::<BookmarkRecord>()
3149                    .kind,
3150                IncomingKind::Tombstone
3151            ),
3152            "is invalid record"
3153        );
3154
3155        let record_for_menu = outgoing
3156            .iter()
3157            .find(|p| p.envelope.id == "menu")
3158            .expect("Should upload menu");
3159        let content_for_menu = record_for_menu.to_test_incoming_t::<FolderRecord>();
3160        assert!(
3161            content_for_menu.children.is_empty(),
3162            "should have been removed from the parent"
3163        );
3164        Ok(())
3165    }
3166
3167    #[test]
3168    fn test_apply_tombstones() -> Result<()> {
3169        let local_modified = Timestamp::now();
3170        let api = new_mem_api();
3171        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3172        insert_local_json_tree(
3173            &writer,
3174            json!({
3175                "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3176                "children": [{
3177                    "guid": "bookmarkAAAA",
3178                    "title": "A",
3179                    "url": "http://example.com/a",
3180                    "date_added": local_modified,
3181                    "last_modified": local_modified,
3182                }, {
3183                    "guid": "separatorAAA",
3184                    "type": BookmarkType::Separator as u8,
3185                    "date_added": local_modified,
3186                    "last_modified": local_modified,
3187                }, {
3188                    "guid": "folderAAAAAA",
3189                    "children": [{
3190                        "guid": "bookmarkBBBB",
3191                        "title": "b",
3192                        "url": "http://example.com/b",
3193                        "date_added": local_modified,
3194                        "last_modified": local_modified,
3195                    }],
3196                }],
3197            }),
3198        );
3199        // a first sync, which will populate our mirror.
3200        let engine = create_sync_engine(&api);
3201        let outgoing = engine_apply_incoming(&engine, vec![]);
3202        let outgoing_ids = outgoing
3203            .iter()
3204            .map(|p| p.envelope.id.clone())
3205            .collect::<Vec<_>>();
3206        // 4 roots + 4 items
3207        assert_eq!(outgoing_ids.len(), 8, "{:?}", outgoing_ids);
3208
3209        engine
3210            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3211            .expect("should work");
3212        engine.sync_finished().expect("should work");
3213
3214        // Now the next sync with incoming tombstones.
3215        let remote_unfiled = json!({
3216            "id": "unfiled",
3217            "type": "folder",
3218            "parentid": "places",
3219            "title": "Unfiled",
3220            "children": [],
3221        });
3222
3223        let incoming = vec![
3224            IncomingBso::new_test_tombstone(Guid::new("bookmarkAAAA")),
3225            IncomingBso::new_test_tombstone(Guid::new("separatorAAA")),
3226            IncomingBso::new_test_tombstone(Guid::new("folderAAAAAA")),
3227            IncomingBso::new_test_tombstone(Guid::new("bookmarkBBBB")),
3228            IncomingBso::from_test_content(remote_unfiled),
3229        ];
3230
3231        let outgoing = engine_apply_incoming(&engine, incoming);
3232        let outgoing_ids = outgoing
3233            .iter()
3234            .map(|p| p.envelope.id.clone())
3235            .collect::<Vec<_>>();
3236        assert_eq!(outgoing_ids.len(), 0, "{:?}", outgoing_ids);
3237
3238        engine
3239            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3240            .expect("should work");
3241        engine.sync_finished().expect("should work");
3242
3243        // We deleted everything from unfiled.
3244        assert_local_json_tree(
3245            &api.get_sync_connection().unwrap().lock(),
3246            &BookmarkRootGuid::Unfiled.as_guid(),
3247            json!({"children" : []}),
3248        );
3249        Ok(())
3250    }
3251
3252    #[test]
3253    fn test_keywords() -> Result<()> {
3254        use crate::storage::bookmarks::bookmarks_get_url_for_keyword;
3255
3256        let api = new_mem_api();
3257        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3258
3259        let records = vec![
3260            json!({
3261                "id": "toolbar",
3262                "type": "folder",
3263                "parentid": "places",
3264                "parentName": "",
3265                "dateAdded": 0,
3266                "title": "toolbar",
3267                "children": ["bookmarkAAAA"],
3268            }),
3269            json!({
3270                "id": "bookmarkAAAA",
3271                "type": "bookmark",
3272                "parentid": "toolbar",
3273                "parentName": "toolbar",
3274                "dateAdded": 1_552_183_116_885u64,
3275                "title": "A",
3276                "bmkUri": "http://example.com/a/%s",
3277                "keyword": "a",
3278            }),
3279        ];
3280
3281        let engine = create_sync_engine(&api);
3282
3283        let incoming = records
3284            .into_iter()
3285            .map(IncomingBso::from_test_content)
3286            .collect();
3287
3288        let outgoing = engine_apply_incoming(&engine, incoming);
3289        let mut outgoing_ids = outgoing
3290            .iter()
3291            .map(|p| p.envelope.id.clone())
3292            .collect::<Vec<_>>();
3293        outgoing_ids.sort();
3294        assert_eq!(outgoing_ids, &["menu", "mobile", "toolbar", "unfiled"],);
3295
3296        assert_eq!(
3297            bookmarks_get_url_for_keyword(&writer, "a")?,
3298            Some(Url::parse("http://example.com/a/%s")?)
3299        );
3300
3301        engine
3302            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3303            .expect("Should push synced changes back to the engine");
3304        engine.sync_finished().expect("should work");
3305
3306        update_bookmark(
3307            &writer,
3308            &"bookmarkAAAA".into(),
3309            &UpdatableBookmark {
3310                title: Some("A (local)".into()),
3311                ..UpdatableBookmark::default()
3312            }
3313            .into(),
3314        )?;
3315
3316        let outgoing = engine_apply_incoming(&engine, vec![]);
3317        assert_eq!(outgoing.len(), 1);
3318        let bk = outgoing[0].to_test_incoming_t::<BookmarkRecord>();
3319        assert_eq!(bk.record_id.as_guid(), "bookmarkAAAA");
3320        assert_eq!(bk.keyword.unwrap(), "a");
3321        assert_eq!(bk.url.unwrap(), "http://example.com/a/%s");
3322
3323        // URLs with keywords should have a foreign count of 3 (one for the
3324        // local bookmark, one for the synced bookmark, and one for the
3325        // keyword), and we shouldn't allow deleting them until the keyword
3326        // is removed.
3327        let foreign_count = writer
3328            .try_query_row(
3329                "SELECT foreign_count FROM moz_places
3330             WHERE url_hash = hash(:url) AND
3331                   url = :url",
3332                &[(":url", &"http://example.com/a/%s")],
3333                |row| -> rusqlite::Result<_> { row.get::<_, i64>(0) },
3334                false,
3335            )?
3336            .expect("Should fetch foreign count for URL A");
3337        assert_eq!(foreign_count, 3);
3338        let err = writer
3339            .execute(
3340                "DELETE FROM moz_places
3341             WHERE url_hash = hash(:url) AND
3342                   url = :url",
3343                rusqlite::named_params! {
3344                    ":url": "http://example.com/a/%s",
3345                },
3346            )
3347            .expect_err("Should fail to delete URL A with keyword");
3348        match err {
3349            RusqlError::SqliteFailure(e, _) => assert_eq!(e.code, ErrorCode::ConstraintViolation),
3350            _ => panic!("Wanted constraint violation error; got {:?}", err),
3351        }
3352
3353        Ok(())
3354    }
3355
3356    #[test]
3357    fn test_apply_complex_bookmark_keywords() -> Result<()> {
3358        use crate::storage::bookmarks::bookmarks_get_url_for_keyword;
3359
3360        // We don't provide an API for setting keywords locally, but we'll
3361        // still round-trip and fix up keywords on the server.
3362
3363        let api = new_mem_api();
3364        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3365
3366        // Let's add some remote bookmarks with keywords.
3367        let remote_records = json!([{
3368            // A1 and A2 have the same URL and keyword, so we shouldn't
3369            // reupload them.
3370            "id": "bookmarkAAA1",
3371            "type": "bookmark",
3372            "parentid": "unfiled",
3373            "parentName": "Unfiled",
3374            "title": "A1",
3375            "bmkUri": "http://example.com/a",
3376            "keyword": "one",
3377        }, {
3378            "id": "bookmarkAAA2",
3379            "type": "bookmark",
3380            "parentid": "menu",
3381            "parentName": "Menu",
3382            "title": "A2",
3383            "bmkUri": "http://example.com/a",
3384            "keyword": "one",
3385        }, {
3386            // B1 and B2 have mismatched keywords, and we should reupload
3387            // both of them. It's not specified which keyword wins, but
3388            // reuploading both means we make them consistent.
3389            "id": "bookmarkBBB1",
3390            "type": "bookmark",
3391            "parentid": "unfiled",
3392            "parentName": "Unfiled",
3393            "title": "B1",
3394            "bmkUri": "http://example.com/b",
3395            "keyword": "two",
3396        }, {
3397            "id": "bookmarkBBB2",
3398            "type": "bookmark",
3399            "parentid": "menu",
3400            "parentName": "Menu",
3401            "title": "B2",
3402            "bmkUri": "http://example.com/b",
3403            "keyword": "three",
3404        }, {
3405            // C1 has a keyword; C2 doesn't. As with B, which one wins
3406            // depends on which record we apply last, and how SQLite
3407            // processes the rows, but we should reupload both.
3408            "id": "bookmarkCCC1",
3409            "type": "bookmark",
3410            "parentid": "unfiled",
3411            "parentName": "Unfiled",
3412            "title": "C1",
3413            "bmkUri": "http://example.com/c",
3414            "keyword": "four",
3415        }, {
3416            "id": "bookmarkCCC2",
3417            "type": "bookmark",
3418            "parentid": "menu",
3419            "parentName": "Menu",
3420            "title": "C2",
3421            "bmkUri": "http://example.com/c",
3422        }, {
3423            // D has a keyword that needs to be cleaned up before
3424            // inserting. In this case, we intentionally don't reupload.
3425            "id": "bookmarkDDDD",
3426            "type": "bookmark",
3427            "parentid": "unfiled",
3428            "parentName": "Unfiled",
3429            "title": "D",
3430            "bmkUri": "http://example.com/d",
3431            "keyword": " FIVE ",
3432        }, {
3433            "id": "unfiled",
3434            "type": "folder",
3435            "parentid": "places",
3436            "title": "Unfiled",
3437            "children": ["bookmarkAAA1", "bookmarkBBB1", "bookmarkCCC1", "bookmarkDDDD"],
3438        }, {
3439            "id": "menu",
3440            "type": "folder",
3441            "parentid": "places",
3442            "title": "Menu",
3443            "children": ["bookmarkAAA2", "bookmarkBBB2", "bookmarkCCC2"],
3444        }]);
3445
3446        let engine = create_sync_engine(&api);
3447        let incoming = if let Value::Array(records) = remote_records {
3448            records
3449                .into_iter()
3450                .map(IncomingBso::from_test_content)
3451                .collect()
3452        } else {
3453            unreachable!("JSON records must be an array");
3454        };
3455        let mut outgoing = engine_apply_incoming(&engine, incoming);
3456        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3457
3458        assert_local_json_tree(
3459            &writer,
3460            &BookmarkRootGuid::Root.as_guid(),
3461            json!({
3462                "guid": &BookmarkRootGuid::Root.as_guid(),
3463                "children": [{
3464                    "guid": &BookmarkRootGuid::Menu.as_guid(),
3465                    "children": [{
3466                        "guid": "bookmarkAAA2",
3467                        "title": "A2",
3468                        "url": "http://example.com/a",
3469                    }, {
3470                        "guid": "bookmarkBBB2",
3471                        "title": "B2",
3472                        "url": "http://example.com/b",
3473                    }, {
3474                        "guid": "bookmarkCCC2",
3475                        "title": "C2",
3476                        "url": "http://example.com/c",
3477                    }],
3478                }, {
3479                    "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3480                    "children": [],
3481                }, {
3482                    "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3483                    "children": [{
3484                        "guid": "bookmarkAAA1",
3485                        "title": "A1",
3486                        "url": "http://example.com/a",
3487                    }, {
3488                        "guid": "bookmarkBBB1",
3489                        "title": "B1",
3490                        "url": "http://example.com/b",
3491                    }, {
3492                        "guid": "bookmarkCCC1",
3493                        "title": "C1",
3494                        "url": "http://example.com/c",
3495                    }, {
3496                        "guid": "bookmarkDDDD",
3497                        "title": "D",
3498                        "url": "http://example.com/d",
3499                    }],
3500                }, {
3501                    "guid": &BookmarkRootGuid::Mobile.as_guid(),
3502                    "children": [],
3503                }],
3504            }),
3505        );
3506        // And verify our local keywords are correct, too.
3507        let url_for_one = bookmarks_get_url_for_keyword(&writer, "one")?
3508            .expect("Should have URL for keyword `one`");
3509        assert_eq!(url_for_one.as_str(), "http://example.com/a");
3510
3511        let keyword_for_b = match (
3512            bookmarks_get_url_for_keyword(&writer, "two")?,
3513            bookmarks_get_url_for_keyword(&writer, "three")?,
3514        ) {
3515            (Some(url), None) => {
3516                assert_eq!(url.as_str(), "http://example.com/b");
3517                "two".to_string()
3518            }
3519            (None, Some(url)) => {
3520                assert_eq!(url.as_str(), "http://example.com/b");
3521                "three".to_string()
3522            }
3523            (Some(_), Some(_)) => panic!("Should pick `two` or `three`, not both"),
3524            (None, None) => panic!("Should have URL for either `two` or `three`"),
3525        };
3526
3527        let keyword_for_c = match bookmarks_get_url_for_keyword(&writer, "four")? {
3528            Some(url) => {
3529                assert_eq!(url.as_str(), "http://example.com/c");
3530                Some("four".to_string())
3531            }
3532            None => None,
3533        };
3534
3535        let url_for_five = bookmarks_get_url_for_keyword(&writer, "five")?
3536            .expect("Should have URL for keyword `five`");
3537        assert_eq!(url_for_five.as_str(), "http://example.com/d");
3538
3539        let expected_outgoing_keywords = &[
3540            ("bookmarkBBB1", Some(keyword_for_b.clone())),
3541            ("bookmarkBBB2", Some(keyword_for_b.clone())),
3542            ("bookmarkCCC1", keyword_for_c.clone()),
3543            ("bookmarkCCC2", keyword_for_c.clone()),
3544            ("menu", None), // Roots always get uploaded on the first sync.
3545            ("mobile", None),
3546            ("toolbar", None),
3547            ("unfiled", None),
3548        ];
3549        assert_eq!(
3550            outgoing
3551                .iter()
3552                .map(|p| (
3553                    p.envelope.id.as_str(),
3554                    p.to_test_incoming_t::<BookmarkRecord>().keyword
3555                ))
3556                .collect::<Vec<_>>(),
3557            expected_outgoing_keywords,
3558            "Should upload new bookmarks and fix up keywords",
3559        );
3560
3561        // Now push the records back to the engine, so we can check what we're
3562        // uploading.
3563        engine
3564            .set_uploaded(
3565                ServerTimestamp(0),
3566                expected_outgoing_keywords
3567                    .iter()
3568                    .map(|(id, _)| SyncGuid::from(id))
3569                    .collect(),
3570            )
3571            .expect("Should push synced changes back to the engine");
3572        engine.sync_finished().expect("should work");
3573
3574        let mut synced_item_for_b = SyncedBookmarkItem::new();
3575        synced_item_for_b
3576            .validity(SyncedBookmarkValidity::Valid)
3577            .kind(SyncedBookmarkKind::Bookmark)
3578            .url(Some("http://example.com/b"))
3579            .keyword(Some(&keyword_for_b));
3580        let mut synced_item_for_c = SyncedBookmarkItem::new();
3581        synced_item_for_c
3582            .validity(SyncedBookmarkValidity::Valid)
3583            .kind(SyncedBookmarkKind::Bookmark)
3584            .url(Some("http://example.com/c"))
3585            .keyword(Some(keyword_for_c.unwrap().as_str()));
3586        let expected_synced_items = &[
3587            ExpectedSyncedItem::with_properties("bookmarkBBB1", &synced_item_for_b, |a| {
3588                a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
3589                    .title(Some("B1"))
3590            }),
3591            ExpectedSyncedItem::with_properties("bookmarkBBB2", &synced_item_for_b, |a| {
3592                a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
3593                    .title(Some("B2"))
3594            }),
3595            ExpectedSyncedItem::with_properties("bookmarkCCC1", &synced_item_for_c, |a| {
3596                a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
3597                    .title(Some("C1"))
3598            }),
3599            ExpectedSyncedItem::with_properties("bookmarkCCC2", &synced_item_for_c, |a| {
3600                a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
3601                    .title(Some("C2"))
3602            }),
3603        ];
3604        for item in expected_synced_items {
3605            item.check(&writer)?;
3606        }
3607
3608        Ok(())
3609    }
3610
3611    #[test]
3612    fn test_wipe() -> Result<()> {
3613        let api = new_mem_api();
3614        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3615
3616        let records = vec![
3617            json!({
3618                "id": "toolbar",
3619                "type": "folder",
3620                "parentid": "places",
3621                "parentName": "",
3622                "dateAdded": 0,
3623                "title": "toolbar",
3624                "children": ["folderAAAAAA"],
3625            }),
3626            json!({
3627                "id": "folderAAAAAA",
3628                "type": "folder",
3629                "parentid": "toolbar",
3630                "parentName": "toolbar",
3631                "dateAdded": 0,
3632                "title": "A",
3633                "children": ["bookmarkBBBB"],
3634            }),
3635            json!({
3636                "id": "bookmarkBBBB",
3637                "type": "bookmark",
3638                "parentid": "folderAAAAAA",
3639                "parentName": "A",
3640                "dateAdded": 0,
3641                "title": "A",
3642                "bmkUri": "http://example.com/a",
3643            }),
3644            json!({
3645                "id": "menu",
3646                "type": "folder",
3647                "parentid": "places",
3648                "parentName": "",
3649                "dateAdded": 0,
3650                "title": "menu",
3651                "children": ["folderCCCCCC"],
3652            }),
3653            json!({
3654                "id": "folderCCCCCC",
3655                "type": "folder",
3656                "parentid": "menu",
3657                "parentName": "menu",
3658                "dateAdded": 0,
3659                "title": "A",
3660                "children": ["bookmarkDDDD", "folderEEEEEE"],
3661            }),
3662            json!({
3663                "id": "bookmarkDDDD",
3664                "type": "bookmark",
3665                "parentid": "folderCCCCCC",
3666                "parentName": "C",
3667                "dateAdded": 0,
3668                "title": "D",
3669                "bmkUri": "http://example.com/d",
3670            }),
3671            json!({
3672                "id": "folderEEEEEE",
3673                "type": "folder",
3674                "parentid": "folderCCCCCC",
3675                "parentName": "C",
3676                "dateAdded": 0,
3677                "title": "E",
3678                "children": ["bookmarkFFFF"],
3679            }),
3680            json!({
3681                "id": "bookmarkFFFF",
3682                "type": "bookmark",
3683                "parentid": "folderEEEEEE",
3684                "parentName": "E",
3685                "dateAdded": 0,
3686                "title": "F",
3687                "bmkUri": "http://example.com/f",
3688            }),
3689        ];
3690
3691        let engine = create_sync_engine(&api);
3692
3693        let incoming = records
3694            .into_iter()
3695            .map(IncomingBso::from_test_content)
3696            .collect();
3697
3698        let outgoing = engine_apply_incoming(&engine, incoming);
3699        let mut outgoing_ids = outgoing
3700            .iter()
3701            .map(|p| p.envelope.id.clone())
3702            .collect::<Vec<_>>();
3703        outgoing_ids.sort();
3704        assert_eq!(outgoing_ids, &["menu", "mobile", "toolbar", "unfiled"],);
3705
3706        engine
3707            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3708            .expect("Should push synced changes back to the engine");
3709        engine.sync_finished().expect("should work");
3710
3711        engine.wipe().expect("Should wipe the store");
3712
3713        // Wiping the store should delete all items except for the roots.
3714        assert_local_json_tree(
3715            &writer,
3716            &BookmarkRootGuid::Root.as_guid(),
3717            json!({
3718                "guid": &BookmarkRootGuid::Root.as_guid(),
3719                "children": [
3720                    {
3721                        "guid": &BookmarkRootGuid::Menu.as_guid(),
3722                        "children": [],
3723                    },
3724                    {
3725                        "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3726                        "children": [],
3727                    },
3728                    {
3729                        "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3730                        "children": [],
3731                    },
3732                    {
3733                        "guid": &BookmarkRootGuid::Mobile.as_guid(),
3734                        "children": [],
3735                    },
3736                ],
3737            }),
3738        );
3739
3740        // Now pretend that F changed remotely between the time we called `wipe`
3741        // and the next sync.
3742        let record_for_f = json!({
3743            "id": "bookmarkFFFF",
3744            "type": "bookmark",
3745            "parentid": "folderEEEEEE",
3746            "parentName": "E",
3747            "dateAdded": 0,
3748            "title": "F (remote)",
3749            "bmkUri": "http://example.com/f-remote",
3750        });
3751
3752        let incoming = vec![IncomingBso::from_test_content_ts(
3753            record_for_f,
3754            ServerTimestamp(1000),
3755        )];
3756
3757        let outgoing = engine_apply_incoming(&engine, incoming);
3758        let (outgoing_tombstones, outgoing_records): (Vec<_>, Vec<_>) =
3759            outgoing.iter().partition(|record| {
3760                matches!(
3761                    record
3762                        .to_test_incoming()
3763                        .into_content::<BookmarkRecord>()
3764                        .kind,
3765                    IncomingKind::Tombstone
3766                )
3767            });
3768        let mut outgoing_record_ids = outgoing_records
3769            .iter()
3770            .map(|p| p.envelope.id.as_str())
3771            .collect::<Vec<_>>();
3772        outgoing_record_ids.sort_unstable();
3773        assert_eq!(
3774            outgoing_record_ids,
3775            &["bookmarkFFFF", "menu", "mobile", "toolbar", "unfiled"],
3776        );
3777        let mut outgoing_tombstone_ids = outgoing_tombstones
3778            .iter()
3779            .map(|p| p.envelope.id.clone())
3780            .collect::<Vec<_>>();
3781        outgoing_tombstone_ids.sort();
3782        assert_eq!(
3783            outgoing_tombstone_ids,
3784            &[
3785                "bookmarkBBBB",
3786                "bookmarkDDDD",
3787                "folderAAAAAA",
3788                "folderCCCCCC",
3789                "folderEEEEEE"
3790            ]
3791        );
3792
3793        // F should move to the closest surviving ancestor, which, in this case,
3794        // is the menu.
3795        assert_local_json_tree(
3796            &writer,
3797            &BookmarkRootGuid::Root.as_guid(),
3798            json!({
3799                "guid": &BookmarkRootGuid::Root.as_guid(),
3800                "children": [
3801                    {
3802                        "guid": &BookmarkRootGuid::Menu.as_guid(),
3803                        "children": [
3804                            {
3805                                "guid": "bookmarkFFFF",
3806                                "title": "F (remote)",
3807                                "url": "http://example.com/f-remote",
3808                            },
3809                        ],
3810                    },
3811                    {
3812                        "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3813                        "children": [],
3814                    },
3815                    {
3816                        "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3817                        "children": [],
3818                    },
3819                    {
3820                        "guid": &BookmarkRootGuid::Mobile.as_guid(),
3821                        "children": [],
3822                    },
3823                ],
3824            }),
3825        );
3826
3827        Ok(())
3828    }
3829
3830    #[test]
3831    fn test_reset() -> anyhow::Result<()> {
3832        let api = new_mem_api();
3833        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3834
3835        insert_local_json_tree(
3836            &writer,
3837            json!({
3838                "guid": &BookmarkRootGuid::Menu.as_guid(),
3839                "children": [
3840                    {
3841                        "guid": "bookmark2___",
3842                        "title": "2",
3843                        "url": "http://example.com/2",
3844                    }
3845                ],
3846            }),
3847        );
3848
3849        {
3850            // scope to kill our sync connection.
3851            let engine = create_sync_engine(&api);
3852
3853            assert_eq!(
3854                engine.get_sync_assoc()?,
3855                EngineSyncAssociation::Disconnected
3856            );
3857
3858            let outgoing = engine_apply_incoming(&engine, vec![]);
3859            let synced_ids: Vec<Guid> = outgoing.into_iter().map(|c| c.envelope.id).collect();
3860            assert_eq!(synced_ids.len(), 5, "should be 4 roots + 1 outgoing item");
3861            engine.set_uploaded(ServerTimestamp(2_000), synced_ids)?;
3862            engine.sync_finished().expect("should work");
3863
3864            let db = api.get_sync_connection().unwrap();
3865            let syncer = db.lock();
3866            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(2_000));
3867
3868            let sync_ids = CollSyncIds {
3869                global: Guid::random(),
3870                coll: Guid::random(),
3871            };
3872            // Temporarily drop the sync connection to avoid a deadlock when the sync engine locks
3873            // the mutex
3874            drop(syncer);
3875            engine.reset(&EngineSyncAssociation::Connected(sync_ids.clone()))?;
3876            let syncer = db.lock();
3877            assert_eq!(
3878                get_meta::<Guid>(&syncer, GLOBAL_SYNCID_META_KEY)?,
3879                Some(sync_ids.global)
3880            );
3881            assert_eq!(
3882                get_meta::<Guid>(&syncer, COLLECTION_SYNCID_META_KEY)?,
3883                Some(sync_ids.coll)
3884            );
3885            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(0));
3886        }
3887        // do it all again - after the reset we should get the same results.
3888        {
3889            let engine = create_sync_engine(&api);
3890
3891            let outgoing = engine_apply_incoming(&engine, vec![]);
3892            let synced_ids: Vec<Guid> = outgoing.into_iter().map(|c| c.envelope.id).collect();
3893            assert_eq!(synced_ids.len(), 5, "should be 4 roots + 1 outgoing item");
3894            engine.set_uploaded(ServerTimestamp(2_000), synced_ids)?;
3895            engine.sync_finished().expect("should work");
3896
3897            let db = api.get_sync_connection().unwrap();
3898            let syncer = db.lock();
3899            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(2_000));
3900
3901            // Temporarily drop the sync connection to avoid a deadlock when the sync engine locks
3902            // the mutex
3903            drop(syncer);
3904            engine.reset(&EngineSyncAssociation::Disconnected)?;
3905            let syncer = db.lock();
3906            assert_eq!(
3907                get_meta::<Option<String>>(&syncer, GLOBAL_SYNCID_META_KEY)?,
3908                None
3909            );
3910            assert_eq!(
3911                get_meta::<Option<String>>(&syncer, COLLECTION_SYNCID_META_KEY)?,
3912                None
3913            );
3914            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(0));
3915        }
3916
3917        Ok(())
3918    }
3919
3920    #[test]
3921    fn test_incoming_timestamps() -> anyhow::Result<()> {
3922        let api = new_mem_api();
3923        let reader = api.open_connection(ConnectionType::ReadOnly)?;
3924
3925        // The timestamp of each record on the server. We expect this to be our "last modified".
3926        let remote_modified = ServerTimestamp::from_millis(1770000000000);
3927
3928        let records = vec![
3929            json!({
3930                "id": "toolbar",
3931                "type": "folder",
3932                "parentid": "places",
3933                "parentName": "",
3934                "dateAdded": 0,
3935                "title": "toolbar",
3936                "children": ["folderAAAAAA"],
3937            }),
3938            json!({
3939                "id": "folderAAAAAA",
3940                "type": "folder",
3941                "parentid": "toolbar",
3942                "parentName": "toolbar",
3943                "dateAdded": 0,
3944                "title": "A",
3945                "children": ["bookmarkBBBB"],
3946            }),
3947            json!({
3948                "id": "bookmarkBBBB",
3949                "type": "bookmark",
3950                "parentid": "folderAAAAAA",
3951                "parentName": "A",
3952                "dateAdded": 0,
3953                "title": "A",
3954                "bmkUri": "http://example.com/a",
3955            }),
3956        ];
3957
3958        let engine = create_sync_engine(&api);
3959
3960        let incoming = records
3961            .clone()
3962            .into_iter()
3963            .map(|json| IncomingBso::from_test_content_ts(json, remote_modified))
3964            .collect();
3965
3966        engine_apply_incoming(&engine, incoming);
3967
3968        // This was the first creation of the bookmark, the lastModified should be the server timestamp.
3969        let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
3970            .expect("must work")
3971            .expect("must exist");
3972
3973        assert_eq!(
3974            bm.date_modified.as_millis_i64(),
3975            remote_modified.as_millis()
3976        );
3977
3978        // Now reset the engine and do it again, which should not adjust date_modified.
3979        engine.reset(&EngineSyncAssociation::Disconnected)?;
3980        let incoming = records
3981            .into_iter()
3982            .map(|json| IncomingBso::from_test_content_ts(json, remote_modified))
3983            .collect();
3984        engine_apply_incoming(&engine, incoming);
3985        let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
3986            .expect("must work")
3987            .expect("must exist");
3988
3989        assert_eq!(
3990            bm.date_modified.as_millis_i64(),
3991            remote_modified.as_millis()
3992        );
3993
3994        // applying a change to the bookmark should update it.
3995        let new_records = vec![json!({
3996            "id": "bookmarkBBBB",
3997            "type": "bookmark",
3998            "parentid": "folderAAAAAA",
3999            "parentName": "A",
4000            "dateAdded": 0,
4001            "title": "A",
4002            "bmkUri": "http://example.com/a",
4003        })];
4004        let new_remote_modified = ServerTimestamp::from_millis(1780000000000);
4005        let new_incoming = new_records
4006            .into_iter()
4007            .map(|json| IncomingBso::from_test_content_ts(json, new_remote_modified))
4008            .collect();
4009        engine_apply_incoming(&engine, new_incoming);
4010        let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
4011            .expect("must work")
4012            .expect("must exist");
4013
4014        assert_eq!(
4015            bm.date_modified.as_millis_i64(),
4016            new_remote_modified.as_millis()
4017        );
4018        Ok(())
4019    }
4020
4021    #[test]
4022    fn test_dedupe_local_newer() -> anyhow::Result<()> {
4023        let api = new_mem_api();
4024        let writer = api.open_connection(ConnectionType::ReadWrite)?;
4025
4026        let local_modified = Timestamp::now();
4027        let remote_modified = local_modified.as_millis() as f64 / 1000f64 - 5f64;
4028
4029        // Start with merged items.
4030        apply_incoming(
4031            &api,
4032            ServerTimestamp::from_float_seconds(remote_modified),
4033            json!([{
4034                "id": "menu",
4035                "type": "folder",
4036                "parentid": "places",
4037                "parentName": "",
4038                "title": "menu",
4039                "children": ["bookmarkAAA5"],
4040            }, {
4041                "id": "bookmarkAAA5",
4042                "type": "bookmark",
4043                "parentid": "menu",
4044                "parentName": "menu",
4045                "title": "A",
4046                "bmkUri": "http://example.com/a",
4047            }]),
4048        );
4049
4050        // Add newer local dupes.
4051        insert_local_json_tree(
4052            &writer,
4053            json!({
4054                "guid": &BookmarkRootGuid::Menu.as_guid(),
4055                "children": [{
4056                    "guid": "bookmarkAAA1",
4057                    "title": "A",
4058                    "url": "http://example.com/a",
4059                    "date_added": local_modified,
4060                    "last_modified": local_modified,
4061                }, {
4062                    "guid": "bookmarkAAA2",
4063                    "title": "A",
4064                    "url": "http://example.com/a",
4065                    "date_added": local_modified,
4066                    "last_modified": local_modified,
4067                }, {
4068                    "guid": "bookmarkAAA3",
4069                    "title": "A",
4070                    "url": "http://example.com/a",
4071                    "date_added": local_modified,
4072                    "last_modified": local_modified,
4073                }],
4074            }),
4075        );
4076
4077        // Add older remote dupes.
4078        apply_incoming(
4079            &api,
4080            ServerTimestamp(local_modified.as_millis() as i64),
4081            json!([{
4082                "id": "menu",
4083                "type": "folder",
4084                "parentid": "places",
4085                "parentName": "",
4086                "title": "menu",
4087                "children": ["bookmarkAAAA", "bookmarkAAA4", "bookmarkAAA5"],
4088            }, {
4089                "id": "bookmarkAAAA",
4090                "type": "bookmark",
4091                "parentid": "menu",
4092                "parentName": "menu",
4093                "title": "A",
4094                "bmkUri": "http://example.com/a",
4095            }, {
4096                "id": "bookmarkAAA4",
4097                "type": "bookmark",
4098                "parentid": "menu",
4099                "parentName": "menu",
4100                "title": "A",
4101                "bmkUri": "http://example.com/a",
4102            }]),
4103        );
4104
4105        assert_local_json_tree(
4106            &writer,
4107            &BookmarkRootGuid::Menu.as_guid(),
4108            json!({
4109                "guid": &BookmarkRootGuid::Menu.as_guid(),
4110                "children": [{
4111                    "guid": "bookmarkAAAA",
4112                    "title": "A",
4113                    "url": "http://example.com/a",
4114                }, {
4115                    "guid": "bookmarkAAA4",
4116                    "title": "A",
4117                    "url": "http://example.com/a",
4118                }, {
4119                    "guid": "bookmarkAAA5",
4120                    "title": "A",
4121                    "url": "http://example.com/a",
4122                }, {
4123                    "guid": "bookmarkAAA3",
4124                    "title": "A",
4125                    "url": "http://example.com/a",
4126                }],
4127            }),
4128        );
4129
4130        Ok(())
4131    }
4132
4133    #[test]
4134    fn test_deduping_remote_newer() -> anyhow::Result<()> {
4135        let api = new_mem_api();
4136        let writer = api.open_connection(ConnectionType::ReadWrite)?;
4137
4138        let local_modified = Timestamp::from(Timestamp::now().as_millis() - 5000);
4139        let remote_modified = local_modified.as_millis() as f64 / 1000f64;
4140
4141        // Start with merged items.
4142        apply_incoming(
4143            &api,
4144            ServerTimestamp::from_float_seconds(remote_modified),
4145            json!([{
4146                "id": "menu",
4147                "type": "folder",
4148                "parentid": "places",
4149                "parentName": "",
4150                "title": "menu",
4151                "children": ["folderAAAAAA"],
4152            }, {
4153                // Shouldn't dedupe to `folderA11111` because it's been applied.
4154                "id": "folderAAAAAA",
4155                "type": "folder",
4156                "parentid": "menu",
4157                "parentName": "menu",
4158                "title": "A",
4159                "children": ["bookmarkGGGG"],
4160            }, {
4161                // Shouldn't dedupe to `bookmarkG111`.
4162                "id": "bookmarkGGGG",
4163                "type": "bookmark",
4164                "parentid": "folderAAAAAA",
4165                "parentName": "A",
4166                "title": "G",
4167                "bmkUri": "http://example.com/g",
4168            }]),
4169        );
4170
4171        // Add older local dupes.
4172        insert_local_json_tree(
4173            &writer,
4174            json!({
4175                "guid": "folderAAAAAA",
4176                "children": [{
4177                    // Not a candidate for `bookmarkH111` because we didn't dupe `folderAAAAAA`.
4178                    "guid": "bookmarkHHHH",
4179                    "title": "H",
4180                    "url": "http://example.com/h",
4181                    "date_added": local_modified,
4182                    "last_modified": local_modified,
4183                }]
4184            }),
4185        );
4186        insert_local_json_tree(
4187            &writer,
4188            json!({
4189                "guid": &BookmarkRootGuid::Menu.as_guid(),
4190                "children": [{
4191                    // Should dupe to `folderB11111`.
4192                    "guid": "folderBBBBBB",
4193                    "type": BookmarkType::Folder as u8,
4194                    "title": "B",
4195                    "date_added": local_modified,
4196                    "last_modified": local_modified,
4197                    "children": [{
4198                        // Should dupe to `bookmarkC222`.
4199                        "guid": "bookmarkC111",
4200                        "title": "C",
4201                        "url": "http://example.com/c",
4202                        "date_added": local_modified,
4203                        "last_modified": local_modified,
4204                    }, {
4205                        // Should dupe to `separatorF11` because the positions are the same.
4206                        "guid": "separatorFFF",
4207                        "type": BookmarkType::Separator as u8,
4208                        "date_added": local_modified,
4209                        "last_modified": local_modified,
4210                    }],
4211                }, {
4212                    // Shouldn't dupe to `separatorE11`, because the positions are different.
4213                    "guid": "separatorEEE",
4214                    "type": BookmarkType::Separator as u8,
4215                    "date_added": local_modified,
4216                    "last_modified": local_modified,
4217                }, {
4218                    // Shouldn't dupe to `bookmarkC222` because the parents are different.
4219                    "guid": "bookmarkCCCC",
4220                    "title": "C",
4221                    "url": "http://example.com/c",
4222                    "date_added": local_modified,
4223                    "last_modified": local_modified,
4224                }, {
4225                    // Should dupe to `queryD111111`.
4226                    "guid": "queryDDDDDDD",
4227                    "title": "Most Visited",
4228                    "url": "place:maxResults=10&sort=8",
4229                    "date_added": local_modified,
4230                    "last_modified": local_modified,
4231                }],
4232            }),
4233        );
4234
4235        // Add newer remote items.
4236        apply_incoming(
4237            &api,
4238            ServerTimestamp::from_float_seconds(remote_modified),
4239            json!([{
4240                "id": "menu",
4241                "type": "folder",
4242                "parentid": "places",
4243                "parentName": "",
4244                "title": "menu",
4245                "children": ["folderAAAAAA", "folderB11111", "folderA11111", "separatorE11", "queryD111111"],
4246                "dateAdded": local_modified.as_millis(),
4247            }, {
4248                "id": "folderB11111",
4249                "type": "folder",
4250                "parentid": "menu",
4251                "parentName": "menu",
4252                "title": "B",
4253                "children": ["bookmarkC222", "separatorF11"],
4254                "dateAdded": local_modified.as_millis(),
4255            }, {
4256                "id": "bookmarkC222",
4257                "type": "bookmark",
4258                "parentid": "folderB11111",
4259                "parentName": "B",
4260                "title": "C",
4261                "bmkUri": "http://example.com/c",
4262                "dateAdded": local_modified.as_millis(),
4263            }, {
4264                "id": "separatorF11",
4265                "type": "separator",
4266                "parentid": "folderB11111",
4267                "parentName": "B",
4268                "dateAdded": local_modified.as_millis(),
4269            }, {
4270                "id": "folderA11111",
4271                "type": "folder",
4272                "parentid": "menu",
4273                "parentName": "menu",
4274                "title": "A",
4275                "children": ["bookmarkG111"],
4276                "dateAdded": local_modified.as_millis(),
4277            }, {
4278                "id": "bookmarkG111",
4279                "type": "bookmark",
4280                "parentid": "folderA11111",
4281                "parentName": "A",
4282                "title": "G",
4283                "bmkUri": "http://example.com/g",
4284                "dateAdded": local_modified.as_millis(),
4285            }, {
4286                "id": "separatorE11",
4287                "type": "separator",
4288                "parentid": "folderB11111",
4289                "parentName": "B",
4290                "dateAdded": local_modified.as_millis(),
4291            }, {
4292                "id": "queryD111111",
4293                "type": "query",
4294                "parentid": "menu",
4295                "parentName": "menu",
4296                "title": "Most Visited",
4297                "bmkUri": "place:maxResults=10&sort=8",
4298                "dateAdded": local_modified.as_millis(),
4299            }]),
4300        );
4301
4302        assert_local_json_tree(
4303            &writer,
4304            &BookmarkRootGuid::Menu.as_guid(),
4305            json!({
4306                "guid": &BookmarkRootGuid::Menu.as_guid(),
4307                "children": [{
4308                    "guid": "folderAAAAAA",
4309                    "children": [{
4310                        "guid": "bookmarkGGGG",
4311                        "title": "G",
4312                        "url": "http://example.com/g",
4313                    }, {
4314                        "guid": "bookmarkHHHH",
4315                        "title": "H",
4316                        "url": "http://example.com/h",
4317                    }]
4318                }, {
4319                    "guid": "folderB11111",
4320                    "children": [{
4321                        "guid": "bookmarkC222",
4322                        "title": "C",
4323                        "url": "http://example.com/c",
4324                    }, {
4325                        "guid": "separatorF11",
4326                        "type": BookmarkType::Separator as u8,
4327                    }],
4328                }, {
4329                    "guid": "folderA11111",
4330                    "children": [{
4331                        "guid": "bookmarkG111",
4332                        "title": "G",
4333                        "url": "http://example.com/g",
4334                    }]
4335                }, {
4336                    "guid": "separatorE11",
4337                    "type": BookmarkType::Separator as u8,
4338                }, {
4339                    "guid": "queryD111111",
4340                    "title": "Most Visited",
4341                    "url": "place:maxResults=10&sort=8",
4342                }, {
4343                    "guid": "separatorEEE",
4344                    "type": BookmarkType::Separator as u8,
4345                }, {
4346                    "guid": "bookmarkCCCC",
4347                    "title": "C",
4348                    "url": "http://example.com/c",
4349                }],
4350            }),
4351        );
4352
4353        Ok(())
4354    }
4355
4356    #[test]
4357    fn test_reconcile_sync_metadata() -> anyhow::Result<()> {
4358        let api = new_mem_api();
4359        let writer = api.open_connection(ConnectionType::ReadWrite)?;
4360
4361        let local_modified = Timestamp::from(Timestamp::now().as_millis() - 5000);
4362        let remote_modified = local_modified.as_millis() as f64 / 1000f64;
4363
4364        insert_local_json_tree(
4365            &writer,
4366            json!({
4367                "guid": &BookmarkRootGuid::Menu.as_guid(),
4368                "children": [{
4369                    // this folder is going to reconcile exactly
4370                    "guid": "folderAAAAAA",
4371                    "type": BookmarkType::Folder as u8,
4372                    "title": "A",
4373                    "date_added": local_modified,
4374                    "last_modified": local_modified,
4375                    "children": [{
4376                        "guid": "bookmarkBBBB",
4377                        "title": "B",
4378                        "url": "http://example.com/b",
4379                        "date_added": local_modified,
4380                        "last_modified": local_modified,
4381                    }]
4382                }, {
4383                    // this folder's existing child isn't on the server (so will be
4384                    // outgoing) and also will take a new child from the server.
4385                    "guid": "folderCCCCCC",
4386                    "type": BookmarkType::Folder as u8,
4387                    "title": "C",
4388                    "date_added": local_modified,
4389                    "last_modified": local_modified,
4390                    "children": [{
4391                        "guid": "bookmarkEEEE",
4392                        "title": "E",
4393                        "url": "http://example.com/e",
4394                        "date_added": local_modified,
4395                        "last_modified": local_modified,
4396                    }]
4397                }, {
4398                    // This bookmark is going to take the remote title.
4399                    "guid": "bookmarkFFFF",
4400                    "title": "f",
4401                    "url": "http://example.com/f",
4402                    "date_added": local_modified,
4403                    "last_modified": local_modified,
4404                }],
4405            }),
4406        );
4407
4408        let outgoing = apply_incoming(
4409            &api,
4410            ServerTimestamp::from_float_seconds(remote_modified),
4411            json!([{
4412                "id": "menu",
4413                "type": "folder",
4414                "parentid": "places",
4415                "parentName": "",
4416                "title": "menu",
4417                "children": ["folderAAAAAA", "folderCCCCCC", "bookmarkFFFF"],
4418                "dateAdded": local_modified.as_millis(),
4419            }, {
4420                "id": "folderAAAAAA",
4421                "type": "folder",
4422                "parentid": "menu",
4423                "parentName": "menu",
4424                "title": "A",
4425                "children": ["bookmarkBBBB"],
4426                "dateAdded": local_modified.as_millis(),
4427            }, {
4428                "id": "bookmarkBBBB",
4429                "type": "bookmark",
4430                "parentid": "folderAAAAAA",
4431                "parentName": "A",
4432                "title": "B",
4433                "bmkUri": "http://example.com/b",
4434                "dateAdded": local_modified.as_millis(),
4435            }, {
4436                "id": "folderCCCCCC",
4437                "type": "folder",
4438                "parentid": "menu",
4439                "parentName": "menu",
4440                "title": "C",
4441                "children": ["bookmarkDDDD"],
4442                "dateAdded": local_modified.as_millis(),
4443            }, {
4444                "id": "bookmarkDDDD",
4445                "type": "bookmark",
4446                "parentid": "folderCCCCCC",
4447                "parentName": "C",
4448                "title": "D",
4449                "bmkUri": "http://example.com/d",
4450                "dateAdded": local_modified.as_millis(),
4451            }, {
4452                "id": "bookmarkFFFF",
4453                "type": "bookmark",
4454                "parentid": "menu",
4455                "parentName": "menu",
4456                "title": "F",
4457                "bmkUri": "http://example.com/f",
4458                "dateAdded": local_modified.as_millis(),
4459            },]),
4460        );
4461
4462        // Assert the tree is correct even though that's not really the point
4463        // of this test.
4464        assert_local_json_tree(
4465            &writer,
4466            &BookmarkRootGuid::Menu.as_guid(),
4467            json!({
4468                "guid": &BookmarkRootGuid::Menu.as_guid(),
4469                "children": [{
4470                    // this folder is going to reconcile exactly
4471                    "guid": "folderAAAAAA",
4472                    "type": BookmarkType::Folder as u8,
4473                    "title": "A",
4474                    "children": [{
4475                        "guid": "bookmarkBBBB",
4476                        "title": "B",
4477                        "url": "http://example.com/b",
4478                    }]
4479                }, {
4480                    "guid": "folderCCCCCC",
4481                    "type": BookmarkType::Folder as u8,
4482                    "title": "C",
4483                    "children": [{
4484                        "guid": "bookmarkDDDD",
4485                        "title": "D",
4486                        "url": "http://example.com/d",
4487                    },{
4488                        "guid": "bookmarkEEEE",
4489                        "title": "E",
4490                        "url": "http://example.com/e",
4491                    }]
4492                }, {
4493                    "guid": "bookmarkFFFF",
4494                    "title": "F",
4495                    "url": "http://example.com/f",
4496                }],
4497            }),
4498        );
4499
4500        // After application everything should have SyncStatus::Normal and
4501        // a change counter of zero.
4502        for guid in &[
4503            "folderAAAAAA",
4504            "bookmarkBBBB",
4505            "folderCCCCCC",
4506            "bookmarkDDDD",
4507            "bookmarkFFFF",
4508        ] {
4509            let bm = get_raw_bookmark(&writer, &guid.into())
4510                .expect("must work")
4511                .expect("must exist");
4512            assert_eq!(bm._sync_status, SyncStatus::Normal, "{}", guid);
4513            assert_eq!(bm._sync_change_counter, 0, "{}", guid);
4514        }
4515        // And bookmarkEEEE wasn't on the server, so should be outgoing, and
4516        // it's parent too.
4517        assert!(outgoing.contains(&"bookmarkEEEE".into()));
4518        assert!(outgoing.contains(&"folderCCCCCC".into()));
4519        Ok(())
4520    }
4521
4522    /*
4523     * Due to bug 1935797, Users were running into a state where in itemsToApply
4524     * localID = None/Null, but mergedGuid was something already locally in the
4525     * tree -- this lead to an uptick of guid collision issues in `apply_remote_items`
4526     * below is an example of a 'user' going into this state and the new code fixing it
4527     */
4528    #[test]
4529    fn test_handle_unique_guid_violation() -> Result<()> {
4530        let api = new_mem_api();
4531        let db = api.get_sync_connection().unwrap();
4532        let conn = db.lock();
4533
4534        conn.execute_batch(
4535            r#"
4536            INSERT INTO moz_places(url, guid, title, frecency)
4537            VALUES
4538                ('http://example.com/', 'testPlaceGuidAAAA', 'Example site', 0)
4539            "#,
4540        )?;
4541
4542        // Insert a local row in moz_bookmarks with guid="collisionGUI"
4543        // so we already have that GUID in the table.
4544        conn.execute_batch(&format!(
4545            r#"
4546        INSERT INTO moz_bookmarks(guid, parent, fk, position, type)
4547        VALUES (
4548            'collisionGUI',
4549            (SELECT id FROM moz_bookmarks WHERE guid = '{menu}'),
4550            (SELECT id FROM moz_places WHERE guid = 'testPlaceGuidAAAA'),
4551            0,
4552            1  -- type=1 => bookmark
4553        );
4554        "#,
4555            menu = BookmarkRootGuid::Menu.as_guid(),
4556        ))?;
4557
4558        // Insert a row into itemsToApply that will cause an insert
4559        // with the same guid="collisionGUI".
4560        // localId is NULL, so the engine sees it as a "new" local item,
4561        // and remoteId could be any integer. We set newKind=1 => "bookmark."
4562        conn.execute(
4563            r#"
4564        INSERT INTO itemsToApply(
4565            mergedGuid,
4566            localId,
4567            remoteId,
4568            remoteGuid,
4569            newKind,
4570            newLevel,
4571            newTitle,
4572            newPlaceId,
4573            oldPlaceId,
4574            localDateAdded,
4575            remoteDateAdded,
4576            lastModified
4577        )
4578        VALUES (
4579            ?1,        -- mergedGuid
4580            NULL,      -- localId => so it doesn't unify
4581            999,       -- remoteId => arbitrary
4582            ?1,        -- remoteGuid
4583            1,         -- newKind=1 => bookmark
4584            0,         -- level
4585            'New Title',   -- newTitle
4586            1,             -- newPlaceId
4587            NULL,          -- oldPlaceId
4588            1000,          -- localDateAdded
4589            2000,          -- remoteDateAdded
4590            2000           -- lastModified
4591        )
4592        "#,
4593            [&"collisionGUI"],
4594        )?;
4595
4596        // Call apply_remote_items directly.
4597        // This tries "INSERT INTO moz_bookmarks(guid='collisionGUI')"
4598        // and should NOT fail with a unique constraint.
4599        let scope = conn.begin_interrupt_scope()?;
4600        apply_remote_items(&conn, &scope, Timestamp(999))?;
4601
4602        // Assert the tree still looks valid after applying
4603        assert_local_json_tree(
4604            &conn,
4605            &BookmarkRootGuid::Menu.as_guid(),
4606            json!({
4607                "guid": &BookmarkRootGuid::Menu.as_guid(),
4608                // should only be one child
4609                "children": [{
4610                    "guid": "collisionGUI",
4611                    "title": "New Title", // title was updated from remote
4612                    "url": "http://example.com/",
4613                }],
4614            }),
4615        );
4616        Ok(())
4617    }
4618}