UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

575 lines 23 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getGraphData = exports.deleteEntity = exports.unarchive = exports.archive = exports.graph = exports.decrypt = exports.encrypt = exports.createSnapshot = exports.getEvents = exports.find = exports.restore = exports.timetravel = exports.get = exports.apply = exports.patch = exports.update = exports.create = void 0; const omit_1 = __importDefault(require("lodash/omit")); const utils_1 = require("../../utils"); const utils_2 = require("../utils"); const constants_1 = require("../../constants"); const handlers = __importStar(require("../events/handlers")); const middleware_1 = require("../middleware"); function create(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiCreate, async (req, res, next) => { const entity = await handlers.created(services, req, req.body); // @ts-ignore res.body = entity.state; services.telemetry.logger.debug('[api/models#create] Entity created', { model: req.params.model, entity: entity.state, }); next(); }); } exports.create = create; function update(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiUpdate, async (req, res, next) => { const entity = await handlers.updated(services, req, req.body); // @ts-ignore res.body = entity.state; services.telemetry.logger.debug('[api/models#update] Entity updated', { model: req.params.model, entity: entity.state, }); next(); }); } exports.update = update; function patch(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiPatch, async (req, res, next) => { const entity = await handlers.patched(services, req, req.body); // @ts-ignore res.body = entity.state; next(); }); } exports.patch = patch; function apply(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiApply, async (req, res, next) => { const entity = await handlers.applied(services, req, req.body); // @ts-ignore res.body = entity.state; next(); }); } exports.apply = apply; function get(services) { const decryptTokens = (0, middleware_1.getTokensByRole)(services.config.security.tokens, 'decrypt'); return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiGet, async (req, res, next) => { const mustDecrypt = req.header('decrypt') === 'true' && !!(0, middleware_1.isAuthorized)(decryptTokens, (0, middleware_1.getAuthorizationToken)(req)); const model = services.models.factory(req.params.model, req.params.correlation_id); await model.getState(); if (model.state === null) { throw new Error('Not Found'); } if (mustDecrypt === true) { // @ts-ignore res.body = await (0, utils_2.decrypt)(services, res.locals, req.params.model, model.state); } else { // @ts-ignore res.body = model.state; } next(); }, { message: { 'Unauthorized field processing': 403, 'Event schema validation error': 422, }, }); } exports.get = get; function timetravel(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiTimetravel, async (req, res, next) => { const Model = services.models.getModel(req.params.model); const model = services.models.factory(req.params.model, req.params.correlation_id); const versionParam = req.params.version; let version = parseInt(versionParam, 10); if (constants_1.REGEXP_DATE_ISO_STRING_8601.test(versionParam)) { const lastEvent = await Model.getEventsCollection(Model.db(services.mongodb)).findOne({ [Model.getCorrelationField()]: req.params.correlation_id, created_at: { $lte: (0, utils_1.getDate)(versionParam), }, }, { sort: { version: -1, }, }); if (!lastEvent) { throw new Error('Not Found'); } version = lastEvent.version; } await model.getState(); if (model.state === null) { throw new Error('Not Found'); } const state = await model.getStateAtVersion(version, false); // @ts-ignore res.body = state; next(); }, { message: { 'Event schema validation error': 422, }, }); } exports.timetravel = timetravel; function restore(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiRestore, async (req, res, next) => { /* @ts-ignore */ const entity = await handlers.restored(services, req, req.body); // @ts-ignore res.body = entity.state; next(); }, { message: { 'Event schema validation error': 422, 'State version does not exist': 404, /* @ts-ignore */ 'Can not rollback a restoration event': async (_err, req, res) => { const { currentVersion, model } = req.locals; /* istanbul ignore next */ if (currentVersion > 0 && model) { // Update the model state due to the reduction fail on the // restoration event: await model.getState(); // Special use case of a restoration handled by admin failing due // to index unicity violation: await model.restore(currentVersion); } return 409; }, }, }); } exports.restore = restore; function find(services) { const decryptTokens = (0, middleware_1.getTokensByRole)(services.config.security.tokens, 'decrypt'); return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiFind, async (req, res, next) => { const Model = services.models.getModel(req.params.model); const correlationField = Model.getModelConfig().correlation_field; // Map the query parameters to request services.MongoDb accordingly const { query: mappedQuery, options } = (0, utils_2.mapFindQuery)(Model, req.query); let _mappedQuery = mappedQuery; // Retrieve pagination parameters: const page = parseInt(req.header('page') ?? '0', 10); const pageSize = parseInt(req.header('page-size') ?? '1000', 10); const cursorLastId = req.header('cursor-last-id'); const cursorLastCorrelationId = req.header('cursor-last-correlation-id'); const withResponseValidation = req.header('with-response-validation') !== 'false'; const mustDecrypt = req.header('decrypt') === 'true' && !!(0, middleware_1.isAuthorized)(decryptTokens, (0, middleware_1.getAuthorizationToken)(req)); services.telemetry.logger.debug('[api/models#find] Query', { model: req.params.model, query: req.query, mapped_query: mappedQuery, options, page: page, page_size: pageSize, headers: req.headers, }); if (mappedQuery === null) { res.set({ 'correlation-field': correlationField, 'cursor-last-id': cursorLastId, 'cursor-last-correlation-id': cursorLastCorrelationId, 'page-count': 0, page, 'page-size': pageSize, count: 0, }); // @ts-ignore res.body = []; services.metrics.incrementApiFind({ state: '200', model: req.params.model, }); return next(); } if (cursorLastId) { _mappedQuery = { $and: [ _mappedQuery, (0, utils_2.getQueryFromCursorLastId)(Model, options, cursorLastId, cursorLastCorrelationId), ], }; } /** * @fixme warning on unindexed queries. */ const [cursor, promiseCount] = [ Model.find(services.mongodb, _mappedQuery, options), pageSize !== 0 ? -1 : Model.count(services.mongodb, mappedQuery), ]; if (!cursorLastId) { cursor.skip(pageSize * page); } cursor.batchSize(pageSize * 2); let entities = []; let i = 0; let lastReturnedFound = false; // i < pageSize useful if pageSize === 0; while (i < pageSize && (await cursor.hasNext())) { const entity = await cursor.next(); if (cursorLastCorrelationId && lastReturnedFound === false) { lastReturnedFound = entity[correlationField] === cursorLastCorrelationId; continue; } entities.push((0, omit_1.default)(entity, '_id')); i++; if (i >= pageSize) { break; } } /** * @warn This cursor closing is critical to * avoid possible memory leaks on some collections. */ await cursor.close(); /** * Adding the correlation field to the response headers * @todo need to apply this to every request and in the API documentation */ const lastEntity = entities[entities.length - 1]; res.set({ 'correlation-field': correlationField, 'cursor-last-id': (0, utils_2.buildCursorLastId)(lastEntity, options), 'cursor-last-correlation-id': lastEntity?.[correlationField] ?? '', }); // Pagination headers if not requested with cursor last ID: const count = await promiseCount; if (count !== -1) { res.set({ 'page-count': Math.ceil(count / pageSize), page, 'page-size': pageSize, count, }); } if (mustDecrypt === true) { entities = await Promise.all(entities.map((data) => (0, utils_2.decrypt)(services, res.locals, req.params.model, data))); } // @ts-ignore res.body = entities; services.telemetry.logger.debug('[api/models#find] Response', { model: req.params.model, count, }); if (withResponseValidation === false) { services.metrics.incrementApiFind({ state: '200', model: req.params.model, }); return res.json(entities); } next(); }, { message: { 'Unauthorized field processing': 403, 'Event schema validation error': 422, }, }); } exports.find = find; function getEvents(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiEvents, async (req, res, _next) => { const Model = services.models.getModel(req.params.model); const correlationField = Model.getModelConfig().correlation_field; // Retrieve pagination parameters: const page = parseInt(req.header('page') ?? '0', 10); const pageSize = parseInt(req.header('page-size') ?? '1000', 10); const cursorLastId = req.header('cursor-last-id'); const cursorLastCorrelationId = req.header('cursor-last-correlation-id'); let cursor; let count; const correlationId = (req.params.correlation_id || req.query.correlation_id); const eventsCollection = Model.getEventsCollection(Model.db(services.mongodb)); let _mappedQuery; let _options; if (correlationId) { const model = services.models.factory(req.params.model, correlationId); await model.getState(); if (model.state === null) { throw new Error('Not Found'); } // @ts-ignore - Warning about the req.query.version!!! 0 == false... const version = parseInt(req.query.version || '-1', 10); const query = { [Model.getCorrelationField()]: correlationId, version: { $gt: version, }, }; _options = { sort: { version: 1 } }; _mappedQuery = query; if (cursorLastId) { _mappedQuery = { $and: [ _mappedQuery, (0, utils_2.getQueryFromCursorLastId)(Model, _options, cursorLastId, cursorLastCorrelationId), ], }; } services.telemetry.logger.debug('[api/models#getEvents] Query', { model: req.params.model, query: req.query, mapped_query: query, page: page, page_size: pageSize, headers: req.headers, }); cursor = eventsCollection.find(_mappedQuery).sort(_options.sort); count = pageSize !== 0 ? -1 : await eventsCollection.count(query); } else { // Map the query parameters to request services.MongoDb accordingly const { query: mappedQuery, options } = (0, utils_2.mapFindQuery)(Model, req.query); _mappedQuery = mappedQuery; _options = options; if (mappedQuery === null) { res.set({ 'correlation-field': correlationField, 'cursor-last-id': '', 'cursor-last-correlation-id': '', 'page-count': 0, page: 0, 'page-size': pageSize, count: 0, }); // @ts-ignore res.body = []; // @ts-ignore return res.json(res.body); } if (cursorLastId) { _mappedQuery = { $and: [ _mappedQuery, (0, utils_2.getQueryFromCursorLastId)(Model, _options, cursorLastId, cursorLastCorrelationId), ], }; } services.telemetry.logger.debug('[api/models#getEvents] Query', { model: req.params.model, query: req.query, mapped_query: mappedQuery, page: page, page_size: pageSize, headers: req.headers, }); cursor = eventsCollection.find(_mappedQuery, options); count = pageSize !== 0 ? -1 : await eventsCollection.count(mappedQuery); } if (!cursorLastId) { cursor.skip(pageSize * page); } cursor.batchSize(pageSize * 2); const events = []; let i = 0; let lastReturnedFound = false; while (i < pageSize && (await cursor.hasNext())) { const entity = await cursor.next(); if (cursorLastCorrelationId && lastReturnedFound === false) { lastReturnedFound = true; lastReturnedFound = `${entity[correlationField]}:${entity.version}` === cursorLastCorrelationId; continue; } events.push((0, omit_1.default)(entity, '_id', 'updated_at')); i++; if (i >= pageSize) { break; } } /** * @warn This cursor closing is critical to * avoid possible memory leaks on some collections. */ await cursor.close(); /** * Adding the correlation field to the response headers * @todo need to apply this to every request and in the API documentation */ const lastEvent = events[events.length - 1]; res.set({ 'correlation-field': correlationField, 'cursor-last-id': (0, utils_2.buildCursorLastId)(lastEvent, _options), 'cursor-last-correlation-id': lastEvent ? `${lastEvent?.[correlationField]}:${lastEvent?.version}` : '', }); // Pagination headers if not requested with cursor last ID: if (count !== -1) { res.set({ 'page-count': Math.ceil(count / pageSize), page, 'page-size': pageSize, count, }); } // @ts-ignore res.body = events; /** * @fixme possible route clash on OpenAPI * response validation */ return res.json(events); }, { message: { 'Event schema validation error': 422, }, }); } exports.getEvents = getEvents; function createSnapshot(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiSnapshot, async (req, res, next) => { const model = services.models.factory(req.params.model, req.params.correlation_id); await model.getState(); /** * @fixme Move this test in the Event Sourced library */ if (model.state === null) { throw new Error('Entity must be created first'); } const snapshot = await model.createSnapshot(); // @ts-ignore res.body = snapshot; next(); }, { message: { 'Event schema validation error': 422, }, }); } exports.createSnapshot = createSnapshot; function encrypt(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiEncrypt, async (req, res, next) => { const Model = services.models.getModel(req.params.model); // @ts-ignore res.body = req.body.map((data) => Model.encrypt(data, req.query.fields)); next(); }); } exports.encrypt = encrypt; function decrypt(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiDecrypt, async (req, res, next) => { // @ts-ignore res.body = await Promise.all(req.body.map((data) => (0, utils_2.decrypt)(services, res.locals, req.params.model, data, req.query.fields))); next(); }, { message: { 'Unauthorized field processing': 403, 'Unsupported state or unable to authenticate data': 500, }, }); } exports.decrypt = decrypt; const graph = (meterName, handler) => (services) => { const meter = services.metrics[meterName]; return (0, utils_2.controllerBuilder)(services, meter, async (req, res, next) => { meter({ state: 'request', model: req.params.model, }); const Model = services.models.getModel(req.params.model); const entities = await services.models.getEntitiesFromGraph(req.params.model, { [Model.getCorrelationField()]: req.params.correlation_id, }, { graph: services.models.getGraph(), models: req.query.deep === 'true' ? undefined : [req.params.model], handler: async (services, Model, entity) => { if (!req.query.models || req.query.models.includes(Model.getModelConfig().name)) { return handler(services, Model, entity); } return entity; }, }); meter({ state: '200', model: req.params.model, }); return res.json(Array.from(entities.values())); }, { message: { 'Entity archived too recently': 422, }, }); }; exports.graph = graph; exports.archive = (0, exports.graph)('incrementApiArchive', async (services, Model, entity, req) => { const e = new Model(services, entity[Model.getCorrelationField()]); await e.archive(); return e.state; }); exports.unarchive = (0, exports.graph)('incrementApiUnarchive', async (services, Model, entity) => { const e = new Model(services, entity[Model.getCorrelationField()]); await e.unarchive(); return e.state; }); exports.deleteEntity = (0, exports.graph)('incrementApiDelete', async (services, Model, entity) => { const e = new Model(services, entity[Model.getCorrelationField()]); await e.delete(); return e.state; }); function getGraphData(services) { return (0, utils_2.controllerBuilder)(services, services.metrics.incrementApiAdminGetGraph, async (req, res, next) => { services.metrics.incrementApiAdminGetGraph({ state: 'request', model: req.params.model, }); const Model = services.models.getModel(req.params.model); const models = req.query.models || Array.from(services.models.MODELS.keys()); const entities = []; await services.models.getEntitiesFromGraph(req.params.model, { [Model.getCorrelationField()]: req.params.correlation_id, }, { graph: services.models.getGraph(), handler: (services, Model, entity) => { entities.push({ model: Model.getModelConfig().name, entity, }); return entity; }, }); services.metrics.incrementApiAdminGetGraph({ state: '200', model: req.params.model, }); return res.json(entities.filter((e) => models.includes(e.model))); }); } exports.getGraphData = getGraphData; //# sourceMappingURL=controllers.js.map