Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions shared-libs/bulk-operations/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
98 changes: 98 additions & 0 deletions shared-libs/bulk-operations/src/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
100 changes: 100 additions & 0 deletions shared-libs/bulk-operations/test/index.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading