gecko-dev/services/common/kinto-storage-adapter.sys.mjs

552 lines
16 KiB
JavaScript

/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs";
import { Kinto } from "resource://services-common/kinto-offline-client.sys.mjs";
/**
* Filter and sort list against provided filters and order.
*
* @param {Object} filters The filters to apply.
* @param {String} order The order to apply.
* @param {Array} list The list to reduce.
* @return {Array}
*/
function reduceRecords(filters, order, list) {
const filtered = filters ? filterObjects(filters, list) : list;
return order ? sortObjects(order, filtered) : filtered;
}
/**
* Checks if a value is undefined.
*
* This is a copy of `_isUndefined` from kinto.js/src/utils.js.
* @param {Any} value
* @return {Boolean}
*/
function _isUndefined(value) {
return typeof value === "undefined";
}
/**
* Sorts records in a list according to a given ordering.
*
* This is a copy of `sortObjects` from kinto.js/src/utils.js.
*
* @param {String} order The ordering, eg. `-last_modified`.
* @param {Array} list The collection to order.
* @return {Array}
*/
function sortObjects(order, list) {
const hasDash = order[0] === "-";
const field = hasDash ? order.slice(1) : order;
const direction = hasDash ? -1 : 1;
return list.slice().sort((a, b) => {
if (a[field] && _isUndefined(b[field])) {
return direction;
}
if (b[field] && _isUndefined(a[field])) {
return -direction;
}
if (_isUndefined(a[field]) && _isUndefined(b[field])) {
return 0;
}
return a[field] > b[field] ? direction : -direction;
});
}
/**
* Test if a single object matches all given filters.
*
* This is a copy of `filterObject` from kinto.js/src/utils.js.
*
* @param {Object} filters The filters object.
* @param {Object} entry The object to filter.
* @return {Function}
*/
function filterObject(filters, entry) {
return Object.keys(filters).every(filter => {
const value = filters[filter];
if (Array.isArray(value)) {
return value.some(candidate => candidate === entry[filter]);
}
return entry[filter] === value;
});
}
/**
* Filters records in a list matching all given filters.
*
* This is a copy of `filterObjects` from kinto.js/src/utils.js.
*
* @param {Object} filters The filters object.
* @param {Array} list The collection to filter.
* @return {Array}
*/
function filterObjects(filters, list) {
return list.filter(entry => {
return filterObject(filters, entry);
});
}
const statements = {
createCollectionData: `
CREATE TABLE collection_data (
collection_name TEXT,
record_id TEXT,
record TEXT
);`,
createCollectionMetadata: `
CREATE TABLE collection_metadata (
collection_name TEXT PRIMARY KEY,
last_modified INTEGER,
metadata TEXT
) WITHOUT ROWID;`,
createCollectionDataRecordIdIndex: `
CREATE UNIQUE INDEX unique_collection_record
ON collection_data(collection_name, record_id);`,
clearData: `
DELETE FROM collection_data
WHERE collection_name = :collection_name;`,
createData: `
INSERT INTO collection_data (collection_name, record_id, record)
VALUES (:collection_name, :record_id, :record);`,
updateData: `
INSERT OR REPLACE INTO collection_data (collection_name, record_id, record)
VALUES (:collection_name, :record_id, :record);`,
deleteData: `
DELETE FROM collection_data
WHERE collection_name = :collection_name
AND record_id = :record_id;`,
saveLastModified: `
INSERT INTO collection_metadata(collection_name, last_modified)
VALUES(:collection_name, :last_modified)
ON CONFLICT(collection_name) DO UPDATE SET last_modified = :last_modified`,
getLastModified: `
SELECT last_modified
FROM collection_metadata
WHERE collection_name = :collection_name;`,
saveMetadata: `
INSERT INTO collection_metadata(collection_name, metadata)
VALUES(:collection_name, :metadata)
ON CONFLICT(collection_name) DO UPDATE SET metadata = :metadata`,
getMetadata: `
SELECT metadata
FROM collection_metadata
WHERE collection_name = :collection_name;`,
getRecord: `
SELECT record
FROM collection_data
WHERE collection_name = :collection_name
AND record_id = :record_id;`,
listRecords: `
SELECT record
FROM collection_data
WHERE collection_name = :collection_name;`,
// N.B. we have to have a dynamic number of placeholders, which you
// can't do without building your own statement. See `execute` for details
listRecordsById: `
SELECT record_id, record
FROM collection_data
WHERE collection_name = ?
AND record_id IN `,
importData: `
REPLACE INTO collection_data (collection_name, record_id, record)
VALUES (:collection_name, :record_id, :record);`,
scanAllRecords: `SELECT * FROM collection_data;`,
clearCollectionMetadata: `DELETE FROM collection_metadata;`,
calculateStorage: `
SELECT collection_name, SUM(LENGTH(record)) as size, COUNT(record) as num_records
FROM collection_data
GROUP BY collection_name;`,
addMetadataColumn: `
ALTER TABLE collection_metadata
ADD COLUMN metadata TEXT;`,
};
const createStatements = [
"createCollectionData",
"createCollectionMetadata",
"createCollectionDataRecordIdIndex",
];
const currentSchemaVersion = 2;
/**
* Firefox adapter.
*
* Uses Sqlite as a backing store.
*
* Options:
* - sqliteHandle: a handle to the Sqlite database this adapter will
* use as its backing store. To open such a handle, use the
* static openConnection() method.
*/
export class FirefoxAdapter extends Kinto.adapters.BaseAdapter {
constructor(collection, options = {}) {
super();
const { sqliteHandle = null } = options;
this.collection = collection;
this._connection = sqliteHandle;
this._options = options;
}
/**
* Initialize a Sqlite connection to be suitable for use with Kinto.
*
* This will be called automatically by open().
*/
static async _init(connection) {
await connection.executeTransaction(async function doSetup() {
const schema = await connection.getSchemaVersion();
if (schema == 0) {
for (let statementName of createStatements) {
await connection.execute(statements[statementName]);
}
await connection.setSchemaVersion(currentSchemaVersion);
} else if (schema == 1) {
await connection.execute(statements.addMetadataColumn);
await connection.setSchemaVersion(currentSchemaVersion);
} else if (schema != 2) {
throw new Error("Unknown database schema: " + schema);
}
});
return connection;
}
_executeStatement(statement, params) {
return this._connection.executeCached(statement, params);
}
/**
* Open and initialize a Sqlite connection to a database that Kinto
* can use. When you are done with this connection, close it by
* calling close().
*
* Options:
* - path: The path for the Sqlite database
*
* @returns SqliteConnection
*/
static async openConnection(options) {
const opts = Object.assign({}, { sharedMemoryCache: false }, options);
const conn = await Sqlite.openConnection(opts).then(this._init);
try {
Sqlite.shutdown.addBlocker(
"Kinto storage adapter connection closing",
() => conn.close()
);
} catch (e) {
// It's too late to block shutdown, just close the connection.
await conn.close();
throw e;
}
return conn;
}
clear() {
const params = { collection_name: this.collection };
return this._executeStatement(statements.clearData, params);
}
execute(callback, options = { preload: [] }) {
let result;
const conn = this._connection;
const collection = this.collection;
return conn
.executeTransaction(async function doExecuteTransaction() {
// Preload specified records from DB, within transaction.
// if options.preload has more elements than the sqlite variable
// limit, split it up.
const limit = 100;
let preloaded = {};
let preload;
let more = options.preload;
while (more.length) {
preload = more.slice(0, limit);
more = more.slice(limit, more.length);
const parameters = [collection, ...preload];
const placeholders = preload.map(_ => "?");
const stmt =
statements.listRecordsById + "(" + placeholders.join(",") + ");";
const rows = await conn.execute(stmt, parameters);
rows.reduce((acc, row) => {
const record = JSON.parse(row.getResultByName("record"));
acc[row.getResultByName("record_id")] = record;
return acc;
}, preloaded);
}
const proxy = transactionProxy(collection, preloaded);
result = callback(proxy);
for (let { statement, params } of proxy.operations) {
await conn.executeCached(statement, params);
}
}, conn.TRANSACTION_EXCLUSIVE)
.then(_ => result);
}
get(id) {
const params = {
collection_name: this.collection,
record_id: id,
};
return this._executeStatement(statements.getRecord, params).then(result => {
if (!result.length) {
return null;
}
return JSON.parse(result[0].getResultByName("record"));
});
}
list(params = { filters: {}, order: "" }) {
const parameters = {
collection_name: this.collection,
};
return this._executeStatement(statements.listRecords, parameters)
.then(result => {
const records = [];
for (let k = 0; k < result.length; k++) {
const row = result[k];
records.push(JSON.parse(row.getResultByName("record")));
}
return records;
})
.then(results => {
// The resulting list of records is filtered and sorted.
// XXX: with some efforts, this could be implemented using SQL.
return reduceRecords(params.filters, params.order, results);
});
}
async loadDump(records) {
return this.importBulk(records);
}
/**
* Load a list of records into the local database.
*
* Note: The adapter is not in charge of filtering the already imported
* records. This is done in `Collection#loadDump()`, as a common behaviour
* between every adapters.
*
* @param {Array} records.
* @return {Array} imported records.
*/
async importBulk(records) {
const connection = this._connection;
const collection_name = this.collection;
await connection.executeTransaction(async function doImport() {
for (let record of records) {
const params = {
collection_name,
record_id: record.id,
record: JSON.stringify(record),
};
await connection.execute(statements.importData, params);
}
const lastModified = Math.max(
...records.map(record => record.last_modified)
);
const params = {
collection_name,
};
const previousLastModified = await connection
.execute(statements.getLastModified, params)
.then(result => {
return result.length
? result[0].getResultByName("last_modified")
: -1;
});
if (lastModified > previousLastModified) {
const params = {
collection_name,
last_modified: lastModified,
};
await connection.execute(statements.saveLastModified, params);
}
});
return records;
}
saveLastModified(lastModified) {
const parsedLastModified = parseInt(lastModified, 10) || null;
const params = {
collection_name: this.collection,
last_modified: parsedLastModified,
};
return this._executeStatement(statements.saveLastModified, params).then(
() => parsedLastModified
);
}
getLastModified() {
const params = {
collection_name: this.collection,
};
return this._executeStatement(statements.getLastModified, params).then(
result => {
if (!result.length) {
return 0;
}
return result[0].getResultByName("last_modified");
}
);
}
async saveMetadata(metadata) {
const params = {
collection_name: this.collection,
metadata: JSON.stringify(metadata),
};
await this._executeStatement(statements.saveMetadata, params);
return metadata;
}
async getMetadata() {
const params = {
collection_name: this.collection,
};
const result = await this._executeStatement(statements.getMetadata, params);
if (!result.length) {
return null;
}
return JSON.parse(result[0].getResultByName("metadata"));
}
calculateStorage() {
return this._executeStatement(statements.calculateStorage, {}).then(
result => {
return Array.from(result, row => ({
collectionName: row.getResultByName("collection_name"),
size: row.getResultByName("size"),
numRecords: row.getResultByName("num_records"),
}));
}
);
}
/**
* Reset the sync status of every record and collection we have
* access to.
*/
resetSyncStatus() {
// We're going to use execute instead of executeCached, so build
// in our own sanity check
if (!this._connection) {
throw new Error("The storage adapter is not open");
}
return this._connection.executeTransaction(async function (conn) {
const promises = [];
await conn.execute(statements.scanAllRecords, null, function (row) {
const record = JSON.parse(row.getResultByName("record"));
const record_id = row.getResultByName("record_id");
const collection_name = row.getResultByName("collection_name");
if (record._status === "deleted") {
// Garbage collect deleted records.
promises.push(
conn.execute(statements.deleteData, { collection_name, record_id })
);
} else {
const newRecord = Object.assign({}, record, {
_status: "created",
last_modified: undefined,
});
promises.push(
conn.execute(statements.updateData, {
record: JSON.stringify(newRecord),
record_id,
collection_name,
})
);
}
});
await Promise.all(promises);
await conn.execute(statements.clearCollectionMetadata);
});
}
}
function transactionProxy(collection, preloaded) {
const _operations = [];
return {
get operations() {
return _operations;
},
create(record) {
_operations.push({
statement: statements.createData,
params: {
collection_name: collection,
record_id: record.id,
record: JSON.stringify(record),
},
});
},
update(record) {
_operations.push({
statement: statements.updateData,
params: {
collection_name: collection,
record_id: record.id,
record: JSON.stringify(record),
},
});
},
delete(id) {
_operations.push({
statement: statements.deleteData,
params: {
collection_name: collection,
record_id: id,
},
});
},
get(id) {
// Gecko JS engine outputs undesired warnings if id is not in preloaded.
return id in preloaded ? preloaded[id] : undefined;
},
};
}