@getanthill/datastore
Version:
Event-Sourced Datastore
595 lines • 21.5 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.ERROR_STREAM_MAX_RECONNECTION_ATTEMPTS_REACHED = exports.ERROR_MISSING_JSON_PATCH = exports.ERROR_MISSING_CORRELATION_ID = exports.ERROR_MISSING_MODEL_NAME = void 0;
const events_1 = require("events");
const lodash_1 = require("lodash");
const jsonpatch = __importStar(require("fast-json-patch"));
const Core_1 = __importDefault(require("./Core"));
const Streams_1 = __importDefault(require("./Streams"));
const GraphQL_1 = __importDefault(require("./GraphQL"));
const utils_1 = require("./utils");
exports.ERROR_MISSING_MODEL_NAME = new Error('Missing Model name');
exports.ERROR_MISSING_CORRELATION_ID = new Error('Missing Correlation ID');
exports.ERROR_MISSING_JSON_PATCH = new Error('Missing JSON Patch');
exports.ERROR_STREAM_MAX_RECONNECTION_ATTEMPTS_REACHED = new Error('Max reconnection attempts reached for streaming');
function mergeWithArrays(objValue, srcValue) {
if (Array.isArray(objValue)) {
return objValue.concat(srcValue);
}
}
function mapValuesDeep(obj, cb, key) {
if (Array.isArray(obj)) {
return obj.map((val, key) => mapValuesDeep(val, cb, key));
}
else if ((0, lodash_1.isObject)(obj)) {
return (0, lodash_1.mapValues)(obj, (val) => mapValuesDeep(val, cb, key));
}
else {
return cb(obj, key);
}
}
class Datastore extends events_1.EventEmitter {
constructor(config = {}) {
super();
this.name = '';
this.config = {
baseUrl: 'http://localhost:3001',
timeout: 10000,
token: 'token',
debug: false,
connector: 'http',
walk: {
maxPageSize: Infinity,
},
};
this.config = (0, lodash_1.merge)({}, this.config, config);
this.core = new Core_1.default(this.config);
this.streams = new Streams_1.default(this.config, this.core);
this.graphql = new GraphQL_1.default(this.config, this.core);
if (this.config.telemetry) {
this.telemetry = this.config.telemetry;
}
}
heartbeat() {
return this.core.request({ method: 'get', url: '/heartbeat' });
}
_checkCorrelationIdExistence(correlationId) {
if (!correlationId) {
throw exports.ERROR_MISSING_CORRELATION_ID;
}
}
_checkModelNameExistence(model) {
if (!model || !model.name) {
throw exports.ERROR_MISSING_MODEL_NAME;
}
}
getModels() {
return this.core.request({
method: 'get',
url: this.core.getPath('admin'),
});
}
getGraph() {
return this.core.request({
method: 'get',
url: this.core.getPath('admin', 'graph'),
});
}
getModel(model) {
return this.core.request({
method: 'get',
url: this.core.getPath('admin'),
params: {
model,
},
});
}
rotateEncryptionKeys(models) {
return this.core.request({
method: 'post',
url: this.core.getPath('admin', 'rotate', 'keys'),
params: { models },
});
}
createModel(modelConfig) {
this._checkModelNameExistence(modelConfig);
return this.core.request({
method: 'post',
url: this.core.getPath('admin'),
data: modelConfig,
});
}
updateModel(modelConfig) {
this._checkModelNameExistence(modelConfig);
return this.core.request({
method: 'post',
url: this.core.getPath('admin', modelConfig.name),
data: modelConfig,
});
}
createModelIndexes(modelConfig) {
this._checkModelNameExistence(modelConfig);
return this.core.request({
method: 'post',
url: this.core.getPath('admin', modelConfig.name, 'indexes'),
data: modelConfig,
});
}
getSchema(modelName) {
this._checkModelNameExistence({ name: modelName });
return this.core.request({
method: 'get',
url: this.core.getPath('admin', modelName, 'schema'),
});
}
encrypt(modelName, data, fields = []) {
this._checkModelNameExistence({ name: modelName });
return this.core.request({
method: 'post',
url: this.core.getPath(modelName, 'encrypt'),
params: {
fields,
},
data,
});
}
decrypt(modelName, data, fields = []) {
this._checkModelNameExistence({ name: modelName });
return this.core.request({
method: 'post',
url: this.core.getPath(modelName, 'decrypt'),
params: {
fields,
},
data,
});
}
create(modelName, payload, headers) {
return this.core.request({
method: 'post',
url: this.core.getPath(modelName),
data: payload,
headers,
});
}
apply(modelName, correlationId, eventType, eventVersion, payload, headers) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'post',
url: this.core.getPath(modelName, correlationId, eventType.toLowerCase(), eventVersion),
data: payload,
headers,
});
}
update(modelName, correlationId, payload, headers) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'post',
url: this.core.getPath(modelName, correlationId),
data: payload,
headers,
});
}
patch(modelName, correlationId, jsonPatch, headers) {
this._checkCorrelationIdExistence(correlationId);
if (!jsonPatch) {
throw exports.ERROR_MISSING_JSON_PATCH;
}
return this.core.request({
method: 'patch',
url: this.core.getPath(modelName, correlationId),
data: {
json_patch: jsonPatch,
},
headers,
});
}
get(modelName, correlationId) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'get',
url: this.core.getPath(modelName, correlationId),
});
}
async count(model, query, source = 'entities') {
const { headers } = await this[source !== 'events' ? 'find' : 'allEvents'](model, query, 0, 0);
return parseInt(headers.count, 10);
}
find(model, query, page, pageSize, headers = {}) {
if (page !== undefined) {
headers.page = page;
}
if (pageSize !== undefined) {
headers['page-size'] = pageSize;
}
return this.core.request({
method: 'get',
url: this.core.getPath(model),
params: query,
headers,
});
}
events(model, correlationId, page, pageSize) {
this._checkCorrelationIdExistence(correlationId);
const headers = {};
if (page !== undefined) {
headers.page = page;
}
if (pageSize !== undefined) {
headers['page-size'] = pageSize;
}
return this.core.request({
method: 'get',
url: this.core.getPath(model, correlationId, 'events'),
headers,
});
}
allEvents(model, query = {}, page, pageSize, headers = {}) {
if (page !== undefined) {
headers.page = page;
}
if (pageSize !== undefined) {
headers['page-size'] = pageSize;
}
return this.core.request({
method: 'get',
url: this.core.getPath(model, 'events'),
headers,
params: query,
});
}
async firstEventVersion(model, query, sort, defaultValue, headers) {
const _query = (0, lodash_1.cloneDeep)(query);
if ('updated_at' in _query) {
// @ts-ignore
_query.created_at = _query.updated_at;
delete _query.updated_at;
}
const { data: [event], } = await this.allEvents(model, {
..._query,
// @ts-ignore
_sort: sort,
_fields: {
version: 1,
},
}, 0, 1, headers);
return event ? event.version : defaultValue;
}
async minEventsVersion(model, query, headers) {
return this.firstEventVersion(model, query, {
version: 1,
}, 0, headers);
}
async maxEventsVersion(model, query, headers) {
return this.firstEventVersion(model, query, {
version: -1,
}, -1, headers);
}
async version(model, correlationId, version) {
this._checkCorrelationIdExistence(correlationId);
try {
return await this.core.request({
method: 'get',
url: this.core.getPath(model, correlationId, version),
});
}
catch (err) {
if (err?.response?.status === 404) {
const res = err.response;
res.data = null;
return res;
}
throw err;
}
}
at(model, correlationId, date) {
return this.version(model, correlationId, date);
}
restore(model, correlationId, version) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'post',
url: this.core.getPath(model, correlationId, version, 'restore'),
});
}
snapshot(model, correlationId) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'post',
url: this.core.getPath(model, correlationId, 'snapshot'),
});
}
data(model, correlationId, models) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'get',
url: this.core.getPath(model, correlationId, 'data'),
params: {
models,
},
});
}
archive(model, correlationId, deep = false, models) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'post',
url: this.core.getPath(model, correlationId, 'archive'),
params: {
deep,
models,
},
});
}
unarchive(model, correlationId, deep = false, models) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'post',
url: this.core.getPath(model, correlationId, 'unarchive'),
params: {
deep,
models,
},
});
}
delete(model, correlationId, deep = false, models) {
this._checkCorrelationIdExistence(correlationId);
return this.core.request({
method: 'delete',
url: this.core.getPath(model, correlationId),
params: {
deep,
models,
},
});
}
aggregate(pipeline, headers) {
return this.core.request({
method: 'post',
url: this.core.getPath('aggregate'),
headers,
data: pipeline,
});
}
_interpolate(str, params) {
if (typeof str !== 'string') {
return str;
}
const matches = str.matchAll(/\$\{([^}]+)\}/g);
let res = str;
for (const match of matches) {
res = res.replace(match[0], (0, lodash_1.get)(params, match[1]));
}
return res;
}
async import(data, modelConfigs, options = { dryRun: false }, entities = new Map()) {
for (const { model, id, idempotency, omit_on_update = [], links = [], entity, } of data) {
let _entity;
let modifiedIdempotency = {
...idempotency,
};
let modifiedEntity = {
...entity,
};
for (const l of links) {
let _link;
if (l.id) {
_link = entities.get(l.id);
}
else {
// Always the first one taken:
const res = await this.find(l.model, {
...l.idempotency,
_must_hash: true,
});
if (res.data.length === 0) {
const err = new Error('[Link] Idempotency condition violation');
// @ts-ignore
err.details = {
...data,
link: l,
idempotency: l.idempotency,
candidates: res.data,
};
throw err;
}
const { data: decrypted } = await this.decrypt(model, res.data);
_link = decrypted[0];
}
const linkResult = mapValuesDeep(l.map, (v, k) => _link[v] ?? this._interpolate(v, _link));
modifiedEntity = (0, lodash_1.mergeWith)({}, modifiedEntity, linkResult, mergeWithArrays);
if (l.is_idempotency_condition === true) {
modifiedIdempotency = (0, lodash_1.mergeWith)({}, modifiedIdempotency, linkResult, mergeWithArrays);
}
}
const { data: candidates } = await this.find(model, {
...modifiedIdempotency,
_must_hash: true,
});
if (candidates.length > 1) {
const err = new Error('Idempotency condition violation');
// @ts-ignore
err.details = {
...data,
idempotency: modifiedIdempotency,
candidates,
};
throw err;
}
_entity = modifiedEntity;
if (candidates.length === 0) {
if (options.dryRun === false) {
const correlationId = modifiedIdempotency?.[modelConfigs?.[model]?.correlation_field];
if (correlationId) {
const res = await this.update(model, correlationId, modifiedEntity, {
upsert: true,
});
_entity = res.data;
}
else {
const res = await this.create(model, modifiedEntity);
_entity = res.data;
}
}
}
else {
const candidate = (0, lodash_1.omit)(candidates[0], 'created_at', 'updated_at', 'version', modelConfigs[model].correlation_field);
const { data: [decryptedCandidate], } = await this.decrypt(model, [candidate]);
const payload = (0, lodash_1.omit)({
..._entity,
...modifiedEntity,
}, 'created_at', 'updated_at', 'version', ...omit_on_update);
const diff = jsonpatch.compare((0, lodash_1.omit)(decryptedCandidate, ...omit_on_update), payload);
if (diff.length > 0 && options.dryRun === false) {
const res = await this.update(model, candidates[0][modelConfigs[model].correlation_field], payload);
_entity = res.data;
}
else {
_entity = candidates[0];
}
const rollback = jsonpatch.compare((0, lodash_1.omit)(_entity, 'created_at', 'updated_at', 'version', ...omit_on_update), (0, lodash_1.omit)(candidates[0], 'created_at', 'updated_at', 'version', ...omit_on_update));
// Store the diff
_entity.__update__ = diff;
_entity.__rollback__ = rollback;
}
const { data: decryted } = await this.decrypt(model, [_entity]);
_entity = decryted[0];
entities.set(id, _entity);
}
return entities;
}
async walkNext(model, query, source, page, pageSize, opts) {
const _query = (0, lodash_1.cloneDeep)(query);
const isVersionOrdered = opts?.version_ordered === true;
if (isVersionOrdered === true) {
_query.version = opts?.current_version;
}
const headers = {
...opts?.headers,
'cursor-last-id': opts?.cursor_last_id,
'cursor-last-correlation-id': opts?.cursor_last_correlation_id,
};
if (source === 'events') {
return this.allEvents(model, _query, page, pageSize, headers);
}
else {
return this.find(model, _query, page, pageSize, headers);
}
}
static async walkMulti(datastores, queries, handler, opts) {
return (0, utils_1.walkMulti)(datastores, queries, opts?.page_size, handler, opts, opts?.sort_handler);
}
async walk(model, query, handler, pageSize = 10, source = 'entities', headers, opts) {
const effectivePageSize = Math.min(pageSize, this.config?.walk?.maxPageSize ?? Infinity);
return (0, utils_1.walkMulti)(new Map([['datastore', this]]), [
{
datastore: 'datastore',
model,
query,
source,
headers,
},
], effectivePageSize, handler, opts);
}
async updateOverwhelmingly(model, query, handler, progress, pageSize) {
const { data: { [model]: modelConfig }, } = await this.getModel(model);
const correlationField = modelConfig.correlation_field;
const total = await this.count(model, query);
const stats = {
total,
done: 0,
error: 0,
progress: 0,
restored: 0,
};
await this.walk(model, query, async (obj) => {
try {
const payload = await handler(obj);
const { data, headers } = await this.update(model, obj[correlationField], payload);
stats.done += 1;
stats.progress = stats.done / stats.total;
progress(stats, data, headers);
}
catch (err) {
stats.error += 1;
await this.restore(model, obj[correlationField], obj.version);
stats.restored += 1;
}
}, pageSize);
return stats;
}
/**
* @deprecated in favor to `datastore.streams.getStreamId`
*/
/* istanbul ignore next */
_streamId(model, source, query) {
return this.streams.getStreamId(model, source, query);
}
/**
* @deprecated in favor to `datastore.streams.listen`
*/
async listen(model, source, query, options) {
return this.streams.listen(model, source, query, {
...options,
forward: this,
});
}
/**
* @deprecated in favor to `datastore.streams.close`
*/
close(streamId) {
return this.streams.close(streamId);
}
/**
* @deprecated in favor to `datastore.streams.closeAll`
*/
closeAll() {
return this.streams.closeAll();
}
/**
* @deprecated in favor to `datastore.streams.stream`
*/
/* istanbul ignore next */
stream(handler, model = 'all', source = 'entities', data = []) {
return this.streams.streamHTTP(handler, model, source, data);
}
/**
* @deprecated in favor of `datastore.graphql.query`
*/
async query(query, variables, operationName) {
return this.graphql.request('query', query, variables, operationName);
}
/**
* @deprecated in favor of `datastore.graphql.mutation`
*/
async mutation(query, variables, operationName) {
return this.graphql.request('mutation', query, variables, operationName);
}
}
exports.default = Datastore;
//# sourceMappingURL=Datastore.js.map