Bug 1435354 - Remove the keywords cache observer and implement cache invalidation. r?mak draft
authorKit Cambridge <kit@yakshaving.ninja>
Tue, 20 Feb 2018 22:04:59 -0800
changeset 758474 7eff029c37d162b2305d82d81e73312a39bc431c
parent 758142 994a684a7564c2735d98d6910a78d079a68f0b25
child 758475 19b49c9d5dc15f4b035883fecf93aaa0884570df
push id100063
push userbmo:kit@mozilla.com
push dateThu, 22 Feb 2018 14:18:46 +0000
reviewersmak
bugs1435354
milestone60.0a1
Bug 1435354 - Remove the keywords cache observer and implement cache invalidation. r?mak MozReview-Commit-ID: dRGyz042JM
services/sync/tests/unit/test_bookmark_tracker.js
toolkit/components/places/Bookmarks.jsm
toolkit/components/places/PlacesCategoriesStarter.js
toolkit/components/places/PlacesUtils.jsm
toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js
toolkit/components/places/tests/bookmarks/test_keywords.js
toolkit/components/places/tests/legacy/test_bookmarks.js
toolkit/components/places/tests/unit/test_keywords.js
toolkit/components/places/tests/unit/test_sync_utils.js
toolkit/components/places/toolkitplaces.manifest
--- a/services/sync/tests/unit/test_bookmark_tracker.js
+++ b/services/sync/tests/unit/test_bookmark_tracker.js
@@ -769,48 +769,16 @@ add_task(async function test_async_onIte
     await verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]);
     Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 4);
   } finally {
     _("Clean up.");
     await cleanup();
   }
 });
 
-add_task(async function test_onItemKeywordChanged() {
-  _("Keyword changes via the synchronous API should be tracked");
-
-  try {
-    await tracker.stop();
-    let folder = PlacesUtils.bookmarks.createFolder(
-      PlacesUtils.bookmarks.bookmarksMenuFolder, "Parent",
-      PlacesUtils.bookmarks.DEFAULT_INDEX);
-    _("Track changes to keywords");
-    let uri = CommonUtils.makeURI("http://getfirefox.com");
-    let b = PlacesUtils.bookmarks.insertBookmark(
-      folder, uri,
-      PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!");
-    let bGUID = await PlacesUtils.promiseItemGuid(b);
-    _("New item is " + b);
-    _("GUID: " + bGUID);
-
-    await startTracking();
-
-    _("Give the item a keyword");
-    PlacesUtils.bookmarks.setKeywordForBookmark(b, "the_keyword");
-
-    // bookmark should be tracked, folder should not be.
-    await verifyTrackedItems([bGUID]);
-    Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE);
-
-  } finally {
-    _("Clean up.");
-    await cleanup();
-  }
-});
-
 add_task(async function test_async_onItemKeywordChanged() {
   _("Keyword changes via the asynchronous API should be tracked");
 
   try {
     await tracker.stop();
 
     _("Insert two bookmarks with the same URL");
     let fxBmk1 = await PlacesUtils.bookmarks.insert({
--- a/toolkit/components/places/Bookmarks.jsm
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -760,16 +760,18 @@ var Bookmarks = Object.freeze({
                                                      entry.type, entry._parentId,
                                                      entry.guid, entry.parentGuid,
                                                      "", updatedItem.source ]);
               }
             }
           }
         }
         if (updateInfo.hasOwnProperty("url")) {
+          await PlacesUtils.keywords.reassign(item.url, updatedItem.url,
+                                              updatedItem.source);
           notify(observers, "onItemChanged", [ updatedItem._id, "uri",
                                                false, updatedItem.url.href,
                                                PlacesUtils.toPRTime(updatedItem.lastModified),
                                                updatedItem.type,
                                                updatedItem._parentId,
                                                updatedItem.guid,
                                                updatedItem.parentGuid,
                                                item.url.href,
@@ -914,16 +916,17 @@ var Bookmarks = Object.freeze({
                                         syncChangeCounter = syncChangeCounter + :syncChangeDelta
                WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
               `, { folderGuid, time, syncChangeDelta });
           }
         });
 
         // We don't wait for the frecency calculation.
         if (urls && urls.length) {
+          await PlacesUtils.keywords.eraseEverything();
           updateFrecency(db, urls, true).catch(Cu.reportError);
         }
       }
     );
   },
 
   /**
    * Returns a list of recently bookmarked items.
@@ -1945,34 +1948,30 @@ function removeBookmarks(items, options)
         await adjustSeparatorsSyncCounter(db, item._parentId, item.index, syncChangeDelta);
       }
 
       for (let guid of parentGuids) {
         // Mark all affected parents as changed.
         await setAncestorsLastModified(db, guid, new Date(), syncChangeDelta);
       }
 
-      // Write a tombstone for the removed item.
+      // Write tombstones for the removed items.
       await insertTombstones(db, items, syncChangeDelta);
     });
 
-    // Update the frecencies outside of the transaction, so that the updates
-    // can progress in the background.
-    for (let item of items) {
+    // Update the frecencies outside of the transaction, excluding tags, so that
+    // the updates can progress in the background.
+    urls = urls.concat(items.filter(item => {
       let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
-
-      // If not a tag recalculate frecency...
-      if (item.type == Bookmarks.TYPE_BOOKMARK && !isUntagging) {
-        // ...though we don't wait for the calculation.
-        updateFrecency(db, [item.url]).catch(Cu.reportError);
-      }
-    }
+      return !isUntagging && "url" in item;
+    }).map(item => item.url));
 
     if (urls.length) {
-      updateFrecency(db, urls, true).catch(Cu.reportError);
+      await PlacesUtils.keywords.removeFromURLsIfNotBookmarked(urls);
+      updateFrecency(db, urls, urls.length > 1).catch(Cu.reportError);
     }
   });
 }
 
 // Reorder implementation.
 
 function reorderChildren(parent, orderedChildrenGuids, options) {
   return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: reorderChildren",
--- a/toolkit/components/places/PlacesCategoriesStarter.js
+++ b/toolkit/components/places/PlacesCategoriesStarter.js
@@ -34,20 +34,16 @@ PlacesCategoriesStarter.prototype = {
         Services.obs.removeObserver(this, TOPIC_GATHER_TELEMETRY);
         if (Cu.isModuleLoaded("resource://gre/modules/PlacesDBUtils.jsm")) {
           PlacesDBUtils.shutdown();
         }
         break;
       case TOPIC_GATHER_TELEMETRY:
         PlacesDBUtils.telemetry();
         break;
-      case PlacesUtils.TOPIC_INIT_COMPLETE:
-        // Init keywords so it starts listening to bookmarks notifications.
-        PlacesUtils.keywords;
-        break;
       case "idle-daily":
         // Once a week run places.sqlite maintenance tasks.
         let lastMaintenance =
           Services.prefs.getIntPref("places.database.lastMaintenance", 0);
         let nowSeconds = parseInt(Date.now() / 1000);
         if (lastMaintenance < nowSeconds - MAINTENANCE_INTERVAL_SECONDS) {
           PlacesDBUtils.maintenanceOnIdle();
         }
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -73,34 +73,31 @@ function notify(observers, notification,
  *        the url to notify about.
  * @param keyword
  *        The keyword to notify, or empty string if a keyword was removed.
  */
 async function notifyKeywordChange(url, keyword, source) {
   // Notify bookmarks about the removal.
   let bookmarks = [];
   await PlacesUtils.bookmarks.fetch({ url }, b => bookmarks.push(b));
-  // We don't want to yield in the gIgnoreKeywordNotifications section.
   for (let bookmark of bookmarks) {
     bookmark.id = await PlacesUtils.promiseItemId(bookmark.guid);
     bookmark.parentId = await PlacesUtils.promiseItemId(bookmark.parentGuid);
   }
   let observers = PlacesUtils.bookmarks.getObservers();
-  gIgnoreKeywordNotifications = true;
   for (let bookmark of bookmarks) {
     notify(observers, "onItemChanged", [ bookmark.id, "keyword", false,
                                          keyword,
                                          bookmark.lastModified * 1000,
                                          bookmark.type,
                                          bookmark.parentId,
                                          bookmark.guid, bookmark.parentGuid,
                                          "", source
                                        ]);
   }
-  gIgnoreKeywordNotifications = false;
 }
 
 /**
  * Serializes the given node in JSON format.
  *
  * @param aNode
  *        An nsINavHistoryResultNode
  * @param aIsLivemark
@@ -1878,21 +1875,16 @@ XPCOMUtils.defineLazyServiceGetter(Place
 XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "tagging",
                                    "@mozilla.org/browser/tagging-service;1",
                                    "nsITaggingService");
 
 XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "livemarks",
                                    "@mozilla.org/browser/livemark-service;2",
                                    "mozIAsyncLivemarks");
 
-XPCOMUtils.defineLazyGetter(PlacesUtils, "keywords", () => {
-  gKeywordsCachePromise.catch(Cu.reportError);
-  return Keywords;
-});
-
 XPCOMUtils.defineLazyGetter(this, "bundle", function() {
   const PLACES_STRING_BUNDLE_URI = "chrome://places/locale/places.properties";
   return Services.strings.createBundle(PLACES_STRING_BUNDLE_URI);
 });
 
 // This is just used as a reasonably-random value for copy & paste / drag operations.
 XPCOMUtils.defineLazyGetter(PlacesUtils, "instanceId", () => {
   return PlacesUtils.history.makeGuid();
@@ -1973,17 +1965,17 @@ XPCOMUtils.defineLazyGetter(this, "gAsyn
  * Keywords management API.
  * Sooner or later these keywords will merge with search aliases, this is an
  * interim API that should then be replaced by a unified one.
  * Keywords are associated with URLs and can have POST data.
  * The relations between URLs and keywords are the following:
  *  - 1 keyword can only point to 1 URL
  *  - 1 URL can have multiple keywords, iff they differ by POST data (included the empty one).
  */
-var Keywords = {
+PlacesUtils.keywords = {
   /**
    * Fetches a keyword entry based on keyword or URL.
    *
    * @param keywordOrEntry
    *        Either the keyword to fetch or an entry providing keyword
    *        or url property to find keywords for.  If both properties are set,
    *        this returns their intersection.
    * @param onResult [optional]
@@ -2018,17 +2010,17 @@ var Keywords = {
         try {
           onResult(entry);
         } catch (ex) {
           Cu.reportError(ex);
         }
       }
     };
 
-    return gKeywordsCachePromise.then(cache => {
+    return promiseKeywordsCache().then(cache => {
       let entries = [];
       if (hasKeyword) {
         let entry = cache.get(keywordOrEntry.keyword);
         if (entry)
           entries.push(entry);
       }
       if (hasUrl) {
         for (let entry of cache.values()) {
@@ -2080,18 +2072,18 @@ var Keywords = {
       keywordEntry.source = PlacesUtils.bookmarks.SOURCES.DEFAULT;
     }
     let { keyword, url, source } = keywordEntry;
     keyword = keyword.trim().toLowerCase();
     let postData = keywordEntry.postData || "";
     // This also checks href for validity
     url = new URL(url);
 
-    return PlacesUtils.withConnectionWrapper("Keywords.insert", async db => {
-        let cache = await gKeywordsCachePromise;
+    return PlacesUtils.withConnectionWrapper("PlacesUtils.keywords.insert", async db => {
+        let cache = await promiseKeywordsCache();
 
         // Trying to set the same keyword is a no-op.
         let oldEntry = cache.get(keyword);
         if (oldEntry && oldEntry.url.href == url.href &&
             (oldEntry.postData || "") == postData) {
           return;
         }
 
@@ -2173,171 +2165,264 @@ var Keywords = {
 
     if (keywordOrEntry === null || typeof(keywordOrEntry) != "object" ||
         !keywordOrEntry.keyword || typeof keywordOrEntry.keyword != "string")
       throw new Error("Invalid keyword");
 
     let { keyword,
           source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = keywordOrEntry;
     keyword = keywordOrEntry.keyword.trim().toLowerCase();
-    return PlacesUtils.withConnectionWrapper("Keywords.remove", async function(db) {
-      let cache = await gKeywordsCachePromise;
+    return PlacesUtils.withConnectionWrapper("PlacesUtils.keywords.remove", async db => {
+      let cache = await promiseKeywordsCache();
       if (!cache.has(keyword))
         return;
       let { url } = cache.get(keyword);
       cache.delete(keyword);
 
       await db.execute(`DELETE FROM moz_keywords WHERE keyword = :keyword`,
                        { keyword });
 
       await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
         db, url, PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source));
 
       // Notify bookmarks about the removal.
       await notifyKeywordChange(url.href, "", source);
     });
-  }
+  },
+
+  /**
+   * Moves all (keyword, POST data) pairs from one URL to another, and fires
+   * observer notifications for all affected bookmarks. If the destination URL
+   * already has keywords, they will be removed and replaced with the source
+   * URL's keywords.
+   *
+   * @param oldURL
+   *        The source URL.
+   * @param newURL
+   *        The destination URL.
+   * @param source
+   *        The change source, forwarded to all bookmark observers.
+   * @return {Promise}
+   * @resolves when all keywords have been moved to the destination URL.
+   */
+  reassign(oldURL, newURL, source = PlacesUtils.bookmarks.SOURCES.DEFAULT) {
+    try {
+      oldURL = BOOKMARK_VALIDATORS.url(oldURL);
+    } catch (ex) {
+      throw new Error(oldURL + " is not a valid source URL");
+    }
+    try {
+      newURL = BOOKMARK_VALIDATORS.url(newURL);
+    } catch (ex) {
+      throw new Error(oldURL + " is not a valid destination URL");
+    }
+    return PlacesUtils.withConnectionWrapper("PlacesUtils.keywords.reassign",
+                                             async function(db) {
+      let keywordsToReassign = [];
+      let keywordsToRemove = [];
+      let cache = await promiseKeywordsCache();
+      for (let [keyword, entry] of cache) {
+        if (entry.url.href == oldURL.href) {
+          keywordsToReassign.push(keyword);
+        }
+        if (entry.url.href == newURL.href) {
+          keywordsToRemove.push(keyword);
+        }
+      }
+      if (!keywordsToReassign.length) {
+        return;
+      }
+
+      await db.executeTransaction(async function() {
+        // Remove existing keywords from the new URL.
+        await db.executeCached(
+          `DELETE FROM moz_keywords WHERE keyword = :keyword`,
+          keywordsToRemove.map(keyword => ({ keyword })));
+
+        // Move keywords from the old URL to the new URL.
+        await db.executeCached(`
+          UPDATE moz_keywords SET
+            place_id = (SELECT id FROM moz_places
+                        WHERE url_hash = hash(:newURL) AND
+                              url = :newURL)
+          WHERE place_id = (SELECT id FROM moz_places
+                            WHERE url_hash = hash(:oldURL) AND
+                                  url = :oldURL)`,
+          { newURL: newURL.href, oldURL: oldURL.href });
+      });
+      for (let keyword of keywordsToReassign) {
+        let entry = cache.get(keyword);
+        entry.url = newURL;
+      }
+      for (let keyword of keywordsToRemove) {
+        cache.delete(keyword);
+      }
+
+      if (keywordsToReassign.length) {
+        // If we moved any keywords, notify that we removed all keywords from
+        // the old and new URLs, then notify for each moved keyword.
+        await notifyKeywordChange(oldURL, "", source);
+        await notifyKeywordChange(newURL, "", source);
+        for (let keyword of keywordsToReassign) {
+          await notifyKeywordChange(newURL, keyword, source);
+        }
+      } else if (keywordsToRemove.length) {
+        // If the old URL didn't have any keywords, but the new URL did, just
+        // notify that we removed all keywords from the new URL.
+        await notifyKeywordChange(oldURL, "", source);
+      }
+    });
+  },
+
+  /**
+   * Removes all orphaned keywords from the given URLs. Orphaned keywords are
+   * associated with URLs that are no longer bookmarked. If a given URL is still
+   * bookmarked, its keywords will not be removed.
+   *
+   * @param urls
+   *        A list of URLs to check for orphaned keywords.
+   * @return {Promise}
+   * @resolves when all keywords have been removed from URLs that are no longer
+   *           bookmarked.
+   */
+  removeFromURLsIfNotBookmarked(urls) {
+    let hrefs = new Set();
+    for (let url of urls) {
+      try {
+        url = BOOKMARK_VALIDATORS.url(url);
+      } catch (ex) {
+        throw new Error(url + " is not a valid URL");
+      }
+      hrefs.add(url.href);
+    }
+    return PlacesUtils.withConnectionWrapper(
+      "PlacesUtils.keywords.removeFromURLsIfNotBookmarked",
+      async function(db) {
+        let keywordsByHref = new Map();
+        let cache = await promiseKeywordsCache();
+        for (let [keyword, entry] of cache) {
+          let href = entry.url.href;
+          if (!hrefs.has(href)) {
+            continue;
+          }
+          if (!keywordsByHref.has(href)) {
+            keywordsByHref.set(href, [keyword]);
+            continue;
+          }
+          let existingKeywords = keywordsByHref.get(href);
+          existingKeywords.push(keyword);
+        }
+        if (!keywordsByHref.size) {
+          return;
+        }
+
+        let placeInfosToRemove = [];
+        let rows = await db.execute(`
+          SELECT h.id, h.url
+          FROM moz_places h
+          JOIN moz_keywords k ON k.place_id = h.id
+          GROUP BY h.id
+          HAVING h.foreign_count = COUNT(*)`);
+        for (let row of rows) {
+          placeInfosToRemove.push({
+            placeId: row.getResultByName("id"),
+            href: row.getResultByName("url"),
+          });
+        }
+        if (!placeInfosToRemove.length) {
+          return;
+        }
+
+        await db.execute(`DELETE FROM moz_keywords WHERE place_id IN (${
+          Array.from(placeInfosToRemove.map(info => info.placeId)).join()})`);
+        for (let { href } of placeInfosToRemove) {
+          let keywords = keywordsByHref.get(href);
+          for (let keyword of keywords) {
+            cache.delete(keyword);
+          }
+        }
+    });
+  },
+
+  /**
+   * Removes all keywords from all URLs.
+   *
+   * @return {Promise}
+   * @resolves when all keywords have been removed.
+   */
+  eraseEverything() {
+    return PlacesUtils.withConnectionWrapper(
+      "PlacesUtils.keywords.eraseEverything",
+      async function(db) {
+        let cache = await promiseKeywordsCache();
+        if (!cache.size) {
+          return;
+        }
+        await db.executeCached(`DELETE FROM moz_keywords`);
+        cache.clear();
+      }
+    );
+  },
+
+  /**
+   * Invalidates the keywords cache, leaving all existing keywords in place.
+   * The cache will be repopulated on the next `PlacesUtils.keywords.*` call.
+   *
+   * @return {Promise}
+   * @resolves when the cache has been cleared.
+   */
+  invalidateCachedKeywords() {
+    gKeywordsCachePromise = gKeywordsCachePromise.then(_ => null);
+    return gKeywordsCachePromise;
+  },
 };
 
-// Set by the keywords API to distinguish notifications fired by the old API.
-// Once the old API will be gone, we can remove this and stop observing.
-var gIgnoreKeywordNotifications = false;
-
-XPCOMUtils.defineLazyGetter(this, "gKeywordsCachePromise", () =>
-  PlacesUtils.withConnectionWrapper("PlacesUtils: gKeywordsCachePromise",
-    async function(db) {
-      let cache = new Map();
-
-      // Start observing changes to bookmarks. For now we are going to keep that
-      // relation for backwards compatibility reasons, but mostly because we are
-      // lacking a UI to manage keywords directly.
-      let observer = {
-        QueryInterface: XPCOMUtils.generateQI(Ci.nsINavBookmarkObserver),
-        onBeginUpdateBatch() {},
-        onEndUpdateBatch() {},
-        onItemAdded() {},
-        onItemVisited() {},
-        onItemMoved() {},
-
-        onItemRemoved(id, parentId, index, itemType, uri, guid, parentGuid,
-                      source) {
-          if (itemType != PlacesUtils.bookmarks.TYPE_BOOKMARK)
-            return;
-
-          let keywords = keywordsForHref(uri.spec);
-          // This uri has no keywords associated, so there's nothing to do.
-          if (keywords.length == 0)
-            return;
-
-          (async function() {
-            // If the uri is not bookmarked anymore, we can remove this keyword.
-            let bookmark = await PlacesUtils.bookmarks.fetch({ url: uri });
-            if (!bookmark) {
-              for (let keyword of keywords) {
-                await PlacesUtils.keywords.remove({ keyword, source });
-              }
-            }
-          })().catch(Cu.reportError);
-        },
-
-        onItemChanged(id, prop, isAnno, val, lastMod, itemType, parentId, guid,
-                      parentGuid, oldVal, source) {
-          if (gIgnoreKeywordNotifications) {
-            return;
-          }
-
-          if (prop == "keyword") {
-            this._onKeywordChanged(guid, val, oldVal);
-          } else if (prop == "uri") {
-            this._onUrlChanged(guid, val, oldVal, source).catch(Cu.reportError);
-          }
-        },
-
-        _onKeywordChanged(guid, keyword, href) {
-          if (keyword.length == 0) {
-            // We are removing a keyword.
-            let keywords = keywordsForHref(href);
-            for (let kw of keywords) {
-              cache.delete(kw);
-            }
-          } else {
-            // We are adding a new keyword.
-            cache.set(keyword, { keyword, url: new URL(href) });
-          }
-        },
+var gKeywordsCachePromise = Promise.resolve();
 
-        async _onUrlChanged(guid, url, oldUrl, source) {
-          // Check if the old url is associated with keywords.
-          let entries = [];
-          await PlacesUtils.keywords.fetch({ url: oldUrl }, e => entries.push(e));
-          if (entries.length == 0) {
-            return;
-          }
-
-          // Move the keywords to the new url.
-          for (let entry of entries) {
-            await PlacesUtils.keywords.remove({
-              keyword: entry.keyword,
-              source,
-            });
-            await PlacesUtils.keywords.insert({
-              keyword: entry.keyword,
-              url,
-              postData: entry.postData,
-              source,
-            });
-          }
-        },
-      };
-
-      PlacesUtils.bookmarks.addObserver(observer);
-      PlacesUtils.registerShutdownFunction(() => {
-        PlacesUtils.bookmarks.removeObserver(observer);
-      });
-
-      let rows = await db.execute(
-        `SELECT keyword, url, post_data
-         FROM moz_keywords k
-         JOIN moz_places h ON h.id = k.place_id
-        `);
-      let brokenKeywords = [];
-      for (let row of rows) {
-        let keyword = row.getResultByName("keyword");
-        try {
-          let entry = { keyword,
-                        url: new URL(row.getResultByName("url")),
-                        postData: row.getResultByName("post_data") || null };
-          cache.set(keyword, entry);
-        } catch (ex) {
-          // The url is invalid, don't load the keyword and remove it, or it
-          // would break the whole keywords API.
-          brokenKeywords.push(keyword);
-        }
-      }
-
-      if (brokenKeywords.length) {
-        await db.execute(
-          `DELETE FROM moz_keywords
-           WHERE keyword IN (${brokenKeywords.map(JSON.stringify).join(",")})
-          `);
-      }
-
-      // Helper to get a keyword from an href.
-      function keywordsForHref(href) {
-        let keywords = [];
-        for (let [ key, val ] of cache) {
-          if (val.url.href == href)
-            keywords.push(key);
-        }
-        return keywords;
-      }
-
+function promiseKeywordsCache() {
+  let promise = gKeywordsCachePromise.then(function(cache) {
+    if (cache) {
       return cache;
     }
-));
+    return PlacesUtils.withConnectionWrapper(
+      "PlacesUtils: promiseKeywordsCache",
+      async db => {
+        let cache = new Map();
+        let rows = await db.execute(
+          `SELECT keyword, url, post_data
+           FROM moz_keywords k
+           JOIN moz_places h ON h.id = k.place_id
+          `);
+        let brokenKeywords = [];
+        for (let row of rows) {
+          let keyword = row.getResultByName("keyword");
+          try {
+            let entry = { keyword,
+                          url: new URL(row.getResultByName("url")),
+                          postData: row.getResultByName("post_data") || null };
+            cache.set(keyword, entry);
+          } catch (ex) {
+            // The url is invalid, don't load the keyword and remove it, or it
+            // would break the whole keywords API.
+            brokenKeywords.push(keyword);
+          }
+        }
+        if (brokenKeywords.length) {
+          await db.execute(
+            `DELETE FROM moz_keywords
+             WHERE keyword IN (${brokenKeywords.map(JSON.stringify).join(",")})
+            `);
+        }
+        return cache;
+      }
+    );
+  });
+  gKeywordsCachePromise = promise.catch(_ => {});
+  return promise;
+}
 
 // Sometime soon, likely as part of the transition to mozIAsyncBookmarks,
 // itemIds will be deprecated in favour of GUIDs, which play much better
 // with multiple undo/redo operations.  Because these GUIDs are already stored,
 // and because we don't want to revise the transactions API once more when this
 // happens, transactions are set to work with GUIDs exclusively, in the sense
 // that they may never expose itemIds, nor do they accept them as input.
 // More importantly, transactions which add or remove items guarantee to
--- a/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js
@@ -116,23 +116,24 @@ add_task(async function remove_multiple_
   let bm2 = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                                                  type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
                                                  url: "http://example1.com/",
                                                  title: "a bookmark" });
   checkBookmarkObject(bm2);
 
   await Promise.all([frecencyChangedPromise, frecencyChangedPromise1]);
 
-  // This second one checks the frecency is changed when we remove the bookmark.
-  frecencyChangedPromise = promiseFrecencyChanged("http://example.com/", 0);
-  frecencyChangedPromise1 = promiseFrecencyChanged("http://example1.com/", 0);
+  // We should get an onManyFrecenciesChanged notification with the removal of
+  // multiple bookmarks.
+  let manyFrencenciesPromise =
+    PlacesTestUtils.waitForNotification("onManyFrecenciesChanged", () => true, "history");
 
   await PlacesUtils.bookmarks.remove([bm1, bm2]);
 
-  await Promise.all([frecencyChangedPromise, frecencyChangedPromise1]);
+  await manyFrencenciesPromise;
 });
 
 add_task(async function remove_bookmark_orphans() {
   let bm1 = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                                                  type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
                                                  url: "http://example.com/",
                                                  title: "a bookmark" });
   checkBookmarkObject(bm1);
@@ -183,25 +184,22 @@ add_task(async function test_contents_re
                                                      title: "a folder" });
   let bm1 = await PlacesUtils.bookmarks.insert({ parentGuid: folder1.guid,
                                                  type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
                                                  url: "http://example.com/",
                                                  title: "" });
 
   let skipDescendantsObserver = expectNotifications(true);
   let receiveAllObserver = expectNotifications(false);
-  let manyFrencenciesPromise =
-    PlacesTestUtils.waitForNotification("onManyFrecenciesChanged", () => true, "history");
+  let frecencyChangedPromise = promiseFrecencyChanged("http://example.com/", 0);
   await PlacesUtils.bookmarks.remove(folder1);
   Assert.strictEqual((await PlacesUtils.bookmarks.fetch(folder1.guid)), null);
   Assert.strictEqual((await PlacesUtils.bookmarks.fetch(bm1.guid)), null);
 
-  // We should get an onManyFrecenciesChanged notification with the removal of
-  // a folder with children.
-  await manyFrencenciesPromise;
+  await frecencyChangedPromise;
 
   let expectedNotifications = [{
     name: "onItemRemoved",
     arguments: {
       guid: folder1.guid,
     },
   }];
 
@@ -229,26 +227,23 @@ add_task(async function test_nested_cont
   let folder2 = await PlacesUtils.bookmarks.insert({ parentGuid: folder1.guid,
                                                      type: PlacesUtils.bookmarks.TYPE_FOLDER,
                                                      title: "a folder" });
   let bm1 = await PlacesUtils.bookmarks.insert({ parentGuid: folder2.guid,
                                                  type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
                                                  url: "http://example.com/",
                                                  title: "" });
 
-  let manyFrencenciesPromise =
-    PlacesTestUtils.waitForNotification("onManyFrecenciesChanged", () => true, "history");
+  let frecencyChangedPromise = promiseFrecencyChanged("http://example.com/", 0);
   await PlacesUtils.bookmarks.remove(folder1);
   Assert.strictEqual((await PlacesUtils.bookmarks.fetch(folder1.guid)), null);
   Assert.strictEqual((await PlacesUtils.bookmarks.fetch(folder2.guid)), null);
   Assert.strictEqual((await PlacesUtils.bookmarks.fetch(bm1.guid)), null);
 
-  // We should get an onManyFrecenciesChanged notification with the removal of
-  // a folder with children.
-  await manyFrencenciesPromise;
+  await frecencyChangedPromise;
 });
 
 add_task(async function remove_folder_empty_title() {
   let bm1 = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
                                                  type: PlacesUtils.bookmarks.TYPE_FOLDER,
                                                  title: "" });
   checkBookmarkObject(bm1);
 
--- a/toolkit/components/places/tests/bookmarks/test_keywords.js
+++ b/toolkit/components/places/tests/bookmarks/test_keywords.js
@@ -1,37 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
 ChromeUtils.defineModuleGetter(this, "Preferences",
                                "resource://gre/modules/Preferences.jsm");
 
-const URI1 = NetUtil.newURI("http://test1.mozilla.org/");
-const URI2 = NetUtil.newURI("http://test2.mozilla.org/");
-const URI3 = NetUtil.newURI("http://test3.mozilla.org/");
+const URI1 = "http://test1.mozilla.org/";
+const URI2 = "http://test2.mozilla.org/";
+const URI3 = "http://test3.mozilla.org/";
 
 async function check_keyword(aURI, aKeyword) {
   if (aKeyword)
     aKeyword = aKeyword.toLowerCase();
 
   let bms = [];
   await PlacesUtils.bookmarks.fetch({ url: aURI }, bm => bms.push(bm));
   for (let bm of bms) {
     let itemId = await PlacesUtils.promiseItemId(bm.guid);
     let keyword = PlacesUtils.bookmarks.getKeywordForBookmark(itemId);
     if (keyword && !aKeyword) {
-      throw (`${aURI.spec} should not have a keyword`);
+      throw (`${aURI} should not have a keyword, found keyword "${keyword}"`);
     } else if (aKeyword && keyword == aKeyword) {
       Assert.equal(keyword, aKeyword);
     }
   }
 
   if (aKeyword) {
     let uri = await PlacesUtils.keywords.fetch(aKeyword);
-    Assert.equal(uri.url, aURI.spec);
+    Assert.equal(uri.url, aURI);
     // Check case insensitivity.
     uri = await PlacesUtils.keywords.fetch(aKeyword.toUpperCase());
-    Assert.equal(uri.url, aURI.spec);
+    Assert.equal(uri.url, aURI);
   }
 }
 
 async function check_orphans() {
   let db = await PlacesUtils.promiseDBConnection();
   let rows = await db.executeCached(
     `SELECT id FROM moz_keywords k
      WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = k.place_id)
@@ -89,243 +92,498 @@ add_task(async function test_addBookmark
   registerCleanupFunction(function() {
     Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
   });
 
   await check_keyword(URI1, null);
   let fc = await foreign_count(URI1);
   let observer = expectNotifications();
 
-  let itemId =
-    PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
-                                         URI1,
-                                         PlacesUtils.bookmarks.DEFAULT_INDEX,
-                                         "test");
-
-  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword");
-  let bookmark = await PlacesUtils.bookmarks.fetch({ url: URI1 });
+  let bookmark = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: URI1,
+    title: "test"
+  });
+  await PlacesUtils.keywords.insert({url: URI1, keyword: "keyword"});
+  let itemId = await PlacesUtils.promiseItemId(bookmark.guid);
   observer.check([ { name: "onItemChanged",
                      arguments: [ itemId, "keyword", false, "keyword",
                                   bookmark.lastModified * 1000, bookmark.type,
                                   (await PlacesUtils.promiseItemId(bookmark.parentGuid)),
-                                  bookmark.guid, bookmark.parentGuid,
-                                  bookmark.url.href,
+                                  bookmark.guid, bookmark.parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
                  ]);
-  await PlacesTestUtils.promiseAsyncUpdates();
-
   await check_keyword(URI1, "keyword");
   Assert.equal((await foreign_count(URI1)), fc + 2); // + 1 bookmark + 1 keyword
-
-  await PlacesTestUtils.promiseAsyncUpdates();
   await check_orphans();
 });
 
 add_task(async function test_addBookmarkToURIHavingKeyword() {
   // The uri has already a keyword.
   await check_keyword(URI1, "keyword");
   let fc = await foreign_count(URI1);
 
-  let itemId =
-    PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
-                                         URI1,
-                                         PlacesUtils.bookmarks.DEFAULT_INDEX,
-                                         "test");
+  let bookmark = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+      url: URI1,
+      title: "test"
+    });
   await check_keyword(URI1, "keyword");
   Assert.equal((await foreign_count(URI1)), fc + 1); // + 1 bookmark
-
-  PlacesUtils.bookmarks.removeItem(itemId);
-  await PlacesTestUtils.promiseAsyncUpdates();
+  await PlacesUtils.bookmarks.remove(bookmark);
   await check_orphans();
 });
 
 add_task(async function test_sameKeywordDifferentURI() {
   let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
   Preferences.set("privacy.reduceTimerPrecision", false);
 
   registerCleanupFunction(function() {
     Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
   });
 
   let fc1 = await foreign_count(URI1);
   let fc2 = await foreign_count(URI2);
   let observer = expectNotifications();
 
-  let itemId =
-    PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
-                                         URI2,
-                                         PlacesUtils.bookmarks.DEFAULT_INDEX,
-                                         "test2");
+  let bookmark2 = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: URI2,
+    title: "test2"
+  });
   await check_keyword(URI1, "keyword");
   await check_keyword(URI2, null);
 
-  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "kEyWoRd");
+  await PlacesUtils.keywords.insert({ url: URI2, keyword: "kEyWoRd" });
 
   let bookmark1 = await PlacesUtils.bookmarks.fetch({ url: URI1 });
-  let bookmark2 = await PlacesUtils.bookmarks.fetch({ url: URI2 });
   observer.check([ { name: "onItemChanged",
                      arguments: [ (await PlacesUtils.promiseItemId(bookmark1.guid)),
                                   "keyword", false, "",
                                   bookmark1.lastModified * 1000, bookmark1.type,
                                   (await PlacesUtils.promiseItemId(bookmark1.parentGuid)),
-                                  bookmark1.guid, bookmark1.parentGuid,
-                                  bookmark1.url.href,
+                                  bookmark1.guid, bookmark1.parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
                     { name: "onItemChanged",
-                     arguments: [ itemId, "keyword", false, "keyword",
+                     arguments: [ (await PlacesUtils.promiseItemId(bookmark2.guid)),
+                                  "keyword", false, "keyword",
                                   bookmark2.lastModified * 1000, bookmark2.type,
                                   (await PlacesUtils.promiseItemId(bookmark2.parentGuid)),
-                                  bookmark2.guid, bookmark2.parentGuid,
-                                  bookmark2.url.href,
+                                  bookmark2.guid, bookmark2.parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
                  ]);
-  await PlacesTestUtils.promiseAsyncUpdates();
 
   // The keyword should have been "moved" to the new URI.
   await check_keyword(URI1, null);
   Assert.equal((await foreign_count(URI1)), fc1 - 1); // - 1 keyword
   await check_keyword(URI2, "keyword");
   Assert.equal((await foreign_count(URI2)), fc2 + 2); // + 1 bookmark + 1 keyword
-
-  await PlacesTestUtils.promiseAsyncUpdates();
   await check_orphans();
 });
 
 add_task(async function test_sameURIDifferentKeyword() {
   let fc = await foreign_count(URI2);
   let observer = expectNotifications();
 
-  let itemId =
-    PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
-                                         URI2,
-                                         PlacesUtils.bookmarks.DEFAULT_INDEX,
-                                         "test2");
+  await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: URI2,
+    title: "test2"
+  });
   await check_keyword(URI2, "keyword");
 
-  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword2");
-
+  await PlacesUtils.keywords.insert({ url: URI2, keyword: "keyword2" });
   let bookmarks = [];
-  await PlacesUtils.bookmarks.fetch({ url: URI2 }, bookmark => bookmarks.push(bookmark));
+  await PlacesUtils.bookmarks.fetch({ url: URI2 }, bm => bookmarks.push(bm));
   observer.check([ { name: "onItemChanged",
                      arguments: [ (await PlacesUtils.promiseItemId(bookmarks[0].guid)),
                                   "keyword", false, "keyword2",
                                   bookmarks[0].lastModified * 1000, bookmarks[0].type,
                                   (await PlacesUtils.promiseItemId(bookmarks[0].parentGuid)),
-                                  bookmarks[0].guid, bookmarks[0].parentGuid,
-                                  bookmarks[0].url.href,
+                                  bookmarks[0].guid, bookmarks[0].parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
                     { name: "onItemChanged",
                      arguments: [ (await PlacesUtils.promiseItemId(bookmarks[1].guid)),
                                   "keyword", false, "keyword2",
                                   bookmarks[1].lastModified * 1000, bookmarks[1].type,
                                   (await PlacesUtils.promiseItemId(bookmarks[1].parentGuid)),
-                                  bookmarks[1].guid, bookmarks[1].parentGuid,
-                                  bookmarks[0].url.href,
+                                  bookmarks[1].guid, bookmarks[1].parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
                  ]);
-  await PlacesTestUtils.promiseAsyncUpdates();
-
   await check_keyword(URI2, "keyword2");
   Assert.equal((await foreign_count(URI2)), fc + 1); // + 1 bookmark - 1 keyword + 1 keyword
-
-  await PlacesTestUtils.promiseAsyncUpdates();
   await check_orphans();
 });
 
 add_task(async function test_removeBookmarkWithKeyword() {
   let fc = await foreign_count(URI2);
-  let itemId =
-    PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
-                                         URI2,
-                                         PlacesUtils.bookmarks.DEFAULT_INDEX,
-                                         "test");
+  let bookmark = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: URI2,
+    title: "test"
+  });
 
    // The keyword should not be removed, since there are other bookmarks yet.
-   PlacesUtils.bookmarks.removeItem(itemId);
+   await PlacesUtils.bookmarks.remove(bookmark);
 
   await check_keyword(URI2, "keyword2");
   Assert.equal((await foreign_count(URI2)), fc); // + 1 bookmark - 1 bookmark
-
-  await PlacesTestUtils.promiseAsyncUpdates();
   await check_orphans();
 });
 
 add_task(async function test_unsetKeyword() {
   let fc = await foreign_count(URI2);
   let observer = expectNotifications();
 
-  let itemId =
-    PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
-                                         URI2,
-                                         PlacesUtils.bookmarks.DEFAULT_INDEX,
-                                         "test");
+  await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: URI2,
+    title: "test"
+  });
 
   // The keyword should be removed from any bookmark.
-  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, null);
+  await PlacesUtils.keywords.remove("keyword2");
 
   let bookmarks = [];
   await PlacesUtils.bookmarks.fetch({ url: URI2 }, bookmark => bookmarks.push(bookmark));
-  info(bookmarks.length);
+  Assert.equal(bookmarks.length, 3, "Check number of bookmarks");
   observer.check([ { name: "onItemChanged",
                      arguments: [ (await PlacesUtils.promiseItemId(bookmarks[0].guid)),
                                   "keyword", false, "",
                                   bookmarks[0].lastModified * 1000, bookmarks[0].type,
                                   (await PlacesUtils.promiseItemId(bookmarks[0].parentGuid)),
-                                  bookmarks[0].guid, bookmarks[0].parentGuid,
-                                  bookmarks[0].url.href,
+                                  bookmarks[0].guid, bookmarks[0].parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
                     { name: "onItemChanged",
                      arguments: [ (await PlacesUtils.promiseItemId(bookmarks[1].guid)),
                                   "keyword", false, "",
                                   bookmarks[1].lastModified * 1000, bookmarks[1].type,
                                   (await PlacesUtils.promiseItemId(bookmarks[1].parentGuid)),
-                                  bookmarks[1].guid, bookmarks[1].parentGuid,
-                                  bookmarks[1].url.href,
+                                  bookmarks[1].guid, bookmarks[1].parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
                     { name: "onItemChanged",
                      arguments: [ (await PlacesUtils.promiseItemId(bookmarks[2].guid)),
                                   "keyword", false, "",
                                   bookmarks[2].lastModified * 1000, bookmarks[2].type,
                                   (await PlacesUtils.promiseItemId(bookmarks[2].parentGuid)),
-                                  bookmarks[2].guid, bookmarks[2].parentGuid,
-                                  bookmarks[2].url.href,
+                                  bookmarks[2].guid, bookmarks[2].parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
                  ]);
 
   await check_keyword(URI1, null);
   await check_keyword(URI2, null);
   Assert.equal((await foreign_count(URI2)), fc); // + 1 bookmark - 1 keyword
-
-  await PlacesTestUtils.promiseAsyncUpdates();
   await check_orphans();
 });
 
 add_task(async function test_addRemoveBookmark() {
+  let fc = await foreign_count(URI3);
   let observer = expectNotifications();
 
-  let itemId =
-    PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
-                                         URI3,
-                                         PlacesUtils.bookmarks.DEFAULT_INDEX,
-                                         "test3");
-
-  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword");
-  let bookmark = await PlacesUtils.bookmarks.fetch({ url: URI3 });
-  let parentId = await PlacesUtils.promiseItemId(bookmark.parentGuid);
-  PlacesUtils.bookmarks.removeItem(itemId);
+  let bookmark = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: URI3,
+    title: "test3"
+  });
+  let itemId = (await PlacesUtils.promiseItemId(bookmark.guid));
+  await PlacesUtils.keywords.insert({ url: URI3, keyword: "keyword" });
+  await PlacesUtils.bookmarks.remove(bookmark);
 
   observer.check([ { name: "onItemChanged",
                      arguments: [ itemId,
                                   "keyword", false, "keyword",
                                   bookmark.lastModified * 1000, bookmark.type,
-                                  parentId,
-                                  bookmark.guid, bookmark.parentGuid,
-                                  bookmark.url.href,
+                                  (await PlacesUtils.promiseItemId(bookmark.parentGuid)),
+                                  bookmark.guid, bookmark.parentGuid, "",
                                   Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
                  ]);
 
   await check_keyword(URI3, null);
-  // Don't check the foreign count since the process is async.
-  // The new test_keywords.js in unit is checking this though.
+  Assert.equal((await foreign_count(URI3)), fc); // +- 1 bookmark +- 1 keyword
+  await check_orphans();
+});
+
+add_task(async function test_reassign() {
+  // Should move keywords from old URL to new URL.
+  info("Old URL with keywords; new URL without keywords");
+  {
+    let oldURL = "http://example.com/1/kw";
+    let oldBmk = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.menuGuid,
+      url: oldURL,
+    });
+    await PlacesUtils.keywords.insert({
+      url: oldURL,
+      keyword: "kw1-1",
+      postData: "a=b",
+    });
+    await PlacesUtils.keywords.insert({
+      url: oldURL,
+      keyword: "kw1-2",
+      postData: "c=d",
+    });
+    let oldFC = await foreign_count(oldURL);
+    equal(oldFC, 3);
+
+    let newURL = "http://example.com/2/no-kw";
+    let newBmk = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.menuGuid,
+      url: newURL,
+    });
+    let newFC = await foreign_count(newURL);
+    equal(newFC, 1);
+
+    let observer = expectNotifications();
+    await PlacesUtils.keywords.reassign(oldURL, newURL);
+    observer.check([ { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(oldBmk.guid)),
+                                    "keyword", false, "",
+                                    oldBmk.lastModified * 1000, oldBmk.type,
+                                    (await PlacesUtils.promiseItemId(oldBmk.parentGuid)),
+                                    oldBmk.guid, oldBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                     { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(newBmk.guid)),
+                                    "keyword", false, "",
+                                    newBmk.lastModified * 1000, newBmk.type,
+                                    (await PlacesUtils.promiseItemId(newBmk.parentGuid)),
+                                    newBmk.guid, newBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                     { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(newBmk.guid)),
+                                    "keyword", false, "kw1-1",
+                                    newBmk.lastModified * 1000, newBmk.type,
+                                    (await PlacesUtils.promiseItemId(newBmk.parentGuid)),
+                                    newBmk.guid, newBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                     { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(newBmk.guid)),
+                                    "keyword", false, "kw1-2",
+                                    newBmk.lastModified * 1000, newBmk.type,
+                                    (await PlacesUtils.promiseItemId(newBmk.parentGuid)),
+                                    newBmk.guid, newBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                   ]);
+
+
+    await check_keyword(oldURL, null);
+    await check_keyword(newURL, "kw1-1");
+    await check_keyword(newURL, "kw1-2");
+
+    equal(await foreign_count(oldURL), oldFC - 2); // Removed both keywords.
+    equal(await foreign_count(newURL), newFC + 2); // Added two keywords.
+  }
+
+  // Should not remove any keywords from new URL.
+  info("Old URL without keywords; new URL with keywords");
+  {
+    let oldURL = "http://example.com/3/no-kw";
+    await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.menuGuid,
+      url: oldURL,
+    });
+    let oldFC = await foreign_count(oldURL);
+    equal(oldFC, 1);
+
+    let newURL = "http://example.com/4/kw";
+    await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.menuGuid,
+      url: newURL,
+    });
+    await PlacesUtils.keywords.insert({
+      url: newURL,
+      keyword: "kw4-1",
+    });
+    let newFC = await foreign_count(newURL);
+    equal(newFC, 2);
+
+    let observer = expectNotifications();
+    await PlacesUtils.keywords.reassign(oldURL, newURL);
+    observer.check([]);
+
+    await check_keyword(newURL, "kw4-1");
+
+    equal(await foreign_count(oldURL), oldFC);
+    equal(await foreign_count(newURL), newFC);
+  }
+
+  // Should remove all keywords from new URL, then move keywords from old URL.
+  info("Old URL with keywords; new URL with keywords");
+  {
+    let oldURL = "http://example.com/8/kw";
+    let oldBmk = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.menuGuid,
+      url: oldURL,
+    });
+    await PlacesUtils.keywords.insert({
+      url: oldURL,
+      keyword: "kw8-1",
+      postData: "a=b",
+    });
+    await PlacesUtils.keywords.insert({
+      url: oldURL,
+      keyword: "kw8-2",
+      postData: "c=d",
+    });
+    let oldFC = await foreign_count(oldURL);
+    equal(oldFC, 3);
+
+    let newURL = "http://example.com/9/kw";
+    let newBmk = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.menuGuid,
+      url: newURL,
+    });
+    await PlacesUtils.keywords.insert({
+      url: newURL,
+      keyword: "kw9-1",
+    });
+    let newFC = await foreign_count(newURL);
+    equal(newFC, 2);
+
+    let observer = expectNotifications();
+    await PlacesUtils.keywords.reassign(oldURL, newURL);
+    observer.check([ { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(oldBmk.guid)),
+                                    "keyword", false, "",
+                                    oldBmk.lastModified * 1000, oldBmk.type,
+                                    (await PlacesUtils.promiseItemId(oldBmk.parentGuid)),
+                                    oldBmk.guid, oldBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                     { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(newBmk.guid)),
+                                    "keyword", false, "",
+                                    newBmk.lastModified * 1000, newBmk.type,
+                                    (await PlacesUtils.promiseItemId(newBmk.parentGuid)),
+                                    newBmk.guid, newBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                     { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(newBmk.guid)),
+                                    "keyword", false, "kw8-1",
+                                    newBmk.lastModified * 1000, newBmk.type,
+                                    (await PlacesUtils.promiseItemId(newBmk.parentGuid)),
+                                    newBmk.guid, newBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                     { name: "onItemChanged",
+                       arguments: [ (await PlacesUtils.promiseItemId(newBmk.guid)),
+                                    "keyword", false, "kw8-2",
+                                    newBmk.lastModified * 1000, newBmk.type,
+                                    (await PlacesUtils.promiseItemId(newBmk.parentGuid)),
+                                    newBmk.guid, newBmk.parentGuid, "",
+                                    Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+                   ]);
+
+    await check_keyword(oldURL, null);
+    await check_keyword(newURL, "kw8-1");
+    await check_keyword(newURL, "kw8-2");
+
+    equal(await foreign_count(oldURL), oldFC - 2); // Removed both keywords.
+    equal(await foreign_count(newURL), newFC + 1); // Removed old keyword; added two keywords.
+  }
+
+  // Should do nothing.
+  info("Old URL without keywords; new URL without keywords");
+  {
+    let oldURL = "http://example.com/10/no-kw";
+    let oldFC = await foreign_count(oldURL);
+
+    let newURL = "http://example.com/11/no-kw";
+    let newFC = await foreign_count(newURL);
+
+    let observer = expectNotifications();
+    await PlacesUtils.keywords.reassign(oldURL, newURL);
+    observer.check([]);
+
+    equal(await foreign_count(oldURL), oldFC);
+    equal(await foreign_count(newURL), newFC);
+  }
+
+  await check_orphans();
+});
+
+add_task(async function test_invalidation() {
+  info("Insert bookmarks");
+  let fx = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.menuGuid,
+    title: "Get Firefox!",
+    url: "http://getfirefox.com",
+  });
+  let tb = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+    title: "Get Thunderbird!",
+    url: "http://getthunderbird.com",
+  });
+
+  info("Set keywords for bookmarks");
+  await PlacesUtils.keywords.insert({ url: fx.url, keyword: "fx" });
+  await PlacesUtils.keywords.insert({ url: tb.url, keyword: "tb" });
+
+  info("Invalidate cached keywords");
+  await PlacesUtils.keywords.invalidateCachedKeywords();
+
+  info("Change URL of bookmark with keyword");
+  let promiseNotification = PlacesTestUtils.waitForNotification(
+    "onItemChanged",
+    (id, prop, isAnnoProp, newValue, lastModified, type, parentId, guid) =>
+      guid == fx.guid && prop == "keyword" && newValue == "fx"
+  );
+  await PlacesUtils.bookmarks.update({
+    guid: fx.guid,
+    url: "https://www.mozilla.org/firefox",
+  });
+  await promiseNotification;
+
+  let entriesByKeyword = [];
+  await PlacesUtils.keywords.fetch({ keyword: "fx" }, e => entriesByKeyword.push(e.url.href));
+  deepEqual(entriesByKeyword, ["https://www.mozilla.org/firefox"],
+    "Should return new URL for keyword");
+
+  ok(!(await PlacesUtils.keywords.fetch({ url: "http://getfirefox.com" })),
+    "Should not return keywords for old URL");
+
+  let entiresByURL = [];
+  await PlacesUtils.keywords.fetch({ url: "https://www.mozilla.org/firefox" },
+    e => entiresByURL.push(e.keyword));
+  deepEqual(entiresByURL, ["fx"], "Should return keyword for new URL");
+
+  info("Invalidate cached keywords");
+  await PlacesUtils.keywords.invalidateCachedKeywords();
+
+  info("Remove bookmark with keyword");
+  await PlacesUtils.bookmarks.remove(tb.guid);
+
+  ok(!(await PlacesUtils.keywords.fetch({ url: "http://getthunderbird.com" })),
+    "Should not return keywords for removed bookmark URL");
+
+  ok(!(await PlacesUtils.keywords.fetch({ keyword: "tb" })),
+    "Should not return URL for removed bookmark keyword");
 
   await PlacesTestUtils.promiseAsyncUpdates();
   await check_orphans();
+
+  await PlacesUtils.bookmarks.eraseEverything();
 });
+
+add_task(async function test_eraseAllBookmarks() {
+  info("Insert bookmarks");
+  let fx = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.menuGuid,
+    title: "Get Firefox!",
+    url: "http://getfirefox.com",
+  });
+  let tb = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+    title: "Get Thunderbird!",
+    url: "http://getthunderbird.com",
+  });
+
+  info("Set keywords for bookmarks");
+  await PlacesUtils.keywords.insert({ url: fx.url, keyword: "fx" });
+  await PlacesUtils.keywords.insert({ url: tb.url, keyword: "tb" });
+
+  info("Erase everything");
+  await PlacesUtils.bookmarks.eraseEverything();
+
+  ok(!(await PlacesUtils.keywords.fetch({ keyword: "fx" })),
+    "Should remove Firefox keyword");
+
+  ok(!(await PlacesUtils.keywords.fetch({ keyword: "tb" })),
+    "Should remove Thunderbird keyword");
+});
--- a/toolkit/components/places/tests/legacy/test_bookmarks.js
+++ b/toolkit/components/places/tests/legacy/test_bookmarks.js
@@ -267,28 +267,16 @@ add_task(async function test_bookmarks()
   Assert.equal(bookmarksObserver._itemMovedOldParent, homeFolder);
   Assert.equal(bookmarksObserver._itemMovedOldIndex, 0);
   Assert.equal(bookmarksObserver._itemMovedNewParent, testRoot);
   Assert.equal(bookmarksObserver._itemMovedNewIndex, 3);
 
   // test get folder's index
   let tmpFolder = bs.createFolder(testRoot, "tmp", 2);
 
-  // test setKeywordForBookmark
-  let kwTestItemId = bs.insertBookmark(testRoot, uri("http://keywordtest.com"),
-                                       bs.DEFAULT_INDEX, "");
-  bs.setKeywordForBookmark(kwTestItemId, "bar");
-
-  // test getKeywordForBookmark
-  let k = bs.getKeywordForBookmark(kwTestItemId);
-  Assert.equal("bar", k);
-
-  // test PlacesUtils.keywords.fetch()
-  let u = await PlacesUtils.keywords.fetch("bar");
-  Assert.equal("http://keywordtest.com/", u.url);
   // test removeFolderChildren
   // 1) add/remove each child type (bookmark, separator, folder)
   tmpFolder = bs.createFolder(testRoot, "removeFolderChildren",
                               bs.DEFAULT_INDEX);
   bs.insertBookmark(tmpFolder, uri("http://foo9.com/"), bs.DEFAULT_INDEX, "");
   bs.createFolder(tmpFolder, "subfolder", bs.DEFAULT_INDEX);
   bs.insertSeparator(tmpFolder, bs.DEFAULT_INDEX);
   // 2) confirm that folder has 3 children
@@ -397,17 +385,17 @@ add_task(async function test_bookmarks()
   // insert a bookmark with title ZZZXXXYYY and then search for it.
   // this test confirms that we can find bookmarks that we haven't visited
   // (which are "hidden") and that we can find by title.
   // see bug #369887 for more details
   let newId13 = bs.insertBookmark(testRoot, uri("http://foobarcheese.com/"),
                                   bs.DEFAULT_INDEX, "");
   Assert.equal(bookmarksObserver._itemAddedId, newId13);
   Assert.equal(bookmarksObserver._itemAddedParent, testRoot);
-  Assert.equal(bookmarksObserver._itemAddedIndex, 10);
+  Assert.equal(bookmarksObserver._itemAddedIndex, 9);
 
   // set bookmark title
   bs.setItemTitle(newId13, "ZZZXXXYYY");
   Assert.equal(bookmarksObserver._itemChangedId, newId13);
   Assert.equal(bookmarksObserver._itemChangedProperty, "title");
   Assert.equal(bookmarksObserver._itemChangedValue, "ZZZXXXYYY");
 
   // check if setting an item annotation triggers onItemChanged
--- a/toolkit/components/places/tests/unit/test_keywords.js
+++ b/toolkit/components/places/tests/unit/test_keywords.js
@@ -466,40 +466,16 @@ add_task(async function test_multipleKey
   await check_keyword(false, "http://example.com/", "keyword", "postData1");
   await check_keyword(true, "http://example.com/", "keyword2", "postData1");
 
   await PlacesUtils.keywords.remove("keyword2");
 
   await check_no_orphans();
 });
 
-add_task(async function test_oldKeywordsAPI() {
-  let bookmark = await PlacesUtils.bookmarks.insert({ url: "http://example.com/",
-                                                    parentGuid: PlacesUtils.bookmarks.unfiledGuid });
-  await check_keyword(false, "http://example.com/", "keyword");
-  let itemId = await PlacesUtils.promiseItemId(bookmark.guid);
-
-  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword");
-  await promiseKeyword("keyword", "http://example.com/");
-
-  // Remove the keyword.
-  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "");
-  await promiseKeyword("keyword", null);
-
-  await PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com" });
-  Assert.equal(PlacesUtils.bookmarks.getKeywordForBookmark(itemId), "keyword");
-
-  let entry = await PlacesUtils.keywords.fetch("keyword");
-  Assert.equal(entry.url, "http://example.com/");
-
-  await PlacesUtils.bookmarks.remove(bookmark);
-
-  await check_no_orphans();
-});
-
 add_task(async function test_bookmarkURLChange() {
   let fc1 = await foreign_count("http://example1.com/");
   let fc2 = await foreign_count("http://example2.com/");
   let bookmark = await PlacesUtils.bookmarks.insert({ url: "http://example1.com/",
                                                       parentGuid: PlacesUtils.bookmarks.unfiledGuid });
   await PlacesUtils.keywords.insert({ keyword: "keyword",
                                       url: "http://example1.com/" });
 
--- a/toolkit/components/places/tests/unit/test_sync_utils.js
+++ b/toolkit/components/places/tests/unit/test_sync_utils.js
@@ -894,24 +894,16 @@ add_task(async function test_update_keyw
       keyword: "test4",
     });
     equal(updatedItem.keyword, "test4", "Initial update succeeds");
     let updatedItem2 = await PlacesSyncUtils.bookmarks.update({
       recordId: item2.recordId,
       keyword: "test5",
     });
     equal(updatedItem2.keyword, "test5", "New update succeeds");
-    await PlacesSyncUtils.bookmarks.update({
-      recordId: item2.recordId,
-      url: item.url.href,
-    });
-    updatedItem2 = await PlacesSyncUtils.bookmarks.fetch(item2.recordId);
-    equal(updatedItem2.keyword, "test4", "Item keyword has been updated");
-    updatedItem = await PlacesSyncUtils.bookmarks.fetch(item.recordId);
-    equal(updatedItem.keyword, "test4", "Initial item still has keyword");
   }
 
 
   await PlacesUtils.bookmarks.eraseEverything();
   await PlacesSyncUtils.bookmarks.reset();
 });
 
 add_task(async function test_conflicting_keywords() {
--- a/toolkit/components/places/toolkitplaces.manifest
+++ b/toolkit/components/places/toolkitplaces.manifest
@@ -12,17 +12,16 @@ contract @mozilla.org/autocomplete/searc
 component {705a423f-2f69-42f3-b9fe-1517e0dee56f} nsPlacesExpiration.js
 contract @mozilla.org/places/expiration;1 {705a423f-2f69-42f3-b9fe-1517e0dee56f}
 category places-init-complete nsPlacesExpiration @mozilla.org/places/expiration;1
 
 # PlacesCategoriesStarter.js
 component {803938d5-e26d-4453-bf46-ad4b26e41114} PlacesCategoriesStarter.js
 contract @mozilla.org/places/categoriesStarter;1 {803938d5-e26d-4453-bf46-ad4b26e41114}
 category idle-daily PlacesCategoriesStarter @mozilla.org/places/categoriesStarter;1
-category places-init-complete PlacesCategoriesStarter @mozilla.org/places/categoriesStarter;1
 
 # ColorAnalyzer.js
 component {d056186c-28a0-494e-aacc-9e433772b143} ColorAnalyzer.js
 contract @mozilla.org/places/colorAnalyzer;1 {d056186c-28a0-494e-aacc-9e433772b143}
 
 # UnifiedComplete.js
 component {f964a319-397a-4d21-8be6-5cdd1ee3e3ae} UnifiedComplete.js
 contract @mozilla.org/autocomplete/search;1?name=unifiedcomplete {f964a319-397a-4d21-8be6-5cdd1ee3e3ae}