Diff
checker
텍스트
텍스트
이미지
문서
Excel
폴더
Legal
Enterprise
데스크톱
요금제
로그인
데스크톱 앱 다운로드
텍스트 비교
두 텍스트 파일의 차이점을 찾아보세요
도구
기록
실시간 편집
변경 없는 행 숨기기
줄바꿈 비활성화
레이아웃
나란히 보기
합쳐 보기
비교 단위
스마트
단어
글자
구문 강조
언어 선택
제외
텍스트 변환
첫 변경으로
수정
Diffchecker Desktop
가장 안전하게 Diffchecker를 사용하는 방법. 데스크톱 앱을 사용하면 비교 데이터가 외부로 전송되지 않습니다!
데스크톱 앱 받기
Kinto.js typescript diff
생성일
6년 전
비교 결과 만료 없음
초기화
내보내기
공유
설명
14 삭제
행
총
삭제
글자
총
삭제
이 기능을 계속 사용하려면 업그레이드해 주세요
Diff
checker
Pro
요금제 보기
755 행
복사
30 추가
행
총
추가
글자
총
추가
이 기능을 계속 사용하려면 업그레이드해 주세요
Diff
checker
Pro
요금제 보기
758 행
복사
/*
/*
*
*
* 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"];
/*
/*
복사
복사됨
복사
복사됨
* 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"
복사
복사됨
복사
복사됨
? (module.exports = factory(
))
? (module.exports = factory(
require("events")
))
: typeof define === "function" && define.amd
: typeof define === "function" && define.amd
복사
복사됨
복사
복사됨
? 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}
*/
*/
복사
복사됨
복사
복사됨
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;
}
}
복사
복사됨
복사
복사됨
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 => {
복사
복사됨
복사
복사됨
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.
복사
복사됨
복사
복사됨
const transaction =
event.target
.transaction;
const transaction =
request
.transaction;
transaction.onabort = event => {
transaction.onabort = event => {
const error =
const error =
복사
복사됨
복사
복사됨
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 => {
복사
복사됨
복사
복사됨
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.
복사
복사됨
복사
복사됨
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.
복사
복사됨
복사
복사됨
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.
복사
복사됨
복사
복사됨
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);
복사
복사됨
복사
복사됨
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) => {
복사
복사됨
복사
복사됨
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.
복사
복사됨
복사
복사됨
abort
(e);
abort
&& abort
(e);
}
}
};
};
// No option to preload records, go straight to `callback`.
// No option to preload records, go straight to `callback`.
복사
복사됨
복사
복사됨
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
복사
복사됨
복사
복사됨
* @param {Object} params
The filters and order to apply to the results.
* @param {Object} params
* @return {Promise}
*/
async list(params = { filters: {} }) {
cons
저장된 비교 결과
원본
파일 열기
/* * * 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; });
수정본
파일 열기
/* * * 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; });
비교하기