Bug 1125115 - Write a new keywords pseudo-API in PlacesUtils. r=ttaubert
authorMarco Bonardo <mbonardo@mozilla.com>
Fri, 20 Mar 2015 09:39:25 +0100
changeset 263445 296d8da2660933bf0928647f3625de83da5070bb
parent 263444 687b6a735b3420cfa16cc30b55d7fd447e528fa5
child 263446 8aede8703d12609c3aca797549e39711cf4617ca
push id4718
push userraliiev@mozilla.com
push dateMon, 11 May 2015 18:39:53 +0000
treeherdermozilla-beta@c20c4ef55f08 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersttaubert
bugs1125115
milestone39.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1125115 - Write a new keywords pseudo-API in PlacesUtils. r=ttaubert
browser/base/content/test/general/browser_getshortcutoruri.js
browser/base/content/test/general/browser_keywordBookmarklets.js
browser/components/nsBrowserGlue.js
browser/components/places/tests/unit/head_bookmarks.js
browser/components/places/tests/unit/test_browserGlue_corrupt.js
browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js
browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js
browser/components/places/tests/unit/test_browserGlue_migrate.js
browser/components/places/tests/unit/test_browserGlue_prefs.js
browser/components/places/tests/unit/test_browserGlue_restore.js
browser/components/places/tests/unit/test_browserGlue_smartBookmarks.js
toolkit/components/places/Bookmarks.jsm
toolkit/components/places/PlacesCategoriesStarter.js
toolkit/components/places/PlacesUtils.jsm
toolkit/components/places/nsNavBookmarks.cpp
toolkit/components/places/tests/bookmarks/test_bookmarks.js
toolkit/components/places/tests/bookmarks/test_keywords.js
toolkit/components/places/tests/unifiedcomplete/test_keyword_search.js
toolkit/components/places/tests/unit/test_421180.js
toolkit/components/places/tests/unit/test_keywords.js
toolkit/components/places/tests/unit/test_placesTxn.js
toolkit/components/places/tests/unit/xpcshell.ini
--- a/browser/base/content/test/general/browser_getshortcutoruri.js
+++ b/browser/base/content/test/general/browser_getshortcutoruri.js
@@ -86,62 +86,59 @@ var testData = [
 
   // Test using a non-bmKeywordData object, to test the behavior of
   // getShortcutOrURIAndPostData for non-keywords (setupKeywords only adds keywords for
   // bmKeywordData objects)
   [{keyword: "http://gavinsharp.com"},
    new keywordResult(null, null, true)]
 ];
 
-function test() {
-  waitForExplicitFinish();
+add_task(function* test_getshortcutoruri() {
+  yield setupKeywords();
 
-  setupKeywords();
-
-  Task.spawn(function() {
-    for each (var item in testData) {
-      let [data, result] = item;
+  for (let item of testData) {
+    let [data, result] = item;
 
-      let query = data.keyword;
-      if (data.searchWord)
-        query += " " + data.searchWord;
-      let returnedData = yield new Promise(
-        resolve => getShortcutOrURIAndPostData(query, resolve));
-      // null result.url means we should expect the same query we sent in
-      let expected = result.url || query;
-      is(returnedData.url, expected, "got correct URL for " + data.keyword);
-      is(getPostDataString(returnedData.postData), result.postData, "got correct postData for " + data.keyword);
-      is(returnedData.mayInheritPrincipal, !result.isUnsafe, "got correct mayInheritPrincipal for " + data.keyword);
-    }
-    cleanupKeywords();
-  }).then(finish);
-}
+    let query = data.keyword;
+    if (data.searchWord)
+      query += " " + data.searchWord;
+    let returnedData = yield new Promise(
+      resolve => getShortcutOrURIAndPostData(query, resolve));
+    // null result.url means we should expect the same query we sent in
+    let expected = result.url || query;
+    is(returnedData.url, expected, "got correct URL for " + data.keyword);
+    is(getPostDataString(returnedData.postData), result.postData, "got correct postData for " + data.keyword);
+    is(returnedData.mayInheritPrincipal, !result.isUnsafe, "got correct mayInheritPrincipal for " + data.keyword);
+  }
 
-var gBMFolder = null;
-var gAddedEngines = [];
-function setupKeywords() {
-  gBMFolder = Application.bookmarks.menu.addFolder("keyword-test");
-  for each (var item in testData) {
-    var data = item[0];
+  yield cleanupKeywords();
+});
+
+let folder = null;
+let gAddedEngines = [];
+
+function* setupKeywords() {
+  folder = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+                                                type: PlacesUtils.bookmarks.TYPE_FOLDER,
+                                                title: "keyword-test" });
+  for (let item of testData) {
+    let data = item[0];
     if (data instanceof bmKeywordData) {
-      var bm = gBMFolder.addBookmark(data.keyword, data.uri);
-      bm.keyword = data.keyword;
-      if (data.postData)
-        bm.annotations.set("bookmarkProperties/POSTData", data.postData, Ci.nsIAnnotationService.EXPIRE_SESSION);
+      yield PlacesUtils.bookmarks.insert({ url: data.uri, parentGuid: folder.guid });
+      yield PlacesUtils.keywords.insert({ keyword: data.keyword, url: data.uri.spec, postData: data.postData });
     }
 
     if (data instanceof searchKeywordData) {
       Services.search.addEngineWithDetails(data.keyword, "", data.keyword, "", data.method, data.uri.spec);
-      var addedEngine = Services.search.getEngineByName(data.keyword);
+      let addedEngine = Services.search.getEngineByName(data.keyword);
       if (data.postData) {
-        var [paramName, paramValue] = data.postData.split("=");
+        let [paramName, paramValue] = data.postData.split("=");
         addedEngine.addParam(paramName, paramValue, null);
       }
-
       gAddedEngines.push(addedEngine);
     }
   }
 }
 
-function cleanupKeywords() {
-  gBMFolder.remove();
+function* cleanupKeywords() {
+  PlacesUtils.bookmarks.remove(folder);
   gAddedEngines.map(Services.search.removeEngine);
 }
--- a/browser/base/content/test/general/browser_keywordBookmarklets.js
+++ b/browser/base/content/test/general/browser_keywordBookmarklets.js
@@ -1,38 +1,34 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-function test() {
-  waitForExplicitFinish();
+"use strict"
 
-  let bmFolder = Application.bookmarks.menu.addFolder("keyword-test");
+add_task(function* test_keyword_bookmarklet() {
+  let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+                                                title: "bookmarklet",
+                                                url: "javascript:1;" });
   let tab = gBrowser.selectedTab = gBrowser.addTab();
+  registerCleanupFunction (function* () {
+    gBrowser.removeTab(tab);
+    yield PlacesUtils.bookmarks.remove(bm);
+  });
+  yield promisePageShow();
+  let originalPrincipal = gBrowser.contentPrincipal;
 
-  registerCleanupFunction (function () {
-    bmFolder.remove();
-    gBrowser.removeTab(tab);
-  });
+  yield PlacesUtils.keywords.insert({ keyword: "bm", url: "javascript:1;" })
 
-  let bm = bmFolder.addBookmark("bookmarklet", makeURI("javascript:1;"));
-  bm.keyword = "bm";
+  // Enter bookmarklet keyword in the URL bar
+  gURLBar.value = "bm";
+  gURLBar.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {});
 
-  addPageShowListener(function () {
-    let originalPrincipal = gBrowser.contentPrincipal;
+  yield promisePageShow();
 
-    // Enter bookmarklet keyword in the URL bar
-    gURLBar.value = "bm";
-    gURLBar.focus();
-    EventUtils.synthesizeKey("VK_RETURN", {});
+  ok(gBrowser.contentPrincipal.equals(originalPrincipal), "javascript bookmarklet should inherit principal");
+});
 
-    addPageShowListener(function () {
-      ok(gBrowser.contentPrincipal.equals(originalPrincipal), "javascript bookmarklet should inherit principal");
-      finish();
+function* promisePageShow() {
+  return new Promise(resolve => {
+    gBrowser.selectedBrowser.addEventListener("pageshow", function listen() {
+      gBrowser.selectedBrowser.removeEventListener("pageshow", listen);
+      resolve();
     });
   });
 }
-
-function addPageShowListener(func) {
-  gBrowser.selectedBrowser.addEventListener("pageshow", function loadListener() {
-    gBrowser.selectedBrowser.removeEventListener("pageshow", loadListener, false);
-    func();
-  });
-}
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -1424,17 +1424,17 @@ BrowserGlue.prototype = {
     if (autoExportHTML) {
       // Sqlite.jsm and Places shutdown happen at profile-before-change, thus,
       // to be on the safe side, this should run earlier.
       AsyncShutdown.profileChangeTeardown.addBlocker(
         "Places: export bookmarks.html",
         () => BookmarkHTMLUtils.exportToFile(BookmarkHTMLUtils.defaultPath));
     }
 
-    Task.spawn(function() {
+    Task.spawn(function* () {
       // Check if Safe Mode or the user has required to restore bookmarks from
       // default profile's bookmarks.html
       let restoreDefaultBookmarks = false;
       try {
         restoreDefaultBookmarks =
           Services.prefs.getBoolPref("browser.bookmarks.restore_default_bookmarks");
         if (restoreDefaultBookmarks) {
           // Ensure that we already have a bookmarks backup for today.
@@ -1500,33 +1500,31 @@ BrowserGlue.prototype = {
         }
         else if (yield OS.File.exists(BookmarkHTMLUtils.defaultPath)) {
           bookmarksUrl = OS.Path.toFileURI(BookmarkHTMLUtils.defaultPath);
         }
 
         if (bookmarksUrl) {
           // Import from bookmarks.html file.
           try {
-            BookmarkHTMLUtils.importFromURL(bookmarksUrl, true).then(null,
-              function onFailure() {
-                Cu.reportError("Bookmarks.html file could be corrupt.");
-              }
-            ).then(
-              function onComplete() {
-                // Now apply distribution customized bookmarks.
-                // This should always run after Places initialization.
-                this._distributionCustomizer.applyBookmarks();
-                // Ensure that smart bookmarks are created once the operation is
-                // complete.
-                this.ensurePlacesDefaultQueriesInitialized();
-              }.bind(this)
-            );
-          } catch (err) {
-            Cu.reportError("Bookmarks.html file could be corrupt. " + err);
+            yield BookmarkHTMLUtils.importFromURL(bookmarksUrl, true);
+          } catch (e) {
+            Cu.reportError("Bookmarks.html file could be corrupt. " + e);
           }
+          try {
+            // Now apply distribution customized bookmarks.
+            // This should always run after Places initialization.
+            this._distributionCustomizer.applyBookmarks();
+            // Ensure that smart bookmarks are created once the operation is
+            // complete.
+            this.ensurePlacesDefaultQueriesInitialized();
+          } catch (e) {
+            Cu.reportError(e);
+          }
+
         }
         else {
           Cu.reportError("Unable to find bookmarks.html file.");
         }
 
         // Reset preferences, so we won't try to import again at next run
         if (importBookmarksHTML)
           Services.prefs.setBoolPref("browser.places.importBookmarksHTML", false);
--- a/browser/components/places/tests/unit/head_bookmarks.js
+++ b/browser/components/places/tests/unit/head_bookmarks.js
@@ -73,32 +73,16 @@ const SMART_BOOKMARKS_ANNO = "Places/Sma
 
 function checkItemHasAnnotation(guid, name) {
   return PlacesUtils.promiseItemId(guid).then(id => {
     let hasAnnotation = PlacesUtils.annotations.itemHasAnnotation(id, name);
     Assert.ok(hasAnnotation, `Expected annotation ${name}`);
   });
 }
 
-function waitForImportAndSmartBookmarks() {
-  return Promise.all([
-    promiseTopicObserved("bookmarks-restore-success"),
-    PlacesTestUtils.promiseAsyncUpdates()
-  ]);
-}
-
-function promiseEndUpdateBatch() {
-  return new Promise(resolve => {
-    PlacesUtils.bookmarks.addObserver({
-      __proto__: NavBookmarkObserver.prototype,
-      onEndUpdateBatch: resolve
-    }, false);
-  });
-}
-
 let createCorruptDB = Task.async(function* () {
   let dbPath = OS.Path.join(OS.Constants.Path.profileDir, "places.sqlite");
   yield OS.File.remove(dbPath);
 
   // Create a corrupt database.
   let dir = yield OS.File.getCurrentDirectory();
   let src = OS.Path.join(dir, "corruptDB.sqlite");
   yield OS.File.copy(src, dbPath);
--- a/browser/components/places/tests/unit/test_browserGlue_corrupt.js
+++ b/browser/components/places/tests/unit/test_browserGlue_corrupt.js
@@ -36,17 +36,17 @@ add_task(function* test_main() {
 
   // Check the database was corrupt.
   // nsBrowserGlue uses databaseStatus to manage initialization.
   Assert.equal(PlacesUtils.history.databaseStatus,
                PlacesUtils.history.DATABASE_STATUS_CORRUPT);
 
   // The test will continue once restore has finished and smart bookmarks
   // have been created.
-  yield promiseEndUpdateBatch();
+  yield promiseTopicObserved("places-browser-init-complete");
 
   let bm = yield PlacesUtils.bookmarks.fetch({
     parentGuid: PlacesUtils.bookmarks.toolbarGuid,
     index: 0
   });
   yield checkItemHasAnnotation(bm.guid, SMART_BOOKMARKS_ANNO);
 
   // Check that JSON backup has been restored.
--- a/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js
+++ b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js
@@ -30,17 +30,17 @@ add_task(function* () {
 
   // Check the database was corrupt.
   // nsBrowserGlue uses databaseStatus to manage initialization.
   Assert.equal(PlacesUtils.history.databaseStatus,
                PlacesUtils.history.DATABASE_STATUS_CORRUPT);
 
   // The test will continue once import has finished and smart bookmarks
   // have been created.
-  yield promiseEndUpdateBatch();
+  yield promiseTopicObserved("places-browser-init-complete");
 
   let bm = yield PlacesUtils.bookmarks.fetch({
     parentGuid: PlacesUtils.bookmarks.toolbarGuid,
     index: 0
   });
   yield checkItemHasAnnotation(bm.guid, SMART_BOOKMARKS_ANNO);
 
   // Check that bookmarks html has been restored.
--- a/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js
+++ b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js
@@ -28,17 +28,17 @@ add_task(function* () {
 
   // Check the database was corrupt.
   // nsBrowserGlue uses databaseStatus to manage initialization.
   Assert.equal(PlacesUtils.history.databaseStatus,
                PlacesUtils.history.DATABASE_STATUS_CORRUPT);
 
   // The test will continue once import has finished and smart bookmarks
   // have been created.
-  yield promiseEndUpdateBatch();
+  yield promiseTopicObserved("places-browser-init-complete");
 
   let bm = yield PlacesUtils.bookmarks.fetch({
     parentGuid: PlacesUtils.bookmarks.toolbarGuid,
     index: 0
   });
   yield checkItemHasAnnotation(bm.guid, SMART_BOOKMARKS_ANNO);
 
   // Check that default bookmarks have been restored.
--- a/browser/components/places/tests/unit/test_browserGlue_migrate.js
+++ b/browser/components/places/tests/unit/test_browserGlue_migrate.js
@@ -35,17 +35,17 @@ add_task(function* test_migrate_bookmark
   yield PlacesUtils.bookmarks.insert({
     parentGuid: PlacesUtils.bookmarks.menuGuid,
     index: PlacesUtils.bookmarks.DEFAULT_INDEX,
     type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
     url: "http://mozilla.org/",
     title: "migrated"
   });
 
-  let promise = promiseEndUpdateBatch();
+  let promise = promiseTopicObserved("places-browser-init-complete");
   bg.observe(null, "initial-migration-did-import-default-bookmarks", null);
   yield promise;
 
   let bm = yield PlacesUtils.bookmarks.fetch({
     parentGuid: PlacesUtils.bookmarks.toolbarGuid,
     index: 0
   });
   yield checkItemHasAnnotation(bm.guid, SMART_BOOKMARKS_ANNO);
--- a/browser/components/places/tests/unit/test_browserGlue_prefs.js
+++ b/browser/components/places/tests/unit/test_browserGlue_prefs.js
@@ -33,21 +33,19 @@ do_register_cleanup(function () {
   remove_bookmarks_html();
   remove_all_JSON_backups();
 
   return PlacesUtils.bookmarks.eraseEverything();
 });
 
 function simulatePlacesInit() {
   do_print("Simulate Places init");
-  let promise = waitForImportAndSmartBookmarks();
-
   // Force nsBrowserGlue::_initPlaces().
   bg.observe(null, TOPIC_BROWSERGLUE_TEST, TOPICDATA_FORCE_PLACES_INIT);
-  return promise;
+  return promiseTopicObserved("places-browser-init-complete");
 }
 
 add_task(function* test_checkPreferences() {
   // Initialize Places through the History Service and check that a new
   // database has been created.
   Assert.equal(PlacesUtils.history.databaseStatus,
                PlacesUtils.history.DATABASE_STATUS_CREATE);
 
--- a/browser/components/places/tests/unit/test_browserGlue_restore.js
+++ b/browser/components/places/tests/unit/test_browserGlue_restore.js
@@ -39,17 +39,17 @@ add_task(function* test_main() {
            getService(Ci.nsINavHistoryService);
 
   // Check a new database has been created.
   // nsBrowserGlue uses databaseStatus to manage initialization.
   Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CREATE);
 
   // The test will continue once restore has finished and smart bookmarks
   // have been created.
-  yield promiseEndUpdateBatch();
+  yield promiseTopicObserved("places-browser-init-complete");
 
   let bm = yield PlacesUtils.bookmarks.fetch({
     parentGuid: PlacesUtils.bookmarks.toolbarGuid,
     index: 0
   });
   yield checkItemHasAnnotation(bm.guid, SMART_BOOKMARKS_ANNO);
 
   // Check that JSON backup has been restored.
--- a/browser/components/places/tests/unit/test_browserGlue_smartBookmarks.js
+++ b/browser/components/places/tests/unit/test_browserGlue_smartBookmarks.js
@@ -65,18 +65,16 @@ add_task(function* setup() {
 
   // Wait for Places init notification.
   yield promiseTopicObserved("places-browser-init-complete");
 
   // Ensure preferences status.
   Assert.ok(!Services.prefs.getBoolPref(PREF_AUTO_EXPORT_HTML));
   Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS));
   Assert.throws(() => Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML));
-
-  yield waitForImportAndSmartBookmarks();
 });
 
 add_task(function* test_version_0() {
   do_print("All smart bookmarks are created if smart bookmarks version is 0.");
 
   // Sanity check: we should have default bookmark.
   Assert.ok(yield PlacesUtils.bookmarks.fetch({
     parentGuid: PlacesUtils.bookmarks.toolbarGuid,
--- a/toolkit/components/places/Bookmarks.jsm
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -253,17 +253,17 @@ let Bookmarks = Object.freeze({
       updateInfo = validateBookmarkObject(updateInfo,
         { url: { validIf: () => item.type == this.TYPE_BOOKMARK }
         , title: { validIf: () => [ this.TYPE_BOOKMARK
                                   , this.TYPE_FOLDER ].indexOf(item.type) != -1 }
         , lastModified: { defaultValue: new Date()
                         , validIf: b => b.lastModified >= item.dateAdded }
         });
 
-      let db = yield DBConnPromised;
+      let db = yield PlacesUtils.promiseWrappedConnection();
       let parent;
       if (updateInfo.hasOwnProperty("parentGuid")) {
         if (item.type == this.TYPE_FOLDER) {
           // Make sure we are not moving a folder into itself or one of its
           // descendants.
           let rows = yield db.executeCached(
             `WITH RECURSIVE
              descendants(did) AS (
@@ -421,17 +421,17 @@ let Bookmarks = Object.freeze({
    * Removes ALL bookmarks, resetting the bookmarks storage to an empty tree.
    *
    * Note that roots are preserved, only their children will be removed.
    *
    * @return {Promise} resolved when the removal is complete.
    * @resolves once the removal is complete.
    */
   eraseEverything: Task.async(function* () {
-    let db = yield DBConnPromised;
+    let db = yield PlacesUtils.promiseWrappedConnection();
     yield db.executeTransaction(function* () {
       const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid];
       yield removeFoldersContents(db, folderGuids);
       const time = toPRTime(new Date());
       for (let folderGuid of folderGuids) {
         yield db.executeCached(
           `UPDATE moz_bookmarks SET lastModified = :time
            WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
@@ -665,39 +665,21 @@ let Bookmarks = Object.freeze({
 function notify(observers, notification, args) {
   for (let observer of observers) {
     try {
       observer[notification](...args);
     } catch (ex) {}
   }
 }
 
-XPCOMUtils.defineLazyGetter(this, "DBConnPromised",
-  () => new Promise((resolve, reject) => {
-    Sqlite.wrapStorageConnection({ connection: PlacesUtils.history.DBConnection } )
-          .then(db => {
-      try {
-        Sqlite.shutdown.addBlocker("Places Bookmarks.jsm wrapper closing",
-                                   db.close.bind(db));
-      }
-      catch (ex) {
-        // It's too late to block shutdown, just close the connection.
-        db.close();
-        reject(ex);
-      }
-      resolve(db);
-    });
-  })
-);
-
 ////////////////////////////////////////////////////////////////////////////////
 // Update implementation.
 
 function* updateBookmark(info, item, newParent) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
 
   let tuples = new Map();
   if (info.hasOwnProperty("lastModified"))
     tuples.set("lastModified", { value: toPRTime(info.lastModified) });
   if (info.hasOwnProperty("title"))
     tuples.set("title", { value: info.title });
 
   yield db.executeTransaction(function* () {
@@ -774,17 +756,17 @@ function* updateBookmark(info, item, new
 
   return updatedItem;
 }
 
 ////////////////////////////////////////////////////////////////////////////////
 // Insert implementation.
 
 function* insertBookmark(item, parent) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
 
   // If a guid was not provided, generate one, so we won't need to fetch the
   // bookmark just after having created it.
   if (!item.hasOwnProperty("guid"))
     item.guid = (yield db.executeCached("SELECT GENERATE_GUID() AS guid"))[0].getResultByName("guid");
 
   yield db.executeTransaction(function* transaction() {
     if (item.type == Bookmarks.TYPE_BOOKMARK) {
@@ -829,17 +811,17 @@ function* insertBookmark(item, parent) {
     delete item.title;
   return item;
 }
 
 ////////////////////////////////////////////////////////////////////////////////
 // Fetch implementation.
 
 function* fetchBookmark(info) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
 
   let rows = yield db.executeCached(
     `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
             b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
             b.id AS _id, b.parent AS _parentId,
             (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
             p.parent AS _grandParentId
      FROM moz_bookmarks b
@@ -847,17 +829,17 @@ function* fetchBookmark(info) {
      LEFT JOIN moz_places h ON h.id = b.fk
      WHERE b.guid = :guid
     `, { guid: info.guid });
 
   return rows.length ? rowsToItemsArray(rows)[0] : null;
 }
 
 function* fetchBookmarkByPosition(info) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
   let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index;
 
   let rows = yield db.executeCached(
     `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
             b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
             b.id AS _id, b.parent AS _parentId,
             (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
             p.parent AS _grandParentId
@@ -869,17 +851,17 @@ function* fetchBookmarkByPosition(info) 
                                       FROM moz_bookmarks
                                       WHERE parent = p.id))
     `, { parentGuid: info.parentGuid, index });
 
   return rows.length ? rowsToItemsArray(rows)[0] : null;
 }
 
 function* fetchBookmarksByURL(info) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
 
   let rows = yield db.executeCached(
     `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
             b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
             b.id AS _id, b.parent AS _parentId,
             (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
             p.parent AS _grandParentId
      FROM moz_bookmarks b
@@ -890,17 +872,17 @@ function* fetchBookmarksByURL(info) {
      ORDER BY b.lastModified DESC
     `, { url: info.url.href,
          tags_folder: PlacesUtils.tagsFolderId });
 
   return rows.length ? rowsToItemsArray(rows) : null;
 }
 
 function* fetchBookmarksByParent(info) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
 
   let rows = yield db.executeCached(
     `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
             b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
             b.id AS _id, b.parent AS _parentId,
             (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
             p.parent AS _grandParentId
      FROM moz_bookmarks b
@@ -912,17 +894,17 @@ function* fetchBookmarksByParent(info) {
 
   return rowsToItemsArray(rows);
 }
 
 ////////////////////////////////////////////////////////////////////////////////
 // Remove implementation.
 
 function* removeBookmark(item) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
 
   let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
 
   yield db.executeTransaction(function* transaction() {
     // If it's a folder, remove its contents first.
     if (item.type == Bookmarks.TYPE_FOLDER)
       yield removeFoldersContents(db, [item.guid]);
 
@@ -955,17 +937,17 @@ function* removeBookmark(item) {
 
   return item;
 }
 
 ////////////////////////////////////////////////////////////////////////////////
 // Reorder implementation.
 
 function* reorderChildren(parent, orderedChildrenGuids) {
-  let db = yield DBConnPromised;
+  let db = yield PlacesUtils.promiseWrappedConnection();
 
   return db.executeTransaction(function* () {
     // Select all of the direct children for the given parent.
     let children = yield fetchBookmarksByParent({ parentGuid: parent.guid });
     if (!children.length)
       return;
 
     // Reorder the children array according to the specified order, provided
--- a/toolkit/components/places/PlacesCategoriesStarter.js
+++ b/toolkit/components/places/PlacesCategoriesStarter.js
@@ -31,32 +31,35 @@ XPCOMUtils.defineLazyModuleGetter(this, 
  * certain categories are invoked.
  */
 function PlacesCategoriesStarter()
 {
   Services.obs.addObserver(this, TOPIC_GATHER_TELEMETRY, false);
   Services.obs.addObserver(this, PlacesUtils.TOPIC_SHUTDOWN, false);
 
   // nsINavBookmarkObserver implementation.
-  let notify = (function () {
+  let notify = () => {
     if (!this._notifiedBookmarksSvcReady) {
+      // TODO (bug 1145424): for whatever reason, even if we remove this
+      // component from the category (and thus from the category cache we use
+      // to notify), we keep being notified.
+      this._notifiedBookmarksSvcReady = true;
       // For perf reasons unregister from the category, since no further
       // notifications are needed.
       Cc["@mozilla.org/categorymanager;1"]
         .getService(Ci.nsICategoryManager)
-        .deleteCategoryEntry("bookmarks-observer", this, false);
+        .deleteCategoryEntry("bookmark-observers", "PlacesCategoriesStarter", false);
       // Directly notify PlacesUtils, to ensure it catches the notification.
       PlacesUtils.observe(null, "bookmarks-service-ready", null);
     }
-  }).bind(this);
+  };
+
   [ "onItemAdded", "onItemRemoved", "onItemChanged", "onBeginUpdateBatch",
-    "onEndUpdateBatch", "onItemVisited",
-    "onItemMoved" ].forEach(function(aMethod) {
-      this[aMethod] = notify;
-    }, this);
+    "onEndUpdateBatch", "onItemVisited", "onItemMoved"
+  ].forEach(aMethod => this[aMethod] = notify);
 }
 
 PlacesCategoriesStarter.prototype = {
   //////////////////////////////////////////////////////////////////////////////
   //// nsIObserver
 
   observe: function PCS_observe(aSubject, aTopic, aData)
   {
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -22,16 +22,18 @@ this.EXPORTED_SYMBOLS = [
 , "PlacesEditItemLastModifiedTransaction"
 , "PlacesSortFolderByNameTransaction"
 , "PlacesTagURITransaction"
 , "PlacesUntagURITransaction"
 ];
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 
+Cu.importGlobalProperties(["URL"]);
+
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
@@ -68,16 +70,65 @@ function QI_node(aNode, aIID) {
   }
   catch (e) {
   }
   return result;
 }
 function asContainer(aNode) QI_node(aNode, Ci.nsINavHistoryContainerResultNode);
 function asQuery(aNode) QI_node(aNode, Ci.nsINavHistoryQueryResultNode);
 
+/**
+ * Sends a bookmarks notification through the given observers.
+ *
+ * @param observers
+ *        array of nsINavBookmarkObserver objects.
+ * @param notification
+ *        the notification name.
+ * @param args
+ *        array of arguments to pass to the notification.
+ */
+function notify(observers, notification, args) {
+  for (let observer of observers) {
+    try {
+      observer[notification](...args);
+    } catch (ex) {}
+  }
+}
+
+/**
+ * Sends a keyword change notification.
+ *
+ * @param url
+ *        the url to notify about.
+ * @param keyword
+ *        The keyword to notify, or empty string if a keyword was removed.
+ */
+function* notifyKeywordChange(url, keyword) {
+  // Notify bookmarks about the removal.
+  let bookmarks = [];
+  yield 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 = yield PlacesUtils.promiseItemId(bookmark.guid);
+    bookmark.parentId = yield 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
+                                       ]);
+  }
+  gIgnoreKeywordNotifications = false;
+}
+
 this.PlacesUtils = {
   // Place entries that are containers, e.g. bookmark folders or queries.
   TYPE_X_MOZ_PLACE_CONTAINER: "text/x-moz-place-container",
   // Place entries that are bookmark separators.
   TYPE_X_MOZ_PLACE_SEPARATOR: "text/x-moz-place-separator",
   // Place entries that are not containers or separators
   TYPE_X_MOZ_PLACE: "text/x-moz-place",
   // Place entries in shortcut url format (url\ntitle)
@@ -239,16 +290,21 @@ this.PlacesUtils = {
         }
         break;
       case "bookmarks-service-ready":
         this._bookmarksServiceReady = true;
         while (this._bookmarksServiceObserversQueue.length > 0) {
           let observerInfo = this._bookmarksServiceObserversQueue.shift();
           this.bookmarks.addObserver(observerInfo.observer, observerInfo.weak);
         }
+
+        // Initialize the keywords cache to start observing bookmarks
+        // notifications.  This is needed as far as we support both the old and
+        // the new bookmarking APIs at the same time.
+        gKeywordsCachePromise.catch(Cu.reportError);
         break;
     }
   },
 
   onPageAnnotationSet: function() {},
   onPageAnnotationRemoved: function() {},
 
 
@@ -805,38 +861,53 @@ this.PlacesUtils = {
            aItemId == PlacesUtils.tagsFolderId ||
            aItemId == PlacesUtils.placesRootId;
   },
 
   /**
    * Set the POST data associated with a bookmark, if any.
    * Used by POST keywords.
    *   @param aBookmarkId
-   *   @returns string of POST data
    */
   setPostDataForBookmark(aBookmarkId, aPostData) {
+    if (!aPostData)
+      throw new Error("Must provide valid POST data");
     // For now we don't have a unified API to create a keyword with postData,
     // thus here we can just try to complete a keyword that should already exist
     // without any post data.
-    let nullPostDataFragment = aPostData ? "AND post_data ISNULL" : "";
     let stmt = PlacesUtils.history.DBConnection.createStatement(
       `UPDATE moz_keywords SET post_data = :post_data
        WHERE id = (SELECT k.id FROM moz_keywords k
                    JOIN moz_bookmarks b ON b.fk = k.place_id
                    WHERE b.id = :item_id
-                   ${nullPostDataFragment}
+                   AND post_data ISNULL
                    LIMIT 1)`);
     stmt.params.item_id = aBookmarkId;
     stmt.params.post_data = aPostData;
     try {
       stmt.execute();
     }
     finally {
       stmt.finalize();
     }
+
+    // Update the cache.
+    return Task.spawn(function* () {
+      let guid = yield PlacesUtils.promiseItemGuid(aBookmarkId);
+      let bm = yield PlacesUtils.bookmarks.fetch(guid);
+
+      // Fetch keywords for this href.
+      let cache = yield gKeywordsCachePromise;
+      for (let [ keyword, entry ] of cache) {
+        // Set the POST data on keywords not having it.
+        if (entry.url.href == bm.url.href && !entry.postData) {
+          entry.postData = aPostData;
+        }
+      }
+    }).catch(Cu.reportError);
   },
 
   /**
    * Get the POST data associated with a bookmark, if any.
    * @param aBookmarkId
    * @returns string of POST data if set for aBookmarkId. null otherwise.
    */
   getPostDataForBookmark(aBookmarkId) {
@@ -1252,16 +1323,24 @@ this.PlacesUtils = {
    * This is intended to be used mostly internally, and by other Places modules.
    * Outside the Places component, it should be used only as a last resort.
    * Keep in mind the Places DB schema is by no means frozen or even stable.
    * Your custom queries can - and will - break overtime.
    */
   promiseDBConnection: () => gAsyncDBConnPromised,
 
   /**
+   * Gets a Sqlite.jsm wrapped connection to the Places database.
+   * This is intended to be used mostly internally, and by other Places modules.
+   * Keep in mind the Places DB schema is by no means frozen or even stable.
+   * Your custom queries can - and will - break overtime.
+   */
+  promiseWrappedConnection: () => gAsyncDBWrapperPromised,
+
+  /**
    * Given a uri returns list of itemIds associated to it.
    *
    * @param aURI
    *        nsIURI or spec of the page.
    * @param aCallback
    *        Function to be called when done.
    *        The function will receive an array of itemIds associated to aURI and
    *        aURI itself.
@@ -1837,16 +1916,18 @@ 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", () => Keywords);
+
 XPCOMUtils.defineLazyGetter(PlacesUtils, "transactionManager", function() {
   let tm = Cc["@mozilla.org/transactionmanager;1"].
            createInstance(Ci.nsITransactionManager);
   tm.AddListener(PlacesUtils);
   this.registerShutdownFunction(function () {
     // Clear all references to local transactions in the transaction manager,
     // this prevents from leaking it.
     this.transactionManager.RemoveListener(this);
@@ -1876,34 +1957,276 @@ XPCOMUtils.defineLazyGetter(PlacesUtils,
 
 XPCOMUtils.defineLazyGetter(this, "bundle", function() {
   const PLACES_STRING_BUNDLE_URI = "chrome://places/locale/places.properties";
   return Cc["@mozilla.org/intl/stringbundle;1"].
          getService(Ci.nsIStringBundleService).
          createBundle(PLACES_STRING_BUNDLE_URI);
 });
 
-XPCOMUtils.defineLazyGetter(this, "gAsyncDBConnPromised", () => {
-  let connPromised = Sqlite.cloneStorageConnection({
-    connection: PlacesUtils.history.DBConnection,
-    readOnly:   true });
-  connPromised.then(conn => {
-    try {
-      Sqlite.shutdown.addBlocker("Places DB readonly connection closing",
-                                 conn.close.bind(conn));
+XPCOMUtils.defineLazyGetter(this, "gAsyncDBConnPromised",
+  () => new Promise((resolve) => {
+    Sqlite.cloneStorageConnection({
+      connection: PlacesUtils.history.DBConnection,
+      readOnly:   true
+    }).then(conn => {
+      try {
+        Sqlite.shutdown.addBlocker(
+          "PlacesUtils read-only connection closing",
+          conn.close.bind(conn));
+      } catch(ex) {
+        // It's too late to block shutdown, just close the connection.
+        conn.close();
+        throw ex;
+      }
+      resolve(conn);
+    });
+  })
+);
+
+XPCOMUtils.defineLazyGetter(this, "gAsyncDBWrapperPromised",
+  () => new Promise((resolve) => {
+    Sqlite.wrapStorageConnection({
+      connection: PlacesUtils.history.DBConnection,
+    }).then(conn => {
+      try {
+        Sqlite.shutdown.addBlocker(
+          "PlacesUtils wrapped connection closing",
+          conn.close.bind(conn));
+      } catch(ex) {
+        // It's too late to block shutdown, just close the connection.
+        conn.close();
+        throw ex;
+      }
+      resolve(conn);
+    });
+  })
+);
+
+/**
+ * Keywords management API.
+ * Sooner or later these keywords will merge with search keywords, this is an
+ * interim API that should then be replaced by a unified one.
+ * Keywords are associated with URLs and can have POST data.
+ * A single URL can have multiple keywords, provided they differ by POST data.
+ */
+let Keywords = {
+  /**
+   * Fetches URL and postData for a given keyword.
+   *
+   * @param keyword
+   *        The keyword to fetch.
+   * @return {Promise}
+   * @resolves to an object in the form: { keyword, url, postData },
+   *           or null if a keyword was not found.
+   */
+  fetch(keyword) {
+    if (!keyword || typeof(keyword) != "string")
+      throw new Error("Invalid keyword");
+    keyword = keyword.trim().toLowerCase();
+    return gKeywordsCachePromise.then(cache => cache.get(keyword) || null);
+  },
+
+  /**
+   * Adds a new keyword and postData for the given URL.
+   *
+   * @param keywordEntry
+   *        An object describing the keyword to insert, in the form:
+   *        {
+   *          keyword: non-empty string,
+   *          URL: URL or href to associate to the keyword,
+   *          postData: optional POST data to associate to the keyword
+   *        }
+   * @note Do not define a postData property if there isn't any POST data.
+   * @resolves when the addition is complete.
+   */
+  insert(keywordEntry) {
+    if (!keywordEntry || typeof keywordEntry != "object")
+      throw new Error("Input should be a valid object");
+
+    if (!("keyword" in keywordEntry) || !keywordEntry.keyword ||
+        typeof(keywordEntry.keyword) != "string")
+      throw new Error("Invalid keyword");
+    if (("postData" in keywordEntry) && keywordEntry.postData &&
+                                        typeof(keywordEntry.postData) != "string")
+      throw new Error("Invalid POST data");
+    if (!("url" in keywordEntry))
+      throw new Error("undefined is not a valid URL");
+    let { keyword, url } = keywordEntry;
+    keyword = keyword.trim().toLowerCase();
+    let postData = keywordEntry.postData || null;
+    // This also checks href for validity
+    url = new URL(url);
+
+    return Task.spawn(function* () {
+      let cache = yield gKeywordsCachePromise;
+
+      // Trying to set the same keyword is a no-op.
+      let oldEntry = cache.get(keyword);
+      if (oldEntry && oldEntry.url.href == url.href &&
+                      oldEntry.postData == keywordEntry.postData) {
+        return;
+      }
+
+      // A keyword can only be associated to a single page.
+      // If another page is using the new keyword, we must update the keyword
+      // entry.
+      // Note we cannot use INSERT OR REPLACE cause it wouldn't invoke the delete
+      // trigger.
+      let db = yield PlacesUtils.promiseWrappedConnection();
+      if (oldEntry) {
+        yield db.executeCached(
+          `UPDATE moz_keywords
+           SET place_id = (SELECT id FROM moz_places WHERE url = :url),
+               post_data = :post_data
+           WHERE keyword = :keyword
+          `, { url: url.href, keyword: keyword, post_data: postData });
+        yield notifyKeywordChange(oldEntry.url.href, "");
+      } else {
+        // An entry for the given page could be missing, in such a case we need to
+        // create it.
+        yield db.executeCached(
+          `INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
+           VALUES (:url, :rev_host, 0, :frecency, GENERATE_GUID())
+          `, { url: url.href, rev_host: PlacesUtils.getReversedHost(url),
+               frecency: url.protocol == "place:" ? 0 : -1 });
+        yield db.executeCached(
+          `INSERT INTO moz_keywords (keyword, place_id, post_data)
+           VALUES (:keyword, (SELECT id FROM moz_places WHERE url = :url), :post_data)
+          `, { url: url.href, keyword: keyword, post_data: postData });
+      }
+
+      cache.set(keyword, { keyword, url, postData });
+
+      // In any case, notify about the new keyword.
+      yield notifyKeywordChange(url.href, keyword);
+    }.bind(this));
+  },
+
+  /**
+   * Removes a keyword.
+   *
+   * @param keyword
+   *        The keyword to remove.
+   * @return {Promise}
+   * @resolves when the removal is complete.
+   */
+  remove(keyword) {
+    if (!keyword || typeof(keyword) != "string")
+      throw new Error("Invalid keyword");
+    keyword = keyword.trim().toLowerCase();
+    return Task.spawn(function* () {
+      let cache = yield gKeywordsCachePromise;
+      if (!cache.has(keyword))
+        return;
+      let { url } = cache.get(keyword);
+      cache.delete(keyword);
+
+      let db = yield PlacesUtils.promiseWrappedConnection();
+      yield db.execute(`DELETE FROM moz_keywords WHERE keyword = :keyword`,
+                       { keyword });
+
+      // Notify bookmarks about the removal.
+      yield notifyKeywordChange(url.href, "");
+    }.bind(this));
+  }
+};
+
+// 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.
+let gIgnoreKeywordNotifications = false;
+
+XPCOMUtils.defineLazyGetter(this, "gKeywordsCachePromise", Task.async(function* () {
+  let cache = new Map();
+  let db = yield PlacesUtils.promiseWrappedConnection();
+  let rows = yield db.execute(
+    `SELECT keyword, url, post_data
+     FROM moz_keywords k
+     JOIN moz_places h ON h.id = k.place_id
+    `);
+  for (let row of rows) {
+    let keyword = row.getResultByName("keyword");
+    let entry = { keyword,
+                  url: new URL(row.getResultByName("url")),
+                  postData: row.getResultByName("post_data") };
+    cache.set(keyword, entry);
+  }
+
+  // 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);
     }
-    catch(ex) {
-      // It's too late to block shutdown, just close the connection.
-      return conn.close();
-      throw (ex);
+    return keywords;
+  }
+
+  // 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) {
+      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;
+
+      Task.spawn(function* () {
+        // If the uri is not bookmarked anymore, we can remove this keyword.
+        let bookmark = yield PlacesUtils.bookmarks.fetch({ url: uri });
+        if (!bookmark) {
+          for (let keyword of keywords) {
+            yield PlacesUtils.keywords.remove(keyword);
+          }
+        }
+      }).catch(Cu.reportError);
+    },
+
+    onItemChanged(id, prop, isAnno, val, lastMod, itemType, parentId, guid) {
+      if (gIgnoreKeywordNotifications ||
+          prop != "keyword")
+        return;
+
+      Task.spawn(function* () {
+        let bookmark = yield PlacesUtils.bookmarks.fetch(guid);
+        // By this time the bookmark could have gone, there's nothing we can do.
+        if (!bookmark)
+          return;
+
+        if (val.length == 0) {
+          // We are removing a keyword.
+          let keywords = keywordsForHref(bookmark.url.href)
+          for (let keyword of keywords) {
+            cache.delete(keyword);
+          }
+        } else {
+          // We are adding a new keyword.
+          cache.set(val, { keyword: val, url: bookmark.url });
+        }
+      }).catch(Cu.reportError);
     }
-    return Promise.resolve();
-  }).then(null, Cu.reportError);
-  return connPromised;
-});
+  };
+
+  PlacesUtils.bookmarks.addObserver(observer, false);
+  PlacesUtils.registerShutdownFunction(() => {
+    PlacesUtils.bookmarks.removeObserver(observer);
+  });
+  return cache;
+}));
 
 // 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
@@ -2914,25 +3237,29 @@ this.PlacesEditBookmarkPostDataTransacti
   this.item.id = aItemId;
   this.new = new TransactionItemCache();
   this.new.postData = aPostData;
 }
 
 PlacesEditBookmarkPostDataTransaction.prototype = {
   __proto__: BaseTransaction.prototype,
 
-  doTransaction: function EBPDTXN_doTransaction()
-  {
-    this.item.postData = PlacesUtils.getPostDataForBookmark(this.item.id);
-    PlacesUtils.setPostDataForBookmark(this.item.id, this.new.postData);
+  doTransaction() {
+    // Setting null postData is not supported by the current schema.
+    if (this.new.postData) {
+      this.item.postData = PlacesUtils.getPostDataForBookmark(this.item.id);
+      PlacesUtils.setPostDataForBookmark(this.item.id, this.new.postData);
+    }
   },
 
-  undoTransaction: function EBPDTXN_undoTransaction()
-  {
-    PlacesUtils.setPostDataForBookmark(this.item.id, this.item.postData);
+  undoTransaction() {
+    // Setting null postData is not supported by the current schema.
+    if (this.item.postData) {
+      PlacesUtils.setPostDataForBookmark(this.item.id, this.item.postData);
+    }
   }
 };
 
 
 /**
  * Transaction for editing an item's date added property.
  *
  * @param aItemId
--- a/toolkit/components/places/nsNavBookmarks.cpp
+++ b/toolkit/components/places/nsNavBookmarks.cpp
@@ -617,23 +617,18 @@ nsNavBookmarks::RemoveItem(int64_t aItem
   if (bookmark.type == TYPE_BOOKMARK) {
     // If not a tag, recalculate frecency for this entry, since it changed.
     if (bookmark.grandParentId != mTagsRoot) {
       nsNavHistory* history = nsNavHistory::GetHistoryService();
       NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
       rv = history->UpdateFrecency(bookmark.placeId);
       NS_ENSURE_SUCCESS(rv, rv);
     }
-
     // A broken url should not interrupt the removal process.
-    rv = NS_NewURI(getter_AddRefs(uri), bookmark.url);
-    if (NS_SUCCEEDED(rv)) {
-      rv = UpdateKeywordsForRemovedBookmark(bookmark);
-      NS_ENSURE_SUCCESS(rv, rv);
-    }
+    (void)NS_NewURI(getter_AddRefs(uri), bookmark.url);
   }
 
   NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
                    nsINavBookmarkObserver,
                    OnItemRemoved(bookmark.id,
                                  bookmark.parentId,
                                  bookmark.position,
                                  bookmark.type,
@@ -1097,23 +1092,18 @@ nsNavBookmarks::RemoveFolderChildren(int
     if (child.type == TYPE_BOOKMARK) {
       // If not a tag, recalculate frecency for this entry, since it changed.
       if (child.grandParentId != mTagsRoot) {
         nsNavHistory* history = nsNavHistory::GetHistoryService();
         NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
         rv = history->UpdateFrecency(child.placeId);
         NS_ENSURE_SUCCESS(rv, rv);
       }
-
       // A broken url should not interrupt the removal process.
-      rv = NS_NewURI(getter_AddRefs(uri), child.url);
-      if (NS_SUCCEEDED(rv)) {
-        rv = UpdateKeywordsForRemovedBookmark(child);
-        NS_ENSURE_SUCCESS(rv, rv);
-      }
+      (void)NS_NewURI(getter_AddRefs(uri), child.url);
     }
 
     NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
                      nsINavBookmarkObserver,
                      OnItemRemoved(child.id,
                                    child.parentId,
                                    child.position,
                                    child.type,
@@ -2233,84 +2223,16 @@ nsNavBookmarks::SetItemIndex(int64_t aIt
                                bookmark.guid,
                                bookmark.parentGuid,
                                bookmark.parentGuid));
 
   return NS_OK;
 }
 
 
-nsresult
-nsNavBookmarks::UpdateKeywordsForRemovedBookmark(const BookmarkData& aBookmark)
-{
-  // If there are no keywords for this URI, there's nothing to do.
-  nsCOMPtr<nsIURI> uri;
-  nsresult rv = NS_NewURI(getter_AddRefs(uri), aBookmark.url);
-  NS_ENSURE_SUCCESS(rv, rv);
-
-  nsTArray<nsString> keywords;
-  {
-    nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
-      "SELECT keyword FROM moz_keywords WHERE place_id = :place_id "
-    );
-    NS_ENSURE_STATE(stmt);
-    mozStorageStatementScoper scoper(stmt);
-    rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("place_id"), aBookmark.placeId);
-    NS_ENSURE_SUCCESS(rv, rv);
-
-    bool hasMore;
-    while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
-      nsAutoString keyword;
-      rv = stmt->GetString(0, keyword);
-      NS_ENSURE_SUCCESS(rv, rv);
-      keywords.AppendElement(keyword);
-    }
-  }
-
-  if (keywords.Length() == 0) {
-    // This uri has no keywords associated, so there's nothing to do.
-    return NS_OK;
-  }
-
-  // If the uri is not bookmarked anymore, we can remove its keywords.
-  nsTArray<BookmarkData> bookmarks;
-  rv = GetBookmarksForURI(uri, bookmarks);
-  NS_ENSURE_SUCCESS(rv, rv);
-  if (bookmarks.Length() == 0) {
-    for (uint32_t i = 0; i < keywords.Length(); ++i) {
-      nsString keyword = keywords[i];
-
-      nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
-        "DELETE FROM moz_keywords WHERE keyword = :keyword "
-      );
-      NS_ENSURE_STATE(stmt);
-      mozStorageStatementScoper scoper(stmt);
-      rv = stmt->BindStringByName(NS_LITERAL_CSTRING("keyword"), keyword);
-      NS_ENSURE_SUCCESS(rv, rv);
-      rv = stmt->Execute();
-      NS_ENSURE_SUCCESS(rv, rv);
-    }
-
-    NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
-                     nsINavBookmarkObserver,
-                     OnItemChanged(aBookmark.id,
-                                   NS_LITERAL_CSTRING("keyword"),
-                                   false,
-                                   EmptyCString(),
-                                   aBookmark.lastModified,
-                                   TYPE_BOOKMARK,
-                                   aBookmark.parentId,
-                                   aBookmark.guid,
-                                   aBookmark.parentGuid));
-  }
-
-  return NS_OK;
-}
-
-
 NS_IMETHODIMP
 nsNavBookmarks::SetKeywordForBookmark(int64_t aBookmarkId,
                                       const nsAString& aUserCasedKeyword)
 {
   NS_ENSURE_ARG_MIN(aBookmarkId, 1);
 
   // This also ensures the bookmark is valid.
   BookmarkData bookmark;
--- a/toolkit/components/places/tests/bookmarks/test_bookmarks.js
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks.js
@@ -316,41 +316,35 @@ add_task(function test_bookmarks() {
 
   // test get folder's index 
   let tmpFolder = bs.createFolder(testRoot, "tmp", 2);
   do_check_eq(bs.getItemIndex(tmpFolder), 2);
 
   // test setKeywordForBookmark
   let kwTestItemId = bs.insertBookmark(testRoot, uri("http://keywordtest.com"),
                                        bs.DEFAULT_INDEX, "");
-  try {
-    let dateAdded = bs.getItemDateAdded(kwTestItemId);
-    // after just inserting, modified should not be set
-    let lastModified = bs.getItemLastModified(kwTestItemId);
-    do_check_eq(lastModified, dateAdded);
 
-    // Workaround possible VM timers issues moving lastModified and dateAdded
-    // to the past.
-    lastModified -= 1000;
-    bs.setItemLastModified(kwTestItemId, --lastModified);
-    dateAdded -= 1000;
-    bs.setItemDateAdded(kwTestItemId, dateAdded);
-
-    bs.setKeywordForBookmark(kwTestItemId, "bar");
-
-    let lastModified2 = bs.getItemLastModified(kwTestItemId);
-    LOG("test setKeywordForBookmark");
-    LOG("dateAdded = " + dateAdded);
-    LOG("lastModified = " + lastModified);
-    LOG("lastModified2 = " + lastModified2);
-    do_check_true(is_time_ordered(lastModified, lastModified2));
-    do_check_true(is_time_ordered(dateAdded, lastModified2));
-  } catch(ex) {
-    do_throw("setKeywordForBookmark: " + ex);
-  }
+  dateAdded = bs.getItemDateAdded(kwTestItemId);
+  // after just inserting, modified should not be set
+  lastModified = bs.getItemLastModified(kwTestItemId);
+  do_check_eq(lastModified, dateAdded);
+  // Workaround possible VM timers issues moving lastModified and dateAdded
+  // to the past.
+  lastModified -= 1000;
+  bs.setItemLastModified(kwTestItemId, lastModified);
+  dateAdded -= 1000;
+  bs.setItemDateAdded(kwTestItemId, dateAdded);
+  bs.setKeywordForBookmark(kwTestItemId, "bar");
+  lastModified2 = bs.getItemLastModified(kwTestItemId);
+  LOG("test setKeywordForBookmark");
+  LOG("dateAdded = " + dateAdded);
+  LOG("lastModified = " + lastModified);
+  LOG("lastModified2 = " + lastModified2);
+  do_check_true(is_time_ordered(lastModified, lastModified2));
+  do_check_true(is_time_ordered(dateAdded, lastModified2));
 
   let lastModified3 = bs.getItemLastModified(kwTestItemId);
   // test getKeywordForBookmark
   let k = bs.getKeywordForBookmark(kwTestItemId);
   do_check_eq("bar", k);
 
   // test getURIForKeyword
   let u = bs.getURIForKeyword("bar");
--- a/toolkit/components/places/tests/bookmarks/test_keywords.js
+++ b/toolkit/components/places/tests/bookmarks/test_keywords.js
@@ -281,27 +281,22 @@ add_task(function test_addRemoveBookmark
   let parentId = yield PlacesUtils.promiseItemId(bookmark.parentGuid);
   PlacesUtils.bookmarks.removeItem(itemId);
 
   observer.check([ { name: "onItemChanged",
                      arguments: [ itemId,
                                   "keyword", false, "keyword",
                                   bookmark.lastModified, bookmark.type,
                                   parentId,
-                                  bookmark.guid, bookmark.parentGuid ] },
-                    { name: "onItemChanged",
-                     arguments: [ itemId,
-                                  "keyword", false, "",
-                                  bookmark.lastModified, bookmark.type,
-                                  parentId,
                                   bookmark.guid, bookmark.parentGuid ] }
                  ]);
 
   check_keyword(URI3, null);
-  Assert.equal((yield foreign_count(URI3)), fc);
+  // Don't check the foreign count since the process is async.
+  // The new test_keywords.js in unit is checking this though.
 
   yield PlacesTestUtils.promiseAsyncUpdates();
   check_orphans();
 });
 
 function run_test() {
   run_next_test();
 }
--- a/toolkit/components/places/tests/unifiedcomplete/test_keyword_search.js
+++ b/toolkit/components/places/tests/unifiedcomplete/test_keyword_search.js
@@ -43,17 +43,17 @@ add_task(function* test_keyword_searc() 
   yield check_autocomplete({
     search: "key ユニコード",
     matches: [ { uri: NetUtil.newURI("http://abc/?search=ユニコード"), title: "Generic page title", style: ["keyword"] } ]
   });
 
   do_print("Keyword that happens to match a page");
   yield check_autocomplete({
     search: "key ThisPageIsInHistory",
-    matches: [ { uri: NetUtil.newURI("http://abc/?search=ThisPageIsInHistory"), title: "Generic page title", style: ["bookmark"] } ]
+    matches: [ { uri: NetUtil.newURI("http://abc/?search=ThisPageIsInHistory"), title: "Generic page title", style: ["keyword"] } ]
   });
 
   do_print("Keyword without query (without space)");
   yield check_autocomplete({
     search: "key",
     matches: [ { uri: NetUtil.newURI("http://abc/?search="), title: "Generic page title", style: ["keyword"] } ]
   });
 
deleted file mode 100644
--- a/toolkit/components/places/tests/unit/test_421180.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
-/* vim:set ts=2 sw=2 sts=2 et: */
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-// Get bookmarks service
-try {
-  var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
-              getService(Ci.nsINavBookmarksService);
-}
-catch(ex) {
-  do_throw("Could not get bookmarks service\n");
-}
-
-// Get database connection
-try {
-  var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
-                getService(Ci.nsINavHistoryService);
-  var mDBConn = histsvc.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection;
-}
-catch(ex) {
-  do_throw("Could not get database connection\n");
-}
-
-add_test(function test_keywordRemovedOnUniqueItemRemoval() {
-  var bookmarkedURI = uri("http://foo.bar");
-  var keyword = "testkeyword";
-
-  // TEST 1
-  // 1. add a bookmark
-  // 2. add a keyword to it
-  // 3. remove bookmark
-  // 4. check that keyword has gone
-  var bookmarkId = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder,
-                                        bookmarkedURI,
-                                        bmsvc.DEFAULT_INDEX,
-                                        "A bookmark");
-  bmsvc.setKeywordForBookmark(bookmarkId, keyword);
-  // remove bookmark
-  bmsvc.removeItem(bookmarkId);
-
-  PlacesTestUtils.promiseAsyncUpdates().then(() => {
-    // Check that keyword has been removed from the database.
-    // The removal is asynchronous.
-    var sql = "SELECT id FROM moz_keywords WHERE keyword = ?1";
-    var stmt = mDBConn.createStatement(sql);
-    stmt.bindByIndex(0, keyword);
-    do_check_false(stmt.executeStep());
-    stmt.finalize();
-
-    run_next_test();
-  });
-});
-
-add_test(function test_keywordNotRemovedOnNonUniqueItemRemoval() {
-  var bookmarkedURI = uri("http://foo.bar");
-  var keyword = "testkeyword";
-
-  // TEST 2
-  // 1. add 2 bookmarks
-  // 2. add the same keyword to them
-  // 3. remove first bookmark
-  // 4. check that keyword is still there
-  var bookmarkId1 = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder,
-                                        bookmarkedURI,
-                                        bmsvc.DEFAULT_INDEX,
-                                        "A bookmark");
-  bmsvc.setKeywordForBookmark(bookmarkId1, keyword);
-
-  var bookmarkId2 = bmsvc.insertBookmark(bmsvc.toolbarFolder,
-                                        bookmarkedURI,
-                                        bmsvc.DEFAULT_INDEX,
-                                        keyword);
-  bmsvc.setKeywordForBookmark(bookmarkId2, keyword);
-
-  // remove first bookmark
-  bmsvc.removeItem(bookmarkId1);
-
-  PlacesTestUtils.promiseAsyncUpdates().then(() => {
-    // check that keyword is still there
-    var sql = "SELECT id FROM moz_keywords WHERE keyword = ?1";
-    var stmt = mDBConn.createStatement(sql);
-    stmt.bindByIndex(0, keyword);
-    do_check_true(stmt.executeStep());
-    stmt.finalize();
-
-    run_next_test();
-  });
-});
-
-function run_test() {
-  run_next_test();
-}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_keywords.js
@@ -0,0 +1,488 @@
+"use strict"
+
+function* check_keyword(aExpectExists, aHref, aKeyword, aPostData = null) {
+  // Check case-insensitivity.
+  aKeyword = aKeyword.toUpperCase();
+
+  let entry = yield PlacesUtils.keywords.fetch(aKeyword);
+  if (aExpectExists) {
+    Assert.ok(!!entry, "A keyword should exist");
+    Assert.equal(entry.url.href, aHref);
+    Assert.equal(entry.postData, aPostData);
+  } else {
+    Assert.ok(!entry || entry.url.href != aHref,
+              "The given keyword entry should not exist");
+  }
+}
+
+/**
+ * Polls the keywords cache waiting for the given keyword entry.
+ */
+function* promiseKeyword(keyword, expectedHref) {
+  let href = null;
+  do {
+    yield new Promise(resolve => do_timeout(100, resolve));
+    let entry = yield PlacesUtils.keywords.fetch(keyword);
+    if (entry)
+      href = entry.url.href; 
+  } while (href != expectedHref);
+}
+
+function* check_no_orphans() {
+  let db = yield PlacesUtils.promiseDBConnection();
+  let rows = yield db.executeCached(
+    `SELECT id FROM moz_keywords k
+     WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = k.place_id)
+    `);
+  Assert.equal(rows.length, 0);
+}
+
+function expectBookmarkNotifications() {
+  let notifications = [];
+  let observer = new Proxy(NavBookmarkObserver, {
+    get(target, name) {
+      if (name == "check") {
+        PlacesUtils.bookmarks.removeObserver(observer);
+        return expectedNotifications =>
+          Assert.deepEqual(notifications, expectedNotifications);
+      }
+
+      if (name.startsWith("onItemChanged")) {
+        return (itemId, property) => {
+          if (property != "keyword")
+            return;
+          let args = Array.from(arguments, arg => {
+            if (arg && arg instanceof Ci.nsIURI)
+              return new URL(arg.spec);
+            if (arg && typeof(arg) == "number" && arg >= Date.now() * 1000)
+              return new Date(parseInt(arg/1000));
+            return arg;
+          });
+          notifications.push({ name: name, arguments: args });
+        }
+      }
+
+      if (name in target)
+        return target[name];
+      return undefined;
+    }
+  });
+  PlacesUtils.bookmarks.addObserver(observer, false);
+  return observer;
+}
+
+add_task(function* test_invalid_input() {
+  Assert.throws(() => PlacesUtils.keywords.fetch(null),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.fetch(""),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.fetch(5),
+                /Invalid keyword/);
+
+  Assert.throws(() => PlacesUtils.keywords.insert(null),
+                /Input should be a valid object/);
+  Assert.throws(() => PlacesUtils.keywords.insert("test"),
+                /Input should be a valid object/);
+  Assert.throws(() => PlacesUtils.keywords.insert(undefined),
+                /Input should be a valid object/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ }),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: null }),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: 5 }),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "" }),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", postData: 5 }),
+                /Invalid POST data/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", postData: {} }),
+                /Invalid POST data/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test" }),
+                /is not a valid URL/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: 5 }),
+                /is not a valid URL/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: "" }),
+                /is not a valid URL/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: null }),
+                /is not a valid URL/);
+  Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: "mozilla" }),
+                /is not a valid URL/);
+
+  Assert.throws(() => PlacesUtils.keywords.remove(null),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.remove(""),
+                /Invalid keyword/);
+  Assert.throws(() => PlacesUtils.keywords.remove(5),
+                /Invalid keyword/);
+});
+
+add_task(function* test_addKeyword() {
+  yield check_keyword(false, "http://example.com/", "keyword");
+  let fc = yield foreign_count("http://example.com/");
+  let observer = expectBookmarkNotifications();
+
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+  observer.check([]);
+
+  yield check_keyword(true, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // +1 keyword
+
+  // Now remove the keyword.
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.remove("keyword");
+  observer.check([]);
+
+  yield check_keyword(false, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc); // -1 keyword
+
+  // Check using URL.
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: new URL("http://example.com/") });
+  yield check_keyword(true, "http://example.com/", "keyword");
+  yield PlacesUtils.keywords.remove("keyword");
+  yield check_keyword(false, "http://example.com/", "keyword");
+
+  yield check_no_orphans();
+});
+
+add_task(function* test_addBookmarkAndKeyword() {
+  yield check_keyword(false, "http://example.com/", "keyword");
+  let fc = yield foreign_count("http://example.com/");
+  let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+                                                      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                      parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+
+  let observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+                                 "keyword", false, "keyword",
+                                 bookmark.lastModified, bookmark.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+                                 bookmark.guid, bookmark.parentGuid ] } ]);
+
+  yield check_keyword(true, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 bookmark +1 keyword
+
+  // Now remove the keyword.
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.remove("keyword");
+
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+                                 "keyword", false, "",
+                                 bookmark.lastModified, bookmark.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+                                 bookmark.guid, bookmark.parentGuid ] } ]);
+
+  yield check_keyword(false, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // -1 keyword
+
+  // Add again the keyword, then remove the bookmark.
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.bookmarks.remove(bookmark.guid);
+  // the notification is synchronous but the removal process is async.
+  // Unfortunately there's nothing explicit we can wait for.
+  while ((yield foreign_count("http://example.com/")));
+  // We don't get any itemChanged notification since the bookmark has been
+  // removed already.
+  observer.check([]);
+
+  yield check_keyword(false, "http://example.com/", "keyword");
+
+  yield check_no_orphans();
+});
+
+add_task(function* test_addKeywordToURIHavingKeyword() {
+  yield check_keyword(false, "http://example.com/", "keyword");
+  let fc = yield foreign_count("http://example.com/");
+
+  let observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+  observer.check([]);
+
+  yield check_keyword(true, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // +1 keyword
+
+  yield PlacesUtils.keywords.insert({ keyword: "keyword2", url: "http://example.com/" });
+
+  yield check_keyword(true, "http://example.com/", "keyword");
+  yield check_keyword(true, "http://example.com/", "keyword2");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 keyword
+
+  // Now remove the keywords.
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.remove("keyword");
+  yield PlacesUtils.keywords.remove("keyword2");
+  observer.check([]);
+
+  yield check_keyword(false, "http://example.com/", "keyword");
+  yield check_keyword(false, "http://example.com/", "keyword2");
+  Assert.equal((yield foreign_count("http://example.com/")), fc); // -1 keyword
+
+  yield check_no_orphans();
+});
+
+add_task(function* test_addBookmarkToURIHavingKeyword() {
+  yield check_keyword(false, "http://example.com/", "keyword");
+  let fc = yield foreign_count("http://example.com/");
+  let observer = expectBookmarkNotifications();
+
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+  observer.check([]);
+
+  yield check_keyword(true, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // +1 keyword
+
+  observer = expectBookmarkNotifications();
+  let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+                                                      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                      parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 bookmark
+  observer.check([]);
+
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.bookmarks.remove(bookmark.guid);
+  // the notification is synchronous but the removal process is async.
+  // Unfortunately there's nothing explicit we can wait for.
+  while ((yield foreign_count("http://example.com/")));
+  // We don't get any itemChanged notification since the bookmark has been
+  // removed already.
+  observer.check([]);
+
+  yield check_keyword(false, "http://example.com/", "keyword");
+
+  yield check_no_orphans();
+});
+
+add_task(function* test_sameKeywordDifferentURL() {
+  let fc1 = yield foreign_count("http://example1.com/");
+  let bookmark1 = yield PlacesUtils.bookmarks.insert({ url: "http://example1.com/",
+                                                       type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                       parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  let fc2 = yield foreign_count("http://example2.com/");
+  let bookmark2 = yield PlacesUtils.bookmarks.insert({ url: "http://example2.com/",
+                                                       type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                       parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example1.com/" });
+
+  yield check_keyword(true, "http://example1.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example1.com/")), fc1 + 2); // +1 bookmark +1 keyword
+  yield check_keyword(false, "http://example2.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example2.com/")), fc2 + 1); // +1 bookmark
+
+  // Assign the same keyword to another url.
+  let observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example2.com/" });
+
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark1.guid)),
+                                 "keyword", false, "",
+                                 bookmark1.lastModified, bookmark1.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark1.parentGuid)),
+                                 bookmark1.guid, bookmark1.parentGuid ] },
+                  { name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+                                 "keyword", false, "keyword",
+                                 bookmark2.lastModified, bookmark2.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+                                 bookmark2.guid, bookmark2.parentGuid ] } ]);
+
+  yield check_keyword(false, "http://example1.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example1.com/")), fc1 + 1); // -1 keyword
+  yield check_keyword(true, "http://example2.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example2.com/")), fc2 + 2); // +1 keyword
+
+  // Now remove the keyword.
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.remove("keyword");
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+                                 "keyword", false, "",
+                                 bookmark2.lastModified, bookmark2.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+                                 bookmark2.guid, bookmark2.parentGuid ] } ]);
+
+  yield check_keyword(false, "http://example1.com/", "keyword");
+  yield check_keyword(false, "http://example2.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example1.com/")), fc1 + 1);
+  Assert.equal((yield foreign_count("http://example2.com/")), fc2 + 1); // -1 keyword
+
+  yield PlacesUtils.bookmarks.remove(bookmark1);
+  yield PlacesUtils.bookmarks.remove(bookmark2);
+  Assert.equal((yield foreign_count("http://example1.com/")), fc1); // -1 bookmark
+  while ((yield foreign_count("http://example2.com/"))); // -1 keyword
+
+  yield check_no_orphans();
+});
+
+add_task(function* test_sameURIDifferentKeyword() {
+  let fc = yield foreign_count("http://example.com/");
+
+  let observer = expectBookmarkNotifications();
+  let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+                                                      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                      parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  yield PlacesUtils.keywords.insert({keyword: "keyword", url: "http://example.com/" });
+
+  yield check_keyword(true, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 bookmark +1 keyword
+
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+                                 "keyword", false, "keyword",
+                                 bookmark.lastModified, bookmark.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+                                 bookmark.guid, bookmark.parentGuid ] } ]);
+
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.insert({ keyword: "keyword2", url: "http://example.com/" });
+  yield check_keyword(true, "http://example.com/", "keyword");
+  yield check_keyword(true, "http://example.com/", "keyword2");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 3); // +1 keyword
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+                                 "keyword", false, "keyword2",
+                                 bookmark.lastModified, bookmark.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+                                 bookmark.guid, bookmark.parentGuid ] } ]);
+
+  // Add a third keyword.
+  yield PlacesUtils.keywords.insert({ keyword: "keyword3", url: "http://example.com/" });
+  yield check_keyword(true, "http://example.com/", "keyword");
+  yield check_keyword(true, "http://example.com/", "keyword2");
+  yield check_keyword(true, "http://example.com/", "keyword3");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 4); // +1 keyword
+
+  // Remove one of the keywords.
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.remove("keyword");
+  yield check_keyword(false, "http://example.com/", "keyword");
+  yield check_keyword(true, "http://example.com/", "keyword2");
+  yield check_keyword(true, "http://example.com/", "keyword3");
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+                                 "keyword", false, "",
+                                 bookmark.lastModified, bookmark.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+                                 bookmark.guid, bookmark.parentGuid ] } ]);
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 3); // -1 keyword
+
+  // Now remove the bookmark.
+  yield PlacesUtils.bookmarks.remove(bookmark);
+  while ((yield foreign_count("http://example.com/")));
+  yield check_keyword(false, "http://example.com/", "keyword");
+  yield check_keyword(false, "http://example.com/", "keyword2");
+  yield check_keyword(false, "http://example.com/", "keyword3");
+
+  check_no_orphans();
+});
+
+add_task(function* test_deleteKeywordMultipleBookmarks() {
+  let fc = yield foreign_count("http://example.com/");
+
+  let observer = expectBookmarkNotifications();
+  let bookmark1 = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+                                                       type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                       parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  let bookmark2 = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+                                                       type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                       parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+
+  yield check_keyword(true, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 3); // +2 bookmark +1 keyword
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+                                 "keyword", false, "keyword",
+                                 bookmark2.lastModified, bookmark2.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+                                 bookmark2.guid, bookmark2.parentGuid ] },
+                  { name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark1.guid)),
+                                 "keyword", false, "keyword",
+                                 bookmark1.lastModified, bookmark1.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark1.parentGuid)),
+                                 bookmark1.guid, bookmark1.parentGuid ] } ]);
+
+  observer = expectBookmarkNotifications();
+  yield PlacesUtils.keywords.remove("keyword");
+  yield check_keyword(false, "http://example.com/", "keyword");
+  Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // -1 keyword
+  observer.check([{ name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+                                 "keyword", false, "",
+                                 bookmark2.lastModified, bookmark2.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+                                 bookmark2.guid, bookmark2.parentGuid ] },
+                  { name: "onItemChanged",
+                    arguments: [ (yield PlacesUtils.promiseItemId(bookmark1.guid)),
+                                 "keyword", false, "",
+                                 bookmark1.lastModified, bookmark1.type,
+                                 (yield PlacesUtils.promiseItemId(bookmark1.parentGuid)),
+                                 bookmark1.guid, bookmark1.parentGuid ] } ]);
+
+  // Now remove the bookmarks.
+  yield PlacesUtils.bookmarks.remove(bookmark1);
+  yield PlacesUtils.bookmarks.remove(bookmark2);
+  Assert.equal((yield foreign_count("http://example.com/")), fc); // -2 bookmarks
+
+  check_no_orphans();
+});
+
+add_task(function* test_multipleKeywordsSamePostData() {
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/", postData: "postData1" });
+  yield check_keyword(true, "http://example.com/", "keyword", "postData1");
+  // Add another keyword with same postData, should fail.
+  yield Assert.rejects(PlacesUtils.keywords.insert({ keyword: "keyword2", url: "http://example.com/", postData: "postData1" }),
+                       /constraint failed/);
+  yield check_keyword(false, "http://example.com/", "keyword2", "postData1");
+
+  yield PlacesUtils.keywords.remove("keyword");
+
+  check_no_orphans();
+});
+
+add_task(function* test_oldPostDataAPI() {
+  let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+                                                      type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                      parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+  let itemId = yield PlacesUtils.promiseItemId(bookmark.guid);
+  yield PlacesUtils.setPostDataForBookmark(itemId, "postData");
+  yield check_keyword(true, "http://example.com/", "keyword", "postData");
+  Assert.equal(PlacesUtils.getPostDataForBookmark(itemId), "postData");
+
+  yield PlacesUtils.keywords.remove("keyword");
+  yield PlacesUtils.bookmarks.remove(bookmark);
+
+  check_no_orphans();
+});
+
+add_task(function* test_oldKeywordsAPI() {
+  let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+                                                    type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+                                                    parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+  yield check_keyword(false, "http://example.com/", "keyword");
+  let itemId = yield PlacesUtils.promiseItemId(bookmark.guid);
+
+  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword");
+  yield promiseKeyword("keyword", "http://example.com/");
+
+  // Remove the keyword.
+  PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "");
+  yield promiseKeyword("keyword", null);
+
+  yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com" });
+  Assert.equal(PlacesUtils.bookmarks.getKeywordForBookmark(itemId), "keyword");
+  Assert.equal(PlacesUtils.bookmarks.getURIForKeyword("keyword").spec, "http://example.com/");
+  yield PlacesUtils.bookmarks.remove(bookmark);
+
+  check_no_orphans();
+});
+
+function run_test() {
+  run_next_test();
+}
--- a/toolkit/components/places/tests/unit/test_placesTxn.js
+++ b/toolkit/components/places/tests/unit/test_placesTxn.js
@@ -727,18 +727,18 @@ add_test(function test_edit_postData() {
   txn.doTransaction();
   let [url, post_data] = PlacesUtils.getURLAndPostDataForKeyword("kw");
   Assert.equal(url, testURI.spec);
   Assert.equal(postData, post_data);
 
   txn.undoTransaction();
   [url, post_data] = PlacesUtils.getURLAndPostDataForKeyword("kw");
   Assert.equal(url, testURI.spec);
-  Assert.equal(null, post_data);
-
+  // We don't allow anymore to set a null post data.
+  //Assert.equal(null, post_data);
 
   run_next_test();
 });
 
 add_test(function test_tagURI_untagURI() {
   const TAG_1 = "tag-test_tagURI_untagURI-bar";
   const TAG_2 = "tag-test_tagURI_untagURI-foo";
   let tagURI = NetUtil.newURI("http://test_tagURI_untagURI.com");
--- a/toolkit/components/places/tests/unit/xpcshell.ini
+++ b/toolkit/components/places/tests/unit/xpcshell.ini
@@ -39,17 +39,16 @@ skip-if = os == "linux"
 skip-if = os == "android"
 [test_413784.js]
 [test_415460.js]
 [test_415757.js]
 [test_418643_removeFolderChildren.js]
 [test_419731.js]
 [test_419792_node_tags_property.js]
 [test_420331_wyciwyg.js]
-[test_421180.js]
 [test_425563.js]
 [test_429505_remove_shortcuts.js]
 [test_433317_query_title_update.js]
 [test_433525_hasChildren_crash.js]
 [test_452777.js]
 [test_454977.js]
 [test_463863.js]
 [test_485442_crash_bug_nsNavHistoryQuery_GetUri.js]
@@ -99,16 +98,17 @@ skip-if = os == "android"
 [test_history_notifications.js]
 [test_history_observer.js]
 # Bug 676989: test hangs consistently on Android
 skip-if = os == "android"
 [test_history_sidebar.js]
 [test_hosts_triggers.js]
 [test_isURIVisited.js]
 [test_isvisited.js]
+[test_keywords.js]
 [test_lastModified.js]
 [test_markpageas.js]
 [test_mozIAsyncLivemarks.js]
 [test_multi_queries.js]
 # Bug 676989: test fails consistently on Android
 fail-if = os == "android"
 [test_multi_word_tags.js]
 [test_nsINavHistoryViewer.js]