UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

1,054 lines (931 loc) 23.4 kB
import type { RawAxiosRequestHeaders, AxiosResponse, RawAxiosResponseHeaders, AxiosResponseHeaders, } from 'axios'; import type { DatastoreImportFixture, DatastoreImportLink, ModelConfig, Telemetry, } from '../typings'; import { EventEmitter } from 'events'; import { cloneDeep, get as _get, omit, mapValues, isObject, merge, mergeWith, } from 'lodash'; import * as jsonpatch from 'fast-json-patch'; import Core from './Core'; import Streams, { StreamClose, StreamHandler } from './Streams'; import { Iteration, MultiQuery, walkMulti } from './utils'; export const ERROR_MISSING_MODEL_NAME = new Error('Missing Model name'); export const ERROR_MISSING_CORRELATION_ID = new Error('Missing Correlation ID'); export const ERROR_MISSING_JSON_PATCH = new Error('Missing JSON Patch'); export const ERROR_STREAM_MAX_RECONNECTION_ATTEMPTS_REACHED = new Error( 'Max reconnection attempts reached for streaming', ); function mergeWithArrays(objValue: any, srcValue: []) { if (Array.isArray(objValue)) { return objValue.concat(srcValue); } } function mapValuesDeep(obj: any, cb: any, key?: any): any { if (Array.isArray(obj)) { return obj.map((val: any, key: any) => mapValuesDeep(val, cb, key)); } else if (isObject(obj)) { return mapValues(obj, (val: any) => mapValuesDeep(val, cb, key)); } else { return cb(obj, key); } } export interface DatastoreConfig { /** * Datastore API base URL * default: http://localhost:3001 */ baseUrl?: string; /** * API Access token */ token?: string; /** * Requests timeout in milliseconds */ timeout?: number; /** * Log HTTP errors */ debug?: boolean; /** * Telemetry instance of logging and metrics */ telemetry?: Telemetry; // @getanthill/telemetry /** * Type of the stream connector: * - 'http': Server Sent Events HTTP Stream * - 'amqp': RabbitMQ stream */ connector?: 'http' | 'amqp'; /** * Force to fetch documents from primary to guarantee * accessing the most up-to-date data. */ forcePrimary?: true; walk?: { maxPageSize?: number; }; } export default class Datastore extends EventEmitter { name = ''; config: DatastoreConfig = { baseUrl: 'http://localhost:3001', timeout: 10000, token: 'token', debug: false, connector: 'http', walk: { maxPageSize: Infinity, }, }; telemetry?: Telemetry; public core: Core; public streams: Streams; constructor(config: DatastoreConfig = {}) { super(); this.config = merge({}, this.config, config); this.core = new Core(this.config); this.streams = new Streams(this.config, this.core); if (this.config.telemetry) { this.telemetry = this.config.telemetry; } } heartbeat(): Promise<AxiosResponse> { return this.core.request({ method: 'get', url: '/heartbeat' }); } _checkCorrelationIdExistence(correlationId: string) { if (!correlationId) { throw ERROR_MISSING_CORRELATION_ID; } } _checkModelNameExistence(model: Partial<ModelConfig> | undefined) { if (!model || !model.name) { throw ERROR_MISSING_MODEL_NAME; } } getModels(): Promise<AxiosResponse> { return this.core.request({ method: 'get', url: this.core.getPath('admin'), }); } getGraph(): Promise<AxiosResponse> { return this.core.request({ method: 'get', url: this.core.getPath('admin', 'graph'), }); } getModel(model: string): Promise<AxiosResponse> { return this.core.request({ method: 'get', url: this.core.getPath('admin'), params: { model, }, }); } rotateEncryptionKeys(models?: string[]): Promise<AxiosResponse> { return this.core.request({ method: 'post', url: this.core.getPath('admin', 'rotate', 'keys'), params: { models }, }); } createModel(modelConfig: ModelConfig): Promise<AxiosResponse> { this._checkModelNameExistence(modelConfig); return this.core.request({ method: 'post', url: this.core.getPath('admin'), data: modelConfig, }); } updateModel(modelConfig: Partial<ModelConfig>): Promise<AxiosResponse> { this._checkModelNameExistence(modelConfig); return this.core.request({ method: 'post', url: this.core.getPath('admin', modelConfig.name!), data: modelConfig, }); } createModelIndexes(modelConfig?: ModelConfig): Promise<AxiosResponse> { this._checkModelNameExistence(modelConfig); return this.core.request({ method: 'post', url: this.core.getPath('admin', modelConfig!.name, 'indexes'), data: modelConfig, }); } getSchema(modelName: string): Promise<AxiosResponse> { this._checkModelNameExistence({ name: modelName }); return this.core.request({ method: 'get', url: this.core.getPath('admin', modelName, 'schema'), }); } encrypt( modelName: string, data: object[], fields: string[] = [], ): Promise<AxiosResponse> { this._checkModelNameExistence({ name: modelName }); return this.core.request({ method: 'post', url: this.core.getPath(modelName, 'encrypt'), params: { fields, }, data, }); } decrypt( modelName: string, data: object[], fields: string[] = [], ): Promise<AxiosResponse> { this._checkModelNameExistence({ name: modelName }); return this.core.request({ method: 'post', url: this.core.getPath(modelName, 'decrypt'), params: { fields, }, data, }); } create( modelName: string, payload: object, headers?: RawAxiosRequestHeaders, ): Promise<AxiosResponse> { return this.core.request({ method: 'post', url: this.core.getPath(modelName), data: payload, headers, }); } apply( modelName: string, correlationId: string, eventType: string, eventVersion: string, payload: object, headers?: RawAxiosRequestHeaders, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'post', url: this.core.getPath( modelName, correlationId, eventType.toLowerCase(), eventVersion, ), data: payload, headers, }); } update<T>( modelName: string, correlationId: string, payload: T, headers?: RawAxiosRequestHeaders, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'post', url: this.core.getPath(modelName, correlationId), data: payload, headers, }); } patch( modelName: string, correlationId: string, jsonPatch: object[], headers?: RawAxiosRequestHeaders, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); if (!jsonPatch) { throw ERROR_MISSING_JSON_PATCH; } return this.core.request({ method: 'patch', url: this.core.getPath(modelName, correlationId), data: { json_patch: jsonPatch, }, headers, }); } get( modelName: string, correlationId: string, headers?: { 'force-primary'?: true; }, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'get', url: this.core.getPath(modelName, correlationId), headers, }); } async count(model: string, query: object, source = 'entities') { const { headers } = await this[source !== 'events' ? 'find' : 'allEvents']( model, query, 0, 0, ); return Number.parseInt(headers.count!, 10); } find( model: string, query: object, page?: number, pageSize?: number, headers: { page?: number; 'page-size'?: number; 'cursor-last-id'?: string; 'force-primary'?: true; } = {}, ): Promise<AxiosResponse> { 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: string, correlationId: string, page?: number, pageSize?: number, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); const headers: { page?: number; 'page-size'?: number } = {}; 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: string, query: object = {}, page?: number, pageSize?: number, headers: { page?: number; 'page-size'?: number; 'cursor-last-id'?: string; } = {}, ): Promise<AxiosResponse> { 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: string, query: object, sort: any, defaultValue: number, headers?: { page?: number; 'page-size'?: number; 'cursor-last-id'?: string; }, ): Promise<number> { const _query = 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: string, query: object, headers?: { page?: number; 'page-size'?: number; 'cursor-last-id'?: string; }, ): Promise<number> { return this.firstEventVersion( model, query, { version: 1, }, 0, headers, ); } async maxEventsVersion( model: string, query: object, headers?: { page?: number; 'page-size'?: number; 'cursor-last-id'?: string; }, ): Promise<number> { return this.firstEventVersion( model, query, { version: -1, }, -1, headers, ); } async version( model: string, correlationId: string, version: number | Date | string, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); try { return await this.core.request({ method: 'get', url: this.core.getPath(model, correlationId, version), }); } catch (err: any) { if (err?.response?.status === 404) { const res = err.response as AxiosResponse; res.data = null; return res; } throw err; } } at( model: string, correlationId: string, date: Date | string, ): Promise<AxiosResponse> { return this.version(model, correlationId, date); } restore( model: string, correlationId: string, version: number, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'post', url: this.core.getPath(model, correlationId, version, 'restore'), }); } snapshot( model: string, correlationId: string, options?: { /** * Version of the state to snapshot. */ version?: number | Date | string; /** * If true, delete past events from the database. */ clean?: boolean; }, ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'post', url: this.core.getPath(model, correlationId, 'snapshot'), params: options, }); } data( model: string, correlationId: string, models?: string[], ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'get', url: this.core.getPath(model, correlationId, 'data'), params: { models, }, }); } archive( model: string, correlationId: string, deep = false, models?: string[], ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'post', url: this.core.getPath(model, correlationId, 'archive'), params: { deep, models, }, }); } unarchive( model: string, correlationId: string, deep = false, models?: string[], ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'post', url: this.core.getPath(model, correlationId, 'unarchive'), params: { deep, models, }, }); } delete( model: string, correlationId: string, deep = false, models?: string[], ): Promise<AxiosResponse> { this._checkCorrelationIdExistence(correlationId); return this.core.request({ method: 'delete', url: this.core.getPath(model, correlationId), params: { deep, models, }, }); } aggregate(pipeline: any[], headers?: any): Promise<AxiosResponse> { return this.core.request({ method: 'post', url: this.core.getPath('aggregate'), headers, data: pipeline, }); } _interpolate(str: string, params: any) { if (typeof str !== 'string') { return str; } const matches = str.matchAll(/\$\{([^}]+)\}/g); let res = str; for (const match of matches) { res = res.replace(match[0], _get(params, match[1])); } return res; } async import( data: DatastoreImportFixture[], modelConfigs: { [key: string]: ModelConfig }, options: { dryRun: boolean; } = { dryRun: false }, entities = new Map<string, object>(), ): Promise<Map<string, object>> { 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: DatastoreImportLink; if (l.id) { _link = entities.get(l.id) as DatastoreImportLink; } 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: keyof DatastoreImportLink, k: any) => _link[v] ?? this._interpolate(v, _link), ); modifiedEntity = mergeWith( {}, modifiedEntity, linkResult, mergeWithArrays, ); if (l.is_idempotency_condition === true) { modifiedIdempotency = 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 = omit( candidates[0], 'created_at', 'updated_at', 'version', modelConfigs[model].correlation_field, ); const { data: [decryptedCandidate], } = await this.decrypt(model, [candidate]); const payload = omit( { ..._entity, ...modifiedEntity, }, 'created_at', 'updated_at', 'version', ...omit_on_update, ); const diff = jsonpatch.compare( 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( omit( _entity, 'created_at', 'updated_at', 'version', ...omit_on_update, ), 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: string, query: object, source: string, page: number, pageSize: number, opts: { current_version: number; version_ordered?: boolean; cursor_last_id?: string; cursor_last_correlation_id: string; headers?: any; }, ) { const _query: { version?: number } = cloneDeep(query); const isVersionOrdered: boolean = 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: Map<string, Datastore>, queries: MultiQuery[], handler: (res: any, query: MultiQuery, queryIteration: Iteration) => any, opts?: { page_size: number; sort_handler?: (a: any, b: any) => any; sleep?: number; version_ordered?: boolean; handle_in_order?: boolean; handle_in_parallel?: boolean; chunk_size?: number; is_mutating?: boolean; }, ) { return walkMulti( datastores, queries, opts?.page_size, handler, opts, opts?.sort_handler, ); } async walk( model: string, query: object, handler: (...args: any[]) => Promise<void> | void, pageSize = 10, source: MultiQuery['source'] = 'entities', headers?: any, opts?: { sleep?: number; version_ordered?: boolean; handle_in_order?: boolean; handle_in_parallel?: boolean; chunk_size?: number; is_mutating?: boolean; }, ) { const effectivePageSize = Math.min( pageSize, this.config?.walk?.maxPageSize ?? Infinity, ); return walkMulti( new Map([['datastore', this]]), [ { datastore: 'datastore', model, query, source, headers, }, ], effectivePageSize, handler, opts, ); } async updateOverwhelmingly<T>( model: string, query: object, handler: (entity: T) => Promise<T>, progress: ( stats: { total: number; done: number; error: number; progress: number; restored: number; }, entity: T, headers: RawAxiosResponseHeaders | AxiosResponseHeaders, ) => void, pageSize?: number, ) { 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: any) => { try { const payload = await handler(obj); const { data, headers } = await this.update<T>( 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: string, source: string, query?: object): string { return this.streams.getStreamId(model, source, query); } /** * @deprecated in favor to `datastore.streams.listen` */ async listen( model: string, source: 'events' | 'entities', query?: object, options?: any, ): Promise<StreamClose> { return this.streams.listen(model, source, query, { ...options, forward: this, }); } /** * @deprecated in favor to `datastore.streams.close` */ close(streamId: string) { 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: StreamHandler, model = 'all', source: 'entities' | 'events' = 'entities', data: object[] = [], ): Promise<StreamClose> { return this.streams.streamHTTP(handler, model, source, data); } }