kinto
Version:
An Offline-First JavaScript client for Kinto.
712 lines (711 loc) • 26.1 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.open = open;
exports.execute = execute;
const base_1 = __importDefault(require("./base"));
const utils_1 = require("../utils");
const INDEXED_FIELDS = ["id", "_status", "last_modified"];
/**
* Small helper that wraps the opening of an IndexedDB into a Promise.
*
* @param dbname {String} The database name.
* @param version {Integer} Schema version
* @param onupgradeneeded {Function} The callback to execute if schema is
* missing or different.
* @return {Promise<IDBDatabase>}
*/
async function open(dbname, { version, onupgradeneeded, }) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbname, version);
request.onupgradeneeded = (event) => {
const db = request.result;
db.onerror = (event) => reject(request.error);
// When an upgrade is needed, a transaction is started.
const transaction = request.transaction;
transaction.onabort = (event) => {
const error = request.error ||
transaction.error ||
new DOMException("The operation has been aborted", "AbortError");
reject(error);
};
// Callback for store creation etc.
return onupgradeneeded(event);
};
request.onerror = (event) => {
reject(event.target.error);
};
request.onsuccess = (event) => {
const db = request.result;
resolve(db);
};
});
}
/**
* Helper to run the specified callback in a single transaction on the
* specified store.
* The helper focuses on transaction wrapping into a promise.
*
* @param db {IDBDatabase} The database instance.
* @param name {String} The store name.
* @param callback {Function} The piece of code to execute in the transaction.
* @param options {Object} Options.
* @param options.mode {String} Transaction mode (default: read).
* @return {Promise} any value returned by the callback.
*/
async function execute(db, name, callback, options = {}) {
const { mode } = options;
return new Promise((resolve, reject) => {
// On Safari, calling IDBDatabase.transaction with mode == undefined raises
// a TypeError.
const transaction = mode
? db.transaction([name], mode)
: db.transaction([name]);
const store = transaction.objectStore(name);
// Let the callback abort this transaction.
const abort = (e) => {
transaction.abort();
console.error(e);
reject(e);
};
// Execute the specified callback **synchronously**.
let result;
try {
result = callback(store, abort);
}
catch (e) {
abort(e);
}
transaction.onerror = (event) => reject(event.target.error);
transaction.oncomplete = (event) => resolve(result);
transaction.onabort = (event) => {
const error = event.target.error ||
transaction.error ||
new DOMException("The operation has been aborted", "AbortError");
reject(error);
};
});
}
/**
* Helper to wrap the deletion of an IndexedDB database into a promise.
*
* @param dbName {String} the database to delete
* @return {Promise}
*/
async function deleteDatabase(dbName) {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = (event) => resolve(event.target);
request.onerror = (event) => reject(event.target.error);
});
}
/**
* IDB cursor handlers.
* @type {Object}
*/
const cursorHandlers = {
all(filters, done) {
const results = [];
return (event) => {
const cursor = event.target.result;
if (cursor) {
const { value } = cursor;
if ((0, utils_1.filterObject)(filters, value)) {
results.push(value);
}
cursor.continue();
}
else {
done(results);
}
};
},
in(values, filters, done) {
const results = [];
let i = 0;
return function (event) {
const cursor = event.target.result;
if (!cursor) {
done(results);
return;
}
const { key, value } = cursor;
// `key` can be an array of two values (see `keyPath` in indices definitions).
// `values` can be an array of arrays if we filter using an index whose key path
// is an array (eg. `cursorHandlers.in([["bid/cid", 42], ["bid/cid", 43]], ...)`)
while (key > values[i]) {
// The cursor has passed beyond this key. Check next.
++i;
if (i === values.length) {
done(results); // There is no next. Stop searching.
return;
}
}
const isEqual = Array.isArray(key)
? (0, utils_1.arrayEqual)(key, values[i])
: key === values[i];
if (isEqual) {
if ((0, utils_1.filterObject)(filters, value)) {
results.push(value);
}
cursor.continue();
}
else {
cursor.continue(values[i]);
}
};
},
};
/**
* Creates an IDB request and attach it the appropriate cursor event handler to
* perform a list query.
*
* Multiple matching values are handled by passing an array.
*
* @param {String} cid The collection id (ie. `{bid}/{cid}`)
* @param {IDBStore} store The IDB store.
* @param {Object} filters Filter the records by field.
* @param {Function} done The operation completion handler.
* @return {IDBRequest}
*/
function createListRequest(cid, store, filters, done) {
const filterFields = Object.keys(filters);
// If no filters, get all results in one bulk.
if (filterFields.length === 0) {
const request = store.index("cid").getAll(IDBKeyRange.only(cid));
request.onsuccess = (event) => done(event.target.result);
return request;
}
// Introspect filters and check if they leverage an indexed field.
const indexField = filterFields.find((field) => {
return INDEXED_FIELDS.includes(field);
});
if (!indexField) {
// Iterate on all records for this collection (ie. cid)
const isSubQuery = Object.keys(filters).some((key) => key.includes(".")); // (ie. filters: {"article.title": "hello"})
if (isSubQuery) {
const newFilter = (0, utils_1.transformSubObjectFilters)(filters);
const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
request.onsuccess = cursorHandlers.all(newFilter, done);
return request;
}
const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
request.onsuccess = cursorHandlers.all(filters, done);
return request;
}
// If `indexField` was used already, don't filter again.
const remainingFilters = (0, utils_1.omitKeys)(filters, [indexField]);
// value specified in the filter (eg. `filters: { _status: ["created", "updated"] }`)
const value = filters[indexField];
// For the "id" field, use the primary key.
const indexStore = indexField === "id" ? store : store.index(indexField);
// WHERE IN equivalent clause
if (Array.isArray(value)) {
if (value.length === 0) {
return done([]);
}
const values = value.map((i) => [cid, i]).sort();
const range = IDBKeyRange.bound(values[0], values[values.length - 1]);
const request = indexStore.openCursor(range);
request.onsuccess = cursorHandlers.in(values, remainingFilters, done);
return request;
}
// If no filters on custom attribute, get all results in one bulk.
if (Object.keys(remainingFilters).length === 0) {
const request = indexStore.getAll(IDBKeyRange.only([cid, value]));
request.onsuccess = (event) => done(event.target.result);
return request;
}
// WHERE field = value clause
const request = indexStore.openCursor(IDBKeyRange.only([cid, value]));
request.onsuccess = cursorHandlers.all(remainingFilters, done);
return request;
}
class IDBError extends Error {
constructor(method, err) {
super(`IndexedDB ${method}() ${err.message}`);
this.name = err.name;
this.stack = err.stack;
}
}
/**
* IndexedDB adapter.
*
* This adapter doesn't support any options.
*/
class IDB extends base_1.default {
/* Expose the IDBError class publicly */
static get IDBError() {
return IDBError;
}
/**
* Constructor.
*
* @param {String} cid The key base for this collection (eg. `bid/cid`)
* @param {Object} options
* @param {String} options.dbName The IndexedDB name (default: `"KintoDB"`)
* @param {String} options.migrateOldData Whether old database data should be migrated (default: `false`)
*/
constructor(cid, options = {}) {
super();
this.cid = cid;
this.dbName = options.dbName || "KintoDB";
this._options = options;
this._db = null;
}
_handleError(method, err) {
throw new IDBError(method, err);
}
/**
* Ensures a connection to the IndexedDB database has been opened.
*
* @override
* @return {Promise}
*/
async open() {
if (this._db) {
return this;
}
// In previous versions, we used to have a database with name `${bid}/${cid}`.
// Check if it exists, and migrate data once new schema is in place.
// Note: the built-in migrations from IndexedDB can only be used if the
// database name does not change.
const dataToMigrate = this._options.migrateOldData
? await migrationRequired(this.cid)
: null;
this._db = await open(this.dbName, {
version: 2,
onupgradeneeded: (event) => {
const db = event.target.result;
if (event.oldVersion < 1) {
// Records store
const recordsStore = db.createObjectStore("records", {
keyPath: ["_cid", "id"],
});
// An index to obtain all the records in a collection.
recordsStore.createIndex("cid", "_cid");
// Here we create indices for every known field in records by collection.
// Local record status ("synced", "created", "updated", "deleted")
recordsStore.createIndex("_status", ["_cid", "_status"]);
// Last modified field
recordsStore.createIndex("last_modified", ["_cid", "last_modified"]);
// Timestamps store
db.createObjectStore("timestamps", {
keyPath: "cid",
});
}
if (event.oldVersion < 2) {
// Collections store
db.createObjectStore("collections", {
keyPath: "cid",
});
}
},
});
if (dataToMigrate) {
const { records, timestamp } = dataToMigrate;
await this.importBulk(records);
await this.saveLastModified(timestamp ?? 0);
console.log(`${this.cid}: data was migrated successfully.`);
// Delete the old database.
await deleteDatabase(this.cid);
console.warn(`${this.cid}: old database was deleted.`);
}
return this;
}
/**
* Closes current connection to the database.
*
* @override
* @return {Promise}
*/
close() {
if (this._db) {
this._db.close(); // indexedDB.close is synchronous
this._db = null;
}
return Promise.resolve();
}
/**
* Returns a transaction and an object store for a store name.
*
* To determine if a transaction has completed successfully, we should rather
* listen to the transaction’s complete event rather than the IDBObjectStore
* request’s success event, because the transaction may still fail after the
* success event fires.
*
* @param {String} name Store name
* @param {Function} callback to execute
* @param {Object} options Options
* @param {String} options.mode Transaction mode ("readwrite" or undefined)
* @return {Object}
*/
async prepare(name, callback, options) {
await this.open();
await execute(this._db, name, callback, options);
}
/**
* Deletes every records in the current collection.
*
* @override
* @return {Promise}
*/
async clear() {
try {
await this.prepare("records", (store) => {
const range = IDBKeyRange.only(this.cid);
const request = store.index("cid").openKeyCursor(range);
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
store.delete(cursor.primaryKey);
cursor.continue();
}
};
return request;
}, { mode: "readwrite" });
}
catch (e) {
this._handleError("clear", e);
}
}
/**
* Executes the set of synchronous CRUD operations described in the provided
* callback within an IndexedDB transaction, for current db store.
*
* The callback will be provided an object exposing the following synchronous
* CRUD operation methods: get, create, update, delete.
*
* Important note: because limitations in IndexedDB implementations, no
* asynchronous code should be performed within the provided callback; the
* promise will therefore be rejected if the callback returns a Promise.
*
* Options:
* - {Array} preload: The list of record IDs to fetch and make available to
* the transaction object get() method (default: [])
*
* @example
* const db = new IDB("example");
* const result = await db.execute(transaction => {
* transaction.create({id: 1, title: "foo"});
* transaction.update({id: 2, title: "bar"});
* transaction.delete(3);
* return "foo";
* });
*
* @override
* @param {Function} callback The operation description callback.
* @param {Object} options The options object.
* @return {Promise}
*/
async execute(callback, options = { preload: [] }) {
// Transactions in IndexedDB are autocommited when a callback does not
// perform any additional operation.
// The way Promises are implemented in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=1193394)
// prevents using within an opened transaction.
// To avoid managing asynchronocity in the specified `callback`, we preload
// a list of record in order to execute the `callback` synchronously.
// See also:
// - http://stackoverflow.com/a/28388805/330911
// - http://stackoverflow.com/a/10405196
// - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
let result;
await this.prepare("records", (store, abort) => {
const runCallback = (preloaded = {}) => {
// Expose a consistent API for every adapter instead of raw store methods.
const proxy = transactionProxy(this, store, preloaded);
// The callback is executed synchronously within the same transaction.
try {
const returned = callback(proxy);
if (returned instanceof Promise) {
// XXX: investigate how to provide documentation details in error.
throw new Error("execute() callback should not return a Promise.");
}
// Bring to scope that will be returned (once promise awaited).
result = returned;
}
catch (e) {
// The callback has thrown an error explicitly. Abort transaction cleanly.
abort && abort(e);
}
};
// No option to preload records, go straight to `callback`.
if (!options.preload) {
runCallback();
return;
}
// Preload specified records using a list request.
const filters = { id: options.preload };
createListRequest(this.cid, store, filters, (records) => {
// Store obtained records by id.
const preloaded = {};
for (const record of records) {
delete record._cid;
preloaded[record.id] = record;
}
runCallback(preloaded);
});
}, { mode: "readwrite" });
return result;
}
/**
* Retrieve a record by its primary key from the IndexedDB database.
*
* @override
* @param {String} id The record id.
* @return {Promise}
*/
async get(id) {
try {
let record;
await this.prepare("records", (store) => {
store.get([this.cid, id]).onsuccess = (e) => (record = e.target.result);
});
return record;
}
catch (e) {
this._handleError("get", e);
}
return null;
}
/**
* Lists all records from the IndexedDB database.
*
* @override
* @param {Object} params The filters and order to apply to the results.
* @return {Promise}
*/
async list(params = {
filters: {},
}) {
const { filters } = params;
try {
let results = [];
await this.prepare("records", (store) => {
createListRequest(this.cid, store, filters, (_results) => {
// we have received all requested records that match the filters,
// we now park them within current scope and hide the `_cid` attribute.
for (const result of _results) {
delete result._cid;
}
results = _results;
});
});
// The resulting list of records is sorted.
// XXX: with some efforts, this could be fully implemented using IDB API.
return params.order ? (0, utils_1.sortObjects)(params.order, results) : results;
}
catch (e) {
this._handleError("list", e);
}
return [];
}
/**
* Store the lastModified value into metadata store.
*
* @override
* @param {Number} lastModified
* @return {Promise}
*/
async saveLastModified(lastModified) {
const value = lastModified || null;
try {
await this.prepare("timestamps", (store) => {
if (value === null) {
store.delete(this.cid);
}
else {
store.put({ cid: this.cid, value });
}
}, { mode: "readwrite" });
return value;
}
catch (e) {
this._handleError("saveLastModified", e);
}
return null;
}
/**
* Retrieve saved lastModified value.
*
* @override
* @return {Promise}
*/
async getLastModified() {
try {
let entry = null;
await this.prepare("timestamps", (store) => {
store.get(this.cid).onsuccess = (e) => {
entry = e.target.result;
};
});
return entry ? entry.value : null;
}
catch (e) {
this._handleError("getLastModified", e);
}
return null;
}
/**
* Load a dump of records exported from a server.
*
* @deprecated Use {@link importBulk} instead.
* @abstract
* @param {Array} records The records to load.
* @return {Promise}
*/
async loadDump(records) {
return this.importBulk(records);
}
/**
* Load records in bulk that were exported from a server.
*
* @abstract
* @param {Array} records The records to load.
* @return {Promise}
*/
async importBulk(records) {
try {
await this.execute((transaction) => {
// Since the put operations are asynchronous, we chain
// them together. The last one will be waited for the
// `transaction.oncomplete` callback. (see #execute())
let i = 0;
putNext();
function putNext() {
if (i === records.length) {
return;
}
// On error, `transaction.onerror` is called.
transaction.update(records[i]).onsuccess = putNext;
++i;
}
});
const previousLastModified = await this.getLastModified();
const lastModified = Math.max(...records.map((record) => record.last_modified));
if (previousLastModified && lastModified > previousLastModified) {
await this.saveLastModified(lastModified);
}
return records;
}
catch (e) {
this._handleError("importBulk", e);
}
return [];
}
async saveMetadata(metadata) {
try {
await this.prepare("collections", (store) => store.put({ cid: this.cid, metadata }), { mode: "readwrite" });
return metadata;
}
catch (e) {
this._handleError("saveMetadata", e);
return null;
}
}
async getMetadata() {
try {
let entry = null;
await this.prepare("collections", (store) => {
store.get(this.cid).onsuccess = (e) => (entry = e.target.result);
});
return entry ? entry.metadata : null;
}
catch (e) {
this._handleError("getMetadata", e);
return null;
}
}
}
exports.default = IDB;
/**
* IDB transaction proxy.
*
* @param {IDB} adapter The call IDB adapter
* @param {IDBStore} store The IndexedDB database store.
* @param {Array} preloaded The list of records to make available to
* get() (default: []).
* @return {Object}
*/
function transactionProxy(adapter, store, preloaded = {}) {
const _cid = adapter.cid;
return {
create(record) {
store.add({ ...record, _cid });
},
update(record) {
return store.put({ ...record, _cid });
},
delete(id) {
store.delete([_cid, id]);
},
get(id) {
return preloaded[id];
},
};
}
/**
* Up to version 10.X of kinto.js, each collection had its own collection.
* The database name was `${bid}/${cid}` (eg. `"blocklists/certificates"`)
* and contained only one store with the same name.
*/
async function migrationRequired(dbName) {
let exists = true;
const db = await open(dbName, {
version: 1,
onupgradeneeded: (event) => {
exists = false;
},
});
// Check that the DB we're looking at is really a legacy one,
// and not some remainder of the open() operation above.
exists =
db.objectStoreNames.contains("__meta__") &&
db.objectStoreNames.contains(dbName);
if (!exists) {
db.close();
// Testing the existence creates it, so delete it :)
await deleteDatabase(dbName);
return null;
}
console.warn(`${dbName}: old IndexedDB database found.`);
try {
// Scan all records.
let records;
await execute(db, dbName, (store) => {
store.openCursor().onsuccess = cursorHandlers.all({}, (res) => (records = res));
});
console.log(`${dbName}: found ${records.length} records.`);
// Check if there's a entry for this.
let timestamp = null;
await execute(db, "__meta__", (store) => {
store.get(`${dbName}-lastModified`).onsuccess = (e) => {
timestamp = e.target.result
? e.target.result.value
: null;
};
});
// Some previous versions, also used to store the timestamps without prefix.
if (!timestamp) {
await execute(db, "__meta__", (store) => {
store.get("lastModified").onsuccess = (e) => {
timestamp = e.target.result
? e.target.result.value
: null;
};
});
}
console.log(`${dbName}: ${timestamp ? "found" : "no"} timestamp.`);
// Those will be inserted in the new database/schema.
return { records: records, timestamp };
}
catch (e) {
console.error("Error occured during migration", e);
return null;
}
finally {
db.close();
}
}