Bulk object operations with ForgeRock IDM

Introduction

Sometimes, there’s a need to apply the same operation to a set of multiple objects. For example, you may want to deactivate a list of user accounts, or update their properties because of a change in the org structure or in corporate identity.
With a carefully designed data model, you can avoid many of such bulk operations through the use of relationships and relationship-derived virtual properties [^1], but for cases such as account de-/activation, rather than iterating through the list on the client side and sending one request per object, it would be much more efficient if we could submit the list to the Identity Platform in a single request.
This article describes how to accomplish this using custom endpoints in ForgeRock IDM.

Note: The SCIM 2.0 standard has a concept of Bulk Operations, which is different in that it actually describes a batch of many single-resource operations submitted in one request, rather than one request submitting the same operation on many different resources. We therefore respectfully beg to differ from SCIM’s terminology and refer to the latter as bulk operations (discussed here) and the former as batch operations, which are discussed in a separate post.

Overview

The sample custom endpoints presented in this article support two flavours of bulk operations: synchronous, for small sets of objects, and asynchronous, for large collections.

Bulk Update Custom Endpoint (synchronous)

This first, rather simple solution is suitable for relatively small sets of target objects, say, less than 100 items. It is invoked with the operation to be performed specified in the request body, and the collection of target objects specified in the form of a query filter, a list of object IDs, or both.
The script then iterates through the list of objects and performs the specified operation sequentially on each object. Only PATCH or DELETE are supported, since UPDATE or CREATE do not make much sense with an identical payload.
Obviously, the longer list, the longer it takes before the response is returned, up to the point when the caller runs into a timeout. The operations will still be performed by IDM, but the status will be lost, therefore, if you require large bulk operations, you should consider the asynchronous version described further down.

Installation

Option 1 (ID Cloud only): Using the Platform Admin UI
  1. Log in to the ID Cloud Admin UI as a tenant administrator.

  2. In the sidebar menu, go to Scripts/Custom Endpoints:
    image

  3. In the main window, click New Endpoint:
    image

  4. Specify your desired name for the endpoint, and enter a suitable description:
    image

  5. Replace all of the code in the script editor pane with the following script code, then click Save and Close:

/*
 *  Copyright 2012-2023 ForgeRock AS. All Rights Reserved
 *
 *  DISCLAIMER: This code is provided to you expressly as an example  (“Sample Code”).
 *  It is the responsibility of the individual recipient user, in his/her sole discretion,
 *  to diligence such Sample Code for accuracy, completeness, security, and final determination for appropriateness of use.
 *
 *  ANY SAMPLE CODE IS PROVIDED ON AN “AS IS” IS BASIS, WITHOUT WARRANTY OF ANY KIND.
 *  FORGEROCK AND ITS LICENSORS EXPRESSLY DISCLAIM ALL WARRANTIES,  WHETHER EXPRESS, IMPLIED, OR STATUTORY,
 *  INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES  OF MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
 *
 *  FORGEROCK SHALL NOT HAVE ANY LIABILITY ARISING OUT OF OR RELATING TO ANY USE, IMPLEMENTATION, INTEGRATION,
 *  OR CONFIGURATION OF ANY SAMPLE CODE IN ANY PRODUCTION ENVIRONMENT OR FOR ANY COMMERCIAL DEPLOYMENT(S).
 *
  * This endpoint applies the specified operation on a set of managed objects,
  * specified either as a list of object IDs or using a query filter.
  *
  *
  * Syntax:
  *   POST <path to endpoint>/<resourcePath>  (if not supplied, defaults to managed/alpha_user)
  *
  * Parameters:
  *   _action       = <patch | delete>                         // (*) required
  *   _queryFilter  = <queryFilter expression>                 // can be specified as a parameter and/or in the request body (see below)
  *
  * Body:
  * {
  *     objectIds: [ <list of object IDs> ],          // (*) required if no queryFilter specified
  *     queryFilter: <query filter expression>,     // can be specified here or as a parameter
  *     operation: <patch operation>,               // required for patch operation
  *     field: <field for patch operation>,         // required for patch operation
  *     value: <value for patch operation>          // required for patch operation
  *  }
  *
  * Returns: request content, parameters, items processed / failed
 */

( function(){

    const _dbg = "FRDEBUG: bulkOperation ENDPOINT: ";

    var info = function( text, args ) { logger.info( "{}{}: {}", _dbg, text, args ? args : "" ) }, isNonEmptyArray = function (field) { return Array.isArray(field) && field.length};

   function executeAction( request ) {

       if ( !request.content || !( isNonEmptyArray(request.content.objectIds) || (!!request.content.queryFilter) || (!!request.additionalParameters._queryFilter) ) ){
         info("ERROR: Bad request, target objects not specified.");
         return { code : 400, message : "Bad request - no valid request content." };
       }

       if ( !request.action || !(["patch","delete"].includes(request.action.toLowerCase() ) )) {
         info("ERROR: Unsupported action",request.action || "<none>" );
         return { code : 400, message : "Bad request - unsupported action: " + request.action };
       }

       if ( request.action.toLowerCase() === "patch" && !request.content.field ){
         info("ERROR: No field specified in PATCH" );
         return { code : 400, message : "Bad request - field not specified in PATCH request." };
       }

      logger.info("{}ACTION:{} on resource path:{} name:{}.",_dbg,request.action,request.resourcePath,request.resourceName);

        const objectPath = request.resourcePath || "managed/alpha_user";
        const qf = String(request.additionalParameters._queryFilter || request.content.queryFilter || "");

        let objectList = (request.content.objectIds || [] ).concat(
          !!qf ? (openidm.query( objectPath , { _queryFilter: qf } , ["_id"] ).result || []) : []
        );

        info("Processing objectList with numEntries", objectList.length);

        let itemsProcessed = 0;
        let itemsFailed = 0;

        objectList.forEach( function(item) {
          let objectId = objectPath + '/' + ( item._id || item );
          switch (request.action) {
            case "patch":
              let payload = [
                {
                  operation: request.content.operation,
                  field: request.content.field,
                  value: request.content.value
                }
              ];

              openidm.patch(
                objectId,
                null,
                payload,
                null,
                ["_id"]
              )._id ? itemsProcessed++ : itemsFailed++;
              break;

            case "delete":
              openidm.delete( objectId, null, null, ["_id"] )._id ? itemsProcessed++ : itemsFailed++ ;
              break;


          }
        }    );

      return {
              method: request.method,
              action: request.action,
              resourcePath: objectPath,
              _queryFilter: request.additionalParameters._queryFilter,
              queryFilter: request.content.queryFilter,
              itemsProcessed: itemsProcessed,
              itemsFailed: itemsFailed
          };
      }

      if (request.method === "action") return executeAction( request );
      throw { code : 400, message : "Unsupported request type: " + request.method };
    }
    )();
Option 2: Using a configuration file or REST call

This option works for both ID Cloud and non-platform or standalone IDM deployments. I’m assuming that, if choosing this option, you’re familiar with managing IDM configuration using files or REST in your respective environment, hence I will not go into the details of the required authentication etc.

Note that, in a self-managed set-up, you can also define the custom endpoint using a file reference, rather than an inline script source. In that case, use the script code from the previous section and save it as a .js file locally on the IDM server, then specify the path to it in the endpoint configuration file in the file property, and remove the source field.

  1. Create a configuration file with the following content, or prepare a PUT in your favourite REST client tool with the following body:
{
    "context":"endpoint/bulkOperation/*",
    "type" : "text/javascript",
    "source" : "\/*\r\n *  Copyright 2012-2023 ForgeRock AS. All Rights Reserved\r\n *\r\n *  DISCLAIMER: This code is provided to you expressly as an example  (\u201CSample Code\u201D).\r\n *  It is the responsibility of the individual recipient user, in his\/her sole discretion,\r\n *  to diligence such Sample Code for accuracy, completeness, security, and final determination for appropriateness of use.\r\n *\r\n *  ANY SAMPLE CODE IS PROVIDED ON AN \u201CAS IS\u201D IS BASIS, WITHOUT WARRANTY OF ANY KIND.\r\n *  FORGEROCK AND ITS LICENSORS EXPRESSLY DISCLAIM ALL WARRANTIES,  WHETHER EXPRESS, IMPLIED, OR STATUTORY,\r\n *  INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES  OF MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.\r\n *\r\n *  FORGEROCK SHALL NOT HAVE ANY LIABILITY ARISING OUT OF OR RELATING TO ANY USE, IMPLEMENTATION, INTEGRATION,\r\n *  OR CONFIGURATION OF ANY SAMPLE CODE IN ANY PRODUCTION ENVIRONMENT OR FOR ANY COMMERCIAL DEPLOYMENT(S).\r\n *\r\n  * This endpoint applies the specified operation on a set of managed objects,\r\n  * specified either as a list of object IDs or using a query filter.\r\n  *\r\n  *\r\n  * Syntax:\r\n  *   POST <path to endpoint>\/<resourcePath>  (if not supplied, defaults to managed\/alpha_user)\r\n  *\r\n  * Parameters:\r\n  *   _action       = <patch | delete>                         \/\/ (*) required\r\n  *   _queryFilter  = <queryFilter expression>                 \/\/ can be specified as a parameter and\/or in the request body (see below)\r\n  *\r\n  * Body:\r\n  * {\r\n  *     objectIds: [ <list of object IDs],          \/\/ (*) required if no queryFilter specified\r\n  *     queryFilter: <query filter expression>,     \/\/ can be specified here or as a parameter\r\n  *     operation: <patch operation>,               \/\/ required for patch operation\r\n  *     field: <field for patch operation>,         \/\/ required for patch operation\r\n  *     value: <value for patch operation>          \/\/ required for patch operation\r\n  *  }\r\n  *\r\n  * Returns: request content, parameters, items processed \/ failed\r\n *\/\r\n\r\n( function(){\r\n\r\n    const _dbg = \"FRDEBUG: bulkOperation ENDPOINT: \";\r\n\r\n    var info = function( text, args ) { logger.info( \"{}{}: {}\", _dbg, text, args ? args : \"\" ) }, isNonEmptyArray = function (field) { return Array.isArray(field) && field.length};\r\n\r\n    \/\/logger.info(\"{}Received request:{} for resource path {}.\",_dbg,String(request.method).toUpperCase(),request.resourcePath);\r\n    \/\/logger.info(\"{}Parameters: {}.\",_dbg,request.additionalParameters);\r\n    \/\/logger.info(\"{}Request content: {}.\",_dbg, !!request.content ? Object.keys(request.content) : \"\");\r\n\r\n    \/\/logger.info(\"{}Request: {}.\",_dbg,request);\r\n\r\n\r\n     function executeAction( request ) {\r\n\r\n       if ( !request.content || !( isNonEmptyArray(request.content.objectIds) || (!!request.content.queryFilter) || (!!request.additionalParameters._queryFilter) ) ){\r\n         info(\"ERROR: Bad request, target objects not specified.\");\r\n         return { code : 400, message : \"Bad request - no valid request content.\" };\r\n       }\r\n\r\n       if ( !request.action || !([\"patch\",\"delete\"].includes(request.action.toLowerCase() ) )) {\r\n         info(\"ERROR: Unsupported action\",request.action || \"<none>\" );\r\n         return { code : 400, message : \"Bad request - unsupported action: \" + request.action };\r\n       }\r\n\r\n       if ( request.action.toLowerCase() === \"patch\" && !request.content.field ){\r\n         info(\"ERROR: No field specified in PATCH\" );\r\n         return { code : 400, message : \"Bad request - field not specified in PATCH request.\" };\r\n       }\r\n\r\n      logger.info(\"{}ACTION:{} on resource path:{} name:{}.\",_dbg,request.action,request.resourcePath,request.resourceName);\r\n\r\n        const objectPath = request.resourcePath || \"managed\/alpha_user\";\r\n        const qf = String(request.additionalParameters._queryFilter || request.content.queryFilter || \"\");\r\n\r\n        let objectList = (request.content.objectIds || [] ).concat(\r\n          !!qf ? (openidm.query( objectPath , { _queryFilter: qf } , [\"_id\"] ).result || []) : []\r\n        );\r\n\r\n        info(\"Processing objectList with numEntries\", objectList.length);\r\n\r\n        let itemsProcessed = 0;\r\n        let itemsFailed = 0;\r\n\r\n        objectList.forEach( function(item) {\r\n          let objectId = objectPath + '\/' + ( item._id || item );\r\n          switch (request.action) {\r\n            case \"patch\":\r\n              let payload = [\r\n                {\r\n                  operation: request.content.operation,\r\n                  field: request.content.field,\r\n                  value: request.content.value\r\n                }\r\n              ];\r\n\r\n              openidm.patch(\r\n                objectId,\r\n                null,\r\n                payload,\r\n                null,\r\n                [\"_id\"]\r\n              )._id ? itemsProcessed++ : itemsFailed++;\r\n              break;\r\n\r\n            case \"delete\":\r\n              openidm.delete( objectId, null, null, [\"_id\"] )._id ? itemsProcessed++ : itemsFailed++ ;\r\n              break;\r\n\r\n\r\n          }\r\n        }    );\r\n\r\n      return {\r\n              method: request.method,\r\n              action: request.action,\r\n              resourcePath: objectPath,\r\n              _queryFilter: request.additionalParameters._queryFilter,\r\n              queryFilter: request.content.queryFilter,\r\n              itemsProcessed: itemsProcessed,\r\n              itemsFailed: itemsFailed\r\n          };\r\n      }\r\n\r\n      if (request.method === \"action\") return executeAction( request );\r\n      throw { code : 400, message : \"Unsupported request type: \" + request.method };\r\n    }\r\n    )();\r\n"
}

Tip: The value of the context field will be the path to your endpoint underneath /openidm/* . You can change the endpoint part to something else, or omit it entirely. In ID Cloud, the Admin UI adds the endpoint prefix behind the scenes.

  1. Save the file to your IDM project’s conf directory, or send it to IDM via a REST PUT.

Usage

  * Syntax:
  *   POST <path to endpoint>/<resourcePath>  (if not supplied, defaults to managed/alpha_user)
  *
  * Parameters:
  *   _action       = <patch | delete>                         // (*) required
  *   _queryFilter  = <queryFilter expression>                 // can be specified as a parameter and/or in the request body (see below)
  *
  * Body:
  * {
  *     objectIds: [ <list of object IDs],          // (*) required if no queryFilter specified
  *     queryFilter: <query filter expression>,     // can be specified here or as a parameter
  *     operation: <patch operation>,               // required for patch operation
  *     field: <field for patch operation>,         // required for patch operation
  *     value: <value for patch operation>          // required for patch operation
  *  }
  *
  * Returns: request content, parameters, items processed / failed
Example
  1. (using HTTPie) Change the description of all users having a userName that starts with “user10”:
Request
http POST "https://<tenant fqdn>/openidm/endpoint/bulkOperation" \
_action==patch \
_queryFilter=="userName sw \"user10\"" \
operation=replace field=description value="New description!!" \
Authorization:"Bearer <admin access token>"
Response
{
    "_queryFilter": "userName sw \"user10\"",
    "action": "patch",
    "itemsFailed": 0,
    "itemsProcessed": 12,
    "method": "action",
    "queryFilter": null,
    "resourcePath": "managed/alpha_user"
}
  1. (curl) Deactivate a list of users:
Request
curl "https://<tenant fqdn>/openidm/endpoint/bulkOperation?_action=patch" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <admin access token>" \
--data '{
"operation":"replace",
"field":"accountStatus",
"value":"inactive",
"objectIds":["9a1c8491-a929-4b0f-a009-038961072b74",
"c68a6420-d3de-41f9-8455-5109c6cfdeb5"]
}'
Response
{"itemsFailed":0,"itemsProcessed":2,"method":"action","_queryFilter":null,"resourcePath":"managed/alpha_user","action":"patch","queryFilter":null}

Bulk Update Custom Endpoint (asynchronous)

While the synchronous approach is straightforward, it is not suitable for large volumes of target objects. We address this by persisting the requested operation in a managed object and scheduling it for deferred execution using IDM’s scheduler service. The asynchronous version of the endpoint works like this:

  1. Client sends request (see Examples section).
  2. The endpoint script checks if a schema object managed/bulkOperation exists.
  • If not, it creates it and returns a 201 as below. In this case, the client needs to resend the request [^2]
HTTP/1.1 201 Created
{
    "_id": "",
    "status": "Schema updated"
}
  • If yes, proceed to step 3.
  1. The endpoint stores the request in a managed/bulkOperation object and triggers a taskscanner scheduled task.
  2. The endpoint returns a 200 along with the bulkOperation object.
  3. The taskscanner task picks up all managed/bulkOperation objects that are not marked as completed and executes the saved operation.
  4. The bulkOperation object is updated with completion time and items processed:

Installation

Option 1: Platform Admin UI

Follow the same steps as described in the first section, picking a suitable endpoint name and using the script below:

/*
 *  Copyright 2012-2023 ForgeRock AS. All Rights Reserved
 *
 *  DISCLAIMER: This code is provided to you expressly as an example  (“Sample Code”).
 *  It is the responsibility of the individual recipient user, in his/her sole discretion,
 *  to diligence such Sample Code for accuracy, completeness, security, and final determination for appropriateness of use.
 *
 *  ANY SAMPLE CODE IS PROVIDED ON AN “AS IS” IS BASIS, WITHOUT WARRANTY OF ANY KIND.
 *  FORGEROCK AND ITS LICENSORS EXPRESSLY DISCLAIM ALL WARRANTIES,  WHETHER EXPRESS, IMPLIED, OR STATUTORY,
 *  INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES  OF MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
 *
 *  FORGEROCK SHALL NOT HAVE ANY LIABILITY ARISING OUT OF OR RELATING TO ANY USE, IMPLEMENTATION, INTEGRATION,
 *  OR CONFIGURATION OF ANY SAMPLE CODE IN ANY PRODUCTION ENVIRONMENT OR FOR ANY COMMERCIAL DEPLOYMENT(S).
 *
 */

 /*
  * This endpoint schedules one operation, PATCH or DELETE, to be performed on multiple managed objects,
  * specified as a list of objects, or using a query filter.
  *
  * Dependencies:
  *   Requires managed/bulkOperation object to be defined in the IDM schema.
  *   If not defined, this routine defines the object and exits without scheduling any operations.
  *
  *
  * Syntax:
  *   POST <path to endpoint>/<resourcePath>
  *
  * Parameters:
  *   _action      = <patch | delete>                         // (*) required
  *   _queryFilter = <queryFilter expression>                 // can be specified either as a parameter or in the payload (see below)
  *   name         = <custom display name for the operation>  // This will be the name of the bulkOperation object
  *
  * Body:
  * {
  *     objectIds: [ <list of object IDs],          // (*) required if no queryFilter specified
  *     queryFilter: <query filter expression>,     // can be specified here or as a parameter
  *     operation: <patch operation>,               // required for patch operation
  *     field: <field for patch operation>,         // required for patch operation
  *     value: <value for patch operation>          // required for patch operation
  *  }
  *
  * Returns: bulkOperation object
 */

( function(){

  const _dbg = "FRDEBUG: deferredBulkOperation ENDPOINT: ";
  var info = function( text, args ) { logger.info( "{}{}: {}", _dbg, text, args ? args : "" ) }, isNonEmptyArray = function (field) { return Array.isArray(field) && field.length};

  logger.info("{}Received request:{} for resource path {}.",_dbg,String(request.method).toUpperCase(),request.resourcePath);
  logger.info("{}Parameters: {}.",_dbg,request.additionalParameters);

  const scheduledTaskName = "deferredBulkOperationScannerTask";
  const schedulerEndpoint = "scheduler/job";
  const scheduledTask = [ schedulerEndpoint, scheduledTaskName].join('/');
  const taskscannerEndpoint = "taskscanner";
  const objectPath = "managed/bulkOperation";
  const bulkOperationType = "BULK";

  const createBulkOperationObject = function( request ) {

    // Validate parameters
    if ( !request.content || !( isNonEmptyArray(request.content.objectIds) || (!!request.content.queryFilter) || (!!request.additionalParameters._queryFilter) ) ){
      info("ERROR: Bad request, target objects not specified.");
      return { code : 400, message : "Bad request - target objects not specified." };
    }
    if ( !request.action || !(["patch","delete"].includes(request.action.toLowerCase() ) )) {
      info("ERROR: Unsupported action",request.action || "<none>" );
      return { code : 400, message : "Bad request - unsupported action: " + request.action };
    }
    if ( request.action.toLowerCase() === "patch" && !request.content.field ){
      info("ERROR: no field specified in PATCH" );
      return { code : 400, message : "Bad request - field not specified in PATCH request." };
    }

   logger.info("{}ACTION:{} on resource path:{} name:{}.",_dbg,request.action,request.resourcePath,request.resourceName);

   // Prepare bulkOperation object to be picked up by the task scanner
   const bulkOperationObject = {
     name: request.additionalParameters.name,
     request: request,
     timeSubmitted: new Date().toISOString(),
     isExpired: false,
     type: bulkOperationType
   };

   return openidm.create(
     objectPath,
     null,
     bulkOperationObject
   );
   }

  const createOrTriggerTask = function( taskName ) {

    const qf = "_id eq \"" + scheduledTaskName + "\"";
    const existingTask = openidm.query( schedulerEndpoint, { _queryFilter: qf } , ["_id"] );
    info("Existing task:", JSON.stringify(existingTask));

      // Create task scanner job unless already defined (e.g. via persisted schedule)
     if ( !existingTask.resultCount) {

       // this is the actual worker script performing the bulk operation
       const execScript = "\/*\r\n *  Copyright 2012-2023 ForgeRock AS. All Rights Reserved\r\n *\r\n *  DISCLAIMER: This code is provided to you expressly as an example  (\u201CSample Code\u201D).\r\n *  It is the responsibility of the individual recipient user, in his\/her sole discretion,\r\n *  to diligence such Sample Code for accuracy, completeness, security, and final determination for appropriateness of use.\r\n *\r\n *  ANY SAMPLE CODE IS PROVIDED ON AN \u201CAS IS\u201D IS BASIS, WITHOUT WARRANTY OF ANY KIND.\r\n *  FORGEROCK AND ITS LICENSORS EXPRESSLY DISCLAIM ALL WARRANTIES,  WHETHER EXPRESS, IMPLIED, OR STATUTORY,\r\n *  INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES  OF MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.\r\n *\r\n *  FORGEROCK SHALL NOT HAVE ANY LIABILITY ARISING OUT OF OR RELATING TO ANY USE, IMPLEMENTATION, INTEGRATION,\r\n *  OR CONFIGURATION OF ANY SAMPLE CODE IN ANY PRODUCTION ENVIRONMENT OR FOR ANY COMMERCIAL DEPLOYMENT(S).\r\n *\r\n *\/\r\n\r\n \/*\r\n  * This script is executed by the task scanner on each qualifying managed\/bulkOperation object, which contains\r\n  * the original request.\r\n  *\r\n  * Note that the task scanner populates the objectID and input variables in the invocation context.\r\n  *\r\n  *\/\r\n\r\n( function(){\r\n\r\n  const _dbg = \"FRDEBUG: deferredBulkOperation EXEC: \";\r\n  const info = function( text, args ) { logger.info( \"{}{}: {}\", _dbg, text, args ? args : \"\" ) }, isNonEmptyArray = function (field) { return Array.isArray(field) && field.length};\r\n\r\n  info(\"Executing task on deferred bulk operation\", objectID);\r\n\r\n  const executeAction = function( request ) {\r\n    \/\/ the original request.content ends up in request.content.object when saving it in the managed object !\r\n    if ( !request.content.object || !( isNonEmptyArray(request.content.object.objectIds) || (!!request.content.object.queryFilter) || (!!request.additionalParameters._queryFilter) ) ){\r\n      info(\"ERROR: Bad request, target objects not specified.\");\r\n      return { code : 400, message : \"Bad request - no valid request content.\" };\r\n    }\r\n    if ( !request.action || !([\"patch\",\"delete\"].includes(request.action.toLowerCase() ) )) {\r\n      info(\"ERROR: Unsupported action\",request.action || \"<none>\" );\r\n      return { code : 400, message : \"Bad request - unsupported action: \" + request.action };\r\n    }\r\n    if ( request.action.toLowerCase() === \"patch\" && !request.content.object.field ){\r\n      info(\"ERROR: no field specified in PATCH\" );\r\n      return { code : 400, message : \"Bad request - field not specified in PATCH request.\" };\r\n    }\r\n\r\n   logger.info(\"{}ACTION:{} on resource path:{} name:{}.\",_dbg,request.action,request.resourcePath,request.resourceName);\r\n\r\n     const objectPath = request.resourcePath || \"managed\/alpha_user\";\r\n     const qf = String(request.additionalParameters._queryFilter || request.content.object.queryFilter || \"\");\r\n\r\n     let objectList = (request.content.object.objectIds || [] ).concat(\r\n       !!qf ? (openidm.query( objectPath , { _queryFilter: qf } , [\"_id\"] ).result || []) : []\r\n     );\r\n\r\n     info(\"Processing objectList with numEntries\", objectList.length);\r\n\r\n     let itemsProcessed = 0;\r\n     let itemsFailed = 0;\r\n\r\n     objectList.forEach( function(item) {\r\n       let objectId = objectPath + '\/' + ( item._id || item );\r\n       switch (request.action) {\r\n         case \"patch\":\r\n           let payload = [\r\n             {\r\n               operation: request.content.object.operation,\r\n               field: request.content.object.field,\r\n               value: request.content.object.value\r\n             }\r\n           ];\r\n           try {\r\n             openidm.patch(\r\n               objectId,\r\n               null,\r\n               payload,\r\n               null,\r\n               [\"_id\"]\r\n             )._id ? itemsProcessed++ : itemsFailed++;\r\n           }\r\n           catch (e) {\r\n           info(\"PATCH operation failed with error\", e);\r\n           itemsFailed++\r\n         }\r\n           break;\r\n\r\n         case \"delete\":\r\n           try {\r\n             openidm.delete( objectId, null, null, [\"_id\"] )._id ? itemsProcessed++ : itemsFailed++ ;\r\n           }\r\n           catch (e) {\r\n             info(\"DELETE operation failed with error\", e);\r\n             itemsFailed++\r\n           }\r\n           break;\r\n       }\r\n     }    );\r\n     \/\/ Update bulkOperation object with completion details\r\n     input.timeCompleted = new Date().toISOString();\r\n     input.status = \"done\";\r\n     input.isExpired = true;\r\n     input.itemsProcessed = itemsProcessed;\r\n     input.itemsFailed = itemsFailed;\r\n\r\n     openidm.update( objectID, null, input);\r\n\r\n   return {\r\n           method: request.method,\r\n           action: request.action,\r\n           resourcePath: objectPath,\r\n           _queryFilter: request.additionalParameters._queryFilter,\r\n           queryFilter: request.content.object.queryFilter,\r\n           itemsProcessed: itemsProcessed,\r\n           itemsFailed: itemsFailed\r\n       };\r\n   }\r\n\r\nif (input.type === \"BULK\") {\r\n  return executeAction( input.request );\r\n} else {\r\n  throw { code : 400, message : \"BULK script launched with wrong operation type: \" + input.type };\r\n}\r\n\r\n\r\n} )();\r\n"

       const task = {
         "enabled" : true,
         "type" : "simple",
         "persisted": false,
         "concurrentExecution" : true,
         "invokeService" : "taskscanner",
         "invokeContext" : {
         "waitForCompletion" : false,
         "numberOfThreads" : 1,
         "scan" : {
           "_queryFilter" : "/type eq \"BULK\" AND !(timeStarted pr) AND !(timeCompleted pr)",
           "object" : "managed/bulkOperation",
           "taskState" : {
             "started" : "/timeStarted",
             "completed" : "/timeCompleted"
           },
           "recovery" : {
             "timeout" : "10m"
           }
         },
         "task" : {
           "script" : {
             "type" : "text/javascript",
             "source" : execScript
           }
         }
       }
      }
      info("Creating new task...");
      return openidm.create( schedulerEndpoint, scheduledTaskName, task, null, ["_id"] )._id;

   } else {

     info("Triggering existing task", scheduledTaskName);
     return openidm.action("taskscanner", "execute", null, { "name" : scheduledTaskName } );

   }
 }

  const updateSchema = function() {

     info("Schema for bulkOperation not found. Updating...");
     const boSchema = {
       name : "bulkOperation",
       type: "object",
       schema : {
           "$schema": "http://forgerock.org/json-schema#",
           "description": "Holds bulkOperations for background processing",
           "icon": "fa-stack-overflow",
           "order": [
               "name",
               "timeSubmitted",
               "timeStarted",
               "timeCompleted",
               "status",
               "itemsProcessed",
               "itemsFailed",
               "isExpired",
               "request",
               "type"
           ],
           "properties": {
               "isExpired": {
                   "searchable": false,
                   "title": "Expired",
                   "type": "boolean",
                   "userEditable": true,
                   "viewable": true
               },
               "name": {
                   "searchable": true,
                   "title": "Name",
                   "type": "string",
                   "userEditable": true,
                   "viewable": true
               },
               "request": {
                   "order": [],
                   "properties": {},
                   "searchable": false,
                   "title": "Request",
                   "type": "object",
                   "userEditable": true,
                   "viewable": true
               },
               "status": {
                   "searchable": true,
                   "title": "Status",
                   "type": "string",
                   "userEditable": true,
                   "viewable": true
               },
               "itemsProcessed": {
                   "searchable": true,
                   "title": "Items successfully processed",
                   "type": "number",
                   "userEditable": true,
                   "viewable": true
               },
               "itemsFailed": {
                   "searchable": true,
                   "title": "Items failed",
                   "type": "number",
                   "userEditable": true,
                   "viewable": true
               },
               "timeCompleted": {
                   "searchable": true,
                   "title": "Completion time",
                   "type": "string",
                   "userEditable": true,
                   "viewable": true
               },
               "timeStarted": {
                   "searchable": true,
                   "title": "Start Time",
                   "type": "string",
                   "userEditable": true,
                   "viewable": true
               },
               "timeSubmitted": {
                   "description": "Time when this bulk request was submitted",
                   "format": null,
                   "isVirtual": false,
                   "searchable": true,
                   "title": "Submission time",
                   "type": "string",
                   "userEditable": true,
                   "viewable": true
               },
               "type": {
                   "searchable": true,
                   "title": "Operation type",
                   "description": "Type of bulk request (BULK or BATCH)",
                   "type": "string",
                   "userEditable": true,
                   "viewable": true
               }
           },
           "required": [],
           "title": "Bulk Operations",
           "type": "object"
       }
     };
     var m = openidm.read("config/managed");
     m.objects.push(boSchema);

     try {
       openidm.update("config/managed", null, m);

       // await new Promise( (r1) => { setTimeout( () => { r1("DING!!"); }, 3000);  } );
          // Unfortunately, the version of the Rhino engine currently used by IDM does not yet support promises.
          // Therefore, the following schema check will always fail and we cannot update the schema in a
          // completely transparent manner.

       if (openidm.read("schema/managed/bulkOperation") ) {
         info("Schema updated & verified successfully.");
       } else {
         info("Schema NOT yet verified");
       }
     }
     catch (e) {
       info("Failed to update schema");
       throw { code : 500, message : "Updating schema with bulkOperation object failed: " + e }
     }

   return true;
 }

// Main function

if (request.method === "action") {

  if (!openidm.read("schema/managed/bulkOperation")) {
    updateSchema();
    return {
      status: "Schema updated. No operation was scheduled."
    }
   } else {
     var bo = createBulkOperationObject( request );
     info("Created BO object", JSON.stringify(bo));
     info("Create Trigger returned", createOrTriggerTask(scheduledTaskName));
     return bo;
   }
 } else if (request.method === "create") {
   if (!openidm.read("schema/managed/bulkOperation")) {
     updateSchema();
     return {
       status: "Schema updated"
     }
   } else {
     return {
       status: "Schema already updated"
     }
   }
 } else {
  throw { code : 400, message : "Unsupported request type: " + request.method };
}

}
)();
Option 2: Config file / REST

Here’s the inline version of the same script:

{
    "context":"deferredBulkOperation/*",
    "type" : "text/javascript",
    "source" : "\/*\r\n *  Copyright 2012-2023 ForgeRock AS. All Rights Reserved\r\n *\r\n *  DISCLAIMER: This code is provided to you expressly as an example  (\u201CSample Code\u201D).\r\n *  It is the responsibility of the individual recipient user, in his\/her sole discretion,\r\n *  to diligence such Sample Code for accuracy, completeness, security, and final determination for appropriateness of use.\r\n *\r\n *  ANY SAMPLE CODE IS PROVIDED ON AN \u201CAS IS\u201D IS BASIS, WITHOUT WARRANTY OF ANY KIND.\r\n *  FORGEROCK AND ITS LICENSORS EXPRESSLY DISCLAIM ALL WARRANTIES,  WHETHER EXPRESS, IMPLIED, OR STATUTORY,\r\n *  INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES  OF MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.\r\n *\r\n *  FORGEROCK SHALL NOT HAVE ANY LIABILITY ARISING OUT OF OR RELATING TO ANY USE, IMPLEMENTATION, INTEGRATION,\r\n *  OR CONFIGURATION OF ANY SAMPLE CODE IN ANY PRODUCTION ENVIRONMENT OR FOR ANY COMMERCIAL DEPLOYMENT(S).\r\n *\r\n *\/\r\n\r\n \/*\r\n  * This endpoint schedules one operation, PATCH or DELETE, to be performed on multiple managed objects,\r\n  * specified as a list of objects, or using a query filter.\r\n  *\r\n  * Dependencies:\r\n  *   Requires managed\/bulkOperation object to be defined in the IDM schema.\r\n  *   If not defined, this routine defines the object and exits without scheduling any operations.\r\n  *\r\n  *\r\n  * Syntax:\r\n  *   POST <path to endpoint>\/<resourcePath>\r\n  *\r\n  * Parameters:\r\n  *   _action      = <patch | delete>                         \/\/ (*) required\r\n  *   _queryFilter = <queryFilter expression>                 \/\/ can be specified either as a parameter or in the payload (see below)\r\n  *   name         = <custom display name for the operation>  \/\/ This will be the name of the bulkOperation object\r\n  *\r\n  * Body:\r\n  * {\r\n  *     objectIds: [ <list of object IDs],          \/\/ (*) required if no queryFilter specified\r\n  *     queryFilter: <query filter expression>,     \/\/ can be specified here or as a parameter\r\n  *     operation: <patch operation>,               \/\/ required for patch operation\r\n  *     field: <field for patch operation>,         \/\/ required for patch operation\r\n  *     value: <value for patch operation>          \/\/ required for patch operation\r\n  *  }\r\n  *\r\n  * Returns: bulkOperation object\r\n *\/\r\n\r\n( function(){\r\n\r\n  const _dbg = \"FRDEBUG: deferredBulkOperation ENDPOINT: \";\r\n  var info = function( text, args ) { logger.info( \"{}{}: {}\", _dbg, text, args ? args : \"\" ) }, isNonEmptyArray = function (field) { return Array.isArray(field) && field.length};\r\n\r\n  logger.info(\"{}Received request:{} for resource path {}.\",_dbg,String(request.method).toUpperCase(),request.resourcePath);\r\n  logger.info(\"{}Parameters: {}.\",_dbg,request.additionalParameters);\r\n\r\n  const scheduledTaskName = \"deferredBulkOperationScannerTask\";\r\n  const schedulerEndpoint = \"scheduler\/job\";\r\n  const scheduledTask = [ schedulerEndpoint, scheduledTaskName].join('\/');\r\n  const taskscannerEndpoint = \"taskscanner\";\r\n  const objectPath = \"managed\/bulkOperation\";\r\n  const bulkOperationType = \"BULK\";\r\n\r\n  const createBulkOperationObject = function( request ) {\r\n\r\n    \/\/ Validate parameters\r\n    if ( !request.content || !( isNonEmptyArray(request.content.objectIds) || (!!request.content.queryFilter) || (!!request.additionalParameters._queryFilter) ) ){\r\n      info(\"ERROR: Bad request, target objects not specified.\");\r\n      return { code : 400, message : \"Bad request - target objects not specified.\" };\r\n    }\r\n    if ( !request.action || !([\"patch\",\"delete\"].includes(request.action.toLowerCase() ) )) {\r\n      info(\"ERROR: Unsupported action\",request.action || \"<none>\" );\r\n      return { code : 400, message : \"Bad request - unsupported action: \" + request.action };\r\n    }\r\n    if ( request.action.toLowerCase() === \"patch\" && !request.content.field ){\r\n      info(\"ERROR: no field specified in PATCH\" );\r\n      return { code : 400, message : \"Bad request - field not specified in PATCH request.\" };\r\n    }\r\n\r\n   logger.info(\"{}ACTION:{} on resource path:{} name:{}.\",_dbg,request.action,request.resourcePath,request.resourceName);\r\n\r\n   \/\/ Prepare bulkOperation object to be picked up by the task scanner\r\n   const bulkOperationObject = {\r\n     name: request.additionalParameters.name,\r\n     request: request,\r\n     timeSubmitted: new Date().toISOString(),\r\n     isExpired: false,\r\n     type: bulkOperationType\r\n   };\r\n\r\n   return openidm.create(\r\n     objectPath,\r\n     null,\r\n     bulkOperationObject\r\n   );\r\n   }\r\n\r\n  const createOrTriggerTask = function( taskName ) {\r\n\r\n    const qf = \"_id eq \\\"\" + scheduledTaskName + \"\\\"\";\r\n    const existingTask = openidm.query( schedulerEndpoint, { _queryFilter: qf } , [\"_id\"] );\r\n    info(\"Existing task:\", JSON.stringify(existingTask));\r\n\r\n      \/\/ Create task scanner job unless already defined (e.g. via persisted schedule)\r\n     if ( !existingTask.resultCount) {\r\n\r\n       \/\/ this is the actual worker script performing the bulk operation\r\n       const execScript = \"\\\/*\\r\\n *  Copyright 2012-2023 ForgeRock AS. All Rights Reserved\\r\\n *\\r\\n *  DISCLAIMER: This code is provided to you expressly as an example  (\\u201CSample Code\\u201D).\\r\\n *  It is the responsibility of the individual recipient user, in his\\\/her sole discretion,\\r\\n *  to diligence such Sample Code for accuracy, completeness, security, and final determination for appropriateness of use.\\r\\n *\\r\\n *  ANY SAMPLE CODE IS PROVIDED ON AN \\u201CAS IS\\u201D IS BASIS, WITHOUT WARRANTY OF ANY KIND.\\r\\n *  FORGEROCK AND ITS LICENSORS EXPRESSLY DISCLAIM ALL WARRANTIES,  WHETHER EXPRESS, IMPLIED, OR STATUTORY,\\r\\n *  INCLUDING WITHOUT LIMITATION, THE IMPLIED WARRANTIES  OF MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.\\r\\n *\\r\\n *  FORGEROCK SHALL NOT HAVE ANY LIABILITY ARISING OUT OF OR RELATING TO ANY USE, IMPLEMENTATION, INTEGRATION,\\r\\n *  OR CONFIGURATION OF ANY SAMPLE CODE IN ANY PRODUCTION ENVIRONMENT OR FOR ANY COMMERCIAL DEPLOYMENT(S).\\r\\n *\\r\\n *\\\/\\r\\n\\r\\n \\\/*\\r\\n  * This script is executed by the task scanner on each qualifying managed\\\/bulkOperation object, which contains\\r\\n  * the original request.\\r\\n  *\\r\\n  * Note that the task scanner populates the objectID and input variables in the invocation context.\\r\\n  *\\r\\n  *\\\/\\r\\n\\r\\n( function(){\\r\\n\\r\\n  const _dbg = \\\"FRDEBUG: deferredBulkOperation EXEC: \\\";\\r\\n  const info = function( text, args ) { logger.info( \\\"{}{}: {}\\\", _dbg, text, args ? args : \\\"\\\" ) }, isNonEmptyArray = function (field) { return Array.isArray(field) && field.length};\\r\\n\\r\\n  info(\\\"Executing task on deferred bulk operation\\\", objectID);\\r\\n\\r\\n  const executeAction = function( request ) {\\r\\n    \\\/\\\/ the original request.content ends up in request.content.object when saving it in the managed object !\\r\\n    if ( !request.content.object || !( isNonEmptyArray(request.content.object.objectIds) || (!!request.content.object.queryFilter) || (!!request.additionalParameters._queryFilter) ) ){\\r\\n      info(\\\"ERROR: Bad request, target objects not specified.\\\");\\r\\n      return { code : 400, message : \\\"Bad request - no valid request content.\\\" };\\r\\n    }\\r\\n    if ( !request.action || !([\\\"patch\\\",\\\"delete\\\"].includes(request.action.toLowerCase() ) )) {\\r\\n      info(\\\"ERROR: Unsupported action\\\",request.action || \\\"<none>\\\" );\\r\\n      return { code : 400, message : \\\"Bad request - unsupported action: \\\" + request.action };\\r\\n    }\\r\\n    if ( request.action.toLowerCase() === \\\"patch\\\" && !request.content.object.field ){\\r\\n      info(\\\"ERROR: no field specified in PATCH\\\" );\\r\\n      return { code : 400, message : \\\"Bad request - field not specified in PATCH request.\\\" };\\r\\n    }\\r\\n\\r\\n   logger.info(\\\"{}ACTION:{} on resource path:{} name:{}.\\\",_dbg,request.action,request.resourcePath,request.resourceName);\\r\\n\\r\\n     const objectPath = request.resourcePath || \\\"managed\\\/alpha_user\\\";\\r\\n     const qf = String(request.additionalParameters._queryFilter || request.content.object.queryFilter || \\\"\\\");\\r\\n\\r\\n     let objectList = (request.content.object.objectIds || [] ).concat(\\r\\n       !!qf ? (openidm.query( objectPath , { _queryFilter: qf } , [\\\"_id\\\"] ).result || []) : []\\r\\n     );\\r\\n\\r\\n     info(\\\"Processing objectList with numEntries\\\", objectList.length);\\r\\n\\r\\n     let itemsProcessed = 0;\\r\\n     let itemsFailed = 0;\\r\\n\\r\\n     objectList.forEach( function(item) {\\r\\n       let objectId = objectPath + '\\\/' + ( item._id || item );\\r\\n       switch (request.action) {\\r\\n         case \\\"patch\\\":\\r\\n           let payload = [\\r\\n             {\\r\\n               operation: request.content.object.operation,\\r\\n               field: request.content.object.field,\\r\\n               value: request.content.object.value\\r\\n             }\\r\\n           ];\\r\\n           try {\\r\\n             openidm.patch(\\r\\n               objectId,\\r\\n               null,\\r\\n               payload,\\r\\n               null,\\r\\n               [\\\"_id\\\"]\\r\\n             )._id ? itemsProcessed++ : itemsFailed++;\\r\\n           }\\r\\n           catch (e) {\\r\\n           info(\\\"PATCH operation failed with error\\\", e);\\r\\n           itemsFailed++\\r\\n         }\\r\\n           break;\\r\\n\\r\\n         case \\\"delete\\\":\\r\\n           try {\\r\\n             openidm.delete( objectId, null, null, [\\\"_id\\\"] )._id ? itemsProcessed++ : itemsFailed++ ;\\r\\n           }\\r\\n           catch (e) {\\r\\n             info(\\\"DELETE operation failed with error\\\", e);\\r\\n             itemsFailed++\\r\\n           }\\r\\n           break;\\r\\n       }\\r\\n     }    );\\r\\n     \\\/\\\/ Update bulkOperation object with completion details\\r\\n     input.timeCompleted = new Date().toISOString();\\r\\n     input.status = \\\"done\\\";\\r\\n     input.isExpired = true;\\r\\n     input.itemsProcessed = itemsProcessed;\\r\\n     input.itemsFailed = itemsFailed;\\r\\n\\r\\n     openidm.update( objectID, null, input);\\r\\n\\r\\n   return {\\r\\n           method: request.method,\\r\\n           action: request.action,\\r\\n           resourcePath: objectPath,\\r\\n           _queryFilter: request.additionalParameters._queryFilter,\\r\\n           queryFilter: request.content.object.queryFilter,\\r\\n           itemsProcessed: itemsProcessed,\\r\\n           itemsFailed: itemsFailed\\r\\n       };\\r\\n   }\\r\\n\\r\\nif (input.type === \\\"BULK\\\") {\\r\\n  return executeAction( input.request );\\r\\n} else {\\r\\n  throw { code : 400, message : \\\"BULK script launched with wrong operation type: \\\" + input.type };\\r\\n}\\r\\n\\r\\n\\r\\n} )();\\r\\n\"\r\n\r\n       const task = {\r\n         \"enabled\" : true,\r\n         \"type\" : \"simple\",\r\n         \"persisted\": false,\r\n         \"concurrentExecution\" : true,\r\n         \"invokeService\" : \"taskscanner\",\r\n         \"invokeContext\" : {\r\n         \"waitForCompletion\" : false,\r\n         \"numberOfThreads\" : 1,\r\n         \"scan\" : {\r\n           \"_queryFilter\" : \"\/type eq \\\"BULK\\\" AND !(timeStarted pr) AND !(timeCompleted pr)\",\r\n           \"object\" : \"managed\/bulkOperation\",\r\n           \"taskState\" : {\r\n             \"started\" : \"\/timeStarted\",\r\n             \"completed\" : \"\/timeCompleted\"\r\n           },\r\n           \"recovery\" : {\r\n             \"timeout\" : \"10m\"\r\n           }\r\n         },\r\n         \"task\" : {\r\n           \"script\" : {\r\n             \"type\" : \"text\/javascript\",\r\n             \"source\" : execScript\r\n           }\r\n         }\r\n       }\r\n      }\r\n      info(\"Creating new task...\");\r\n      return openidm.create( schedulerEndpoint, scheduledTaskName, task, null, [\"_id\"] )._id;\r\n\r\n   } else {\r\n\r\n     info(\"Triggering existing task\", scheduledTaskName);\r\n     return openidm.action(\"taskscanner\", \"execute\", null, { \"name\" : scheduledTaskName } );\r\n\r\n   }\r\n }\r\n\r\n  const updateSchema = function() {\r\n\r\n     info(\"Schema for bulkOperation not found. Updating...\");\r\n     const boSchema = {\r\n       name : \"bulkOperation\",\r\n       type: \"object\",\r\n       schema : {\r\n           \"$schema\": \"http:\/\/forgerock.org\/json-schema#\",\r\n           \"description\": \"Holds bulkOperations for background processing\",\r\n           \"icon\": \"fa-stack-overflow\",\r\n           \"order\": [\r\n               \"name\",\r\n               \"timeSubmitted\",\r\n               \"timeStarted\",\r\n               \"timeCompleted\",\r\n               \"status\",\r\n               \"itemsProcessed\",\r\n               \"itemsFailed\",\r\n               \"isExpired\",\r\n               \"request\",\r\n               \"type\"\r\n           ],\r\n           \"properties\": {\r\n               \"isExpired\": {\r\n                   \"searchable\": false,\r\n                   \"title\": \"Expired\",\r\n                   \"type\": \"boolean\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"name\": {\r\n                   \"searchable\": true,\r\n                   \"title\": \"Name\",\r\n                   \"type\": \"string\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"request\": {\r\n                   \"order\": [],\r\n                   \"properties\": {},\r\n                   \"searchable\": false,\r\n                   \"title\": \"Request\",\r\n                   \"type\": \"object\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"status\": {\r\n                   \"searchable\": true,\r\n                   \"title\": \"Status\",\r\n                   \"type\": \"string\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"itemsProcessed\": {\r\n                   \"searchable\": true,\r\n                   \"title\": \"Items successfully processed\",\r\n                   \"type\": \"number\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"itemsFailed\": {\r\n                   \"searchable\": true,\r\n                   \"title\": \"Items failed\",\r\n                   \"type\": \"number\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"timeCompleted\": {\r\n                   \"searchable\": true,\r\n                   \"title\": \"Completion time\",\r\n                   \"type\": \"string\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"timeStarted\": {\r\n                   \"searchable\": true,\r\n                   \"title\": \"Start Time\",\r\n                   \"type\": \"string\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"timeSubmitted\": {\r\n                   \"description\": \"Time when this bulk request was submitted\",\r\n                   \"format\": null,\r\n                   \"isVirtual\": false,\r\n                   \"searchable\": true,\r\n                   \"title\": \"Submission time\",\r\n                   \"type\": \"string\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               },\r\n               \"type\": {\r\n                   \"searchable\": true,\r\n                   \"title\": \"Operation type\",\r\n                   \"description\": \"Type of bulk request (BULK or BATCH)\",\r\n                   \"type\": \"string\",\r\n                   \"userEditable\": true,\r\n                   \"viewable\": true\r\n               }\r\n           },\r\n           \"required\": [],\r\n           \"title\": \"Bulk Operations\",\r\n           \"type\": \"object\"\r\n       }\r\n     };\r\n     var m = openidm.read(\"config\/managed\");\r\n     m.objects.push(boSchema);\r\n\r\n     try {\r\n       openidm.update(\"config\/managed\", null, m);\r\n\r\n       \/\/ await new Promise( (r1) => { setTimeout( () => { r1(\"DING!!\"); }, 3000);  } );\r\n          \/\/ Unfortunately, the version of the Rhino engine currently used by IDM does not yet support promises.\r\n          \/\/ Therefore, the following schema check will always fail and we cannot update the schema in a\r\n          \/\/ completely transparent manner.\r\n\r\n       if (openidm.read(\"schema\/managed\/bulkOperation\") ) {\r\n         info(\"Schema updated & verified successfully.\");\r\n       } else {\r\n         info(\"Schema NOT yet verified\");\r\n       }\r\n     }\r\n     catch (e) {\r\n       info(\"Failed to update schema\");\r\n       throw { code : 500, message : \"Updating schema with bulkOperation object failed: \" + e }\r\n     }\r\n\r\n   return true;\r\n }\r\n\r\n\/\/ Main function\r\n\r\nif (request.method === \"action\") {\r\n\r\n  if (!openidm.read(\"schema\/managed\/bulkOperation\")) {\r\n    updateSchema();\r\n    return {\r\n      status: \"Schema updated. No operation was scheduled.\"\r\n    }\r\n   } else {\r\n     var bo = createBulkOperationObject( request );\r\n     info(\"Created BO object\", JSON.stringify(bo));\r\n     info(\"Create Trigger returned\", createOrTriggerTask(scheduledTaskName));\r\n     return bo;\r\n   }\r\n } else if (request.method === \"create\") {\r\n   if (!openidm.read(\"schema\/managed\/bulkOperation\")) {\r\n     updateSchema();\r\n     return {\r\n       status: \"Schema updated\"\r\n     }\r\n   } else {\r\n     return {\r\n       status: \"Schema already updated\"\r\n     }\r\n   }\r\n } else {\r\n  throw { code : 400, message : \"Unsupported request type: \" + request.method };\r\n}\r\n\r\n}\r\n)();\r\n"
}

Usage

Schema Update

When the endpoint is called for the first time, it will check if a bulkOperation object exists in the managed object schema. If yes, it will proceed. If not, it will add the object definition to the schema and return HTTP status code 201. In the latter case, the client needs to resend the original request.
You can avoid this ambiguity by requesting the schema update explicitly, by sending an empty POST to the endpoint:

Request

http POST https://<tenant fqdn>/openidm/endpoint/deferredBulkOperation Authorization:"Bearer <admin access token" a=1

Response

{ "_id": "", "status": "Schema updated" }

Bulk Operations
  * Syntax:
  *   POST <path to endpoint>/<resourcePath>
  *
  * Parameters:
  *   _action      = <patch | delete>                         // (*) required
  *   _queryFilter = <queryFilter expression>                 // can be specified either as a parameter or in the payload (see below)
  *   name         = <custom display name for the operation>  // This will be the name of the bulkOperation object
  *
  * Body:
  * {
  *     objectIds: [ <list of object IDs],          // (*) required if no queryFilter specified
  *     queryFilter: <query filter expression>,     // can be specified here or as a parameter
  *     operation: <patch operation>,               // required for patch operation
  *     field: <field for patch operation>,         // required for patch operation
  *     value: <value for patch operation>          // required for patch operation
  *  }
  *
  * Returns: bulkOperation object
Examples
  1. (using HTTPie) Change the description of all users having a userName that starts with “user1”
Request
http POST "https://<tenant fqdn>/openidm/endpoint/deferredBulkOperation" \
_action==patch \
_queryFilter=="userName sw \"user1\"" \
name=="A bulky asynchronous operation" \
operation=replace field=description value="New description!!" \
Authorization:"Bearer <admin access token>"
Response
{
    "_id": "c9f105ba-bf7b-419a-81a0-33eaaca346da",
    "_rev": "8da074b0-45c0-46a0-adc3-551ca72bbf25-2831",
    "isExpired": false,
    "name": "A bulky asynchronous operation",
    "request": {
        "action": "patch",
        "additionalParameters": {
            "_queryFilter": "userName sw \"user1\""
        },
        "content": {
            "boolean": false,
            "collection": false,
            "list": false,
            "map": true,
            "notNull": true,
            "null": false,
            "number": false,
            "object": {
                "field": "description",
                "operation": "replace",
                "value": "BIG_BULK"
            },
            "pointer": {
                "empty": true,
                "value": "/"
            },
            "string": false
        },
        "fields": [],
        "preferredLocales": {
            "locales": [
                ""
            ],
            "preferredLocale": ""
        },
        "requestType": "ACTION",
        "resourcePath": "",
        "resourcePathObject": {
            "empty": true
        },
        "resourceVersion": null
    },
    "timeSubmitted": "2023-10-10T08:00:53.088Z",
    "type": "BULK"
}

Once the operation has completed, the bulkOperation will be updated with the completion time and number of items processed, and its isExpired flag will change to true :

    "_id": "c9f105ba-bf7b-419a-81a0-33eaaca346da",
    "_rev": "8da074b0-45c0-46a0-adc3-551ca72bbf25-3504",
    "isExpired": true,
    "itemsFailed": 0,
    "itemsProcessed": 112,
    "name": "A bulky asynchronous operation",
    "request": {
       ...
    },
    "status": "done",
    "timeCompleted": "2023-10-10T08:01:03.619Z",
    "timeStarted": "2023-10-10T08:00:53.138093338",
    "timeSubmitted": "2023-10-10T08:00:53.088Z",
    "type": "BULK"
}

[1]: Reference to article on RDVPs to be added here.

[2]: The reason for this behaviour is the lack of support for promises in the version of the Rhino script engine currently used by IDM. This makes it difficult to implement an efficient way of checking for the successful completion of the schema update, and subsequent re-start of the managed schema services, in line with the bulk operation request.

3 Likes