diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js index 607f79af..1a252e94 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -2,9 +2,9 @@ * Dependencies. */ -var assert = require('assert') - , compat = require('../compat') - , _ = require('underscore'); +var assert = require('assert'); +var remoting = require('strong-remoting'); +var compat = require('../compat'); /** * Export the RemoteConnector class. @@ -24,6 +24,7 @@ function RemoteConnector(settings) { this.root = settings.root || ''; this.host = settings.host || 'localhost'; this.port = settings.port || 3000; + this.remotes = remoting.create(); if(settings.url) { this.url = settings.url; @@ -36,9 +37,9 @@ function RemoteConnector(settings) { } RemoteConnector.prototype.connect = function() { + this.remotes.connect(this.url, this.adapter); } - RemoteConnector.initialize = function(dataSource, callback) { var connector = dataSource.connector = new RemoteConnector(dataSource.settings); connector.connect(); @@ -48,24 +49,52 @@ RemoteConnector.initialize = function(dataSource, callback) { RemoteConnector.prototype.define = function(definition) { var Model = definition.model; var className = compat.getClassNameForRemoting(Model); - var url = this.url; - var adapter = this.adapter; + var remotes = this.remotes + var SharedClass; + var classes; + var i = 0; - assert(Model.app, 'Cannot attach Model: ' + Model.modelName - + ' to a RemoteConnector. You must first attach it to an app!'); + remotes.exports[className] = Model; - Model.remotes(function(err, remotes) { - var sharedClass = getSharedClass(remotes, className); - remotes.connect(url, adapter); - sharedClass - .methods() - .forEach(Model.createProxyMethod.bind(Model)); - }); + classes = remotes.classes(); + + for(; i < classes.length; i++) { + SharedClass = classes[i]; + if(SharedClass.name === className) { + SharedClass + .methods() + .forEach(function(remoteMethod) { + // TODO(ritch) more elegant way of ignoring a nested shared class + if(remoteMethod.name !== 'Change' + && remoteMethod.name !== 'Checkpoint') { + createProxyMethod(Model, remotes, remoteMethod); + } + }); + + return; + } + } } -function getSharedClass(remotes, className) { - return _.find(remotes.classes(), function(sharedClass) { - return sharedClass.name === className; - }); +function createProxyMethod(Model, remotes, remoteMethod) { + var scope = remoteMethod.isStatic ? Model : Model.prototype; + var original = scope[remoteMethod.name]; + + var fn = scope[remoteMethod.name] = function remoteMethodProxy() { + var args = Array.prototype.slice.call(arguments); + var lastArgIsFunc = typeof args[args.length - 1] === 'function'; + var callback; + if(lastArgIsFunc) { + callback = args.pop(); + } + + remotes.invoke(remoteMethod.stringName, args, callback); + } + + for(var key in original) { + fn[key] = original[key]; + } + fn._delegate = true; } + function noop() {} diff --git a/lib/models/change.js b/lib/models/change.js index 333c4689..3eab893b 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -2,7 +2,7 @@ * Module Dependencies. */ -var Model = require('../loopback').Model +var DataModel = require('./data-model') , loopback = require('../loopback') , crypto = require('crypto') , CJSON = {stringify: require('canonical-json')} @@ -44,7 +44,7 @@ var options = { * @inherits {Model} */ -var Change = module.exports = Model.extend('Change', properties, options); +var Change = module.exports = DataModel.extend('Change', properties, options); /*! * Constants @@ -271,6 +271,7 @@ Change.prototype.getModelCtor = function() { */ Change.prototype.equals = function(change) { + if(!change) return false; return change.rev === this.rev; } @@ -337,9 +338,10 @@ Change.diff = function(modelName, since, remoteChanges, callback) { var localModelIds = []; localChanges.forEach(function(localChange) { + localChange = new Change(localChange); localModelIds.push(localChange.modelId); var remoteChange = remoteChangeIndex[localChange.modelId]; - if(!localChange.equals(remoteChange)) { + if(remoteChange && !localChange.equals(remoteChange)) { if(remoteChange.isBasedOn(localChange)) { deltas.push(remoteChange); } else { diff --git a/lib/models/model.js b/lib/models/model.js index d88daa00..018ef3bf 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -3,10 +3,13 @@ */ var loopback = require('../loopback'); var compat = require('../compat'); -var ModelBuilder = require('loopback-datasource-juggler').ModelBuilder; +var juggler = require('loopback-datasource-juggler'); +var ModelBuilder = juggler.ModelBuilder; +var DataSource = juggler.DataSource; var modeler = new ModelBuilder(); var async = require('async'); var assert = require('assert'); +var _ = require('underscore'); /** * The base class for **all models**. @@ -89,6 +92,10 @@ Model.setup = function () { var ModelCtor = this; var options = this.settings; + if(options.trackChanges) { + this._defineChangeModel(); + } + ModelCtor.sharedCtor = function (data, id, fn) { if(typeof data === 'function') { fn = data; @@ -176,12 +183,12 @@ Model.setup = function () { ModelCtor.sharedCtor.returns = {root: true}; - ModelCtor.once('dataSourceAttached', function() { - // enable change tracking (usually for replication) - if(options.trackChanges) { + // enable change tracking (usually for replication) + if(options.trackChanges) { + ModelCtor.once('dataSourceAttached', function() { ModelCtor.enableChangeTracking(); - } - }); + }); + } return ModelCtor; }; @@ -294,51 +301,6 @@ Model.getApp = function(callback) { } } -/** - * Get the Model's `RemoteObjects`. - * - * @callback {Function} callback - * @param {Error} err - * @param {RemoteObjects} remoteObjects - * @end - */ - -Model.remotes = function(callback) { - this.getApp(function(err, app) { - callback(null, app.remotes()); - }); -} - -/*! - * Create a proxy function for invoking remote methods. - * - * @param {SharedMethod} sharedMethod - */ - -Model.createProxyMethod = function createProxyFunction(remoteMethod) { - var Model = this; - var scope = remoteMethod.isStatic ? Model : Model.prototype; - var original = scope[remoteMethod.name]; - - var fn = scope[remoteMethod.name] = function proxy() { - var args = Array.prototype.slice.call(arguments); - var lastArgIsFunc = typeof args[args.length - 1] === 'function'; - var callback; - if(lastArgIsFunc) { - callback = args.pop(); - } - - Model.remotes(function(err, remotes) { - remotes.invoke(remoteMethod.stringName, args, callback); - }); - } - - for(var key in original) { - fn[key] = original[key]; - } - fn._delegate = true; -} - // setup the initial model Model.setup(); @@ -435,22 +397,39 @@ Model.currentCheckpoint = function(cb) { /** * Replicate changes since the given checkpoint to the given target model. * - * @param {Number} since Since this checkpoint + * @param {Number} [since] Since this checkpoint * @param {Model} targetModel Target this model class - * @options {Object} options - * @property {Object} filter Replicate models that match this filter - * @callback {Function} callback + * @param {Object} [options] + * @param {Object} [options.filter] Replicate models that match this filter + * @callback {Function} [callback] * @param {Error} err - * @param {Array} conflicts A list of changes that could not be replicated + * @param {Conflict[]} conflicts A list of changes that could not be replicated * due to conflicts. */ Model.replicate = function(since, targetModel, options, callback) { + var lastArg = arguments[arguments.length - 1]; + + if(typeof lastArg === 'function' && arguments.length > 1) { + callback = lastArg; + } + + if(typeof since === 'funciton' && since.modelName) { + since = -1; + targetModel = since; + } + var sourceModel = this; var diff; var updates; var Change = this.getChangeModel(); var TargetChange = targetModel.getChangeModel(); + var changeTrackingEnabled = Change && TargetChange; + + assert( + changeTrackingEnabled, + 'You must enable change tracking before replicating' + ); var tasks = [ getLocalChanges, @@ -586,18 +565,16 @@ Model.bulkUpdate = function(updates, callback) { /** * Get the `Change` model. * + * @throws {Error} Throws an error if the change model is not correctly setup. * @return {Change} */ Model.getChangeModel = function() { var changeModel = this.Change; - if(changeModel) return changeModel; - this.Change = changeModel = require('./change').extend(this.modelName + '-change'); + var isSetup = changeModel && changeModel.dataSource; - assert(this.dataSource, 'Cannot getChangeModel(): ' + this.modelName - + ' is not attached to a dataSource'); + assert(isSetup, 'Cannot get a setup Change model'); - changeModel.attachTo(this.dataSource); return changeModel; } @@ -628,9 +605,15 @@ Model.getSourceId = function(cb) { Model.enableChangeTracking = function() { var Model = this; - var Change = Model.getChangeModel(); + var Change = this.Change || this._defineChangeModel(); var cleanupInterval = Model.settings.changeCleanupInterval || 30000; + assert(this.dataSource, 'Cannot enableChangeTracking(): ' + this.modelName + + ' is not attached to a dataSource'); + + Change.attachTo(this.dataSource); + Change.getCheckpointModel().attachTo(this.dataSource); + Model.on('changed', function(obj) { Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { if(err) { @@ -666,3 +649,8 @@ Model.enableChangeTracking = function() { }); } } + +Model._defineChangeModel = function() { + var BaseChangeModel = require('./change'); + return this.Change = BaseChangeModel.extend(this.modelName + '-change'); +} diff --git a/package.json b/package.json index b1b223e8..4c6aeb28 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "karma": "~0.10.9", "karma-browserify": "~0.2.0", "karma-mocha": "~0.1.1", - "grunt-karma": "~0.6.2" + "grunt-karma": "~0.6.2", + "loopback-explorer": "~1.1.0" }, "repository": { "type": "git", diff --git a/test/e2e/remote-connector.e2e.js b/test/e2e/remote-connector.e2e.js index 3fb806fb..791b43c5 100644 --- a/test/e2e/remote-connector.e2e.js +++ b/test/e2e/remote-connector.e2e.js @@ -7,12 +7,10 @@ var assert = require('assert'); describe('RemoteConnector', function() { before(function() { // setup the remote connector - var localApp = loopback(); var ds = loopback.createDataSource({ url: 'http://localhost:3000/api', connector: loopback.Remote }); - localApp.model(TestModel); TestModel.attachTo(ds); }); diff --git a/test/e2e/replication.e2e.js b/test/e2e/replication.e2e.js index e38264a7..fc42def4 100644 --- a/test/e2e/replication.e2e.js +++ b/test/e2e/replication.e2e.js @@ -2,61 +2,36 @@ var path = require('path'); var loopback = require('../../'); var models = require('../fixtures/e2e/models'); var TestModel = models.TestModel; -var LocalTestModel = TestModel.extend('LocalTestModel'); +var LocalTestModel = TestModel.extend('LocalTestModel', {}, { + trackChanges: true +}); var assert = require('assert'); -describe('ReplicationModel', function () { - it('ReplicationModel.enableChangeTracking()', function (done) { - var TestReplicationModel = loopback.DataModel.extend('TestReplicationModel'); - var remote = loopback.createDataSource({ - url: 'http://localhost:3000/api', - connector: loopback.Remote - }); - var testApp = loopback(); - testApp.model(TestReplicationModel); - TestReplicationModel.attachTo(remote); - // chicken-egg condition - // getChangeModel() requires it to be attached to an app - // attaching to the app requires getChangeModel() - var Change = TestReplicationModel.getChangeModel(); - testApp.model(Change); - Change.attachTo(remote); - TestReplicationModel.enableChangeTracking(); - }); -}); - -describe.skip('Replication', function() { - beforeEach(function() { +describe('Replication', function() { + before(function() { // setup the remote connector - var localApp = loopback(); var ds = loopback.createDataSource({ url: 'http://localhost:3000/api', connector: loopback.Remote }); - localApp.model(TestModel); - localApp.model(LocalTestModel); TestModel.attachTo(ds); var memory = loopback.memory(); LocalTestModel.attachTo(memory); - - // TODO(ritch) this should be internal... - LocalTestModel.getChangeModel().attachTo(memory); - - LocalTestModel.enableChangeTracking(); - - // failing because change model is not properly attached - TestModel.enableChangeTracking(); }); it('should replicate local data to the remote', function (done) { + var RANDOM = Math.random(); + LocalTestModel.create({ - foo: 'bar' - }, function() { + n: RANDOM + }, function(err, created) { LocalTestModel.replicate(0, TestModel, function() { - console.log('replicated'); - done(); + if(err) return done(err); + TestModel.findOne({n: RANDOM}, function(err, found) { + assert.equal(created.id, found.id); + done(); + }); }); }); }); - }); diff --git a/test/fixtures/e2e/app.js b/test/fixtures/e2e/app.js index 337d6145..462d0426 100644 --- a/test/fixtures/e2e/app.js +++ b/test/fixtures/e2e/app.js @@ -3,12 +3,18 @@ var path = require('path'); var app = module.exports = loopback(); var models = require('./models'); var TestModel = models.TestModel; +var explorer = require('loopback-explorer'); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; app.use(apiPath, loopback.rest()); + +TestModel.attachTo(loopback.memory()); +app.model(TestModel); +app.model(TestModel.getChangeModel()); + +app.use('/explorer', explorer(app, {basePath: apiPath})); + app.use(loopback.static(path.join(__dirname, 'public'))); app.use(loopback.urlNotFound()); app.use(loopback.errorHandler()); -app.model(TestModel); -TestModel.attachTo(loopback.memory()); diff --git a/test/fixtures/e2e/models.js b/test/fixtures/e2e/models.js index dad14f61..e1c22d6e 100644 --- a/test/fixtures/e2e/models.js +++ b/test/fixtures/e2e/models.js @@ -1,4 +1,6 @@ var loopback = require('../../../'); var DataModel = loopback.DataModel; -exports.TestModel = DataModel.extend('TestModel'); +exports.TestModel = DataModel.extend('TestModel', {}, { + trackChanges: true +}); diff --git a/test/model.test.js b/test/model.test.js index 8eaf7675..77069ec8 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -18,6 +18,8 @@ describe('Model', function() { 'gender': String, 'domain': String, 'email': String + }, { + trackChanges: true }); });