PushDB.sys.mjs (12977B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { IndexedDBHelper } from "resource://gre/modules/IndexedDBHelper.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineLazyGetter(lazy, "console", () => { 10 return console.createInstance({ 11 maxLogLevelPref: "dom.push.loglevel", 12 prefix: "PushDB", 13 }); 14 }); 15 16 export function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) { 17 lazy.console.debug("PushDB()"); 18 this._dbStoreName = dbStoreName; 19 this._keyPath = keyPath; 20 this._model = model; 21 22 // set the indexeddb database 23 this.initDBHelper(dbName, dbVersion, [dbStoreName]); 24 } 25 26 PushDB.prototype = { 27 __proto__: IndexedDBHelper.prototype, 28 29 toPushRecord(record) { 30 if (!record) { 31 return null; 32 } 33 return new this._model(record); 34 }, 35 36 isValidRecord(record) { 37 return ( 38 record && 39 typeof record.scope == "string" && 40 typeof record.originAttributes == "string" && 41 record.quota >= 0 && 42 typeof record[this._keyPath] == "string" 43 ); 44 }, 45 46 upgradeSchema(aTransaction, aDb, aOldVersion) { 47 if (aOldVersion <= 3) { 48 // XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old 49 // registrations away without even informing the app. 50 if (aDb.objectStoreNames.contains(this._dbStoreName)) { 51 aDb.deleteObjectStore(this._dbStoreName); 52 } 53 54 let objectStore = aDb.createObjectStore(this._dbStoreName, { 55 keyPath: this._keyPath, 56 }); 57 58 // index to fetch records based on endpoints. used by unregister 59 objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true }); 60 61 // index to fetch records by identifiers. 62 // In the current security model, the originAttributes distinguish between 63 // different 'apps' on the same origin. Since ServiceWorkers are 64 // same-origin to the scope they are registered for, the attributes and 65 // scope are enough to reconstruct a valid principal. 66 objectStore.createIndex("identifiers", ["scope", "originAttributes"], { 67 unique: true, 68 }); 69 objectStore.createIndex("originAttributes", "originAttributes", { 70 unique: false, 71 }); 72 } 73 74 if (aOldVersion < 4) { 75 let objectStore = aTransaction.objectStore(this._dbStoreName); 76 77 // index to fetch active and expired registrations. 78 objectStore.createIndex("quota", "quota", { unique: false }); 79 } 80 }, 81 82 /** 83 * @param aRecord 84 * The record to be added. 85 */ 86 87 put(aRecord) { 88 lazy.console.debug("put()", aRecord); 89 if (!this.isValidRecord(aRecord)) { 90 return Promise.reject( 91 new TypeError( 92 "Scope, originAttributes, and quota are required! " + 93 JSON.stringify(aRecord) 94 ) 95 ); 96 } 97 98 return new Promise((resolve, reject) => 99 this.newTxn( 100 "readwrite", 101 this._dbStoreName, 102 (aTxn, aStore) => { 103 aTxn.result = undefined; 104 105 aStore.put(aRecord).onsuccess = aEvent => { 106 lazy.console.debug( 107 "put: Request successful. Updated record", 108 aEvent.target.result 109 ); 110 aTxn.result = this.toPushRecord(aRecord); 111 }; 112 }, 113 resolve, 114 reject 115 ) 116 ); 117 }, 118 119 /** 120 * @param aKeyID 121 * The ID of record to be deleted. 122 */ 123 delete(aKeyID) { 124 lazy.console.debug("delete()"); 125 126 return new Promise((resolve, reject) => 127 this.newTxn( 128 "readwrite", 129 this._dbStoreName, 130 (aTxn, aStore) => { 131 lazy.console.debug("delete: Removing record", aKeyID); 132 aStore.get(aKeyID).onsuccess = event => { 133 aTxn.result = this.toPushRecord(event.target.result); 134 aStore.delete(aKeyID); 135 }; 136 }, 137 resolve, 138 reject 139 ) 140 ); 141 }, 142 143 // testFn(record) is called with a database record and should return true if 144 // that record should be deleted. 145 clearIf(testFn) { 146 lazy.console.debug("clearIf()"); 147 return new Promise((resolve, reject) => 148 this.newTxn( 149 "readwrite", 150 this._dbStoreName, 151 (aTxn, aStore) => { 152 aTxn.result = undefined; 153 154 aStore.openCursor().onsuccess = event => { 155 let cursor = event.target.result; 156 if (cursor) { 157 let record = this.toPushRecord(cursor.value); 158 if (testFn(record)) { 159 let deleteRequest = cursor.delete(); 160 deleteRequest.onerror = e => { 161 lazy.console.error( 162 "clearIf: Error removing record", 163 record.keyID, 164 e 165 ); 166 }; 167 } 168 cursor.continue(); 169 } 170 }; 171 }, 172 resolve, 173 reject 174 ) 175 ); 176 }, 177 178 getByPushEndpoint(aPushEndpoint) { 179 lazy.console.debug("getByPushEndpoint()"); 180 181 return new Promise((resolve, reject) => 182 this.newTxn( 183 "readonly", 184 this._dbStoreName, 185 (aTxn, aStore) => { 186 aTxn.result = undefined; 187 188 let index = aStore.index("pushEndpoint"); 189 index.get(aPushEndpoint).onsuccess = aEvent => { 190 let record = this.toPushRecord(aEvent.target.result); 191 lazy.console.debug("getByPushEndpoint: Got record", record); 192 aTxn.result = record; 193 }; 194 }, 195 resolve, 196 reject 197 ) 198 ); 199 }, 200 201 getByKeyID(aKeyID) { 202 lazy.console.debug("getByKeyID()"); 203 204 return new Promise((resolve, reject) => 205 this.newTxn( 206 "readonly", 207 this._dbStoreName, 208 (aTxn, aStore) => { 209 aTxn.result = undefined; 210 211 aStore.get(aKeyID).onsuccess = aEvent => { 212 let record = this.toPushRecord(aEvent.target.result); 213 lazy.console.debug("getByKeyID: Got record", record); 214 aTxn.result = record; 215 }; 216 }, 217 resolve, 218 reject 219 ) 220 ); 221 }, 222 223 /** 224 * Iterates over all records associated with an origin. 225 * 226 * @param {string} origin The origin, matched as a prefix against the scope. 227 * @param {string} originAttributes Additional origin attributes. Requires 228 * an exact match. 229 * @param {Function} callback A function with the signature `(record, 230 * cursor)`, called for each record. `record` is the registration, and 231 * `cursor` is an `IDBCursor`. 232 * @returns {Promise} Resolves once all records have been processed. 233 */ 234 forEachOrigin(origin, originAttributes, callback) { 235 lazy.console.debug("forEachOrigin()"); 236 237 return new Promise((resolve, reject) => 238 this.newTxn( 239 "readwrite", 240 this._dbStoreName, 241 (aTxn, aStore) => { 242 aTxn.result = undefined; 243 244 let index = aStore.index("identifiers"); 245 let range = IDBKeyRange.bound( 246 [origin, originAttributes], 247 [origin + "\x7f", originAttributes] 248 ); 249 index.openCursor(range).onsuccess = event => { 250 let cursor = event.target.result; 251 if (!cursor) { 252 return; 253 } 254 callback(this.toPushRecord(cursor.value), cursor); 255 cursor.continue(); 256 }; 257 }, 258 resolve, 259 reject 260 ) 261 ); 262 }, 263 264 // Perform a unique match against { scope, originAttributes } 265 getByIdentifiers(aPageRecord) { 266 lazy.console.debug("getByIdentifiers()", aPageRecord); 267 if (!aPageRecord.scope || aPageRecord.originAttributes == undefined) { 268 lazy.console.error( 269 "getByIdentifiers: Scope and originAttributes are required", 270 aPageRecord 271 ); 272 return Promise.reject(new TypeError("Invalid page record")); 273 } 274 275 return new Promise((resolve, reject) => 276 this.newTxn( 277 "readonly", 278 this._dbStoreName, 279 (aTxn, aStore) => { 280 aTxn.result = undefined; 281 282 let index = aStore.index("identifiers"); 283 let request = index.get( 284 IDBKeyRange.only([aPageRecord.scope, aPageRecord.originAttributes]) 285 ); 286 request.onsuccess = aEvent => { 287 aTxn.result = this.toPushRecord(aEvent.target.result); 288 }; 289 }, 290 resolve, 291 reject 292 ) 293 ); 294 }, 295 296 _getAllByKey(aKeyName, aKeyValue) { 297 return new Promise((resolve, reject) => 298 this.newTxn( 299 "readonly", 300 this._dbStoreName, 301 (aTxn, aStore) => { 302 aTxn.result = undefined; 303 304 let index = aStore.index(aKeyName); 305 // It seems ok to use getAll here, since unlike contacts or other 306 // high storage APIs, we don't expect more than a handful of 307 // registrations per domain, and usually only one. 308 let getAllReq = index.mozGetAll(aKeyValue); 309 getAllReq.onsuccess = aEvent => { 310 aTxn.result = aEvent.target.result.map(record => 311 this.toPushRecord(record) 312 ); 313 }; 314 }, 315 resolve, 316 reject 317 ) 318 ); 319 }, 320 321 // aOriginAttributes must be a string! 322 getAllByOriginAttributes(aOriginAttributes) { 323 if (typeof aOriginAttributes !== "string") { 324 return Promise.reject("Expected string!"); 325 } 326 return this._getAllByKey("originAttributes", aOriginAttributes); 327 }, 328 329 getAllKeyIDs() { 330 lazy.console.debug("getAllKeyIDs()"); 331 332 return new Promise((resolve, reject) => 333 this.newTxn( 334 "readonly", 335 this._dbStoreName, 336 (aTxn, aStore) => { 337 aTxn.result = undefined; 338 aStore.mozGetAll().onsuccess = event => { 339 aTxn.result = event.target.result.map(record => 340 this.toPushRecord(record) 341 ); 342 }; 343 }, 344 resolve, 345 reject 346 ) 347 ); 348 }, 349 350 _getAllByPushQuota(range) { 351 lazy.console.debug("getAllByPushQuota()"); 352 353 return new Promise((resolve, reject) => 354 this.newTxn( 355 "readonly", 356 this._dbStoreName, 357 (aTxn, aStore) => { 358 aTxn.result = []; 359 360 let index = aStore.index("quota"); 361 index.openCursor(range).onsuccess = event => { 362 let cursor = event.target.result; 363 if (cursor) { 364 aTxn.result.push(this.toPushRecord(cursor.value)); 365 cursor.continue(); 366 } 367 }; 368 }, 369 resolve, 370 reject 371 ) 372 ); 373 }, 374 375 getAllUnexpired() { 376 lazy.console.debug("getAllUnexpired()"); 377 return this._getAllByPushQuota(IDBKeyRange.lowerBound(1)); 378 }, 379 380 getAllExpired() { 381 lazy.console.debug("getAllExpired()"); 382 return this._getAllByPushQuota(IDBKeyRange.only(0)); 383 }, 384 385 /** 386 * Updates an existing push registration. 387 * 388 * @param {string} aKeyID The registration ID. 389 * @param {Function} aUpdateFunc A function that receives the existing 390 * registration record as its argument, and returns a new record. 391 * @returns {Promise} A promise resolved with either the updated record. 392 * Rejects if the record does not exist, or the function returns an invalid 393 * record. 394 */ 395 update(aKeyID, aUpdateFunc) { 396 return new Promise((resolve, reject) => 397 this.newTxn( 398 "readwrite", 399 this._dbStoreName, 400 (aTxn, aStore) => { 401 aStore.get(aKeyID).onsuccess = aEvent => { 402 aTxn.result = undefined; 403 404 let record = aEvent.target.result; 405 if (!record) { 406 throw new Error("Record " + aKeyID + " does not exist"); 407 } 408 let newRecord = aUpdateFunc(this.toPushRecord(record)); 409 if (!this.isValidRecord(newRecord)) { 410 lazy.console.error( 411 "update: Ignoring invalid update", 412 aKeyID, 413 newRecord 414 ); 415 throw new Error("Invalid update for record " + aKeyID); 416 } 417 function putRecord() { 418 let req = aStore.put(newRecord); 419 req.onsuccess = () => { 420 lazy.console.debug( 421 "update: Update successful", 422 aKeyID, 423 newRecord 424 ); 425 aTxn.result = newRecord; 426 }; 427 } 428 if (aKeyID === newRecord.keyID) { 429 putRecord(); 430 } else { 431 // If we changed the primary key, delete the old record to avoid 432 // unique constraint errors. 433 aStore.delete(aKeyID).onsuccess = putRecord; 434 } 435 }; 436 }, 437 resolve, 438 reject 439 ) 440 ); 441 }, 442 443 drop() { 444 lazy.console.debug("drop()"); 445 446 return new Promise((resolve, reject) => 447 this.newTxn( 448 "readwrite", 449 this._dbStoreName, 450 function txnCb(aTxn, aStore) { 451 aStore.clear(); 452 }, 453 resolve, 454 reject 455 ) 456 ); 457 }, 458 };