diff --git a/package-lock.json b/package-lock.json index 53a1d09fb49..f16699ff667 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5750,6 +5750,10 @@ "resolved": "shared-libs/bulk-docs-utils", "link": true }, + "node_modules/@medic/bulk-operations": { + "resolved": "shared-libs/bulk-operations", + "link": true + }, "node_modules/@medic/calendar-interval": { "resolved": "shared-libs/calendar-interval", "link": true @@ -44275,6 +44279,11 @@ "version": "1.0.0", "license": "Apache-2.0" }, + "shared-libs/bulk-operations": { + "name": "@medic/bulk-operations", + "version": "1.0.0", + "license": "Apache-2.0" + }, "shared-libs/calendar-interval": { "name": "@medic/calendar-interval", "version": "1.0.0", diff --git a/shared-libs/bulk-operations/package.json b/shared-libs/bulk-operations/package.json new file mode 100644 index 00000000000..24d1de4831d --- /dev/null +++ b/shared-libs/bulk-operations/package.json @@ -0,0 +1,11 @@ +{ + "name": "@medic/bulk-operations", + "version": "1.0.0", + "description": "Shared document model for the CHT-Core bulk operation framework (delete, move, merge)", + "main": "src/index.js", + "scripts": { + "test": "nyc --nycrcPath='../nyc.config.js' mocha ./test" + }, + "author": "", + "license": "Apache-2.0" +} diff --git a/shared-libs/bulk-operations/src/index.js b/shared-libs/bulk-operations/src/index.js new file mode 100644 index 00000000000..d84078cce83 --- /dev/null +++ b/shared-libs/bulk-operations/src/index.js @@ -0,0 +1,98 @@ +const { v7: uuid } = require('uuid'); + +/** + * Document model for the bulk operation framework. + * + * A bulk operation is recorded with two kinds of document: + * - a single LOG document (stored in medic-logs) that tracks overall status and is read by the polling endpoint. + * - one ACTION document per action type (stored in medic-sentinel) that Sentinel processes in batches. + * + * Delete, move and merge all express their work as a set of these actions. + */ + +const LOG_ID_PREFIX = 'bulk-operation:'; +const ACTION_ID_PREFIX = 'bulk-operation-action:'; + +const ACTIONS = { + ARCHIVE: 'archive', + SET_CONTACT: 'set-contact', + DELETE_USER: 'delete-user', +}; + +const STATUSES = { + QUEUED: 'queued', + COMPLETED: 'completed', + FAILED: 'failed', +}; + +// The per-item params for each action live in an attachment rather than inline, so updating the +// action document's cursor as Sentinel works through the batches does not rewrite the whole list. +const OPERATIONS_ATTACHMENT = 'operations'; +const OPERATIONS_CONTENT_TYPE = 'application/json'; + +const getOperationUuid = (operationId) => operationId.slice(LOG_ID_PREFIX.length); + +const generateOperationId = () => `${LOG_ID_PREFIX}${uuid()}`; + +const generateActionId = (operationId) => `${ACTION_ID_PREFIX}${getOperationUuid(operationId)}:${uuid()}`; + +const encodeOperations = (operations) => ({ + content_type: OPERATIONS_CONTENT_TYPE, + data: Buffer.from(JSON.stringify(operations)).toString('base64'), +}); + +const decodeOperations = (attachment) => JSON.parse(Buffer.from(attachment.data, 'base64').toString()); + +const buildActionDoc = (operationId, action, operations) => ({ + _id: generateActionId(operationId), + bulk_operation_id: operationId, + action, + cursor: 0, + total: operations.length, + _attachments: { + [OPERATIONS_ATTACHMENT]: encodeOperations(operations), + }, +}); + +const buildLogAction = (action, totalChangesCount, date) => ({ + status: STATUSES.QUEUED, + action, + updated_date: date, + total_changes_count: totalChangesCount, +}); + +/** + * Builds the documents that make up a single bulk operation. + * @param {Array<{action: string, operations: Object[]}>} actionOperations - one entry per action type, each holding + * its list of per-item params + * @param {Date} date - the operation start date, also the initial updated_date of every action + * @returns {{log: Object, actions: Object[]}} the log document (for medic-logs) and the action documents + * (for medic-sentinel); the log's `actions` map is keyed by each action document's `_id` + */ +const buildBulkOperation = (actionOperations, date) => { + const operationId = generateOperationId(); + const actions = actionOperations.map(({ action, operations }) => buildActionDoc(operationId, action, operations)); + + const logActions = {}; + actions.forEach((actionDoc) => { + logActions[actionDoc._id] = buildLogAction(actionDoc.action, actionDoc.total, date); + }); + + const log = { + _id: operationId, + start_date: date, + actions: logActions, + }; + + return { log, actions }; +}; + +module.exports = { + LOG_ID_PREFIX, + ACTION_ID_PREFIX, + ACTIONS, + STATUSES, + OPERATIONS_ATTACHMENT, + buildBulkOperation, + decodeOperations, +}; diff --git a/shared-libs/bulk-operations/test/index.js b/shared-libs/bulk-operations/test/index.js new file mode 100644 index 00000000000..c4fc5074008 --- /dev/null +++ b/shared-libs/bulk-operations/test/index.js @@ -0,0 +1,100 @@ +const chai = require('chai'); +const { validate: isUuid } = require('uuid'); + +const service = require('../src/index'); + +const expect = chai.expect; + +describe('bulk-operations document model', () => { + describe('constants', () => { + it('exposes the action types', () => { + expect(service.ACTIONS).to.deep.equal({ + ARCHIVE: 'archive', + SET_CONTACT: 'set-contact', + DELETE_USER: 'delete-user', + }); + }); + + it('exposes the statuses', () => { + expect(service.STATUSES).to.deep.equal({ + QUEUED: 'queued', + COMPLETED: 'completed', + FAILED: 'failed', + }); + }); + + it('exposes the document id prefixes', () => { + expect(service.LOG_ID_PREFIX).to.equal('bulk-operation:'); + expect(service.ACTION_ID_PREFIX).to.equal('bulk-operation-action:'); + }); + }); + + describe('buildBulkOperation', () => { + const date = new Date('2026-06-29T12:00:00.000Z'); + + it('builds a log document with a uuid-v7 operation id', () => { + const { log } = service.buildBulkOperation([{ action: 'archive', operations: [{ id: 'a' }] }], date); + + expect(log._id.startsWith('bulk-operation:')).to.equal(true); + expect(isUuid(log._id.slice('bulk-operation:'.length))).to.equal(true); + expect(log.start_date).to.equal(date); + }); + + it('builds one action document per action type, linked back to the operation', () => { + const groups = [ + { action: 'archive', operations: [{ id: 'person' }, { id: 'report' }] }, + { action: 'set-contact', operations: [{ id: 'place', current_contact_id: 'person' }] }, + { action: 'delete-user', operations: [{ id: 'org.couchdb.user:chw' }] }, + ]; + + const { log, actions } = service.buildBulkOperation(groups, date); + + expect(actions).to.have.length(3); + actions.forEach((actionDoc, i) => { + expect(actionDoc._id.startsWith('bulk-operation-action:')).to.equal(true); + // the action id embeds the operation's uuid, so the two ids stay parseable together + expect(actionDoc._id).to.include(log._id.slice('bulk-operation:'.length)); + expect(actionDoc.bulk_operation_id).to.equal(log._id); + expect(actionDoc.action).to.equal(groups[i].action); + expect(actionDoc.cursor).to.equal(0); + expect(actionDoc.total).to.equal(groups[i].operations.length); + expect(service.decodeOperations(actionDoc._attachments.operations)).to.deep.equal(groups[i].operations); + }); + }); + + it('stores the operation params in a base64 json attachment', () => { + const operations = [{ id: 'place', current_contact_id: 'person' }]; + const { actions } = service.buildBulkOperation([{ action: 'set-contact', operations }], date); + + const attachment = actions[0]._attachments.operations; + expect(attachment.content_type).to.equal('application/json'); + expect(attachment.data).to.be.a('string'); + expect(service.decodeOperations(attachment)).to.deep.equal(operations); + }); + + it('cross-links each action document to its entry in the log', () => { + const groups = [ + { action: 'archive', operations: [{ id: 'person' }] }, + { action: 'set-contact', operations: [{ id: 'place' }, { id: 'place2' }] }, + ]; + + const { log, actions } = service.buildBulkOperation(groups, date); + + expect(Object.keys(log.actions)).to.deep.equal(actions.map(actionDoc => actionDoc._id)); + actions.forEach((actionDoc) => { + const logAction = log.actions[actionDoc._id]; + expect(logAction.status).to.equal('queued'); + expect(logAction.action).to.equal(actionDoc.action); + expect(logAction.updated_date).to.equal(date); + expect(logAction.total_changes_count).to.equal(actionDoc.total); + }); + }); + + it('generates a distinct operation id on each call', () => { + const first = service.buildBulkOperation([{ action: 'archive', operations: [] }], date); + const second = service.buildBulkOperation([{ action: 'archive', operations: [] }], date); + + expect(first.log._id).to.not.equal(second.log._id); + }); + }); +});