1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7var EXPORTED_SYMBOLS = ["PlacesSyncUtils"];
8
9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10const { XPCOMUtils } = ChromeUtils.import(
11  "resource://gre/modules/XPCOMUtils.jsm"
12);
13
14XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "URLSearchParams"]);
15
16ChromeUtils.defineModuleGetter(this, "Log", "resource://gre/modules/Log.jsm");
17ChromeUtils.defineModuleGetter(
18  this,
19  "PlacesUtils",
20  "resource://gre/modules/PlacesUtils.jsm"
21);
22
23/**
24 * This module exports functions for Sync to use when applying remote
25 * records. The calls are similar to those in `Bookmarks.jsm` and
26 * `nsINavBookmarksService`, with special handling for
27 * tags, keywords, synced annotations, and missing parents.
28 */
29var PlacesSyncUtils = {};
30
31const { SOURCE_SYNC } = Ci.nsINavBookmarksService;
32
33const MICROSECONDS_PER_SECOND = 1000000;
34
35const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks";
36
37// These are defined as lazy getters to defer initializing the bookmarks
38// service until it's needed.
39XPCOMUtils.defineLazyGetter(this, "ROOT_RECORD_ID_TO_GUID", () => ({
40  menu: PlacesUtils.bookmarks.menuGuid,
41  places: PlacesUtils.bookmarks.rootGuid,
42  tags: PlacesUtils.bookmarks.tagsGuid,
43  toolbar: PlacesUtils.bookmarks.toolbarGuid,
44  unfiled: PlacesUtils.bookmarks.unfiledGuid,
45  mobile: PlacesUtils.bookmarks.mobileGuid,
46}));
47
48XPCOMUtils.defineLazyGetter(this, "ROOT_GUID_TO_RECORD_ID", () => ({
49  [PlacesUtils.bookmarks.menuGuid]: "menu",
50  [PlacesUtils.bookmarks.rootGuid]: "places",
51  [PlacesUtils.bookmarks.tagsGuid]: "tags",
52  [PlacesUtils.bookmarks.toolbarGuid]: "toolbar",
53  [PlacesUtils.bookmarks.unfiledGuid]: "unfiled",
54  [PlacesUtils.bookmarks.mobileGuid]: "mobile",
55}));
56
57XPCOMUtils.defineLazyGetter(this, "ROOTS", () =>
58  Object.keys(ROOT_RECORD_ID_TO_GUID)
59);
60
61// Gets the history transition values we ignore and do not sync, as a
62// string, which is a comma-separated set of values - ie, something which can
63// be used with sqlite's IN operator. Does *not* includes the parens.
64XPCOMUtils.defineLazyGetter(this, "IGNORED_TRANSITIONS_AS_SQL_LIST", () =>
65  // * We don't sync `TRANSITION_FRAMED_LINK` visits - these are excluded when
66  //   rendering the history menu, so we use the same constraints for Sync.
67  // * We don't sync `TRANSITION_DOWNLOAD` because it makes no sense to see
68  //   these on other devices - the downloaded file can not exist.
69  // * We don't want to sync TRANSITION_EMBED visits, but these aren't
70  //   stored in the DB, so no need to specify them.
71  // * 0 is invalid, and hopefully don't exist, but let's exclude it anyway.
72  // Array.toString() semantics are well defined and exactly what we need, so..
73  [
74    0,
75    PlacesUtils.history.TRANSITION_FRAMED_LINK,
76    PlacesUtils.history.TRANSITION_DOWNLOAD,
77  ].toString()
78);
79
80const HistorySyncUtils = (PlacesSyncUtils.history = Object.freeze({
81  SYNC_ID_META_KEY: "sync/history/syncId",
82  LAST_SYNC_META_KEY: "sync/history/lastSync",
83
84  /**
85   * Returns the current history sync ID, or `""` if one isn't set.
86   */
87  getSyncId() {
88    return PlacesUtils.metadata.get(HistorySyncUtils.SYNC_ID_META_KEY, "");
89  },
90
91  /**
92   * Assigns a new sync ID. This is called when we sync for the first time with
93   * a new account, and when we're the first to sync after a node reassignment.
94   *
95   * @return {Promise} resolved once the ID has been updated.
96   * @resolves to the new sync ID.
97   */
98  resetSyncId() {
99    return PlacesUtils.withConnectionWrapper(
100      "HistorySyncUtils: resetSyncId",
101      function(db) {
102        let newSyncId = PlacesUtils.history.makeGuid();
103        return db.executeTransaction(async function() {
104          await setHistorySyncId(db, newSyncId);
105          return newSyncId;
106        });
107      }
108    );
109  },
110
111  /**
112   * Ensures that the existing local sync ID, if any, is up-to-date with the
113   * server. This is called when we sync with an existing account.
114   *
115   * @param newSyncId
116   *        The server's sync ID.
117   * @return {Promise} resolved once the ID has been updated.
118   */
119  async ensureCurrentSyncId(newSyncId) {
120    if (!newSyncId || typeof newSyncId != "string") {
121      throw new TypeError("Invalid new history sync ID");
122    }
123    await PlacesUtils.withConnectionWrapper(
124      "HistorySyncUtils: ensureCurrentSyncId",
125      async function(db) {
126        let existingSyncId = await PlacesUtils.metadata.getWithConnection(
127          db,
128          HistorySyncUtils.SYNC_ID_META_KEY,
129          ""
130        );
131
132        if (existingSyncId == newSyncId) {
133          HistorySyncLog.trace("History sync ID up-to-date", {
134            existingSyncId,
135          });
136          return;
137        }
138
139        HistorySyncLog.info("History sync ID changed; resetting metadata", {
140          existingSyncId,
141          newSyncId,
142        });
143        await db.executeTransaction(function() {
144          return setHistorySyncId(db, newSyncId);
145        });
146      }
147    );
148  },
149
150  /**
151   * Returns the last sync time, in seconds, for the history collection, or 0
152   * if history has never synced before.
153   */
154  async getLastSync() {
155    let lastSync = await PlacesUtils.metadata.get(
156      HistorySyncUtils.LAST_SYNC_META_KEY,
157      0
158    );
159    return lastSync / 1000;
160  },
161
162  /**
163   * Updates the history collection last sync time.
164   *
165   * @param lastSyncSeconds
166   *        The collection last sync time, in seconds, as a number or string.
167   */
168  async setLastSync(lastSyncSeconds) {
169    let lastSync = Math.floor(lastSyncSeconds * 1000);
170    if (!Number.isInteger(lastSync)) {
171      throw new TypeError("Invalid history last sync timestamp");
172    }
173    await PlacesUtils.metadata.set(
174      HistorySyncUtils.LAST_SYNC_META_KEY,
175      lastSync
176    );
177  },
178
179  /**
180   * Removes all history visits and pages from the database. Sync calls this
181   * method when it receives a command from a remote client to wipe all stored
182   * data.
183   *
184   * @return {Promise} resolved once all pages and visits have been removed.
185   */
186  async wipe() {
187    await PlacesUtils.history.clear();
188    await HistorySyncUtils.reset();
189  },
190
191  /**
192   * Removes the sync ID and last sync time for the history collection. Unlike
193   * `wipe`, this keeps all existing history pages and visits.
194   *
195   * @return {Promise} resolved once the metadata have been removed.
196   */
197  reset() {
198    return PlacesUtils.metadata.delete(
199      HistorySyncUtils.SYNC_ID_META_KEY,
200      HistorySyncUtils.LAST_SYNC_META_KEY
201    );
202  },
203
204  /**
205   * Clamps a history visit date between the current date and the earliest
206   * sensible date.
207   *
208   * @param {Date} visitDate
209   *        The visit date.
210   * @return {Date} The clamped visit date.
211   */
212  clampVisitDate(visitDate) {
213    let currentDate = new Date();
214    if (visitDate > currentDate) {
215      return currentDate;
216    }
217    if (visitDate < BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP) {
218      return new Date(BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP);
219    }
220    return visitDate;
221  },
222
223  /**
224   * Fetches the frecency for the URL provided
225   *
226   * @param url
227   * @returns {Number} The frecency of the given url
228   */
229  async fetchURLFrecency(url) {
230    let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
231
232    let db = await PlacesUtils.promiseDBConnection();
233    let rows = await db.executeCached(
234      `
235      SELECT frecency
236      FROM moz_places
237      WHERE url_hash = hash(:url) AND url = :url
238      LIMIT 1`,
239      { url: canonicalURL.href }
240    );
241
242    return rows.length ? rows[0].getResultByName("frecency") : -1;
243  },
244
245  /**
246   * Filters syncable places from a collection of places guids.
247   *
248   * @param guids
249   *
250   * @returns {Array} new Array with the guids that aren't syncable
251   */
252  async determineNonSyncableGuids(guids) {
253    // Filter out hidden pages and transitions that we don't sync.
254    let db = await PlacesUtils.promiseDBConnection();
255    let nonSyncableGuids = [];
256    for (let chunk of PlacesUtils.chunkArray(guids, db.variableLimit)) {
257      let rows = await db.execute(
258        `
259        SELECT DISTINCT p.guid FROM moz_places p
260        JOIN moz_historyvisits v ON p.id = v.place_id
261        WHERE p.guid IN (${new Array(chunk.length).fill("?").join(",")}) AND
262            (p.hidden = 1 OR v.visit_type IN (${IGNORED_TRANSITIONS_AS_SQL_LIST}))
263      `,
264        chunk
265      );
266      nonSyncableGuids = nonSyncableGuids.concat(
267        rows.map(row => row.getResultByName("guid"))
268      );
269    }
270    return nonSyncableGuids;
271  },
272
273  /**
274   * Change the guid of the given uri
275   *
276   * @param uri
277   * @param guid
278   */
279  changeGuid(uri, guid) {
280    let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(uri);
281    let validatedGuid = PlacesUtils.BOOKMARK_VALIDATORS.guid(guid);
282    return PlacesUtils.withConnectionWrapper(
283      "PlacesSyncUtils.history: changeGuid",
284      async function(db) {
285        await db.executeCached(
286          `
287            UPDATE moz_places
288            SET guid = :guid
289            WHERE url_hash = hash(:page_url) AND url = :page_url`,
290          { guid: validatedGuid, page_url: canonicalURL.href }
291        );
292      }
293    );
294  },
295
296  /**
297   * Fetch the last 20 visits (date and type of it) corresponding to a given url
298   *
299   * @param url
300   * @returns {Array} Each element of the Array is an object with members: date and type
301   */
302  async fetchVisitsForURL(url) {
303    let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
304    let db = await PlacesUtils.promiseDBConnection();
305    let rows = await db.executeCached(
306      `
307      SELECT visit_type type, visit_date date
308      FROM moz_historyvisits
309      JOIN moz_places h ON h.id = place_id
310      WHERE url_hash = hash(:url) AND url = :url
311      ORDER BY date DESC LIMIT 20`,
312      { url: canonicalURL.href }
313    );
314    return rows.map(row => {
315      let visitDate = row.getResultByName("date");
316      let visitType = row.getResultByName("type");
317      return { date: visitDate, type: visitType };
318    });
319  },
320
321  /**
322   * Fetches the guid of a uri
323   *
324   * @param uri
325   * @returns {String} The guid of the given uri
326   */
327  async fetchGuidForURL(url) {
328    let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
329    let db = await PlacesUtils.promiseDBConnection();
330    let rows = await db.executeCached(
331      `
332        SELECT guid
333        FROM moz_places
334        WHERE url_hash = hash(:page_url) AND url = :page_url`,
335      { page_url: canonicalURL.href }
336    );
337    if (!rows.length) {
338      return null;
339    }
340    return rows[0].getResultByName("guid");
341  },
342
343  /**
344   * Fetch information about a guid (url, title and frecency)
345   *
346   * @param guid
347   * @returns {Object} Object with three members: url, title and frecency of the given guid
348   */
349  async fetchURLInfoForGuid(guid) {
350    let db = await PlacesUtils.promiseDBConnection();
351    let rows = await db.executeCached(
352      `
353      SELECT url, IFNULL(title, '') AS title, frecency
354      FROM moz_places
355      WHERE guid = :guid`,
356      { guid }
357    );
358    if (rows.length === 0) {
359      return null;
360    }
361    return {
362      url: rows[0].getResultByName("url"),
363      title: rows[0].getResultByName("title"),
364      frecency: rows[0].getResultByName("frecency"),
365    };
366  },
367
368  /**
369   * Get all URLs filtered by the limit and since members of the options object.
370   *
371   * @param options
372   *        Options object with two members, since and limit. Both of them must be provided
373   * @returns {Array} - Up to limit number of URLs starting from the date provided by since
374   *
375   * Note that some visit types are explicitly excluded - downloads and framed
376   * links.
377   */
378  async getAllURLs(options) {
379    // Check that the limit property is finite number.
380    if (!Number.isFinite(options.limit)) {
381      throw new Error("The number provided in options.limit is not finite.");
382    }
383    // Check that the since property is of type Date.
384    if (
385      !options.since ||
386      Object.prototype.toString.call(options.since) != "[object Date]"
387    ) {
388      throw new Error(
389        "The property since of the options object must be of type Date."
390      );
391    }
392    let db = await PlacesUtils.promiseDBConnection();
393    let sinceInMicroseconds = PlacesUtils.toPRTime(options.since);
394    let rows = await db.executeCached(
395      `
396      SELECT DISTINCT p.url
397      FROM moz_places p
398      JOIN moz_historyvisits v ON p.id = v.place_id
399      WHERE p.last_visit_date > :cutoff_date AND
400            p.hidden = 0 AND
401            v.visit_type NOT IN (${IGNORED_TRANSITIONS_AS_SQL_LIST})
402      ORDER BY frecency DESC
403      LIMIT :max_results`,
404      { cutoff_date: sinceInMicroseconds, max_results: options.limit }
405    );
406    return rows.map(row => row.getResultByName("url"));
407  },
408}));
409
410const BookmarkSyncUtils = (PlacesSyncUtils.bookmarks = Object.freeze({
411  SYNC_PARENT_ANNO: "sync/parent",
412
413  SYNC_ID_META_KEY: "sync/bookmarks/syncId",
414  LAST_SYNC_META_KEY: "sync/bookmarks/lastSync",
415  WIPE_REMOTE_META_KEY: "sync/bookmarks/wipeRemote",
416
417  // Jan 23, 1993 in milliseconds since 1970. Corresponds roughly to the release
418  // of the original NCSA Mosiac. We can safely assume that any dates before
419  // this time are invalid.
420  EARLIEST_BOOKMARK_TIMESTAMP: Date.UTC(1993, 0, 23),
421
422  KINDS: {
423    BOOKMARK: "bookmark",
424    QUERY: "query",
425    FOLDER: "folder",
426    LIVEMARK: "livemark",
427    SEPARATOR: "separator",
428  },
429
430  get ROOTS() {
431    return ROOTS;
432  },
433
434  /**
435   * Returns the current bookmarks sync ID, or `""` if one isn't set.
436   */
437  getSyncId() {
438    return PlacesUtils.metadata.get(BookmarkSyncUtils.SYNC_ID_META_KEY, "");
439  },
440
441  /**
442   * Indicates if the bookmarks engine should erase all bookmarks on the server
443   * and all other clients, because the user manually restored their bookmarks
444   * from a backup on this client.
445   */
446  async shouldWipeRemote() {
447    let shouldWipeRemote = await PlacesUtils.metadata.get(
448      BookmarkSyncUtils.WIPE_REMOTE_META_KEY,
449      false
450    );
451    return !!shouldWipeRemote;
452  },
453
454  /**
455   * Assigns a new sync ID, bumps the change counter, and flags all items as
456   * "NEW" for upload. This is called when we sync for the first time with a
457   * new account, when we're the first to sync after a node reassignment, and
458   * on the first sync after a manual restore.
459   *
460   * @return {Promise} resolved once the ID and all items have been updated.
461   * @resolves to the new sync ID.
462   */
463  resetSyncId() {
464    return PlacesUtils.withConnectionWrapper(
465      "BookmarkSyncUtils: resetSyncId",
466      function(db) {
467        let newSyncId = PlacesUtils.history.makeGuid();
468        return db.executeTransaction(async function() {
469          await setBookmarksSyncId(db, newSyncId);
470          await resetAllSyncStatuses(db, PlacesUtils.bookmarks.SYNC_STATUS.NEW);
471          return newSyncId;
472        });
473      }
474    );
475  },
476
477  /**
478   * Ensures that the existing local sync ID, if any, is up-to-date with the
479   * server. This is called when we sync with an existing account.
480   *
481   * We always take the server's sync ID. If we don't have an existing ID,
482   * we're either syncing for the first time with an existing account, or Places
483   * has automatically restored from a backup. If the sync IDs don't match,
484   * we're likely syncing after a node reassignment, where another client
485   * uploaded their bookmarks first.
486   *
487   * @param newSyncId
488   *        The server's sync ID.
489   * @return {Promise} resolved once the ID and all items have been updated.
490   */
491  async ensureCurrentSyncId(newSyncId) {
492    if (!newSyncId || typeof newSyncId != "string") {
493      throw new TypeError("Invalid new bookmarks sync ID");
494    }
495    await PlacesUtils.withConnectionWrapper(
496      "BookmarkSyncUtils: ensureCurrentSyncId",
497      async function(db) {
498        let existingSyncId = await PlacesUtils.metadata.getWithConnection(
499          db,
500          BookmarkSyncUtils.SYNC_ID_META_KEY,
501          ""
502        );
503
504        // If we don't have a sync ID, take the server's without resetting
505        // sync statuses.
506        if (!existingSyncId) {
507          BookmarkSyncLog.info("Taking new bookmarks sync ID", { newSyncId });
508          await db.executeTransaction(() => setBookmarksSyncId(db, newSyncId));
509          return;
510        }
511
512        // If the existing sync ID matches the server, great!
513        if (existingSyncId == newSyncId) {
514          BookmarkSyncLog.trace("Bookmarks sync ID up-to-date", {
515            existingSyncId,
516          });
517          return;
518        }
519
520        // Otherwise, we have a sync ID, but it doesn't match, so we were likely
521        // node reassigned. Take the server's sync ID and reset all items to
522        // "UNKNOWN" so that we can merge.
523        BookmarkSyncLog.info(
524          "Bookmarks sync ID changed; resetting sync statuses",
525          { existingSyncId, newSyncId }
526        );
527        await db.executeTransaction(async function() {
528          await setBookmarksSyncId(db, newSyncId);
529          await resetAllSyncStatuses(
530            db,
531            PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
532          );
533        });
534      }
535    );
536  },
537
538  /**
539   * Returns the last sync time, in seconds, for the bookmarks collection, or 0
540   * if bookmarks have never synced before.
541   */
542  async getLastSync() {
543    let lastSync = await PlacesUtils.metadata.get(
544      BookmarkSyncUtils.LAST_SYNC_META_KEY,
545      0
546    );
547    return lastSync / 1000;
548  },
549
550  /**
551   * Updates the bookmarks collection last sync time.
552   *
553   * @param lastSyncSeconds
554   *        The collection last sync time, in seconds, as a number or string.
555   */
556  async setLastSync(lastSyncSeconds) {
557    let lastSync = Math.floor(lastSyncSeconds * 1000);
558    if (!Number.isInteger(lastSync)) {
559      throw new TypeError("Invalid bookmarks last sync timestamp");
560    }
561    await PlacesUtils.metadata.set(
562      BookmarkSyncUtils.LAST_SYNC_META_KEY,
563      lastSync
564    );
565  },
566
567  /**
568   * Resets Sync metadata for bookmarks in Places. This function behaves
569   * differently depending on the change source, and may be called from
570   * `PlacesSyncUtils.bookmarks.reset` or
571   * `PlacesUtils.bookmarks.eraseEverything`.
572   *
573   * - RESTORE: The user is restoring from a backup. Drop the sync ID, last
574   *   sync time, and tombstones; reset sync statuses for remaining items to
575   *   "NEW"; then set a flag to wipe the server and all other clients. On the
576   *   next sync, we'll replace their bookmarks with ours.
577   *
578   * - RESTORE_ON_STARTUP: Places is automatically restoring from a backup to
579   *   recover from a corrupt database. The sync ID, last sync time, and
580   *   tombstones don't exist, since we don't back them up; reset sync statuses
581   *   for the roots to "UNKNOWN"; but don't wipe the server. On the next sync,
582   *   we'll merge the restored bookmarks with the ones on the server.
583   *
584   * - SYNC: Either another client told us to erase our bookmarks
585   *   (`PlacesSyncUtils.bookmarks.wipe`), or the user disconnected Sync
586   *   (`PlacesSyncUtils.bookmarks.reset`). In both cases, drop the existing
587   *   sync ID, last sync time, and tombstones; reset sync statuses for
588   *   remaining items to "NEW"; and don't wipe the server.
589   *
590   * @param db
591   *        the Sqlite.jsm connection handle.
592   * @param source
593   *        the change source constant.
594   */
595  async resetSyncMetadata(db, source) {
596    if (
597      ![
598        PlacesUtils.bookmarks.SOURCES.RESTORE,
599        PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
600        PlacesUtils.bookmarks.SOURCES.SYNC,
601      ].includes(source)
602    ) {
603      return;
604    }
605
606    // Remove the sync ID and last sync time in all cases.
607    await PlacesUtils.metadata.deleteWithConnection(
608      db,
609      BookmarkSyncUtils.SYNC_ID_META_KEY,
610      BookmarkSyncUtils.LAST_SYNC_META_KEY
611    );
612
613    // If we're manually restoring from a backup, wipe the server and other
614    // clients, so that we replace their bookmarks with the restored tree. If
615    // we're automatically restoring to recover from a corrupt database, don't
616    // wipe; we want to merge the restored tree with the one on the server.
617    await PlacesUtils.metadata.setWithConnection(
618      db,
619      BookmarkSyncUtils.WIPE_REMOTE_META_KEY,
620      source == PlacesUtils.bookmarks.SOURCES.RESTORE
621    );
622
623    // Reset change counters and sync statuses for roots and remaining
624    // items, and drop tombstones.
625    let syncStatus =
626      source == PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP
627        ? PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
628        : PlacesUtils.bookmarks.SYNC_STATUS.NEW;
629    await resetAllSyncStatuses(db, syncStatus);
630  },
631
632  /**
633   * Converts a Places GUID to a Sync record ID. Record IDs are identical to
634   * Places GUIDs for all items except roots.
635   */
636  guidToRecordId(guid) {
637    return ROOT_GUID_TO_RECORD_ID[guid] || guid;
638  },
639
640  /**
641   * Converts a Sync record ID to a Places GUID.
642   */
643  recordIdToGuid(recordId) {
644    return ROOT_RECORD_ID_TO_GUID[recordId] || recordId;
645  },
646
647  /**
648   * Fetches the record IDs for a folder's children, ordered by their position
649   * within the folder.
650   * Used only be tests - but that includes tps, so it lives here.
651   */
652  fetchChildRecordIds(parentRecordId) {
653    PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
654    let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);
655
656    return PlacesUtils.withConnectionWrapper(
657      "BookmarkSyncUtils: fetchChildRecordIds",
658      async function(db) {
659        let childGuids = await fetchChildGuids(db, parentGuid);
660        return childGuids.map(guid => BookmarkSyncUtils.guidToRecordId(guid));
661      }
662    );
663  },
664
665  /**
666   * Migrates an array of `{ recordId, modified }` tuples from the old JSON-based
667   * tracker to the new sync change counter. `modified` is when the change was
668   * added to the old tracker, in milliseconds.
669   *
670   * Sync calls this method before the first bookmark sync after the Places
671   * schema migration.
672   */
673  migrateOldTrackerEntries(entries) {
674    return PlacesUtils.withConnectionWrapper(
675      "BookmarkSyncUtils: migrateOldTrackerEntries",
676      function(db) {
677        return db.executeTransaction(async function() {
678          // Mark all existing bookmarks as synced, and clear their change
679          // counters to avoid a full upload on the next sync. Note that
680          // this means we'll miss changes made between startup and the first
681          // post-migration sync, as well as changes made on a new release
682          // channel that weren't synced before the user downgraded. This is
683          // unfortunate, but no worse than the behavior of the old tracker.
684          //
685          // We also likely have bookmarks that don't exist on the server,
686          // because the old tracker missed them. We'll eventually fix the
687          // server once we decide on a repair strategy.
688          await db.executeCached(
689            `
690            WITH RECURSIVE
691            syncedItems(id) AS (
692              SELECT b.id FROM moz_bookmarks b
693              WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
694                               'mobile______')
695              UNION ALL
696              SELECT b.id FROM moz_bookmarks b
697              JOIN syncedItems s ON b.parent = s.id
698            )
699            UPDATE moz_bookmarks SET
700              syncStatus = :syncStatus,
701              syncChangeCounter = 0
702            WHERE id IN syncedItems`,
703            { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
704          );
705
706          await db.executeCached(`DELETE FROM moz_bookmarks_deleted`);
707
708          await db.executeCached(`CREATE TEMP TABLE moz_bookmarks_tracked (
709            guid TEXT PRIMARY KEY,
710            time INTEGER
711          )`);
712
713          try {
714            for (let { recordId, modified } of entries) {
715              let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
716              if (!PlacesUtils.isValidGuid(guid)) {
717                BookmarkSyncLog.warn(
718                  `migrateOldTrackerEntries: Ignoring ` +
719                    `change for invalid item ${guid}`
720                );
721                continue;
722              }
723              let time = PlacesUtils.toPRTime(
724                Number.isFinite(modified) ? modified : Date.now()
725              );
726              await db.executeCached(
727                `
728                INSERT OR IGNORE INTO moz_bookmarks_tracked (guid, time)
729                VALUES (:guid, :time)`,
730                { guid, time }
731              );
732            }
733
734            // Bump the change counter for existing tracked items.
735            await db.executeCached(`
736              INSERT OR REPLACE INTO moz_bookmarks (id, fk, type, parent,
737                                                    position, title,
738                                                    dateAdded, lastModified,
739                                                    guid, syncChangeCounter,
740                                                    syncStatus)
741              SELECT b.id, b.fk, b.type, b.parent, b.position, b.title,
742                     b.dateAdded, MAX(b.lastModified, t.time), b.guid,
743                     b.syncChangeCounter + 1, b.syncStatus
744              FROM moz_bookmarks b
745              JOIN moz_bookmarks_tracked t ON b.guid = t.guid`);
746
747            // Insert tombstones for nonexistent tracked items, using the most
748            // recent deletion date for more accurate reconciliation. We assume
749            // the tracked item belongs to a synced root.
750            await db.executeCached(`
751              INSERT OR REPLACE INTO moz_bookmarks_deleted (guid, dateRemoved)
752              SELECT t.guid, MAX(IFNULL((SELECT dateRemoved FROM moz_bookmarks_deleted
753                                         WHERE guid = t.guid), 0), t.time)
754              FROM moz_bookmarks_tracked t
755              LEFT JOIN moz_bookmarks b ON t.guid = b.guid
756              WHERE b.guid IS NULL`);
757          } finally {
758            await db.executeCached(`DROP TABLE moz_bookmarks_tracked`);
759          }
760        });
761      }
762    );
763  },
764
765  /**
766   * Reorders a folder's children, based on their order in the array of sync
767   * IDs.
768   *
769   * Sync uses this method to reorder all synced children after applying all
770   * incoming records.
771   *
772   * @return {Promise} resolved when reordering is complete.
773   * @rejects if an error happens while reordering.
774   * @throws if the arguments are invalid.
775   */
776  order(parentRecordId, childRecordIds) {
777    PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
778    if (!childRecordIds.length) {
779      return undefined;
780    }
781    let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);
782    if (parentGuid == PlacesUtils.bookmarks.rootGuid) {
783      // Reordering roots doesn't make sense, but Sync will do this on the
784      // first sync.
785      return undefined;
786    }
787    let orderedChildrenGuids = childRecordIds.map(
788      BookmarkSyncUtils.recordIdToGuid
789    );
790    return PlacesUtils.bookmarks.reorder(parentGuid, orderedChildrenGuids, {
791      source: SOURCE_SYNC,
792    });
793  },
794
795  /**
796   * Resolves to true if there are known sync changes.
797   */
798  havePendingChanges() {
799    return PlacesUtils.withConnectionWrapper(
800      "BookmarkSyncUtils: havePendingChanges",
801      async function(db) {
802        let rows = await db.executeCached(`
803          WITH RECURSIVE
804          syncedItems(id, guid, syncChangeCounter) AS (
805            SELECT b.id, b.guid, b.syncChangeCounter
806             FROM moz_bookmarks b
807             WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
808                              'mobile______')
809            UNION ALL
810            SELECT b.id, b.guid, b.syncChangeCounter
811            FROM moz_bookmarks b
812            JOIN syncedItems s ON b.parent = s.id
813          ),
814          changedItems(guid) AS (
815            SELECT guid FROM syncedItems
816            WHERE syncChangeCounter >= 1
817            UNION ALL
818            SELECT guid FROM moz_bookmarks_deleted
819          )
820          SELECT EXISTS(SELECT guid FROM changedItems) AS haveChanges`);
821        return !!rows[0].getResultByName("haveChanges");
822      }
823    );
824  },
825
826  /**
827   * Returns a changeset containing local bookmark changes since the last sync.
828   *
829   * @return {Promise} resolved once all items have been fetched.
830   * @resolves to an object containing records for changed bookmarks, keyed by
831   *           the record ID.
832   * @see pullSyncChanges for the implementation, and markChangesAsSyncing for
833   *      an explanation of why we update the sync status.
834   */
835  pullChanges() {
836    return PlacesUtils.withConnectionWrapper(
837      "BookmarkSyncUtils: pullChanges",
838      pullSyncChanges
839    );
840  },
841
842  /**
843   * Updates the sync status of all "NEW" bookmarks to "NORMAL", so that Sync
844   * can recover correctly after an interrupted sync.
845   *
846   * @param changeRecords
847   *        A changeset containing sync change records, as returned by
848   *        `pullChanges`.
849   * @return {Promise} resolved once all records have been updated.
850   */
851  markChangesAsSyncing(changeRecords) {
852    return PlacesUtils.withConnectionWrapper(
853      "BookmarkSyncUtils: markChangesAsSyncing",
854      db => markChangesAsSyncing(db, changeRecords)
855    );
856  },
857
858  /**
859   * Decrements the sync change counter, updates the sync status, and cleans up
860   * tombstones for successfully synced items. Sync calls this method at the
861   * end of each bookmark sync.
862   *
863   * @param changeRecords
864   *        A changeset containing sync change records, as returned by
865   *        `pullChanges`.
866   * @return {Promise} resolved once all records have been updated.
867   */
868  pushChanges(changeRecords) {
869    return PlacesUtils.withConnectionWrapper(
870      "BookmarkSyncUtils: pushChanges",
871      async function(db) {
872        let skippedCount = 0;
873        let weakCount = 0;
874        let updateParams = [];
875        let tombstoneGuidsToRemove = [];
876
877        for (let recordId in changeRecords) {
878          // Validate change records to catch coding errors.
879          let changeRecord = validateChangeRecord(
880            "BookmarkSyncUtils: pushChanges",
881            changeRecords[recordId],
882            {
883              tombstone: { required: true },
884              counter: { required: true },
885              synced: { required: true },
886            }
887          );
888
889          // Skip weakly uploaded records.
890          if (!changeRecord.counter) {
891            weakCount++;
892            continue;
893          }
894
895          // Sync sets the `synced` flag for reconciled or successfully
896          // uploaded items. If upload failed, ignore the change; we'll
897          // try again on the next sync.
898          if (!changeRecord.synced) {
899            skippedCount++;
900            continue;
901          }
902
903          let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
904          if (changeRecord.tombstone) {
905            tombstoneGuidsToRemove.push(guid);
906          } else {
907            updateParams.push({
908              guid,
909              syncChangeDelta: changeRecord.counter,
910              syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
911            });
912          }
913        }
914
915        // Reduce the change counter and update the sync status for
916        // reconciled and uploaded items. If the bookmark was updated
917        // during the sync, its change counter will still be > 0 for the
918        // next sync.
919        if (updateParams.length || tombstoneGuidsToRemove.length) {
920          await db.executeTransaction(async function() {
921            if (updateParams.length) {
922              await db.executeCached(
923                `
924                UPDATE moz_bookmarks
925                SET syncChangeCounter = MAX(syncChangeCounter - :syncChangeDelta, 0),
926                    syncStatus = :syncStatus
927                WHERE guid = :guid`,
928                updateParams
929              );
930              // and if there are *both* bookmarks and tombstones for these
931              // items, we nuke the tombstones.
932              // This should be unlikely, but bad if it happens.
933              let dupedGuids = updateParams.map(({ guid }) => guid);
934              await removeUndeletedTombstones(db, dupedGuids);
935            }
936            await removeTombstones(db, tombstoneGuidsToRemove);
937          });
938        }
939
940        BookmarkSyncLog.debug(`pushChanges: Processed change records`, {
941          weak: weakCount,
942          skipped: skippedCount,
943          updated: updateParams.length,
944        });
945      }
946    );
947  },
948
949  /**
950   * Removes items from the database. Sync buffers incoming tombstones, and
951   * calls this method to apply them at the end of each sync. Deletion
952   * happens in three steps:
953   *
954   *  1. Remove all non-folder items. Deleting a folder on a remote client
955   *     uploads tombstones for the folder and its children at the time of
956   *     deletion. This preserves any new children we've added locally since
957   *     the last sync.
958   *  2. Reparent remaining children to the tombstoned folder's parent. This
959   *     bumps the change counter for the children and their new parent.
960   *  3. Remove the tombstoned folder. Because we don't do this in a
961   *     transaction, the user might move new items into the folder before we
962   *     can remove it. In that case, we keep the folder and upload the new
963   *     subtree to the server.
964   *
965   * See the comment above `BookmarksStore::deletePending` for the details on
966   * why delete works the way it does.
967   */
968  remove(recordIds) {
969    if (!recordIds.length) {
970      return null;
971    }
972
973    return PlacesUtils.withConnectionWrapper(
974      "BookmarkSyncUtils: remove",
975      async function(db) {
976        let folderGuids = [];
977        for (let recordId of recordIds) {
978          if (recordId in ROOT_RECORD_ID_TO_GUID) {
979            BookmarkSyncLog.warn(`remove: Refusing to remove root ${recordId}`);
980            continue;
981          }
982          let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
983          let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid);
984          if (!bookmarkItem) {
985            BookmarkSyncLog.trace(`remove: Item ${guid} already removed`);
986            continue;
987          }
988          let kind = await getKindForItem(db, bookmarkItem);
989          if (kind == BookmarkSyncUtils.KINDS.FOLDER) {
990            folderGuids.push(bookmarkItem.guid);
991            continue;
992          }
993          let wasRemoved = await deleteSyncedAtom(bookmarkItem);
994          if (wasRemoved) {
995            BookmarkSyncLog.trace(
996              `remove: Removed item ${guid} with kind ${kind}`
997            );
998          }
999        }
1000
1001        for (let guid of folderGuids) {
1002          let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid);
1003          if (!bookmarkItem) {
1004            BookmarkSyncLog.trace(`remove: Folder ${guid} already removed`);
1005            continue;
1006          }
1007          let wasRemoved = await deleteSyncedFolder(db, bookmarkItem);
1008          if (wasRemoved) {
1009            BookmarkSyncLog.trace(
1010              `remove: Removed folder ${bookmarkItem.guid}`
1011            );
1012          }
1013        }
1014
1015        // TODO (Bug 1313890): Refactor the bookmarks engine to pull change records
1016        // before uploading, instead of returning records to merge into the engine's
1017        // initial changeset.
1018        return pullSyncChanges(db);
1019      }
1020    );
1021  },
1022
1023  /**
1024   * Returns true for record IDs that are considered roots.
1025   */
1026  isRootRecordID(id) {
1027    return ROOT_RECORD_ID_TO_GUID.hasOwnProperty(id);
1028  },
1029
1030  /**
1031   * Removes all bookmarks and tombstones from the database. Sync calls this
1032   * method when it receives a command from a remote client to wipe all stored
1033   * data.
1034   *
1035   * @return {Promise} resolved once all items have been removed.
1036   */
1037  wipe() {
1038    return PlacesUtils.bookmarks.eraseEverything({
1039      source: SOURCE_SYNC,
1040    });
1041  },
1042
1043  /**
1044   * Marks all bookmarks as "NEW" and removes all tombstones. Unlike `wipe`,
1045   * this keeps all existing bookmarks, and only clears their sync change
1046   * tracking info.
1047   *
1048   * @return {Promise} resolved once all items have been updated.
1049   */
1050  reset() {
1051    return PlacesUtils.withConnectionWrapper(
1052      "BookmarkSyncUtils: reset",
1053      function(db) {
1054        return db.executeTransaction(async function() {
1055          await BookmarkSyncUtils.resetSyncMetadata(db, SOURCE_SYNC);
1056        });
1057      }
1058    );
1059  },
1060
1061  /**
1062   * Fetches a Sync bookmark object for an item in the tree.
1063   *
1064   * Should only be used by SYNC TESTS.
1065   * We should remove this in bug XXXXXX, updating the tests to use
1066   * PlacesUtils.bookmarks.fetch.
1067   *
1068   * The object contains
1069   * the following properties, depending on the item's kind:
1070   *
1071   *  - kind (all): A string representing the item's kind.
1072   *  - recordId (all): The item's record ID.
1073   *  - parentRecordId (all): The record ID of the item's parent.
1074   *  - parentTitle (all): The title of the item's parent, used for de-duping.
1075   *    Omitted for the Places root and parents with empty titles.
1076   *  - dateAdded (all): Timestamp in milliseconds, when the bookmark was added
1077   *    or created on a remote device if known.
1078   *  - title ("bookmark", "folder", "query"): The item's title.
1079   *    Omitted if empty.
1080   *  - url ("bookmark", "query"): The item's URL.
1081   *  - tags ("bookmark", "query"): An array containing the item's tags.
1082   *  - keyword ("bookmark"): The bookmark's keyword, if one exists.
1083   *  - childRecordIds ("folder"): An array containing the record IDs of the item's
1084   *    children, used to determine child order.
1085   *  - folder ("query"): The tag folder name, if this is a tag query.
1086   *  - index ("separator"): The separator's position within its parent.
1087   */
1088  async fetch(recordId) {
1089    let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
1090    let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid);
1091    if (!bookmarkItem) {
1092      return null;
1093    }
1094    return PlacesUtils.withConnectionWrapper(
1095      "BookmarkSyncUtils: fetch",
1096      async function(db) {
1097        // Convert the Places bookmark object to a Sync bookmark and add
1098        // kind-specific properties. Titles are required for bookmarks,
1099        // and folders; optional for queries, and omitted for separators.
1100        let kind = await getKindForItem(db, bookmarkItem);
1101        let item;
1102        switch (kind) {
1103          case BookmarkSyncUtils.KINDS.BOOKMARK:
1104            item = await fetchBookmarkItem(db, bookmarkItem);
1105            break;
1106
1107          case BookmarkSyncUtils.KINDS.QUERY:
1108            item = await fetchQueryItem(db, bookmarkItem);
1109            break;
1110
1111          case BookmarkSyncUtils.KINDS.FOLDER:
1112            item = await fetchFolderItem(db, bookmarkItem);
1113            break;
1114
1115          case BookmarkSyncUtils.KINDS.SEPARATOR:
1116            item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
1117            item.index = bookmarkItem.index;
1118            break;
1119
1120          default:
1121            throw new Error(`Unknown bookmark kind: ${kind}`);
1122        }
1123
1124        // Sync uses the parent title for de-duping. All Sync bookmark objects
1125        // except the Places root should have this property.
1126        if (bookmarkItem.parentGuid) {
1127          let parent = await PlacesUtils.bookmarks.fetch(
1128            bookmarkItem.parentGuid
1129          );
1130          item.parentTitle = parent.title || "";
1131        }
1132
1133        return item;
1134      }
1135    );
1136  },
1137
1138  /**
1139   * Returns the sync change counter increment for a change source constant.
1140   */
1141  determineSyncChangeDelta(source) {
1142    // Don't bump the change counter when applying changes made by Sync, to
1143    // avoid sync loops.
1144    return source == PlacesUtils.bookmarks.SOURCES.SYNC ? 0 : 1;
1145  },
1146
1147  /**
1148   * Returns the sync status for a new item inserted by a change source.
1149   */
1150  determineInitialSyncStatus(source) {
1151    if (source == PlacesUtils.bookmarks.SOURCES.SYNC) {
1152      // Incoming bookmarks are "NORMAL", since they already exist on the server.
1153      return PlacesUtils.bookmarks.SYNC_STATUS.NORMAL;
1154    }
1155    if (source == PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP) {
1156      // If the user restores from a backup, or Places automatically recovers
1157      // from a corrupt database, all prior sync tracking is lost. Setting the
1158      // status to "UNKNOWN" allows Sync to reconcile restored bookmarks with
1159      // those on the server.
1160      return PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN;
1161    }
1162    // For all other sources, mark items as "NEW". We'll update their statuses
1163    // to "NORMAL" after the first sync.
1164    return PlacesUtils.bookmarks.SYNC_STATUS.NEW;
1165  },
1166
1167  /**
1168   * An internal helper that bumps the change counter for all bookmarks with
1169   * a given URL. This is used to update bookmarks when adding or changing a
1170   * tag or keyword entry.
1171   *
1172   * @param db
1173   *        the Sqlite.jsm connection handle.
1174   * @param url
1175   *        the bookmark URL object.
1176   * @param syncChangeDelta
1177   *        the sync change counter increment.
1178   * @return {Promise} resolved when the counters have been updated.
1179   */
1180  addSyncChangesForBookmarksWithURL(db, url, syncChangeDelta) {
1181    if (!url || !syncChangeDelta) {
1182      return Promise.resolve();
1183    }
1184    return db.executeCached(
1185      `
1186      UPDATE moz_bookmarks
1187        SET syncChangeCounter = syncChangeCounter + :syncChangeDelta
1188      WHERE type = :type AND
1189            fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND
1190                  url = :url)`,
1191      {
1192        syncChangeDelta,
1193        type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
1194        url: url.href,
1195      }
1196    );
1197  },
1198
1199  async removeLivemark(livemarkInfo) {
1200    let info = validateSyncBookmarkObject(
1201      "BookmarkSyncUtils: removeLivemark",
1202      livemarkInfo,
1203      {
1204        kind: {
1205          required: true,
1206          validIf: b => b.kind == BookmarkSyncUtils.KINDS.LIVEMARK,
1207        },
1208        recordId: { required: true },
1209        parentRecordId: { required: true },
1210      }
1211    );
1212
1213    let guid = BookmarkSyncUtils.recordIdToGuid(info.recordId);
1214    let parentGuid = BookmarkSyncUtils.recordIdToGuid(info.parentRecordId);
1215
1216    return PlacesUtils.withConnectionWrapper(
1217      "BookmarkSyncUtils: removeLivemark",
1218      async function(db) {
1219        if (await GUIDMissing(guid)) {
1220          // If the livemark doesn't exist in the database, insert a tombstone
1221          // and bump its parent's change counter to ensure it's removed from
1222          // the server in the current sync.
1223          await db.executeTransaction(async function() {
1224            await db.executeCached(
1225              `
1226              UPDATE moz_bookmarks SET
1227                syncChangeCounter = syncChangeCounter + 1
1228              WHERE guid = :parentGuid`,
1229              { parentGuid }
1230            );
1231
1232            await db.executeCached(
1233              `
1234              INSERT OR IGNORE INTO moz_bookmarks_deleted (guid, dateRemoved)
1235              VALUES (:guid, ${PlacesUtils.toPRTime(Date.now())})`,
1236              { guid }
1237            );
1238          });
1239        } else {
1240          await PlacesUtils.bookmarks.remove({
1241            guid,
1242            // `SYNC_REPARENT_REMOVED_FOLDER_CHILDREN` bumps the change counter for
1243            // the child and its new parent, without incrementing the bookmark
1244            // tracker's score.
1245            source:
1246              PlacesUtils.bookmarks.SOURCES
1247                .SYNC_REPARENT_REMOVED_FOLDER_CHILDREN,
1248          });
1249        }
1250
1251        return pullSyncChanges(db, [guid, parentGuid]);
1252      }
1253    );
1254  },
1255
1256  /**
1257   * Returns `0` if no sensible timestamp could be found.
1258   * Otherwise, returns the earliest sensible timestamp between `existingMillis`
1259   * and `serverMillis`.
1260   */
1261  ratchetTimestampBackwards(
1262    existingMillis,
1263    serverMillis,
1264    lowerBound = BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP
1265  ) {
1266    const possible = [+existingMillis, +serverMillis].filter(
1267      n => !isNaN(n) && n > lowerBound
1268    );
1269    if (!possible.length) {
1270      return 0;
1271    }
1272    return Math.min(...possible);
1273  },
1274
1275  /**
1276   * Rebuilds the left pane query for the mobile root under "All Bookmarks" if
1277   * necessary. Sync calls this method at the end of each bookmark sync. This
1278   * code should eventually move to `PlacesUIUtils#maybeRebuildLeftPane`; see
1279   * bug 647605.
1280   *
1281   * - If there are no mobile bookmarks, the query will not be created, or
1282   *   will be removed if it already exists.
1283   * - If there are mobile bookmarks, the query will be created if it doesn't
1284   *   exist, or will be updated with the correct title and URL otherwise.
1285   */
1286  async ensureMobileQuery() {
1287    let db = await PlacesUtils.promiseDBConnection();
1288
1289    let mobileChildGuids = await fetchChildGuids(
1290      db,
1291      PlacesUtils.bookmarks.mobileGuid
1292    );
1293    let hasMobileBookmarks = !!mobileChildGuids.length;
1294
1295    Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, hasMobileBookmarks);
1296  },
1297
1298  /**
1299   * Fetches an array of GUIDs for items that have an annotation set with the
1300   * given value.
1301   */
1302  async fetchGuidsWithAnno(anno, val) {
1303    let db = await PlacesUtils.promiseDBConnection();
1304    return fetchGuidsWithAnno(db, anno, val);
1305  },
1306}));
1307
1308PlacesSyncUtils.test = {};
1309PlacesSyncUtils.test.bookmarks = Object.freeze({
1310  /**
1311   * Inserts a synced bookmark into the tree. Only SYNC TESTS should call this
1312   * method; other callers should use `PlacesUtils.bookmarks.insert`.
1313   *
1314   * It is in this file rather than a test-only file because it makes use of
1315   * other internal functions here, so moving is not trivial - see bug 1662602.
1316   *
1317   * The following properties are supported:
1318   *  - kind: Required.
1319   *  - guid: Required.
1320   *  - parentGuid: Required.
1321   *  - url: Required for bookmarks.
1322   *  - tags: An optional array of tag strings.
1323   *  - keyword: An optional keyword string.
1324   *
1325   * Sync doesn't set the index, since it appends and reorders children
1326   * after applying all incoming items.
1327   *
1328   * @param info
1329   *        object representing a synced bookmark.
1330   *
1331   * @return {Promise} resolved when the creation is complete.
1332   * @resolves to an object representing the created bookmark.
1333   * @rejects if it's not possible to create the requested bookmark.
1334   * @throws if the arguments are invalid.
1335   */
1336  insert(info) {
1337    let insertInfo = validateNewBookmark("BookmarkTestUtils: insert", info);
1338
1339    return PlacesUtils.withConnectionWrapper(
1340      "BookmarkTestUtils: insert",
1341      async db => {
1342        // If we're inserting a tag query, make sure the tag exists and fix the
1343        // folder ID to refer to the local tag folder.
1344        insertInfo = await updateTagQueryFolder(db, insertInfo);
1345
1346        let bookmarkInfo = syncBookmarkToPlacesBookmark(insertInfo);
1347        let bookmarkItem = await PlacesUtils.bookmarks.insert(bookmarkInfo);
1348        let newItem = await insertBookmarkMetadata(
1349          db,
1350          bookmarkItem,
1351          insertInfo
1352        );
1353
1354        return newItem;
1355      }
1356    );
1357  },
1358});
1359
1360XPCOMUtils.defineLazyGetter(this, "HistorySyncLog", () => {
1361  return Log.repository.getLogger("Sync.Engine.History.HistorySyncUtils");
1362});
1363
1364XPCOMUtils.defineLazyGetter(this, "BookmarkSyncLog", () => {
1365  // Use a sub-log of the bookmarks engine, so setting the level for that
1366  // engine also adjust the level of this log.
1367  return Log.repository.getLogger("Sync.Engine.Bookmarks.BookmarkSyncUtils");
1368});
1369
1370function validateSyncBookmarkObject(name, input, behavior) {
1371  return PlacesUtils.validateItemProperties(
1372    name,
1373    PlacesUtils.SYNC_BOOKMARK_VALIDATORS,
1374    input,
1375    behavior
1376  );
1377}
1378
1379// Validates a sync change record as returned by `pullChanges` and passed to
1380// `pushChanges`.
1381function validateChangeRecord(name, changeRecord, behavior) {
1382  return PlacesUtils.validateItemProperties(
1383    name,
1384    PlacesUtils.SYNC_CHANGE_RECORD_VALIDATORS,
1385    changeRecord,
1386    behavior
1387  );
1388}
1389
1390// Similar to the private `fetchBookmarksByParent` implementation in
1391// `Bookmarks.jsm`.
1392var fetchChildGuids = async function(db, parentGuid) {
1393  let rows = await db.executeCached(
1394    `
1395    SELECT guid
1396    FROM moz_bookmarks
1397    WHERE parent = (
1398      SELECT id FROM moz_bookmarks WHERE guid = :parentGuid
1399    )
1400    ORDER BY position`,
1401    { parentGuid }
1402  );
1403  return rows.map(row => row.getResultByName("guid"));
1404};
1405
1406// A helper for whenever we want to know if a GUID doesn't exist in the places
1407// database. Primarily used to detect orphans on incoming records.
1408var GUIDMissing = async function(guid) {
1409  try {
1410    await PlacesUtils.promiseItemId(guid);
1411    return false;
1412  } catch (ex) {
1413    if (ex.message == "no item found for the given GUID") {
1414      return true;
1415    }
1416    throw ex;
1417  }
1418};
1419
1420// Legacy tag queries may use a `place:` URL that refers to the tag folder ID.
1421// When we apply a synced tag query from a remote client, we need to update the
1422// URL to point to the local tag.
1423function updateTagQueryFolder(db, info) {
1424  if (
1425    info.kind != BookmarkSyncUtils.KINDS.QUERY ||
1426    !info.folder ||
1427    !info.url ||
1428    info.url.protocol != "place:"
1429  ) {
1430    return info;
1431  }
1432
1433  let params = new URLSearchParams(info.url.pathname);
1434  let type = +params.get("type");
1435  if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
1436    return info;
1437  }
1438
1439  BookmarkSyncLog.debug(
1440    `updateTagQueryFolder: Tag query folder: ${info.folder}`
1441  );
1442
1443  // Rewrite the query to directly reference the tag.
1444  params.delete("queryType");
1445  params.delete("type");
1446  params.delete("folder");
1447  params.set("tag", info.folder);
1448  info.url = new URL(info.url.protocol + params);
1449  return info;
1450}
1451
1452// Keywords are a 1 to 1 mapping between strings and pairs of (URL, postData).
1453// (the postData is not synced, so we ignore it). Sync associates keywords with
1454// bookmarks, which is not really accurate. -- We might already have a keyword
1455// with that name, or we might already have another bookmark with that URL with
1456// a different keyword, etc.
1457//
1458// If we don't handle those cases by removing the conflicting keywords first,
1459// the insertion  will fail, and the keywords will either be wrong, or missing.
1460// This function handles those cases.
1461function removeConflictingKeywords(bookmarkURL, newKeyword) {
1462  return PlacesUtils.withConnectionWrapper(
1463    "BookmarkSyncUtils: removeConflictingKeywords",
1464    async function(db) {
1465      let entryForURL = await PlacesUtils.keywords.fetch({
1466        url: bookmarkURL.href,
1467      });
1468      if (entryForURL && entryForURL.keyword !== newKeyword) {
1469        await PlacesUtils.keywords.remove({
1470          keyword: entryForURL.keyword,
1471          source: SOURCE_SYNC,
1472        });
1473        // This will cause us to reupload this record for this sync, but
1474        // without it, we will risk data corruption.
1475        await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL(
1476          db,
1477          entryForURL.url,
1478          1
1479        );
1480      }
1481      if (!newKeyword) {
1482        return;
1483      }
1484      let entryForNewKeyword = await PlacesUtils.keywords.fetch({
1485        keyword: newKeyword,
1486      });
1487      if (entryForNewKeyword) {
1488        await PlacesUtils.keywords.remove({
1489          keyword: entryForNewKeyword.keyword,
1490          source: SOURCE_SYNC,
1491        });
1492        await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL(
1493          db,
1494          entryForNewKeyword.url,
1495          1
1496        );
1497      }
1498    }
1499  );
1500}
1501
1502// Sets annotations, keywords, and tags on a new bookmark. Returns a Sync
1503// bookmark object.
1504async function insertBookmarkMetadata(db, bookmarkItem, insertInfo) {
1505  let newItem = await placesBookmarkToSyncBookmark(db, bookmarkItem);
1506
1507  try {
1508    newItem.tags = tagItem(bookmarkItem, insertInfo.tags);
1509  } catch (ex) {
1510    BookmarkSyncLog.warn(
1511      `insertBookmarkMetadata: Error tagging item ${insertInfo.recordId}`,
1512      ex
1513    );
1514  }
1515
1516  if (insertInfo.keyword) {
1517    await removeConflictingKeywords(bookmarkItem.url, insertInfo.keyword);
1518    await PlacesUtils.keywords.insert({
1519      keyword: insertInfo.keyword,
1520      url: bookmarkItem.url.href,
1521      source: SOURCE_SYNC,
1522    });
1523    newItem.keyword = insertInfo.keyword;
1524  }
1525
1526  return newItem;
1527}
1528
1529// Determines the Sync record kind for an existing bookmark.
1530async function getKindForItem(db, item) {
1531  switch (item.type) {
1532    case PlacesUtils.bookmarks.TYPE_FOLDER: {
1533      return BookmarkSyncUtils.KINDS.FOLDER;
1534    }
1535    case PlacesUtils.bookmarks.TYPE_BOOKMARK:
1536      return item.url.protocol == "place:"
1537        ? BookmarkSyncUtils.KINDS.QUERY
1538        : BookmarkSyncUtils.KINDS.BOOKMARK;
1539
1540    case PlacesUtils.bookmarks.TYPE_SEPARATOR:
1541      return BookmarkSyncUtils.KINDS.SEPARATOR;
1542  }
1543  return null;
1544}
1545
1546// Returns the `nsINavBookmarksService` bookmark type constant for a Sync
1547// record kind.
1548function getTypeForKind(kind) {
1549  switch (kind) {
1550    case BookmarkSyncUtils.KINDS.BOOKMARK:
1551    case BookmarkSyncUtils.KINDS.QUERY:
1552      return PlacesUtils.bookmarks.TYPE_BOOKMARK;
1553
1554    case BookmarkSyncUtils.KINDS.FOLDER:
1555      return PlacesUtils.bookmarks.TYPE_FOLDER;
1556
1557    case BookmarkSyncUtils.KINDS.SEPARATOR:
1558      return PlacesUtils.bookmarks.TYPE_SEPARATOR;
1559  }
1560  throw new Error(`Unknown bookmark kind: ${kind}`);
1561}
1562
1563function validateNewBookmark(name, info) {
1564  let insertInfo = validateSyncBookmarkObject(name, info, {
1565    kind: { required: true },
1566    recordId: { required: true },
1567    url: {
1568      requiredIf: b =>
1569        [
1570          BookmarkSyncUtils.KINDS.BOOKMARK,
1571          BookmarkSyncUtils.KINDS.QUERY,
1572        ].includes(b.kind),
1573      validIf: b =>
1574        [
1575          BookmarkSyncUtils.KINDS.BOOKMARK,
1576          BookmarkSyncUtils.KINDS.QUERY,
1577        ].includes(b.kind),
1578    },
1579    parentRecordId: { required: true },
1580    title: {
1581      validIf: b =>
1582        [
1583          BookmarkSyncUtils.KINDS.BOOKMARK,
1584          BookmarkSyncUtils.KINDS.QUERY,
1585          BookmarkSyncUtils.KINDS.FOLDER,
1586        ].includes(b.kind) || b.title === "",
1587    },
1588    query: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY },
1589    folder: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY },
1590    tags: {
1591      validIf: b =>
1592        [
1593          BookmarkSyncUtils.KINDS.BOOKMARK,
1594          BookmarkSyncUtils.KINDS.QUERY,
1595        ].includes(b.kind),
1596    },
1597    keyword: {
1598      validIf: b =>
1599        [
1600          BookmarkSyncUtils.KINDS.BOOKMARK,
1601          BookmarkSyncUtils.KINDS.QUERY,
1602        ].includes(b.kind),
1603    },
1604    dateAdded: { required: false },
1605  });
1606
1607  return insertInfo;
1608}
1609
1610async function fetchGuidsWithAnno(db, anno, val) {
1611  let rows = await db.executeCached(
1612    `
1613    SELECT b.guid FROM moz_items_annos a
1614    JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
1615    JOIN moz_bookmarks b ON b.id = a.item_id
1616    WHERE n.name = :anno AND
1617          a.content = :val`,
1618    { anno, val }
1619  );
1620  return rows.map(row => row.getResultByName("guid"));
1621}
1622
1623function tagItem(item, tags) {
1624  if (!item.url) {
1625    return [];
1626  }
1627
1628  // Remove leading and trailing whitespace, then filter out empty tags.
1629  let newTags = tags ? tags.map(tag => tag.trim()).filter(Boolean) : [];
1630
1631  // Removing the last tagged item will also remove the tag. To preserve
1632  // tag IDs, we temporarily tag a dummy URI, ensuring the tags exist.
1633  let dummyURI = PlacesUtils.toURI("about:weave#BStore_tagURI");
1634  let bookmarkURI = PlacesUtils.toURI(item.url.href);
1635  if (newTags && newTags.length) {
1636    PlacesUtils.tagging.tagURI(dummyURI, newTags, SOURCE_SYNC);
1637  }
1638  PlacesUtils.tagging.untagURI(bookmarkURI, null, SOURCE_SYNC);
1639  if (newTags && newTags.length) {
1640    PlacesUtils.tagging.tagURI(bookmarkURI, newTags, SOURCE_SYNC);
1641  }
1642  PlacesUtils.tagging.untagURI(dummyURI, null, SOURCE_SYNC);
1643
1644  return newTags;
1645}
1646
1647// Converts a Places bookmark to a Sync bookmark. This function maps Places
1648// GUIDs to record IDs and filters out extra Places properties like date added,
1649// last modified, and index.
1650async function placesBookmarkToSyncBookmark(db, bookmarkItem) {
1651  let item = {};
1652
1653  for (let prop in bookmarkItem) {
1654    switch (prop) {
1655      // Record IDs are identical to Places GUIDs for all items except roots.
1656      case "guid":
1657        item.recordId = BookmarkSyncUtils.guidToRecordId(bookmarkItem.guid);
1658        break;
1659
1660      case "parentGuid":
1661        item.parentRecordId = BookmarkSyncUtils.guidToRecordId(
1662          bookmarkItem.parentGuid
1663        );
1664        break;
1665
1666      // Sync uses kinds instead of types, which distinguish between folders,
1667      // livemarks, bookmarks, and queries.
1668      case "type":
1669        item.kind = await getKindForItem(db, bookmarkItem);
1670        break;
1671
1672      case "title":
1673      case "url":
1674        item[prop] = bookmarkItem[prop];
1675        break;
1676
1677      case "dateAdded":
1678        item[prop] = new Date(bookmarkItem[prop]).getTime();
1679        break;
1680    }
1681  }
1682
1683  return item;
1684}
1685
1686// Converts a Sync bookmark object to a Places bookmark or livemark object.
1687// This function maps record IDs to Places GUIDs, and filters out extra Sync
1688// properties like keywords, tags. Returns an object that can be passed to
1689// `PlacesUtils.bookmarks.{insert, update}`.
1690function syncBookmarkToPlacesBookmark(info) {
1691  let bookmarkInfo = {
1692    source: SOURCE_SYNC,
1693  };
1694
1695  for (let prop in info) {
1696    switch (prop) {
1697      case "kind":
1698        bookmarkInfo.type = getTypeForKind(info.kind);
1699        break;
1700
1701      // Convert record IDs to Places GUIDs for roots.
1702      case "recordId":
1703        bookmarkInfo.guid = BookmarkSyncUtils.recordIdToGuid(info.recordId);
1704        break;
1705
1706      case "dateAdded":
1707        bookmarkInfo.dateAdded = new Date(info.dateAdded);
1708        break;
1709
1710      case "parentRecordId":
1711        bookmarkInfo.parentGuid = BookmarkSyncUtils.recordIdToGuid(
1712          info.parentRecordId
1713        );
1714        // Instead of providing an index, Sync reorders children at the end of
1715        // the sync using `BookmarkSyncUtils.order`. We explicitly specify the
1716        // default index here to prevent `PlacesUtils.bookmarks.update` from
1717        // throwing.
1718        bookmarkInfo.index = PlacesUtils.bookmarks.DEFAULT_INDEX;
1719        break;
1720
1721      case "title":
1722      case "url":
1723        bookmarkInfo[prop] = info[prop];
1724        break;
1725    }
1726  }
1727
1728  return bookmarkInfo;
1729}
1730
1731// Creates and returns a Sync bookmark object containing the bookmark's
1732// tags, keyword.
1733var fetchBookmarkItem = async function(db, bookmarkItem) {
1734  let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
1735
1736  if (!item.title) {
1737    item.title = "";
1738  }
1739
1740  item.tags = PlacesUtils.tagging.getTagsForURI(
1741    PlacesUtils.toURI(bookmarkItem.url)
1742  );
1743
1744  let keywordEntry = await PlacesUtils.keywords.fetch({
1745    url: bookmarkItem.url,
1746  });
1747  if (keywordEntry) {
1748    item.keyword = keywordEntry.keyword;
1749  }
1750
1751  return item;
1752};
1753
1754// Creates and returns a Sync bookmark object containing the folder's children.
1755async function fetchFolderItem(db, bookmarkItem) {
1756  let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
1757
1758  if (!item.title) {
1759    item.title = "";
1760  }
1761
1762  let childGuids = await fetchChildGuids(db, bookmarkItem.guid);
1763  item.childRecordIds = childGuids.map(guid =>
1764    BookmarkSyncUtils.guidToRecordId(guid)
1765  );
1766
1767  return item;
1768}
1769
1770// Creates and returns a Sync bookmark object containing the query's tag
1771// folder name.
1772async function fetchQueryItem(db, bookmarkItem) {
1773  let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
1774
1775  let params = new URLSearchParams(bookmarkItem.url.pathname);
1776  let tags = params.getAll("tag");
1777  if (tags.length == 1) {
1778    item.folder = tags[0];
1779  }
1780
1781  return item;
1782}
1783
1784function addRowToChangeRecords(row, changeRecords) {
1785  let guid = row.getResultByName("guid");
1786  if (!guid) {
1787    throw new Error(`Changed item missing GUID`);
1788  }
1789  let isTombstone = !!row.getResultByName("tombstone");
1790  let recordId = BookmarkSyncUtils.guidToRecordId(guid);
1791  if (recordId in changeRecords) {
1792    let existingRecord = changeRecords[recordId];
1793    if (existingRecord.tombstone == isTombstone) {
1794      // Should never happen: `moz_bookmarks.guid` has a unique index, and
1795      // `moz_bookmarks_deleted.guid` is the primary key.
1796      throw new Error(`Duplicate item or tombstone ${recordId} in changeset`);
1797    }
1798    if (!existingRecord.tombstone && isTombstone) {
1799      // Don't replace undeleted items with tombstones...
1800      BookmarkSyncLog.warn(
1801        "addRowToChangeRecords: Ignoring tombstone for undeleted item",
1802        recordId
1803      );
1804      return;
1805    }
1806    // ...But replace undeleted tombstones with items.
1807    BookmarkSyncLog.warn(
1808      "addRowToChangeRecords: Replacing tombstone for undeleted item",
1809      recordId
1810    );
1811  }
1812  let modifiedAsPRTime = row.getResultByName("modified");
1813  let modified = modifiedAsPRTime / MICROSECONDS_PER_SECOND;
1814  if (Number.isNaN(modified) || modified <= 0) {
1815    BookmarkSyncLog.error(
1816      "addRowToChangeRecords: Invalid modified date for " + recordId,
1817      modifiedAsPRTime
1818    );
1819    modified = 0;
1820  }
1821  changeRecords[recordId] = {
1822    modified,
1823    counter: row.getResultByName("syncChangeCounter"),
1824    status: row.getResultByName("syncStatus"),
1825    tombstone: isTombstone,
1826    synced: false,
1827  };
1828}
1829
1830/**
1831 * Queries the database for synced bookmarks and tombstones, and returns a
1832 * changeset for the Sync bookmarks engine.
1833 *
1834 * @param db
1835 *        The Sqlite.jsm connection handle.
1836 * @param forGuids
1837 *        Fetch Sync tracking information for only the requested GUIDs.
1838 * @return {Promise} resolved once all items have been fetched.
1839 * @resolves to an object containing records for changed bookmarks, keyed by
1840 *           the record ID.
1841 */
1842var pullSyncChanges = async function(db, forGuids = []) {
1843  let changeRecords = {};
1844
1845  let itemConditions = ["syncChangeCounter >= 1"];
1846  let tombstoneConditions = ["1 = 1"];
1847  if (forGuids.length) {
1848    let restrictToGuids = `guid IN (${forGuids
1849      .map(guid => JSON.stringify(guid))
1850      .join(",")})`;
1851    itemConditions.push(restrictToGuids);
1852    tombstoneConditions.push(restrictToGuids);
1853  }
1854
1855  let rows = await db.executeCached(
1856    `
1857    WITH RECURSIVE
1858    syncedItems(id, guid, modified, syncChangeCounter, syncStatus) AS (
1859      SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus
1860       FROM moz_bookmarks b
1861       WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
1862                        'mobile______')
1863      UNION ALL
1864      SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus
1865      FROM moz_bookmarks b
1866      JOIN syncedItems s ON b.parent = s.id
1867    )
1868    SELECT guid, modified, syncChangeCounter, syncStatus, 0 AS tombstone
1869    FROM syncedItems
1870    WHERE ${itemConditions.join(" AND ")}
1871    UNION ALL
1872    SELECT guid, dateRemoved AS modified, 1 AS syncChangeCounter,
1873           :deletedSyncStatus, 1 AS tombstone
1874    FROM moz_bookmarks_deleted
1875    WHERE ${tombstoneConditions.join(" AND ")}`,
1876    { deletedSyncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
1877  );
1878  for (let row of rows) {
1879    addRowToChangeRecords(row, changeRecords);
1880  }
1881
1882  return changeRecords;
1883};
1884
1885// Moves a synced folder's remaining children to its parent, and deletes the
1886// folder if it's empty.
1887async function deleteSyncedFolder(db, bookmarkItem) {
1888  // At this point, any member in the folder that remains is either a folder
1889  // pending deletion (which we'll get to in this function), or an item that
1890  // should not be deleted. To avoid deleting these items, we first move them
1891  // to the parent of the folder we're about to delete.
1892  let childGuids = await fetchChildGuids(db, bookmarkItem.guid);
1893  if (!childGuids.length) {
1894    // No children -- just delete the folder.
1895    return deleteSyncedAtom(bookmarkItem);
1896  }
1897
1898  if (BookmarkSyncLog.level <= Log.Level.Trace) {
1899    BookmarkSyncLog.trace(
1900      `deleteSyncedFolder: Moving ${JSON.stringify(childGuids)} children of ` +
1901        `"${bookmarkItem.guid}" to grandparent
1902      "${BookmarkSyncUtils.guidToRecordId(bookmarkItem.parentGuid)}" before ` +
1903        `deletion`
1904    );
1905  }
1906
1907  // Move children out of the parent and into the grandparent
1908  for (let guid of childGuids) {
1909    await PlacesUtils.bookmarks.update({
1910      guid,
1911      parentGuid: bookmarkItem.parentGuid,
1912      index: PlacesUtils.bookmarks.DEFAULT_INDEX,
1913      // `SYNC_REPARENT_REMOVED_FOLDER_CHILDREN` bumps the change counter for
1914      // the child and its new parent, without incrementing the bookmark
1915      // tracker's score.
1916      //
1917      // We intentionally don't check if the child is one we'll remove later,
1918      // so it's possible we'll bump the change counter of the closest living
1919      // ancestor when it's not needed. This avoids inconsistency if removal
1920      // is interrupted, since we don't run this operation in a transaction.
1921      source:
1922        PlacesUtils.bookmarks.SOURCES.SYNC_REPARENT_REMOVED_FOLDER_CHILDREN,
1923    });
1924  }
1925
1926  // Delete the (now empty) parent
1927  try {
1928    await PlacesUtils.bookmarks.remove(bookmarkItem.guid, {
1929      preventRemovalOfNonEmptyFolders: true,
1930      // We don't want to bump the change counter for this deletion, because
1931      // a tombstone for the folder is already on the server.
1932      source: SOURCE_SYNC,
1933    });
1934  } catch (e) {
1935    // We failed, probably because someone added something to this folder
1936    // between when we got the children and now (or the database is corrupt,
1937    // or something else happened...) This is unlikely, but possible. To
1938    // avoid corruption in this case, we need to reupload the record to the
1939    // server.
1940    //
1941    // (Ideally this whole operation would be done in a transaction, and this
1942    // wouldn't be possible).
1943    BookmarkSyncLog.trace(
1944      `deleteSyncedFolder: Error removing parent ` +
1945        `${bookmarkItem.guid} after reparenting children`,
1946      e
1947    );
1948    return false;
1949  }
1950
1951  return true;
1952}
1953
1954// Removes a synced bookmark or empty folder from the database.
1955var deleteSyncedAtom = async function(bookmarkItem) {
1956  try {
1957    await PlacesUtils.bookmarks.remove(bookmarkItem.guid, {
1958      preventRemovalOfNonEmptyFolders: true,
1959      source: SOURCE_SYNC,
1960    });
1961  } catch (ex) {
1962    // Likely already removed.
1963    BookmarkSyncLog.trace(
1964      `deleteSyncedAtom: Error removing ` + bookmarkItem.guid,
1965      ex
1966    );
1967    return false;
1968  }
1969
1970  return true;
1971};
1972
1973/**
1974 * Updates the sync status on all "NEW" and "UNKNOWN" bookmarks to "NORMAL".
1975 *
1976 * We do this when pulling changes instead of in `pushChanges` to make sure
1977 * we write tombstones if a new item is deleted after an interrupted sync. (For
1978 * example, if a "NEW" record is uploaded or reconciled, then the app is closed
1979 * before Sync calls `pushChanges`).
1980 */
1981function markChangesAsSyncing(db, changeRecords) {
1982  let unsyncedGuids = [];
1983  for (let recordId in changeRecords) {
1984    if (changeRecords[recordId].tombstone) {
1985      continue;
1986    }
1987    if (
1988      changeRecords[recordId].status == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
1989    ) {
1990      continue;
1991    }
1992    let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
1993    unsyncedGuids.push(JSON.stringify(guid));
1994  }
1995  if (!unsyncedGuids.length) {
1996    return Promise.resolve();
1997  }
1998  return db.execute(
1999    `
2000    UPDATE moz_bookmarks
2001    SET syncStatus = :syncStatus
2002    WHERE guid IN (${unsyncedGuids.join(",")})`,
2003    { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
2004  );
2005}
2006
2007/**
2008 * Removes tombstones for successfully synced items.
2009 *
2010 * @return {Promise}
2011 */
2012var removeTombstones = function(db, guids) {
2013  if (!guids.length) {
2014    return Promise.resolve();
2015  }
2016  return db.execute(`
2017    DELETE FROM moz_bookmarks_deleted
2018    WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})`);
2019};
2020
2021/**
2022 * Removes tombstones for successfully synced items where the specified GUID
2023 * exists in *both* the bookmarks and tombstones tables.
2024 *
2025 * @return {Promise}
2026 */
2027var removeUndeletedTombstones = function(db, guids) {
2028  if (!guids.length) {
2029    return Promise.resolve();
2030  }
2031  // sqlite can't join in a DELETE, so we use a subquery.
2032  return db.execute(`
2033    DELETE FROM moz_bookmarks_deleted
2034    WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})
2035    AND guid IN (SELECT guid from moz_bookmarks)`);
2036};
2037
2038// Sets the history sync ID and clears the last sync time.
2039async function setHistorySyncId(db, newSyncId) {
2040  await PlacesUtils.metadata.setWithConnection(
2041    db,
2042    HistorySyncUtils.SYNC_ID_META_KEY,
2043    newSyncId
2044  );
2045
2046  await PlacesUtils.metadata.deleteWithConnection(
2047    db,
2048    HistorySyncUtils.LAST_SYNC_META_KEY
2049  );
2050}
2051
2052// Sets the bookmarks sync ID and clears the last sync time.
2053async function setBookmarksSyncId(db, newSyncId) {
2054  await PlacesUtils.metadata.setWithConnection(
2055    db,
2056    BookmarkSyncUtils.SYNC_ID_META_KEY,
2057    newSyncId
2058  );
2059
2060  await PlacesUtils.metadata.deleteWithConnection(
2061    db,
2062    BookmarkSyncUtils.LAST_SYNC_META_KEY,
2063    BookmarkSyncUtils.WIPE_REMOTE_META_KEY
2064  );
2065}
2066
2067// Bumps the change counter and sets the given sync status for all bookmarks,
2068// removes all orphan annos, and drops stale tombstones.
2069async function resetAllSyncStatuses(db, syncStatus) {
2070  await db.execute(
2071    `
2072    UPDATE moz_bookmarks
2073    SET syncChangeCounter = 1,
2074        syncStatus = :syncStatus`,
2075    { syncStatus }
2076  );
2077
2078  // The orphan anno isn't meaningful after a restore, disconnect, or node
2079  // reassignment.
2080  await db.execute(
2081    `
2082    DELETE FROM moz_items_annos
2083    WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes
2084                               WHERE name = :orphanAnno)`,
2085    { orphanAnno: BookmarkSyncUtils.SYNC_PARENT_ANNO }
2086  );
2087
2088  // Drop stale tombstones.
2089  await db.execute("DELETE FROM moz_bookmarks_deleted");
2090}
2091