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
--- 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";
+ });
+});