kinto-offline-client.sys.mjs (97859B)
1 /* 2 * 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16 /* 17 * This file is generated from kinto.js - do not modify directly. 18 */ 19 20 /* 21 * Version 13.0.0 - 7fbf95d 22 */ 23 24 /** 25 * Base db adapter. 26 * 27 * @abstract 28 */ 29 class BaseAdapter { 30 /** 31 * Deletes every records present in the database. 32 * 33 * @abstract 34 * @return {Promise} 35 */ 36 clear() { 37 throw new Error("Not Implemented."); 38 } 39 /** 40 * Executes a batch of operations within a single transaction. 41 * 42 * @abstract 43 * @param {Function} callback The operation callback. 44 * @param {Object} options The options object. 45 * @return {Promise} 46 */ 47 execute(callback, options = { preload: [] }) { 48 throw new Error("Not Implemented."); 49 } 50 /** 51 * Retrieve a record by its primary key from the database. 52 * 53 * @abstract 54 * @param {String} id The record id. 55 * @return {Promise} 56 */ 57 get(id) { 58 throw new Error("Not Implemented."); 59 } 60 /** 61 * Lists all records from the database. 62 * 63 * @abstract 64 * @param {Object} params The filters and order to apply to the results. 65 * @return {Promise} 66 */ 67 list(params = { filters: {}, order: "" }) { 68 throw new Error("Not Implemented."); 69 } 70 /** 71 * Store the lastModified value. 72 * 73 * @abstract 74 * @param {Number} lastModified 75 * @return {Promise} 76 */ 77 saveLastModified(lastModified) { 78 throw new Error("Not Implemented."); 79 } 80 /** 81 * Retrieve saved lastModified value. 82 * 83 * @abstract 84 * @return {Promise} 85 */ 86 getLastModified() { 87 throw new Error("Not Implemented."); 88 } 89 /** 90 * Load records in bulk that were exported from a server. 91 * 92 * @abstract 93 * @param {Array} records The records to load. 94 * @return {Promise} 95 */ 96 importBulk(records) { 97 throw new Error("Not Implemented."); 98 } 99 /** 100 * Load a dump of records exported from a server. 101 * 102 * @deprecated Use {@link importBulk} instead. 103 * @abstract 104 * @param {Array} records The records to load. 105 * @return {Promise} 106 */ 107 loadDump(records) { 108 throw new Error("Not Implemented."); 109 } 110 saveMetadata(metadata) { 111 throw new Error("Not Implemented."); 112 } 113 getMetadata() { 114 throw new Error("Not Implemented."); 115 } 116 } 117 118 const RE_RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; 119 /** 120 * Checks if a value is undefined. 121 * @param {Any} value 122 * @return {Boolean} 123 */ 124 function _isUndefined(value) { 125 return typeof value === "undefined"; 126 } 127 /** 128 * Sorts records in a list according to a given ordering. 129 * 130 * @param {String} order The ordering, eg. `-last_modified`. 131 * @param {Array} list The collection to order. 132 * @return {Array} 133 */ 134 function sortObjects(order, list) { 135 const hasDash = order[0] === "-"; 136 const field = hasDash ? order.slice(1) : order; 137 const direction = hasDash ? -1 : 1; 138 return list.slice().sort((a, b) => { 139 if (a[field] && _isUndefined(b[field])) { 140 return direction; 141 } 142 if (b[field] && _isUndefined(a[field])) { 143 return -direction; 144 } 145 if (_isUndefined(a[field]) && _isUndefined(b[field])) { 146 return 0; 147 } 148 return a[field] > b[field] ? direction : -direction; 149 }); 150 } 151 /** 152 * Test if a single object matches all given filters. 153 * 154 * @param {Object} filters The filters object. 155 * @param {Object} entry The object to filter. 156 * @return {Boolean} 157 */ 158 function filterObject(filters, entry) { 159 return Object.keys(filters).every(filter => { 160 const value = filters[filter]; 161 if (Array.isArray(value)) { 162 return value.some(candidate => candidate === entry[filter]); 163 } 164 else if (typeof value === "object") { 165 return filterObject(value, entry[filter]); 166 } 167 else if (!Object.prototype.hasOwnProperty.call(entry, filter)) { 168 console.error(`The property ${filter} does not exist`); 169 return false; 170 } 171 return entry[filter] === value; 172 }); 173 } 174 /** 175 * Resolves a list of functions sequentially, which can be sync or async; in 176 * case of async, functions must return a promise. 177 * 178 * @param {Array} fns The list of functions. 179 * @param {Any} init The initial value. 180 * @return {Promise} 181 */ 182 function waterfall(fns, init) { 183 if (!fns.length) { 184 return Promise.resolve(init); 185 } 186 return fns.reduce((promise, nextFn) => { 187 return promise.then(nextFn); 188 }, Promise.resolve(init)); 189 } 190 /** 191 * Simple deep object comparison function. This only supports comparison of 192 * serializable JavaScript objects. 193 * 194 * @param {Object} a The source object. 195 * @param {Object} b The compared object. 196 * @return {Boolean} 197 */ 198 function deepEqual(a, b) { 199 if (a === b) { 200 return true; 201 } 202 if (typeof a !== typeof b) { 203 return false; 204 } 205 if (!(a && typeof a == "object") || !(b && typeof b == "object")) { 206 return false; 207 } 208 if (Object.keys(a).length !== Object.keys(b).length) { 209 return false; 210 } 211 for (const k in a) { 212 if (!deepEqual(a[k], b[k])) { 213 return false; 214 } 215 } 216 return true; 217 } 218 /** 219 * Return an object without the specified keys. 220 * 221 * @param {Object} obj The original object. 222 * @param {Array} keys The list of keys to exclude. 223 * @return {Object} A copy without the specified keys. 224 */ 225 function omitKeys(obj, keys = []) { 226 const result = Object.assign({}, obj); 227 for (const key of keys) { 228 delete result[key]; 229 } 230 return result; 231 } 232 function arrayEqual(a, b) { 233 if (a.length !== b.length) { 234 return false; 235 } 236 for (let i = a.length; i--;) { 237 if (a[i] !== b[i]) { 238 return false; 239 } 240 } 241 return true; 242 } 243 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) { 244 const last = arr.length - 1; 245 return arr.reduce((acc, cv, i) => { 246 if (i === last) { 247 return (acc[cv] = val); 248 } 249 else if (Object.prototype.hasOwnProperty.call(acc, cv)) { 250 return acc[cv]; 251 } 252 else { 253 return (acc[cv] = {}); 254 } 255 }, nestedFiltersObj); 256 } 257 function transformSubObjectFilters(filtersObj) { 258 const transformedFilters = {}; 259 for (const key in filtersObj) { 260 const keysArr = key.split("."); 261 const val = filtersObj[key]; 262 makeNestedObjectFromArr(keysArr, val, transformedFilters); 263 } 264 return transformedFilters; 265 } 266 267 const INDEXED_FIELDS = ["id", "_status", "last_modified"]; 268 /** 269 * Small helper that wraps the opening of an IndexedDB into a Promise. 270 * 271 * @param dbname {String} The database name. 272 * @param version {Integer} Schema version 273 * @param onupgradeneeded {Function} The callback to execute if schema is 274 * missing or different. 275 * @return {Promise<IDBDatabase>} 276 */ 277 async function open(dbname, { version, onupgradeneeded }) { 278 return new Promise((resolve, reject) => { 279 const request = indexedDB.open(dbname, version); 280 request.onupgradeneeded = event => { 281 const db = event.target.result; 282 db.onerror = event => reject(event.target.error); 283 // When an upgrade is needed, a transaction is started. 284 const transaction = event.target.transaction; 285 transaction.onabort = event => { 286 const error = event.target.error || 287 transaction.error || 288 new DOMException("The operation has been aborted", "AbortError"); 289 reject(error); 290 }; 291 // Callback for store creation etc. 292 return onupgradeneeded(event); 293 }; 294 request.onerror = event => { 295 reject(event.target.error); 296 }; 297 request.onsuccess = event => { 298 const db = event.target.result; 299 resolve(db); 300 }; 301 }); 302 } 303 /** 304 * Helper to run the specified callback in a single transaction on the 305 * specified store. 306 * The helper focuses on transaction wrapping into a promise. 307 * 308 * @param db {IDBDatabase} The database instance. 309 * @param name {String} The store name. 310 * @param callback {Function} The piece of code to execute in the transaction. 311 * @param options {Object} Options. 312 * @param options.mode {String} Transaction mode (default: read). 313 * @return {Promise} any value returned by the callback. 314 */ 315 async function execute(db, name, callback, options = {}) { 316 const { mode } = options; 317 return new Promise((resolve, reject) => { 318 // On Safari, calling IDBDatabase.transaction with mode == undefined raises 319 // a TypeError. 320 const transaction = mode 321 ? db.transaction([name], mode) 322 : db.transaction([name]); 323 const store = transaction.objectStore(name); 324 // Let the callback abort this transaction. 325 const abort = e => { 326 transaction.abort(); 327 reject(e); 328 }; 329 // Execute the specified callback **synchronously**. 330 let result; 331 try { 332 result = callback(store, abort); 333 } 334 catch (e) { 335 abort(e); 336 } 337 transaction.onerror = event => reject(event.target.error); 338 transaction.oncomplete = event => resolve(result); 339 transaction.onabort = event => { 340 const error = event.target.error || 341 transaction.error || 342 new DOMException("The operation has been aborted", "AbortError"); 343 reject(error); 344 }; 345 }); 346 } 347 /** 348 * Helper to wrap the deletion of an IndexedDB database into a promise. 349 * 350 * @param dbName {String} the database to delete 351 * @return {Promise} 352 */ 353 async function deleteDatabase(dbName) { 354 return new Promise((resolve, reject) => { 355 const request = indexedDB.deleteDatabase(dbName); 356 request.onsuccess = event => resolve(event.target); 357 request.onerror = event => reject(event.target.error); 358 }); 359 } 360 /** 361 * IDB cursor handlers. 362 * @type {Object} 363 */ 364 const cursorHandlers = { 365 all(filters, done) { 366 const results = []; 367 return event => { 368 const cursor = event.target.result; 369 if (cursor) { 370 const { value } = cursor; 371 if (filterObject(filters, value)) { 372 results.push(value); 373 } 374 cursor.continue(); 375 } 376 else { 377 done(results); 378 } 379 }; 380 }, 381 in(values, filters, done) { 382 const results = []; 383 let i = 0; 384 return function (event) { 385 const cursor = event.target.result; 386 if (!cursor) { 387 done(results); 388 return; 389 } 390 const { key, value } = cursor; 391 // `key` can be an array of two values (see `keyPath` in indices definitions). 392 // `values` can be an array of arrays if we filter using an index whose key path 393 // is an array (eg. `cursorHandlers.in([["bid/cid", 42], ["bid/cid", 43]], ...)`) 394 while (key > values[i]) { 395 // The cursor has passed beyond this key. Check next. 396 ++i; 397 if (i === values.length) { 398 done(results); // There is no next. Stop searching. 399 return; 400 } 401 } 402 const isEqual = Array.isArray(key) 403 ? arrayEqual(key, values[i]) 404 : key === values[i]; 405 if (isEqual) { 406 if (filterObject(filters, value)) { 407 results.push(value); 408 } 409 cursor.continue(); 410 } 411 else { 412 cursor.continue(values[i]); 413 } 414 }; 415 }, 416 }; 417 /** 418 * Creates an IDB request and attach it the appropriate cursor event handler to 419 * perform a list query. 420 * 421 * Multiple matching values are handled by passing an array. 422 * 423 * @param {String} cid The collection id (ie. `{bid}/{cid}`) 424 * @param {IDBStore} store The IDB store. 425 * @param {Object} filters Filter the records by field. 426 * @param {Function} done The operation completion handler. 427 * @return {IDBRequest} 428 */ 429 function createListRequest(cid, store, filters, done) { 430 const filterFields = Object.keys(filters); 431 // If no filters, get all results in one bulk. 432 if (filterFields.length == 0) { 433 const request = store.index("cid").getAll(IDBKeyRange.only(cid)); 434 request.onsuccess = event => done(event.target.result); 435 return request; 436 } 437 // Introspect filters and check if they leverage an indexed field. 438 const indexField = filterFields.find(field => { 439 return INDEXED_FIELDS.includes(field); 440 }); 441 if (!indexField) { 442 // Iterate on all records for this collection (ie. cid) 443 const isSubQuery = Object.keys(filters).some(key => key.includes(".")); // (ie. filters: {"article.title": "hello"}) 444 if (isSubQuery) { 445 const newFilter = transformSubObjectFilters(filters); 446 const request = store.index("cid").openCursor(IDBKeyRange.only(cid)); 447 request.onsuccess = cursorHandlers.all(newFilter, done); 448 return request; 449 } 450 const request = store.index("cid").openCursor(IDBKeyRange.only(cid)); 451 request.onsuccess = cursorHandlers.all(filters, done); 452 return request; 453 } 454 // If `indexField` was used already, don't filter again. 455 const remainingFilters = omitKeys(filters, [indexField]); 456 // value specified in the filter (eg. `filters: { _status: ["created", "updated"] }`) 457 const value = filters[indexField]; 458 // For the "id" field, use the primary key. 459 const indexStore = indexField == "id" ? store : store.index(indexField); 460 // WHERE IN equivalent clause 461 if (Array.isArray(value)) { 462 if (value.length === 0) { 463 return done([]); 464 } 465 const values = value.map(i => [cid, i]).sort(); 466 const range = IDBKeyRange.bound(values[0], values[values.length - 1]); 467 const request = indexStore.openCursor(range); 468 request.onsuccess = cursorHandlers.in(values, remainingFilters, done); 469 return request; 470 } 471 // If no filters on custom attribute, get all results in one bulk. 472 if (remainingFilters.length == 0) { 473 const request = indexStore.getAll(IDBKeyRange.only([cid, value])); 474 request.onsuccess = event => done(event.target.result); 475 return request; 476 } 477 // WHERE field = value clause 478 const request = indexStore.openCursor(IDBKeyRange.only([cid, value])); 479 request.onsuccess = cursorHandlers.all(remainingFilters, done); 480 return request; 481 } 482 class IDBError extends Error { 483 constructor(method, err) { 484 super(`IndexedDB ${method}() ${err.message}`); 485 this.name = err.name; 486 this.stack = err.stack; 487 } 488 } 489 /** 490 * IndexedDB adapter. 491 * 492 * This adapter doesn't support any options. 493 */ 494 class IDB extends BaseAdapter { 495 /* Expose the IDBError class publicly */ 496 static get IDBError() { 497 return IDBError; 498 } 499 /** 500 * Constructor. 501 * 502 * @param {String} cid The key base for this collection (eg. `bid/cid`) 503 * @param {Object} options 504 * @param {String} options.dbName The IndexedDB name (default: `"KintoDB"`) 505 * @param {String} options.migrateOldData Whether old database data should be migrated (default: `false`) 506 */ 507 constructor(cid, options = {}) { 508 super(); 509 this.cid = cid; 510 this.dbName = options.dbName || "KintoDB"; 511 this._options = options; 512 this._db = null; 513 } 514 _handleError(method, err) { 515 throw new IDBError(method, err); 516 } 517 /** 518 * Ensures a connection to the IndexedDB database has been opened. 519 * 520 * @override 521 * @return {Promise} 522 */ 523 async open() { 524 if (this._db) { 525 return this; 526 } 527 // In previous versions, we used to have a database with name `${bid}/${cid}`. 528 // Check if it exists, and migrate data once new schema is in place. 529 // Note: the built-in migrations from IndexedDB can only be used if the 530 // database name does not change. 531 const dataToMigrate = this._options.migrateOldData 532 ? await migrationRequired(this.cid) 533 : null; 534 this._db = await open(this.dbName, { 535 version: 2, 536 onupgradeneeded: event => { 537 const db = event.target.result; 538 if (event.oldVersion < 1) { 539 // Records store 540 const recordsStore = db.createObjectStore("records", { 541 keyPath: ["_cid", "id"], 542 }); 543 // An index to obtain all the records in a collection. 544 recordsStore.createIndex("cid", "_cid"); 545 // Here we create indices for every known field in records by collection. 546 // Local record status ("synced", "created", "updated", "deleted") 547 recordsStore.createIndex("_status", ["_cid", "_status"]); 548 // Last modified field 549 recordsStore.createIndex("last_modified", ["_cid", "last_modified"]); 550 // Timestamps store 551 db.createObjectStore("timestamps", { 552 keyPath: "cid", 553 }); 554 } 555 if (event.oldVersion < 2) { 556 // Collections store 557 db.createObjectStore("collections", { 558 keyPath: "cid", 559 }); 560 } 561 }, 562 }); 563 if (dataToMigrate) { 564 const { records, timestamp } = dataToMigrate; 565 await this.importBulk(records); 566 await this.saveLastModified(timestamp); 567 console.log(`${this.cid}: data was migrated successfully.`); 568 // Delete the old database. 569 await deleteDatabase(this.cid); 570 console.warn(`${this.cid}: old database was deleted.`); 571 } 572 return this; 573 } 574 /** 575 * Closes current connection to the database. 576 * 577 * @override 578 * @return {Promise} 579 */ 580 close() { 581 if (this._db) { 582 this._db.close(); // indexedDB.close is synchronous 583 this._db = null; 584 } 585 return Promise.resolve(); 586 } 587 /** 588 * Returns a transaction and an object store for a store name. 589 * 590 * To determine if a transaction has completed successfully, we should rather 591 * listen to the transaction’s complete event rather than the IDBObjectStore 592 * request’s success event, because the transaction may still fail after the 593 * success event fires. 594 * 595 * @param {String} name Store name 596 * @param {Function} callback to execute 597 * @param {Object} options Options 598 * @param {String} options.mode Transaction mode ("readwrite" or undefined) 599 * @return {Object} 600 */ 601 async prepare(name, callback, options) { 602 await this.open(); 603 await execute(this._db, name, callback, options); 604 } 605 /** 606 * Deletes every records in the current collection. 607 * 608 * @override 609 * @return {Promise} 610 */ 611 async clear() { 612 try { 613 await this.prepare("records", store => { 614 const range = IDBKeyRange.only(this.cid); 615 const request = store.index("cid").openKeyCursor(range); 616 request.onsuccess = event => { 617 const cursor = event.target.result; 618 if (cursor) { 619 store.delete(cursor.primaryKey); 620 cursor.continue(); 621 } 622 }; 623 return request; 624 }, { mode: "readwrite" }); 625 } 626 catch (e) { 627 this._handleError("clear", e); 628 } 629 } 630 /** 631 * Executes the set of synchronous CRUD operations described in the provided 632 * callback within an IndexedDB transaction, for current db store. 633 * 634 * The callback will be provided an object exposing the following synchronous 635 * CRUD operation methods: get, create, update, delete. 636 * 637 * Important note: because limitations in IndexedDB implementations, no 638 * asynchronous code should be performed within the provided callback; the 639 * promise will therefore be rejected if the callback returns a Promise. 640 * 641 * Options: 642 * - {Array} preload: The list of record IDs to fetch and make available to 643 * the transaction object get() method (default: []) 644 * 645 * @example 646 * const db = new IDB("example"); 647 * const result = await db.execute(transaction => { 648 * transaction.create({id: 1, title: "foo"}); 649 * transaction.update({id: 2, title: "bar"}); 650 * transaction.delete(3); 651 * return "foo"; 652 * }); 653 * 654 * @override 655 * @param {Function} callback The operation description callback. 656 * @param {Object} options The options object. 657 * @return {Promise} 658 */ 659 async execute(callback, options = { preload: [] }) { 660 // Transactions in IndexedDB are autocommited when a callback does not 661 // perform any additional operation. 662 // The way Promises are implemented in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=1193394) 663 // prevents using within an opened transaction. 664 // To avoid managing asynchronocity in the specified `callback`, we preload 665 // a list of record in order to execute the `callback` synchronously. 666 // See also: 667 // - http://stackoverflow.com/a/28388805/330911 668 // - http://stackoverflow.com/a/10405196 669 // - https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ 670 let result; 671 await this.prepare("records", (store, abort) => { 672 const runCallback = (preloaded = []) => { 673 // Expose a consistent API for every adapter instead of raw store methods. 674 const proxy = transactionProxy(this, store, preloaded); 675 // The callback is executed synchronously within the same transaction. 676 try { 677 const returned = callback(proxy); 678 if (returned instanceof Promise) { 679 // XXX: investigate how to provide documentation details in error. 680 throw new Error("execute() callback should not return a Promise."); 681 } 682 // Bring to scope that will be returned (once promise awaited). 683 result = returned; 684 } 685 catch (e) { 686 // The callback has thrown an error explicitly. Abort transaction cleanly. 687 abort(e); 688 } 689 }; 690 // No option to preload records, go straight to `callback`. 691 if (!options.preload.length) { 692 return runCallback(); 693 } 694 // Preload specified records using a list request. 695 const filters = { id: options.preload }; 696 createListRequest(this.cid, store, filters, records => { 697 // Store obtained records by id. 698 const preloaded = {}; 699 for (const record of records) { 700 delete record["_cid"]; 701 preloaded[record.id] = record; 702 } 703 runCallback(preloaded); 704 }); 705 }, { mode: "readwrite" }); 706 return result; 707 } 708 /** 709 * Retrieve a record by its primary key from the IndexedDB database. 710 * 711 * @override 712 * @param {String} id The record id. 713 * @return {Promise} 714 */ 715 async get(id) { 716 try { 717 let record; 718 await this.prepare("records", store => { 719 store.get([this.cid, id]).onsuccess = e => (record = e.target.result); 720 }); 721 return record; 722 } 723 catch (e) { 724 this._handleError("get", e); 725 } 726 } 727 /** 728 * Lists all records from the IndexedDB database. 729 * 730 * @override 731 * @param {Object} params The filters and order to apply to the results. 732 * @return {Promise} 733 */ 734 async list(params = { filters: {} }) { 735 const { filters } = params; 736 try { 737 let results = []; 738 await this.prepare("records", store => { 739 createListRequest(this.cid, store, filters, _results => { 740 // we have received all requested records that match the filters, 741 // we now park them within current scope and hide the `_cid` attribute. 742 for (const result of _results) { 743 delete result["_cid"]; 744 } 745 results = _results; 746 }); 747 }); 748 // The resulting list of records is sorted. 749 // XXX: with some efforts, this could be fully implemented using IDB API. 750 return params.order ? sortObjects(params.order, results) : results; 751 } 752 catch (e) { 753 this._handleError("list", e); 754 } 755 } 756 /** 757 * Store the lastModified value into metadata store. 758 * 759 * @override 760 * @param {Number} lastModified 761 * @return {Promise} 762 */ 763 async saveLastModified(lastModified) { 764 const value = parseInt(lastModified, 10) || null; 765 try { 766 await this.prepare("timestamps", store => { 767 if (value === null) { 768 store.delete(this.cid); 769 } 770 else { 771 store.put({ cid: this.cid, value }); 772 } 773 }, { mode: "readwrite" }); 774 return value; 775 } 776 catch (e) { 777 this._handleError("saveLastModified", e); 778 } 779 } 780 /** 781 * Retrieve saved lastModified value. 782 * 783 * @override 784 * @return {Promise} 785 */ 786 async getLastModified() { 787 try { 788 let entry = null; 789 await this.prepare("timestamps", store => { 790 store.get(this.cid).onsuccess = e => (entry = e.target.result); 791 }); 792 return entry ? entry.value : null; 793 } 794 catch (e) { 795 this._handleError("getLastModified", e); 796 } 797 } 798 /** 799 * Load a dump of records exported from a server. 800 * 801 * @deprecated Use {@link importBulk} instead. 802 * @abstract 803 * @param {Array} records The records to load. 804 * @return {Promise} 805 */ 806 async loadDump(records) { 807 return this.importBulk(records); 808 } 809 /** 810 * Load records in bulk that were exported from a server. 811 * 812 * @abstract 813 * @param {Array} records The records to load. 814 * @return {Promise} 815 */ 816 async importBulk(records) { 817 try { 818 await this.execute(transaction => { 819 // Since the put operations are asynchronous, we chain 820 // them together. The last one will be waited for the 821 // `transaction.oncomplete` callback. (see #execute()) 822 let i = 0; 823 putNext(); 824 function putNext() { 825 if (i == records.length) { 826 return; 827 } 828 // On error, `transaction.onerror` is called. 829 transaction.update(records[i]).onsuccess = putNext; 830 ++i; 831 } 832 }); 833 const previousLastModified = await this.getLastModified(); 834 const lastModified = Math.max(...records.map(record => record.last_modified)); 835 if (lastModified > previousLastModified) { 836 await this.saveLastModified(lastModified); 837 } 838 return records; 839 } 840 catch (e) { 841 this._handleError("importBulk", e); 842 } 843 } 844 async saveMetadata(metadata) { 845 try { 846 await this.prepare("collections", store => store.put({ cid: this.cid, metadata }), { mode: "readwrite" }); 847 return metadata; 848 } 849 catch (e) { 850 this._handleError("saveMetadata", e); 851 } 852 } 853 async getMetadata() { 854 try { 855 let entry = null; 856 await this.prepare("collections", store => { 857 store.get(this.cid).onsuccess = e => (entry = e.target.result); 858 }); 859 return entry ? entry.metadata : null; 860 } 861 catch (e) { 862 this._handleError("getMetadata", e); 863 } 864 } 865 } 866 /** 867 * IDB transaction proxy. 868 * 869 * @param {IDB} adapter The call IDB adapter 870 * @param {IDBStore} store The IndexedDB database store. 871 * @param {Array} preloaded The list of records to make available to 872 * get() (default: []). 873 * @return {Object} 874 */ 875 function transactionProxy(adapter, store, preloaded = []) { 876 const _cid = adapter.cid; 877 return { 878 create(record) { 879 store.add(Object.assign(Object.assign({}, record), { _cid })); 880 }, 881 update(record) { 882 return store.put(Object.assign(Object.assign({}, record), { _cid })); 883 }, 884 delete(id) { 885 store.delete([_cid, id]); 886 }, 887 get(id) { 888 return preloaded[id]; 889 }, 890 }; 891 } 892 /** 893 * Up to version 10.X of kinto.js, each collection had its own collection. 894 * The database name was `${bid}/${cid}` (eg. `"blocklists/certificates"`) 895 * and contained only one store with the same name. 896 */ 897 async function migrationRequired(dbName) { 898 let exists = true; 899 const db = await open(dbName, { 900 version: 1, 901 onupgradeneeded: event => { 902 exists = false; 903 }, 904 }); 905 // Check that the DB we're looking at is really a legacy one, 906 // and not some remainder of the open() operation above. 907 exists &= 908 db.objectStoreNames.contains("__meta__") && 909 db.objectStoreNames.contains(dbName); 910 if (!exists) { 911 db.close(); 912 // Testing the existence creates it, so delete it :) 913 await deleteDatabase(dbName); 914 return null; 915 } 916 console.warn(`${dbName}: old IndexedDB database found.`); 917 try { 918 // Scan all records. 919 let records; 920 await execute(db, dbName, store => { 921 store.openCursor().onsuccess = cursorHandlers.all({}, res => (records = res)); 922 }); 923 console.log(`${dbName}: found ${records.length} records.`); 924 // Check if there's a entry for this. 925 let timestamp = null; 926 await execute(db, "__meta__", store => { 927 store.get(`${dbName}-lastModified`).onsuccess = e => { 928 timestamp = e.target.result ? e.target.result.value : null; 929 }; 930 }); 931 // Some previous versions, also used to store the timestamps without prefix. 932 if (!timestamp) { 933 await execute(db, "__meta__", store => { 934 store.get("lastModified").onsuccess = e => { 935 timestamp = e.target.result ? e.target.result.value : null; 936 }; 937 }); 938 } 939 console.log(`${dbName}: ${timestamp ? "found" : "no"} timestamp.`); 940 // Those will be inserted in the new database/schema. 941 return { records, timestamp }; 942 } 943 catch (e) { 944 console.error("Error occured during migration", e); 945 return null; 946 } 947 finally { 948 db.close(); 949 } 950 } 951 952 var uuid4 = {}; 953 954 const RECORD_FIELDS_TO_CLEAN = ["_status"]; 955 const AVAILABLE_HOOKS = ["incoming-changes"]; 956 const IMPORT_CHUNK_SIZE = 200; 957 /** 958 * Compare two records omitting local fields and synchronization 959 * attributes (like _status and last_modified) 960 * @param {Object} a A record to compare. 961 * @param {Object} b A record to compare. 962 * @param {Array} localFields Additional fields to ignore during the comparison 963 * @return {boolean} 964 */ 965 function recordsEqual(a, b, localFields = []) { 966 const fieldsToClean = RECORD_FIELDS_TO_CLEAN.concat(["last_modified"]).concat(localFields); 967 const cleanLocal = r => omitKeys(r, fieldsToClean); 968 return deepEqual(cleanLocal(a), cleanLocal(b)); 969 } 970 /** 971 * Synchronization result object. 972 */ 973 class SyncResultObject { 974 /** 975 * Public constructor. 976 */ 977 constructor() { 978 /** 979 * Current synchronization result status; becomes `false` when conflicts or 980 * errors are registered. 981 * @type {Boolean} 982 */ 983 this.lastModified = null; 984 this._lists = {}; 985 [ 986 "errors", 987 "created", 988 "updated", 989 "deleted", 990 "published", 991 "conflicts", 992 "skipped", 993 "resolved", 994 "void", 995 ].forEach(l => (this._lists[l] = [])); 996 this._cached = {}; 997 } 998 /** 999 * Adds entries for a given result type. 1000 * 1001 * @param {String} type The result type. 1002 * @param {Array} entries The result entries. 1003 * @return {SyncResultObject} 1004 */ 1005 add(type, entries) { 1006 if (!Array.isArray(this._lists[type])) { 1007 console.warn(`Unknown type "${type}"`); 1008 return; 1009 } 1010 if (!Array.isArray(entries)) { 1011 entries = [entries]; 1012 } 1013 this._lists[type] = this._lists[type].concat(entries); 1014 delete this._cached[type]; 1015 return this; 1016 } 1017 get ok() { 1018 return this.errors.length + this.conflicts.length === 0; 1019 } 1020 get errors() { 1021 return this._lists["errors"]; 1022 } 1023 get conflicts() { 1024 return this._lists["conflicts"]; 1025 } 1026 get skipped() { 1027 return this._deduplicate("skipped"); 1028 } 1029 get resolved() { 1030 return this._deduplicate("resolved"); 1031 } 1032 get created() { 1033 return this._deduplicate("created"); 1034 } 1035 get updated() { 1036 return this._deduplicate("updated"); 1037 } 1038 get deleted() { 1039 return this._deduplicate("deleted"); 1040 } 1041 get published() { 1042 return this._deduplicate("published"); 1043 } 1044 _deduplicate(list) { 1045 if (!(list in this._cached)) { 1046 // Deduplicate entries by id. If the values don't have `id` attribute, just 1047 // keep all. 1048 const recordsWithoutId = new Set(); 1049 const recordsById = new Map(); 1050 this._lists[list].forEach(record => { 1051 if (!record.id) { 1052 recordsWithoutId.add(record); 1053 } 1054 else { 1055 recordsById.set(record.id, record); 1056 } 1057 }); 1058 this._cached[list] = Array.from(recordsById.values()).concat(Array.from(recordsWithoutId)); 1059 } 1060 return this._cached[list]; 1061 } 1062 /** 1063 * Reinitializes result entries for a given result type. 1064 * 1065 * @param {String} type The result type. 1066 * @return {SyncResultObject} 1067 */ 1068 reset(type) { 1069 this._lists[type] = []; 1070 delete this._cached[type]; 1071 return this; 1072 } 1073 toObject() { 1074 // Only used in tests. 1075 return { 1076 ok: this.ok, 1077 lastModified: this.lastModified, 1078 errors: this.errors, 1079 created: this.created, 1080 updated: this.updated, 1081 deleted: this.deleted, 1082 skipped: this.skipped, 1083 published: this.published, 1084 conflicts: this.conflicts, 1085 resolved: this.resolved, 1086 }; 1087 } 1088 } 1089 class ServerWasFlushedError extends Error { 1090 constructor(clientTimestamp, serverTimestamp, message) { 1091 super(message); 1092 if (Error.captureStackTrace) { 1093 Error.captureStackTrace(this, ServerWasFlushedError); 1094 } 1095 this.clientTimestamp = clientTimestamp; 1096 this.serverTimestamp = serverTimestamp; 1097 } 1098 } 1099 function createUUIDSchema() { 1100 return { 1101 generate() { 1102 return uuid4(); 1103 }, 1104 validate(id) { 1105 return typeof id == "string" && RE_RECORD_ID.test(id); 1106 }, 1107 }; 1108 } 1109 function markStatus(record, status) { 1110 return Object.assign(Object.assign({}, record), { _status: status }); 1111 } 1112 function markDeleted(record) { 1113 return markStatus(record, "deleted"); 1114 } 1115 function markSynced(record) { 1116 return markStatus(record, "synced"); 1117 } 1118 /** 1119 * Import a remote change into the local database. 1120 * 1121 * @param {IDBTransactionProxy} transaction The transaction handler. 1122 * @param {Object} remote The remote change object to import. 1123 * @param {Array<String>} localFields The list of fields that remain local. 1124 * @param {String} strategy The {@link Collection.strategy}. 1125 * @return {Object} 1126 */ 1127 function importChange(transaction, remote, localFields, strategy) { 1128 const local = transaction.get(remote.id); 1129 if (!local) { 1130 // Not found locally but remote change is marked as deleted; skip to 1131 // avoid recreation. 1132 if (remote.deleted) { 1133 return { type: "skipped", data: remote }; 1134 } 1135 const synced = markSynced(remote); 1136 transaction.create(synced); 1137 return { type: "created", data: synced }; 1138 } 1139 // Apply remote changes on local record. 1140 const synced = Object.assign(Object.assign({}, local), markSynced(remote)); 1141 // With pull only, we don't need to compare records since we override them. 1142 if (strategy === Collection.strategy.PULL_ONLY) { 1143 if (remote.deleted) { 1144 transaction.delete(remote.id); 1145 return { type: "deleted", data: local }; 1146 } 1147 transaction.update(synced); 1148 return { type: "updated", data: { old: local, new: synced } }; 1149 } 1150 // With other sync strategies, we detect conflicts, 1151 // by comparing local and remote, ignoring local fields. 1152 const isIdentical = recordsEqual(local, remote, localFields); 1153 // Detect or ignore conflicts if record has also been modified locally. 1154 if (local._status !== "synced") { 1155 // Locally deleted, unsynced: scheduled for remote deletion. 1156 if (local._status === "deleted") { 1157 return { type: "skipped", data: local }; 1158 } 1159 if (isIdentical) { 1160 // If records are identical, import anyway, so we bump the 1161 // local last_modified value from the server and set record 1162 // status to "synced". 1163 transaction.update(synced); 1164 return { type: "updated", data: { old: local, new: synced } }; 1165 } 1166 if (local.last_modified !== undefined && 1167 local.last_modified === remote.last_modified) { 1168 // If our local version has the same last_modified as the remote 1169 // one, this represents an object that corresponds to a resolved 1170 // conflict. Our local version represents the final output, so 1171 // we keep that one. (No transaction operation to do.) 1172 // But if our last_modified is undefined, 1173 // that means we've created the same object locally as one on 1174 // the server, which *must* be a conflict. 1175 return { type: "void" }; 1176 } 1177 return { 1178 type: "conflicts", 1179 data: { type: "incoming", local: local, remote: remote }, 1180 }; 1181 } 1182 // Local record was synced. 1183 if (remote.deleted) { 1184 transaction.delete(remote.id); 1185 return { type: "deleted", data: local }; 1186 } 1187 // Import locally. 1188 transaction.update(synced); 1189 // if identical, simply exclude it from all SyncResultObject lists 1190 const type = isIdentical ? "void" : "updated"; 1191 return { type, data: { old: local, new: synced } }; 1192 } 1193 /** 1194 * Abstracts a collection of records stored in the local database, providing 1195 * CRUD operations and synchronization helpers. 1196 */ 1197 class Collection { 1198 /** 1199 * Constructor. 1200 * 1201 * Options: 1202 * - `{BaseAdapter} adapter` The DB adapter (default: `IDB`) 1203 * 1204 * @param {String} bucket The bucket identifier. 1205 * @param {String} name The collection name. 1206 * @param {KintoBase} kinto The Kinto instance. 1207 * @param {Object} options The options object. 1208 */ 1209 constructor(bucket, name, kinto, options = {}) { 1210 this._bucket = bucket; 1211 this._name = name; 1212 this._lastModified = null; 1213 const DBAdapter = options.adapter || IDB; 1214 if (!DBAdapter) { 1215 throw new Error("No adapter provided"); 1216 } 1217 const db = new DBAdapter(`${bucket}/${name}`, options.adapterOptions); 1218 if (!(db instanceof BaseAdapter)) { 1219 throw new Error("Unsupported adapter."); 1220 } 1221 // public properties 1222 /** 1223 * The db adapter instance 1224 * @type {BaseAdapter} 1225 */ 1226 this.db = db; 1227 /** 1228 * The KintoBase instance. 1229 * @type {KintoBase} 1230 */ 1231 this.kinto = kinto; 1232 /** 1233 * The event emitter instance. 1234 * @type {EventEmitter} 1235 */ 1236 this.events = options.events; 1237 /** 1238 * The IdSchema instance. 1239 * @type {Object} 1240 */ 1241 this.idSchema = this._validateIdSchema(options.idSchema); 1242 /** 1243 * The list of remote transformers. 1244 * @type {Array} 1245 */ 1246 this.remoteTransformers = this._validateRemoteTransformers(options.remoteTransformers); 1247 /** 1248 * The list of hooks. 1249 * @type {Object} 1250 */ 1251 this.hooks = this._validateHooks(options.hooks); 1252 /** 1253 * The list of fields names that will remain local. 1254 * @type {Array} 1255 */ 1256 this.localFields = options.localFields || []; 1257 } 1258 /** 1259 * The HTTP client. 1260 * @type {KintoClient} 1261 */ 1262 get api() { 1263 return this.kinto.api; 1264 } 1265 /** 1266 * The collection name. 1267 * @type {String} 1268 */ 1269 get name() { 1270 return this._name; 1271 } 1272 /** 1273 * The bucket name. 1274 * @type {String} 1275 */ 1276 get bucket() { 1277 return this._bucket; 1278 } 1279 /** 1280 * The last modified timestamp. 1281 * @type {Number} 1282 */ 1283 get lastModified() { 1284 return this._lastModified; 1285 } 1286 /** 1287 * Synchronization strategies. Available strategies are: 1288 * 1289 * - `MANUAL`: Conflicts will be reported in a dedicated array. 1290 * - `SERVER_WINS`: Conflicts are resolved using remote data. 1291 * - `CLIENT_WINS`: Conflicts are resolved using local data. 1292 * 1293 * @type {Object} 1294 */ 1295 static get strategy() { 1296 return { 1297 CLIENT_WINS: "client_wins", 1298 SERVER_WINS: "server_wins", 1299 PULL_ONLY: "pull_only", 1300 MANUAL: "manual", 1301 }; 1302 } 1303 /** 1304 * Validates an idSchema. 1305 * 1306 * @param {Object|undefined} idSchema 1307 * @return {Object} 1308 */ 1309 _validateIdSchema(idSchema) { 1310 if (typeof idSchema === "undefined") { 1311 return createUUIDSchema(); 1312 } 1313 if (typeof idSchema !== "object") { 1314 throw new Error("idSchema must be an object."); 1315 } 1316 else if (typeof idSchema.generate !== "function") { 1317 throw new Error("idSchema must provide a generate function."); 1318 } 1319 else if (typeof idSchema.validate !== "function") { 1320 throw new Error("idSchema must provide a validate function."); 1321 } 1322 return idSchema; 1323 } 1324 /** 1325 * Validates a list of remote transformers. 1326 * 1327 * @param {Array|undefined} remoteTransformers 1328 * @return {Array} 1329 */ 1330 _validateRemoteTransformers(remoteTransformers) { 1331 if (typeof remoteTransformers === "undefined") { 1332 return []; 1333 } 1334 if (!Array.isArray(remoteTransformers)) { 1335 throw new Error("remoteTransformers should be an array."); 1336 } 1337 return remoteTransformers.map(transformer => { 1338 if (typeof transformer !== "object") { 1339 throw new Error("A transformer must be an object."); 1340 } 1341 else if (typeof transformer.encode !== "function") { 1342 throw new Error("A transformer must provide an encode function."); 1343 } 1344 else if (typeof transformer.decode !== "function") { 1345 throw new Error("A transformer must provide a decode function."); 1346 } 1347 return transformer; 1348 }); 1349 } 1350 /** 1351 * Validate the passed hook is correct. 1352 * 1353 * @param {Array|undefined} hook. 1354 * @return {Array} 1355 **/ 1356 _validateHook(hook) { 1357 if (!Array.isArray(hook)) { 1358 throw new Error("A hook definition should be an array of functions."); 1359 } 1360 return hook.map(fn => { 1361 if (typeof fn !== "function") { 1362 throw new Error("A hook definition should be an array of functions."); 1363 } 1364 return fn; 1365 }); 1366 } 1367 /** 1368 * Validates a list of hooks. 1369 * 1370 * @param {Object|undefined} hooks 1371 * @return {Object} 1372 */ 1373 _validateHooks(hooks) { 1374 if (typeof hooks === "undefined") { 1375 return {}; 1376 } 1377 if (Array.isArray(hooks)) { 1378 throw new Error("hooks should be an object, not an array."); 1379 } 1380 if (typeof hooks !== "object") { 1381 throw new Error("hooks should be an object."); 1382 } 1383 const validatedHooks = {}; 1384 for (const hook in hooks) { 1385 if (!AVAILABLE_HOOKS.includes(hook)) { 1386 throw new Error("The hook should be one of " + AVAILABLE_HOOKS.join(", ")); 1387 } 1388 validatedHooks[hook] = this._validateHook(hooks[hook]); 1389 } 1390 return validatedHooks; 1391 } 1392 /** 1393 * Deletes every records in the current collection and marks the collection as 1394 * never synced. 1395 * 1396 * @return {Promise} 1397 */ 1398 async clear() { 1399 await this.db.clear(); 1400 await this.db.saveMetadata(null); 1401 await this.db.saveLastModified(null); 1402 return { data: [], permissions: {} }; 1403 } 1404 /** 1405 * Encodes a record. 1406 * 1407 * @param {String} type Either "remote" or "local". 1408 * @param {Object} record The record object to encode. 1409 * @return {Promise} 1410 */ 1411 _encodeRecord(type, record) { 1412 if (!this[`${type}Transformers`].length) { 1413 return Promise.resolve(record); 1414 } 1415 return waterfall(this[`${type}Transformers`].map(transformer => { 1416 return record => transformer.encode(record); 1417 }), record); 1418 } 1419 /** 1420 * Decodes a record. 1421 * 1422 * @param {String} type Either "remote" or "local". 1423 * @param {Object} record The record object to decode. 1424 * @return {Promise} 1425 */ 1426 _decodeRecord(type, record) { 1427 if (!this[`${type}Transformers`].length) { 1428 return Promise.resolve(record); 1429 } 1430 return waterfall(this[`${type}Transformers`].reverse().map(transformer => { 1431 return record => transformer.decode(record); 1432 }), record); 1433 } 1434 /** 1435 * Adds a record to the local database, asserting that none 1436 * already exist with this ID. 1437 * 1438 * Note: If either the `useRecordId` or `synced` options are true, then the 1439 * record object must contain the id field to be validated. If none of these 1440 * options are true, an id is generated using the current IdSchema; in this 1441 * case, the record passed must not have an id. 1442 * 1443 * Options: 1444 * - {Boolean} synced Sets record status to "synced" (default: `false`). 1445 * - {Boolean} useRecordId Forces the `id` field from the record to be used, 1446 * instead of one that is generated automatically 1447 * (default: `false`). 1448 * 1449 * @param {Object} record 1450 * @param {Object} options 1451 * @return {Promise} 1452 */ 1453 create(record, options = { useRecordId: false, synced: false }) { 1454 // Validate the record and its ID (if any), even though this 1455 // validation is also done in the CollectionTransaction method, 1456 // because we need to pass the ID to preloadIds. 1457 const reject = msg => Promise.reject(new Error(msg)); 1458 if (typeof record !== "object") { 1459 return reject("Record is not an object."); 1460 } 1461 if ((options.synced || options.useRecordId) && 1462 !Object.prototype.hasOwnProperty.call(record, "id")) { 1463 return reject("Missing required Id; synced and useRecordId options require one"); 1464 } 1465 if (!options.synced && 1466 !options.useRecordId && 1467 Object.prototype.hasOwnProperty.call(record, "id")) { 1468 return reject("Extraneous Id; can't create a record having one set."); 1469 } 1470 const newRecord = Object.assign(Object.assign({}, record), { id: options.synced || options.useRecordId 1471 ? record.id 1472 : this.idSchema.generate(record), _status: options.synced ? "synced" : "created" }); 1473 if (!this.idSchema.validate(newRecord.id)) { 1474 return reject(`Invalid Id: ${newRecord.id}`); 1475 } 1476 return this.execute(txn => txn.create(newRecord), { 1477 preloadIds: [newRecord.id], 1478 }).catch(err => { 1479 if (options.useRecordId) { 1480 throw new Error("Couldn't create record. It may have been virtually deleted."); 1481 } 1482 throw err; 1483 }); 1484 } 1485 /** 1486 * Like {@link CollectionTransaction#update}, but wrapped in its own transaction. 1487 * 1488 * Options: 1489 * - {Boolean} synced: Sets record status to "synced" (default: false) 1490 * - {Boolean} patch: Extends the existing record instead of overwriting it 1491 * (default: false) 1492 * 1493 * @param {Object} record 1494 * @param {Object} options 1495 * @return {Promise} 1496 */ 1497 update(record, options = { synced: false, patch: false }) { 1498 // Validate the record and its ID, even though this validation is 1499 // also done in the CollectionTransaction method, because we need 1500 // to pass the ID to preloadIds. 1501 if (typeof record !== "object") { 1502 return Promise.reject(new Error("Record is not an object.")); 1503 } 1504 if (!Object.prototype.hasOwnProperty.call(record, "id")) { 1505 return Promise.reject(new Error("Cannot update a record missing id.")); 1506 } 1507 if (!this.idSchema.validate(record.id)) { 1508 return Promise.reject(new Error(`Invalid Id: ${record.id}`)); 1509 } 1510 return this.execute(txn => txn.update(record, options), { 1511 preloadIds: [record.id], 1512 }); 1513 } 1514 /** 1515 * Like {@link CollectionTransaction#upsert}, but wrapped in its own transaction. 1516 * 1517 * @param {Object} record 1518 * @return {Promise} 1519 */ 1520 upsert(record) { 1521 // Validate the record and its ID, even though this validation is 1522 // also done in the CollectionTransaction method, because we need 1523 // to pass the ID to preloadIds. 1524 if (typeof record !== "object") { 1525 return Promise.reject(new Error("Record is not an object.")); 1526 } 1527 if (!Object.prototype.hasOwnProperty.call(record, "id")) { 1528 return Promise.reject(new Error("Cannot update a record missing id.")); 1529 } 1530 if (!this.idSchema.validate(record.id)) { 1531 return Promise.reject(new Error(`Invalid Id: ${record.id}`)); 1532 } 1533 return this.execute(txn => txn.upsert(record), { preloadIds: [record.id] }); 1534 } 1535 /** 1536 * Like {@link CollectionTransaction#get}, but wrapped in its own transaction. 1537 * 1538 * Options: 1539 * - {Boolean} includeDeleted: Include virtually deleted records. 1540 * 1541 * @param {String} id 1542 * @param {Object} options 1543 * @return {Promise} 1544 */ 1545 get(id, options = { includeDeleted: false }) { 1546 return this.execute(txn => txn.get(id, options), { preloadIds: [id] }); 1547 } 1548 /** 1549 * Like {@link CollectionTransaction#getAny}, but wrapped in its own transaction. 1550 * 1551 * @param {String} id 1552 * @return {Promise} 1553 */ 1554 getAny(id) { 1555 return this.execute(txn => txn.getAny(id), { preloadIds: [id] }); 1556 } 1557 /** 1558 * Same as {@link Collection#delete}, but wrapped in its own transaction. 1559 * 1560 * Options: 1561 * - {Boolean} virtual: When set to `true`, doesn't actually delete the record, 1562 * update its `_status` attribute to `deleted` instead (default: true) 1563 * 1564 * @param {String} id The record's Id. 1565 * @param {Object} options The options object. 1566 * @return {Promise} 1567 */ 1568 delete(id, options = { virtual: true }) { 1569 return this.execute(transaction => { 1570 return transaction.delete(id, options); 1571 }, { preloadIds: [id] }); 1572 } 1573 /** 1574 * Same as {@link Collection#deleteAll}, but wrapped in its own transaction, execulding the parameter. 1575 * 1576 * @return {Promise} 1577 */ 1578 async deleteAll() { 1579 const { data } = await this.list({}, { includeDeleted: false }); 1580 const recordIds = data.map(record => record.id); 1581 return this.execute(transaction => { 1582 return transaction.deleteAll(recordIds); 1583 }, { preloadIds: recordIds }); 1584 } 1585 /** 1586 * The same as {@link CollectionTransaction#deleteAny}, but wrapped 1587 * in its own transaction. 1588 * 1589 * @param {String} id The record's Id. 1590 * @return {Promise} 1591 */ 1592 deleteAny(id) { 1593 return this.execute(txn => txn.deleteAny(id), { preloadIds: [id] }); 1594 } 1595 /** 1596 * Lists records from the local database. 1597 * 1598 * Params: 1599 * - {Object} filters Filter the results (default: `{}`). 1600 * - {String} order The order to apply (default: `-last_modified`). 1601 * 1602 * Options: 1603 * - {Boolean} includeDeleted: Include virtually deleted records. 1604 * 1605 * @param {Object} params The filters and order to apply to the results. 1606 * @param {Object} options The options object. 1607 * @return {Promise} 1608 */ 1609 async list(params = {}, options = { includeDeleted: false }) { 1610 params = Object.assign({ order: "-last_modified", filters: {} }, params); 1611 const results = await this.db.list(params); 1612 let data = results; 1613 if (!options.includeDeleted) { 1614 data = results.filter(record => record._status !== "deleted"); 1615 } 1616 return { data, permissions: {} }; 1617 } 1618 /** 1619 * Imports remote changes into the local database. 1620 * This method is in charge of detecting the conflicts, and resolve them 1621 * according to the specified strategy. 1622 * @param {SyncResultObject} syncResultObject The sync result object. 1623 * @param {Array} decodedChanges The list of changes to import in the local database. 1624 * @param {String} strategy The {@link Collection.strategy} (default: MANUAL) 1625 * @return {Promise} 1626 */ 1627 async importChanges(syncResultObject, decodedChanges, strategy = Collection.strategy.MANUAL) { 1628 // Retrieve records matching change ids. 1629 try { 1630 for (let i = 0; i < decodedChanges.length; i += IMPORT_CHUNK_SIZE) { 1631 const slice = decodedChanges.slice(i, i + IMPORT_CHUNK_SIZE); 1632 const { imports, resolved } = await this.db.execute(transaction => { 1633 const imports = slice.map(remote => { 1634 // Store remote change into local database. 1635 return importChange(transaction, remote, this.localFields, strategy); 1636 }); 1637 const conflicts = imports 1638 .filter(i => i.type === "conflicts") 1639 .map(i => i.data); 1640 const resolved = this._handleConflicts(transaction, conflicts, strategy); 1641 return { imports, resolved }; 1642 }, { preload: slice.map(record => record.id) }); 1643 // Lists of created/updated/deleted records 1644 imports.forEach(({ type, data }) => syncResultObject.add(type, data)); 1645 // Automatically resolved conflicts (if not manual) 1646 if (resolved.length > 0) { 1647 syncResultObject.reset("conflicts").add("resolved", resolved); 1648 } 1649 } 1650 } 1651 catch (err) { 1652 const data = { 1653 type: "incoming", 1654 message: err.message, 1655 stack: err.stack, 1656 }; 1657 // XXX one error of the whole transaction instead of per atomic op 1658 syncResultObject.add("errors", data); 1659 } 1660 return syncResultObject; 1661 } 1662 /** 1663 * Imports the responses of pushed changes into the local database. 1664 * Basically it stores the timestamp assigned by the server into the local 1665 * database. 1666 * @param {SyncResultObject} syncResultObject The sync result object. 1667 * @param {Array} toApplyLocally The list of changes to import in the local database. 1668 * @param {Array} conflicts The list of conflicts that have to be resolved. 1669 * @param {String} strategy The {@link Collection.strategy}. 1670 * @return {Promise} 1671 */ 1672 async _applyPushedResults(syncResultObject, toApplyLocally, conflicts, strategy = Collection.strategy.MANUAL) { 1673 const toDeleteLocally = toApplyLocally.filter(r => r.deleted); 1674 const toUpdateLocally = toApplyLocally.filter(r => !r.deleted); 1675 const { published, resolved } = await this.db.execute(transaction => { 1676 const updated = toUpdateLocally.map(record => { 1677 const synced = markSynced(record); 1678 transaction.update(synced); 1679 return synced; 1680 }); 1681 const deleted = toDeleteLocally.map(record => { 1682 transaction.delete(record.id); 1683 // Amend result data with the deleted attribute set 1684 return { id: record.id, deleted: true }; 1685 }); 1686 const published = updated.concat(deleted); 1687 // Handle conflicts, if any 1688 const resolved = this._handleConflicts(transaction, conflicts, strategy); 1689 return { published, resolved }; 1690 }); 1691 syncResultObject.add("published", published); 1692 if (resolved.length > 0) { 1693 syncResultObject 1694 .reset("conflicts") 1695 .reset("resolved") 1696 .add("resolved", resolved); 1697 } 1698 return syncResultObject; 1699 } 1700 /** 1701 * Handles synchronization conflicts according to specified strategy. 1702 * 1703 * @param {SyncResultObject} result The sync result object. 1704 * @param {String} strategy The {@link Collection.strategy}. 1705 * @return {Promise<Array<Object>>} The resolved conflicts, as an 1706 * array of {accepted, rejected} objects 1707 */ 1708 _handleConflicts(transaction, conflicts, strategy) { 1709 if (strategy === Collection.strategy.MANUAL) { 1710 return []; 1711 } 1712 return conflicts.map(conflict => { 1713 const resolution = strategy === Collection.strategy.CLIENT_WINS 1714 ? conflict.local 1715 : conflict.remote; 1716 const rejected = strategy === Collection.strategy.CLIENT_WINS 1717 ? conflict.remote 1718 : conflict.local; 1719 let accepted, status, id; 1720 if (resolution === null) { 1721 // We "resolved" with the server-side deletion. Delete locally. 1722 // This only happens during SERVER_WINS because the local 1723 // version of a record can never be null. 1724 // We can get "null" from the remote side if we got a conflict 1725 // and there is no remote version available; see kinto-http.js 1726 // batch.js:aggregate. 1727 transaction.delete(conflict.local.id); 1728 accepted = null; 1729 // The record was deleted, but that status is "synced" with 1730 // the server, so we don't need to push the change. 1731 status = "synced"; 1732 id = conflict.local.id; 1733 } 1734 else { 1735 const updated = this._resolveRaw(conflict, resolution); 1736 transaction.update(updated); 1737 accepted = updated; 1738 status = updated._status; 1739 id = updated.id; 1740 } 1741 return { rejected, accepted, id, _status: status }; 1742 }); 1743 } 1744 /** 1745 * Execute a bunch of operations in a transaction. 1746 * 1747 * This transaction should be atomic -- either all of its operations 1748 * will succeed, or none will. 1749 * 1750 * The argument to this function is itself a function which will be 1751 * called with a {@link CollectionTransaction}. Collection methods 1752 * are available on this transaction, but instead of returning 1753 * promises, they are synchronous. execute() returns a Promise whose 1754 * value will be the return value of the provided function. 1755 * 1756 * Most operations will require access to the record itself, which 1757 * must be preloaded by passing its ID in the preloadIds option. 1758 * 1759 * Options: 1760 * - {Array} preloadIds: list of IDs to fetch at the beginning of 1761 * the transaction 1762 * 1763 * @return {Promise} Resolves with the result of the given function 1764 * when the transaction commits. 1765 */ 1766 execute(doOperations, { preloadIds = [] } = {}) { 1767 for (const id of preloadIds) { 1768 if (!this.idSchema.validate(id)) { 1769 return Promise.reject(Error(`Invalid Id: ${id}`)); 1770 } 1771 } 1772 return this.db.execute(transaction => { 1773 const txn = new CollectionTransaction(this, transaction); 1774 const result = doOperations(txn); 1775 txn.emitEvents(); 1776 return result; 1777 }, { preload: preloadIds }); 1778 } 1779 /** 1780 * Resets the local records as if they were never synced; existing records are 1781 * marked as newly created, deleted records are dropped. 1782 * 1783 * A next call to {@link Collection.sync} will thus republish the whole 1784 * content of the local collection to the server. 1785 * 1786 * @return {Promise} Resolves with the number of processed records. 1787 */ 1788 async resetSyncStatus() { 1789 const unsynced = await this.list({ filters: { _status: ["deleted", "synced"] }, order: "" }, { includeDeleted: true }); 1790 await this.db.execute(transaction => { 1791 unsynced.data.forEach(record => { 1792 if (record._status === "deleted") { 1793 // Garbage collect deleted records. 1794 transaction.delete(record.id); 1795 } 1796 else { 1797 // Records that were synced become «created». 1798 transaction.update(Object.assign(Object.assign({}, record), { last_modified: undefined, _status: "created" })); 1799 } 1800 }); 1801 }); 1802 this._lastModified = null; 1803 await this.db.saveLastModified(null); 1804 return unsynced.data.length; 1805 } 1806 /** 1807 * Returns an object containing two lists: 1808 * 1809 * - `toDelete`: unsynced deleted records we can safely delete; 1810 * - `toSync`: local updates to send to the server. 1811 * 1812 * @return {Promise} 1813 */ 1814 async gatherLocalChanges() { 1815 const unsynced = await this.list({ 1816 filters: { _status: ["created", "updated"] }, 1817 order: "", 1818 }); 1819 const deleted = await this.list({ filters: { _status: "deleted" }, order: "" }, { includeDeleted: true }); 1820 return await Promise.all(unsynced.data 1821 .concat(deleted.data) 1822 .map(this._encodeRecord.bind(this, "remote"))); 1823 } 1824 /** 1825 * Fetch remote changes, import them to the local database, and handle 1826 * conflicts according to `options.strategy`. Then, updates the passed 1827 * {@link SyncResultObject} with import results. 1828 * 1829 * Options: 1830 * - {String} strategy: The selected sync strategy. 1831 * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter. 1832 * - {Array<String>} exclude: A list of record ids to exclude from pull. 1833 * - {Object} headers: The HTTP headers to use in the request. 1834 * - {int} retry: The number of retries to do if the HTTP request fails. 1835 * - {int} lastModified: The timestamp to use in `?_since` query. 1836 * 1837 * @param {KintoClient.Collection} client Kinto client Collection instance. 1838 * @param {SyncResultObject} syncResultObject The sync result object. 1839 * @param {Object} options The options object. 1840 * @return {Promise} 1841 */ 1842 async pullChanges(client, syncResultObject, options = {}) { 1843 if (!syncResultObject.ok) { 1844 return syncResultObject; 1845 } 1846 const since = this.lastModified 1847 ? this.lastModified 1848 : await this.db.getLastModified(); 1849 options = Object.assign({ strategy: Collection.strategy.MANUAL, lastModified: since, headers: {} }, options); 1850 // Optionally ignore some records when pulling for changes. 1851 // (avoid redownloading our own changes on last step of #sync()) 1852 let filters; 1853 if (options.exclude) { 1854 // Limit the list of excluded records to the first 50 records in order 1855 // to remain under de-facto URL size limit (~2000 chars). 1856 // http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers/417184#417184 1857 const exclude_id = options.exclude 1858 .slice(0, 50) 1859 .map(r => r.id) 1860 .join(","); 1861 filters = { exclude_id }; 1862 } 1863 if (options.expectedTimestamp) { 1864 filters = Object.assign(Object.assign({}, filters), { _expected: options.expectedTimestamp }); 1865 } 1866 // First fetch remote changes from the server 1867 const { data, last_modified } = await client.listRecords({ 1868 // Since should be ETag (see https://github.com/Kinto/kinto.js/issues/356) 1869 since: options.lastModified ? `${options.lastModified}` : undefined, 1870 headers: options.headers, 1871 retry: options.retry, 1872 // Fetch every page by default (FIXME: option to limit pages, see #277) 1873 pages: Infinity, 1874 filters, 1875 }); 1876 // last_modified is the ETag header value (string). 1877 // For retro-compatibility with first kinto.js versions 1878 // parse it to integer. 1879 const unquoted = last_modified ? parseInt(last_modified, 10) : undefined; 1880 // Check if server was flushed. 1881 // This is relevant for the Kinto demo server 1882 // (and thus for many new comers). 1883 const localSynced = options.lastModified; 1884 const serverChanged = unquoted > options.lastModified; 1885 const emptyCollection = data.length === 0; 1886 if (!options.exclude && localSynced && serverChanged && emptyCollection) { 1887 const e = new ServerWasFlushedError(localSynced, unquoted, "Server has been flushed. Client Side Timestamp: " + 1888 localSynced + 1889 " Server Side Timestamp: " + 1890 unquoted); 1891 throw e; 1892 } 1893 // Atomic updates are not sensible here because unquoted is not 1894 // computed as a function of syncResultObject.lastModified. 1895 // eslint-disable-next-line require-atomic-updates 1896 syncResultObject.lastModified = unquoted; 1897 // Decode incoming changes. 1898 const decodedChanges = await Promise.all(data.map(change => { 1899 return this._decodeRecord("remote", change); 1900 })); 1901 // Hook receives decoded records. 1902 const payload = { lastModified: unquoted, changes: decodedChanges }; 1903 const afterHooks = await this.applyHook("incoming-changes", payload); 1904 // No change, nothing to import. 1905 if (afterHooks.changes.length > 0) { 1906 // Reflect these changes locally 1907 await this.importChanges(syncResultObject, afterHooks.changes, options.strategy); 1908 } 1909 return syncResultObject; 1910 } 1911 applyHook(hookName, payload) { 1912 if (typeof this.hooks[hookName] == "undefined") { 1913 return Promise.resolve(payload); 1914 } 1915 return waterfall(this.hooks[hookName].map(hook => { 1916 return record => { 1917 const result = hook(payload, this); 1918 const resultThenable = result && typeof result.then === "function"; 1919 const resultChanges = result && Object.prototype.hasOwnProperty.call(result, "changes"); 1920 if (!(resultThenable || resultChanges)) { 1921 throw new Error(`Invalid return value for hook: ${JSON.stringify(result)} has no 'then()' or 'changes' properties`); 1922 } 1923 return result; 1924 }; 1925 }), payload); 1926 } 1927 /** 1928 * Publish local changes to the remote server and updates the passed 1929 * {@link SyncResultObject} with publication results. 1930 * 1931 * Options: 1932 * - {String} strategy: The selected sync strategy. 1933 * - {Object} headers: The HTTP headers to use in the request. 1934 * - {int} retry: The number of retries to do if the HTTP request fails. 1935 * 1936 * @param {KintoClient.Collection} client Kinto client Collection instance. 1937 * @param {SyncResultObject} syncResultObject The sync result object. 1938 * @param {Object} changes The change object. 1939 * @param {Array} changes.toDelete The list of records to delete. 1940 * @param {Array} changes.toSync The list of records to create/update. 1941 * @param {Object} options The options object. 1942 * @return {Promise} 1943 */ 1944 async pushChanges(client, changes, syncResultObject, options = {}) { 1945 if (!syncResultObject.ok) { 1946 return syncResultObject; 1947 } 1948 const safe = !options.strategy || options.strategy !== Collection.CLIENT_WINS; 1949 const toDelete = changes.filter(r => r._status == "deleted"); 1950 const toSync = changes.filter(r => r._status != "deleted"); 1951 // Perform a batch request with every changes. 1952 const synced = await client.batch(batch => { 1953 toDelete.forEach(r => { 1954 // never published locally deleted records should not be pusblished 1955 if (r.last_modified) { 1956 batch.deleteRecord(r); 1957 } 1958 }); 1959 toSync.forEach(r => { 1960 // Clean local fields (like _status) before sending to server. 1961 const published = this.cleanLocalFields(r); 1962 if (r._status === "created") { 1963 batch.createRecord(published); 1964 } 1965 else { 1966 batch.updateRecord(published); 1967 } 1968 }); 1969 }, { 1970 headers: options.headers, 1971 retry: options.retry, 1972 safe, 1973 aggregate: true, 1974 }); 1975 // Store outgoing errors into sync result object 1976 syncResultObject.add("errors", synced.errors.map(e => (Object.assign(Object.assign({}, e), { type: "outgoing" })))); 1977 // Store outgoing conflicts into sync result object 1978 const conflicts = []; 1979 for (const { type, local, remote } of synced.conflicts) { 1980 // Note: we ensure that local data are actually available, as they may 1981 // be missing in the case of a published deletion. 1982 const safeLocal = (local && local.data) || { id: remote.id }; 1983 const realLocal = await this._decodeRecord("remote", safeLocal); 1984 // We can get "null" from the remote side if we got a conflict 1985 // and there is no remote version available; see kinto-http.js 1986 // batch.js:aggregate. 1987 const realRemote = remote && (await this._decodeRecord("remote", remote)); 1988 const conflict = { type, local: realLocal, remote: realRemote }; 1989 conflicts.push(conflict); 1990 } 1991 syncResultObject.add("conflicts", conflicts); 1992 // Records that must be deleted are either deletions that were pushed 1993 // to server (published) or deleted records that were never pushed (skipped). 1994 const missingRemotely = synced.skipped.map(r => (Object.assign(Object.assign({}, r), { deleted: true }))); 1995 // For created and updated records, the last_modified coming from server 1996 // will be stored locally. 1997 // Reflect publication results locally using the response from 1998 // the batch request. 1999 const published = synced.published.map(c => c.data); 2000 const toApplyLocally = published.concat(missingRemotely); 2001 // Apply the decode transformers, if any 2002 const decoded = await Promise.all(toApplyLocally.map(record => { 2003 return this._decodeRecord("remote", record); 2004 })); 2005 // We have to update the local records with the responses of the server 2006 // (eg. last_modified values etc.). 2007 if (decoded.length > 0 || conflicts.length > 0) { 2008 await this._applyPushedResults(syncResultObject, decoded, conflicts, options.strategy); 2009 } 2010 return syncResultObject; 2011 } 2012 /** 2013 * Return a copy of the specified record without the local fields. 2014 * 2015 * @param {Object} record A record with potential local fields. 2016 * @return {Object} 2017 */ 2018 cleanLocalFields(record) { 2019 const localKeys = RECORD_FIELDS_TO_CLEAN.concat(this.localFields); 2020 return omitKeys(record, localKeys); 2021 } 2022 /** 2023 * Resolves a conflict, updating local record according to proposed 2024 * resolution — keeping remote record `last_modified` value as a reference for 2025 * further batch sending. 2026 * 2027 * @param {Object} conflict The conflict object. 2028 * @param {Object} resolution The proposed record. 2029 * @return {Promise} 2030 */ 2031 resolve(conflict, resolution) { 2032 return this.db.execute(transaction => { 2033 const updated = this._resolveRaw(conflict, resolution); 2034 transaction.update(updated); 2035 return { data: updated, permissions: {} }; 2036 }); 2037 } 2038 /** 2039 * @private 2040 */ 2041 _resolveRaw(conflict, resolution) { 2042 const resolved = Object.assign(Object.assign({}, resolution), { 2043 // Ensure local record has the latest authoritative timestamp 2044 last_modified: conflict.remote && conflict.remote.last_modified }); 2045 // If the resolution object is strictly equal to the 2046 // remote record, then we can mark it as synced locally. 2047 // Otherwise, mark it as updated (so that the resolution is pushed). 2048 const synced = deepEqual(resolved, conflict.remote); 2049 return markStatus(resolved, synced ? "synced" : "updated"); 2050 } 2051 /** 2052 * Synchronize remote and local data. The promise will resolve with a 2053 * {@link SyncResultObject}, though will reject: 2054 * 2055 * - if the server is currently backed off; 2056 * - if the server has been detected flushed. 2057 * 2058 * Options: 2059 * - {Object} headers: HTTP headers to attach to outgoing requests. 2060 * - {String} expectedTimestamp: A timestamp to use as a "cache busting" query parameter. 2061 * - {Number} retry: Number of retries when server fails to process the request (default: 1). 2062 * - {Collection.strategy} strategy: See {@link Collection.strategy}. 2063 * - {Boolean} ignoreBackoff: Force synchronization even if server is currently 2064 * backed off. 2065 * - {String} bucket: The remove bucket id to use (default: null) 2066 * - {String} collection: The remove collection id to use (default: null) 2067 * - {String} remote The remote Kinto server endpoint to use (default: null). 2068 * 2069 * @param {Object} options Options. 2070 * @return {Promise} 2071 * @throws {Error} If an invalid remote option is passed. 2072 */ 2073 async sync(options = { 2074 strategy: Collection.strategy.MANUAL, 2075 headers: {}, 2076 retry: 1, 2077 ignoreBackoff: false, 2078 bucket: null, 2079 collection: null, 2080 remote: null, 2081 expectedTimestamp: null, 2082 }) { 2083 options = Object.assign(Object.assign({}, options), { bucket: options.bucket || this.bucket, collection: options.collection || this.name }); 2084 const previousRemote = this.api.remote; 2085 if (options.remote) { 2086 // Note: setting the remote ensures it's valid, throws when invalid. 2087 this.api.remote = options.remote; 2088 } 2089 if (!options.ignoreBackoff && this.api.backoff > 0) { 2090 const seconds = Math.ceil(this.api.backoff / 1000); 2091 return Promise.reject(new Error(`Server is asking clients to back off; retry in ${seconds}s or use the ignoreBackoff option.`)); 2092 } 2093 const client = this.api 2094 .bucket(options.bucket) 2095 .collection(options.collection); 2096 const result = new SyncResultObject(); 2097 try { 2098 // Fetch collection metadata. 2099 await this.pullMetadata(client, options); 2100 // Fetch last changes from the server. 2101 await this.pullChanges(client, result, options); 2102 const { lastModified } = result; 2103 if (options.strategy != Collection.strategy.PULL_ONLY) { 2104 // Fetch local changes 2105 const toSync = await this.gatherLocalChanges(); 2106 // Publish local changes and pull local resolutions 2107 await this.pushChanges(client, toSync, result, options); 2108 // Publish local resolution of push conflicts to server (on CLIENT_WINS) 2109 const resolvedUnsynced = result.resolved.filter(r => r._status !== "synced"); 2110 if (resolvedUnsynced.length > 0) { 2111 const resolvedEncoded = await Promise.all(resolvedUnsynced.map(resolution => { 2112 let record = resolution.accepted; 2113 if (record === null) { 2114 record = { id: resolution.id, _status: resolution._status }; 2115 } 2116 return this._encodeRecord("remote", record); 2117 })); 2118 await this.pushChanges(client, resolvedEncoded, result, options); 2119 } 2120 // Perform a last pull to catch changes that occured after the last pull, 2121 // while local changes were pushed. Do not do it nothing was pushed. 2122 if (result.published.length > 0) { 2123 // Avoid redownloading our own changes during the last pull. 2124 const pullOpts = Object.assign(Object.assign({}, options), { lastModified, exclude: result.published }); 2125 await this.pullChanges(client, result, pullOpts); 2126 } 2127 } 2128 // Don't persist lastModified value if any conflict or error occured 2129 if (result.ok) { 2130 // No conflict occured, persist collection's lastModified value 2131 this._lastModified = await this.db.saveLastModified(result.lastModified); 2132 } 2133 } 2134 catch (e) { 2135 this.events.emit("sync:error", Object.assign(Object.assign({}, options), { error: e })); 2136 throw e; 2137 } 2138 finally { 2139 // Ensure API default remote is reverted if a custom one's been used 2140 this.api.remote = previousRemote; 2141 } 2142 this.events.emit("sync:success", Object.assign(Object.assign({}, options), { result })); 2143 return result; 2144 } 2145 /** 2146 * Load a list of records already synced with the remote server. 2147 * 2148 * The local records which are unsynced or whose timestamp is either missing 2149 * or superior to those being loaded will be ignored. 2150 * 2151 * @deprecated Use {@link importBulk} instead. 2152 * @param {Array} records The previously exported list of records to load. 2153 * @return {Promise} with the effectively imported records. 2154 */ 2155 async loadDump(records) { 2156 return this.importBulk(records); 2157 } 2158 /** 2159 * Load a list of records already synced with the remote server. 2160 * 2161 * The local records which are unsynced or whose timestamp is either missing 2162 * or superior to those being loaded will be ignored. 2163 * 2164 * @param {Array} records The previously exported list of records to load. 2165 * @return {Promise} with the effectively imported records. 2166 */ 2167 async importBulk(records) { 2168 if (!Array.isArray(records)) { 2169 throw new Error("Records is not an array."); 2170 } 2171 for (const record of records) { 2172 if (!Object.prototype.hasOwnProperty.call(record, "id") || 2173 !this.idSchema.validate(record.id)) { 2174 throw new Error("Record has invalid ID: " + JSON.stringify(record)); 2175 } 2176 if (!record.last_modified) { 2177 throw new Error("Record has no last_modified value: " + JSON.stringify(record)); 2178 } 2179 } 2180 // Fetch all existing records from local database, 2181 // and skip those who are newer or not marked as synced. 2182 // XXX filter by status / ids in records 2183 const { data } = await this.list({}, { includeDeleted: true }); 2184 const existingById = data.reduce((acc, record) => { 2185 acc[record.id] = record; 2186 return acc; 2187 }, {}); 2188 const newRecords = records.filter(record => { 2189 const localRecord = existingById[record.id]; 2190 const shouldKeep = 2191 // No local record with this id. 2192 localRecord === undefined || 2193 // Or local record is synced 2194 (localRecord._status === "synced" && 2195 // And was synced from server 2196 localRecord.last_modified !== undefined && 2197 // And is older than imported one. 2198 record.last_modified > localRecord.last_modified); 2199 return shouldKeep; 2200 }); 2201 return await this.db.importBulk(newRecords.map(markSynced)); 2202 } 2203 async pullMetadata(client, options = {}) { 2204 const { expectedTimestamp, headers } = options; 2205 const query = expectedTimestamp 2206 ? { query: { _expected: expectedTimestamp } } 2207 : undefined; 2208 const metadata = await client.getData(Object.assign(Object.assign({}, query), { headers })); 2209 return this.db.saveMetadata(metadata); 2210 } 2211 async metadata() { 2212 return this.db.getMetadata(); 2213 } 2214 } 2215 /** 2216 * A Collection-oriented wrapper for an adapter's transaction. 2217 * 2218 * This defines the high-level functions available on a collection. 2219 * The collection itself offers functions of the same name. These will 2220 * perform just one operation in its own transaction. 2221 */ 2222 class CollectionTransaction { 2223 constructor(collection, adapterTransaction) { 2224 this.collection = collection; 2225 this.adapterTransaction = adapterTransaction; 2226 this._events = []; 2227 } 2228 _queueEvent(action, payload) { 2229 this._events.push({ action, payload }); 2230 } 2231 /** 2232 * Emit queued events, to be called once every transaction operations have 2233 * been executed successfully. 2234 */ 2235 emitEvents() { 2236 for (const { action, payload } of this._events) { 2237 this.collection.events.emit(action, payload); 2238 } 2239 if (this._events.length > 0) { 2240 const targets = this._events.map(({ action, payload }) => (Object.assign({ action }, payload))); 2241 this.collection.events.emit("change", { targets }); 2242 } 2243 this._events = []; 2244 } 2245 /** 2246 * Retrieve a record by its id from the local database, or 2247 * undefined if none exists. 2248 * 2249 * This will also return virtually deleted records. 2250 * 2251 * @param {String} id 2252 * @return {Object} 2253 */ 2254 getAny(id) { 2255 const record = this.adapterTransaction.get(id); 2256 return { data: record, permissions: {} }; 2257 } 2258 /** 2259 * Retrieve a record by its id from the local database. 2260 * 2261 * Options: 2262 * - {Boolean} includeDeleted: Include virtually deleted records. 2263 * 2264 * @param {String} id 2265 * @param {Object} options 2266 * @return {Object} 2267 */ 2268 get(id, options = { includeDeleted: false }) { 2269 const res = this.getAny(id); 2270 if (!res.data || 2271 (!options.includeDeleted && res.data._status === "deleted")) { 2272 throw new Error(`Record with id=${id} not found.`); 2273 } 2274 return res; 2275 } 2276 /** 2277 * Deletes a record from the local database. 2278 * 2279 * Options: 2280 * - {Boolean} virtual: When set to `true`, doesn't actually delete the record, 2281 * update its `_status` attribute to `deleted` instead (default: true) 2282 * 2283 * @param {String} id The record's Id. 2284 * @param {Object} options The options object. 2285 * @return {Object} 2286 */ 2287 delete(id, options = { virtual: true }) { 2288 // Ensure the record actually exists. 2289 const existing = this.adapterTransaction.get(id); 2290 const alreadyDeleted = existing && existing._status == "deleted"; 2291 if (!existing || (alreadyDeleted && options.virtual)) { 2292 throw new Error(`Record with id=${id} not found.`); 2293 } 2294 // Virtual updates status. 2295 if (options.virtual) { 2296 this.adapterTransaction.update(markDeleted(existing)); 2297 } 2298 else { 2299 // Delete for real. 2300 this.adapterTransaction.delete(id); 2301 } 2302 this._queueEvent("delete", { data: existing }); 2303 return { data: existing, permissions: {} }; 2304 } 2305 /** 2306 * Soft delete all records from the local database. 2307 * 2308 * @param {Array} ids Array of non-deleted Record Ids. 2309 * @return {Object} 2310 */ 2311 deleteAll(ids) { 2312 const existingRecords = []; 2313 ids.forEach(id => { 2314 existingRecords.push(this.adapterTransaction.get(id)); 2315 this.delete(id); 2316 }); 2317 this._queueEvent("deleteAll", { data: existingRecords }); 2318 return { data: existingRecords, permissions: {} }; 2319 } 2320 /** 2321 * Deletes a record from the local database, if any exists. 2322 * Otherwise, do nothing. 2323 * 2324 * @param {String} id The record's Id. 2325 * @return {Object} 2326 */ 2327 deleteAny(id) { 2328 const existing = this.adapterTransaction.get(id); 2329 if (existing) { 2330 this.adapterTransaction.update(markDeleted(existing)); 2331 this._queueEvent("delete", { data: existing }); 2332 } 2333 return { data: Object.assign({ id }, existing), deleted: !!existing, permissions: {} }; 2334 } 2335 /** 2336 * Adds a record to the local database, asserting that none 2337 * already exist with this ID. 2338 * 2339 * @param {Object} record, which must contain an ID 2340 * @return {Object} 2341 */ 2342 create(record) { 2343 if (typeof record !== "object") { 2344 throw new Error("Record is not an object."); 2345 } 2346 if (!Object.prototype.hasOwnProperty.call(record, "id")) { 2347 throw new Error("Cannot create a record missing id"); 2348 } 2349 if (!this.collection.idSchema.validate(record.id)) { 2350 throw new Error(`Invalid Id: ${record.id}`); 2351 } 2352 this.adapterTransaction.create(record); 2353 this._queueEvent("create", { data: record }); 2354 return { data: record, permissions: {} }; 2355 } 2356 /** 2357 * Updates a record from the local database. 2358 * 2359 * Options: 2360 * - {Boolean} synced: Sets record status to "synced" (default: false) 2361 * - {Boolean} patch: Extends the existing record instead of overwriting it 2362 * (default: false) 2363 * 2364 * @param {Object} record 2365 * @param {Object} options 2366 * @return {Object} 2367 */ 2368 update(record, options = { synced: false, patch: false }) { 2369 if (typeof record !== "object") { 2370 throw new Error("Record is not an object."); 2371 } 2372 if (!Object.prototype.hasOwnProperty.call(record, "id")) { 2373 throw new Error("Cannot update a record missing id."); 2374 } 2375 if (!this.collection.idSchema.validate(record.id)) { 2376 throw new Error(`Invalid Id: ${record.id}`); 2377 } 2378 const oldRecord = this.adapterTransaction.get(record.id); 2379 if (!oldRecord) { 2380 throw new Error(`Record with id=${record.id} not found.`); 2381 } 2382 const newRecord = options.patch ? Object.assign(Object.assign({}, oldRecord), record) : record; 2383 const updated = this._updateRaw(oldRecord, newRecord, options); 2384 this.adapterTransaction.update(updated); 2385 this._queueEvent("update", { data: updated, oldRecord }); 2386 return { data: updated, oldRecord, permissions: {} }; 2387 } 2388 /** 2389 * Lower-level primitive for updating a record while respecting 2390 * _status and last_modified. 2391 * 2392 * @param {Object} oldRecord: the record retrieved from the DB 2393 * @param {Object} newRecord: the record to replace it with 2394 * @return {Object} 2395 */ 2396 _updateRaw(oldRecord, newRecord, { synced = false } = {}) { 2397 const updated = Object.assign({}, newRecord); 2398 // Make sure to never loose the existing timestamp. 2399 if (oldRecord && oldRecord.last_modified && !updated.last_modified) { 2400 updated.last_modified = oldRecord.last_modified; 2401 } 2402 // If only local fields have changed, then keep record as synced. 2403 // If status is created, keep record as created. 2404 // If status is deleted, mark as updated. 2405 const isIdentical = oldRecord && 2406 recordsEqual(oldRecord, updated, this.collection.localFields); 2407 const keepSynced = isIdentical && oldRecord._status == "synced"; 2408 const neverSynced = !oldRecord || (oldRecord && oldRecord._status == "created"); 2409 const newStatus = keepSynced || synced ? "synced" : neverSynced ? "created" : "updated"; 2410 return markStatus(updated, newStatus); 2411 } 2412 /** 2413 * Upsert a record into the local database. 2414 * 2415 * This record must have an ID. 2416 * 2417 * If a record with this ID already exists, it will be replaced. 2418 * Otherwise, this record will be inserted. 2419 * 2420 * @param {Object} record 2421 * @return {Object} 2422 */ 2423 upsert(record) { 2424 if (typeof record !== "object") { 2425 throw new Error("Record is not an object."); 2426 } 2427 if (!Object.prototype.hasOwnProperty.call(record, "id")) { 2428 throw new Error("Cannot update a record missing id."); 2429 } 2430 if (!this.collection.idSchema.validate(record.id)) { 2431 throw new Error(`Invalid Id: ${record.id}`); 2432 } 2433 let oldRecord = this.adapterTransaction.get(record.id); 2434 const updated = this._updateRaw(oldRecord, record); 2435 this.adapterTransaction.update(updated); 2436 // Don't return deleted records -- pretend they are gone 2437 if (oldRecord && oldRecord._status == "deleted") { 2438 oldRecord = undefined; 2439 } 2440 if (oldRecord) { 2441 this._queueEvent("update", { data: updated, oldRecord }); 2442 } 2443 else { 2444 this._queueEvent("create", { data: updated }); 2445 } 2446 return { data: updated, oldRecord, permissions: {} }; 2447 } 2448 } 2449 2450 const DEFAULT_BUCKET_NAME = "default"; 2451 const DEFAULT_REMOTE = "http://localhost:8888/v1"; 2452 const DEFAULT_RETRY = 1; 2453 /** 2454 * KintoBase class. 2455 */ 2456 class KintoBase { 2457 /** 2458 * Provides a public access to the base adapter class. Users can create a 2459 * custom DB adapter by extending {@link BaseAdapter}. 2460 * 2461 * @type {Object} 2462 */ 2463 static get adapters() { 2464 return { 2465 BaseAdapter: BaseAdapter, 2466 }; 2467 } 2468 /** 2469 * Synchronization strategies. Available strategies are: 2470 * 2471 * - `MANUAL`: Conflicts will be reported in a dedicated array. 2472 * - `SERVER_WINS`: Conflicts are resolved using remote data. 2473 * - `CLIENT_WINS`: Conflicts are resolved using local data. 2474 * 2475 * @type {Object} 2476 */ 2477 static get syncStrategy() { 2478 return Collection.strategy; 2479 } 2480 /** 2481 * Constructor. 2482 * 2483 * Options: 2484 * - `{String}` `remote` The server URL to use. 2485 * - `{String}` `bucket` The collection bucket name. 2486 * - `{EventEmitter}` `events` Events handler. 2487 * - `{BaseAdapter}` `adapter` The base DB adapter class. 2488 * - `{Object}` `adapterOptions` Options given to the adapter. 2489 * - `{Object}` `headers` The HTTP headers to use. 2490 * - `{Object}` `retry` Number of retries when the server fails to process the request (default: `1`) 2491 * - `{String}` `requestMode` The HTTP CORS mode to use. 2492 * - `{Number}` `timeout` The requests timeout in ms (default: `5000`). 2493 * 2494 * @param {Object} options The options object. 2495 */ 2496 constructor(options = {}) { 2497 const defaults = { 2498 bucket: DEFAULT_BUCKET_NAME, 2499 remote: DEFAULT_REMOTE, 2500 retry: DEFAULT_RETRY, 2501 }; 2502 this._options = Object.assign(Object.assign({}, defaults), options); 2503 if (!this._options.adapter) { 2504 throw new Error("No adapter provided"); 2505 } 2506 this._api = null; 2507 /** 2508 * The event emitter instance. 2509 * @type {EventEmitter} 2510 */ 2511 this.events = this._options.events; 2512 } 2513 /** 2514 * The kinto HTTP client instance. 2515 * @type {KintoClient} 2516 */ 2517 get api() { 2518 const { events, headers, remote, requestMode, retry, timeout, } = this._options; 2519 if (!this._api) { 2520 this._api = new this.ApiClass(remote, { 2521 events, 2522 headers, 2523 requestMode, 2524 retry, 2525 timeout, 2526 }); 2527 } 2528 return this._api; 2529 } 2530 /** 2531 * Creates a {@link Collection} instance. The second (optional) parameter 2532 * will set collection-level options like e.g. `remoteTransformers`. 2533 * 2534 * @param {String} collName The collection name. 2535 * @param {Object} [options={}] Extra options or override client's options. 2536 * @param {Object} [options.idSchema] IdSchema instance (default: UUID) 2537 * @param {Object} [options.remoteTransformers] Array<RemoteTransformer> (default: `[]`]) 2538 * @param {Object} [options.hooks] Array<Hook> (default: `[]`]) 2539 * @param {Object} [options.localFields] Array<Field> (default: `[]`]) 2540 * @return {Collection} 2541 */ 2542 collection(collName, options = {}) { 2543 if (!collName) { 2544 throw new Error("missing collection name"); 2545 } 2546 const { bucket, events, adapter, adapterOptions } = Object.assign(Object.assign({}, this._options), options); 2547 const { idSchema, remoteTransformers, hooks, localFields } = options; 2548 return new Collection(bucket, collName, this, { 2549 events, 2550 adapter, 2551 adapterOptions, 2552 idSchema, 2553 remoteTransformers, 2554 hooks, 2555 localFields, 2556 }); 2557 } 2558 } 2559 2560 /* 2561 * 2562 * Licensed under the Apache License, Version 2.0 (the "License"); 2563 * you may not use this file except in compliance with the License. 2564 * You may obtain a copy of the License at 2565 * 2566 * http://www.apache.org/licenses/LICENSE-2.0 2567 * 2568 * Unless required by applicable law or agreed to in writing, software 2569 * distributed under the License is distributed on an "AS IS" BASIS, 2570 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 2571 * See the License for the specific language governing permissions and 2572 * limitations under the License. 2573 */ 2574 const lazy = {}; 2575 ChromeUtils.defineESModuleGetters(lazy, { 2576 EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", 2577 // Use standalone kinto-http module landed in FFx. 2578 KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs" 2579 }); 2580 export class Kinto extends KintoBase { 2581 static get adapters() { 2582 return { 2583 BaseAdapter, 2584 IDB, 2585 }; 2586 } 2587 get ApiClass() { 2588 return lazy.KintoHttpClient; 2589 } 2590 constructor(options = {}) { 2591 const events = {}; 2592 lazy.EventEmitter.decorate(events); 2593 const defaults = { 2594 adapter: IDB, 2595 events, 2596 }; 2597 super(Object.assign(Object.assign({}, defaults), options)); 2598 } 2599 collection(collName, options = {}) { 2600 const idSchema = { 2601 validate(id) { 2602 return typeof id == "string" && RE_RECORD_ID.test(id); 2603 }, 2604 generate() { 2605 return Services.uuid.generateUUID() 2606 .toString() 2607 .replace(/[{}]/g, ""); 2608 }, 2609 }; 2610 return super.collection(collName, Object.assign({ idSchema }, options)); 2611 } 2612 }