Bug 1131362 - Create a reading list SQLite database and JSM to provide access to it. r=markh,Unfocused a=readinglist
authorDrew Willcoxon <adw>
Thu, 05 Mar 2015 15:27:00 +1300
changeset 248416 ef56ce2db9302160775ac3f87472b4ca24919916
parent 248415 6d3dc7ed4b28fa7fb95e48634cf3fa89b69f74e2
child 248417 1b23ce8302cda994023c58841a7eb53a275ea28a
push id7837
push userjwein@mozilla.com
push dateFri, 27 Mar 2015 00:27:16 +0000
treeherdermozilla-aurora@cb0db44ce60e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh, Unfocused, readinglist
bugs1131362
milestone38.0a2
Bug 1131362 - Create a reading list SQLite database and JSM to provide access to it. r=markh,Unfocused a=readinglist
browser/components/readinglist/ReadingList.jsm
browser/components/readinglist/SQLiteStore.jsm
browser/components/readinglist/moz.build
browser/components/readinglist/test/xpcshell/test_ReadingList.js
browser/components/readinglist/test/xpcshell/test_SQLiteStore.js
browser/components/readinglist/test/xpcshell/xpcshell.ini
--- a/browser/components/readinglist/ReadingList.jsm
+++ b/browser/components/readinglist/ReadingList.jsm
@@ -1,356 +1,758 @@
 /* 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/. */
+ * 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/. */
 
 "use strict";
 
-this.EXPORTED_SYMBOLS = ["ReadingList"];
+this.EXPORTED_SYMBOLS = [
+  "ReadingList",
+];
 
-const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
-
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "SQLiteStore",
+  "resource:///modules/readinglist/SQLiteStore.jsm");
 
-(function() {
+
+{ // Prevent the parent log setup from leaking into the global scope.
   let parentLog = Log.repository.getLogger("readinglist");
   parentLog.level = Preferences.get("browser.readinglist.logLevel", Log.Level.Warn);
   Preferences.observe("browser.readinglist.logLevel", value => {
     parentLog.level = value;
   });
   let formatter = new Log.BasicFormatter();
   parentLog.addAppender(new Log.ConsoleAppender(formatter));
   parentLog.addAppender(new Log.DumpAppender(formatter));
-})();
+}
+let log = Log.repository.getLogger("readinglist.api");
+
 
-let log = Log.repository.getLogger("readinglist.api");
+// Names of basic properties on ReadingListItem.
+const ITEM_BASIC_PROPERTY_NAMES = `
+  guid
+  lastModified
+  url
+  title
+  resolvedURL
+  resolvedTitle
+  excerpt
+  status
+  favorite
+  isArticle
+  wordCount
+  unread
+  addedBy
+  addedOn
+  storedOn
+  markedReadBy
+  markedReadOn
+  readPosition
+`.trim().split(/\s+/);
 
 /**
- * Represents an item in the Reading List.
- * @constructor
- * @see https://github.com/mozilla-services/readinglist/wiki/API-Design-proposal#data-model
+ * A reading list contains ReadingListItems.
+ *
+ * A list maintains only one copy of an item per URL.  So if for example you use
+ * an iterator to get two references to items with the same URL, your references
+ * actually refer to the same JS object.
+ *
+ * Options Objects
+ * ---------------
+ *
+ * Some methods on ReadingList take an "optsList", a variable number of
+ * arguments, each of which is an "options object".  Options objects let you
+ * control the items that the method acts on.
+ *
+ * Each options object is a simple object with properties whose names are drawn
+ * from ITEM_BASIC_PROPERTY_NAMES.  For an item to match an options object, the
+ * properties of the item must match all the properties in the object.  For
+ * example, an object { guid: "123" } matches any item whose GUID is 123.  An
+ * object { guid: "123", title: "foo" } matches any item whose GUID is 123 *and*
+ * whose title is foo.
+ *
+ * You can pass multiple options objects as separate arguments.  For an item to
+ * match multiple objects, its properties must match all the properties in at
+ * least one of the objects.  For example, a list of objects { guid: "123" } and
+ * { title: "foo" } matches any item whose GUID is 123 *or* whose title is
+ * foo.
+ *
+ * The properties in an options object can be arrays, not only scalars.  When a
+ * property is an array, then for an item to match, its corresponding property
+ * must have a value that matches any value in the array.  For example, an
+ * options object { guid: ["123", "456"] } matches any item whose GUID is either
+ * 123 *or* 456.
+ *
+ * In addition to properties with names from ITEM_BASIC_PROPERTY_NAMES, options
+ * objects can also have the following special properties:
+ *
+ *   * sort: The name of a property to sort on.
+ *   * descending: A boolean, true to sort descending, false to sort ascending.
+ *     If `sort` is given but `descending` isn't, the sort is ascending (since
+ *     `descending` is falsey).
+ *   * limit: Limits the number of matching items to this number.
+ *   * offset: Starts matching items at this index in the results.
+ *
+ * Since you can pass multiple options objects in a list, you can include these
+ * special properties in any number of the objects in the list, but it doesn't
+ * really make sense to do so.  The last property in the list is the one that's
+ * used.
+ *
+ * @param store Backing storage for the list.  See SQLiteStore.jsm for what this
+ *        object's interface should look like.
  */
-function Item(data) {
-  this._data = data;
+function ReadingListImpl(store) {
+  this._store = store;
+  this._itemsByURL = new Map();
+  this._iterators = new Set();
 }
 
-Item.prototype = {
-  /**
-   * UUID
-   * @type {string}
-   */
-  get id() {
-    return this._data.id;
-  },
-
-  /**
-   * Server timestamp
-   * @type {string}
-   */
-  get lastModified() {
-    return this._data.last_modified;
-  },
+ReadingListImpl.prototype = {
 
-  /**
-   * @type {nsIURL}
-   */
-  get originalUrl() {
-    return Services.io.newURI(this._data.url, null, null);
-  },
-
-  /**
-   * @type {string}
-   */
-  get originalTitle() {
-    return this._data.title || "";
-  },
-
-  /**
-   * @type {nsIURL}
-   */
-  get resolvedUrl() {
-    return Services.io.newURI(this._data.resolved_url || this._data.url, null, null);
-  },
+  ItemBasicPropertyNames: ITEM_BASIC_PROPERTY_NAMES,
 
   /**
-   * @type {string}
-   */
-  get resolvedTitle() {
-    return this._data.resolved_title || this.originalTitle;
-  },
-
-  /**
-   * @type {string}
+   * Yields the number of items in the list.
+   *
+   * @param optsList A variable number of options objects that control the
+   *        items that are matched.  See Options Objects.
+   * @return Promise<number> The number of matching items in the list.  Rejected
+   *         with an Error on error.
    */
-  get excerpt() {
-    return this._data.excerpt || "";
-  },
-
-  /**
-   * @type {ItemStates}
-   */
-  get state() {
-    return ReadingList.ItemStates[this._data.state] || ReadingList.ItemStates.OK;
-  },
-
-  /**
-   * @type {boolean}
-   */
-  get isFavorite() {
-    return !!this._data.favorite;
-  },
+  count: Task.async(function* (...optsList) {
+    return (yield this._store.count(...optsList));
+  }),
 
   /**
-   * @type {boolean}
+   * Enumerates the items in the list that match the given options.
+   *
+   * @param callback Called for each item in the enumeration.  It's passed a
+   *        single object, a ReadingListItem.  It may return a promise; if so,
+   *        the callback will not be called for the next item until the promise
+   *        is resolved.
+   * @param optsList A variable number of options objects that control the
+   *        items that are matched.  See Options Objects.
+   * @return Promise<null> Resolved when the enumeration completes *and* the
+   *         last promise returned by the callback is resolved.  Rejected with
+   *         an Error on error.
    */
-  get isArticle() {
-    return !!this._data.is_article;
-  },
-
-  /**
-   * @type {number}
-   */
-  get wordCount() {
-    return this._data.word_count || 0;
-  },
+  forEachItem: Task.async(function* (callback, ...optsList) {
+    let promiseChain = Promise.resolve();
+    yield this._store.forEachItem(obj => {
+      promiseChain = promiseChain.then(() => {
+        return new Promise((resolve, reject) => {
+          let promise = callback(this._itemFromObject(obj));
+          if (promise instanceof Promise) {
+            return promise.then(resolve, reject);
+          }
+          resolve();
+          return undefined;
+        });
+      });
+    }, ...optsList);
+    yield promiseChain;
+  }),
 
   /**
-   * @type {boolean}
-   */
-  get isUnread() {
-    return !!this._data.unread;
-  },
-
-  /**
-   * Device name
-   * @type {string}
+   * Returns a new ReadingListItemIterator that can be used to enumerate items
+   * in the list.
+   *
+   * @param optsList A variable number of options objects that control the
+   *        items that are matched.  See Options Objects.
+   * @return A new ReadingListItemIterator.
    */
-  get addedBy() {
-    return this._data.added_by;
-  },
-
-  /**
-   * @type {Date}
-   */
-  get addedOn() {
-    return new Date(this._data.added_on);
+  iterator(...optsList) {
+    let iter = new ReadingListItemIterator(this, ...optsList);
+    this._iterators.add(Cu.getWeakReference(iter));
+    return iter;
   },
 
   /**
-   * @type {Date}
+   * Adds an item to the list that isn't already present.
+   *
+   * The given object represents a new item, and the properties of the object
+   * are those in ITEM_BASIC_PROPERTY_NAMES.  It may have as few or as many
+   * properties that you want to set, but it must have a `url` property.
+   *
+   * It's an error to call this with an object whose `url` or `guid` properties
+   * are the same as those of items that are already present in the list.  The
+   * returned promise is rejected in that case.
+   *
+   * @param item A simple object representing an item.
+   * @return Promise<null> Resolved when the list is updated.  Rejected with an
+   *         Error on error.
    */
-  get storedOn() {
-    return new Date(this._data.stored_on);
-  },
+  addItem: Task.async(function* (item) {
+    yield this._store.addItem(simpleObjectFromItem(item));
+    this._invalidateIterators();
+  }),
 
   /**
-   * Device name
-   * @type {string}
+   * Updates the properties of an item that belongs to the list.
+   *
+   * The passed-in item may have as few or as many properties that you want to
+   * set; only the properties that are present are updated.  The item must have
+   * a `url`, however.
+   *
+   * It's an error to call this for an item that doesn't belong to the list.
+   * The returned promise is rejected in that case.
+   *
+   * @param item The ReadingListItem to update.
+   * @return Promise<null> Resolved when the list is updated.  Rejected with an
+   *         Error on error.
    */
-  get markedReadBy() {
-    return this._data.marked_read_by;
-  },
+  updateItem: Task.async(function* (item) {
+    this._ensureItemBelongsToList(item);
+    yield this._store.updateItem(item._properties);
+    this._invalidateIterators();
+  }),
 
   /**
-   * @type {Date}
+   * Deletes an item from the list.  The item must have a `url`.
+   *
+   * It's an error to call this for an item that doesn't belong to the list.
+   * The returned promise is rejected in that case.
+   *
+   * @param item The ReadingListItem to delete.
+   * @return Promise<null> Resolved when the list is updated.  Rejected with an
+   *         Error on error.
    */
-  get markedReadOn() {
-    return new date(this._data.marked_read_on);
-  },
+  deleteItem: Task.async(function* (item) {
+    this._ensureItemBelongsToList(item);
+    yield this._store.deleteItemByURL(item.url);
+    item.list = null;
+    this._itemsByURL.delete(item.url);
+    this._invalidateIterators();
+  }),
 
   /**
-   * @type {number}
+   * Call this when you're done with the list.  Don't use it afterward.
    */
-  get readPosition() {
-    return this._data.read_position;
-  },
+  destroy: Task.async(function* () {
+    yield this._store.destroy();
+    for (let itemWeakRef of this._itemsByURL.values()) {
+      let item = itemWeakRef.get();
+      if (item) {
+        item.list = null;
+      }
+    }
+    this._itemsByURL.clear();
+  }),
 
-  // Data not specified by the current server API
+  // The list's backing store.
+  _store: null,
+
+  // A Map mapping URL strings to nsIWeakReferences that refer to
+  // ReadingListItems.
+  _itemsByURL: null,
+
+  // A Set containing nsIWeakReferences that refer to valid iterators produced
+  // by the list.
+  _iterators: null,
 
   /**
-   * Array of scraped or captured summary images for this page.
-   * TODO: Implement this.
-   * @type {[nsIURL]}
+   * Returns the ReadingListItem represented by the given simple object.  If
+   * the item doesn't exist yet, it's created first.
+   *
+   * @param obj A simple object with item properties.
+   * @return The ReadingListItem.
    */
-  get images() {
-    return [];
+  _itemFromObject(obj) {
+    let itemWeakRef = this._itemsByURL.get(obj.url);
+    let item = itemWeakRef ? itemWeakRef.get() : null;
+    if (item) {
+      item.setProperties(obj, false);
+    }
+    else {
+      item = new ReadingListItem(obj);
+      item.list = this;
+      this._itemsByURL.set(obj.url, Cu.getWeakReference(item));
+    }
+    return item;
   },
 
   /**
-   * Favicon for this site.
-   * @type {nsIURL}
-   * TODO: Generate moz-anno: URI for favicon.
+   * Marks all the list's iterators as invalid, meaning it's not safe to use
+   * them anymore.
    */
-  get favicon() {
-    return null;
+  _invalidateIterators() {
+    for (let iterWeakRef of this._iterators) {
+      let iter = iterWeakRef.get();
+      if (iter) {
+        iter.invalidate();
+      }
+    }
+    this._iterators.clear();
   },
 
-  // Helpers
+  _ensureItemBelongsToList(item) {
+    if (item.list != this) {
+      throw new Error("The item does not belong to this list");
+    }
+  },
+};
+
+/**
+ * An item in a reading list.
+ *
+ * Each item belongs to a list, and it's an error to use an item with a
+ * ReadingList that the item doesn't belong to.
+ *
+ * @param props The properties of the item, as few or many as you want.
+ */
+function ReadingListItem(props={}) {
+  this._properties = {};
+  this.setProperties(props, false);
+}
+
+ReadingListItem.prototype = {
 
   /**
-   * Alias for resolvedUrl.
-   * TODO: This url/resolvedUrl alias makes it feel like the server API hasn't got this right.
+   * The item's GUID.
+   * @type string
    */
-  get url() {
-    return this.resolvedUrl;
-  },
-  /**
-   * Alias for resolvedTitle
-   */
-  get title() {
-    return this.resolvedTitle;
+  get guid() {
+    return this._properties.guid || undefined;
   },
-
-  /**
-   * Domain portion of the URL, with prefixes stripped. For display purposes.
-   * @type {string}
-   */
-  get domain() {
-    let host = this.resolvedUrl.host;
-    if (host.startsWith("www.")) {
-      host = host.slice(4);
+  set guid(val) {
+    this._properties.guid = val;
+    if (this.list) {
+      this.commit();
     }
-    return host;
   },
 
   /**
-   * Convert this Item to a string representation.
+   * The date the item was last modified.
+   * @type Date
    */
-  toString() {
-    return `[Item url=${this.url.spec}]`;
+  get lastModified() {
+    return this._properties.lastModified ?
+           new Date(this._properties.lastModified) :
+           undefined;
+  },
+  set lastModified(val) {
+    this._properties.lastModified = val.valueOf();
+    if (this.list) {
+      this.commit();
+    }
   },
 
   /**
-   * Get the value that should be used for a JSON representation of this Item.
+   * The item's URL.
+   * @type string
    */
-  toJSON() {
-    return this._data;
+  get url() {
+    return this._properties.url;
   },
-};
-
-
-let ItemStates = {
-  OK: Symbol("ok"),
-  ARCHIVED: Symbol("archived"),
-  DELETED: Symbol("deleted"),
-};
-
-
-this.ReadingList = {
-  Item: Item,
-  ItemStates: ItemStates,
-
-  _listeners: new Set(),
-  _items: [],
+  set url(val) {
+    this._properties.url = val;
+    if (this.list) {
+      this.commit();
+    }
+  },
 
   /**
-   * Initialize the ReadingList component.
+   * The item's URL as an nsIURI.
+   * @type nsIURI
    */
-  _init() {
-    log.debug("Init");
+  get uri() {
+    return this._properties.url ?
+           Services.io.newURI(this._properties.url, "", null) :
+           undefined;
+  },
+  set uri(val) {
+    this.url = val.spec;
+    if (this.list) {
+      this.commit();
+    }
+  },
 
-    // Initialize mock data
-    let mockData = JSON.parse(Preferences.get("browser.readinglist.mockData", "[]"));
-    for (let itemData of mockData) {
-      this._items.push(new Item(itemData));
+  /**
+   * The item's resolved URL.
+   * @type string
+   */
+  get resolvedURL() {
+    return this._properties.resolvedURL;
+  },
+  set resolvedURL(val) {
+    this._properties.resolvedURL = val;
+    if (this.list) {
+      this.commit();
     }
   },
 
   /**
-   * Add an event listener.
-   * @param {object} listener - Listener object to start notifying.
+   * The item's resolved URL as an nsIURI.
+   * @type nsIURI
    */
-  addListener(listener) {
-    this._listeners.add(listener);
+  get resolvedURI() {
+    return this._properties.resolvedURL ?
+           Services.io.newURI(this._properties.resolvedURL, "", null) :
+           undefined;
+  },
+  set resolvedURI(val) {
+    this.resolvedURL = val.spec;
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * The item's title.
+   * @type string
+   */
+  get title() {
+    return this._properties.title;
+  },
+  set title(val) {
+    this._properties.title = val;
+    if (this.list) {
+      this.commit();
+    }
   },
 
   /**
-   * Remove a specified event listener.
-   * @param {object} listener - Listener object to stop notifying.
+   * The item's resolved title.
+   * @type string
+   */
+  get resolvedTitle() {
+    return this._properties.resolvedTitle;
+  },
+  set resolvedTitle(val) {
+    this._properties.resolvedTitle = val;
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * The item's excerpt.
+   * @type string
    */
-  removeListener(listener) {
-    this._listeners.delete(listener);
+  get excerpt() {
+    return this._properties.excerpt;
+  },
+  set excerpt(val) {
+    this._properties.excerpt = val;
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * The item's status.
+   * @type integer
+   */
+  get status() {
+    return this._properties.status;
+  },
+  set status(val) {
+    this._properties.status = val;
+    if (this.list) {
+      this.commit();
+    }
   },
 
   /**
-   * Notify all registered event listeners of an event.
-   * @param {string} eventName - Event name, which will be used as a method name
-   *                             on listeners to call.
+   * Whether the item is a favorite.
+   * @type boolean
+   */
+  get favorite() {
+    return !!this._properties.favorite;
+  },
+  set favorite(val) {
+    this._properties.favorite = !!val;
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * Whether the item is an article.
+   * @type boolean
+   */
+  get isArticle() {
+    return !!this._properties.isArticle;
+  },
+  set isArticle(val) {
+    this._properties.isArticle = !!val;
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * The item's word count.
+   * @type integer
+   */
+  get wordCount() {
+    return this._properties.wordCount;
+  },
+  set wordCount(val) {
+    this._properties.wordCount = val;
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * Whether the item is unread.
+   * @type boolean
    */
-  _notifyListeners(eventName, ...args) {
-    for (let listener of this._listeners) {
-      if (typeof listener[eventName] != "function") {
-        continue;
-      }
+  get unread() {
+    return !!this._properties.unread;
+  },
+  set unread(val) {
+    this._properties.unread = !!val;
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * The date the item was added.
+   * @type Date
+   */
+  get addedOn() {
+    return this._properties.addedOn ?
+           new Date(this._properties.addedOn) :
+           undefined;
+  },
+  set addedOn(val) {
+    this._properties.addedOn = val.valueOf();
+    if (this.list) {
+      this.commit();
+    }
+  },
 
-      try {
-        listener[eventName](...args);
-      } catch (e) {
-        log.error(`Error calling listener.${eventName}`, e);
-      }
+  /**
+   * The date the item was stored.
+   * @type Date
+   */
+  get storedOn() {
+    return this._properties.storedOn ?
+           new Date(this._properties.storedOn) :
+           undefined;
+  },
+  set storedOn(val) {
+    this._properties.storedOn = val.valueOf();
+    if (this.list) {
+      this.commit();
+    }
+  },
+
+  /**
+   * The GUID of the device that marked the item read.
+   * @type string
+   */
+  get markedReadBy() {
+    return this._properties.markedReadBy;
+  },
+  set markedReadBy(val) {
+    this._properties.markedReadBy = val;
+    if (this.list) {
+      this.commit();
     }
   },
 
   /**
-   * Fetch the number of items that match a set of given conditions.
-   * TODO: Implement filtering, sorting, etc. Needs backend storage work.
-   *
-   * @param {Object} conditions Object specifying a set of conditions for
-   *                            filtering items.
-   * @return {Promise}
-   * @resolves {number}
+   * The date the item marked read.
+   * @type Date
    */
-  getNumItems(conditions = {unread: false}) {
-    return new Promise((resolve, reject) => {
-      resolve(this._items.length);
-    });
+  get markedReadOn() {
+    return this._properties.markedReadOn ?
+           new Date(this._properties.markedReadOn) :
+           undefined;
+  },
+  set markedReadOn(val) {
+    this._properties.markedReadOn = val.valueOf();
+    if (this.list) {
+      this.commit();
+    }
   },
 
   /**
-   * Fetch items matching a set of conditions, in a sorted list.
-   * TODO: Implement filtering, sorting, etc. Needs backend storage work.
-   *
-   * @return {Promise}
-   * @resolves {[Item]}
+   * The item's read position.
+   * @param integer
    */
-  getItems(options = {sort: "addedOn", conditions: {unread: false}}) {
-    return new Promise((resolve, reject) => {
-      resolve([...this._items]);
-    });
+  get readPosition() {
+    return this._properties.readPosition;
+  },
+  set readPosition(val) {
+    this._properties.readPosition = val;
+    if (this.list) {
+      this.commit();
+    }
   },
 
   /**
-   * Find an item based on its ID.
-   * TODO: Implement. Needs backend storage work.
+   * Sets the given properties of the item, optionally calling commit().
    *
-   * @return {Promise}
-   * @resolves {Item}
+   * @param props A simple object containing the properties to set.
+   * @param commit If true, commit() is called.
+   * @return Promise<null> If commit is true, resolved when the commit
+   *         completes; otherwise resolved immediately.
    */
-  getItemByID(url) {
-    return new Promise((resolve, reject) => {
-      resolve(null);
-    });
-  },
+  setProperties: Task.async(function* (props, commit=true) {
+    for (let name in props) {
+      this._properties[name] = props[name];
+    }
+    if (commit) {
+      yield this.commit();
+    }
+  }),
 
   /**
-   * Find an item based on its URL.
+   * Deletes the item from its list.
    *
-   * TODO: Implement. Needs backend storage work.
-   * TODO: Does this match original or resolved URL, or both?
-   * TODO: Should this just be a generic findItem API?
+   * @return Promise<null> Resolved when the list has been updated.
+   */
+  delete: Task.async(function* () {
+    this._ensureBelongsToList();
+    yield this.list.deleteItem(this);
+    this.delete = () => Promise.reject("The item has already been deleted");
+  }),
+
+  /**
+   * Notifies the item's list that the item has changed so that the list can
+   * update itself.
    *
-   * @return {Promise}
-   * @resolves {Item}
+   * @return Promise<null> Resolved when the list has been updated.
    */
-  getItemByURL(url) {
-    return new Promise((resolve, reject) => {
-      resolve(null);
-    });
+  commit: Task.async(function* () {
+    this._ensureBelongsToList();
+    yield this.list.updateItem(this);
+  }),
+
+  toJSON() {
+    return this._properties;
+  },
+
+  _ensureBelongsToList() {
+    if (!this.list) {
+      throw new Error("The item must belong to a reading list");
+    }
   },
 };
 
+/**
+ * An object that enumerates over items in a list.
+ *
+ * You can enumerate items a chunk at a time by passing counts to forEach() and
+ * items().  An iterator remembers where it left off, so for example calling
+ * forEach() with a count of 10 will enumerate the first 10 items, and then
+ * calling it again with 10 will enumerate the next 10 items.
+ *
+ * It's possible for an iterator's list to be modified between calls to
+ * forEach() and items().  If that happens, the iterator is no longer safe to
+ * use, so it's invalidated.  You can check whether an iterator is invalid by
+ * getting its `invalid` property.  Attempting to use an invalid iterator will
+ * throw an error.
+ *
+ * @param list The ReadingList to enumerate.
+ * @param optsList A variable number of options objects that control the items
+ *        that are matched.  See Options Objects.
+ */
+function ReadingListItemIterator(list, ...optsList) {
+  this.list = list;
+  this.index = 0;
+  this.optsList = optsList;
+}
 
-ReadingList._init();
+ReadingListItemIterator.prototype = {
+
+  /**
+   * True if it's not safe to use the iterator.  Attempting to use an invalid
+   * iterator will throw an error.
+   */
+  invalid: false,
+
+  /**
+   * Enumerates the items in the iterator starting at its current index.  The
+   * iterator is advanced by the number of items enumerated.
+   *
+   * @param callback Called for each item in the enumeration.  It's passed a
+   *        single object, a ReadingListItem.  It may return a promise; if so,
+   *        the callback will not be called for the next item until the promise
+   *        is resolved.
+   * @param count The maximum number of items to enumerate.  Pass -1 to
+   *        enumerate them all.
+   * @return Promise<null> Resolved when the enumeration completes *and* the
+   *         last promise returned by the callback is resolved.
+   */
+  forEach: Task.async(function* (callback, count=-1) {
+    this._ensureValid();
+    let optsList = clone(this.optsList);
+    optsList.push({
+      offset: this.index,
+      limit: count,
+    });
+    yield this.list.forEachItem(item => {
+      this.index++;
+      return callback(item);
+    }, ...optsList);
+  }),
+
+  /**
+   * Gets an array of items in the iterator starting at its current index.  The
+   * iterator is advanced by the number of items fetched.
+   *
+   * @param count The maximum number of items to get.
+   * @return Promise<array> The fetched items.
+   */
+  items: Task.async(function* (count) {
+    this._ensureValid();
+    let optsList = clone(this.optsList);
+    optsList.push({
+      offset: this.index,
+      limit: count,
+    });
+    let items = [];
+    yield this.list.forEachItem(item => items.push(item), ...optsList);
+    this.index += items.length;
+    return items;
+  }),
+
+  /**
+   * Invalidates the iterator.  You probably don't want to call this unless
+   * you're a ReadingList.
+   */
+  invalidate() {
+    this.invalid = true;
+  },
+
+  _ensureValid() {
+    if (this.invalid) {
+      throw new Error("The iterator has been invalidated");
+    }
+  },
+};
+
+function simpleObjectFromItem(item) {
+  let obj = {};
+  for (let name of ITEM_BASIC_PROPERTY_NAMES) {
+    if (name in item) {
+      obj[name] = item[name];
+    }
+  }
+  return obj;
+}
+
+function clone(obj) {
+  return Cu.cloneInto(obj, {}, { cloneFunctions: false });
+}
+
+Object.defineProperty(this, "ReadingList", {
+  get() {
+    if (!this._singleton) {
+      let store = new SQLiteStore("reading-list-temp.sqlite");
+      this._singleton = new ReadingListImpl(store);
+    }
+    return this._singleton;
+  },
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/SQLiteStore.jsm
@@ -0,0 +1,332 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "SQLiteStore",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
+  "resource:///modules/readinglist/ReadingList.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+  "resource://gre/modules/Sqlite.jsm");
+
+/**
+ * A SQLite Reading List store backed by a database on disk.  The database is
+ * created if it doesn't exist.
+ *
+ * @param pathRelativeToProfileDir The path of the database file relative to
+ *        the profile directory.
+ */
+this.SQLiteStore = function SQLiteStore(pathRelativeToProfileDir) {
+  this.pathRelativeToProfileDir = pathRelativeToProfileDir;
+  this._ensureConnection(pathRelativeToProfileDir);
+};
+
+this.SQLiteStore.prototype = {
+
+  /**
+   * Yields the number of items in the store that match the given options.
+   *
+   * @param optsList A variable number of options objects that control the
+   *        items that are matched.  See Options Objects in ReadingList.jsm.
+   * @return Promise<number> The number of matching items in the store.
+   *         Rejected with an Error on error.
+   */
+  count: Task.async(function* (...optsList) {
+    let [sql, args] = sqlFromOptions(optsList);
+    let count = 0;
+    let conn = yield this._connectionPromise;
+    yield conn.executeCached(`
+      SELECT COUNT(*) AS count FROM items ${sql};
+    `, args, row => count = row.getResultByName("count"));
+    return count;
+  }),
+
+  /**
+   * Enumerates the items in the store that match the given options.
+   *
+   * @param callback Called for each item in the enumeration.  It's passed a
+   *        single object, an item.
+   * @param optsList A variable number of options objects that control the
+   *        items that are matched.  See Options Objects in ReadingList.jsm.
+   * @return Promise<null> Resolved when the enumeration completes.  Rejected
+   *         with an Error on error.
+   */
+  forEachItem: Task.async(function* (callback, ...optsList) {
+    let [sql, args] = sqlFromOptions(optsList);
+    let colNames = ReadingList.ItemBasicPropertyNames;
+    let conn = yield this._connectionPromise;
+    yield conn.executeCached(`
+      SELECT ${colNames} FROM items ${sql};
+    `, args, row => callback(itemFromRow(row)));
+  }),
+
+  /**
+   * Adds an item to the store that isn't already present.  See
+   * ReadingList.prototype.addItems.
+   *
+   * @param items A simple object representing an item.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  addItem: Task.async(function* (item) {
+    let colNames = [];
+    let paramNames = [];
+    for (let propName in item) {
+      colNames.push(propName);
+      paramNames.push(`:${propName}`);
+    }
+    let conn = yield this._connectionPromise;
+    yield conn.executeCached(`
+      INSERT INTO items (${colNames}) VALUES (${paramNames});
+    `, item);
+  }),
+
+  /**
+   * Updates the properties of an item that's already present in the store.  See
+   * ReadingList.prototype.updateItem.
+   *
+   * @param item The item to update.  It must have a `url`.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  updateItem: Task.async(function* (item) {
+    let assignments = [];
+    for (let propName in item) {
+      assignments.push(`${propName} = :${propName}`);
+    }
+    let conn = yield this._connectionPromise;
+    yield conn.executeCached(`
+      UPDATE items SET ${assignments} WHERE url = :url;
+    `, item);
+  }),
+
+  /**
+   * Deletes an item from the store.
+   *
+   * @param url The URL string of the item to delete.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  deleteItemByURL: Task.async(function* (url) {
+    let conn = yield this._connectionPromise;
+    yield conn.executeCached(`
+      DELETE FROM items WHERE url = :url;
+    `, { url: url });
+  }),
+
+  /**
+   * Call this when you're done with the store.  Don't use it afterward.
+   */
+  destroy: Task.async(function* () {
+    let conn = yield this._connectionPromise;
+    yield conn.close();
+    this._connectionPromise = Promise.reject("Store destroyed");
+  }),
+
+  /**
+   * Creates the database connection if it hasn't been created already.
+   *
+   * @param pathRelativeToProfileDir The path of the database file relative to
+   *        the profile directory.
+   */
+  _ensureConnection: Task.async(function* (pathRelativeToProfileDir) {
+    if (!this._connectionPromise) {
+      this._connectionPromise = Task.spawn(function* () {
+        let conn = yield Sqlite.openConnection({
+          path: pathRelativeToProfileDir,
+          sharedMemoryCache: false,
+        });
+        Sqlite.shutdown.addBlocker("readinglist/SQLiteStore: Destroy",
+                                   this.destroy.bind(this));
+        yield conn.execute(`
+          PRAGMA locking_mode = EXCLUSIVE;
+        `);
+        yield this._checkSchema(conn);
+        return conn;
+      }.bind(this));
+    }
+  }),
+
+  // Promise<Sqlite.OpenedConnection>
+  _connectionPromise: null,
+
+  // The current schema version.
+  _schemaVersion: 1,
+
+  _checkSchema: Task.async(function* (conn) {
+    let version = parseInt(yield conn.getSchemaVersion());
+    for (; version < this._schemaVersion; version++) {
+      let meth = `_migrateSchema${version}To${version + 1}`;
+      yield this[meth](conn);
+    }
+    yield conn.setSchemaVersion(this._schemaVersion);
+  }),
+
+  _migrateSchema0To1: Task.async(function* (conn) {
+    yield conn.execute(`
+      PRAGMA journal_mode = wal;
+    `);
+    // 524288 bytes = 512 KiB
+    yield conn.execute(`
+      PRAGMA journal_size_limit = 524288;
+    `);
+    yield conn.execute(`
+      CREATE TABLE items (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        guid TEXT UNIQUE,
+        url TEXT NOT NULL UNIQUE,
+        resolvedURL TEXT UNIQUE,
+        lastModified INTEGER,
+        title TEXT,
+        resolvedTitle TEXT,
+        excerpt TEXT,
+        status INTEGER,
+        favorite BOOLEAN,
+        isArticle BOOLEAN,
+        wordCount INTEGER,
+        unread BOOLEAN,
+        addedBy TEXT,
+        addedOn INTEGER,
+        storedOn INTEGER,
+        markedReadBy TEXT,
+        markedReadOn INTEGER,
+        readPosition INTEGER
+      );
+    `);
+    yield conn.execute(`
+      CREATE INDEX items_addedOn ON items (addedOn);
+    `);
+    yield conn.execute(`
+      CREATE INDEX items_unread ON items (unread);
+    `);
+  }),
+};
+
+/**
+ * Returns a simple object whose properties are the
+ * ReadingList.ItemBasicPropertyNames properties lifted from the given row.
+ *
+ * @param row A mozIStorageRow.
+ * @return The item.
+ */
+function itemFromRow(row) {
+  let item = {};
+  for (let name of ReadingList.ItemBasicPropertyNames) {
+    item[name] = row.getResultByName(name);
+  }
+  return item;
+}
+
+/**
+ * Returns the back part of a SELECT statement generated from the given list of
+ * options.
+ *
+ * @param optsList See Options Objects in ReadingList.jsm.
+ * @return An array [sql, args].  sql is a string of SQL.  args is an object
+ *         that contains arguments for all the parameters in sql.
+ */
+function sqlFromOptions(optsList) {
+  // We modify the options objects, which were passed in by the store client, so
+  // clone them first.
+  optsList = Cu.cloneInto(optsList, {}, { cloneFunctions: false });
+
+  let sort;
+  let sortDir;
+  let limit;
+  let offset;
+  for (let opts of optsList) {
+    if ("sort" in opts) {
+      sort = opts.sort;
+      delete opts.sort;
+    }
+    if ("descending" in opts) {
+      if (opts.descending) {
+        sortDir = "DESC";
+      }
+      delete opts.descending;
+    }
+    if ("limit" in opts) {
+      limit = opts.limit;
+      delete opts.limit;
+    }
+    if ("offset" in opts) {
+      offset = opts.offset;
+      delete opts.offset;
+    }
+  }
+
+  let fragments = [];
+
+  if (sort) {
+    sortDir = sortDir || "ASC";
+    fragments.push(`ORDER BY ${sort} ${sortDir}`);
+  }
+  if (limit) {
+    fragments.push(`LIMIT ${limit}`);
+    if (offset) {
+      fragments.push(`OFFSET ${offset}`);
+    }
+  }
+
+  let args = {};
+
+  function uniqueParamName(name) {
+    if (name in args) {
+      for (let i = 1; ; i++) {
+        let newName = `${name}_${i}`;
+        if (!(newName in args)) {
+          return newName;
+        }
+      }
+    }
+    return name;
+  }
+
+  // Build a WHERE clause for the remaining properties.  Assume they all refer
+  // to columns.  (If they don't, the SQL query will fail.)
+  let disjunctions = [];
+  for (let opts of optsList) {
+    let conjunctions = [];
+    for (let key in opts) {
+      if (Array.isArray(opts[key])) {
+        // Convert arrays to IN expressions.  e.g., { guid: ['a', 'b', 'c'] }
+        // becomes "guid IN (:guid, :guid_1, :guid_2)".  The guid_i arguments
+        // are added to opts.
+        let array = opts[key];
+        let params = [];
+        for (let i = 0; i < array.length; i++) {
+          let paramName = uniqueParamName(key);
+          params.push(`:${paramName}`);
+          args[paramName] = array[i];
+        }
+        conjunctions.push(`${key} IN (${params})`);
+      }
+      else {
+        let paramName = uniqueParamName(key);
+        conjunctions.push(`${key} = :${paramName}`);
+        args[paramName] = opts[key];
+      }
+    }
+    let conjunction = conjunctions.join(" AND ");
+    if (conjunction) {
+      disjunctions.push(`(${conjunction})`);
+    }
+  }
+  let disjunction = disjunctions.join(" OR ");
+  if (disjunction) {
+    let where = `WHERE ${disjunction}`;
+    fragments = [where].concat(fragments);
+  }
+
+  let sql = fragments.join(" ");
+  return [sql, args];
+}
--- a/browser/components/readinglist/moz.build
+++ b/browser/components/readinglist/moz.build
@@ -1,16 +1,17 @@
 # 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/.
 
 JAR_MANIFESTS += ['jar.mn']
 
 EXTRA_JS_MODULES.readinglist += [
     'ReadingList.jsm',
+    'SQLiteStore.jsm',
 ]
 
 TESTING_JS_MODULES += [
     'test/ReadingListTestUtils.jsm',
 ]
 
 BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
 
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/xpcshell/test_ReadingList.js
@@ -0,0 +1,642 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let gDBFile = do_get_profile();
+
+Cu.import("resource:///modules/readinglist/ReadingList.jsm");
+Cu.import("resource:///modules/readinglist/SQLiteStore.jsm");
+Cu.import("resource://gre/modules/Sqlite.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+
+var gList;
+var gItems;
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function* prepare() {
+  gList = ReadingList;
+  Assert.ok(gList);
+  gDBFile.append(gList._store.pathRelativeToProfileDir);
+  do_register_cleanup(() => {
+    if (gDBFile.exists()) {
+      gDBFile.remove(true);
+    }
+  });
+
+  gItems = [];
+  for (let i = 0; i < 3; i++) {
+    gItems.push({
+      list: gList,
+      guid: `guid${i}`,
+      url: `http://example.com/${i}`,
+      resolvedURL: `http://example.com/resolved/${i}`,
+      title: `title ${i}`,
+      excerpt: `excerpt ${i}`,
+      unread: 0,
+      addedOn: Date.now(),
+      lastModified: Date.now(),
+      favorite: 0,
+      isArticle: 1,
+      storedOn: Date.now(),
+    });
+  }
+
+  for (let item of gItems) {
+    yield gList.addItem(item);
+  }
+});
+
+add_task(function* item_properties() {
+  // get an item
+  let iter = gList.iterator({
+    sort: "guid",
+  });
+  let item = (yield iter.items(1))[0];
+  Assert.ok(item);
+
+  Assert.ok(item.uri);
+  Assert.ok(item.uri instanceof Ci.nsIURI);
+  Assert.equal(item.uri.spec, item.url);
+
+  Assert.ok(item.resolvedURI);
+  Assert.ok(item.resolvedURI instanceof Ci.nsIURI);
+  Assert.equal(item.resolvedURI.spec, item.resolvedURL);
+
+  Assert.ok(item.lastModified);
+  Assert.ok(item.lastModified instanceof Cu.getGlobalForObject(ReadingList).Date);
+
+  Assert.ok(item.addedOn);
+  Assert.ok(item.addedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
+
+  Assert.ok(item.storedOn);
+  Assert.ok(item.storedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
+
+  Assert.ok(typeof(item.favorite) == "boolean");
+  Assert.ok(typeof(item.isArticle) == "boolean");
+  Assert.ok(typeof(item.unread) == "boolean");
+});
+
+add_task(function* constraints() {
+  // add an item again
+  let err = null;
+  try {
+    yield gList.addItem(gItems[0]);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err);
+
+  // add a new item with an existing guid
+  function kindOfClone(item) {
+    let newItem = {};
+    for (let prop in item) {
+      newItem[prop] = item[prop];
+      if (typeof(newItem[prop]) == "string") {
+        newItem[prop] += " -- make this string different";
+      }
+    }
+    return newItem;
+  }
+  let item = kindOfClone(gItems[0]);
+  item.guid = gItems[0].guid;
+  err = null;
+  try {
+    yield gList.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err);
+
+  // add a new item with an existing url
+  item = kindOfClone(gItems[0]);
+  item.url = gItems[0].url;
+  err = null;
+  try {
+    yield gList.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err);
+
+  // update an item with an existing url
+  item.guid = gItems[1].guid;
+  err = null;
+  try {
+    yield gList.updateItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err);
+
+  // add a new item with an existing resolvedURL
+  item = kindOfClone(gItems[0]);
+  item.resolvedURL = gItems[0].resolvedURL;
+  err = null;
+  try {
+    yield gList.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err);
+
+  // update an item with an existing resolvedURL
+  item.url = gItems[1].url;
+  err = null;
+  try {
+    yield gList.updateItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err);
+
+  // add a new item with no guid, which is allowed
+  item = kindOfClone(gItems[0]);
+  delete item.guid;
+  err = null;
+  try {
+    yield gList.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  Assert.ok(!err, err ? err.message : undefined);
+  let item1 = item;
+
+  // add a second item with no guid, which is allowed
+  item = kindOfClone(gItems[1]);
+  delete item.guid;
+  err = null;
+  try {
+    yield gList.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  Assert.ok(!err, err ? err.message : undefined);
+  let item2 = item;
+
+  // Delete both items since other tests assume the store contains only gItems.
+  item1.list = gList;
+  item2.list = gList;
+  yield gList.deleteItem(item1);
+  yield gList.deleteItem(item2);
+  let items = [];
+  yield gList.forEachItem(i => items.push(i), { url: [item1.url, item2.url] });
+  Assert.equal(items.length, 0);
+
+  // add a new item with no url
+  item = kindOfClone(gItems[0]);
+  delete item.url;
+  err = null;
+  try {
+    yield gList.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err);
+});
+
+add_task(function* count() {
+  let count = yield gList.count();
+  Assert.equal(count, gItems.length);
+
+  count = yield gList.count({
+    guid: gItems[0].guid,
+  });
+  Assert.equal(count, 1);
+});
+
+add_task(function* forEachItem() {
+  // all items
+  let items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    sort: "guid",
+  });
+  checkItems(items, gItems);
+
+  // first item
+  items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    limit: 1,
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(0, 1));
+
+  // last item
+  items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    limit: 1,
+    sort: "guid",
+    descending: true,
+  });
+  checkItems(items, gItems.slice(gItems.length - 1, gItems.length));
+
+  // match on a scalar property
+  items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    guid: gItems[0].guid,
+  });
+  checkItems(items, gItems.slice(0, 1));
+
+  // match on an array
+  items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    guid: gItems.map(i => i.guid),
+    sort: "guid",
+  });
+  checkItems(items, gItems);
+
+  // match on AND'ed properties
+  items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    guid: gItems.map(i => i.guid),
+    title: gItems[0].title,
+    sort: "guid",
+  });
+  checkItems(items, [gItems[0]]);
+
+  // match on OR'ed properties
+  items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    guid: gItems[1].guid,
+    sort: "guid",
+  }, {
+    guid: gItems[0].guid,
+  });
+  checkItems(items, [gItems[0], gItems[1]]);
+
+  // match on AND'ed and OR'ed properties
+  items = [];
+  yield gList.forEachItem(item => items.push(item), {
+    guid: gItems.map(i => i.guid),
+    title: gItems[1].title,
+    sort: "guid",
+  }, {
+    guid: gItems[0].guid,
+  });
+  checkItems(items, [gItems[0], gItems[1]]);
+});
+
+add_task(function* forEachItem_promises() {
+  // promises resolved immediately
+  let items = [];
+  yield gList.forEachItem(item => {
+    items.push(item);
+    return Promise.resolve();
+  }, {
+    sort: "guid",
+  });
+  checkItems(items, gItems);
+
+  // promises resolved after a delay
+  items = [];
+  let i = 0;
+  let promises = [];
+  yield gList.forEachItem(item => {
+    items.push(item);
+    // The previous promise should have been resolved by now.
+    if (i > 0) {
+      Assert.equal(promises[i - 1], null);
+    }
+    // Make a new promise that should continue iteration when resolved.
+    let this_i = i++;
+    let promise = new Promise(resolve => {
+      // Resolve the promise one second from now.  The idea is that if
+      // forEachItem works correctly, then the callback should not be called
+      // again before the promise resolves -- before one second elapases.
+      // Maybe there's a better way to do this that doesn't hinge on timeouts.
+      setTimeout(() => {
+        promises[this_i] = null;
+        resolve();
+      }, 0);
+    });
+    promises.push(promise);
+    return promise;
+  }, {
+    sort: "guid",
+  });
+  checkItems(items, gItems);
+});
+
+add_task(function* iterator_forEach() {
+  // no limit
+  let items = [];
+  let iter = gList.iterator({
+    sort: "guid",
+  });
+  yield iter.forEach(item => items.push(item));
+  checkItems(items, gItems);
+
+  // limit one each time
+  items = [];
+  iter = gList.iterator({
+    sort: "guid",
+  });
+  for (let i = 0; i < gItems.length; i++) {
+    yield iter.forEach(item => items.push(item), 1);
+    checkItems(items, gItems.slice(0, i + 1));
+  }
+  yield iter.forEach(item => items.push(item), 100);
+  checkItems(items, gItems);
+  yield iter.forEach(item => items.push(item));
+  checkItems(items, gItems);
+
+  // match on a scalar property
+  items = [];
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems[0].guid,
+  });
+  yield iter.forEach(item => items.push(item));
+  checkItems(items, [gItems[0]]);
+
+  // match on an array
+  items = [];
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems.map(i => i.guid),
+  });
+  yield iter.forEach(item => items.push(item));
+  checkItems(items, gItems);
+
+  // match on AND'ed properties
+  items = [];
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems.map(i => i.guid),
+    title: gItems[0].title,
+  });
+  yield iter.forEach(item => items.push(item));
+  checkItems(items, [gItems[0]]);
+
+  // match on OR'ed properties
+  items = [];
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems[1].guid,
+  }, {
+    guid: gItems[0].guid,
+  });
+  yield iter.forEach(item => items.push(item));
+  checkItems(items, [gItems[0], gItems[1]]);
+
+  // match on AND'ed and OR'ed properties
+  items = [];
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems.map(i => i.guid),
+    title: gItems[1].title,
+  }, {
+    guid: gItems[0].guid,
+  });
+  yield iter.forEach(item => items.push(item));
+  checkItems(items, [gItems[0], gItems[1]]);
+});
+
+add_task(function* iterator_items() {
+  // no limit
+  let iter = gList.iterator({
+    sort: "guid",
+  });
+  let items = yield iter.items(gItems.length);
+  checkItems(items, gItems);
+  items = yield iter.items(100);
+  checkItems(items, []);
+
+  // limit one each time
+  iter = gList.iterator({
+    sort: "guid",
+  });
+  for (let i = 0; i < gItems.length; i++) {
+    items = yield iter.items(1);
+    checkItems(items, gItems.slice(i, i + 1));
+  }
+  items = yield iter.items(100);
+  checkItems(items, []);
+
+  // match on a scalar property
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems[0].guid,
+  });
+  items = yield iter.items(gItems.length);
+  checkItems(items, [gItems[0]]);
+
+  // match on an array
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems.map(i => i.guid),
+  });
+  items = yield iter.items(gItems.length);
+  checkItems(items, gItems);
+
+  // match on AND'ed properties
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems.map(i => i.guid),
+    title: gItems[0].title,
+  });
+  items = yield iter.items(gItems.length);
+  checkItems(items, [gItems[0]]);
+
+  // match on OR'ed properties
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems[1].guid,
+  }, {
+    guid: gItems[0].guid,
+  });
+  items = yield iter.items(gItems.length);
+  checkItems(items, [gItems[0], gItems[1]]);
+
+  // match on AND'ed and OR'ed properties
+  iter = gList.iterator({
+    sort: "guid",
+    guid: gItems.map(i => i.guid),
+    title: gItems[1].title,
+  }, {
+    guid: gItems[0].guid,
+  });
+  items = yield iter.items(gItems.length);
+  checkItems(items, [gItems[0], gItems[1]]);
+});
+
+add_task(function* iterator_forEach_promise() {
+  // promises resolved immediately
+  let items = [];
+  let iter = gList.iterator({
+    sort: "guid",
+  });
+  yield iter.forEach(item => {
+    items.push(item);
+    return Promise.resolve();
+  });
+  checkItems(items, gItems);
+
+  // promises resolved after a delay
+  // See forEachItem_promises above for comments on this part.
+  items = [];
+  let i = 0;
+  let promises = [];
+  iter = gList.iterator({
+    sort: "guid",
+  });
+  yield iter.forEach(item => {
+    items.push(item);
+    if (i > 0) {
+      Assert.equal(promises[i - 1], null);
+    }
+    let this_i = i++;
+    let promise = new Promise(resolve => {
+      setTimeout(() => {
+        promises[this_i] = null;
+        resolve();
+      }, 0);
+    });
+    promises.push(promise);
+    return promise;
+  });
+  checkItems(items, gItems);
+});
+
+add_task(function* updateItem() {
+  // get an item
+  let items = [];
+  yield gList.forEachItem(i => items.push(i), {
+    guid: gItems[0].guid,
+  });
+  Assert.equal(items.length, 1);
+  let item = {
+    _properties: items[0]._properties,
+    list: items[0].list,
+  };
+
+  // update its title
+  let newTitle = "updateItem new title";
+  Assert.notEqual(item.title, newTitle);
+  item._properties.title = newTitle;
+  yield gList.updateItem(item);
+
+  // get the item again
+  items = [];
+  yield gList.forEachItem(i => items.push(i), {
+    guid: gItems[0].guid,
+  });
+  Assert.equal(items.length, 1);
+  item = items[0];
+  Assert.equal(item.title, newTitle);
+});
+
+add_task(function* item_setProperties() {
+  // get an item
+  let iter = gList.iterator({
+    sort: "guid",
+  });
+  let item = (yield iter.items(1))[0];
+  Assert.ok(item);
+
+  // item.setProperties(commit=false).  After fetching the item again, its title
+  // should be the old title.
+  let oldTitle = item.title;
+  let newTitle = "item_setProperties title 1";
+  Assert.notEqual(oldTitle, newTitle);
+  item.setProperties({ title: newTitle }, false);
+  Assert.equal(item.title, newTitle);
+  iter = gList.iterator({
+    sort: "guid",
+  });
+  let sameItem = (yield iter.items(1))[0];
+  Assert.ok(item === sameItem);
+  Assert.equal(sameItem.title, oldTitle);
+
+  // item.setProperties(commit=true).  After fetching the item again, its title
+  // should be the new title.
+  newTitle = "item_setProperties title 2";
+  item.setProperties({ title: newTitle }, true);
+  Assert.equal(item.title, newTitle);
+  iter = gList.iterator({
+    sort: "guid",
+  });
+  sameItem = (yield iter.items(1))[0];
+  Assert.ok(item === sameItem);
+  Assert.equal(sameItem.title, newTitle);
+
+  // Set item.title directly.  After fetching the item again, its title should
+  // be the new title.
+  newTitle = "item_setProperties title 3";
+  item.title = newTitle;
+  Assert.equal(item.title, newTitle);
+  iter = gList.iterator({
+    sort: "guid",
+  });
+  sameItem = (yield iter.items(1))[0];
+  Assert.ok(item === sameItem);
+  Assert.equal(sameItem.title, newTitle);
+});
+
+// This test deletes items so it should probably run last.
+add_task(function* deleteItem() {
+  // delete first item with item.delete()
+  let iter = gList.iterator({
+    sort: "guid",
+  });
+  let item = (yield iter.items(1))[0];
+  Assert.ok(item);
+  item.delete();
+  gItems[0].list = null;
+  Assert.equal((yield gList.count()), gItems.length - 1);
+  let items = [];
+  yield gList.forEachItem(i => items.push(i), {
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(1));
+
+  // delete second item with list.deleteItem()
+  yield gList.deleteItem(gItems[1]);
+  gItems[1].list = null;
+  Assert.equal((yield gList.count()), gItems.length - 2);
+  items = [];
+  yield gList.forEachItem(i => items.push(i), {
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(2));
+
+  // delete third item with list.deleteItem()
+  yield gList.deleteItem(gItems[2]);
+  gItems[2].list = null;
+  Assert.equal((yield gList.count()), gItems.length - 3);
+  items = [];
+  yield gList.forEachItem(i => items.push(i), {
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(3));
+});
+
+function checkItems(actualItems, expectedItems) {
+  Assert.equal(actualItems.length, expectedItems.length);
+  for (let i = 0; i < expectedItems.length; i++) {
+    for (let prop in expectedItems[i]) {
+      if (prop != "list") {
+        Assert.ok(prop in actualItems[i]._properties, prop);
+        Assert.equal(actualItems[i]._properties[prop], expectedItems[i][prop]);
+      }
+    }
+    Assert.equal(actualItems[i].list, expectedItems[i].list);
+  }
+}
+
+function checkError(err) {
+  Assert.ok(err);
+  Assert.ok(err instanceof Cu.getGlobalForObject(Sqlite).Error);
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/xpcshell/test_SQLiteStore.js
@@ -0,0 +1,314 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource:///modules/readinglist/SQLiteStore.jsm");
+Cu.import("resource://gre/modules/Sqlite.jsm");
+
+var gStore;
+var gItems;
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function* prepare() {
+  let basename = "reading-list-test.sqlite";
+  let dbFile = do_get_profile();
+  dbFile.append(basename);
+  function removeDB() {
+    if (dbFile.exists()) {
+      dbFile.remove(true);
+    }
+  }
+  removeDB();
+  do_register_cleanup(removeDB);
+
+  gStore = new SQLiteStore(dbFile.path);
+
+  gItems = [];
+  for (let i = 0; i < 3; i++) {
+    gItems.push({
+      guid: `guid${i}`,
+      url: `http://example.com/${i}`,
+      resolvedURL: `http://example.com/resolved/${i}`,
+      title: `title ${i}`,
+      excerpt: `excerpt ${i}`,
+      unread: true,
+      addedOn: i,
+    });
+  }
+
+  for (let item of gItems) {
+    yield gStore.addItem(item);
+  }
+});
+
+add_task(function* constraints() {
+  // add an item again
+  let err = null;
+  try {
+    yield gStore.addItem(gItems[0]);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err, "UNIQUE constraint failed");
+
+  // add a new item with an existing guid
+  function kindOfClone(item) {
+    let newItem = {};
+    for (let prop in item) {
+      newItem[prop] = item[prop];
+      if (typeof(newItem[prop]) == "string") {
+        newItem[prop] += " -- make this string different";
+      }
+    }
+    return newItem;
+  }
+  let item = kindOfClone(gItems[0]);
+  item.guid = gItems[0].guid;
+  err = null;
+  try {
+    yield gStore.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err, "UNIQUE constraint failed: items.guid");
+
+  // add a new item with an existing url
+  item = kindOfClone(gItems[0]);
+  item.url = gItems[0].url;
+  err = null;
+  try {
+    yield gStore.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err, "UNIQUE constraint failed: items.url");
+
+  // update an item with an existing url
+  item.guid = gItems[1].guid;
+  err = null;
+  try {
+    yield gStore.updateItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  // The failure actually happens on items.guid, not items.url, because the item
+  // is first looked up by url, and then its other properties are updated on the
+  // resulting row.
+  checkError(err, "UNIQUE constraint failed: items.guid");
+
+  // add a new item with an existing resolvedURL
+  item = kindOfClone(gItems[0]);
+  item.resolvedURL = gItems[0].resolvedURL;
+  err = null;
+  try {
+    yield gStore.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err, "UNIQUE constraint failed: items.resolvedURL");
+
+  // update an item with an existing resolvedURL
+  item.url = gItems[1].url;
+  err = null;
+  try {
+    yield gStore.updateItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err, "UNIQUE constraint failed: items.resolvedURL");
+
+  // add a new item with no guid, which is allowed
+  item = kindOfClone(gItems[0]);
+  delete item.guid;
+  err = null;
+  try {
+    yield gStore.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  Assert.ok(!err, err ? err.message : undefined);
+  let url1 = item.url;
+
+  // add a second new item with no guid, which is allowed
+  item = kindOfClone(gItems[1]);
+  delete item.guid;
+  err = null;
+  try {
+    yield gStore.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  Assert.ok(!err, err ? err.message : undefined);
+  let url2 = item.url;
+
+  // Delete both items since other tests assume the store contains only gItems.
+  yield gStore.deleteItemByURL(url1);
+  yield gStore.deleteItemByURL(url2);
+  let items = [];
+  yield gStore.forEachItem(i => items.push(i), { url: [url1, url2] });
+  Assert.equal(items.length, 0);
+
+  // add a new item with no url, which is not allowed
+  item = kindOfClone(gItems[0]);
+  delete item.url;
+  err = null;
+  try {
+    yield gStore.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  checkError(err, "NOT NULL constraint failed: items.url");
+});
+
+add_task(function* count() {
+  let count = yield gStore.count();
+  Assert.equal(count, gItems.length);
+
+  count = yield gStore.count({
+    guid: gItems[0].guid,
+  });
+  Assert.equal(count, 1);
+});
+
+add_task(function* forEachItem() {
+  // all items
+  let items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    sort: "guid",
+  });
+  checkItems(items, gItems);
+
+  // first item
+  items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    limit: 1,
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(0, 1));
+
+  // last item
+  items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    limit: 1,
+    sort: "guid",
+    descending: true,
+  });
+  checkItems(items, gItems.slice(gItems.length - 1, gItems.length));
+
+  // match on a scalar property
+  items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    guid: gItems[0].guid,
+  });
+  checkItems(items, gItems.slice(0, 1));
+
+  // match on an array
+  items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    guid: gItems.map(i => i.guid),
+    sort: "guid",
+  });
+  checkItems(items, gItems);
+
+  // match on AND'ed properties
+  items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    guid: gItems.map(i => i.guid),
+    title: gItems[0].title,
+    sort: "guid",
+  });
+  checkItems(items, [gItems[0]]);
+
+  // match on OR'ed properties
+  items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    guid: gItems[1].guid,
+    sort: "guid",
+  }, {
+    guid: gItems[0].guid,
+  });
+  checkItems(items, [gItems[0], gItems[1]]);
+
+  // match on AND'ed and OR'ed properties
+  items = [];
+  yield gStore.forEachItem(item => items.push(item), {
+    guid: gItems.map(i => i.guid),
+    title: gItems[1].title,
+    sort: "guid",
+  }, {
+    guid: gItems[0].guid,
+  });
+  checkItems(items, [gItems[0], gItems[1]]);
+});
+
+add_task(function* updateItem() {
+  let newTitle = "a new title";
+  gItems[0].title = newTitle;
+  yield gStore.updateItem(gItems[0]);
+  let item;
+  yield gStore.forEachItem(i => item = i, {
+    guid: gItems[0].guid,
+  });
+  Assert.ok(item);
+  Assert.equal(item.title, gItems[0].title);
+});
+
+// This test deletes items so it should probably run last.
+add_task(function* deleteItemByURL() {
+  // delete first item
+  yield gStore.deleteItemByURL(gItems[0].url);
+  Assert.equal((yield gStore.count()), gItems.length - 1);
+  let items = [];
+  yield gStore.forEachItem(i => items.push(i), {
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(1));
+
+  // delete second item
+  yield gStore.deleteItemByURL(gItems[1].url);
+  Assert.equal((yield gStore.count()), gItems.length - 2);
+  items = [];
+  yield gStore.forEachItem(i => items.push(i), {
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(2));
+
+  // delete third item
+  yield gStore.deleteItemByURL(gItems[2].url);
+  Assert.equal((yield gStore.count()), gItems.length - 3);
+  items = [];
+  yield gStore.forEachItem(i => items.push(i), {
+    sort: "guid",
+  });
+  checkItems(items, gItems.slice(3));
+});
+
+function checkItems(actualItems, expectedItems) {
+  Assert.equal(actualItems.length, expectedItems.length);
+  for (let i = 0; i < expectedItems.length; i++) {
+    for (let prop in expectedItems[i]) {
+      Assert.ok(prop in actualItems[i], prop);
+      Assert.equal(actualItems[i][prop], expectedItems[i][prop]);
+    }
+  }
+}
+
+function checkError(err, expectedMsgSubstring) {
+  Assert.ok(err);
+  Assert.ok(err instanceof Cu.getGlobalForObject(Sqlite).Error);
+  Assert.ok(err.message);
+  Assert.ok(err.message.indexOf(expectedMsgSubstring) >= 0, err.message);
+}
--- a/browser/components/readinglist/test/xpcshell/xpcshell.ini
+++ b/browser/components/readinglist/test/xpcshell/xpcshell.ini
@@ -1,5 +1,7 @@
 [DEFAULT]
 head = head.js
 firefox-appdir = browser
 
+[test_ReadingList.js]
 [test_scheduler.js]
+[test_SQLiteStore.js]