├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── README.markdown ├── backbone-indexeddb.js ├── lib ├── backbone-min.js ├── jquery.min.js ├── qunit.css ├── qunit.js └── underscore-min.js ├── license.txt ├── package.json └── tests ├── backbone-indexeddbTests.js ├── jsTestDriver.conf ├── mockAjax.json ├── test.html └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | before_script: 5 | - npm install -g grunt-cli -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | grunt.initConfig({ 3 | connect: { 4 | server: { 5 | options: { 6 | base: '', 7 | port: 9999 8 | } 9 | } 10 | }, 11 | 12 | 'saucelabs-qunit': { 13 | all: { 14 | options: { 15 | username: 'idbbackbone', 16 | key: '6ffa8cb3-47f3-4532-aedb-aee964d0eefb', 17 | urls: ['http://127.0.0.1:9999/tests/test.html'], 18 | build: process.env.TRAVIS_JOB_ID, 19 | concurrency: 2, 20 | testname: 'qunit tests', 21 | browsers: [{ 22 | browserName: 'chrome', 23 | platform: 'linux' 24 | }] 25 | } 26 | } 27 | }, 28 | watch: {} 29 | }); 30 | 31 | // Load dependencies 32 | for (var key in grunt.file.readJSON('package.json').devDependencies) { 33 | if (key !== 'grunt' && key.indexOf('grunt') === 0) grunt.loadNpmTasks(key); 34 | } 35 | 36 | grunt.registerTask('default', ['connect', 'watch']); 37 | grunt.registerTask('test', ['connect', 'saucelabs-qunit']); 38 | }; 39 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | [![build status](https://secure.travis-ci.org/superfeedr/indexeddb-backbonejs-adapter.png)](http://travis-ci.org/superfeedr/indexeddb-backbonejs-adapter) 2 | This is an [IndexedDB](http://www.w3.org/TR/IndexedDB/) adapter for [Backbone.js](http://documentcloud.github.com/backbone/). 3 | 4 | # Warnings 5 | 6 | *It lacks a lot of documentation, so it's good idea to look at the tests if you're interested in using it.* 7 | 8 | # Browser support and limitations 9 | 10 | In Firefox, `backbone-indexeddb.js` should work from 4.0 up; but it 11 | 12 | * won't work at all with local files (`file:///` URLs). As soon as you try to create the database it will raise an error `Permission denied for to create wrapper for object of class UnnamedClass` 13 | * will ask the user for permission before creating a database 14 | * requests permission again when the database grows to a certain size (50MB by default). After this the disk is the limit, unlike the fairly concrete and currently fixed limit (5MB by default) that `localStorage` gets (which will just fail after that with no way to ask the user to increase it). 15 | 16 | Chrome 11 and later are supported. (Chrome 9 and 10 should also work but are untested.) In Chrome 11, `backbone-indexeddb.js` 17 | 18 | * will work with `file:///` URLs, but 19 | * poses some hard size limit (5MB? quantity untested) unless Chrome is started with `--unlimited-quota-for-indexeddb`, with apparently no way to request increasing the quota. 20 | 21 | IE10 support has been added thanks to [lcalvy](https://github.com/lcalvy). 22 | 23 | Other browsers implementing the Indexed Database API Working Draft should work, with some of these limitations possibly cropping up or possibly not. Support and ease of use is expected to improve in upcoming releases of browsers. 24 | 25 | # Tests 26 | 27 | Just open the /tests/test.html in your favorite browser. (or serve if from a webserver for Firefox, which can't run indexedDB on local file.) 28 | 29 | # Node 30 | 31 | This is quite useless to most people, but there is also an npm module for this. It's useless because IndexedDB hasn't been (yet?) ported to node.js. 32 | It can be used in the context of [browserify](https://github.com/substack/node-browserify) though... and this is exactly why this npm version exists. 33 | 34 | # Implementation 35 | 36 | ## Database & Schema 37 | 38 | Both your Backbone model and collections need to point to a `database` and a `storeName` attributes that are used by the adapter. 39 | 40 | The `storeName` is the name of the store used for the objects of this Model or Collection. You _should_ use the same `storeName` for the model and collections of that same model. 41 | 42 | The `database` is an object literal that define the following : 43 | 44 | * `id` : and unique id for the database 45 | * `description` : a description of the database [OPTIONAL] 46 | * `migrations` : an array of migration to be applied to the database to get the schema that your app needs. 47 | 48 | The migrations are object literals with the following : 49 | 50 | * `version` : the version of the database once the migration is applied. 51 | * `migrate` : a Javascript function that will be called by the driver to perform the migration. It is called with a `IDBDatabase` object, a `IDBVersionChangeRequest` object and a function that needs to be called when the migration is performed, so that the next migration can be executed. 52 | * `before` *[optional]* : a Javascript function that will be called with the database, before the transaction is run. It's useful to update fields before updating the schema. 53 | * `after` *[optional]* : a Javascript function that will be called with the database, after the transaction has been run. It's useful to update fields after updating the schema. 54 | 55 | ### Example 56 | 57 | ```js 58 | var database = { 59 | id: "my-database", 60 | description: "The database for the Movies", 61 | migrations : [ 62 | { 63 | version: "1.0", 64 | before: function(next) { 65 | // Do magic stuff before the migration. For example, before adding indices, the Chrome implementation requires to set define a value for each of the objects. 66 | next(); 67 | } 68 | migrate: function(transaction, next) { 69 | var store = transaction.db.createObjectStore("movies"); // Adds a store, we will use "movies" as the storeName in our Movie model and Collections 70 | next(); 71 | } 72 | }, { 73 | version: "1.1", 74 | migrate: function(transaction, next) { 75 | var store = transaction.db.objectStore("movies") 76 | store.createIndex("titleIndex", "title", { unique: true}); // Adds an index on the movies titles 77 | store.createIndex("formatIndex", "format", { unique: false}); // Adds an index on the movies formats 78 | store.createIndex("genreIndex", "genre", { unique: false}); // Adds an index on the movies genres 79 | next(); 80 | } 81 | } 82 | ] 83 | } 84 | ``` 85 | 86 | ## Models 87 | 88 | Not much change to your usual models. The only significant change is that you can now fetch a given model with its id, or with a value for one of its index. 89 | 90 | For example, in your traditional backbone apps, you would do something like : 91 | 92 | ```js 93 | var movie = new Movie({id: "123"}) 94 | movie.fetch() 95 | ``` 96 | 97 | to fetch from the remote server the Movie with the id `123`. This is convenient when you know the id. With this adapter, you can do something like 98 | 99 | ```js 100 | var movie = new Movie({title: "Avatar"}) 101 | movie.fetch() 102 | ``` 103 | 104 | Obviously, to perform this, you need to have and index on `title`, and a movie with "Avatar" as a title obviously. If the index is not unique, the database will only return the first one. 105 | 106 | ## Collections 107 | 108 | I added a lot of fun things to the collections, that make use of the `options` param used in Backbone to take advantage of IndexedDB's features, namely **indices, cursors and bounds**. 109 | 110 | First, you can `limit` and `offset` the number of items that are being fetched by a collection. 111 | 112 | ```js 113 | var theater = new Theater() // Theater is a collection of movies 114 | theater.fetch({ 115 | offset: 1, 116 | limit: 3, 117 | success: function() { 118 | // The theater collection will be populated with at most 3 items, skipping the first one 119 | } 120 | }); 121 | ``` 122 | 123 | You can also *provide a range* applied to the id. 124 | 125 | ```js 126 | var theater = new Theater() // Theater is a collection of movies 127 | theater.fetch({ 128 | range: ["a", "b"], 129 | success: function() { 130 | // The theater collection will be populated with all the items with an id comprised between "a" and "b" ("alphonse" is between "a" and "b") 131 | } 132 | }); 133 | ``` 134 | 135 | You can also get *all items with a given value for a specific value of an index*. We use the `conditions` keyword. 136 | 137 | ```js 138 | var theater = new Theater() // Theater is a collection of movies 139 | theater.fetch({ 140 | conditions: {genre: "adventure"}, 141 | success: function() { 142 | // The theater collection will be populated with all the movies whose genre is "adventure" 143 | } 144 | }); 145 | ``` 146 | 147 | 148 | 149 | You can also *get all items for which an indexed value is comprised between 2 values*. The collection will be sorted based on the order of these 2 keys. 150 | 151 | ```js 152 | var theater = new Theater() // Theater is a collection of movies 153 | theater.fetch({ 154 | conditions: {genre: ["a", "e"]}, 155 | success: function() { 156 | // The theater collection will be populated with all the movies whose genre is "adventure", "comic", "drama", but not "thriller". 157 | } 158 | }); 159 | ``` 160 | 161 | You can also selects indexed value with some "Comparison Query Operators" (like mongodb) 162 | The options are: 163 | * $gte = greater than or equal to (i.e. >=) 164 | * $gt = greater than (i.e. >) 165 | * $lte = less than or equal to (i.e. <=) 166 | * $lt = less than (i.e. <) 167 | 168 | See an example. 169 | 170 | ```js 171 | var theater = new Theater() // Theater is a collection of movies 172 | theater.fetch({ 173 | conditions: {year: {$gte: 2013}, 174 | success: function() { 175 | // The theater collection will be populated with all the movies with year >= 2013 176 | } 177 | }); 178 | ``` 179 | 180 | You can also *get all items after a certain object (excluding that object), or from a certain object (including) to a certain object (including)* (using their ids). This combined with the addIndividually option allows you to lazy load a full collection, by always loading the next element. 181 | 182 | ```js 183 | var theater = new Theater() // Theater is a collection of movies 184 | theater.fetch({ 185 | from: new Movie({id: 12345, ...}), 186 | after: new Movie({id: 12345, ...}), 187 | to: new Movie({id: 12345, ...}), 188 | success: function() { 189 | // The theater collection will be populated with all the movies whose genre is "adventure", "comic", "drama", but not "thriller". 190 | } 191 | }); 192 | ``` 193 | 194 | You can also obviously combine all these. 195 | 196 | ## Optional Persistence 197 | If needing to persist via ajax as well as indexed-db, just override your model's sync to use ajax instead. 198 | 199 | ```coffeescript 200 | class MyMode extends Backbone.Model 201 | 202 | sync: Backbone.ajaxSync 203 | ``` 204 | 205 | Any more complex dual persistence can be provided in method overrides, which could eventually drive out the design for a multi-layer persistence adapter. 206 | -------------------------------------------------------------------------------- /backbone-indexeddb.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | // AMD. Register as an anonymous module. 4 | define(['backbone', 'underscore'], factory); 5 | } else if (typeof exports === 'object') { 6 | // Node. Does not work with strict CommonJS, but 7 | // only CommonJS-like environments that support module.exports, 8 | // like Node. 9 | module.exports = factory(require('backbone'), require('underscore')); 10 | } else { 11 | // Browser globals (root is window) 12 | root.returnExports = factory(root.Backbone, root._); 13 | } 14 | }(this, function (Backbone, _) { 15 | 16 | // Generate four random hex digits. 17 | function S4() { 18 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); 19 | } 20 | 21 | // Generate a pseudo-GUID by concatenating random hexadecimal. 22 | function guid() { 23 | return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); 24 | } 25 | 26 | if (typeof indexedDB === "undefined") { return; } 27 | 28 | var Deferred = Backbone.$ && Backbone.$.Deferred; 29 | 30 | // Driver object 31 | // That's the interesting part. 32 | // 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 33 | // version of the database. 34 | function Driver(schema, ready, nolog, onerror) { 35 | this.schema = schema; 36 | this.ready = ready; 37 | this.error = null; 38 | this.transactions = []; // Used to list all transactions and keep track of active ones. 39 | this.db = null; 40 | this.nolog = nolog; 41 | this.onerror = onerror; 42 | var lastMigrationPathVersion = _.last(this.schema.migrations).version; 43 | if (!this.nolog) debugLog("opening database " + this.schema.id + " in version #" + lastMigrationPathVersion); 44 | this.dbRequest = indexedDB.open(this.schema.id,lastMigrationPathVersion); //schema version need to be an unsigned long 45 | 46 | this.launchMigrationPath = function(dbVersion) { 47 | var transaction = this.dbRequest.transaction; 48 | var clonedMigrations = _.clone(schema.migrations); 49 | this.migrate(transaction, clonedMigrations, dbVersion, { 50 | error: _.bind(function(event) { 51 | this.error = "Database not up to date. " + dbVersion + " expected was " + lastMigrationPathVersion; 52 | }, this) 53 | }); 54 | }; 55 | 56 | this.dbRequest.onblocked = function(event){ 57 | if (!this.nolog) debugLog("connection to database blocked"); 58 | }; 59 | 60 | this.dbRequest.onsuccess = _.bind(function (e) { 61 | this.db = e.target.result; // Attach the connection ot the queue. 62 | var currentIntDBVersion = (parseInt(this.db.version) || 0); // we need convert beacuse chrome store in integer and ie10 DP4+ in int; 63 | var lastMigrationInt = (parseInt(lastMigrationPathVersion) || 0); // And make sure we compare numbers with numbers. 64 | 65 | if (currentIntDBVersion === lastMigrationInt) { //if support new event onupgradeneeded will trigger the ready function 66 | // No migration to perform! 67 | this.ready(); 68 | } else if (currentIntDBVersion < lastMigrationInt ) { 69 | // We need to migrate up to the current migration defined in the database 70 | this.launchMigrationPath(currentIntDBVersion); 71 | } else { 72 | // Looks like the IndexedDB is at a higher version than the current driver schema. 73 | this.error = "Database version is greater than current code " + currentIntDBVersion + " expected was " + lastMigrationInt; 74 | } 75 | }, this); 76 | 77 | 78 | this.dbRequest.onerror = _.bind(function (e) { 79 | // Failed to open the database 80 | this.error = "Couldn't not connect to the database" 81 | if (!this.nolog) debugLog("Couldn't not connect to the database"); 82 | this.onerror(); 83 | }, this); 84 | 85 | this.dbRequest.onabort = _.bind(function (e) { 86 | // Failed to open the database 87 | this.error = "Connection to the database aborted" 88 | if (!this.nolog) debugLog("Connection to the database aborted"); 89 | this.onerror(); 90 | }, this); 91 | 92 | 93 | this.dbRequest.onupgradeneeded = _.bind(function(iDBVersionChangeEvent){ 94 | this.db =iDBVersionChangeEvent.target.result; 95 | 96 | var newVersion = iDBVersionChangeEvent.newVersion; 97 | var oldVersion = iDBVersionChangeEvent.oldVersion; 98 | 99 | // Fix Safari 8 and iOS 8 bug 100 | // at the first connection oldVersion is equal to 9223372036854776000 101 | // but the real value is 0 102 | if (oldVersion > 99999999999) 103 | oldVersion = 0; 104 | 105 | if (!this.nolog) debugLog("onupgradeneeded = " + oldVersion + " => " + newVersion); 106 | this.launchMigrationPath(oldVersion); 107 | }, this); 108 | } 109 | 110 | function debugLog(str) { 111 | if (typeof console !== "undefined" && typeof console.log === "function") { 112 | console.log(str); 113 | } 114 | } 115 | 116 | // Driver Prototype 117 | Driver.prototype = { 118 | 119 | // Tracks transactions. Mostly for debugging purposes. TO-IMPROVE 120 | _track_transaction: function(transaction) { 121 | this.transactions.push(transaction); 122 | var removeIt = _.bind(function() { 123 | var idx = this.transactions.indexOf(transaction); 124 | if (idx !== -1) {this.transactions.splice(idx); } 125 | }, this); 126 | transaction.oncomplete = removeIt; 127 | transaction.onabort = removeIt; 128 | transaction.onerror = removeIt; 129 | }, 130 | 131 | // Performs all the migrations to reach the right version of the database. 132 | migrate: function (transaction, migrations, version, options) { 133 | transaction.onerror = options.error; 134 | transaction.onabort = options.error; 135 | 136 | if (!this.nolog) debugLog("migrate begin version from #" + version); 137 | var that = this; 138 | var migration = migrations.shift(); 139 | if (migration) { 140 | if (!version || version < migration.version) { 141 | // We need to apply this migration- 142 | if (typeof migration.before == "undefined") { 143 | migration.before = function (next) { 144 | next(); 145 | }; 146 | } 147 | if (typeof migration.after == "undefined") { 148 | migration.after = function (next) { 149 | next(); 150 | }; 151 | } 152 | // First, let's run the before script 153 | if (!that.nolog) debugLog("migrate begin before version #" + migration.version); 154 | migration.before(function () { 155 | if (!that.nolog) debugLog("migrate done before version #" + migration.version); 156 | 157 | if (!that.nolog) debugLog("migrate begin migrate version #" + migration.version); 158 | 159 | migration.migrate(transaction, function () { 160 | if (!that.nolog) debugLog("migrate done migrate version #" + migration.version); 161 | // Migration successfully appliedn let's go to the next one! 162 | if (!that.nolog) debugLog("migrate begin after version #" + migration.version); 163 | migration.after(function () { 164 | if (!that.nolog) debugLog("migrate done after version #" + migration.version); 165 | if (!that.nolog) debugLog("Migrated to " + migration.version); 166 | 167 | //last modification occurred, need finish 168 | if(migrations.length ==0) { 169 | if (!that.nolog) { 170 | debugLog("migrate setting transaction.oncomplete to finish version #" + migration.version); 171 | transaction.oncomplete = function() { 172 | debugLog("migrate done transaction.oncomplete version #" + migration.version); 173 | debugLog("Done migrating"); 174 | }; 175 | } 176 | } 177 | else { 178 | if (!that.nolog) debugLog("migrate end from version #" + version + " to " + migration.version); 179 | that.migrate(transaction, migrations, version, options); 180 | } 181 | 182 | }); 183 | }); 184 | }); 185 | } else { 186 | // No need to apply this migration 187 | if (!that.nolog) debugLog("Skipping migration " + migration.version); 188 | that.migrate(transaction, migrations, version, options); 189 | } 190 | } 191 | }, 192 | 193 | // This is the main method, called by the ExecutionQueue when the driver is ready (database open and migration performed) 194 | execute: function (storeName, method, object, options) { 195 | if (!this.nolog) debugLog("execute : " + method + " on " + storeName + " for " + object.id); 196 | switch (method) { 197 | case "create": 198 | this.create(storeName, object, options); 199 | break; 200 | case "read": 201 | if (object.id || object.cid) { 202 | this.read(storeName, object, options); // It's a model 203 | } else { 204 | this.query(storeName, object, options); // It's a collection 205 | } 206 | break; 207 | case "update": 208 | this.update(storeName, object, options); // We may want to check that this is not a collection. TOFIX 209 | break; 210 | case "delete": 211 | if (object.id || object.cid) { 212 | this['delete'](storeName, object, options); 213 | } else { 214 | this.clear(storeName, object, options); 215 | } 216 | break; 217 | default: 218 | // Hum what? 219 | } 220 | }, 221 | 222 | // Writes the json to the storeName in db. It is a create operations, which means it will fail if the key already exists 223 | // options are just success and error callbacks. 224 | create: function (storeName, object, options) { 225 | var writeTransaction = this.db.transaction([storeName], 'readwrite'); 226 | //this._track_transaction(writeTransaction); 227 | var store = writeTransaction.objectStore(storeName); 228 | var json = object.toJSON(); 229 | var idAttribute = _.result(object, 'idAttribute'); 230 | var writeRequest; 231 | 232 | if (json[idAttribute] === undefined && !store.autoIncrement) json[idAttribute] = guid(); 233 | 234 | writeTransaction.onerror = function (e) { 235 | options.error(e); 236 | }; 237 | writeTransaction.oncomplete = function (e) { 238 | options.success(json); 239 | }; 240 | 241 | if (!store.keyPath) 242 | writeRequest = store.add(json, json[idAttribute]); 243 | else 244 | writeRequest = store.add(json); 245 | }, 246 | 247 | // 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 248 | // options are just success and error callbacks. 249 | update: function (storeName, object, options) { 250 | var writeTransaction = this.db.transaction([storeName], 'readwrite'); 251 | //this._track_transaction(writeTransaction); 252 | var store = writeTransaction.objectStore(storeName); 253 | var json = object.toJSON(); 254 | var idAttribute = _.result(object, 'idAttribute'); 255 | var writeRequest; 256 | 257 | if (!json[idAttribute]) json[idAttribute] = guid(); 258 | 259 | if (!store.keyPath) 260 | writeRequest = store.put(json, json[idAttribute]); 261 | else 262 | writeRequest = store.put(json); 263 | 264 | writeRequest.onerror = function (e) { 265 | options.error(e); 266 | }; 267 | writeTransaction.oncomplete = function (e) { 268 | options.success(json); 269 | }; 270 | }, 271 | 272 | // 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 273 | read: function (storeName, object, options) { 274 | var readTransaction = this.db.transaction([storeName], "readonly"); 275 | this._track_transaction(readTransaction); 276 | 277 | var store = readTransaction.objectStore(storeName); 278 | var json = object.toJSON(); 279 | var idAttribute = _.result(object, 'idAttribute'); 280 | 281 | var getRequest = null; 282 | if (json[idAttribute]) { 283 | getRequest = store.get(json[idAttribute]); 284 | } else if(options.index) { 285 | var index = store.index(options.index.name); 286 | getRequest = index.get(options.index.value); 287 | } else { 288 | // We need to find which index we have 289 | var cardinality = 0; // try to fit the index with most matches 290 | _.each(store.indexNames, function (key) { 291 | var index = store.index(key); 292 | if(typeof index.keyPath === 'string' && 1 > cardinality) { 293 | // simple index 294 | if (json[index.keyPath] !== undefined) { 295 | getRequest = index.get(json[index.keyPath]); 296 | cardinality = 1; 297 | } 298 | } else if(typeof index.keyPath === 'object' && index.keyPath.length > cardinality) { 299 | // compound index 300 | var valid = true; 301 | var keyValue = _.map(index.keyPath, function(keyPart) { 302 | valid = valid && json[keyPart] !== undefined; 303 | return json[keyPart]; 304 | }); 305 | if(valid) { 306 | getRequest = index.get(keyValue); 307 | cardinality = index.keyPath.length; 308 | } 309 | } 310 | }); 311 | } 312 | if (getRequest) { 313 | getRequest.onsuccess = function (event) { 314 | if (event.target.result) { 315 | options.success(event.target.result); 316 | } else { 317 | options.error("Not Found"); 318 | } 319 | }; 320 | getRequest.onerror = function () { 321 | options.error("Not Found"); // We couldn't find the record. 322 | } 323 | } else { 324 | options.error("Not Found"); // We couldn't even look for it, as we don't have enough data. 325 | } 326 | }, 327 | 328 | // Deletes the json.id key and value in storeName from db. 329 | delete: function (storeName, object, options) { 330 | var deleteTransaction = this.db.transaction([storeName], 'readwrite'); 331 | //this._track_transaction(deleteTransaction); 332 | 333 | var store = deleteTransaction.objectStore(storeName); 334 | var json = object.toJSON(); 335 | var idAttribute = store.keyPath || _.result(object, 'idAttribute'); 336 | 337 | var deleteRequest = store['delete'](json[idAttribute]); 338 | 339 | deleteTransaction.oncomplete = function (event) { 340 | options.success(null); 341 | }; 342 | deleteRequest.onerror = function (event) { 343 | options.error("Not Deleted"); 344 | }; 345 | }, 346 | 347 | // Clears all records for storeName from db. 348 | clear: function (storeName, object, options) { 349 | var deleteTransaction = this.db.transaction([storeName], "readwrite"); 350 | //this._track_transaction(deleteTransaction); 351 | 352 | var store = deleteTransaction.objectStore(storeName); 353 | 354 | var deleteRequest = store.clear(); 355 | deleteRequest.onsuccess = function (event) { 356 | options.success(null); 357 | }; 358 | deleteRequest.onerror = function (event) { 359 | options.error("Not Cleared"); 360 | }; 361 | }, 362 | 363 | // Performs a query on storeName in db. 364 | // options may include : 365 | // - conditions : value of an index, or range for an index 366 | // - range : range for the primary key 367 | // - limit : max number of elements to be yielded 368 | // - offset : skipped items. 369 | query: function (storeName, collection, options) { 370 | var elements = []; 371 | var skipped = 0, processed = 0; 372 | var queryTransaction = this.db.transaction([storeName], "readonly"); 373 | //this._track_transaction(queryTransaction); 374 | 375 | var idAttribute = _.result(collection.model.prototype, 'idAttribute'); 376 | var readCursor = null; 377 | var store = queryTransaction.objectStore(storeName); 378 | var index = null, 379 | lower = null, 380 | upper = null, 381 | bounds = null, 382 | key; 383 | 384 | if (options.conditions) { 385 | // We have a condition, we need to use it for the cursor 386 | _.each(store.indexNames, function (key) { 387 | if (!readCursor) { 388 | index = store.index(key); 389 | if (options.conditions[index.keyPath] instanceof Array) { 390 | lower = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][1] : options.conditions[index.keyPath][0]; 391 | upper = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][0] : options.conditions[index.keyPath][1]; 392 | bounds = IDBKeyRange.bound(lower, upper, true, true); 393 | 394 | if (options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1]) { 395 | // Looks like we want the DESC order 396 | readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev"); 397 | } else { 398 | // We want ASC order 399 | readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); 400 | } 401 | } else if (typeof options.conditions[index.keyPath] === 'object' && ('$gt' in options.conditions[index.keyPath] || '$gte' in options.conditions[index.keyPath])) { 402 | if('$gt' in options.conditions[index.keyPath]) 403 | bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gt'], true); 404 | else 405 | bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gte']); 406 | readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); 407 | } else if (typeof options.conditions[index.keyPath] === 'object' && ('$lt' in options.conditions[index.keyPath] || '$lte' in options.conditions[index.keyPath])) { 408 | if('$lt' in options.conditions[index.keyPath]) 409 | bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lt'], true); 410 | else 411 | bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lte']); 412 | readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); 413 | } else if (options.conditions[index.keyPath] != undefined) { 414 | bounds = IDBKeyRange.only(options.conditions[index.keyPath]); 415 | readCursor = index.openCursor(bounds); 416 | } 417 | } 418 | }); 419 | } else { 420 | // No conditions, use the index 421 | if (options.range) { 422 | lower = options.range[0] > options.range[1] ? options.range[1] : options.range[0]; 423 | upper = options.range[0] > options.range[1] ? options.range[0] : options.range[1]; 424 | bounds = IDBKeyRange.bound(lower, upper); 425 | if (options.range[0] > options.range[1]) { 426 | readCursor = store.openCursor(bounds, window.IDBCursor.PREV || "prev"); 427 | } else { 428 | readCursor = store.openCursor(bounds, window.IDBCursor.NEXT || "next"); 429 | } 430 | } else if (options.sort && options.sort.index) { 431 | if (options.sort.order === -1) { 432 | readCursor = store.index(options.sort.index).openCursor(null, window.IDBCursor.PREV || "prev"); 433 | } else { 434 | readCursor = store.index(options.sort.index).openCursor(null, window.IDBCursor.NEXT || "next"); 435 | } 436 | } else { 437 | readCursor = store.openCursor(); 438 | } 439 | } 440 | 441 | if (typeof (readCursor) == "undefined" || !readCursor) { 442 | options.error("No Cursor"); 443 | } else { 444 | readCursor.onerror = function(e){ 445 | options.error("readCursor error", e); 446 | }; 447 | // Setup a handler for the cursor’s `success` event: 448 | readCursor.onsuccess = function (e) { 449 | var cursor = e.target.result; 450 | if (!cursor) { 451 | if (options.addIndividually || options.clear) { 452 | options.success(elements, true); 453 | } else { 454 | options.success(elements); // We're done. No more elements. 455 | } 456 | } 457 | else { 458 | // Cursor is not over yet. 459 | if (options.abort || (options.limit && processed >= options.limit)) { 460 | // Yet, we have processed enough elements. So, let's just skip. 461 | if (bounds && options.conditions[index.keyPath]) { 462 | cursor["continue"](options.conditions[index.keyPath][1] + 1); /* We need to 'terminate' the cursor cleany, by moving to the end */ 463 | } else { 464 | cursor["continue"](); /* We need to 'terminate' the cursor cleany, by moving to the end */ 465 | } 466 | } 467 | else if (options.offset && options.offset > skipped) { 468 | skipped++; 469 | cursor["continue"](); /* We need to Moving the cursor forward */ 470 | } else { 471 | // This time, it looks like it's good! 472 | if (!options.filter || typeof options.filter !== 'function' || options.filter(cursor.value)) { 473 | if (options.addIndividually) { 474 | collection.add(cursor.value); 475 | } else if (options.clear) { 476 | var deleteRequest = store['delete'](cursor.value[idAttribute]); 477 | deleteRequest.onsuccess = deleteRequest.onerror = function (event) { 478 | elements.push(cursor.value); 479 | }; 480 | 481 | } else { 482 | elements.push(cursor.value); 483 | } 484 | } 485 | processed++; 486 | cursor["continue"](); 487 | } 488 | } 489 | }; 490 | } 491 | }, 492 | close :function(){ 493 | if(this.db){ 494 | this.db.close(); 495 | } 496 | } 497 | }; 498 | 499 | // ExecutionQueue object 500 | // The execution queue is an abstraction to buffer up requests to the database. 501 | // It holds a "driver". When the driver is ready, it just fires up the queue and executes in sync. 502 | function ExecutionQueue(schema,next,nolog) { 503 | this.driver = new Driver(schema, this.ready.bind(this), nolog, this.error.bind(this)); 504 | this.started = false; 505 | this.failed = false; 506 | this.stack = []; 507 | this.version = _.last(schema.migrations).version; 508 | this.next = next; 509 | } 510 | 511 | // ExecutionQueue Prototype 512 | ExecutionQueue.prototype = { 513 | // Called when the driver is ready 514 | // It just loops over the elements in the queue and executes them. 515 | ready: function () { 516 | this.started = true; 517 | _.each(this.stack, this.execute, this); 518 | this.stack = []; // fix memory leak 519 | this.next(); 520 | }, 521 | 522 | error: function() { 523 | this.failed = true; 524 | _.each(this.stack, this.execute, this); 525 | this.stack = []; 526 | this.next(); 527 | }, 528 | 529 | // Executes a given command on the driver. If not started, just stacks up one more element. 530 | execute: function (message) { 531 | if (this.started) { 532 | this.driver.execute(message[2].storeName || message[1].storeName, message[0], message[1], message[2]); // Upon messages, we execute the query 533 | } else if (this.failed) { 534 | message[2].error(); 535 | } else { 536 | this.stack.push(message); 537 | } 538 | }, 539 | 540 | close : function(){ 541 | this.driver.close(); 542 | } 543 | }; 544 | 545 | // Method used by Backbone for sync of data with data store. It was initially designed to work with "server side" APIs, This wrapper makes 546 | // it work with the local indexedDB stuff. It uses the schema attribute provided by the object. 547 | // The wrapper keeps an active Executuon Queue for each "schema", and executes querues agains it, based on the object type (collection or 548 | // single model), but also the method... etc. 549 | // Keeps track of the connections 550 | var Databases = {}; 551 | 552 | function sync(method, object, options) { 553 | 554 | if(method == "closeall"){ 555 | _.invoke(Databases, "close"); 556 | // Clean up active databases object. 557 | Databases = {}; 558 | return Deferred && Deferred().resolve(); 559 | } 560 | 561 | // If a model or a collection does not define a database, fall back on ajaxSync 562 | if (!object || !_.isObject(object.database)) { 563 | return Backbone.ajaxSync(method, object, options); 564 | } 565 | 566 | var schema = object.database; 567 | if (Databases[schema.id]) { 568 | if(Databases[schema.id].version != _.last(schema.migrations).version){ 569 | Databases[schema.id].close(); 570 | delete Databases[schema.id]; 571 | } 572 | } 573 | 574 | var dfd, promise; 575 | if (Deferred) { 576 | dfd = Deferred(); 577 | promise = dfd.promise(); 578 | promise.abort = function () { 579 | options.abort = true; 580 | }; 581 | } 582 | 583 | var success = options.success; 584 | options.success = function(resp, silenced) { 585 | if (!silenced) { 586 | if (success) success(resp); 587 | object.trigger('sync', object, resp, options); 588 | } 589 | if (dfd) { 590 | if (!options.abort) { 591 | dfd.resolve(resp); 592 | } else { 593 | dfd.reject(); 594 | } 595 | } 596 | }; 597 | 598 | var error = options.error; 599 | options.error = function(resp) { 600 | if (error) error(resp); 601 | if (dfd) dfd.reject(resp); 602 | object.trigger('error', object, resp, options); 603 | }; 604 | 605 | var next = function(){ 606 | Databases[schema.id].execute([method, object, options]); 607 | }; 608 | 609 | if (!Databases[schema.id]) { 610 | Databases[schema.id] = new ExecutionQueue(schema,next,schema.nolog); 611 | } else { 612 | next(); 613 | } 614 | 615 | return promise; 616 | }; 617 | 618 | Backbone.ajaxSync = Backbone.sync; 619 | Backbone.sync = sync; 620 | 621 | return { sync: sync, debugLog: debugLog}; 622 | })); 623 | -------------------------------------------------------------------------------- /lib/backbone-min.js: -------------------------------------------------------------------------------- 1 | (function(){var t=this;var e=t.Backbone;var i=[];var r=i.push;var s=i.slice;var n=i.splice;var a;if(typeof exports!=="undefined"){a=exports}else{a=t.Backbone={}}a.VERSION="1.0.0";var h=t._;if(!h&&typeof require!=="undefined")h=require("underscore");a.$=t.jQuery||t.Zepto||t.ender||t.$;a.noConflict=function(){t.Backbone=e;return this};a.emulateHTTP=false;a.emulateJSON=false;var o=a.Events={on:function(t,e,i){if(!l(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,i){if(!l(this,"once",t,[e,i])||!e)return this;var r=this;var s=h.once(function(){r.off(t,s);e.apply(this,arguments)});s._callback=e;return this.on(t,s,i)},off:function(t,e,i){var r,s,n,a,o,u,c,f;if(!this._events||!l(this,"off",t,[e,i]))return this;if(!t&&!e&&!i){this._events={};return this}a=t?[t]:h.keys(this._events);for(o=0,u=a.length;o").attr(t);this.setElement(e,false)}else{this.setElement(h.result(this,"el"),false)}}});a.sync=function(t,e,i){var r=k[t];h.defaults(i||(i={}),{emulateHTTP:a.emulateHTTP,emulateJSON:a.emulateJSON});var s={type:r,dataType:"json"};if(!i.url){s.url=h.result(e,"url")||U()}if(i.data==null&&e&&(t==="create"||t==="update"||t==="patch")){s.contentType="application/json";s.data=JSON.stringify(i.attrs||e.toJSON(i))}if(i.emulateJSON){s.contentType="application/x-www-form-urlencoded";s.data=s.data?{model:s.data}:{}}if(i.emulateHTTP&&(r==="PUT"||r==="DELETE"||r==="PATCH")){s.type="POST";if(i.emulateJSON)s.data._method=r;var n=i.beforeSend;i.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",r);if(n)return n.apply(this,arguments)}}if(s.type!=="GET"&&!i.emulateJSON){s.processData=false}if(s.type==="PATCH"&&window.ActiveXObject&&!(window.external&&window.external.msActiveXFilteringEnabled)){s.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var o=i.xhr=a.ajax(h.extend(s,i));e.trigger("request",e,o,i);return o};var k={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};a.ajax=function(){return a.$.ajax.apply(a.$,arguments)};var S=a.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var $=/\((.*?)\)/g;var T=/(\(\?)?:\w+/g;var H=/\*\w+/g;var A=/[\-{}\[\]+?.,\\\^$|#\s]/g;h.extend(S.prototype,o,{initialize:function(){},route:function(t,e,i){if(!h.isRegExp(t))t=this._routeToRegExp(t);if(h.isFunction(e)){i=e;e=""}if(!i)i=this[e];var r=this;a.history.route(t,function(s){var n=r._extractParameters(t,s);i&&i.apply(r,n);r.trigger.apply(r,["route:"+e].concat(n));r.trigger("route",e,n);a.history.trigger("route",r,e,n)});return this},navigate:function(t,e){a.history.navigate(t,e);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=h.result(this,"routes");var t,e=h.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(A,"\\$&").replace($,"(?:$1)?").replace(T,function(t,e){return e?t:"([^/]+)"}).replace(H,"(.*?)");return new RegExp("^"+t+"$")},_extractParameters:function(t,e){var i=t.exec(e).slice(1);return h.map(i,function(t){return t?decodeURIComponent(t):null})}});var I=a.History=function(){this.handlers=[];h.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var N=/^[#\/]|\s+$/g;var P=/^\/+|\/+$/g;var O=/msie [\w.]+/;var C=/\/$/;I.started=false;h.extend(I.prototype,o,{interval:50,getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=this.location.pathname;var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.substr(i.length)}else{t=this.getHash()}}return t.replace(N,"")},start:function(t){if(I.started)throw new Error("Backbone.history has already been started");I.started=true;this.options=h.extend({},{root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var e=this.getFragment();var i=document.documentMode;var r=O.exec(navigator.userAgent.toLowerCase())&&(!i||i<=7);this.root=("/"+this.root+"/").replace(P,"/");if(r&&this._wantsHashChange){this.iframe=a.$('