Diff
checker
Testo
Testo
Immagini
Documenti
Excel
Cartelle
Legal
Enterprise
Applicazione per desktop
Prezzi
Accedi
Scarica Diffchecker Desktop
Confronta il testo
Trova la differenza tra due file di testo
Strumenti
Cronologia
Editor live
Comprimi invariate
Senza a capo
Layout
Diviso
Unificato
Livello di dettaglio
Intelligente
Parola
Carattere
Evidenziazione sintassi
Scegli sintassi
Ignora
Trasforma testo
Vai alla prima modifica
Modifica input
Diffchecker Desktop
Il modo più sicuro per usare Diffchecker. Ottieni l'app Diffchecker Desktop: i tuoi diff non lasciano mai il tuo computer!
Ottieni Desktop
Kinto.js typescript diff
Creato
6 anni fa
Il diff non scade mai
Eliminare
Esporta
Condividere
Spiegare
14 rimozioni
Linee
Totale
Rimosso
Caratteri
Totale
Rimosso
Per continuare a utilizzare questa funzione, aggiorna a
Diff
checker
Pro
Visualizza prezzi
755 linee
Copia tutti
30 aggiunte
Linee
Totale
Aggiunto
Caratteri
Totale
Aggiunto
Per continuare a utilizzare questa funzione, aggiorna a
Diff
checker
Pro
Visualizza prezzi
758 linee
Copia tutti
/*
/*
*
*
* Licensed under the Apache License, Version 2.0 (the "License");
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* See the License for the specific language governing permissions and
* limitations under the License.
* limitations under the License.
*/
*/
"use strict";
"use strict";
/*
/*
* This file is generated from kinto.js - do not modify directly.
* This file is generated from kinto.js - do not modify directly.
*/
*/
// This is required because with Babel compiles ES2015 modules into a
// This is required because with Babel compiles ES2015 modules into a
// require() form that tries to keep its modules on "this", but
// require() form that tries to keep its modules on "this", but
// doesn't specify "this", leaving it to default to the global
// doesn't specify "this", leaving it to default to the global
// object. However, in strict mode, "this" no longer defaults to the
// object. However, in strict mode, "this" no longer defaults to the
// global object, so expose the global object explicitly. Babel's
// global object, so expose the global object explicitly. Babel's
// compiled output will use a variable called "global" if one is
// compiled output will use a variable called "global" if one is
// present.
// present.
//
//
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1394556#c3 for
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1394556#c3 for
// more details.
// more details.
const global = this;
const global = this;
var EXPORTED_SYMBOLS = ["Kinto"];
var EXPORTED_SYMBOLS = ["Kinto"];
/*
/*
Copia
Copiato
Copia
Copiato
* Version 12.7.0 -
57a2440
* Version 12.7.0 -
cf865b6
*/
*/
(function(global, factory) {
(function(global, factory) {
typeof exports === "object" && typeof module !== "undefined"
typeof exports === "object" && typeof module !== "undefined"
Copia
Copiato
Copia
Copiato
? (module.exports = factory(
))
? (module.exports = factory(
require("events")
))
: typeof define === "function" && define.amd
: typeof define === "function" && define.amd
Copia
Copiato
Copia
Copiato
? define(
factory)
? define(
["events"],
factory)
: ((global = global || self), (global.Kinto = factory(
)));
: ((global = global || self), (global.Kinto = factory(
global.events
)));
})(this, function(
) {
})(this, function(
events
) {
"use strict";
"use strict";
/**
/**
* Base db adapter.
* Base db adapter.
*
*
* @abstract
* @abstract
*/
*/
class BaseAdapter {
class BaseAdapter {
/**
/**
* Deletes every records present in the database.
* Deletes every records present in the database.
*
*
* @abstract
* @abstract
* @return {Promise}
* @return {Promise}
*/
*/
clear() {
clear() {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
/**
/**
* Executes a batch of operations within a single transaction.
* Executes a batch of operations within a single transaction.
*
*
* @abstract
* @abstract
* @param {Function} callback The operation callback.
* @param {Function} callback The operation callback.
* @param {Object} options The options object.
* @param {Object} options The options object.
* @return {Promise}
* @return {Promise}
*/
*/
execute(callback, options = { preload: [] }) {
execute(callback, options = { preload: [] }) {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
/**
/**
* Retrieve a record by its primary key from the database.
* Retrieve a record by its primary key from the database.
*
*
* @abstract
* @abstract
* @param {String} id The record id.
* @param {String} id The record id.
* @return {Promise}
* @return {Promise}
*/
*/
get(id) {
get(id) {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
/**
/**
* Lists all records from the database.
* Lists all records from the database.
*
*
* @abstract
* @abstract
* @param {Object} params The filters and order to apply to the results.
* @param {Object} params The filters and order to apply to the results.
* @return {Promise}
* @return {Promise}
*/
*/
Copia
Copiato
Copia
Copiato
list(
params = {
filters: {},
order: ""
}
) {
list(
params = {
filters: {},
order: ""
,
}
) {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
/**
/**
* Store the lastModified value.
* Store the lastModified value.
*
*
* @abstract
* @abstract
* @param {Number} lastModified
* @param {Number} lastModified
* @return {Promise}
* @return {Promise}
*/
*/
saveLastModified(lastModified) {
saveLastModified(lastModified) {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
/**
/**
* Retrieve saved lastModified value.
* Retrieve saved lastModified value.
*
*
* @abstract
* @abstract
* @return {Promise}
* @return {Promise}
*/
*/
getLastModified() {
getLastModified() {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
/**
/**
* Load records in bulk that were exported from a server.
* Load records in bulk that were exported from a server.
*
*
* @abstract
* @abstract
* @param {Array} records The records to load.
* @param {Array} records The records to load.
* @return {Promise}
* @return {Promise}
*/
*/
importBulk(records) {
importBulk(records) {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
/**
/**
* Load a dump of records exported from a server.
* Load a dump of records exported from a server.
*
*
* @deprecated Use {@link importBulk} instead.
* @deprecated Use {@link importBulk} instead.
* @abstract
* @abstract
* @param {Array} records The records to load.
* @param {Array} records The records to load.
* @return {Promise}
* @return {Promise}
*/
*/
loadDump(records) {
loadDump(records) {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
saveMetadata(metadata) {
saveMetadata(metadata) {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
getMetadata() {
getMetadata() {
throw new Error("Not Implemented.");
throw new Error("Not Implemented.");
}
}
}
}
const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
/**
/**
* Checks if a value is undefined.
* Checks if a value is undefined.
* @param {Any} value
* @param {Any} value
* @return {Boolean}
* @return {Boolean}
*/
*/
function _isUndefined(value) {
function _isUndefined(value) {
return typeof value === "undefined";
return typeof value === "undefined";
}
}
/**
/**
* Sorts records in a list according to a given ordering.
* Sorts records in a list according to a given ordering.
*
*
* @param {String} order The ordering, eg. `-last_modified`.
* @param {String} order The ordering, eg. `-last_modified`.
* @param {Array} list The collection to order.
* @param {Array} list The collection to order.
* @return {Array}
* @return {Array}
*/
*/
function sortObjects(order, list) {
function sortObjects(order, list) {
const hasDash = order[0] === "-";
const hasDash = order[0] === "-";
const field = hasDash ? order.slice(1) : order;
const field = hasDash ? order.slice(1) : order;
const direction = hasDash ? -1 : 1;
const direction = hasDash ? -1 : 1;
return list.slice().sort((a, b) => {
return list.slice().sort((a, b) => {
if (a[field] && _isUndefined(b[field])) {
if (a[field] && _isUndefined(b[field])) {
return direction;
return direction;
}
}
if (b[field] && _isUndefined(a[field])) {
if (b[field] && _isUndefined(a[field])) {
return -direction;
return -direction;
}
}
if (_isUndefined(a[field]) && _isUndefined(b[field])) {
if (_isUndefined(a[field]) && _isUndefined(b[field])) {
return 0;
return 0;
}
}
return a[field] > b[field] ? direction : -direction;
return a[field] > b[field] ? direction : -direction;
});
});
}
}
/**
/**
* Test if a single object matches all given filters.
* Test if a single object matches all given filters.
*
*
* @param {Object} filters The filters object.
* @param {Object} filters The filters object.
* @param {Object} entry The object to filter.
* @param {Object} entry The object to filter.
* @return {Boolean}
* @return {Boolean}
*/
*/
function filterObject(filters, entry) {
function filterObject(filters, entry) {
return Object.keys(filters).every(filter => {
return Object.keys(filters).every(filter => {
const value = filters[filter];
const value = filters[filter];
if (Array.isArray(value)) {
if (Array.isArray(value)) {
return value.some(candidate => candidate === entry[filter]);
return value.some(candidate => candidate === entry[filter]);
} else if (typeof value === "object") {
} else if (typeof value === "object") {
return filterObject(value, entry[filter]);
return filterObject(value, entry[filter]);
} else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
} else if (!Object.prototype.hasOwnProperty.call(entry, filter)) {
console.error(`The property ${filter} does not exist`);
console.error(`The property ${filter} does not exist`);
return false;
return false;
}
}
return entry[filter] === value;
return entry[filter] === value;
});
});
}
}
/**
/**
* Resolves a list of functions sequentially, which can be sync or async; in
* Resolves a list of functions sequentially, which can be sync or async; in
* case of async, functions must return a promise.
* case of async, functions must return a promise.
*
*
* @param {Array} fns The list of functions.
* @param {Array} fns The list of functions.
* @param {Any} init The initial value.
* @param {Any} init The initial value.
* @return {Promise}
* @return {Promise}
*/
*/
function waterfall(fns, init) {
function waterfall(fns, init) {
if (!fns.length) {
if (!fns.length) {
return Promise.resolve(init);
return Promise.resolve(init);
}
}
return fns.reduce((promise, nextFn) => {
return fns.reduce((promise, nextFn) => {
return promise.then(nextFn);
return promise.then(nextFn);
}, Promise.resolve(init));
}, Promise.resolve(init));
}
}
/**
/**
* Simple deep object comparison function. This only supports comparison of
* Simple deep object comparison function. This only supports comparison of
* serializable JavaScript objects.
* serializable JavaScript objects.
*
*
* @param {Object} a The source object.
* @param {Object} a The source object.
* @param {Object} b The compared object.
* @param {Object} b The compared object.
* @return {Boolean}
* @return {Boolean}
*/
*/
function deepEqual(a, b) {
function deepEqual(a, b) {
if (a === b) {
if (a === b) {
return true;
return true;
}
}
if (typeof a !== typeof b) {
if (typeof a !== typeof b) {
return false;
return false;
}
}
Copia
Copiato
Copia
Copiato
if (!(a && typeof a ==
"object") || !(b && typeof b ==
"object")) {
if (!(a && typeof a ==
=
"object") || !(b && typeof b ==
=
"object")) {
return false;
return false;
}
}
if (Object.keys(a).length !== Object.keys(b).length) {
if (Object.keys(a).length !== Object.keys(b).length) {
return false;
return false;
}
}
for (const k in a) {
for (const k in a) {
if (!deepEqual(a[k], b[k])) {
if (!deepEqual(a[k], b[k])) {
return false;
return false;
}
}
}
}
return true;
return true;
}
}
/**
/**
* Return an object without the specified keys.
* Return an object without the specified keys.
*
*
* @param {Object} obj The original object.
* @param {Object} obj The original object.
* @param {Array} keys The list of keys to exclude.
* @param {Array} keys The list of keys to exclude.
* @return {Object} A copy without the specified keys.
* @return {Object} A copy without the specified keys.
*/
*/
function omitKeys(obj, keys = []) {
function omitKeys(obj, keys = []) {
const result = Object.assign({}, obj);
const result = Object.assign({}, obj);
for (const key of keys) {
for (const key of keys) {
delete result[key];
delete result[key];
}
}
return result;
return result;
}
}
function arrayEqual(a, b) {
function arrayEqual(a, b) {
if (a.length !== b.length) {
if (a.length !== b.length) {
return false;
return false;
}
}
for (let i = a.length; i--; ) {
for (let i = a.length; i--; ) {
if (a[i] !== b[i]) {
if (a[i] !== b[i]) {
return false;
return false;
}
}
}
}
return true;
return true;
}
}
function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
function makeNestedObjectFromArr(arr, val, nestedFiltersObj) {
const last = arr.length - 1;
const last = arr.length - 1;
return arr.reduce((acc, cv, i) => {
return arr.reduce((acc, cv, i) => {
if (i === last) {
if (i === last) {
return (acc[cv] = val);
return (acc[cv] = val);
} else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
} else if (Object.prototype.hasOwnProperty.call(acc, cv)) {
return acc[cv];
return acc[cv];
} else {
} else {
return (acc[cv] = {});
return (acc[cv] = {});
}
}
}, nestedFiltersObj);
}, nestedFiltersObj);
}
}
function transformSubObjectFilters(filtersObj) {
function transformSubObjectFilters(filtersObj) {
const transformedFilters = {};
const transformedFilters = {};
for (const key in filtersObj) {
for (const key in filtersObj) {
const keysArr = key.split(".");
const keysArr = key.split(".");
const val = filtersObj[key];
const val = filtersObj[key];
makeNestedObjectFromArr(keysArr, val, transformedFilters);
makeNestedObjectFromArr(keysArr, val, transformedFilters);
}
}
return transformedFilters;
return transformedFilters;
}
}
const INDEXED_FIELDS = ["id", "_status", "last_modified"];
const INDEXED_FIELDS = ["id", "_status", "last_modified"];
/**
/**
* Small helper that wraps the opening of an IndexedDB into a Promise.
* Small helper that wraps the opening of an IndexedDB into a Promise.
*
*
* @param dbname {String} The database name.
* @param dbname {String} The database name.
* @param version {Integer} Schema version
* @param version {Integer} Schema version
* @param onupgradeneeded {Function} The callback to execute if schema is
* @param onupgradeneeded {Function} The callback to execute if schema is
* missing or different.
* missing or different.
* @return {Promise<IDBDatabase>}
* @return {Promise<IDBDatabase>}
*/
*/
async function open(dbname, { version, onupgradeneeded }) {
async function open(dbname, { version, onupgradeneeded }) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbname, version);
const request = indexedDB.open(dbname, version);
request.onupgradeneeded = event => {
request.onupgradeneeded = event => {
Copia
Copiato
Copia
Copiato
const db =
event.target
.result;
const db =
request
.result;
db.onerror = event => reject(
event.target
.error);
db.onerror = event => reject(
request
.error);
// When an upgrade is needed, a transaction is started.
// When an upgrade is needed, a transaction is started.
Copia
Copiato
Copia
Copiato
const transaction =
event.target
.transaction;
const transaction =
request
.transaction;
transaction.onabort = event => {
transaction.onabort = event => {
const error =
const error =
Copia
Copiato
Copia
Copiato
event.target
.error ||
request
.error ||
transaction.error ||
transaction.error ||
new DOMException("The operation has been aborted", "AbortError");
new DOMException("The operation has been aborted", "AbortError");
reject(error);
reject(error);
};
};
// Callback for store creation etc.
// Callback for store creation etc.
return onupgradeneeded(event);
return onupgradeneeded(event);
};
};
request.onerror = event => {
request.onerror = event => {
reject(event.target.error);
reject(event.target.error);
};
};
request.onsuccess = event => {
request.onsuccess = event => {
Copia
Copiato
Copia
Copiato
const db =
event.target
.result;
const db =
request
.result;
resolve(db);
resolve(db);
};
};
});
});
}
}
/**
/**
* Helper to run the specified callback in a single transaction on the
* Helper to run the specified callback in a single transaction on the
* specified store.
* specified store.
* The helper focuses on transaction wrapping into a promise.
* The helper focuses on transaction wrapping into a promise.
*
*
* @param db {IDBDatabase} The database instance.
* @param db {IDBDatabase} The database instance.
* @param name {String} The store name.
* @param name {String} The store name.
* @param callback {Function} The piece of code to execute in the transaction.
* @param callback {Function} The piece of code to execute in the transaction.
* @param options {Object} Options.
* @param options {Object} Options.
* @param options.mode {String} Transaction mode (default: read).
* @param options.mode {String} Transaction mode (default: read).
* @return {Promise} any value returned by the callback.
* @return {Promise} any value returned by the callback.
*/
*/
async function execute(db, name, callback, options = {}) {
async function execute(db, name, callback, options = {}) {
const { mode } = options;
const { mode } = options;
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
// On Safari, calling IDBDatabase.transaction with mode == undefined raises
// On Safari, calling IDBDatabase.transaction with mode == undefined raises
// a TypeError.
// a TypeError.
const transaction = mode
const transaction = mode
? db.transaction([name], mode)
? db.transaction([name], mode)
: db.transaction([name]);
: db.transaction([name]);
const store = transaction.objectStore(name);
const store = transaction.objectStore(name);
// Let the callback abort this transaction.
// Let the callback abort this transaction.
const abort = e => {
const abort = e => {
transaction.abort();
transaction.abort();
reject(e);
reject(e);
};
};
// Execute the specified callback **synchronously**.
// Execute the specified callback **synchronously**.
let result;
let result;
try {
try {
result = callback(store, abort);
result = callback(store, abort);
} catch (e) {
} catch (e) {
abort(e);
abort(e);
}
}
transaction.onerror = event => reject(event.target.error);
transaction.onerror = event => reject(event.target.error);
transaction.oncomplete = event => resolve(result);
transaction.oncomplete = event => resolve(result);
transaction.onabort = event => {
transaction.onabort = event => {
const error =
const error =
event.target.error ||
event.target.error ||
transaction.error ||
transaction.error ||
new DOMException("The operation has been aborted", "AbortError");
new DOMException("The operation has been aborted", "AbortError");
reject(error);
reject(error);
};
};
});
});
}
}
/**
/**
* Helper to wrap the deletion of an IndexedDB database into a promise.
* Helper to wrap the deletion of an IndexedDB database into a promise.
*
*
* @param dbName {String} the database to delete
* @param dbName {String} the database to delete
* @return {Promise}
* @return {Promise}
*/
*/
async function deleteDatabase(dbName) {
async function deleteDatabase(dbName) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = event => resolve(event.target);
request.onsuccess = event => resolve(event.target);
request.onerror = event => reject(event.target.error);
request.onerror = event => reject(event.target.error);
});
});
}
}
/**
/**
* IDB cursor handlers.
* IDB cursor handlers.
* @type {Object}
* @type {Object}
*/
*/
const cursorHandlers = {
const cursorHandlers = {
all(filters, done) {
all(filters, done) {
const results = [];
const results = [];
return event => {
return event => {
const cursor = event.target.result;
const cursor = event.target.result;
if (cursor) {
if (cursor) {
const { value } = cursor;
const { value } = cursor;
if (filterObject(filters, value)) {
if (filterObject(filters, value)) {
results.push(value);
results.push(value);
}
}
cursor.continue();
cursor.continue();
} else {
} else {
done(results);
done(results);
}
}
};
};
},
},
in(values, filters, done) {
in(values, filters, done) {
const results = [];
const results = [];
let i = 0;
let i = 0;
return function(event) {
return function(event) {
const cursor = event.target.result;
const cursor = event.target.result;
if (!cursor) {
if (!cursor) {
done(results);
done(results);
return;
return;
}
}
const { key, value } = cursor;
const { key, value } = cursor;
// `key` can be an array of two values (see `keyPath` in indices definitions).
// `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
// `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]], ...)`)
// is an array (eg. `cursorHandlers.in([["bid/cid", 42], ["bid/cid", 43]], ...)`)
while (key > values[i]) {
while (key > values[i]) {
// The cursor has passed beyond this key. Check next.
// The cursor has passed beyond this key. Check next.
++i;
++i;
if (i === values.length) {
if (i === values.length) {
done(results); // There is no next. Stop searching.
done(results); // There is no next. Stop searching.
return;
return;
}
}
}
}
const isEqual = Array.isArray(key)
const isEqual = Array.isArray(key)
? arrayEqual(key, values[i])
? arrayEqual(key, values[i])
: key === values[i];
: key === values[i];
if (isEqual) {
if (isEqual) {
if (filterObject(filters, value)) {
if (filterObject(filters, value)) {
results.push(value);
results.push(value);
}
}
cursor.continue();
cursor.continue();
} else {
} else {
cursor.continue(values[i]);
cursor.continue(values[i]);
}
}
};
};
},
},
};
};
/**
/**
* Creates an IDB request and attach it the appropriate cursor event handler to
* Creates an IDB request and attach it the appropriate cursor event handler to
* perform a list query.
* perform a list query.
*
*
* Multiple matching values are handled by passing an array.
* Multiple matching values are handled by passing an array.
*
*
* @param {String} cid The collection id (ie. `{bid}/{cid}`)
* @param {String} cid The collection id (ie. `{bid}/{cid}`)
* @param {IDBStore} store The IDB store.
* @param {IDBStore} store The IDB store.
* @param {Object} filters Filter the records by field.
* @param {Object} filters Filter the records by field.
* @param {Function} done The operation completion handler.
* @param {Function} done The operation completion handler.
* @return {IDBRequest}
* @return {IDBRequest}
*/
*/
function createListRequest(cid, store, filters, done) {
function createListRequest(cid, store, filters, done) {
const filterFields = Object.keys(filters);
const filterFields = Object.keys(filters);
// If no filters, get all results in one bulk.
// If no filters, get all results in one bulk.
Copia
Copiato
Copia
Copiato
if (filterFields.length ==
0) {
if (filterFields.length ==
=
0) {
const request = store.index("cid").getAll(IDBKeyRange.only(cid));
const request = store.index("cid").getAll(IDBKeyRange.only(cid));
request.onsuccess = event => done(event.target.result);
request.onsuccess = event => done(event.target.result);
return request;
return request;
}
}
// Introspect filters and check if they leverage an indexed field.
// Introspect filters and check if they leverage an indexed field.
const indexField = filterFields.find(field => {
const indexField = filterFields.find(field => {
return INDEXED_FIELDS.includes(field);
return INDEXED_FIELDS.includes(field);
});
});
if (!indexField) {
if (!indexField) {
// Iterate on all records for this collection (ie. cid)
// Iterate on all records for this collection (ie. cid)
const isSubQuery = Object.keys(filters).some(key => key.includes(".")); // (ie. filters: {"article.title": "hello"})
const isSubQuery = Object.keys(filters).some(key => key.includes(".")); // (ie. filters: {"article.title": "hello"})
if (isSubQuery) {
if (isSubQuery) {
const newFilter = transformSubObjectFilters(filters);
const newFilter = transformSubObjectFilters(filters);
const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
request.onsuccess = cursorHandlers.all(newFilter, done);
request.onsuccess = cursorHandlers.all(newFilter, done);
return request;
return request;
}
}
const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
const request = store.index("cid").openCursor(IDBKeyRange.only(cid));
request.onsuccess = cursorHandlers.all(filters, done);
request.onsuccess = cursorHandlers.all(filters, done);
return request;
return request;
}
}
// If `indexField` was used already, don't filter again.
// If `indexField` was used already, don't filter again.
const remainingFilters = omitKeys(filters, [indexField]);
const remainingFilters = omitKeys(filters, [indexField]);
// value specified in the filter (eg. `filters: { _status: ["created", "updated"] }`)
// value specified in the filter (eg. `filters: { _status: ["created", "updated"] }`)
const value = filters[indexField];
const value = filters[indexField];
// For the "id" field, use the primary key.
// For the "id" field, use the primary key.
Copia
Copiato
Copia
Copiato
const indexStore = indexField ==
"id" ? store : store.index(indexField);
const indexStore = indexField ==
=
"id" ? store : store.index(indexField);
// WHERE IN equivalent clause
// WHERE IN equivalent clause
if (Array.isArray(value)) {
if (Array.isArray(value)) {
if (value.length === 0) {
if (value.length === 0) {
return done([]);
return done([]);
}
}
const values = value.map(i => [cid, i]).sort();
const values = value.map(i => [cid, i]).sort();
const range = IDBKeyRange.bound(values[0], values[values.length - 1]);
const range = IDBKeyRange.bound(values[0], values[values.length - 1]);
const request = indexStore.openCursor(range);
const request = indexStore.openCursor(range);
request.onsuccess = cursorHandlers.in(values, remainingFilters, done);
request.onsuccess = cursorHandlers.in(values, remainingFilters, done);
return request;
return request;
}
}
// If no filters on custom attribute, get all results in one bulk.
// If no filters on custom attribute, get all results in one bulk.
Copia
Copiato
Copia
Copiato
if (remainingFilters.length ==
0) {
if (remainingFilters.length ==
=
0) {
const request = indexStore.getAll(IDBKeyRange.only([cid, value]));
const request = indexStore.getAll(IDBKeyRange.only([cid, value]));
request.onsuccess = event => done(event.target.result);
request.onsuccess = event => done(event.target.result);
return request;
return request;
}
}
// WHERE field = value clause
// WHERE field = value clause
const request = indexStore.openCursor(IDBKeyRange.only([cid, value]));
const request = indexStore.openCursor(IDBKeyRange.only([cid, value]));
request.onsuccess = cursorHandlers.all(remainingFilters, done);
request.onsuccess = cursorHandlers.all(remainingFilters, done);
return request;
return request;
}
}
/**
/**
* IndexedDB adapter.
* IndexedDB adapter.
*
*
* This adapter doesn't support any options.
* This adapter doesn't support any options.
*/
*/
class IDB extends BaseAdapter {
class IDB extends BaseAdapter {
/**
/**
* Constructor.
* Constructor.
*
*
* @param {String} cid The key base for this collection (eg. `bid/cid`)
* @param {String} cid The key base for this collection (eg. `bid/cid`)
* @param {Object} options
* @param {Object} options
* @param {String} options.dbName The IndexedDB name (default: `"KintoDB"`)
* @param {String} options.dbName The IndexedDB name (default: `"KintoDB"`)
* @param {String} options.migrateOldData Whether old database data should be migrated (default: `false`)
* @param {String} options.migrateOldData Whether old database data should be migrated (default: `false`)
*/
*/
constructor(cid, options = {}) {
constructor(cid, options = {}) {
super();
super();
this.cid = cid;
this.cid = cid;
this.dbName = options.dbName || "KintoDB";
this.dbName = options.dbName || "KintoDB";
this._options = options;
this._options = options;
this._db = null;
this._db = null;
}
}
_handleError(method, err) {
_handleError(method, err) {
const error = new Error(`IndexedDB ${method}() ${err.message}`);
const error = new Error(`IndexedDB ${method}() ${err.message}`);
error.stack = err.stack;
error.stack = err.stack;
throw error;
throw error;
}
}
/**
/**
* Ensures a connection to the IndexedDB database has been opened.
* Ensures a connection to the IndexedDB database has been opened.
*
*
* @override
* @override
* @return {Promise}
* @return {Promise}
*/
*/
async open() {
async open() {
if (this._db) {
if (this._db) {
return this;
return this;
}
}
// In previous versions, we used to have a database with name `${bid}/${cid}`.
// 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.
// 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
// Note: the built-in migrations from IndexedDB can only be used if the
// database name does not change.
// database name does not change.
const dataToMigrate = this._options.migrateOldData
const dataToMigrate = this._options.migrateOldData
? await migrationRequired(this.cid)
? await migrationRequired(this.cid)
: null;
: null;
this._db = await open(this.dbName, {
this._db = await open(this.dbName, {
version: 2,
version: 2,
onupgradeneeded: event => {
onupgradeneeded: event => {
const db = event.target.result;
const db = event.target.result;
if (event.oldVersion < 1) {
if (event.oldVersion < 1) {
// Records store
// Records store
const recordsStore = db.createObjectStore("records", {
const recordsStore = db.createObjectStore("records", {
keyPath: ["_cid", "id"],
keyPath: ["_cid", "id"],
});
});
// An index to obtain all the records in a collection.
// An index to obtain all the records in a collection.
recordsStore.createIndex("cid", "_cid");
recordsStore.createIndex("cid", "_cid");
// Here we create indices for every known field in records by collection.
// Here we create indices for every known field in records by collection.
// Local record status ("synced", "created", "updated", "deleted")
// Local record status ("synced", "created", "updated", "deleted")
recordsStore.createIndex("_status", ["_cid", "_status"]);
recordsStore.createIndex("_status", ["_cid", "_status"]);
// Last modified field
// Last modified field
recordsStore.createIndex("last_modified", [
recordsStore.createIndex("last_modified", [
"_cid",
"_cid",
"last_modified",
"last_modified",
]);
]);
// Timestamps store
// Timestamps store
db.createObjectStore("timestamps", {
db.createObjectStore("timestamps", {
keyPath: "cid",
keyPath: "cid",
});
});
}
}
if (event.oldVersion < 2) {
if (event.oldVersion < 2) {
// Collections store
// Collections store
db.createObjectStore("collections", {
db.createObjectStore("collections", {
keyPath: "cid",
keyPath: "cid",
});
});
}
}
},
},
});
});
if (dataToMigrate) {
if (dataToMigrate) {
const { records, timestamp } = dataToMigrate;
const { records, timestamp } = dataToMigrate;
await this.importBulk(records);
await this.importBulk(records);
Copia
Copiato
Copia
Copiato
await this.saveLastModified(
timestamp
);
await this.saveLastModified(
timestamp !== null && timestamp !== void 0 ?
timestamp
: 0
);
console.log(`${this.cid}: data was migrated successfully.`);
console.log(`${this.cid}: data was migrated successfully.`);
// Delete the old database.
// Delete the old database.
await deleteDatabase(this.cid);
await deleteDatabase(this.cid);
console.warn(`${this.cid}: old database was deleted.`);
console.warn(`${this.cid}: old database was deleted.`);
}
}
return this;
return this;
}
}
/**
/**
* Closes current connection to the database.
* Closes current connection to the database.
*
*
* @override
* @override
* @return {Promise}
* @return {Promise}
*/
*/
close() {
close() {
if (this._db) {
if (this._db) {
this._db.close(); // indexedDB.close is synchronous
this._db.close(); // indexedDB.close is synchronous
this._db = null;
this._db = null;
}
}
return Promise.resolve();
return Promise.resolve();
}
}
/**
/**
* Returns a transaction and an object store for a store name.
* Returns a transaction and an object store for a store name.
*
*
* To determine if a transaction has completed successfully, we should rather
* To determine if a transaction has completed successfully, we should rather
* listen to the transaction’s complete event rather than the IDBObjectStore
* listen to the transaction’s complete event rather than the IDBObjectStore
* request’s success event, because the transaction may still fail after the
* request’s success event, because the transaction may still fail after the
* success event fires.
* success event fires.
*
*
* @param {String} name Store name
* @param {String} name Store name
* @param {Function} callback to execute
* @param {Function} callback to execute
* @param {Object} options Options
* @param {Object} options Options
* @param {String} options.mode Transaction mode ("readwrite" or undefined)
* @param {String} options.mode Transaction mode ("readwrite" or undefined)
* @return {Object}
* @return {Object}
*/
*/
async prepare(name, callback, options) {
async prepare(name, callback, options) {
await this.open();
await this.open();
await execute(this._db, name, callback, options);
await execute(this._db, name, callback, options);
}
}
/**
/**
* Deletes every records in the current collection.
* Deletes every records in the current collection.
*
*
* @override
* @override
* @return {Promise}
* @return {Promise}
*/
*/
async clear() {
async clear() {
try {
try {
await this.prepare(
await this.prepare(
"records",
"records",
store => {
store => {
const range = IDBKeyRange.only(this.cid);
const range = IDBKeyRange.only(this.cid);
const request = store.index("cid").openKeyCursor(range);
const request = store.index("cid").openKeyCursor(range);
request.onsuccess = event => {
request.onsuccess = event => {
const cursor = event.target.result;
const cursor = event.target.result;
if (cursor) {
if (cursor) {
store.delete(cursor.primaryKey);
store.delete(cursor.primaryKey);
cursor.continue();
cursor.continue();
}
}
};
};
return request;
return request;
},
},
{ mode: "readwrite" }
{ mode: "readwrite" }
);
);
} catch (e) {
} catch (e) {
this._handleError("clear", e);
this._handleError("clear", e);
}
}
}
}
/**
/**
* Executes the set of synchronous CRUD operations described in the provided
* Executes the set of synchronous CRUD operations described in the provided
* callback within an IndexedDB transaction, for current db store.
* callback within an IndexedDB transaction, for current db store.
*
*
* The callback will be provided an object exposing the following synchronous
* The callback will be provided an object exposing the following synchronous
* CRUD operation methods: get, create, update, delete.
* CRUD operation methods: get, create, update, delete.
*
*
* Important note: because limitations in IndexedDB implementations, no
* Important note: because limitations in IndexedDB implementations, no
* asynchronous code should be performed within the provided callback; the
* asynchronous code should be performed within the provided callback; the
* promise will therefore be rejected if the callback returns a Promise.
* promise will therefore be rejected if the callback returns a Promise.
*
*
* Options:
* Options:
* - {Array} preload: The list of record IDs to fetch and make available to
* - {Array} preload: The list of record IDs to fetch and make available to
* the transaction object get() method (default: [])
* the transaction object get() method (default: [])
*
*
* @example
* @example
* const db = new IDB("example");
* const db = new IDB("example");
* const result = await db.execute(transaction => {
* const result = await db.execute(transaction => {
* transaction.create({id: 1, title: "foo"});
* transaction.create({id: 1, title: "foo"});
* transaction.update({id: 2, title: "bar"});
* transaction.update({id: 2, title: "bar"});
* transaction.delete(3);
* transaction.delete(3);
* return "foo";
* return "foo";
* });
* });
*
*
* @override
* @override
* @param {Function} callback The operation description callback.
* @param {Function} callback The operation description callback.
* @param {Object} options The options object.
* @param {Object} options The options object.
* @return {Promise}
* @return {Promise}
*/
*/
async execute(callback, options = { preload: [] }) {
async execute(callback, options = { preload: [] }) {
// Transactions in IndexedDB are autocommited when a callback does not
// Transactions in IndexedDB are autocommited when a callback does not
// perform any additional operation.
// perform any additional operation.
// The way Promises are implemented in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=1193394)
// The way Promises are implemented in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=1193394)
// prevents using within an opened transaction.
// prevents using within an opened transaction.
// To avoid managing asynchronocity in the specified `callback`, we preload
// To avoid managing asynchronocity in the specified `callback`, we preload
// a list of record in order to execute the `callback` synchronously.
// a list of record in order to execute the `callback` synchronously.
// See also:
// See also:
// - http://stackoverflow.com/a/28388805/330911
// - http://stackoverflow.com/a/28388805/330911
// - http://stackoverflow.com/a/10405196
// - http://stackoverflow.com/a/10405196
// - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
// - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
let result;
let result;
await this.prepare(
await this.prepare(
"records",
"records",
(store, abort) => {
(store, abort) => {
Copia
Copiato
Copia
Copiato
const runCallback = (preloaded =
[]
) => {
const runCallback = (preloaded =
{}
) => {
// Expose a consistent API for every adapter instead of raw store methods.
// Expose a consistent API for every adapter instead of raw store methods.
const proxy = transactionProxy(this, store, preloaded);
const proxy = transactionProxy(this, store, preloaded);
// The callback is executed synchronously within the same transaction.
// The callback is executed synchronously within the same transaction.
try {
try {
const returned = callback(proxy);
const returned = callback(proxy);
if (returned instanceof Promise) {
if (returned instanceof Promise) {
// XXX: investigate how to provide documentation details in error.
// XXX: investigate how to provide documentation details in error.
throw new Error(
throw new Error(
"execute() callback should not return a Promise."
"execute() callback should not return a Promise."
);
);
}
}
// Bring to scope that will be returned (once promise awaited).
// Bring to scope that will be returned (once promise awaited).
result = returned;
result = returned;
} catch (e) {
} catch (e) {
// The callback has thrown an error explicitly. Abort transaction cleanly.
// The callback has thrown an error explicitly. Abort transaction cleanly.
Copia
Copiato
Copia
Copiato
abort
(e);
abort
&& abort
(e);
}
}
};
};
// No option to preload records, go straight to `callback`.
// No option to preload records, go straight to `callback`.
Copia
Copiato
Copia
Copiato
if (!options.preload
.length
) {
if (!options.preload
) {
return runCallback();
return runCallback();
}
}
// Preload specified records using a list request.
// Preload specified records using a list request.
const filters = { id: options.preload };
const filters = { id: options.preload };
createListRequest(this.cid, store, filters, records => {
createListRequest(this.cid, store, filters, records => {
// Store obtained records by id.
// Store obtained records by id.
const preloaded = {};
const preloaded = {};
for (const record of records) {
for (const record of records) {
delete record["_cid"];
delete record["_cid"];
preloaded[record.id] = record;
preloaded[record.id] = record;
}
}
runCallback(preloaded);
runCallback(preloaded);
});
});
},
},
{ mode: "readwrite" }
{ mode: "readwrite" }
);
);
return result;
return result;
}
}
/**
/**
* Retrieve a record by its primary key from the IndexedDB database.
* Retrieve a record by its primary key from the IndexedDB database.
*
*
* @override
* @override
* @param {String} id The record id.
* @param {String} id The record id.
* @return {Promise}
* @return {Promise}
*/
*/
async get(id) {
async get(id) {
try {
try {
let record;
let record;
await this.prepare("records", store => {
await this.prepare("records", store => {
store.get([this.cid, id]).onsuccess = e => (record = e.target.result);
store.get([this.cid, id]).onsuccess = e => (record = e.target.result);
});
});
return record;
return record;
} catch (e) {
} catch (e) {
this._handleError("get", e);
this._handleError("get", e);
}
}
}
}
/**
/**
* Lists all records from the IndexedDB database.
* Lists all records from the IndexedDB database.
*
*
* @override
* @override
Copia
Copiato
Copia
Copiato
* @param {Object} params
The filters and order to apply to the results.
* @param {Object} params
* @return {Promise}
*/
async list(params = { filters: {} }) {
cons
Diff salvati
Testo originale
Apri file
/* * * 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. */ "use strict"; /* * This file is generated from kinto.js - do not modify directly. */ // This is required because with Babel compiles ES2015 modules into a // require() form that tries to keep its modules on "this", but // doesn't specify "this", leaving it to default to the global // object. However, in strict mode, "this" no longer defaults to the // global object, so expose the global object explicitly. Babel's // compiled output will use a variable called "global" if one is // present. // // See https://bugzilla.mozilla.org/show_bug.cgi?id=1394556#c3 for // more details. const global = this; var EXPORTED_SYMBOLS = ["Kinto"]; /* * Version 12.7.0 - 57a2440 */ (function(global, factory) { typeof exports === "object" && typeof module !== "undefined" ? (module.exports = factory()) : typeof define === "function" && define.amd ? define(factory) : ((global = global || self), (global.Kinto = factory())); })(this, function() { "use strict"; /** * Base db adapter. * * @abstract */ class BaseAdapter { /** * Deletes every records present in the database. * * @abstract * @return {Promise} */ clear() { throw new Error("Not Implemented."); } /** * Executes a batch of operations within a single transaction. * * @abstract * @param {Function} callback The operation callback. * @param {Object} options The options object. * @return {Promise} */ execute(callback, options = { preload: [] }) { throw new Error("Not Implemented."); } /** * Retrieve a record by its primary key from the database. * * @abstract * @param {String} id The record id. * @return {Promise} */ get(id) { throw new Error("Not Implemented."); } /** * Lists all records from the database. * * @abstract * @param {Object} params The filters and order to apply to the results. * @return {Promise} */ list(params = { filters: {}, order: "" }) { throw new Error("Not Implemented."); } /** * Store the lastModified value. * * @abstract * @param {Number} lastModified * @return {Promise} */ saveLastModified(lastModified) { throw new Error("Not Implemented."); } /** * Retrieve saved lastModified value. * * @abstract * @return {Promise} */ getLastModified() { throw new Error("Not Implemented."); } /** * Load records in bulk that were exported from a server. * * @abstract * @param {Array} records The records to load. * @return {Promise} */ importBulk(records) { throw new Error("Not Implemented."); } /** * Load a dump of records exported from a server. * * @deprecated Use {@link importBulk} instead. * @abstract * @param {Array} records The records to load. * @return {Promise} */ loadDump(records) { throw new Error("Not Implemented."); } saveMetadata(metadata) { throw new Error("Not Implemented."); } getMetadata() { throw new Error("Not Implemented."); } } const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; /** * Checks if a value is undefined. * @param {Any} value * @return {Boolean} */ function _isUndefined(value) { return typeof value === "undefined"; } /** * Sorts records in a list according to a given ordering. * * @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. * * @param {Object} filters The filters object. * @param {Object} entry The object to filter. * @return {Boolean} */ 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]); } else if (typeof value === "object") { return filterObject(value, entry[filter]); } else if (!Object.prototype.hasOwnProperty.call(entry, filter)) { console.error(`The property ${filter} does not exist`); return false; } return entry[filter] === value; }); } /** * Resolves a list of functions sequentially, which can be sync or async; in * case of async, functions must return a promise. * * @param {Array} fns The list of functions. * @param {Any} init The initial value. * @return {Promise} */ function waterfall(fns, init) { if (!fns.length) { return Promise.resolve(init); } return fns.reduce((promise, nextFn) => { return promise.then(nextFn); }, Promise.resolve(init)); } /** * Simple deep object comparison function. This only supports comparison of * serializable JavaScript objects. * * @param {Object} a The source object. * @param {Object} b The compared object. * @return {Boolean} */ function deepEqual(a, b) { if (a === b) { return true; } if (typeof a !== typeof b) { return false; } if (!(a && typeof a == "object") || !(b && typeof b == "object")) { return false; } if (Object.keys(a).length !== Object.keys(b).length) { return false; } for (const k in a) { if (!deepEqual(a[k], b[k])) { return false; } } return true; } /** * Return an object without the specified keys. * * @param {Object} obj The original object. * @param {Array} keys The list of keys to exclude. * @return {Object} A copy without the specified keys. */ function omitKeys(obj, keys = []) { const result = Object.assign({}, obj); for (const key of keys) { delete result[key]; } return result; } function arrayEqual(a, b) { if (a.length !== b.length) { return false; } for (let i = a.length; i--; ) { if (a[i] !== b[i]) { return false; } } return true; } function makeNestedObjectFromArr(arr, val, nestedFiltersObj) { const last = arr.length - 1; return arr.reduce((acc, cv, i) => { if (i === last) { return (acc[cv] = val); } else if (Object.prototype.hasOwnProperty.call(acc, cv)) { return acc[cv]; } else { return (acc[cv] = {}); } }, nestedFiltersObj); } function transformSubObjectFilters(filtersObj) { const transformedFilters = {}; for (const key in filtersObj) { const keysArr = key.split("."); const val = filtersObj[key]; makeNestedObjectFromArr(keysArr, val, transformedFilters); } return transformedFilters; } 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 = event.target.result; db.onerror = event => reject(event.target.error); // When an upgrade is needed, a transaction is started. const transaction = event.target.transaction; transaction.onabort = event => { const error = event.target.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 = event.target.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(); 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 (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) ? arrayEqual(key, values[i]) : key === values[i]; if (isEqual) { if (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 = 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 = 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 (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; } /** * IndexedDB adapter. * * This adapter doesn't support any options. */ class IDB extends BaseAdapter { /** * 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) { const error = new Error(`IndexedDB ${method}() ${err.message}`); error.stack = err.stack; throw error; } /** * 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); 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(e); } }; // No option to preload records, go straight to `callback`. if (!options.preload.length) { return runCallback(); } // 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); } } /** * 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 ? sortObjects(params.order, results) : results; } catch (e) { this._handleError("list", e); } } /** * Store the lastModified value into metadata store. * * @override * @param {Number} lastModified * @return {Promise} */ async saveLastModified(lastModified) { const value = parseInt(lastModified, 10) || 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); } } /** * 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); } } /** * 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 (lastModified > previousLastModified) { await this.saveLastModified(lastModified); } return records; } catch (e) { this._handleError("importBulk", e); } } 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); } } 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); } } } /** * 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(Object.assign(Object.assign({}, record), { _cid })); }, update(record) { return store.put(Object.assign(Object.assign({}, 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, timestamp }; } catch (e) { console.error("Error occured during migration", e); return null; } finally { db.close(); } } var uuid4 = {}; const RECORD_FIELDS_TO_CLEAN = ["_status"]; const AVAILABLE_HOOKS = ["incoming-changes"]; const IMPORT_CHUNK_SIZE = 200; /** * Compare two records omitting local fields and synchronization * attributes (like _status and last_modified) * @param {Object} a A record to compare. * @param {Object} b A record to compare. * @param {Array} localFields Additional fields to ignore during the comparison * @return {boolean} */ function recordsEqual(a, b, localFields = []) { const fieldsToClean = RECORD_FIELDS_TO_CLEAN.concat([ "last_modified", ]).concat(localFields); const cleanLocal = r => omitKeys(r, fieldsToClean); return deepEqual(cleanLocal(a), cleanLocal(b)); } /** * Synchronization result object. */ class SyncResultObject { /** * Public constructor. */ constructor() { /** * Current synchronization result status; becomes `false` when conflicts or * errors are registered. * @type {Boolean} */ this.lastModified = null; this._lists = {}; [ "errors", "created", "updated", "deleted", "published", "conflicts", "skipped", "resolved", "void", ].forEach(l => (this._lists[l] = [])); this._cached = {}; } /** * Adds entries for a given result type. * * @param {String} type The result type. * @param {Array} entries The result entries. * @return {SyncResultObject} */ add(type, entries) { if (!Array.isArray(this._lists[type])) { console.warn(`Unknown type "${type}"`); return; } if (!Array.isArray(entries)) { entries = [entries]; } this._lists[type] = this._lists[type].concat(entries); delete this._cached[type]; return this; } get ok() { return this.errors.length + this.conflicts.length === 0; } get errors() { return this._lists["errors"]; } get conflicts() { return this._lists["conflicts"]; } get skipped() { return this._deduplicate("skipped"); } get resolved() { return this._deduplicate("resolved"); } get created() { return this._deduplicate("created"); } get updated() { return this._deduplicate("updated"); } get deleted() { return this._deduplicate("deleted"); } get published() { return this._deduplicate("published"); } _deduplicate(list) { if (!(list in this._cached)) { // Deduplicate entries by id. If the values don't have `id` attribute, just // keep all. const recordsWithoutId = new Set(); const recordsById = new Map(); this._lists[list].forEach(record => { if (!record.id) { recordsWithoutId.add(record); } else { recordsById.set(record.id, record); } }); this._cached[list] = Array.from(recordsById.values()).concat( Array.from(recordsWithoutId) ); } return this._cached[list]; } /** * Reinitializes result entries for a given result type. * * @param {String} type The result type. * @return {SyncResultObject} */ reset(type) { this._lists[type] = []; delete this._cached[type]; return this; } toObject() { // Only used in tests. return { ok: this.ok, lastModified: this.lastModified, errors: this.errors, created: this.created, updated: this.updated, deleted: this.deleted, skipped: this.skipped, published: this.published, conflicts: this.conflicts, resolved: this.resolved, }; } } class ServerWasFlushedError extends Error { constructor(clientTimestamp, serverTimestamp, message) { super(message); if (Error.captureStackTrace) { Error.captureStackTrace(this, ServerWasFlushedError); } this.clientTimestamp = clientTimestamp; this.serverTimestamp = serverTimestamp; } } function createUUIDSchema() { return { generate() { return uuid4(); }, validate(id) { return typeof id == "string" && RE_RECORD_ID.test(id); }, }; } function markStatus(record, status) { return Object.assign(Object.assign({}, record), { _status: status }); } function markDeleted(record) { return markStatus(record, "deleted"); } function markSynced(record) { return markStatus(record, "synced"); } /** * Import a remote change into the local database. * * @param {IDBTransactionProxy} transaction The transaction handler. * @param {Object} remote The remote change object to import. * @param {Array<String>} localFields The list of fields that remain local. * @param {String} strategy The {@link Collection.strategy}. * @return {Object} */ function importChange(transaction, remote, localFields, strategy) { const local = transaction.get(remote.id); if (!local) { // Not found locally but remote change is marked as deleted; skip to // avoid recreation. if (remote.deleted) { return { type: "skipped", data: remote }; } const synced = markSynced(remote); transaction.create(synced); return { type: "created", data: synced }; } // Apply remote changes on local record. const synced = Object.assign(Object.assign({}, local), markSynced(remote)); // With pull only, we don't need to compare records since we override them. if (strategy === Collection.strategy.PULL_ONLY) { if (remote.deleted) { transaction.delete(remote.id); return { type: "deleted", data: local }; } transaction.update(synced); return { type: "updated", data: { old: local, new: synced } }; } // With other sync strategies, we detect conflicts, // by comparing local and remote, ignoring local fields. const isIdentical = recordsEqual(local, remote, localFields); // Detect or ignore conflicts if record has also been modified locally. if (local._status !== "synced") { // Locally deleted, unsynced: scheduled for remote deletion. if (local._status === "deleted") { return { type: "skipped", data: local }; } if (isIdentical) { // If records are identical, import anyway, so we bump the // local last_modified value from the server and set record // status to "synced". transaction.update(synced); return { type: "updated", data: { old: local, new: synced } }; } if ( local.last_modified !== undefined && local.last_modified === remote.last_modified ) { // If our local version has the same last_modified as the remote // one, this represents an object that corresponds to a resolved // conflict. Our local version represents the final output, so // we keep that one. (No transaction operation to do.) // But if our last_modified is undefined, // that means we've created the same object locally as one on // the server, which *must* be a conflict. return { type: "void" }; } return { type: "conflicts", data: { type: "incoming", local: local, remote: remote }, }; } // Local record was synced. if (remote.deleted) { transaction.delete(remote.id); return { type: "deleted", data: local }; } // Import locally. transaction.update(synced); // if identical, simply exclude it from all SyncResultObject lists const type = isIdentical ? "void" : "updated"; return { type, data: { old: local, new: synced } }; } /** * Abstracts a collection of records stored in the local database, providing * CRUD operations and synchronization helpers. */ class Collection { /** * Constructor. * * Options: * - `{BaseAdapter} adapter` The DB adapter (default: `IDB`) * * @param {String} bucket The bucket identifier. * @param {String} name The collection name. * @param {KintoBase} kinto The Kinto instance. * @param {Object} options The options object. */ constructor(bucket, name, kinto, options = {}) { this._bucket = bucket; this._name = name; this._lastModified = null; const DBAdapter = options.adapter || IDB; if (!DBAdapter) { throw new Error("No adapter provided"); } const db = new DBAdapter(`${bucket}/${name}`, options.adapterOptions); if (!(db instanceof BaseAdapter)) { throw new Error("Unsupported adapter."); } // public properties /** * The db adapter instance * @type {BaseAdapter} */ this.db = db; /** * The KintoBase instance. * @type {KintoBase} */ this.kinto = kinto; /** * The event emitter instance. * @type {EventEmitter} */ this.events = options.events; /** * The IdSchema instance. * @type {Object} */ this.idSchema = this._validateIdSchema(options.idSchema); /** * The list of remote transformers. * @type {Array} */ this.remoteTransformers = this._validateRemoteTransformers( options.remoteTransformers ); /** * The list of hooks. * @type {Object} */ this.hooks = this._validateHooks(options.hooks); /** * The list of fields names that will remain local. * @type {Array} */ this.localFields = options.localFields || []; } /** * The HTTP client. * @type {KintoClient} */ get api() { return this.kinto.api; } /** * The collection name. * @type {String} */ get name() { return this._name; } /** * The bucket name. * @type {String} */ get bucket() { return this._bucket; } /** * The last modified timestamp. * @type {Number} */ get lastModified() { return this._lastModified; } /** * Synchronization strategies. Available strategies are: * * - `MANUAL`: Conflicts will be reported in a dedicated array. * - `SERVER_WINS`: Conflicts are resolved using remote data. * - `CLIENT_WINS`: Conflicts are resolved using local data. * * @type {Object} */ static get strategy() { return { CLIENT_WINS: "client_wins", SERVER_WINS: "server_wins", PULL_ONLY: "pull_only", MANUAL: "manual", }; } /** * Validates an idSchema. * * @param {Object|undefined} idSchema * @return {Object} */ _validateIdSchema(idSchema) { if (typeof idSchema === "undefined") { return createUUIDSchema(); } if (typeof idSchema !== "object") { throw new Error("idSchema must be an object."); } else if (typeof idSchema.generate !== "function") { throw new Error("idSchema must provide a generate function."); } else if (typeof idSchema.validate !== "function") { throw new Error("idSchema must provide a validate function."); } return idSchema; } /** * Validates a list of remote transformers. * * @param {Array|undefined} remoteTransformers * @return {Array} */ _validateRemoteTransformers(remoteTransformers) { if (typeof remoteTransformers === "undefined") { return []; } if (!Array.isArray(remoteTransformers)) { throw new Error("remoteTransformers should be an array."); } return remoteTransformers.map(transformer => { if (typeof transformer !== "object") { throw new Error("A transformer must be an object."); } else if (typeof transformer.encode !== "function") { throw new Error("A transformer must provide an encode function."); } else if (typeof transformer.decode !== "function") { throw new Error("A transformer must provide a decode function."); } return transformer; }); } /** * Validate the passed hook is correct. * * @param {Array|undefined} hook. * @return {Array} **/ _validateHook(hook) { if (!Array.isArray(hook)) { throw new Error("A hook definition should be an array of functions."); } return hook.map(fn => { if (typeof fn !== "function") { throw new Error("A hook definition should be an array of functions."); } return fn; }); } /** * Validates a list of hooks. * * @param {Object|undefined} hooks * @return {Object} */ _validateHooks(hooks) { if (typeof hooks === "undefined") { return {}; } if (Array.isArray(hooks)) { throw new Error("hooks should be an object, not an array."); } if (typeof hooks !== "object") { throw new Error("hooks should be an object."); } const validatedHooks = {}; for (const hook in hooks) { if (!AVAILABLE_HOOKS.includes(hook)) { throw new Error( "The hook should be one of " + AVAILABLE_HOOKS.join(", ") ); } validatedHooks[hook] = this._validateHook(hooks[hook]); } return validatedHooks; } /** * Deletes every records in the current collection and marks the collection as * never synced. * * @return {Promise} */ async clear() { await this.db.clear(); await this.db.saveMetadata(null); await this.db.saveLastModified(null); return { data: [], permissions: {} }; } /** * Encodes a record. * * @param {String} type Either "remote" or "local". * @param {Object} record The record object to encode. * @return {Promise} */ _encodeRecord(type, record) { if (!this[`${type}Transformers`].length) { return Promise.resolve(record); } return waterfall( this[`${type}Transformers`].map(transformer => { return record => transformer.encode(record); }), record ); } /** * Decodes a record. * * @param {String} type Either "remote" or "local". * @param {Object} record The record object to decode. * @return {Promise} */ _decodeRecord(type, record) { if (!this[`${type}Transformers`].length) { return Promise.resolve(record); } return waterfall( this[`${type}Transformers`].reverse().map(transformer => { return record => transformer.decode(record); }), record ); } /** * Adds a record to the local database, asserting that none * already exist with this ID. * * Note: If either the `useRecordId` or `synced` options are true, then the * record object must contain the id field to be validated. If none of these * options are true, an id is generated using the current IdSchema; in this * case, the record passed must not have an id. * * Options: * - {Boolean} synced Sets record status to "synced" (default: `false`). * - {Boolean} useRecordId Forces the `id` field from the record to be used, * instead of one that is generated automatically * (default: `false`). * * @param {Object} record * @param {Object} options * @return {Promise} */ create(record, options = { useRecordId: false, synced: false }) { // Validate the record and its ID (if any), even though this // validation is also done in the CollectionTransaction method, // because we need to pass the ID to preloadIds. const reject = msg => Promise.reject(new Error(msg)); if (typeof record !== "object") { return reject("Record is not an object."); } if ( (options.synced || options.useRecordId) && !Object.prototype.hasOwnProperty.call(record, "id") ) { return reject( "Missing required Id; synced and useRecordId options require one" ); } if ( !options.synced && !options.useRecordId && Object.prototype.hasOwnProperty.call(record, "id") ) { return reject("Extraneous Id; can't create a record having one set."); } const newRecord = Object.assign(Object.assign({}, record), { id: options.synced || options.useRecordId ? record.id : this.idSchema.generate(record), _status: options.synced ? "synced" : "created", }); if (!this.idSchema.validate(newRecord.id)) { return reject(`Invalid Id: ${newRecord.id}`); } return this.execute(txn => txn.create(newRecord), { preloadIds: [newRecord.id], }).catch(err => { if (options.useRecordId) { throw new Error( "Couldn't create record. It may have been virtually deleted." ); } throw err; }); } /** * Like {@link CollectionTransaction#update}, but wrapped in its own transaction. * * Options: * - {Boolean} synced: Sets record status to "synced" (default: false) * - {Boolean} patch: Extends the existing record instead of overwriting it * (default: false) * * @param {Object} record * @param {Object} options * @return {Promise} */ update(record, options = { synced: false, patch: false }) { // Validate the record and its ID, even though this validation is // also done in the CollectionTransaction method, because we need // to pass the ID to preloadIds. if (typeof record !== "object") { return Promise.reject(new Error("Record is not an object.")); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { return Promise.reject(new Error("Cannot update a record missing id.")); } if (!this.idSchema.validate(record.id)) { return Promise.reject(new Error(`Invalid Id: ${record.id}`)); } return this.execute(txn => txn.update(record, options), { preloadIds: [record.id], }); } /** * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction. * * @param {Object} record * @return {Promise} */ upsert(record) { // Validate the record and its ID, even though this validation is // also done in the CollectionTransaction method, because we need // to pass the ID to preloadIds. if (typeof record !== "object") { return Promise.reject(new Error("Record is not an object.")); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { return Promise.reject(new Error("Cannot update a record missing id.")); } if (!this.idSchema.validate(record.id)) { return Promise.reject(new Error(`Invalid Id: ${record.id}`)); } return this.execute(txn => txn.upsert(record), { preloadIds: [record.id], }); } /** * Like {@link CollectionTransaction#get}, but wrapped in its own transaction. * * Options: * - {Boolean} includeDeleted: Include virtually deleted records. * * @param {String} id * @param {Object} options * @return {Promise} */ get(id, options = { includeDeleted: false }) { return this.execute(txn => txn.get(id, options), { preloadIds: [id] }); } /** * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction. * * @param {String} id * @return {Promise} */ getAny(id) { return this.execute(txn => txn.getAny(id), { preloadIds: [id] }); } /** * Same as {@link Collection#delete}, but wrapped in its own transaction. * * Options: * - {Boolean} virtual: When set to `true`, doesn't actually delete the record, * update its `_status` attribute to `deleted` instead (default: true) * * @param {String} id The record's Id. * @param {Object} options The options object. * @return {Promise} */ delete(id, options = { virtual: true }) { return this.execute( transaction => { return transaction.delete(id, options); }, { preloadIds: [id] } ); } /** * Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter. * * @return {Promise} */ async deleteAll() { const { data } = await this.list({}, { includeDeleted: false }); const recordIds = data.map(record => record.id); return this.execute( transaction => { return transaction.deleteAll(recordIds); }, { preloadIds: recordIds } ); } /** * The same as {@link CollectionTransaction#deleteAny}, but wrapped * in its own transaction. * * @param {String} id The record's Id. * @return {Promise} */ deleteAny(id) { return this.execute(txn => txn.deleteAny(id), { preloadIds: [id] }); } /** * Lists records from the local database. * * Params: * - {Object} filters Filter the results (default: `{}`). * - {String} order The order to apply (default: `-last_modified`). * * Options: * - {Boolean} includeDeleted: Include virtually deleted records. * * @param {Object} params The filters and order to apply to the results. * @param {Object} options The options object. * @return {Promise} */ async list(params = {}, options = { includeDeleted: false }) { params = Object.assign({ order: "-last_modified", filters: {} }, params); const results = await this.db.list(params); let data = results; if (!options.includeDeleted) { data = results.filter(record => record._status !== "deleted"); } return { data, permissions: {} }; } /** * Imports remote changes into the local database. * This method is in charge of detecting the conflicts, and resolve them * according to the specified strategy. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Array} decodedChanges The list of changes to import in the local database. * @param {String} strategy The {@link Collection.strategy} (default: MANUAL) * @return {Promise} */ async importChanges( syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL ) { // Retrieve records matching change ids. try { for (let i = 0; i < decodedChanges.length; i += IMPORT_CHUNK_SIZE) { const slice = decodedChanges.slice(i, i + IMPORT_CHUNK_SIZE); const { imports, resolved } = await this.db.execute( transaction => { const imports = slice.map(remote => { // Store remote change into local database. return importChange( transaction, remote, this.localFields, strategy ); }); const conflicts = imports .filter(i => i.type === "conflicts") .map(i => i.data); const resolved = this._handleConflicts( transaction, conflicts, strategy ); return { imports, resolved }; }, { preload: slice.map(record => record.id) } ); // Lists of created/updated/deleted records imports.forEach(({ type, data }) => syncResultObject.add(type, data)); // Automatically resolved conflicts (if not manual) if (resolved.length > 0) { syncResultObject.reset("conflicts").add("resolved", resolved); } } } catch (err) { const data = { type: "incoming", message: err.message, stack: err.stack, }; // XXX one error of the whole transaction instead of per atomic op syncResultObject.add("errors", data); } return syncResultObject; } /** * Imports the responses of pushed changes into the local database. * Basically it stores the timestamp assigned by the server into the local * database. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Array} toApplyLocally The list of changes to import in the local database. * @param {Array} conflicts The list of conflicts that have to be resolved. * @param {String} strategy The {@link Collection.strategy}. * @return {Promise} */ async _applyPushedResults( syncResultObject, toApplyLocally, conflicts, strategy = Collection.strategy.MANUAL ) { const toDeleteLocally = toApplyLocally.filter(r => r.deleted); const toUpdateLocally = toApplyLocally.filter(r => !r.deleted); const { published, resolved } = await this.db.execute(transaction => { const updated = toUpdateLocally.map(record => { const synced = markSynced(record); transaction.update(synced); return synced; }); const deleted = toDeleteLocally.map(record => { transaction.delete(record.id); // Amend result data with the deleted attribute set return { id: record.id, deleted: true }; }); const published = updated.concat(deleted); // Handle conflicts, if any const resolved = this._handleConflicts( transaction, conflicts, strategy ); return { published, resolved }; }); syncResultObject.add("published", published); if (resolved.length > 0) { syncResultObject .reset("conflicts") .reset("resolved") .add("resolved", resolved); } return syncResultObject; } /** * Handles synchronization conflicts according to specified strategy. * * @param {SyncResultObject} result The sync result object. * @param {String} strategy The {@link Collection.strategy}. * @return {Promise<Array<Object>>} The resolved conflicts, as an * array of {accepted, rejected} objects */ _handleConflicts(transaction, conflicts, strategy) { if (strategy === Collection.strategy.MANUAL) { return []; } return conflicts.map(conflict => { const resolution = strategy === Collection.strategy.CLIENT_WINS ? conflict.local : conflict.remote; const rejected = strategy === Collection.strategy.CLIENT_WINS ? conflict.remote : conflict.local; let accepted, status, id; if (resolution === null) { // We "resolved" with the server-side deletion. Delete locally. // This only happens during SERVER_WINS because the local // version of a record can never be null. // We can get "null" from the remote side if we got a conflict // and there is no remote version available; see kinto-http.js // batch.js:aggregate. transaction.delete(conflict.local.id); accepted = null; // The record was deleted, but that status is "synced" with // the server, so we don't need to push the change. status = "synced"; id = conflict.local.id; } else { const updated = this._resolveRaw(conflict, resolution); transaction.update(updated); accepted = updated; status = updated._status; id = updated.id; } return { rejected, accepted, id, _status: status }; }); } /** * Execute a bunch of operations in a transaction. * * This transaction should be atomic -- either all of its operations * will succeed, or none will. * * The argument to this function is itself a function which will be * called with a {@link CollectionTransaction}. Collection methods * are available on this transaction, but instead of returning * promises, they are synchronous. execute() returns a Promise whose * value will be the return value of the provided function. * * Most operations will require access to the record itself, which * must be preloaded by passing its ID in the preloadIds option. * * Options: * - {Array} preloadIds: list of IDs to fetch at the beginning of * the transaction * * @return {Promise} Resolves with the result of the given function * when the transaction commits. */ execute(doOperations, { preloadIds = [] } = {}) { for (const id of preloadIds) { if (!this.idSchema.validate(id)) { return Promise.reject(Error(`Invalid Id: ${id}`)); } } return this.db.execute( transaction => { const txn = new CollectionTransaction(this, transaction); const result = doOperations(txn); txn.emitEvents(); return result; }, { preload: preloadIds } ); } /** * Resets the local records as if they were never synced; existing records are * marked as newly created, deleted records are dropped. * * A next call to {@link Collection.sync} will thus republish the whole * content of the local collection to the server. * * @return {Promise} Resolves with the number of processed records. */ async resetSyncStatus() { const unsynced = await this.list( { filters: { _status: ["deleted", "synced"] }, order: "" }, { includeDeleted: true } ); await this.db.execute(transaction => { unsynced.data.forEach(record => { if (record._status === "deleted") { // Garbage collect deleted records. transaction.delete(record.id); } else { // Records that were synced become «created». transaction.update( Object.assign(Object.assign({}, record), { last_modified: undefined, _status: "created", }) ); } }); }); this._lastModified = null; await this.db.saveLastModified(null); return unsynced.data.length; } /** * Returns an object containing two lists: * * - `toDelete`: unsynced deleted records we can safely delete; * - `toSync`: local updates to send to the server. * * @return {Promise} */ async gatherLocalChanges() { const unsynced = await this.list({ filters: { _status: ["created", "updated"] }, order: "", }); const deleted = await this.list( { filters: { _status: "deleted" }, order: "" }, { includeDeleted: true } ); return await Promise.all( unsynced.data .concat(deleted.data) .map(this._encodeRecord.bind(this, "remote")) ); } /** * Fetch remote changes, import them to the local database, and handle * conflicts according to `options.strategy`. Then, updates the passed * {@link SyncResultObject} with import results. * * Options: * - {String} strategy: The selected sync strategy. * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter. * - {Array<String>} exclude: A list of record ids to exclude from pull. * - {Object} headers: The HTTP headers to use in the request. * - {int} retry: The number of retries to do if the HTTP request fails. * - {int} lastModified: The timestamp to use in `?_since` query. * * @param {KintoClient.Collection} client Kinto client Collection instance. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Object} options The options object. * @return {Promise} */ async pullChanges(client, syncResultObject, options = {}) { if (!syncResultObject.ok) { return syncResultObject; } const since = this.lastModified ? this.lastModified : await this.db.getLastModified(); options = Object.assign( { strategy: Collection.strategy.MANUAL, lastModified: since, headers: {}, }, options ); // Optionally ignore some records when pulling for changes. // (avoid redownloading our own changes on last step of #sync()) let filters; if (options.exclude) { // Limit the list of excluded records to the first 50 records in order // to remain under de-facto URL size limit (~2000 chars). // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers/417184#417184 const exclude_id = options.exclude .slice(0, 50) .map(r => r.id) .join(","); filters = { exclude_id }; } if (options.expectedTimestamp) { filters = Object.assign(Object.assign({}, filters), { _expected: options.expectedTimestamp, }); } // First fetch remote changes from the server const { data, last_modified } = await client.listRecords({ // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356) since: options.lastModified ? `${options.lastModified}` : undefined, headers: options.headers, retry: options.retry, // Fetch every page by default (FIXME: option to limit pages, see #277) pages: Infinity, filters, }); // last_modified is the ETag header value (string). // For retro-compatibility with first kinto.js versions // parse it to integer. const unquoted = last_modified ? parseInt(last_modified, 10) : undefined; // Check if server was flushed. // This is relevant for the Kinto demo server // (and thus for many new comers). const localSynced = options.lastModified; const serverChanged = unquoted > options.lastModified; const emptyCollection = data.length === 0; if (!options.exclude && localSynced && serverChanged && emptyCollection) { const e = new ServerWasFlushedError( localSynced, unquoted, "Server has been flushed. Client Side Timestamp: " + localSynced + " Server Side Timestamp: " + unquoted ); throw e; } // Atomic updates are not sensible here because unquoted is not // computed as a function of syncResultObject.lastModified. // eslint-disable-next-line require-atomic-updates syncResultObject.lastModified = unquoted; // Decode incoming changes. const decodedChanges = await Promise.all( data.map(change => { return this._decodeRecord("remote", change); }) ); // Hook receives decoded records. const payload = { lastModified: unquoted, changes: decodedChanges }; const afterHooks = await this.applyHook("incoming-changes", payload); // No change, nothing to import. if (afterHooks.changes.length > 0) { // Reflect these changes locally await this.importChanges( syncResultObject, afterHooks.changes, options.strategy ); } return syncResultObject; } applyHook(hookName, payload) { if (typeof this.hooks[hookName] == "undefined") { return Promise.resolve(payload); } return waterfall( this.hooks[hookName].map(hook => { return record => { const result = hook(payload, this); const resultThenable = result && typeof result.then === "function"; const resultChanges = result && Object.prototype.hasOwnProperty.call(result, "changes"); if (!(resultThenable || resultChanges)) { throw new Error( `Invalid return value for hook: ${JSON.stringify( result )} has no 'then()' or 'changes' properties` ); } return result; }; }), payload ); } /** * Publish local changes to the remote server and updates the passed * {@link SyncResultObject} with publication results. * * Options: * - {String} strategy: The selected sync strategy. * - {Object} headers: The HTTP headers to use in the request. * - {int} retry: The number of retries to do if the HTTP request fails. * * @param {KintoClient.Collection} client Kinto client Collection instance. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Object} changes The change object. * @param {Array} changes.toDelete The list of records to delete. * @param {Array} changes.toSync The list of records to create/update. * @param {Object} options The options object. * @return {Promise} */ async pushChanges(client, changes, syncResultObject, options = {}) { if (!syncResultObject.ok) { return syncResultObject; } const safe = !options.strategy || options.strategy !== Collection.CLIENT_WINS; const toDelete = changes.filter(r => r._status == "deleted"); const toSync = changes.filter(r => r._status != "deleted"); // Perform a batch request with every changes. const synced = await client.batch( batch => { toDelete.forEach(r => { // never published locally deleted records should not be pusblished if (r.last_modified) { batch.deleteRecord(r); } }); toSync.forEach(r => { // Clean local fields (like _status) before sending to server. const published = this.cleanLocalFields(r); if (r._status === "created") { batch.createRecord(published); } else { batch.updateRecord(published); } }); }, { headers: options.headers, retry: options.retry, safe, aggregate: true, } ); // Store outgoing errors into sync result object syncResultObject.add( "errors", synced.errors.map(e => Object.assign(Object.assign({}, e), { type: "outgoing" }) ) ); // Store outgoing conflicts into sync result object const conflicts = []; for (const { type, local, remote } of synced.conflicts) { // Note: we ensure that local data are actually available, as they may // be missing in the case of a published deletion. const safeLocal = (local && local.data) || { id: remote.id }; const realLocal = await this._decodeRecord("remote", safeLocal); // We can get "null" from the remote side if we got a conflict // and there is no remote version available; see kinto-http.js // batch.js:aggregate. const realRemote = remote && (await this._decodeRecord("remote", remote)); const conflict = { type, local: realLocal, remote: realRemote }; conflicts.push(conflict); } syncResultObject.add("conflicts", conflicts); // Records that must be deleted are either deletions that were pushed // to server (published) or deleted records that were never pushed (skipped). const missingRemotely = synced.skipped.map(r => Object.assign(Object.assign({}, r), { deleted: true }) ); // For created and updated records, the last_modified coming from server // will be stored locally. // Reflect publication results locally using the response from // the batch request. const published = synced.published.map(c => c.data); const toApplyLocally = published.concat(missingRemotely); // Apply the decode transformers, if any const decoded = await Promise.all( toApplyLocally.map(record => { return this._decodeRecord("remote", record); }) ); // We have to update the local records with the responses of the server // (eg. last_modified values etc.). if (decoded.length > 0 || conflicts.length > 0) { await this._applyPushedResults( syncResultObject, decoded, conflicts, options.strategy ); } return syncResultObject; } /** * Return a copy of the specified record without the local fields. * * @param {Object} record A record with potential local fields. * @return {Object} */ cleanLocalFields(record) { const localKeys = RECORD_FIELDS_TO_CLEAN.concat(this.localFields); return omitKeys(record, localKeys); } /** * Resolves a conflict, updating local record according to proposed * resolution — keeping remote record `last_modified` value as a reference for * further batch sending. * * @param {Object} conflict The conflict object. * @param {Object} resolution The proposed record. * @return {Promise} */ resolve(conflict, resolution) { return this.db.execute(transaction => { const updated = this._resolveRaw(conflict, resolution); transaction.update(updated); return { data: updated, permissions: {} }; }); } /** * @private */ _resolveRaw(conflict, resolution) { const resolved = Object.assign(Object.assign({}, resolution), { // Ensure local record has the latest authoritative timestamp last_modified: conflict.remote && conflict.remote.last_modified, }); // If the resolution object is strictly equal to the // remote record, then we can mark it as synced locally. // Otherwise, mark it as updated (so that the resolution is pushed). const synced = deepEqual(resolved, conflict.remote); return markStatus(resolved, synced ? "synced" : "updated"); } /** * Synchronize remote and local data. The promise will resolve with a * {@link SyncResultObject}, though will reject: * * - if the server is currently backed off; * - if the server has been detected flushed. * * Options: * - {Object} headers: HTTP headers to attach to outgoing requests. * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter. * - {Number} retry: Number of retries when server fails to process the request (default: 1). * - {Collection.strategy} strategy: See {@link Collection.strategy}. * - {Boolean} ignoreBackoff: Force synchronization even if server is currently * backed off. * - {String} bucket: The remove bucket id to use (default: null) * - {String} collection: The remove collection id to use (default: null) * - {String} remote The remote Kinto server endpoint to use (default: null). * * @param {Object} options Options. * @return {Promise} * @throws {Error} If an invalid remote option is passed. */ async sync( options = { strategy: Collection.strategy.MANUAL, headers: {}, retry: 1, ignoreBackoff: false, bucket: null, collection: null, remote: null, expectedTimestamp: null, } ) { options = Object.assign(Object.assign({}, options), { bucket: options.bucket || this.bucket, collection: options.collection || this.name, }); const previousRemote = this.api.remote; if (options.remote) { // Note: setting the remote ensures it's valid, throws when invalid. this.api.remote = options.remote; } if (!options.ignoreBackoff && this.api.backoff > 0) { const seconds = Math.ceil(this.api.backoff / 1000); return Promise.reject( new Error( `Server is asking clients to back off; retry in ${seconds}s or use the ignoreBackoff option.` ) ); } const client = this.api .bucket(options.bucket) .collection(options.collection); const result = new SyncResultObject(); try { // Fetch collection metadata. await this.pullMetadata(client, options); // Fetch last changes from the server. await this.pullChanges(client, result, options); const { lastModified } = result; if (options.strategy != Collection.strategy.PULL_ONLY) { // Fetch local changes const toSync = await this.gatherLocalChanges(); // Publish local changes and pull local resolutions await this.pushChanges(client, toSync, result, options); // Publish local resolution of push conflicts to server (on CLIENT_WINS) const resolvedUnsynced = result.resolved.filter( r => r._status !== "synced" ); if (resolvedUnsynced.length > 0) { const resolvedEncoded = await Promise.all( resolvedUnsynced.map(resolution => { let record = resolution.accepted; if (record === null) { record = { id: resolution.id, _status: resolution._status }; } return this._encodeRecord("remote", record); }) ); await this.pushChanges(client, resolvedEncoded, result, options); } // Perform a last pull to catch changes that occured after the last pull, // while local changes were pushed. Do not do it nothing was pushed. if (result.published.length > 0) { // Avoid redownloading our own changes during the last pull. const pullOpts = Object.assign(Object.assign({}, options), { lastModified, exclude: result.published, }); await this.pullChanges(client, result, pullOpts); } } // Don't persist lastModified value if any conflict or error occured if (result.ok) { // No conflict occured, persist collection's lastModified value this._lastModified = await this.db.saveLastModified( result.lastModified ); } } catch (e) { this.events.emit( "sync:error", Object.assign(Object.assign({}, options), { error: e }) ); throw e; } finally { // Ensure API default remote is reverted if a custom one's been used this.api.remote = previousRemote; } this.events.emit( "sync:success", Object.assign(Object.assign({}, options), { result }) ); return result; } /** * Load a list of records already synced with the remote server. * * The local records which are unsynced or whose timestamp is either missing * or superior to those being loaded will be ignored. * * @deprecated Use {@link importBulk} instead. * @param {Array} records The previously exported list of records to load. * @return {Promise} with the effectively imported records. */ async loadDump(records) { return this.importBulk(records); } /** * Load a list of records already synced with the remote server. * * The local records which are unsynced or whose timestamp is either missing * or superior to those being loaded will be ignored. * * @param {Array} records The previously exported list of records to load. * @return {Promise} with the effectively imported records. */ async importBulk(records) { if (!Array.isArray(records)) { throw new Error("Records is not an array."); } for (const record of records) { if ( !Object.prototype.hasOwnProperty.call(record, "id") || !this.idSchema.validate(record.id) ) { throw new Error("Record has invalid ID: " + JSON.stringify(record)); } if (!record.last_modified) { throw new Error( "Record has no last_modified value: " + JSON.stringify(record) ); } } // Fetch all existing records from local database, // and skip those who are newer or not marked as synced. // XXX filter by status / ids in records const { data } = await this.list({}, { includeDeleted: true }); const existingById = data.reduce((acc, record) => { acc[record.id] = record; return acc; }, {}); const newRecords = records.filter(record => { const localRecord = existingById[record.id]; const shouldKeep = // No local record with this id. localRecord === undefined || // Or local record is synced (localRecord._status === "synced" && // And was synced from server localRecord.last_modified !== undefined && // And is older than imported one. record.last_modified > localRecord.last_modified); return shouldKeep; }); return await this.db.importBulk(newRecords.map(markSynced)); } async pullMetadata(client, options = {}) { const { expectedTimestamp, headers } = options; const query = expectedTimestamp ? { query: { _expected: expectedTimestamp } } : undefined; const metadata = await client.getData( Object.assign(Object.assign({}, query), { headers }) ); return this.db.saveMetadata(metadata); } async metadata() { return this.db.getMetadata(); } } /** * A Collection-oriented wrapper for an adapter's transaction. * * This defines the high-level functions available on a collection. * The collection itself offers functions of the same name. These will * perform just one operation in its own transaction. */ class CollectionTransaction { constructor(collection, adapterTransaction) { this.collection = collection; this.adapterTransaction = adapterTransaction; this._events = []; } _queueEvent(action, payload) { this._events.push({ action, payload }); } /** * Emit queued events, to be called once every transaction operations have * been executed successfully. */ emitEvents() { for (const { action, payload } of this._events) { this.collection.events.emit(action, payload); } if (this._events.length > 0) { const targets = this._events.map(({ action, payload }) => Object.assign({ action }, payload) ); this.collection.events.emit("change", { targets }); } this._events = []; } /** * Retrieve a record by its id from the local database, or * undefined if none exists. * * This will also return virtually deleted records. * * @param {String} id * @return {Object} */ getAny(id) { const record = this.adapterTransaction.get(id); return { data: record, permissions: {} }; } /** * Retrieve a record by its id from the local database. * * Options: * - {Boolean} includeDeleted: Include virtually deleted records. * * @param {String} id * @param {Object} options * @return {Object} */ get(id, options = { includeDeleted: false }) { const res = this.getAny(id); if ( !res.data || (!options.includeDeleted && res.data._status === "deleted") ) { throw new Error(`Record with id=${id} not found.`); } return res; } /** * Deletes a record from the local database. * * Options: * - {Boolean} virtual: When set to `true`, doesn't actually delete the record, * update its `_status` attribute to `deleted` instead (default: true) * * @param {String} id The record's Id. * @param {Object} options The options object. * @return {Object} */ delete(id, options = { virtual: true }) { // Ensure the record actually exists. const existing = this.adapterTransaction.get(id); const alreadyDeleted = existing && existing._status == "deleted"; if (!existing || (alreadyDeleted && options.virtual)) { throw new Error(`Record with id=${id} not found.`); } // Virtual updates status. if (options.virtual) { this.adapterTransaction.update(markDeleted(existing)); } else { // Delete for real. this.adapterTransaction.delete(id); } this._queueEvent("delete", { data: existing }); return { data: existing, permissions: {} }; } /** * Soft delete all records from the local database. * * @param {Array} ids Array of non-deleted Record Ids. * @return {Object} */ deleteAll(ids) { const existingRecords = []; ids.forEach(id => { existingRecords.push(this.adapterTransaction.get(id)); this.delete(id); }); this._queueEvent("deleteAll", { data: existingRecords }); return { data: existingRecords, permissions: {} }; } /** * Deletes a record from the local database, if any exists. * Otherwise, do nothing. * * @param {String} id The record's Id. * @return {Object} */ deleteAny(id) { const existing = this.adapterTransaction.get(id); if (existing) { this.adapterTransaction.update(markDeleted(existing)); this._queueEvent("delete", { data: existing }); } return { data: Object.assign({ id }, existing), deleted: !!existing, permissions: {}, }; } /** * Adds a record to the local database, asserting that none * already exist with this ID. * * @param {Object} record, which must contain an ID * @return {Object} */ create(record) { if (typeof record !== "object") { throw new Error("Record is not an object."); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { throw new Error("Cannot create a record missing id"); } if (!this.collection.idSchema.validate(record.id)) { throw new Error(`Invalid Id: ${record.id}`); } this.adapterTransaction.create(record); this._queueEvent("create", { data: record }); return { data: record, permissions: {} }; } /** * Updates a record from the local database. * * Options: * - {Boolean} synced: Sets record status to "synced" (default: false) * - {Boolean} patch: Extends the existing record instead of overwriting it * (default: false) * * @param {Object} record * @param {Object} options * @return {Object} */ update(record, options = { synced: false, patch: false }) { if (typeof record !== "object") { throw new Error("Record is not an object."); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { throw new Error("Cannot update a record missing id."); } if (!this.collection.idSchema.validate(record.id)) { throw new Error(`Invalid Id: ${record.id}`); } const oldRecord = this.adapterTransaction.get(record.id); if (!oldRecord) { throw new Error(`Record with id=${record.id} not found.`); } const newRecord = options.patch ? Object.assign(Object.assign({}, oldRecord), record) : record; const updated = this._updateRaw(oldRecord, newRecord, options); this.adapterTransaction.update(updated); this._queueEvent("update", { data: updated, oldRecord }); return { data: updated, oldRecord, permissions: {} }; } /** * Lower-level primitive for updating a record while respecting * _status and last_modified. * * @param {Object} oldRecord: the record retrieved from the DB * @param {Object} newRecord: the record to replace it with * @return {Object} */ _updateRaw(oldRecord, newRecord, { synced = false } = {}) { const updated = Object.assign({}, newRecord); // Make sure to never loose the existing timestamp. if (oldRecord && oldRecord.last_modified && !updated.last_modified) { updated.last_modified = oldRecord.last_modified; } // If only local fields have changed, then keep record as synced. // If status is created, keep record as created. // If status is deleted, mark as updated. const isIdentical = oldRecord && recordsEqual(oldRecord, updated, this.collection.localFields); const keepSynced = isIdentical && oldRecord._status == "synced"; const neverSynced = !oldRecord || (oldRecord && oldRecord._status == "created"); const newStatus = keepSynced || synced ? "synced" : neverSynced ? "created" : "updated"; return markStatus(updated, newStatus); } /** * Upsert a record into the local database. * * This record must have an ID. * * If a record with this ID already exists, it will be replaced. * Otherwise, this record will be inserted. * * @param {Object} record * @return {Object} */ upsert(record) { if (typeof record !== "object") { throw new Error("Record is not an object."); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { throw new Error("Cannot update a record missing id."); } if (!this.collection.idSchema.validate(record.id)) { throw new Error(`Invalid Id: ${record.id}`); } let oldRecord = this.adapterTransaction.get(record.id); const updated = this._updateRaw(oldRecord, record); this.adapterTransaction.update(updated); // Don't return deleted records -- pretend they are gone if (oldRecord && oldRecord._status == "deleted") { oldRecord = undefined; } if (oldRecord) { this._queueEvent("update", { data: updated, oldRecord }); } else { this._queueEvent("create", { data: updated }); } return { data: updated, oldRecord, permissions: {} }; } } const DEFAULT_BUCKET_NAME = "default"; const DEFAULT_REMOTE = "http://localhost:8888/v1"; const DEFAULT_RETRY = 1; /** * KintoBase class. */ class KintoBase { /** * Provides a public access to the base adapter class. Users can create a * custom DB adapter by extending {@link BaseAdapter}. * * @type {Object} */ static get adapters() { return { BaseAdapter: BaseAdapter, }; } /** * Synchronization strategies. Available strategies are: * * - `MANUAL`: Conflicts will be reported in a dedicated array. * - `SERVER_WINS`: Conflicts are resolved using remote data. * - `CLIENT_WINS`: Conflicts are resolved using local data. * * @type {Object} */ static get syncStrategy() { return Collection.strategy; } /** * Constructor. * * Options: * - `{String}` `remote` The server URL to use. * - `{String}` `bucket` The collection bucket name. * - `{EventEmitter}` `events` Events handler. * - `{BaseAdapter}` `adapter` The base DB adapter class. * - `{Object}` `adapterOptions` Options given to the adapter. * - `{Object}` `headers` The HTTP headers to use. * - `{Object}` `retry` Number of retries when the server fails to process the request (default: `1`) * - `{String}` `requestMode` The HTTP CORS mode to use. * - `{Number}` `timeout` The requests timeout in ms (default: `5000`). * * @param {Object} options The options object. */ constructor(options = {}) { const defaults = { bucket: DEFAULT_BUCKET_NAME, remote: DEFAULT_REMOTE, retry: DEFAULT_RETRY, }; this._options = Object.assign(Object.assign({}, defaults), options); if (!this._options.adapter) { throw new Error("No adapter provided"); } this._api = null; /** * The event emitter instance. * @type {EventEmitter} */ this.events = this._options.events; } /** * The kinto HTTP client instance. * @type {KintoClient} */ get api() { const { events, headers, remote, requestMode, retry, timeout, } = this._options; if (!this._api) { this._api = new this.ApiClass(remote, { events, headers, requestMode, retry, timeout, }); } return this._api; } /** * Creates a {@link Collection} instance. The second (optional) parameter * will set collection-level options like e.g. `remoteTransformers`. * * @param {String} collName The collection name. * @param {Object} [options={}] Extra options or override client's options. * @param {Object} [options.idSchema] IdSchema instance (default: UUID) * @param {Object} [options.remoteTransformers] Array<RemoteTransformer> (default: `[]`]) * @param {Object} [options.hooks] Array<Hook> (default: `[]`]) * @param {Object} [options.localFields] Array<Field> (default: `[]`]) * @return {Collection} */ collection(collName, options = {}) { if (!collName) { throw new Error("missing collection name"); } const { bucket, events, adapter, adapterOptions } = Object.assign( Object.assign({}, this._options), options ); const { idSchema, remoteTransformers, hooks, localFields } = options; return new Collection(bucket, collName, this, { events, adapter, adapterOptions, idSchema, remoteTransformers, hooks, localFields, }); } } /* * * 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. */ ChromeUtils.import("resource://gre/modules/Timer.jsm", global); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); XPCOMUtils.defineLazyGlobalGetters(global, ["fetch", "indexedDB"]); ChromeUtils.defineModuleGetter( global, "EventEmitter", "resource://gre/modules/EventEmitter.jsm" ); // Use standalone kinto-http module landed in FFx. ChromeUtils.defineModuleGetter( global, "KintoHttpClient", "resource://services-common/kinto-http-client.js" ); XPCOMUtils.defineLazyGetter(global, "generateUUID", () => { const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService( Ci.nsIUUIDGenerator ); return generateUUID; }); class Kinto extends KintoBase { static get adapters() { return { BaseAdapter, IDB, }; } get ApiClass() { return KintoHttpClient; } constructor(options = {}) { const events = {}; EventEmitter.decorate(events); const defaults = { adapter: IDB, events, }; super(Object.assign(Object.assign({}, defaults), options)); } collection(collName, options = {}) { const idSchema = { validate(id) { return typeof id == "string" && RE_RECORD_ID.test(id); }, generate() { return generateUUID() .toString() .replace(/[{}]/g, ""); }, }; return super.collection(collName, Object.assign({ idSchema }, options)); } } return Kinto; });
Testo modificato
Apri file
/* * * 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. */ "use strict"; /* * This file is generated from kinto.js - do not modify directly. */ // This is required because with Babel compiles ES2015 modules into a // require() form that tries to keep its modules on "this", but // doesn't specify "this", leaving it to default to the global // object. However, in strict mode, "this" no longer defaults to the // global object, so expose the global object explicitly. Babel's // compiled output will use a variable called "global" if one is // present. // // See https://bugzilla.mozilla.org/show_bug.cgi?id=1394556#c3 for // more details. const global = this; var EXPORTED_SYMBOLS = ["Kinto"]; /* * Version 12.7.0 - cf865b6 */ (function(global, factory) { typeof exports === "object" && typeof module !== "undefined" ? (module.exports = factory(require("events"))) : typeof define === "function" && define.amd ? define(["events"], factory) : ((global = global || self), (global.Kinto = factory(global.events))); })(this, function(events) { "use strict"; /** * Base db adapter. * * @abstract */ class BaseAdapter { /** * Deletes every records present in the database. * * @abstract * @return {Promise} */ clear() { throw new Error("Not Implemented."); } /** * Executes a batch of operations within a single transaction. * * @abstract * @param {Function} callback The operation callback. * @param {Object} options The options object. * @return {Promise} */ execute(callback, options = { preload: [] }) { throw new Error("Not Implemented."); } /** * Retrieve a record by its primary key from the database. * * @abstract * @param {String} id The record id. * @return {Promise} */ get(id) { throw new Error("Not Implemented."); } /** * Lists all records from the database. * * @abstract * @param {Object} params The filters and order to apply to the results. * @return {Promise} */ list( params = { filters: {}, order: "", } ) { throw new Error("Not Implemented."); } /** * Store the lastModified value. * * @abstract * @param {Number} lastModified * @return {Promise} */ saveLastModified(lastModified) { throw new Error("Not Implemented."); } /** * Retrieve saved lastModified value. * * @abstract * @return {Promise} */ getLastModified() { throw new Error("Not Implemented."); } /** * Load records in bulk that were exported from a server. * * @abstract * @param {Array} records The records to load. * @return {Promise} */ importBulk(records) { throw new Error("Not Implemented."); } /** * Load a dump of records exported from a server. * * @deprecated Use {@link importBulk} instead. * @abstract * @param {Array} records The records to load. * @return {Promise} */ loadDump(records) { throw new Error("Not Implemented."); } saveMetadata(metadata) { throw new Error("Not Implemented."); } getMetadata() { throw new Error("Not Implemented."); } } const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; /** * Checks if a value is undefined. * @param {Any} value * @return {Boolean} */ function _isUndefined(value) { return typeof value === "undefined"; } /** * Sorts records in a list according to a given ordering. * * @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. * * @param {Object} filters The filters object. * @param {Object} entry The object to filter. * @return {Boolean} */ 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]); } else if (typeof value === "object") { return filterObject(value, entry[filter]); } else if (!Object.prototype.hasOwnProperty.call(entry, filter)) { console.error(`The property ${filter} does not exist`); return false; } return entry[filter] === value; }); } /** * Resolves a list of functions sequentially, which can be sync or async; in * case of async, functions must return a promise. * * @param {Array} fns The list of functions. * @param {Any} init The initial value. * @return {Promise} */ function waterfall(fns, init) { if (!fns.length) { return Promise.resolve(init); } return fns.reduce((promise, nextFn) => { return promise.then(nextFn); }, Promise.resolve(init)); } /** * Simple deep object comparison function. This only supports comparison of * serializable JavaScript objects. * * @param {Object} a The source object. * @param {Object} b The compared object. * @return {Boolean} */ function deepEqual(a, b) { if (a === b) { return true; } if (typeof a !== typeof b) { return false; } if (!(a && typeof a === "object") || !(b && typeof b === "object")) { return false; } if (Object.keys(a).length !== Object.keys(b).length) { return false; } for (const k in a) { if (!deepEqual(a[k], b[k])) { return false; } } return true; } /** * Return an object without the specified keys. * * @param {Object} obj The original object. * @param {Array} keys The list of keys to exclude. * @return {Object} A copy without the specified keys. */ function omitKeys(obj, keys = []) { const result = Object.assign({}, obj); for (const key of keys) { delete result[key]; } return result; } function arrayEqual(a, b) { if (a.length !== b.length) { return false; } for (let i = a.length; i--; ) { if (a[i] !== b[i]) { return false; } } return true; } function makeNestedObjectFromArr(arr, val, nestedFiltersObj) { const last = arr.length - 1; return arr.reduce((acc, cv, i) => { if (i === last) { return (acc[cv] = val); } else if (Object.prototype.hasOwnProperty.call(acc, cv)) { return acc[cv]; } else { return (acc[cv] = {}); } }, nestedFiltersObj); } function transformSubObjectFilters(filtersObj) { const transformedFilters = {}; for (const key in filtersObj) { const keysArr = key.split("."); const val = filtersObj[key]; makeNestedObjectFromArr(keysArr, val, transformedFilters); } return transformedFilters; } 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(); 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 (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) ? arrayEqual(key, values[i]) : key === values[i]; if (isEqual) { if (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 = 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 = 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 (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; } /** * IndexedDB adapter. * * This adapter doesn't support any options. */ class IDB extends BaseAdapter { /** * 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) { const error = new Error(`IndexedDB ${method}() ${err.message}`); error.stack = err.stack; throw error; } /** * 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 !== null && timestamp !== void 0 ? 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) { return runCallback(); } // 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); } } /** * 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 ? 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); } } 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); } } } /** * 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(Object.assign(Object.assign({}, record), { _cid })); }, update(record) { return store.put(Object.assign(Object.assign({}, 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(); } } var uuid4 = {}; const RECORD_FIELDS_TO_CLEAN = ["_status"]; const AVAILABLE_HOOKS = ["incoming-changes"]; const IMPORT_CHUNK_SIZE = 200; /** * Compare two records omitting local fields and synchronization * attributes (like _status and last_modified) * @param {Object} a A record to compare. * @param {Object} b A record to compare. * @param {Array} localFields Additional fields to ignore during the comparison * @return {boolean} */ function recordsEqual(a, b, localFields = []) { const fieldsToClean = [ ...RECORD_FIELDS_TO_CLEAN, "last_modified", ...localFields, ]; const cleanLocal = r => omitKeys(r, fieldsToClean); return deepEqual(cleanLocal(a), cleanLocal(b)); } /** * Synchronization result object. */ class SyncResultObject { constructor() { /** * Current synchronization result status; becomes `false` when conflicts or * errors are registered. * @type {Boolean} */ this.lastModified = null; this._lists = { errors: [], created: [], updated: [], deleted: [], published: [], conflicts: [], skipped: [], resolved: [], void: [], }; this._cached = {}; } /** * Adds entries for a given result type. * * @param {String} type The result type. * @param {Array} entries The result entries. * @return {SyncResultObject} */ add(type, entries) { if (!Array.isArray(this._lists[type])) { console.warn(`Unknown type "${type}"`); return this; } if (!Array.isArray(entries)) { entries = [entries]; } this._lists[type] = [...this._lists[type], ...entries]; delete this._cached[type]; return this; } get ok() { return this.errors.length + this.conflicts.length === 0; } get errors() { return this._lists["errors"]; } get conflicts() { return this._lists["conflicts"]; } get skipped() { return this._deduplicate("skipped"); } get resolved() { return this._deduplicate("resolved"); } get created() { return this._deduplicate("created"); } get updated() { return this._deduplicate("updated"); } get deleted() { return this._deduplicate("deleted"); } get published() { return this._deduplicate("published"); } _deduplicate(list) { if (!(list in this._cached)) { // Deduplicate entries by id. If the values don't have `id` attribute, just // keep all. const recordsWithoutId = new Set(); const recordsById = new Map(); this._lists[list].forEach(record => { if (!record.id) { recordsWithoutId.add(record); } else { recordsById.set(record.id, record); } }); this._cached[list] = Array.from(recordsById.values()).concat( Array.from(recordsWithoutId) ); } return this._cached[list]; } /** * Reinitializes result entries for a given result type. * * @param {String} type The result type. * @return {SyncResultObject} */ reset(type) { this._lists[type] = []; delete this._cached[type]; return this; } toObject() { // Only used in tests. return { ok: this.ok, lastModified: this.lastModified, errors: this.errors, created: this.created, updated: this.updated, deleted: this.deleted, skipped: this.skipped, published: this.published, conflicts: this.conflicts, resolved: this.resolved, }; } } class ServerWasFlushedError extends Error { constructor(clientTimestamp, serverTimestamp, message) { super(message); if (Error.captureStackTrace) { Error.captureStackTrace(this, ServerWasFlushedError); } this.clientTimestamp = clientTimestamp; this.serverTimestamp = serverTimestamp; } } function createUUIDSchema() { return { generate() { return uuid4(); }, validate(id) { return typeof id === "string" && RE_RECORD_ID.test(id); }, }; } function markStatus(record, status) { return Object.assign(Object.assign({}, record), { _status: status }); } function markDeleted(record) { return markStatus(record, "deleted"); } function markSynced(record) { return markStatus(record, "synced"); } /** * Import a remote change into the local database. * * @param {IDBTransactionProxy} transaction The transaction handler. * @param {Object} remote The remote change object to import. * @param {Array<String>} localFields The list of fields that remain local. * @param {String} strategy The {@link Collection.strategy}. * @return {Object} */ function importChange(transaction, remote, localFields, strategy) { const local = transaction.get(remote.id); if (!local) { // Not found locally but remote change is marked as deleted; skip to // avoid recreation. if (remote.deleted) { return { type: "skipped", data: remote }; } const synced = markSynced(remote); transaction.create(synced); return { type: "created", data: synced }; } // Apply remote changes on local record. const synced = Object.assign(Object.assign({}, local), markSynced(remote)); // With pull only, we don't need to compare records since we override them. if (strategy === Collection.strategy.PULL_ONLY) { if (remote.deleted) { transaction.delete(remote.id); return { type: "deleted", data: local }; } transaction.update(synced); return { type: "updated", data: { old: local, new: synced } }; } // With other sync strategies, we detect conflicts, // by comparing local and remote, ignoring local fields. const isIdentical = recordsEqual(local, remote, localFields); // Detect or ignore conflicts if record has also been modified locally. if (local._status !== "synced") { // Locally deleted, unsynced: scheduled for remote deletion. if (local._status === "deleted") { return { type: "skipped", data: local }; } if (isIdentical) { // If records are identical, import anyway, so we bump the // local last_modified value from the server and set record // status to "synced". transaction.update(synced); return { type: "updated", data: { old: local, new: synced } }; } if ( local.last_modified !== undefined && local.last_modified === remote.last_modified ) { // If our local version has the same last_modified as the remote // one, this represents an object that corresponds to a resolved // conflict. Our local version represents the final output, so // we keep that one. (No transaction operation to do.) // But if our last_modified is undefined, // that means we've created the same object locally as one on // the server, which *must* be a conflict. return { type: "void" }; } return { type: "conflicts", data: { type: "incoming", local: local, remote: remote }, }; } // Local record was synced. if (remote.deleted) { transaction.delete(remote.id); return { type: "deleted", data: local }; } // Import locally. transaction.update(synced); // if identical, simply exclude it from all SyncResultObject lists if (isIdentical) { return { type: "void" }; } return { type: "updated", data: { old: local, new: synced } }; } /** * Abstracts a collection of records stored in the local database, providing * CRUD operations and synchronization helpers. */ class Collection { constructor(bucket, name, kinto, options = {}) { this._bucket = bucket; this._name = name; this._lastModified = null; const DBAdapter = options.adapter || IDB; if (!DBAdapter) { throw new Error("No adapter provided"); } const db = new DBAdapter(`${bucket}/${name}`, options.adapterOptions); if (!(db instanceof BaseAdapter)) { throw new Error("Unsupported adapter."); } // public properties this.db = db; /** * The KintoBase instance. * @type {KintoBase} */ this.kinto = kinto; /** * The event emitter instance. * @type {EventEmitter} */ this.events = options.events || new events.EventEmitter(); /** * The IdSchema instance. * @type {Object} */ this.idSchema = this._validateIdSchema(options.idSchema); /** * The list of remote transformers. * @type {Array} */ this.remoteTransformers = this._validateRemoteTransformers( options.remoteTransformers ); /** * The list of hooks. * @type {Object} */ this.hooks = this._validateHooks(options.hooks); /** * The list of fields names that will remain local. * @type {Array} */ this.localFields = options.localFields || []; } /** * The HTTP client. * @type {KintoClient} */ get api() { return this.kinto.api; } /** * The collection name. * @type {String} */ get name() { return this._name; } /** * The bucket name. * @type {String} */ get bucket() { return this._bucket; } /** * The last modified timestamp. * @type {Number} */ get lastModified() { return this._lastModified; } /** * Synchronization strategies. Available strategies are: * * - `MANUAL`: Conflicts will be reported in a dedicated array. * - `SERVER_WINS`: Conflicts are resolved using remote data. * - `CLIENT_WINS`: Conflicts are resolved using local data. * * @type {Object} */ static get strategy() { return { CLIENT_WINS: "client_wins", SERVER_WINS: "server_wins", PULL_ONLY: "pull_only", MANUAL: "manual", }; } /** * Validates an idSchema. * * @param {Object|undefined} idSchema * @return {Object} */ _validateIdSchema(idSchema) { if (typeof idSchema === "undefined") { return createUUIDSchema(); } if (typeof idSchema !== "object") { throw new Error("idSchema must be an object."); } else if (typeof idSchema.generate !== "function") { throw new Error("idSchema must provide a generate function."); } else if (typeof idSchema.validate !== "function") { throw new Error("idSchema must provide a validate function."); } return idSchema; } /** * Validates a list of remote transformers. * * @param {Array|undefined} remoteTransformers * @return {Array} */ _validateRemoteTransformers(remoteTransformers) { if (typeof remoteTransformers === "undefined") { return []; } if (!Array.isArray(remoteTransformers)) { throw new Error("remoteTransformers should be an array."); } return remoteTransformers.map(transformer => { if (typeof transformer !== "object") { throw new Error("A transformer must be an object."); } else if (typeof transformer.encode !== "function") { throw new Error("A transformer must provide an encode function."); } else if (typeof transformer.decode !== "function") { throw new Error("A transformer must provide a decode function."); } return transformer; }); } /** * Validate the passed hook is correct. * * @param {Array|undefined} hook. * @return {Array} **/ _validateHook(hook) { if (!Array.isArray(hook)) { throw new Error("A hook definition should be an array of functions."); } return hook.map(fn => { if (typeof fn !== "function") { throw new Error("A hook definition should be an array of functions."); } return fn; }); } /** * Validates a list of hooks. * * @param {Object|undefined} hooks * @return {Object} */ _validateHooks(hooks) { if (typeof hooks === "undefined") { return {}; } if (Array.isArray(hooks)) { throw new Error("hooks should be an object, not an array."); } if (typeof hooks !== "object") { throw new Error("hooks should be an object."); } const validatedHooks = {}; for (const hook in hooks) { if (!AVAILABLE_HOOKS.includes(hook)) { throw new Error( "The hook should be one of " + AVAILABLE_HOOKS.join(", ") ); } validatedHooks[hook] = this._validateHook(hooks[hook]); } return validatedHooks; } /** * Deletes every records in the current collection and marks the collection as * never synced. * * @return {Promise} */ async clear() { await this.db.clear(); await this.db.saveMetadata(null); await this.db.saveLastModified(null); return { data: [], permissions: {} }; } /** * Encodes a record. * * @param {String} type Either "remote" or "local". * @param {Object} record The record object to encode. * @return {Promise} */ _encodeRecord(type, record) { const transformers = type === "remote" ? this.remoteTransformers : []; if (!transformers.length) { return Promise.resolve(record); } return waterfall( transformers.map(transformer => { return record => transformer.encode(record); }), record ); } /** * Decodes a record. * * @param {String} type Either "remote" or "local". * @param {Object} record The record object to decode. * @return {Promise} */ _decodeRecord(type, record) { const transformers = type === "remote" ? this.remoteTransformers : []; if (!transformers.length) { return Promise.resolve(record); } return waterfall( transformers.reverse().map(transformer => { return record => transformer.decode(record); }), record ); } /** * Adds a record to the local database, asserting that none * already exist with this ID. * * Note: If either the `useRecordId` or `synced` options are true, then the * record object must contain the id field to be validated. If none of these * options are true, an id is generated using the current IdSchema; in this * case, the record passed must not have an id. * * Options: * - {Boolean} synced Sets record status to "synced" (default: `false`). * - {Boolean} useRecordId Forces the `id` field from the record to be used, * instead of one that is generated automatically * (default: `false`). * * @param {Object} record * @param {Object} options * @return {Promise} */ create( record, options = { useRecordId: false, synced: false, } ) { // Validate the record and its ID (if any), even though this // validation is also done in the CollectionTransaction method, // because we need to pass the ID to preloadIds. const reject = msg => Promise.reject(new Error(msg)); if (typeof record !== "object") { return reject("Record is not an object."); } if ( (options.synced || options.useRecordId) && !Object.prototype.hasOwnProperty.call(record, "id") ) { return reject( "Missing required Id; synced and useRecordId options require one" ); } if ( !options.synced && !options.useRecordId && Object.prototype.hasOwnProperty.call(record, "id") ) { return reject("Extraneous Id; can't create a record having one set."); } const newRecord = Object.assign(Object.assign({}, record), { id: options.synced || options.useRecordId ? record.id : this.idSchema.generate(record), _status: options.synced ? "synced" : "created", }); if (!this.idSchema.validate(newRecord.id)) { return reject(`Invalid Id: ${newRecord.id}`); } return this.execute(txn => txn.create(newRecord), { preloadIds: [newRecord.id], }).catch(err => { if (options.useRecordId) { throw new Error( "Couldn't create record. It may have been virtually deleted." ); } throw err; }); } /** * Like {@link CollectionTransaction#update}, but wrapped in its own transaction. * * Options: * - {Boolean} synced: Sets record status to "synced" (default: false) * - {Boolean} patch: Extends the existing record instead of overwriting it * (default: false) * * @param {Object} record * @param {Object} options * @return {Promise} */ update( record, options = { synced: false, patch: false, } ) { // Validate the record and its ID, even though this validation is // also done in the CollectionTransaction method, because we need // to pass the ID to preloadIds. if (typeof record !== "object") { return Promise.reject(new Error("Record is not an object.")); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { return Promise.reject(new Error("Cannot update a record missing id.")); } if (!this.idSchema.validate(record.id)) { return Promise.reject(new Error(`Invalid Id: ${record.id}`)); } return this.execute( txn => { var _a, _b; return txn.update(record, { synced: ((_a = options.synced), _a !== null && _a !== void 0 ? _a : false), patch: ((_b = options.patch), _b !== null && _b !== void 0 ? _b : false), }); }, { preloadIds: [record.id], } ); } /** * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction. * * @param {Object} record * @return {Promise} */ upsert(record) { // Validate the record and its ID, even though this validation is // also done in the CollectionTransaction method, because we need // to pass the ID to preloadIds. if (typeof record !== "object") { return Promise.reject(new Error("Record is not an object.")); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { return Promise.reject(new Error("Cannot update a record missing id.")); } if (!this.idSchema.validate(record.id)) { return Promise.reject(new Error(`Invalid Id: ${record.id}`)); } return this.execute(txn => txn.upsert(record), { preloadIds: [record.id], }); } /** * Like {@link CollectionTransaction#get}, but wrapped in its own transaction. * * Options: * - {Boolean} includeDeleted: Include virtually deleted records. * * @param {String} id * @param {Object} options * @return {Promise} */ get(id, options = { includeDeleted: false }) { return this.execute(txn => txn.get(id, options), { preloadIds: [id] }); } /** * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction. * * @param {String} id * @return {Promise} */ getAny(id) { return this.execute(txn => txn.getAny(id), { preloadIds: [id] }); } /** * Same as {@link Collection#delete}, but wrapped in its own transaction. * * Options: * - {Boolean} virtual: When set to `true`, doesn't actually delete the record, * update its `_status` attribute to `deleted` instead (default: true) * * @param {String} id The record's Id. * @param {Object} options The options object. * @return {Promise} */ delete(id, options = { virtual: true }) { return this.execute( transaction => { return transaction.delete(id, options); }, { preloadIds: [id] } ); } /** * Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter. * * @return {Promise} */ async deleteAll() { const { data } = await this.list({}, { includeDeleted: false }); const recordIds = data.map(record => record.id); return this.execute( transaction => { return transaction.deleteAll(recordIds); }, { preloadIds: recordIds } ); } /** * The same as {@link CollectionTransaction#deleteAny}, but wrapped * in its own transaction. * * @param {String} id The record's Id. * @return {Promise} */ deleteAny(id) { return this.execute(txn => txn.deleteAny(id), { preloadIds: [id] }); } /** * Lists records from the local database. * * Params: * - {Object} filters Filter the results (default: `{}`). * - {String} order The order to apply (default: `-last_modified`). * * Options: * - {Boolean} includeDeleted: Include virtually deleted records. * * @param {Object} params The filters and order to apply to the results. * @param {Object} options The options object. * @return {Promise} */ async list(params = {}, options = { includeDeleted: false }) { params = Object.assign({ order: "-last_modified", filters: {} }, params); const results = await this.db.list(params); let data = results; if (!options.includeDeleted) { data = results.filter(record => record._status !== "deleted"); } return { data, permissions: {} }; } /** * Imports remote changes into the local database. * This method is in charge of detecting the conflicts, and resolve them * according to the specified strategy. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Array} decodedChanges The list of changes to import in the local database. * @param {String} strategy The {@link Collection.strategy} (default: MANUAL) * @return {Promise} */ async importChanges( syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL ) { // Retrieve records matching change ids. try { for (let i = 0; i < decodedChanges.length; i += IMPORT_CHUNK_SIZE) { const slice = decodedChanges.slice(i, i + IMPORT_CHUNK_SIZE); const { imports, resolved } = await this.db.execute( transaction => { const imports = slice.map(remote => { // Store remote change into local database. return importChange( transaction, remote, this.localFields, strategy ); }); const conflicts = imports .filter(i => i.type === "conflicts") .map(i => i.data); const resolved = this._handleConflicts( transaction, conflicts, strategy ); return { imports, resolved }; }, { preload: slice.map(record => record.id) } ); // Lists of created/updated/deleted records imports.forEach(({ type, data }) => syncResultObject.add(type, data)); // Automatically resolved conflicts (if not manual) if (resolved.length > 0) { syncResultObject.reset("conflicts").add("resolved", resolved); } } } catch (err) { const data = { type: "incoming", message: err.message, stack: err.stack, }; // XXX one error of the whole transaction instead of per atomic op syncResultObject.add("errors", data); } return syncResultObject; } /** * Imports the responses of pushed changes into the local database. * Basically it stores the timestamp assigned by the server into the local * database. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Array} toApplyLocally The list of changes to import in the local database. * @param {Array} conflicts The list of conflicts that have to be resolved. * @param {String} strategy The {@link Collection.strategy}. * @return {Promise} */ async _applyPushedResults( syncResultObject, toApplyLocally, conflicts, strategy = Collection.strategy.MANUAL ) { const toDeleteLocally = toApplyLocally.filter(r => r.deleted); const toUpdateLocally = toApplyLocally.filter(r => !r.deleted); const { published, resolved } = await this.db.execute(transaction => { const updated = toUpdateLocally.map(record => { const synced = markSynced(record); transaction.update(synced); return synced; }); const deleted = toDeleteLocally.map(record => { transaction.delete(record.id); // Amend result data with the deleted attribute set return { id: record.id, deleted: true }; }); const published = updated.concat(deleted); // Handle conflicts, if any const resolved = this._handleConflicts( transaction, conflicts, strategy ); return { published, resolved }; }); syncResultObject.add("published", published); if (resolved.length > 0) { syncResultObject .reset("conflicts") .reset("resolved") .add("resolved", resolved); } return syncResultObject; } /** * Handles synchronization conflicts according to specified strategy. * * @param {SyncResultObject} result The sync result object. * @param {String} strategy The {@link Collection.strategy}. * @return {Promise<Array<Object>>} The resolved conflicts, as an * array of {accepted, rejected} objects */ _handleConflicts(transaction, conflicts, strategy) { if (strategy === Collection.strategy.MANUAL) { return []; } return conflicts.map(conflict => { const resolution = strategy === Collection.strategy.CLIENT_WINS ? conflict.local : conflict.remote; const rejected = strategy === Collection.strategy.CLIENT_WINS ? conflict.remote : conflict.local; let accepted, status, id; if (resolution === null) { // We "resolved" with the server-side deletion. Delete locally. // This only happens during SERVER_WINS because the local // version of a record can never be null. // We can get "null" from the remote side if we got a conflict // and there is no remote version available; see kinto-http.js // batch.js:aggregate. transaction.delete(conflict.local.id); accepted = null; // The record was deleted, but that status is "synced" with // the server, so we don't need to push the change. status = "synced"; id = conflict.local.id; } else { const updated = this._resolveRaw(conflict, resolution); transaction.update(updated); accepted = updated; status = updated._status; id = updated.id; } return { rejected, accepted, id, _status: status }; }); } /** * Execute a bunch of operations in a transaction. * * This transaction should be atomic -- either all of its operations * will succeed, or none will. * * The argument to this function is itself a function which will be * called with a {@link CollectionTransaction}. Collection methods * are available on this transaction, but instead of returning * promises, they are synchronous. execute() returns a Promise whose * value will be the return value of the provided function. * * Most operations will require access to the record itself, which * must be preloaded by passing its ID in the preloadIds option. * * Options: * - {Array} preloadIds: list of IDs to fetch at the beginning of * the transaction * * @return {Promise} Resolves with the result of the given function * when the transaction commits. */ execute(doOperations, { preloadIds = [] } = {}) { for (const id of preloadIds) { if (!this.idSchema.validate(id)) { return Promise.reject(Error(`Invalid Id: ${id}`)); } } return this.db.execute( transaction => { const txn = new CollectionTransaction(this, transaction); const result = doOperations(txn); txn.emitEvents(); return result; }, { preload: preloadIds } ); } /** * Resets the local records as if they were never synced; existing records are * marked as newly created, deleted records are dropped. * * A next call to {@link Collection.sync} will thus republish the whole * content of the local collection to the server. * * @return {Promise} Resolves with the number of processed records. */ async resetSyncStatus() { const unsynced = await this.list( { filters: { _status: ["deleted", "synced"] }, order: "" }, { includeDeleted: true } ); await this.db.execute(transaction => { unsynced.data.forEach(record => { if (record._status === "deleted") { // Garbage collect deleted records. transaction.delete(record.id); } else { // Records that were synced become «created». transaction.update( Object.assign(Object.assign({}, record), { last_modified: undefined, _status: "created", }) ); } }); }); this._lastModified = null; await this.db.saveLastModified(null); return unsynced.data.length; } /** * Returns an object containing two lists: * * - `toDelete`: unsynced deleted records we can safely delete; * - `toSync`: local updates to send to the server. * * @return {Promise} */ async gatherLocalChanges() { const unsynced = await this.list({ filters: { _status: ["created", "updated"] }, order: "", }); const deleted = await this.list( { filters: { _status: "deleted" }, order: "" }, { includeDeleted: true } ); return await Promise.all( unsynced.data .concat(deleted.data) .map(this._encodeRecord.bind(this, "remote")) ); } /** * Fetch remote changes, import them to the local database, and handle * conflicts according to `options.strategy`. Then, updates the passed * {@link SyncResultObject} with import results. * * Options: * - {String} strategy: The selected sync strategy. * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter. * - {Array<String>} exclude: A list of record ids to exclude from pull. * - {Object} headers: The HTTP headers to use in the request. * - {int} retry: The number of retries to do if the HTTP request fails. * - {int} lastModified: The timestamp to use in `?_since` query. * * @param {KintoClient.Collection} client Kinto client Collection instance. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Object} options The options object. * @return {Promise} */ async pullChanges(client, syncResultObject, options = {}) { if (!syncResultObject.ok) { return syncResultObject; } const since = this.lastModified ? this.lastModified : await this.db.getLastModified(); options = Object.assign( { strategy: Collection.strategy.MANUAL, lastModified: since, headers: {}, }, options ); // Optionally ignore some records when pulling for changes. // (avoid redownloading our own changes on last step of #sync()) let filters; if (options.exclude) { // Limit the list of excluded records to the first 50 records in order // to remain under de-facto URL size limit (~2000 chars). // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers/417184#417184 const exclude_id = options.exclude .slice(0, 50) .map(r => r.id) .join(","); filters = { exclude_id }; } if (options.expectedTimestamp) { filters = Object.assign(Object.assign({}, filters), { _expected: options.expectedTimestamp, }); } // First fetch remote changes from the server const { data, last_modified } = await client.listRecords({ // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356) since: options.lastModified ? `${options.lastModified}` : undefined, headers: options.headers, retry: options.retry, // Fetch every page by default (FIXME: option to limit pages, see #277) pages: Infinity, filters, }); // last_modified is the ETag header value (string). // For retro-compatibility with first kinto.js versions // parse it to integer. const unquoted = last_modified ? parseInt(last_modified, 10) : undefined; // Check if server was flushed. // This is relevant for the Kinto demo server // (and thus for many new comers). const localSynced = options.lastModified; const serverChanged = unquoted && unquoted > options.lastModified; const emptyCollection = data.length === 0; if (!options.exclude && localSynced && serverChanged && emptyCollection) { const e = new ServerWasFlushedError( localSynced, unquoted, "Server has been flushed. Client Side Timestamp: " + localSynced + " Server Side Timestamp: " + unquoted ); throw e; } // Atomic updates are not sensible here because unquoted is not // computed as a function of syncResultObject.lastModified. // eslint-disable-next-line require-atomic-updates syncResultObject.lastModified = unquoted; // Decode incoming changes. const decodedChanges = await Promise.all( data.map(change => { return this._decodeRecord("remote", change); }) ); // Hook receives decoded records. const payload = { lastModified: unquoted, changes: decodedChanges }; const afterHooks = await this.applyHook("incoming-changes", payload); // No change, nothing to import. if (afterHooks.changes.length > 0) { // Reflect these changes locally await this.importChanges( syncResultObject, afterHooks.changes, options.strategy ); } return syncResultObject; } applyHook(hookName, payload) { if (typeof this.hooks[hookName] === "undefined") { return Promise.resolve(payload); } return waterfall( this.hooks[hookName].map(hook => { return record => { const result = hook(payload, this); const resultThenable = result && typeof result.then === "function"; const resultChanges = result && Object.prototype.hasOwnProperty.call(result, "changes"); if (!(resultThenable || resultChanges)) { throw new Error( `Invalid return value for hook: ${JSON.stringify( result )} has no 'then()' or 'changes' properties` ); } return result; }; }), payload ); } /** * Publish local changes to the remote server and updates the passed * {@link SyncResultObject} with publication results. * * Options: * - {String} strategy: The selected sync strategy. * - {Object} headers: The HTTP headers to use in the request. * - {int} retry: The number of retries to do if the HTTP request fails. * * @param {KintoClient.Collection} client Kinto client Collection instance. * @param {SyncResultObject} syncResultObject The sync result object. * @param {Object} changes The change object. * @param {Array} changes.toDelete The list of records to delete. * @param {Array} changes.toSync The list of records to create/update. * @param {Object} options The options object. * @return {Promise} */ async pushChanges(client, changes, syncResultObject, options = {}) { if (!syncResultObject.ok) { return syncResultObject; } // FIXME: replacing `undefined` with Collection.strategy.CLIENT_WINS breaks tests const safe = !options.strategy || options.strategy !== undefined; const toDelete = changes.filter(r => r._status === "deleted"); const toSync = changes.filter(r => r._status != "deleted"); // Perform a batch request with every changes. const synced = await client.batch( batch => { toDelete.forEach(r => { // never published locally deleted records should not be pusblished if (r.last_modified) { batch.deleteRecord(r); } }); toSync.forEach(r => { // Clean local fields (like _status) before sending to server. const published = this.cleanLocalFields(r); if (r._status === "created") { batch.createRecord(published); } else { batch.updateRecord(published); } }); }, { headers: options.headers, retry: options.retry, safe, aggregate: true, } ); // Store outgoing errors into sync result object syncResultObject.add( "errors", synced.errors.map(e => Object.assign(Object.assign({}, e), { type: "outgoing" }) ) ); // Store outgoing conflicts into sync result object const conflicts = []; for (const { type, local, remote } of synced.conflicts) { // Note: we ensure that local data are actually available, as they may // be missing in the case of a published deletion. const safeLocal = (local && local.data) || { id: remote.id }; const realLocal = await this._decodeRecord("remote", safeLocal); // We can get "null" from the remote side if we got a conflict // and there is no remote version available; see kinto-http.js // batch.js:aggregate. const realRemote = remote && (await this._decodeRecord("remote", remote)); const conflict = { type, local: realLocal, remote: realRemote }; conflicts.push(conflict); } syncResultObject.add("conflicts", conflicts); // Records that must be deleted are either deletions that were pushed // to server (published) or deleted records that were never pushed (skipped). const missingRemotely = synced.skipped.map(r => Object.assign(Object.assign({}, r), { deleted: true }) ); // For created and updated records, the last_modified coming from server // will be stored locally. // Reflect publication results locally using the response from // the batch request. const published = synced.published.map(c => c.data); const toApplyLocally = published.concat(missingRemotely); // Apply the decode transformers, if any const decoded = await Promise.all( toApplyLocally.map(record => { return this._decodeRecord("remote", record); }) ); // We have to update the local records with the responses of the server // (eg. last_modified values etc.). if (decoded.length > 0 || conflicts.length > 0) { await this._applyPushedResults( syncResultObject, decoded, conflicts, options.strategy ); } return syncResultObject; } /** * Return a copy of the specified record without the local fields. * * @param {Object} record A record with potential local fields. * @return {Object} */ cleanLocalFields(record) { const localKeys = [...RECORD_FIELDS_TO_CLEAN, ...this.localFields]; return omitKeys(record, localKeys); } /** * Resolves a conflict, updating local record according to proposed * resolution — keeping remote record `last_modified` value as a reference for * further batch sending. * * @param {Object} conflict The conflict object. * @param {Object} resolution The proposed record. * @return {Promise} */ resolve(conflict, resolution) { return this.db.execute(transaction => { const updated = this._resolveRaw(conflict, resolution); transaction.update(updated); return { data: updated, permissions: {} }; }); } /** * @private */ _resolveRaw(conflict, resolution) { const resolved = Object.assign(Object.assign({}, resolution), { // Ensure local record has the latest authoritative timestamp last_modified: conflict.remote && conflict.remote.last_modified, }); // If the resolution object is strictly equal to the // remote record, then we can mark it as synced locally. // Otherwise, mark it as updated (so that the resolution is pushed). const synced = deepEqual(resolved, conflict.remote); return markStatus(resolved, synced ? "synced" : "updated"); } /** * Synchronize remote and local data. The promise will resolve with a * {@link SyncResultObject}, though will reject: * * - if the server is currently backed off; * - if the server has been detected flushed. * * Options: * - {Object} headers: HTTP headers to attach to outgoing requests. * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter. * - {Number} retry: Number of retries when server fails to process the request (default: 1). * - {Collection.strategy} strategy: See {@link Collection.strategy}. * - {Boolean} ignoreBackoff: Force synchronization even if server is currently * backed off. * - {String} bucket: The remove bucket id to use (default: null) * - {String} collection: The remove collection id to use (default: null) * - {String} remote The remote Kinto server endpoint to use (default: null). * * @param {Object} options Options. * @return {Promise} * @throws {Error} If an invalid remote option is passed. */ async sync( options = { strategy: Collection.strategy.MANUAL, headers: {}, retry: 1, ignoreBackoff: false, bucket: null, collection: null, remote: null, expectedTimestamp: null, } ) { options = Object.assign(Object.assign({}, options), { bucket: options.bucket || this.bucket, collection: options.collection || this.name, }); const previousRemote = this.api.remote; if (options.remote) { // Note: setting the remote ensures it's valid, throws when invalid. this.api.remote = options.remote; } if (!options.ignoreBackoff && this.api.backoff > 0) { const seconds = Math.ceil(this.api.backoff / 1000); return Promise.reject( new Error( `Server is asking clients to back off; retry in ${seconds}s or use the ignoreBackoff option.` ) ); } const client = this.api .bucket(options.bucket) .collection(options.collection); const result = new SyncResultObject(); try { // Fetch collection metadata. await this.pullMetadata(client, options); // Fetch last changes from the server. await this.pullChanges(client, result, options); const { lastModified } = result; if (options.strategy !== Collection.strategy.PULL_ONLY) { // Fetch local changes const toSync = await this.gatherLocalChanges(); // Publish local changes and pull local resolutions await this.pushChanges(client, toSync, result, options); // Publish local resolution of push conflicts to server (on CLIENT_WINS) const resolvedUnsynced = result.resolved.filter( r => r._status !== "synced" ); if (resolvedUnsynced.length > 0) { const resolvedEncoded = await Promise.all( resolvedUnsynced.map(resolution => { let record = resolution.accepted; if (record === null) { record = { id: resolution.id, _status: resolution._status }; } return this._encodeRecord("remote", record); }) ); await this.pushChanges(client, resolvedEncoded, result, options); } // Perform a last pull to catch changes that occured after the last pull, // while local changes were pushed. Do not do it nothing was pushed. if (result.published.length > 0) { // Avoid redownloading our own changes during the last pull. const pullOpts = Object.assign(Object.assign({}, options), { lastModified, exclude: result.published, }); await this.pullChanges(client, result, pullOpts); } } // Don't persist lastModified value if any conflict or error occured if (result.ok) { // No conflict occured, persist collection's lastModified value this._lastModified = await this.db.saveLastModified( result.lastModified ); } } catch (e) { this.events.emit( "sync:error", Object.assign(Object.assign({}, options), { error: e }) ); throw e; } finally { // Ensure API default remote is reverted if a custom one's been used this.api.remote = previousRemote; } this.events.emit( "sync:success", Object.assign(Object.assign({}, options), { result }) ); return result; } /** * Load a list of records already synced with the remote server. * * The local records which are unsynced or whose timestamp is either missing * or superior to those being loaded will be ignored. * * @deprecated Use {@link importBulk} instead. * @param {Array} records The previously exported list of records to load. * @return {Promise} with the effectively imported records. */ async loadDump(records) { return this.importBulk(records); } /** * Load a list of records already synced with the remote server. * * The local records which are unsynced or whose timestamp is either missing * or superior to those being loaded will be ignored. * * @param {Array} records The previously exported list of records to load. * @return {Promise} with the effectively imported records. */ async importBulk(records) { if (!Array.isArray(records)) { throw new Error("Records is not an array."); } for (const record of records) { if ( !Object.prototype.hasOwnProperty.call(record, "id") || !this.idSchema.validate(record.id) ) { throw new Error("Record has invalid ID: " + JSON.stringify(record)); } if (!record.last_modified) { throw new Error( "Record has no last_modified value: " + JSON.stringify(record) ); } } // Fetch all existing records from local database, // and skip those who are newer or not marked as synced. // XXX filter by status / ids in records const { data } = await this.list({}, { includeDeleted: true }); const existingById = data.reduce((acc, record) => { acc[record.id] = record; return acc; }, {}); const newRecords = records.filter(record => { const localRecord = existingById[record.id]; const shouldKeep = // No local record with this id. localRecord === undefined || // Or local record is synced (localRecord._status === "synced" && // And was synced from server localRecord.last_modified !== undefined && // And is older than imported one. record.last_modified > localRecord.last_modified); return shouldKeep; }); return await this.db.importBulk(newRecords.map(markSynced)); } async pullMetadata(client, options = {}) { const { expectedTimestamp, headers } = options; const query = expectedTimestamp ? { query: { _expected: expectedTimestamp.toString() } } : undefined; const metadata = await client.getData( Object.assign(Object.assign({}, query), { headers }) ); return this.db.saveMetadata(metadata); } async metadata() { return this.db.getMetadata(); } } /** * A Collection-oriented wrapper for an adapter's transaction. * * This defines the high-level functions available on a collection. * The collection itself offers functions of the same name. These will * perform just one operation in its own transaction. */ class CollectionTransaction { constructor(collection, adapterTransaction) { this.collection = collection; this.adapterTransaction = adapterTransaction; this._events = []; } _queueEvent(action, payload) { this._events.push({ action, payload }); } /** * Emit queued events, to be called once every transaction operations have * been executed successfully. */ emitEvents() { for (const { action, payload } of this._events) { this.collection.events.emit(action, payload); } if (this._events.length > 0) { const targets = this._events.map(({ action, payload }) => Object.assign({ action }, payload) ); this.collection.events.emit("change", { targets }); } this._events = []; } /** * Retrieve a record by its id from the local database, or * undefined if none exists. * * This will also return virtually deleted records. * * @param {String} id * @return {Object} */ getAny(id) { const record = this.adapterTransaction.get(id); return { data: record, permissions: {} }; } /** * Retrieve a record by its id from the local database. * * Options: * - {Boolean} includeDeleted: Include virtually deleted records. * * @param {String} id * @param {Object} options * @return {Object} */ get(id, options = { includeDeleted: false }) { const res = this.getAny(id); if ( !res.data || (!options.includeDeleted && res.data._status === "deleted") ) { throw new Error(`Record with id=${id} not found.`); } return res; } /** * Deletes a record from the local database. * * Options: * - {Boolean} virtual: When set to `true`, doesn't actually delete the record, * update its `_status` attribute to `deleted` instead (default: true) * * @param {String} id The record's Id. * @param {Object} options The options object. * @return {Object} */ delete(id, options = { virtual: true }) { // Ensure the record actually exists. const existing = this.adapterTransaction.get(id); const alreadyDeleted = existing && existing._status === "deleted"; if (!existing || (alreadyDeleted && options.virtual)) { throw new Error(`Record with id=${id} not found.`); } // Virtual updates status. if (options.virtual) { this.adapterTransaction.update(markDeleted(existing)); } else { // Delete for real. this.adapterTransaction.delete(id); } this._queueEvent("delete", { data: existing }); return { data: existing, permissions: {} }; } /** * Soft delete all records from the local database. * * @param {Array} ids Array of non-deleted Record Ids. * @return {Object} */ deleteAll(ids) { const existingRecords = []; ids.forEach(id => { existingRecords.push(this.adapterTransaction.get(id)); this.delete(id); }); this._queueEvent("deleteAll", { data: existingRecords }); return { data: existingRecords, permissions: {} }; } /** * Deletes a record from the local database, if any exists. * Otherwise, do nothing. * * @param {String} id The record's Id. * @return {Object} */ deleteAny(id) { const existing = this.adapterTransaction.get(id); if (existing) { this.adapterTransaction.update(markDeleted(existing)); this._queueEvent("delete", { data: existing }); } return { data: Object.assign({ id }, existing), deleted: !!existing, permissions: {}, }; } /** * Adds a record to the local database, asserting that none * already exist with this ID. * * @param {Object} record, which must contain an ID * @return {Object} */ create(record) { if (typeof record !== "object") { throw new Error("Record is not an object."); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { throw new Error("Cannot create a record missing id"); } if (!this.collection.idSchema.validate(record.id)) { throw new Error(`Invalid Id: ${record.id}`); } this.adapterTransaction.create(record); this._queueEvent("create", { data: record }); return { data: record, permissions: {} }; } /** * Updates a record from the local database. * * Options: * - {Boolean} synced: Sets record status to "synced" (default: false) * - {Boolean} patch: Extends the existing record instead of overwriting it * (default: false) * * @param {Object} record * @param {Object} options * @return {Object} */ update( record, options = { synced: false, patch: false, } ) { if (typeof record !== "object") { throw new Error("Record is not an object."); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { throw new Error("Cannot update a record missing id."); } if (!this.collection.idSchema.validate(record.id)) { throw new Error(`Invalid Id: ${record.id}`); } const oldRecord = this.adapterTransaction.get(record.id); if (!oldRecord) { throw new Error(`Record with id=${record.id} not found.`); } const newRecord = options.patch ? Object.assign(Object.assign({}, oldRecord), record) : record; const updated = this._updateRaw(oldRecord, newRecord, options); this.adapterTransaction.update(updated); this._queueEvent("update", { data: updated, oldRecord }); return { data: updated, oldRecord, permissions: {} }; } /** * Lower-level primitive for updating a record while respecting * _status and last_modified. * * @param {Object} oldRecord: the record retrieved from the DB * @param {Object} newRecord: the record to replace it with * @return {Object} */ _updateRaw(oldRecord, newRecord, { synced = false } = {}) { const updated = Object.assign({}, newRecord); // Make sure to never loose the existing timestamp. if (oldRecord && oldRecord.last_modified && !updated.last_modified) { updated.last_modified = oldRecord.last_modified; } // If only local fields have changed, then keep record as synced. // If status is created, keep record as created. // If status is deleted, mark as updated. const isIdentical = oldRecord && recordsEqual(oldRecord, updated, this.collection.localFields); const keepSynced = isIdentical && oldRecord._status === "synced"; const neverSynced = !oldRecord || (oldRecord && oldRecord._status === "created"); const newStatus = keepSynced || synced ? "synced" : neverSynced ? "created" : "updated"; return markStatus(updated, newStatus); } /** * Upsert a record into the local database. * * This record must have an ID. * * If a record with this ID already exists, it will be replaced. * Otherwise, this record will be inserted. * * @param {Object} record * @return {Object} */ upsert(record) { if (typeof record !== "object") { throw new Error("Record is not an object."); } if (!Object.prototype.hasOwnProperty.call(record, "id")) { throw new Error("Cannot update a record missing id."); } if (!this.collection.idSchema.validate(record.id)) { throw new Error(`Invalid Id: ${record.id}`); } let oldRecord = this.adapterTransaction.get(record.id); const updated = this._updateRaw(oldRecord, record); this.adapterTransaction.update(updated); // Don't return deleted records -- pretend they are gone if (oldRecord && oldRecord._status === "deleted") { oldRecord = undefined; } if (oldRecord) { this._queueEvent("update", { data: updated, oldRecord }); } else { this._queueEvent("create", { data: updated }); } return { data: updated, oldRecord, permissions: {} }; } } const DEFAULT_BUCKET_NAME = "default"; const DEFAULT_REMOTE = "http://localhost:8888/v1"; const DEFAULT_RETRY = 1; /** * KintoBase class. */ class KintoBase { /** * Constructor. * * Options: * - `{String}` `remote` The server URL to use. * - `{String}` `bucket` The collection bucket name. * - `{EventEmitter}` `events` Events handler. * - `{BaseAdapter}` `adapter` The base DB adapter class. * - `{Object}` `adapterOptions` Options given to the adapter. * - `{Object}` `headers` The HTTP headers to use. * - `{Object}` `retry` Number of retries when the server fails to process the request (default: `1`) * - `{String}` `requestMode` The HTTP CORS mode to use. * - `{Number}` `timeout` The requests timeout in ms (default: `5000`). * * @param {Object} options The options object. */ constructor(options = {}) { const defaults = { bucket: DEFAULT_BUCKET_NAME, remote: DEFAULT_REMOTE, retry: DEFAULT_RETRY, }; this._options = Object.assign(Object.assign({}, defaults), options); if (!this._options.adapter) { throw new Error("No adapter provided"); } this._api = null; /** * The event emitter instance. * @type {EventEmitter} */ this.events = this._options.events; } /** * Provides a public access to the base adapter class. Users can create a * custom DB adapter by extending {@link BaseAdapter}. * * @type {Object} */ static get adapters() { return { BaseAdapter: BaseAdapter, }; } /** * Synchronization strategies. Available strategies are: * * - `MANUAL`: Conflicts will be reported in a dedicated array. * - `SERVER_WINS`: Conflicts are resolved using remote data. * - `CLIENT_WINS`: Conflicts are resolved using local data. * * @type {Object} */ static get syncStrategy() { return Collection.strategy; } get ApiClass() { throw new Error("ApiClass() must be implemented by subclasses."); } /** * The kinto HTTP client instance. * @type {KintoClient} */ get api() { const { events, headers, remote, requestMode, retry, timeout, } = this._options; if (!this._api) { this._api = new this.ApiClass(remote, { events, headers, requestMode, retry, timeout, }); } return this._api; } /** * Creates a {@link Collection} instance. The second (optional) parameter * will set collection-level options like e.g. `remoteTransformers`. * * @param {String} collName The collection name. * @param {Object} [options={}] Extra options or override client's options. * @param {Object} [options.idSchema] IdSchema instance (default: UUID) * @param {Object} [options.remoteTransformers] Array<RemoteTransformer> (default: `[]`]) * @param {Object} [options.hooks] Array<Hook> (default: `[]`]) * @param {Object} [options.localFields] Array<Field> (default: `[]`]) * @return {Collection} */ collection(collName, options = {}) { if (!collName) { throw new Error("missing collection name"); } const { bucket, events, adapter, adapterOptions } = Object.assign( Object.assign({}, this._options), options ); const { idSchema, remoteTransformers, hooks, localFields } = options; return new Collection(bucket, collName, this, { events, adapter, adapterOptions, idSchema, remoteTransformers, hooks, localFields, }); } } /* * * 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. */ ChromeUtils.import("resource://gre/modules/Timer.jsm", global); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); XPCOMUtils.defineLazyGlobalGetters(global, ["fetch", "indexedDB"]); ChromeUtils.defineModuleGetter( global, "EventEmitter", "resource://gre/modules/EventEmitter.jsm" ); // Use standalone kinto-http module landed in FFx. ChromeUtils.defineModuleGetter( global, "KintoHttpClient", "resource://services-common/kinto-http-client.js" ); XPCOMUtils.defineLazyGetter(global, "generateUUID", () => { const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService( Ci.nsIUUIDGenerator ); return generateUUID; }); class Kinto extends KintoBase { static get adapters() { return { BaseAdapter, IDB, }; } get ApiClass() { return global.KintoHttpClient; } constructor(options = {}) { const events = {}; global.EventEmitter.decorate(events); const defaults = { adapter: IDB, events, }; super(Object.assign(Object.assign({}, defaults), options)); } collection(collName, options = {}) { const idSchema = { validate(id) { return typeof id === "string" && RE_RECORD_ID.test(id); }, generate() { return global .generateUUID() .toString() .replace(/[{}]/g, ""); }, }; return super.collection(collName, Object.assign({ idSchema }, options)); } } return Kinto; });
Trovare la differenza