Database.sys.mjs (20970B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", 9 CommonUtils: "resource://services-common/utils.sys.mjs", 10 IDBHelpers: "resource://services-settings/IDBHelpers.sys.mjs", 11 ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", 12 Utils: "resource://services-settings/Utils.sys.mjs", 13 }); 14 15 ChromeUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log); 16 17 class EmptyDatabaseError extends Error { 18 constructor(cid) { 19 super(`"${cid}" has not been synced yet`); 20 this.name = "EmptyDatabaseError"; 21 } 22 } 23 24 /** 25 * Database is a tiny wrapper with the objective 26 * of providing major kinto-offline-client collection API. 27 * (with the objective of getting rid of kinto-offline-client) 28 */ 29 export class Database { 30 static get EmptyDatabaseError() { 31 return EmptyDatabaseError; 32 } 33 34 static destroy() { 35 return destroyIDB(); 36 } 37 38 constructor(identifier) { 39 ensureShutdownBlocker(); 40 this.identifier = identifier; 41 } 42 43 async list(options = {}) { 44 const { filters = {}, order = "" } = options; 45 let results = []; 46 let timestamp = null; 47 try { 48 await executeIDB( 49 ["timestamps", "records"], 50 (stores, rejectTransaction) => { 51 const [storeTimestamps, storeRecords] = stores; 52 // Since we cannot distinguish an empty collection from 53 // a collection that has not been synced yet, we also 54 // retrieve the timestamp for the collection. 55 storeTimestamps.get(this.identifier).onsuccess = evt => 56 (timestamp = evt.target.result); 57 58 // Fast-path the (very common) no-filters case 59 if (lazy.ObjectUtils.isEmpty(filters)) { 60 const range = IDBKeyRange.only(this.identifier); 61 const request = storeRecords.index("cid").getAll(range); 62 request.onsuccess = e => { 63 results = e.target.result; 64 }; 65 return; 66 } 67 // If we have filters, we need to iterate over the records 68 // and apply the filters to each record using a cursor. 69 const request = storeRecords 70 .index("cid") 71 .openCursor(IDBKeyRange.only(this.identifier)); 72 const objFilters = transformSubObjectFilters(filters); 73 request.onsuccess = event => { 74 try { 75 const cursor = event.target.result; 76 if (cursor) { 77 const { value } = cursor; 78 if (lazy.Utils.filterObject(objFilters, value)) { 79 results.push(value); 80 } 81 cursor.continue(); 82 } 83 } catch (ex) { 84 rejectTransaction(ex); 85 } 86 }; 87 }, 88 { mode: "readonly" } 89 ); 90 } catch (e) { 91 throw new lazy.IDBHelpers.IndexedDBError(e, "list()", this.identifier); 92 } 93 // Throw an error if consumer tries to list records of a collection 94 // that has not been synced yet. 95 if (results.length === 0 && !timestamp) { 96 throw new EmptyDatabaseError(this.identifier); 97 } 98 // Remove IDB key field from results. 99 for (const result of results) { 100 delete result._cid; 101 } 102 return order ? lazy.Utils.sortObjects(order, results) : results; 103 } 104 105 async importChanges(metadata, timestamp, records = [], options = {}) { 106 const { clear = false } = options; 107 const _cid = this.identifier; 108 try { 109 await executeIDB( 110 ["collections", "timestamps", "records"], 111 (stores, rejectTransaction) => { 112 const [storeMetadata, storeTimestamps, storeRecords] = stores; 113 114 if (clear) { 115 // Our index is over the _cid and id fields. We want to remove 116 // all of the items in the collection for which the object was 117 // created, ie with _cid == this.identifier. 118 // We would like to just tell IndexedDB: 119 // store.index(IDBKeyRange.only(this.identifier)).delete(); 120 // to delete all records matching the first part of the 2-part key. 121 // Unfortunately such an API does not exist. 122 // While we could iterate over the index with a cursor, we'd do 123 // a roundtrip to PBackground for each item. Once you have 1000 124 // items, the result is very slow because of all the overhead of 125 // jumping between threads and serializing/deserializing. 126 // So instead, we tell the store to delete everything between 127 // "our" _cid identifier, and what would be the next identifier 128 // (via lexicographical sorting). Unfortunately there does not 129 // seem to be a way to specify bounds for all items that share 130 // the same first part of the key using just that first part, hence 131 // the use of the hypothetical [] for the second part of the end of 132 // the bounds. 133 storeRecords.delete( 134 IDBKeyRange.bound([_cid], [_cid, []], false, true) 135 ); 136 } 137 138 // Store or erase metadata. 139 if (metadata === null) { 140 storeMetadata.delete(_cid); 141 } else if (metadata) { 142 storeMetadata.put({ cid: _cid, metadata }); 143 } 144 // Store or erase timestamp. 145 if (timestamp === null) { 146 storeTimestamps.delete(_cid); 147 } else if (timestamp) { 148 storeTimestamps.put({ cid: _cid, value: timestamp }); 149 } 150 151 if (!records.length) { 152 return; 153 } 154 155 // Separate tombstones from creations/updates. 156 const toDelete = records.filter(r => r.deleted); 157 const toInsert = records.filter(r => !r.deleted); 158 lazy.console.debug( 159 `${_cid} ${toDelete.length} to delete, ${toInsert.length} to insert` 160 ); 161 // Delete local records for each tombstone. 162 lazy.IDBHelpers.bulkOperationHelper( 163 storeRecords, 164 { 165 reject: rejectTransaction, 166 completion() { 167 // Overwrite all other data. 168 lazy.IDBHelpers.bulkOperationHelper( 169 storeRecords, 170 { 171 reject: rejectTransaction, 172 }, 173 "put", 174 toInsert.map(item => ({ ...item, _cid })) 175 ); 176 }, 177 }, 178 "delete", 179 toDelete.map(item => [_cid, item.id]) 180 ); 181 }, 182 { desc: "importChanges() in " + _cid } 183 ); 184 } catch (e) { 185 throw new lazy.IDBHelpers.IndexedDBError(e, "importChanges()", _cid); 186 } 187 } 188 189 async getLastModified() { 190 let entry = null; 191 try { 192 await executeIDB( 193 "timestamps", 194 store => { 195 store.get(this.identifier).onsuccess = e => (entry = e.target.result); 196 }, 197 { mode: "readonly" } 198 ); 199 } catch (e) { 200 throw new lazy.IDBHelpers.IndexedDBError( 201 e, 202 "getLastModified()", 203 this.identifier 204 ); 205 } 206 if (!entry) { 207 return null; 208 } 209 // Some distributions where released with a modified dump that did not 210 // contain timestamps for last_modified. Work around this here, and return 211 // the timestamp as zero, so that the entries should get updated. 212 if (isNaN(entry.value)) { 213 lazy.console.warn(`Local timestamp is NaN for ${this.identifier}`); 214 return 0; 215 } 216 return entry.value; 217 } 218 219 async getMetadata() { 220 let entry = null; 221 try { 222 await executeIDB( 223 "collections", 224 store => { 225 store.get(this.identifier).onsuccess = e => (entry = e.target.result); 226 }, 227 { mode: "readonly" } 228 ); 229 } catch (e) { 230 throw new lazy.IDBHelpers.IndexedDBError( 231 e, 232 "getMetadata()", 233 this.identifier 234 ); 235 } 236 return entry ? entry.metadata : null; 237 } 238 239 async getAttachment(attachmentId) { 240 let entry = null; 241 try { 242 await executeIDB( 243 "attachments", 244 store => { 245 store.get([this.identifier, attachmentId]).onsuccess = e => { 246 entry = e.target.result; 247 }; 248 }, 249 { mode: "readonly" } 250 ); 251 } catch (e) { 252 throw new lazy.IDBHelpers.IndexedDBError( 253 e, 254 "getAttachment()", 255 this.identifier 256 ); 257 } 258 return entry ? entry.attachment : null; 259 } 260 261 async saveAttachment(attachmentId, attachment) { 262 return await this.saveAttachments([[attachmentId, attachment]]); 263 } 264 265 async saveAttachments(idsAndBlobs) { 266 try { 267 await executeIDB( 268 "attachments", 269 store => { 270 for (const [attachmentId, attachment] of idsAndBlobs) { 271 if (attachment) { 272 store.put({ cid: this.identifier, attachmentId, attachment }); 273 } else { 274 store.delete([this.identifier, attachmentId]); 275 } 276 } 277 }, 278 { 279 desc: 280 "saveAttachments(<" + 281 idsAndBlobs.length + 282 " items>) in " + 283 this.identifier, 284 } 285 ); 286 } catch (e) { 287 throw new lazy.IDBHelpers.IndexedDBError( 288 e, 289 "saveAttachments()", 290 this.identifier 291 ); 292 } 293 } 294 295 async hasAttachments() { 296 let count = 0; 297 try { 298 const range = IDBKeyRange.bound( 299 [this.identifier], 300 [this.identifier, []], 301 false, 302 true 303 ); 304 await executeIDB( 305 "attachments", 306 store => { 307 store.count(range).onsuccess = e => { 308 count = e.target.result; 309 }; 310 }, 311 { mode: "readonly" } 312 ); 313 } catch (e) { 314 throw new lazy.IDBHelpers.IndexedDBError( 315 e, 316 "hasAttachments()", 317 this.identifier 318 ); 319 } 320 return count > 0; 321 } 322 323 /** 324 * Delete all attachments which don't match any record. 325 * 326 * Attachments are linked to records, except when a fixed `attachmentId` is used. 327 * A record can be updated or deleted, potentially by deleting a record and restoring an updated version 328 * of the record with the same ID. Potentially leaving orphaned attachments in the database. 329 * Since we run the pruning logic after syncing, any attachment without a 330 * matching record can be discarded as they will be unreachable forever. 331 * 332 * @param {Array<string>} excludeIds List of attachments IDs to exclude from pruning. 333 */ 334 async pruneAttachments(excludeIds) { 335 const _cid = this.identifier; 336 let deletedCount = 0; 337 try { 338 await executeIDB( 339 ["attachments", "records"], 340 async (stores, rejectTransaction) => { 341 const [attachmentsStore, recordsStore] = stores; 342 343 // List all stored attachments. 344 // All keys ≥ [_cid, ..] && < [_cid, []]. See comment in `importChanges()` 345 const rangeAllKeys = IDBKeyRange.bound( 346 [_cid], 347 [_cid, []], 348 false, 349 true 350 ); 351 const allAttachments = await new Promise((resolve, reject) => { 352 const request = attachmentsStore.getAll(rangeAllKeys); 353 request.onsuccess = e => resolve(e.target.result); 354 request.onerror = e => reject(e); 355 }); 356 if (!allAttachments.length) { 357 lazy.console.debug( 358 `${this.identifier} No attachments in IDB cache. Nothing to do.` 359 ); 360 return; 361 } 362 363 // List all stored records. 364 const allRecords = await new Promise((resolve, reject) => { 365 const rangeAllIndexed = IDBKeyRange.only(_cid); 366 const request = recordsStore.index("cid").getAll(rangeAllIndexed); 367 request.onsuccess = e => resolve(e.target.result); 368 request.onerror = e => reject(e); 369 }); 370 371 // Compare known records IDs to those stored along the attachments. 372 const currentRecordsIDs = new Set(allRecords.map(r => r.id)); 373 const attachmentsToDelete = allAttachments.reduce((acc, entry) => { 374 // Skip excluded attachments. 375 if (excludeIds.includes(entry.attachmentId)) { 376 return acc; 377 } 378 // Delete attachment if associated record does not exist. 379 if (!currentRecordsIDs.has(entry.attachment.record.id)) { 380 acc.push([_cid, entry.attachmentId]); 381 } 382 return acc; 383 }, []); 384 385 // Perform a bulk delete of all obsolete attachments. 386 lazy.console.debug( 387 `${this.identifier} Bulk delete ${attachmentsToDelete.length} obsolete attachments` 388 ); 389 lazy.IDBHelpers.bulkOperationHelper( 390 attachmentsStore, 391 { 392 reject: rejectTransaction, 393 }, 394 "delete", 395 attachmentsToDelete 396 ); 397 deletedCount = attachmentsToDelete.length; 398 }, 399 { desc: "pruneAttachments() in " + this.identifier } 400 ); 401 } catch (e) { 402 throw new lazy.IDBHelpers.IndexedDBError( 403 e, 404 "pruneAttachments()", 405 this.identifier 406 ); 407 } 408 return deletedCount; 409 } 410 411 async clear() { 412 try { 413 await this.importChanges(null, null, [], { clear: true }); 414 } catch (e) { 415 throw new lazy.IDBHelpers.IndexedDBError(e, "clear()", this.identifier); 416 } 417 } 418 419 /* 420 * Methods used by unit tests. 421 */ 422 423 async create(record) { 424 if (!("id" in record)) { 425 record = { ...record, id: lazy.CommonUtils.generateUUID() }; 426 } 427 try { 428 await executeIDB( 429 "records", 430 store => { 431 store.add({ ...record, _cid: this.identifier }); 432 }, 433 { desc: "create() in " + this.identifier } 434 ); 435 } catch (e) { 436 throw new lazy.IDBHelpers.IndexedDBError(e, "create()", this.identifier); 437 } 438 return record; 439 } 440 441 async update(record) { 442 try { 443 await executeIDB( 444 "records", 445 store => { 446 store.put({ ...record, _cid: this.identifier }); 447 }, 448 { desc: "update() in " + this.identifier } 449 ); 450 } catch (e) { 451 throw new lazy.IDBHelpers.IndexedDBError(e, "update()", this.identifier); 452 } 453 } 454 455 async delete(recordId) { 456 try { 457 await executeIDB( 458 "records", 459 store => { 460 store.delete([this.identifier, recordId]); // [_cid, id] 461 }, 462 { desc: "delete() in " + this.identifier } 463 ); 464 } catch (e) { 465 throw new lazy.IDBHelpers.IndexedDBError(e, "delete()", this.identifier); 466 } 467 } 468 } 469 470 let gDB = null; 471 let gDBPromise = null; 472 473 /** 474 * This function attempts to ensure `gDB` points to a valid database value. 475 * If gDB is already a database, it will do no-op (but this may take a 476 * microtask or two). 477 * If opening the database fails, it will throw an IndexedDBError. 478 */ 479 async function openIDB() { 480 // We can be called multiple times in a race; always ensure that when 481 // we complete, `gDB` is no longer null, but avoid doing the actual 482 // IndexedDB work more than once. 483 if (!gDBPromise) { 484 // Open and initialize/upgrade if needed. 485 gDBPromise = lazy.IDBHelpers.openIDB(); 486 } 487 let db = await gDBPromise; 488 if (!gDB) { 489 gDB = db; 490 } 491 } 492 493 const gPendingReadOnlyTransactions = new Set(); 494 const gPendingWriteOperations = new Set(); 495 /** 496 * Helper to wrap some IDBObjectStore operations into a promise. 497 * 498 * @param {IDBDatabase} db 499 * @param {string | string[]} storeNames - either a string or an array of strings. 500 * @param {function} callback 501 * @param {object} options 502 * @param {string} options.mode 503 * @param {string} options.desc for shutdown tracking. 504 */ 505 async function executeIDB(storeNames, callback, options = {}) { 506 if (!gDB) { 507 // Check if we're shutting down. Services.startup.shuttingDown will 508 // be true sooner, but is never true in xpcshell tests, so we check 509 // both that and a bool we set ourselves when `profile-before-change` 510 // starts. 511 if (gShutdownStarted || Services.startup.shuttingDown) { 512 throw new lazy.IDBHelpers.ShutdownError( 513 "The application is shutting down", 514 "execute()" 515 ); 516 } 517 await openIDB(); 518 } else { 519 // Even if we have a db, wait a tick to avoid making IndexedDB sad. 520 // We should be able to remove this once bug 1626935 is fixed. 521 await Promise.resolve(); 522 } 523 524 // Check for shutdown again as we've await'd something... 525 if (!gDB && (gShutdownStarted || Services.startup.shuttingDown)) { 526 throw new lazy.IDBHelpers.ShutdownError( 527 "The application is shutting down", 528 "execute()" 529 ); 530 } 531 532 // Start the actual transaction: 533 const { mode = "readwrite", desc = "" } = options; 534 let { promise, transaction } = lazy.IDBHelpers.executeIDB( 535 gDB, 536 storeNames, 537 mode, 538 callback, 539 desc 540 ); 541 542 // We track all readonly transactions and abort them at shutdown. 543 // We track all readwrite ones and await their completion at shutdown 544 // (to avoid dataloss when writes fail). 545 // We use a `.finally()` clause for this; it'll run the function irrespective 546 // of whether the promise resolves or rejects, and the promise it returns 547 // will resolve/reject with the same value. 548 let finishedFn; 549 if (mode == "readonly") { 550 gPendingReadOnlyTransactions.add(transaction); 551 finishedFn = () => gPendingReadOnlyTransactions.delete(transaction); 552 } else { 553 let obj = { promise, desc }; 554 gPendingWriteOperations.add(obj); 555 finishedFn = () => gPendingWriteOperations.delete(obj); 556 } 557 return promise.finally(finishedFn); 558 } 559 560 async function destroyIDB() { 561 if (gDB) { 562 if (gShutdownStarted || Services.startup.shuttingDown) { 563 throw new lazy.IDBHelpers.ShutdownError( 564 "The application is shutting down", 565 "destroyIDB()" 566 ); 567 } 568 569 // This will return immediately; the actual close will happen once 570 // there are no more running transactions. 571 gDB.close(); 572 const allTransactions = new Set([ 573 ...gPendingWriteOperations, 574 ...gPendingReadOnlyTransactions, 575 ]); 576 for (let transaction of Array.from(allTransactions)) { 577 try { 578 transaction.abort(); 579 } catch (ex) { 580 // Ignore errors to abort transactions, we'll destroy everything. 581 } 582 } 583 } 584 gDB = null; 585 gDBPromise = null; 586 return lazy.IDBHelpers.destroyIDB(); 587 } 588 589 function makeNestedObjectFromArr(arr, val, nestedFiltersObj) { 590 const last = arr.length - 1; 591 return arr.reduce((acc, cv, i) => { 592 if (i === last) { 593 return (acc[cv] = val); 594 } else if (Object.prototype.hasOwnProperty.call(acc, cv)) { 595 return acc[cv]; 596 } 597 return (acc[cv] = {}); 598 }, nestedFiltersObj); 599 } 600 601 function transformSubObjectFilters(filtersObj) { 602 const transformedFilters = {}; 603 for (const [key, val] of Object.entries(filtersObj)) { 604 const keysArr = key.split("."); 605 makeNestedObjectFromArr(keysArr, val, transformedFilters); 606 } 607 return transformedFilters; 608 } 609 610 // We need to expose this wrapper function so we can test 611 // shutdown handling. 612 Database._executeIDB = executeIDB; 613 614 let gShutdownStarted = false; 615 // Test-only helper to be able to test shutdown multiple times: 616 Database._cancelShutdown = () => { 617 gShutdownStarted = false; 618 }; 619 620 let gShutdownBlocker = false; 621 Database._shutdownHandler = () => { 622 gShutdownStarted = true; 623 const NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR = 0x80660006; 624 // Duplicate the list (to avoid it being modified) and then 625 // abort all read-only transactions. 626 for (let transaction of Array.from(gPendingReadOnlyTransactions)) { 627 try { 628 transaction.abort(); 629 } catch (ex) { 630 // Ensure we don't throw/break, because either way we're in shutdown. 631 632 // In particular, `transaction.abort` can throw if the transaction 633 // is complete, ie if we manage to get called in between the 634 // transaction completing, and our completion handler being called 635 // to remove the item from the set. We don't care about that. 636 if (ex.result != NS_ERROR_DOM_INDEXEDDB_NOT_ALLOWED_ERR) { 637 // Report any other errors: 638 console.error(ex); 639 } 640 } 641 } 642 if (gDB) { 643 // This will return immediately; the actual close will happen once 644 // there are no more running transactions. 645 gDB.close(); 646 gDB = null; 647 } 648 gDBPromise = null; 649 return Promise.allSettled( 650 Array.from(gPendingWriteOperations).map(op => op.promise) 651 ); 652 }; 653 654 function ensureShutdownBlocker() { 655 if (gShutdownBlocker) { 656 return; 657 } 658 gShutdownBlocker = true; 659 lazy.AsyncShutdown.profileBeforeChange.addBlocker( 660 "RemoteSettingsClient - finish IDB access.", 661 Database._shutdownHandler, 662 { 663 fetchState() { 664 return Array.from(gPendingWriteOperations).map(op => op.desc); 665 }, 666 } 667 ); 668 }