Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / services / common / kinto-storage-adapter.sys.mjs
blob75c6c06a2693b489c7046f49c92dd3be86a6c72b
1 /*
2  * Licensed under the Apache License, Version 2.0 (the "License");
3  * you may not use this file except in compliance with the License.
4  * You may obtain a copy of the License at
5  *
6  *     http://www.apache.org/licenses/LICENSE-2.0
7  *
8  * Unless required by applicable law or agreed to in writing, software
9  * distributed under the License is distributed on an "AS IS" BASIS,
10  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11  * See the License for the specific language governing permissions and
12  * limitations under the License.
13  */
14 import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs";
16 const { Kinto } = ChromeUtils.import(
17   "resource://services-common/kinto-offline-client.js"
20 /**
21  * Filter and sort list against provided filters and order.
22  *
23  * @param  {Object} filters  The filters to apply.
24  * @param  {String} order    The order to apply.
25  * @param  {Array}  list     The list to reduce.
26  * @return {Array}
27  */
28 function reduceRecords(filters, order, list) {
29   const filtered = filters ? filterObjects(filters, list) : list;
30   return order ? sortObjects(order, filtered) : filtered;
33 /**
34  * Checks if a value is undefined.
35  *
36  * This is a copy of `_isUndefined` from kinto.js/src/utils.js.
37  * @param  {Any}  value
38  * @return {Boolean}
39  */
40 function _isUndefined(value) {
41   return typeof value === "undefined";
44 /**
45  * Sorts records in a list according to a given ordering.
46  *
47  * This is a copy of `sortObjects` from kinto.js/src/utils.js.
48  *
49  * @param  {String} order The ordering, eg. `-last_modified`.
50  * @param  {Array}  list  The collection to order.
51  * @return {Array}
52  */
53 function sortObjects(order, list) {
54   const hasDash = order[0] === "-";
55   const field = hasDash ? order.slice(1) : order;
56   const direction = hasDash ? -1 : 1;
57   return list.slice().sort((a, b) => {
58     if (a[field] && _isUndefined(b[field])) {
59       return direction;
60     }
61     if (b[field] && _isUndefined(a[field])) {
62       return -direction;
63     }
64     if (_isUndefined(a[field]) && _isUndefined(b[field])) {
65       return 0;
66     }
67     return a[field] > b[field] ? direction : -direction;
68   });
71 /**
72  * Test if a single object matches all given filters.
73  *
74  * This is a copy of `filterObject` from kinto.js/src/utils.js.
75  *
76  * @param  {Object} filters  The filters object.
77  * @param  {Object} entry    The object to filter.
78  * @return {Function}
79  */
80 function filterObject(filters, entry) {
81   return Object.keys(filters).every(filter => {
82     const value = filters[filter];
83     if (Array.isArray(value)) {
84       return value.some(candidate => candidate === entry[filter]);
85     }
86     return entry[filter] === value;
87   });
90 /**
91  * Filters records in a list matching all given filters.
92  *
93  * This is a copy of `filterObjects` from kinto.js/src/utils.js.
94  *
95  * @param  {Object} filters  The filters object.
96  * @param  {Array}  list     The collection to filter.
97  * @return {Array}
98  */
99 function filterObjects(filters, list) {
100   return list.filter(entry => {
101     return filterObject(filters, entry);
102   });
105 const statements = {
106   createCollectionData: `
107     CREATE TABLE collection_data (
108       collection_name TEXT,
109       record_id TEXT,
110       record TEXT
111     );`,
113   createCollectionMetadata: `
114     CREATE TABLE collection_metadata (
115       collection_name TEXT PRIMARY KEY,
116       last_modified INTEGER,
117       metadata TEXT
118     ) WITHOUT ROWID;`,
120   createCollectionDataRecordIdIndex: `
121     CREATE UNIQUE INDEX unique_collection_record
122       ON collection_data(collection_name, record_id);`,
124   clearData: `
125     DELETE FROM collection_data
126       WHERE collection_name = :collection_name;`,
128   createData: `
129     INSERT INTO collection_data (collection_name, record_id, record)
130       VALUES (:collection_name, :record_id, :record);`,
132   updateData: `
133     INSERT OR REPLACE INTO collection_data (collection_name, record_id, record)
134       VALUES (:collection_name, :record_id, :record);`,
136   deleteData: `
137     DELETE FROM collection_data
138       WHERE collection_name = :collection_name
139       AND record_id = :record_id;`,
141   saveLastModified: `
142     INSERT INTO collection_metadata(collection_name, last_modified)
143       VALUES(:collection_name, :last_modified)
144         ON CONFLICT(collection_name) DO UPDATE SET last_modified = :last_modified`,
146   getLastModified: `
147     SELECT last_modified
148       FROM collection_metadata
149         WHERE collection_name = :collection_name;`,
151   saveMetadata: `
152     INSERT INTO collection_metadata(collection_name, metadata)
153       VALUES(:collection_name, :metadata)
154         ON CONFLICT(collection_name) DO UPDATE SET metadata = :metadata`,
156   getMetadata: `
157     SELECT metadata
158       FROM collection_metadata
159         WHERE collection_name = :collection_name;`,
161   getRecord: `
162     SELECT record
163       FROM collection_data
164         WHERE collection_name = :collection_name
165         AND record_id = :record_id;`,
167   listRecords: `
168     SELECT record
169       FROM collection_data
170         WHERE collection_name = :collection_name;`,
172   // N.B. we have to have a dynamic number of placeholders, which you
173   // can't do without building your own statement. See `execute` for details
174   listRecordsById: `
175     SELECT record_id, record
176       FROM collection_data
177         WHERE collection_name = ?
178           AND record_id IN `,
180   importData: `
181     REPLACE INTO collection_data (collection_name, record_id, record)
182       VALUES (:collection_name, :record_id, :record);`,
184   scanAllRecords: `SELECT * FROM collection_data;`,
186   clearCollectionMetadata: `DELETE FROM collection_metadata;`,
188   calculateStorage: `
189     SELECT collection_name, SUM(LENGTH(record)) as size, COUNT(record) as num_records
190       FROM collection_data
191         GROUP BY collection_name;`,
193   addMetadataColumn: `
194     ALTER TABLE collection_metadata
195       ADD COLUMN metadata TEXT;`,
198 const createStatements = [
199   "createCollectionData",
200   "createCollectionMetadata",
201   "createCollectionDataRecordIdIndex",
204 const currentSchemaVersion = 2;
207  * Firefox adapter.
209  * Uses Sqlite as a backing store.
211  * Options:
212  *  - sqliteHandle: a handle to the Sqlite database this adapter will
213  *    use as its backing store. To open such a handle, use the
214  *    static openConnection() method.
215  */
216 export class FirefoxAdapter extends Kinto.adapters.BaseAdapter {
217   constructor(collection, options = {}) {
218     super();
219     const { sqliteHandle = null } = options;
220     this.collection = collection;
221     this._connection = sqliteHandle;
222     this._options = options;
223   }
225   /**
226    * Initialize a Sqlite connection to be suitable for use with Kinto.
227    *
228    * This will be called automatically by open().
229    */
230   static async _init(connection) {
231     await connection.executeTransaction(async function doSetup() {
232       const schema = await connection.getSchemaVersion();
234       if (schema == 0) {
235         for (let statementName of createStatements) {
236           await connection.execute(statements[statementName]);
237         }
238         await connection.setSchemaVersion(currentSchemaVersion);
239       } else if (schema == 1) {
240         await connection.execute(statements.addMetadataColumn);
241         await connection.setSchemaVersion(currentSchemaVersion);
242       } else if (schema != 2) {
243         throw new Error("Unknown database schema: " + schema);
244       }
245     });
246     return connection;
247   }
249   _executeStatement(statement, params) {
250     return this._connection.executeCached(statement, params);
251   }
253   /**
254    * Open and initialize a Sqlite connection to a database that Kinto
255    * can use. When you are done with this connection, close it by
256    * calling close().
257    *
258    * Options:
259    *   - path: The path for the Sqlite database
260    *
261    * @returns SqliteConnection
262    */
263   static async openConnection(options) {
264     const opts = Object.assign({}, { sharedMemoryCache: false }, options);
265     const conn = await Sqlite.openConnection(opts).then(this._init);
266     try {
267       Sqlite.shutdown.addBlocker(
268         "Kinto storage adapter connection closing",
269         () => conn.close()
270       );
271     } catch (e) {
272       // It's too late to block shutdown, just close the connection.
273       await conn.close();
274       throw e;
275     }
276     return conn;
277   }
279   clear() {
280     const params = { collection_name: this.collection };
281     return this._executeStatement(statements.clearData, params);
282   }
284   execute(callback, options = { preload: [] }) {
285     let result;
286     const conn = this._connection;
287     const collection = this.collection;
289     return conn
290       .executeTransaction(async function doExecuteTransaction() {
291         // Preload specified records from DB, within transaction.
293         // if options.preload has more elements than the sqlite variable
294         // limit, split it up.
295         const limit = 100;
296         let preloaded = {};
297         let preload;
298         let more = options.preload;
300         while (more.length) {
301           preload = more.slice(0, limit);
302           more = more.slice(limit, more.length);
304           const parameters = [collection, ...preload];
305           const placeholders = preload.map(_ => "?");
306           const stmt =
307             statements.listRecordsById + "(" + placeholders.join(",") + ");";
308           const rows = await conn.execute(stmt, parameters);
310           rows.reduce((acc, row) => {
311             const record = JSON.parse(row.getResultByName("record"));
312             acc[row.getResultByName("record_id")] = record;
313             return acc;
314           }, preloaded);
315         }
316         const proxy = transactionProxy(collection, preloaded);
317         result = callback(proxy);
319         for (let { statement, params } of proxy.operations) {
320           await conn.executeCached(statement, params);
321         }
322       }, conn.TRANSACTION_EXCLUSIVE)
323       .then(_ => result);
324   }
326   get(id) {
327     const params = {
328       collection_name: this.collection,
329       record_id: id,
330     };
331     return this._executeStatement(statements.getRecord, params).then(result => {
332       if (!result.length) {
333         return null;
334       }
335       return JSON.parse(result[0].getResultByName("record"));
336     });
337   }
339   list(params = { filters: {}, order: "" }) {
340     const parameters = {
341       collection_name: this.collection,
342     };
343     return this._executeStatement(statements.listRecords, parameters)
344       .then(result => {
345         const records = [];
346         for (let k = 0; k < result.length; k++) {
347           const row = result[k];
348           records.push(JSON.parse(row.getResultByName("record")));
349         }
350         return records;
351       })
352       .then(results => {
353         // The resulting list of records is filtered and sorted.
354         // XXX: with some efforts, this could be implemented using SQL.
355         return reduceRecords(params.filters, params.order, results);
356       });
357   }
359   async loadDump(records) {
360     return this.importBulk(records);
361   }
363   /**
364    * Load a list of records into the local database.
365    *
366    * Note: The adapter is not in charge of filtering the already imported
367    * records. This is done in `Collection#loadDump()`, as a common behaviour
368    * between every adapters.
369    *
370    * @param  {Array} records.
371    * @return {Array} imported records.
372    */
373   async importBulk(records) {
374     const connection = this._connection;
375     const collection_name = this.collection;
376     await connection.executeTransaction(async function doImport() {
377       for (let record of records) {
378         const params = {
379           collection_name,
380           record_id: record.id,
381           record: JSON.stringify(record),
382         };
383         await connection.execute(statements.importData, params);
384       }
385       const lastModified = Math.max(
386         ...records.map(record => record.last_modified)
387       );
388       const params = {
389         collection_name,
390       };
391       const previousLastModified = await connection
392         .execute(statements.getLastModified, params)
393         .then(result => {
394           return result.length
395             ? result[0].getResultByName("last_modified")
396             : -1;
397         });
398       if (lastModified > previousLastModified) {
399         const params = {
400           collection_name,
401           last_modified: lastModified,
402         };
403         await connection.execute(statements.saveLastModified, params);
404       }
405     });
406     return records;
407   }
409   saveLastModified(lastModified) {
410     const parsedLastModified = parseInt(lastModified, 10) || null;
411     const params = {
412       collection_name: this.collection,
413       last_modified: parsedLastModified,
414     };
415     return this._executeStatement(statements.saveLastModified, params).then(
416       () => parsedLastModified
417     );
418   }
420   getLastModified() {
421     const params = {
422       collection_name: this.collection,
423     };
424     return this._executeStatement(statements.getLastModified, params).then(
425       result => {
426         if (!result.length) {
427           return 0;
428         }
429         return result[0].getResultByName("last_modified");
430       }
431     );
432   }
434   async saveMetadata(metadata) {
435     const params = {
436       collection_name: this.collection,
437       metadata: JSON.stringify(metadata),
438     };
439     await this._executeStatement(statements.saveMetadata, params);
440     return metadata;
441   }
443   async getMetadata() {
444     const params = {
445       collection_name: this.collection,
446     };
447     const result = await this._executeStatement(statements.getMetadata, params);
448     if (!result.length) {
449       return null;
450     }
451     return JSON.parse(result[0].getResultByName("metadata"));
452   }
454   calculateStorage() {
455     return this._executeStatement(statements.calculateStorage, {}).then(
456       result => {
457         return Array.from(result, row => ({
458           collectionName: row.getResultByName("collection_name"),
459           size: row.getResultByName("size"),
460           numRecords: row.getResultByName("num_records"),
461         }));
462       }
463     );
464   }
466   /**
467    * Reset the sync status of every record and collection we have
468    * access to.
469    */
470   resetSyncStatus() {
471     // We're going to use execute instead of executeCached, so build
472     // in our own sanity check
473     if (!this._connection) {
474       throw new Error("The storage adapter is not open");
475     }
477     return this._connection.executeTransaction(async function (conn) {
478       const promises = [];
479       await conn.execute(statements.scanAllRecords, null, function (row) {
480         const record = JSON.parse(row.getResultByName("record"));
481         const record_id = row.getResultByName("record_id");
482         const collection_name = row.getResultByName("collection_name");
483         if (record._status === "deleted") {
484           // Garbage collect deleted records.
485           promises.push(
486             conn.execute(statements.deleteData, { collection_name, record_id })
487           );
488         } else {
489           const newRecord = Object.assign({}, record, {
490             _status: "created",
491             last_modified: undefined,
492           });
493           promises.push(
494             conn.execute(statements.updateData, {
495               record: JSON.stringify(newRecord),
496               record_id,
497               collection_name,
498             })
499           );
500         }
501       });
502       await Promise.all(promises);
503       await conn.execute(statements.clearCollectionMetadata);
504     });
505   }
508 function transactionProxy(collection, preloaded) {
509   const _operations = [];
511   return {
512     get operations() {
513       return _operations;
514     },
516     create(record) {
517       _operations.push({
518         statement: statements.createData,
519         params: {
520           collection_name: collection,
521           record_id: record.id,
522           record: JSON.stringify(record),
523         },
524       });
525     },
527     update(record) {
528       _operations.push({
529         statement: statements.updateData,
530         params: {
531           collection_name: collection,
532           record_id: record.id,
533           record: JSON.stringify(record),
534         },
535       });
536     },
538     delete(id) {
539       _operations.push({
540         statement: statements.deleteData,
541         params: {
542           collection_name: collection,
543           record_id: id,
544         },
545       });
546     },
548     get(id) {
549       // Gecko JS engine outputs undesired warnings if id is not in preloaded.
550       return id in preloaded ? preloaded[id] : undefined;
551     },
552   };