summaryrefslogtreecommitdiffstats
path: root/services/settings/SyncHistory.sys.mjs
blob: f609eb26f7f641cfeaa46604e9f3df76142c0fa1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/* 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/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  KeyValueService: "resource://gre/modules/kvstore.sys.mjs",
});

/**
 * A helper to keep track of synchronization statuses.
 *
 * We rely on a different storage backend than for storing Remote Settings data,
 * because the eventual goal is to be able to detect `IndexedDB` issues and act
 * accordingly.
 */
export class SyncHistory {
  // Internal reference to underlying rkv store.
  #store;

  /**
   * @param {String} source the synchronization source (eg. `"settings-sync"`)
   * @param {Object} options
   * @param {int} options.size Maximum number of entries per source.
   */
  constructor(source, { size } = { size: 100 }) {
    this.source = source;
    this.size = size;
  }

  /**
   * Store the synchronization status. The ETag is converted and stored as
   * a millisecond epoch timestamp.
   * The entries with the oldest timestamps will be deleted to maintain the
   * history size under the configured maximum.
   *
   * @param {String} etag the ETag value from the server (eg. `"1647961052593"`)
   * @param {String} status the synchronization status (eg. `"success"`)
   * @param {Object} infos optional additional information to keep track of
   */
  async store(etag, status, infos = {}) {
    const rkv = await this.#init();
    const timestamp = parseInt(etag.replace('"', ""), 10);
    if (Number.isNaN(timestamp)) {
      throw new Error(`Invalid ETag value ${etag}`);
    }
    const key = `v1-${this.source}\t${timestamp}`;
    const value = { timestamp, status, infos };
    await rkv.put(key, JSON.stringify(value));
    // Trim old entries.
    const allEntries = await this.list();
    for (let i = this.size; i < allEntries.length; i++) {
      let { timestamp } = allEntries[i];
      await rkv.delete(`v1-${this.source}\t${timestamp}`);
    }
  }

  /**
   * Retrieve the stored history entries for a certain source, sorted by
   * timestamp descending.
   *
   * @returns {Array<Object>} a list of objects
   */
  async list() {
    const rkv = await this.#init();
    const entries = [];
    // The "from" and "to" key parameters to nsIKeyValueStore.enumerate()
    // are inclusive and exclusive, respectively, and keys are tuples
    // of source and datetime joined by a tab (\t), which is character code 9;
    // so enumerating ["source", "source\n"), where the line feed (\n)
    // is character code 10, enumerates all pairs with the given source.
    for (const { value } of await rkv.enumerate(
      `v1-${this.source}`,
      `v1-${this.source}\n`
    )) {
      try {
        const stored = JSON.parse(value);
        entries.push({ ...stored, datetime: new Date(stored.timestamp) });
      } catch (e) {
        // Ignore malformed entries.
        console.error(e);
      }
    }
    // Sort entries by `timestamp` descending.
    entries.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1));
    return entries;
  }

  /**
   * Return the most recent entry.
   */
  async last() {
    // List is sorted from newer to older.
    return (await this.list())[0];
  }

  /**
   * Wipe out the **whole** store.
   */
  async clear() {
    const rkv = await this.#init();
    await rkv.clear();
  }

  /**
   * Initialize the rkv store in the user profile.
   *
   * @returns {Object} the underlying `KeyValueService` instance.
   */
  async #init() {
    if (!this.#store) {
      // Get and cache a handle to the kvstore.
      const dir = PathUtils.join(PathUtils.profileDir, "settings");
      await IOUtils.makeDirectory(dir);
      this.#store = await lazy.KeyValueService.getOrCreate(dir, "synchistory");
    }
    return this.#store;
  }
}