├── amd.footer ├── .gitignore ├── Makefile ├── amd.header ├── spec ├── spec_helper.coffee ├── spec_helper.js ├── bugs_spec.coffee ├── bugs_spec.js ├── backbone_extensions_spec.coffee ├── localstorage_store_spec.coffee ├── backbone_extensions_spec.js ├── integration_spec.coffee ├── localstorage_store_spec.js ├── integration_spec.js ├── localsync_spec.coffee ├── localsync_spec.js ├── backbone.dualstorage.js ├── dualsync_spec.coffee └── dualsync_spec.js ├── bower.json ├── package.json ├── lib └── jasmine-1.3.1 │ ├── MIT.LICENSE │ ├── jasmine.css │ └── jasmine-html.js ├── LICENSE.md ├── CONTRIBUTING.md ├── SpecRunner.html ├── CODE_OF_CONDUCT.md ├── CHANGES.md ├── README.md ├── backbone.dualstorage.coffee ├── backbone.dualstorage.amd.js └── backbone.dualstorage.js /amd.footer: -------------------------------------------------------------------------------- 1 | }); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .floo 2 | *map 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | compile: 2 | coffee -mc backbone.dualstorage.coffee spec/*.coffee 3 | coffee -mcbo spec backbone.dualstorage.coffee 4 | cat amd.header spec/backbone.dualstorage.js amd.footer > backbone.dualstorage.amd.js 5 | 6 | watch: 7 | coffee -wmc backbone.dualstorage.coffee spec/*.coffee & 8 | coffee -wmcbo spec backbone.dualstorage.coffee & 9 | # Press ^C to exit 10 | while true; do sleep 100; done 11 | -------------------------------------------------------------------------------- /amd.header: -------------------------------------------------------------------------------- 1 | (function(root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define(['backbone'], factory); 4 | } else if (typeof require === 'function' && ((typeof module !== "undefined" && module !== null ? module.exports : void 0) != null)) { 5 | return module.exports = factory(require('backbone')); 6 | } else { 7 | factory(root.Backbone); 8 | } 9 | })(this, function(Backbone) { 10 | -------------------------------------------------------------------------------- /spec/spec_helper.coffee: -------------------------------------------------------------------------------- 1 | window.Backbone.sync = jasmine.createSpy('sync').andCallFake (method, model, options) -> 2 | model.updatedByRemoteSync = true 3 | resp = options.serverResponse || model.toJSON() 4 | status = 200 5 | callback = options.success 6 | xhr = {status: status, response: resp} 7 | if typeof options.errorStatus is 'number' 8 | resp.status = xhr.status = status = options.errorStatus 9 | callback = options.error 10 | if typeof options.successStatus is 'number' 11 | resp.status = xhr.status = status = options.successStatus 12 | if Backbone.VERSION == '0.9.10' 13 | callback(model, resp, options) 14 | else if Backbone.VERSION[0] == '0' 15 | callback(resp, status, xhr) 16 | else 17 | options.xhr = xhr 18 | callback(resp) 19 | xhr 20 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Backbone.dualStorage", 3 | "main": "backbone.dualstorage.js", 4 | "version": "1.4.2", 5 | "homepage": "https://github.com/nilbus/Backbone.dualStorage", 6 | "authors": [ 7 | "Edward Anderson ", 8 | "Lucian Mihaila", 9 | "Jerome Gravel-Niquet" 10 | ], 11 | "description": "A dual (localStorage and REST) sync adapter for Backbone.js", 12 | "keywords": [ 13 | "backbone", 14 | "storage", 15 | "adapter", 16 | "localcache", 17 | "sync" 18 | ], 19 | "license": "MIT", 20 | "ignore": [ 21 | "**/.*", 22 | "node_modules", 23 | "bower_components", 24 | "test", 25 | "tests" 26 | ], 27 | "dependencies": { 28 | "backbone": ">=0.9.2", 29 | "underscore": "~1" 30 | }, 31 | "devDependencies": { 32 | "coffee-script": "~1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Backbone.dualStorage", 3 | "main": "backbone.dualstorage.js", 4 | "version": "1.4.2", 5 | "homepage": "https://github.com/nilbus/Backbone.dualStorage", 6 | "authors": [ 7 | "Edward Anderson ", 8 | "Lucian Mihaila", 9 | "Jerome Gravel-Niquet" 10 | ], 11 | "description": "A dual (localStorage and REST) sync adapter for Backbone.js", 12 | "keywords": [ 13 | "backbone", 14 | "storage", 15 | "adapter", 16 | "localcache", 17 | "sync" 18 | ], 19 | "license": "MIT", 20 | "ignore": [ 21 | "**/.*", 22 | "node_modules", 23 | "bower_components", 24 | "test", 25 | "tests" 26 | ], 27 | "dependencies": { 28 | "backbone": ">=0.9.2", 29 | "underscore": "~1" 30 | }, 31 | "devDependencies": { 32 | "coffee-script": "~1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /spec/spec_helper.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | (function() { 3 | window.Backbone.sync = jasmine.createSpy('sync').andCallFake(function(method, model, options) { 4 | var callback, resp, status, xhr; 5 | model.updatedByRemoteSync = true; 6 | resp = options.serverResponse || model.toJSON(); 7 | status = 200; 8 | callback = options.success; 9 | xhr = { 10 | status: status, 11 | response: resp 12 | }; 13 | if (typeof options.errorStatus === 'number') { 14 | resp.status = xhr.status = status = options.errorStatus; 15 | callback = options.error; 16 | } 17 | if (typeof options.successStatus === 'number') { 18 | resp.status = xhr.status = status = options.successStatus; 19 | } 20 | if (Backbone.VERSION === '0.9.10') { 21 | callback(model, resp, options); 22 | } else if (Backbone.VERSION[0] === '0') { 23 | callback(resp, status, xhr); 24 | } else { 25 | options.xhr = xhr; 26 | callback(resp); 27 | } 28 | return xhr; 29 | }); 30 | 31 | }).call(this); 32 | 33 | //# sourceMappingURL=spec_helper.js.map 34 | -------------------------------------------------------------------------------- /lib/jasmine-1.3.1/MIT.LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-2011 Pivotal Labs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Licensed under MIT license 2 | 3 | Copyright (c) 2011-2012 SF Software Ltd. 4 | 5 | Copyright (c) 2010 Jerome Gravel-Niquet 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /spec/bugs_spec.coffee: -------------------------------------------------------------------------------- 1 | {Store, backboneSync, localsync} = window 2 | 3 | describe 'bugs, that once fixed, should be moved to the proper spec file and modified to test their inverse', -> 4 | it 'fails to throw an error when no storeName is provided to the Store constructor, 5 | even though this will cause problems later. 6 | The root cause is that the model has no url set; the error should reflect this.', -> 7 | createNamelessStore = -> new Store 8 | expect(createNamelessStore).not.toThrow() 9 | 10 | describe 'idAttribute being ignored', -> 11 | {Role, RoleCollection, collection, model} = {} 12 | 13 | beforeEach -> 14 | backboneSync.calls = [] 15 | localsync 'clear', {}, success: (->), error: (->) 16 | collection = new Backbone.Collection 17 | collection.url = 'eyes/' 18 | model = new Backbone.Model 19 | model.collection = collection 20 | model.set id: 1 21 | 22 | setup = (useIdAttribute) -> 23 | Role = Backbone.Model.extend 24 | idAttribute: if useIdAttribute then '_id' else undefined, 25 | urlRoot: "/roles", 26 | RoleCollection = Backbone.Collection.extend 27 | model: Role 28 | url: "/roles" 29 | -------------------------------------------------------------------------------- /spec/bugs_spec.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | (function() { 3 | var Store, backboneSync, localsync; 4 | 5 | Store = window.Store, backboneSync = window.backboneSync, localsync = window.localsync; 6 | 7 | describe('bugs, that once fixed, should be moved to the proper spec file and modified to test their inverse', function() { 8 | it('fails to throw an error when no storeName is provided to the Store constructor, even though this will cause problems later. The root cause is that the model has no url set; the error should reflect this.', function() { 9 | var createNamelessStore; 10 | createNamelessStore = function() { 11 | return new Store; 12 | }; 13 | return expect(createNamelessStore).not.toThrow(); 14 | }); 15 | return describe('idAttribute being ignored', function() { 16 | var Role, RoleCollection, collection, model, ref, setup; 17 | ref = {}, Role = ref.Role, RoleCollection = ref.RoleCollection, collection = ref.collection, model = ref.model; 18 | beforeEach(function() { 19 | backboneSync.calls = []; 20 | localsync('clear', {}, { 21 | success: (function() {}), 22 | error: (function() {}) 23 | }); 24 | collection = new Backbone.Collection; 25 | collection.url = 'eyes/'; 26 | model = new Backbone.Model; 27 | model.collection = collection; 28 | return model.set({ 29 | id: 1 30 | }); 31 | }); 32 | return setup = function(useIdAttribute) { 33 | Role = Backbone.Model.extend({ 34 | idAttribute: useIdAttribute ? '_id' : void 0, 35 | urlRoot: "/roles" 36 | }); 37 | return RoleCollection = Backbone.Collection.extend({ 38 | model: Role, 39 | url: "/roles" 40 | }); 41 | }; 42 | }); 43 | }); 44 | 45 | }).call(this); 46 | 47 | //# sourceMappingURL=bugs_spec.js.map 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Pull requests for features, improvements, and fixes are welcome! I have limited 5 | time to contribute to this project and therefore rely on contributors like you 6 | to bring in the changes you need. 7 | 8 | To submit a change request: 9 | 10 | 1. Fork the repository and make your change in a feature branch. 11 | You may also want to discuss the change first in an issue. 12 | 2. Compile the javascript files using the instructions below. 13 | 3. Ensure the tests pass, following the instructions below. 14 | 4. Update the changelog (CHANGES.md), summarizing the change. 15 | Include any relevant issue/pull request number, version numbers, and the author. 16 | 5. Open a [pull request](https://help.github.com/articles/creating-a-pull-request/) 17 | with your change. 18 | 19 | This project is not on a set release schedule. If a feature you want is in 20 | master but has not been released, feel free to ask for a release. 21 | 22 | Compiling 23 | --------- 24 | 25 | Compile the coffeescript into javascript with `make`. This requires that 26 | node.js and coffee-script are installed. 27 | 28 | npm install -g coffee-script 29 | 30 | make 31 | 32 | During development, use `make watch` to compile as you make changes. 33 | 34 | Testing 35 | ------- 36 | 37 | To run the test suite, clone the project and open **SpecRunner.html** in a browser. 38 | 39 | Note that the tests run against **spec/backbone.dualstorage.js**, not the copy 40 | in the project root. 41 | The spec version needs to be unwrapped to allow mocking components for testing. 42 | This version is compiled automatically when running `make`. 43 | 44 | dualStorage has been tested against Backbone versions 0.9.2 - 1.1.2. 45 | Test with other versions by altering the version included in `SpecRunner.html`. 46 | 47 | Getting Help 48 | ------------ 49 | 50 | Open an issue and ask for help if you need it. I make a good effort to be responsive. 51 | -------------------------------------------------------------------------------- /SpecRunner.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | Jasmine Spec Runner - Backbone.dualStorage 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /spec/backbone_extensions_spec.coffee: -------------------------------------------------------------------------------- 1 | {Backbone, localStorage} = window 2 | 3 | describe 'monkey patching', -> 4 | it 'aliases Backbone.sync to backboneSync', -> 5 | expect(window.backboneSync).toBeDefined() 6 | expect(window.backboneSync.identity).toEqual('sync') 7 | 8 | describe 'offline localStorage sync', -> 9 | {collection} = {} 10 | {model} = {} 11 | model = Backbone.Model.extend 12 | idAttribute: '_id' 13 | beforeEach -> 14 | localStorage.clear() 15 | localStorage.setItem 'cats', '1,2,3,a,deadbeef-c03d-f00d-aced-dec0ded4b1ff' 16 | localStorage.setItem 'cats_dirty', '2,a,deadbeef-c03d-f00d-aced-dec0ded4b1ff' 17 | localStorage.setItem 'cats_destroyed', '3' 18 | localStorage.setItem 'cats1', '{"_id": "1", "color": "translucent"}' 19 | localStorage.setItem 'cats2', '{"_id": "2", "color": "auburn"}' 20 | localStorage.setItem 'cats3', '{"_id": "3", "color": "burgundy"}' 21 | localStorage.setItem 'catsa', '{"_id": "a", "color": "scarlet"}' 22 | localStorage.setItem 'catsnew', '{"_id": "deadbeef-c03d-f00d-aced-dec0ded4b1ff", "color": "pearl"}' 23 | Collection = Backbone.Collection.extend 24 | model: model 25 | url: 'cats' 26 | collection = new Collection [ 27 | {_id: 1, color: 'translucent'}, 28 | {_id: 2, color: 'auburn'}, 29 | {_id: 3, color: 'burgundy'}, 30 | {_id: 'a', color: 'scarlet'} 31 | {_id: 'deadbeef-c03d-f00d-aced-dec0ded4b1ff', color: 'pearl'} 32 | ] 33 | 34 | describe 'syncDirtyAndDestroyed', -> 35 | it 'calls syncDirty and syncDestroyed', -> 36 | syncDirty = spyOn Backbone.Collection.prototype, 'syncDirty' 37 | syncDestroyed = spyOn Backbone.Collection.prototype, 'syncDestroyed' 38 | collection.syncDirtyAndDestroyed() 39 | expect(syncDirty).toHaveBeenCalled() 40 | expect(syncDestroyed).toHaveBeenCalled() 41 | 42 | describe 'syncDirty', -> 43 | it 'finds and saves all dirty models', -> 44 | saveInteger = spyOn(collection.get(2), 'save').andCallThrough() 45 | saveString = spyOn(collection.get('a'), 'save').andCallThrough() 46 | collection.syncDirty() 47 | expect(saveInteger).toHaveBeenCalled() 48 | expect(saveString).toHaveBeenCalled() 49 | expect(localStorage.getItem 'cats_dirty').toBeFalsy() 50 | 51 | it 'works when there are no dirty records', -> 52 | localStorage.removeItem 'cats_dirty' 53 | collection.syncDirty() 54 | 55 | describe 'syncDestroyed', -> 56 | it 'finds all models marked as destroyed and destroys them', -> 57 | destroy = spyOn collection.get(3), 'destroy' 58 | collection.syncDestroyed() 59 | expect(localStorage.getItem 'cats_destroyed').toBeFalsy() 60 | 61 | it 'works when there are no destroyed records', -> 62 | localStorage.setItem 'cats_destroyed', '' 63 | collection.syncDestroyed() 64 | 65 | describe 'dirtyModels', -> 66 | it 'returns the model instances that are dirty', -> 67 | expect(collection.dirtyModels().map((model) -> model.id)).toEqual [2, 'a', 'deadbeef-c03d-f00d-aced-dec0ded4b1ff'] 68 | 69 | describe 'destoyedModelsIds', -> 70 | it 'returns the ids of models that have been destroyed locally but not synced', -> 71 | expect(collection.destroyedModelIds()).toEqual ['3'] 72 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at nilbus@nilbus.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /spec/localstorage_store_spec.coffee: -------------------------------------------------------------------------------- 1 | {Store, localStorage} = window 2 | 3 | describe 'window.Store', -> 4 | {store, model} = {} 5 | beforeEach -> 6 | localStorage.clear() 7 | localStorage.setItem 'cats', '3' 8 | localStorage.setItem 'cats3', '{"id": "3", "color": "burgundy"}' 9 | store = new Store 'cats' 10 | 11 | describe 'creation', -> 12 | it 'takes a name in its constructor', -> 13 | store = new Store 'convenience store' 14 | expect(store.name).toBe 'convenience store' 15 | 16 | describe 'persistence', -> 17 | describe 'find', -> 18 | it 'fetches records by id', -> 19 | expect(store.find(id: 3)).toEqual id: '3', color: 'burgundy' 20 | 21 | # JSON.parse(null) causes error on Android 2.x devices, so it should be avoided 22 | it 'does not try to JSON.parse null values', -> 23 | spyOn JSON, 'parse' 24 | store.find id: 'unpersistedId' 25 | expect(JSON.parse).not.toHaveBeenCalledWith(null) 26 | 27 | it 'returns null when not found', -> 28 | result = store.find(id: 'unpersistedId') 29 | expect(result).toBeNull() 30 | 31 | it 'fetches all records with findAll', -> 32 | expect(store.findAll()).toEqual [id: '3', color: 'burgundy'] 33 | 34 | it 'clears out its records', -> 35 | store.clear() 36 | expect(localStorage.getItem 'cats').toBe '' 37 | expect(localStorage.getItem 'cats3').toBeNull() 38 | 39 | it 'creates records', -> 40 | model = id: 2, color: 'blue' 41 | store.create model 42 | expect(localStorage.getItem 'cats').toBe '3,2' 43 | expect(JSON.parse(localStorage.getItem 'cats2')).toEqual id: 2, color: 'blue' 44 | 45 | it 'overwrites existing records with the same id on create', -> 46 | model = id: 3, color: 'lavender' 47 | store.create model 48 | expect(JSON.parse(localStorage.getItem 'cats3')).toEqual id: 3, color: 'lavender' 49 | 50 | it 'generates an id when creating records with no id', -> 51 | localStorage.clear() 52 | store = new Store 'cats' 53 | model = color: 'calico', idAttribute: 'id', set: (attribute, value) -> this[attribute] = value 54 | store.create model 55 | expect(model.id).not.toBeNull() 56 | expect(localStorage.getItem('cats')).toBe model.id 57 | 58 | it 'updates records', -> 59 | store.update id: 3, color: 'green' 60 | expect(JSON.parse(localStorage.getItem 'cats3')).toEqual id: 3, color: 'green' 61 | 62 | it 'destroys records', -> 63 | store.destroy id: 3 64 | expect(localStorage.getItem 'cats').toBe '' 65 | expect(localStorage.getItem 'cats3').toBeNull() 66 | 67 | describe 'offline', -> 68 | it 'on a clean slate, hasDirtyOrDestroyed returns false', -> 69 | expect(store.hasDirtyOrDestroyed()).toBeFalsy() 70 | 71 | it 'marks records dirty and clean, and reports if it hasDirtyOrDestroyed records', -> 72 | store.dirty id: 3 73 | expect(store.hasDirtyOrDestroyed()).toBeTruthy() 74 | store.clean id: 3, 'dirty' 75 | expect(store.hasDirtyOrDestroyed()).toBeFalsy() 76 | 77 | it 'marks records destroyed and clean from destruction, and reports if it hasDirtyOrDestroyed records', -> 78 | store.destroyed id: 3 79 | expect(store.hasDirtyOrDestroyed()).toBeTruthy() 80 | store.clean id: 3, 'destroyed' 81 | expect(store.hasDirtyOrDestroyed()).toBeFalsy() 82 | 83 | it 'cleans the list of dirty or destroyed models out of localStorage after saving or destroying', -> 84 | collection = new Backbone.Collection [{id: 2, color: 'auburn'}, {id: 3, color: 'burgundy'}] 85 | collection.url = 'cats' 86 | store.dirty id: 2 87 | store.destroyed id: 3 88 | expect(store.hasDirtyOrDestroyed()).toBeTruthy() 89 | collection.get(2).save() 90 | collection.get(3).destroy() 91 | expect(store.hasDirtyOrDestroyed()).toBeFalsy() 92 | expect(localStorage.getItem('cats_dirty').length).toBe 0 93 | expect(localStorage.getItem('cats_destroyed').length).toBe 0 94 | -------------------------------------------------------------------------------- /spec/backbone_extensions_spec.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | (function() { 3 | var Backbone, localStorage; 4 | 5 | Backbone = window.Backbone, localStorage = window.localStorage; 6 | 7 | describe('monkey patching', function() { 8 | return it('aliases Backbone.sync to backboneSync', function() { 9 | expect(window.backboneSync).toBeDefined(); 10 | return expect(window.backboneSync.identity).toEqual('sync'); 11 | }); 12 | }); 13 | 14 | describe('offline localStorage sync', function() { 15 | var collection, model; 16 | collection = {}.collection; 17 | model = {}.model; 18 | model = Backbone.Model.extend({ 19 | idAttribute: '_id' 20 | }); 21 | beforeEach(function() { 22 | var Collection; 23 | localStorage.clear(); 24 | localStorage.setItem('cats', '1,2,3,a,deadbeef-c03d-f00d-aced-dec0ded4b1ff'); 25 | localStorage.setItem('cats_dirty', '2,a,deadbeef-c03d-f00d-aced-dec0ded4b1ff'); 26 | localStorage.setItem('cats_destroyed', '3'); 27 | localStorage.setItem('cats1', '{"_id": "1", "color": "translucent"}'); 28 | localStorage.setItem('cats2', '{"_id": "2", "color": "auburn"}'); 29 | localStorage.setItem('cats3', '{"_id": "3", "color": "burgundy"}'); 30 | localStorage.setItem('catsa', '{"_id": "a", "color": "scarlet"}'); 31 | localStorage.setItem('catsnew', '{"_id": "deadbeef-c03d-f00d-aced-dec0ded4b1ff", "color": "pearl"}'); 32 | Collection = Backbone.Collection.extend({ 33 | model: model, 34 | url: 'cats' 35 | }); 36 | return collection = new Collection([ 37 | { 38 | _id: 1, 39 | color: 'translucent' 40 | }, { 41 | _id: 2, 42 | color: 'auburn' 43 | }, { 44 | _id: 3, 45 | color: 'burgundy' 46 | }, { 47 | _id: 'a', 48 | color: 'scarlet' 49 | }, { 50 | _id: 'deadbeef-c03d-f00d-aced-dec0ded4b1ff', 51 | color: 'pearl' 52 | } 53 | ]); 54 | }); 55 | describe('syncDirtyAndDestroyed', function() { 56 | return it('calls syncDirty and syncDestroyed', function() { 57 | var syncDestroyed, syncDirty; 58 | syncDirty = spyOn(Backbone.Collection.prototype, 'syncDirty'); 59 | syncDestroyed = spyOn(Backbone.Collection.prototype, 'syncDestroyed'); 60 | collection.syncDirtyAndDestroyed(); 61 | expect(syncDirty).toHaveBeenCalled(); 62 | return expect(syncDestroyed).toHaveBeenCalled(); 63 | }); 64 | }); 65 | describe('syncDirty', function() { 66 | it('finds and saves all dirty models', function() { 67 | var saveInteger, saveString; 68 | saveInteger = spyOn(collection.get(2), 'save').andCallThrough(); 69 | saveString = spyOn(collection.get('a'), 'save').andCallThrough(); 70 | collection.syncDirty(); 71 | expect(saveInteger).toHaveBeenCalled(); 72 | expect(saveString).toHaveBeenCalled(); 73 | return expect(localStorage.getItem('cats_dirty')).toBeFalsy(); 74 | }); 75 | return it('works when there are no dirty records', function() { 76 | localStorage.removeItem('cats_dirty'); 77 | return collection.syncDirty(); 78 | }); 79 | }); 80 | describe('syncDestroyed', function() { 81 | it('finds all models marked as destroyed and destroys them', function() { 82 | var destroy; 83 | destroy = spyOn(collection.get(3), 'destroy'); 84 | collection.syncDestroyed(); 85 | return expect(localStorage.getItem('cats_destroyed')).toBeFalsy(); 86 | }); 87 | return it('works when there are no destroyed records', function() { 88 | localStorage.setItem('cats_destroyed', ''); 89 | return collection.syncDestroyed(); 90 | }); 91 | }); 92 | describe('dirtyModels', function() { 93 | return it('returns the model instances that are dirty', function() { 94 | return expect(collection.dirtyModels().map(function(model) { 95 | return model.id; 96 | })).toEqual([2, 'a', 'deadbeef-c03d-f00d-aced-dec0ded4b1ff']); 97 | }); 98 | }); 99 | return describe('destoyedModelsIds', function() { 100 | return it('returns the ids of models that have been destroyed locally but not synced', function() { 101 | return expect(collection.destroyedModelIds()).toEqual(['3']); 102 | }); 103 | }); 104 | }); 105 | 106 | }).call(this); 107 | 108 | //# sourceMappingURL=backbone_extensions_spec.js.map 109 | -------------------------------------------------------------------------------- /spec/integration_spec.coffee: -------------------------------------------------------------------------------- 1 | {backboneSync, localsync, dualSync, localStorage} = window 2 | {collection, model} = {} 3 | 4 | beforeEach -> 5 | backboneSync.calls = [] 6 | localsync 'clear', {}, ignoreCallbacks: true, storeName: 'eyes/' 7 | collection = new Backbone.Collection 8 | id: 123 9 | vision: 'crystal' 10 | collection.url = 'eyes/' 11 | model = collection.models[0] 12 | 13 | describe 'using Backbone.sync directly', -> 14 | it 'should save and retrieve data', -> 15 | {successCallback, errorCallback} = {} 16 | saved = false 17 | runs -> 18 | localStorage.clear() 19 | successCallback = jasmine.createSpy('success').andCallFake -> saved = true 20 | errorCallback = jasmine.createSpy('error') 21 | dualsync 'create', model, success: successCallback, error: errorCallback 22 | waitsFor (-> saved), "The success callback for 'create' should have been called", 100 23 | runs -> 24 | expect(backboneSync.calls.length).toEqual(1) 25 | expect(successCallback).toHaveBeenCalled() 26 | expect(errorCallback).not.toHaveBeenCalled() 27 | expect(localStorage.length).toBeGreaterThan(0) 28 | 29 | fetched = false 30 | runs -> 31 | successCallback = jasmine.createSpy('success').andCallFake callbackTranslator.forBackboneCaller (resp) -> 32 | fetched = true 33 | expect(resp.vision).toEqual('crystal') 34 | errorCallback = jasmine.createSpy('error') 35 | dualsync 'read', model, success: successCallback, error: errorCallback 36 | waitsFor (-> fetched), "The success callback for 'read' should have been called", 100 37 | runs -> 38 | expect(backboneSync.calls.length).toEqual(2) 39 | expect(successCallback).toHaveBeenCalled() 40 | expect(errorCallback).not.toHaveBeenCalled() 41 | 42 | describe 'using backbone models and retrieving from local storage', -> 43 | it "fetches a model offline after saving it online", -> 44 | saved = false 45 | runs -> 46 | model.save {}, success: -> saved = true 47 | waitsFor (-> saved), "The success callback for 'save' should have been called", 100 48 | fetched = false 49 | retrievedModel = new Backbone.Model id: 123 50 | retrievedModel.collection = collection 51 | runs -> 52 | retrievedModel.fetch remote: false, success: -> fetched = true 53 | waitsFor (-> fetched), "The success callback for 'fetch' should have been called", 100 54 | runs -> 55 | expect(retrievedModel.get('vision')).toEqual('crystal') 56 | 57 | it "works with an idAttribute other than 'id'", -> 58 | class NonstandardModel extends Backbone.Model 59 | idAttribute: 'eyeDee' 60 | url: 'eyes/' 61 | model = new NonstandardModel eyeDee: 123, vision: 'crystal' 62 | saved = false 63 | runs -> 64 | model.save {}, success: -> saved = true 65 | waitsFor (-> saved), "The success callback for 'save' should have been called", 100 66 | fetched = false 67 | retrievedModel = new NonstandardModel eyeDee: 123 68 | runs -> 69 | retrievedModel.fetch remote: false, success: -> fetched = true 70 | waitsFor (-> fetched), "The success callback for 'fetch' should have been called", 100 71 | runs -> 72 | expect(retrievedModel.get('vision')).toEqual('crystal') 73 | 74 | describe 'using backbone collections and retrieving from local storage', -> 75 | it 'loads a collection after adding several models to it', -> 76 | saved = 0 77 | runs -> 78 | for id in [1..3] 79 | newModel = new Backbone.Model id: id 80 | newModel.collection = collection 81 | newModel.save {}, success: -> saved += 1 82 | waitsFor (-> saved == 3), "The success callback for 'save' should have been called for id ##{id}", 100 83 | fetched = false 84 | runs -> 85 | collection.fetch remote: false, success: -> fetched = true 86 | waitsFor (-> fetched), "The success callback for 'fetch' should have been called", 100 87 | runs -> 88 | expect(collection.length).toEqual(3) 89 | expect(collection.map (model) -> model.id).toEqual [1,2,3] 90 | 91 | describe 'success and error callback parameters', -> 92 | it "passes back the response into the remote method's callback", -> 93 | callbackResponse = null 94 | runs -> 95 | model.remote = true 96 | model.fetch success: (args...) -> callbackResponse = args 97 | waitsFor (-> callbackResponse), "The success callback for 'fetch' should have been called", 100 98 | runs -> 99 | expect(callbackResponse[0]).toEqual model 100 | expect(callbackResponse[1]).toEqual model.attributes 101 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ### 1.4.2 / 2017-07-30 2 | 3 | * Merge #156: Pass options to model creation - supports integration in systems with custom backbone models (@paaddyy) 4 | 5 | ### 1.4.1 / 2016-04-18 6 | 7 | * Fix #145: Prevent model's id from being nulled (and saved locally with that id) when an offlineStatusCode is returned; broken since 1.4.0 8 | 9 | ### 1.4.0 / 2015-02-16 10 | 11 | * Fix #123: Expose original BackboneSync as Backbone.DualStorage.originalSync to allow custom online sync methods (Richard Tibbles) 12 | * Fix #117: Don't generate temporary IDs that conflict with UUIDs (David Almilli) 13 | * Fix #115: Don't attempt to delete models from the server that were only local, and call the callback in this case (Micha Reiser) 14 | * Fix bug introduced by #94 in 1.3.1: Allow records to be created when offline and no store exists 15 | * Merge #123: Expose original Backbone Sync to allow for customization of online sync method (Richard Tibbles) 16 | * Merge #132, #133: Allow options to be passed to toJSON (Pavel Karoukin) 17 | * Allow Backbone.DualStorage.offlineStatusCodes to include 200 OK as an offline status 18 | * syncDirty, syncDestroyed, and syncDirtyAndDestroyed accept options (and therefore callbacks) which are passed to save and destroy 19 | * Call the error callback when offline and fetching a model that was never cached 20 | 21 | ### 1.3.1 / 2014-15-30 22 | 23 | * Make syncDestroyed compatible with backbone >= 1.1.1 24 | * Fix #94: Call the error callback when offline if the collection has never been fetched (Dave Taylor) 25 | * Fix #99: Restore compatibility with lodash; incompatible since 1.2.0 (Aleksandr Motsjonov) 26 | 27 | ### 1.3.0 / 2014-05-10 28 | 29 | * Fix #104, #67: Instead of treating ajax errors as offline, use the error callback (Elad Efrat) 30 | * Fix #106: Restore proper call to a model's parse method; broken since 1.0.2 (Eduardo Matos) 31 | * Fix #105: Always set options.dirty in callbacks when offline (Elad Efrat) 32 | * Fix #93: syncDirty works when models use a custom idAttribute (Ben Salinas) 33 | * Fix #78: Do not clear local collection cache when a model is fetched 34 | * Add Backbone.DualStorage.offlineStatusCodes for configuring what to consider as offline 35 | * Add `make watch` for continual coffeescript compilation during development 36 | * `make` compiles sourcemaps between coffeescript and javascript 37 | * Prevent id duplication in the internal list of model ids for a collection when a model is fetched 38 | 39 | ### 1.2.0 / 2014-01-31 40 | 41 | * Add dirty/destroyed querying via `Collection.dirtyModels` and `Collection.destroyedModelIds` 42 | * Allow the default url-based storeName to be overriden with a `storeName` property on the model or collection 43 | * Use `model.urlRoot` as the storeName, when available, before `model.url`. 44 | This fixes the issue described in #80 where models with the same `urlRoot` 45 | that are intended to be part of the same collection end up in different stores 46 | when the collection attribute is not set on the model. 47 | 48 | **Existing apps that rely on this incorrect behavior may break.** 49 | 50 | If your app expects models with the same `urlRoot` and differing `url`s to 51 | be in different stores locally, use the following workaround in your model: 52 | 53 | storeName: function() { return this.url() } 54 | 55 | * Ensure models in the dirty list exist before saving. 56 | This mitigates concurrency issues noted in #62 until #35 is resolved, which should fix this problem. 57 | * Guard against JSON.parse(null) for Android browsers 58 | * Remove all usages of Model.clone() to play along with plugins (backbone-relational) that do not work with clone. 59 | * Fix where fetching models/collections would not merge but overwrite locally stored attributes. 60 | * Use the model idAttribute when saving models that were created offline. 61 | In this scenario, an update request (for an object with a temp ID) would be sent on save instead of a create request. 62 | 63 | ### 1.1.0 / 2013-11-10 64 | 65 | * Add support for RequireJS / AMD 66 | 67 | ### 1.0.2 / 2013-11-10 68 | 69 | * Fix support for models with a custom `idAttribute` 70 | * Update locally cached attributes when the server response to a save updates attributes 71 | * Add bower metadata 72 | * Support non-numeric model ids 73 | * Conform localSync to Backbone.sync protocol by returning attributes 74 | * Fix support for models with `url` defined as a function 75 | 76 | ### 1.0.1 / 2013-03-27 77 | 78 | * Add compatibility for Backbone 0.9.10 79 | * Remove console.log calls for cleanup and IE support 80 | * Add build Makefile for development 81 | * Add test suite 82 | * Add limited supoort for `fetch` add and merge options 83 | * Support defining `parseBeforeLocalSave` on models for parsing server responses before passing them to localsync 84 | * Support controlling behavior with `local` and `remote` options 85 | 86 | ### 1.0.0 / 2013-01-27 87 | 88 | * Forked Backbone.dualStorage from Backbone.localStorage 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Backbone dualStorage Adapter v1.4.2 2 | =================================== 3 | 4 | A dualStorage adapter for Backbone. It's a drop-in replacement for Backbone.Sync() to handle saving to a localStorage database as a cache for the remote models. 5 | 6 | Usage 7 | ----- 8 | 9 | Include Backbone.dualStorage after having included Backbone.js: 10 | 11 | 12 | 13 | 14 | Create your models and collections in the usual way. 15 | Feel free to use Backbone as you usually would; this is a drop-in replacement. 16 | 17 | Keep in mind that Backbone.dualStorage really loves your models. By default it will cache everything that passes through Backbone.sync. You can override this behaviour with the booleans ```remote``` or ```local``` on models and collections: 18 | 19 | SomeCollection = Backbone.Collection.extend({ 20 | remote: true // never cached, dualStorage is bypassed entirely 21 | local: true // always fetched and saved only locally, never saves on remote 22 | local: function() { return trueOrFalse; } // local and remote can also be dynamic 23 | }); 24 | 25 | You can also deactivate dualsync to some requests, when you want to sync with the server only later. 26 | 27 | SomeCollection.create({name: "someone"}, {remote: false}); 28 | 29 | Data synchronization 30 | -------------------- 31 | 32 | When the client goes offline, dualStorage allows you to keep changing and destroying records. All changes will be sent when the client goes online again. 33 | 34 | // server online. Go! 35 | people.fetch(); // load people models and save them into localstorage 36 | 37 | // server offline! 38 | people.create({name: "Turing"}); // you still can create new people... 39 | people.models[0].save({age: 41}); // update existing ones... 40 | people.models[1].destroy(); // and destroy as well 41 | 42 | // collections track what is dirty and destroyed 43 | people.dirtyModels() // => Array of dirty models 44 | people.destroyedModelIds() // => Array of destroyed model ids 45 | 46 | // server online again! 47 | people.syncDirtyAndDestroyed(); // all changes are sent to the server and localStorage is updated 48 | 49 | Keep in mind that if you try to fetch() a collection that has dirty data, only data currently in the localStorage will be loaded. collection.syncDirtyAndDestroyed() needs to be executed before trying to download new data from the server. 50 | 51 | It is possible to tell whether the operation succeeded remotely or locally by examining `options.dirty` in the `success` callback: 52 | 53 | model.save({ 54 | name: "Turing" 55 | }, { 56 | success: function(model, response, options) { 57 | if (options.dirty) { 58 | // saved locally 59 | } else { 60 | // saved remotely 61 | } 62 | } 63 | }); 64 | 65 | Offline state detection 66 | ----------------------- 67 | dualStorage **always** treats an Ajax status code of `0` as an indication it is working offline. Additional status codes can be added by setting `offlineStatusCodes` to either an array: 68 | 69 | Backbone.DualStorage.offlineStatusCodes = [408]; 70 | 71 | or a function that accepts the `response` object and returns an array: 72 | 73 | Backbone.DualStorage.offlineStatusCodes = function(xhr) { 74 | var codes = []; 75 | 76 | if (...) { 77 | codes.push(xhr.status); 78 | } 79 | 80 | return codes; 81 | } 82 | 83 | 84 | Data parsing 85 | ------------ 86 | 87 | Sometimes you may want to customize how data from the remote server is parsed before it's saved to localStorage. 88 | Typically your model's `parse` method takes care of this. 89 | Since dualStorage provides two layers of backend, we need a second parse method. 90 | For example, if your remote API returns data in a way that the default `parse` method interprets the result as a single record, 91 | use `parseBeforeLocalSave` to break up the data into an array of records like you would with [parse](http://backbonejs.org/#Model-parse). 92 | 93 | * The model's `parse` method still parses data read from localStorage. 94 | * The model's `parseBeforeLocalSave` method parses data read from the remote _before_ it is saved to localStorage on read. 95 | 96 | Local data storage 97 | ------------------ 98 | 99 | dualStorage stores the local cache in localStorage. 100 | Each collection's (or model's) `url` property is used as the storage namespace to separate different collections of data. 101 | This can be overridden by defining a `storeName` property on your model or collection. 102 | Defining storeName can be useful when your url is dynamic or when your models do not have the collection set but should be treated as part of that collection in the local cache. 103 | 104 | Install 105 | ------- 106 | 107 | Clone like usual or via npm `npm install nilbus/backbone.dualstorage --save`. 108 | 109 | Contributing 110 | ------------ 111 | 112 | See CONTRIBUTING.md 113 | 114 | Authors 115 | ------- 116 | 117 | Thanks to [Edward Anderson](https://github.com/nilbus) for the test suite and continued maintenance. 118 | Thanks to [Lucian Mihaila](https://github.com/lucian1900) for creating Backbone.dualStorage. 119 | Thanks to [Jerome Gravel-Niquet](https://github.com/jeromegn) for Backbone.localStorage. 120 | -------------------------------------------------------------------------------- /spec/localstorage_store_spec.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | (function() { 3 | var Store, localStorage; 4 | 5 | Store = window.Store, localStorage = window.localStorage; 6 | 7 | describe('window.Store', function() { 8 | var model, ref, store; 9 | ref = {}, store = ref.store, model = ref.model; 10 | beforeEach(function() { 11 | localStorage.clear(); 12 | localStorage.setItem('cats', '3'); 13 | localStorage.setItem('cats3', '{"id": "3", "color": "burgundy"}'); 14 | return store = new Store('cats'); 15 | }); 16 | describe('creation', function() { 17 | return it('takes a name in its constructor', function() { 18 | store = new Store('convenience store'); 19 | return expect(store.name).toBe('convenience store'); 20 | }); 21 | }); 22 | describe('persistence', function() { 23 | describe('find', function() { 24 | it('fetches records by id', function() { 25 | return expect(store.find({ 26 | id: 3 27 | })).toEqual({ 28 | id: '3', 29 | color: 'burgundy' 30 | }); 31 | }); 32 | it('does not try to JSON.parse null values', function() { 33 | spyOn(JSON, 'parse'); 34 | store.find({ 35 | id: 'unpersistedId' 36 | }); 37 | return expect(JSON.parse).not.toHaveBeenCalledWith(null); 38 | }); 39 | return it('returns null when not found', function() { 40 | var result; 41 | result = store.find({ 42 | id: 'unpersistedId' 43 | }); 44 | return expect(result).toBeNull(); 45 | }); 46 | }); 47 | it('fetches all records with findAll', function() { 48 | return expect(store.findAll()).toEqual([ 49 | { 50 | id: '3', 51 | color: 'burgundy' 52 | } 53 | ]); 54 | }); 55 | it('clears out its records', function() { 56 | store.clear(); 57 | expect(localStorage.getItem('cats')).toBe(''); 58 | return expect(localStorage.getItem('cats3')).toBeNull(); 59 | }); 60 | it('creates records', function() { 61 | model = { 62 | id: 2, 63 | color: 'blue' 64 | }; 65 | store.create(model); 66 | expect(localStorage.getItem('cats')).toBe('3,2'); 67 | return expect(JSON.parse(localStorage.getItem('cats2'))).toEqual({ 68 | id: 2, 69 | color: 'blue' 70 | }); 71 | }); 72 | it('overwrites existing records with the same id on create', function() { 73 | model = { 74 | id: 3, 75 | color: 'lavender' 76 | }; 77 | store.create(model); 78 | return expect(JSON.parse(localStorage.getItem('cats3'))).toEqual({ 79 | id: 3, 80 | color: 'lavender' 81 | }); 82 | }); 83 | it('generates an id when creating records with no id', function() { 84 | localStorage.clear(); 85 | store = new Store('cats'); 86 | model = { 87 | color: 'calico', 88 | idAttribute: 'id', 89 | set: function(attribute, value) { 90 | return this[attribute] = value; 91 | } 92 | }; 93 | store.create(model); 94 | expect(model.id).not.toBeNull(); 95 | return expect(localStorage.getItem('cats')).toBe(model.id); 96 | }); 97 | it('updates records', function() { 98 | store.update({ 99 | id: 3, 100 | color: 'green' 101 | }); 102 | return expect(JSON.parse(localStorage.getItem('cats3'))).toEqual({ 103 | id: 3, 104 | color: 'green' 105 | }); 106 | }); 107 | return it('destroys records', function() { 108 | store.destroy({ 109 | id: 3 110 | }); 111 | expect(localStorage.getItem('cats')).toBe(''); 112 | return expect(localStorage.getItem('cats3')).toBeNull(); 113 | }); 114 | }); 115 | return describe('offline', function() { 116 | it('on a clean slate, hasDirtyOrDestroyed returns false', function() { 117 | return expect(store.hasDirtyOrDestroyed()).toBeFalsy(); 118 | }); 119 | it('marks records dirty and clean, and reports if it hasDirtyOrDestroyed records', function() { 120 | store.dirty({ 121 | id: 3 122 | }); 123 | expect(store.hasDirtyOrDestroyed()).toBeTruthy(); 124 | store.clean({ 125 | id: 3 126 | }, 'dirty'); 127 | return expect(store.hasDirtyOrDestroyed()).toBeFalsy(); 128 | }); 129 | it('marks records destroyed and clean from destruction, and reports if it hasDirtyOrDestroyed records', function() { 130 | store.destroyed({ 131 | id: 3 132 | }); 133 | expect(store.hasDirtyOrDestroyed()).toBeTruthy(); 134 | store.clean({ 135 | id: 3 136 | }, 'destroyed'); 137 | return expect(store.hasDirtyOrDestroyed()).toBeFalsy(); 138 | }); 139 | return it('cleans the list of dirty or destroyed models out of localStorage after saving or destroying', function() { 140 | var collection; 141 | collection = new Backbone.Collection([ 142 | { 143 | id: 2, 144 | color: 'auburn' 145 | }, { 146 | id: 3, 147 | color: 'burgundy' 148 | } 149 | ]); 150 | collection.url = 'cats'; 151 | store.dirty({ 152 | id: 2 153 | }); 154 | store.destroyed({ 155 | id: 3 156 | }); 157 | expect(store.hasDirtyOrDestroyed()).toBeTruthy(); 158 | collection.get(2).save(); 159 | collection.get(3).destroy(); 160 | expect(store.hasDirtyOrDestroyed()).toBeFalsy(); 161 | expect(localStorage.getItem('cats_dirty').length).toBe(0); 162 | return expect(localStorage.getItem('cats_destroyed').length).toBe(0); 163 | }); 164 | }); 165 | }); 166 | 167 | }).call(this); 168 | 169 | //# sourceMappingURL=localstorage_store_spec.js.map 170 | -------------------------------------------------------------------------------- /lib/jasmine-1.3.1/jasmine.css: -------------------------------------------------------------------------------- 1 | body { background-color: #eeeeee; padding: 0; margin: 5px; overflow-y: scroll; } 2 | 3 | #HTMLReporter { font-size: 11px; font-family: Monaco, "Lucida Console", monospace; line-height: 14px; color: #333333; } 4 | #HTMLReporter a { text-decoration: none; } 5 | #HTMLReporter a:hover { text-decoration: underline; } 6 | #HTMLReporter p, #HTMLReporter h1, #HTMLReporter h2, #HTMLReporter h3, #HTMLReporter h4, #HTMLReporter h5, #HTMLReporter h6 { margin: 0; line-height: 14px; } 7 | #HTMLReporter .banner, #HTMLReporter .symbolSummary, #HTMLReporter .summary, #HTMLReporter .resultMessage, #HTMLReporter .specDetail .description, #HTMLReporter .alert .bar, #HTMLReporter .stackTrace { padding-left: 9px; padding-right: 9px; } 8 | #HTMLReporter #jasmine_content { position: fixed; right: 100%; } 9 | #HTMLReporter .version { color: #aaaaaa; } 10 | #HTMLReporter .banner { margin-top: 14px; } 11 | #HTMLReporter .duration { color: #aaaaaa; float: right; } 12 | #HTMLReporter .symbolSummary { overflow: hidden; *zoom: 1; margin: 14px 0; } 13 | #HTMLReporter .symbolSummary li { display: block; float: left; height: 7px; width: 14px; margin-bottom: 7px; font-size: 16px; } 14 | #HTMLReporter .symbolSummary li.passed { font-size: 14px; } 15 | #HTMLReporter .symbolSummary li.passed:before { color: #5e7d00; content: "\02022"; } 16 | #HTMLReporter .symbolSummary li.failed { line-height: 9px; } 17 | #HTMLReporter .symbolSummary li.failed:before { color: #b03911; content: "x"; font-weight: bold; margin-left: -1px; } 18 | #HTMLReporter .symbolSummary li.skipped { font-size: 14px; } 19 | #HTMLReporter .symbolSummary li.skipped:before { color: #bababa; content: "\02022"; } 20 | #HTMLReporter .symbolSummary li.pending { line-height: 11px; } 21 | #HTMLReporter .symbolSummary li.pending:before { color: #aaaaaa; content: "-"; } 22 | #HTMLReporter .exceptions { color: #fff; float: right; margin-top: 5px; margin-right: 5px; } 23 | #HTMLReporter .bar { line-height: 28px; font-size: 14px; display: block; color: #eee; } 24 | #HTMLReporter .runningAlert { background-color: #666666; } 25 | #HTMLReporter .skippedAlert { background-color: #aaaaaa; } 26 | #HTMLReporter .skippedAlert:first-child { background-color: #333333; } 27 | #HTMLReporter .skippedAlert:hover { text-decoration: none; color: white; text-decoration: underline; } 28 | #HTMLReporter .passingAlert { background-color: #a6b779; } 29 | #HTMLReporter .passingAlert:first-child { background-color: #5e7d00; } 30 | #HTMLReporter .failingAlert { background-color: #cf867e; } 31 | #HTMLReporter .failingAlert:first-child { background-color: #b03911; } 32 | #HTMLReporter .results { margin-top: 14px; } 33 | #HTMLReporter #details { display: none; } 34 | #HTMLReporter .resultsMenu, #HTMLReporter .resultsMenu a { background-color: #fff; color: #333333; } 35 | #HTMLReporter.showDetails .summaryMenuItem { font-weight: normal; text-decoration: inherit; } 36 | #HTMLReporter.showDetails .summaryMenuItem:hover { text-decoration: underline; } 37 | #HTMLReporter.showDetails .detailsMenuItem { font-weight: bold; text-decoration: underline; } 38 | #HTMLReporter.showDetails .summary { display: none; } 39 | #HTMLReporter.showDetails #details { display: block; } 40 | #HTMLReporter .summaryMenuItem { font-weight: bold; text-decoration: underline; } 41 | #HTMLReporter .summary { margin-top: 14px; } 42 | #HTMLReporter .summary .suite .suite, #HTMLReporter .summary .specSummary { margin-left: 14px; } 43 | #HTMLReporter .summary .specSummary.passed a { color: #5e7d00; } 44 | #HTMLReporter .summary .specSummary.failed a { color: #b03911; } 45 | #HTMLReporter .description + .suite { margin-top: 0; } 46 | #HTMLReporter .suite { margin-top: 14px; } 47 | #HTMLReporter .suite a { color: #333333; } 48 | #HTMLReporter #details .specDetail { margin-bottom: 28px; } 49 | #HTMLReporter #details .specDetail .description { display: block; color: white; background-color: #b03911; } 50 | #HTMLReporter .resultMessage { padding-top: 14px; color: #333333; } 51 | #HTMLReporter .resultMessage span.result { display: block; } 52 | #HTMLReporter .stackTrace { margin: 5px 0 0 0; max-height: 224px; overflow: auto; line-height: 18px; color: #666666; border: 1px solid #ddd; background: white; white-space: pre; } 53 | 54 | #TrivialReporter { padding: 8px 13px; position: absolute; top: 0; bottom: 0; left: 0; right: 0; overflow-y: scroll; background-color: white; font-family: "Helvetica Neue Light", "Lucida Grande", "Calibri", "Arial", sans-serif; /*.resultMessage {*/ /*white-space: pre;*/ /*}*/ } 55 | #TrivialReporter a:visited, #TrivialReporter a { color: #303; } 56 | #TrivialReporter a:hover, #TrivialReporter a:active { color: blue; } 57 | #TrivialReporter .run_spec { float: right; padding-right: 5px; font-size: .8em; text-decoration: none; } 58 | #TrivialReporter .banner { color: #303; background-color: #fef; padding: 5px; } 59 | #TrivialReporter .logo { float: left; font-size: 1.1em; padding-left: 5px; } 60 | #TrivialReporter .logo .version { font-size: .6em; padding-left: 1em; } 61 | #TrivialReporter .runner.running { background-color: yellow; } 62 | #TrivialReporter .options { text-align: right; font-size: .8em; } 63 | #TrivialReporter .suite { border: 1px outset gray; margin: 5px 0; padding-left: 1em; } 64 | #TrivialReporter .suite .suite { margin: 5px; } 65 | #TrivialReporter .suite.passed { background-color: #dfd; } 66 | #TrivialReporter .suite.failed { background-color: #fdd; } 67 | #TrivialReporter .spec { margin: 5px; padding-left: 1em; clear: both; } 68 | #TrivialReporter .spec.failed, #TrivialReporter .spec.passed, #TrivialReporter .spec.skipped { padding-bottom: 5px; border: 1px solid gray; } 69 | #TrivialReporter .spec.failed { background-color: #fbb; border-color: red; } 70 | #TrivialReporter .spec.passed { background-color: #bfb; border-color: green; } 71 | #TrivialReporter .spec.skipped { background-color: #bbb; } 72 | #TrivialReporter .messages { border-left: 1px dashed gray; padding-left: 1em; padding-right: 1em; } 73 | #TrivialReporter .passed { background-color: #cfc; display: none; } 74 | #TrivialReporter .failed { background-color: #fbb; } 75 | #TrivialReporter .skipped { color: #777; background-color: #eee; display: none; } 76 | #TrivialReporter .resultMessage span.result { display: block; line-height: 2em; color: black; } 77 | #TrivialReporter .resultMessage .mismatch { color: black; } 78 | #TrivialReporter .stackTrace { white-space: pre; font-size: .8em; margin-left: 10px; max-height: 5em; overflow: auto; border: 1px inset red; padding: 1em; background: #eef; } 79 | #TrivialReporter .finished-at { padding-left: 1em; font-size: .6em; } 80 | #TrivialReporter.show-passed .passed, #TrivialReporter.show-skipped .skipped { display: block; } 81 | #TrivialReporter #jasmine_content { position: fixed; right: 100%; } 82 | #TrivialReporter .runner { border: 1px solid gray; display: block; margin: 5px 0; padding: 2px 0 2px 10px; } 83 | -------------------------------------------------------------------------------- /spec/integration_spec.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | (function() { 3 | var backboneSync, collection, dualSync, localStorage, localsync, model, ref, 4 | extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, 5 | hasProp = {}.hasOwnProperty, 6 | slice = [].slice; 7 | 8 | backboneSync = window.backboneSync, localsync = window.localsync, dualSync = window.dualSync, localStorage = window.localStorage; 9 | 10 | ref = {}, collection = ref.collection, model = ref.model; 11 | 12 | beforeEach(function() { 13 | backboneSync.calls = []; 14 | localsync('clear', {}, { 15 | ignoreCallbacks: true, 16 | storeName: 'eyes/' 17 | }); 18 | collection = new Backbone.Collection({ 19 | id: 123, 20 | vision: 'crystal' 21 | }); 22 | collection.url = 'eyes/'; 23 | return model = collection.models[0]; 24 | }); 25 | 26 | describe('using Backbone.sync directly', function() { 27 | return it('should save and retrieve data', function() { 28 | var errorCallback, fetched, ref1, saved, successCallback; 29 | ref1 = {}, successCallback = ref1.successCallback, errorCallback = ref1.errorCallback; 30 | saved = false; 31 | runs(function() { 32 | localStorage.clear(); 33 | successCallback = jasmine.createSpy('success').andCallFake(function() { 34 | return saved = true; 35 | }); 36 | errorCallback = jasmine.createSpy('error'); 37 | return dualsync('create', model, { 38 | success: successCallback, 39 | error: errorCallback 40 | }); 41 | }); 42 | waitsFor((function() { 43 | return saved; 44 | }), "The success callback for 'create' should have been called", 100); 45 | runs(function() { 46 | expect(backboneSync.calls.length).toEqual(1); 47 | expect(successCallback).toHaveBeenCalled(); 48 | expect(errorCallback).not.toHaveBeenCalled(); 49 | return expect(localStorage.length).toBeGreaterThan(0); 50 | }); 51 | fetched = false; 52 | runs(function() { 53 | successCallback = jasmine.createSpy('success').andCallFake(callbackTranslator.forBackboneCaller(function(resp) { 54 | fetched = true; 55 | return expect(resp.vision).toEqual('crystal'); 56 | })); 57 | errorCallback = jasmine.createSpy('error'); 58 | return dualsync('read', model, { 59 | success: successCallback, 60 | error: errorCallback 61 | }); 62 | }); 63 | waitsFor((function() { 64 | return fetched; 65 | }), "The success callback for 'read' should have been called", 100); 66 | return runs(function() { 67 | expect(backboneSync.calls.length).toEqual(2); 68 | expect(successCallback).toHaveBeenCalled(); 69 | return expect(errorCallback).not.toHaveBeenCalled(); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('using backbone models and retrieving from local storage', function() { 75 | it("fetches a model offline after saving it online", function() { 76 | var fetched, retrievedModel, saved; 77 | saved = false; 78 | runs(function() { 79 | return model.save({}, { 80 | success: function() { 81 | return saved = true; 82 | } 83 | }); 84 | }); 85 | waitsFor((function() { 86 | return saved; 87 | }), "The success callback for 'save' should have been called", 100); 88 | fetched = false; 89 | retrievedModel = new Backbone.Model({ 90 | id: 123 91 | }); 92 | retrievedModel.collection = collection; 93 | runs(function() { 94 | return retrievedModel.fetch({ 95 | remote: false, 96 | success: function() { 97 | return fetched = true; 98 | } 99 | }); 100 | }); 101 | waitsFor((function() { 102 | return fetched; 103 | }), "The success callback for 'fetch' should have been called", 100); 104 | return runs(function() { 105 | return expect(retrievedModel.get('vision')).toEqual('crystal'); 106 | }); 107 | }); 108 | return it("works with an idAttribute other than 'id'", function() { 109 | var NonstandardModel, fetched, retrievedModel, saved; 110 | NonstandardModel = (function(superClass) { 111 | extend(NonstandardModel, superClass); 112 | 113 | function NonstandardModel() { 114 | return NonstandardModel.__super__.constructor.apply(this, arguments); 115 | } 116 | 117 | NonstandardModel.prototype.idAttribute = 'eyeDee'; 118 | 119 | NonstandardModel.prototype.url = 'eyes/'; 120 | 121 | return NonstandardModel; 122 | 123 | })(Backbone.Model); 124 | model = new NonstandardModel({ 125 | eyeDee: 123, 126 | vision: 'crystal' 127 | }); 128 | saved = false; 129 | runs(function() { 130 | return model.save({}, { 131 | success: function() { 132 | return saved = true; 133 | } 134 | }); 135 | }); 136 | waitsFor((function() { 137 | return saved; 138 | }), "The success callback for 'save' should have been called", 100); 139 | fetched = false; 140 | retrievedModel = new NonstandardModel({ 141 | eyeDee: 123 142 | }); 143 | runs(function() { 144 | return retrievedModel.fetch({ 145 | remote: false, 146 | success: function() { 147 | return fetched = true; 148 | } 149 | }); 150 | }); 151 | waitsFor((function() { 152 | return fetched; 153 | }), "The success callback for 'fetch' should have been called", 100); 154 | return runs(function() { 155 | return expect(retrievedModel.get('vision')).toEqual('crystal'); 156 | }); 157 | }); 158 | }); 159 | 160 | describe('using backbone collections and retrieving from local storage', function() { 161 | return it('loads a collection after adding several models to it', function() { 162 | var fetched, saved; 163 | saved = 0; 164 | runs(function() { 165 | var i, id, newModel; 166 | for (id = i = 1; i <= 3; id = ++i) { 167 | newModel = new Backbone.Model({ 168 | id: id 169 | }); 170 | newModel.collection = collection; 171 | newModel.save({}, { 172 | success: function() { 173 | return saved += 1; 174 | } 175 | }); 176 | } 177 | return waitsFor((function() { 178 | return saved === 3; 179 | }), "The success callback for 'save' should have been called for id #" + id, 100); 180 | }); 181 | fetched = false; 182 | runs(function() { 183 | return collection.fetch({ 184 | remote: false, 185 | success: function() { 186 | return fetched = true; 187 | } 188 | }); 189 | }); 190 | waitsFor((function() { 191 | return fetched; 192 | }), "The success callback for 'fetch' should have been called", 100); 193 | return runs(function() { 194 | expect(collection.length).toEqual(3); 195 | return expect(collection.map(function(model) { 196 | return model.id; 197 | })).toEqual([1, 2, 3]); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('success and error callback parameters', function() { 203 | return it("passes back the response into the remote method's callback", function() { 204 | var callbackResponse; 205 | callbackResponse = null; 206 | runs(function() { 207 | model.remote = true; 208 | return model.fetch({ 209 | success: function() { 210 | var args; 211 | args = 1 <= arguments.length ? slice.call(arguments, 0) : []; 212 | return callbackResponse = args; 213 | } 214 | }); 215 | }); 216 | waitsFor((function() { 217 | return callbackResponse; 218 | }), "The success callback for 'fetch' should have been called", 100); 219 | return runs(function() { 220 | expect(callbackResponse[0]).toEqual(model); 221 | return expect(callbackResponse[1]).toEqual(model.attributes); 222 | }); 223 | }); 224 | }); 225 | 226 | }).call(this); 227 | 228 | //# sourceMappingURL=integration_spec.js.map 229 | -------------------------------------------------------------------------------- /spec/localsync_spec.coffee: -------------------------------------------------------------------------------- 1 | {Store, Backbone, localsync} = window 2 | 3 | describe 'localsync', -> 4 | describe 'standard Backbone.sync methods', -> 5 | describe 'creating records', -> 6 | it 'creates records', -> 7 | {ready, create, model, options} = {} 8 | runs -> 9 | create = spyOn(Store.prototype, 'create') 10 | model = new Backbone.Model id: 1 11 | options = {success: (-> ready = true), error: (-> ready = true)} 12 | localsync 'create', model, options 13 | waitsFor (-> ready), "A callback should have been called", 100 14 | runs -> 15 | expect(create).toHaveBeenCalledWith model, options 16 | 17 | it 'does not overwrite existing models with fetch(add: true) unless passed merge: true', -> 18 | {ready, create} = {} 19 | runs -> 20 | ready = false 21 | create = spyOn(Store.prototype, 'find').andReturn id: 1 22 | create = spyOn(Store.prototype, 'create') 23 | model = new Backbone.Model id: 1 24 | localsync 'create', model, {success: (-> ready = true), error: (-> ready = true), add: true} 25 | waitsFor (-> ready), "A callback should have been called", 100 26 | runs -> 27 | ready = false 28 | expect(create).not.toHaveBeenCalled() 29 | model = new Backbone.Model id: 1 30 | localsync 'create', model, {success: (-> ready = true), error: (-> ready = true), add: true, merge: true} 31 | waitsFor (-> ready), "A callback should have been called", 100 32 | runs -> 33 | expect(create).toHaveBeenCalled() 34 | 35 | it 'supports marking a new record dirty', -> 36 | {ready, create, model, dirty, options} = {} 37 | runs -> 38 | model = new Backbone.Model id: 1 39 | create = spyOn(Store.prototype, 'create').andReturn model 40 | dirty = spyOn(Store.prototype, 'dirty') 41 | options = {success: (-> ready = true), error: (-> ready = true), dirty: true} 42 | localsync 'create', model, options 43 | waitsFor (-> ready), "A callback should have been called", 100 44 | runs -> 45 | expect(create).toHaveBeenCalledWith model, options 46 | expect(dirty).toHaveBeenCalledWith model 47 | 48 | describe 'reading records', -> 49 | it 'reads models', -> 50 | {ready, find, model} = {} 51 | runs -> 52 | find = spyOn(Store.prototype, 'find') 53 | model = new Backbone.Model id: 1 54 | localsync 'read', model, {success: (-> ready = true), error: (-> ready = true)} 55 | waitsFor (-> ready), "A callback should have been called", 100 56 | runs -> 57 | expect(find).toHaveBeenCalledWith model 58 | 59 | it 'reads collections', -> 60 | {ready, findAll} = {} 61 | runs -> 62 | findAll = spyOn(Store.prototype, 'findAll') 63 | localsync 'read', new Backbone.Collection, {success: (-> ready = true), error: (-> ready = true)} 64 | waitsFor (-> ready), "A callback should have been called", 100 65 | runs -> 66 | expect(findAll).toHaveBeenCalled() 67 | 68 | describe 'updating records', -> 69 | it 'updates records', -> 70 | {ready, update, model, options} = {} 71 | runs -> 72 | update = spyOn(Store.prototype, 'update') 73 | model = new Backbone.Model id: 1 74 | options = {success: (-> ready = true), error: (-> ready = true)} 75 | localsync 'update', model, options 76 | waitsFor (-> ready), "A callback should have been called", 100 77 | runs -> 78 | expect(update).toHaveBeenCalledWith model, options 79 | 80 | it 'supports marking an updated record dirty', -> 81 | {ready, update, model, dirty, options} = {} 82 | runs -> 83 | model = new Backbone.Model id: 1 84 | update = spyOn(Store.prototype, 'update') 85 | dirty = spyOn(Store.prototype, 'dirty') 86 | options = {success: (-> ready = true), error: (-> ready = true), dirty: true} 87 | localsync 'update', model, options 88 | waitsFor (-> ready), "A callback should have been called", 100 89 | runs -> 90 | expect(update).toHaveBeenCalledWith model, options 91 | expect(dirty).toHaveBeenCalledWith model 92 | 93 | describe 'deleting records', -> 94 | it 'deletes records', -> 95 | {ready, destroy, model} = {} 96 | runs -> 97 | destroy = spyOn(Store.prototype, 'destroy') 98 | model = new Backbone.Model id: 1 99 | localsync 'delete', model, {success: (-> ready = true), error: (-> ready = true)} 100 | waitsFor (-> ready), "A callback should have been called", 100 101 | runs -> 102 | expect(destroy).toHaveBeenCalledWith model 103 | 104 | it 'supports marking a dirty record destroyed', -> 105 | {ready, destroy, destroyed, model} = {} 106 | runs -> 107 | model = new Backbone.Model id: 1 108 | destroy = spyOn(Store.prototype, 'destroy') 109 | destroyed = spyOn(Store.prototype, 'destroyed') 110 | localsync 'delete', model, {success: (-> ready = true), error: (-> ready = true), dirty: true} 111 | waitsFor (-> ready), "A callback should have been called", 100 112 | runs -> 113 | expect(destroy).toHaveBeenCalledWith model 114 | expect(destroyed).toHaveBeenCalledWith model 115 | 116 | it "doesn't mark a model with a temp id as destroyed", -> 117 | {destroy, destroyed, model, success} = {} 118 | runs -> 119 | model = new Backbone.Model 120 | model.id = Store.prototype.generateId() 121 | 122 | destroy = spyOn(Store.prototype, 'destroy') 123 | destroyed = spyOn(Store.prototype, 'destroyed') 124 | success = jasmine.createSpy("success") 125 | localsync 'delete', model, { dirty: true, success: success } 126 | runs -> 127 | expect(destroy).toHaveBeenCalledWith model 128 | expect(destroyed).not.toHaveBeenCalled 129 | expect(success).toHaveBeenCalled() 130 | 131 | describe 'extra methods', -> 132 | it 'clears out all records from the store', -> 133 | runs -> 134 | clear = spyOn(Store.prototype, 'clear') 135 | localsync 'clear', {}, {success: (-> ready = true), error: (-> ready = true)} 136 | 137 | it 'reports whether or not it hasDirtyOrDestroyed', -> 138 | runs -> 139 | clear = spyOn(Store.prototype, 'hasDirtyOrDestroyed') 140 | localsync 'hasDirtyOrDestroyed', {}, {success: (-> ready = true), error: (-> ready = true)} 141 | 142 | describe 'callbacks', -> 143 | it "sends the models's attributes as the callback response", -> 144 | {model, response} = {} 145 | runs -> 146 | model = new Backbone.Model id: 1 147 | localsync 'create', model, {success: ((resp) -> response = resp)} 148 | waitsFor (-> response), "A callback should have been called with a response", 100 149 | runs -> 150 | expect(response).toEqual model.attributes 151 | 152 | it 'ignores callbacks when the ignoreCallbacks option is set', -> 153 | {start, callback} = {start: new Date().getTime()} 154 | runs -> 155 | callback = jasmine.createSpy 'callback' 156 | model = new Backbone.Model id: 1 157 | localsync 'create', model, {success: callback, error: callback, ignoreCallbacks: true} 158 | waitsFor (-> new Date().getTime() - start > 5), 'Wait 5 ms to give the callback a chance to execute', 100 159 | runs -> 160 | start = false 161 | expect(callback).not.toHaveBeenCalled() 162 | model = new Backbone.Model id: 1 163 | localsync 'create', model, {success: callback, error: callback} 164 | waitsFor (-> callback.wasCalled), 'The callback should have been called', 100 165 | 166 | describe 'model parameter', -> 167 | beforeEach -> 168 | spyOn(Store.prototype, 'create') 169 | 170 | it 'should not accept objects / attributes as model', -> 171 | attributes = {} 172 | call = -> localsync 'create', attributes, {ignoreCallbacks: true} 173 | expect(call).toThrow() 174 | 175 | it 'should accept a backbone model as model', -> 176 | call = -> localsync 'create', new Backbone.Model, {ignoreCallbacks: true} 177 | expect(call).not.toThrow() 178 | 179 | it 'should accept a backbone collection as model', -> 180 | call = -> localsync 'create', new Backbone.Collection, {ignoreCallbacks: true} 181 | expect(call).not.toThrow() 182 | 183 | it 'should accept any object as model on extra method "clear"', -> 184 | call = -> localsync 'clear', {}, {ignoreCallbacks: true} 185 | expect(call).not.toThrow() 186 | 187 | it 'should accept any object as model on extra method "hasDirtyOrDestroyed"', -> 188 | call = -> localsync 'hasDirtyOrDestroyed', {}, {ignoreCallbacks: true} 189 | expect(call).not.toThrow() 190 | -------------------------------------------------------------------------------- /backbone.dualstorage.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | Backbone dualStorage Adapter v1.4.2 3 | 4 | A simple module to replace `Backbone.sync` with *localStorage*-based 5 | persistence. Models are given GUIDS, and saved into a JSON object. Simple 6 | as that. 7 | ### 8 | 9 | Backbone.DualStorage = { 10 | offlineStatusCodes: [408, 502] 11 | } 12 | 13 | Backbone.Model.prototype.hasTempId = -> 14 | _.isString(@id) and @id.length is 36 and @id.indexOf('t') == 0 15 | 16 | getStoreName = (collection, model) -> 17 | model ||= collection.model.prototype 18 | _.result(collection, 'storeName') || _.result(model, 'storeName') || 19 | _.result(collection, 'url') || _.result(model, 'urlRoot') || _.result(model, 'url') 20 | 21 | # Make it easy for collections to sync dirty and destroyed records 22 | # Simply call collection.syncDirtyAndDestroyed() 23 | Backbone.Collection.prototype.syncDirty = (options) -> 24 | store = localStorage.getItem("#{getStoreName(@)}_dirty") 25 | ids = (store and store.split(',')) or [] 26 | 27 | for id in ids 28 | @get(id)?.save(null, options) 29 | 30 | Backbone.Collection.prototype.dirtyModels = -> 31 | store = localStorage.getItem("#{getStoreName(@)}_dirty") 32 | ids = (store and store.split(',')) or [] 33 | models = for id in ids 34 | @get(id) 35 | 36 | _.compact(models) 37 | 38 | Backbone.Collection.prototype.syncDestroyed = (options) -> 39 | store = localStorage.getItem("#{getStoreName(@)}_destroyed") 40 | ids = (store and store.split(',')) or [] 41 | 42 | for id in ids 43 | model = new @model 44 | model.set model.idAttribute, id 45 | model.collection = @ 46 | model.destroy(options) 47 | 48 | Backbone.Collection.prototype.destroyedModelIds = -> 49 | store = localStorage.getItem("#{getStoreName(@)}_destroyed") 50 | 51 | ids = (store and store.split(',')) or [] 52 | 53 | Backbone.Collection.prototype.syncDirtyAndDestroyed = (options) -> 54 | @syncDirty(options) 55 | @syncDestroyed(options) 56 | 57 | # Generate four random hex digits. 58 | S4 = -> 59 | (((1 + Math.random()) * 0x10000) | 0).toString(16).substring 1 60 | 61 | # Our Store is represented by a single JS object in *localStorage*. Create it 62 | # with a meaningful name, like the name you'd give a table. 63 | class window.Store 64 | sep: '' # previously '-' 65 | 66 | constructor: (name) -> 67 | @name = name 68 | @records = @recordsOn @name 69 | 70 | # Generates an unique id to use when saving new instances into localstorage 71 | # by default generates a pseudo-GUID by concatenating random hexadecimal. 72 | # you can overwrite this function to use another strategy 73 | generateId: -> 74 | 't' + S4().substring(1) + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4() 75 | 76 | # Save the current state of the **Store** to *localStorage*. 77 | save: -> 78 | localStorage.setItem @name, @records.join(',') 79 | 80 | recordsOn: (key) -> 81 | store = localStorage.getItem(key) 82 | (store and store.split(',')) or [] 83 | 84 | dirty: (model) -> 85 | dirtyRecords = @recordsOn @name + '_dirty' 86 | if not _.include(dirtyRecords, model.id.toString()) 87 | dirtyRecords.push model.id 88 | localStorage.setItem @name + '_dirty', dirtyRecords.join(',') 89 | model 90 | 91 | clean: (model, from) -> 92 | store = "#{@name}_#{from}" 93 | dirtyRecords = @recordsOn store 94 | if _.include dirtyRecords, model.id.toString() 95 | localStorage.setItem store, _.without(dirtyRecords, model.id.toString()).join(',') 96 | model 97 | 98 | destroyed: (model) -> 99 | destroyedRecords = @recordsOn @name + '_destroyed' 100 | if not _.include destroyedRecords, model.id.toString() 101 | destroyedRecords.push model.id 102 | localStorage.setItem @name + '_destroyed', destroyedRecords.join(',') 103 | model 104 | 105 | # Add a model, giving it a unique GUID, if it doesn't already 106 | # have an id of it's own. 107 | create: (model, options) -> 108 | if not _.isObject(model) then return model 109 | if not model.id 110 | model.set model.idAttribute, @generateId() 111 | localStorage.setItem @name + @sep + model.id, JSON.stringify(if model.toJSON then model.toJSON(options) else model) 112 | @records.push model.id.toString() 113 | @save() 114 | model 115 | 116 | # Update a model by replacing its copy in `this.data`. 117 | update: (model, options) -> 118 | localStorage.setItem @name + @sep + model.id, JSON.stringify(if model.toJSON then model.toJSON(options) else model) 119 | if not _.include(@records, model.id.toString()) 120 | @records.push model.id.toString() 121 | @save() 122 | model 123 | 124 | clear: -> 125 | for id in @records 126 | localStorage.removeItem @name + @sep + id 127 | @records = [] 128 | @save() 129 | 130 | hasDirtyOrDestroyed: -> 131 | not _.isEmpty(localStorage.getItem(@name + '_dirty')) or not _.isEmpty(localStorage.getItem(@name + '_destroyed')) 132 | 133 | # Retrieve a model from `this.data` by id. 134 | find: (model) -> 135 | modelAsJson = localStorage.getItem(@name + @sep + model.id) 136 | return null if modelAsJson == null 137 | JSON.parse modelAsJson 138 | 139 | # Return the array of all models currently in storage. 140 | findAll: -> 141 | for id in @records 142 | JSON.parse localStorage.getItem(@name + @sep + id) 143 | 144 | # Delete a model from `this.data`, returning it. 145 | destroy: (model) -> 146 | localStorage.removeItem @name + @sep + model.id 147 | @records = _.reject(@records, (record_id) -> 148 | record_id is model.id.toString() 149 | ) 150 | @save() 151 | model 152 | 153 | 154 | window.Store.exists = (storeName) -> localStorage.getItem(storeName) isnt null 155 | 156 | callbackTranslator = 157 | needsTranslation: Backbone.VERSION == '0.9.10' 158 | 159 | forBackboneCaller: (callback) -> 160 | if @needsTranslation 161 | (model, resp, options) -> callback.call null, resp 162 | else 163 | callback 164 | 165 | forDualstorageCaller: (callback, model, options) -> 166 | if @needsTranslation 167 | (resp) -> callback.call null, model, resp, options 168 | else 169 | callback 170 | 171 | # Override `Backbone.sync` to use delegate to the model or collection's 172 | # *localStorage* property, which should be an instance of `Store`. 173 | localsync = (method, model, options) -> 174 | isValidModel = (method is 'clear') or (method is 'hasDirtyOrDestroyed') 175 | isValidModel ||= model instanceof Backbone.Model 176 | isValidModel ||= model instanceof Backbone.Collection 177 | 178 | if not isValidModel 179 | throw new Error 'model parameter is required to be a backbone model or collection.' 180 | 181 | store = new Store options.storeName 182 | 183 | response = switch method 184 | when 'read' 185 | if model instanceof Backbone.Model 186 | store.find(model) 187 | else 188 | store.findAll() 189 | when 'hasDirtyOrDestroyed' 190 | store.hasDirtyOrDestroyed() 191 | when 'clear' 192 | store.clear() 193 | when 'create' 194 | if options.add and not options.merge and (preExisting = store.find(model)) 195 | preExisting 196 | else 197 | model = store.create(model, options) 198 | store.dirty(model) if options.dirty 199 | model 200 | when 'update' 201 | store.update(model, options) 202 | if options.dirty 203 | store.dirty(model) 204 | else 205 | store.clean(model, 'dirty') 206 | when 'delete' 207 | store.destroy(model) 208 | if options.dirty && !model.hasTempId() 209 | store.destroyed(model) 210 | else 211 | if model.hasTempId() 212 | store.clean(model, 'dirty') 213 | else 214 | store.clean(model, 'destroyed') 215 | 216 | if response 217 | if response.toJSON 218 | response = response.toJSON(options) 219 | if response.attributes 220 | response = response.attributes 221 | 222 | unless options.ignoreCallbacks 223 | if response 224 | options.success response 225 | else 226 | options.error 'Record not found' 227 | 228 | response 229 | 230 | # Helper function to run parseBeforeLocalSave() in order to 231 | # parse a remote JSON response before caching locally 232 | parseRemoteResponse = (object, response) -> 233 | if not (object and object.parseBeforeLocalSave) then return response 234 | if _.isFunction(object.parseBeforeLocalSave) then object.parseBeforeLocalSave(response) 235 | 236 | modelUpdatedWithResponse = (model, response) -> 237 | modelClone = new Backbone.Model 238 | modelClone.idAttribute = model.idAttribute 239 | modelClone.set model.attributes 240 | modelClone.set model.parse response 241 | modelClone 242 | 243 | backboneSync = Backbone.DualStorage.originalSync = Backbone.sync 244 | onlineSync = (method, model, options) -> 245 | options.success = callbackTranslator.forBackboneCaller(options.success) 246 | options.error = callbackTranslator.forBackboneCaller(options.error) 247 | Backbone.DualStorage.originalSync(method, model, options) 248 | 249 | dualsync = (method, model, options) -> 250 | options.storeName = getStoreName(model.collection, model) 251 | options.storeExists = Store.exists(options.storeName) 252 | options.success = callbackTranslator.forDualstorageCaller(options.success, model, options) 253 | options.error = callbackTranslator.forDualstorageCaller(options.error, model, options) 254 | 255 | # execute only online sync 256 | return onlineSync(method, model, options) if _.result(model, 'remote') or _.result(model.collection, 'remote') 257 | 258 | # execute only local sync 259 | local = _.result(model, 'local') or _.result(model.collection, 'local') 260 | options.dirty = options.remote is false and not local 261 | return localsync(method, model, options) if options.remote is false or local 262 | 263 | # execute dual sync 264 | options.ignoreCallbacks = true 265 | 266 | success = options.success 267 | error = options.error 268 | 269 | useOfflineStorage = -> 270 | options.dirty = true 271 | options.ignoreCallbacks = false 272 | options.success = success 273 | options.error = error 274 | localsync(method, model, options) 275 | 276 | hasOfflineStatusCode = (xhr) -> 277 | offlineStatusCodes = Backbone.DualStorage.offlineStatusCodes 278 | offlineStatusCodes = offlineStatusCodes(xhr) if _.isFunction(offlineStatusCodes) 279 | xhr.status == 0 or xhr.status in offlineStatusCodes 280 | 281 | relayErrorCallback = (xhr) -> 282 | online = not hasOfflineStatusCode xhr 283 | if online or method == 'read' and not options.storeExists 284 | error xhr 285 | else 286 | useOfflineStorage() 287 | 288 | switch method 289 | when 'read' 290 | if localsync('hasDirtyOrDestroyed', model, options) 291 | useOfflineStorage() 292 | else 293 | options.success = (resp, _status, _xhr) -> 294 | return useOfflineStorage() if hasOfflineStatusCode options.xhr 295 | resp = parseRemoteResponse(model, resp) 296 | 297 | if model instanceof Backbone.Collection 298 | collection = model 299 | idAttribute = collection.model.prototype.idAttribute 300 | localsync('clear', collection, options) unless options.add 301 | for modelAttributes in resp 302 | model = collection.get(modelAttributes[idAttribute]) 303 | if model 304 | responseModel = modelUpdatedWithResponse(model, modelAttributes) 305 | else 306 | responseModel = new collection.model(modelAttributes, options) 307 | localsync('update', responseModel, options) 308 | else 309 | responseModel = modelUpdatedWithResponse(model, resp) 310 | localsync('update', responseModel, options) 311 | 312 | success(resp, _status, _xhr) 313 | 314 | options.error = (xhr) -> 315 | relayErrorCallback xhr 316 | 317 | options.xhr = onlineSync(method, model, options) 318 | 319 | when 'create' 320 | options.success = (resp, _status, _xhr) -> 321 | return useOfflineStorage() if hasOfflineStatusCode options.xhr 322 | updatedModel = modelUpdatedWithResponse model, resp 323 | localsync(method, updatedModel, options) 324 | success(resp, _status, _xhr) 325 | options.error = (xhr) -> 326 | relayErrorCallback xhr 327 | 328 | options.xhr = onlineSync(method, model, options) 329 | 330 | when 'update' 331 | if model.hasTempId() 332 | temporaryId = model.id 333 | 334 | options.success = (resp, _status, _xhr) -> 335 | model.set model.idAttribute, temporaryId, silent: true 336 | return useOfflineStorage() if hasOfflineStatusCode options.xhr 337 | updatedModel = modelUpdatedWithResponse model, resp 338 | localsync('delete', model, options) 339 | localsync('create', updatedModel, options) 340 | success(resp, _status, _xhr) 341 | options.error = (xhr) -> 342 | model.set model.idAttribute, temporaryId, silent: true 343 | relayErrorCallback xhr 344 | 345 | model.set model.idAttribute, null, silent: true 346 | options.xhr = onlineSync('create', model, options) 347 | else 348 | options.success = (resp, _status, _xhr) -> 349 | return useOfflineStorage() if hasOfflineStatusCode options.xhr 350 | updatedModel = modelUpdatedWithResponse model, resp 351 | localsync(method, updatedModel, options) 352 | success(resp, _status, _xhr) 353 | options.error = (xhr) -> 354 | relayErrorCallback xhr 355 | 356 | options.xhr = onlineSync(method, model, options) 357 | 358 | when 'delete' 359 | if model.hasTempId() 360 | options.ignoreCallbacks = false 361 | localsync(method, model, options) 362 | else 363 | options.success = (resp, _status, _xhr) -> 364 | return useOfflineStorage() if hasOfflineStatusCode options.xhr 365 | localsync(method, model, options) 366 | success(resp, _status, _xhr) 367 | options.error = (xhr) -> 368 | relayErrorCallback xhr 369 | 370 | options.xhr = onlineSync(method, model, options) 371 | 372 | Backbone.sync = dualsync 373 | -------------------------------------------------------------------------------- /spec/localsync_spec.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | (function() { 3 | var Backbone, Store, localsync; 4 | 5 | Store = window.Store, Backbone = window.Backbone, localsync = window.localsync; 6 | 7 | describe('localsync', function() { 8 | describe('standard Backbone.sync methods', function() { 9 | describe('creating records', function() { 10 | it('creates records', function() { 11 | var create, model, options, ready, ref; 12 | ref = {}, ready = ref.ready, create = ref.create, model = ref.model, options = ref.options; 13 | runs(function() { 14 | create = spyOn(Store.prototype, 'create'); 15 | model = new Backbone.Model({ 16 | id: 1 17 | }); 18 | options = { 19 | success: (function() { 20 | return ready = true; 21 | }), 22 | error: (function() { 23 | return ready = true; 24 | }) 25 | }; 26 | return localsync('create', model, options); 27 | }); 28 | waitsFor((function() { 29 | return ready; 30 | }), "A callback should have been called", 100); 31 | return runs(function() { 32 | return expect(create).toHaveBeenCalledWith(model, options); 33 | }); 34 | }); 35 | it('does not overwrite existing models with fetch(add: true) unless passed merge: true', function() { 36 | var create, ready, ref; 37 | ref = {}, ready = ref.ready, create = ref.create; 38 | runs(function() { 39 | var model; 40 | ready = false; 41 | create = spyOn(Store.prototype, 'find').andReturn({ 42 | id: 1 43 | }); 44 | create = spyOn(Store.prototype, 'create'); 45 | model = new Backbone.Model({ 46 | id: 1 47 | }); 48 | return localsync('create', model, { 49 | success: (function() { 50 | return ready = true; 51 | }), 52 | error: (function() { 53 | return ready = true; 54 | }), 55 | add: true 56 | }); 57 | }); 58 | waitsFor((function() { 59 | return ready; 60 | }), "A callback should have been called", 100); 61 | runs(function() { 62 | var model; 63 | ready = false; 64 | expect(create).not.toHaveBeenCalled(); 65 | model = new Backbone.Model({ 66 | id: 1 67 | }); 68 | return localsync('create', model, { 69 | success: (function() { 70 | return ready = true; 71 | }), 72 | error: (function() { 73 | return ready = true; 74 | }), 75 | add: true, 76 | merge: true 77 | }); 78 | }); 79 | waitsFor((function() { 80 | return ready; 81 | }), "A callback should have been called", 100); 82 | return runs(function() { 83 | return expect(create).toHaveBeenCalled(); 84 | }); 85 | }); 86 | return it('supports marking a new record dirty', function() { 87 | var create, dirty, model, options, ready, ref; 88 | ref = {}, ready = ref.ready, create = ref.create, model = ref.model, dirty = ref.dirty, options = ref.options; 89 | runs(function() { 90 | model = new Backbone.Model({ 91 | id: 1 92 | }); 93 | create = spyOn(Store.prototype, 'create').andReturn(model); 94 | dirty = spyOn(Store.prototype, 'dirty'); 95 | options = { 96 | success: (function() { 97 | return ready = true; 98 | }), 99 | error: (function() { 100 | return ready = true; 101 | }), 102 | dirty: true 103 | }; 104 | return localsync('create', model, options); 105 | }); 106 | waitsFor((function() { 107 | return ready; 108 | }), "A callback should have been called", 100); 109 | return runs(function() { 110 | expect(create).toHaveBeenCalledWith(model, options); 111 | return expect(dirty).toHaveBeenCalledWith(model); 112 | }); 113 | }); 114 | }); 115 | describe('reading records', function() { 116 | it('reads models', function() { 117 | var find, model, ready, ref; 118 | ref = {}, ready = ref.ready, find = ref.find, model = ref.model; 119 | runs(function() { 120 | find = spyOn(Store.prototype, 'find'); 121 | model = new Backbone.Model({ 122 | id: 1 123 | }); 124 | return localsync('read', model, { 125 | success: (function() { 126 | return ready = true; 127 | }), 128 | error: (function() { 129 | return ready = true; 130 | }) 131 | }); 132 | }); 133 | waitsFor((function() { 134 | return ready; 135 | }), "A callback should have been called", 100); 136 | return runs(function() { 137 | return expect(find).toHaveBeenCalledWith(model); 138 | }); 139 | }); 140 | return it('reads collections', function() { 141 | var findAll, ready, ref; 142 | ref = {}, ready = ref.ready, findAll = ref.findAll; 143 | runs(function() { 144 | findAll = spyOn(Store.prototype, 'findAll'); 145 | return localsync('read', new Backbone.Collection, { 146 | success: (function() { 147 | return ready = true; 148 | }), 149 | error: (function() { 150 | return ready = true; 151 | }) 152 | }); 153 | }); 154 | waitsFor((function() { 155 | return ready; 156 | }), "A callback should have been called", 100); 157 | return runs(function() { 158 | return expect(findAll).toHaveBeenCalled(); 159 | }); 160 | }); 161 | }); 162 | describe('updating records', function() { 163 | it('updates records', function() { 164 | var model, options, ready, ref, update; 165 | ref = {}, ready = ref.ready, update = ref.update, model = ref.model, options = ref.options; 166 | runs(function() { 167 | update = spyOn(Store.prototype, 'update'); 168 | model = new Backbone.Model({ 169 | id: 1 170 | }); 171 | options = { 172 | success: (function() { 173 | return ready = true; 174 | }), 175 | error: (function() { 176 | return ready = true; 177 | }) 178 | }; 179 | return localsync('update', model, options); 180 | }); 181 | waitsFor((function() { 182 | return ready; 183 | }), "A callback should have been called", 100); 184 | return runs(function() { 185 | return expect(update).toHaveBeenCalledWith(model, options); 186 | }); 187 | }); 188 | return it('supports marking an updated record dirty', function() { 189 | var dirty, model, options, ready, ref, update; 190 | ref = {}, ready = ref.ready, update = ref.update, model = ref.model, dirty = ref.dirty, options = ref.options; 191 | runs(function() { 192 | model = new Backbone.Model({ 193 | id: 1 194 | }); 195 | update = spyOn(Store.prototype, 'update'); 196 | dirty = spyOn(Store.prototype, 'dirty'); 197 | options = { 198 | success: (function() { 199 | return ready = true; 200 | }), 201 | error: (function() { 202 | return ready = true; 203 | }), 204 | dirty: true 205 | }; 206 | return localsync('update', model, options); 207 | }); 208 | waitsFor((function() { 209 | return ready; 210 | }), "A callback should have been called", 100); 211 | return runs(function() { 212 | expect(update).toHaveBeenCalledWith(model, options); 213 | return expect(dirty).toHaveBeenCalledWith(model); 214 | }); 215 | }); 216 | }); 217 | return describe('deleting records', function() { 218 | it('deletes records', function() { 219 | var destroy, model, ready, ref; 220 | ref = {}, ready = ref.ready, destroy = ref.destroy, model = ref.model; 221 | runs(function() { 222 | destroy = spyOn(Store.prototype, 'destroy'); 223 | model = new Backbone.Model({ 224 | id: 1 225 | }); 226 | return localsync('delete', model, { 227 | success: (function() { 228 | return ready = true; 229 | }), 230 | error: (function() { 231 | return ready = true; 232 | }) 233 | }); 234 | }); 235 | waitsFor((function() { 236 | return ready; 237 | }), "A callback should have been called", 100); 238 | return runs(function() { 239 | return expect(destroy).toHaveBeenCalledWith(model); 240 | }); 241 | }); 242 | it('supports marking a dirty record destroyed', function() { 243 | var destroy, destroyed, model, ready, ref; 244 | ref = {}, ready = ref.ready, destroy = ref.destroy, destroyed = ref.destroyed, model = ref.model; 245 | runs(function() { 246 | model = new Backbone.Model({ 247 | id: 1 248 | }); 249 | destroy = spyOn(Store.prototype, 'destroy'); 250 | destroyed = spyOn(Store.prototype, 'destroyed'); 251 | return localsync('delete', model, { 252 | success: (function() { 253 | return ready = true; 254 | }), 255 | error: (function() { 256 | return ready = true; 257 | }), 258 | dirty: true 259 | }); 260 | }); 261 | waitsFor((function() { 262 | return ready; 263 | }), "A callback should have been called", 100); 264 | return runs(function() { 265 | expect(destroy).toHaveBeenCalledWith(model); 266 | return expect(destroyed).toHaveBeenCalledWith(model); 267 | }); 268 | }); 269 | return it("doesn't mark a model with a temp id as destroyed", function() { 270 | var destroy, destroyed, model, ref, success; 271 | ref = {}, destroy = ref.destroy, destroyed = ref.destroyed, model = ref.model, success = ref.success; 272 | runs(function() { 273 | model = new Backbone.Model; 274 | model.id = Store.prototype.generateId(); 275 | destroy = spyOn(Store.prototype, 'destroy'); 276 | destroyed = spyOn(Store.prototype, 'destroyed'); 277 | success = jasmine.createSpy("success"); 278 | return localsync('delete', model, { 279 | dirty: true, 280 | success: success 281 | }); 282 | }); 283 | return runs(function() { 284 | expect(destroy).toHaveBeenCalledWith(model); 285 | expect(destroyed).not.toHaveBeenCalled; 286 | return expect(success).toHaveBeenCalled(); 287 | }); 288 | }); 289 | }); 290 | }); 291 | describe('extra methods', function() { 292 | it('clears out all records from the store', function() { 293 | return runs(function() { 294 | var clear; 295 | clear = spyOn(Store.prototype, 'clear'); 296 | return localsync('clear', {}, { 297 | success: (function() { 298 | var ready; 299 | return ready = true; 300 | }), 301 | error: (function() { 302 | var ready; 303 | return ready = true; 304 | }) 305 | }); 306 | }); 307 | }); 308 | return it('reports whether or not it hasDirtyOrDestroyed', function() { 309 | return runs(function() { 310 | var clear; 311 | clear = spyOn(Store.prototype, 'hasDirtyOrDestroyed'); 312 | return localsync('hasDirtyOrDestroyed', {}, { 313 | success: (function() { 314 | var ready; 315 | return ready = true; 316 | }), 317 | error: (function() { 318 | var ready; 319 | return ready = true; 320 | }) 321 | }); 322 | }); 323 | }); 324 | }); 325 | describe('callbacks', function() { 326 | it("sends the models's attributes as the callback response", function() { 327 | var model, ref, response; 328 | ref = {}, model = ref.model, response = ref.response; 329 | runs(function() { 330 | model = new Backbone.Model({ 331 | id: 1 332 | }); 333 | return localsync('create', model, { 334 | success: (function(resp) { 335 | return response = resp; 336 | }) 337 | }); 338 | }); 339 | waitsFor((function() { 340 | return response; 341 | }), "A callback should have been called with a response", 100); 342 | return runs(function() { 343 | return expect(response).toEqual(model.attributes); 344 | }); 345 | }); 346 | return it('ignores callbacks when the ignoreCallbacks option is set', function() { 347 | var callback, ref, start; 348 | ref = { 349 | start: new Date().getTime() 350 | }, start = ref.start, callback = ref.callback; 351 | runs(function() { 352 | var model; 353 | callback = jasmine.createSpy('callback'); 354 | model = new Backbone.Model({ 355 | id: 1 356 | }); 357 | return localsync('create', model, { 358 | success: callback, 359 | error: callback, 360 | ignoreCallbacks: true 361 | }); 362 | }); 363 | waitsFor((function() { 364 | return new Date().getTime() - start > 5; 365 | }), 'Wait 5 ms to give the callback a chance to execute', 100); 366 | runs(function() { 367 | var model; 368 | start = false; 369 | expect(callback).not.toHaveBeenCalled(); 370 | model = new Backbone.Model({ 371 | id: 1 372 | }); 373 | return localsync('create', model, { 374 | success: callback, 375 | error: callback 376 | }); 377 | }); 378 | return waitsFor((function() { 379 | return callback.wasCalled; 380 | }), 'The callback should have been called', 100); 381 | }); 382 | }); 383 | return describe('model parameter', function() { 384 | beforeEach(function() { 385 | return spyOn(Store.prototype, 'create'); 386 | }); 387 | it('should not accept objects / attributes as model', function() { 388 | var attributes, call; 389 | attributes = {}; 390 | call = function() { 391 | return localsync('create', attributes, { 392 | ignoreCallbacks: true 393 | }); 394 | }; 395 | return expect(call).toThrow(); 396 | }); 397 | it('should accept a backbone model as model', function() { 398 | var call; 399 | call = function() { 400 | return localsync('create', new Backbone.Model, { 401 | ignoreCallbacks: true 402 | }); 403 | }; 404 | return expect(call).not.toThrow(); 405 | }); 406 | it('should accept a backbone collection as model', function() { 407 | var call; 408 | call = function() { 409 | return localsync('create', new Backbone.Collection, { 410 | ignoreCallbacks: true 411 | }); 412 | }; 413 | return expect(call).not.toThrow(); 414 | }); 415 | it('should accept any object as model on extra method "clear"', function() { 416 | var call; 417 | call = function() { 418 | return localsync('clear', {}, { 419 | ignoreCallbacks: true 420 | }); 421 | }; 422 | return expect(call).not.toThrow(); 423 | }); 424 | return it('should accept any object as model on extra method "hasDirtyOrDestroyed"', function() { 425 | var call; 426 | call = function() { 427 | return localsync('hasDirtyOrDestroyed', {}, { 428 | ignoreCallbacks: true 429 | }); 430 | }; 431 | return expect(call).not.toThrow(); 432 | }); 433 | }); 434 | }); 435 | 436 | }).call(this); 437 | 438 | //# sourceMappingURL=localsync_spec.js.map 439 | -------------------------------------------------------------------------------- /spec/backbone.dualstorage.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | 3 | /* 4 | Backbone dualStorage Adapter v1.4.2 5 | 6 | A simple module to replace `Backbone.sync` with *localStorage*-based 7 | persistence. Models are given GUIDS, and saved into a JSON object. Simple 8 | as that. 9 | */ 10 | var S4, backboneSync, callbackTranslator, dualsync, getStoreName, localsync, modelUpdatedWithResponse, onlineSync, parseRemoteResponse, 11 | indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 12 | 13 | Backbone.DualStorage = { 14 | offlineStatusCodes: [408, 502] 15 | }; 16 | 17 | Backbone.Model.prototype.hasTempId = function() { 18 | return _.isString(this.id) && this.id.length === 36 && this.id.indexOf('t') === 0; 19 | }; 20 | 21 | getStoreName = function(collection, model) { 22 | model || (model = collection.model.prototype); 23 | return _.result(collection, 'storeName') || _.result(model, 'storeName') || _.result(collection, 'url') || _.result(model, 'urlRoot') || _.result(model, 'url'); 24 | }; 25 | 26 | Backbone.Collection.prototype.syncDirty = function(options) { 27 | var i, id, ids, len, ref, results, store; 28 | store = localStorage.getItem((getStoreName(this)) + "_dirty"); 29 | ids = (store && store.split(',')) || []; 30 | results = []; 31 | for (i = 0, len = ids.length; i < len; i++) { 32 | id = ids[i]; 33 | results.push((ref = this.get(id)) != null ? ref.save(null, options) : void 0); 34 | } 35 | return results; 36 | }; 37 | 38 | Backbone.Collection.prototype.dirtyModels = function() { 39 | var id, ids, models, store; 40 | store = localStorage.getItem((getStoreName(this)) + "_dirty"); 41 | ids = (store && store.split(',')) || []; 42 | models = (function() { 43 | var i, len, results; 44 | results = []; 45 | for (i = 0, len = ids.length; i < len; i++) { 46 | id = ids[i]; 47 | results.push(this.get(id)); 48 | } 49 | return results; 50 | }).call(this); 51 | return _.compact(models); 52 | }; 53 | 54 | Backbone.Collection.prototype.syncDestroyed = function(options) { 55 | var i, id, ids, len, model, results, store; 56 | store = localStorage.getItem((getStoreName(this)) + "_destroyed"); 57 | ids = (store && store.split(',')) || []; 58 | results = []; 59 | for (i = 0, len = ids.length; i < len; i++) { 60 | id = ids[i]; 61 | model = new this.model; 62 | model.set(model.idAttribute, id); 63 | model.collection = this; 64 | results.push(model.destroy(options)); 65 | } 66 | return results; 67 | }; 68 | 69 | Backbone.Collection.prototype.destroyedModelIds = function() { 70 | var ids, store; 71 | store = localStorage.getItem((getStoreName(this)) + "_destroyed"); 72 | return ids = (store && store.split(',')) || []; 73 | }; 74 | 75 | Backbone.Collection.prototype.syncDirtyAndDestroyed = function(options) { 76 | this.syncDirty(options); 77 | return this.syncDestroyed(options); 78 | }; 79 | 80 | S4 = function() { 81 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); 82 | }; 83 | 84 | window.Store = (function() { 85 | Store.prototype.sep = ''; 86 | 87 | function Store(name) { 88 | this.name = name; 89 | this.records = this.recordsOn(this.name); 90 | } 91 | 92 | Store.prototype.generateId = function() { 93 | return 't' + S4().substring(1) + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4(); 94 | }; 95 | 96 | Store.prototype.save = function() { 97 | return localStorage.setItem(this.name, this.records.join(',')); 98 | }; 99 | 100 | Store.prototype.recordsOn = function(key) { 101 | var store; 102 | store = localStorage.getItem(key); 103 | return (store && store.split(',')) || []; 104 | }; 105 | 106 | Store.prototype.dirty = function(model) { 107 | var dirtyRecords; 108 | dirtyRecords = this.recordsOn(this.name + '_dirty'); 109 | if (!_.include(dirtyRecords, model.id.toString())) { 110 | dirtyRecords.push(model.id); 111 | localStorage.setItem(this.name + '_dirty', dirtyRecords.join(',')); 112 | } 113 | return model; 114 | }; 115 | 116 | Store.prototype.clean = function(model, from) { 117 | var dirtyRecords, store; 118 | store = this.name + "_" + from; 119 | dirtyRecords = this.recordsOn(store); 120 | if (_.include(dirtyRecords, model.id.toString())) { 121 | localStorage.setItem(store, _.without(dirtyRecords, model.id.toString()).join(',')); 122 | } 123 | return model; 124 | }; 125 | 126 | Store.prototype.destroyed = function(model) { 127 | var destroyedRecords; 128 | destroyedRecords = this.recordsOn(this.name + '_destroyed'); 129 | if (!_.include(destroyedRecords, model.id.toString())) { 130 | destroyedRecords.push(model.id); 131 | localStorage.setItem(this.name + '_destroyed', destroyedRecords.join(',')); 132 | } 133 | return model; 134 | }; 135 | 136 | Store.prototype.create = function(model, options) { 137 | if (!_.isObject(model)) { 138 | return model; 139 | } 140 | if (!model.id) { 141 | model.set(model.idAttribute, this.generateId()); 142 | } 143 | localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model)); 144 | this.records.push(model.id.toString()); 145 | this.save(); 146 | return model; 147 | }; 148 | 149 | Store.prototype.update = function(model, options) { 150 | localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model)); 151 | if (!_.include(this.records, model.id.toString())) { 152 | this.records.push(model.id.toString()); 153 | } 154 | this.save(); 155 | return model; 156 | }; 157 | 158 | Store.prototype.clear = function() { 159 | var i, id, len, ref; 160 | ref = this.records; 161 | for (i = 0, len = ref.length; i < len; i++) { 162 | id = ref[i]; 163 | localStorage.removeItem(this.name + this.sep + id); 164 | } 165 | this.records = []; 166 | return this.save(); 167 | }; 168 | 169 | Store.prototype.hasDirtyOrDestroyed = function() { 170 | return !_.isEmpty(localStorage.getItem(this.name + '_dirty')) || !_.isEmpty(localStorage.getItem(this.name + '_destroyed')); 171 | }; 172 | 173 | Store.prototype.find = function(model) { 174 | var modelAsJson; 175 | modelAsJson = localStorage.getItem(this.name + this.sep + model.id); 176 | if (modelAsJson === null) { 177 | return null; 178 | } 179 | return JSON.parse(modelAsJson); 180 | }; 181 | 182 | Store.prototype.findAll = function() { 183 | var i, id, len, ref, results; 184 | ref = this.records; 185 | results = []; 186 | for (i = 0, len = ref.length; i < len; i++) { 187 | id = ref[i]; 188 | results.push(JSON.parse(localStorage.getItem(this.name + this.sep + id))); 189 | } 190 | return results; 191 | }; 192 | 193 | Store.prototype.destroy = function(model) { 194 | localStorage.removeItem(this.name + this.sep + model.id); 195 | this.records = _.reject(this.records, function(record_id) { 196 | return record_id === model.id.toString(); 197 | }); 198 | this.save(); 199 | return model; 200 | }; 201 | 202 | return Store; 203 | 204 | })(); 205 | 206 | window.Store.exists = function(storeName) { 207 | return localStorage.getItem(storeName) !== null; 208 | }; 209 | 210 | callbackTranslator = { 211 | needsTranslation: Backbone.VERSION === '0.9.10', 212 | forBackboneCaller: function(callback) { 213 | if (this.needsTranslation) { 214 | return function(model, resp, options) { 215 | return callback.call(null, resp); 216 | }; 217 | } else { 218 | return callback; 219 | } 220 | }, 221 | forDualstorageCaller: function(callback, model, options) { 222 | if (this.needsTranslation) { 223 | return function(resp) { 224 | return callback.call(null, model, resp, options); 225 | }; 226 | } else { 227 | return callback; 228 | } 229 | } 230 | }; 231 | 232 | localsync = function(method, model, options) { 233 | var isValidModel, preExisting, response, store; 234 | isValidModel = (method === 'clear') || (method === 'hasDirtyOrDestroyed'); 235 | isValidModel || (isValidModel = model instanceof Backbone.Model); 236 | isValidModel || (isValidModel = model instanceof Backbone.Collection); 237 | if (!isValidModel) { 238 | throw new Error('model parameter is required to be a backbone model or collection.'); 239 | } 240 | store = new Store(options.storeName); 241 | response = (function() { 242 | switch (method) { 243 | case 'read': 244 | if (model instanceof Backbone.Model) { 245 | return store.find(model); 246 | } else { 247 | return store.findAll(); 248 | } 249 | break; 250 | case 'hasDirtyOrDestroyed': 251 | return store.hasDirtyOrDestroyed(); 252 | case 'clear': 253 | return store.clear(); 254 | case 'create': 255 | if (options.add && !options.merge && (preExisting = store.find(model))) { 256 | return preExisting; 257 | } else { 258 | model = store.create(model, options); 259 | if (options.dirty) { 260 | store.dirty(model); 261 | } 262 | return model; 263 | } 264 | break; 265 | case 'update': 266 | store.update(model, options); 267 | if (options.dirty) { 268 | return store.dirty(model); 269 | } else { 270 | return store.clean(model, 'dirty'); 271 | } 272 | break; 273 | case 'delete': 274 | store.destroy(model); 275 | if (options.dirty && !model.hasTempId()) { 276 | return store.destroyed(model); 277 | } else { 278 | if (model.hasTempId()) { 279 | return store.clean(model, 'dirty'); 280 | } else { 281 | return store.clean(model, 'destroyed'); 282 | } 283 | } 284 | } 285 | })(); 286 | if (response) { 287 | if (response.toJSON) { 288 | response = response.toJSON(options); 289 | } 290 | if (response.attributes) { 291 | response = response.attributes; 292 | } 293 | } 294 | if (!options.ignoreCallbacks) { 295 | if (response) { 296 | options.success(response); 297 | } else { 298 | options.error('Record not found'); 299 | } 300 | } 301 | return response; 302 | }; 303 | 304 | parseRemoteResponse = function(object, response) { 305 | if (!(object && object.parseBeforeLocalSave)) { 306 | return response; 307 | } 308 | if (_.isFunction(object.parseBeforeLocalSave)) { 309 | return object.parseBeforeLocalSave(response); 310 | } 311 | }; 312 | 313 | modelUpdatedWithResponse = function(model, response) { 314 | var modelClone; 315 | modelClone = new Backbone.Model; 316 | modelClone.idAttribute = model.idAttribute; 317 | modelClone.set(model.attributes); 318 | modelClone.set(model.parse(response)); 319 | return modelClone; 320 | }; 321 | 322 | backboneSync = Backbone.DualStorage.originalSync = Backbone.sync; 323 | 324 | onlineSync = function(method, model, options) { 325 | options.success = callbackTranslator.forBackboneCaller(options.success); 326 | options.error = callbackTranslator.forBackboneCaller(options.error); 327 | return Backbone.DualStorage.originalSync(method, model, options); 328 | }; 329 | 330 | dualsync = function(method, model, options) { 331 | var error, hasOfflineStatusCode, local, relayErrorCallback, success, temporaryId, useOfflineStorage; 332 | options.storeName = getStoreName(model.collection, model); 333 | options.storeExists = Store.exists(options.storeName); 334 | options.success = callbackTranslator.forDualstorageCaller(options.success, model, options); 335 | options.error = callbackTranslator.forDualstorageCaller(options.error, model, options); 336 | if (_.result(model, 'remote') || _.result(model.collection, 'remote')) { 337 | return onlineSync(method, model, options); 338 | } 339 | local = _.result(model, 'local') || _.result(model.collection, 'local'); 340 | options.dirty = options.remote === false && !local; 341 | if (options.remote === false || local) { 342 | return localsync(method, model, options); 343 | } 344 | options.ignoreCallbacks = true; 345 | success = options.success; 346 | error = options.error; 347 | useOfflineStorage = function() { 348 | options.dirty = true; 349 | options.ignoreCallbacks = false; 350 | options.success = success; 351 | options.error = error; 352 | return localsync(method, model, options); 353 | }; 354 | hasOfflineStatusCode = function(xhr) { 355 | var offlineStatusCodes, ref; 356 | offlineStatusCodes = Backbone.DualStorage.offlineStatusCodes; 357 | if (_.isFunction(offlineStatusCodes)) { 358 | offlineStatusCodes = offlineStatusCodes(xhr); 359 | } 360 | return xhr.status === 0 || (ref = xhr.status, indexOf.call(offlineStatusCodes, ref) >= 0); 361 | }; 362 | relayErrorCallback = function(xhr) { 363 | var online; 364 | online = !hasOfflineStatusCode(xhr); 365 | if (online || method === 'read' && !options.storeExists) { 366 | return error(xhr); 367 | } else { 368 | return useOfflineStorage(); 369 | } 370 | }; 371 | switch (method) { 372 | case 'read': 373 | if (localsync('hasDirtyOrDestroyed', model, options)) { 374 | return useOfflineStorage(); 375 | } else { 376 | options.success = function(resp, _status, _xhr) { 377 | var collection, i, idAttribute, len, modelAttributes, responseModel; 378 | if (hasOfflineStatusCode(options.xhr)) { 379 | return useOfflineStorage(); 380 | } 381 | resp = parseRemoteResponse(model, resp); 382 | if (model instanceof Backbone.Collection) { 383 | collection = model; 384 | idAttribute = collection.model.prototype.idAttribute; 385 | if (!options.add) { 386 | localsync('clear', collection, options); 387 | } 388 | for (i = 0, len = resp.length; i < len; i++) { 389 | modelAttributes = resp[i]; 390 | model = collection.get(modelAttributes[idAttribute]); 391 | if (model) { 392 | responseModel = modelUpdatedWithResponse(model, modelAttributes); 393 | } else { 394 | responseModel = new collection.model(modelAttributes, options); 395 | } 396 | localsync('update', responseModel, options); 397 | } 398 | } else { 399 | responseModel = modelUpdatedWithResponse(model, resp); 400 | localsync('update', responseModel, options); 401 | } 402 | return success(resp, _status, _xhr); 403 | }; 404 | options.error = function(xhr) { 405 | return relayErrorCallback(xhr); 406 | }; 407 | return options.xhr = onlineSync(method, model, options); 408 | } 409 | break; 410 | case 'create': 411 | options.success = function(resp, _status, _xhr) { 412 | var updatedModel; 413 | if (hasOfflineStatusCode(options.xhr)) { 414 | return useOfflineStorage(); 415 | } 416 | updatedModel = modelUpdatedWithResponse(model, resp); 417 | localsync(method, updatedModel, options); 418 | return success(resp, _status, _xhr); 419 | }; 420 | options.error = function(xhr) { 421 | return relayErrorCallback(xhr); 422 | }; 423 | return options.xhr = onlineSync(method, model, options); 424 | case 'update': 425 | if (model.hasTempId()) { 426 | temporaryId = model.id; 427 | options.success = function(resp, _status, _xhr) { 428 | var updatedModel; 429 | model.set(model.idAttribute, temporaryId, { 430 | silent: true 431 | }); 432 | if (hasOfflineStatusCode(options.xhr)) { 433 | return useOfflineStorage(); 434 | } 435 | updatedModel = modelUpdatedWithResponse(model, resp); 436 | localsync('delete', model, options); 437 | localsync('create', updatedModel, options); 438 | return success(resp, _status, _xhr); 439 | }; 440 | options.error = function(xhr) { 441 | model.set(model.idAttribute, temporaryId, { 442 | silent: true 443 | }); 444 | return relayErrorCallback(xhr); 445 | }; 446 | model.set(model.idAttribute, null, { 447 | silent: true 448 | }); 449 | return options.xhr = onlineSync('create', model, options); 450 | } else { 451 | options.success = function(resp, _status, _xhr) { 452 | var updatedModel; 453 | if (hasOfflineStatusCode(options.xhr)) { 454 | return useOfflineStorage(); 455 | } 456 | updatedModel = modelUpdatedWithResponse(model, resp); 457 | localsync(method, updatedModel, options); 458 | return success(resp, _status, _xhr); 459 | }; 460 | options.error = function(xhr) { 461 | return relayErrorCallback(xhr); 462 | }; 463 | return options.xhr = onlineSync(method, model, options); 464 | } 465 | break; 466 | case 'delete': 467 | if (model.hasTempId()) { 468 | options.ignoreCallbacks = false; 469 | return localsync(method, model, options); 470 | } else { 471 | options.success = function(resp, _status, _xhr) { 472 | if (hasOfflineStatusCode(options.xhr)) { 473 | return useOfflineStorage(); 474 | } 475 | localsync(method, model, options); 476 | return success(resp, _status, _xhr); 477 | }; 478 | options.error = function(xhr) { 479 | return relayErrorCallback(xhr); 480 | }; 481 | return options.xhr = onlineSync(method, model, options); 482 | } 483 | } 484 | }; 485 | 486 | Backbone.sync = dualsync; 487 | 488 | //# sourceMappingURL=backbone.dualstorage.js.map 489 | -------------------------------------------------------------------------------- /backbone.dualstorage.amd.js: -------------------------------------------------------------------------------- 1 | (function(root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define(['backbone'], factory); 4 | } else if (typeof require === 'function' && ((typeof module !== "undefined" && module !== null ? module.exports : void 0) != null)) { 5 | return module.exports = factory(require('backbone')); 6 | } else { 7 | factory(root.Backbone); 8 | } 9 | })(this, function(Backbone) { 10 | // Generated by CoffeeScript 1.9.3 11 | 12 | /* 13 | Backbone dualStorage Adapter v1.4.2 14 | 15 | A simple module to replace `Backbone.sync` with *localStorage*-based 16 | persistence. Models are given GUIDS, and saved into a JSON object. Simple 17 | as that. 18 | */ 19 | var S4, backboneSync, callbackTranslator, dualsync, getStoreName, localsync, modelUpdatedWithResponse, onlineSync, parseRemoteResponse, 20 | indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 21 | 22 | Backbone.DualStorage = { 23 | offlineStatusCodes: [408, 502] 24 | }; 25 | 26 | Backbone.Model.prototype.hasTempId = function() { 27 | return _.isString(this.id) && this.id.length === 36 && this.id.indexOf('t') === 0; 28 | }; 29 | 30 | getStoreName = function(collection, model) { 31 | model || (model = collection.model.prototype); 32 | return _.result(collection, 'storeName') || _.result(model, 'storeName') || _.result(collection, 'url') || _.result(model, 'urlRoot') || _.result(model, 'url'); 33 | }; 34 | 35 | Backbone.Collection.prototype.syncDirty = function(options) { 36 | var i, id, ids, len, ref, results, store; 37 | store = localStorage.getItem((getStoreName(this)) + "_dirty"); 38 | ids = (store && store.split(',')) || []; 39 | results = []; 40 | for (i = 0, len = ids.length; i < len; i++) { 41 | id = ids[i]; 42 | results.push((ref = this.get(id)) != null ? ref.save(null, options) : void 0); 43 | } 44 | return results; 45 | }; 46 | 47 | Backbone.Collection.prototype.dirtyModels = function() { 48 | var id, ids, models, store; 49 | store = localStorage.getItem((getStoreName(this)) + "_dirty"); 50 | ids = (store && store.split(',')) || []; 51 | models = (function() { 52 | var i, len, results; 53 | results = []; 54 | for (i = 0, len = ids.length; i < len; i++) { 55 | id = ids[i]; 56 | results.push(this.get(id)); 57 | } 58 | return results; 59 | }).call(this); 60 | return _.compact(models); 61 | }; 62 | 63 | Backbone.Collection.prototype.syncDestroyed = function(options) { 64 | var i, id, ids, len, model, results, store; 65 | store = localStorage.getItem((getStoreName(this)) + "_destroyed"); 66 | ids = (store && store.split(',')) || []; 67 | results = []; 68 | for (i = 0, len = ids.length; i < len; i++) { 69 | id = ids[i]; 70 | model = new this.model; 71 | model.set(model.idAttribute, id); 72 | model.collection = this; 73 | results.push(model.destroy(options)); 74 | } 75 | return results; 76 | }; 77 | 78 | Backbone.Collection.prototype.destroyedModelIds = function() { 79 | var ids, store; 80 | store = localStorage.getItem((getStoreName(this)) + "_destroyed"); 81 | return ids = (store && store.split(',')) || []; 82 | }; 83 | 84 | Backbone.Collection.prototype.syncDirtyAndDestroyed = function(options) { 85 | this.syncDirty(options); 86 | return this.syncDestroyed(options); 87 | }; 88 | 89 | S4 = function() { 90 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); 91 | }; 92 | 93 | window.Store = (function() { 94 | Store.prototype.sep = ''; 95 | 96 | function Store(name) { 97 | this.name = name; 98 | this.records = this.recordsOn(this.name); 99 | } 100 | 101 | Store.prototype.generateId = function() { 102 | return 't' + S4().substring(1) + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4(); 103 | }; 104 | 105 | Store.prototype.save = function() { 106 | return localStorage.setItem(this.name, this.records.join(',')); 107 | }; 108 | 109 | Store.prototype.recordsOn = function(key) { 110 | var store; 111 | store = localStorage.getItem(key); 112 | return (store && store.split(',')) || []; 113 | }; 114 | 115 | Store.prototype.dirty = function(model) { 116 | var dirtyRecords; 117 | dirtyRecords = this.recordsOn(this.name + '_dirty'); 118 | if (!_.include(dirtyRecords, model.id.toString())) { 119 | dirtyRecords.push(model.id); 120 | localStorage.setItem(this.name + '_dirty', dirtyRecords.join(',')); 121 | } 122 | return model; 123 | }; 124 | 125 | Store.prototype.clean = function(model, from) { 126 | var dirtyRecords, store; 127 | store = this.name + "_" + from; 128 | dirtyRecords = this.recordsOn(store); 129 | if (_.include(dirtyRecords, model.id.toString())) { 130 | localStorage.setItem(store, _.without(dirtyRecords, model.id.toString()).join(',')); 131 | } 132 | return model; 133 | }; 134 | 135 | Store.prototype.destroyed = function(model) { 136 | var destroyedRecords; 137 | destroyedRecords = this.recordsOn(this.name + '_destroyed'); 138 | if (!_.include(destroyedRecords, model.id.toString())) { 139 | destroyedRecords.push(model.id); 140 | localStorage.setItem(this.name + '_destroyed', destroyedRecords.join(',')); 141 | } 142 | return model; 143 | }; 144 | 145 | Store.prototype.create = function(model, options) { 146 | if (!_.isObject(model)) { 147 | return model; 148 | } 149 | if (!model.id) { 150 | model.set(model.idAttribute, this.generateId()); 151 | } 152 | localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model)); 153 | this.records.push(model.id.toString()); 154 | this.save(); 155 | return model; 156 | }; 157 | 158 | Store.prototype.update = function(model, options) { 159 | localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model)); 160 | if (!_.include(this.records, model.id.toString())) { 161 | this.records.push(model.id.toString()); 162 | } 163 | this.save(); 164 | return model; 165 | }; 166 | 167 | Store.prototype.clear = function() { 168 | var i, id, len, ref; 169 | ref = this.records; 170 | for (i = 0, len = ref.length; i < len; i++) { 171 | id = ref[i]; 172 | localStorage.removeItem(this.name + this.sep + id); 173 | } 174 | this.records = []; 175 | return this.save(); 176 | }; 177 | 178 | Store.prototype.hasDirtyOrDestroyed = function() { 179 | return !_.isEmpty(localStorage.getItem(this.name + '_dirty')) || !_.isEmpty(localStorage.getItem(this.name + '_destroyed')); 180 | }; 181 | 182 | Store.prototype.find = function(model) { 183 | var modelAsJson; 184 | modelAsJson = localStorage.getItem(this.name + this.sep + model.id); 185 | if (modelAsJson === null) { 186 | return null; 187 | } 188 | return JSON.parse(modelAsJson); 189 | }; 190 | 191 | Store.prototype.findAll = function() { 192 | var i, id, len, ref, results; 193 | ref = this.records; 194 | results = []; 195 | for (i = 0, len = ref.length; i < len; i++) { 196 | id = ref[i]; 197 | results.push(JSON.parse(localStorage.getItem(this.name + this.sep + id))); 198 | } 199 | return results; 200 | }; 201 | 202 | Store.prototype.destroy = function(model) { 203 | localStorage.removeItem(this.name + this.sep + model.id); 204 | this.records = _.reject(this.records, function(record_id) { 205 | return record_id === model.id.toString(); 206 | }); 207 | this.save(); 208 | return model; 209 | }; 210 | 211 | return Store; 212 | 213 | })(); 214 | 215 | window.Store.exists = function(storeName) { 216 | return localStorage.getItem(storeName) !== null; 217 | }; 218 | 219 | callbackTranslator = { 220 | needsTranslation: Backbone.VERSION === '0.9.10', 221 | forBackboneCaller: function(callback) { 222 | if (this.needsTranslation) { 223 | return function(model, resp, options) { 224 | return callback.call(null, resp); 225 | }; 226 | } else { 227 | return callback; 228 | } 229 | }, 230 | forDualstorageCaller: function(callback, model, options) { 231 | if (this.needsTranslation) { 232 | return function(resp) { 233 | return callback.call(null, model, resp, options); 234 | }; 235 | } else { 236 | return callback; 237 | } 238 | } 239 | }; 240 | 241 | localsync = function(method, model, options) { 242 | var isValidModel, preExisting, response, store; 243 | isValidModel = (method === 'clear') || (method === 'hasDirtyOrDestroyed'); 244 | isValidModel || (isValidModel = model instanceof Backbone.Model); 245 | isValidModel || (isValidModel = model instanceof Backbone.Collection); 246 | if (!isValidModel) { 247 | throw new Error('model parameter is required to be a backbone model or collection.'); 248 | } 249 | store = new Store(options.storeName); 250 | response = (function() { 251 | switch (method) { 252 | case 'read': 253 | if (model instanceof Backbone.Model) { 254 | return store.find(model); 255 | } else { 256 | return store.findAll(); 257 | } 258 | break; 259 | case 'hasDirtyOrDestroyed': 260 | return store.hasDirtyOrDestroyed(); 261 | case 'clear': 262 | return store.clear(); 263 | case 'create': 264 | if (options.add && !options.merge && (preExisting = store.find(model))) { 265 | return preExisting; 266 | } else { 267 | model = store.create(model, options); 268 | if (options.dirty) { 269 | store.dirty(model); 270 | } 271 | return model; 272 | } 273 | break; 274 | case 'update': 275 | store.update(model, options); 276 | if (options.dirty) { 277 | return store.dirty(model); 278 | } else { 279 | return store.clean(model, 'dirty'); 280 | } 281 | break; 282 | case 'delete': 283 | store.destroy(model); 284 | if (options.dirty && !model.hasTempId()) { 285 | return store.destroyed(model); 286 | } else { 287 | if (model.hasTempId()) { 288 | return store.clean(model, 'dirty'); 289 | } else { 290 | return store.clean(model, 'destroyed'); 291 | } 292 | } 293 | } 294 | })(); 295 | if (response) { 296 | if (response.toJSON) { 297 | response = response.toJSON(options); 298 | } 299 | if (response.attributes) { 300 | response = response.attributes; 301 | } 302 | } 303 | if (!options.ignoreCallbacks) { 304 | if (response) { 305 | options.success(response); 306 | } else { 307 | options.error('Record not found'); 308 | } 309 | } 310 | return response; 311 | }; 312 | 313 | parseRemoteResponse = function(object, response) { 314 | if (!(object && object.parseBeforeLocalSave)) { 315 | return response; 316 | } 317 | if (_.isFunction(object.parseBeforeLocalSave)) { 318 | return object.parseBeforeLocalSave(response); 319 | } 320 | }; 321 | 322 | modelUpdatedWithResponse = function(model, response) { 323 | var modelClone; 324 | modelClone = new Backbone.Model; 325 | modelClone.idAttribute = model.idAttribute; 326 | modelClone.set(model.attributes); 327 | modelClone.set(model.parse(response)); 328 | return modelClone; 329 | }; 330 | 331 | backboneSync = Backbone.DualStorage.originalSync = Backbone.sync; 332 | 333 | onlineSync = function(method, model, options) { 334 | options.success = callbackTranslator.forBackboneCaller(options.success); 335 | options.error = callbackTranslator.forBackboneCaller(options.error); 336 | return Backbone.DualStorage.originalSync(method, model, options); 337 | }; 338 | 339 | dualsync = function(method, model, options) { 340 | var error, hasOfflineStatusCode, local, relayErrorCallback, success, temporaryId, useOfflineStorage; 341 | options.storeName = getStoreName(model.collection, model); 342 | options.storeExists = Store.exists(options.storeName); 343 | options.success = callbackTranslator.forDualstorageCaller(options.success, model, options); 344 | options.error = callbackTranslator.forDualstorageCaller(options.error, model, options); 345 | if (_.result(model, 'remote') || _.result(model.collection, 'remote')) { 346 | return onlineSync(method, model, options); 347 | } 348 | local = _.result(model, 'local') || _.result(model.collection, 'local'); 349 | options.dirty = options.remote === false && !local; 350 | if (options.remote === false || local) { 351 | return localsync(method, model, options); 352 | } 353 | options.ignoreCallbacks = true; 354 | success = options.success; 355 | error = options.error; 356 | useOfflineStorage = function() { 357 | options.dirty = true; 358 | options.ignoreCallbacks = false; 359 | options.success = success; 360 | options.error = error; 361 | return localsync(method, model, options); 362 | }; 363 | hasOfflineStatusCode = function(xhr) { 364 | var offlineStatusCodes, ref; 365 | offlineStatusCodes = Backbone.DualStorage.offlineStatusCodes; 366 | if (_.isFunction(offlineStatusCodes)) { 367 | offlineStatusCodes = offlineStatusCodes(xhr); 368 | } 369 | return xhr.status === 0 || (ref = xhr.status, indexOf.call(offlineStatusCodes, ref) >= 0); 370 | }; 371 | relayErrorCallback = function(xhr) { 372 | var online; 373 | online = !hasOfflineStatusCode(xhr); 374 | if (online || method === 'read' && !options.storeExists) { 375 | return error(xhr); 376 | } else { 377 | return useOfflineStorage(); 378 | } 379 | }; 380 | switch (method) { 381 | case 'read': 382 | if (localsync('hasDirtyOrDestroyed', model, options)) { 383 | return useOfflineStorage(); 384 | } else { 385 | options.success = function(resp, _status, _xhr) { 386 | var collection, i, idAttribute, len, modelAttributes, responseModel; 387 | if (hasOfflineStatusCode(options.xhr)) { 388 | return useOfflineStorage(); 389 | } 390 | resp = parseRemoteResponse(model, resp); 391 | if (model instanceof Backbone.Collection) { 392 | collection = model; 393 | idAttribute = collection.model.prototype.idAttribute; 394 | if (!options.add) { 395 | localsync('clear', collection, options); 396 | } 397 | for (i = 0, len = resp.length; i < len; i++) { 398 | modelAttributes = resp[i]; 399 | model = collection.get(modelAttributes[idAttribute]); 400 | if (model) { 401 | responseModel = modelUpdatedWithResponse(model, modelAttributes); 402 | } else { 403 | responseModel = new collection.model(modelAttributes, options); 404 | } 405 | localsync('update', responseModel, options); 406 | } 407 | } else { 408 | responseModel = modelUpdatedWithResponse(model, resp); 409 | localsync('update', responseModel, options); 410 | } 411 | return success(resp, _status, _xhr); 412 | }; 413 | options.error = function(xhr) { 414 | return relayErrorCallback(xhr); 415 | }; 416 | return options.xhr = onlineSync(method, model, options); 417 | } 418 | break; 419 | case 'create': 420 | options.success = function(resp, _status, _xhr) { 421 | var updatedModel; 422 | if (hasOfflineStatusCode(options.xhr)) { 423 | return useOfflineStorage(); 424 | } 425 | updatedModel = modelUpdatedWithResponse(model, resp); 426 | localsync(method, updatedModel, options); 427 | return success(resp, _status, _xhr); 428 | }; 429 | options.error = function(xhr) { 430 | return relayErrorCallback(xhr); 431 | }; 432 | return options.xhr = onlineSync(method, model, options); 433 | case 'update': 434 | if (model.hasTempId()) { 435 | temporaryId = model.id; 436 | options.success = function(resp, _status, _xhr) { 437 | var updatedModel; 438 | model.set(model.idAttribute, temporaryId, { 439 | silent: true 440 | }); 441 | if (hasOfflineStatusCode(options.xhr)) { 442 | return useOfflineStorage(); 443 | } 444 | updatedModel = modelUpdatedWithResponse(model, resp); 445 | localsync('delete', model, options); 446 | localsync('create', updatedModel, options); 447 | return success(resp, _status, _xhr); 448 | }; 449 | options.error = function(xhr) { 450 | model.set(model.idAttribute, temporaryId, { 451 | silent: true 452 | }); 453 | return relayErrorCallback(xhr); 454 | }; 455 | model.set(model.idAttribute, null, { 456 | silent: true 457 | }); 458 | return options.xhr = onlineSync('create', model, options); 459 | } else { 460 | options.success = function(resp, _status, _xhr) { 461 | var updatedModel; 462 | if (hasOfflineStatusCode(options.xhr)) { 463 | return useOfflineStorage(); 464 | } 465 | updatedModel = modelUpdatedWithResponse(model, resp); 466 | localsync(method, updatedModel, options); 467 | return success(resp, _status, _xhr); 468 | }; 469 | options.error = function(xhr) { 470 | return relayErrorCallback(xhr); 471 | }; 472 | return options.xhr = onlineSync(method, model, options); 473 | } 474 | break; 475 | case 'delete': 476 | if (model.hasTempId()) { 477 | options.ignoreCallbacks = false; 478 | return localsync(method, model, options); 479 | } else { 480 | options.success = function(resp, _status, _xhr) { 481 | if (hasOfflineStatusCode(options.xhr)) { 482 | return useOfflineStorage(); 483 | } 484 | localsync(method, model, options); 485 | return success(resp, _status, _xhr); 486 | }; 487 | options.error = function(xhr) { 488 | return relayErrorCallback(xhr); 489 | }; 490 | return options.xhr = onlineSync(method, model, options); 491 | } 492 | } 493 | }; 494 | 495 | Backbone.sync = dualsync; 496 | 497 | //# sourceMappingURL=backbone.dualstorage.js.map 498 | }); -------------------------------------------------------------------------------- /spec/dualsync_spec.coffee: -------------------------------------------------------------------------------- 1 | {Backbone, backboneSync, localsync, localStorage} = window 2 | {collection, model, ModelWithAlternateIdAttribute} = {} 3 | 4 | beforeEach -> 5 | backboneSync.calls = [] 6 | localStorage.clear() 7 | ModelWithAlternateIdAttribute = Backbone.Model.extend idAttribute: '_id' 8 | collection = new Backbone.Collection 9 | collection.model = ModelWithAlternateIdAttribute 10 | collection.add 11 | _id: 12 12 | position: 'arm' 13 | collection.url = 'bones/' 14 | delete collection.remote 15 | model = collection.models[0] 16 | delete model.remote 17 | 18 | spyOnLocalsync = -> 19 | spyOn(window, 'localsync') 20 | .andCallFake (method, model, options) -> 21 | options.success?() unless options.ignoreCallbacks 22 | localsync = window.localsync 23 | 24 | describe 'delegating to localsync and backboneSync, and calling the model callbacks', -> 25 | describe 'dual tier storage', -> 26 | checkMergedAttributesFor = (method, modelToUpdate = model) -> 27 | spyOnLocalsync() 28 | originalAttributes = null 29 | ready = false 30 | runs -> 31 | modelToUpdate.set updatedAttribute: 'original value' 32 | originalAttributes = _.clone(modelToUpdate.attributes) 33 | serverResponse = _.extend(model.toJSON(), updatedAttribute: 'updated value', newAttribute: 'new value') 34 | dualsync(method, modelToUpdate, success: (-> ready = true), serverResponse: serverResponse) 35 | waitsFor (-> ready), "The success callback should have been called", 100 36 | runs -> 37 | expect(modelToUpdate.attributes).toEqual originalAttributes 38 | localsyncedAttributes = _(localsync.calls).map((call) -> call.args[1].attributes) 39 | updatedAttributes = 40 | _id: 12 41 | position: 'arm' 42 | updatedAttribute: 'updated value' 43 | newAttribute: 'new value' 44 | expect(localsyncedAttributes).toContain updatedAttributes 45 | 46 | describe 'create', -> 47 | it 'delegates to both localsync and backboneSync', -> 48 | spyOnLocalsync() 49 | ready = false 50 | runs -> 51 | dualsync('create', model, success: (-> ready = true)) 52 | waitsFor (-> ready), "The success callback should have been called", 100 53 | runs -> 54 | expect(backboneSync).toHaveBeenCalled() 55 | expect(backboneSync.calls[0].args[0]).toEqual 'create' 56 | expect(localsync).toHaveBeenCalled() 57 | expect(localsync.calls[0].args[0]).toEqual 'create' 58 | expect(_(localsync.calls).every((call) -> call.args[1] instanceof Backbone.Model)).toBeTruthy() 59 | 60 | it 'merges the response attributes into the model attributes', -> 61 | checkMergedAttributesFor 'create' 62 | 63 | describe 'read', -> 64 | it 'delegates to both localsync and backboneSync', -> 65 | spyOnLocalsync() 66 | ready = false 67 | runs -> 68 | dualsync('read', model, success: (-> ready = true)) 69 | waitsFor (-> ready), "The success callback should have been called", 100 70 | runs -> 71 | expect(backboneSync).toHaveBeenCalled() 72 | expect(_(backboneSync.calls).any((call) -> call.args[0] == 'read')).toBeTruthy() 73 | expect(localsync).toHaveBeenCalled() 74 | expect(_(localsync.calls).any((call) -> call.args[0] == 'update')).toBeTruthy() 75 | expect(_(localsync.calls).every((call) -> call.args[1] instanceof Backbone.Model)).toBeTruthy() 76 | 77 | describe 'for collections', -> 78 | it 'calls localsync update once for each model', -> 79 | spyOnLocalsync() 80 | ready = false 81 | collectionResponse = [{_id: 12, position: 'arm'}, {_id: 13, position: 'a new model'}] 82 | runs -> 83 | dualsync('read', collection, success: (-> ready = true), serverResponse: collectionResponse) 84 | waitsFor (-> ready), "The success callback should have been called", 100 85 | runs -> 86 | expect(backboneSync).toHaveBeenCalled() 87 | expect(_(backboneSync.calls).any((call) -> call.args[0] == 'read')).toBeTruthy() 88 | expect(localsync).toHaveBeenCalled() 89 | updateCalls = _(localsync.calls).select((call) -> call.args[0] == 'update') 90 | expect(updateCalls.length).toEqual 2 91 | expect(_(updateCalls).every((call) -> call.args[1] instanceof Backbone.Model)).toBeTruthy() 92 | updatedModelAttributes = _(updateCalls).map((call) -> call.args[1].attributes) 93 | expect(updatedModelAttributes[0]).toEqual _id: 12, position: 'arm' 94 | expect(updatedModelAttributes[1]).toEqual _id: 13, position: 'a new model' 95 | 96 | describe 'update', -> 97 | it 'delegates to both localsync and backboneSync', -> 98 | spyOnLocalsync() 99 | ready = false 100 | runs -> 101 | dualsync('update', model, success: (-> ready = true)) 102 | waitsFor (-> ready), "The success callback should have been called", 100 103 | runs -> 104 | expect(backboneSync).toHaveBeenCalled() 105 | expect(_(backboneSync.calls).any((call) -> call.args[0] == 'update')).toBeTruthy() 106 | expect(localsync).toHaveBeenCalled() 107 | expect(_(localsync.calls).any((call) -> call.args[0] == 'update')).toBeTruthy() 108 | expect(_(localsync.calls).every((call) -> call.args[1] instanceof Backbone.Model)).toBeTruthy() 109 | 110 | it 'merges the response attributes into the model attributes', -> 111 | checkMergedAttributesFor 'update' 112 | 113 | describe 'delete', -> 114 | it 'delegates to both localsync and backboneSync', -> 115 | spyOnLocalsync() 116 | ready = false 117 | runs -> 118 | dualsync('delete', model, success: (-> ready = true)) 119 | waitsFor (-> ready), "The success callback should have been called", 100 120 | runs -> 121 | expect(backboneSync).toHaveBeenCalled() 122 | expect(_(backboneSync.calls).any((call) -> call.args[0] == 'delete')).toBeTruthy() 123 | expect(localsync).toHaveBeenCalled() 124 | expect(_(localsync.calls).any((call) -> call.args[0] == 'delete')).toBeTruthy() 125 | expect(_(localsync.calls).every((call) -> call.args[1] instanceof Backbone.Model)).toBeTruthy() 126 | 127 | describe 'respects the remote only attribute on models', -> 128 | it 'delegates for remote models', -> 129 | ready = false 130 | runs -> 131 | model.remote = true 132 | dualsync('create', model, success: (-> ready = true)) 133 | waitsFor (-> ready), "The success callback should have been called", 100 134 | runs -> 135 | expect(backboneSync).toHaveBeenCalled() 136 | expect(backboneSync.calls[0].args[0]).toEqual 'create' 137 | 138 | it 'delegates for remote collections', -> 139 | ready = false 140 | runs -> 141 | collection.remote = true 142 | dualsync('read', model, success: (-> ready = true)) 143 | waitsFor (-> ready), "The success callback should have been called", 100 144 | runs -> 145 | expect(backboneSync).toHaveBeenCalled() 146 | expect(backboneSync.calls[0].args[0]).toEqual 'read' 147 | 148 | describe 'respects the local only attribute on models', -> 149 | it 'delegates for local models', -> 150 | spyOnLocalsync() 151 | ready = false 152 | runs -> 153 | model.local = true 154 | backboneSync.reset() 155 | dualsync('update', model, success: (-> ready = true)) 156 | waitsFor (-> ready), "The success callback should have been called", 100 157 | runs -> 158 | expect(localsync).toHaveBeenCalled() 159 | expect(localsync.calls[0].args[0]).toEqual 'update' 160 | 161 | it 'delegates for local collections', -> 162 | ready = false 163 | runs -> 164 | collection.local = true 165 | backboneSync.reset() 166 | dualsync('delete', model, success: (-> ready = true)) 167 | waitsFor (-> ready), "The success callback should have been called", 100 168 | runs -> 169 | expect(backboneSync).not.toHaveBeenCalled() 170 | 171 | it 'respects the remote: false sync option', -> 172 | ready = false 173 | runs -> 174 | backboneSync.reset() 175 | dualsync('create', model, success: (-> ready = true), remote: false) 176 | waitsFor (-> ready), "The success callback should have been called", 100 177 | runs -> 178 | expect(backboneSync).not.toHaveBeenCalled() 179 | 180 | describe 'server response', -> 181 | describe 'on read', -> 182 | describe 'for models', -> 183 | it 'gets merged with existing attributes on a model', -> 184 | spyOnLocalsync() 185 | localsync.reset() 186 | ready = false 187 | runs -> 188 | dualsync('read', model, success: (-> ready = true), serverResponse: {side: 'left', _id: 13}) 189 | waitsFor (-> ready), "The success callback should have been called", 100 190 | runs -> 191 | expect(localsync.calls[1].args[0]).toEqual 'update' 192 | expect(localsync.calls[1].args[1].attributes).toEqual position: 'arm', side: 'left', _id: 13 193 | 194 | describe 'for collections', -> 195 | it 'gets merged with existing attributes on the model with the same id', -> 196 | spyOnLocalsync() 197 | localsync.reset() 198 | ready = false 199 | runs -> 200 | dualsync('read', collection, success: (-> ready = true), serverResponse: [{side: 'left', _id: 12}]) 201 | waitsFor (-> ready), "The success callback should have been called", 100 202 | runs -> 203 | expect(localsync.calls[2].args[0]).toEqual 'update' 204 | expect(localsync.calls[2].args[1].attributes).toEqual position: 'arm', side: 'left', _id: 12 205 | 206 | describe 'on create', -> 207 | it 'gets merged with existing attributes on a model', -> 208 | spyOnLocalsync() 209 | localsync.reset() 210 | ready = false 211 | runs -> 212 | dualsync('create', model, success: (-> ready = true), serverResponse: {side: 'left', _id: 13}) 213 | waitsFor (-> ready), "The success callback should have been called", 100 214 | runs -> 215 | expect(localsync.calls[0].args[0]).toEqual 'create' 216 | expect(localsync.calls[0].args[1].attributes).toEqual position: 'arm', side: 'left', _id: 13 217 | 218 | describe 'on update', -> 219 | it 'gets merged with existing attributes on a model', -> 220 | spyOnLocalsync() 221 | localsync.reset() 222 | ready = false 223 | runs -> 224 | dualsync('update', model, success: (-> ready = true), serverResponse: {side: 'left', _id: 13}) 225 | waitsFor (-> ready), "The success callback should have been called", 100 226 | runs -> 227 | expect(localsync.calls[0].args[0]).toEqual 'update' 228 | expect(localsync.calls[0].args[1].attributes).toEqual position: 'arm', side: 'left', _id: 13 229 | 230 | describe 'offline storage', -> 231 | it 'marks records dirty when options.remote is false, except if the model/collection is marked as local', -> 232 | spyOnLocalsync() 233 | ready = undefined 234 | runs -> 235 | ready = false 236 | collection.local = true 237 | dualsync('update', model, success: (-> ready = true), remote: false) 238 | waitsFor (-> ready), "The success callback should have been called", 100 239 | runs -> 240 | expect(localsync).toHaveBeenCalled() 241 | expect(localsync.calls.length).toEqual 1 242 | expect(localsync.calls[0].args[2].dirty).toBeFalsy() 243 | runs -> 244 | localsync.reset() 245 | ready = false 246 | collection.local = false 247 | dualsync('update', model, success: (-> ready = true), remote: false) 248 | waitsFor (-> ready), "The success callback should have been called", 100 249 | runs -> 250 | expect(localsync).toHaveBeenCalled() 251 | expect(localsync.calls.length).toEqual 1 252 | expect(localsync.calls[0].args[2].dirty).toBeTruthy() 253 | 254 | it "preserves an offline-saved model's temporary id when updated offline", -> 255 | ready = undefined 256 | temporaryId = 'tttttttttttttttttttttttttttttttttttt' 257 | runs -> 258 | ready = false 259 | model.set model.idAttribute, temporaryId 260 | dualsync('update', model, success: (-> ready = true), successStatus: 0) 261 | waitsFor (-> ready), "The success callback should have been called", 100 262 | runs -> 263 | expect(model.id).toEqual temporaryId 264 | 265 | describe 'dualStorage hooks', -> 266 | beforeEach -> 267 | model.parseBeforeLocalSave = -> 268 | new ModelWithAlternateIdAttribute(parsedRemote: true) 269 | ready = false 270 | runs -> 271 | dualsync 'create', model, success: (-> ready = true) 272 | waitsFor (-> ready), "The success callback should have been called", 100 273 | 274 | it 'filters read responses through parseBeforeLocalSave when defined on the model or collection', -> 275 | response = null 276 | runs -> 277 | dualsync 'read', model, success: (callback_args...) -> 278 | response = callback_args 279 | waitsFor (-> response), "The success callback should have been called", 100 280 | runs -> 281 | expect(response[0].get('parsedRemote') || response[1].get('parsedRemote')).toBeTruthy() 282 | 283 | describe 'storeName selection', -> 284 | it 'uses the model url as a store name', -> 285 | model = new ModelWithAlternateIdAttribute() 286 | model.local = true 287 | model.url = '/bacon/bits' 288 | spyOnLocalsync() 289 | dualsync(null, model, {}) 290 | expect(localsync.calls[0].args[2].storeName).toEqual model.url 291 | 292 | it 'prefers the model urlRoot over the url as a store name', -> 293 | model = new ModelWithAlternateIdAttribute() 294 | model.local = true 295 | model.url = '/bacon/bits' 296 | model.urlRoot = '/bacon' 297 | spyOnLocalsync() 298 | dualsync(null, model, {}) 299 | expect(localsync.calls[0].args[2].storeName).toEqual model.urlRoot 300 | 301 | it 'prefers the collection url over the model urlRoot as a store name', -> 302 | model = new ModelWithAlternateIdAttribute() 303 | model.local = true 304 | model.url = '/bacon/bits' 305 | model.urlRoot = '/bacon' 306 | model.collection = new Backbone.Collection() 307 | model.collection.url = '/ranch' 308 | spyOnLocalsync() 309 | dualsync(null, model, {}) 310 | expect(localsync.calls[0].args[2].storeName).toEqual model.collection.url 311 | 312 | it 'prefers the model storeName over the collection url as a store name', -> 313 | model = new ModelWithAlternateIdAttribute() 314 | model.local = true 315 | model.url = '/bacon/bits' 316 | model.urlRoot = '/bacon' 317 | model.collection = new Backbone.Collection() 318 | model.collection.url = '/ranch' 319 | model.storeName = 'melted cheddar' 320 | spyOnLocalsync() 321 | dualsync(null, model, {}) 322 | expect(localsync.calls[0].args[2].storeName).toEqual model.storeName 323 | 324 | it 'prefers the collection storeName over the model storeName as a store name', -> 325 | model = new ModelWithAlternateIdAttribute() 326 | model.local = true 327 | model.url = '/bacon/bits' 328 | model.urlRoot = '/bacon' 329 | model.collection = new Backbone.Collection() 330 | model.collection.url = '/ranch' 331 | model.storeName = 'melted cheddar' 332 | model.collection.storeName = 'ketchup' 333 | spyOnLocalsync() 334 | dualsync(null, model, {}) 335 | expect(localsync.calls[0].args[2].storeName).toEqual model.collection.storeName 336 | 337 | describe 'when to call user-specified success and error callbacks', -> 338 | it 'uses the success callback when the network is down', -> 339 | ready = false 340 | localStorage.setItem 'bones/', "1" 341 | runs -> 342 | dualsync('create', model, success: (-> ready = true), errorStatus: 0) 343 | waitsFor (-> ready), "The success callback should have been called", 100 344 | 345 | it 'uses the success callback when an offline error status is received (e.g. 408)', -> 346 | ready = false 347 | localStorage.setItem 'bones/', "1" 348 | runs -> 349 | dualsync('create', model, success: (-> ready = true), errorStatus: 408) 350 | waitsFor (-> ready), "The success callback should have been called", 100 351 | 352 | it 'uses the error callback when an error status is received (e.g. 500)', -> 353 | ready = false 354 | runs -> 355 | dualsync('create', model, error: (-> ready = true), errorStatus: 500) 356 | waitsFor (-> ready), "The error callback should have been called", 100 357 | 358 | it 'when a model with a temp id has been destroyed', -> 359 | modelWithTempId = new collection.model() 360 | modelWithTempId.url = "http://test.ch/" 361 | modelWithTempId.save({}, { remote: false }) 362 | spyOnLocalsync() 363 | successSpy = jasmine.createSpy("successHandler") 364 | modelWithTempId.destroy({ success: successSpy }) 365 | expect(successSpy).toHaveBeenCalled() 366 | 367 | describe 'when offline', -> 368 | it 'uses the error callback if no existing local store is found', -> 369 | ready = false 370 | runs -> 371 | dualsync('read', model, 372 | error: (-> ready = true) 373 | errorStatus: 0 374 | ) 375 | waitsFor (-> ready), "The error callback should have been called", 100 376 | 377 | it 'uses the success callback if the store exists with data', -> 378 | storeModel = model.clone() 379 | storeModel.storeName = 'store-exists' 380 | modelId = storeModel.id 381 | localStorage.setItem storeModel.storeName, modelId 382 | localStorage.setItem "#{storeModel.storeName}#{modelId}", "{\"id\": #{modelId}}" 383 | ready = false 384 | runs -> 385 | dualsync('read', storeModel, 386 | success: (-> ready = true) 387 | errorStatus: 0 388 | ) 389 | waitsFor (-> ready), "The success callback should have been called", 100 390 | 391 | it 'errors if the model has not been cached locally', -> 392 | storeModel = model.clone() 393 | storeModel.storeName = 'store-exists' 394 | localStorage.setItem storeModel.storeName, "" 395 | ready = false 396 | runs -> 397 | dualsync('read', storeModel, 398 | error: (-> ready = true) 399 | errorStatus: 0 400 | ) 401 | waitsFor (-> ready), "The success callback should have been called", 100 402 | -------------------------------------------------------------------------------- /backbone.dualstorage.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | 3 | /* 4 | Backbone dualStorage Adapter v1.4.2 5 | 6 | A simple module to replace `Backbone.sync` with *localStorage*-based 7 | persistence. Models are given GUIDS, and saved into a JSON object. Simple 8 | as that. 9 | */ 10 | 11 | (function() { 12 | var S4, backboneSync, callbackTranslator, dualsync, getStoreName, localsync, modelUpdatedWithResponse, onlineSync, parseRemoteResponse, 13 | indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 14 | 15 | Backbone.DualStorage = { 16 | offlineStatusCodes: [408, 502] 17 | }; 18 | 19 | Backbone.Model.prototype.hasTempId = function() { 20 | return _.isString(this.id) && this.id.length === 36 && this.id.indexOf('t') === 0; 21 | }; 22 | 23 | getStoreName = function(collection, model) { 24 | model || (model = collection.model.prototype); 25 | return _.result(collection, 'storeName') || _.result(model, 'storeName') || _.result(collection, 'url') || _.result(model, 'urlRoot') || _.result(model, 'url'); 26 | }; 27 | 28 | Backbone.Collection.prototype.syncDirty = function(options) { 29 | var i, id, ids, len, ref, results, store; 30 | store = localStorage.getItem((getStoreName(this)) + "_dirty"); 31 | ids = (store && store.split(',')) || []; 32 | results = []; 33 | for (i = 0, len = ids.length; i < len; i++) { 34 | id = ids[i]; 35 | results.push((ref = this.get(id)) != null ? ref.save(null, options) : void 0); 36 | } 37 | return results; 38 | }; 39 | 40 | Backbone.Collection.prototype.dirtyModels = function() { 41 | var id, ids, models, store; 42 | store = localStorage.getItem((getStoreName(this)) + "_dirty"); 43 | ids = (store && store.split(',')) || []; 44 | models = (function() { 45 | var i, len, results; 46 | results = []; 47 | for (i = 0, len = ids.length; i < len; i++) { 48 | id = ids[i]; 49 | results.push(this.get(id)); 50 | } 51 | return results; 52 | }).call(this); 53 | return _.compact(models); 54 | }; 55 | 56 | Backbone.Collection.prototype.syncDestroyed = function(options) { 57 | var i, id, ids, len, model, results, store; 58 | store = localStorage.getItem((getStoreName(this)) + "_destroyed"); 59 | ids = (store && store.split(',')) || []; 60 | results = []; 61 | for (i = 0, len = ids.length; i < len; i++) { 62 | id = ids[i]; 63 | model = new this.model; 64 | model.set(model.idAttribute, id); 65 | model.collection = this; 66 | results.push(model.destroy(options)); 67 | } 68 | return results; 69 | }; 70 | 71 | Backbone.Collection.prototype.destroyedModelIds = function() { 72 | var ids, store; 73 | store = localStorage.getItem((getStoreName(this)) + "_destroyed"); 74 | return ids = (store && store.split(',')) || []; 75 | }; 76 | 77 | Backbone.Collection.prototype.syncDirtyAndDestroyed = function(options) { 78 | this.syncDirty(options); 79 | return this.syncDestroyed(options); 80 | }; 81 | 82 | S4 = function() { 83 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); 84 | }; 85 | 86 | window.Store = (function() { 87 | Store.prototype.sep = ''; 88 | 89 | function Store(name) { 90 | this.name = name; 91 | this.records = this.recordsOn(this.name); 92 | } 93 | 94 | Store.prototype.generateId = function() { 95 | return 't' + S4().substring(1) + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4(); 96 | }; 97 | 98 | Store.prototype.save = function() { 99 | return localStorage.setItem(this.name, this.records.join(',')); 100 | }; 101 | 102 | Store.prototype.recordsOn = function(key) { 103 | var store; 104 | store = localStorage.getItem(key); 105 | return (store && store.split(',')) || []; 106 | }; 107 | 108 | Store.prototype.dirty = function(model) { 109 | var dirtyRecords; 110 | dirtyRecords = this.recordsOn(this.name + '_dirty'); 111 | if (!_.include(dirtyRecords, model.id.toString())) { 112 | dirtyRecords.push(model.id); 113 | localStorage.setItem(this.name + '_dirty', dirtyRecords.join(',')); 114 | } 115 | return model; 116 | }; 117 | 118 | Store.prototype.clean = function(model, from) { 119 | var dirtyRecords, store; 120 | store = this.name + "_" + from; 121 | dirtyRecords = this.recordsOn(store); 122 | if (_.include(dirtyRecords, model.id.toString())) { 123 | localStorage.setItem(store, _.without(dirtyRecords, model.id.toString()).join(',')); 124 | } 125 | return model; 126 | }; 127 | 128 | Store.prototype.destroyed = function(model) { 129 | var destroyedRecords; 130 | destroyedRecords = this.recordsOn(this.name + '_destroyed'); 131 | if (!_.include(destroyedRecords, model.id.toString())) { 132 | destroyedRecords.push(model.id); 133 | localStorage.setItem(this.name + '_destroyed', destroyedRecords.join(',')); 134 | } 135 | return model; 136 | }; 137 | 138 | Store.prototype.create = function(model, options) { 139 | if (!_.isObject(model)) { 140 | return model; 141 | } 142 | if (!model.id) { 143 | model.set(model.idAttribute, this.generateId()); 144 | } 145 | localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model)); 146 | this.records.push(model.id.toString()); 147 | this.save(); 148 | return model; 149 | }; 150 | 151 | Store.prototype.update = function(model, options) { 152 | localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model)); 153 | if (!_.include(this.records, model.id.toString())) { 154 | this.records.push(model.id.toString()); 155 | } 156 | this.save(); 157 | return model; 158 | }; 159 | 160 | Store.prototype.clear = function() { 161 | var i, id, len, ref; 162 | ref = this.records; 163 | for (i = 0, len = ref.length; i < len; i++) { 164 | id = ref[i]; 165 | localStorage.removeItem(this.name + this.sep + id); 166 | } 167 | this.records = []; 168 | return this.save(); 169 | }; 170 | 171 | Store.prototype.hasDirtyOrDestroyed = function() { 172 | return !_.isEmpty(localStorage.getItem(this.name + '_dirty')) || !_.isEmpty(localStorage.getItem(this.name + '_destroyed')); 173 | }; 174 | 175 | Store.prototype.find = function(model) { 176 | var modelAsJson; 177 | modelAsJson = localStorage.getItem(this.name + this.sep + model.id); 178 | if (modelAsJson === null) { 179 | return null; 180 | } 181 | return JSON.parse(modelAsJson); 182 | }; 183 | 184 | Store.prototype.findAll = function() { 185 | var i, id, len, ref, results; 186 | ref = this.records; 187 | results = []; 188 | for (i = 0, len = ref.length; i < len; i++) { 189 | id = ref[i]; 190 | results.push(JSON.parse(localStorage.getItem(this.name + this.sep + id))); 191 | } 192 | return results; 193 | }; 194 | 195 | Store.prototype.destroy = function(model) { 196 | localStorage.removeItem(this.name + this.sep + model.id); 197 | this.records = _.reject(this.records, function(record_id) { 198 | return record_id === model.id.toString(); 199 | }); 200 | this.save(); 201 | return model; 202 | }; 203 | 204 | return Store; 205 | 206 | })(); 207 | 208 | window.Store.exists = function(storeName) { 209 | return localStorage.getItem(storeName) !== null; 210 | }; 211 | 212 | callbackTranslator = { 213 | needsTranslation: Backbone.VERSION === '0.9.10', 214 | forBackboneCaller: function(callback) { 215 | if (this.needsTranslation) { 216 | return function(model, resp, options) { 217 | return callback.call(null, resp); 218 | }; 219 | } else { 220 | return callback; 221 | } 222 | }, 223 | forDualstorageCaller: function(callback, model, options) { 224 | if (this.needsTranslation) { 225 | return function(resp) { 226 | return callback.call(null, model, resp, options); 227 | }; 228 | } else { 229 | return callback; 230 | } 231 | } 232 | }; 233 | 234 | localsync = function(method, model, options) { 235 | var isValidModel, preExisting, response, store; 236 | isValidModel = (method === 'clear') || (method === 'hasDirtyOrDestroyed'); 237 | isValidModel || (isValidModel = model instanceof Backbone.Model); 238 | isValidModel || (isValidModel = model instanceof Backbone.Collection); 239 | if (!isValidModel) { 240 | throw new Error('model parameter is required to be a backbone model or collection.'); 241 | } 242 | store = new Store(options.storeName); 243 | response = (function() { 244 | switch (method) { 245 | case 'read': 246 | if (model instanceof Backbone.Model) { 247 | return store.find(model); 248 | } else { 249 | return store.findAll(); 250 | } 251 | break; 252 | case 'hasDirtyOrDestroyed': 253 | return store.hasDirtyOrDestroyed(); 254 | case 'clear': 255 | return store.clear(); 256 | case 'create': 257 | if (options.add && !options.merge && (preExisting = store.find(model))) { 258 | return preExisting; 259 | } else { 260 | model = store.create(model, options); 261 | if (options.dirty) { 262 | store.dirty(model); 263 | } 264 | return model; 265 | } 266 | break; 267 | case 'update': 268 | store.update(model, options); 269 | if (options.dirty) { 270 | return store.dirty(model); 271 | } else { 272 | return store.clean(model, 'dirty'); 273 | } 274 | break; 275 | case 'delete': 276 | store.destroy(model); 277 | if (options.dirty && !model.hasTempId()) { 278 | return store.destroyed(model); 279 | } else { 280 | if (model.hasTempId()) { 281 | return store.clean(model, 'dirty'); 282 | } else { 283 | return store.clean(model, 'destroyed'); 284 | } 285 | } 286 | } 287 | })(); 288 | if (response) { 289 | if (response.toJSON) { 290 | response = response.toJSON(options); 291 | } 292 | if (response.attributes) { 293 | response = response.attributes; 294 | } 295 | } 296 | if (!options.ignoreCallbacks) { 297 | if (response) { 298 | options.success(response); 299 | } else { 300 | options.error('Record not found'); 301 | } 302 | } 303 | return response; 304 | }; 305 | 306 | parseRemoteResponse = function(object, response) { 307 | if (!(object && object.parseBeforeLocalSave)) { 308 | return response; 309 | } 310 | if (_.isFunction(object.parseBeforeLocalSave)) { 311 | return object.parseBeforeLocalSave(response); 312 | } 313 | }; 314 | 315 | modelUpdatedWithResponse = function(model, response) { 316 | var modelClone; 317 | modelClone = new Backbone.Model; 318 | modelClone.idAttribute = model.idAttribute; 319 | modelClone.set(model.attributes); 320 | modelClone.set(model.parse(response)); 321 | return modelClone; 322 | }; 323 | 324 | backboneSync = Backbone.DualStorage.originalSync = Backbone.sync; 325 | 326 | onlineSync = function(method, model, options) { 327 | options.success = callbackTranslator.forBackboneCaller(options.success); 328 | options.error = callbackTranslator.forBackboneCaller(options.error); 329 | return Backbone.DualStorage.originalSync(method, model, options); 330 | }; 331 | 332 | dualsync = function(method, model, options) { 333 | var error, hasOfflineStatusCode, local, relayErrorCallback, success, temporaryId, useOfflineStorage; 334 | options.storeName = getStoreName(model.collection, model); 335 | options.storeExists = Store.exists(options.storeName); 336 | options.success = callbackTranslator.forDualstorageCaller(options.success, model, options); 337 | options.error = callbackTranslator.forDualstorageCaller(options.error, model, options); 338 | if (_.result(model, 'remote') || _.result(model.collection, 'remote')) { 339 | return onlineSync(method, model, options); 340 | } 341 | local = _.result(model, 'local') || _.result(model.collection, 'local'); 342 | options.dirty = options.remote === false && !local; 343 | if (options.remote === false || local) { 344 | return localsync(method, model, options); 345 | } 346 | options.ignoreCallbacks = true; 347 | success = options.success; 348 | error = options.error; 349 | useOfflineStorage = function() { 350 | options.dirty = true; 351 | options.ignoreCallbacks = false; 352 | options.success = success; 353 | options.error = error; 354 | return localsync(method, model, options); 355 | }; 356 | hasOfflineStatusCode = function(xhr) { 357 | var offlineStatusCodes, ref; 358 | offlineStatusCodes = Backbone.DualStorage.offlineStatusCodes; 359 | if (_.isFunction(offlineStatusCodes)) { 360 | offlineStatusCodes = offlineStatusCodes(xhr); 361 | } 362 | return xhr.status === 0 || (ref = xhr.status, indexOf.call(offlineStatusCodes, ref) >= 0); 363 | }; 364 | relayErrorCallback = function(xhr) { 365 | var online; 366 | online = !hasOfflineStatusCode(xhr); 367 | if (online || method === 'read' && !options.storeExists) { 368 | return error(xhr); 369 | } else { 370 | return useOfflineStorage(); 371 | } 372 | }; 373 | switch (method) { 374 | case 'read': 375 | if (localsync('hasDirtyOrDestroyed', model, options)) { 376 | return useOfflineStorage(); 377 | } else { 378 | options.success = function(resp, _status, _xhr) { 379 | var collection, i, idAttribute, len, modelAttributes, responseModel; 380 | if (hasOfflineStatusCode(options.xhr)) { 381 | return useOfflineStorage(); 382 | } 383 | resp = parseRemoteResponse(model, resp); 384 | if (model instanceof Backbone.Collection) { 385 | collection = model; 386 | idAttribute = collection.model.prototype.idAttribute; 387 | if (!options.add) { 388 | localsync('clear', collection, options); 389 | } 390 | for (i = 0, len = resp.length; i < len; i++) { 391 | modelAttributes = resp[i]; 392 | model = collection.get(modelAttributes[idAttribute]); 393 | if (model) { 394 | responseModel = modelUpdatedWithResponse(model, modelAttributes); 395 | } else { 396 | responseModel = new collection.model(modelAttributes, options); 397 | } 398 | localsync('update', responseModel, options); 399 | } 400 | } else { 401 | responseModel = modelUpdatedWithResponse(model, resp); 402 | localsync('update', responseModel, options); 403 | } 404 | return success(resp, _status, _xhr); 405 | }; 406 | options.error = function(xhr) { 407 | return relayErrorCallback(xhr); 408 | }; 409 | return options.xhr = onlineSync(method, model, options); 410 | } 411 | break; 412 | case 'create': 413 | options.success = function(resp, _status, _xhr) { 414 | var updatedModel; 415 | if (hasOfflineStatusCode(options.xhr)) { 416 | return useOfflineStorage(); 417 | } 418 | updatedModel = modelUpdatedWithResponse(model, resp); 419 | localsync(method, updatedModel, options); 420 | return success(resp, _status, _xhr); 421 | }; 422 | options.error = function(xhr) { 423 | return relayErrorCallback(xhr); 424 | }; 425 | return options.xhr = onlineSync(method, model, options); 426 | case 'update': 427 | if (model.hasTempId()) { 428 | temporaryId = model.id; 429 | options.success = function(resp, _status, _xhr) { 430 | var updatedModel; 431 | model.set(model.idAttribute, temporaryId, { 432 | silent: true 433 | }); 434 | if (hasOfflineStatusCode(options.xhr)) { 435 | return useOfflineStorage(); 436 | } 437 | updatedModel = modelUpdatedWithResponse(model, resp); 438 | localsync('delete', model, options); 439 | localsync('create', updatedModel, options); 440 | return success(resp, _status, _xhr); 441 | }; 442 | options.error = function(xhr) { 443 | model.set(model.idAttribute, temporaryId, { 444 | silent: true 445 | }); 446 | return relayErrorCallback(xhr); 447 | }; 448 | model.set(model.idAttribute, null, { 449 | silent: true 450 | }); 451 | return options.xhr = onlineSync('create', model, options); 452 | } else { 453 | options.success = function(resp, _status, _xhr) { 454 | var updatedModel; 455 | if (hasOfflineStatusCode(options.xhr)) { 456 | return useOfflineStorage(); 457 | } 458 | updatedModel = modelUpdatedWithResponse(model, resp); 459 | localsync(method, updatedModel, options); 460 | return success(resp, _status, _xhr); 461 | }; 462 | options.error = function(xhr) { 463 | return relayErrorCallback(xhr); 464 | }; 465 | return options.xhr = onlineSync(method, model, options); 466 | } 467 | break; 468 | case 'delete': 469 | if (model.hasTempId()) { 470 | options.ignoreCallbacks = false; 471 | return localsync(method, model, options); 472 | } else { 473 | options.success = function(resp, _status, _xhr) { 474 | if (hasOfflineStatusCode(options.xhr)) { 475 | return useOfflineStorage(); 476 | } 477 | localsync(method, model, options); 478 | return success(resp, _status, _xhr); 479 | }; 480 | options.error = function(xhr) { 481 | return relayErrorCallback(xhr); 482 | }; 483 | return options.xhr = onlineSync(method, model, options); 484 | } 485 | } 486 | }; 487 | 488 | Backbone.sync = dualsync; 489 | 490 | }).call(this); 491 | 492 | //# sourceMappingURL=backbone.dualstorage.js.map 493 | -------------------------------------------------------------------------------- /lib/jasmine-1.3.1/jasmine-html.js: -------------------------------------------------------------------------------- 1 | jasmine.HtmlReporterHelpers = {}; 2 | 3 | jasmine.HtmlReporterHelpers.createDom = function(type, attrs, childrenVarArgs) { 4 | var el = document.createElement(type); 5 | 6 | for (var i = 2; i < arguments.length; i++) { 7 | var child = arguments[i]; 8 | 9 | if (typeof child === 'string') { 10 | el.appendChild(document.createTextNode(child)); 11 | } else { 12 | if (child) { 13 | el.appendChild(child); 14 | } 15 | } 16 | } 17 | 18 | for (var attr in attrs) { 19 | if (attr == "className") { 20 | el[attr] = attrs[attr]; 21 | } else { 22 | el.setAttribute(attr, attrs[attr]); 23 | } 24 | } 25 | 26 | return el; 27 | }; 28 | 29 | jasmine.HtmlReporterHelpers.getSpecStatus = function(child) { 30 | var results = child.results(); 31 | var status = results.passed() ? 'passed' : 'failed'; 32 | if (results.skipped) { 33 | status = 'skipped'; 34 | } 35 | 36 | return status; 37 | }; 38 | 39 | jasmine.HtmlReporterHelpers.appendToSummary = function(child, childElement) { 40 | var parentDiv = this.dom.summary; 41 | var parentSuite = (typeof child.parentSuite == 'undefined') ? 'suite' : 'parentSuite'; 42 | var parent = child[parentSuite]; 43 | 44 | if (parent) { 45 | if (typeof this.views.suites[parent.id] == 'undefined') { 46 | this.views.suites[parent.id] = new jasmine.HtmlReporter.SuiteView(parent, this.dom, this.views); 47 | } 48 | parentDiv = this.views.suites[parent.id].element; 49 | } 50 | 51 | parentDiv.appendChild(childElement); 52 | }; 53 | 54 | 55 | jasmine.HtmlReporterHelpers.addHelpers = function(ctor) { 56 | for(var fn in jasmine.HtmlReporterHelpers) { 57 | ctor.prototype[fn] = jasmine.HtmlReporterHelpers[fn]; 58 | } 59 | }; 60 | 61 | jasmine.HtmlReporter = function(_doc) { 62 | var self = this; 63 | var doc = _doc || window.document; 64 | 65 | var reporterView; 66 | 67 | var dom = {}; 68 | 69 | // Jasmine Reporter Public Interface 70 | self.logRunningSpecs = false; 71 | 72 | self.reportRunnerStarting = function(runner) { 73 | var specs = runner.specs() || []; 74 | 75 | if (specs.length == 0) { 76 | return; 77 | } 78 | 79 | createReporterDom(runner.env.versionString()); 80 | doc.body.appendChild(dom.reporter); 81 | setExceptionHandling(); 82 | 83 | reporterView = new jasmine.HtmlReporter.ReporterView(dom); 84 | reporterView.addSpecs(specs, self.specFilter); 85 | }; 86 | 87 | self.reportRunnerResults = function(runner) { 88 | reporterView && reporterView.complete(); 89 | }; 90 | 91 | self.reportSuiteResults = function(suite) { 92 | reporterView.suiteComplete(suite); 93 | }; 94 | 95 | self.reportSpecStarting = function(spec) { 96 | if (self.logRunningSpecs) { 97 | self.log('>> Jasmine Running ' + spec.suite.description + ' ' + spec.description + '...'); 98 | } 99 | }; 100 | 101 | self.reportSpecResults = function(spec) { 102 | reporterView.specComplete(spec); 103 | }; 104 | 105 | self.log = function() { 106 | var console = jasmine.getGlobal().console; 107 | if (console && console.log) { 108 | if (console.log.apply) { 109 | console.log.apply(console, arguments); 110 | } else { 111 | console.log(arguments); // ie fix: console.log.apply doesn't exist on ie 112 | } 113 | } 114 | }; 115 | 116 | self.specFilter = function(spec) { 117 | if (!focusedSpecName()) { 118 | return true; 119 | } 120 | 121 | return spec.getFullName().indexOf(focusedSpecName()) === 0; 122 | }; 123 | 124 | return self; 125 | 126 | function focusedSpecName() { 127 | var specName; 128 | 129 | (function memoizeFocusedSpec() { 130 | if (specName) { 131 | return; 132 | } 133 | 134 | var paramMap = []; 135 | var params = jasmine.HtmlReporter.parameters(doc); 136 | 137 | for (var i = 0; i < params.length; i++) { 138 | var p = params[i].split('='); 139 | paramMap[decodeURIComponent(p[0])] = decodeURIComponent(p[1]); 140 | } 141 | 142 | specName = paramMap.spec; 143 | })(); 144 | 145 | return specName; 146 | } 147 | 148 | function createReporterDom(version) { 149 | dom.reporter = self.createDom('div', { id: 'HTMLReporter', className: 'jasmine_reporter' }, 150 | dom.banner = self.createDom('div', { className: 'banner' }, 151 | self.createDom('span', { className: 'title' }, "Jasmine "), 152 | self.createDom('span', { className: 'version' }, version)), 153 | 154 | dom.symbolSummary = self.createDom('ul', {className: 'symbolSummary'}), 155 | dom.alert = self.createDom('div', {className: 'alert'}, 156 | self.createDom('span', { className: 'exceptions' }, 157 | self.createDom('label', { className: 'label', 'for': 'no_try_catch' }, 'No try/catch'), 158 | self.createDom('input', { id: 'no_try_catch', type: 'checkbox' }))), 159 | dom.results = self.createDom('div', {className: 'results'}, 160 | dom.summary = self.createDom('div', { className: 'summary' }), 161 | dom.details = self.createDom('div', { id: 'details' })) 162 | ); 163 | } 164 | 165 | function noTryCatch() { 166 | return window.location.search.match(/catch=false/); 167 | } 168 | 169 | function searchWithCatch() { 170 | var params = jasmine.HtmlReporter.parameters(window.document); 171 | var removed = false; 172 | var i = 0; 173 | 174 | while (!removed && i < params.length) { 175 | if (params[i].match(/catch=/)) { 176 | params.splice(i, 1); 177 | removed = true; 178 | } 179 | i++; 180 | } 181 | if (jasmine.CATCH_EXCEPTIONS) { 182 | params.push("catch=false"); 183 | } 184 | 185 | return params.join("&"); 186 | } 187 | 188 | function setExceptionHandling() { 189 | var chxCatch = document.getElementById('no_try_catch'); 190 | 191 | if (noTryCatch()) { 192 | chxCatch.setAttribute('checked', true); 193 | jasmine.CATCH_EXCEPTIONS = false; 194 | } 195 | chxCatch.onclick = function() { 196 | window.location.search = searchWithCatch(); 197 | }; 198 | } 199 | }; 200 | jasmine.HtmlReporter.parameters = function(doc) { 201 | var paramStr = doc.location.search.substring(1); 202 | var params = []; 203 | 204 | if (paramStr.length > 0) { 205 | params = paramStr.split('&'); 206 | } 207 | return params; 208 | } 209 | jasmine.HtmlReporter.sectionLink = function(sectionName) { 210 | var link = '?'; 211 | var params = []; 212 | 213 | if (sectionName) { 214 | params.push('spec=' + encodeURIComponent(sectionName)); 215 | } 216 | if (!jasmine.CATCH_EXCEPTIONS) { 217 | params.push("catch=false"); 218 | } 219 | if (params.length > 0) { 220 | link += params.join("&"); 221 | } 222 | 223 | return link; 224 | }; 225 | jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter); 226 | jasmine.HtmlReporter.ReporterView = function(dom) { 227 | this.startedAt = new Date(); 228 | this.runningSpecCount = 0; 229 | this.completeSpecCount = 0; 230 | this.passedCount = 0; 231 | this.failedCount = 0; 232 | this.skippedCount = 0; 233 | 234 | this.createResultsMenu = function() { 235 | this.resultsMenu = this.createDom('span', {className: 'resultsMenu bar'}, 236 | this.summaryMenuItem = this.createDom('a', {className: 'summaryMenuItem', href: "#"}, '0 specs'), 237 | ' | ', 238 | this.detailsMenuItem = this.createDom('a', {className: 'detailsMenuItem', href: "#"}, '0 failing')); 239 | 240 | this.summaryMenuItem.onclick = function() { 241 | dom.reporter.className = dom.reporter.className.replace(/ showDetails/g, ''); 242 | }; 243 | 244 | this.detailsMenuItem.onclick = function() { 245 | showDetails(); 246 | }; 247 | }; 248 | 249 | this.addSpecs = function(specs, specFilter) { 250 | this.totalSpecCount = specs.length; 251 | 252 | this.views = { 253 | specs: {}, 254 | suites: {} 255 | }; 256 | 257 | for (var i = 0; i < specs.length; i++) { 258 | var spec = specs[i]; 259 | this.views.specs[spec.id] = new jasmine.HtmlReporter.SpecView(spec, dom, this.views); 260 | if (specFilter(spec)) { 261 | this.runningSpecCount++; 262 | } 263 | } 264 | }; 265 | 266 | this.specComplete = function(spec) { 267 | this.completeSpecCount++; 268 | 269 | if (isUndefined(this.views.specs[spec.id])) { 270 | this.views.specs[spec.id] = new jasmine.HtmlReporter.SpecView(spec, dom); 271 | } 272 | 273 | var specView = this.views.specs[spec.id]; 274 | 275 | switch (specView.status()) { 276 | case 'passed': 277 | this.passedCount++; 278 | break; 279 | 280 | case 'failed': 281 | this.failedCount++; 282 | break; 283 | 284 | case 'skipped': 285 | this.skippedCount++; 286 | break; 287 | } 288 | 289 | specView.refresh(); 290 | this.refresh(); 291 | }; 292 | 293 | this.suiteComplete = function(suite) { 294 | var suiteView = this.views.suites[suite.id]; 295 | if (isUndefined(suiteView)) { 296 | return; 297 | } 298 | suiteView.refresh(); 299 | }; 300 | 301 | this.refresh = function() { 302 | 303 | if (isUndefined(this.resultsMenu)) { 304 | this.createResultsMenu(); 305 | } 306 | 307 | // currently running UI 308 | if (isUndefined(this.runningAlert)) { 309 | this.runningAlert = this.createDom('a', { href: jasmine.HtmlReporter.sectionLink(), className: "runningAlert bar" }); 310 | dom.alert.appendChild(this.runningAlert); 311 | } 312 | this.runningAlert.innerHTML = "Running " + this.completeSpecCount + " of " + specPluralizedFor(this.totalSpecCount); 313 | 314 | // skipped specs UI 315 | if (isUndefined(this.skippedAlert)) { 316 | this.skippedAlert = this.createDom('a', { href: jasmine.HtmlReporter.sectionLink(), className: "skippedAlert bar" }); 317 | } 318 | 319 | this.skippedAlert.innerHTML = "Skipping " + this.skippedCount + " of " + specPluralizedFor(this.totalSpecCount) + " - run all"; 320 | 321 | if (this.skippedCount === 1 && isDefined(dom.alert)) { 322 | dom.alert.appendChild(this.skippedAlert); 323 | } 324 | 325 | // passing specs UI 326 | if (isUndefined(this.passedAlert)) { 327 | this.passedAlert = this.createDom('span', { href: jasmine.HtmlReporter.sectionLink(), className: "passingAlert bar" }); 328 | } 329 | this.passedAlert.innerHTML = "Passing " + specPluralizedFor(this.passedCount); 330 | 331 | // failing specs UI 332 | if (isUndefined(this.failedAlert)) { 333 | this.failedAlert = this.createDom('span', {href: "?", className: "failingAlert bar"}); 334 | } 335 | this.failedAlert.innerHTML = "Failing " + specPluralizedFor(this.failedCount); 336 | 337 | if (this.failedCount === 1 && isDefined(dom.alert)) { 338 | dom.alert.appendChild(this.failedAlert); 339 | dom.alert.appendChild(this.resultsMenu); 340 | } 341 | 342 | // summary info 343 | this.summaryMenuItem.innerHTML = "" + specPluralizedFor(this.runningSpecCount); 344 | this.detailsMenuItem.innerHTML = "" + this.failedCount + " failing"; 345 | }; 346 | 347 | this.complete = function() { 348 | dom.alert.removeChild(this.runningAlert); 349 | 350 | this.skippedAlert.innerHTML = "Ran " + this.runningSpecCount + " of " + specPluralizedFor(this.totalSpecCount) + " - run all"; 351 | 352 | if (this.failedCount === 0) { 353 | dom.alert.appendChild(this.createDom('span', {className: 'passingAlert bar'}, "Passing " + specPluralizedFor(this.passedCount))); 354 | } else { 355 | showDetails(); 356 | } 357 | 358 | dom.banner.appendChild(this.createDom('span', {className: 'duration'}, "finished in " + ((new Date().getTime() - this.startedAt.getTime()) / 1000) + "s")); 359 | }; 360 | 361 | return this; 362 | 363 | function showDetails() { 364 | if (dom.reporter.className.search(/showDetails/) === -1) { 365 | dom.reporter.className += " showDetails"; 366 | } 367 | } 368 | 369 | function isUndefined(obj) { 370 | return typeof obj === 'undefined'; 371 | } 372 | 373 | function isDefined(obj) { 374 | return !isUndefined(obj); 375 | } 376 | 377 | function specPluralizedFor(count) { 378 | var str = count + " spec"; 379 | if (count > 1) { 380 | str += "s" 381 | } 382 | return str; 383 | } 384 | 385 | }; 386 | 387 | jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.ReporterView); 388 | 389 | 390 | jasmine.HtmlReporter.SpecView = function(spec, dom, views) { 391 | this.spec = spec; 392 | this.dom = dom; 393 | this.views = views; 394 | 395 | this.symbol = this.createDom('li', { className: 'pending' }); 396 | this.dom.symbolSummary.appendChild(this.symbol); 397 | 398 | this.summary = this.createDom('div', { className: 'specSummary' }, 399 | this.createDom('a', { 400 | className: 'description', 401 | href: jasmine.HtmlReporter.sectionLink(this.spec.getFullName()), 402 | title: this.spec.getFullName() 403 | }, this.spec.description) 404 | ); 405 | 406 | this.detail = this.createDom('div', { className: 'specDetail' }, 407 | this.createDom('a', { 408 | className: 'description', 409 | href: '?spec=' + encodeURIComponent(this.spec.getFullName()), 410 | title: this.spec.getFullName() 411 | }, this.spec.getFullName()) 412 | ); 413 | }; 414 | 415 | jasmine.HtmlReporter.SpecView.prototype.status = function() { 416 | return this.getSpecStatus(this.spec); 417 | }; 418 | 419 | jasmine.HtmlReporter.SpecView.prototype.refresh = function() { 420 | this.symbol.className = this.status(); 421 | 422 | switch (this.status()) { 423 | case 'skipped': 424 | break; 425 | 426 | case 'passed': 427 | this.appendSummaryToSuiteDiv(); 428 | break; 429 | 430 | case 'failed': 431 | this.appendSummaryToSuiteDiv(); 432 | this.appendFailureDetail(); 433 | break; 434 | } 435 | }; 436 | 437 | jasmine.HtmlReporter.SpecView.prototype.appendSummaryToSuiteDiv = function() { 438 | this.summary.className += ' ' + this.status(); 439 | this.appendToSummary(this.spec, this.summary); 440 | }; 441 | 442 | jasmine.HtmlReporter.SpecView.prototype.appendFailureDetail = function() { 443 | this.detail.className += ' ' + this.status(); 444 | 445 | var resultItems = this.spec.results().getItems(); 446 | var messagesDiv = this.createDom('div', { className: 'messages' }); 447 | 448 | for (var i = 0; i < resultItems.length; i++) { 449 | var result = resultItems[i]; 450 | 451 | if (result.type == 'log') { 452 | messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage log'}, result.toString())); 453 | } else if (result.type == 'expect' && result.passed && !result.passed()) { 454 | messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage fail'}, result.message)); 455 | 456 | if (result.trace.stack) { 457 | messagesDiv.appendChild(this.createDom('div', {className: 'stackTrace'}, result.trace.stack)); 458 | } 459 | } 460 | } 461 | 462 | if (messagesDiv.childNodes.length > 0) { 463 | this.detail.appendChild(messagesDiv); 464 | this.dom.details.appendChild(this.detail); 465 | } 466 | }; 467 | 468 | jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.SpecView);jasmine.HtmlReporter.SuiteView = function(suite, dom, views) { 469 | this.suite = suite; 470 | this.dom = dom; 471 | this.views = views; 472 | 473 | this.element = this.createDom('div', { className: 'suite' }, 474 | this.createDom('a', { className: 'description', href: jasmine.HtmlReporter.sectionLink(this.suite.getFullName()) }, this.suite.description) 475 | ); 476 | 477 | this.appendToSummary(this.suite, this.element); 478 | }; 479 | 480 | jasmine.HtmlReporter.SuiteView.prototype.status = function() { 481 | return this.getSpecStatus(this.suite); 482 | }; 483 | 484 | jasmine.HtmlReporter.SuiteView.prototype.refresh = function() { 485 | this.element.className += " " + this.status(); 486 | }; 487 | 488 | jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.SuiteView); 489 | 490 | /* @deprecated Use jasmine.HtmlReporter instead 491 | */ 492 | jasmine.TrivialReporter = function(doc) { 493 | this.document = doc || document; 494 | this.suiteDivs = {}; 495 | this.logRunningSpecs = false; 496 | }; 497 | 498 | jasmine.TrivialReporter.prototype.createDom = function(type, attrs, childrenVarArgs) { 499 | var el = document.createElement(type); 500 | 501 | for (var i = 2; i < arguments.length; i++) { 502 | var child = arguments[i]; 503 | 504 | if (typeof child === 'string') { 505 | el.appendChild(document.createTextNode(child)); 506 | } else { 507 | if (child) { el.appendChild(child); } 508 | } 509 | } 510 | 511 | for (var attr in attrs) { 512 | if (attr == "className") { 513 | el[attr] = attrs[attr]; 514 | } else { 515 | el.setAttribute(attr, attrs[attr]); 516 | } 517 | } 518 | 519 | return el; 520 | }; 521 | 522 | jasmine.TrivialReporter.prototype.reportRunnerStarting = function(runner) { 523 | var showPassed, showSkipped; 524 | 525 | this.outerDiv = this.createDom('div', { id: 'TrivialReporter', className: 'jasmine_reporter' }, 526 | this.createDom('div', { className: 'banner' }, 527 | this.createDom('div', { className: 'logo' }, 528 | this.createDom('span', { className: 'title' }, "Jasmine"), 529 | this.createDom('span', { className: 'version' }, runner.env.versionString())), 530 | this.createDom('div', { className: 'options' }, 531 | "Show ", 532 | showPassed = this.createDom('input', { id: "__jasmine_TrivialReporter_showPassed__", type: 'checkbox' }), 533 | this.createDom('label', { "for": "__jasmine_TrivialReporter_showPassed__" }, " passed "), 534 | showSkipped = this.createDom('input', { id: "__jasmine_TrivialReporter_showSkipped__", type: 'checkbox' }), 535 | this.createDom('label', { "for": "__jasmine_TrivialReporter_showSkipped__" }, " skipped") 536 | ) 537 | ), 538 | 539 | this.runnerDiv = this.createDom('div', { className: 'runner running' }, 540 | this.createDom('a', { className: 'run_spec', href: '?' }, "run all"), 541 | this.runnerMessageSpan = this.createDom('span', {}, "Running..."), 542 | this.finishedAtSpan = this.createDom('span', { className: 'finished-at' }, "")) 543 | ); 544 | 545 | this.document.body.appendChild(this.outerDiv); 546 | 547 | var suites = runner.suites(); 548 | for (var i = 0; i < suites.length; i++) { 549 | var suite = suites[i]; 550 | var suiteDiv = this.createDom('div', { className: 'suite' }, 551 | this.createDom('a', { className: 'run_spec', href: '?spec=' + encodeURIComponent(suite.getFullName()) }, "run"), 552 | this.createDom('a', { className: 'description', href: '?spec=' + encodeURIComponent(suite.getFullName()) }, suite.description)); 553 | this.suiteDivs[suite.id] = suiteDiv; 554 | var parentDiv = this.outerDiv; 555 | if (suite.parentSuite) { 556 | parentDiv = this.suiteDivs[suite.parentSuite.id]; 557 | } 558 | parentDiv.appendChild(suiteDiv); 559 | } 560 | 561 | this.startedAt = new Date(); 562 | 563 | var self = this; 564 | showPassed.onclick = function(evt) { 565 | if (showPassed.checked) { 566 | self.outerDiv.className += ' show-passed'; 567 | } else { 568 | self.outerDiv.className = self.outerDiv.className.replace(/ show-passed/, ''); 569 | } 570 | }; 571 | 572 | showSkipped.onclick = function(evt) { 573 | if (showSkipped.checked) { 574 | self.outerDiv.className += ' show-skipped'; 575 | } else { 576 | self.outerDiv.className = self.outerDiv.className.replace(/ show-skipped/, ''); 577 | } 578 | }; 579 | }; 580 | 581 | jasmine.TrivialReporter.prototype.reportRunnerResults = function(runner) { 582 | var results = runner.results(); 583 | var className = (results.failedCount > 0) ? "runner failed" : "runner passed"; 584 | this.runnerDiv.setAttribute("class", className); 585 | //do it twice for IE 586 | this.runnerDiv.setAttribute("className", className); 587 | var specs = runner.specs(); 588 | var specCount = 0; 589 | for (var i = 0; i < specs.length; i++) { 590 | if (this.specFilter(specs[i])) { 591 | specCount++; 592 | } 593 | } 594 | var message = "" + specCount + " spec" + (specCount == 1 ? "" : "s" ) + ", " + results.failedCount + " failure" + ((results.failedCount == 1) ? "" : "s"); 595 | message += " in " + ((new Date().getTime() - this.startedAt.getTime()) / 1000) + "s"; 596 | this.runnerMessageSpan.replaceChild(this.createDom('a', { className: 'description', href: '?'}, message), this.runnerMessageSpan.firstChild); 597 | 598 | this.finishedAtSpan.appendChild(document.createTextNode("Finished at " + new Date().toString())); 599 | }; 600 | 601 | jasmine.TrivialReporter.prototype.reportSuiteResults = function(suite) { 602 | var results = suite.results(); 603 | var status = results.passed() ? 'passed' : 'failed'; 604 | if (results.totalCount === 0) { // todo: change this to check results.skipped 605 | status = 'skipped'; 606 | } 607 | this.suiteDivs[suite.id].className += " " + status; 608 | }; 609 | 610 | jasmine.TrivialReporter.prototype.reportSpecStarting = function(spec) { 611 | if (this.logRunningSpecs) { 612 | this.log('>> Jasmine Running ' + spec.suite.description + ' ' + spec.description + '...'); 613 | } 614 | }; 615 | 616 | jasmine.TrivialReporter.prototype.reportSpecResults = function(spec) { 617 | var results = spec.results(); 618 | var status = results.passed() ? 'passed' : 'failed'; 619 | if (results.skipped) { 620 | status = 'skipped'; 621 | } 622 | var specDiv = this.createDom('div', { className: 'spec ' + status }, 623 | this.createDom('a', { className: 'run_spec', href: '?spec=' + encodeURIComponent(spec.getFullName()) }, "run"), 624 | this.createDom('a', { 625 | className: 'description', 626 | href: '?spec=' + encodeURIComponent(spec.getFullName()), 627 | title: spec.getFullName() 628 | }, spec.description)); 629 | 630 | 631 | var resultItems = results.getItems(); 632 | var messagesDiv = this.createDom('div', { className: 'messages' }); 633 | for (var i = 0; i < resultItems.length; i++) { 634 | var result = resultItems[i]; 635 | 636 | if (result.type == 'log') { 637 | messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage log'}, result.toString())); 638 | } else if (result.type == 'expect' && result.passed && !result.passed()) { 639 | messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage fail'}, result.message)); 640 | 641 | if (result.trace.stack) { 642 | messagesDiv.appendChild(this.createDom('div', {className: 'stackTrace'}, result.trace.stack)); 643 | } 644 | } 645 | } 646 | 647 | if (messagesDiv.childNodes.length > 0) { 648 | specDiv.appendChild(messagesDiv); 649 | } 650 | 651 | this.suiteDivs[spec.suite.id].appendChild(specDiv); 652 | }; 653 | 654 | jasmine.TrivialReporter.prototype.log = function() { 655 | var console = jasmine.getGlobal().console; 656 | if (console && console.log) { 657 | if (console.log.apply) { 658 | console.log.apply(console, arguments); 659 | } else { 660 | console.log(arguments); // ie fix: console.log.apply doesn't exist on ie 661 | } 662 | } 663 | }; 664 | 665 | jasmine.TrivialReporter.prototype.getLocation = function() { 666 | return this.document.location; 667 | }; 668 | 669 | jasmine.TrivialReporter.prototype.specFilter = function(spec) { 670 | var paramMap = {}; 671 | var params = this.getLocation().search.substring(1).split('&'); 672 | for (var i = 0; i < params.length; i++) { 673 | var p = params[i].split('='); 674 | paramMap[decodeURIComponent(p[0])] = decodeURIComponent(p[1]); 675 | } 676 | 677 | if (!paramMap.spec) { 678 | return true; 679 | } 680 | return spec.getFullName().indexOf(paramMap.spec) === 0; 681 | }; 682 | -------------------------------------------------------------------------------- /spec/dualsync_spec.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.3 2 | (function() { 3 | var Backbone, ModelWithAlternateIdAttribute, backboneSync, collection, localStorage, localsync, model, ref, spyOnLocalsync, 4 | slice = [].slice; 5 | 6 | Backbone = window.Backbone, backboneSync = window.backboneSync, localsync = window.localsync, localStorage = window.localStorage; 7 | 8 | ref = {}, collection = ref.collection, model = ref.model, ModelWithAlternateIdAttribute = ref.ModelWithAlternateIdAttribute; 9 | 10 | beforeEach(function() { 11 | backboneSync.calls = []; 12 | localStorage.clear(); 13 | ModelWithAlternateIdAttribute = Backbone.Model.extend({ 14 | idAttribute: '_id' 15 | }); 16 | collection = new Backbone.Collection; 17 | collection.model = ModelWithAlternateIdAttribute; 18 | collection.add({ 19 | _id: 12, 20 | position: 'arm' 21 | }); 22 | collection.url = 'bones/'; 23 | delete collection.remote; 24 | model = collection.models[0]; 25 | return delete model.remote; 26 | }); 27 | 28 | spyOnLocalsync = function() { 29 | spyOn(window, 'localsync').andCallFake(function(method, model, options) { 30 | if (!options.ignoreCallbacks) { 31 | return typeof options.success === "function" ? options.success() : void 0; 32 | } 33 | }); 34 | return localsync = window.localsync; 35 | }; 36 | 37 | describe('delegating to localsync and backboneSync, and calling the model callbacks', function() { 38 | describe('dual tier storage', function() { 39 | var checkMergedAttributesFor; 40 | checkMergedAttributesFor = function(method, modelToUpdate) { 41 | var originalAttributes, ready; 42 | if (modelToUpdate == null) { 43 | modelToUpdate = model; 44 | } 45 | spyOnLocalsync(); 46 | originalAttributes = null; 47 | ready = false; 48 | runs(function() { 49 | var serverResponse; 50 | modelToUpdate.set({ 51 | updatedAttribute: 'original value' 52 | }); 53 | originalAttributes = _.clone(modelToUpdate.attributes); 54 | serverResponse = _.extend(model.toJSON(), { 55 | updatedAttribute: 'updated value', 56 | newAttribute: 'new value' 57 | }); 58 | return dualsync(method, modelToUpdate, { 59 | success: (function() { 60 | return ready = true; 61 | }), 62 | serverResponse: serverResponse 63 | }); 64 | }); 65 | waitsFor((function() { 66 | return ready; 67 | }), "The success callback should have been called", 100); 68 | return runs(function() { 69 | var localsyncedAttributes, updatedAttributes; 70 | expect(modelToUpdate.attributes).toEqual(originalAttributes); 71 | localsyncedAttributes = _(localsync.calls).map(function(call) { 72 | return call.args[1].attributes; 73 | }); 74 | updatedAttributes = { 75 | _id: 12, 76 | position: 'arm', 77 | updatedAttribute: 'updated value', 78 | newAttribute: 'new value' 79 | }; 80 | return expect(localsyncedAttributes).toContain(updatedAttributes); 81 | }); 82 | }; 83 | describe('create', function() { 84 | it('delegates to both localsync and backboneSync', function() { 85 | var ready; 86 | spyOnLocalsync(); 87 | ready = false; 88 | runs(function() { 89 | return dualsync('create', model, { 90 | success: (function() { 91 | return ready = true; 92 | }) 93 | }); 94 | }); 95 | waitsFor((function() { 96 | return ready; 97 | }), "The success callback should have been called", 100); 98 | return runs(function() { 99 | expect(backboneSync).toHaveBeenCalled(); 100 | expect(backboneSync.calls[0].args[0]).toEqual('create'); 101 | expect(localsync).toHaveBeenCalled(); 102 | expect(localsync.calls[0].args[0]).toEqual('create'); 103 | return expect(_(localsync.calls).every(function(call) { 104 | return call.args[1] instanceof Backbone.Model; 105 | })).toBeTruthy(); 106 | }); 107 | }); 108 | return it('merges the response attributes into the model attributes', function() { 109 | return checkMergedAttributesFor('create'); 110 | }); 111 | }); 112 | describe('read', function() { 113 | it('delegates to both localsync and backboneSync', function() { 114 | var ready; 115 | spyOnLocalsync(); 116 | ready = false; 117 | runs(function() { 118 | return dualsync('read', model, { 119 | success: (function() { 120 | return ready = true; 121 | }) 122 | }); 123 | }); 124 | waitsFor((function() { 125 | return ready; 126 | }), "The success callback should have been called", 100); 127 | return runs(function() { 128 | expect(backboneSync).toHaveBeenCalled(); 129 | expect(_(backboneSync.calls).any(function(call) { 130 | return call.args[0] === 'read'; 131 | })).toBeTruthy(); 132 | expect(localsync).toHaveBeenCalled(); 133 | expect(_(localsync.calls).any(function(call) { 134 | return call.args[0] === 'update'; 135 | })).toBeTruthy(); 136 | return expect(_(localsync.calls).every(function(call) { 137 | return call.args[1] instanceof Backbone.Model; 138 | })).toBeTruthy(); 139 | }); 140 | }); 141 | return describe('for collections', function() { 142 | return it('calls localsync update once for each model', function() { 143 | var collectionResponse, ready; 144 | spyOnLocalsync(); 145 | ready = false; 146 | collectionResponse = [ 147 | { 148 | _id: 12, 149 | position: 'arm' 150 | }, { 151 | _id: 13, 152 | position: 'a new model' 153 | } 154 | ]; 155 | runs(function() { 156 | return dualsync('read', collection, { 157 | success: (function() { 158 | return ready = true; 159 | }), 160 | serverResponse: collectionResponse 161 | }); 162 | }); 163 | waitsFor((function() { 164 | return ready; 165 | }), "The success callback should have been called", 100); 166 | return runs(function() { 167 | var updateCalls, updatedModelAttributes; 168 | expect(backboneSync).toHaveBeenCalled(); 169 | expect(_(backboneSync.calls).any(function(call) { 170 | return call.args[0] === 'read'; 171 | })).toBeTruthy(); 172 | expect(localsync).toHaveBeenCalled(); 173 | updateCalls = _(localsync.calls).select(function(call) { 174 | return call.args[0] === 'update'; 175 | }); 176 | expect(updateCalls.length).toEqual(2); 177 | expect(_(updateCalls).every(function(call) { 178 | return call.args[1] instanceof Backbone.Model; 179 | })).toBeTruthy(); 180 | updatedModelAttributes = _(updateCalls).map(function(call) { 181 | return call.args[1].attributes; 182 | }); 183 | expect(updatedModelAttributes[0]).toEqual({ 184 | _id: 12, 185 | position: 'arm' 186 | }); 187 | return expect(updatedModelAttributes[1]).toEqual({ 188 | _id: 13, 189 | position: 'a new model' 190 | }); 191 | }); 192 | }); 193 | }); 194 | }); 195 | describe('update', function() { 196 | it('delegates to both localsync and backboneSync', function() { 197 | var ready; 198 | spyOnLocalsync(); 199 | ready = false; 200 | runs(function() { 201 | return dualsync('update', model, { 202 | success: (function() { 203 | return ready = true; 204 | }) 205 | }); 206 | }); 207 | waitsFor((function() { 208 | return ready; 209 | }), "The success callback should have been called", 100); 210 | return runs(function() { 211 | expect(backboneSync).toHaveBeenCalled(); 212 | expect(_(backboneSync.calls).any(function(call) { 213 | return call.args[0] === 'update'; 214 | })).toBeTruthy(); 215 | expect(localsync).toHaveBeenCalled(); 216 | expect(_(localsync.calls).any(function(call) { 217 | return call.args[0] === 'update'; 218 | })).toBeTruthy(); 219 | return expect(_(localsync.calls).every(function(call) { 220 | return call.args[1] instanceof Backbone.Model; 221 | })).toBeTruthy(); 222 | }); 223 | }); 224 | return it('merges the response attributes into the model attributes', function() { 225 | return checkMergedAttributesFor('update'); 226 | }); 227 | }); 228 | return describe('delete', function() { 229 | return it('delegates to both localsync and backboneSync', function() { 230 | var ready; 231 | spyOnLocalsync(); 232 | ready = false; 233 | runs(function() { 234 | return dualsync('delete', model, { 235 | success: (function() { 236 | return ready = true; 237 | }) 238 | }); 239 | }); 240 | waitsFor((function() { 241 | return ready; 242 | }), "The success callback should have been called", 100); 243 | return runs(function() { 244 | expect(backboneSync).toHaveBeenCalled(); 245 | expect(_(backboneSync.calls).any(function(call) { 246 | return call.args[0] === 'delete'; 247 | })).toBeTruthy(); 248 | expect(localsync).toHaveBeenCalled(); 249 | expect(_(localsync.calls).any(function(call) { 250 | return call.args[0] === 'delete'; 251 | })).toBeTruthy(); 252 | return expect(_(localsync.calls).every(function(call) { 253 | return call.args[1] instanceof Backbone.Model; 254 | })).toBeTruthy(); 255 | }); 256 | }); 257 | }); 258 | }); 259 | describe('respects the remote only attribute on models', function() { 260 | it('delegates for remote models', function() { 261 | var ready; 262 | ready = false; 263 | runs(function() { 264 | model.remote = true; 265 | return dualsync('create', model, { 266 | success: (function() { 267 | return ready = true; 268 | }) 269 | }); 270 | }); 271 | waitsFor((function() { 272 | return ready; 273 | }), "The success callback should have been called", 100); 274 | return runs(function() { 275 | expect(backboneSync).toHaveBeenCalled(); 276 | return expect(backboneSync.calls[0].args[0]).toEqual('create'); 277 | }); 278 | }); 279 | return it('delegates for remote collections', function() { 280 | var ready; 281 | ready = false; 282 | runs(function() { 283 | collection.remote = true; 284 | return dualsync('read', model, { 285 | success: (function() { 286 | return ready = true; 287 | }) 288 | }); 289 | }); 290 | waitsFor((function() { 291 | return ready; 292 | }), "The success callback should have been called", 100); 293 | return runs(function() { 294 | expect(backboneSync).toHaveBeenCalled(); 295 | return expect(backboneSync.calls[0].args[0]).toEqual('read'); 296 | }); 297 | }); 298 | }); 299 | describe('respects the local only attribute on models', function() { 300 | it('delegates for local models', function() { 301 | var ready; 302 | spyOnLocalsync(); 303 | ready = false; 304 | runs(function() { 305 | model.local = true; 306 | backboneSync.reset(); 307 | return dualsync('update', model, { 308 | success: (function() { 309 | return ready = true; 310 | }) 311 | }); 312 | }); 313 | waitsFor((function() { 314 | return ready; 315 | }), "The success callback should have been called", 100); 316 | return runs(function() { 317 | expect(localsync).toHaveBeenCalled(); 318 | return expect(localsync.calls[0].args[0]).toEqual('update'); 319 | }); 320 | }); 321 | return it('delegates for local collections', function() { 322 | var ready; 323 | ready = false; 324 | runs(function() { 325 | collection.local = true; 326 | backboneSync.reset(); 327 | return dualsync('delete', model, { 328 | success: (function() { 329 | return ready = true; 330 | }) 331 | }); 332 | }); 333 | waitsFor((function() { 334 | return ready; 335 | }), "The success callback should have been called", 100); 336 | return runs(function() { 337 | return expect(backboneSync).not.toHaveBeenCalled(); 338 | }); 339 | }); 340 | }); 341 | it('respects the remote: false sync option', function() { 342 | var ready; 343 | ready = false; 344 | runs(function() { 345 | backboneSync.reset(); 346 | return dualsync('create', model, { 347 | success: (function() { 348 | return ready = true; 349 | }), 350 | remote: false 351 | }); 352 | }); 353 | waitsFor((function() { 354 | return ready; 355 | }), "The success callback should have been called", 100); 356 | return runs(function() { 357 | return expect(backboneSync).not.toHaveBeenCalled(); 358 | }); 359 | }); 360 | return describe('server response', function() { 361 | describe('on read', function() { 362 | describe('for models', function() { 363 | return it('gets merged with existing attributes on a model', function() { 364 | var ready; 365 | spyOnLocalsync(); 366 | localsync.reset(); 367 | ready = false; 368 | runs(function() { 369 | return dualsync('read', model, { 370 | success: (function() { 371 | return ready = true; 372 | }), 373 | serverResponse: { 374 | side: 'left', 375 | _id: 13 376 | } 377 | }); 378 | }); 379 | waitsFor((function() { 380 | return ready; 381 | }), "The success callback should have been called", 100); 382 | return runs(function() { 383 | expect(localsync.calls[1].args[0]).toEqual('update'); 384 | return expect(localsync.calls[1].args[1].attributes).toEqual({ 385 | position: 'arm', 386 | side: 'left', 387 | _id: 13 388 | }); 389 | }); 390 | }); 391 | }); 392 | return describe('for collections', function() { 393 | return it('gets merged with existing attributes on the model with the same id', function() { 394 | var ready; 395 | spyOnLocalsync(); 396 | localsync.reset(); 397 | ready = false; 398 | runs(function() { 399 | return dualsync('read', collection, { 400 | success: (function() { 401 | return ready = true; 402 | }), 403 | serverResponse: [ 404 | { 405 | side: 'left', 406 | _id: 12 407 | } 408 | ] 409 | }); 410 | }); 411 | waitsFor((function() { 412 | return ready; 413 | }), "The success callback should have been called", 100); 414 | return runs(function() { 415 | expect(localsync.calls[2].args[0]).toEqual('update'); 416 | return expect(localsync.calls[2].args[1].attributes).toEqual({ 417 | position: 'arm', 418 | side: 'left', 419 | _id: 12 420 | }); 421 | }); 422 | }); 423 | }); 424 | }); 425 | describe('on create', function() { 426 | return it('gets merged with existing attributes on a model', function() { 427 | var ready; 428 | spyOnLocalsync(); 429 | localsync.reset(); 430 | ready = false; 431 | runs(function() { 432 | return dualsync('create', model, { 433 | success: (function() { 434 | return ready = true; 435 | }), 436 | serverResponse: { 437 | side: 'left', 438 | _id: 13 439 | } 440 | }); 441 | }); 442 | waitsFor((function() { 443 | return ready; 444 | }), "The success callback should have been called", 100); 445 | return runs(function() { 446 | expect(localsync.calls[0].args[0]).toEqual('create'); 447 | return expect(localsync.calls[0].args[1].attributes).toEqual({ 448 | position: 'arm', 449 | side: 'left', 450 | _id: 13 451 | }); 452 | }); 453 | }); 454 | }); 455 | return describe('on update', function() { 456 | return it('gets merged with existing attributes on a model', function() { 457 | var ready; 458 | spyOnLocalsync(); 459 | localsync.reset(); 460 | ready = false; 461 | runs(function() { 462 | return dualsync('update', model, { 463 | success: (function() { 464 | return ready = true; 465 | }), 466 | serverResponse: { 467 | side: 'left', 468 | _id: 13 469 | } 470 | }); 471 | }); 472 | waitsFor((function() { 473 | return ready; 474 | }), "The success callback should have been called", 100); 475 | return runs(function() { 476 | expect(localsync.calls[0].args[0]).toEqual('update'); 477 | return expect(localsync.calls[0].args[1].attributes).toEqual({ 478 | position: 'arm', 479 | side: 'left', 480 | _id: 13 481 | }); 482 | }); 483 | }); 484 | }); 485 | }); 486 | }); 487 | 488 | describe('offline storage', function() { 489 | it('marks records dirty when options.remote is false, except if the model/collection is marked as local', function() { 490 | var ready; 491 | spyOnLocalsync(); 492 | ready = void 0; 493 | runs(function() { 494 | ready = false; 495 | collection.local = true; 496 | return dualsync('update', model, { 497 | success: (function() { 498 | return ready = true; 499 | }), 500 | remote: false 501 | }); 502 | }); 503 | waitsFor((function() { 504 | return ready; 505 | }), "The success callback should have been called", 100); 506 | runs(function() { 507 | expect(localsync).toHaveBeenCalled(); 508 | expect(localsync.calls.length).toEqual(1); 509 | return expect(localsync.calls[0].args[2].dirty).toBeFalsy(); 510 | }); 511 | runs(function() { 512 | localsync.reset(); 513 | ready = false; 514 | collection.local = false; 515 | return dualsync('update', model, { 516 | success: (function() { 517 | return ready = true; 518 | }), 519 | remote: false 520 | }); 521 | }); 522 | waitsFor((function() { 523 | return ready; 524 | }), "The success callback should have been called", 100); 525 | return runs(function() { 526 | expect(localsync).toHaveBeenCalled(); 527 | expect(localsync.calls.length).toEqual(1); 528 | return expect(localsync.calls[0].args[2].dirty).toBeTruthy(); 529 | }); 530 | }); 531 | return it("preserves an offline-saved model's temporary id when updated offline", function() { 532 | var ready, temporaryId; 533 | ready = void 0; 534 | temporaryId = 'tttttttttttttttttttttttttttttttttttt'; 535 | runs(function() { 536 | ready = false; 537 | model.set(model.idAttribute, temporaryId); 538 | return dualsync('update', model, { 539 | success: (function() { 540 | return ready = true; 541 | }), 542 | successStatus: 0 543 | }); 544 | }); 545 | waitsFor((function() { 546 | return ready; 547 | }), "The success callback should have been called", 100); 548 | return runs(function() { 549 | return expect(model.id).toEqual(temporaryId); 550 | }); 551 | }); 552 | }); 553 | 554 | describe('dualStorage hooks', function() { 555 | beforeEach(function() { 556 | var ready; 557 | model.parseBeforeLocalSave = function() { 558 | return new ModelWithAlternateIdAttribute({ 559 | parsedRemote: true 560 | }); 561 | }; 562 | ready = false; 563 | runs(function() { 564 | return dualsync('create', model, { 565 | success: (function() { 566 | return ready = true; 567 | }) 568 | }); 569 | }); 570 | return waitsFor((function() { 571 | return ready; 572 | }), "The success callback should have been called", 100); 573 | }); 574 | return it('filters read responses through parseBeforeLocalSave when defined on the model or collection', function() { 575 | var response; 576 | response = null; 577 | runs(function() { 578 | return dualsync('read', model, { 579 | success: function() { 580 | var callback_args; 581 | callback_args = 1 <= arguments.length ? slice.call(arguments, 0) : []; 582 | return response = callback_args; 583 | } 584 | }); 585 | }); 586 | waitsFor((function() { 587 | return response; 588 | }), "The success callback should have been called", 100); 589 | return runs(function() { 590 | return expect(response[0].get('parsedRemote') || response[1].get('parsedRemote')).toBeTruthy(); 591 | }); 592 | }); 593 | }); 594 | 595 | describe('storeName selection', function() { 596 | it('uses the model url as a store name', function() { 597 | model = new ModelWithAlternateIdAttribute(); 598 | model.local = true; 599 | model.url = '/bacon/bits'; 600 | spyOnLocalsync(); 601 | dualsync(null, model, {}); 602 | return expect(localsync.calls[0].args[2].storeName).toEqual(model.url); 603 | }); 604 | it('prefers the model urlRoot over the url as a store name', function() { 605 | model = new ModelWithAlternateIdAttribute(); 606 | model.local = true; 607 | model.url = '/bacon/bits'; 608 | model.urlRoot = '/bacon'; 609 | spyOnLocalsync(); 610 | dualsync(null, model, {}); 611 | return expect(localsync.calls[0].args[2].storeName).toEqual(model.urlRoot); 612 | }); 613 | it('prefers the collection url over the model urlRoot as a store name', function() { 614 | model = new ModelWithAlternateIdAttribute(); 615 | model.local = true; 616 | model.url = '/bacon/bits'; 617 | model.urlRoot = '/bacon'; 618 | model.collection = new Backbone.Collection(); 619 | model.collection.url = '/ranch'; 620 | spyOnLocalsync(); 621 | dualsync(null, model, {}); 622 | return expect(localsync.calls[0].args[2].storeName).toEqual(model.collection.url); 623 | }); 624 | it('prefers the model storeName over the collection url as a store name', function() { 625 | model = new ModelWithAlternateIdAttribute(); 626 | model.local = true; 627 | model.url = '/bacon/bits'; 628 | model.urlRoot = '/bacon'; 629 | model.collection = new Backbone.Collection(); 630 | model.collection.url = '/ranch'; 631 | model.storeName = 'melted cheddar'; 632 | spyOnLocalsync(); 633 | dualsync(null, model, {}); 634 | return expect(localsync.calls[0].args[2].storeName).toEqual(model.storeName); 635 | }); 636 | return it('prefers the collection storeName over the model storeName as a store name', function() { 637 | model = new ModelWithAlternateIdAttribute(); 638 | model.local = true; 639 | model.url = '/bacon/bits'; 640 | model.urlRoot = '/bacon'; 641 | model.collection = new Backbone.Collection(); 642 | model.collection.url = '/ranch'; 643 | model.storeName = 'melted cheddar'; 644 | model.collection.storeName = 'ketchup'; 645 | spyOnLocalsync(); 646 | dualsync(null, model, {}); 647 | return expect(localsync.calls[0].args[2].storeName).toEqual(model.collection.storeName); 648 | }); 649 | }); 650 | 651 | describe('when to call user-specified success and error callbacks', function() { 652 | it('uses the success callback when the network is down', function() { 653 | var ready; 654 | ready = false; 655 | localStorage.setItem('bones/', "1"); 656 | runs(function() { 657 | return dualsync('create', model, { 658 | success: (function() { 659 | return ready = true; 660 | }), 661 | errorStatus: 0 662 | }); 663 | }); 664 | return waitsFor((function() { 665 | return ready; 666 | }), "The success callback should have been called", 100); 667 | }); 668 | it('uses the success callback when an offline error status is received (e.g. 408)', function() { 669 | var ready; 670 | ready = false; 671 | localStorage.setItem('bones/', "1"); 672 | runs(function() { 673 | return dualsync('create', model, { 674 | success: (function() { 675 | return ready = true; 676 | }), 677 | errorStatus: 408 678 | }); 679 | }); 680 | return waitsFor((function() { 681 | return ready; 682 | }), "The success callback should have been called", 100); 683 | }); 684 | it('uses the error callback when an error status is received (e.g. 500)', function() { 685 | var ready; 686 | ready = false; 687 | runs(function() { 688 | return dualsync('create', model, { 689 | error: (function() { 690 | return ready = true; 691 | }), 692 | errorStatus: 500 693 | }); 694 | }); 695 | return waitsFor((function() { 696 | return ready; 697 | }), "The error callback should have been called", 100); 698 | }); 699 | it('when a model with a temp id has been destroyed', function() { 700 | var modelWithTempId, successSpy; 701 | modelWithTempId = new collection.model(); 702 | modelWithTempId.url = "http://test.ch/"; 703 | modelWithTempId.save({}, { 704 | remote: false 705 | }); 706 | spyOnLocalsync(); 707 | successSpy = jasmine.createSpy("successHandler"); 708 | modelWithTempId.destroy({ 709 | success: successSpy 710 | }); 711 | return expect(successSpy).toHaveBeenCalled(); 712 | }); 713 | return describe('when offline', function() { 714 | it('uses the error callback if no existing local store is found', function() { 715 | var ready; 716 | ready = false; 717 | runs(function() { 718 | return dualsync('read', model, { 719 | error: (function() { 720 | return ready = true; 721 | }), 722 | errorStatus: 0 723 | }); 724 | }); 725 | return waitsFor((function() { 726 | return ready; 727 | }), "The error callback should have been called", 100); 728 | }); 729 | it('uses the success callback if the store exists with data', function() { 730 | var modelId, ready, storeModel; 731 | storeModel = model.clone(); 732 | storeModel.storeName = 'store-exists'; 733 | modelId = storeModel.id; 734 | localStorage.setItem(storeModel.storeName, modelId); 735 | localStorage.setItem("" + storeModel.storeName + modelId, "{\"id\": " + modelId + "}"); 736 | ready = false; 737 | return runs(function() { 738 | dualsync('read', storeModel, { 739 | success: (function() { 740 | return ready = true; 741 | }), 742 | errorStatus: 0 743 | }); 744 | return waitsFor((function() { 745 | return ready; 746 | }), "The success callback should have been called", 100); 747 | }); 748 | }); 749 | return it('errors if the model has not been cached locally', function() { 750 | var ready, storeModel; 751 | storeModel = model.clone(); 752 | storeModel.storeName = 'store-exists'; 753 | localStorage.setItem(storeModel.storeName, ""); 754 | ready = false; 755 | return runs(function() { 756 | dualsync('read', storeModel, { 757 | error: (function() { 758 | return ready = true; 759 | }), 760 | errorStatus: 0 761 | }); 762 | return waitsFor((function() { 763 | return ready; 764 | }), "The success callback should have been called", 100); 765 | }); 766 | }); 767 | }); 768 | }); 769 | 770 | }).call(this); 771 | 772 | //# sourceMappingURL=dualsync_spec.js.map 773 | --------------------------------------------------------------------------------