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 import { Kinto } from "resource://services-common/kinto-offline-client.sys.mjs";
19 * Filter and sort list against provided filters and order.
21 * @param {Object} filters The filters to apply.
22 * @param {String} order The order to apply.
23 * @param {Array} list The list to reduce.
26 function reduceRecords(filters, order, list) {
27 const filtered = filters ? filterObjects(filters, list) : list;
28 return order ? sortObjects(order, filtered) : filtered;
32 * Checks if a value is undefined.
34 * This is a copy of `_isUndefined` from kinto.js/src/utils.js.
38 function _isUndefined(value) {
39 return typeof value === "undefined";
43 * Sorts records in a list according to a given ordering.
45 * This is a copy of `sortObjects` from kinto.js/src/utils.js.
47 * @param {String} order The ordering, eg. `-last_modified`.
48 * @param {Array} list The collection to order.
51 function sortObjects(order, list) {
52 const hasDash = order[0] === "-";
53 const field = hasDash ? order.slice(1) : order;
54 const direction = hasDash ? -1 : 1;
55 return list.slice().sort((a, b) => {
56 if (a[field] && _isUndefined(b[field])) {
59 if (b[field] && _isUndefined(a[field])) {
62 if (_isUndefined(a[field]) && _isUndefined(b[field])) {
65 return a[field] > b[field] ? direction : -direction;
70 * Test if a single object matches all given filters.
72 * This is a copy of `filterObject` from kinto.js/src/utils.js.
74 * @param {Object} filters The filters object.
75 * @param {Object} entry The object to filter.
78 function filterObject(filters, entry) {
79 return Object.keys(filters).every(filter => {
80 const value = filters[filter];
81 if (Array.isArray(value)) {
82 return value.some(candidate => candidate === entry[filter]);
84 return entry[filter] === value;
89 * Filters records in a list matching all given filters.
91 * This is a copy of `filterObjects` from kinto.js/src/utils.js.
93 * @param {Object} filters The filters object.
94 * @param {Array} list The collection to filter.
97 function filterObjects(filters, list) {
98 return list.filter(entry => {
99 return filterObject(filters, entry);
104 createCollectionData: `
105 CREATE TABLE collection_data (
106 collection_name TEXT,
111 createCollectionMetadata: `
112 CREATE TABLE collection_metadata (
113 collection_name TEXT PRIMARY KEY,
114 last_modified INTEGER,
118 createCollectionDataRecordIdIndex: `
119 CREATE UNIQUE INDEX unique_collection_record
120 ON collection_data(collection_name, record_id);`,
123 DELETE FROM collection_data
124 WHERE collection_name = :collection_name;`,
127 INSERT INTO collection_data (collection_name, record_id, record)
128 VALUES (:collection_name, :record_id, :record);`,
131 INSERT OR REPLACE INTO collection_data (collection_name, record_id, record)
132 VALUES (:collection_name, :record_id, :record);`,
135 DELETE FROM collection_data
136 WHERE collection_name = :collection_name
137 AND record_id = :record_id;`,
140 INSERT INTO collection_metadata(collection_name, last_modified)
141 VALUES(:collection_name, :last_modified)
142 ON CONFLICT(collection_name) DO UPDATE SET last_modified = :last_modified`,
146 FROM collection_metadata
147 WHERE collection_name = :collection_name;`,
150 INSERT INTO collection_metadata(collection_name, metadata)
151 VALUES(:collection_name, :metadata)
152 ON CONFLICT(collection_name) DO UPDATE SET metadata = :metadata`,
156 FROM collection_metadata
157 WHERE collection_name = :collection_name;`,
162 WHERE collection_name = :collection_name
163 AND record_id = :record_id;`,
168 WHERE collection_name = :collection_name;`,
170 // N.B. we have to have a dynamic number of placeholders, which you
171 // can't do without building your own statement. See `execute` for details
173 SELECT record_id, record
175 WHERE collection_name = ?
179 REPLACE INTO collection_data (collection_name, record_id, record)
180 VALUES (:collection_name, :record_id, :record);`,
182 scanAllRecords: `SELECT * FROM collection_data;`,
184 clearCollectionMetadata: `DELETE FROM collection_metadata;`,
187 SELECT collection_name, SUM(LENGTH(record)) as size, COUNT(record) as num_records
189 GROUP BY collection_name;`,
192 ALTER TABLE collection_metadata
193 ADD COLUMN metadata TEXT;`,
196 const createStatements = [
197 "createCollectionData",
198 "createCollectionMetadata",
199 "createCollectionDataRecordIdIndex",
202 const currentSchemaVersion = 2;
207 * Uses Sqlite as a backing store.
210 * - sqliteHandle: a handle to the Sqlite database this adapter will
211 * use as its backing store. To open such a handle, use the
212 * static openConnection() method.
214 export class FirefoxAdapter extends Kinto.adapters.BaseAdapter {
215 constructor(collection, options = {}) {
217 const { sqliteHandle = null } = options;
218 this.collection = collection;
219 this._connection = sqliteHandle;
220 this._options = options;
224 * Initialize a Sqlite connection to be suitable for use with Kinto.
226 * This will be called automatically by open().
228 static async _init(connection) {
229 await connection.executeTransaction(async function doSetup() {
230 const schema = await connection.getSchemaVersion();
233 for (let statementName of createStatements) {
234 await connection.execute(statements[statementName]);
236 await connection.setSchemaVersion(currentSchemaVersion);
237 } else if (schema == 1) {
238 await connection.execute(statements.addMetadataColumn);
239 await connection.setSchemaVersion(currentSchemaVersion);
240 } else if (schema != 2) {
241 throw new Error("Unknown database schema: " + schema);
247 _executeStatement(statement, params) {
248 return this._connection.executeCached(statement, params);
252 * Open and initialize a Sqlite connection to a database that Kinto
253 * can use. When you are done with this connection, close it by
257 * - path: The path for the Sqlite database
259 * @returns SqliteConnection
261 static async openConnection(options) {
262 const opts = Object.assign({}, { sharedMemoryCache: false }, options);
263 const conn = await Sqlite.openConnection(opts).then(this._init);
265 Sqlite.shutdown.addBlocker(
266 "Kinto storage adapter connection closing",
270 // It's too late to block shutdown, just close the connection.
278 const params = { collection_name: this.collection };
279 return this._executeStatement(statements.clearData, params);
282 execute(callback, options = { preload: [] }) {
284 const conn = this._connection;
285 const collection = this.collection;
288 .executeTransaction(async function doExecuteTransaction() {
289 // Preload specified records from DB, within transaction.
291 // if options.preload has more elements than the sqlite variable
292 // limit, split it up.
296 let more = options.preload;
298 while (more.length) {
299 preload = more.slice(0, limit);
300 more = more.slice(limit, more.length);
302 const parameters = [collection, ...preload];
303 const placeholders = preload.map(_ => "?");
305 statements.listRecordsById + "(" + placeholders.join(",") + ");";
306 const rows = await conn.execute(stmt, parameters);
308 rows.reduce((acc, row) => {
309 const record = JSON.parse(row.getResultByName("record"));
310 acc[row.getResultByName("record_id")] = record;
314 const proxy = transactionProxy(collection, preloaded);
315 result = callback(proxy);
317 for (let { statement, params } of proxy.operations) {
318 await conn.executeCached(statement, params);
320 }, conn.TRANSACTION_EXCLUSIVE)
326 collection_name: this.collection,
329 return this._executeStatement(statements.getRecord, params).then(result => {
330 if (!result.length) {
333 return JSON.parse(result[0].getResultByName("record"));
337 list(params = { filters: {}, order: "" }) {
339 collection_name: this.collection,
341 return this._executeStatement(statements.listRecords, parameters)
344 for (let k = 0; k < result.length; k++) {
345 const row = result[k];
346 records.push(JSON.parse(row.getResultByName("record")));
351 // The resulting list of records is filtered and sorted.
352 // XXX: with some efforts, this could be implemented using SQL.
353 return reduceRecords(params.filters, params.order, results);
357 async loadDump(records) {
358 return this.importBulk(records);
362 * Load a list of records into the local database.
364 * Note: The adapter is not in charge of filtering the already imported
365 * records. This is done in `Collection#loadDump()`, as a common behaviour
366 * between every adapters.
368 * @param {Array} records.
369 * @return {Array} imported records.
371 async importBulk(records) {
372 const connection = this._connection;
373 const collection_name = this.collection;
374 await connection.executeTransaction(async function doImport() {
375 for (let record of records) {
378 record_id: record.id,
379 record: JSON.stringify(record),
381 await connection.execute(statements.importData, params);
383 const lastModified = Math.max(
384 ...records.map(record => record.last_modified)
389 const previousLastModified = await connection
390 .execute(statements.getLastModified, params)
393 ? result[0].getResultByName("last_modified")
396 if (lastModified > previousLastModified) {
399 last_modified: lastModified,
401 await connection.execute(statements.saveLastModified, params);
407 saveLastModified(lastModified) {
408 const parsedLastModified = parseInt(lastModified, 10) || null;
410 collection_name: this.collection,
411 last_modified: parsedLastModified,
413 return this._executeStatement(statements.saveLastModified, params).then(
414 () => parsedLastModified
420 collection_name: this.collection,
422 return this._executeStatement(statements.getLastModified, params).then(
424 if (!result.length) {
427 return result[0].getResultByName("last_modified");
432 async saveMetadata(metadata) {
434 collection_name: this.collection,
435 metadata: JSON.stringify(metadata),
437 await this._executeStatement(statements.saveMetadata, params);
441 async getMetadata() {
443 collection_name: this.collection,
445 const result = await this._executeStatement(statements.getMetadata, params);
446 if (!result.length) {
449 return JSON.parse(result[0].getResultByName("metadata"));
453 return this._executeStatement(statements.calculateStorage, {}).then(
455 return Array.from(result, row => ({
456 collectionName: row.getResultByName("collection_name"),
457 size: row.getResultByName("size"),
458 numRecords: row.getResultByName("num_records"),
465 * Reset the sync status of every record and collection we have
469 // We're going to use execute instead of executeCached, so build
470 // in our own sanity check
471 if (!this._connection) {
472 throw new Error("The storage adapter is not open");
475 return this._connection.executeTransaction(async function (conn) {
477 await conn.execute(statements.scanAllRecords, null, function (row) {
478 const record = JSON.parse(row.getResultByName("record"));
479 const record_id = row.getResultByName("record_id");
480 const collection_name = row.getResultByName("collection_name");
481 if (record._status === "deleted") {
482 // Garbage collect deleted records.
484 conn.execute(statements.deleteData, { collection_name, record_id })
487 const newRecord = Object.assign({}, record, {
489 last_modified: undefined,
492 conn.execute(statements.updateData, {
493 record: JSON.stringify(newRecord),
500 await Promise.all(promises);
501 await conn.execute(statements.clearCollectionMetadata);
506 function transactionProxy(collection, preloaded) {
507 const _operations = [];
516 statement: statements.createData,
518 collection_name: collection,
519 record_id: record.id,
520 record: JSON.stringify(record),
527 statement: statements.updateData,
529 collection_name: collection,
530 record_id: record.id,
531 record: JSON.stringify(record),
538 statement: statements.deleteData,
540 collection_name: collection,
547 // Gecko JS engine outputs undesired warnings if id is not in preloaded.
548 return id in preloaded ? preloaded[id] : undefined;