Bug 1608150 [wpt PR 21112] - Add missing space in `./wpt lint` command line docs...
[gecko.git] / toolkit / modules / JSONFile.jsm
blob3a93a4f1921ffe9fcb82353dae82759c38689f4b
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6  * Handles serialization of the data and persistence into a file.
7  *
8  * This modules handles the raw data stored in JavaScript serializable objects,
9  * and contains no special validation or query logic, that is handled entirely
10  * by "storage.js" instead.
11  *
12  * The data can be manipulated only after it has been loaded from disk.  The
13  * load process can happen asynchronously, through the "load" method, or
14  * synchronously, through "ensureDataReady".  After any modification, the
15  * "saveSoon" method must be called to flush the data to disk asynchronously.
16  *
17  * The raw data should be manipulated synchronously, without waiting for the
18  * event loop or for promise resolution, so that the saved file is always
19  * consistent.  This synchronous approach also simplifies the query and update
20  * logic.  For example, it is possible to find an object and modify it
21  * immediately without caring whether other code modifies it in the meantime.
22  *
23  * An asynchronous shutdown observer makes sure that data is always saved before
24  * the browser is closed. The data cannot be modified during shutdown.
25  *
26  * The file is stored in JSON format, without indentation, using UTF-8 encoding.
27  */
29 "use strict";
31 var EXPORTED_SYMBOLS = ["JSONFile"];
33 // Globals
35 const { XPCOMUtils } = ChromeUtils.import(
36   "resource://gre/modules/XPCOMUtils.jsm"
39 ChromeUtils.defineModuleGetter(
40   this,
41   "AsyncShutdown",
42   "resource://gre/modules/AsyncShutdown.jsm"
44 ChromeUtils.defineModuleGetter(
45   this,
46   "DeferredTask",
47   "resource://gre/modules/DeferredTask.jsm"
49 ChromeUtils.defineModuleGetter(
50   this,
51   "FileUtils",
52   "resource://gre/modules/FileUtils.jsm"
54 ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
55 ChromeUtils.defineModuleGetter(
56   this,
57   "NetUtil",
58   "resource://gre/modules/NetUtil.jsm"
61 XPCOMUtils.defineLazyGetter(this, "gTextDecoder", function() {
62   return new TextDecoder();
63 });
65 XPCOMUtils.defineLazyGetter(this, "gTextEncoder", function() {
66   return new TextEncoder();
67 });
69 const FileInputStream = Components.Constructor(
70   "@mozilla.org/network/file-input-stream;1",
71   "nsIFileInputStream",
72   "init"
75 /**
76  * Delay between a change to the data and the related save operation.
77  */
78 const kSaveDelayMs = 1500;
80 // JSONFile
82 /**
83  * Handles serialization of the data and persistence into a file.
84  *
85  * @param config An object containing following members:
86  *        - path: String containing the file path where data should be saved.
87  *        - dataPostProcessor: Function triggered when data is just loaded. The
88  *                             data object will be passed as the first argument
89  *                             and should be returned no matter it's modified or
90  *                             not. Its failure leads to the failure of load()
91  *                             and ensureDataReady().
92  *        - saveDelayMs: Number indicating the delay (in milliseconds) between a
93  *                       change to the data and the related save operation. The
94  *                       default value will be applied if omitted.
95  *        - beforeSave: Promise-returning function triggered just before the
96  *                      data is written to disk. This can be used to create any
97  *                      intermediate directories before saving. The file will
98  *                      not be saved if the promise rejects or the function
99  *                      throws an exception.
100  *        - finalizeAt: An `AsyncShutdown` phase or barrier client that should
101  *                      automatically finalize the file when triggered. Defaults
102  *                      to `profileBeforeChange`; exposed as an option for
103  *                      testing.
104  *        - compression: A compression algorithm to use when reading and
105  *                       writing the data.
106  */
107 function JSONFile(config) {
108   this.path = config.path;
110   if (typeof config.dataPostProcessor === "function") {
111     this._dataPostProcessor = config.dataPostProcessor;
112   }
113   if (typeof config.beforeSave === "function") {
114     this._beforeSave = config.beforeSave;
115   }
117   if (config.saveDelayMs === undefined) {
118     config.saveDelayMs = kSaveDelayMs;
119   }
120   this._saver = new DeferredTask(() => this._save(), config.saveDelayMs);
122   this._options = {};
123   if (config.compression) {
124     this._options.compression = config.compression;
125   }
127   this._finalizeAt = config.finalizeAt || AsyncShutdown.profileBeforeChange;
128   this._finalizeInternalBound = this._finalizeInternal.bind(this);
129   this._finalizeAt.addBlocker(
130     "JSON store: writing data",
131     this._finalizeInternalBound
132   );
135 JSONFile.prototype = {
136   /**
137    * String containing the file path where data should be saved.
138    */
139   path: "",
141   /**
142    * True when data has been loaded.
143    */
144   dataReady: false,
146   /**
147    * DeferredTask that handles the save operation.
148    */
149   _saver: null,
151   /**
152    * Internal data object.
153    */
154   _data: null,
156   /**
157    * Internal fields used during finalization.
158    */
159   _finalizeAt: null,
160   _finalizePromise: null,
161   _finalizeInternalBound: null,
163   /**
164    * Serializable object containing the data. This is populated directly with
165    * the data loaded from the file, and is saved without modifications.
166    *
167    * The raw data should be manipulated synchronously, without waiting for the
168    * event loop or for promise resolution, so that the saved file is always
169    * consistent.
170    */
171   get data() {
172     if (!this.dataReady) {
173       throw new Error("Data is not ready.");
174     }
175     return this._data;
176   },
178   /**
179    * Sets the loaded data to a new object. This will overwrite any persisted
180    * data on the next save.
181    */
182   set data(data) {
183     this._data = data;
184     this.dataReady = true;
185   },
187   /**
188    * Loads persistent data from the file to memory.
189    *
190    * @return {Promise}
191    * @resolves When the operation finished successfully.
192    * @rejects JavaScript exception when dataPostProcessor fails. It never fails
193    *          if there is no dataPostProcessor.
194    */
195   async load() {
196     if (this.dataReady) {
197       return;
198     }
200     let data = {};
202     try {
203       let bytes = await OS.File.read(this.path, this._options);
205       // If synchronous loading happened in the meantime, exit now.
206       if (this.dataReady) {
207         return;
208       }
210       data = JSON.parse(gTextDecoder.decode(bytes));
211     } catch (ex) {
212       // If an exception occurred because the file did not exist, we should
213       // just start with new data.  Other errors may indicate that the file is
214       // corrupt, thus we move it to a backup location before allowing it to
215       // be overwritten by an empty file.
216       if (!(ex instanceof OS.File.Error && ex.becauseNoSuchFile)) {
217         Cu.reportError(ex);
219         // Move the original file to a backup location, ignoring errors.
220         try {
221           let openInfo = await OS.File.openUnique(this.path + ".corrupt", {
222             humanReadable: true,
223           });
224           await openInfo.file.close();
225           await OS.File.move(this.path, openInfo.path);
226         } catch (e2) {
227           Cu.reportError(e2);
228         }
229       }
231       // In some rare cases it's possible for data to have been added to
232       // our database between the call to OS.File.read and when we've been
233       // notified that there was a problem with it. In that case, leave the
234       // synchronously-added data alone.
235       if (this.dataReady) {
236         return;
237       }
238     }
240     this._processLoadedData(data);
241   },
243   /**
244    * Loads persistent data from the file to memory, synchronously. An exception
245    * can be thrown only if dataPostProcessor exists and fails.
246    */
247   ensureDataReady() {
248     if (this.dataReady) {
249       return;
250     }
252     let data = {};
254     try {
255       // This reads the file and automatically detects the UTF-8 encoding.
256       let inputStream = new FileInputStream(
257         new FileUtils.File(this.path),
258         FileUtils.MODE_RDONLY,
259         FileUtils.PERMS_FILE,
260         0
261       );
262       try {
263         let bytes = NetUtil.readInputStream(
264           inputStream,
265           inputStream.available()
266         );
267         data = JSON.parse(gTextDecoder.decode(bytes));
268       } finally {
269         inputStream.close();
270       }
271     } catch (ex) {
272       // If an exception occurred because the file did not exist, we should just
273       // start with new data.  Other errors may indicate that the file is
274       // corrupt, thus we move it to a backup location before allowing it to be
275       // overwritten by an empty file.
276       if (
277         !(
278           ex instanceof Components.Exception &&
279           ex.result == Cr.NS_ERROR_FILE_NOT_FOUND
280         )
281       ) {
282         Cu.reportError(ex);
283         // Move the original file to a backup location, ignoring errors.
284         try {
285           let originalFile = new FileUtils.File(this.path);
286           let backupFile = originalFile.clone();
287           backupFile.leafName += ".corrupt";
288           backupFile.createUnique(
289             Ci.nsIFile.NORMAL_FILE_TYPE,
290             FileUtils.PERMS_FILE
291           );
292           backupFile.remove(false);
293           originalFile.moveTo(backupFile.parent, backupFile.leafName);
294         } catch (e2) {
295           Cu.reportError(e2);
296         }
297       }
298     }
300     this._processLoadedData(data);
301   },
303   /**
304    * Called when the data changed, this triggers asynchronous serialization.
305    */
306   saveSoon() {
307     return this._saver.arm();
308   },
310   /**
311    * Saves persistent data from memory to the file.
312    *
313    * If an error occurs, the previous file is not deleted.
314    *
315    * @return {Promise}
316    * @resolves When the operation finished successfully.
317    * @rejects JavaScript exception.
318    */
319   async _save() {
320     let json;
321     try {
322       json = JSON.stringify(this._data);
323     } catch (e) {
324       // If serialization fails, try fallback safe JSON converter.
325       if (typeof this._data.toJSONSafe == "function") {
326         json = JSON.stringify(this._data.toJSONSafe());
327       } else {
328         throw e;
329       }
330     }
332     // Create or overwrite the file.
333     let bytes = gTextEncoder.encode(json);
334     if (this._beforeSave) {
335       await Promise.resolve(this._beforeSave());
336     }
337     await OS.File.writeAtomic(
338       this.path,
339       bytes,
340       Object.assign({ tmpPath: this.path + ".tmp" }, this._options)
341     );
342   },
344   /**
345    * Synchronously work on the data just loaded into memory.
346    */
347   _processLoadedData(data) {
348     if (this._finalizePromise) {
349       // It's possible for `load` to race with `finalize`. In that case, don't
350       // process or set the loaded data.
351       return;
352     }
353     this.data = this._dataPostProcessor ? this._dataPostProcessor(data) : data;
354   },
356   /**
357    * Finishes persisting data to disk and resets all state for this file.
358    *
359    * @return {Promise}
360    * @resolves When the object is finalized.
361    */
362   _finalizeInternal() {
363     if (this._finalizePromise) {
364       // Finalization already in progress; return the pending promise. This is
365       // possible if `finalize` is called concurrently with shutdown.
366       return this._finalizePromise;
367     }
368     this._finalizePromise = (async () => {
369       await this._saver.finalize();
370       this._data = null;
371       this.dataReady = false;
372     })();
373     return this._finalizePromise;
374   },
376   /**
377    * Ensures that all data is persisted to disk, and prevents future calls to
378    * `saveSoon`. This is called automatically on shutdown, but can also be
379    * called explicitly when the file is no longer needed.
380    */
381   async finalize() {
382     if (this._finalizePromise) {
383       throw new Error(`The file ${this.path} has already been finalized`);
384     }
385     // Wait for finalization before removing the shutdown blocker.
386     await this._finalizeInternal();
387     this._finalizeAt.removeBlocker(this._finalizeInternalBound);
388   },