UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

595 lines 21.5 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.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