@getanthill/datastore
Version:
Event-Sourced Datastore
575 lines • 23 kB
JavaScript
"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