1use 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";
40pub 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
46const MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK: usize = 400;
51
52struct 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 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 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
126fn update_local_items_in_places(
133 db: &PlacesDb,
134 scope: &SqlInterruptScope,
135 now: Timestamp,
136 ops: &CompletionOps<'_>,
137) -> Result<()> {
138 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 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 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 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 debug!("Applying new local structure");
390 scope.err_if_interrupted()?;
391 db.execute_batch("DELETE FROM applyNewLocalStructureOps")?;
392
393 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 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 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 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 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 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 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 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
696fn 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 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 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 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 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
802fn 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
934fn push_synced_items(
938 db: &PlacesDb,
939 scope: &SqlInterruptScope,
940 uploaded_at: ServerTimestamp,
941 records_synced: Vec<SyncGuid>,
942) -> Result<()> {
943 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 put_meta(db, LAST_SYNC_META_KEY, &uploaded_at.as_millis())?;
971
972 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 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 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 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 frecencies.len() < MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK {
1038 break;
1039 }
1040 frecencies.clear();
1041 }
1042
1043 tx.commit()?;
1044
1045 Ok(())
1046}
1047
1048pub struct BookmarksSyncEngine {
1050 db: Arc<SharedPlacesDb>,
1051 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 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 put_meta(&conn, LAST_SYNC_META_KEY, ×tamp.as_millis())?;
1094
1095 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 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 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
1222pub(crate) struct Merger<'a> {
1224 db: &'a PlacesDb,
1225 scope: &'a SqlInterruptScope,
1226 remote_time: ServerTimestamp,
1227 local_time: Timestamp,
1228 external_transaction: bool,
1233 telem: Option<&'a mut telemetry::Engine>,
1234 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 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 if let Some(ref mut telem) = self.telem {
1305 telem.validation(driver.validation.into_inner());
1306 }
1307 result
1308 }
1309
1310 fn prepare(&self) -> Result<()> {
1312 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 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 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 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 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 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 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 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 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 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 fn fetch_remote_tree(&self) -> Result<Tree> {
1583 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 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 folder_kind = SyncedBookmarkKind::Folder as u8;
1654 let root_guid_str = BookmarkRootGuid::Root.as_guid();
1655 let (child_tombstoned, child_absent, parent_tombstoned, parent_absent, non_folder_parent):
1656 (bool, bool, bool, bool, bool) = self.db.query_row(
1657 &format!(
1658 "SELECT
1659 MAX(child.isDeleted IS 1),
1660 MAX(child.guid IS NULL),
1661 MAX(parent.isDeleted IS 1),
1662 MAX(parent.guid IS NULL),
1663 MAX(parent.isDeleted IS 0 AND parent.kind <> {folder_kind})
1664 FROM moz_bookmarks_synced_structure s
1665 LEFT JOIN moz_bookmarks_synced child ON child.guid = s.guid
1666 LEFT JOIN moz_bookmarks_synced parent ON parent.guid = s.parentGuid
1667 WHERE s.guid <> '{root_guid}'
1668 ",
1669 root_guid = root_guid_str.as_str(),
1670 folder_kind = folder_kind,
1671 ),
1672 [],
1673 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
1674 )?;
1675 if child_tombstoned {
1676 error_support::report_error!(
1678 "places-bookmarks-structure-child-tombstoned",
1679 "moz_bookmarks_synced_structure has rows whose child guid is tombstoned"
1680 );
1681 }
1682 if child_absent {
1683 error_support::report_error!("places-bookmarks-structure-child-absent",
1685 "moz_bookmarks_synced_structure has rows whose child guid is absent from moz_bookmarks_synced");
1686 }
1687 if parent_tombstoned {
1688 error_support::report_error!(
1690 "places-bookmarks-structure-parent-tombstoned",
1691 "moz_bookmarks_synced_structure has rows whose parent guid is tombstoned"
1692 );
1693 }
1694 if parent_absent {
1695 error_support::report_error!("places-bookmarks-structure-parent-absent",
1698 "moz_bookmarks_synced_structure has rows whose parent guid is absent from moz_bookmarks_synced");
1699 }
1700 if non_folder_parent {
1701 error_support::report_error!(
1703 "places-bookmarks-structure-non-folder-parent",
1704 "moz_bookmarks_synced_structure has rows whose parent is a non-folder item"
1705 );
1706 }
1707
1708 let sql = format!(
1715 "SELECT s.guid, s.parentGuid
1716 FROM moz_bookmarks_synced_structure s
1717 JOIN moz_bookmarks_synced child
1718 ON child.guid = s.guid AND NOT child.isDeleted
1719 JOIN moz_bookmarks_synced parent
1720 ON parent.guid = s.parentGuid AND NOT parent.isDeleted
1721 AND parent.kind = {folder_kind}
1722 WHERE s.guid <> '{root_guid}'
1723 ORDER BY s.parentGuid, s.position",
1724 folder_kind = folder_kind,
1725 root_guid = root_guid_str.as_str()
1726 );
1727 let mut stmt = self.db.prepare(&sql)?;
1728 let mut results = stmt.query([])?;
1729 while let Some(row) = results.next()? {
1730 self.scope.err_if_interrupted()?;
1731 let guid = row.get::<_, SyncGuid>("guid")?;
1732 let parent_guid = row.get::<_, SyncGuid>("parentGuid")?;
1733 builder
1734 .parent_for(&guid.as_str().into())
1735 .by_children(&parent_guid.as_str().into())?;
1736 }
1737
1738 let tree = Tree::try_from(builder)?;
1739 Ok(tree)
1740 }
1741
1742 fn apply(&mut self, root: MergedRoot<'_>) -> Result<()> {
1743 let ops = root.completion_ops_with_signal(&MergeInterruptee(self.scope))?;
1744
1745 if ops.is_empty() {
1746 return Ok(());
1749 }
1750
1751 let tx = if !self.external_transaction {
1752 Some(self.db.begin_transaction()?)
1753 } else {
1754 None
1755 };
1756
1757 if self.global_change_tracker.changed() {
1760 info!("Aborting update of local items as local tree changed while merging");
1761 if let Some(tx) = tx {
1762 tx.rollback()?;
1763 }
1764 return Ok(());
1765 }
1766
1767 debug!("Updating local items in Places");
1768 update_local_items_in_places(self.db, self.scope, self.local_time, &ops)?;
1769
1770 debug!(
1771 "Staging {} items and {} tombstones to upload",
1772 ops.upload_items.len(),
1773 ops.upload_tombstones.len()
1774 );
1775 stage_items_to_upload(
1776 self.db,
1777 self.scope,
1778 &ops.upload_items,
1779 &ops.upload_tombstones,
1780 )?;
1781
1782 self.db.execute_batch("DELETE FROM itemsToApply;")?;
1783 if let Some(tx) = tx {
1784 tx.commit()?;
1785 }
1786 Ok(())
1787 }
1788}
1789
1790struct NullableFragment<T>(Option<T>);
1793
1794impl<T> fmt::Display for NullableFragment<T>
1795where
1796 T: fmt::Display,
1797{
1798 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1799 match &self.0 {
1800 Some(v) => v.fmt(f),
1801 None => write!(f, "NULL"),
1802 }
1803 }
1804}
1805
1806struct ItemTypeFragment(&'static str);
1810
1811impl fmt::Display for ItemTypeFragment {
1812 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1813 write!(
1814 f,
1815 "(CASE WHEN {col} IN ({bookmark_kind}, {query_kind})
1816 THEN {bookmark_type}
1817 WHEN {col} IN ({folder_kind}, {livemark_kind})
1818 THEN {folder_type}
1819 WHEN {col} = {separator_kind}
1820 THEN {separator_type}
1821 END)",
1822 col = self.0,
1823 bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
1824 query_kind = SyncedBookmarkKind::Query as u8,
1825 bookmark_type = BookmarkType::Bookmark as u8,
1826 folder_kind = SyncedBookmarkKind::Folder as u8,
1827 livemark_kind = SyncedBookmarkKind::Livemark as u8,
1828 folder_type = BookmarkType::Folder as u8,
1829 separator_kind = SyncedBookmarkKind::Separator as u8,
1830 separator_type = BookmarkType::Separator as u8,
1831 )
1832 }
1833}
1834
1835struct UploadItemsFragment(&'static str);
1838
1839impl fmt::Display for UploadItemsFragment {
1840 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1841 write!(
1842 f,
1843 "SELECT {alias}.id, {alias}.guid, {alias}.syncChangeCounter,
1844 p.guid AS parentGuid, p.title AS parentTitle,
1845 {alias}.dateAdded, {kind_fragment} AS kind,
1846 {alias}.title, h.id AS placeId, h.url,
1847 (SELECT k.keyword FROM moz_keywords k
1848 WHERE k.place_id = h.id) AS keyword,
1849 {alias}.position
1850 FROM moz_bookmarks {alias}
1851 JOIN moz_bookmarks p ON p.id = {alias}.parent
1852 LEFT JOIN moz_places h ON h.id = {alias}.fk",
1853 alias = self.0,
1854 kind_fragment = item_kind_fragment(self.0, "type", UrlOrPlaceIdFragment::Url("h.url")),
1855 )
1856 }
1857}
1858
1859struct LocalItemsFragment<'a>(&'a str);
1862
1863impl fmt::Display for LocalItemsFragment<'_> {
1864 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1865 write!(
1866 f,
1867 "{name}(id, guid, parentId, parentGuid, position, type, title, parentTitle,
1868 placeId, dateAdded, lastModified, syncChangeCounter, level) AS (
1869 SELECT b.id, b.guid, 0, NULL, b.position, b.type, b.title, NULL,
1870 b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, 0
1871 FROM moz_bookmarks b
1872 WHERE b.guid = '{root_guid}'
1873 UNION ALL
1874 SELECT b.id, b.guid, s.id, s.guid, b.position, b.type, b.title, s.title,
1875 b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, s.level + 1
1876 FROM moz_bookmarks b
1877 JOIN {name} s ON s.id = b.parent)",
1878 name = self.0,
1879 root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1880 )
1881 }
1882}
1883
1884fn item_kind_fragment(
1885 table_name: &'static str,
1886 type_column_name: &'static str,
1887 url_or_place_id_fragment: UrlOrPlaceIdFragment,
1888) -> ItemKindFragment {
1889 ItemKindFragment {
1890 table_name,
1891 type_column_name,
1892 url_or_place_id_fragment,
1893 }
1894}
1895
1896struct ItemKindFragment {
1900 table_name: &'static str,
1902 type_column_name: &'static str,
1904 url_or_place_id_fragment: UrlOrPlaceIdFragment,
1906}
1907
1908impl fmt::Display for ItemKindFragment {
1909 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1910 write!(
1911 f,
1912 "(CASE {table_name}.{type_column_name}
1913 WHEN {bookmark_type} THEN (
1914 CASE substr({url}, 1, 6)
1915 /* Queries are bookmarks with a 'place:' URL scheme. */
1916 WHEN 'place:' THEN {query_kind}
1917 ELSE {bookmark_kind}
1918 END
1919 )
1920 WHEN {folder_type} THEN {folder_kind}
1921 WHEN {separator_type} THEN {separator_kind}
1922 END)",
1923 table_name = self.table_name,
1924 type_column_name = self.type_column_name,
1925 bookmark_type = BookmarkType::Bookmark as u8,
1926 url = self.url_or_place_id_fragment,
1927 query_kind = SyncedBookmarkKind::Query as u8,
1928 bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
1929 folder_type = BookmarkType::Folder as u8,
1930 folder_kind = SyncedBookmarkKind::Folder as u8,
1931 separator_type = BookmarkType::Separator as u8,
1932 separator_kind = SyncedBookmarkKind::Separator as u8,
1933 )
1934 }
1935}
1936
1937enum UrlOrPlaceIdFragment {
1941 Url(&'static str),
1944 PlaceId(&'static str),
1947}
1948
1949impl fmt::Display for UrlOrPlaceIdFragment {
1950 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1951 match self {
1952 UrlOrPlaceIdFragment::Url(s) => write!(f, "{}", s),
1953 UrlOrPlaceIdFragment::PlaceId(s) => {
1954 write!(f, "(SELECT h.url FROM moz_places h WHERE h.id = {})", s)
1955 }
1956 }
1957 }
1958}
1959
1960struct RootsFragment<'a>(&'a [BookmarkRootGuid]);
1963
1964impl fmt::Display for RootsFragment<'_> {
1965 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1966 f.write_str("(")?;
1967 for (i, guid) in self.0.iter().enumerate() {
1968 if i != 0 {
1969 f.write_str(",")?;
1970 }
1971 write!(f, "'{}'", guid.as_str())?;
1972 }
1973 f.write_str(")")
1974 }
1975}
1976
1977#[cfg(test)]
1978mod tests {
1979 use super::*;
1980 use crate::api::places_api::{test::new_mem_api, ConnectionType, PlacesApi};
1981 use crate::bookmark_sync::tests::SyncedBookmarkItem;
1982 use crate::db::PlacesDb;
1983 use crate::storage::{
1984 bookmarks::{
1985 get_raw_bookmark, insert_bookmark, update_bookmark, BookmarkPosition,
1986 InsertableBookmark, UpdatableBookmark, USER_CONTENT_ROOTS,
1987 },
1988 history::frecency_stale_at,
1989 tags,
1990 };
1991 use crate::tests::{
1992 assert_json_tree as assert_local_json_tree, insert_json_tree as insert_local_json_tree,
1993 };
1994 use dogear::{Store as DogearStore, Validity};
1995 use rusqlite::{Error as RusqlError, ErrorCode};
1996 use serde_json::{json, Value};
1997 use std::{
1998 borrow::Cow,
1999 time::{Duration, SystemTime},
2000 };
2001 use sync15::bso::{IncomingBso, IncomingKind};
2002 use sync15::engine::CollSyncIds;
2003 use sync_guid::Guid;
2004 use url::Url;
2005
2006 struct ExpectedSyncedItem<'a>(SyncGuid, Cow<'a, SyncedBookmarkItem>);
2008
2009 impl<'a> ExpectedSyncedItem<'a> {
2010 fn new(
2011 guid: impl Into<SyncGuid>,
2012 expected: &'a SyncedBookmarkItem,
2013 ) -> ExpectedSyncedItem<'a> {
2014 ExpectedSyncedItem(guid.into(), Cow::Borrowed(expected))
2015 }
2016
2017 fn with_properties(
2018 guid: impl Into<SyncGuid>,
2019 expected: &'a SyncedBookmarkItem,
2020 f: impl FnOnce(&mut SyncedBookmarkItem) -> &mut SyncedBookmarkItem + 'static,
2021 ) -> ExpectedSyncedItem<'a> {
2022 let mut expected = expected.clone();
2023 f(&mut expected);
2024 ExpectedSyncedItem(guid.into(), Cow::Owned(expected))
2025 }
2026
2027 fn check(&self, conn: &PlacesDb) -> Result<()> {
2028 let actual =
2029 SyncedBookmarkItem::get(conn, &self.0)?.expect("Expected synced item should exist");
2030 assert_eq!(&actual, &*self.1);
2031 Ok(())
2032 }
2033 }
2034
2035 fn create_sync_engine(api: &PlacesApi) -> BookmarksSyncEngine {
2036 BookmarksSyncEngine::new(api.get_sync_connection().unwrap()).unwrap()
2037 }
2038
2039 fn engine_apply_incoming(
2040 engine: &BookmarksSyncEngine,
2041 incoming: Vec<IncomingBso>,
2042 ) -> Vec<OutgoingBso> {
2043 let mut telem = telemetry::Engine::new(engine.collection_name());
2044 engine
2045 .stage_incoming(incoming, &mut telem)
2046 .expect("Should stage incoming");
2047 engine
2048 .apply(ServerTimestamp(0), &mut telem)
2049 .expect("Should apply")
2050 }
2051
2052 fn apply_incoming(
2056 api: &PlacesApi,
2057 remote_time: ServerTimestamp,
2058 records_json: Value,
2059 ) -> Vec<Guid> {
2060 let engine = create_sync_engine(api);
2062
2063 let incoming = match records_json {
2064 Value::Array(records) => records
2065 .into_iter()
2066 .map(|record| IncomingBso::from_test_content_ts(record, remote_time))
2067 .collect(),
2068 Value::Object(_) => {
2069 vec![IncomingBso::from_test_content_ts(records_json, remote_time)]
2070 }
2071 _ => panic!("unexpected json value"),
2072 };
2073
2074 engine_apply_incoming(&engine, incoming);
2075
2076 let sync_db = api.get_sync_connection().unwrap();
2077 let syncer = sync_db.lock();
2078 let mut stmt = syncer
2079 .prepare("SELECT guid FROM itemsToUpload")
2080 .expect("Should prepare statement to fetch uploaded GUIDs");
2081 let uploaded_guids: Vec<Guid> = stmt
2082 .query_and_then([], |row| -> rusqlite::Result<_> { row.get::<_, Guid>(0) })
2083 .expect("Should fetch uploaded GUIDs")
2084 .map(std::result::Result::unwrap)
2085 .collect();
2086
2087 push_synced_items(&syncer, &engine.scope, remote_time, uploaded_guids.clone())
2088 .expect("Should push synced changes back to the engine");
2089 uploaded_guids
2090 }
2091
2092 fn assert_incoming_creates_local_tree(
2093 api: &PlacesApi,
2094 records_json: Value,
2095 local_folder: &SyncGuid,
2096 local_tree: Value,
2097 ) {
2098 apply_incoming(api, ServerTimestamp(0), records_json);
2099 assert_local_json_tree(
2100 &api.get_sync_connection().unwrap().lock(),
2101 local_folder,
2102 local_tree,
2103 );
2104 }
2105
2106 #[test]
2107 fn test_fetch_remote_tree() -> Result<()> {
2108 let records = vec![
2109 json!({
2110 "id": "qqVTRWhLBOu3",
2111 "type": "bookmark",
2112 "parentid": "unfiled",
2113 "parentName": "Unfiled Bookmarks",
2114 "dateAdded": 1_381_542_355_843u64,
2115 "title": "The title",
2116 "bmkUri": "https://example.com",
2117 "tags": [],
2118 }),
2119 json!({
2120 "id": "unfiled",
2121 "type": "folder",
2122 "parentid": "places",
2123 "parentName": "",
2124 "dateAdded": 0,
2125 "title": "Unfiled Bookmarks",
2126 "children": ["qqVTRWhLBOu3"],
2127 "tags": [],
2128 }),
2129 ];
2130
2131 let api = new_mem_api();
2132 let db = api.get_sync_connection().unwrap();
2133 let conn = db.lock();
2134
2135 let interrupt_scope = conn.begin_interrupt_scope()?;
2137
2138 let incoming = records
2139 .into_iter()
2140 .map(IncomingBso::from_test_content)
2141 .collect();
2142
2143 stage_incoming(
2144 &conn,
2145 &interrupt_scope,
2146 incoming,
2147 &mut telemetry::EngineIncoming::new(),
2148 )
2149 .expect("Should apply incoming and stage outgoing records");
2150
2151 let merger = Merger::new(&conn, &interrupt_scope, ServerTimestamp(0));
2152
2153 let tree = merger.fetch_remote_tree()?;
2154
2155 assert_eq!(tree.guids().count(), USER_CONTENT_ROOTS.len() + 2);
2157
2158 let node = tree
2159 .node_for_guid(&"qqVTRWhLBOu3".into())
2160 .expect("should exist");
2161 assert!(node.needs_merge);
2162 assert_eq!(node.validity, Validity::Valid);
2163 assert_eq!(node.level(), 2);
2164 assert!(node.is_syncable());
2165
2166 let node = tree
2167 .node_for_guid(&BookmarkRootGuid::Unfiled.as_guid().as_str().into())
2168 .expect("should exist");
2169 assert!(node.needs_merge);
2170 assert_eq!(node.validity, Validity::Valid);
2171 assert_eq!(node.level(), 1);
2172 assert!(node.is_syncable());
2173
2174 let node = tree
2175 .node_for_guid(&BookmarkRootGuid::Menu.as_guid().as_str().into())
2176 .expect("should exist");
2177 assert!(!node.needs_merge);
2178 assert_eq!(node.validity, Validity::Valid);
2179 assert_eq!(node.level(), 1);
2180 assert!(node.is_syncable());
2181
2182 let node = tree
2183 .node_for_guid(&BookmarkRootGuid::Root.as_guid().as_str().into())
2184 .expect("should exist");
2185 assert_eq!(node.validity, Validity::Valid);
2186 assert_eq!(node.level(), 0);
2187 assert!(!node.is_syncable());
2188
2189 assert!(db_has_changes(&conn).unwrap());
2191 Ok(())
2192 }
2193
2194 #[test]
2200 fn test_fetch_remote_tree_with_tombstoned_structure_endpoints() -> Result<()> {
2201 let api = new_mem_api();
2202 let db = api.get_sync_connection().unwrap();
2203 let conn = db.lock();
2204 let interrupt_scope = conn.begin_interrupt_scope()?;
2205
2206 conn.execute_batch(&format!(
2207 "INSERT INTO moz_bookmarks_synced(guid, isDeleted, needsMerge, kind)
2208 VALUES ('Pppppppppppp', 1, 0, {folder}),
2209 ('Cccccccccccc', 1, 0, {bookmark});
2210 INSERT INTO moz_bookmarks_synced_structure(guid, parentGuid, position)
2211 VALUES ('Cccccccccccc', 'Pppppppppppp', 0);",
2212 folder = SyncedBookmarkKind::Folder as u8,
2213 bookmark = SyncedBookmarkKind::Bookmark as u8,
2214 ))?;
2215
2216 let merger = Merger::new(&conn, &interrupt_scope, ServerTimestamp(0));
2217 let tree = merger.fetch_remote_tree()?;
2220 assert!(tree.node_for_guid(&"Cccccccccccc".into()).is_none());
2222 assert!(tree.node_for_guid(&"Pppppppppppp".into()).is_none());
2223 Ok(())
2224 }
2225
2226 #[test]
2232 fn test_fetch_remote_tree_with_non_folder_parent_in_structure() -> Result<()> {
2233 let api = new_mem_api();
2234 let db = api.get_sync_connection().unwrap();
2235 let conn = db.lock();
2236 let interrupt_scope = conn.begin_interrupt_scope()?;
2237
2238 conn.execute_batch(&format!(
2239 "INSERT INTO moz_bookmarks_synced(guid, isDeleted, needsMerge, kind, validity)
2240 VALUES ('Pppppppppppp', 0, 0, {bookmark}, {valid}),
2241 ('Cccccccccccc', 0, 0, {bookmark}, {valid});
2242 INSERT INTO moz_bookmarks_synced_structure(guid, parentGuid, position)
2243 VALUES ('Cccccccccccc', 'Pppppppppppp', 0);",
2244 bookmark = SyncedBookmarkKind::Bookmark as u8,
2245 valid = SyncedBookmarkValidity::Valid as u8,
2246 ))?;
2247
2248 let merger = Merger::new(&conn, &interrupt_scope, ServerTimestamp(0));
2249 let tree = merger.fetch_remote_tree()?;
2252 let c_node = tree
2256 .node_for_guid(&"Cccccccccccc".into())
2257 .expect("Cccccccccccc should be in tree as orphan");
2258 let c_parent_guid = c_node.parent().map(|p| p.item().guid.clone());
2259 assert_ne!(
2260 c_parent_guid.as_deref(),
2261 Some("Pppppppppppp"),
2262 "Cccccccccccc should not be parented to the non-folder Pppppppppppp"
2263 );
2264 Ok(())
2265 }
2266
2267 #[test]
2268 fn test_fetch_local_tree() -> Result<()> {
2269 let now = SystemTime::now();
2270 let previously_ts: Timestamp = (now - Duration::new(10, 0)).into();
2271 let api = new_mem_api();
2272 let writer = api.open_connection(ConnectionType::ReadWrite)?;
2273 let sync_db = api.get_sync_connection().unwrap();
2274 let syncer = sync_db.lock();
2275
2276 writer
2277 .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
2278 .expect("should work");
2279
2280 insert_local_json_tree(
2281 &writer,
2282 json!({
2283 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
2284 "children": [
2285 {
2286 "guid": "bookmark1___",
2287 "title": "the bookmark",
2288 "url": "https://www.example.com/",
2289 "last_modified": previously_ts,
2290 "date_added": previously_ts,
2291 },
2292 ]
2293 }),
2294 );
2295
2296 let interrupt_scope = syncer.begin_interrupt_scope()?;
2297 let merger =
2298 Merger::with_localtime(&syncer, &interrupt_scope, ServerTimestamp(0), now.into());
2299
2300 let tree = merger.fetch_local_tree()?;
2301
2302 assert_eq!(tree.guids().count(), USER_CONTENT_ROOTS.len() + 2);
2304
2305 let node = tree
2306 .node_for_guid(&"bookmark1___".into())
2307 .expect("should exist");
2308 assert!(node.needs_merge);
2309 assert_eq!(node.level(), 2);
2310 assert!(node.is_syncable());
2311 assert_eq!(node.age, 10000);
2312
2313 let node = tree
2314 .node_for_guid(&BookmarkRootGuid::Unfiled.as_guid().as_str().into())
2315 .expect("should exist");
2316 assert!(node.needs_merge);
2317 assert_eq!(node.level(), 1);
2318 assert!(node.is_syncable());
2319
2320 let node = tree
2321 .node_for_guid(&BookmarkRootGuid::Menu.as_guid().as_str().into())
2322 .expect("should exist");
2323 assert!(!node.needs_merge);
2324 assert_eq!(node.level(), 1);
2325 assert!(node.is_syncable());
2326
2327 let node = tree
2328 .node_for_guid(&BookmarkRootGuid::Root.as_guid().as_str().into())
2329 .expect("should exist");
2330 assert!(!node.needs_merge);
2331 assert_eq!(node.level(), 0);
2332 assert!(!node.is_syncable());
2333 let max_dur = SystemTime::now().duration_since(now).unwrap();
2335 let max_age = max_dur.as_secs() as i64 * 1000 + i64::from(max_dur.subsec_millis());
2336 assert!(node.age <= max_age);
2337
2338 assert!(db_has_changes(&syncer).unwrap());
2340 Ok(())
2341 }
2342
2343 #[test]
2344 fn test_apply_bookmark() {
2345 let api = new_mem_api();
2346 assert_incoming_creates_local_tree(
2347 &api,
2348 json!([{
2349 "id": "bookmark1___",
2350 "type": "bookmark",
2351 "parentid": "unfiled",
2352 "parentName": "Unfiled Bookmarks",
2353 "dateAdded": 1_381_542_355_843u64,
2354 "title": "Some bookmark",
2355 "bmkUri": "http://example.com",
2356 },
2357 {
2358 "id": "unfiled",
2359 "type": "folder",
2360 "parentid": "places",
2361 "dateAdded": 1_381_542_355_843u64,
2362 "title": "Unfiled",
2363 "children": ["bookmark1___"],
2364 }]),
2365 &BookmarkRootGuid::Unfiled.as_guid(),
2366 json!({"children" : [{"guid": "bookmark1___", "url": "http://example.com"}]}),
2367 );
2368 let reader = api
2369 .open_connection(ConnectionType::ReadOnly)
2370 .expect("Should open read-only connection");
2371 assert!(
2372 frecency_stale_at(&reader, &Url::parse("http://example.com").unwrap())
2373 .expect("Should check stale frecency")
2374 .is_some(),
2375 "Should mark frecency for bookmark URL as stale"
2376 );
2377
2378 let writer = api
2379 .open_connection(ConnectionType::ReadWrite)
2380 .expect("Should open read-write connection");
2381 insert_local_json_tree(
2382 &writer,
2383 json!({
2384 "guid": &BookmarkRootGuid::Menu.as_guid(),
2385 "children": [
2386 {
2387 "guid": "bookmark2___",
2388 "title": "2",
2389 "url": "http://example.com/2",
2390 }
2391 ],
2392 }),
2393 );
2394 assert_incoming_creates_local_tree(
2395 &api,
2396 json!([{
2397 "id": "menu",
2398 "type": "folder",
2399 "parentid": "places",
2400 "parentName": "",
2401 "dateAdded": 0,
2402 "title": "menu",
2403 "children": ["bookmark2___"],
2404 }, {
2405 "id": "bookmark2___",
2406 "type": "bookmark",
2407 "parentid": "menu",
2408 "parentName": "menu",
2409 "dateAdded": 1_381_542_355_843u64,
2410 "title": "2",
2411 "bmkUri": "http://example.com/2-remote",
2412 }]),
2413 &BookmarkRootGuid::Menu.as_guid(),
2414 json!({"children" : [{"guid": "bookmark2___", "url": "http://example.com/2-remote"}]}),
2415 );
2416 assert!(
2417 frecency_stale_at(&reader, &Url::parse("http://example.com/2").unwrap())
2418 .expect("Should check stale frecency for old URL")
2419 .is_some(),
2420 "Should mark frecency for old URL as stale"
2421 );
2422 assert!(
2423 frecency_stale_at(&reader, &Url::parse("http://example.com/2-remote").unwrap())
2424 .expect("Should check stale frecency for new URL")
2425 .is_some(),
2426 "Should mark frecency for new URL as stale"
2427 );
2428
2429 let sync_db = api.get_sync_connection().unwrap();
2430 let syncer = sync_db.lock();
2431 let interrupt_scope = syncer.begin_interrupt_scope().unwrap();
2432
2433 update_frecencies(&syncer, &interrupt_scope).expect("Should update frecencies");
2434
2435 assert!(
2436 frecency_stale_at(&reader, &Url::parse("http://example.com").unwrap())
2437 .expect("Should check stale frecency")
2438 .is_none(),
2439 "Should recalculate frecency for first bookmark"
2440 );
2441 assert!(
2442 frecency_stale_at(&reader, &Url::parse("http://example.com/2").unwrap())
2443 .expect("Should check stale frecency for old URL")
2444 .is_none(),
2445 "Should recalculate frecency for old URL"
2446 );
2447 assert!(
2448 frecency_stale_at(&reader, &Url::parse("http://example.com/2-remote").unwrap())
2449 .expect("Should check stale frecency for new URL")
2450 .is_none(),
2451 "Should recalculate frecency for new URL"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_apply_complex_bookmark_tags() -> Result<()> {
2457 let api = new_mem_api();
2458 let writer = api.open_connection(ConnectionType::ReadWrite)?;
2459
2460 let local_bookmarks = vec![
2464 InsertableBookmark {
2465 parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2466 position: BookmarkPosition::Append,
2467 date_added: None,
2468 last_modified: None,
2469 guid: Some("bookmarkAAA1".into()),
2470 url: Url::parse("http://example.com/a").unwrap(),
2471 title: Some("A1".into()),
2472 }
2473 .into(),
2474 InsertableBookmark {
2475 parent_guid: BookmarkRootGuid::Menu.as_guid(),
2476 position: BookmarkPosition::Append,
2477 date_added: None,
2478 last_modified: None,
2479 guid: Some("bookmarkAAA2".into()),
2480 url: Url::parse("http://example.com/a").unwrap(),
2481 title: Some("A2".into()),
2482 }
2483 .into(),
2484 InsertableBookmark {
2485 parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2486 position: BookmarkPosition::Append,
2487 date_added: None,
2488 last_modified: None,
2489 guid: Some("bookmarkBBBB".into()),
2490 url: Url::parse("http://example.com/b").unwrap(),
2491 title: Some("B".into()),
2492 }
2493 .into(),
2494 ];
2495 let local_tags = &[
2496 ("http://example.com/a", vec!["one", "two"]),
2497 (
2498 "http://example.com/b",
2499 vec!["two", "three", "three", "four"],
2501 ),
2502 ];
2503 for bm in local_bookmarks.into_iter() {
2504 insert_bookmark(&writer, bm)?;
2505 }
2506 for (url, tags) in local_tags {
2507 let url = Url::parse(url)?;
2508 for t in tags.iter() {
2509 tags::tag_url(&writer, &url, t)?;
2510 }
2511 }
2512
2513 let remote_records = json!([{
2516 "id": "bookmarkBBBB",
2519 "type": "bookmark",
2520 "parentid": "unfiled",
2521 "parentName": "Unfiled",
2522 "dateAdded": 1_381_542_355_843u64,
2523 "title": "B",
2524 "bmkUri": "http://example.com/b",
2525 "tags": ["two", "two", "three", "eight"],
2526 }, {
2527 "id": "bookmarkCCC1",
2531 "type": "bookmark",
2532 "parentid": "unfiled",
2533 "parentName": "Unfiled",
2534 "dateAdded": 1_381_542_355_843u64,
2535 "title": "C1",
2536 "bmkUri": "http://example.com/c",
2537 "tags": ["four", "five", "six"],
2538 }, {
2539 "id": "bookmarkCCC2",
2540 "type": "bookmark",
2541 "parentid": "menu",
2542 "parentName": "Menu",
2543 "dateAdded": 1_381_542_355_843u64,
2544 "title": "C2",
2545 "bmkUri": "http://example.com/c",
2546 "tags": ["four", "five", "six"],
2547 }, {
2548 "id": "bookmarkCCC3",
2549 "type": "bookmark",
2550 "parentid": "menu",
2551 "parentName": "Menu",
2552 "dateAdded": 1_381_542_355_843u64,
2553 "title": "C3",
2554 "bmkUri": "http://example.com/c",
2555 "tags": ["six", "six", "seven"],
2556 }, {
2557 "id": "bookmarkDDDD",
2562 "type": "bookmark",
2563 "parentid": "unfiled",
2564 "parentName": "Unfiled",
2565 "dateAdded": 1_381_542_355_843u64,
2566 "title": "D",
2567 "bmkUri": "http://example.com/d",
2568 "tags": ["four", "five", "six"],
2569 }, {
2570 "id": "bookmarkEEE1",
2573 "type": "bookmark",
2574 "parentid": "toolbar",
2575 "parentName": "Toolbar",
2576 "dateAdded": 1_381_542_355_843u64,
2577 "title": "E1",
2578 "bmkUri": "http://example.com/e",
2579 "tags": ["nine", "ten", "eleven"],
2580 }, {
2581 "id": "bookmarkEEE2",
2582 "type": "bookmark",
2583 "parentid": "mobile",
2584 "parentName": "Mobile",
2585 "dateAdded": 1_381_542_355_843u64,
2586 "title": "E2",
2587 "bmkUri": "http://example.com/e",
2588 "tags": ["nine", "ten", "eleven"],
2589 }, {
2590 "id": "bookmarkFFF1",
2593 "type": "bookmark",
2594 "parentid": "toolbar",
2595 "parentName": "Toolbar",
2596 "dateAdded": 1_381_542_355_843u64,
2597 "title": "F1",
2598 "bmkUri": "http://example.com/f",
2599 "tags": ["twelve"],
2600 }, {
2601 "id": "bookmarkFFF2",
2602 "type": "bookmark",
2603 "parentid": "mobile",
2604 "parentName": "Mobile",
2605 "dateAdded": 1_381_542_355_843u64,
2606 "title": "F2",
2607 "bmkUri": "http://example.com/f",
2608 }, {
2609 "id": "unfiled",
2610 "type": "folder",
2611 "parentid": "places",
2612 "dateAdded": 1_381_542_355_843u64,
2613 "title": "Unfiled",
2614 "children": ["bookmarkBBBB", "bookmarkCCC1", "bookmarkDDDD"],
2615 }, {
2616 "id": "menu",
2617 "type": "folder",
2618 "parentid": "places",
2619 "dateAdded": 1_381_542_355_843u64,
2620 "title": "Menu",
2621 "children": ["bookmarkCCC2", "bookmarkCCC3"],
2622 }, {
2623 "id": "toolbar",
2624 "type": "folder",
2625 "parentid": "places",
2626 "dateAdded": 1_381_542_355_843u64,
2627 "title": "Toolbar",
2628 "children": ["bookmarkEEE1", "bookmarkFFF1"],
2629 }, {
2630 "id": "mobile",
2631 "type": "folder",
2632 "parentid": "places",
2633 "dateAdded": 1_381_542_355_843u64,
2634 "title": "Mobile",
2635 "children": ["bookmarkEEE2", "bookmarkFFF2"],
2636 }]);
2637
2638 let engine = create_sync_engine(&api);
2641 let incoming = if let Value::Array(records) = remote_records {
2642 records
2643 .into_iter()
2644 .map(IncomingBso::from_test_content)
2645 .collect()
2646 } else {
2647 unreachable!("JSON records must be an array");
2648 };
2649 let mut outgoing = engine_apply_incoming(&engine, incoming);
2650 outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
2651
2652 assert_local_json_tree(
2654 &writer,
2655 &BookmarkRootGuid::Root.as_guid(),
2656 json!({
2657 "guid": &BookmarkRootGuid::Root.as_guid(),
2658 "children": [{
2659 "guid": &BookmarkRootGuid::Menu.as_guid(),
2660 "children": [{
2661 "guid": "bookmarkCCC2",
2662 "title": "C2",
2663 "url": "http://example.com/c",
2664 }, {
2665 "guid": "bookmarkCCC3",
2666 "title": "C3",
2667 "url": "http://example.com/c",
2668 }, {
2669 "guid": "bookmarkAAA2",
2670 "title": "A2",
2671 "url": "http://example.com/a",
2672 }],
2673 }, {
2674 "guid": &BookmarkRootGuid::Toolbar.as_guid(),
2675 "children": [{
2676 "guid": "bookmarkEEE1",
2677 "title": "E1",
2678 "url": "http://example.com/e",
2679 }, {
2680 "guid": "bookmarkFFF1",
2681 "title": "F1",
2682 "url": "http://example.com/f",
2683 }],
2684 }, {
2685 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
2686 "children": [{
2687 "guid": "bookmarkBBBB",
2688 "title": "B",
2689 "url": "http://example.com/b",
2690 }, {
2691 "guid": "bookmarkCCC1",
2692 "title": "C1",
2693 "url": "http://example.com/c",
2694 }, {
2695 "guid": "bookmarkDDDD",
2696 "title": "D",
2697 "url": "http://example.com/d",
2698 }, {
2699 "guid": "bookmarkAAA1",
2700 "title": "A1",
2701 "url": "http://example.com/a",
2702 }],
2703 }, {
2704 "guid": &BookmarkRootGuid::Mobile.as_guid(),
2705 "children": [{
2706 "guid": "bookmarkEEE2",
2707 "title": "E2",
2708 "url": "http://example.com/e",
2709 }, {
2710 "guid": "bookmarkFFF2",
2711 "title": "F2",
2712 "url": "http://example.com/f",
2713 }],
2714 }],
2715 }),
2716 );
2717 let expected_local_tags = &[
2719 ("http://example.com/a", vec!["one", "two"]),
2720 ("http://example.com/b", vec!["eight", "three", "two"]),
2721 ("http://example.com/c", vec!["five", "four", "seven", "six"]),
2722 ("http://example.com/d", vec!["five", "four", "six"]),
2723 ("http://example.com/e", vec!["eleven", "nine", "ten"]),
2724 ("http://example.com/f", vec!["twelve"]),
2725 ];
2726 for (href, expected) in expected_local_tags {
2727 let mut actual = tags::get_tags_for_url(&writer, &Url::parse(href).unwrap())?;
2728 actual.sort();
2729 assert_eq!(&actual, expected);
2730 }
2731
2732 let expected_outgoing_ids = &[
2733 "bookmarkAAA1", "bookmarkAAA2",
2735 "bookmarkBBBB", "bookmarkCCC1", "bookmarkCCC2",
2738 "bookmarkCCC3",
2739 "bookmarkFFF2", "menu", "mobile",
2742 "toolbar",
2743 "unfiled",
2744 ];
2745 assert_eq!(
2746 outgoing
2747 .iter()
2748 .map(|p| p.envelope.id.as_str())
2749 .collect::<Vec<_>>(),
2750 expected_outgoing_ids,
2751 "Should upload new bookmarks and fix up tags",
2752 );
2753
2754 engine
2757 .set_uploaded(
2758 ServerTimestamp(0),
2759 expected_outgoing_ids.iter().map(SyncGuid::from).collect(),
2760 )
2761 .expect("Should push synced changes back to the engine");
2762 engine.sync_finished().expect("should work");
2763
2764 let mut synced_item_for_a = SyncedBookmarkItem::new();
2769 synced_item_for_a
2770 .validity(SyncedBookmarkValidity::Valid)
2771 .kind(SyncedBookmarkKind::Bookmark)
2772 .url(Some("http://example.com/a"))
2773 .tags(["one", "two"].iter().map(|&tag| tag.into()).collect());
2774 let mut synced_item_for_b = SyncedBookmarkItem::new();
2775 synced_item_for_b
2776 .validity(SyncedBookmarkValidity::Valid)
2777 .kind(SyncedBookmarkKind::Bookmark)
2778 .url(Some("http://example.com/b"))
2779 .tags(
2780 ["eight", "three", "two"]
2781 .iter()
2782 .map(|&tag| tag.into())
2783 .collect(),
2784 )
2785 .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2786 .title(Some("B"));
2787 let mut synced_item_for_c = SyncedBookmarkItem::new();
2788 synced_item_for_c
2789 .validity(SyncedBookmarkValidity::Valid)
2790 .kind(SyncedBookmarkKind::Bookmark)
2791 .url(Some("http://example.com/c"))
2792 .tags(
2793 ["five", "four", "seven", "six"]
2794 .iter()
2795 .map(|&tag| tag.into())
2796 .collect(),
2797 );
2798 let mut synced_item_for_f = SyncedBookmarkItem::new();
2799 synced_item_for_f
2800 .validity(SyncedBookmarkValidity::Valid)
2801 .kind(SyncedBookmarkKind::Bookmark)
2802 .url(Some("http://example.com/f"))
2803 .tags(vec!["twelve".into()]);
2804 let expected_synced_items = &[
2808 ExpectedSyncedItem::with_properties("bookmarkAAA1", &synced_item_for_a, |a| {
2809 a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2810 .title(Some("A1"))
2811 }),
2812 ExpectedSyncedItem::with_properties("bookmarkAAA2", &synced_item_for_a, |a| {
2813 a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2814 .title(Some("A2"))
2815 }),
2816 ExpectedSyncedItem::new("bookmarkBBBB", &synced_item_for_b),
2817 ExpectedSyncedItem::with_properties("bookmarkCCC1", &synced_item_for_c, |c| {
2818 c.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2819 .title(Some("C1"))
2820 }),
2821 ExpectedSyncedItem::with_properties("bookmarkCCC2", &synced_item_for_c, |c| {
2822 c.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2823 .title(Some("C2"))
2824 }),
2825 ExpectedSyncedItem::with_properties("bookmarkCCC3", &synced_item_for_c, |c| {
2826 c.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2827 .title(Some("C3"))
2828 }),
2829 ExpectedSyncedItem::with_properties(
2830 "bookmarkFFF1",
2832 &synced_item_for_f,
2833 |f| {
2834 f.parent_guid(Some(&BookmarkRootGuid::Toolbar.as_guid()))
2835 .title(Some("F1"))
2836 },
2837 ),
2838 ExpectedSyncedItem::with_properties("bookmarkFFF2", &synced_item_for_f, |f| {
2839 f.parent_guid(Some(&BookmarkRootGuid::Mobile.as_guid()))
2840 .title(Some("F2"))
2841 }),
2842 ];
2843 for item in expected_synced_items {
2844 item.check(&writer)?;
2845 }
2846
2847 Ok(())
2848 }
2849
2850 #[test]
2851 fn test_apply_bookmark_tags() -> Result<()> {
2852 let api = new_mem_api();
2853 let writer = api.open_connection(ConnectionType::ReadWrite)?;
2854
2855 insert_bookmark(
2857 &writer,
2858 InsertableBookmark {
2859 parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2860 position: BookmarkPosition::Append,
2861 date_added: None,
2862 last_modified: None,
2863 guid: Some("bookmarkAAAA".into()),
2864 url: Url::parse("http://example.com/a").unwrap(),
2865 title: Some("A".into()),
2866 }
2867 .into(),
2868 )?;
2869 tags::tag_url(&writer, &Url::parse("http://example.com/a").unwrap(), "one")?;
2870
2871 let mut tags_for_a =
2872 tags::get_tags_for_url(&writer, &Url::parse("http://example.com/a").unwrap())?;
2873 tags_for_a.sort();
2874 assert_eq!(tags_for_a, vec!["one".to_owned()]);
2875
2876 assert_incoming_creates_local_tree(
2877 &api,
2878 json!([{
2879 "id": "bookmarkBBBB",
2880 "type": "bookmark",
2881 "parentid": "unfiled",
2882 "parentName": "Unfiled",
2883 "dateAdded": 1_381_542_355_843u64,
2884 "title": "B",
2885 "bmkUri": "http://example.com/b",
2886 "tags": ["one", "two"],
2887 }, {
2888 "id": "bookmarkCCCC",
2889 "type": "bookmark",
2890 "parentid": "unfiled",
2891 "parentName": "Unfiled",
2892 "dateAdded": 1_381_542_355_843u64,
2893 "title": "C",
2894 "bmkUri": "http://example.com/c",
2895 "tags": ["three"],
2896 }, {
2897 "id": "unfiled",
2898 "type": "folder",
2899 "parentid": "places",
2900 "dateAdded": 1_381_542_355_843u64,
2901 "title": "Unfiled",
2902 "children": ["bookmarkBBBB", "bookmarkCCCC"],
2903 }]),
2904 &BookmarkRootGuid::Unfiled.as_guid(),
2905 json!({"children" : [
2906 {"guid": "bookmarkBBBB", "url": "http://example.com/b"},
2907 {"guid": "bookmarkCCCC", "url": "http://example.com/c"},
2908 {"guid": "bookmarkAAAA", "url": "http://example.com/a"},
2909 ]}),
2910 );
2911
2912 let mut tags_for_a =
2913 tags::get_tags_for_url(&writer, &Url::parse("http://example.com/a").unwrap())?;
2914 tags_for_a.sort();
2915 assert_eq!(tags_for_a, vec!["one".to_owned()]);
2916
2917 let mut tags_for_b =
2918 tags::get_tags_for_url(&writer, &Url::parse("http://example.com/b").unwrap())?;
2919 tags_for_b.sort();
2920 assert_eq!(tags_for_b, vec!["one".to_owned(), "two".to_owned()]);
2921
2922 let mut tags_for_c =
2923 tags::get_tags_for_url(&writer, &Url::parse("http://example.com/c").unwrap())?;
2924 tags_for_c.sort();
2925 assert_eq!(tags_for_c, vec!["three".to_owned()]);
2926
2927 let synced_item_for_a = SyncedBookmarkItem::get(&writer, &"bookmarkAAAA".into())
2928 .expect("Should fetch A")
2929 .expect("A should exist");
2930 assert_eq!(
2931 synced_item_for_a,
2932 *SyncedBookmarkItem::new()
2933 .validity(SyncedBookmarkValidity::Valid)
2934 .kind(SyncedBookmarkKind::Bookmark)
2935 .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2936 .title(Some("A"))
2937 .url(Some("http://example.com/a"))
2938 .tags(vec!["one".into()])
2939 );
2940
2941 let synced_item_for_b = SyncedBookmarkItem::get(&writer, &"bookmarkBBBB".into())
2942 .expect("Should fetch B")
2943 .expect("B should exist");
2944 assert_eq!(
2945 synced_item_for_b,
2946 *SyncedBookmarkItem::new()
2947 .validity(SyncedBookmarkValidity::Valid)
2948 .kind(SyncedBookmarkKind::Bookmark)
2949 .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2950 .title(Some("B"))
2951 .url(Some("http://example.com/b"))
2952 .tags(vec!["one".into(), "two".into()])
2953 );
2954
2955 Ok(())
2956 }
2957
2958 #[test]
2959 fn test_apply_bookmark_keyword() -> Result<()> {
2960 let api = new_mem_api();
2961
2962 let records = json!([{
2963 "id": "bookmarkAAAA",
2964 "type": "bookmark",
2965 "parentid": "unfiled",
2966 "parentName": "Unfiled",
2967 "dateAdded": 1_381_542_355_843u64,
2968 "title": "A",
2969 "bmkUri": "http://example.com/a?b=c&d=%s",
2970 "keyword": "ex",
2971 },
2972 {
2973 "id": "unfiled",
2974 "type": "folder",
2975 "parentid": "places",
2976 "dateAdded": 1_381_542_355_843u64,
2977 "title": "Unfiled",
2978 "children": ["bookmarkAAAA"],
2979 }]);
2980
2981 let db_mutex = api.get_sync_connection().unwrap();
2982 let db = db_mutex.lock();
2983 let tx = db.begin_transaction()?;
2984 let applicator = IncomingApplicator::new(&db);
2985
2986 if let Value::Array(records) = records {
2987 for record in records {
2988 applicator.apply_bso(IncomingBso::from_test_content(record))?;
2989 }
2990 } else {
2991 unreachable!("JSON records must be an array");
2992 }
2993
2994 tx.commit()?;
2995
2996 db.execute(
2999 "UPDATE moz_bookmarks_synced SET
3000 validity = :validity
3001 WHERE guid = :guid",
3002 rusqlite::named_params! {
3003 ":validity": SyncedBookmarkValidity::Reupload,
3004 ":guid": SyncGuid::from("bookmarkAAAA"),
3005 },
3006 )?;
3007
3008 let interrupt_scope = db.begin_interrupt_scope()?;
3009
3010 let mut merger = Merger::new(&db, &interrupt_scope, ServerTimestamp(0));
3011 merger.merge()?;
3012
3013 assert_local_json_tree(
3014 &db,
3015 &BookmarkRootGuid::Unfiled.as_guid(),
3016 json!({"children" : [{"guid": "bookmarkAAAA", "url": "http://example.com/a?b=c&d=%s"}]}),
3017 );
3018
3019 let outgoing = fetch_outgoing_records(&db, &interrupt_scope)?;
3020 let record_for_a = outgoing
3021 .iter()
3022 .find(|payload| payload.envelope.id == "bookmarkAAAA")
3023 .expect("Should reupload A");
3024 let bk = record_for_a.to_test_incoming_t::<BookmarkRecord>();
3025 assert_eq!(bk.url.unwrap(), "http://example.com/a?b=c&d=%s");
3026 assert_eq!(bk.keyword.unwrap(), "ex");
3027
3028 Ok(())
3029 }
3030
3031 #[test]
3032 fn test_apply_query() {
3033 let api = new_mem_api();
3035 assert_incoming_creates_local_tree(
3036 &api,
3037 json!([{
3038 "id": "query1______",
3039 "type": "query",
3040 "parentid": "unfiled",
3041 "parentName": "Unfiled Bookmarks",
3042 "dateAdded": 1_381_542_355_843u64,
3043 "title": "Some query",
3044 "bmkUri": "place:tag=foo",
3045 },
3046 {
3047 "id": "unfiled",
3048 "type": "folder",
3049 "parentid": "places",
3050 "dateAdded": 1_381_542_355_843u64,
3051 "title": "Unfiled",
3052 "children": ["query1______"],
3053 }]),
3054 &BookmarkRootGuid::Unfiled.as_guid(),
3055 json!({"children" : [{"guid": "query1______", "url": "place:tag=foo"}]}),
3056 );
3057 let reader = api
3058 .open_connection(ConnectionType::ReadOnly)
3059 .expect("Should open read-only connection");
3060 assert!(
3061 frecency_stale_at(&reader, &Url::parse("place:tag=foo").unwrap())
3062 .expect("Should check stale frecency")
3063 .is_none(),
3064 "Should not mark frecency for queries as stale"
3065 );
3066 }
3067
3068 #[test]
3069 fn test_apply() -> Result<()> {
3070 let api = new_mem_api();
3071 let writer = api.open_connection(ConnectionType::ReadWrite)?;
3072 let db = api.get_sync_connection().unwrap();
3073 let syncer = db.lock();
3074
3075 syncer
3076 .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
3077 .expect("should work");
3078
3079 insert_local_json_tree(
3080 &writer,
3081 json!({
3082 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3083 "children": [
3084 {
3085 "guid": "bookmarkAAAA",
3086 "title": "A",
3087 "url": "http://example.com/a",
3088 },
3089 {
3090 "guid": "bookmarkBBBB",
3091 "title": "B",
3092 "url": "http://example.com/b",
3093 },
3094 ]
3095 }),
3096 );
3097 tags::tag_url(
3098 &writer,
3099 &Url::parse("http://example.com/a").expect("Should parse URL for A"),
3100 "baz",
3101 )
3102 .expect("Should tag A");
3103
3104 let records = vec![
3105 json!({
3106 "id": "bookmarkCCCC",
3107 "type": "bookmark",
3108 "parentid": "menu",
3109 "parentName": "menu",
3110 "dateAdded": 1_552_183_116_885u64,
3111 "title": "C",
3112 "bmkUri": "http://example.com/c",
3113 "tags": ["foo", "bar"],
3114 }),
3115 json!({
3116 "id": "menu",
3117 "type": "folder",
3118 "parentid": "places",
3119 "parentName": "",
3120 "dateAdded": 0,
3121 "title": "menu",
3122 "children": ["bookmarkCCCC"],
3123 }),
3124 ];
3125
3126 drop(syncer);
3128 let engine = create_sync_engine(&api);
3129
3130 let incoming = records
3131 .into_iter()
3132 .map(IncomingBso::from_test_content)
3133 .collect();
3134
3135 let mut outgoing = engine_apply_incoming(&engine, incoming);
3136 outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3137 assert_eq!(
3138 outgoing
3139 .iter()
3140 .map(|p| p.envelope.id.as_str())
3141 .collect::<Vec<_>>(),
3142 vec!["bookmarkAAAA", "bookmarkBBBB", "unfiled",]
3143 );
3144 let record_for_a = outgoing
3145 .iter()
3146 .find(|p| p.envelope.id == "bookmarkAAAA")
3147 .expect("Should upload A");
3148 let content_for_a = record_for_a.to_test_incoming_t::<BookmarkRecord>();
3149 assert_eq!(content_for_a.tags, vec!["baz".to_string()]);
3150
3151 assert_local_json_tree(
3152 &writer,
3153 &BookmarkRootGuid::Root.as_guid(),
3154 json!({
3155 "guid": &BookmarkRootGuid::Root.as_guid(),
3156 "children": [
3157 {
3158 "guid": &BookmarkRootGuid::Menu.as_guid(),
3159 "children": [
3160 {
3161 "guid": "bookmarkCCCC",
3162 "title": "C",
3163 "url": "http://example.com/c",
3164 "date_added": Timestamp(1_552_183_116_885),
3165 },
3166 ],
3167 },
3168 {
3169 "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3170 "children": [],
3171 },
3172 {
3173 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3174 "children": [
3175 {
3176 "guid": "bookmarkAAAA",
3177 "title": "A",
3178 "url": "http://example.com/a",
3179 },
3180 {
3181 "guid": "bookmarkBBBB",
3182 "title": "B",
3183 "url": "http://example.com/b",
3184 },
3185 ],
3186 },
3187 {
3188 "guid": &BookmarkRootGuid::Mobile.as_guid(),
3189 "children": [],
3190 },
3191 ],
3192 }),
3193 );
3194
3195 let guid_for_a: SyncGuid = "bookmarkAAAA".into();
3198 let info_for_a = get_raw_bookmark(&writer, &guid_for_a)
3199 .expect("Should fetch info for A")
3200 .unwrap();
3201 assert_eq!(info_for_a._sync_change_counter, 2);
3202 let info_for_unfiled = get_raw_bookmark(&writer, &BookmarkRootGuid::Unfiled.as_guid())
3203 .expect("Should fetch info for unfiled")
3204 .unwrap();
3205 assert_eq!(info_for_unfiled._sync_change_counter, 2);
3206
3207 engine
3208 .set_uploaded(
3209 ServerTimestamp(0),
3210 vec![
3211 "bookmarkAAAA".into(),
3212 "bookmarkBBBB".into(),
3213 "unfiled".into(),
3214 ],
3215 )
3216 .expect("Should push synced changes back to the engine");
3217 engine.sync_finished().expect("finish always works");
3218
3219 let info_for_a = get_raw_bookmark(&writer, &guid_for_a)
3220 .expect("Should fetch info for A")
3221 .unwrap();
3222 assert_eq!(info_for_a._sync_change_counter, 0);
3223 let info_for_unfiled = get_raw_bookmark(&writer, &BookmarkRootGuid::Unfiled.as_guid())
3224 .expect("Should fetch info for unfiled")
3225 .unwrap();
3226 assert_eq!(info_for_unfiled._sync_change_counter, 0);
3227
3228 let mut tags_for_c = tags::get_tags_for_url(
3229 &writer,
3230 &Url::parse("http://example.com/c").expect("Should parse URL for C"),
3231 )
3232 .expect("Should return tags for C");
3233 tags_for_c.sort();
3234 assert_eq!(tags_for_c, &["bar", "foo"]);
3235
3236 Ok(())
3237 }
3238
3239 #[test]
3240 fn test_apply_invalid_url() -> Result<()> {
3241 let api = new_mem_api();
3242 let db = api.get_sync_connection().unwrap();
3243 let syncer = db.lock();
3244
3245 syncer
3246 .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
3247 .expect("should work");
3248
3249 let records = vec![
3250 json!({
3251 "id": "bookmarkXXXX",
3252 "type": "bookmark",
3253 "parentid": "menu",
3254 "parentName": "menu",
3255 "dateAdded": 1_552_183_116_885u64,
3256 "title": "Invalid",
3257 "bmkUri": "invalid url",
3258 }),
3259 json!({
3260 "id": "menu",
3261 "type": "folder",
3262 "parentid": "places",
3263 "parentName": "",
3264 "dateAdded": 0,
3265 "title": "menu",
3266 "children": ["bookmarkXXXX"],
3267 }),
3268 ];
3269
3270 drop(syncer);
3272 let engine = create_sync_engine(&api);
3273
3274 let incoming = records
3275 .into_iter()
3276 .map(IncomingBso::from_test_content)
3277 .collect();
3278
3279 let mut outgoing = engine_apply_incoming(&engine, incoming);
3280 outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3281 assert_eq!(
3282 outgoing
3283 .iter()
3284 .map(|p| p.envelope.id.as_str())
3285 .collect::<Vec<_>>(),
3286 vec!["bookmarkXXXX", "menu",]
3287 );
3288
3289 let record_for_invalid = outgoing
3290 .iter()
3291 .find(|p| p.envelope.id == "bookmarkXXXX")
3292 .expect("Should re-upload the invalid record");
3293
3294 assert!(
3295 matches!(
3296 record_for_invalid
3297 .to_test_incoming()
3298 .into_content::<BookmarkRecord>()
3299 .kind,
3300 IncomingKind::Tombstone
3301 ),
3302 "is invalid record"
3303 );
3304
3305 let record_for_menu = outgoing
3306 .iter()
3307 .find(|p| p.envelope.id == "menu")
3308 .expect("Should upload menu");
3309 let content_for_menu = record_for_menu.to_test_incoming_t::<FolderRecord>();
3310 assert!(
3311 content_for_menu.children.is_empty(),
3312 "should have been removed from the parent"
3313 );
3314 Ok(())
3315 }
3316
3317 #[test]
3318 fn test_apply_tombstones() -> Result<()> {
3319 let local_modified = Timestamp::now();
3320 let api = new_mem_api();
3321 let writer = api.open_connection(ConnectionType::ReadWrite)?;
3322 insert_local_json_tree(
3323 &writer,
3324 json!({
3325 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3326 "children": [{
3327 "guid": "bookmarkAAAA",
3328 "title": "A",
3329 "url": "http://example.com/a",
3330 "date_added": local_modified,
3331 "last_modified": local_modified,
3332 }, {
3333 "guid": "separatorAAA",
3334 "type": BookmarkType::Separator as u8,
3335 "date_added": local_modified,
3336 "last_modified": local_modified,
3337 }, {
3338 "guid": "folderAAAAAA",
3339 "children": [{
3340 "guid": "bookmarkBBBB",
3341 "title": "b",
3342 "url": "http://example.com/b",
3343 "date_added": local_modified,
3344 "last_modified": local_modified,
3345 }],
3346 }],
3347 }),
3348 );
3349 let engine = create_sync_engine(&api);
3351 let outgoing = engine_apply_incoming(&engine, vec![]);
3352 let outgoing_ids = outgoing
3353 .iter()
3354 .map(|p| p.envelope.id.clone())
3355 .collect::<Vec<_>>();
3356 assert_eq!(outgoing_ids.len(), 8, "{:?}", outgoing_ids);
3358
3359 engine
3360 .set_uploaded(ServerTimestamp(0), outgoing_ids)
3361 .expect("should work");
3362 engine.sync_finished().expect("should work");
3363
3364 let remote_unfiled = json!({
3366 "id": "unfiled",
3367 "type": "folder",
3368 "parentid": "places",
3369 "title": "Unfiled",
3370 "children": [],
3371 });
3372
3373 let incoming = vec![
3374 IncomingBso::new_test_tombstone(Guid::new("bookmarkAAAA")),
3375 IncomingBso::new_test_tombstone(Guid::new("separatorAAA")),
3376 IncomingBso::new_test_tombstone(Guid::new("folderAAAAAA")),
3377 IncomingBso::new_test_tombstone(Guid::new("bookmarkBBBB")),
3378 IncomingBso::from_test_content(remote_unfiled),
3379 ];
3380
3381 let outgoing = engine_apply_incoming(&engine, incoming);
3382 let outgoing_ids = outgoing
3383 .iter()
3384 .map(|p| p.envelope.id.clone())
3385 .collect::<Vec<_>>();
3386 assert_eq!(outgoing_ids.len(), 0, "{:?}", outgoing_ids);
3387
3388 engine
3389 .set_uploaded(ServerTimestamp(0), outgoing_ids)
3390 .expect("should work");
3391 engine.sync_finished().expect("should work");
3392
3393 assert_local_json_tree(
3395 &api.get_sync_connection().unwrap().lock(),
3396 &BookmarkRootGuid::Unfiled.as_guid(),
3397 json!({"children" : []}),
3398 );
3399 Ok(())
3400 }
3401
3402 #[test]
3403 fn test_keywords() -> Result<()> {
3404 use crate::storage::bookmarks::bookmarks_get_url_for_keyword;
3405
3406 let api = new_mem_api();
3407 let writer = api.open_connection(ConnectionType::ReadWrite)?;
3408
3409 let records = vec![
3410 json!({
3411 "id": "toolbar",
3412 "type": "folder",
3413 "parentid": "places",
3414 "parentName": "",
3415 "dateAdded": 0,
3416 "title": "toolbar",
3417 "children": ["bookmarkAAAA"],
3418 }),
3419 json!({
3420 "id": "bookmarkAAAA",
3421 "type": "bookmark",
3422 "parentid": "toolbar",
3423 "parentName": "toolbar",
3424 "dateAdded": 1_552_183_116_885u64,
3425 "title": "A",
3426 "bmkUri": "http://example.com/a/%s",
3427 "keyword": "a",
3428 }),
3429 ];
3430
3431 let engine = create_sync_engine(&api);
3432
3433 let incoming = records
3434 .into_iter()
3435 .map(IncomingBso::from_test_content)
3436 .collect();
3437
3438 let outgoing = engine_apply_incoming(&engine, incoming);
3439 let mut outgoing_ids = outgoing
3440 .iter()
3441 .map(|p| p.envelope.id.clone())
3442 .collect::<Vec<_>>();
3443 outgoing_ids.sort();
3444 assert_eq!(outgoing_ids, &["menu", "mobile", "toolbar", "unfiled"],);
3445
3446 assert_eq!(
3447 bookmarks_get_url_for_keyword(&writer, "a")?,
3448 Some(Url::parse("http://example.com/a/%s")?)
3449 );
3450
3451 engine
3452 .set_uploaded(ServerTimestamp(0), outgoing_ids)
3453 .expect("Should push synced changes back to the engine");
3454 engine.sync_finished().expect("should work");
3455
3456 update_bookmark(
3457 &writer,
3458 &"bookmarkAAAA".into(),
3459 &UpdatableBookmark {
3460 title: Some("A (local)".into()),
3461 ..UpdatableBookmark::default()
3462 }
3463 .into(),
3464 )?;
3465
3466 let outgoing = engine_apply_incoming(&engine, vec![]);
3467 assert_eq!(outgoing.len(), 1);
3468 let bk = outgoing[0].to_test_incoming_t::<BookmarkRecord>();
3469 assert_eq!(bk.record_id.as_guid(), "bookmarkAAAA");
3470 assert_eq!(bk.keyword.unwrap(), "a");
3471 assert_eq!(bk.url.unwrap(), "http://example.com/a/%s");
3472
3473 let foreign_count = writer
3478 .try_query_row(
3479 "SELECT foreign_count FROM moz_places
3480 WHERE url_hash = hash(:url) AND
3481 url = :url",
3482 &[(":url", &"http://example.com/a/%s")],
3483 |row| -> rusqlite::Result<_> { row.get::<_, i64>(0) },
3484 false,
3485 )?
3486 .expect("Should fetch foreign count for URL A");
3487 assert_eq!(foreign_count, 3);
3488 let err = writer
3489 .execute(
3490 "DELETE FROM moz_places
3491 WHERE url_hash = hash(:url) AND
3492 url = :url",
3493 rusqlite::named_params! {
3494 ":url": "http://example.com/a/%s",
3495 },
3496 )
3497 .expect_err("Should fail to delete URL A with keyword");
3498 match err {
3499 RusqlError::SqliteFailure(e, _) => assert_eq!(e.code, ErrorCode::ConstraintViolation),
3500 _ => panic!("Wanted constraint violation error; got {:?}", err),
3501 }
3502
3503 Ok(())
3504 }
3505
3506 #[test]
3507 fn test_apply_complex_bookmark_keywords() -> Result<()> {
3508 use crate::storage::bookmarks::bookmarks_get_url_for_keyword;
3509
3510 let api = new_mem_api();
3514 let writer = api.open_connection(ConnectionType::ReadWrite)?;
3515
3516 let remote_records = json!([{
3518 "id": "bookmarkAAA1",
3521 "type": "bookmark",
3522 "parentid": "unfiled",
3523 "parentName": "Unfiled",
3524 "title": "A1",
3525 "bmkUri": "http://example.com/a",
3526 "keyword": "one",
3527 }, {
3528 "id": "bookmarkAAA2",
3529 "type": "bookmark",
3530 "parentid": "menu",
3531 "parentName": "Menu",
3532 "title": "A2",
3533 "bmkUri": "http://example.com/a",
3534 "keyword": "one",
3535 }, {
3536 "id": "bookmarkBBB1",
3540 "type": "bookmark",
3541 "parentid": "unfiled",
3542 "parentName": "Unfiled",
3543 "title": "B1",
3544 "bmkUri": "http://example.com/b",
3545 "keyword": "two",
3546 }, {
3547 "id": "bookmarkBBB2",
3548 "type": "bookmark",
3549 "parentid": "menu",
3550 "parentName": "Menu",
3551 "title": "B2",
3552 "bmkUri": "http://example.com/b",
3553 "keyword": "three",
3554 }, {
3555 "id": "bookmarkCCC1",
3559 "type": "bookmark",
3560 "parentid": "unfiled",
3561 "parentName": "Unfiled",
3562 "title": "C1",
3563 "bmkUri": "http://example.com/c",
3564 "keyword": "four",
3565 }, {
3566 "id": "bookmarkCCC2",
3567 "type": "bookmark",
3568 "parentid": "menu",
3569 "parentName": "Menu",
3570 "title": "C2",
3571 "bmkUri": "http://example.com/c",
3572 }, {
3573 "id": "bookmarkDDDD",
3576 "type": "bookmark",
3577 "parentid": "unfiled",
3578 "parentName": "Unfiled",
3579 "title": "D",
3580 "bmkUri": "http://example.com/d",
3581 "keyword": " FIVE ",
3582 }, {
3583 "id": "unfiled",
3584 "type": "folder",
3585 "parentid": "places",
3586 "title": "Unfiled",
3587 "children": ["bookmarkAAA1", "bookmarkBBB1", "bookmarkCCC1", "bookmarkDDDD"],
3588 }, {
3589 "id": "menu",
3590 "type": "folder",
3591 "parentid": "places",
3592 "title": "Menu",
3593 "children": ["bookmarkAAA2", "bookmarkBBB2", "bookmarkCCC2"],
3594 }]);
3595
3596 let engine = create_sync_engine(&api);
3597 let incoming = if let Value::Array(records) = remote_records {
3598 records
3599 .into_iter()
3600 .map(IncomingBso::from_test_content)
3601 .collect()
3602 } else {
3603 unreachable!("JSON records must be an array");
3604 };
3605 let mut outgoing = engine_apply_incoming(&engine, incoming);
3606 outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3607
3608 assert_local_json_tree(
3609 &writer,
3610 &BookmarkRootGuid::Root.as_guid(),
3611 json!({
3612 "guid": &BookmarkRootGuid::Root.as_guid(),
3613 "children": [{
3614 "guid": &BookmarkRootGuid::Menu.as_guid(),
3615 "children": [{
3616 "guid": "bookmarkAAA2",
3617 "title": "A2",
3618 "url": "http://example.com/a",
3619 }, {
3620 "guid": "bookmarkBBB2",
3621 "title": "B2",
3622 "url": "http://example.com/b",
3623 }, {
3624 "guid": "bookmarkCCC2",
3625 "title": "C2",
3626 "url": "http://example.com/c",
3627 }],
3628 }, {
3629 "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3630 "children": [],
3631 }, {
3632 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3633 "children": [{
3634 "guid": "bookmarkAAA1",
3635 "title": "A1",
3636 "url": "http://example.com/a",
3637 }, {
3638 "guid": "bookmarkBBB1",
3639 "title": "B1",
3640 "url": "http://example.com/b",
3641 }, {
3642 "guid": "bookmarkCCC1",
3643 "title": "C1",
3644 "url": "http://example.com/c",
3645 }, {
3646 "guid": "bookmarkDDDD",
3647 "title": "D",
3648 "url": "http://example.com/d",
3649 }],
3650 }, {
3651 "guid": &BookmarkRootGuid::Mobile.as_guid(),
3652 "children": [],
3653 }],
3654 }),
3655 );
3656 let url_for_one = bookmarks_get_url_for_keyword(&writer, "one")?
3658 .expect("Should have URL for keyword `one`");
3659 assert_eq!(url_for_one.as_str(), "http://example.com/a");
3660
3661 let keyword_for_b = match (
3662 bookmarks_get_url_for_keyword(&writer, "two")?,
3663 bookmarks_get_url_for_keyword(&writer, "three")?,
3664 ) {
3665 (Some(url), None) => {
3666 assert_eq!(url.as_str(), "http://example.com/b");
3667 "two".to_string()
3668 }
3669 (None, Some(url)) => {
3670 assert_eq!(url.as_str(), "http://example.com/b");
3671 "three".to_string()
3672 }
3673 (Some(_), Some(_)) => panic!("Should pick `two` or `three`, not both"),
3674 (None, None) => panic!("Should have URL for either `two` or `three`"),
3675 };
3676
3677 let keyword_for_c = match bookmarks_get_url_for_keyword(&writer, "four")? {
3678 Some(url) => {
3679 assert_eq!(url.as_str(), "http://example.com/c");
3680 Some("four".to_string())
3681 }
3682 None => None,
3683 };
3684
3685 let url_for_five = bookmarks_get_url_for_keyword(&writer, "five")?
3686 .expect("Should have URL for keyword `five`");
3687 assert_eq!(url_for_five.as_str(), "http://example.com/d");
3688
3689 let expected_outgoing_keywords = &[
3690 ("bookmarkBBB1", Some(keyword_for_b.clone())),
3691 ("bookmarkBBB2", Some(keyword_for_b.clone())),
3692 ("bookmarkCCC1", keyword_for_c.clone()),
3693 ("bookmarkCCC2", keyword_for_c.clone()),
3694 ("menu", None), ("mobile", None),
3696 ("toolbar", None),
3697 ("unfiled", None),
3698 ];
3699 assert_eq!(
3700 outgoing
3701 .iter()
3702 .map(|p| (
3703 p.envelope.id.as_str(),
3704 p.to_test_incoming_t::<BookmarkRecord>().keyword
3705 ))
3706 .collect::<Vec<_>>(),
3707 expected_outgoing_keywords,
3708 "Should upload new bookmarks and fix up keywords",
3709 );
3710
3711 engine
3714 .set_uploaded(
3715 ServerTimestamp(0),
3716 expected_outgoing_keywords
3717 .iter()
3718 .map(|(id, _)| SyncGuid::from(id))
3719 .collect(),
3720 )
3721 .expect("Should push synced changes back to the engine");
3722 engine.sync_finished().expect("should work");
3723
3724 let mut synced_item_for_b = SyncedBookmarkItem::new();
3725 synced_item_for_b
3726 .validity(SyncedBookmarkValidity::Valid)
3727 .kind(SyncedBookmarkKind::Bookmark)
3728 .url(Some("http://example.com/b"))
3729 .keyword(Some(&keyword_for_b));
3730 let mut synced_item_for_c = SyncedBookmarkItem::new();
3731 synced_item_for_c
3732 .validity(SyncedBookmarkValidity::Valid)
3733 .kind(SyncedBookmarkKind::Bookmark)
3734 .url(Some("http://example.com/c"))
3735 .keyword(Some(keyword_for_c.unwrap().as_str()));
3736 let expected_synced_items = &[
3737 ExpectedSyncedItem::with_properties("bookmarkBBB1", &synced_item_for_b, |a| {
3738 a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
3739 .title(Some("B1"))
3740 }),
3741 ExpectedSyncedItem::with_properties("bookmarkBBB2", &synced_item_for_b, |a| {
3742 a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
3743 .title(Some("B2"))
3744 }),
3745 ExpectedSyncedItem::with_properties("bookmarkCCC1", &synced_item_for_c, |a| {
3746 a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
3747 .title(Some("C1"))
3748 }),
3749 ExpectedSyncedItem::with_properties("bookmarkCCC2", &synced_item_for_c, |a| {
3750 a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
3751 .title(Some("C2"))
3752 }),
3753 ];
3754 for item in expected_synced_items {
3755 item.check(&writer)?;
3756 }
3757
3758 Ok(())
3759 }
3760
3761 #[test]
3762 fn test_wipe() -> Result<()> {
3763 let api = new_mem_api();
3764 let writer = api.open_connection(ConnectionType::ReadWrite)?;
3765
3766 let records = vec![
3767 json!({
3768 "id": "toolbar",
3769 "type": "folder",
3770 "parentid": "places",
3771 "parentName": "",
3772 "dateAdded": 0,
3773 "title": "toolbar",
3774 "children": ["folderAAAAAA"],
3775 }),
3776 json!({
3777 "id": "folderAAAAAA",
3778 "type": "folder",
3779 "parentid": "toolbar",
3780 "parentName": "toolbar",
3781 "dateAdded": 0,
3782 "title": "A",
3783 "children": ["bookmarkBBBB"],
3784 }),
3785 json!({
3786 "id": "bookmarkBBBB",
3787 "type": "bookmark",
3788 "parentid": "folderAAAAAA",
3789 "parentName": "A",
3790 "dateAdded": 0,
3791 "title": "A",
3792 "bmkUri": "http://example.com/a",
3793 }),
3794 json!({
3795 "id": "menu",
3796 "type": "folder",
3797 "parentid": "places",
3798 "parentName": "",
3799 "dateAdded": 0,
3800 "title": "menu",
3801 "children": ["folderCCCCCC"],
3802 }),
3803 json!({
3804 "id": "folderCCCCCC",
3805 "type": "folder",
3806 "parentid": "menu",
3807 "parentName": "menu",
3808 "dateAdded": 0,
3809 "title": "A",
3810 "children": ["bookmarkDDDD", "folderEEEEEE"],
3811 }),
3812 json!({
3813 "id": "bookmarkDDDD",
3814 "type": "bookmark",
3815 "parentid": "folderCCCCCC",
3816 "parentName": "C",
3817 "dateAdded": 0,
3818 "title": "D",
3819 "bmkUri": "http://example.com/d",
3820 }),
3821 json!({
3822 "id": "folderEEEEEE",
3823 "type": "folder",
3824 "parentid": "folderCCCCCC",
3825 "parentName": "C",
3826 "dateAdded": 0,
3827 "title": "E",
3828 "children": ["bookmarkFFFF"],
3829 }),
3830 json!({
3831 "id": "bookmarkFFFF",
3832 "type": "bookmark",
3833 "parentid": "folderEEEEEE",
3834 "parentName": "E",
3835 "dateAdded": 0,
3836 "title": "F",
3837 "bmkUri": "http://example.com/f",
3838 }),
3839 ];
3840
3841 let engine = create_sync_engine(&api);
3842
3843 let incoming = records
3844 .into_iter()
3845 .map(IncomingBso::from_test_content)
3846 .collect();
3847
3848 let outgoing = engine_apply_incoming(&engine, incoming);
3849 let mut outgoing_ids = outgoing
3850 .iter()
3851 .map(|p| p.envelope.id.clone())
3852 .collect::<Vec<_>>();
3853 outgoing_ids.sort();
3854 assert_eq!(outgoing_ids, &["menu", "mobile", "toolbar", "unfiled"],);
3855
3856 engine
3857 .set_uploaded(ServerTimestamp(0), outgoing_ids)
3858 .expect("Should push synced changes back to the engine");
3859 engine.sync_finished().expect("should work");
3860
3861 engine.wipe().expect("Should wipe the store");
3862
3863 assert_local_json_tree(
3865 &writer,
3866 &BookmarkRootGuid::Root.as_guid(),
3867 json!({
3868 "guid": &BookmarkRootGuid::Root.as_guid(),
3869 "children": [
3870 {
3871 "guid": &BookmarkRootGuid::Menu.as_guid(),
3872 "children": [],
3873 },
3874 {
3875 "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3876 "children": [],
3877 },
3878 {
3879 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3880 "children": [],
3881 },
3882 {
3883 "guid": &BookmarkRootGuid::Mobile.as_guid(),
3884 "children": [],
3885 },
3886 ],
3887 }),
3888 );
3889
3890 let record_for_f = json!({
3893 "id": "bookmarkFFFF",
3894 "type": "bookmark",
3895 "parentid": "folderEEEEEE",
3896 "parentName": "E",
3897 "dateAdded": 0,
3898 "title": "F (remote)",
3899 "bmkUri": "http://example.com/f-remote",
3900 });
3901
3902 let incoming = vec![IncomingBso::from_test_content_ts(
3903 record_for_f,
3904 ServerTimestamp(1000),
3905 )];
3906
3907 let outgoing = engine_apply_incoming(&engine, incoming);
3908 let (outgoing_tombstones, outgoing_records): (Vec<_>, Vec<_>) =
3909 outgoing.iter().partition(|record| {
3910 matches!(
3911 record
3912 .to_test_incoming()
3913 .into_content::<BookmarkRecord>()
3914 .kind,
3915 IncomingKind::Tombstone
3916 )
3917 });
3918 let mut outgoing_record_ids = outgoing_records
3919 .iter()
3920 .map(|p| p.envelope.id.as_str())
3921 .collect::<Vec<_>>();
3922 outgoing_record_ids.sort_unstable();
3923 assert_eq!(
3924 outgoing_record_ids,
3925 &["bookmarkFFFF", "menu", "mobile", "toolbar", "unfiled"],
3926 );
3927 let mut outgoing_tombstone_ids = outgoing_tombstones
3928 .iter()
3929 .map(|p| p.envelope.id.clone())
3930 .collect::<Vec<_>>();
3931 outgoing_tombstone_ids.sort();
3932 assert_eq!(
3933 outgoing_tombstone_ids,
3934 &[
3935 "bookmarkBBBB",
3936 "bookmarkDDDD",
3937 "folderAAAAAA",
3938 "folderCCCCCC",
3939 "folderEEEEEE"
3940 ]
3941 );
3942
3943 assert_local_json_tree(
3946 &writer,
3947 &BookmarkRootGuid::Root.as_guid(),
3948 json!({
3949 "guid": &BookmarkRootGuid::Root.as_guid(),
3950 "children": [
3951 {
3952 "guid": &BookmarkRootGuid::Menu.as_guid(),
3953 "children": [
3954 {
3955 "guid": "bookmarkFFFF",
3956 "title": "F (remote)",
3957 "url": "http://example.com/f-remote",
3958 },
3959 ],
3960 },
3961 {
3962 "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3963 "children": [],
3964 },
3965 {
3966 "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3967 "children": [],
3968 },
3969 {
3970 "guid": &BookmarkRootGuid::Mobile.as_guid(),
3971 "children": [],
3972 },
3973 ],
3974 }),
3975 );
3976
3977 Ok(())
3978 }
3979
3980 #[test]
3981 fn test_reset() -> anyhow::Result<()> {
3982 let api = new_mem_api();
3983 let writer = api.open_connection(ConnectionType::ReadWrite)?;
3984
3985 insert_local_json_tree(
3986 &writer,
3987 json!({
3988 "guid": &BookmarkRootGuid::Menu.as_guid(),
3989 "children": [
3990 {
3991 "guid": "bookmark2___",
3992 "title": "2",
3993 "url": "http://example.com/2",
3994 }
3995 ],
3996 }),
3997 );
3998
3999 {
4000 let engine = create_sync_engine(&api);
4002
4003 assert_eq!(
4004 engine.get_sync_assoc()?,
4005 EngineSyncAssociation::Disconnected
4006 );
4007
4008 let outgoing = engine_apply_incoming(&engine, vec![]);
4009 let synced_ids: Vec<Guid> = outgoing.into_iter().map(|c| c.envelope.id).collect();
4010 assert_eq!(synced_ids.len(), 5, "should be 4 roots + 1 outgoing item");
4011 engine.set_uploaded(ServerTimestamp(2_000), synced_ids)?;
4012 engine.sync_finished().expect("should work");
4013
4014 let db = api.get_sync_connection().unwrap();
4015 let syncer = db.lock();
4016 assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(2_000));
4017
4018 let sync_ids = CollSyncIds {
4019 global: Guid::random(),
4020 coll: Guid::random(),
4021 };
4022 drop(syncer);
4025 engine.reset(&EngineSyncAssociation::Connected(sync_ids.clone()))?;
4026 let syncer = db.lock();
4027 assert_eq!(
4028 get_meta::<Guid>(&syncer, GLOBAL_SYNCID_META_KEY)?,
4029 Some(sync_ids.global)
4030 );
4031 assert_eq!(
4032 get_meta::<Guid>(&syncer, COLLECTION_SYNCID_META_KEY)?,
4033 Some(sync_ids.coll)
4034 );
4035 assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(0));
4036 }
4037 {
4039 let engine = create_sync_engine(&api);
4040
4041 let outgoing = engine_apply_incoming(&engine, vec![]);
4042 let synced_ids: Vec<Guid> = outgoing.into_iter().map(|c| c.envelope.id).collect();
4043 assert_eq!(synced_ids.len(), 5, "should be 4 roots + 1 outgoing item");
4044 engine.set_uploaded(ServerTimestamp(2_000), synced_ids)?;
4045 engine.sync_finished().expect("should work");
4046
4047 let db = api.get_sync_connection().unwrap();
4048 let syncer = db.lock();
4049 assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(2_000));
4050
4051 drop(syncer);
4054 engine.reset(&EngineSyncAssociation::Disconnected)?;
4055 let syncer = db.lock();
4056 assert_eq!(
4057 get_meta::<Option<String>>(&syncer, GLOBAL_SYNCID_META_KEY)?,
4058 None
4059 );
4060 assert_eq!(
4061 get_meta::<Option<String>>(&syncer, COLLECTION_SYNCID_META_KEY)?,
4062 None
4063 );
4064 assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(0));
4065 }
4066
4067 Ok(())
4068 }
4069
4070 #[test]
4071 fn test_incoming_timestamps() -> anyhow::Result<()> {
4072 let api = new_mem_api();
4073 let reader = api.open_connection(ConnectionType::ReadOnly)?;
4074
4075 let remote_modified = ServerTimestamp::from_millis(1770000000000);
4077
4078 let records = vec![
4079 json!({
4080 "id": "toolbar",
4081 "type": "folder",
4082 "parentid": "places",
4083 "parentName": "",
4084 "dateAdded": 0,
4085 "title": "toolbar",
4086 "children": ["folderAAAAAA"],
4087 }),
4088 json!({
4089 "id": "folderAAAAAA",
4090 "type": "folder",
4091 "parentid": "toolbar",
4092 "parentName": "toolbar",
4093 "dateAdded": 0,
4094 "title": "A",
4095 "children": ["bookmarkBBBB"],
4096 }),
4097 json!({
4098 "id": "bookmarkBBBB",
4099 "type": "bookmark",
4100 "parentid": "folderAAAAAA",
4101 "parentName": "A",
4102 "dateAdded": 0,
4103 "title": "A",
4104 "bmkUri": "http://example.com/a",
4105 }),
4106 ];
4107
4108 let engine = create_sync_engine(&api);
4109
4110 let incoming = records
4111 .clone()
4112 .into_iter()
4113 .map(|json| IncomingBso::from_test_content_ts(json, remote_modified))
4114 .collect();
4115
4116 engine_apply_incoming(&engine, incoming);
4117
4118 let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
4120 .expect("must work")
4121 .expect("must exist");
4122
4123 assert_eq!(
4124 bm.date_modified.as_millis_i64(),
4125 remote_modified.as_millis()
4126 );
4127
4128 engine.reset(&EngineSyncAssociation::Disconnected)?;
4130 let incoming = records
4131 .into_iter()
4132 .map(|json| IncomingBso::from_test_content_ts(json, remote_modified))
4133 .collect();
4134 engine_apply_incoming(&engine, incoming);
4135 let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
4136 .expect("must work")
4137 .expect("must exist");
4138
4139 assert_eq!(
4140 bm.date_modified.as_millis_i64(),
4141 remote_modified.as_millis()
4142 );
4143
4144 let new_records = vec![json!({
4146 "id": "bookmarkBBBB",
4147 "type": "bookmark",
4148 "parentid": "folderAAAAAA",
4149 "parentName": "A",
4150 "dateAdded": 0,
4151 "title": "A",
4152 "bmkUri": "http://example.com/a",
4153 })];
4154 let new_remote_modified = ServerTimestamp::from_millis(1780000000000);
4155 let new_incoming = new_records
4156 .into_iter()
4157 .map(|json| IncomingBso::from_test_content_ts(json, new_remote_modified))
4158 .collect();
4159 engine_apply_incoming(&engine, new_incoming);
4160 let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
4161 .expect("must work")
4162 .expect("must exist");
4163
4164 assert_eq!(
4165 bm.date_modified.as_millis_i64(),
4166 new_remote_modified.as_millis()
4167 );
4168 Ok(())
4169 }
4170
4171 #[test]
4172 fn test_dedupe_local_newer() -> anyhow::Result<()> {
4173 let api = new_mem_api();
4174 let writer = api.open_connection(ConnectionType::ReadWrite)?;
4175
4176 let local_modified = Timestamp::now();
4177 let remote_modified = local_modified.as_millis() as f64 / 1000f64 - 5f64;
4178
4179 apply_incoming(
4181 &api,
4182 ServerTimestamp::from_float_seconds(remote_modified),
4183 json!([{
4184 "id": "menu",
4185 "type": "folder",
4186 "parentid": "places",
4187 "parentName": "",
4188 "title": "menu",
4189 "children": ["bookmarkAAA5"],
4190 }, {
4191 "id": "bookmarkAAA5",
4192 "type": "bookmark",
4193 "parentid": "menu",
4194 "parentName": "menu",
4195 "title": "A",
4196 "bmkUri": "http://example.com/a",
4197 }]),
4198 );
4199
4200 insert_local_json_tree(
4202 &writer,
4203 json!({
4204 "guid": &BookmarkRootGuid::Menu.as_guid(),
4205 "children": [{
4206 "guid": "bookmarkAAA1",
4207 "title": "A",
4208 "url": "http://example.com/a",
4209 "date_added": local_modified,
4210 "last_modified": local_modified,
4211 }, {
4212 "guid": "bookmarkAAA2",
4213 "title": "A",
4214 "url": "http://example.com/a",
4215 "date_added": local_modified,
4216 "last_modified": local_modified,
4217 }, {
4218 "guid": "bookmarkAAA3",
4219 "title": "A",
4220 "url": "http://example.com/a",
4221 "date_added": local_modified,
4222 "last_modified": local_modified,
4223 }],
4224 }),
4225 );
4226
4227 apply_incoming(
4229 &api,
4230 ServerTimestamp(local_modified.as_millis() as i64),
4231 json!([{
4232 "id": "menu",
4233 "type": "folder",
4234 "parentid": "places",
4235 "parentName": "",
4236 "title": "menu",
4237 "children": ["bookmarkAAAA", "bookmarkAAA4", "bookmarkAAA5"],
4238 }, {
4239 "id": "bookmarkAAAA",
4240 "type": "bookmark",
4241 "parentid": "menu",
4242 "parentName": "menu",
4243 "title": "A",
4244 "bmkUri": "http://example.com/a",
4245 }, {
4246 "id": "bookmarkAAA4",
4247 "type": "bookmark",
4248 "parentid": "menu",
4249 "parentName": "menu",
4250 "title": "A",
4251 "bmkUri": "http://example.com/a",
4252 }]),
4253 );
4254
4255 assert_local_json_tree(
4256 &writer,
4257 &BookmarkRootGuid::Menu.as_guid(),
4258 json!({
4259 "guid": &BookmarkRootGuid::Menu.as_guid(),
4260 "children": [{
4261 "guid": "bookmarkAAAA",
4262 "title": "A",
4263 "url": "http://example.com/a",
4264 }, {
4265 "guid": "bookmarkAAA4",
4266 "title": "A",
4267 "url": "http://example.com/a",
4268 }, {
4269 "guid": "bookmarkAAA5",
4270 "title": "A",
4271 "url": "http://example.com/a",
4272 }, {
4273 "guid": "bookmarkAAA3",
4274 "title": "A",
4275 "url": "http://example.com/a",
4276 }],
4277 }),
4278 );
4279
4280 Ok(())
4281 }
4282
4283 #[test]
4284 fn test_deduping_remote_newer() -> anyhow::Result<()> {
4285 let api = new_mem_api();
4286 let writer = api.open_connection(ConnectionType::ReadWrite)?;
4287
4288 let local_modified = Timestamp::from(Timestamp::now().as_millis() - 5000);
4289 let remote_modified = local_modified.as_millis() as f64 / 1000f64;
4290
4291 apply_incoming(
4293 &api,
4294 ServerTimestamp::from_float_seconds(remote_modified),
4295 json!([{
4296 "id": "menu",
4297 "type": "folder",
4298 "parentid": "places",
4299 "parentName": "",
4300 "title": "menu",
4301 "children": ["folderAAAAAA"],
4302 }, {
4303 "id": "folderAAAAAA",
4305 "type": "folder",
4306 "parentid": "menu",
4307 "parentName": "menu",
4308 "title": "A",
4309 "children": ["bookmarkGGGG"],
4310 }, {
4311 "id": "bookmarkGGGG",
4313 "type": "bookmark",
4314 "parentid": "folderAAAAAA",
4315 "parentName": "A",
4316 "title": "G",
4317 "bmkUri": "http://example.com/g",
4318 }]),
4319 );
4320
4321 insert_local_json_tree(
4323 &writer,
4324 json!({
4325 "guid": "folderAAAAAA",
4326 "children": [{
4327 "guid": "bookmarkHHHH",
4329 "title": "H",
4330 "url": "http://example.com/h",
4331 "date_added": local_modified,
4332 "last_modified": local_modified,
4333 }]
4334 }),
4335 );
4336 insert_local_json_tree(
4337 &writer,
4338 json!({
4339 "guid": &BookmarkRootGuid::Menu.as_guid(),
4340 "children": [{
4341 "guid": "folderBBBBBB",
4343 "type": BookmarkType::Folder as u8,
4344 "title": "B",
4345 "date_added": local_modified,
4346 "last_modified": local_modified,
4347 "children": [{
4348 "guid": "bookmarkC111",
4350 "title": "C",
4351 "url": "http://example.com/c",
4352 "date_added": local_modified,
4353 "last_modified": local_modified,
4354 }, {
4355 "guid": "separatorFFF",
4357 "type": BookmarkType::Separator as u8,
4358 "date_added": local_modified,
4359 "last_modified": local_modified,
4360 }],
4361 }, {
4362 "guid": "separatorEEE",
4364 "type": BookmarkType::Separator as u8,
4365 "date_added": local_modified,
4366 "last_modified": local_modified,
4367 }, {
4368 "guid": "bookmarkCCCC",
4370 "title": "C",
4371 "url": "http://example.com/c",
4372 "date_added": local_modified,
4373 "last_modified": local_modified,
4374 }, {
4375 "guid": "queryDDDDDDD",
4377 "title": "Most Visited",
4378 "url": "place:maxResults=10&sort=8",
4379 "date_added": local_modified,
4380 "last_modified": local_modified,
4381 }],
4382 }),
4383 );
4384
4385 apply_incoming(
4387 &api,
4388 ServerTimestamp::from_float_seconds(remote_modified),
4389 json!([{
4390 "id": "menu",
4391 "type": "folder",
4392 "parentid": "places",
4393 "parentName": "",
4394 "title": "menu",
4395 "children": ["folderAAAAAA", "folderB11111", "folderA11111", "separatorE11", "queryD111111"],
4396 "dateAdded": local_modified.as_millis(),
4397 }, {
4398 "id": "folderB11111",
4399 "type": "folder",
4400 "parentid": "menu",
4401 "parentName": "menu",
4402 "title": "B",
4403 "children": ["bookmarkC222", "separatorF11"],
4404 "dateAdded": local_modified.as_millis(),
4405 }, {
4406 "id": "bookmarkC222",
4407 "type": "bookmark",
4408 "parentid": "folderB11111",
4409 "parentName": "B",
4410 "title": "C",
4411 "bmkUri": "http://example.com/c",
4412 "dateAdded": local_modified.as_millis(),
4413 }, {
4414 "id": "separatorF11",
4415 "type": "separator",
4416 "parentid": "folderB11111",
4417 "parentName": "B",
4418 "dateAdded": local_modified.as_millis(),
4419 }, {
4420 "id": "folderA11111",
4421 "type": "folder",
4422 "parentid": "menu",
4423 "parentName": "menu",
4424 "title": "A",
4425 "children": ["bookmarkG111"],
4426 "dateAdded": local_modified.as_millis(),
4427 }, {
4428 "id": "bookmarkG111",
4429 "type": "bookmark",
4430 "parentid": "folderA11111",
4431 "parentName": "A",
4432 "title": "G",
4433 "bmkUri": "http://example.com/g",
4434 "dateAdded": local_modified.as_millis(),
4435 }, {
4436 "id": "separatorE11",
4437 "type": "separator",
4438 "parentid": "folderB11111",
4439 "parentName": "B",
4440 "dateAdded": local_modified.as_millis(),
4441 }, {
4442 "id": "queryD111111",
4443 "type": "query",
4444 "parentid": "menu",
4445 "parentName": "menu",
4446 "title": "Most Visited",
4447 "bmkUri": "place:maxResults=10&sort=8",
4448 "dateAdded": local_modified.as_millis(),
4449 }]),
4450 );
4451
4452 assert_local_json_tree(
4453 &writer,
4454 &BookmarkRootGuid::Menu.as_guid(),
4455 json!({
4456 "guid": &BookmarkRootGuid::Menu.as_guid(),
4457 "children": [{
4458 "guid": "folderAAAAAA",
4459 "children": [{
4460 "guid": "bookmarkGGGG",
4461 "title": "G",
4462 "url": "http://example.com/g",
4463 }, {
4464 "guid": "bookmarkHHHH",
4465 "title": "H",
4466 "url": "http://example.com/h",
4467 }]
4468 }, {
4469 "guid": "folderB11111",
4470 "children": [{
4471 "guid": "bookmarkC222",
4472 "title": "C",
4473 "url": "http://example.com/c",
4474 }, {
4475 "guid": "separatorF11",
4476 "type": BookmarkType::Separator as u8,
4477 }],
4478 }, {
4479 "guid": "folderA11111",
4480 "children": [{
4481 "guid": "bookmarkG111",
4482 "title": "G",
4483 "url": "http://example.com/g",
4484 }]
4485 }, {
4486 "guid": "separatorE11",
4487 "type": BookmarkType::Separator as u8,
4488 }, {
4489 "guid": "queryD111111",
4490 "title": "Most Visited",
4491 "url": "place:maxResults=10&sort=8",
4492 }, {
4493 "guid": "separatorEEE",
4494 "type": BookmarkType::Separator as u8,
4495 }, {
4496 "guid": "bookmarkCCCC",
4497 "title": "C",
4498 "url": "http://example.com/c",
4499 }],
4500 }),
4501 );
4502
4503 Ok(())
4504 }
4505
4506 #[test]
4507 fn test_reconcile_sync_metadata() -> anyhow::Result<()> {
4508 let api = new_mem_api();
4509 let writer = api.open_connection(ConnectionType::ReadWrite)?;
4510
4511 let local_modified = Timestamp::from(Timestamp::now().as_millis() - 5000);
4512 let remote_modified = local_modified.as_millis() as f64 / 1000f64;
4513
4514 insert_local_json_tree(
4515 &writer,
4516 json!({
4517 "guid": &BookmarkRootGuid::Menu.as_guid(),
4518 "children": [{
4519 "guid": "folderAAAAAA",
4521 "type": BookmarkType::Folder as u8,
4522 "title": "A",
4523 "date_added": local_modified,
4524 "last_modified": local_modified,
4525 "children": [{
4526 "guid": "bookmarkBBBB",
4527 "title": "B",
4528 "url": "http://example.com/b",
4529 "date_added": local_modified,
4530 "last_modified": local_modified,
4531 }]
4532 }, {
4533 "guid": "folderCCCCCC",
4536 "type": BookmarkType::Folder as u8,
4537 "title": "C",
4538 "date_added": local_modified,
4539 "last_modified": local_modified,
4540 "children": [{
4541 "guid": "bookmarkEEEE",
4542 "title": "E",
4543 "url": "http://example.com/e",
4544 "date_added": local_modified,
4545 "last_modified": local_modified,
4546 }]
4547 }, {
4548 "guid": "bookmarkFFFF",
4550 "title": "f",
4551 "url": "http://example.com/f",
4552 "date_added": local_modified,
4553 "last_modified": local_modified,
4554 }],
4555 }),
4556 );
4557
4558 let outgoing = apply_incoming(
4559 &api,
4560 ServerTimestamp::from_float_seconds(remote_modified),
4561 json!([{
4562 "id": "menu",
4563 "type": "folder",
4564 "parentid": "places",
4565 "parentName": "",
4566 "title": "menu",
4567 "children": ["folderAAAAAA", "folderCCCCCC", "bookmarkFFFF"],
4568 "dateAdded": local_modified.as_millis(),
4569 }, {
4570 "id": "folderAAAAAA",
4571 "type": "folder",
4572 "parentid": "menu",
4573 "parentName": "menu",
4574 "title": "A",
4575 "children": ["bookmarkBBBB"],
4576 "dateAdded": local_modified.as_millis(),
4577 }, {
4578 "id": "bookmarkBBBB",
4579 "type": "bookmark",
4580 "parentid": "folderAAAAAA",
4581 "parentName": "A",
4582 "title": "B",
4583 "bmkUri": "http://example.com/b",
4584 "dateAdded": local_modified.as_millis(),
4585 }, {
4586 "id": "folderCCCCCC",
4587 "type": "folder",
4588 "parentid": "menu",
4589 "parentName": "menu",
4590 "title": "C",
4591 "children": ["bookmarkDDDD"],
4592 "dateAdded": local_modified.as_millis(),
4593 }, {
4594 "id": "bookmarkDDDD",
4595 "type": "bookmark",
4596 "parentid": "folderCCCCCC",
4597 "parentName": "C",
4598 "title": "D",
4599 "bmkUri": "http://example.com/d",
4600 "dateAdded": local_modified.as_millis(),
4601 }, {
4602 "id": "bookmarkFFFF",
4603 "type": "bookmark",
4604 "parentid": "menu",
4605 "parentName": "menu",
4606 "title": "F",
4607 "bmkUri": "http://example.com/f",
4608 "dateAdded": local_modified.as_millis(),
4609 },]),
4610 );
4611
4612 assert_local_json_tree(
4615 &writer,
4616 &BookmarkRootGuid::Menu.as_guid(),
4617 json!({
4618 "guid": &BookmarkRootGuid::Menu.as_guid(),
4619 "children": [{
4620 "guid": "folderAAAAAA",
4622 "type": BookmarkType::Folder as u8,
4623 "title": "A",
4624 "children": [{
4625 "guid": "bookmarkBBBB",
4626 "title": "B",
4627 "url": "http://example.com/b",
4628 }]
4629 }, {
4630 "guid": "folderCCCCCC",
4631 "type": BookmarkType::Folder as u8,
4632 "title": "C",
4633 "children": [{
4634 "guid": "bookmarkDDDD",
4635 "title": "D",
4636 "url": "http://example.com/d",
4637 },{
4638 "guid": "bookmarkEEEE",
4639 "title": "E",
4640 "url": "http://example.com/e",
4641 }]
4642 }, {
4643 "guid": "bookmarkFFFF",
4644 "title": "F",
4645 "url": "http://example.com/f",
4646 }],
4647 }),
4648 );
4649
4650 for guid in &[
4653 "folderAAAAAA",
4654 "bookmarkBBBB",
4655 "folderCCCCCC",
4656 "bookmarkDDDD",
4657 "bookmarkFFFF",
4658 ] {
4659 let bm = get_raw_bookmark(&writer, &guid.into())
4660 .expect("must work")
4661 .expect("must exist");
4662 assert_eq!(bm._sync_status, SyncStatus::Normal, "{}", guid);
4663 assert_eq!(bm._sync_change_counter, 0, "{}", guid);
4664 }
4665 assert!(outgoing.contains(&"bookmarkEEEE".into()));
4668 assert!(outgoing.contains(&"folderCCCCCC".into()));
4669 Ok(())
4670 }
4671
4672 #[test]
4679 fn test_handle_unique_guid_violation() -> Result<()> {
4680 let api = new_mem_api();
4681 let db = api.get_sync_connection().unwrap();
4682 let conn = db.lock();
4683
4684 conn.execute_batch(
4685 r#"
4686 INSERT INTO moz_places(url, guid, title, frecency)
4687 VALUES
4688 ('http://example.com/', 'testPlaceGuidAAAA', 'Example site', 0)
4689 "#,
4690 )?;
4691
4692 conn.execute_batch(&format!(
4695 r#"
4696 INSERT INTO moz_bookmarks(guid, parent, fk, position, type)
4697 VALUES (
4698 'collisionGUI',
4699 (SELECT id FROM moz_bookmarks WHERE guid = '{menu}'),
4700 (SELECT id FROM moz_places WHERE guid = 'testPlaceGuidAAAA'),
4701 0,
4702 1 -- type=1 => bookmark
4703 );
4704 "#,
4705 menu = BookmarkRootGuid::Menu.as_guid(),
4706 ))?;
4707
4708 conn.execute(
4713 r#"
4714 INSERT INTO itemsToApply(
4715 mergedGuid,
4716 localId,
4717 remoteId,
4718 remoteGuid,
4719 newKind,
4720 newLevel,
4721 newTitle,
4722 newPlaceId,
4723 oldPlaceId,
4724 localDateAdded,
4725 remoteDateAdded,
4726 lastModified
4727 )
4728 VALUES (
4729 ?1, -- mergedGuid
4730 NULL, -- localId => so it doesn't unify
4731 999, -- remoteId => arbitrary
4732 ?1, -- remoteGuid
4733 1, -- newKind=1 => bookmark
4734 0, -- level
4735 'New Title', -- newTitle
4736 1, -- newPlaceId
4737 NULL, -- oldPlaceId
4738 1000, -- localDateAdded
4739 2000, -- remoteDateAdded
4740 2000 -- lastModified
4741 )
4742 "#,
4743 [&"collisionGUI"],
4744 )?;
4745
4746 let scope = conn.begin_interrupt_scope()?;
4750 apply_remote_items(&conn, &scope, Timestamp(999))?;
4751
4752 assert_local_json_tree(
4754 &conn,
4755 &BookmarkRootGuid::Menu.as_guid(),
4756 json!({
4757 "guid": &BookmarkRootGuid::Menu.as_guid(),
4758 "children": [{
4760 "guid": "collisionGUI",
4761 "title": "New Title", "url": "http://example.com/",
4763 }],
4764 }),
4765 );
4766 Ok(())
4767 }
4768}