Bug 871445 - patch 3 - DataStore: getChanges + revisionID, r=ehsan, sr=mounir, r=bent
authorAndrea Marchesini <amarchesini@mozilla.com>
Wed, 02 Oct 2013 13:27:11 -0400
changeset 158362 46b906bb782b2e12d82134ef205e6a11666205dc
parent 158361 6e29aa59b5a6181ea6478176c377f7b5442a51de
child 158363 ce3eceb7fa125532171e5985e5a4b1e6ae284b00
push id4537
push userlsblakk@mozilla.com
push dateMon, 28 Oct 2013 22:18:47 +0000
treeherdermozilla-aurora@60c6fd67470e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersehsan, mounir, bent
bugs871445
milestone27.0a1
Bug 871445 - patch 3 - DataStore: getChanges + revisionID, r=ehsan, sr=mounir, r=bent
dom/base/IndexedDBHelper.jsm
dom/datastore/DataStore.jsm
dom/datastore/DataStoreDB.jsm
dom/datastore/DataStoreService.js
dom/datastore/tests/Makefile.in
dom/datastore/tests/test_revision.html
--- a/dom/base/IndexedDBHelper.jsm
+++ b/dom/base/IndexedDBHelper.jsm
@@ -107,19 +107,27 @@ IndexedDBHelper.prototype = {
    *        Success callback to call on a successful transaction commit.
    *        The result is stored in txn.result.
    * @param failureCb
    *        Error callback to call when an error is encountered.
    */
   newTxn: function newTxn(txn_type, store_name, callback, successCb, failureCb) {
     this.ensureDB(function () {
       if (DEBUG) debug("Starting new transaction" + txn_type);
-      let txn = this._db.transaction(this.dbStoreNames, txn_type);
+      let txn = this._db.transaction(Array.isArray(store_name) ? store_name : this.dbStoreNames, txn_type);
       if (DEBUG) debug("Retrieving object store", this.dbName);
-      let store = txn.objectStore(store_name);
+      let stores;
+      if (Array.isArray(store_name)) {
+        stores = [];
+        for (let i = 0; i < store_name.length; ++i) {
+          stores.push(txn.objectStore(store_name[i]));
+        }
+      } else {
+        stores = txn.objectStore(store_name);
+      }
 
       txn.oncomplete = function (event) {
         if (DEBUG) debug("Transaction complete. Returning to callback.");
         if (successCb) {
           successCb(txn.result);
         }
       };
 
@@ -132,17 +140,17 @@ IndexedDBHelper.prototype = {
         if (failureCb) {
           if (event.target.error) {
             failureCb(event.target.error.name);
           } else {
             failureCb("UnknownError");
           }
         }
       };
-      callback(txn, store);
+      callback(txn, stores);
     }.bind(this), failureCb);
   },
 
   /**
    * Initialize the DB. Does not call open.
    *
    * @param aDBName
    *        DB name for the open call.
--- a/dom/datastore/DataStore.jsm
+++ b/dom/datastore/DataStore.jsm
@@ -9,20 +9,30 @@
 var EXPORTED_SYMBOLS = ["DataStore"];
 
 function debug(s) {
   // dump('DEBUG DataStore: ' + s + '\n');
 }
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
+const REVISION_ADDED = "added";
+const REVISION_UPDATED = "updated";
+const REVISION_REMOVED = "removed";
+const REVISION_VOID = "void";
+
 Cu.import("resource://gre/modules/DataStoreDB.jsm");
 Cu.import("resource://gre/modules/ObjectWrapper.jsm");
 Cu.import('resource://gre/modules/Services.jsm');
 
+/* Helper function */
+function createDOMError(aWindow, aEvent) {
+  return new aWindow.DOMError(aEvent.target.error.name);
+}
+
 /* DataStore object */
 
 function DataStore(aAppId, aName, aOwner, aReadOnly, aGlobalScope) {
   this.appId = aAppId;
   this.name = aName;
   this.owner = aOwner;
   this.readOnly = aReadOnly;
 
@@ -30,106 +40,163 @@ function DataStore(aAppId, aName, aOwner
   this.db.init(aOwner, aName, aGlobalScope);
 }
 
 DataStore.prototype = {
   appId: null,
   name: null,
   owner: null,
   readOnly: null,
+  revisionId: null,
 
   newDBPromise: function(aWindow, aTxnType, aFunction) {
     let db = this.db;
     return new aWindow.Promise(function(aResolve, aReject) {
       debug("DBPromise started");
       db.txn(
         aTxnType,
-        function(aTxn, aStore) {
+        function(aTxn, aStore, aRevisionStore) {
           debug("DBPromise success");
-          aFunction(aResolve, aReject, aTxn, aStore);
+          aFunction(aResolve, aReject, aTxn, aStore, aRevisionStore);
         },
-        function() {
+        function(aEvent) {
           debug("DBPromise error");
-          aReject(new aWindow.DOMError("InvalidStateError"));
+          aReject(createDOMError(aWindow, aEvent));
         }
       );
     });
   },
 
-  getInternal: function(aWindow, aResolve, aReject, aStore, aId) {
+  getInternal: function(aWindow, aResolve, aStore, aId) {
     debug("GetInternal " + aId);
 
     let request = aStore.get(aId);
     request.onsuccess = function(aEvent) {
       debug("GetInternal success. Record: " + aEvent.target.result);
       aResolve(ObjectWrapper.wrap(aEvent.target.result, aWindow));
     };
-
-    request.onerror = function(aEvent) {
-      debug("GetInternal error");
-      aReject(new aWindow.DOMError(aEvent.target.error.name));
-    };
   },
 
-  updateInternal: function(aWindow, aResolve, aReject, aStore, aId, aObj) {
+  updateInternal: function(aResolve, aStore, aRevisionStore, aId, aObj) {
     debug("UpdateInternal " + aId);
 
+    let self = this;
     let request = aStore.put(aObj, aId);
     request.onsuccess = function(aEvent) {
       debug("UpdateInternal success");
-      // No wrap here because the result is always a int.
-      aResolve(aEvent.target.result);
-    };
-    request.onerror = function(aEvent) {
-      debug("UpdateInternal error");
-      aReject(new aWindow.DOMError(aEvent.target.error.name));
+
+      self.addRevision(aRevisionStore, aId, REVISION_UPDATED,
+        function() {
+          debug("UpdateInternal - revisionId increased");
+          // No wrap here because the result is always a int.
+          aResolve(aEvent.target.result);
+        }
+      );
     };
   },
 
-  addInternal: function(aWindow, aResolve, aReject, aStore, aObj) {
+  addInternal: function(aResolve, aStore, aRevisionStore, aObj) {
     debug("AddInternal");
 
+    let self = this;
     let request = aStore.put(aObj);
     request.onsuccess = function(aEvent) {
       debug("Request successful. Id: " + aEvent.target.result);
-      // No wrap here because the result is always a int.
-      aResolve(aEvent.target.result);
+      self.addRevision(aRevisionStore, aEvent.target.result, REVISION_ADDED,
+        function() {
+          debug("AddInternal - revisionId increased");
+          // No wrap here because the result is always a int.
+          aResolve(aEvent.target.result);
+        }
+      );
     };
-    request.onerror = function(aEvent) {
-      debug("AddInternal error");
-      aReject(new aWindow.DOMError(aEvent.target.error.name));
+  },
+
+  removeInternal: function(aResolve, aStore, aRevisionStore, aId) {
+    debug("RemoveInternal");
+
+    let self = this;
+    let request = aStore.get(aId);
+    request.onsuccess = function(aEvent) {
+      debug("RemoveInternal success. Record: " + aEvent.target.result);
+      if (aEvent.target.result === undefined) {
+        aResolve(false);
+        return;
+      }
+
+      let deleteRequest = aStore.delete(aId);
+      deleteRequest.onsuccess = function() {
+        debug("RemoveInternal success");
+        self.addRevision(aRevisionStore, aId, REVISION_REMOVED,
+          function() {
+            aResolve(true);
+          }
+        );
+      };
     };
   },
 
-  removeInternal: function(aResolve, aReject, aStore, aId) {
-    debug("RemoveInternal");
+  clearInternal: function(aResolve, aStore, aRevisionStore) {
+    debug("ClearInternal");
 
-    let request = aStore.delete(aId);
+    let self = this;
+    let request = aStore.clear();
     request.onsuccess = function() {
-      debug("RemoveInternal success");
-      aResolve();
-    };
-    request.onerror = function(aEvent) {
-      debug("RemoveInternal error");
-      aReject(new aWindow.DOMError(aEvent.target.error.name));
+      debug("ClearInternal success");
+      self.db.clearRevisions(aRevisionStore,
+        function() {
+          debug("Revisions cleared");
+
+          self.addRevision(aRevisionStore, 0, REVISION_VOID,
+            function() {
+              debug("ClearInternal - revisionId increased");
+              aResolve();
+            }
+          );
+        }
+      );
     };
   },
 
-  clearInternal: function(aResolve, aReject, aStore) {
-    debug("ClearInternal");
+  addRevision: function(aRevisionStore, aId, aType, aSuccessCb) {
+    let self = this;
+    this.db.addRevision(aRevisionStore, aId, aType,
+      function(aRevisionId) {
+        self.revisionId = aRevisionId;
+        aSuccessCb();
+      }
+    );
+  },
+
+  retrieveRevisionId: function(aSuccessCb) {
+    if (this.revisionId != null) {
+      aSuccessCb();
+      return;
+    }
 
-    let request = aStore.clear();
-    request.onsuccess = function() {
-      debug("ClearInternal success");
-      aResolve();
-    };
-    request.onerror = function(aEvent) {
-      debug("ClearInternal error");
-      aReject(new aWindow.DOMError(aEvent.target.error.name));
-    };
+    let self = this;
+    this.db.revisionTxn(
+      'readwrite',
+      function(aTxn, aRevisionStore) {
+        debug("RetrieveRevisionId transaction success");
+
+        let request = aRevisionStore.openCursor(null, 'prev');
+        request.onsuccess = function(aEvent) {
+          let cursor = aEvent.target.result;
+          if (!cursor) {
+            // If the revision doesn't exist, let's create the first one.
+            self.addRevision(aRevisionStore, 0, REVISION_VOID, aSuccessCb);
+            return;
+          }
+
+          self.revisionId = cursor.value.revisionId;
+          aSuccessCb();
+        };
+      }
+    );
   },
 
   throwInvalidArg: function(aWindow) {
     return aWindow.Promise.reject(
       new aWindow.DOMError("SyntaxError", "Non-numeric or invalid id"));
   },
 
   throwReadOnly: function(aWindow) {
@@ -158,100 +225,200 @@ DataStore.prototype = {
       get: function DS_get(aId) {
         aId = parseInt(aId);
         if (isNaN(aId) || aId <= 0) {
           return self.throwInvalidArg(aWindow);
         }
 
         // Promise<Object>
         return self.newDBPromise(aWindow, "readonly",
-          function(aResolve, aReject, aTxn, aStore) {
-            self.getInternal(aWindow, aResolve, aReject, aStore, aId);
+          function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
+            self.getInternal(aWindow, aResolve, aStore, aId);
           }
         );
       },
 
       update: function DS_update(aId, aObj) {
         aId = parseInt(aId);
         if (isNaN(aId) || aId <= 0) {
           return self.throwInvalidArg(aWindow);
         }
 
         if (self.readOnly) {
           return self.throwReadOnly(aWindow);
         }
 
         // Promise<void>
         return self.newDBPromise(aWindow, "readwrite",
-          function(aResolve, aReject, aTxn, aStore) {
-            self.updateInternal(aWindow, aResolve, aReject, aStore, aId, aObj);
+          function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
+            self.updateInternal(aResolve, aStore, aRevisionStore, aId, aObj);
           }
         );
       },
 
       add: function DS_add(aObj) {
         if (self.readOnly) {
           return self.throwReadOnly(aWindow);
         }
 
         // Promise<int>
         return self.newDBPromise(aWindow, "readwrite",
-          function(aResolve, aReject, aTxn, aStore) {
-            self.addInternal(aWindow, aResolve, aReject, aStore, aObj);
+          function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
+            self.addInternal(aResolve, aStore, aRevisionStore, aObj);
           }
         );
       },
 
       remove: function DS_remove(aId) {
         aId = parseInt(aId);
         if (isNaN(aId) || aId <= 0) {
           return self.throwInvalidArg(aWindow);
         }
 
         if (self.readOnly) {
           return self.throwReadOnly(aWindow);
         }
 
         // Promise<void>
         return self.newDBPromise(aWindow, "readwrite",
-          function(aResolve, aReject, aTxn, aStore) {
-            self.removeInternal(aResolve, aReject, aStore, aId);
+          function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
+            self.removeInternal(aResolve, aStore, aRevisionStore, aId);
           }
         );
       },
 
       clear: function DS_clear() {
         if (self.readOnly) {
           return self.throwReadOnly(aWindow);
         }
 
         // Promise<void>
         return self.newDBPromise(aWindow, "readwrite",
-          function(aResolve, aReject, aTxn, aStore) {
-            self.clearInternal(aResolve, aReject, aStore);
+          function(aResolve, aReject, aTxn, aStore, aRevisionStore) {
+            self.clearInternal(aResolve, aStore, aRevisionStore);
           }
         );
       },
 
+      get revisionId() {
+        return self.revisionId;
+      },
+
+      getChanges: function(aRevisionId) {
+        debug("GetChanges: " + aRevisionId);
+
+        if (aRevisionId === null || aRevisionId === undefined) {
+          return aWindow.Promise.reject(
+            new aWindow.DOMError("SyntaxError", "Invalid revisionId"));
+        }
+
+        // Promise<DataStoreChanges>
+        return new aWindow.Promise(function(aResolve, aReject) {
+          debug("GetChanges promise started");
+          self.db.revisionTxn(
+            'readonly',
+            function(aTxn, aStore) {
+              debug("GetChanges transaction success");
+
+              let request = self.db.getInternalRevisionId(
+                aRevisionId,
+                aStore,
+                function(aInternalRevisionId) {
+                  if (aInternalRevisionId == undefined) {
+                    aResolve(undefined);
+                    return;
+                  }
+
+                  // This object is the return value of this promise.
+                  // Initially we use maps, and then we convert them in array.
+                  let changes = {
+                    revisionId: '',
+                    addedIds: {},
+                    updatedIds: {},
+                    removedIds: {}
+                  };
+
+                  let request = aStore.mozGetAll(aWindow.IDBKeyRange.lowerBound(aInternalRevisionId, true));
+                  request.onsuccess = function(aEvent) {
+                    for (let i = 0; i < aEvent.target.result.length; ++i) {
+                      let data = aEvent.target.result[i];
+
+                      switch (data.operation) {
+                        case REVISION_ADDED:
+                          changes.addedIds[data.objectId] = true;
+                          break;
+
+                        case REVISION_UPDATED:
+                          // We don't consider an update if this object has been added
+                          // or if it has been already modified by a previous
+                          // operation.
+                          if (!(data.objectId in changes.addedIds) &&
+                              !(data.objectId in changes.updatedIds)) {
+                            changes.updatedIds[data.objectId] = true;
+                          }
+                          break;
+
+                        case REVISION_REMOVED:
+                          let id = data.objectId;
+
+                          // If the object has been added in this range of revisions
+                          // we can ignore it and remove it from the list.
+                          if (id in changes.addedIds) {
+                            delete changes.addedIds[id];
+                          } else {
+                            changes.removedIds[id] = true;
+                          }
+
+                          if (id in changes.updatedIds) {
+                            delete changes.updatedIds[id];
+                          }
+                          break;
+                      }
+                    }
+
+                    // The last revisionId.
+                    if (aEvent.target.result.length) {
+                      changes.revisionId = aEvent.target.result[aEvent.target.result.length - 1].revisionId;
+                    }
+
+                    // From maps to arrays.
+                    changes.addedIds = Object.keys(changes.addedIds).map(function(aKey) { return parseInt(aKey, 10); });
+                    changes.updatedIds = Object.keys(changes.updatedIds).map(function(aKey) { return parseInt(aKey, 10); });
+                    changes.removedIds = Object.keys(changes.removedIds).map(function(aKey) { return parseInt(aKey, 10); });
+
+                    let wrappedObject = ObjectWrapper.wrap(changes, aWindow);
+                    aResolve(wrappedObject);
+                  };
+                }
+              );
+            },
+            function(aEvent) {
+              debug("GetChanges transaction failed");
+              aReject(createDOMError(aWindow, aEvent));
+            }
+          );
+        });
+      },
+
       /* TODO:
-         readonly attribute DOMString revisionId
          attribute EventHandler onchange;
-         Promise<DataStoreChanges> getChanges(DOMString revisionId)
          getAll(), getLength()
        */
 
       __exposedProps__: {
         name: 'r',
         owner: 'r',
         readOnly: 'r',
         get: 'r',
         update: 'r',
         add: 'r',
         remove: 'r',
-        clear: 'r'
+        clear: 'r',
+        revisionId: 'r',
+        getChanges: 'r'
       }
     };
 
     return object;
   },
 
   delete: function() {
     this.db.delete();
--- a/dom/datastore/DataStoreDB.jsm
+++ b/dom/datastore/DataStoreDB.jsm
@@ -9,46 +9,96 @@ const {classes: Cc, interfaces: Ci, util
 this.EXPORTED_SYMBOLS = ['DataStoreDB'];
 
 function debug(s) {
   // dump('DEBUG DataStoreDB: ' + s + '\n');
 }
 
 const DATASTOREDB_VERSION = 1;
 const DATASTOREDB_OBJECTSTORE_NAME = 'DataStoreDB';
+const DATASTOREDB_REVISION = 'revision';
+const DATASTOREDB_REVISION_INDEX = 'revisionIndex';
 
 Cu.import('resource://gre/modules/IndexedDBHelper.jsm');
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
+                                   "@mozilla.org/uuid-generator;1",
+                                   "nsIUUIDGenerator");
 
 this.DataStoreDB = function DataStoreDB() {}
 
 DataStoreDB.prototype = {
 
   __proto__: IndexedDBHelper.prototype,
 
   upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) {
     debug('updateSchema');
     aDb.createObjectStore(DATASTOREDB_OBJECTSTORE_NAME, { autoIncrement: true });
+    let store = aDb.createObjectStore(DATASTOREDB_REVISION,
+                                      { autoIncrement: true,
+                                        keyPath: 'internalRevisionId' });
+    store.createIndex(DATASTOREDB_REVISION_INDEX, 'revisionId', { unique: true });
   },
 
   init: function(aOrigin, aName) {
     let dbName = aOrigin + '_' + aName;
     this.initDBHelper(dbName, DATASTOREDB_VERSION,
-                      [DATASTOREDB_OBJECTSTORE_NAME]);
+                      [DATASTOREDB_OBJECTSTORE_NAME, DATASTOREDB_REVISION]);
   },
 
   txn: function(aType, aCallback, aErrorCb) {
     debug('Transaction request');
     this.newTxn(
       aType,
-      DATASTOREDB_OBJECTSTORE_NAME,
+      aType == 'readonly'
+        ? [ DATASTOREDB_OBJECTSTORE_NAME ] : [ DATASTOREDB_OBJECTSTORE_NAME, DATASTOREDB_REVISION ],
+      function(aTxn, aStores) {
+        aType == 'readonly' ? aCallback(aTxn, aStores[0], null) : aCallback(aTxn, aStores[0], aStores[1]);
+      },
+      function() {},
+      aErrorCb
+    );
+  },
+
+  revisionTxn: function(aType, aCallback, aErrorCb) {
+    debug("Transaction request");
+    this.newTxn(
+      aType,
+      DATASTOREDB_REVISION,
       aCallback,
       function() {},
       aErrorCb
     );
   },
 
+  addRevision: function(aStore, aId, aType, aSuccessCb) {
+    debug("AddRevision: " + aId + " - " + aType);
+    let revisionId =  uuidgen.generateUUID().toString();
+    let request = aStore.put({ revisionId: revisionId, objectId: aId, operation: aType });
+    request.onsuccess = function() {
+      aSuccessCb(revisionId);
+    }
+  },
+
+  getInternalRevisionId: function(aRevisionId, aStore, aSuccessCb) {
+    debug('GetInternalRevisionId');
+    let request = aStore.index(DATASTOREDB_REVISION_INDEX).getKey(aRevisionId);
+    request.onsuccess = function(aEvent) {
+      aSuccessCb(aEvent.target.result);
+    }
+  },
+
+  clearRevisions: function(aStore, aSuccessCb) {
+    debug("ClearRevisions");
+    let request = aStore.clear();
+    request.onsuccess = function() {
+      aSuccessCb();
+    }
+  },
+
   delete: function() {
     debug('delete');
     this.close();
     indexedDB.deleteDatabase(this.dbName);
     debug('database deleted');
   }
 }
--- a/dom/datastore/DataStoreService.js
+++ b/dom/datastore/DataStoreService.js
@@ -56,26 +56,48 @@ DataStoreService.prototype = {
 
     this.stores[aName][aAppId] = store;
   },
 
   getDataStores: function(aWindow, aName) {
     debug('getDataStores - aName: ' + aName);
     let self = this;
     return new aWindow.Promise(function(resolve, reject) {
-      let results = [];
+      let matchingStores = [];
 
       if (aName in self.stores) {
         for (let appId in self.stores[aName]) {
-          let obj = self.stores[aName][appId].exposeObject(aWindow);
-          results.push(obj);
+          matchingStores.push(self.stores[aName][appId]);
         }
       }
 
-      resolve(results);
+      let callbackPending = matchingStores.length;
+      let results = [];
+
+      if (!callbackPending) {
+        resolve(results);
+        return;
+      }
+
+      for (let i = 0; i < matchingStores.length; ++i) {
+        let obj = matchingStores[i].exposeObject(aWindow);
+        results.push(obj);
+
+        matchingStores[i].retrieveRevisionId(
+          function() {
+            --callbackPending;
+            if (!callbackPending) {
+              resolve(results);
+            }
+          },
+          function() {
+            reject();
+          }
+        );
+      }
     });
   },
 
   observe: function observe(aSubject, aTopic, aData) {
     debug('getDataStores - aTopic: ' + aTopic);
     if (aTopic != 'webapps-clear-data') {
       return;
     }
--- a/dom/datastore/tests/Makefile.in
+++ b/dom/datastore/tests/Makefile.in
@@ -10,13 +10,14 @@ VPATH            = @srcdir@
 relativesrcdir   = @relativesrcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 MOCHITEST_FILES = \
   test_app_install.html \
   test_readonly.html \
   test_basic.html \
+  test_revision.html \
   file_app.sjs \
   file_app.template.webapp \
   $(NULL)
 
 include $(topsrcdir)/config/rules.mk
new file mode 100644
--- /dev/null
+++ b/dom/datastore/tests/test_revision.html
@@ -0,0 +1,235 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test for DataStore - basic operation on a readonly db</title>
+  <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+  <script type="application/javascript;version=1.7">
+
+  var gBaseURL = 'http://test/tests/dom/datastore/tests/';
+  var gHostedManifestURL = gBaseURL + 'file_app.sjs';
+  var gApp;
+  var gStore;
+  var gPreviousRevisionId = '';
+
+  function cbError() {
+    ok(false, "Error callback invoked");
+    finish();
+  }
+
+  function installApp() {
+    var request = navigator.mozApps.install(gHostedManifestURL);
+    request.onerror = cbError;
+    request.onsuccess = function() {
+      gApp = request.result;
+      runTest();
+    }
+  }
+
+  function testGetDataStores() {
+    navigator.getDataStores('foo').then(function(stores) {
+      is(stores.length, 1, "getDataStores('foo') returns 1 element");
+      is(stores[0].name, 'foo', 'The dataStore.name is foo');
+      is(stores[0].readOnly, false, 'The dataStore foo is not in readonly');
+
+      gStore = stores[0];
+
+      runTest();
+    }, cbError);
+  }
+
+  function testStoreAdd(value, expectedId) {
+    return gStore.add(value).then(function(id) {
+      is(id, expectedId, "store.add() is called");
+      runTest();
+    }, cbError);
+  }
+
+  function testStoreUpdate(id, value) {
+    return gStore.update(id, value).then(function(retId) {
+      is(id, retId, "store.update() is called with the right id");
+      runTest();
+    }, cbError);
+  }
+
+  function testStoreRemove(id, expectedSuccess) {
+    return gStore.remove(id).then(function(success) {
+      is(success, expectedSuccess, "store.remove() returns the right value");
+      runTest();
+    }, cbError);
+  }
+
+  function testStoreRevisionId() {
+    is(/[0-9a-zA-Z]{8}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{12}/.test(gStore.revisionId), true, "store.revisionId returns something");
+    runTest();
+  }
+
+  function testStoreWrongRevisions(id) {
+    return gStore.getChanges(id).then(
+      function(what) {
+        is(what, undefined, "Wrong revisionId == undefined object");
+        runTest();
+      }, cbError);
+  }
+
+  function testStoreRevisions(id, changes) {
+    return gStore.getChanges(id).then(function(what) {
+      is(JSON.stringify(changes.addedIds),
+         JSON.stringify(what.addedIds), "store.revisions - addedIds: " +
+         JSON.stringify(what.addedIds) + " | " + JSON.stringify(changes.addedIds));
+      is(JSON.stringify(changes.updatedIds),
+         JSON.stringify(what.updatedIds), "store.revisions - updatedIds: " +
+         JSON.stringify(what.updatedIds) + " | " + JSON.stringify(changes.updatedIds));
+      is(JSON.stringify(changes.removedIds),
+         JSON.stringify(what.removedIds), "store.revisions - removedIds: " +
+         JSON.stringify(what.removedIds) + " | " + JSON.stringify(changes.removedIds));
+      runTest();
+    }, cbError);
+  }
+
+  function uninstallApp() {
+    // Uninstall the app.
+    request = navigator.mozApps.mgmt.uninstall(gApp);
+    request.onerror = cbError;
+    request.onsuccess = function() {
+      // All done.
+      ok(true, "All done");
+      runTest();
+    }
+  }
+
+  function testStoreRevisionIdChanged() {
+    isnot(gStore.revisionId, gPreviousRevisionId, "Revision changed");
+    gPreviousRevisionId = gStore.revisionId;
+    runTest();
+  }
+
+  function testStoreRevisionIdNotChanged() {
+    is(gStore.revisionId, gPreviousRevisionId, "Revision changed");
+    runTest();
+  }
+
+  var revisions = [];
+
+  var tests = [
+    // Permissions
+    function() {
+      SpecialPowers.pushPermissions(
+        [{ "type": "browser", "allow": 1, "context": document },
+         { "type": "embed-apps", "allow": 1, "context": document },
+         { "type": "webapps-manage", "allow": 1, "context": document }], runTest);
+    },
+
+    // Preferences
+    function() {
+      SpecialPowers.pushPrefEnv({"set": [["dom.promise.enabled", true]]}, runTest);
+    },
+
+    // No confirmation needed when an app is installed
+    function() {
+      SpecialPowers.autoConfirmAppInstall(runTest);
+    },
+
+    // Installing the app
+    installApp,
+
+    // Test for GetDataStore
+    testGetDataStores,
+
+    // The first revision is not empty
+    testStoreRevisionIdChanged,
+
+    // wrong revision ID
+    function() { testStoreWrongRevisions('foobar'); },
+
+    // Add
+    function() { testStoreAdd({ number: 42 }, 1); },
+    function() { revisions.push(gStore.revisionId); testStoreRevisionId(); },
+    testStoreRevisionIdChanged,
+    function() { testStoreRevisions(revisions[0], { addedIds: [], updatedIds: [], removedIds: [] }); },
+
+    // Add
+    function() { testStoreAdd({ number: 42 }, 2); },
+    function() { revisions.push(gStore.revisionId); runTest(); },
+    testStoreRevisionIdChanged,
+    function() { testStoreRevisions(revisions[0], { addedIds: [2], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[1], { addedIds: [], updatedIds: [], removedIds: [] }); },
+
+    // Add
+    function() { testStoreAdd({ number: 42 }, 3); },
+    function() { revisions.push(gStore.revisionId); runTest(); },
+    testStoreRevisionIdChanged,
+    function() { testStoreRevisions(revisions[0], { addedIds: [2,3], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[1], { addedIds: [3], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [], removedIds: [] }); },
+
+    // Update
+    function() { testStoreUpdate(3, { number: 43 }); },
+    function() { revisions.push(gStore.revisionId); runTest(); },
+    testStoreRevisionIdChanged,
+    function() { testStoreRevisions(revisions[0], { addedIds: [2,3], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[1], { addedIds: [3], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [3], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[3], { addedIds: [], updatedIds: [], removedIds: [] }); },
+
+    // Update
+    function() { testStoreUpdate(3, { number: 42 }); },
+    function() { revisions.push(gStore.revisionId); runTest(); },
+    testStoreRevisionIdChanged,
+    function() { testStoreRevisions(revisions[0], { addedIds: [2,3], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[1], { addedIds: [3], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [3], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[3], { addedIds: [], updatedIds: [3], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[4], { addedIds: [], updatedIds: [], removedIds: [] }); },
+
+    // Remove
+    function() { testStoreRemove(3, true); },
+    function() { revisions.push(gStore.revisionId); runTest(); },
+    testStoreRevisionIdChanged,
+    function() { testStoreRevisions(revisions[0], { addedIds: [2], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[1], { addedIds: [], updatedIds: [], removedIds: [] }); },
+    function() { testStoreRevisions(revisions[2], { addedIds: [], updatedIds: [], removedIds: [3] }); },
+    function() { testStoreRevisions(revisions[3], { addedIds: [], updatedIds: [], removedIds: [3] }); },
+    function() { testStoreRevisions(revisions[4], { addedIds: [], updatedIds: [], removedIds: [3] }); },
+    function() { testStoreRevisions(revisions[5], { addedIds: [], updatedIds: [], removedIds: [] }); },
+
+    function() { testStoreRemove(3, false); },
+    testStoreRevisionIdNotChanged,
+
+    // Remove
+    function() { testStoreRemove(42, false); },
+    testStoreRevisionIdNotChanged,
+
+    // Uninstall the app
+    uninstallApp
+  ];
+
+  function runTest() {
+    if (!tests.length) {
+      finish();
+      return;
+    }
+
+    var test = tests.shift();
+    test();
+  }
+
+  function finish() {
+    SimpleTest.finish();
+  }
+
+  SimpleTest.waitForExplicitFinish();
+  runTest();
+  </script>
+</pre>
+</body>
+</html>
+