signal-desktop/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js
lilia e5b54d9b6a Bubble up InvalidStateErrors and log them
These may be indicative of a potentially fatal lack of disk space.

// FREEBIE
2016-08-11 12:22:32 -07:00

662 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['backbone', 'underscore'], factory);
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(require('backbone'), require('underscore'));
} else {
// Browser globals (root is window)
root.returnExports = factory(root.Backbone, root._);
}
}(this, function (Backbone, _) {
// Generate four random hex digits.
function S4() {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
}
// Generate a pseudo-GUID by concatenating random hexadecimal.
function guid() {
return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4());
}
if ( _(indexedDB).isUndefined() ) { return; }
// Driver object
// That's the interesting part.
// There is a driver for each schema provided. The schema is a te combination of name (for the database), a version as well as migrations to reach that
// version of the database.
function Driver(schema, ready, nolog, onerror) {
this.schema = schema;
this.ready = ready;
this.error = null;
this.transactions = []; // Used to list all transactions and keep track of active ones.
this.db = null;
this.nolog = nolog;
this.onerror = onerror;
var lastMigrationPathVersion = _.last(this.schema.migrations).version;
if (!this.nolog) debugLog("opening database " + this.schema.id + " in version #" + lastMigrationPathVersion);
this.dbRequest = indexedDB.open(this.schema.id,lastMigrationPathVersion); //schema version need to be an unsigned long
this.launchMigrationPath = function(dbVersion) {
var transaction = this.dbRequest.transaction;
var clonedMigrations = _.clone(schema.migrations);
this.migrate(transaction, clonedMigrations, dbVersion, {
error: function (event) {
this.error = "Database not up to date. " + dbVersion + " expected was " + lastMigrationPathVersion;
}.bind(this)
});
};
this.dbRequest.onblocked = function(event){
if (!this.nolog) debugLog("connection to database blocked");
}
this.dbRequest.onsuccess = function (e) {
this.db = e.target.result; // Attach the connection ot the queue.
var currentIntDBVersion = (parseInt(this.db.version) || 0); // we need convert beacuse chrome store in integer and ie10 DP4+ in int;
var lastMigrationInt = (parseInt(lastMigrationPathVersion) || 0); // And make sure we compare numbers with numbers.
if (currentIntDBVersion === lastMigrationInt) { //if support new event onupgradeneeded will trigger the ready function
// No migration to perform!
this.ready();
} else if (currentIntDBVersion < lastMigrationInt ) {
// We need to migrate up to the current migration defined in the database
this.launchMigrationPath(currentIntDBVersion);
} else {
// Looks like the IndexedDB is at a higher version than the current driver schema.
this.error = "Database version is greater than current code " + currentIntDBVersion + " expected was " + lastMigrationInt;
}
}.bind(this);
this.dbRequest.onerror = function (e) {
// Failed to open the database
this.error = "Couldn't not connect to the database"
if (!this.nolog) debugLog("Couldn't not connect to the database");
this.onerror();
}.bind(this);
this.dbRequest.onabort = function (e) {
// Failed to open the database
this.error = "Connection to the database aborted"
if (!this.nolog) debugLog("Connection to the database aborted");
this.onerror();
}.bind(this);
this.dbRequest.onupgradeneeded = function(iDBVersionChangeEvent){
this.db =iDBVersionChangeEvent.target.result;
var newVersion = iDBVersionChangeEvent.newVersion;
var oldVersion = iDBVersionChangeEvent.oldVersion;
// Fix Safari 8 and iOS 8 bug
// at the first connection oldVersion is equal to 9223372036854776000
// but the real value is 0
if (oldVersion > 99999999999)
oldVersion = 0;
if (!this.nolog) debugLog("onupgradeneeded = " + oldVersion + " => " + newVersion);
this.launchMigrationPath(oldVersion);
}.bind(this);
}
function debugLog(str) {
if (typeof window !== "undefined" && typeof window.console !== "undefined" && typeof window.console.log !== "undefined") {
window.console.log(str);
}
else if(console.log !== "undefined") {
console.log(str)
}
}
// Driver Prototype
Driver.prototype = {
// Tracks transactions. Mostly for debugging purposes. TO-IMPROVE
_track_transaction: function(transaction) {
this.transactions.push(transaction);
function removeIt() {
var idx = this.transactions.indexOf(transaction);
if (idx !== -1) {this.transactions.splice(idx); }
};
transaction.oncomplete = removeIt.bind(this);
transaction.onabort = removeIt.bind(this);
transaction.onerror = removeIt.bind(this);
},
// Performs all the migrations to reach the right version of the database.
migrate: function (transaction, migrations, version, options) {
transaction.onerror = options.error;
transaction.onabort = options.error;
if (!this.nolog) debugLog("migrate begin version from #" + version);
var that = this;
var migration = migrations.shift();
if (migration) {
if (!version || version < migration.version) {
// We need to apply this migration-
if (typeof migration.before == "undefined") {
migration.before = function (next) {
next();
};
}
if (typeof migration.after == "undefined") {
migration.after = function (next) {
next();
};
}
// First, let's run the before script
if (!this.nolog) debugLog("migrate begin before version #" + migration.version);
migration.before(function () {
if (!this.nolog) debugLog("migrate done before version #" + migration.version);
if (!this.nolog) debugLog("migrate begin migrate version #" + migration.version);
migration.migrate(transaction, function () {
if (!this.nolog) debugLog("migrate done migrate version #" + migration.version);
// Migration successfully appliedn let's go to the next one!
if (!this.nolog) debugLog("migrate begin after version #" + migration.version);
migration.after(function () {
if (!this.nolog) debugLog("migrate done after version #" + migration.version);
if (!this.nolog) debugLog("Migrated to " + migration.version);
//last modification occurred, need finish
if(migrations.length ==0) {
if (!this.nolog) {
debugLog("migrate setting transaction.oncomplete to finish version #" + migration.version);
transaction.oncomplete = function() {
debugLog("migrate done transaction.oncomplete version #" + migration.version);
debugLog("Done migrating");
}
}
}
else
{
if (!this.nolog) debugLog("migrate end from version #" + version + " to " + migration.version);
that.migrate(transaction, migrations, version, options);
}
}.bind(this));
}.bind(this));
}.bind(this));
} else {
// No need to apply this migration
if (!this.nolog) debugLog("Skipping migration " + migration.version);
this.migrate(transaction, migrations, version, options);
}
}
},
// This is the main method, called by the ExecutionQueue when the driver is ready (database open and migration performed)
execute: function (storeName, method, object, options) {
if (!this.nolog) debugLog("execute : " + method + " on " + storeName + " for " + object.id);
switch (method) {
case "create":
this.create(storeName, object, options);
break;
case "read":
if (object.id || object.cid) {
this.read(storeName, object, options); // It's a model
} else {
this.query(storeName, object, options); // It's a collection
}
break;
case "update":
this.update(storeName, object, options); // We may want to check that this is not a collection. TOFIX
break;
case "delete":
if (object.id || object.cid) {
this.delete(storeName, object, options);
} else {
this.clear(storeName, object, options);
}
break;
default:
// Hum what?
}
},
// Writes the json to the storeName in db. It is a create operations, which means it will fail if the key already exists
// options are just success and error callbacks.
create: function (storeName, object, options) {
var writeTransaction = this.db.transaction([storeName], 'readwrite');
//this._track_transaction(writeTransaction);
var store = writeTransaction.objectStore(storeName);
var json = object.toJSON();
var idAttribute = _.result(object, 'idAttribute');
var writeRequest;
if (json[idAttribute] === undefined && !store.autoIncrement) json[idAttribute] = guid();
writeTransaction.onerror = function (e) {
options.error(e);
};
writeTransaction.oncomplete = function (e) {
options.success(json);
};
if (!store.keyPath)
writeRequest = store.add(json, json[idAttribute]);
else
writeRequest = store.add(json);
},
// Writes the json to the storeName in db. It is an update operation, which means it will overwrite the value if the key already exist
// options are just success and error callbacks.
update: function (storeName, object, options) {
var writeTransaction = this.db.transaction([storeName], 'readwrite');
//this._track_transaction(writeTransaction);
var store = writeTransaction.objectStore(storeName);
var json = object.toJSON();
var idAttribute = _.result(object, 'idAttribute');
var writeRequest;
if (!json[idAttribute]) json[idAttribute] = guid();
if (!store.keyPath)
writeRequest = store.put(json, json[idAttribute]);
else
writeRequest = store.put(json);
writeRequest.onerror = function (e) {
options.error(e);
};
writeTransaction.oncomplete = function (e) {
options.success(json);
};
},
// Reads from storeName in db with json.id if it's there of with any json.xxxx as long as xxx is an index in storeName
read: function (storeName, object, options) {
var readTransaction = this.db.transaction([storeName], "readonly");
this._track_transaction(readTransaction);
var store = readTransaction.objectStore(storeName);
var json = object.toJSON();
var idAttribute = _.result(object, 'idAttribute');
var getRequest = null;
if (json[idAttribute]) {
getRequest = store.get(json[idAttribute]);
} else if(options.index) {
var index = store.index(options.index.name);
getRequest = index.get(options.index.value);
} else {
// We need to find which index we have
var cardinality = 0; // try to fit the index with most matches
_.each(store.indexNames, function (key, index) {
index = store.index(key);
if(typeof index.keyPath === 'string' && 1 > cardinality) {
// simple index
if (json[index.keyPath] !== undefined) {
getRequest = index.get(json[index.keyPath]);
cardinality = 1;
}
} else if(typeof index.keyPath === 'object' && index.keyPath.length > cardinality) {
// compound index
var valid = true;
var keyValue = _.map(index.keyPath, function(keyPart) {
valid = valid && json[keyPart] !== undefined;
return json[keyPart];
});
if(valid) {
getRequest = index.get(keyValue);
cardinality = index.keyPath.length;
}
}
});
}
if (getRequest) {
getRequest.onsuccess = function (event) {
if (event.target.result) {
options.success(event.target.result);
} else {
options.error("Not Found");
}
};
getRequest.onerror = function () {
options.error("Not Found"); // We couldn't find the record.
}
} else {
options.error("Not Found"); // We couldn't even look for it, as we don't have enough data.
}
},
// Deletes the json.id key and value in storeName from db.
delete: function (storeName, object, options) {
var deleteTransaction = this.db.transaction([storeName], 'readwrite');
//this._track_transaction(deleteTransaction);
var store = deleteTransaction.objectStore(storeName);
var json = object.toJSON();
var idAttribute = _.result(object, 'idAttribute');
var deleteRequest = store.delete(json[idAttribute]);
deleteTransaction.oncomplete = function (event) {
options.success(null);
};
deleteRequest.onerror = function (event) {
options.error("Not Deleted");
};
},
// Clears all records for storeName from db.
clear: function (storeName, object, options) {
var deleteTransaction = this.db.transaction([storeName], "readwrite");
//this._track_transaction(deleteTransaction);
var store = deleteTransaction.objectStore(storeName);
var deleteRequest = store.clear();
deleteRequest.onsuccess = function (event) {
options.success(null);
};
deleteRequest.onerror = function (event) {
options.error("Not Cleared");
};
},
// Performs a query on storeName in db.
// options may include :
// - conditions : value of an index, or range for an index
// - range : range for the primary key
// - limit : max number of elements to be yielded
// - offset : skipped items.
query: function (storeName, collection, options) {
var elements = [];
var skipped = 0, processed = 0;
var queryTransaction = this.db.transaction([storeName], "readonly");
//this._track_transaction(queryTransaction);
var idAttribute = _.result(collection.model.prototype, 'idAttribute');
var readCursor = null;
var store = queryTransaction.objectStore(storeName);
var index = null,
lower = null,
upper = null,
bounds = null;
if (options.conditions) {
// We have a condition, we need to use it for the cursor
_.each(store.indexNames, function (key) {
if (!readCursor) {
index = store.index(key);
if (options.conditions[index.keyPath] instanceof Array) {
lower = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][1] : options.conditions[index.keyPath][0];
upper = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][0] : options.conditions[index.keyPath][1];
bounds = IDBKeyRange.bound(lower, upper, true, true);
if (options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1]) {
// Looks like we want the DESC order
readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev");
} else {
// We want ASC order
readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next");
}
} else if (typeof options.conditions[index.keyPath] === 'object' && ('$gt' in options.conditions[index.keyPath] || '$gte' in options.conditions[index.keyPath])) {
if('$gt' in options.conditions[index.keyPath])
bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gt'], true);
else
bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gte']);
readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next");
} else if (typeof options.conditions[index.keyPath] === 'object' && ('$lt' in options.conditions[index.keyPath] || '$lte' in options.conditions[index.keyPath])) {
if('$lt' in options.conditions[index.keyPath])
bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lt'], true);
else
bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lte']);
readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next");
} else if (options.conditions[index.keyPath] != undefined) {
bounds = IDBKeyRange.only(options.conditions[index.keyPath]);
readCursor = index.openCursor(bounds);
}
}
});
} else if (options.index) {
index = store.index(options.index.name);
var excludeLower = !!options.index.excludeLower;
var excludeUpper = !!options.index.excludeUpper;
if (index) {
if (options.index.lower && options.index.upper) {
bounds = IDBKeyRange.bound(options.index.lower, options.index.upper, excludeLower, excludeUpper);
} else if (options.index.lower) {
bounds = IDBKeyRange.lowerBound(options.index.lower, excludeLower);
} else if (options.index.upper) {
bounds = IDBKeyRange.upperBound(options.index.upper, excludeUpper);
} else if (options.index.only) {
bounds = IDBKeyRange.only(options.index.only);
}
if (typeof options.index.order === 'string' && options.index.order.toLowerCase() === 'desc') {
readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev");
} else {
readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next");
}
}
} else {
// No conditions, use the index
if (options.range) {
lower = options.range[0] > options.range[1] ? options.range[1] : options.range[0];
upper = options.range[0] > options.range[1] ? options.range[0] : options.range[1];
bounds = IDBKeyRange.bound(lower, upper);
if (options.range[0] > options.range[1]) {
readCursor = store.openCursor(bounds, window.IDBCursor.PREV || "prev");
} else {
readCursor = store.openCursor(bounds, window.IDBCursor.NEXT || "next");
}
} else {
readCursor = store.openCursor();
}
}
if (typeof (readCursor) == "undefined" || !readCursor) {
options.error("No Cursor");
} else {
readCursor.onerror = function(e){
options.error("readCursor error", e);
};
// Setup a handler for the cursors `success` event:
readCursor.onsuccess = function (e) {
var cursor = e.target.result;
if (!cursor) {
if (options.addIndividually || options.clear) {
// nothing!
// We need to indicate that we're done. But, how?
collection.trigger("reset");
} else {
options.success(elements); // We're done. No more elements.
}
}
else {
// Cursor is not over yet.
if (options.limit && processed >= options.limit) {
// Yet, we have processed enough elements. So, let's just skip.
if (bounds) {
if (options.conditions && options.conditions[index.keyPath]) {
cursor.continue(options.conditions[index.keyPath][1] + 1); /* We need to 'terminate' the cursor cleany, by moving to the end */
} else if (options.index && (options.index.upper || options.index.lower)) {
if (typeof options.index.order === 'string' && options.index.order.toLowerCase() === 'desc') {
cursor.continue(options.index.lower);
} else {
cursor.continue(options.index.upper);
}
}
} else {
cursor.continue(); /* We need to 'terminate' the cursor cleany, by moving to the end */
}
}
else if (options.offset && options.offset > skipped) {
skipped++;
cursor.continue(); /* We need to Moving the cursor forward */
} else {
// This time, it looks like it's good!
if (options.addIndividually) {
collection.add(cursor.value);
} else if (options.clear) {
var deleteRequest = store.delete(cursor.value[idAttribute]);
deleteRequest.onsuccess = function (event) {
elements.push(cursor.value);
};
deleteRequest.onerror = function (event) {
elements.push(cursor.value);
};
} else {
elements.push(cursor.value);
}
processed++;
cursor.continue();
}
}
};
}
},
close :function(){
if(this.db){
this.db.close();
}
}
};
// ExecutionQueue object
// The execution queue is an abstraction to buffer up requests to the database.
// It holds a "driver". When the driver is ready, it just fires up the queue and executes in sync.
function ExecutionQueue(schema,next,nolog) {
this.driver = new Driver(schema, this.ready.bind(this), nolog, this.error.bind(this));
this.started = false;
this.failed = false;
this.stack = [];
this.version = _.last(schema.migrations).version;
this.next = next;
}
// ExecutionQueue Prototype
ExecutionQueue.prototype = {
// Called when the driver is ready
// It just loops over the elements in the queue and executes them.
ready: function () {
this.started = true;
_.each(this.stack, function (message) {
this.execute(message);
}.bind(this));
this.stack = []; // fix memory leak
this.next();
},
error: function() {
this.failed = true;
_.each(this.stack, function (message) {
this.execute(message);
}.bind(this));
this.stack = [];
this.next();
},
// Executes a given command on the driver. If not started, just stacks up one more element.
execute: function (message) {
if (this.started) {
try {
this.driver.execute(message[2].storeName || message[1].storeName, message[0], message[1], message[2]); // Upon messages, we execute the query
} catch (e) {
if (e.name === 'InvalidStateError') {
var f = window.onInvalidStateError;
if (f) f(e);
}
throw e;
}
} else if (this.failed) {
message[2].error();
} else {
this.stack.push(message);
}
},
close : function(){
this.driver.close();
}
};
// Method used by Backbone for sync of data with data store. It was initially designed to work with "server side" APIs, This wrapper makes
// it work with the local indexedDB stuff. It uses the schema attribute provided by the object.
// The wrapper keeps an active Executuon Queue for each "schema", and executes querues agains it, based on the object type (collection or
// single model), but also the method... etc.
// Keeps track of the connections
var Databases = {};
function sync(method, object, options) {
if(method == "closeall"){
_.each(Databases,function(database){
database.close();
});
// Clean up active databases object.
Databases = {};
return Backbone.$.Deferred().resolve();
}
// If a model or a collection does not define a database, fall back on ajaxSync
if (!object || !_.isObject(object.database)) {
return Backbone.ajaxSync(method, object, options);
}
var schema = object.database;
if (Databases[schema.id]) {
if(Databases[schema.id].version != _.last(schema.migrations).version){
Databases[schema.id].close();
delete Databases[schema.id];
}
}
var promise;
if (typeof Backbone.$ === 'undefined' || typeof Backbone.$.Deferred === 'undefined') {
var noop = function() {};
var resolve = noop;
var reject = noop;
} else {
var dfd = Backbone.$.Deferred();
var resolve = dfd.resolve;
var reject = dfd.reject;
promise = dfd.promise();
}
var success = options.success;
options.success = function(resp) {
if (success) success(resp);
resolve();
object.trigger('sync', object, resp, options);
};
var error = options.error;
options.error = function(resp) {
if (error) error(resp);
reject();
object.trigger('error', object, resp, options);
};
var next = function(){
Databases[schema.id].execute([method, object, options]);
};
if (!Databases[schema.id]) {
Databases[schema.id] = new ExecutionQueue(schema,next,schema.nolog);
} else {
next();
}
return promise;
};
Backbone.ajaxSync = Backbone.sync;
Backbone.sync = sync;
return { sync: sync, debugLog: debugLog};
}));