├── app ├── .gitkeep └── initializers │ ├── syncer.js │ ├── local-store.js │ └── extend-ds-model.js ├── addon ├── .gitkeep ├── utils │ ├── is-object.js │ ├── is-model-instance.js │ ├── backup.js │ ├── generate-unique-id.js │ └── reload-local-records.js ├── model.js ├── initializers │ ├── local-store.js │ ├── extend-ds-model.js │ └── syncer.js ├── stores │ ├── local-store.js │ ├── main-store │ │ ├── decorate-api-call.js │ │ ├── decorate-serializer.js │ │ └── decorate-adapter.js │ └── main-store.js └── syncer.js ├── vendor └── .gitkeep ├── tests ├── unit │ └── .gitkeep ├── dummy │ ├── app │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── models │ │ │ ├── .gitkeep │ │ │ ├── animal.js │ │ │ ├── team.js │ │ │ ├── job.js │ │ │ └── user.js │ │ ├── routes │ │ │ ├── .gitkeep │ │ │ ├── index.js │ │ │ ├── fetch-by-id.js │ │ │ ├── reload-record.js │ │ │ ├── fetch-all.js │ │ │ ├── update.js │ │ │ ├── user-update-with-id.js │ │ │ ├── delete.js │ │ │ ├── find-by-id-private.js │ │ │ ├── fetch-all-jobs.js │ │ │ ├── create.js │ │ │ ├── application.js │ │ │ └── create-job.js │ │ ├── styles │ │ │ └── app.css │ │ ├── views │ │ │ └── .gitkeep │ │ ├── components │ │ │ └── .gitkeep │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── templates │ │ │ ├── components │ │ │ │ └── .gitkeep │ │ │ ├── fetch-by-id.hbs │ │ │ ├── reload-record.hbs │ │ │ ├── find-by-id-private.hbs │ │ │ ├── create.hbs │ │ │ ├── delete.hbs │ │ │ ├── update.hbs │ │ │ ├── user-update-with-id.hbs │ │ │ ├── fetch-all.hbs │ │ │ ├── fetch-all-jobs.hbs │ │ │ ├── create-job.hbs │ │ │ └── application.hbs │ │ ├── serializers │ │ │ └── animal.js │ │ ├── store.js │ │ ├── app.js │ │ ├── index.html │ │ ├── router.js │ │ └── initializers │ │ │ └── reopen-syncer.js │ ├── public │ │ ├── robots.txt │ │ └── crossdomain.xml │ └── config │ │ └── environment.js ├── test-helper.js ├── helpers │ ├── resolver.js │ └── start-app.js ├── .jshintrc ├── index.html └── acceptance │ ├── user-reload-record-test.js │ ├── user-fetch-by-id-test.js │ ├── user-find-by-id-private-test.js │ ├── syncer-reset-test.js │ ├── user-create-delete-test.js │ ├── user-create-test.js │ ├── animal-create-test.js │ ├── user-fetch-all-test.js │ ├── user-delete-test.js │ ├── user-update-test.js │ ├── user-create-update-test.js │ ├── team-create-test.js │ └── job-create-test.js ├── server ├── .jshintrc ├── index.js └── mocks │ ├── teams.js │ ├── jobs.js │ ├── animals.js │ └── users.js ├── .bowerrc ├── index.js ├── config └── environment.js ├── blueprints ├── application-store │ ├── files │ │ └── app │ │ │ └── store.js │ └── index.js └── reopen-syncer-initializer │ ├── index.js │ └── files │ └── app │ └── initializers │ └── reopen-syncer.js ├── .npmignore ├── testem.json ├── .ember-cli ├── .gitignore ├── .travis.yml ├── bower.json ├── .editorconfig ├── .jshintrc ├── Brocfile.js ├── LICENSE.md ├── LICENSE ├── package.json └── README.md /app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components", 3 | "analytics": false 4 | } 5 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 'use strict'; 3 | 4 | module.exports = { 5 | name: 'ember-fryctoria' 6 | }; 7 | -------------------------------------------------------------------------------- /addon/utils/is-object.js: -------------------------------------------------------------------------------- 1 | export default function isObject(val) { 2 | return val !== null && typeof val === 'object'; 3 | } 4 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(/* environment, appConfig */) { 4 | return { }; 5 | }; 6 | -------------------------------------------------------------------------------- /tests/dummy/app/serializers/animal.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | 3 | export default DS.ActiveModelSerializer.extend({ 4 | }); 5 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/fetch-by-id.hbs: -------------------------------------------------------------------------------- 1 |

fetchById

2 |
{{model.id}}
3 |
{{model.name}}
4 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import resolver from './helpers/resolver'; 2 | import { setResolver } from 'ember-mocha'; 3 | 4 | setResolver(resolver); 5 | -------------------------------------------------------------------------------- /addon/utils/is-model-instance.js: -------------------------------------------------------------------------------- 1 | export default function isModelInstance(val) { 2 | return val && val.get && val.get('constructor.typeKey'); 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/reload-record.hbs: -------------------------------------------------------------------------------- 1 |

Reload Record

2 |
{{model.id}}
3 |
{{model.name}}
4 | 5 | -------------------------------------------------------------------------------- /tests/dummy/app/store.js: -------------------------------------------------------------------------------- 1 | import FryctoriaMainStore from 'ember-fryctoria/stores/main-store'; 2 | 3 | export default FryctoriaMainStore.extend({ 4 | }); 5 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/find-by-id-private.hbs: -------------------------------------------------------------------------------- 1 |

findById(Private)

2 |
{{model.id}}
3 |
{{model.name}}
4 | 5 | -------------------------------------------------------------------------------- /blueprints/application-store/files/app/store.js: -------------------------------------------------------------------------------- 1 | import FryctoriaMainStore from 'ember-fryctoria/stores/main-store'; 2 | 3 | export default FryctoriaMainStore.extend({ 4 | }); 5 | 6 | -------------------------------------------------------------------------------- /blueprints/reopen-syncer-initializer/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalizeEntityName: function() {}, 3 | description: 'Generates a initializer to customize syncer.' 4 | }; 5 | 6 | -------------------------------------------------------------------------------- /blueprints/application-store/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | normalizeEntityName: function() {}, 3 | description: 'Generates application store which extends from FryctoriaStore.' 4 | }; 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | tests/ 3 | 4 | .bowerrc 5 | .editorconfig 6 | .ember-cli 7 | .travis.yml 8 | .npmignore 9 | **/.gitkeep 10 | bower.json 11 | Brocfile.js 12 | testem.json 13 | -------------------------------------------------------------------------------- /tests/dummy/app/models/animal.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | 3 | var Animal = DS.Model.extend({ 4 | nickName: DS.attr('string'), 5 | }); 6 | 7 | export default Animal; 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/dummy/app/models/team.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | 3 | var Team = DS.Model.extend({ 4 | name: DS.attr('string'), 5 | users: DS.hasMany('user') 6 | }); 7 | 8 | export default Team; 9 | -------------------------------------------------------------------------------- /addon/model.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | import decorateAPICall from './stores/main-store/decorate-api-call'; 3 | 4 | export default DS.Model.extend({ 5 | save: decorateAPICall('single') 6 | }); 7 | -------------------------------------------------------------------------------- /testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "qunit", 3 | "test_page": "tests/index.html?hidepassed", 4 | "launch_in_ci": [ 5 | "PhantomJS" 6 | ], 7 | "launch_in_dev": [ 8 | "PhantomJS", 9 | "Chrome" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /app/initializers/syncer.js: -------------------------------------------------------------------------------- 1 | import initializer from 'ember-fryctoria/initializers/syncer'; 2 | import { initialize } from 'ember-fryctoria/initializers/syncer'; 3 | 4 | export { initialize as initialize }; 5 | export default initializer; 6 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | // model: function() { 5 | // return this.store.syncer.runAllJobs(); 6 | // } 7 | }); 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/dummy/app/models/job.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | 3 | var Job = DS.Model.extend({ 4 | user: DS.belongsTo('user'), 5 | name: DS.attr('string'), 6 | salary: DS.attr('number') 7 | }); 8 | 9 | export default Job; 10 | 11 | -------------------------------------------------------------------------------- /app/initializers/local-store.js: -------------------------------------------------------------------------------- 1 | import initializer from 'ember-fryctoria/initializers/local-store'; 2 | import { initialize } from 'ember-fryctoria/initializers/local-store'; 3 | 4 | export { initialize as initialize }; 5 | export default initializer; 6 | 7 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/create.hbs: -------------------------------------------------------------------------------- 1 |

create

2 | {{#if model.isSaved}} 3 |
created
4 | {{/if}} 5 | {{input value=model.name id='name'}} 6 | {{input value=model.age id='age'}} 7 | 8 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/delete.hbs: -------------------------------------------------------------------------------- 1 |

delete

2 | {{#if model.isDeleted}} 3 |
deleted
4 | {{/if}} 5 |
{{model.id}}
6 |
{{model.name}}
7 | 8 | -------------------------------------------------------------------------------- /app/initializers/extend-ds-model.js: -------------------------------------------------------------------------------- 1 | import initializer from 'ember-fryctoria/initializers/extend-ds-model'; 2 | import { initialize } from 'ember-fryctoria/initializers/extend-ds-model'; 3 | 4 | export { initialize as initialize }; 5 | export default initializer; 6 | 7 | -------------------------------------------------------------------------------- /tests/dummy/app/models/user.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | 3 | var User = DS.Model.extend({ 4 | name: DS.attr('string'), 5 | age: DS.attr('number'), 6 | isActive: DS.attr('boolean', {defaultValue: false}), 7 | team: DS.belongsTo('team'), 8 | }); 9 | 10 | export default User; 11 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/fetch-by-id.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function(params) { 5 | var firstUserId = this.store.all('user').get('firstObject.id'); 6 | return this.store.fetchById('user', firstUserId); 7 | } 8 | }); 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/reload-record.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | var firstUser = this.store.all('user').get('firstObject'); 6 | return firstUser.reload(); // this makes a call to store#reloadRecord 7 | } 8 | }); 9 | 10 | 11 | -------------------------------------------------------------------------------- /addon/utils/backup.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default function backup(isOffline, backupFn, args) { 4 | return function(error) { 5 | if(isOffline(error)) { 6 | return backupFn.apply(null, args); 7 | } else { 8 | return Ember.RSVP.reject(error); 9 | } 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/fetch-all.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | return this.store.fetchAll('user'); 6 | }, 7 | 8 | actions: { 9 | delete: function(model) { 10 | model.destroyRecord(); 11 | } 12 | } 13 | }); 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/update.hbs: -------------------------------------------------------------------------------- 1 |

update

2 |
3 | {{#if model.isDirty}} 4 | isDirty 5 | {{else}} 6 | isClean 7 | {{/if}} 8 |
9 | {{input value=model.name id='name'}} 10 | {{input value=model.age id='age'}} 11 | 12 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /tests/helpers/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember/resolver'; 2 | import config from '../../config/environment'; 3 | 4 | var resolver = Resolver.create(); 5 | 6 | resolver.namespace = { 7 | modulePrefix: config.modulePrefix, 8 | podModulePrefix: config.podModulePrefix 9 | }; 10 | 11 | export default resolver; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log 17 | testem.log 18 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/update.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | return this.store.all('user').get('firstObject'); 6 | }, 7 | 8 | actions: { 9 | update: function() { 10 | this.get('controller.model').save(); 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | 4 | sudo: false 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | before_install: 11 | - "npm config set spin false" 12 | - "npm install -g npm@^2" 13 | 14 | install: 15 | - npm install -g bower 16 | - npm install 17 | - bower install 18 | 19 | script: 20 | - npm test 21 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/user-update-with-id.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function(params) { 5 | return this.store.getById('user', params.id); 6 | }, 7 | 8 | actions: { 9 | update: function() { 10 | this.get('controller.model').save(); 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/user-update-with-id.hbs: -------------------------------------------------------------------------------- 1 |

user update with id

2 |
3 | {{#if model.isDirty}} 4 | isDirty 5 | {{else}} 6 | isClean 7 | {{/if}} 8 |
9 | {{input value=model.name id='name'}} 10 | {{input value=model.age id='age'}} 11 | 12 | -------------------------------------------------------------------------------- /addon/initializers/local-store.js: -------------------------------------------------------------------------------- 1 | import FryctoriaLocalStore from '../stores/local-store'; 2 | 3 | export function initialize(container, application) { 4 | application.register('store:local', FryctoriaLocalStore); 5 | } 6 | 7 | export default { 8 | name: 'local-store', 9 | before: 'syncer', 10 | initialize: initialize 11 | }; 12 | -------------------------------------------------------------------------------- /addon/initializers/extend-ds-model.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | import FryctoriaModel from '../model'; 3 | 4 | export function initialize(/* container, application */) { 5 | DS.set('Model', FryctoriaModel); 6 | } 7 | 8 | export default { 9 | name: 'extend-ds-model', 10 | before: 'store', 11 | initialize: initialize 12 | }; 13 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/fetch-all.hbs: -------------------------------------------------------------------------------- 1 |

fetchAll

2 | 11 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/delete.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | return this.store.all('user').get('firstObject'); 6 | }, 7 | 8 | actions: { 9 | delete: function() { 10 | this.get('controller.model').destroyRecord(); 11 | } 12 | } 13 | }); 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/find-by-id-private.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | var firstUser = this.store.all('user').get('firstObject'); 6 | // force ember data to talk to remote server 7 | firstUser.unloadRecord(); 8 | return this.store.findById('user', firstUser.get('id')); 9 | } 10 | }); 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/fetch-all-jobs.hbs: -------------------------------------------------------------------------------- 1 |

fetchAll jobs

2 | 11 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/fetch-all-jobs.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | var store = this.store; 6 | return store.fetchAll('user').then(function() { 7 | return store.fetchAll('job'); 8 | }); 9 | }, 10 | 11 | actions: { 12 | delete: function(model) { 13 | model.destroyRecord(); 14 | } 15 | } 16 | }); 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/create-job.hbs: -------------------------------------------------------------------------------- 1 |

job create

2 | {{#if model.isSaved}} 3 |
created
4 | {{/if}} 5 | {{input value=model.name id ='name'}} 6 | {{input value=model.salary id ='salary'}} 7 | {{view "select" id='user' 8 | content=users 9 | value=model.user 10 | optionValuePath="content.user" 11 | optionLabelPath="content.name"}} 12 | 13 | -------------------------------------------------------------------------------- /addon/utils/generate-unique-id.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @return { String } a combination of timestamp and 5 random digits 3 | */ 4 | export default function generateUniqueId(prefix) { 5 | var time, randomFiveDigits; 6 | 7 | prefix = prefix || 'fryctoria'; 8 | time = (new Date()).getTime(); 9 | randomFiveDigits = Math.random().toFixed(5).slice(2); 10 | return [prefix, time, randomFiveDigits].join('-'); 11 | } 12 | 13 | 14 | -------------------------------------------------------------------------------- /addon/initializers/syncer.js: -------------------------------------------------------------------------------- 1 | import Syncer from '../syncer'; 2 | 3 | export function initialize(container, application) { 4 | application.register('syncer:main', Syncer); 5 | application.inject('store:main', 'syncer', 'syncer:main'); 6 | application.inject('model', 'syncer', 'syncer:main'); 7 | } 8 | 9 | export default { 10 | name: 'syncer', 11 | before: 'store', 12 | after: 'extend-ds-model', 13 | initialize: initialize 14 | }; 15 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Resolver from 'ember/resolver'; 3 | import loadInitializers from 'ember/load-initializers'; 4 | import config from './config/environment'; 5 | 6 | Ember.MODEL_FACTORY_INJECTIONS = true; 7 | 8 | var App = Ember.Application.extend({ 9 | modulePrefix: config.modulePrefix, 10 | podModulePrefix: config.podModulePrefix, 11 | Resolver: Resolver 12 | }); 13 | 14 | loadInitializers(App, config.modulePrefix); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-fryctoria", 3 | "dependencies": { 4 | "jquery": "^1.11.1", 5 | "ember": "1.10.0", 6 | "ember-data": "1.0.0-beta.16.1", 7 | "ember-resolver": "~0.1.12", 8 | "loader.js": "ember-cli/loader.js#3.2.0", 9 | "ember-cli-shims": "ember-cli/ember-cli-shims#0.0.3", 10 | "ember-cli-test-loader": "ember-cli-test-loader#0.1.3", 11 | "ember-load-initializers": "ember-cli/ember-load-initializers#0.0.2", 12 | "ember-mocha": "~0.5.1", 13 | "localforage": "~1.2.1", 14 | "jquery-mockjax": "~1.6.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/create.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | var newName = 'user' + ( Math.random() * 1000 ).toFixed(); 6 | return this.store.createRecord('user', {name: newName}); 7 | }, 8 | 9 | actions: { 10 | create: function() { 11 | this.get('controller.model').save(); 12 | }, 13 | willTransition: function() { 14 | var model = this.get('controller.model'); 15 | if(model.get('isNew')) { 16 | model.deleteRecord(); 17 | } else { 18 | model.rollback(); 19 | } 20 | } 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.js] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.hbs] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.css] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | [*.html] 29 | indent_style = space 30 | indent_size = 2 31 | 32 | [*.{diff,md}] 33 | trim_trailing_whitespace = false 34 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "document", 4 | "window", 5 | "-Promise", 6 | "andLater" 7 | ], 8 | "browser": true, 9 | "boss": true, 10 | "curly": true, 11 | "debug": false, 12 | "devel": true, 13 | "eqeqeq": true, 14 | "evil": true, 15 | "forin": false, 16 | "immed": false, 17 | "laxbreak": false, 18 | "newcap": true, 19 | "noarg": true, 20 | "noempty": false, 21 | "nonew": false, 22 | "nomen": false, 23 | "onevar": false, 24 | "plusplus": false, 25 | "regexp": false, 26 | "undef": true, 27 | "sub": true, 28 | "strict": false, 29 | "white": false, 30 | "eqnull": true, 31 | "esnext": true, 32 | "unused": true 33 | } 34 | -------------------------------------------------------------------------------- /tests/dummy/public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy 7 | 8 | 9 | 10 | {{content-for 'head'}} 11 | 12 | 13 | 14 | 15 | {{content-for 'head-footer'}} 16 | 17 | 18 | {{content-for 'body'}} 19 | 20 | 21 | 22 | 23 | {{content-for 'body-footer'}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from './config/environment'; 3 | 4 | var Router = Ember.Router.extend({ 5 | location: config.locationType 6 | }); 7 | 8 | Router.map(function() { 9 | this.route('fetch-all'); 10 | this.route('fetch-by-id'); 11 | this.route('find-by-id-private'); 12 | this.route('reload-record'); 13 | // This may not be supported by an adapter, implement later 14 | // this.route('findQuery'); 15 | this.route('update'); 16 | this.route('user-update-with-id', { path: 'user-update-with-id/:id' }); 17 | this.route('create'); 18 | this.route('delete'); 19 | 20 | // belongsTo relationship 21 | this.route('fetch-all-jobs'); 22 | this.route('create-job'); 23 | }); 24 | 25 | export default Router; 26 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 |

Ember Fryctoria

2 | 3 | 10 | 11 |

User (Single CRUD)

12 |

13 | {{link-to 'Index' 'index'}} 14 | {{link-to 'fetchAll' 'fetch-all'}} 15 | {{link-to 'fetchById' 'fetch-by-id'}} 16 | {{link-to 'findById(Private)' 'find-by-id-private'}} 17 | {{link-to 'reloadRecord' 'reload-record'}} 18 | {{link-to 'update' 'update'}} 19 | {{link-to 'create' 'create'}} 20 | {{link-to 'delete' 'delete'}} 21 |

22 | 23 |

Job (belongsTo User)

24 |

25 | {{link-to 'fetchAll Users & Jobs' 'fetch-all-jobs'}} 26 | {{link-to 'create-job' 'create-job'}} 27 |

28 | 29 | {{outlet}} 30 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/application.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import ENV from 'dummy/config/environment'; 3 | 4 | export default Ember.Route.extend({ 5 | setupController: function(ctrl) { 6 | ctrl.set('isOnline', true); 7 | }, 8 | 9 | actions: { 10 | toggleOnline: function() { 11 | var ctrl = this.get('controller'); 12 | var isOnline = ctrl.get('isOnline'); 13 | 14 | if(isOnline) { 15 | var mockId = Ember.$.mockjax({ 16 | status: 0, 17 | url: /.*/, 18 | responseTime: 0, 19 | }); 20 | ctrl.set('mockId', mockId); 21 | } else { 22 | Ember.$.mockjax.clear(ctrl.get('mockId')); 23 | } 24 | 25 | ctrl.set('isOnline', !isOnline); 26 | } 27 | } 28 | }); 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | // To use it create some files under `mocks/` 2 | // e.g. `server/mocks/ember-hamsters.js` 3 | // 4 | // module.exports = function(app) { 5 | // app.get('/ember-hamsters', function(req, res) { 6 | // res.send('hello'); 7 | // }); 8 | // }; 9 | var bodyParser = require('body-parser'); 10 | 11 | module.exports = function(app) { 12 | var globSync = require('glob').sync; 13 | var mocks = globSync('./mocks/**/*.js', { cwd: __dirname }).map(require); 14 | var proxies = globSync('./proxies/**/*.js', { cwd: __dirname }).map(require); 15 | 16 | // Log proxy requests 17 | var morgan = require('morgan'); 18 | app.use(morgan('dev')); 19 | app.use(bodyParser.json()); 20 | 21 | mocks.forEach(function(route) { route(app); }); 22 | proxies.forEach(function(route) { route(app); }); 23 | 24 | }; 25 | -------------------------------------------------------------------------------- /Brocfile.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | /* global require, module */ 3 | 4 | var EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 5 | 6 | var app = new EmberAddon(); 7 | 8 | // Use `app.import` to add additional libraries to the generated 9 | // output files. 10 | // 11 | // If you need to use different assets in different 12 | // environments, specify an object as the first parameter. That 13 | // object's keys should be the environment name and the values 14 | // should be the asset to use in that environment. 15 | // 16 | // If the library that you are including contains AMD or ES6 17 | // modules that you would like to import into your application 18 | // please specify an object with the list of modules as keys 19 | // along with the exports of each module as its value. 20 | app.import('bower_components/jquery-mockjax/jquery.mockjax.js'); 21 | 22 | module.exports = app.toTree(); 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /tests/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "document", 4 | "window", 5 | "location", 6 | "setTimeout", 7 | "$", 8 | "-Promise", 9 | "define", 10 | "console", 11 | "visit", 12 | "exists", 13 | "fillIn", 14 | "click", 15 | "keyEvent", 16 | "triggerEvent", 17 | "find", 18 | "findWithAssert", 19 | "wait", 20 | "DS", 21 | "andThen", 22 | "andLater", 23 | "currentURL", 24 | "currentPath", 25 | "currentRouteName", 26 | "setOnlineStatus" 27 | ], 28 | "node": false, 29 | "browser": false, 30 | "boss": true, 31 | "curly": false, 32 | "debug": false, 33 | "devel": false, 34 | "eqeqeq": true, 35 | "evil": true, 36 | "forin": false, 37 | "immed": false, 38 | "laxbreak": false, 39 | "newcap": true, 40 | "noarg": true, 41 | "noempty": false, 42 | "nonew": false, 43 | "nomen": false, 44 | "onevar": false, 45 | "plusplus": false, 46 | "regexp": false, 47 | "undef": true, 48 | "sub": true, 49 | "strict": false, 50 | "white": false, 51 | "eqnull": true, 52 | "esnext": true 53 | } 54 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for 'head'}} 11 | {{content-for 'test-head'}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for 'head-footer'}} 18 | {{content-for 'test-head-footer'}} 19 | 20 | 21 | 22 | {{content-for 'body'}} 23 | {{content-for 'test-body'}} 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for 'body-footer'}} 31 | {{content-for 'test-body-footer'}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 yang2007chun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/create-job.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Route.extend({ 4 | model: function() { 5 | var newName = 'job' + ( Math.random() * 1000 ).toFixed(); 6 | var newSalary = ( Math.random() * 1000 ).toFixed(); 7 | var user = this.store.all('user').get('firstObject'); 8 | return this.store.createRecord('job', { 9 | name: newName, 10 | salary: newSalary, 11 | user: user 12 | }); 13 | }, 14 | 15 | setupController: function(controller, model) { 16 | var users = this.store.all('user') 17 | .map(function(user) { 18 | return { 19 | user: user, 20 | name: user.get('name') 21 | }; 22 | }); 23 | controller.set('model', model); 24 | controller.set('users', users); 25 | }, 26 | 27 | actions: { 28 | create: function() { 29 | this.get('controller.model').save(); 30 | }, 31 | willTransition: function() { 32 | var model = this.get('controller.model'); 33 | if(model.get('isNew')) { 34 | model.deleteRecord(); 35 | } else { 36 | model.rollback(); 37 | } 38 | } 39 | } 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /tests/dummy/app/initializers/reopen-syncer.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function initialize(container, application) { 4 | var syncer = container.lookup('syncer:main'); 5 | 6 | /* 7 | * Handle errors when tyring to syncUp. 8 | * 9 | * Default: undefined 10 | * 11 | * The following example remove all jobs and records in localforage 12 | * and restart your app. 13 | */ 14 | // syncer.reopen({ 15 | // handleSyncUpError: function(error) { 16 | // // delete all jobs and all records in localforage. 17 | // this.reset().then(function() { 18 | // // reload page. 19 | // window.location.reload(); 20 | // }); 21 | // } 22 | // }); 23 | 24 | /* 25 | * Decide what is offline. 26 | * 27 | * Default: 28 | * ``` 29 | * isOffline: function(error) { 30 | * return error && error.status === 0; 31 | * } 32 | * ``` 33 | */ 34 | // syncer.reopen({ 35 | // isOffline: function(error) { 36 | // return error && error.status === 0; 37 | // } 38 | // }); 39 | } 40 | 41 | export default { 42 | name: 'reopen-syncer', 43 | after: 'syncer', 44 | initialize: initialize 45 | }; 46 | 47 | -------------------------------------------------------------------------------- /blueprints/reopen-syncer-initializer/files/app/initializers/reopen-syncer.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export function initialize(container, application) { 4 | var syncer = container.lookup('syncer:main'); 5 | 6 | /* 7 | * Handle errors when tyring to syncUp. 8 | * 9 | * Default: undefined 10 | * 11 | * The following example remove all jobs and records in localforage 12 | * and restart your app. 13 | */ 14 | // syncer.reopen({ 15 | // handleSyncUpError: function(error) { 16 | // // delete all jobs and all records in localforage. 17 | // this.reset().then(function() { 18 | // // reload page. 19 | // window.location.reload(); 20 | // }); 21 | // } 22 | // }); 23 | 24 | /* 25 | * Decide what is offline. 26 | * 27 | * Default: 28 | * ``` 29 | * isOffline: function(error) { 30 | * return error && error.status === 0; 31 | * } 32 | * ``` 33 | */ 34 | // syncer.reopen({ 35 | // isOffline: function(error) { 36 | // return error && error.status === 0; 37 | // } 38 | // }); 39 | } 40 | 41 | export default { 42 | name: 'reopen-syncer', 43 | after: 'syncer', 44 | initialize: initialize 45 | }; 46 | -------------------------------------------------------------------------------- /server/mocks/teams.js: -------------------------------------------------------------------------------- 1 | var teams = [ 2 | { id: 1, name: 'Hurricane' }, 3 | { id: 2, name: 'Typhoon' }, 4 | { id: 3, name: 'Tsunami' } 5 | ]; 6 | 7 | module.exports = function(app) { 8 | var express = require('express'); 9 | var teamsRouter = express.Router(); 10 | 11 | teamsRouter.get('/', function(req, res) { 12 | res.send({ 13 | 'teams': teams.filter(Boolean) 14 | }); 15 | }); 16 | 17 | teamsRouter.post('/', function(req, res) { 18 | var team = req.body.team; 19 | team.id = teams.length + 1; 20 | teams.push(team); 21 | 22 | res.status(201); 23 | res.send({ 24 | 'teams': team 25 | }); 26 | }); 27 | 28 | teamsRouter.get('/:id', function(req, res) { 29 | res.send({ 30 | 'teams': teams[+req.params.id - 1] 31 | }); 32 | }); 33 | 34 | teamsRouter.put('/:id', function(req, res) { 35 | var id = req.params.id; 36 | var team = req.body.team; 37 | team.id = id; 38 | teams[+id - 1] = team; 39 | 40 | res.send({ 41 | 'teams': { 42 | id: req.params.id 43 | } 44 | }); 45 | }); 46 | 47 | teamsRouter.delete('/:id', function(req, res) { 48 | delete teams[+req.params.id - 1]; 49 | res.status(204).end(); 50 | }); 51 | 52 | app.use('/teams', teamsRouter); 53 | }; 54 | 55 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 3 | module.exports = function(environment) { 4 | var ENV = { 5 | modulePrefix: 'dummy', 6 | environment: environment, 7 | baseURL: '/', 8 | locationType: 'auto', 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. 'with-controller': true 13 | } 14 | }, 15 | 16 | APP: { 17 | // Here you can pass flags/options to your application instance 18 | // when it is created 19 | } 20 | }; 21 | 22 | if (environment === 'development') { 23 | // ENV.APP.LOG_RESOLVER = true; 24 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 25 | // ENV.APP.LOG_TRANSITIONS = true; 26 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 27 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 28 | } 29 | 30 | if (environment === 'test') { 31 | // Testem prefers this... 32 | ENV.baseURL = '/'; 33 | ENV.locationType = 'none'; 34 | 35 | // keep test console output quieter 36 | ENV.APP.LOG_ACTIVE_GENERATION = false; 37 | ENV.APP.LOG_VIEW_LOOKUPS = false; 38 | 39 | ENV.APP.rootElement = '#ember-testing'; 40 | } 41 | 42 | if (environment === 'production') { 43 | 44 | } 45 | 46 | return ENV; 47 | }; 48 | -------------------------------------------------------------------------------- /server/mocks/jobs.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | var express = require('express'); 3 | var jobsRouter = express.Router(); 4 | var jobs = [ 5 | {id: 1, name: 'Mechanical Engineer', salary: 100}, 6 | {id: 2, name: 'Teacher', salary: 100}, 7 | {id: 3, name: 'Web Developer', salary: 100} 8 | ]; 9 | 10 | jobsRouter.get('/', function(req, res) { 11 | res.send({ 12 | 'jobs': jobs.filter(Boolean) 13 | }); 14 | }); 15 | 16 | jobsRouter.post('/', function(req, res) { 17 | var job = req.body.job; 18 | job.id = jobs.length + 1; 19 | jobs.push(job); 20 | 21 | res.status(201); 22 | res.send({ 23 | 'jobs': job 24 | }); 25 | }); 26 | 27 | jobsRouter.get('/:id', function(req, res) { 28 | res.send({ 29 | 'jobs': jobs[+req.params.id - 1] 30 | }); 31 | }); 32 | 33 | jobsRouter.put('/:id', function(req, res) { 34 | var id = req.params.id; 35 | var job = req.body.job; 36 | job.id = id; 37 | jobs[+id - 1] = job; 38 | 39 | res.send({ 40 | 'jobs': { 41 | id: req.params.id 42 | } 43 | }); 44 | }); 45 | 46 | jobsRouter.delete('/:id', function(req, res) { 47 | delete jobs[+req.params.id - 1]; 48 | res.status(204).end(); 49 | }); 50 | 51 | app.use('/jobs', jobsRouter); 52 | }; 53 | -------------------------------------------------------------------------------- /tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Application from '../../app'; 3 | import Router from '../../router'; 4 | import config from '../../config/environment'; 5 | 6 | // register helper; 7 | 8 | Ember.Test.registerAsyncHelper('andLater', 9 | function(app, callback, timeout) { 10 | Ember.run(function() { 11 | Ember.run.later(function() { 12 | callback(); 13 | }, timeout || 500); 14 | }); 15 | } 16 | ); 17 | 18 | var setOnlineStatusFn = function() { 19 | var mockId; 20 | 21 | return function(app, status) { 22 | if(status) { 23 | Ember.$.mockjax.clear(mockId); 24 | } else { 25 | mockId = Ember.$.mockjax({ 26 | status: 0, 27 | url: /.*/, 28 | responseTime: 0, 29 | }); 30 | } 31 | }; 32 | }(); 33 | 34 | Ember.Test.registerHelper('setOnlineStatus', setOnlineStatusFn); 35 | 36 | export default function startApp(attrs) { 37 | var application; 38 | 39 | var attributes = Ember.merge({}, config.APP); 40 | attributes = Ember.merge(attributes, attrs); // use defaults, but you can override; 41 | 42 | Ember.run(function() { 43 | application = Application.create(attributes); 44 | application.setupForTesting(); 45 | application.injectTestHelpers(); 46 | }); 47 | 48 | return application; 49 | } 50 | -------------------------------------------------------------------------------- /server/mocks/animals.js: -------------------------------------------------------------------------------- 1 | var animals = [ 2 | { id: 1, nick_name: 'Lili' }, 3 | { id: 2, nick_name: 'Xiaohu' }, 4 | { id: 3, nick_name: 'Xiaobao'} 5 | ]; 6 | 7 | module.exports = function(app) { 8 | var express = require('express'); 9 | var animalsRouter = express.Router(); 10 | 11 | animalsRouter.get('/', function(req, res) { 12 | res.send({ 13 | 'animals': animals.filter(Boolean) 14 | }); 15 | }); 16 | 17 | animalsRouter.post('/', function(req, res) { 18 | var animal = req.body.animal; 19 | animal.id = animals.length + 1; 20 | animals.push(animal); 21 | 22 | res.status(201); 23 | res.send({ 24 | 'animals': animal 25 | }); 26 | }); 27 | 28 | animalsRouter.get('/:id', function(req, res) { 29 | res.send({ 30 | 'animals': animals[+req.params.id - 1] 31 | }); 32 | }); 33 | 34 | animalsRouter.put('/:id', function(req, res) { 35 | var id = req.params.id; 36 | var animal = req.body.animal; 37 | animal.id = id; 38 | animals[+id - 1] = animal; 39 | 40 | res.send({ 41 | 'animals': { 42 | id: req.params.id 43 | } 44 | }); 45 | }); 46 | 47 | animalsRouter.delete('/:id', function(req, res) { 48 | delete animals[+req.params.id - 1]; 49 | res.status(204).end(); 50 | }); 51 | 52 | app.use('/animals', animalsRouter); 53 | }; 54 | -------------------------------------------------------------------------------- /server/mocks/users.js: -------------------------------------------------------------------------------- 1 | var users = [ 2 | { id: 1, name: 'Chun', age: 27, isActive: true }, 3 | { id: 2, name: 'John', age: 20, isActive: true }, 4 | { id: 3, name: 'Daniel', age: 18, isActive: false } 5 | ]; 6 | 7 | module.exports = function(app) { 8 | var express = require('express'); 9 | var usersRouter = express.Router(); 10 | 11 | usersRouter.get('/', function(req, res) { 12 | res.send({ 13 | 'users': users.filter(Boolean) 14 | }); 15 | }); 16 | 17 | usersRouter.post('/', function(req, res) { 18 | var user = req.body.user; 19 | user.id = users.length + 1; 20 | users.push(user); 21 | 22 | res.status(201); 23 | res.send({ 24 | 'users': user 25 | }); 26 | }); 27 | 28 | usersRouter.get('/:id', function(req, res) { 29 | res.send({ 30 | 'users': users[+req.params.id - 1] 31 | }); 32 | }); 33 | 34 | usersRouter.put('/:id', function(req, res) { 35 | var id = req.params.id; 36 | var user = req.body.user; 37 | user.id = id; 38 | users[+id - 1] = user; 39 | 40 | res.send({ 41 | 'users': { 42 | id: req.params.id 43 | } 44 | }); 45 | }); 46 | 47 | usersRouter.delete('/:id', function(req, res) { 48 | delete users[+req.params.id - 1]; 49 | res.status(204).end(); 50 | }); 51 | 52 | app.use('/users', usersRouter); 53 | }; 54 | -------------------------------------------------------------------------------- /tests/acceptance/user-reload-record-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: User Reload Record', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var name = 'user-reload-id-1'; 27 | var userPromise; 28 | 29 | Ember.run(function() { 30 | userPromise = store.createRecord('user', {name: name}); 31 | }); 32 | 33 | userPromise.save().then(function(user) { 34 | // #offline 35 | setOnlineStatus(false); 36 | return user.reload(); 37 | 38 | }).then(function(user) { 39 | expect(user.get('name')).to.equal(name); 40 | setOnlineStatus(true); 41 | return user.destroyRecord(); 42 | 43 | }).then(function() { 44 | // NOTE: wait until saveLocal is finished, 45 | // otherwise we would get strange error when destroying the app. 46 | andLater(function() { 47 | done(); 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/acceptance/user-fetch-by-id-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: User Fetch By Id', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var name = 'user-fetch-by-id-1'; 27 | var userPromise; 28 | 29 | Ember.run(function() { 30 | userPromise = store.createRecord('user', {name: name}); 31 | }); 32 | 33 | userPromise.save().then(function(userCreated) { 34 | // #offline 35 | setOnlineStatus(false); 36 | return store.fetchById('user', userCreated.get('id')); 37 | 38 | }).then(function(userFetched) { 39 | expect(userFetched.get('name')).to.equal(name); 40 | setOnlineStatus(true); 41 | return userFetched.destroyRecord(); 42 | 43 | }).then(function() { 44 | // NOTE: wait until saveLocal is finished, 45 | // otherwise we would get strange error when destroying the app. 46 | andLater(function() { 47 | done(); 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /addon/stores/local-store.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | import LFAdapter from 'ember-localforage-adapter/adapters/localforage'; 3 | import LFSerializer from 'ember-localforage-adapter/serializers/localforage'; 4 | import generateUniqueId from '../utils/generate-unique-id'; 5 | 6 | /** 7 | * 8 | * @class FryctoriaLocalStore 9 | * @extends DS.Store 10 | */ 11 | export default DS.Store.extend({ 12 | init: function() { 13 | var container = this.get('container'); 14 | 15 | var serializer = LFSerializer.create({ container: container }); 16 | var adapter = LFAdapter 17 | .extend({generateIdForRecord: generateUniqueId}) 18 | .create({ 19 | container: container, 20 | serializer: serializer, 21 | clear: clearLFAdapter 22 | }); 23 | 24 | this.set('adapter', adapter); 25 | 26 | this._super.apply(this, arguments); 27 | }, 28 | 29 | /** 30 | * Serializer is fetched via this method or adapter.serializer 31 | * 32 | * @method serializerFor 33 | * @public 34 | */ 35 | serializerFor: function() { 36 | return this.get('adapter.serializer'); 37 | }, 38 | 39 | adapterFor: function() { 40 | return this.get('adapter'); 41 | }, 42 | }); 43 | 44 | function clearLFAdapter() { 45 | // clear cache 46 | var cache = this.get('cache'); 47 | if(cache) { 48 | cache.clear(); 49 | } 50 | 51 | // clear data in localforage 52 | return window.localforage.setItem('DS.LFAdapter', []); 53 | } 54 | -------------------------------------------------------------------------------- /tests/acceptance/user-find-by-id-private-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: User Find By Id(Private)', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var name = 'user-find-by-id-1'; 27 | var userPromise; 28 | 29 | Ember.run(function() { 30 | userPromise = store.createRecord('user', {name: name}).save(); 31 | }); 32 | 33 | userPromise.then(function(userCreated) { 34 | // #offline 35 | setOnlineStatus(false); 36 | userCreated.unloadRecord(); 37 | return store.findById('user', userCreated.get('id')); 38 | 39 | }).then(function(userFetched) { 40 | expect(userFetched.get('name')).to.equal(name); 41 | setOnlineStatus(true); 42 | return userFetched.destroyRecord(); 43 | 44 | }).then(function() { 45 | // NOTE: wait until saveLocal is finished, 46 | // otherwise we would get strange error when destroying the app. 47 | andLater(function() { 48 | done(); 49 | }); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/acceptance/syncer-reset-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | var RSVP = Ember.RSVP; 14 | 15 | describe('Acceptance: Syncer Reset', function() { 16 | beforeEach(function() { 17 | App = startApp(); 18 | store = App.__container__.lookup('store:main'); 19 | }); 20 | 21 | afterEach(function() { 22 | setOnlineStatus(true); 23 | Ember.run(App, 'destroy'); 24 | }); 25 | 26 | it('works when offline', function(done) { 27 | var name = 'syncer-reset-user-id-1'; 28 | var usersPromise; 29 | 30 | // #offline 31 | setOnlineStatus(false); 32 | Ember.run(function() { 33 | usersPromise = [ 34 | store.createRecord('user', {name: name}).save(), 35 | ]; 36 | }); 37 | 38 | RSVP.all(usersPromise).then(function(users) { 39 | return store.fetchAll('user'); 40 | 41 | }).then(function(users) { 42 | var user = users.findBy('name', name); 43 | expect(user).to.exist(); 44 | 45 | store.unloadAll('user'); 46 | return store.get('syncer').reset(); 47 | 48 | }).then(function() { 49 | return store.fetchAll('user'); 50 | 51 | }).then(function(users) { 52 | expect(users.get('length')).to.equal(0); 53 | expect(store.get('syncer.jobs.length')).to.equal(0); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | 59 | -------------------------------------------------------------------------------- /tests/acceptance/user-create-delete-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: User Create and Delete', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var name = 'user-create-delete-1'; 27 | var userPromise; 28 | 29 | // #offline 30 | setOnlineStatus(false); 31 | 32 | Ember.run(function() { 33 | userPromise = store.createRecord('user', {name: name}); 34 | }); 35 | 36 | userPromise.save().then(function(user) { 37 | return user.destroyRecord(); 38 | 39 | }).then(function() { 40 | // #online 41 | setOnlineStatus(true); 42 | return store.find('user'); 43 | 44 | }).then(function(users) { 45 | // assertions 46 | var userDeleted = users.findBy('name', name); 47 | expect(userDeleted).not.to.exist(); 48 | 49 | }).then(function() { 50 | // NOTE: wait until saveLocal is finished, 51 | // otherwise we would get strange error when destroying the app. 52 | andLater(function() { 53 | done(); 54 | }); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-fryctoria", 3 | "version": "1.0.0-alpha.2", 4 | "description": "Make ember data work offline with automatic sync.", 5 | "directories": { 6 | "doc": "doc", 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "start": "ember server", 11 | "build": "ember build", 12 | "test": "ember test" 13 | }, 14 | "repository": "https://github.com/poetic/ember-fryctoria", 15 | "engines": { 16 | "node": ">= 0.10.0" 17 | }, 18 | "author": "Chun Yang", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "body-parser": "^1.12.0", 22 | "broccoli-asset-rev": "^2.0.0", 23 | "connect-restreamer": "^1.0.1", 24 | "ember-cli": "0.2.0", 25 | "ember-cli-app-version": "0.3.2", 26 | "ember-cli-babel": "^4.0.0", 27 | "ember-cli-content-security-policy": "0.3.0", 28 | "ember-cli-dependency-checker": "0.0.8", 29 | "ember-cli-htmlbars": "0.7.4", 30 | "ember-cli-ic-ajax": "0.1.1", 31 | "ember-cli-inject-live-reload": "^1.3.0", 32 | "ember-cli-mocha": "^0.5.0", 33 | "ember-cli-uglify": "1.0.1", 34 | "ember-data": "1.0.0-beta.16.1", 35 | "ember-export-application-global": "^1.0.2", 36 | "ember-localforage-adapter": "0.6.9", 37 | "express": "^4.8.5", 38 | "glob": "^4.0.5", 39 | "morgan": "^1.5.2" 40 | }, 41 | "keywords": [ 42 | "ember-addon", 43 | "sync", 44 | "offline", 45 | "ember-data", 46 | "ember-data store" 47 | ], 48 | "ember-addon": { 49 | "configPath": "tests/dummy/config", 50 | "defaultBlueprint": "application-store" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /addon/stores/main-store/decorate-api-call.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | /* 4 | * syncUp before finder 5 | * syncDown after finder 6 | * @param {String} finderType {'single'|'multiple'|'all'} 7 | */ 8 | export default function decorateAPICall(finderType) { 9 | return function apiCall() { 10 | var store = this; 11 | var args = arguments; 12 | var syncer = store.get('syncer'); 13 | var _superFinder = store.__nextSuper; 14 | 15 | if(args.length > 0) { 16 | var options = args[args.length - 1]; 17 | var bypass = (typeof options === 'object') && options.bypass; 18 | if(bypass) { 19 | return _superFinder.apply(store, args); 20 | } 21 | } 22 | 23 | return syncUp() 24 | .then(function() { return _superFinder.apply(store, args); }) 25 | .then(syncDown); 26 | 27 | function syncUp() { 28 | return syncer.syncUp().catch(function(error) { 29 | Ember.Logger.warn('Syncing Error:'); 30 | Ember.Logger.error(error && error.stack); 31 | }); 32 | } 33 | 34 | function syncDown(result) { 35 | if(finderType === 'all') { 36 | var typeName = result.get('type.typeKey'); 37 | syncer.syncDown(typeName); 38 | 39 | } else if(finderType === 'single'){ 40 | syncer.syncDown(result); 41 | 42 | } else if(finderType === 'multiple'){ 43 | syncer.syncDown(result); 44 | 45 | } else { 46 | throw new Error('finderType must be one of single, multiple or all, but got ' + finderType); 47 | } 48 | 49 | return result; 50 | } 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /addon/stores/main-store/decorate-serializer.js: -------------------------------------------------------------------------------- 1 | import isObject from '../../utils/is-object'; 2 | 3 | /* 4 | * Extend serializer so that we can use local serializer when local adater is 5 | * userd. 6 | */ 7 | export default function decorateSerializer(serializer, container) { 8 | if(serializer.get('fryctoria')) { 9 | return serializer; 10 | } 11 | 12 | serializer.set('fryctoria', true); 13 | 14 | var localSerializer = container.lookup('store:local').get('adapter.serializer'); 15 | 16 | // serialize() 17 | // extract() 18 | // normalize() is not used in localforage adapter, so we do not decorate 19 | decorateSerializerMethod(serializer, localSerializer, 'serialize', 0); 20 | decorateSerializerMethod(serializer, localSerializer, 'extract', 2); 21 | // decorateSerializerMethod(serializer, localSerializer, 'normalize', 2); 22 | 23 | return serializer; 24 | } 25 | 26 | function decorateSerializerMethod(serializer, localSerializer, methodName, wrappedArgIndex) { 27 | var originMethod = serializer[methodName]; 28 | var backupMethod = function() { 29 | // remove fryctoria from arg 30 | var args = Array.prototype.slice.call(arguments); 31 | delete args[wrappedArgIndex].fryctoria; 32 | 33 | return localSerializer[methodName].apply(localSerializer, args); 34 | }; 35 | 36 | serializer[methodName] = function() { 37 | var payload = arguments[wrappedArgIndex]; 38 | 39 | if(isObject(payload) && payload.fryctoria) { 40 | return backupMethod.apply(localSerializer, arguments); 41 | } else { 42 | return originMethod.apply(serializer, arguments); 43 | } 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /tests/acceptance/user-create-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: User Create ', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var name = 'user-create-1'; 27 | var age = 98; 28 | var userPromise; 29 | 30 | // #offline 31 | setOnlineStatus(false); 32 | 33 | Ember.run(function() { 34 | userPromise = store.createRecord('user', {name: name, age: age}); 35 | }); 36 | 37 | userPromise.save().then(function() { 38 | // #online 39 | setOnlineStatus(true); 40 | return store.find('user'); 41 | }).then(function(users) { 42 | var userCreated = users.findBy('name', name); 43 | expect(userCreated).to.exist('Record should be created and returned from server'); 44 | expect(userCreated.get('id')).not.to.include('fryctoria', 'Record should have a remoteId instead of a generated one'); 45 | return userCreated; 46 | }).then(function(userCreated) { 47 | // cleanup 48 | return userCreated.destroyRecord(); 49 | 50 | }).then(function() { 51 | // NOTE: wait until saveLocal is finished, 52 | // otherwise we would get strange error when destroying the app. 53 | andLater(function() { 54 | done(); 55 | }); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/acceptance/animal-create-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: Animal Create ', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var nickName = 'animal-create-1'; 27 | var animalPromise; 28 | 29 | // #offline 30 | setOnlineStatus(false); 31 | 32 | Ember.run(function() { 33 | animalPromise = store.createRecord('animal', {nickName: nickName}); 34 | }); 35 | 36 | animalPromise.save().then(function() { 37 | // #online 38 | setOnlineStatus(true); 39 | return store.find('animal'); 40 | 41 | }).then(function(animals) { 42 | var animalCreated = animals.findBy('nickName', nickName); 43 | expect(animalCreated).to.exist('Record should be created and returned from server'); 44 | expect(animalCreated.get('id')).not.to.include('fryctoria', 'Record should have a remoteId instead of a generated one'); 45 | return animalCreated; 46 | 47 | }).then(function(animalCreated) { 48 | // cleanup 49 | return animalCreated.destroyRecord(); 50 | 51 | }).then(function() { 52 | // NOTE: wait until saveLocal is finished, 53 | // otherwise we would get strange error when destroying the app. 54 | andLater(function() { 55 | done(); 56 | }); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/acceptance/user-fetch-all-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | var RSVP = Ember.RSVP; 14 | 15 | describe('Acceptance: User Fetch All', function() { 16 | beforeEach(function() { 17 | App = startApp(); 18 | store = App.__container__.lookup('store:main'); 19 | }); 20 | 21 | afterEach(function() { 22 | setOnlineStatus(true); 23 | Ember.run(App, 'destroy'); 24 | }); 25 | 26 | it('works when offline', function(done) { 27 | var name1 = 'user-fetch-all-id-1'; 28 | var name2 = 'user-fetch-all-id-2'; 29 | var usersPromise; 30 | 31 | Ember.run(function() { 32 | usersPromise = [ 33 | store.createRecord('user', {name: name1}).save(), 34 | store.createRecord('user', {name: name2}).save() 35 | ]; 36 | }); 37 | 38 | RSVP.all(usersPromise).then(function(users) { 39 | // #offline 40 | setOnlineStatus(false); 41 | return store.fetchAll('user'); 42 | 43 | }).then(function(users) { 44 | var user1 = users.findBy('name', name1); 45 | var user2 = users.findBy('name', name2); 46 | expect(user1.get('name')).to.equal(name1); 47 | expect(user2.get('name')).to.equal(name2); 48 | 49 | setOnlineStatus(true); 50 | return RSVP.all([ 51 | user1.destroyRecord(), 52 | user2.destroyRecord(), 53 | ]); 54 | 55 | }).then(function() { 56 | // NOTE: wait until saveLocal is finished, 57 | // otherwise we would get strange error when destroying the app. 58 | andLater(function() { 59 | done(); 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /addon/utils/reload-local-records.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | var RSVP = Ember.RSVP; 4 | 5 | /* 6 | * This method does not change store, only change localforage 7 | * 8 | * @method reloadLocalRecords 9 | * @param {DS.Store} store store:main 10 | * @param {String|DS.Model} type 11 | */ 12 | export default function reloadLocalRecords(container, type) { 13 | var store = container.lookup('store:main'); 14 | var modelType = store.modelFor(type); 15 | 16 | var localStore = container.lookup('store:local'); 17 | var localAdapter = localStore.get('adapter'); 18 | 19 | var reloadedRecords = localAdapter.findAll(localStore, modelType) 20 | .then(deleteAll) 21 | .then(createAll); 22 | 23 | return reloadedRecords; 24 | 25 | function deleteAll(localRecords) { 26 | var deletedRecords = localRecords.map(function(record) { 27 | return localAdapter.deleteRecord(localStore, modelType, {id: record.id}); 28 | }); 29 | 30 | return RSVP.all(deletedRecords); 31 | } 32 | 33 | function createAll() { 34 | var records = store.all(type); 35 | var createdRecords = records.map(function(record) { 36 | return createLocalRecord(localAdapter, localStore, modelType, record); 37 | }); 38 | 39 | return RSVP.all(createdRecords); 40 | } 41 | } 42 | 43 | function createLocalRecord(localAdapter, localStore, modelType, record) { 44 | if(record.get('id')) { 45 | var snapshot = record._createSnapshot(); 46 | return localAdapter.createRecord(localStore, modelType, snapshot); 47 | 48 | } else { 49 | var recordName = record.constructor && record.constructor.typeKey; 50 | var warnMessage = 'Record ' + recordName + ' does not have an id, therefor we can not create it locally: '; 51 | 52 | var recordData = record.toJSON && record.toJSON(); 53 | 54 | Ember.Logger.warn(warnMessage, recordData); 55 | 56 | return RSVP.resolve(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/acceptance/user-delete-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | var RSVP = Ember.RSVP; 14 | 15 | describe('Acceptance: User Delete', function() { 16 | beforeEach(function() { 17 | App = startApp(); 18 | store = App.__container__.lookup('store:main'); 19 | }); 20 | 21 | afterEach(function() { 22 | setOnlineStatus(true); 23 | Ember.run(App, 'destroy'); 24 | }); 25 | 26 | it('works when offline', function(done) { 27 | this.timeout(10000); 28 | 29 | var name = 'user-delete-1'; 30 | var age = 98; 31 | var userPromise; 32 | 33 | // NOTE: delete all user with the same name, make tests more robust 34 | store.find('user').then(function(users) { 35 | return RSVP.all(users.map(function(user) { 36 | if(user.get('name') === name) { 37 | return user.destroyRecord(); 38 | } 39 | })); 40 | 41 | }).then(function() { 42 | Ember.run(function() { 43 | userPromise = store.createRecord('user', {name: name, age: age}); 44 | }); 45 | return userPromise.save(); 46 | 47 | }).then(function(user) { 48 | // #offline delete 49 | setOnlineStatus(false); 50 | return user.destroyRecord(); 51 | 52 | }).then(function() { 53 | // #online 54 | setOnlineStatus(true); 55 | return store.find('user'); 56 | 57 | }).then(function(users) { 58 | // assertions 59 | var userDeleted = users.findBy('name', name); 60 | expect(userDeleted).not.to.exist(); 61 | 62 | }).then(function() { 63 | // NOTE: wait until reloadLocalRecords is finished, 64 | // otherwise we would get strange error when destroying the app. 65 | andLater(function() { 66 | done(); 67 | }, 1000); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/acceptance/user-update-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: User Update', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var name = 'user-create-1'; 27 | var nameUpdated = 'user-create-2'; 28 | var age = 98; 29 | var ageUpdated = 102; 30 | var userPromise; 31 | 32 | Ember.run(function() { 33 | userPromise = store.createRecord('user', {name: name, age: age}); 34 | }); 35 | 36 | userPromise.save().then(function(user) { 37 | // #offline update 38 | setOnlineStatus(false); 39 | return user.setProperties({name: nameUpdated, age: ageUpdated}).save(); 40 | 41 | }).then(function() { 42 | // #online 43 | setOnlineStatus(true); 44 | return store.find('user'); 45 | 46 | }).then(function(users) { 47 | // assertions 48 | var userUpdated = users.findBy('name', nameUpdated); 49 | expect(userUpdated).to.exist('Record should be created and returned from server'); 50 | expect(userUpdated.get('id')).not.to.include('fryctoria', 'Record should have a remoteId instead of a generated one'); 51 | expect(userUpdated.get('name')).to.equal(nameUpdated); 52 | expect(userUpdated.get('age')).to.equal(ageUpdated); 53 | return userUpdated; 54 | 55 | }).then(function(userUpdated) { 56 | // cleanup 57 | return userUpdated.destroyRecord(); 58 | 59 | }).then(function() { 60 | // NOTE: wait until saveLocal is finished, 61 | // otherwise we would get strange error when destroying the app. 62 | andLater(function() { 63 | done(); 64 | }); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/acceptance/user-create-update-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | 14 | describe('Acceptance: User Create and Update', function() { 15 | beforeEach(function() { 16 | App = startApp(); 17 | store = App.__container__.lookup('store:main'); 18 | }); 19 | 20 | afterEach(function() { 21 | setOnlineStatus(true); 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | var name = 'user-create-update-1'; 27 | var nameUpdated = 'user-create-update-2'; 28 | var age = 98; 29 | var ageUpdated = 102; 30 | var userPromise; 31 | 32 | Ember.run(function() { 33 | // #offline 34 | setOnlineStatus(false); 35 | userPromise = store.createRecord('user', {name: name, age: age}); 36 | }); 37 | 38 | userPromise.save().then(function(user) { 39 | return user.setProperties({name: nameUpdated, age: ageUpdated}).save(); 40 | 41 | }).then(function() { 42 | // #online 43 | setOnlineStatus(true); 44 | return store.find('user'); 45 | 46 | }).then(function(users) { 47 | // assertions 48 | var userUpdated = users.findBy('name', nameUpdated); 49 | expect(userUpdated).to.exist('Record should be created and returned from server'); 50 | expect(userUpdated.get('id')).not.to.include('fryctoria', 'Record should have a remoteId instead of a generated one'); 51 | expect(userUpdated.get('name')).to.equal(nameUpdated); 52 | expect(userUpdated.get('age')).to.equal(ageUpdated); 53 | return userUpdated; 54 | }).then(function(userUpdated) { 55 | // cleanup 56 | return userUpdated.destroyRecord(); 57 | 58 | }).then(function() { 59 | // NOTE: wait until saveLocal is finished, 60 | // otherwise we would get strange error when destroying the app. 61 | andLater(function() { 62 | done(); 63 | }); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /addon/stores/main-store.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | import decorateAdapter from './main-store/decorate-adapter'; 3 | import decorateSerializer from './main-store/decorate-serializer'; 4 | import decorateAPICall from './main-store/decorate-api-call'; 5 | 6 | /** 7 | * This will be used as store:main 8 | * 9 | * @class FryctoriaMainStore 10 | * @extends DS.Store 11 | */ 12 | export default DS.Store.extend({ 13 | /** 14 | This method returns a fresh collection from the server, regardless of if there is already records 15 | in the store or not. 16 | @method fetchAll 17 | @param {String or subclass of DS.Model} type 18 | @return {Promise} promise 19 | */ 20 | fetchAll: decorateAPICall('all'), 21 | 22 | /* 23 | * fetchById use the following methods: 24 | * find -> findById 25 | * model#reload -> store#reloadRecord 26 | * NOTE: this will trigger syncUp twice, this is OK. And since this is 27 | * a public method, we probably want to preserve this. 28 | */ 29 | fetchById: decorateAPICall('single'), 30 | 31 | /** 32 | This method returns a record for a given type and id combination. 33 | It is Used by: 34 | #find(<- #fetchById) 35 | #findByIds(private, orphan) 36 | @method findById 37 | @private 38 | @param {String or subclass of DS.Model} type 39 | @param {String|Integer} id 40 | @param {Object} preload - optional set of attributes and relationships passed in either as IDs or as actual models 41 | @return {Promise} promise 42 | */ 43 | findById: decorateAPICall('single'), 44 | 45 | /** 46 | * Used by: 47 | * #find 48 | * model#reload 49 | */ 50 | 51 | /** 52 | This method is called by the record's `reload` method. 53 | This method calls the adapter's `find` method, which returns a promise. When 54 | **that** promise resolves, `reloadRecord` will resolve the promise returned 55 | by the record's `reload`. 56 | @method reloadRecord 57 | @private 58 | @param {DS.Model} record 59 | @return {Promise} promise 60 | */ 61 | reloadRecord: decorateAPICall('single'), 62 | 63 | adapterFor: function(type) { 64 | var adapter = this._super(type); 65 | return decorateAdapter(adapter, this.container); 66 | }, 67 | 68 | serializerFor: function(type) { 69 | var serializer = this._super(type); 70 | return decorateSerializer(serializer, this.container); 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /tests/acceptance/team-create-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | var RSVP = Ember.RSVP; 14 | 15 | describe('Acceptance: Create A Team(hasMany users)', function() { 16 | beforeEach(function() { 17 | App = startApp(); 18 | store = App.__container__.lookup('store:main'); 19 | }); 20 | 21 | afterEach(function() { 22 | Ember.run(App, 'destroy'); 23 | }); 24 | 25 | it('works when offline', function(done) { 26 | this.timeout(10000); 27 | 28 | var users, userRecords; 29 | 30 | Ember.run(function() { 31 | users = [ 32 | store.createRecord('user', { name: 'create-team-user-1' }).save(), 33 | store.createRecord('user', { name: 'create-team-user-2' }).save() 34 | ]; 35 | }); 36 | 37 | var teamName = 'create-team-team-1'; 38 | 39 | // #online 40 | RSVP.all(users).then(function(users) { 41 | userRecords = users; 42 | 43 | // #offline 44 | setOnlineStatus(false); 45 | // create a team with hasMany relationship while offline: 46 | var team = store.createRecord('team', { name: teamName }); 47 | team.get('users').pushObjects(users); 48 | 49 | return team.save(); 50 | }).then(function(team) { 51 | // #online 52 | setOnlineStatus(true); 53 | // after online, we do the syncing by geting all teams: 54 | return store.find('team'); 55 | }).then(function(teams) { 56 | var team = teams.findBy('name', teamName); 57 | expect(team).to.exist('Team should be created and returned from server'); 58 | expect(team.get('id')).not.to.include('fryctoria', 'Team should have a remoteId instead of a generated one'); 59 | return team; 60 | }).then(function(team) { 61 | // clean up, remove teams and users created 62 | var deletedUsers = userRecords.map(function(user) { 63 | return user.destroyRecord(); 64 | }); 65 | return RSVP.all(deletedUsers).then(function() { 66 | return team.destroyRecord(); 67 | }); 68 | }).then(function() { 69 | // NOTE: wait until reloadLocalRecords is finished, 70 | // otherwise we would get strange error when destroying the app. 71 | andLater(function() { 72 | done(); 73 | }, 1000); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/acceptance/job-create-test.js: -------------------------------------------------------------------------------- 1 | /* jshint expr:true */ 2 | import { 3 | describe, 4 | it, 5 | beforeEach, 6 | afterEach 7 | } from 'mocha'; 8 | import { expect } from 'chai'; 9 | import Ember from 'ember'; 10 | import startApp from '../helpers/start-app'; 11 | 12 | var App, store; 13 | var RSVP = Ember.RSVP; 14 | 15 | describe('Acceptance: Job(belongsTo) Create', function() { 16 | beforeEach(function() { 17 | App = startApp(); 18 | store = App.__container__.lookup('store:main'); 19 | }); 20 | 21 | afterEach(function() { 22 | setOnlineStatus(true); 23 | Ember.run(App, 'destroy'); 24 | }); 25 | 26 | it('works when offline', function(done) { 27 | this.timeout(10000); 28 | 29 | var userName = 'job-create-user-1'; 30 | var jobName = 'job-create-1'; 31 | var userPromise; 32 | var jobPromise; 33 | 34 | // #offline 35 | setOnlineStatus(false); 36 | 37 | Ember.run(function() { 38 | userPromise = store.createRecord('user', {name: userName}); 39 | }); 40 | 41 | userPromise.save().then(function(user) { 42 | jobPromise = store.createRecord('job', {name: jobName, user: user}).save(); 43 | return jobPromise; 44 | 45 | }).then(function(job) { 46 | setOnlineStatus(true); 47 | return store.find('job'); 48 | 49 | }).then(function(jobs) { 50 | var jobCreated = jobs.findBy('name', jobName); 51 | expect(jobCreated).to.exist('Record should be created and returned from server'); 52 | expect(jobCreated.get('id')).not.to.include('fryctoria', 'Record should have a remoteId instead of a generated one'); 53 | expect(jobCreated.get('user.id')).to.exist(); 54 | expect(jobCreated.get('user.id')).not.to.include('fryctoria', 'Record should have a remoteId instead of a generated one'); 55 | return jobCreated; 56 | }).then(function(job) { 57 | var user = job.get('user'); 58 | return RSVP.all([job.destroyRecord(), user.destroyRecord()]); 59 | 60 | }).then(function() { 61 | // wait untill job and user are deleted locally 62 | andLater(function() { 63 | setOnlineStatus(false); 64 | return RSVP.all([store.find('user'), store.find('job')]).then(function(data) { 65 | var user = data[0].findBy('name', userName); 66 | var job = data[1].findBy('name', jobName); 67 | expect(user).not.to.exist(); 68 | expect(job).not.to.exist(); 69 | }); 70 | }, 1000); 71 | 72 | }).then(function() { 73 | // NOTE: wait until saveLocal is finished, 74 | // otherwise we would get strange error when destroying the app. 75 | andLater(function() { 76 | done(); 77 | }, 1000); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /addon/stores/main-store/decorate-adapter.js: -------------------------------------------------------------------------------- 1 | import backup from '../../utils/backup'; 2 | import isObject from '../../utils/is-object'; 3 | import generateUniqueId from '../../utils/generate-unique-id'; 4 | 5 | /* 6 | * Extend adapter so that we can use local adapter when offline 7 | */ 8 | export default function decorateAdapter(adapter, container) { 9 | if(adapter.get('fryctoria')) { 10 | return adapter; 11 | } 12 | 13 | adapter.set('fryctoria', {}); 14 | 15 | var localAdapter = container.lookup('store:local').get('adapter'); 16 | 17 | // find() 18 | // findAll() 19 | // findQuery() 20 | // findMany() 21 | // createRecord() 22 | // updateRecord() 23 | // deleteRecord() 24 | var methods = [ 25 | 'find', 'findAll', 'findQuery', 'findMany', 26 | 'createRecord', 'updateRecord', 'deleteRecord' 27 | ]; 28 | 29 | methods.forEach(function(methodName) { 30 | decorateAdapterMethod(adapter, localAdapter, methodName); 31 | }); 32 | 33 | return adapter; 34 | } 35 | 36 | function decorateAdapterMethod(adapter, localAdapter, methodName) { 37 | var originMethod = adapter[methodName]; 38 | var backupMethod = createBackupMethod(localAdapter, methodName); 39 | var isOffline = adapter.container.lookup('syncer:main').isOffline; 40 | 41 | adapter[methodName] = function() { 42 | return originMethod.apply(adapter, arguments) 43 | .catch(backup(isOffline, backupMethod, arguments)); 44 | }; 45 | 46 | adapter.fryctoria[methodName] = originMethod; 47 | } 48 | 49 | function createBackupMethod(localAdapter, methodName) { 50 | var container = localAdapter.container; 51 | var crudMethods = ['createRecord', 'updateRecord', 'deleteRecord']; 52 | var isCRUD = crudMethods.indexOf(methodName) !== -1; 53 | var isCreate = methodName === 'createRecord'; 54 | 55 | return function backupMethod() { 56 | var args = Array.prototype.slice.call(arguments); 57 | 58 | // ---------- CRUD specific 59 | if(isCRUD) { 60 | var snapshot = args[2]; 61 | 62 | if(isCreate) { 63 | snapshot = addIdToSnapshot(snapshot); 64 | } 65 | 66 | createJobInSyncer(container, methodName, snapshot); 67 | 68 | // decorate snapshot for serializer#serialize, this should be after 69 | // createJob in syncer 70 | snapshot.fryctoria = true; 71 | args[2] = snapshot; 72 | } 73 | // ---------- CRUD specific END 74 | 75 | return localAdapter[methodName].apply(localAdapter, args) 76 | .then(function(payload) { 77 | // decorate payload for serializer#extract 78 | if(isObject(payload)) { 79 | payload.fryctoria = true; 80 | } 81 | return payload; 82 | }); 83 | }; 84 | } 85 | 86 | // Add an id to record before create in local 87 | function addIdToSnapshot(snapshot) { 88 | var record = snapshot.record; 89 | record.get('store').updateId(record, {id: generateUniqueId()}); 90 | return record._createSnapshot(); 91 | } 92 | 93 | function createJobInSyncer(container, methodName, snapshot) { 94 | var syncer = container.lookup('syncer:main'); 95 | syncer.createJob(methodName, snapshot); 96 | } 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Warning 2 | Now the addon is in alpha, please help by reporting bugs and opening issues. 3 | 4 | # Instruction 5 | ### Step1: Install the addons, generate an initializer 6 | ```bash 7 | ember install ember-localforage-adapter 8 | ember install ember-fryctoria 9 | ember g reopen-syncer-initializer 10 | ``` 11 | ### Step2: Offline! 12 | 13 | # Requirements 14 | ember-data v1.0.0-beta.16.1 15 | 16 | # How does it work? 17 | ember-fryctoria utilizes [ember-localforage-adapter](https://github.com/genkgo/ember-localforage-adapter/) to read and write locally. 18 | 19 | It always try to connect to the server and fall back to local data when an offline error is caught. 20 | 21 | When online, it will use your defalut store, adapter and serializer. After each request to server, it will automatically save records into localforage-adapter. 22 | 23 | When offline, it will use the local backup(localforage) to retrive records. A queue of jobs is created when the user create, update or delete while offline. When online we flush this queue to keep the server in sync. 24 | 25 | **Features NOT supported(yet):** 26 | - Sideloaded records are not saved to localforage automatically, only the main 27 | records are saved. [Here](https://github.com/poetic/ember-fryctoria/issues/2) is a work around. 28 | - Changes in embeded records will not be pushed to server if you create or update offline 29 | and try to sync when online. Only the main record will be updated or created. 30 | - Customized transforms are not supported, see work around [here](https://github.com/poetic/ember-fryctoria/issues/1). 31 | 32 | 33 | # How to sync? 34 | An object called *syncer* is responsible for syncing. 35 | - It is registered into *container*, therefore you can get syncer like this 36 | ```javascript 37 | container.lookup('main:syncer') 38 | ``` 39 | 40 | - It is also injected into main *store*, therefore you can get syncer like this 41 | ```javascript 42 | store.get('syncer') 43 | ``` 44 | 45 | It has a *jobs* property whichs is a queue of operations including create, update and delete. These are your offline operations. 46 | 47 | There are two important methods in *syncer*: 48 | 49 | - syncUp: push local changes to remote server, flush the queue of jobs. 50 | - syncDown: save data in main *store* into localforage. 51 | ```javascript 52 | syncer.syncDown('user'); // remove all records in localforage and save all current user records into localforage 53 | syncer.syncDown(user); // create or update user record into localforage 54 | syncer.syncDown([user1, user2]); // create or update user records into localforage 55 | ``` 56 | 57 | ### Automatic sync 58 | In most cases, you do not need to manully sync since ember-fryctoria automatially 59 | *syncUp* before every request to server and automatially 60 | *syncDown* after every request to server. 61 | ```javascript 62 | store.find('user') // syncDown('user') is called. 63 | store.fetchAll('user') // syncDown('user') is called. 64 | store.find('user', 1) // syncDown(user) is called. 65 | store.fetchById('user', 1) // syncDown(user) is called. 66 | user.reload() // syncDown(user) is called. 67 | ``` 68 | 69 | ### Manual sync 70 | When you sideload or embed records, you probably want to manully save sideloaded or embeded records to localforage. Also you may want to syncUp periodially. In these cases, you can manully syncDown or syncUp. 71 | 72 | 73 | # How to handle errors during syncUp? 74 | By default, when we get an error during syncUp, syncer will stop syncing. In the 75 | next syncUp, syncer will retry starting from the failed job. 76 | 77 | You can customize this by overwriting *handleSyncUpError* method in syncer in 78 | reopen-syncer-initializer. 79 | 80 | IMO, there is really not a single robust way to handle syncing faliure for 81 | a conventional database like SQL combined with ember data. I would recommand you 82 | to only enable user to read while offline. Or you should implement a robust way 83 | to handle syncing errors for your app. 84 | 85 | # How to decide what is offline? 86 | By default, offline is defined by ```jqXHR && jqXHR.status === 0```. 87 | [jqXHR](http://api.jquery.com/jQuery.ajax/#jqXHR) 88 | 89 | You can overwrite this by overwriting *isOffline* method in syncer in 90 | reopen-syncer-initializer. 91 | 92 | # How to bypass syncing 93 | You can bypass syncing by passing an option object {bypass: true} at the end of 94 | a api call of store: 95 | ``` 96 | store.find('user', {bypass: true}) // this would not wait for syncing 97 | ``` 98 | -------------------------------------------------------------------------------- /addon/syncer.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import generateUniqueId from './utils/generate-unique-id'; 3 | import reloadLocalRecords from './utils/reload-local-records'; 4 | import isModelInstance from './utils/is-model-instance'; 5 | 6 | var RSVP = Ember.RSVP; 7 | 8 | /** 9 | We save offline jobs to localforage and run them one at a time when online 10 | 11 | Job schema: 12 | ``` 13 | { 14 | id: { String }, 15 | operation: { 'createRecord'|'updateRecord'|'deleteRecord' }, 16 | typeName: { String }, 17 | record: { Object }, 18 | createdAt: { Date }, 19 | } 20 | ``` 21 | 22 | We save remoteIdRecords to localforage. They are used to lookup remoteIds 23 | from localIds. 24 | 25 | RecordId schema: 26 | ``` 27 | { 28 | typeName: { String }, 29 | localId: { String }, 30 | remoteId: { String } 31 | } 32 | ``` 33 | 34 | @class Syncer 35 | @extends Ember.Object 36 | */ 37 | export default Ember.Object.extend({ 38 | db: null, 39 | // initialize jobs since jobs may be used before we fetch from localforage 40 | jobs: [], 41 | remoteIdRecords: [], 42 | 43 | /** 44 | * Initialize db. 45 | * 46 | * Initialize jobs, remoteIdRecords. 47 | * 48 | * @method init 49 | * @private 50 | */ 51 | init: function() { 52 | var syncer = this; 53 | 54 | var localStore = syncer.get('container').lookup('store:local'); 55 | var localAdapter = localStore.get('adapter'); 56 | 57 | syncer.set('db', window.localforage); 58 | syncer.set('localStore', localStore); 59 | syncer.set('localAdapter', localAdapter); 60 | 61 | // NOTE: get remoteIdRecords first then get jobs, 62 | // since jobs depend on remoteIdRecords 63 | syncer.getAll('remoteIdRecord').then( 64 | syncer.getAll.bind(syncer, 'job') 65 | ); 66 | }, 67 | 68 | /** 69 | * Save an offline job to localforage, this is used in DS.Model.save 70 | * 71 | * @method createJob 72 | * @public 73 | * @param {String} operation 74 | * @param {DS.Snapshot} snapshot 75 | * @return {Promise} jobs 76 | */ 77 | createJob: function(operation, snapshot) { 78 | var typeName = snapshot.typeKey; 79 | var serializer = this.get('mainStore').serializerFor(typeName); 80 | snapshot.fryctoria = true; 81 | 82 | return this.create('job', { 83 | id: generateUniqueId('job'), 84 | operation: operation, 85 | typeName: typeName, 86 | record: serializer.serialize(snapshot, {includeId: true}), 87 | createdAt: (new Date()).getTime(), 88 | }); 89 | }, 90 | 91 | /** 92 | * Run all the jobs to sync up. If there is an error while syncing, it will 93 | * only log the error instead of return a rejected promise. This is called in 94 | * all the methods in DS.Store and DS.Model that talk to the server. 95 | * 96 | * @method syncUp 97 | * @public 98 | * @return {Promise} this promise will always resolve. 99 | */ 100 | syncUp: function() { 101 | return this.runAllJobs(); 102 | }, 103 | 104 | /** 105 | * TODO: 106 | * Save all records in the store into localforage. 107 | * 108 | * @method syncDown 109 | * @public 110 | * @param {String|DS.Model|Array} typeName, record, records 111 | * @return {Promie} 112 | */ 113 | syncDown: function(descriptor) { 114 | var syncer = this; 115 | 116 | if(typeof descriptor === 'string') { 117 | return reloadLocalRecords(syncer.get('container'), descriptor); 118 | 119 | } else if(isModelInstance(descriptor)) { 120 | return syncer.syncDownRecord(descriptor); 121 | 122 | } else if(Ember.isArray(descriptor)) { 123 | var updatedRecords = descriptor.map(function(record) { 124 | return syncer.syncDownRecord(record); 125 | }); 126 | return RSVP.all(updatedRecords); 127 | 128 | } else { 129 | throw new Error('Input can only be a string, a DS.Model or an array of DS.Model, but is ' + descriptor); 130 | } 131 | }, 132 | 133 | /** 134 | * Reset syncer and localforage records. 135 | * Remove all jobs and remoteIdRecords. 136 | * Remove all records in localforage. 137 | * 138 | * @method 139 | * @public 140 | */ 141 | reset: function() { 142 | return RSVP.all([ 143 | this.deleteAll('job'), 144 | this.deleteAll('remoteIdRecord'), 145 | this.get('localAdapter').clear() 146 | ]); 147 | }, 148 | 149 | /** 150 | * Decide if the error indicates offline 151 | * 152 | * @method 153 | * @public 154 | */ 155 | isOffline: function(error) { 156 | return error && error.status === 0; 157 | }, 158 | 159 | /** 160 | * This method does not talk to remote store, it only need to get serializer 161 | * from a store. 162 | * 163 | * @method 164 | * @private 165 | */ 166 | syncDownRecord: function(record) { 167 | var localStore = this.get('localStore'); 168 | var localAdapter = this.get('localAdapter'); 169 | var snapshot = record._createSnapshot(); 170 | 171 | if(record.get('isDeleted')) { 172 | return localAdapter.deleteRecord(localStore, snapshot.type, snapshot); 173 | } else { 174 | return localAdapter.createRecord(localStore, snapshot.type, snapshot); 175 | } 176 | }, 177 | 178 | /** 179 | * Attampt to run all the jobs one by one. Deal with syncing failure. 180 | * 181 | * @method runAllJobs 182 | * @private 183 | * @return {Promise} 184 | */ 185 | runAllJobs: function() { 186 | var syncer = this; 187 | var jobs = this.get('jobs'); 188 | 189 | if(jobs.length === 0) { 190 | Ember.Logger.info('Syncing jobs are empty.'); 191 | return RSVP.resolve(); 192 | } 193 | 194 | Ember.Logger.info('Syncing started.'); 195 | jobs = jobs.sortBy('createdAt'); 196 | 197 | // run jobs one at a time 198 | return jobs.reduce(function(acc, job) { 199 | return acc.then(function() { 200 | return syncer.runJob(job); 201 | }); 202 | }, RSVP.resolve()) 203 | 204 | .then(function() { 205 | syncer.deleteAll('remoteIdRecord'); 206 | Ember.Logger.info('Syncing succeed.'); 207 | }) 208 | 209 | .catch(function(error) { 210 | if(syncer.isOffline(error)) { 211 | Ember.Logger.info('Can not connect to server, stop syncing'); 212 | } else if(syncer.handleSyncUpError){ 213 | return syncer.handleSyncUpError(error); 214 | } else { 215 | return RSVP.reject(error); 216 | } 217 | }); 218 | }, 219 | 220 | runJob: function(job) { 221 | var syncer = this; 222 | 223 | var store = syncer.get('mainStore'); 224 | 225 | var typeName = job.typeName; 226 | var type = store.modelFor(typeName); 227 | 228 | var adapter = store.adapterFor(typeName); 229 | var remoteCRUD = adapter.get('fryctoria'); 230 | 231 | var record = createRecordFromJob(syncer, job, type); 232 | var snapshot = record._createSnapshot(); 233 | 234 | var operation = job.operation; 235 | 236 | var syncedRecord; 237 | 238 | if(operation === 'deleteRecord') { 239 | syncedRecord = remoteCRUD.deleteRecord.call(adapter, store, type, snapshot); 240 | 241 | } else if(operation === 'updateRecord') { 242 | // TODO: make reverse update possible 243 | // for now, we do not accept 'reverse update' i.e. update from the server 244 | // will not be reflected in the store 245 | syncedRecord = remoteCRUD.updateRecord.call(adapter, store, type, snapshot); 246 | 247 | } else if(operation === 'createRecord') { 248 | // TODO: make reverse update possible 249 | // for now, we do not accept 'reverse update' i.e. update from the server 250 | // will not be reflected in the store 251 | var recordIdBeforeCreate = record.get('id'); 252 | record.set('id', null); 253 | snapshot = record._createSnapshot(); 254 | 255 | // adapter -> store -> syncer(remoteId) -> localforage 256 | syncedRecord = remoteCRUD.createRecord.call(adapter, store, type, snapshot) 257 | .then(updateIdInStore) 258 | .then(createRemoteIdRecord) 259 | .then(refreshLocalRecord); 260 | } 261 | 262 | // delete from db after syncing success 263 | return syncedRecord.then(function() { 264 | return syncer.deleteById('job', job.id); 265 | }); 266 | 267 | function updateIdInStore(payload) { 268 | var recordExtracted = store.serializerFor(type).extract( 269 | store, type, payload, record.get('id'), 'single' 270 | ); 271 | 272 | var recordInStore = store.getById(typeName, recordIdBeforeCreate); 273 | 274 | // INFO: recordInStore may be null because it may be deleted 275 | if(recordInStore) { 276 | recordInStore.set('id', null); 277 | store.updateId(recordInStore, recordExtracted); 278 | } 279 | 280 | return recordExtracted; 281 | } 282 | 283 | function createRemoteIdRecord(recordExtracted) { 284 | return syncer.create('remoteIdRecord', { 285 | typeName: typeName, 286 | localId: recordIdBeforeCreate, 287 | remoteId: recordExtracted.id 288 | }).then(function() { 289 | return recordExtracted; 290 | }); 291 | } 292 | 293 | // This method does not talk to store, only to adapter 294 | function refreshLocalRecord(recordExtracted) { 295 | // NOTE: we should pass snapshot instead of rawRecord to deleteRecord, 296 | // in deleteRecord, we only call snapshot.id, we can just pass the 297 | // rawRecord to it. 298 | 299 | // delete existing record with localId 300 | var localStore = syncer.get('localStore'); 301 | var localAdapter = syncer.get('localAdapter'); 302 | 303 | return localAdapter.deleteRecord( 304 | localStore, type, {id: recordIdBeforeCreate} 305 | ).then(function() { 306 | 307 | // create new record with remoteId 308 | record.set('id', recordExtracted.id); 309 | var snapshot = record._createSnapshot(); 310 | return localAdapter.createRecord(localStore, type, snapshot); 311 | }); 312 | } 313 | }, 314 | 315 | // lazy evaluate main store, since main store is initialized after syncer 316 | mainStore: Ember.computed(function() { 317 | return this.get('container').lookup('store:main'); 318 | }), 319 | 320 | getRemoteId: function(typeName, id) { 321 | Ember.assert('Id can not be blank.', !Ember.isNone(id)); 322 | 323 | if(isRemoteId(id)) { 324 | return id; 325 | } 326 | 327 | // Try to find a remote id from local id 328 | // NOTE: it is possible we are trying to create one record in local 329 | // and does not have a remote id yet. 330 | var remoteIdRecord = this.get('remoteIdRecords').find(function(record) { 331 | return record.typeName === typeName && record.localId === id; 332 | }); 333 | 334 | return remoteIdRecord ? remoteIdRecord.remoteId : id; 335 | }, 336 | 337 | // CRUD for jobs and remoteIdRecords 338 | getAll: function(typeName) { 339 | var syncer = this; 340 | var namespace = getNamespace(typeName); 341 | 342 | return syncer.get('db').getItem(namespace).then(function(records) { 343 | records = records || []; 344 | syncer.set(pluralize(typeName), records); 345 | return records; 346 | }); 347 | }, 348 | 349 | deleteAll: function(typeName) { 350 | return this.saveAll(typeName, []); 351 | }, 352 | 353 | deleteById: function(typeName, id) { 354 | var records = this.get(pluralize(typeName)).filter(function(record) { 355 | return id !== record.id; 356 | }); 357 | 358 | return this.saveAll(typeName, records); 359 | }, 360 | 361 | create: function(typeName, record) { 362 | var records = this.get(pluralize(typeName)); 363 | records.push(record); 364 | 365 | return this.saveAll(typeName, records); 366 | }, 367 | 368 | saveAll: function(typeName, records) { 369 | this.set(pluralize(typeName), records); 370 | 371 | var namespace = getNamespace(typeName); 372 | return this.get('db').setItem(namespace, records); 373 | }, 374 | }); 375 | 376 | function pluralize(typeName) { 377 | return typeName + 's'; 378 | } 379 | 380 | function getNamespace(typeName) { 381 | var LocalForageKeyHash = { 382 | 'job': 'EmberFryctoriaJobs', 383 | 'remoteIdRecord': 'EmberFryctoriaRemoteIdRecords', 384 | }; 385 | return LocalForageKeyHash[typeName]; 386 | } 387 | 388 | function isRemoteId(id) { 389 | return id.indexOf('fryctoria') !== 0; 390 | } 391 | 392 | function createRecordFromJob(syncer, job, type) { 393 | var remoteId = syncer.getRemoteId(job.typeName, job.record.id); 394 | var record = createRecordInLocalStore(syncer, type, remoteId); 395 | 396 | record.setupData(job.record); 397 | record.eachRelationship(function(name, descriptor) { 398 | addRelationshipToRecord(name, descriptor, job.record, record, syncer); 399 | }); // load relationships 400 | 401 | return record; 402 | } 403 | 404 | function addRelationshipToRecord(name, descriptor, jobRecord, record, syncer) { 405 | var relationship = jobRecord[name]; 406 | var relationshipId, relationshipIds; 407 | var relationshipTypeName = descriptor.type.typeKey; 408 | 409 | if(!relationship) { return; } 410 | 411 | if(descriptor.kind === 'belongsTo') { 412 | var belongsToRecord; 413 | // belongsTo 414 | relationshipId = relationship; 415 | relationshipId = syncer.getRemoteId(relationshipTypeName, relationshipId); 416 | // NOTE: It is possible that the association is deleted in the store 417 | // and getById is null, so we create a fake record with the right id 418 | belongsToRecord = getOrCreateRecord(syncer, descriptor.type, relationshipId); 419 | record.set(name, belongsToRecord); 420 | 421 | } else if(descriptor.kind === 'hasMany') { 422 | var hasManyRecords; 423 | // hasMany 424 | relationshipIds = relationship || []; 425 | hasManyRecords = relationshipIds.map(function(id) { 426 | var remoteId = syncer.getRemoteId(relationshipTypeName, id); 427 | return getOrCreateRecord(syncer, descriptor.type, remoteId); 428 | }); 429 | record.get(descriptor.key).pushObjects(hasManyRecords); 430 | } 431 | } 432 | 433 | function getOrCreateRecord(syncer, type, id) { 434 | var mainStore = syncer.get('mainStore'); 435 | 436 | return mainStore.getById(type.typeKey, id) || 437 | createRecordInLocalStore(syncer, type, id); 438 | } 439 | 440 | 441 | function createRecordInLocalStore(syncer, type, id) { 442 | // after create, the state becomes "root.empty" 443 | var record = type._create({ 444 | id: id, 445 | store: syncer.get('localStore'), 446 | container: syncer.get('container'), 447 | }); 448 | 449 | // after setupData, the state becomes "root.loaded.saved" 450 | record.setupData({}); 451 | return record; 452 | } 453 | --------------------------------------------------------------------------------