UNPKG

@balderdash/sails-edge

Version:

API-driven framework for building realtime apps, using MVC conventions (based on Express and Socket.io)

1,347 lines (1,071 loc) 46.8 kB
/** * Module dependencies. */ var util = require('util'); var _ = require('lodash'); var STRINGFILE = require('sails-stringfile'); /** * Module errors */ var Err = { dependency: function (dependent, dependency) { return new Error( '\n' + 'Cannot use `' + dependent + '` hook ' + 'without the `' + dependency + '` hook enabled!' ); } }; module.exports = function(sails) { var parseId = function (id) { if(!_.isObject(this.attributes)) { return id; } var pkAttrDef = this.attributes[this.primaryKey]; if(_.isPlainObject(pkAttrDef)) { if (pkAttrDef.type === 'integer') { return parseInt(id); } else if (pkAttrDef.type === 'string') { return new String(id).toString(); } } return id; }; /** * Expose Hook definition */ return { initialize: function(cb) { var self = this; // If `views` and `http` hook is not enabled, complain and respond w/ error if (!sails.hooks.sockets) { return cb( Err.dependency('pubsub', 'sockets') ); } if (!sails.hooks.orm) { return cb( Err.dependency('pubsub', 'orm') ); } // Wait for `hook:orm:loaded` sails.on('hook:orm:loaded', function() { // Do the heavy lifting self.augmentModels(); // Indicate that the hook is fully loaded cb(); }); // When the orm is reloaded, re-apply all of the pubsub methods to the // models sails.on('hook:orm:reloaded', function() { self.augmentModels(); // Trigger an event in case something needs to respond to the pubsub reload sails.emit('hook:pubsub:reloaded'); }); sails.on('hook:sockets:adminMessage', self.handleAdminMessage); }, augmentModels: function() { // Augment models with room/socket logic (& bind context) for (var identity in sails.models) { var AugmentedModel = _.defaults(sails.models[identity], getPubsubMethods(), {autosubscribe: true} ); _.bindAll(AugmentedModel, 'subscribe', 'watch', 'introduce', 'retire', 'unwatch', 'unsubscribe', 'publish', 'room', 'publishCreate', 'publishUpdate', 'publishDestroy', 'publishAdd', 'publishRemove' ); sails.models[identity] = AugmentedModel; } }, handleAdminMessage: function(data) { sails.log.silly("Got admin message: ", data); var event = data.event; var payload = data.payload; var Model; switch (event) { case 'introduce': Model = sails.models[payload.model]; if (!Model) { sails.log.warn("Ignoring `introduce` request for non-existent model `" + payload.model + "`."); return; } sails.log.silly("Running introduce: ", data); Model.introduce(payload.id); break; case 'retire': Model = sails.models[payload.model]; if (!Model) { sails.log.warn("Ignoring `retire` request for non-existent model `" + payload.model + "`."); return; } sails.log.silly("Running retire: ", data); Model.retire(payload.id); break; } } }; /** * These methods get appended to the Model class objects * Some take req.socket as an argument to get access * to user('s|s') socket object(s) */ function getPubsubMethods () { return { /** * Broadcast a message to a room * * Wrapper for sails.sockets.broadcast * Can be overridden at a model level, i.e. for encapsulating messages within a single event name. * * @param {string} roomName The room to broadcast a message to * @param {string} eventName The event name to broadcast * @param {object} data The data to broadcast * @param {object} socket Optional socket to omit * * @api private */ broadcast: function(roomName, eventName, data, socketToOmit) { sails.sockets.broadcast(roomName, eventName, data, socketToOmit); }, /** * TODO: document */ getAllContexts: function() { var contexts = ['update', 'destroy', 'message']; _.each(this.associations, function(association) { if (association.type == 'collection') { contexts.push('add:'+association.alias); contexts.push('remove:'+association.alias); } }); return contexts; }, /** * Broadcast a custom message to sockets connected to the specified models * @param {Object|String|Finite} record -- record or ID of record whose subscribers should receive the message * @param {Object|Array|String|Finite} message -- the message payload * @param {Request|Socket} req - if specified, broadcast using this * socket (effectively omitting it) * */ message: function(record, data, req) { // If a request object was sent, get its socket, otherwise assume a socket was sent. var socketToOmit = (req && req.socket ? req.socket : req); // If no records provided, throw an error if (!record) { return sails.log.error( util.format( 'Must specify a record or record ID when calling `Model.publish` '+ '(you specified: `%s`)', record)); } // Otherwise publish to each instance room else { // Get the record ID (if the record argument isn't already a scalar) var id = record[this.primaryKey] || record; // Get the socket room to publish to var room = this.room(id, "message"); // Ensure that we're working with a clean, unencumbered object data = _.cloneDeep(data); // Create the payload var payload = { verb: "messaged", id: id, data: data }; this.broadcast( room, this.identity, payload, socketToOmit ); sails.log.silly("Published message to ", room, ": ", payload); } }, /** * Broadcast a message to sockets connected to the specified models * (or null to broadcast to the entire class room) * * @param {Object|Array|String|Finite} models -- models whose subscribers should receive the message * @param {String} eventName -- the event name to broadcast with * @param {String} context -- the context to broadcast to * @param {Object|Array|String|Finite} data -- the message payload * socket (effectively omitting it) * * @api private */ publish: function (models, eventName, context, data, req) { var self = this; // If the event name is an object, assume we're seeing `publish(models, data, req)` if (typeof eventName === 'object') { req = context; context = null; data = eventName; eventName = null; } // Default to the event name being the model identity if (!eventName) { eventName = this.identity; } // If the context is an object, assume we're seeing `publish(models, eventName, data, req)` if (typeof context === 'object' && context !== null) { req = data; data = context; context = null; } // Default to using the message context if (!context) { sails.log.warn('`Model.publish` should specify a context; defaulting to "message". Try `Model.message` instead?'); context = 'message'; } // If a request object was sent, get its socket, otherwise assume a socket was sent. var socketToOmit = (req && req.socket ? req.socket : req); // If no models provided, publish to the class room if (!models) { STRINGFILE.logDeprecationNotice( 'Model.publish(null, ...)', STRINGFILE.get('links.docs.sockets.pubsub'), sails.log.debug) && STRINGFILE.logUpgradeNotice(STRINGFILE.get('upgrade.classrooms'), [], sails.log.debug); sails.log.silly('Published ', eventName, ' to ', self.classRoom()); self.broadcast( self.classRoom(), eventName, data, socketToOmit ); return; } // Otherwise publish to each instance room else { models = this.pluralize(models); var ids = _.pluck(models, this.primaryKey); if ( ids.length === 0 ) { sails.log.warn('Can\'t publish a message to an empty list of instances-- ignoring...'); } _.each(ids,function eachInstance (id) { var room = self.room(id, context); sails.log.silly("Published ", eventName, " to ", room); self.broadcast( room, eventName, data, socketToOmit ); // Also broadcasts a message to the legacy instance room (derived by // using the `legacy_v0.9` context). // Uses traditional eventName === "message". // Uses traditional message format. if (sails.config.sockets['backwardsCompatibilityFor0.9SocketClients']) { var legacyRoom = self.room(id, 'legacy_v0.9'); var legacyMsg = _.cloneDeep(data); legacyMsg.model = self.identity; if (legacyMsg.verb === 'created') { legacyMsg.verb = 'create'; } if (legacyMsg.verb === 'updated') { legacyMsg.verb = 'update'; } if (legacyMsg.verb === 'destroyed') { legacyMsg.verb = 'destroy'; } self.broadcast( legacyRoom, 'message', legacyMsg, socketToOmit ); } }); } }, /** * Check that models are a list, if not, make them a list * Also if they are ids, make them dummy objects with an `id` property * * @param {Object|Array|String|Finite} models * @returns {Array} array of things that have an `id` property * * @api private * @synchronous */ pluralize: function (models) { // If `models` is a non-array object, // turn it into a single-item array ("pluralize" it) // e.g. { id: 7 } -----> [ { id: 7 } ] if ( !_.isArray(models) ) { var model = models; models = [model]; } // If a list of ids things look ids (finite numbers or strings), // wrap them up as dummy objects; e.g. [1,2] ---> [ {id: 1}, {id: 2} ] var self = this; return _.map(models, function (model) { if ( _.isString(model) || _.isFinite(model) ) { var id = model; var data = {}; data[self.primaryKey] = id; return data; } return model; }); }, /** * @param {String|} id * @return {String} name of the instance room for an instance of this model w/ given id * @synchronous */ room: function (id, context) { if (!id) return sails.log.error('Must specify an `id` when calling `Model.room(id)`'); return 'sails_model_'+this.identity+'_'+id+':'+context; }, classRoom: function () { STRINGFILE.logDeprecationNotice( 'Model.classRoom', STRINGFILE.get('links.docs.sockets.pubsub'), sails.log.debug) && STRINGFILE.logUpgradeNotice(STRINGFILE.get('upgrade.classrooms'), [], sails.log.debug); return this._classRoom(); }, /** * @return {String} name of this model's global class room * @synchronous * @api private */ _classRoom: function() { return 'sails_model_create_'+this.identity; }, /** * Return the set of sockets subscribed to this instance * @param {String|Integer} id * @return {Array[Socket]} * @synchronous * @api private */ subscribers: function (id, context) { // If an empty id sent in, return an empty array if (!id) { sails.log.warn('`Model.subscribers()` called with an empty `id` argument.'); return []; } // Support instance argument id = id[this.primaryKey] || id; // For a single context, return just the socket subscribed to that context if (context) { return sails.sockets.subscribers(this.room(id, context), true); } // Otherwise return the unique set of sockets subscribed to ALL contexts // // TODO: handle custom contexts here, which aren't returned by getAllContexts // Not currently a big issue since `publish` is a private API, so subscribing // to a custom context doesn't let you do much. var contexts = this.getAllContexts(); var subscribers = []; _.each(contexts, function(context) { subscribers = _.union(subscribers, this.subscribers(id, context)); }, this); return _.uniq(subscribers); }, /** * Return the set of sockets subscribed to this class room * @return {Array[Socket]} * @synchronous * @api private */ watchers: function() { return sails.sockets.subscribers(this._classRoom(), true); }, /** * Subscribe a socket to a handful of records in this model * * Usage: * Model.subscribe(req,socket [, records] ) * * @param {Request|Socket} req - request containing the socket to subscribe, or the socket itself * @param {Object|Array|String|Finite} records - id, array of ids, model, or array of records * * e.g. * // Subscribe to User.create() * User.subscribe(req.socket) * * // Subscribe to User.update() and User.destroy() * // for the specified instances (or user.save() / user.destroy()) * User.subscribe(req.socket, users) * * @api public */ subscribe: function (req, records, contexts) { // If a request object was sent, get its socket, otherwise assume a socket was sent. var socket = sails.sockets.parseSocket(req); if (!socket) { return sails.log.warn('`Model.subscribe()` called by a non-socket request. Only requests originating from a connected socket may be subscribed. Ignoring...'); } var self = this; // Subscribe to class room to hear about new records if (!records) { sails.log.warn('Missing or empty second argument `records`. API is `.subscribe(request, records [, contexts])`.'); STRINGFILE.logDeprecationNotice( 'Model.subscribe(socket, null, ...)', STRINGFILE.get('links.docs.sockets.pubsub'), sails.log.debug) && STRINGFILE.logUpgradeNotice(STRINGFILE.get('upgrade.classrooms'), [], sails.log.debug); this.watch(req); return; } contexts = contexts || this.autosubscribe; if (!contexts) { sails.log.warn("`subscribe` called without context on a model with autosubscribe:false. No action will be taken."); return; } if (contexts === true || contexts == '*') { contexts = this.getAllContexts(); } else if (sails.util.isString(contexts)) { contexts = [contexts]; } // If the subscribing socket is using the legacy (v0.9.x) socket SDK (sails.io.js), // always subscribe the client to the `legacy_v0.9` context. // if (sails.config.sockets['backwardsCompatibilityFor0.9SocketClients'] && socket.handshake) { // var sdk = app.getSDKMetadata(socket.handshake); // var isLegacySocketClient = sdk.version === '0.9.0'; // if (isLegacySocketClient) { // contexts.push('legacy_v0.9'); // } // } // Subscribe to model instances records = self.pluralize(records); var ids = _.pluck(records, this.primaryKey); _.each(ids,function (id) { _.each(contexts, function(context) { sails.log.silly( 'Subscribed to the ' + self.globalId + ' with id=' + id + '\t(room :: ' + self.room(id, context) + ')' ); sails.sockets.join( socket, self.room(id, context) ); }); }); }, /** * Unsubscribe a socket from some records * * @param {Request|Socket} req - request containing the socket to unsubscribe, or the socket itself * @param {Object|Array|String|Finite} models - id, array of ids, model, or array of models */ unsubscribe: function (req, records, contexts) { // If a request object was sent, get its socket, otherwise assume a socket was sent. var socket = sails.sockets.parseSocket(req); if (!socket) { return sails.log.warn('`Model.unsubscribe()` called by a non-socket request. Only requests originating from a connected socket may be subscribed. Ignoring...'); } var self = this; // If no records provided, unsubscribe from the class room if (!records) { STRINGFILE.logDeprecationNotice( 'Model.unsubscribe(socket, null, ...)', STRINGFILE.get('links.docs.sockets.pubsub'), sails.log.debug) && STRINGFILE.logUpgradeNotice(STRINGFILE.get('upgrade.classrooms'), [], sails.log.debug); this.unwatch(); } contexts = contexts || this.getAllContexts(); if (contexts === true) { contexts = this.getAllContexts(); } // if (sails.config.sockets['backwardsCompatibilityFor0.9SocketClients'] && socket.handshake) { // var sdk = app.getSDKMetadata(socket.handshake); // var isLegacySocketClient = sdk.version === '0.9.0'; // if (isLegacySocketClient) { // contexts.push('legacy_v0.9'); // } // } records = self.pluralize(records); var ids = _.pluck(records, this.primaryKey); _.each(ids,function (id) { _.each(contexts, function(context) { sails.log.silly( 'Unsubscribed from the ' + self.globalId + ' with id=' + id + '\t(room :: ' + self.room(id, context) + ')' ); sails.sockets.leave( socket, self.room(id, context)); }); }); }, /** * Publish an update on a particular model * * @param {String|Finite} id * - primary key of the instance we're referring to * * @param {Object} changes * - an object of changes to this instance that will be broadcasted * * @param {Request|Socket} req - if specified, broadcast using this socket (effectively omitting it) * * @api public */ publishUpdate: function (id, changes, req, options) { var reverseAssociation; // Make sure there's an options object options = options || {}; // Ensure that we're working with a clean, unencumbered object changes = _.cloneDeep(changes); // Enforce valid usage var validId = _.isString(id) || _.isFinite(id); if ( !validId ) { return sails.log.error( 'Invalid usage of `' + this.identity + '.publishUpdate(id, changes, [socketToOmit])`' ); } if (sails.util.isFunction(this.beforePublishUpdate)) { this.beforePublishUpdate(id, changes, req, options); } // Coerce id to match the attribute type of the primary key of the model id = parseId(id); var data = { model: this.identity, verb: 'update', data: changes, id: id }; if (options.previous && !options.noReverse) { var previous = options.previous; // If any of the changes were to association attributes, publish add or remove messages. _.each(changes, function(val, key) { // If value wasn't changed, do nothing if (val == previous[key]) return; // Find an association matching this attribute var association = _.find(this.associations, {alias: key}); // If the attribute isn't an assoctiation, return if (!association) return; // Get the associated model class var ReferencedModel = sails.models[association.type == 'model' ? association.model : association.collection]; // Bail if this attribute isn't in the model's schema if (association.type == 'model') { var previousPK = _.isObject(previous[key]) ? previous[key][ReferencedModel.primaryKey] : previous[key]; var newPK = _.isObject(val) ? val[this.primaryKey] : val; if (previousPK == newPK) return; // Get the inverse association definition, if any reverseAssociation = _.find(ReferencedModel.associations, {collection: this.identity, via: key}) || _.find(ReferencedModel.associations, {model: this.identity, via: key}); if (!reverseAssociation) {return;} // If this is a to-many association, do publishAdd or publishRemove as necessary // on the other side if (reverseAssociation.type == 'collection') { // If there was a previous value, alert the previously associated model if (previous[key]) { ReferencedModel.publishRemove(previousPK, reverseAssociation.alias, id, req, {noReverse:true}); } // If there's a new value (i.e. it's not null), alert the newly associated model if (val) { ReferencedModel.publishAdd(newPK, reverseAssociation.alias, id, req, {noReverse:true}); } } // Otherwise do a publishUpdate else { var pubData = {}; // If there was a previous association, notify it that it has been nullified if (previous[key]) { pubData[reverseAssociation.alias] = null; ReferencedModel.publishUpdate(previousPK, pubData, req, {noReverse:true}); } // If there's a new association, notify it that it has been linked if (val) { pubData[reverseAssociation.alias] = id; ReferencedModel.publishUpdate(newPK, pubData, req, {noReverse:true}); } } } else { // Get the reverse association definition, if any reverseAssociation = _.find(ReferencedModel.associations, {collection: this.identity, via: key}) || _.find(ReferencedModel.associations, {model: this.identity, alias: association.via}); if (!reverseAssociation) {return;} // If we can't get the previous PKs (b/c previous isn't populated), bail if (typeof(previous[key]) == 'undefined') return; // Get the previous set of IDs var previousPKs = _.pluck(previous[key], ReferencedModel.primaryKey); // Get the current set of IDs var updatedPKs = _.map(val, function(_val) { if (_.isObject(_val)) { return _val[ReferencedModel.primaryKey]; } else { return _val; } }); // Find any values that were added to the collection var addedPKs = _.difference(updatedPKs, previousPKs); // Find any values that were removed from the collection var removedPKs = _.difference(previousPKs, updatedPKs); // If this is a to-many association, do publishAdd or publishRemove as necessary // on the other side if (reverseAssociation.type == 'collection') { // Alert any removed models _.each(removedPKs, function(pk) { ReferencedModel.publishRemove(pk, reverseAssociation.alias, id, req, {noReverse:true}); }); // Alert any added models _.each(addedPKs, function(pk) { ReferencedModel.publishAdd(pk, reverseAssociation.alias, id, req, {noReverse:true}); }); } // Otherwise do a publishUpdate else { // Alert any removed models _.each(removedPKs, function(pk) { var pubData = {}; pubData[reverseAssociation.alias] = null; ReferencedModel.publishUpdate(pk, pubData, req, {noReverse:true}); }); // Alert any added models _.each(addedPKs, function(pk) { var pubData = {}; pubData[reverseAssociation.alias] = id; ReferencedModel.publishUpdate(pk, pubData, req, {noReverse:true}); }); } } }, this); } // If a request object was sent, get its socket, otherwise assume a socket was sent. var socketToOmit = (req && req.socket ? req.socket : req); // In development environment, blast out a message to everyone sails.sockets.publishToFirehose(data); data.verb = 'updated'; data.previous = options.previous; delete data.model; // Broadcast to the model instance room this.publish(id, this.identity, 'update', data, socketToOmit); if (sails.util.isFunction(this.afterPublishUpdate)) { this.afterPublishUpdate(id, changes, req, options); } }, /** * Publish the destruction of a particular model * * @param {String|Finite} id * - primary key of the instance we're referring to * * @param {Request|Socket} req - if specified, broadcast using this socket (effectively omitting it) * */ publishDestroy: function (id, req, options) { var reverseAssociation; options = options || {}; // Enforce valid usage var invalidId = !id || _.isObject(id); if ( invalidId ) { return sails.log.error( 'Invalid usage of `' + this.identity + '.publishDestroy(id, [socketToOmit])`' ); } if (sails.util.isFunction(this.beforePublishDestroy)) { this.beforePublishDestroy(id, req, options); } // Coerce id to match the attribute type of the primary key of the model id = parseId(id); var data = { model: this.identity, verb: 'destroy', id: id, previous: options.previous }; // If a request object was sent, get its socket, otherwise assume a socket was sent. var socketToOmit = (req && req.socket ? req.socket : req); // In development environment, blast out a message to everyone sails.sockets.publishToFirehose(data); data.verb = 'destroyed'; delete data.model; // Broadcast to the model instance room this.publish(id, this.identity, 'destroy', data, socketToOmit); // Unsubscribe everyone from the model instance this.retire(id); sails.hooks.sockets.broadcastAdminMessage("retire", {model: this.identity, id: id}); if (options.previous) { var previous = options.previous; // Loop through associations and alert as necessary _.each(this.associations, function(association) { var ReferencedModel; // If it's a to-one association, and it wasn't falsy, alert // the reverse side if (association.type == 'model' && [association.alias] && previous[association.alias]) { ReferencedModel = sails.models[association.model]; // Get the inverse association definition, if any reverseAssociation = _.find(ReferencedModel.associations, {collection: this.identity}) || _.find(ReferencedModel.associations, {model: this.identity}); if (reverseAssociation) { // If it's a to-one, publish a simple update alert var referencedModelId = _.isObject(previous[association.alias]) ? previous[association.alias][ReferencedModel.primaryKey] : previous[association.alias]; if (reverseAssociation.type == 'model') { var pubData = {}; pubData[reverseAssociation.alias] = null; ReferencedModel.publishUpdate(referencedModelId, pubData, req, {noReverse:true}); } // If it's a to-many, publish a "removed" alert else { ReferencedModel.publishRemove(referencedModelId, reverseAssociation.alias, id, req, {noReverse:true}); } } } else if (association.type == 'collection' && previous[association.alias].length) { ReferencedModel = sails.models[association.collection]; // Get the inverse association definition, if any reverseAssociation = _.find(ReferencedModel.associations, {collection: this.identity}) || _.find(ReferencedModel.associations, {model: this.identity}); if (reverseAssociation) { _.each(previous[association.alias], function(associatedModel) { // If it's a to-one, publish a simple update alert if (reverseAssociation.type == 'model') { var pubData = {}; pubData[reverseAssociation.alias] = null; ReferencedModel.publishUpdate(associatedModel[ReferencedModel.primaryKey], pubData, req, {noReverse:true}); } // If it's a to-many, publish a "removed" alert else { ReferencedModel.publishRemove(associatedModel[ReferencedModel.primaryKey], reverseAssociation.alias, id, req, {noReverse:true}); } }); } } }, this); } if (sails.util.isFunction(this.afterPublishDestroy)) { this.afterPublishDestroy(id, req, options); } }, /** * publishAdd * * @param {[type]} id [description] * @param {[type]} alias [description] * @param {[type]} idAdded [description] * @param {[type]} socketToOmit [description] */ publishAdd: function(id, alias, added, req, options) { var reverseAssociation; // Make sure there's an options object options = options || {}; // Enforce valid usage var invalidId = !id || _.isObject(id); var invalidAlias = !alias || !_.isString(alias); var invalidAddedId = !added || _.isArray(added); if ( invalidId || invalidAlias || invalidAddedId ) { return sails.log.error( 'Invalid usage of `' + this.identity + '.publishAdd(id, alias, idAdded|recordAdded, [socketToOmit])`' ); } // Get the model on the opposite side of the association var reverseModel = sails.models[_.find(this.associations, {alias: alias}).collection]; // Determine whether `added` was provided as a pk value or an object var idAdded; // If it is a pk value, we'll turn it into `idAdded`: if (!_.isObject(added)) { idAdded = added; added = undefined; } // Otherwise we'll leave it as `added` for use below, and determine `idAdded` by examining the object // using our knowledge of what the name of the primary key attribute is. else { idAdded = added[reverseModel.primaryKey]; // If we don't find a primary key value, we'll log an error and return early. if (!_.isString(idAdded) && !_.isNumber(idAdded)) { sails.log.error( 'Invalid usage of publishAdd(): expected object provided '+ 'for `recordAdded` to have a "%s" attribute', reverseModel.primaryKey ); return; } } // Lifecycle event if (sails.util.isFunction(this.beforePublishAdd)) { this.beforePublishAdd(id, alias, idAdded, req); } // If a request object was sent, get its socket, otherwise assume a socket was sent. var socketToOmit = (req && req.socket ? req.socket : req); // In development environment, blast out a message to everyone sails.sockets.publishToFirehose({ id: id, model: this.identity, verb: 'addedTo', attribute: alias, addedId: idAdded }); this.publish(id, this.identity, 'add:'+alias, (function (){ var event = { id: id, verb: 'addedTo', attribute: alias, addedId: idAdded }; if (added) { event.added = added; } return event; })(), socketToOmit); if (!options.noReverse) { var data; // Subscribe to the model you're adding if (req && req.isSocket) { data = {}; data[reverseModel.primaryKey] = idAdded; reverseModel.subscribe(req, data); } // Find the reverse association, if any reverseAssociation = _.find(reverseModel.associations, {alias: _.find(this.associations, {alias: alias}).via}) ; if (reverseAssociation) { // If this is a many-to-many association, do a publishAdd for the // other side. if (reverseAssociation.type == 'collection') { reverseModel.publishAdd(idAdded, reverseAssociation.alias, id, req, {noReverse:true}); } // Otherwise, do a publishUpdate else { data = {}; data[reverseAssociation.alias] = id; reverseModel.publishUpdate(idAdded, data, req, {noReverse:true}); } } } if (sails.util.isFunction(this.afterPublishAdd)) { this.afterPublishAdd(id, alias, idAdded, req); } }, /** * publishRemove * * @param {[type]} id [description] * @param {[type]} alias [description] * @param {[type]} idRemoved [description] * @param {[type]} socketToOmit [description] */ publishRemove: function(id, alias, idRemoved, req, options) { var reverseAssociation; // Make sure there's an options object options = options || {}; // Enforce valid usage var invalidId = !id || _.isObject(id); var invalidAlias = !alias || !_.isString(alias); var invalidRemovedId = !idRemoved || _.isObject(idRemoved); if ( invalidId || invalidAlias || invalidRemovedId ) { return sails.log.error( 'Invalid usage of `' + this.identity + '.publishRemove(id, alias, idRemoved, [socketToOmit])`' ); } if (sails.util.isFunction(this.beforePublishRemove)) { this.beforePublishRemove(id, alias, idRemoved, req); } // Coerce id to match the attribute type of the primary key of the model id = parseId(id); // If a request object was sent, get its socket, otherwise assume a socket was sent. var socketToOmit = (req && req.socket ? req.socket : req); // In development environment, blast out a message to everyone sails.sockets.publishToFirehose({ id: id, model: this.identity, verb: 'removedFrom', attribute: alias, removedId: idRemoved }); this.publish(id, this.identity, 'remove:' + alias, { id: id, verb: 'removedFrom', attribute: alias, removedId: idRemoved }, socketToOmit); if (!options.noReverse) { // Get the reverse association, if any var reverseModel = sails.models[_.find(this.associations, {alias: alias}).collection]; reverseAssociation = _.find(reverseModel.associations, {alias: _.find(this.associations, {alias: alias}).via}); if (reverseAssociation) { // If this is a many-to-many association, do a publishAdd for the // other side. if (reverseAssociation.type == 'collection') { reverseModel.publishRemove(idRemoved, reverseAssociation.alias, id, req, {noReverse:true}); } // Otherwise, do a publishUpdate else { var data = {}; data[reverseAssociation.alias] = null; reverseModel.publishUpdate(idRemoved, data, req, {noReverse:true}); } } } if (sails.util.isFunction(this.afterPublishRemove)) { this.afterPublishRemove(id, alias, idRemoved, req); } }, /** * Publish the creation of a model * * @param {Object} values * - the data to publish * * @param {Request|Socket} req - if specified, broadcast using this socket (effectively omitting it) * @api private */ publishCreate: function(values, req, options) { var self = this; var reverseAssociation; options = options || {}; if (sails.util.isUndefined(values[this.primaryKey])) { return sails.log.error( 'Invalid usage of publishCreate() :: ' + 'Values must have an `'+this.primaryKey+'`, instead got ::\n' + util.inspect(values) ); } if (sails.util.isFunction(this.beforePublishCreate)) { this.beforePublishCreate(values, req); } var id = values[this.primaryKey]; // Coerce id to match the attribute type of the primary key of the model id = parseId(id); // If any of the added values were association attributes, publish add or remove messages. _.each(values, function(val, key) { // If the user hasn't yet given this association a value, bail out if (val === null) { return; } var association = _.find(this.associations, {alias: key}); // If the attribute isn't an assoctiation, return if (!association) return; // Get the associated model class var ReferencedModel = sails.models[association.type == 'model' ? association.model : association.collection]; // Bail if the model doesn't exist if (!ReferencedModel) return; // Bail if this attribute isn't in the model's schema if (association.type == 'model') { // Get the inverse association definition, if any reverseAssociation = _.find(ReferencedModel.associations, {collection: this.identity, via: key}) || _.find(ReferencedModel.associations, {model: this.identity, via: key}); if (!reverseAssociation) {return;} // If this is a to-many association, do publishAdd on the other side // TODO -- support nested creates. For now, we can't tell if an object value here represents // a NEW object or an existing one, so we'll ignore it. if (reverseAssociation.type == 'collection' && !_.isObject(val)) { ReferencedModel.publishAdd(val, reverseAssociation.alias, id, req, {noReverse:true}); } // Otherwise do a publishUpdate // TODO -- support nested creates. For now, we can't tell if an object value here represents // a NEW object or an existing one, so we'll ignore it. else { var pubData = {}; if (!_.isObject(val)) { pubData[reverseAssociation.alias] = id; ReferencedModel.publishUpdate(val, pubData, req, {noReverse:true}); } } } else { // Get the inverse association definition, if any reverseAssociation = _.find(ReferencedModel.associations, {collection: this.identity, via: key}) || _.find(ReferencedModel.associations, {model: this.identity, alias: association.via}); if (!reverseAssociation) {return;} // If this is a to-many association, do publishAdds on the other side if (reverseAssociation.type == 'collection') { // Alert any added models _.each(val, function(pk) { // TODO -- support nested creates. For now, we can't tell if an object value here represents // a NEW object or an existing one, so we'll ignore it. if (_.isObject(pk)) return; ReferencedModel.publishAdd(pk, reverseAssociation.alias, id, req, {noReverse:true}); }); } // Otherwise do a publishUpdate else { // Alert any added models _.each(val, function(pk) { // TODO -- support nested creates. For now, we can't tell if an object value here represents // a NEW object or an existing one, so we'll ignore it. if (_.isObject(pk)) return; var pubData = {}; pubData[reverseAssociation.alias] = id; ReferencedModel.publishUpdate(pk, pubData, req, {noReverse:true}); }); } } }, this); // Ensure that we're working with a plain object values = _.clone(values); // If a request object was sent, get its socket, otherwise assume a socket was sent. var socketToOmit = (req && req.socket ? req.socket : req); // Blast success message sails.sockets.publishToFirehose({ model: this.identity, verb: 'create', data: values, id: values[this.primaryKey] }); // Publish to classroom var eventName = this.identity; this.broadcast(this._classRoom(), eventName, { verb: 'created', data: values, id: values[this.primaryKey] }, socketToOmit); // Also broadcasts a message to the legacy class room (derived by // using the `:legacy_v0.9` trailer on the class room name). // Uses traditional eventName === "message". // Uses traditional message format. if (sails.config.sockets['backwardsCompatibilityFor0.9SocketClients']) { var legacyData = _.cloneDeep({ verb: 'create', data: values, model: self.identity, id: values[this.primaryKey] }); var legacyRoom = this._classRoom()+':legacy_v0.9'; self.broadcast( legacyRoom, 'message', legacyData, socketToOmit ); } // Subscribe watchers to the new instance if (!options.noIntroduce) { this.introduce(values[this.primaryKey]); sails.hooks.sockets.broadcastAdminMessage("introduce", {model: this.identity, id: values[this.primaryKey]}); } if (sails.util.isFunction(this.afterPublishCreate)) { this.afterPublishCreate(values, req); } }, /** * * @return {[type]} [description] */ watch: function ( req ) { var socket = sails.sockets.parseSocket(req); if (!socket) { return sails.log.warn('`Model.watch()` called by a non-socket request. Only requests originating from a connected socket may be subscribed. Ignoring...'); } sails.sockets.join(socket, this._classRoom()); sails.log.silly("Subscribed socket ", sails.sockets.id(socket), "to", this._classRoom()); // if (sails.config.sockets['backwardsCompatibilityFor0.9SocketClients'] && socket.handshake) { // var sdk = app.getSDKMetadata(socket.handshake); // var isLegacySocketClient = sdk.version === '0.9.0'; // if (isLegacySocketClient) { // sails.sockets.join(socket, this._classRoom()+':legacy_v0.9'); // } // } }, /** * [unwatch description] * @param {[type]} socket [description] * @return {[type]} [description] */ unwatch: function ( req ) { var socket = sails.sockets.parseSocket(req); if (!socket) { return sails.log.warn('`Model.unwatch()` called by a non-socket request. Only requests originating from a connected socket may be subscribed. Ignoring...'); } sails.sockets.leave(socket, this._classRoom()); sails.log.silly("Unubscribed socket ", sails.sockets.id(socket), "from", this._classRoom()); // if (sails.config.sockets['backwardsCompatibilityFor0.9SocketClients'] && socket.handshake) { // var sdk = app.getSDKMetadata(socket.handshake); // var isLegacySocketClient = sdk.version === '0.9.0'; // if (isLegacySocketClient) { // sails.sockets.leave(socket, this._classRoom()+':legacy_v0.9'); // } // } }, /** * Introduce a new instance * * Take all of the subscribers to the class room and 'introduce' them * to a new instance room * * @param {String|Finite} id * - primary key of the instance we're referring to * * @api private */ introduce: function(model) { var id = model[this.primaryKey] || model; _.each(this.watchers(), function(socket) { this.subscribe(socket, id); }, this); }, /** * Bid farewell to a destroyed instance * Take all of the socket subscribers in this instance room * and unsubscribe them from it */ retire: function(model) { var id = model[this.primaryKey] || model; _.each(this.subscribers(id), function(socket) { this.unsubscribe(socket, id); }, this); } }; } };