UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

174 lines (148 loc) 4.25 kB
import type { AnyValidateFunction } from 'ajv/dist/core'; import type { AnyObject, Event, ModelConfig } from '../typings'; import type FullyHomomorphicEncryptionClient from '../services/fhe'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import * as jsonpatch from 'fast-json-patch'; import _ from 'lodash'; import * as c from '../constants'; import { deepCoerce, getDate, validateEntity, mergeWithReplacedArrays, } from '../utils'; import { validate } from './events'; import { handle } from './handler'; import { mapDateTimeFormatToEitherStringOrObject } from './schema'; export function defaultReducer( state: AnyObject | null, event: AnyObject, patch: jsonpatch.Operation[] = [], ) { const now = event.created_at ?? getDate(); delete event.created_at; /** * @note cloneDeep is required here to remove `readonly` properties * applied on the state. */ const updatedState = _.cloneDeep({ created_at: now, ..._.mergeWith({}, state, event, mergeWithReplacedArrays), updated_at: now, }); return jsonpatch.applyPatch(updatedState, patch).newDocument; } export async function applyEventHandler( schema: AnyValidateFunction, eventSchema: AnyObject, state: AnyObject, event: Event, options: { throwOnInvalidEvent?: boolean; model?: AnyObject; modelConfig?: ModelConfig; fhe?: FullyHomomorphicEncryptionClient; }, ) { // fields is validated by the schema up there so all the data sent should be reduced const { type: eventType, v, json_patch: patch = [], ...fields } = event; let finalState = state; const handlers = eventSchema.handlers ?? [ { is_fhe: eventSchema.is_fhe, handler: eventSchema.handler, }, ]; /** * @alpha */ for (const handler of handlers) { const [handlerState, handlerPatch = [], event = {}] = await handle( handler.handler, finalState, fields, options?.modelConfig, handler.is_fhe === true ? options?.fhe : undefined, ); finalState = defaultReducer( handlerState, event, [...patch, ...handlerPatch], //@ts-ignore schema.model, ); } return finalState; } export default ( schemas: AnyObject, options: { throwOnInvalidEvent?: boolean; model?: AnyObject; modelConfig?: ModelConfig; fhe?: FullyHomomorphicEncryptionClient; } = {}, ) => { const mappedSchemas = mapDateTimeFormatToEitherStringOrObject(schemas); const mappedModel = mapDateTimeFormatToEitherStringOrObject( options.model ?? {}, ); const VALIDATOR = new Ajv({ strict: false, schemas: [mappedSchemas], useDefaults: true, }); // @ts-ignore addFormats(VALIDATOR); return async ( state: object, event: Event, validator: Ajv = VALIDATOR, ): Promise<object> => { const schema = validator.getSchema('events')!; // Will throw if the schema is wrong const eventSchema = validate( event, schema, validator, options?.throwOnInvalidEvent, ); // fields is validated by the schema up there so all the data sent should be reduced const { type: eventType, v, json_patch: patch = [], ...fields } = event; const mustBeCreated = eventType !== c.EVENT_TYPE_CREATED && eventSchema.is_created !== true && eventSchema.upsert !== true; if (state === null && mustBeCreated === true) { throw new Error('Entity must be created first'); } const mustNotBeCreated = eventType === c.EVENT_TYPE_CREATED || (eventSchema.upsert !== true && eventSchema.is_created === true); if (state !== null && mustNotBeCreated === true) { throw new Error('Entity already created'); } let finalState; if ('handler' in eventSchema || 'handlers' in eventSchema) { finalState = await applyEventHandler( schema, eventSchema, state, event, options, ); } else { finalState = defaultReducer(state, fields, patch); } options?.model && validateEntity( validator, finalState, mappedModel, options?.throwOnInvalidEvent, ); // @ts-ignore return deepCoerce(finalState, schemas.model ?? {}); }; };