@getanthill/datastore
Version:
Event-Sourced Datastore
174 lines (148 loc) • 4.25 kB
text/typescript
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 ?? {});
};
};