(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 cursor’s `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}; }));