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
6 * http://www.apache.org/licenses/LICENSE-2.0
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.
14 import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs";
16 const { Kinto } = ChromeUtils.import(
17 "resource://services-common/kinto-offline-client.js"
21 * Filter and sort list against provided filters and order.
23 * @param {Object} filters The filters to apply.
24 * @param {String} order The order to apply.
25 * @param {Array} list The list to reduce.
28 function reduceRecords(filters, order, list) {
29 const filtered = filters ? filterObjects(filters, list) : list;
30 return order ? sortObjects(order, filtered) : filtered;
34 * Checks if a value is undefined.
36 * This is a copy of `_isUndefined` from kinto.js/src/utils.js.
40 function _isUndefined(value) {
41 return typeof value === "undefined";
45 * Sorts records in a list according to a given ordering.
47 * This is a copy of `sortObjects` from kinto.js/src/utils.js.
49 * @param {String} order The ordering, eg. `-last_modified`.
50 * @param {Array} list The collection to order.
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])) {
61 if (b[field] && _isUndefined(a[field])) {
64 if (_isUndefined(a[field]) && _isUndefined(b[field])) {
67 return a[field] > b[field] ? direction : -direction;
72 * Test if a single object matches all given filters.
74 * This is a copy of `filterObject` from kinto.js/src/utils.js.
76 * @param {Object} filters The filters object.
77 * @param {Object} entry The object to filter.
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]);
86 return entry[filter] === value;
91 * Filters records in a list matching all given filters.
93 * This is a copy of `filterObjects` from kinto.js/src/utils.js.
95 * @param {Object} filters The filters object.
96 * @param {Array} list The collection to filter.
99 function filterObjects(filters, list) {
100 return list.filter(entry => {
101 return filterObject(filters, entry);
106 createCollectionData: `
107 CREATE TABLE collection_data (
108 collection_name TEXT,
113 createCollectionMetadata: `
114 CREATE TABLE collection_metadata (
115 collection_name TEXT PRIMARY KEY,
116 last_modified INTEGER,
120 createCollectionDataRecordIdIndex: `
121 CREATE UNIQUE INDEX unique_collection_record
122 ON collection_data(collection_name, record_id);`,
125 DELETE FROM collection_data
126 WHERE collection_name = :collection_name;`,
129 INSERT INTO collection_data (collection_name, record_id, record)
130 VALUES (:collection_name, :record_id, :record);`,
133 INSERT OR REPLACE INTO collection_data (collection_name, record_id, record)
134 VALUES (:collection_name, :record_id, :record);`,
137 DELETE FROM collection_data
138 WHERE collection_name = :collection_name
139 AND record_id = :record_id;`,
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`,
148 FROM collection_metadata
149 WHERE collection_name = :collection_name;`,
152 INSERT INTO collection_metadata(collection_name, metadata)
153 VALUES(:collection_name, :metadata)
154 ON CONFLICT(collection_name) DO UPDATE SET metadata = :metadata`,
158 FROM collection_metadata
159 WHERE collection_name = :collection_name;`,
164 WHERE collection_name = :collection_name
165 AND record_id = :record_id;`,
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
175 SELECT record_id, record
177 WHERE collection_name = ?
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;`,
189 SELECT collection_name, SUM(LENGTH(record)) as size, COUNT(record) as num_records
191 GROUP BY collection_name;`,
194 ALTER TABLE collection_metadata
195 ADD COLUMN metadata TEXT;`,
198 const createStatements = [
199 "createCollectionData",
200 "createCollectionMetadata",
201 "createCollectionDataRecordIdIndex",
204 const currentSchemaVersion = 2;
209 * Uses Sqlite as a backing store.
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.
216 export class FirefoxAdapter extends Kinto.adapters.BaseAdapter {
217 constructor(collection, options = {}) {
219 const { sqliteHandle = null } = options;
220 this.collection = collection;
221 this._connection = sqliteHandle;
222 this._options = options;
226 * Initialize a Sqlite connection to be suitable for use with Kinto.
228 * This will be called automatically by open().
230 static async _init(connection) {
231 await connection.executeTransaction(async function doSetup() {
232 const schema = await connection.getSchemaVersion();
235 for (let statementName of createStatements) {
236 await connection.execute(statements[statementName]);
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);
249 _executeStatement(statement, params) {
250 return this._connection.executeCached(statement, params);
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
259 * - path: The path for the Sqlite database
261 * @returns SqliteConnection
263 static async openConnection(options) {
264 const opts = Object.assign({}, { sharedMemoryCache: false }, options);
265 const conn = await Sqlite.openConnection(opts).then(this._init);
267 Sqlite.shutdown.addBlocker(
268 "Kinto storage adapter connection closing",
272 // It's too late to block shutdown, just close the connection.
280 const params = { collection_name: this.collection };
281 return this._executeStatement(statements.clearData, params);
284 execute(callback, options = { preload: [] }) {
286 const conn = this._connection;
287 const collection = this.collection;
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.
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(_ => "?");
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;
316 const proxy = transactionProxy(collection, preloaded);
317 result = callback(proxy);
319 for (let { statement, params } of proxy.operations) {
320 await conn.executeCached(statement, params);
322 }, conn.TRANSACTION_EXCLUSIVE)
328 collection_name: this.collection,
331 return this._executeStatement(statements.getRecord, params).then(result => {
332 if (!result.length) {
335 return JSON.parse(result[0].getResultByName("record"));
339 list(params = { filters: {}, order: "" }) {
341 collection_name: this.collection,
343 return this._executeStatement(statements.listRecords, parameters)
346 for (let k = 0; k < result.length; k++) {
347 const row = result[k];
348 records.push(JSON.parse(row.getResultByName("record")));
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);
359 async loadDump(records) {
360 return this.importBulk(records);
364 * Load a list of records into the local database.
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.
370 * @param {Array} records.
371 * @return {Array} imported records.
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) {
380 record_id: record.id,
381 record: JSON.stringify(record),
383 await connection.execute(statements.importData, params);
385 const lastModified = Math.max(
386 ...records.map(record => record.last_modified)
391 const previousLastModified = await connection
392 .execute(statements.getLastModified, params)
395 ? result[0].getResultByName("last_modified")
398 if (lastModified > previousLastModified) {
401 last_modified: lastModified,
403 await connection.execute(statements.saveLastModified, params);
409 saveLastModified(lastModified) {
410 const parsedLastModified = parseInt(lastModified, 10) || null;
412 collection_name: this.collection,
413 last_modified: parsedLastModified,
415 return this._executeStatement(statements.saveLastModified, params).then(
416 () => parsedLastModified
422 collection_name: this.collection,
424 return this._executeStatement(statements.getLastModified, params).then(
426 if (!result.length) {
429 return result[0].getResultByName("last_modified");
434 async saveMetadata(metadata) {
436 collection_name: this.collection,
437 metadata: JSON.stringify(metadata),
439 await this._executeStatement(statements.saveMetadata, params);
443 async getMetadata() {
445 collection_name: this.collection,
447 const result = await this._executeStatement(statements.getMetadata, params);
448 if (!result.length) {
451 return JSON.parse(result[0].getResultByName("metadata"));
455 return this._executeStatement(statements.calculateStorage, {}).then(
457 return Array.from(result, row => ({
458 collectionName: row.getResultByName("collection_name"),
459 size: row.getResultByName("size"),
460 numRecords: row.getResultByName("num_records"),
467 * Reset the sync status of every record and collection we have
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");
477 return this._connection.executeTransaction(async function (conn) {
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.
486 conn.execute(statements.deleteData, { collection_name, record_id })
489 const newRecord = Object.assign({}, record, {
491 last_modified: undefined,
494 conn.execute(statements.updateData, {
495 record: JSON.stringify(newRecord),
502 await Promise.all(promises);
503 await conn.execute(statements.clearCollectionMetadata);
508 function transactionProxy(collection, preloaded) {
509 const _operations = [];
518 statement: statements.createData,
520 collection_name: collection,
521 record_id: record.id,
522 record: JSON.stringify(record),
529 statement: statements.updateData,
531 collection_name: collection,
532 record_id: record.id,
533 record: JSON.stringify(record),
540 statement: statements.deleteData,
542 collection_name: collection,
549 // Gecko JS engine outputs undesired warnings if id is not in preloaded.
550 return id in preloaded ? preloaded[id] : undefined;