diff options
Diffstat (limited to 'dom/push/PushDB.jsm')
-rw-r--r-- | dom/push/PushDB.jsm | 440 |
1 files changed, 440 insertions, 0 deletions
diff --git a/dom/push/PushDB.jsm b/dom/push/PushDB.jsm new file mode 100644 index 000000000..02f623fa7 --- /dev/null +++ b/dom/push/PushDB.jsm @@ -0,0 +1,440 @@ +/* jshint moz: true, esnext: true */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cu = Components.utils; +Cu.import("resource://gre/modules/IndexedDBHelper.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.importGlobalProperties(["indexedDB"]); + +this.EXPORTED_SYMBOLS = ["PushDB"]; + +XPCOMUtils.defineLazyGetter(this, "console", () => { + let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {}); + return new ConsoleAPI({ + maxLogLevelPref: "dom.push.loglevel", + prefix: "PushDB", + }); +}); + +this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) { + console.debug("PushDB()"); + this._dbStoreName = dbStoreName; + this._keyPath = keyPath; + this._model = model; + + // set the indexeddb database + this.initDBHelper(dbName, dbVersion, + [dbStoreName]); +}; + +this.PushDB.prototype = { + __proto__: IndexedDBHelper.prototype, + + toPushRecord: function(record) { + if (!record) { + return; + } + return new this._model(record); + }, + + isValidRecord: function(record) { + return record && typeof record.scope == "string" && + typeof record.originAttributes == "string" && + record.quota >= 0 && + typeof record[this._keyPath] == "string"; + }, + + upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) { + if (aOldVersion <= 3) { + //XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old + //registrations away without even informing the app. + if (aDb.objectStoreNames.contains(this._dbStoreName)) { + aDb.deleteObjectStore(this._dbStoreName); + } + + let objectStore = aDb.createObjectStore(this._dbStoreName, + { keyPath: this._keyPath }); + + // index to fetch records based on endpoints. used by unregister + objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true }); + + // index to fetch records by identifiers. + // In the current security model, the originAttributes distinguish between + // different 'apps' on the same origin. Since ServiceWorkers are + // same-origin to the scope they are registered for, the attributes and + // scope are enough to reconstruct a valid principal. + objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true }); + objectStore.createIndex("originAttributes", "originAttributes", { unique: false }); + } + + if (aOldVersion < 4) { + let objectStore = aTransaction.objectStore(this._dbStoreName); + + // index to fetch active and expired registrations. + objectStore.createIndex("quota", "quota", { unique: false }); + } + }, + + /* + * @param aRecord + * The record to be added. + */ + + put: function(aRecord) { + console.debug("put()", aRecord); + if (!this.isValidRecord(aRecord)) { + return Promise.reject(new TypeError( + "Scope, originAttributes, and quota are required! " + + JSON.stringify(aRecord) + ) + ); + } + + return new Promise((resolve, reject) => + this.newTxn( + "readwrite", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + + aStore.put(aRecord).onsuccess = aEvent => { + console.debug("put: Request successful. Updated record", + aEvent.target.result); + aTxn.result = this.toPushRecord(aRecord); + }; + }, + resolve, + reject + ) + ); + }, + + /* + * @param aKeyID + * The ID of record to be deleted. + */ + delete: function(aKeyID) { + console.debug("delete()"); + + return new Promise((resolve, reject) => + this.newTxn( + "readwrite", + this._dbStoreName, + (aTxn, aStore) => { + console.debug("delete: Removing record", aKeyID); + aStore.get(aKeyID).onsuccess = event => { + aTxn.result = this.toPushRecord(event.target.result); + aStore.delete(aKeyID); + }; + }, + resolve, + reject + ) + ); + }, + + // testFn(record) is called with a database record and should return true if + // that record should be deleted. + clearIf: function(testFn) { + console.debug("clearIf()"); + return new Promise((resolve, reject) => + this.newTxn( + "readwrite", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + + aStore.openCursor().onsuccess = event => { + let cursor = event.target.result; + if (cursor) { + let record = this.toPushRecord(cursor.value); + if (testFn(record)) { + let deleteRequest = cursor.delete(); + deleteRequest.onerror = e => { + console.error("clearIf: Error removing record", + record.keyID, e); + } + } + cursor.continue(); + } + } + }, + resolve, + reject + ) + ); + }, + + getByPushEndpoint: function(aPushEndpoint) { + console.debug("getByPushEndpoint()"); + + return new Promise((resolve, reject) => + this.newTxn( + "readonly", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + + let index = aStore.index("pushEndpoint"); + index.get(aPushEndpoint).onsuccess = aEvent => { + let record = this.toPushRecord(aEvent.target.result); + console.debug("getByPushEndpoint: Got record", record); + aTxn.result = record; + }; + }, + resolve, + reject + ) + ); + }, + + getByKeyID: function(aKeyID) { + console.debug("getByKeyID()"); + + return new Promise((resolve, reject) => + this.newTxn( + "readonly", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + + aStore.get(aKeyID).onsuccess = aEvent => { + let record = this.toPushRecord(aEvent.target.result); + console.debug("getByKeyID: Got record", record); + aTxn.result = record; + }; + }, + resolve, + reject + ) + ); + }, + + /** + * Iterates over all records associated with an origin. + * + * @param {String} origin The origin, matched as a prefix against the scope. + * @param {String} originAttributes Additional origin attributes. Requires + * an exact match. + * @param {Function} callback A function with the signature `(record, + * cursor)`, called for each record. `record` is the registration, and + * `cursor` is an `IDBCursor`. + * @returns {Promise} Resolves once all records have been processed. + */ + forEachOrigin: function(origin, originAttributes, callback) { + console.debug("forEachOrigin()"); + + return new Promise((resolve, reject) => + this.newTxn( + "readwrite", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + + let index = aStore.index("identifiers"); + let range = IDBKeyRange.bound( + [origin, originAttributes], + [origin + "\x7f", originAttributes] + ); + index.openCursor(range).onsuccess = event => { + let cursor = event.target.result; + if (!cursor) { + return; + } + callback(this.toPushRecord(cursor.value), cursor); + cursor.continue(); + }; + }, + resolve, + reject + ) + ); + }, + + // Perform a unique match against { scope, originAttributes } + getByIdentifiers: function(aPageRecord) { + console.debug("getByIdentifiers()", aPageRecord); + if (!aPageRecord.scope || aPageRecord.originAttributes == undefined) { + console.error("getByIdentifiers: Scope and originAttributes are required", + aPageRecord); + return Promise.reject(new TypeError("Invalid page record")); + } + + return new Promise((resolve, reject) => + this.newTxn( + "readonly", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + + let index = aStore.index("identifiers"); + let request = index.get(IDBKeyRange.only([aPageRecord.scope, aPageRecord.originAttributes])); + request.onsuccess = aEvent => { + aTxn.result = this.toPushRecord(aEvent.target.result); + }; + }, + resolve, + reject + ) + ); + }, + + _getAllByKey: function(aKeyName, aKeyValue) { + return new Promise((resolve, reject) => + this.newTxn( + "readonly", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + + let index = aStore.index(aKeyName); + // It seems ok to use getAll here, since unlike contacts or other + // high storage APIs, we don't expect more than a handful of + // registrations per domain, and usually only one. + let getAllReq = index.mozGetAll(aKeyValue); + getAllReq.onsuccess = aEvent => { + aTxn.result = aEvent.target.result.map( + record => this.toPushRecord(record)); + }; + }, + resolve, + reject + ) + ); + }, + + // aOriginAttributes must be a string! + getAllByOriginAttributes: function(aOriginAttributes) { + if (typeof aOriginAttributes !== "string") { + return Promise.reject("Expected string!"); + } + return this._getAllByKey("originAttributes", aOriginAttributes); + }, + + getAllKeyIDs: function() { + console.debug("getAllKeyIDs()"); + + return new Promise((resolve, reject) => + this.newTxn( + "readonly", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = undefined; + aStore.mozGetAll().onsuccess = event => { + aTxn.result = event.target.result.map( + record => this.toPushRecord(record)); + }; + }, + resolve, + reject + ) + ); + }, + + _getAllByPushQuota: function(range) { + console.debug("getAllByPushQuota()"); + + return new Promise((resolve, reject) => + this.newTxn( + "readonly", + this._dbStoreName, + (aTxn, aStore) => { + aTxn.result = []; + + let index = aStore.index("quota"); + index.openCursor(range).onsuccess = event => { + let cursor = event.target.result; + if (cursor) { + aTxn.result.push(this.toPushRecord(cursor.value)); + cursor.continue(); + } + }; + }, + resolve, + reject + ) + ); + }, + + getAllUnexpired: function() { + console.debug("getAllUnexpired()"); + return this._getAllByPushQuota(IDBKeyRange.lowerBound(1)); + }, + + getAllExpired: function() { + console.debug("getAllExpired()"); + return this._getAllByPushQuota(IDBKeyRange.only(0)); + }, + + /** + * Updates an existing push registration. + * + * @param {String} aKeyID The registration ID. + * @param {Function} aUpdateFunc A function that receives the existing + * registration record as its argument, and returns a new record. + * @returns {Promise} A promise resolved with either the updated record. + * Rejects if the record does not exist, or the function returns an invalid + * record. + */ + update: function(aKeyID, aUpdateFunc) { + return new Promise((resolve, reject) => + this.newTxn( + "readwrite", + this._dbStoreName, + (aTxn, aStore) => { + aStore.get(aKeyID).onsuccess = aEvent => { + aTxn.result = undefined; + + let record = aEvent.target.result; + if (!record) { + throw new Error("Record " + aKeyID + " does not exist"); + } + let newRecord = aUpdateFunc(this.toPushRecord(record)); + if (!this.isValidRecord(newRecord)) { + console.error("update: Ignoring invalid update", + aKeyID, newRecord); + throw new Error("Invalid update for record " + aKeyID); + } + function putRecord() { + let req = aStore.put(newRecord); + req.onsuccess = aEvent => { + console.debug("update: Update successful", aKeyID, newRecord); + aTxn.result = newRecord; + }; + } + if (aKeyID === newRecord.keyID) { + putRecord(); + } else { + // If we changed the primary key, delete the old record to avoid + // unique constraint errors. + aStore.delete(aKeyID).onsuccess = putRecord; + } + }; + }, + resolve, + reject + ) + ); + }, + + drop: function() { + console.debug("drop()"); + + return new Promise((resolve, reject) => + this.newTxn( + "readwrite", + this._dbStoreName, + function txnCb(aTxn, aStore) { + aStore.clear(); + }, + resolve, + reject + ) + ); + }, +}; |