Bug 1319175 - Add a `data` setter and `beforeSave` hook to `JSONFile`. r=MattN draft
authorKit Cambridge <kit@yakshaving.ninja>
Mon, 19 Dec 2016 08:32:29 -0800
changeset 451278 42c0235774ca34e097e858b6ef796c4d1bd4f122
parent 449758 b1ab720c6d3e412ede797b08dfe63dca170f6ee0
child 451279 0691f5a16da21c8c118f8c6949315afd5c5f18f2
child 451321 86ce31561cd50f4ced17913b347478f21d2f4256
child 451575 6323e7468a01144f2e2c8118558a39ef72523b69
push id39120
push userbmo:kit@mozilla.com
push dateTue, 20 Dec 2016 03:42:01 +0000
reviewersMattN
bugs1319175
milestone53.0a1
Bug 1319175 - Add a `data` setter and `beforeSave` hook to `JSONFile`. r=MattN * The `data` setter sets the backing data object to a new value, and flips `dataReady` so that `ensureDataReady` and `load` don't read stale data from disk. This can be used to clear existing data. * The `beforeSave` hook is called from `_save`, as part of the deferred task. This can be used to create intermediate directories containing the file, or run other pre-save tasks that shouldn't be interrupted by shutdown. MozReview-Commit-ID: AzOx7u2Rali
toolkit/modules/JSONFile.jsm
toolkit/modules/tests/xpcshell/test_JSONFile.js
--- a/toolkit/modules/JSONFile.jsm
+++ b/toolkit/modules/JSONFile.jsm
@@ -76,23 +76,31 @@ const kSaveDelayMs = 1500;
  *        - dataPostProcessor: Function triggered when data is just loaded. The
  *                             data object will be passed as the first argument
  *                             and should be returned no matter it's modified or
  *                             not. Its failure leads to the failure of load()
  *                             and ensureDataReady().
  *        - saveDelayMs: Number indicating the delay (in milliseconds) between a
  *                       change to the data and the related save operation. The
  *                       default value will be applied if omitted.
+ *        - beforeSave: Promise-returning function triggered just before the
+ *                      data is written to disk. This can be used to create any
+ *                      intermediate directories before saving. The file will
+ *                      not be saved if the promise rejects or the function
+ *                      throws an exception.
  */
 function JSONFile(config) {
   this.path = config.path;
 
   if (typeof config.dataPostProcessor === "function") {
     this._dataPostProcessor = config.dataPostProcessor;
   }
+  if (typeof config.beforeSave === "function") {
+    this._beforeSave = config.beforeSave;
+  }
 
   if (config.saveDelayMs === undefined) {
     config.saveDelayMs = kSaveDelayMs;
   }
   this._saver = new DeferredTask(() => this._save(), config.saveDelayMs);
 
   AsyncShutdown.profileBeforeChange.addBlocker("JSON store: writing data",
                                                () => this._saver.finalize());
@@ -130,16 +138,25 @@ JSONFile.prototype = {
   get data() {
     if (!this.dataReady) {
       throw new Error("Data is not ready.");
     }
     return this._data;
   },
 
   /**
+   * Sets the loaded data to a new object. This will overwrite any persisted
+   * data on the next save.
+   */
+  set data(data) {
+    this._data = data;
+    this.dataReady = true;
+  },
+
+  /**
    * Loads persistent data from the file to memory.
    *
    * @return {Promise}
    * @resolves When the operation finished successfully.
    * @rejects JavaScript exception when dataPostProcessor fails. It never fails
    *          if there is no dataPostProcessor.
    */
   load: Task.async(function* () {
@@ -247,20 +264,22 @@ JSONFile.prototype = {
    *
    * @return {Promise}
    * @resolves When the operation finished successfully.
    * @rejects JavaScript exception.
    */
   _save: Task.async(function* () {
     // Create or overwrite the file.
     let bytes = gTextEncoder.encode(JSON.stringify(this._data));
+    if (this._beforeSave) {
+      yield Promise.resolve(this._beforeSave());
+    }
     yield OS.File.writeAtomic(this.path, bytes,
                               { tmpPath: this.path + ".tmp" });
   }),
 
   /**
    * Synchronously work on the data just loaded into memory.
    */
   _processLoadedData(data) {
-    this._data = this._dataPostProcessor ? this._dataPostProcessor(data) : data;
-    this.dataReady = true;
+    this.data = this._dataPostProcessor ? this._dataPostProcessor(data) : data;
   },
 };
--- a/toolkit/modules/tests/xpcshell/test_JSONFile.js
+++ b/toolkit/modules/tests/xpcshell/test_JSONFile.js
@@ -235,8 +235,81 @@ add_task(function* test_load_string_malf
   // A backup file should have been created.
   do_check_true(yield OS.File.exists(store.path + ".corrupt"));
   yield OS.File.remove(store.path + ".corrupt");
 
   // The store should be ready to accept new data.
   do_check_true(store.dataReady);
   do_check_matches(store.data, {});
 });
+
+add_task(function* test_overwrite_data()
+{
+  let storeForSave = new JSONFile({
+    path: getTempFile(TEST_STORE_FILE_NAME).path,
+  });
+
+  let string = `{"number":456,"string":"tset","object":{"prop1":3,"prop2":4}}`;
+
+  yield OS.File.writeAtomic(storeForSave.path, new TextEncoder().encode(string),
+                            { tmpPath: storeForSave.path + ".tmp" });
+
+  Assert.ok(!storeForSave.dataReady);
+  storeForSave.data = TEST_DATA;
+  Assert.ok(storeForSave.dataReady);
+  Assert.equal(storeForSave.data, TEST_DATA);
+
+  yield new Promise((resolve) => {
+    let save = storeForSave._save.bind(storeForSave);
+    storeForSave._save = () => {
+      save();
+      resolve();
+    };
+    storeForSave.saveSoon();
+  });
+
+  let storeForLoad = new JSONFile({
+    path: storeForSave.path,
+  });
+
+  yield storeForLoad.load();
+
+  Assert.deepEqual(storeForLoad.data, TEST_DATA);
+});
+
+add_task(function* test_beforeSave()
+{
+  let store;
+  let promiseBeforeSave = new Promise((resolve) => {
+    store = new JSONFile({
+      path: getTempFile(TEST_STORE_FILE_NAME).path,
+      beforeSave: resolve,
+      saveDelayMs: 250,
+    });
+  });
+
+  store.saveSoon();
+
+  yield promiseBeforeSave;
+});
+
+add_task(function* test_beforeSave_rejects()
+{
+  let storeForSave = new JSONFile({
+    path: getTempFile(TEST_STORE_FILE_NAME).path,
+    beforeSave() {
+      return Promise.reject(new Error("oops"));
+    },
+    saveDelayMs: 250,
+  });
+
+  let promiseSave = new Promise((resolve, reject) => {
+    let save = storeForSave._save.bind(storeForSave);
+    storeForSave._save = () => {
+      save().then(resolve, reject);
+    };
+    storeForSave.saveSoon();
+  });
+
+  yield rejects(promiseSave, function(ex) {
+    return ex.message == "oops";
+  });
+});