diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 9029eed977..dbce2cc649 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -2005,3 +2005,304 @@ describe('ParseLiveQuery ACL transition disclosure', function () { expect(leave.object.secretField).toBe('VISIBLE_NEW'); }); }); + +describe('ParseLiveQuery beforeLiveQueryEvent', function () { + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + }); + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + await client.close(); + }); + + it('runs beforeLiveQueryEvent when creating an object', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const triggerPromise = resolvingPromise(); + Parse.Cloud.beforeLiveQueryEvent('TestObject', req => { + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + triggerPromise.resolve(); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + const createPromise = resolvingPromise(); + subscription.on('create', object => { + createPromise.resolve(object); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + await triggerPromise; + const created = await createPromise; + expect(created.get('foo')).toBe('bar'); + // The published object must keep its identifier so LiveQuery clients can + // correlate the event with the object. + expect(created.id).toBe(object.id); + }); + + it('runs beforeLiveQueryEvent when updating an object', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + const triggerPromise = resolvingPromise(); + Parse.Cloud.beforeLiveQueryEvent('TestObject', async req => { + expect(req.object.get('foo')).toBe('baz'); + triggerPromise.resolve(); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + const updatePromise = resolvingPromise(); + subscription.on('update', updated => { + updatePromise.resolve(updated); + }); + + object.set('foo', 'baz'); + await object.save(); + + await triggerPromise; + const updated = await updatePromise; + expect(updated.get('foo')).toBe('baz'); + expect(updated.id).toBe(object.id); + }); + + it('prevents a LiveQuery create event when beforeLiveQueryEvent returns false', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + Parse.Cloud.beforeLiveQueryEvent('TestObject', req => { + expect(req.object.get('foo')).toBe('bar'); + return false; + }); + + const query = new Parse.Query(TestObject).equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + await sleep(500); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('prevents a LiveQuery update event when beforeLiveQueryEvent returns false', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + Parse.Cloud.beforeLiveQueryEvent('TestObject', async req => { + expect(req.object.get('foo')).toBe('baz'); + return false; + }); + + const query = new Parse.Query(TestObject).equalTo('foo', 'baz'); + const subscription = await query.subscribe(); + const updateSpy = jasmine.createSpy('update'); + subscription.on('update', updateSpy); + + object.set('foo', 'baz'); + await object.save(); + + await sleep(500); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('still publishes the event and logs when beforeLiveQueryEvent throws', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const logger = require('../lib/logger').logger; + const warnSpy = spyOn(logger, 'warn').and.callThrough(); + + Parse.Cloud.beforeLiveQueryEvent('TestObject', () => { + throw new Error('beforeLiveQueryEvent failure'); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + const createPromise = resolvingPromise(); + subscription.on('create', object => { + createPromise.resolve(object); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + // The event is still published even though the trigger threw. + const created = await createPromise; + expect(created.get('foo')).toBe('bar'); + expect(warnSpy).toHaveBeenCalledWith('beforeLiveQueryEvent caught an error', jasmine.anything()); + }); + + it('runs beforeLiveQueryEvent when deleting an object', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + const triggerPromise = resolvingPromise(); + Parse.Cloud.beforeLiveQueryEvent('TestObject', req => { + expect(req.object.id).toBe(object.id); + expect(req.object.get('foo')).toBe('bar'); + triggerPromise.resolve(); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + const deletePromise = resolvingPromise(); + subscription.on('delete', deleted => { + deletePromise.resolve(deleted); + }); + + await object.destroy(); + + await triggerPromise; + const deleted = await deletePromise; + expect(deleted.id).toBe(object.id); + }); + + it('prevents a LiveQuery delete event when beforeLiveQueryEvent returns false', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + Parse.Cloud.beforeLiveQueryEvent('TestObject', req => { + expect(req.object.id).toBe(object.id); + return false; + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + const deleteSpy = jasmine.createSpy('delete'); + subscription.on('delete', deleteSpy); + + await object.destroy(); + + await sleep(500); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('prevents a LiveQuery leave event when beforeLiveQueryEvent returns false', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + Parse.Cloud.beforeLiveQueryEvent('TestObject', req => { + expect(req.object.get('foo')).toBe('baz'); + return false; + }); + + const query = new Parse.Query(TestObject).equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + const leaveSpy = jasmine.createSpy('leave'); + subscription.on('leave', leaveSpy); + + // The object leaves the subscription's query, which would publish a + // leave event if the trigger did not prevent it. + object.set('foo', 'baz'); + await object.save(); + + await sleep(500); + expect(leaveSpy).not.toHaveBeenCalled(); + }); + + it('does not publish the event when the beforeLiveQueryEvent validator fails', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const logger = require('../lib/logger').logger; + const warnSpy = spyOn(logger, 'warn').and.callThrough(); + + const handlerSpy = jasmine.createSpy('handler'); + Parse.Cloud.beforeLiveQueryEvent('TestObject', handlerSpy, { + requireMaster: true, + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + await sleep(500); + expect(createSpy).not.toHaveBeenCalled(); + expect(handlerSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith('beforeLiveQueryEvent validation failed', jasmine.anything()); + }); +}); diff --git a/src/RestWrite.js b/src/RestWrite.js index 98c9fd4656..0d9d53e3b0 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1784,16 +1784,35 @@ RestWrite.prototype.runAfterSaveTrigger = function () { updatedObject._handleSaveResponse(this.response.response, this.response.status || 200); if (hasLiveQuery) { - this.config.database.loadSchema().then(schemaController => { - // Notify LiveQueryServer if possible - const perms = schemaController.getClassLevelPermissions(updatedObject.className); - this.config.liveQueryController.onAfterSave( - updatedObject.className, + // Run the beforeLiveQueryEvent trigger, if defined, to let Cloud Code decide + // whether the event should be published. Returning `false` from the trigger + // prevents the event from being sent to the LiveQuery server, which saves + // network and CPU resources for events that no client needs to receive. + // Fire-and-forget: the trigger and the notification must not delay the + // save response. + triggers + .maybeRunBeforeLiveQueryEventTrigger( + this.auth, updatedObject, originalObject, - perms - ); - }); + this.config, + this.context + ) + .then(publish => { + if (!publish) { + return; + } + return this.config.database.loadSchema().then(schemaController => { + // Notify LiveQueryServer if possible + const perms = schemaController.getClassLevelPermissions(updatedObject.className); + this.config.liveQueryController.onAfterSave( + updatedObject.className, + updatedObject, + originalObject, + perms + ); + }); + }); } if (!hasAfterSaveHook) { return Promise.resolve(); diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index e829a492e0..8e1c326131 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -673,6 +673,64 @@ ParseCloud.beforeSubscribe = function (parseClass, handler, validationHandler) { ); }; +/** + * Registers a before live query event function. + * + * **Available in Cloud Code only.** + * + * The function runs on the Parse Server instance before an object create, + * update or delete is published to the LiveQuery server, once per object write. + * This is different from {@link Parse.Cloud.afterLiveQueryEvent}, which runs on + * the LiveQuery server once per matching subscription. + * + * Return `false` from the function to prevent the event from being published to + * the LiveQuery server. This is useful to avoid publishing events that no client + * needs to receive, saving network and CPU resources. Returning any other value + * (including `undefined`) publishes the event as usual. If a validator is + * defined and fails, the event is not published; if the function itself throws, + * the error is logged and the event is published as usual. + * + * This trigger is intended only to allow or prevent publishing an event. Do not + * mutate `request.object` here: it is the same object instance that is passed to + * the `afterSave` trigger, so changes would leak into `afterSave` and the save + * response. `request.context` is provided read-only and is not merged back. To + * transform the payload delivered to LiveQuery clients, use + * {@link Parse.Cloud.afterLiveQueryEvent} instead. + * + * If you want to use beforeLiveQueryEvent for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.beforeLiveQueryEvent('MyCustomClass', (request) => { + * if (request.object.get('status') === 'draft') { + * // Prevent the event from being published to the LiveQuery server. + * return false; + * } + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeLiveQueryEvent(Parse.User, (request) => { + * // code here + * }, { ...validationObject }); + *``` + * + * @method beforeLiveQueryEvent + * @name Parse.Cloud.beforeLiveQueryEvent + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the before live query event function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run before a live query event (publish) is made. This function can be async and should take one parameter, a {@link Parse.Cloud.TriggerRequest}. Return `false` to prevent the event from being published. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.beforeLiveQueryEvent = function (parseClass, handler, validationHandler) { + validateValidator(validationHandler); + const className = triggers.getClassName(parseClass); + triggers.addTrigger( + triggers.Types.beforeEvent, + className, + handler, + Parse.applicationId, + validationHandler + ); +}; + ParseCloud.onLiveQueryEvent = function (handler) { triggers.addLiveQueryEventHandler(handler, Parse.applicationId); }; diff --git a/src/rest.js b/src/rest.js index 7a78f2f8b5..cd27402257 100644 --- a/src/rest.js +++ b/src/rest.js @@ -241,9 +241,17 @@ function del(config, auth, className, objectId, context) { ); }) .then(() => { - // Notify LiveQuery server if possible + // Notify LiveQuery server if possible, unless the beforeLiveQueryEvent + // trigger prevents it. Fire-and-forget: the trigger and the notification + // must not delay the delete response. const perms = schemaController.getClassLevelPermissions(className); - config.liveQueryController.onAfterDelete(className, inflatedObject, null, perms); + triggers + .maybeRunBeforeLiveQueryEventTrigger(auth, inflatedObject, null, config, context) + .then(publish => { + if (publish) { + config.liveQueryController.onAfterDelete(className, inflatedObject, null, perms); + } + }); return triggers.maybeRunTrigger( triggers.Types.afterDelete, auth, diff --git a/src/triggers.js b/src/triggers.js index f66d96f942..a3e224caf4 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -16,6 +16,7 @@ export const Types = { afterFind: 'afterFind', beforeConnect: 'beforeConnect', beforeSubscribe: 'beforeSubscribe', + beforeEvent: 'beforeEvent', afterEvent: 'afterEvent', }; @@ -307,7 +308,8 @@ export function getRequestObject( triggerType === Types.beforeLogin || triggerType === Types.afterLogin || triggerType === Types.beforePasswordResetRequest || - triggerType === Types.afterFind + triggerType === Types.afterFind || + triggerType === Types.beforeEvent ) { // Set a copy of the context on the request object. request.context = Object.assign(Object.create(null), context); @@ -373,6 +375,11 @@ export function getRequestQueryObject(triggerType, auth, query, count, config, c export function getResponseObject(request, resolve, reject) { return { success: function (response) { + if (request.triggerName === Types.beforeEvent) { + // Pass the handler's return value through unchanged so that the caller + // can react to it (e.g. returning `false` to prevent a LiveQuery event). + return resolve(response); + } if (request.triggerName === Types.afterFind) { if (!response) { response = request.objects; @@ -1022,6 +1029,39 @@ export function maybeRunTrigger( }); } +// Runs the beforeLiveQueryEvent trigger, if defined, and returns whether the +// LiveQuery event should be published. The event is not published when the +// trigger returns `false` or when its validator fails (a failing validator is a +// deliberate gate, so it fails closed). Any other trigger error is logged and +// the event is published as usual, so a faulty trigger cannot silently drop +// events. This function never rejects. +export async function maybeRunBeforeLiveQueryEventTrigger( + auth, + parseObject, + originalParseObject, + config, + context +) { + try { + const result = await maybeRunTrigger( + Types.beforeEvent, + auth, + parseObject, + originalParseObject, + config, + context + ); + return result !== false; + } catch (error) { + if (error && error.code === Parse.Error.VALIDATION_ERROR) { + logger.warn('beforeLiveQueryEvent validation failed', error); + return false; + } + logger.warn('beforeLiveQueryEvent caught an error', error); + return true; + } +} + // Converts a REST-format object to a Parse.Object // data is either className or an object export function inflate(data, restObject) {