From 555e25bf33bb26496c9922463dc8e1accdaacbca Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Thu, 10 Mar 2016 14:27:00 -0800 Subject: [PATCH] Add LiveQuery --- package.json | 7 +- spec/Client.spec.js | 290 +++++++ spec/EventEmitterPubSub.spec.js | 44 ++ spec/ParseCloudCodePublisher.spec.js | 70 ++ spec/ParseLiveQueryServer.spec.js | 962 +++++++++++++++++++++++ spec/ParsePubSub.spec.js | 65 ++ spec/ParseWebSocket.spec.js | 43 + spec/ParseWebSocketServer.spec.js | 37 + spec/QueryTools.spec.js | 384 +++++++++ spec/RedisPubSub.spec.js | 29 + spec/SessionTokenCache.spec.js | 52 ++ spec/Subscription.spec.js | 123 +++ spec/helper.js | 19 + src/Config.js | 21 +- src/Controllers/LiveQueryController.js | 51 ++ src/LiveQuery/Client.js | 104 +++ src/LiveQuery/EventEmitterPubSub.js | 59 ++ src/LiveQuery/Id.js | 22 + src/LiveQuery/PLog.js | 41 + src/LiveQuery/ParseCloudCodePublisher.js | 37 + src/LiveQuery/ParseLiveQueryServer.js | 460 +++++++++++ src/LiveQuery/ParsePubSub.js | 29 + src/LiveQuery/ParseWebSocketServer.js | 44 ++ src/LiveQuery/QueryTools.js | 280 +++++++ src/LiveQuery/RedisPubSub.js | 18 + src/LiveQuery/RequestSchema.js | 101 +++ src/LiveQuery/SessionTokenCache.js | 38 + src/LiveQuery/Subscription.js | 55 ++ src/LiveQuery/equalObjects.js | 48 ++ src/RestWrite.js | 9 +- src/index.js | 79 +- src/rest.js | 6 +- src/testing-routes.js | 1 + 33 files changed, 3580 insertions(+), 48 deletions(-) create mode 100644 spec/Client.spec.js create mode 100644 spec/EventEmitterPubSub.spec.js create mode 100644 spec/ParseCloudCodePublisher.spec.js create mode 100644 spec/ParseLiveQueryServer.spec.js create mode 100644 spec/ParsePubSub.spec.js create mode 100644 spec/ParseWebSocket.spec.js create mode 100644 spec/ParseWebSocketServer.spec.js create mode 100644 spec/QueryTools.spec.js create mode 100644 spec/RedisPubSub.spec.js create mode 100644 spec/SessionTokenCache.spec.js create mode 100644 spec/Subscription.spec.js create mode 100644 src/Controllers/LiveQueryController.js create mode 100644 src/LiveQuery/Client.js create mode 100644 src/LiveQuery/EventEmitterPubSub.js create mode 100644 src/LiveQuery/Id.js create mode 100644 src/LiveQuery/PLog.js create mode 100644 src/LiveQuery/ParseCloudCodePublisher.js create mode 100644 src/LiveQuery/ParseLiveQueryServer.js create mode 100644 src/LiveQuery/ParsePubSub.js create mode 100644 src/LiveQuery/ParseWebSocketServer.js create mode 100644 src/LiveQuery/QueryTools.js create mode 100644 src/LiveQuery/RedisPubSub.js create mode 100644 src/LiveQuery/RequestSchema.js create mode 100644 src/LiveQuery/SessionTokenCache.js create mode 100644 src/LiveQuery/Subscription.js create mode 100644 src/LiveQuery/equalObjects.js diff --git a/package.json b/package.json index 703fb401ed..a33ca5d40f 100644 --- a/package.json +++ b/package.json @@ -29,19 +29,24 @@ "deepcopy": "^0.6.1", "express": "^4.13.4", "gcloud": "^0.28.0", + "lru-cache": "^4.0.0", "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", "node-gcm": "^0.14.0", "parse": "^1.7.0", + "redis": "^2.5.0-1", "request": "^2.65.0", - "winston": "^2.1.1" + "tv4": "^1.2.7", + "winston": "^2.1.1", + "ws": "^1.0.1" }, "devDependencies": { "babel-cli": "^6.5.1", "babel-core": "^6.5.1", "babel-istanbul": "^0.6.0", + "babel-plugin-syntax-flow": "^6.5.0", "babel-plugin-transform-flow-strip-types": "^6.5.0", "babel-preset-es2015": "^6.5.0", "babel-preset-stage-0": "^6.5.0", diff --git a/spec/Client.spec.js b/spec/Client.spec.js new file mode 100644 index 0000000000..7ebc502929 --- /dev/null +++ b/spec/Client.spec.js @@ -0,0 +1,290 @@ +var Client = require('../src/LiveQuery/Client').Client; +var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket; + +describe('Client', function() { + + it('can be initialized', function() { + var parseWebSocket = new ParseWebSocket({}); + var client = new Client(1, parseWebSocket); + + expect(client.id).toBe(1); + expect(client.parseWebSocket).toBe(parseWebSocket); + expect(client.subscriptionInfos.size).toBe(0); + }); + + it('can push response', function() { + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + Client.pushResponse(parseWebSocket, 'message'); + + expect(parseWebSocket.send).toHaveBeenCalledWith('message'); + }); + + it('can push error', function() { + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + Client.pushError(parseWebSocket, 1, 'error', true); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('error'); + expect(messageJSON.error).toBe('error'); + expect(messageJSON.code).toBe(1); + expect(messageJSON.reconnect).toBe(true); + }); + + it('can add subscription information', function() { + var subscription = {}; + var fields = ['test']; + var subscriptionInfo = { + subscription: subscription, + fields: fields + } + var client = new Client(1, {}); + client.addSubscriptionInfo(1, subscriptionInfo); + + expect(client.subscriptionInfos.size).toBe(1); + expect(client.subscriptionInfos.get(1)).toBe(subscriptionInfo); + }); + + it('can get subscription information', function() { + var subscription = {}; + var fields = ['test']; + var subscriptionInfo = { + subscription: subscription, + fields: fields + } + var client = new Client(1, {}); + client.addSubscriptionInfo(1, subscriptionInfo); + var subscriptionInfoAgain = client.getSubscriptionInfo(1); + + expect(subscriptionInfoAgain).toBe(subscriptionInfo); + }); + + it('can delete subscription information', function() { + var subscription = {}; + var fields = ['test']; + var subscriptionInfo = { + subscription: subscription, + fields: fields + } + var client = new Client(1, {}); + client.addSubscriptionInfo(1, subscriptionInfo); + client.deleteSubscriptionInfo(1); + + expect(client.subscriptionInfos.size).toBe(0); + }); + + + it('can generate ParseObject JSON with null selected field', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + }; + var client = new Client(1, {}); + + expect(client._toJSONWithFields(parseObjectJSON, null)).toBe(parseObjectJSON); + }); + + it('can generate ParseObject JSON with undefined selected field', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + }; + var client = new Client(1, {}); + + expect(client._toJSONWithFields(parseObjectJSON, undefined)).toBe(parseObjectJSON); + }); + + it('can generate ParseObject JSON with selected fields', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test' + }; + var client = new Client(1, {}); + + expect(client._toJSONWithFields(parseObjectJSON, ['test'])).toEqual({ + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test' + }); + }); + + it('can generate ParseObject JSON with nonexistent selected fields', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test' + }; + var client = new Client(1, {}); + var limitedParseObject = client._toJSONWithFields(parseObjectJSON, ['name']); + + expect(limitedParseObject).toEqual({ + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + }); + expect('name' in limitedParseObject).toBe(false); + }); + + it('can push connect response', function() { + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + var client = new Client(1, parseWebSocket); + client.pushConnect(); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('connected'); + expect(messageJSON.clientId).toBe(1); + }); + + it('can push subscribe response', function() { + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + var client = new Client(1, parseWebSocket); + client.pushSubscribe(2); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('subscribed'); + expect(messageJSON.clientId).toBe(1); + expect(messageJSON.requestId).toBe(2); + }); + + it('can push unsubscribe response', function() { + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + var client = new Client(1, parseWebSocket); + client.pushUnsubscribe(2); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('unsubscribed'); + expect(messageJSON.clientId).toBe(1); + expect(messageJSON.requestId).toBe(2); + }); + + it('can push create response', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test' + }; + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + var client = new Client(1, parseWebSocket); + client.pushCreate(2, parseObjectJSON); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('create'); + expect(messageJSON.clientId).toBe(1); + expect(messageJSON.requestId).toBe(2); + expect(messageJSON.object).toEqual(parseObjectJSON); + }); + + it('can push enter response', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test' + }; + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + var client = new Client(1, parseWebSocket); + client.pushEnter(2, parseObjectJSON); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('enter'); + expect(messageJSON.clientId).toBe(1); + expect(messageJSON.requestId).toBe(2); + expect(messageJSON.object).toEqual(parseObjectJSON); + }); + + it('can push update response', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test' + }; + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + var client = new Client(1, parseWebSocket); + client.pushUpdate(2, parseObjectJSON); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('update'); + expect(messageJSON.clientId).toBe(1); + expect(messageJSON.requestId).toBe(2); + expect(messageJSON.object).toEqual(parseObjectJSON); + }); + + it('can push leave response', function() { + var parseObjectJSON = { + key : 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test' + }; + var parseWebSocket = { + send: jasmine.createSpy('send') + }; + var client = new Client(1, parseWebSocket); + client.pushLeave(2, parseObjectJSON); + + var lastCall = parseWebSocket.send.calls.first(); + var messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('leave'); + expect(messageJSON.clientId).toBe(1); + expect(messageJSON.requestId).toBe(2); + expect(messageJSON.object).toEqual(parseObjectJSON); + }); +}); diff --git a/spec/EventEmitterPubSub.spec.js b/spec/EventEmitterPubSub.spec.js new file mode 100644 index 0000000000..abfa9fb232 --- /dev/null +++ b/spec/EventEmitterPubSub.spec.js @@ -0,0 +1,44 @@ +var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; + +describe('EventEmitterPubSub', function() { + + it('can publish and subscribe', function() { + var publisher = EventEmitterPubSub.createPublisher(); + var subscriber = EventEmitterPubSub.createSubscriber(); + subscriber.subscribe('testChannel'); + // Register mock checked for subscriber + var isChecked = false; + subscriber.on('message', function(channel, message) { + isChecked = true; + expect(channel).toBe('testChannel'); + expect(message).toBe('testMessage'); + }); + + publisher.publish('testChannel', 'testMessage'); + // Make sure the callback is checked + expect(isChecked).toBe(true); + }); + + it('can unsubscribe', function() { + var publisher = EventEmitterPubSub.createPublisher(); + var subscriber = EventEmitterPubSub.createSubscriber(); + subscriber.subscribe('testChannel'); + subscriber.unsubscribe('testChannel'); + // Register mock checked for subscriber + var isCalled = false; + subscriber.on('message', function(channel, message) { + isCalled = true; + }); + + publisher.publish('testChannel', 'testMessage'); + // Make sure the callback is not called + expect(isCalled).toBe(false); + }); + + it('can unsubscribe not subscribing channel', function() { + var subscriber = EventEmitterPubSub.createSubscriber(); + + // Make sure subscriber does not throw exception + subscriber.unsubscribe('testChannel'); + }); +}); diff --git a/spec/ParseCloudCodePublisher.spec.js b/spec/ParseCloudCodePublisher.spec.js new file mode 100644 index 0000000000..c018af05f2 --- /dev/null +++ b/spec/ParseCloudCodePublisher.spec.js @@ -0,0 +1,70 @@ +var ParseCloudCodePublisher = require('../src/LiveQuery/ParseCloudCodePublisher').ParseCloudCodePublisher; +var Parse = require('parse/node'); + +describe('ParseCloudCodePublisher', function() { + + beforeEach(function(done) { + // Mock ParsePubSub + var mockParsePubSub = { + createPublisher: jasmine.createSpy('publish').and.returnValue({ + publish: jasmine.createSpy('publish'), + on: jasmine.createSpy('on') + }), + createSubscriber: jasmine.createSpy('publish').and.returnValue({ + subscribe: jasmine.createSpy('subscribe'), + on: jasmine.createSpy('on') + }) + }; + jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); + done(); + }); + + it('can initialize', function() { + var config = {} + var publisher = new ParseCloudCodePublisher(config); + + var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub; + expect(ParsePubSub.createPublisher).toHaveBeenCalledWith(config); + }); + + it('can handle cloud code afterSave request', function() { + var publisher = new ParseCloudCodePublisher({}); + publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage'); + var request = {}; + publisher.onCloudCodeAfterSave(request); + + expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith('afterSave', request); + }); + + it('can handle cloud code afterDelete request', function() { + var publisher = new ParseCloudCodePublisher({}); + publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage'); + var request = {}; + publisher.onCloudCodeAfterDelete(request); + + expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith('afterDelete', request); + }); + + it('can handle cloud code request', function() { + var publisher = new ParseCloudCodePublisher({}); + var currentParseObject = new Parse.Object('Test'); + currentParseObject.set('key', 'value'); + var originalParseObject = new Parse.Object('Test'); + originalParseObject.set('key', 'originalValue'); + var request = { + object: currentParseObject, + original: originalParseObject + }; + publisher._onCloudCodeMessage('afterSave', request); + + var args = publisher.parsePublisher.publish.calls.mostRecent().args; + expect(args[0]).toBe('afterSave'); + var message = JSON.parse(args[1]); + expect(message.currentParseObject).toEqual(request.object._toFullJSON()); + expect(message.originalParseObject).toEqual(request.original._toFullJSON()); + }); + + afterEach(function(){ + jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub'); + }); +}); diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js new file mode 100644 index 0000000000..b672fb30b2 --- /dev/null +++ b/spec/ParseLiveQueryServer.spec.js @@ -0,0 +1,962 @@ +var Parse = require('parse/node'); +var ParseLiveQueryServer = require('../src/LiveQuery/ParseLiveQueryServer').ParseLiveQueryServer; + +// Global mock info +var queryHashValue = 'hash'; +var testUserId = 'userId'; +var testClassName = 'TestObject'; + +describe('ParseLiveQueryServer', function() { + + beforeEach(function(done) { + // Mock ParseWebSocketServer + var mockParseWebSocketServer = jasmine.createSpy('ParseWebSocketServer'); + jasmine.mockLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer', mockParseWebSocketServer); + // Mock Client + var mockClient = function() { + this.pushConnect = jasmine.createSpy('pushConnect'); + this.pushSubscribe = jasmine.createSpy('pushSubscribe'); + this.pushUnsubscribe = jasmine.createSpy('pushUnsubscribe'); + this.pushDelete = jasmine.createSpy('pushDelete'); + this.pushCreate = jasmine.createSpy('pushCreate'); + this.pushEnter = jasmine.createSpy('pushEnter'); + this.pushUpdate = jasmine.createSpy('pushUpdate'); + this.pushLeave = jasmine.createSpy('pushLeave'); + this.addSubscriptionInfo = jasmine.createSpy('addSubscriptionInfo'); + this.getSubscriptionInfo = jasmine.createSpy('getSubscriptionInfo'); + this.deleteSubscriptionInfo = jasmine.createSpy('deleteSubscriptionInfo'); + } + mockClient.pushError = jasmine.createSpy('pushError'); + jasmine.mockLibrary('../src/LiveQuery/Client', 'Client', mockClient); + // Mock Subscription + var mockSubscriotion = function() { + this.addClientSubscription = jasmine.createSpy('addClientSubscription'); + this.deleteClientSubscription = jasmine.createSpy('deleteClientSubscription'); + } + jasmine.mockLibrary('../src/LiveQuery/Subscription', 'Subscription', mockSubscriotion); + // Mock queryHash + var mockQueryHash = jasmine.createSpy('matchesQuery').and.returnValue(queryHashValue); + jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'queryHash', mockQueryHash); + // Mock matchesQuery + var mockMatchesQuery = jasmine.createSpy('matchesQuery').and.returnValue(true); + jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'matchesQuery', mockMatchesQuery); + // Mock tv4 + var mockValidate = function() { + return true; + } + jasmine.mockLibrary('tv4', 'validate', mockValidate); + // Mock ParsePubSub + var mockParsePubSub = { + createPublisher: function() { + return { + publish: jasmine.createSpy('publish'), + on: jasmine.createSpy('on') + } + }, + createSubscriber: function() { + return { + subscribe: jasmine.createSpy('subscribe'), + on: jasmine.createSpy('on') + } + } + }; + jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); + // Make mock SessionTokenCache + var mockSessionTokenCache = function(){ + this.getUserId = function(sessionToken){ + if (typeof sessionToken === 'undefined') { + return Parse.Promise.as(undefined); + } + if (sessionToken === null) { + return Parse.Promise.error(); + } + return Parse.Promise.as(testUserId); + }; + }; + jasmine.mockLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache', mockSessionTokenCache); + done(); + }); + + it('can be initialized', function() { + var httpServer = {}; + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, httpServer); + + expect(parseLiveQueryServer.clientId).toBe(0); + expect(parseLiveQueryServer.clients.size).toBe(0); + expect(parseLiveQueryServer.subscriptions.size).toBe(0); + }); + + it('can handle connect command', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var parseWebSocket = { + clientId: -1 + }; + parseLiveQueryServer._validateKeys = jasmine.createSpy('validateKeys').and.returnValue(true); + parseLiveQueryServer._handleConnect(parseWebSocket); + + expect(parseLiveQueryServer.clientId).toBe(1); + expect(parseWebSocket.clientId).toBe(0); + var client = parseLiveQueryServer.clients.get(0); + expect(client).not.toBeNull(); + // Make sure we send connect response to the client + expect(client.pushConnect).toHaveBeenCalled(); + }); + + it('can handle subscribe command without clientId', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var incompleteParseConn = { + }; + parseLiveQueryServer._handleSubscribe(incompleteParseConn, {}); + + var Client = require('../src/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('can handle subscribe command with new query', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Handle mock subscription + var parseWebSocket = { + clientId: clientId + }; + var query = { + className: 'test', + where: { + key: 'value' + }, + fields: [ 'test' ] + } + var requestId = 2; + var request = { + query: query, + requestId: requestId, + sessionToken: 'sessionToken' + } + parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + // Make sure we add the subscription to the server + var subscriptions = parseLiveQueryServer.subscriptions; + expect(subscriptions.size).toBe(1); + expect(subscriptions.get(query.className)).not.toBeNull(); + var classSubscriptions = subscriptions.get(query.className); + expect(classSubscriptions.size).toBe(1); + expect(classSubscriptions.get('hash')).not.toBeNull(); + // TODO(check subscription constructor to verify we pass the right argument) + // Make sure we add clientInfo to the subscription + var subscription = classSubscriptions.get('hash'); + expect(subscription.addClientSubscription).toHaveBeenCalledWith(clientId, requestId); + // Make sure we add subscriptionInfo to the client + var args = client.addSubscriptionInfo.calls.first().args; + expect(args[0]).toBe(requestId); + expect(args[1].fields).toBe(query.fields); + expect(args[1].sessionToken).toBe(request.sessionToken); + // Make sure we send subscribe response to the client + expect(client.pushSubscribe).toHaveBeenCalledWith(requestId); + }); + + it('can handle subscribe command with existing query', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Add two mock clients + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + var clientIdAgain = 2; + var clientAgain = addMockClient(parseLiveQueryServer, clientIdAgain); + // Add subscription for mock client 1 + var parseWebSocket = { + clientId: clientId + }; + var requestId = 2; + var query = { + className: 'test', + where: { + key: 'value' + }, + fields: [ 'test' ] + } + addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + // Add subscription for mock client 2 + var parseWebSocketAgain = { + clientId: clientIdAgain + }; + var queryAgain = { + className: 'test', + where: { + key: 'value' + }, + fields: [ 'testAgain' ] + } + var requestIdAgain = 1; + addMockSubscription(parseLiveQueryServer, clientIdAgain, requestIdAgain, parseWebSocketAgain, queryAgain); + + // Make sure we only have one subscription + var subscriptions = parseLiveQueryServer.subscriptions; + expect(subscriptions.size).toBe(1); + expect(subscriptions.get(query.className)).not.toBeNull(); + var classSubscriptions = subscriptions.get(query.className); + expect(classSubscriptions.size).toBe(1); + expect(classSubscriptions.get('hash')).not.toBeNull(); + // Make sure we add clientInfo to the subscription + var subscription = classSubscriptions.get('hash'); + // Make sure client 2 info has been added + var args = subscription.addClientSubscription.calls.mostRecent().args; + expect(args).toEqual([clientIdAgain, requestIdAgain]); + // Make sure we add subscriptionInfo to the client 2 + args = clientAgain.addSubscriptionInfo.calls.mostRecent().args; + expect(args[0]).toBe(requestIdAgain); + expect(args[1].fields).toBe(queryAgain.fields); + }); + + it('can handle unsubscribe command without clientId', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var incompleteParseConn = { + }; + parseLiveQueryServer._handleUnsubscribe(incompleteParseConn, {}); + + var Client = require('../src/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('can handle unsubscribe command without not existed client', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var parseWebSocket = { + clientId: 1 + }; + parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {}); + + var Client = require('../src/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('can handle unsubscribe command without not existed query', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Handle unsubscribe command + var parseWebSocket = { + clientId: 1 + }; + parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {}); + + var Client = require('../src/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('can handle unsubscribe command', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Add subscription for mock client + var parseWebSocket = { + clientId: 1 + }; + var requestId = 2; + var subscription = addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket); + // Mock client.getSubscriptionInfo + var subscriptionInfo = client.addSubscriptionInfo.calls.mostRecent().args[1]; + client.getSubscriptionInfo = function() { + return subscriptionInfo; + }; + // Handle unsubscribe command + var requestAgain = { + requestId: requestId + }; + parseLiveQueryServer._handleUnsubscribe(parseWebSocket, requestAgain); + + // Make sure we delete subscription from client + expect(client.deleteSubscriptionInfo).toHaveBeenCalledWith(requestId); + // Make sure we delete client from subscription + expect(subscription.deleteClientSubscription).toHaveBeenCalledWith(clientId, requestId); + // Make sure we clear subscription in the server + var subscriptions = parseLiveQueryServer.subscriptions; + expect(subscriptions.size).toBe(0); + }); + + it('can set connect command message handler for a parseWebSocket', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Register mock connect/subscribe/unsubscribe handler for the server + parseLiveQueryServer._handleConnect = jasmine.createSpy('_handleSubscribe'); + // Make mock parseWebsocket + var EventEmitter = require('events'); + var parseWebSocket = new EventEmitter(); + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Check connect request + var connectRequest = { + op: 'connect' + }; + // Trigger message event + parseWebSocket.emit('message', connectRequest); + // Make sure _handleConnect is called + var args = parseLiveQueryServer._handleConnect.calls.mostRecent().args; + expect(args[0]).toBe(parseWebSocket); + }); + + it('can set subscribe command message handler for a parseWebSocket', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Register mock connect/subscribe/unsubscribe handler for the server + parseLiveQueryServer._handleSubscribe = jasmine.createSpy('_handleSubscribe'); + // Make mock parseWebsocket + var EventEmitter = require('events'); + var parseWebSocket = new EventEmitter(); + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Check subscribe request + var subscribeRequest = '{"op":"subscribe"}'; + // Trigger message event + parseWebSocket.emit('message', subscribeRequest); + // Make sure _handleSubscribe is called + var args = parseLiveQueryServer._handleSubscribe.calls.mostRecent().args; + expect(args[0]).toBe(parseWebSocket); + expect(JSON.stringify(args[1])).toBe(subscribeRequest); + }); + + it('can set unsubscribe command message handler for a parseWebSocket', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Register mock connect/subscribe/unsubscribe handler for the server + parseLiveQueryServer._handleUnsubscribe = jasmine.createSpy('_handleSubscribe'); + // Make mock parseWebsocket + var EventEmitter = require('events'); + var parseWebSocket = new EventEmitter(); + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Check unsubscribe request + var unsubscribeRequest = '{"op":"unsubscribe"}'; + // Trigger message event + parseWebSocket.emit('message', unsubscribeRequest); + // Make sure _handleUnsubscribe is called + var args = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent().args; + expect(args[0]).toBe(parseWebSocket); + expect(JSON.stringify(args[1])).toBe(unsubscribeRequest); + }); + + it('can set unknown command message handler for a parseWebSocket', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock parseWebsocket + var EventEmitter = require('events'); + var parseWebSocket = new EventEmitter(); + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Check unknown request + var unknownRequest = '{"op":"unknown"}'; + // Trigger message event + parseWebSocket.emit('message', unknownRequest); + var Client = require('../src/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('can set disconnect command message handler for a parseWebSocket which has not registered to the server', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var EventEmitter = require('events'); + var parseWebSocket = new EventEmitter(); + parseWebSocket.clientId = 1; + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Make sure we do not crash + // Trigger disconnect event + parseWebSocket.emit('disconnect'); + }); + + // TODO: Test server can set disconnect command message handler for a parseWebSocket + + it('has no subscription and can handle object delete command', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make deletedParseObject + var parseObject = new Parse.Object(testClassName); + parseObject._finishFetch({ + key: 'value', + className: testClassName + }); + // Make mock message + var message = { + currentParseObject: parseObject + }; + // Make sure we do not crash in this case + parseLiveQueryServer._onAfterDelete(message, {}); + }); + + it('can handle object delete command which does not match any subscription', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make deletedParseObject + var parseObject = new Parse.Object(testClassName); + parseObject._finishFetch({ + key: 'value', + className: testClassName + }); + // Make mock message + var message = { + currentParseObject: parseObject + }; + + // Add mock client + var clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + // Add mock subscription + var requestId = 2; + addMockSubscription(parseLiveQueryServer, clientId, requestId); + var client = parseLiveQueryServer.clients.get(clientId); + // Mock _matchesSubscription to return not matching + parseLiveQueryServer._matchesSubscription = function() { + return false; + }; + parseLiveQueryServer._matchesACL = function() { + return true; + }; + parseLiveQueryServer._onAfterDelete(message); + + // Make sure we do not send command to client + expect(client.pushDelete).not.toHaveBeenCalled(); + }); + + it('can handle object delete command which matches some subscriptions', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make deletedParseObject + var parseObject = new Parse.Object(testClassName); + parseObject._finishFetch({ + key: 'value', + className: testClassName + }); + // Make mock message + var message = { + currentParseObject: parseObject + }; + // Add mock client + var clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + // Add mock subscription + var requestId = 2; + addMockSubscription(parseLiveQueryServer, clientId, requestId); + var client = parseLiveQueryServer.clients.get(clientId); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function() { + return true; + }; + parseLiveQueryServer._matchesACL = function() { + return Parse.Promise.as(true); + }; + parseLiveQueryServer._onAfterDelete(message); + + // Make sure we send command to client, since _matchesACL is async, we have to + // wait and check + setTimeout(function() { + expect(client.pushDelete).toHaveBeenCalled(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + + it('has no subscription and can handle object save command', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock request message + var message = generateMockMessage(); + // Make sure we do not crash in this case + parseLiveQueryServer._onAfterSave(message); + }); + + it('can handle object save command which does not match any subscription', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock request message + var message = generateMockMessage(); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Add mock subscription + var requestId = 2; + addMockSubscription(parseLiveQueryServer, clientId, requestId); + // Mock _matchesSubscription to return not matching + parseLiveQueryServer._matchesSubscription = function() { + return false; + }; + parseLiveQueryServer._matchesACL = function() { + return Parse.Promise.as(true) + }; + // Trigger onAfterSave + parseLiveQueryServer._onAfterSave(message); + + // Make sure we do not send command to client + setTimeout(function(){ + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + + it('can handle object enter command which matches some subscriptions', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock request message + var message = generateMockMessage(true); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Add mock subscription + var requestId = 2; + addMockSubscription(parseLiveQueryServer, clientId, requestId); + // Mock _matchesSubscription to return matching + // In order to mimic a enter, we need original match return false + // and the current match return true + var counter = 0; + parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + if (!parseObject) { + return false; + } + counter += 1; + return counter % 2 === 0; + }; + parseLiveQueryServer._matchesACL = function() { + return Parse.Promise.as(true) + }; + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send enter command to client + setTimeout(function(){ + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + + it('can handle object update command which matches some subscriptions', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock request message + var message = generateMockMessage(true); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Add mock subscription + var requestId = 2; + addMockSubscription(parseLiveQueryServer, clientId, requestId); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + if (!parseObject) { + return false; + } + return true; + }; + parseLiveQueryServer._matchesACL = function() { + return Parse.Promise.as(true) + }; + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send update command to client + setTimeout(function(){ + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + + it('can handle object leave command which matches some subscriptions', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock request message + var message = generateMockMessage(true); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Add mock subscription + var requestId = 2; + addMockSubscription(parseLiveQueryServer, clientId, requestId); + // Mock _matchesSubscription to return matching + // In order to mimic a leave, we need original match return true + // and the current match return false + var counter = 0; + parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + if (!parseObject) { + return false; + } + counter += 1; + return counter % 2 !== 0; + }; + parseLiveQueryServer._matchesACL = function() { + return Parse.Promise.as(true) + }; + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send leave command to client + setTimeout(function(){ + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).toHaveBeenCalled(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + + it('can handle object create command which matches some subscriptions', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock request message + var message = generateMockMessage(); + // Add mock client + var clientId = 1; + var client = addMockClient(parseLiveQueryServer, clientId); + // Add mock subscription + var requestId = 2; + addMockSubscription(parseLiveQueryServer, clientId, requestId); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + if (!parseObject) { + return false; + } + return true; + }; + parseLiveQueryServer._matchesACL = function() { + return Parse.Promise.as(true) + }; + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send create command to client + setTimeout(function(){ + expect(client.pushCreate).toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + + it('can match subscription for null or undefined parse object', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock subscription + var subscription = { + match: jasmine.createSpy('match') + } + + expect(parseLiveQueryServer._matchesSubscription(null, subscription)).toBe(false); + expect(parseLiveQueryServer._matchesSubscription(undefined, subscription)).toBe(false); + // Make sure subscription.match is not called + expect(subscription.match).not.toHaveBeenCalled(); + }); + + it('can match subscription', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock subscription + var subscription = { + query: {} + } + var parseObject = {}; + expect(parseLiveQueryServer._matchesSubscription(parseObject, subscription)).toBe(true); + // Make sure matchesQuery is called + var matchesQuery = require('../src/LiveQuery/QueryTools').matchesQuery; + expect(matchesQuery).toHaveBeenCalledWith(parseObject, subscription.query); + }); + + it('can inflate parse object', function() { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Make mock request + var objectJSON = { + "className":"testClassName", + "createdAt":"2015-12-22T01:51:12.955Z", + "key":"value", + "objectId":"BfwxBCz6yW", + "updatedAt":"2016-01-05T00:46:45.659Z" + }; + var originalObjectJSON = { + "className":"testClassName", + "createdAt":"2015-12-22T01:51:12.955Z", + "key":"originalValue", + "objectId":"BfwxBCz6yW", + "updatedAt":"2016-01-05T00:46:45.659Z" + }; + var message = { + currentParseObject: objectJSON, + originalParseObject: originalObjectJSON + }; + // Inflate the object + parseLiveQueryServer._inflateParseObject(message); + + // Verify object + var object = message.currentParseObject; + expect(object instanceof Parse.Object).toBeTruthy(); + expect(object.get('key')).toEqual('value'); + expect(object.className).toEqual('testClassName'); + expect(object.id).toBe('BfwxBCz6yW'); + expect(object.createdAt).not.toBeUndefined(); + expect(object.updatedAt).not.toBeUndefined(); + // Verify original object + var originalObject = message.originalParseObject; + expect(originalObject instanceof Parse.Object).toBeTruthy(); + expect(originalObject.get('key')).toEqual('originalValue'); + expect(originalObject.className).toEqual('testClassName'); + expect(originalObject.id).toBe('BfwxBCz6yW'); + expect(originalObject.createdAt).not.toBeUndefined(); + expect(originalObject.updatedAt).not.toBeUndefined(); + }); + + it('can match undefined ACL', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var client = {}; + var requestId = 0; + + parseLiveQueryServer._matchesACL(undefined, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); + }); + + it('can match ACL with none exist requestId', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var acl = new Parse.ACL(); + var client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue(undefined) + }; + var requestId = 0; + + var isChecked = false; + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + + it('can match ACL with public read access', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + var client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: 'sessionToken' + }) + }; + var requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); + }); + + it('can match ACL with valid subscription sessionToken', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + var client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: 'sessionToken' + }) + }; + var requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); + }); + + it('can match ACL with valid client sessionToken', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return false when sessionToken is undefined + var client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: undefined + }) + }; + var requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); + }); + + it('can match ACL with invalid subscription and client sessionToken', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return false when sessionToken is undefined + var client = { + sessionToken: undefined, + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: undefined + }) + }; + var requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + + it('can match ACL with subscription sessionToken checking error', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return error when sessionToken is null, this is just + // the behaviour of our mock sessionTokenCache, not real sessionTokenCache + var client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: null + }) + }; + var requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + + it('can match ACL with client sessionToken checking error', function(done) { + var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + var acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return error when sessionToken is null + var client = { + sessionToken: null, + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: null + }) + }; + var requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + + it('can validate key when valid key is provided', function() { + var parseLiveQueryServer = new ParseLiveQueryServer({}, { + keyPairs: { + clientKey: 'test' + } + }); + var request = { + clientKey: 'test' + } + + expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); + }); + + it('can validate key when invalid key is provided', function() { + var parseLiveQueryServer = new ParseLiveQueryServer({}, { + keyPairs: { + clientKey: 'test' + } + }); + var request = { + clientKey: 'error' + } + + expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + }); + + it('can validate key when key is not provided', function() { + var parseLiveQueryServer = new ParseLiveQueryServer({}, { + keyPairs: { + clientKey: 'test' + } + }); + var request = { + } + + expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + }); + + it('can validate key when validKerPairs is empty', function() { + var parseLiveQueryServer = new ParseLiveQueryServer({}, {}); + var request = { + } + + expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); + }); + + afterEach(function(){ + jasmine.restoreLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer'); + jasmine.restoreLibrary('../src/LiveQuery/Client', 'Client'); + jasmine.restoreLibrary('../src/LiveQuery/Subscription', 'Subscription'); + jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'queryHash'); + jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'matchesQuery'); + jasmine.restoreLibrary('tv4', 'validate'); + jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub'); + jasmine.restoreLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache'); + }); + + // Helper functions to add mock client and subscription to a liveQueryServer + function addMockClient(parseLiveQueryServer, clientId) { + var Client = require('../src/LiveQuery/Client').Client; + var client = new Client(clientId, {}); + parseLiveQueryServer.clients.set(clientId, client); + return client; + } + + function addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query) { + // If parseWebSocket is null, we use the default one + if (!parseWebSocket) { + var EventEmitter = require('events'); + parseWebSocket = new EventEmitter(); + } + parseWebSocket.clientId = clientId; + // If query is null, we use the default one + if (!query) { + query = { + className: testClassName, + where: { + key: 'value' + }, + fields: [ 'test' ] + }; + } + var request = { + query: query, + requestId: requestId, + sessionToken: 'sessionToken' + }; + parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + // Make mock subscription + var subscription = parseLiveQueryServer.subscriptions.get(query.className).get(queryHashValue); + subscription.hasSubscribingClient = function() { + return false; + } + subscription.className = query.className; + subscription.hash = queryHashValue; + if (subscription.clientRequestIds && subscription.clientRequestIds.has(clientId)) { + subscription.clientRequestIds.get(clientId).push(requestId); + } else { + subscription.clientRequestIds = new Map([[clientId, [requestId]]]); + } + return subscription; + } + + // Helper functiosn to generate request message + function generateMockMessage(hasOriginalParseObject) { + var parseObject = new Parse.Object(testClassName); + parseObject._finishFetch({ + key: 'value', + className: testClassName + }); + var message = { + currentParseObject: parseObject + }; + if (hasOriginalParseObject) { + var originalParseObject = new Parse.Object(testClassName); + originalParseObject._finishFetch({ + key: 'originalValue', + className: testClassName + }); + message.originalParseObject = originalParseObject; + } + return message; + } +}); diff --git a/spec/ParsePubSub.spec.js b/spec/ParsePubSub.spec.js new file mode 100644 index 0000000000..3cf676447e --- /dev/null +++ b/spec/ParsePubSub.spec.js @@ -0,0 +1,65 @@ +var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub; + +describe('ParsePubSub', function() { + + beforeEach(function(done) { + // Mock RedisPubSub + var mockRedisPubSub = { + createPublisher: jasmine.createSpy('createPublisherRedis'), + createSubscriber: jasmine.createSpy('createSubscriberRedis') + }; + jasmine.mockLibrary('../src/LiveQuery/RedisPubSub', 'RedisPubSub', mockRedisPubSub); + // Mock EventEmitterPubSub + var mockEventEmitterPubSub = { + createPublisher: jasmine.createSpy('createPublisherEventEmitter'), + createSubscriber: jasmine.createSpy('createSubscriberEventEmitter') + }; + jasmine.mockLibrary('../src/LiveQuery/EventEmitterPubSub', 'EventEmitterPubSub', mockEventEmitterPubSub); + done(); + }); + + it('can create redis publisher', function() { + var publisher = ParsePubSub.createPublisher({ + redisURL: 'redisURL' + }); + + var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; + var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; + expect(RedisPubSub.createPublisher).toHaveBeenCalledWith('redisURL'); + expect(EventEmitterPubSub.createPublisher).not.toHaveBeenCalled(); + }); + + it('can create event emitter publisher', function() { + var publisher = ParsePubSub.createPublisher({}); + + var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; + var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; + expect(RedisPubSub.createPublisher).not.toHaveBeenCalled(); + expect(EventEmitterPubSub.createPublisher).toHaveBeenCalled(); + }); + + it('can create redis subscriber', function() { + var subscriber = ParsePubSub.createSubscriber({ + redisURL: 'redisURL' + }); + + var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; + var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; + expect(RedisPubSub.createSubscriber).toHaveBeenCalledWith('redisURL'); + expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled(); + }); + + it('can create event emitter subscriber', function() { + var subscriptionInfos = ParsePubSub.createSubscriber({}); + + var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; + var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; + expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled(); + expect(EventEmitterPubSub.createSubscriber).toHaveBeenCalled(); + }); + + afterEach(function(){ + jasmine.restoreLibrary('../src/LiveQuery/RedisPubSub', 'RedisPubSub'); + jasmine.restoreLibrary('../src/LiveQuery/EventEmitterPubSub', 'EventEmitterPubSub'); + }); +}); diff --git a/spec/ParseWebSocket.spec.js b/spec/ParseWebSocket.spec.js new file mode 100644 index 0000000000..11a7ae214b --- /dev/null +++ b/spec/ParseWebSocket.spec.js @@ -0,0 +1,43 @@ +var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket; + +describe('ParseWebSocket', function() { + + it('can be initialized', function() { + var ws = {}; + var parseWebSocket = new ParseWebSocket(ws); + + expect(parseWebSocket.ws).toBe(ws); + }); + + it('can handle events defined in typeMap', function() { + var ws = { + on: jasmine.createSpy('on') + }; + var callback = {}; + var parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.on('disconnect', callback); + + expect(parseWebSocket.ws.on).toHaveBeenCalledWith('close', callback); + }); + + it('can handle events which are not defined in typeMap', function() { + var ws = { + on: jasmine.createSpy('on') + }; + var callback = {}; + var parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.on('open', callback); + + expect(parseWebSocket.ws.on).toHaveBeenCalledWith('open', callback); + }); + + it('can send a message', function() { + var ws = { + send: jasmine.createSpy('send') + }; + var parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.send('message') + + expect(parseWebSocket.ws.send).toHaveBeenCalledWith('message'); + }); +}); diff --git a/spec/ParseWebSocketServer.spec.js b/spec/ParseWebSocketServer.spec.js new file mode 100644 index 0000000000..1ccba41543 --- /dev/null +++ b/spec/ParseWebSocketServer.spec.js @@ -0,0 +1,37 @@ +var ParseWebSocketServer = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocketServer; + +describe('ParseWebSocketServer', function() { + + beforeEach(function(done) { + // Mock ws server + var EventEmitter = require('events'); + var mockServer = function() { + return new EventEmitter(); + }; + jasmine.mockLibrary('ws', 'Server', mockServer); + done(); + }); + + it('can handle connect event when ws is open', function(done) { + var onConnectCallback = jasmine.createSpy('onConnectCallback'); + var parseWebSocketServer = new ParseWebSocketServer({}, onConnectCallback, 5).server; + var ws = { + readyState: 0, + OPEN: 0, + ping: jasmine.createSpy('ping') + }; + parseWebSocketServer.emit('connection', ws); + + // Make sure callback is called + expect(onConnectCallback).toHaveBeenCalled(); + // Make sure we ping to the client + setTimeout(function() { + expect(ws.ping).toHaveBeenCalled(); + done(); + }, 10) + }); + + afterEach(function(){ + jasmine.restoreLibrary('ws', 'Server'); + }); +}); diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js new file mode 100644 index 0000000000..3e794b7053 --- /dev/null +++ b/spec/QueryTools.spec.js @@ -0,0 +1,384 @@ +var Parse = require('parse/node'); + +var Id = require('../src/LiveQuery/Id'); +var QueryTools = require('../src/LiveQuery/QueryTools'); +var queryHash = QueryTools.queryHash; +var matchesQuery = QueryTools.matchesQuery; + +var Item = Parse.Object.extend('Item'); + +describe('queryHash', function() { + + it('should always hash a query to the same string', function() { + var q = new Parse.Query(Item); + q.equalTo('field', 'value'); + q.exists('name'); + q.ascending('createdAt'); + q.limit(10); + var firstHash = queryHash(q); + var secondHash = queryHash(q); + expect(firstHash).toBe(secondHash); + }); + + it('should return equivalent hashes for equivalent queries', function() { + var q1 = new Parse.Query(Item); + q1.equalTo('field', 'value'); + q1.exists('name'); + q1.lessThan('age', 30); + q1.greaterThan('age', 3); + q1.ascending('createdAt'); + q1.include(['name', 'age']); + q1.limit(10); + + var q2 = new Parse.Query(Item); + q2.limit(10); + q2.greaterThan('age', 3); + q2.lessThan('age', 30); + q2.include(['name', 'age']); + q2.ascending('createdAt'); + q2.exists('name'); + q2.equalTo('field', 'value'); + + var firstHash = queryHash(q1); + var secondHash = queryHash(q2); + expect(firstHash).toBe(secondHash); + + q1.containedIn('fruit', ['apple', 'banana', 'cherry']); + firstHash = queryHash(q1); + expect(firstHash).not.toBe(secondHash); + + q2.containedIn('fruit', ['banana', 'cherry', 'apple']); + secondHash = queryHash(q2); + expect(secondHash).toBe(firstHash); + + q1.containedIn('fruit', ['coconut']); + firstHash = queryHash(q1); + expect(firstHash).not.toBe(secondHash); + + q1 = new Parse.Query(Item); + q1.equalTo('field', 'value'); + q1.lessThan('age', 30); + q1.exists('name'); + + q2 = new Parse.Query(Item); + q2.equalTo('name', 'person'); + q2.equalTo('field', 'other'); + + firstHash = queryHash(Parse.Query.or(q1, q2)); + secondHash = queryHash(Parse.Query.or(q2, q1)); + expect(firstHash).toBe(secondHash); + }); + + it('should not let fields of different types appear similar', function() { + var q1 = new Parse.Query(Item); + q1.lessThan('age', 30); + + var q2 = new Parse.Query(Item); + q2.equalTo('age', '{$lt:30}'); + + expect(queryHash(q1)).not.toBe(queryHash(q2)); + + q1 = new Parse.Query(Item); + q1.equalTo('age', 15); + + q2.equalTo('age', '15'); + + expect(queryHash(q1)).not.toBe(queryHash(q2)); + }); +}); + +describe('matchesQuery', function() { + it('matches blanket queries', function() { + var obj = { + id: new Id('Klass', 'O1'), + value: 12 + }; + var q = new Parse.Query('Klass'); + expect(matchesQuery(obj, q)).toBe(true); + + obj.id = new Id('Other', 'O1'); + expect(matchesQuery(obj, q)).toBe(false); + }); + + it('matches existence queries', function() { + var obj = { + id: new Id('Item', 'O1'), + count: 15 + }; + var q = new Parse.Query('Item'); + q.exists('count'); + expect(matchesQuery(obj, q)).toBe(true); + q.exists('name'); + expect(matchesQuery(obj, q)).toBe(false); + }); + + it('matches on equality queries', function() { + var day = new Date(); + var location = new Parse.GeoPoint({ + latitude: 37.484815, + longitude: -122.148377 + }); + var obj = { + id: new Id('Person', 'O1'), + score: 12, + name: 'Bill', + birthday: day, + lastLocation: location + }; + + var q = new Parse.Query('Person'); + q.equalTo('score', 12); + expect(matchesQuery(obj, q)).toBe(true); + + q = new Parse.Query('Person'); + q.equalTo('name', 'Bill'); + expect(matchesQuery(obj, q)).toBe(true); + q.equalTo('name', 'Jeff'); + expect(matchesQuery(obj, q)).toBe(false); + + q = new Parse.Query('Person'); + q.containedIn('name', ['Adam', 'Ben', 'Charles']); + expect(matchesQuery(obj, q)).toBe(false); + q.containedIn('name', ['Adam', 'Bill', 'Charles']); + expect(matchesQuery(obj, q)).toBe(true); + + q = new Parse.Query('Person'); + q.notContainedIn('name', ['Adam', 'Bill', 'Charles']); + expect(matchesQuery(obj, q)).toBe(false); + q.notContainedIn('name', ['Adam', 'Ben', 'Charles']); + expect(matchesQuery(obj, q)).toBe(true); + + q = new Parse.Query('Person'); + q.equalTo('birthday', day); + expect(matchesQuery(obj, q)).toBe(true); + q.equalTo('birthday', new Date()); + expect(matchesQuery(obj, q)).toBe(false); + + q = new Parse.Query('Person'); + q.equalTo('lastLocation', new Parse.GeoPoint({ + latitude: 37.484815, + longitude: -122.148377 + })); + expect(matchesQuery(obj, q)).toBe(true); + q.equalTo('lastLocation', new Parse.GeoPoint({ + latitude: 37.4848, + longitude: -122.1483 + })); + expect(matchesQuery(obj, q)).toBe(false); + + q.equalTo('lastLocation', new Parse.GeoPoint({ + latitude: 37.484815, + longitude: -122.148377 + })); + q.equalTo('score', 12); + q.equalTo('name', 'Bill'); + q.equalTo('birthday', day); + expect(matchesQuery(obj, q)).toBe(true); + + q.equalTo('name', 'bill'); + expect(matchesQuery(obj, q)).toBe(false); + + var img = { + id: new Id('Image', 'I1'), + tags: ['nofilter', 'latergram', 'tbt'] + }; + + q = new Parse.Query('Image'); + q.equalTo('tags', 'selfie'); + expect(matchesQuery(img, q)).toBe(false); + q.equalTo('tags', 'tbt'); + expect(matchesQuery(img, q)).toBe(true); + + var q2 = new Parse.Query('Image'); + q2.containsAll('tags', ['latergram', 'nofilter']); + expect(matchesQuery(img, q2)).toBe(true); + q2.containsAll('tags', ['latergram', 'selfie']); + expect(matchesQuery(img, q2)).toBe(false); + + var u = new Parse.User(); + u.id = 'U2'; + q = new Parse.Query('Image'); + q.equalTo('owner', u); + + img = { + className: 'Image', + objectId: 'I1', + owner: { + className: '_User', + objectId: 'U2' + } + }; + expect(matchesQuery(img, q)).toBe(true); + + img.owner.objectId = 'U3'; + expect(matchesQuery(img, q)).toBe(false); + }); + + it('matches on inequalities', function() { + var player = { + id: new Id('Person', 'O1'), + score: 12, + name: 'Bill', + birthday: new Date(1980, 2, 4), + }; + var q = new Parse.Query('Person'); + q.lessThan('score', 15); + expect(matchesQuery(player, q)).toBe(true); + q.lessThan('score', 10); + expect(matchesQuery(player, q)).toBe(false); + + q = new Parse.Query('Person'); + q.lessThanOrEqualTo('score', 15); + expect(matchesQuery(player, q)).toBe(true); + q.lessThanOrEqualTo('score', 12); + expect(matchesQuery(player, q)).toBe(true); + q.lessThanOrEqualTo('score', 10); + expect(matchesQuery(player, q)).toBe(false); + + q = new Parse.Query('Person'); + q.greaterThan('score', 15); + expect(matchesQuery(player, q)).toBe(false); + q.greaterThan('score', 10); + expect(matchesQuery(player, q)).toBe(true); + + q = new Parse.Query('Person'); + q.greaterThanOrEqualTo('score', 15); + expect(matchesQuery(player, q)).toBe(false); + q.greaterThanOrEqualTo('score', 12); + expect(matchesQuery(player, q)).toBe(true); + q.greaterThanOrEqualTo('score', 10); + expect(matchesQuery(player, q)).toBe(true); + + q = new Parse.Query('Person'); + q.notEqualTo('score', 12); + expect(matchesQuery(player, q)).toBe(false); + q.notEqualTo('score', 40); + expect(matchesQuery(player, q)).toBe(true); + }); + + it('matches an $or query', function() { + var player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + score: 12 + }; + var q = new Parse.Query('Player'); + q.equalTo('name', 'Player 1'); + var q2 = new Parse.Query('Player'); + q2.equalTo('name', 'Player 2'); + var orQuery = Parse.Query.or(q, q2); + expect(matchesQuery(player, q)).toBe(true); + expect(matchesQuery(player, q2)).toBe(false); + expect(matchesQuery(player, orQuery)).toBe(true); + }); + + it('matches $regex queries', function() { + var player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + score: 12 + }; + + var q = new Parse.Query('Player'); + q.startsWith('name', 'Play'); + expect(matchesQuery(player, q)).toBe(true); + q.startsWith('name', 'Ploy'); + expect(matchesQuery(player, q)).toBe(false); + + q = new Parse.Query('Player'); + q.endsWith('name', ' 1'); + expect(matchesQuery(player, q)).toBe(true); + q.endsWith('name', ' 2'); + expect(matchesQuery(player, q)).toBe(false); + + // Check that special characters are escaped + player.name = 'Android-7'; + q = new Parse.Query('Player'); + q.contains('name', 'd-7'); + expect(matchesQuery(player, q)).toBe(true); + + q = new Parse.Query('Player'); + q.matches('name', /A.d/); + expect(matchesQuery(player, q)).toBe(true); + + q.matches('name', /A[^n]d/); + expect(matchesQuery(player, q)).toBe(false); + + // Check that the string \\E is returned to normal + player.name = 'Slash \\E'; + q = new Parse.Query('Player'); + q.endsWith('name', 'h \\E'); + expect(matchesQuery(player, q)).toBe(true); + + q.endsWith('name', 'h \\Ee'); + expect(matchesQuery(player, q)).toBe(false); + + player.name = 'Slash \\Q and more'; + q = new Parse.Query('Player'); + q.contains('name', 'h \\Q and'); + expect(matchesQuery(player, q)).toBe(true); + q.contains('name', 'h \\Q or'); + expect(matchesQuery(player, q)).toBe(false); + }); + + it('matches $nearSphere queries', function() { + var q = new Parse.Query('Checkin'); + q.near('location', new Parse.GeoPoint(20, 20)); + // With no max distance, any GeoPoint is 'near' + var pt = { + id: new Id('Checkin', 'C1'), + location: new Parse.GeoPoint(40, 40) + }; + expect(matchesQuery(pt, q)).toBe(true); + + q = new Parse.Query('Checkin'); + pt.location = new Parse.GeoPoint(40, 40); + q.withinRadians('location', new Parse.GeoPoint(30, 30), 0.3); + expect(matchesQuery(pt, q)).toBe(true); + + q.withinRadians('location', new Parse.GeoPoint(30, 30), 0.2); + expect(matchesQuery(pt, q)).toBe(false); + }); + + it('matches $within queries', function() { + var caltrainStation = { + id: new Id('Checkin', 'C1'), + location: new Parse.GeoPoint(37.776346, -122.394218), + name: 'Caltrain' + }; + + var santaClara = { + id: new Id('Checkin', 'C2'), + location: new Parse.GeoPoint(37.325635, -121.945753), + name: 'Santa Clara' + }; + + var q = new Parse.Query('Checkin').withinGeoBox( + 'location', + new Parse.GeoPoint(37.708813, -122.526398), + new Parse.GeoPoint(37.822802, -122.373962) + ); + + expect(matchesQuery(caltrainStation, q)).toBe(true); + expect(matchesQuery(santaClara, q)).toBe(false); + + // Invalid rectangles + q = new Parse.Query('Checkin').withinGeoBox( + 'location', + new Parse.GeoPoint(37.822802, -122.373962), + new Parse.GeoPoint(37.708813, -122.526398) + ); + + expect(matchesQuery(caltrainStation, q)).toBe(false); + expect(matchesQuery(santaClara, q)).toBe(false); + + q = new Parse.Query('Checkin').withinGeoBox( + 'location', + new Parse.GeoPoint(37.708813, -122.373962), + new Parse.GeoPoint(37.822802, -122.526398) + ); + + expect(matchesQuery(caltrainStation, q)).toBe(false); + expect(matchesQuery(santaClara, q)).toBe(false); + }); +}); diff --git a/spec/RedisPubSub.spec.js b/spec/RedisPubSub.spec.js new file mode 100644 index 0000000000..097a678d67 --- /dev/null +++ b/spec/RedisPubSub.spec.js @@ -0,0 +1,29 @@ +var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; + +describe('RedisPubSub', function() { + + beforeEach(function(done) { + // Mock redis + var createClient = jasmine.createSpy('createClient'); + jasmine.mockLibrary('redis', 'createClient', createClient); + done(); + }); + + it('can create publisher', function() { + var publisher = RedisPubSub.createPublisher('redisAddress'); + + var redis = require('redis'); + expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true }); + }); + + it('can create subscriber', function() { + var subscriber = RedisPubSub.createSubscriber('redisAddress'); + + var redis = require('redis'); + expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true }); + }); + + afterEach(function() { + jasmine.restoreLibrary('redis', 'createClient'); + }); +}); diff --git a/spec/SessionTokenCache.spec.js b/spec/SessionTokenCache.spec.js new file mode 100644 index 0000000000..b02a8bf891 --- /dev/null +++ b/spec/SessionTokenCache.spec.js @@ -0,0 +1,52 @@ +var SessionTokenCache = require('../src/LiveQuery/SessionTokenCache').SessionTokenCache; + +describe('SessionTokenCache', function() { + + beforeEach(function(done) { + var Parse = require('parse/node'); + // Mock parse + var mockUser = { + become: jasmine.createSpy('become').and.returnValue(Parse.Promise.as({ + id: 'userId' + })) + } + jasmine.mockLibrary('parse/node', 'User', mockUser); + done(); + }); + + it('can get undefined userId', function(done) { + var sessionTokenCache = new SessionTokenCache(); + + sessionTokenCache.getUserId(undefined).then((userIdFromCache) => { + }, (error) => { + expect(error).not.toBeNull(); + done(); + }); + }); + + it('can get existing userId', function(done) { + var sessionTokenCache = new SessionTokenCache(); + var sessionToken = 'sessionToken'; + var userId = 'userId' + sessionTokenCache.cache.set(sessionToken, userId); + + sessionTokenCache.getUserId(sessionToken).then((userIdFromCache) => { + expect(userIdFromCache).toBe(userId); + done(); + }); + }); + + it('can get new userId', function(done) { + var sessionTokenCache = new SessionTokenCache(); + + sessionTokenCache.getUserId('sessionToken').then((userIdFromCache) => { + expect(userIdFromCache).toBe('userId'); + expect(sessionTokenCache.cache.length).toBe(1); + done(); + }); + }); + + afterEach(function() { + jasmine.restoreLibrary('parse/node', 'User'); + }); +}); diff --git a/spec/Subscription.spec.js b/spec/Subscription.spec.js new file mode 100644 index 0000000000..a9f35020be --- /dev/null +++ b/spec/Subscription.spec.js @@ -0,0 +1,123 @@ +var Subscription = require('../src/LiveQuery/Subscription').Subscription; + +describe('Subscription', function() { + + beforeEach(function() { + var mockError = jasmine.createSpy('error'); + jasmine.mockLibrary('../src/LiveQuery/PLog', 'error', mockError); + }); + + it('can be initialized', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + + expect(subscription.className).toBe('className'); + expect(subscription.query).toEqual({ key : 'value' }); + expect(subscription.hash).toBe('hash'); + expect(subscription.clientRequestIds.size).toBe(0); + }); + + it('can check it has subscribing clients', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + + expect(subscription.hasSubscribingClient()).toBe(false); + }); + + it('can check it does not have subscribing clients', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + + expect(subscription.hasSubscribingClient()).toBe(true); + }); + + it('can add one request for one client', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + + expect(subscription.clientRequestIds.size).toBe(1); + expect(subscription.clientRequestIds.get(1)).toEqual([1]); + }); + + it('can add requests for one client', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + subscription.addClientSubscription(1, 2); + + expect(subscription.clientRequestIds.size).toBe(1); + expect(subscription.clientRequestIds.get(1)).toEqual([1, 2]); + }); + + it('can add requests for clients', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + subscription.addClientSubscription(1, 2); + subscription.addClientSubscription(2, 2); + subscription.addClientSubscription(2, 3); + + expect(subscription.clientRequestIds.size).toBe(2); + expect(subscription.clientRequestIds.get(1)).toEqual([1, 2]); + expect(subscription.clientRequestIds.get(2)).toEqual([2, 3]); + }); + + it('can delete requests for nonexistent client', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.deleteClientSubscription(1, 1); + + var PLog =require('../src/LiveQuery/PLog'); + expect(PLog.error).toHaveBeenCalled(); + }); + + it('can delete nonexistent request for one client', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + subscription.deleteClientSubscription(1, 2); + + var PLog =require('../src/LiveQuery/PLog'); + expect(PLog.error).toHaveBeenCalled(); + expect(subscription.clientRequestIds.size).toBe(1); + expect(subscription.clientRequestIds.get(1)).toEqual([1]); + }); + + it('can delete some requests for one client', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + subscription.addClientSubscription(1, 2); + subscription.deleteClientSubscription(1, 2); + + var PLog =require('../src/LiveQuery/PLog'); + expect(PLog.error).not.toHaveBeenCalled(); + expect(subscription.clientRequestIds.size).toBe(1); + expect(subscription.clientRequestIds.get(1)).toEqual([1]); + }); + + it('can delete all requests for one client', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + subscription.addClientSubscription(1, 2); + subscription.deleteClientSubscription(1, 1); + subscription.deleteClientSubscription(1, 2); + + var PLog =require('../src/LiveQuery/PLog'); + expect(PLog.error).not.toHaveBeenCalled(); + expect(subscription.clientRequestIds.size).toBe(0); + }); + + it('can delete requests for multiple clients', function() { + var subscription = new Subscription('className', { key : 'value' }, 'hash'); + subscription.addClientSubscription(1, 1); + subscription.addClientSubscription(1, 2); + subscription.addClientSubscription(2, 1); + subscription.addClientSubscription(2, 2); + subscription.deleteClientSubscription(1, 2); + subscription.deleteClientSubscription(2, 1); + subscription.deleteClientSubscription(2, 2); + + var PLog =require('../src/LiveQuery/PLog'); + expect(PLog.error).not.toHaveBeenCalled(); + expect(subscription.clientRequestIds.size).toBe(1); + expect(subscription.clientRequestIds.get(1)).toEqual([1]); + }); + + afterEach(function(){ + jasmine.restoreLibrary('../src/LiveQuery/PLog', 'error'); + }); +}); diff --git a/spec/helper.js b/spec/helper.js index 407135b273..37491dd7a2 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -252,3 +252,22 @@ global.jequal = jequal; global.range = range; global.setServerConfiguration = setServerConfiguration; global.defaultConfiguration = defaultConfiguration; + +// LiveQuery test setting +require('../src/LiveQuery/PLog').logLevel = 'NONE'; +var libraryCache = {}; +jasmine.mockLibrary = function(library, name, mock) { + var original = require(library)[name]; + if (!libraryCache[library]) { + libraryCache[library] = {}; + } + require(library)[name] = mock; + libraryCache[library][name] = original; +} + +jasmine.restoreLibrary = function(library, name) { + if (!libraryCache[library] || !libraryCache[library][name]) { + throw 'Can not find library ' + library + ' ' + name; + } + require(library)[name] = libraryCache[library][name]; +} diff --git a/src/Config.js b/src/Config.js index e80c3b2872..4e599bde39 100644 --- a/src/Config.js +++ b/src/Config.js @@ -22,7 +22,7 @@ export class Config { this.facebookAppIds = cacheInfo.facebookAppIds; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); - + this.serverURL = cacheInfo.serverURL; this.publicServerURL = cacheInfo.publicServerURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; @@ -36,14 +36,15 @@ export class Config { this.authDataManager = cacheInfo.authDataManager; this.customPages = cacheInfo.customPages || {}; this.mount = mount; + this.liveQueryController = cacheInfo.liveQueryController; } - + static validate(options) { - this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails, - appName: options.appName, + this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails, + appName: options.appName, publicServerURL: options.publicServerURL}) } - + static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) { if (verifyUserEmails) { if (typeof appName !== 'string') { @@ -58,23 +59,23 @@ export class Config { get invalidLinkURL() { return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`; } - + get verifyEmailSuccessURL() { return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`; } - + get choosePasswordURL() { return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`; } - + get requestResetPasswordURL() { return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`; } - + get passwordResetSuccessURL() { return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`; } - + get verifyEmailURL() { return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`; } diff --git a/src/Controllers/LiveQueryController.js b/src/Controllers/LiveQueryController.js new file mode 100644 index 0000000000..e68c1466a7 --- /dev/null +++ b/src/Controllers/LiveQueryController.js @@ -0,0 +1,51 @@ +import { ParseCloudCodePublisher } from '../LiveQuery/ParseCloudCodePublisher'; + +export class LiveQueryController { + classNames: any; + liveQueryPublisher: any; + + constructor(config: any) { + let classNames; + // If config is empty, we just assume no classs needs to be registered as LiveQuery + if (!config || !config.classNames) { + this.classNames = new Set(); + } else if (config.classNames instanceof Array) { + this.classNames = new Set(config.classNames); + } else { + throw 'liveQuery.classes should be an array of string' + } + this.liveQueryPublisher = new ParseCloudCodePublisher(config); + } + + onAfterSave(className: string, currentObject: any, originalObject: any) { + if (!this.hasLiveQuery(className)) { + return; + } + let req = this._makePublisherRequest(currentObject, originalObject); + this.liveQueryPublisher.onCloudCodeAfterSave(req); + } + + onAfterDelete(className: string, currentObject: any, originalObject: any) { + if (!this.hasLiveQuery(className)) { + return; + } + let req = this._makePublisherRequest(currentObject, originalObject); + this.liveQueryPublisher.onCloudCodeAfterDelete(req); + } + + hasLiveQuery(className: string): boolean { + return this.classNames.has(className); + } + + _makePublisherRequest(currentObject: any, originalObject: any): any { + let req = { + object: currentObject + }; + if (currentObject) { + req.original = originalObject; + } + return req; + } +} + +export default LiveQueryController; diff --git a/src/LiveQuery/Client.js b/src/LiveQuery/Client.js new file mode 100644 index 0000000000..72e4a9d393 --- /dev/null +++ b/src/LiveQuery/Client.js @@ -0,0 +1,104 @@ +import PLog from './PLog'; +import Parse from 'parse/node'; + +import type { FlattenedObjectData } from './Subscription'; +export type Message = { [attr: string]: any }; + +let dafaultFields = ['className', 'objectId', 'updatedAt', 'createdAt', 'ACL']; + +class Client { + id: number; + parseWebSocket: any; + userId: string; + roles: Array; + subscriptionInfos: Object; + pushConnect: Function; + pushSubscribe: Function; + pushUnsubscribe: Function; + pushCreate: Function; + pushEnter: Function; + pushUpdate: Function; + pushDelete: Function; + pushLeave: Function; + + constructor(id: number, parseWebSocket: any) { + this.id = id; + this.parseWebSocket = parseWebSocket; + this.roles = []; + this.subscriptionInfos = new Map(); + this.pushConnect = this._pushEvent('connected'); + this.pushSubscribe = this._pushEvent('subscribed'); + this.pushUnsubscribe = this._pushEvent('unsubscribed'); + this.pushCreate = this._pushEvent('create'); + this.pushEnter = this._pushEvent('enter'); + this.pushUpdate = this._pushEvent('update'); + this.pushDelete = this._pushEvent('delete'); + this.pushLeave = this._pushEvent('leave'); + } + + static pushResponse(parseWebSocket: any, message: Message): void { + PLog.verbose('Push Response : %j', message); + parseWebSocket.send(message); + } + + static pushError(parseWebSocket: any, code: number, error: string, reconnect: boolean = true): void { + Client.pushResponse(parseWebSocket, JSON.stringify({ + 'op': 'error', + 'error': error, + 'code': code, + 'reconnect': reconnect + })); + } + + addSubscriptionInfo(requestId: number, subscriptionInfo: any): void { + this.subscriptionInfos.set(requestId, subscriptionInfo); + } + + getSubscriptionInfo(requestId: numner): any { + return this.subscriptionInfos.get(requestId); + } + + deleteSubscriptionInfo(requestId: number): void { + return this.subscriptionInfos.delete(requestId); + } + + _pushEvent(type: string): Function { + return function(subscriptionId: number, parseObjectJSON: any): void { + let response: Message = { + 'op' : type, + 'clientId' : this.id + }; + if (typeof subscriptionId !== 'undefined') { + response['requestId'] = subscriptionId; + } + if (typeof parseObjectJSON !== 'undefined') { + let fields; + if (this.subscriptionInfos.has(subscriptionId)) { + fields = this.subscriptionInfos.get(subscriptionId).fields; + } + response['object'] = this._toJSONWithFields(parseObjectJSON, fields); + } + Client.pushResponse(this.parseWebSocket, JSON.stringify(response)); + } + } + + _toJSONWithFields(parseObjectJSON: any, fields: any): FlattenedObjectData { + if (!fields) { + return parseObjectJSON; + } + let limitedParseObject = {}; + for (let field of dafaultFields) { + limitedParseObject[field] = parseObjectJSON[field]; + } + for (let field of fields) { + if (field in parseObjectJSON) { + limitedParseObject[field] = parseObjectJSON[field]; + } + } + return limitedParseObject; + } +} + +export { + Client +} diff --git a/src/LiveQuery/EventEmitterPubSub.js b/src/LiveQuery/EventEmitterPubSub.js new file mode 100644 index 0000000000..7318d082f0 --- /dev/null +++ b/src/LiveQuery/EventEmitterPubSub.js @@ -0,0 +1,59 @@ +import events from 'events'; + +let emitter = new events.EventEmitter(); + +class Publisher { + emitter: any; + + constructor(emitter: any) { + this.emitter = emitter; + } + + publish(channel: string, message: string): void { + this.emitter.emit(channel, message); + } +} + +class Subscriber extends events.EventEmitter { + emitter: any; + subscriptions: any; + + constructor(emitter: any) { + super(); + this.emitter = emitter; + this.subscriptions = new Map(); + } + + subscribe(channel: string): void { + let handler = (message) => { + this.emit('message', channel, message); + } + this.subscriptions.set(channel, handler); + this.emitter.on(channel, handler); + } + + unsubscribe(channel: string): void { + if (!this.subscriptions.has(channel)) { + return; + } + this.emitter.removeListener(channel, this.subscriptions.get(channel)); + this.subscriptions.delete(channel); + } +} + +function createPublisher(): any { + return new Publisher(emitter); +} + +function createSubscriber(): any { + return new Subscriber(emitter); +} + +let EventEmitterPubSub = { + createPublisher, + createSubscriber +} + +export { + EventEmitterPubSub +} diff --git a/src/LiveQuery/Id.js b/src/LiveQuery/Id.js new file mode 100644 index 0000000000..40da60b72e --- /dev/null +++ b/src/LiveQuery/Id.js @@ -0,0 +1,22 @@ +class Id { + className: string; + objectId: string; + + constructor(className: string, objectId: string) { + this.className = className; + this.objectId = objectId; + } + toString(): string { + return this.className + ':' + this.objectId; + } + + static fromString(str: string) { + var split = str.split(':'); + if (split.length !== 2) { + throw new TypeError('Cannot create Id object from this string'); + } + return new Id(split[0], split[1]); + } +} + +module.exports = Id; diff --git a/src/LiveQuery/PLog.js b/src/LiveQuery/PLog.js new file mode 100644 index 0000000000..d2e3d9b5a2 --- /dev/null +++ b/src/LiveQuery/PLog.js @@ -0,0 +1,41 @@ +let LogLevel = { + 'VERBOSE': 0, + 'DEBUG': 1, + 'INFO': 2, + 'ERROR': 3, + 'NONE': 4 +} + +function getCurrentLogLevel() { + if (PLog.logLevel && PLog.logLevel in LogLevel) { + return LogLevel[PLog.logLevel]; + } + return LogLevel['ERROR']; +} + +function verbose(): void { + if (getCurrentLogLevel() <= LogLevel['VERBOSE']) { + console.log.apply(console, arguments) + } +} + +function log(): void { + if (getCurrentLogLevel() <= LogLevel['INFO']) { + console.log.apply(console, arguments) + } +} + +function error(): void { + if (getCurrentLogLevel() <= LogLevel['ERROR']) { + console.error.apply(console, arguments) + } +} + +let PLog = { + log: log, + error: error, + verbose: verbose, + logLevel: 'INFO' +}; + +module.exports = PLog; diff --git a/src/LiveQuery/ParseCloudCodePublisher.js b/src/LiveQuery/ParseCloudCodePublisher.js new file mode 100644 index 0000000000..ac5e9d3483 --- /dev/null +++ b/src/LiveQuery/ParseCloudCodePublisher.js @@ -0,0 +1,37 @@ +import { ParsePubSub } from './ParsePubSub'; +import PLog from './PLog'; + +class ParseCloudCodePublisher { + parsePublisher: Object; + + // config object of the publisher, right now it only contains the redisURL, + // but we may extend it later. + constructor(config: any = {}) { + this.parsePublisher = ParsePubSub.createPublisher(config); + } + + onCloudCodeAfterSave(request: any): void { + this._onCloudCodeMessage('afterSave', request); + } + + onCloudCodeAfterDelete(request: any): void { + this._onCloudCodeMessage('afterDelete', request); + } + + // Request is the request object from cloud code functions. request.object is a ParseObject. + _onCloudCodeMessage(type: string, request: any): void { + PLog.verbose('Raw request from cloud code current : %j | original : %j', request.object, request.original); + // We need the full JSON which includes className + let message = { + currentParseObject: request.object._toFullJSON() + } + if (request.original) { + message.originalParseObject = request.original._toFullJSON(); + } + this.parsePublisher.publish(type, JSON.stringify(message)); + } +} + +export { + ParseCloudCodePublisher +} diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js new file mode 100644 index 0000000000..ff768a5064 --- /dev/null +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -0,0 +1,460 @@ +import tv4 from 'tv4'; +import Parse from 'parse/node'; +import { Subscription } from './Subscription'; +import { Client } from './Client'; +import { ParseWebSocketServer } from './ParseWebSocketServer'; +import PLog from './PLog'; +import RequestSchema from './RequestSchema'; +import { matchesQuery, queryHash } from './QueryTools'; +import { ParsePubSub } from './ParsePubSub'; +import { SessionTokenCache } from './SessionTokenCache'; + +class ParseLiveQueryServer { + clientId: number; + clients: Object; + // className -> (queryHash -> subscription) + subscriptions: Object; + parseWebSocketServer: Object; + keyPairs : any; + // The subscriber we use to get object update from publisher + subscriber: Object; + + constructor(server: any, config: any) { + this.clientId = 0; + this.clients = new Map(); + this.subscriptions = new Map(); + + config = config || {}; + // Set LogLevel + PLog.logLevel = config.logLevel || 'INFO'; + + // Store keys, convert obj to map + let keyPairs = config.keyPairs || {}; + this.keyPairs = new Map(); + for (let key of Object.keys(keyPairs)) { + this.keyPairs.set(key, keyPairs[key]); + } + PLog.verbose('Support key pairs', this.keyPairs); + + // Initialize Parse + Parse.Object.disableSingleInstance(); + Parse.User.enableUnsafeCurrentUser(); + + let serverURL = config.serverURL || Parse.serverURL; + Parse.serverURL = serverURL; + let appId = config.appId || Parse.applicationId; + let javascriptKey = Parse.javaScriptKey; + let masterKey = config.masterKey || Parse.masterKey; + Parse.initialize(appId, javascriptKey, masterKey); + + // Initialize websocket server + this.parseWebSocketServer = new ParseWebSocketServer( + server, + (parseWebsocket) => this._onConnect(parseWebsocket), + config.websocketTimeout + ); + + // Initialize subscriber + this.subscriber = ParsePubSub.createSubscriber({ + redisURL: config.redisURL + }); + this.subscriber.subscribe('afterSave'); + this.subscriber.subscribe('afterDelete'); + // Register message handler for subscriber. When publisher get messages, it will publish message + // to the subscribers and the handler will be called. + this.subscriber.on('message', (channel, messageStr) => { + PLog.verbose('Subscribe messsage %j', messageStr); + let message = JSON.parse(messageStr); + this._inflateParseObject(message); + if (channel === 'afterSave') { + this._onAfterSave(message); + } else if (channel === 'afterDelete') { + this._onAfterDelete(message); + } else { + PLog.error('Get message %s from unknown channel %j', message, channel); + } + }); + + // Initialize sessionToken cache + this.sessionTokenCache = new SessionTokenCache(config.cacheTimeout); + } + + // Message is the JSON object from publisher. Message.currentParseObject is the ParseObject JSON after changes. + // Message.originalParseObject is the original ParseObject JSON. + _inflateParseObject(message: any): void { + // Inflate merged object + let currentParseObject = message.currentParseObject; + let className = currentParseObject.className; + let parseObject = new Parse.Object(className); + parseObject._finishFetch(currentParseObject); + message.currentParseObject = parseObject; + // Inflate original object + let originalParseObject = message.originalParseObject; + if (originalParseObject) { + className = originalParseObject.className; + parseObject = new Parse.Object(className); + parseObject._finishFetch(originalParseObject); + message.originalParseObject = parseObject; + } + } + + // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. + // Message.originalParseObject is the original ParseObject. + _onAfterDelete(message: any): void { + PLog.verbose('afterDelete is triggered'); + + let deletedParseObject = message.currentParseObject.toJSON(); + let className = deletedParseObject.className; + PLog.verbose('ClassName: %j | ObjectId: %s', className, deletedParseObject.id); + PLog.verbose('Current client number : %d', this.clients.size); + + let classSubscriptions = this.subscriptions.get(className); + if (typeof classSubscriptions === 'undefined') { + PLog.error('Can not find subscriptions under this class ' + className); + return; + } + for (let subscription of classSubscriptions.values()) { + let isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); + if (!isSubscriptionMatched) { + continue; + } + for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) { + let client = this.clients.get(clientId); + if (typeof client === 'undefined') { + continue; + } + for (let requestId of requestIds) { + let acl = message.currentParseObject.getACL(); + // Check ACL + this._matchesACL(acl, client, requestId).then((isMatched) => { + if (!isMatched) { + return null; + } + client.pushDelete(requestId, deletedParseObject); + }, (error) => { + PLog.error('Matching ACL error : ', error); + }); + } + } + } + } + + // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. + // Message.originalParseObject is the original ParseObject. + _onAfterSave(message: any): void { + PLog.verbose('afterSave is triggered'); + + let originalParseObject = null; + if (message.originalParseObject) { + originalParseObject = message.originalParseObject.toJSON(); + } + let currentParseObject = message.currentParseObject.toJSON(); + let className = currentParseObject.className; + PLog.verbose('ClassName: %s | ObjectId: %s', className, currentParseObject.id); + PLog.verbose('Current client number : %d', this.clients.size); + + let classSubscriptions = this.subscriptions.get(className); + if (typeof classSubscriptions === 'undefined') { + PLog.error('Can not find subscriptions under this class ' + className); + return; + } + for (let subscription of classSubscriptions.values()) { + let isOriginalSubscriptionMatched = this._matchesSubscription(originalParseObject, subscription); + let isCurrentSubscriptionMatched = this._matchesSubscription(currentParseObject, subscription); + for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) { + let client = this.clients.get(clientId); + if (typeof client === 'undefined') { + continue; + } + for (let requestId of requestIds) { + // Set orignal ParseObject ACL checking promise, if the object does not match + // subscription, we do not need to check ACL + let originalACLCheckingPromise; + if (!isOriginalSubscriptionMatched) { + originalACLCheckingPromise = Parse.Promise.as(false); + } else { + let originalACL; + if (message.originalParseObject) { + originalACL = message.originalParseObject.getACL(); + } + originalACLCheckingPromise = this._matchesACL(originalACL, client, requestId); + } + // Set current ParseObject ACL checking promise, if the object does not match + // subscription, we do not need to check ACL + let currentACLCheckingPromise; + if (!isCurrentSubscriptionMatched) { + currentACLCheckingPromise = Parse.Promise.as(false); + } else { + let currentACL = message.currentParseObject.getACL(); + currentACLCheckingPromise = this._matchesACL(currentACL, client, requestId); + } + + Parse.Promise.when( + originalACLCheckingPromise, + currentACLCheckingPromise + ).then((isOriginalMatched, isCurrentMatched) => { + PLog.verbose('Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', + originalParseObject, + currentParseObject, + isOriginalSubscriptionMatched, + isCurrentSubscriptionMatched, + isOriginalMatched, + isCurrentMatched, + subscription.hash + ); + + // Decide event type + let type; + if (isOriginalMatched && isCurrentMatched) { + type = 'Update'; + } else if (isOriginalMatched && !isCurrentMatched) { + type = 'Leave'; + } else if (!isOriginalMatched && isCurrentMatched) { + if (originalParseObject) { + type = 'Enter'; + } else { + type = 'Create'; + } + } else { + return null; + } + let functionName = 'push' + type; + client[functionName](requestId, currentParseObject); + }, (error) => { + PLog.error('Matching ACL error : ', error); + }); + } + } + } + } + + _onConnect(parseWebsocket: any): void { + parseWebsocket.on('message', (request) => { + if (typeof request === 'string') { + request = JSON.parse(request); + } + PLog.verbose('Request: %j', request); + + // Check whether this request is a valid request, return error directly if not + if (!tv4.validate(request, RequestSchema['general']) || !tv4.validate(request, RequestSchema[request.op])) { + Client.pushError(parseWebsocket, 1, tv4.error.message); + PLog.error('Connect message error %s', tv4.error.message); + return; + } + + switch(request.op) { + case 'connect': + this._handleConnect(parseWebsocket, request); + break; + case 'subscribe': + this._handleSubscribe(parseWebsocket, request); + break; + case 'unsubscribe': + this._handleUnsubscribe(parseWebsocket, request); + break; + default: + Client.pushError(parseWebsocket, 3, 'Get unknown operation'); + PLog.error('Get unknown operation', request.op); + } + }); + + parseWebsocket.on('disconnect', () => { + PLog.log('Client disconnect: %d', parseWebsocket.clientId); + let clientId = parseWebsocket.clientId; + if (!this.clients.has(clientId)) { + PLog.error('Can not find client %d on disconnect', clientId); + return; + } + + // Delete client + let client = this.clients.get(clientId); + this.clients.delete(clientId); + + // Delete client from subscriptions + for (let [requestId, subscriptionInfo] of client.subscriptionInfos.entries()) { + let subscription = subscriptionInfo.subscription; + subscription.deleteClientSubscription(clientId, requestId); + + // If there is no client which is subscribing this subscription, remove it from subscriptions + let classSubscriptions = this.subscriptions.get(subscription.className); + if (!subscription.hasSubscribingClient()) { + classSubscriptions.delete(subscription.hash); + } + // If there is no subscriptions under this class, remove it from subscriptions + if (classSubscriptions.size === 0) { + this.subscriptions.delete(subscription.className); + } + } + + PLog.verbose('Current clients %d', this.clients.size); + PLog.verbose('Current subscriptions %d', this.subscriptions.size); + }); + } + + _matchesSubscription(parseObject: any, subscription: any): boolean { + // Object is undefined or null, not match + if (!parseObject) { + return false; + } + return matchesQuery(parseObject, subscription.query); + } + + _matchesACL(acl: any, client: any, requestId: number): any { + // If ACL is undefined or null, or ACL has public read access, return true directly + if (!acl || acl.getPublicReadAccess()) { + return Parse.Promise.as(true); + } + // Check subscription sessionToken matches ACL first + let subscriptionInfo = client.getSubscriptionInfo(requestId); + if (typeof subscriptionInfo === 'undefined') { + return Parse.Promise.as(false); + } + + let subscriptionSessionToken = subscriptionInfo.sessionToken; + return this.sessionTokenCache.getUserId(subscriptionSessionToken).then((userId) => { + return acl.getReadAccess(userId); + }).then((isSubscriptionSessionTokenMatched) => { + if (isSubscriptionSessionTokenMatched) { + return Parse.Promise.as(true); + } + // Check client sessionToken matches ACL + let clientSessionToken = client.sessionToken; + return this.sessionTokenCache.getUserId(clientSessionToken).then((userId) => { + return acl.getReadAccess(userId); + }); + }).then((isMatched) => { + return Parse.Promise.as(isMatched); + }, (error) => { + return Parse.Promise.as(false); + }); + } + + _handleConnect(parseWebsocket: any, request: any): any { + if (!this._validateKeys(request, this.keyPairs)) { + Client.pushError(parseWebsocket, 4, 'Key in request is not valid'); + PLog.error('Key in request is not valid'); + return; + } + let client = new Client(this.clientId, parseWebsocket); + parseWebsocket.clientId = this.clientId; + this.clientId += 1; + this.clients.set(parseWebsocket.clientId, client); + PLog.log('Create new client: %d', parseWebsocket.clientId); + client.pushConnect(); + } + + _validateKeys(request: any, validKeyPairs: any): boolean { + if (!validKeyPairs || validKeyPairs.size == 0) { + return true; + } + let isValid = false; + for (let [key, secret] of validKeyPairs) { + if (!request[key] || request[key] !== secret) { + continue; + } + isValid = true; + break; + } + return isValid; + } + + _handleSubscribe(parseWebsocket: any, request: any): any { + // If we can not find this client, return error to client + if (!parseWebsocket.hasOwnProperty('clientId')) { + Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before subscribing'); + PLog.error('Can not find this client, make sure you connect to server before subscribing'); + return; + } + let client = this.clients.get(parseWebsocket.clientId); + + // Get subscription from subscriptions, create one if necessary + let subscriptionHash = queryHash(request.query); + // Add className to subscriptions if necessary + let className = request.query.className; + if (!this.subscriptions.has(className)) { + this.subscriptions.set(className, new Map()); + } + let classSubscriptions = this.subscriptions.get(className); + let subscription; + if (classSubscriptions.has(subscriptionHash)) { + subscription = classSubscriptions.get(subscriptionHash); + } else { + subscription = new Subscription(className, request.query.where, subscriptionHash); + classSubscriptions.set(subscriptionHash, subscription); + } + + // Add subscriptionInfo to client + let subscriptionInfo = { + subscription: subscription + }; + // Add selected fields and sessionToken for this subscription if necessary + if (request.query.fields) { + subscriptionInfo.fields = request.query.fields; + } + if (request.sessionToken) { + subscriptionInfo.sessionToken = request.sessionToken; + } + client.addSubscriptionInfo(request.requestId, subscriptionInfo); + + // Add clientId to subscription + subscription.addClientSubscription(parseWebsocket.clientId, request.requestId); + + client.pushSubscribe(request.requestId); + + PLog.verbose('Create client %d new subscription: %d', parseWebsocket.clientId, request.requestId); + PLog.verbose('Current client number: %d', this.clients.size); + } + + _handleUnsubscribe(parseWebsocket: any, request: any): any { + // If we can not find this client, return error to client + if (!parseWebsocket.hasOwnProperty('clientId')) { + Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before unsubscribing'); + PLog.error('Can not find this client, make sure you connect to server before unsubscribing'); + return; + } + let requestId = request.requestId; + let client = this.clients.get(parseWebsocket.clientId); + if (typeof client === 'undefined') { + Client.pushError(parseWebsocket, 2, 'Cannot find client with clientId ' + parseWebsocket.clientId + + '. Make sure you connect to live query server before unsubscribing.'); + PLog.error('Can not find this client ' + parseWebsocket.clientId); + return; + } + + let subscriptionInfo = client.getSubscriptionInfo(requestId); + if (typeof subscriptionInfo === 'undefined') { + Client.pushError(parseWebsocket, 2, 'Cannot find subscription with clientId ' + parseWebsocket.clientId + + ' subscriptionId ' + requestId + '. Make sure you subscribe to live query server before unsubscribing.'); + PLog.error('Can not find subscription with clientId ' + parseWebsocket.clientId + ' subscriptionId ' + requestId); + return; + } + + // Remove subscription from client + client.deleteSubscriptionInfo(requestId); + // Remove client from subscription + let subscription = subscriptionInfo.subscription; + let className = subscription.className; + subscription.deleteClientSubscription(parseWebsocket.clientId, requestId); + // If there is no client which is subscribing this subscription, remove it from subscriptions + let classSubscriptions = this.subscriptions.get(className); + if (!subscription.hasSubscribingClient()) { + classSubscriptions.delete(subscription.hash); + } + // If there is no subscriptions under this class, remove it from subscriptions + if (classSubscriptions.size === 0) { + this.subscriptions.delete(className); + } + + client.pushUnsubscribe(request.requestId); + + PLog.verbose('Delete client: %d | subscription: %d', parseWebsocket.clientId, request.requestId); + } +} + +ParseLiveQueryServer.setLogLevel = function(logLevel) { + PLog.logLevel = logLevel; +} + +export { + ParseLiveQueryServer +} diff --git a/src/LiveQuery/ParsePubSub.js b/src/LiveQuery/ParsePubSub.js new file mode 100644 index 0000000000..d49d8566db --- /dev/null +++ b/src/LiveQuery/ParsePubSub.js @@ -0,0 +1,29 @@ +import { RedisPubSub } from './RedisPubSub'; +import { EventEmitterPubSub } from './EventEmitterPubSub'; + +let ParsePubSub = {}; + +function useRedis(config: any): boolean { + let redisURL = config.redisURL; + return typeof redisURL !== 'undefined' && redisURL !== ''; +} + +ParsePubSub.createPublisher = function(config: any): any { + if (useRedis(config)) { + return RedisPubSub.createPublisher(config.redisURL); + } else { + return EventEmitterPubSub.createPublisher(); + } +} + +ParsePubSub.createSubscriber = function(config: any): void { + if (useRedis(config)) { + return RedisPubSub.createSubscriber(config.redisURL); + } else { + return EventEmitterPubSub.createSubscriber(); + } +} + +export { + ParsePubSub +} diff --git a/src/LiveQuery/ParseWebSocketServer.js b/src/LiveQuery/ParseWebSocketServer.js new file mode 100644 index 0000000000..e97223063b --- /dev/null +++ b/src/LiveQuery/ParseWebSocketServer.js @@ -0,0 +1,44 @@ +import PLog from './PLog'; + +let typeMap = new Map([['disconnect', 'close']]); + +export class ParseWebSocketServer { + server: Object; + + constructor(server: any, onConnect: Function, websocketTimeout: number = 10 * 1000) { + let WebSocketServer = require('ws').Server; + let wss = new WebSocketServer({ server: server }); + wss.on('listening', () => { + PLog.log('Parse LiveQuery Server starts running'); + }); + wss.on('connection', (ws) => { + onConnect(new ParseWebSocket(ws)); + // Send ping to client periodically + let pingIntervalId = setInterval(() => { + if (ws.readyState == ws.OPEN) { + ws.ping(); + } else { + clearInterval(pingIntervalId); + } + }, websocketTimeout); + }); + this.server = wss; + } +} + +export class ParseWebSocket { + ws: any; + + constructor(ws: any) { + this.ws = ws; + } + + on(type: string, callback): void { + let wsType = typeMap.has(type) ? typeMap.get(type) : type; + this.ws.on(wsType, callback); + } + + send(message: any, channel: string): void { + this.ws.send(message); + } +} diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js new file mode 100644 index 0000000000..5710c8c973 --- /dev/null +++ b/src/LiveQuery/QueryTools.js @@ -0,0 +1,280 @@ +var equalObjects = require('./equalObjects'); +var Id = require('./Id'); +var Parse = require('parse/node'); + +/** + * Query Hashes are deterministic hashes for Parse Queries. + * Any two queries that have the same set of constraints will produce the same + * hash. This lets us reliably group components by the queries they depend upon, + * and quickly determine if a query has changed. + */ + +/** + * Convert $or queries into an array of where conditions + */ +function flattenOrQueries(where) { + if (!where.hasOwnProperty('$or')) { + return where; + } + var accum = []; + for (var i = 0; i < where.$or.length; i++) { + accum = accum.concat(where.$or[i]); + } + return accum; +} + +/** + * Deterministically turns an object into a string. Disregards ordering + */ +function stringify(object): string { + if (typeof object !== 'object' || object === null) { + if (typeof object === 'string') { + return '"' + object.replace(/\|/g, '%|') + '"'; + } + return object + ''; + } + if (Array.isArray(object)) { + var copy = object.map(stringify); + copy.sort(); + return '[' + copy.join(',') + ']'; + } + var sections = []; + var keys = Object.keys(object); + keys.sort(); + for (var k = 0; k < keys.length; k++) { + sections.push(stringify(keys[k]) + ':' + stringify(object[keys[k]])); + } + return '{' + sections.join(',') + '}'; +} + +/** + * Generate a hash from a query, with unique fields for columns, values, order, + * skip, and limit. + */ +function queryHash(query) { + if (query instanceof Parse.Query) { + query = { + className: query.className, + where: query._where + } + } + var where = flattenOrQueries(query.where || {}); + var columns = []; + var values = []; + var i; + if (Array.isArray(where)) { + var uniqueColumns = {}; + for (i = 0; i < where.length; i++) { + var subValues = {}; + var keys = Object.keys(where[i]); + keys.sort(); + for (var j = 0; j < keys.length; j++) { + subValues[keys[j]] = where[i][keys[j]]; + uniqueColumns[keys[j]] = true; + } + values.push(subValues); + } + columns = Object.keys(uniqueColumns); + columns.sort(); + } else { + columns = Object.keys(where); + columns.sort(); + for (i = 0; i < columns.length; i++) { + values.push(where[columns[i]]); + } + } + + var sections = [columns.join(','), stringify(values)]; + + return query.className + ':' + sections.join('|'); +} + +/** + * matchesQuery -- Determines if an object would be returned by a Parse Query + * It's a lightweight, where-clause only implementation of a full query engine. + * Since we find queries that match objects, rather than objects that match + * queries, we can avoid building a full-blown query tool. + */ +function matchesQuery(object: any, query: any): boolean { + if (query instanceof Parse.Query) { + var className = + (object.id instanceof Id) ? object.id.className : object.className; + if (className !== query.className) { + return false; + } + return matchesQuery(object, query._where); + } + for (var field in query) { + if (!matchesKeyConstraints(object, field, query[field])) { + return false; + } + } + return true; +} + + +/** + * Determines whether an object matches a single key's constraints + */ +function matchesKeyConstraints(object, key, constraints) { + var i; + if (key === '$or') { + for (i = 0; i < constraints.length; i++) { + if (matchesQuery(object, constraints[i])) { + return true; + } + } + return false; + } + if (key === '$relatedTo') { + // Bail! We can't handle relational queries locally + return false; + } + // Equality (or Array contains) cases + if (typeof constraints !== 'object') { + if (Array.isArray(object[key])) { + return object[key].indexOf(constraints) > -1; + } + return object[key] === constraints; + } + var compareTo; + if (constraints.__type) { + if (constraints.__type === 'Pointer') { + return ( + constraints.className === object[key].className && + constraints.objectId === object[key].objectId + ); + } + compareTo = Parse._decode(key, constraints); + if (Array.isArray(object[key])) { + for (i = 0; i < object[key].length; i++) { + if (equalObjects(object[key][i], compareTo)) { + return true; + } + } + return false; + } + return equalObjects(object[key], compareTo); + } + // More complex cases + for (var condition in constraints) { + compareTo = constraints[condition]; + if (compareTo.__type) { + compareTo = Parse._decode(key, compareTo); + } + switch (condition) { + case '$lt': + if (object[key] >= compareTo) { + return false; + } + break; + case '$lte': + if (object[key] > compareTo) { + return false; + } + break; + case '$gt': + if (object[key] <= compareTo) { + return false; + } + break; + case '$gte': + if (object[key] < compareTo) { + return false; + } + break; + case '$ne': + if (equalObjects(object[key], compareTo)) { + return false; + } + break; + case '$in': + if (compareTo.indexOf(object[key]) < 0) { + return false; + } + break; + case '$nin': + if (compareTo.indexOf(object[key]) > -1) { + return false; + } + break; + case '$all': + for (i = 0; i < compareTo.length; i++) { + if (object[key].indexOf(compareTo[i]) < 0) { + return false; + } + } + break; + case '$exists': + if (typeof object[key] === 'undefined') { + return false; + } + break; + case '$regex': + if (typeof compareTo === 'object') { + return compareTo.test(object[key]); + } + // JS doesn't support perl-style escaping + var expString = ''; + var escapeEnd = -2; + var escapeStart = compareTo.indexOf('\\Q'); + while (escapeStart > -1) { + // Add the unescaped portion + expString += compareTo.substring(escapeEnd + 2, escapeStart); + escapeEnd = compareTo.indexOf('\\E', escapeStart); + if (escapeEnd > -1) { + expString += compareTo.substring(escapeStart + 2, escapeEnd) + .replace(/\\\\\\\\E/g, '\\E').replace(/\W/g, '\\$&'); + } + + escapeStart = compareTo.indexOf('\\Q', escapeEnd); + } + expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2)); + var exp = new RegExp(expString, constraints.$options || ''); + if (!exp.test(object[key])) { + return false; + } + break; + case '$nearSphere': + var distance = compareTo.radiansTo(object[key]); + var max = constraints.$maxDistance || Infinity; + return distance <= max; + case '$within': + var southWest = compareTo.$box[0]; + var northEast = compareTo.$box[1]; + if (southWest.latitude > northEast.latitude || + southWest.longitude > northEast.longitude) { + // Invalid box, crosses the date line + return false; + } + return ( + object[key].latitude > southWest.latitude && + object[key].latitude < northEast.latitude && + object[key].longitude > southWest.longitude && + object[key].longitude < northEast.longitude + ); + case '$options': + // Not a query type, but a way to add options to $regex. Ignore and + // avoid the default + break; + case '$maxDistance': + // Not a query type, but a way to add a cap to $nearSphere. Ignore and + // avoid the default + break; + case '$select': + return false; + case '$dontSelect': + return false; + default: + return false; + } + } + return true; +} + +var QueryTools = { + queryHash: queryHash, + matchesQuery: matchesQuery +}; + +module.exports = QueryTools; diff --git a/src/LiveQuery/RedisPubSub.js b/src/LiveQuery/RedisPubSub.js new file mode 100644 index 0000000000..92e3d86e66 --- /dev/null +++ b/src/LiveQuery/RedisPubSub.js @@ -0,0 +1,18 @@ +import redis from 'redis'; + +function createPublisher(redisURL: string): any { + return redis.createClient(redisURL, { no_ready_check: true }); +} + +function createSubscriber(redisURL: string): any { + return redis.createClient(redisURL, { no_ready_check: true }); +} + +let RedisPubSub = { + createPublisher, + createSubscriber +} + +export { + RedisPubSub +} diff --git a/src/LiveQuery/RequestSchema.js b/src/LiveQuery/RequestSchema.js new file mode 100644 index 0000000000..9811df5738 --- /dev/null +++ b/src/LiveQuery/RequestSchema.js @@ -0,0 +1,101 @@ +let general = { + 'title': 'General request schema', + 'type': 'object', + 'properties': { + 'op': { + 'type': 'string', + 'enum': ['connect', 'subscribe', 'unsubscribe'] + }, + }, +}; + +let connect = { + 'title': 'Connect operation schema', + 'type': 'object', + 'properties': { + 'op': 'connect', + 'applicationId': { + 'type': 'string' + }, + 'javascriptKey': { + type: 'string' + }, + 'masterKey': { + type: 'string' + }, + 'clientKey': { + type: 'string' + }, + 'windowsKey': { + type: 'string' + }, + 'restAPIKey': { + 'type': 'string' + }, + 'sessionToken': { + 'type': 'string' + } + }, + 'required': ['op', 'applicationId'], + "additionalProperties": false +}; + +let subscribe = { + 'title': 'Subscribe operation schema', + 'type': 'object', + 'properties': { + 'op': 'subscribe', + 'requestId': { + 'type': 'number' + }, + 'query': { + 'title': 'Query field schema', + 'type': 'object', + 'properties': { + 'className': { + 'type': 'string' + }, + 'where': { + 'type': 'object' + }, + 'fields': { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + }, + 'required': ['where', 'className'], + 'additionalProperties': false + }, + 'sessionToken': { + 'type': 'string' + } + }, + 'required': ['op', 'requestId', 'query'], + 'additionalProperties': false +}; + +let unsubscribe = { + 'title': 'Unsubscribe operation schema', + 'type': 'object', + 'properties': { + 'op': 'unsubscribe', + 'requestId': { + 'type': 'number' + } + }, + 'required': ['op', 'requestId'], + "additionalProperties": false +} + +let RequestSchema = { + 'general': general, + 'connect': connect, + 'subscribe': subscribe, + 'unsubscribe': unsubscribe +} + +export default RequestSchema; diff --git a/src/LiveQuery/SessionTokenCache.js b/src/LiveQuery/SessionTokenCache.js new file mode 100644 index 0000000000..07d9d62744 --- /dev/null +++ b/src/LiveQuery/SessionTokenCache.js @@ -0,0 +1,38 @@ +import Parse from 'parse/node'; +import LRU from 'lru-cache'; +import PLog from './PLog'; + +class SessionTokenCache { + cache: Object; + + constructor(timeout: number = 30 * 24 * 60 *60 * 1000, maxSize: number = 10000) { + this.cache = new LRU({ + max: maxSize, + maxAge: timeout + }); + } + + getUserId(sessionToken: string): any { + if (!sessionToken) { + return Parse.Promise.error('Empty sessionToken'); + } + let userId = this.cache.get(sessionToken); + if (userId) { + PLog.verbose('Fetch userId %s of sessionToken %s from Cache', userId, sessionToken); + return Parse.Promise.as(userId); + } + return Parse.User.become(sessionToken).then((user) => { + PLog.verbose('Fetch userId %s of sessionToken %s from Parse', user.id, sessionToken); + let userId = user.id; + this.cache.set(sessionToken, userId); + return Parse.Promise.as(userId); + }, (error) => { + PLog.error('Can not fetch userId for sessionToken %j, error %j', sessionToken, error); + return Parse.Promise.error(error); + }); + } +} + +export { + SessionTokenCache +} diff --git a/src/LiveQuery/Subscription.js b/src/LiveQuery/Subscription.js new file mode 100644 index 0000000000..e3b63dafd3 --- /dev/null +++ b/src/LiveQuery/Subscription.js @@ -0,0 +1,55 @@ +import {matchesQuery, queryHash} from './QueryTools'; +import PLog from './PLog'; + +export type FlattenedObjectData = { [attr: string]: any }; +export type QueryData = { [attr: string]: any }; + +class Subscription { + // It is query condition eg query.where + query: QueryData; + className: string; + hash: string; + clientRequestIds: Object; + + constructor(className: string, query: QueryData, queryHash: string) { + this.className = className; + this.query = query; + this.hash = queryHash; + this.clientRequestIds = new Map(); + } + + addClientSubscription(clientId: number, requestId: number): void { + if (!this.clientRequestIds.has(clientId)) { + this.clientRequestIds.set(clientId, []); + } + let requestIds = this.clientRequestIds.get(clientId); + requestIds.push(requestId); + } + + deleteClientSubscription(clientId: number, requestId: number): void { + let requestIds = this.clientRequestIds.get(clientId); + if (typeof requestIds === 'undefined') { + PLog.error('Can not find client %d to delete', clientId); + return; + } + + let index = requestIds.indexOf(requestId); + if (index < 0) { + PLog.error('Can not find client %d subscription %d to delete', clientId, requestId); + return; + } + requestIds.splice(index, 1); + // Delete client reference if it has no subscription + if (requestIds.length == 0) { + this.clientRequestIds.delete(clientId); + } + } + + hasSubscribingClient(): boolean { + return this.clientRequestIds.size > 0; + } +} + +export { + Subscription +} diff --git a/src/LiveQuery/equalObjects.js b/src/LiveQuery/equalObjects.js new file mode 100644 index 0000000000..931d392fd8 --- /dev/null +++ b/src/LiveQuery/equalObjects.js @@ -0,0 +1,48 @@ +var toString = Object.prototype.toString; + +/** + * Determines whether two objects represent the same primitive, special Parse + * type, or full Parse Object. + */ +function equalObjects(a, b) { + if (typeof a !== typeof b) { + return false; + } + if (typeof a !== 'object') { + return (a === b); + } + if (a === b) { + return true; + } + if (toString.call(a) === '[object Date]') { + if (toString.call(b) === '[object Date]') { + return (+a === +b); + } + return false; + } + if (Array.isArray(a)) { + if (Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (!equalObjects(a[i], b[i])) { + return false; + } + } + return true; + } + return false; + } + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + for (var key in a) { + if (!equalObjects(a[key], b[key])) { + return false; + } + } + return true; +} + +module.exports = equalObjects; diff --git a/src/RestWrite.js b/src/RestWrite.js index 91f1f7ae0d..1d72fc0304 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -266,6 +266,7 @@ RestWrite.prototype.findUsersWithAuthData = function(authData) { return findPromise; } + RestWrite.prototype.handleAuthData = function(authData) { let results; return this.handleAuthDataValidation(authData).then(() => { @@ -768,7 +769,9 @@ RestWrite.prototype.runAfterTrigger = function() { } // Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class. - if (!triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId)) { + let hasAfterSaveHook = triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId); + let hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className); + if (!hasAfterSaveHook && !hasLiveQuery) { return Promise.resolve(); } @@ -789,6 +792,10 @@ RestWrite.prototype.runAfterTrigger = function() { updatedObject.set(this.sanitizedData()); updatedObject._handleSaveResponse(this.response.response, this.response.status || 200); + // Notifiy LiveQueryServer if possible + this.config.liveQueryController.onAfterSave(updatedObject.className, updatedObject, originalObject); + + // Run afterSave trigger triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config.applicationId); }; diff --git a/src/index.js b/src/index.js index 87ab0331eb..01a1d48ab4 100644 --- a/src/index.js +++ b/src/index.js @@ -11,41 +11,43 @@ var batch = require('./batch'), Parse = require('parse/node').Parse, authDataManager = require('./authDataManager'); -//import passwordReset from './passwordReset'; -import cache from './cache'; -import Config from './Config'; -import parseServerPackage from '../package.json'; -import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; -import PromiseRouter from './PromiseRouter'; -import requiredParameter from './requiredParameter'; -import { AnalyticsRouter } from './Routers/AnalyticsRouter'; -import { ClassesRouter } from './Routers/ClassesRouter'; -import { FeaturesRouter } from './Routers/FeaturesRouter'; -import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; -import { FilesController } from './Controllers/FilesController'; -import { FilesRouter } from './Routers/FilesRouter'; -import { FunctionsRouter } from './Routers/FunctionsRouter'; -import { GCSAdapter } from './Adapters/Files/GCSAdapter'; -import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; -import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; -import { HooksController } from './Controllers/HooksController'; -import { HooksRouter } from './Routers/HooksRouter'; -import { IAPValidationRouter } from './Routers/IAPValidationRouter'; -import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { loadAdapter } from './Adapters/AdapterLoader'; -import { LoggerController } from './Controllers/LoggerController'; -import { LogsRouter } from './Routers/LogsRouter'; -import { PublicAPIRouter } from './Routers/PublicAPIRouter'; -import { PushController } from './Controllers/PushController'; -import { PushRouter } from './Routers/PushRouter'; -import { randomString } from './cryptoUtils'; -import { RolesRouter } from './Routers/RolesRouter'; -import { S3Adapter } from './Adapters/Files/S3Adapter'; -import { SchemasRouter } from './Routers/SchemasRouter'; -import { SessionsRouter } from './Routers/SessionsRouter'; -import { setFeature } from './features'; -import { UserController } from './Controllers/UserController'; -import { UsersRouter } from './Routers/UsersRouter'; +//import passwordReset from './passwordReset'; +import cache from './cache'; +import Config from './Config'; +import parseServerPackage from '../package.json'; +import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; +import PromiseRouter from './PromiseRouter'; +import requiredParameter from './requiredParameter'; +import { AnalyticsRouter } from './Routers/AnalyticsRouter'; +import { ClassesRouter } from './Routers/ClassesRouter'; +import { FeaturesRouter } from './Routers/FeaturesRouter'; +import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; +import { FilesController } from './Controllers/FilesController'; +import { FilesRouter } from './Routers/FilesRouter'; +import { FunctionsRouter } from './Routers/FunctionsRouter'; +import { GCSAdapter } from './Adapters/Files/GCSAdapter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; +import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; +import { HooksController } from './Controllers/HooksController'; +import { HooksRouter } from './Routers/HooksRouter'; +import { IAPValidationRouter } from './Routers/IAPValidationRouter'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { loadAdapter } from './Adapters/AdapterLoader'; +import { LiveQueryController } from './Controllers/LiveQueryController'; +import { LoggerController } from './Controllers/LoggerController'; +import { LogsRouter } from './Routers/LogsRouter'; +import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer'; +import { PublicAPIRouter } from './Routers/PublicAPIRouter'; +import { PushController } from './Controllers/PushController'; +import { PushRouter } from './Routers/PushRouter'; +import { randomString } from './cryptoUtils'; +import { RolesRouter } from './Routers/RolesRouter'; +import { S3Adapter } from './Adapters/Files/S3Adapter'; +import { SchemasRouter } from './Routers/SchemasRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; +import { setFeature } from './features'; +import { UserController } from './Controllers/UserController'; +import { UsersRouter } from './Routers/UsersRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -108,6 +110,7 @@ function ParseServer({ choosePassword: undefined, passwordResetSuccess: undefined }, + liveQuery = {} }) { setFeature('serverVersion', parseServerPackage.version); // Initialize the node client SDK automatically @@ -151,6 +154,7 @@ function ParseServer({ const loggerController = new LoggerController(loggerControllerAdapter, appId); const hooksController = new HooksController(appId, collectionPrefix); const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); + const liveQueryController = new LiveQueryController(liveQuery); cache.apps.set(appId, { @@ -174,6 +178,7 @@ function ParseServer({ appName: appName, publicServerURL: publicServerURL, customPages: customPages, + liveQueryController: liveQueryController }); // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability @@ -262,6 +267,10 @@ function addParseCloud() { global.Parse = Parse; } +ParseServer.createLiveQueryServer = function(httpServer, config) { + return new ParseLiveQueryServer(httpServer, config); +} + module.exports = { ParseServer: ParseServer, S3Adapter: S3Adapter, diff --git a/src/rest.js b/src/rest.js index 82821549c8..4c0becd284 100644 --- a/src/rest.js +++ b/src/rest.js @@ -42,6 +42,7 @@ function del(config, auth, className, objectId) { return Promise.resolve().then(() => { if (triggers.getTrigger(className, triggers.Types.beforeDelete, config.applicationId) || triggers.getTrigger(className, triggers.Types.afterDelete, config.applicationId) || + (config.liveQueryController && config.liveQueryController.hasLiveQuery(className)) || className == '_Session') { return find(config, Auth.master(config), className, {objectId: objectId}) .then((response) => { @@ -49,6 +50,8 @@ function del(config, auth, className, objectId) { response.results[0].className = className; cache.users.remove(response.results[0].sessionToken); inflatedObject = Parse.Object.fromJSON(response.results[0]); + // Notify LiveQuery server if possible + config.liveQueryController.onAfterDelete(inflatedObject.className, inflatedObject); return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config.applicationId); } throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, @@ -97,7 +100,8 @@ function update(config, auth, className, objectId, restObject) { return Promise.resolve().then(() => { if (triggers.getTrigger(className, triggers.Types.beforeSave, config.applicationId) || - triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId)) { + triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId) || + (config.liveQueryController && config.liveQueryController.hasLiveQuery(className))) { return find(config, Auth.master(config), className, {objectId: objectId}); } return Promise.resolve({}); diff --git a/src/testing-routes.js b/src/testing-routes.js index b44c3fdd05..20173fe38f 100644 --- a/src/testing-routes.js +++ b/src/testing-routes.js @@ -3,6 +3,7 @@ import cache from './cache'; import * as middlewares from './middlewares'; import { ParseServer } from './index'; import { Parse } from 'parse/node'; + var express = require('express'), cryptoUtils = require('./cryptoUtils');