summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/lib/AddonRollouts.sys.mjs
blob: ab2994a5f90cfcdde40850c6ae031b28e78689ea (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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
/* 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, {
  IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
});

/**
 * AddonRollouts store info about an active or expired addon rollouts.
 * @typedef {object} AddonRollout
 * @property {int} recipeId
 *   The ID of the recipe.
 * @property {string} slug
 *   Unique slug of the rollout.
 * @property {string} state
 *   The current state of the rollout: "active", or "rolled-back".
 *   Active means that Normandy is actively managing therollout. Rolled-back
 *   means that the rollout was previously active, but has been rolled back for
 *   this user.
 * @property {int} extensionApiId
 *   The ID used to look up the extension in Normandy's API.
 * @property {string} addonId
 *   The add-on ID for this particular rollout.
 * @property {string} addonVersion
 *   The rollout add-on version number
 * @property {string} xpiUrl
 *   URL that the add-on was installed from.
 * @property {string} xpiHash
 *   The hash of the XPI file.
 * @property {string} xpiHashAlgorithm
 *   The algorithm used to hash the XPI file.
 */

const DB_NAME = "normandy-addon-rollout";
const STORE_NAME = "addon-rollouts";
const DB_OPTIONS = { version: 1 };

/**
 * Create a new connection to the database.
 */
function openDatabase() {
  return lazy.IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
    db.createObjectStore(STORE_NAME, {
      keyPath: "slug",
    });
  });
}

/**
 * Cache the database connection so that it is shared among multiple operations.
 */
let databasePromise;
function getDatabase() {
  if (!databasePromise) {
    databasePromise = openDatabase();
  }
  return databasePromise;
}

/**
 * Get a transaction for interacting with the rollout store.
 *
 * @param {IDBDatabase} db
 * @param {String} mode Either "readonly" or "readwrite"
 *
 * NOTE: Methods on the store returned by this function MUST be called
 * synchronously, otherwise the transaction with the store will expire.
 * This is why the helper takes a database as an argument; if we fetched the
 * database in the helper directly, the helper would be async and the
 * transaction would expire before methods on the store were called.
 */
function getStore(db, mode) {
  if (!mode) {
    throw new Error("mode is required");
  }
  return db.objectStore(STORE_NAME, mode);
}

export const AddonRollouts = {
  STATE_ACTIVE: "active",
  STATE_ROLLED_BACK: "rolled-back",

  async init() {
    for (const rollout of await this.getAllActive()) {
      lazy.TelemetryEnvironment.setExperimentActive(
        rollout.slug,
        rollout.state,
        {
          type: "normandy-addonrollout",
        }
      );
    }
  },

  /**
   * Add a new rollout
   * @param {AddonRollout} rollout
   */
  async add(rollout) {
    const db = await getDatabase();
    return getStore(db, "readwrite").add(rollout);
  },

  /**
   * Update an existing rollout
   * @param {AddonRollout} rollout
   * @throws If a matching rollout does not exist.
   */
  async update(rollout) {
    if (!(await this.has(rollout.slug))) {
      throw new Error(
        `Tried to update ${rollout.slug}, but it doesn't already exist.`
      );
    }
    const db = await getDatabase();
    return getStore(db, "readwrite").put(rollout);
  },

  /**
   * Update many existing rollouts. More efficient than calling `update` many
   * times in a row.
   * @param {Array<PreferenceRollout>} rollouts
   * @throws If any of the passed rollouts have a slug that doesn't exist in the database already.
   */
  async updateMany(rollouts) {
    // Don't touch the database if there is nothing to do
    if (!rollouts.length) {
      return;
    }

    // Both of the below operations use .map() instead of a normal loop becaues
    // once we get the object store, we can't let it expire by spinning the
    // event loop. This approach queues up all the interactions with the store
    // immediately, preventing it from expiring too soon.

    const db = await getDatabase();
    let store = await getStore(db, "readonly");
    await Promise.all(
      rollouts.map(async ({ slug }) => {
        let existingRollout = await store.get(slug);
        if (!existingRollout) {
          throw new Error(`Tried to update ${slug}, but it doesn't exist.`);
        }
      })
    );

    // awaiting spun the event loop, so the store is now invalid. Get a new
    // store. This is also a chance to get it in readwrite mode.
    store = await getStore(db, "readwrite");
    await Promise.all(rollouts.map(rollout => store.put(rollout)));
  },

  /**
   * Test whether there is a rollout in storage with the given slug.
   * @param {string} slug
   * @returns {Promise<boolean>}
   */
  async has(slug) {
    const db = await getDatabase();
    const rollout = await getStore(db, "readonly").get(slug);
    return !!rollout;
  },

  /**
   * Get a rollout by slug
   * @param {string} slug
   */
  async get(slug) {
    const db = await getDatabase();
    return getStore(db, "readonly").get(slug);
  },

  /** Get all rollouts in the database. */
  async getAll() {
    const db = await getDatabase();
    return getStore(db, "readonly").getAll();
  },

  /** Get all rollouts in the "active" state. */
  async getAllActive() {
    const rollouts = await this.getAll();
    return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
  },

  /**
   * Test wrapper that temporarily replaces the stored rollout data with fake
   * data for testing.
   */
  withTestMock() {
    return function (testFunction) {
      return async function inner(...args) {
        let db = await getDatabase();
        const oldData = await getStore(db, "readonly").getAll();
        await getStore(db, "readwrite").clear();
        try {
          await testFunction(...args);
        } finally {
          db = await getDatabase();
          await getStore(db, "readwrite").clear();
          const store = getStore(db, "readwrite");
          await Promise.all(oldData.map(d => store.add(d)));
        }
      };
    };
  },
};