diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..25fa6215 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/packages/core/src/event/eventType.ts b/packages/core/src/event/eventType.ts index 8567bc35..b7acabd5 100644 --- a/packages/core/src/event/eventType.ts +++ b/packages/core/src/event/eventType.ts @@ -1,6 +1,26 @@ import type { EventDetail } from './eventDetail'; import { reservedEventTypes } from './reservedEventTypes'; +export type ParsedCandidate = + | { + isValid: false; + parsedCandidate?: never; + parsingErrors: [Error, ...Error[]]; + } + | { + isValid: true; + parsedCandidate: T; + parsingErrors?: never; + }; + +export type CandidateParser = (candidate: EventDetail) => ParsedCandidate; + +export type EventDetailParser< + TYPE extends string = string, + PAYLOAD = unknown, + METADATA = unknown, +> = CandidateParser>; + export class EventType< TYPE extends string = string, PAYLOAD = string extends TYPE ? unknown : never, @@ -10,26 +30,22 @@ export class EventType< detail: EventDetail; }; type: TYPE; - parseEventDetail?: (candidate: unknown) => - | { - isValid: true; - parsedEventDetail: EventDetail; - parsingErrors?: never; - } - | { - isValid: false; - parsedEventDetail?: never; - parsingErrors?: [Error, ...Error[]]; - }; + parseEventDetail?: EventDetailParser | undefined; - constructor({ type }: { type: TYPE }) { + constructor({ + type, + parseEventDetail, + }: { + type: TYPE; + parseEventDetail?: EventDetailParser | undefined; + }) { if (reservedEventTypes.has(type)) { throw new Error( `${type} is a reserved event type. Please chose another one.`, ); } - this.type = type; + this.parseEventDetail = parseEventDetail; } } diff --git a/packages/core/src/event/eventType.unit.test.ts b/packages/core/src/event/eventType.unit.test.ts index ad92ad31..42c7bb89 100644 --- a/packages/core/src/event/eventType.unit.test.ts +++ b/packages/core/src/event/eventType.unit.test.ts @@ -5,7 +5,9 @@ const eventType = new EventType({ type: 'some type' }); describe('event store', () => { it('has correct properties', () => { - expect(new Set(Object.keys(eventType))).toStrictEqual(new Set(['type'])); + expect(new Set(Object.keys(eventType))).toStrictEqual( + new Set(['type', 'parseEventDetail']), + ); }); it('raises an error if type is reserved', () => { diff --git a/packages/core/src/eventStore/errors/eventDetailParserNotDefined.ts b/packages/core/src/eventStore/errors/eventDetailParserNotDefined.ts new file mode 100644 index 00000000..b4d7fd57 --- /dev/null +++ b/packages/core/src/eventStore/errors/eventDetailParserNotDefined.ts @@ -0,0 +1,7 @@ +export class EventDetailParserNotDefinedError extends Error { + constructor(type: string) { + super( + `Can not validate input because EventType "${type}" has no parseEventDetail method defined.`, + ); + } +} diff --git a/packages/core/src/eventStore/errors/eventDetailTypeDoesNotExist.ts b/packages/core/src/eventStore/errors/eventDetailTypeDoesNotExist.ts new file mode 100644 index 00000000..e5e2139f --- /dev/null +++ b/packages/core/src/eventStore/errors/eventDetailTypeDoesNotExist.ts @@ -0,0 +1,15 @@ +export class EventDetailTypeDoesNotExistError extends Error { + constructor({ + type, + allowedTypes, + }: { + type: string; + allowedTypes: string[]; + }) { + super( + `${type} is not a valid event detail type. Allowed types are ${allowedTypes.join( + ', ', + )}.`, + ); + } +} diff --git a/packages/core/src/eventStore/errors/index.ts b/packages/core/src/eventStore/errors/index.ts index e6223a9a..e83c5f31 100644 --- a/packages/core/src/eventStore/errors/index.ts +++ b/packages/core/src/eventStore/errors/index.ts @@ -1,3 +1,5 @@ export * from './aggregateNotFound'; export * from './eventAlreadyExists'; +export * from './eventDetailTypeDoesNotExist'; +export * from './eventDetailParserNotDefined'; export * from './undefinedEventStorageAdapter'; diff --git a/packages/core/src/eventStore/eventStore.ts b/packages/core/src/eventStore/eventStore.ts index 2d3a0af5..639d908e 100644 --- a/packages/core/src/eventStore/eventStore.ts +++ b/packages/core/src/eventStore/eventStore.ts @@ -6,6 +6,11 @@ import { GroupedEvent } from '~/event/groupedEvent'; import type { EventStorageAdapter } from '~/eventStorageAdapter'; import type { $Contravariant } from '~/utils'; +import { EventDetailParser } from '../event/eventType'; +import { + EventDetailParserNotDefinedError, + EventDetailTypeDoesNotExistError, +} from './errors'; import { AggregateNotFoundError } from './errors/aggregateNotFound'; import { UndefinedEventStorageAdapterError } from './errors/undefinedEventStorageAdapter'; import type { @@ -20,6 +25,7 @@ import type { AggregateGetter, AggregateSimulator, Reducer, + ValidateEventDetail, } from './types'; export class EventStore< @@ -178,10 +184,58 @@ export class EventStore< */ ) as Promise<{ events: EVENT_DETAILS[] }>; + const shouldValidateEventDetail = ( + validate: ValidateEventDetail, + eventDetailType: string, + eventType?: EventType, + ): eventType is EventType => { + if (validate === false) { + return false; + } + + if (eventType === undefined) { + throw new EventDetailTypeDoesNotExistError({ + type: eventDetailType, + allowedTypes: this.eventTypes.map(({ type }) => type), + }); + } + + if (validate === 'auto' && eventType.parseEventDetail === undefined) { + return false; + } + + return true; + }; + + const validateEventDetail = ( + eventDetail: EventDetail, + eventDetailParser?: EventDetailParser, + ) => { + if (eventDetailParser === undefined) { + throw new EventDetailParserNotDefinedError(eventDetail.type); + } + + const { parsingErrors, isValid } = eventDetailParser(eventDetail); + + if (isValid) { + return; + } + + throw new Error(parsingErrors[0].message); + }; + this.pushEvent = async ( eventDetail, - { prevAggregate, force = false } = {}, + { prevAggregate, force = false, validate = 'auto' } = {}, ) => { + const eventType = this.eventTypes.find( + ({ type }) => type === eventDetail.type, + ); + + if (shouldValidateEventDetail(validate, eventDetail.type, eventType)) { + validateEventDetail(eventDetail, eventType.parseEventDetail); + } + const eventStorageAdapter = this.getEventStorageAdapter(); const { event } = (await eventStorageAdapter.pushEvent(eventDetail, { diff --git a/packages/core/src/eventStore/eventStore.type.test.ts b/packages/core/src/eventStore/eventStore.type.test.ts index 525c72b5..ad663610 100644 --- a/packages/core/src/eventStore/eventStore.type.test.ts +++ b/packages/core/src/eventStore/eventStore.type.test.ts @@ -11,6 +11,7 @@ import { EventStoreAggregate, EventStoreEventDetails, GetAggregateOptions, + ValidateEventDetail, } from '~/eventStore'; import { @@ -109,7 +110,12 @@ assertPushEventInput1; const assertPushEventInput2: A.Equals< Parameters[1], - { prevAggregate?: PokemonAggregate | undefined; force?: boolean } | undefined + | { + prevAggregate?: PokemonAggregate | undefined; + force?: boolean; + validate?: ValidateEventDetail; + } + | undefined > = 1; assertPushEventInput2; diff --git a/packages/core/src/eventStore/types.ts b/packages/core/src/eventStore/types.ts index 452e495c..72359184 100644 --- a/packages/core/src/eventStore/types.ts +++ b/packages/core/src/eventStore/types.ts @@ -28,6 +28,8 @@ export type EventsGetter = ( options?: EventsQueryOptions, ) => Promise<{ events: EVENT_DETAIL[] }>; +export type ValidateEventDetail = boolean | 'auto'; + export type EventPusher< EVENT_DETAILS extends EventDetail, $EVENT_DETAILS extends EventDetail, @@ -37,7 +39,11 @@ export type EventPusher< event: $EVENT_DETAILS extends EventDetail ? OptionalTimestamp<$EVENT_DETAILS> : $EVENT_DETAILS, - options?: { prevAggregate?: $AGGREGATE; force?: boolean }, + options?: { + prevAggregate?: $AGGREGATE; + force?: boolean; + validate?: ValidateEventDetail; + }, ) => Promise<{ event: EVENT_DETAILS; nextAggregate?: AGGREGATE }>; export type AggregateIdsLister = ( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3a9d076d..d3ec8e6b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export type { Aggregate } from './aggregate'; -export { EventType } from './event/eventType'; +export { EventType, EventDetailParser } from './event/eventType'; export type { EventTypeDetail, EventTypeDetails } from './event/eventType'; export { GroupedEvent } from './event/groupedEvent'; export { __REPLAYED__, __AGGREGATE_EXISTS__ } from './event/reservedEventTypes'; diff --git a/packages/event-type-zod/src/eventType.ts b/packages/event-type-zod/src/eventType.ts index 7cb808a4..42141bb2 100644 --- a/packages/event-type-zod/src/eventType.ts +++ b/packages/event-type-zod/src/eventType.ts @@ -1,28 +1,138 @@ -import type { z, ZodType } from 'zod'; +import { ZodType, ZodTypeAny } from 'zod'; -import { EventType } from '@castore/core'; +import { EventDetail, EventType, EventDetailParser } from '@castore/core'; + +const zodPayloadParser = + (payloadSchema: ZodType) => + (eventDetail: EventDetail) => { + const parsedPayload = payloadSchema.safeParse(eventDetail.payload); + + if (parsedPayload.success) { + return { + isValid: true, + parsedCandidate: { + ...eventDetail, + payload: parsedPayload.data, + }, + }; + } + + return { + isValid: false, + parsingErrors: parsedPayload.error.errors.map( + ({ message }) => new Error(message), + ), + }; + }; + +const zodMetadataParser = + (metadataSchema: ZodType) => + (eventDetail: EventDetail) => { + const parsedMetadata = metadataSchema.safeParse(eventDetail.metadata); + + if (parsedMetadata.success) { + return { + isValid: true, + parsedCandidate: { + ...eventDetail, + metadata: parsedMetadata.data, + }, + }; + } + + return { + isValid: false, + parsingErrors: parsedMetadata.error.errors.map( + ({ message }) => new Error(message), + ), + }; + }; + +/* eslint-disable complexity */ +const zodEventDetailParser = ( + payloadSchema: ZodType | undefined, + metadataSchema: ZodType | undefined, +): EventDetailParser | undefined => { + if (payloadSchema === undefined && metadataSchema === undefined) { + return undefined; + } + + if (metadataSchema === undefined && payloadSchema !== undefined) { + return zodPayloadParser(payloadSchema) as EventDetailParser< + TYPE, + PAYLOAD, + METADATA + >; + } + + if (metadataSchema !== undefined && payloadSchema === undefined) { + return zodMetadataParser(metadataSchema) as EventDetailParser< + TYPE, + PAYLOAD, + METADATA + >; + } + + if (payloadSchema !== undefined && metadataSchema !== undefined) { + const parser = (eventDetail: EventDetail) => { + const parsedPayload = payloadSchema.safeParse(eventDetail.payload); + const parsedMetadata = metadataSchema.safeParse(eventDetail.metadata); + + if (parsedPayload.success && parsedMetadata.success) { + return { + isValid: true, + parsedCandidate: { + ...eventDetail, + payload: parsedPayload.data, + metadata: parsedMetadata.data, + }, + }; + } + + const payloadErrors = parsedPayload.success + ? [] + : parsedPayload.error.errors.map(({ message }) => new Error(message)); + const metadataErrors = parsedMetadata.success + ? [] + : parsedMetadata.error.errors.map(({ message }) => new Error(message)); + + return { + isValid: false, + parsingErrors: [...payloadErrors, ...metadataErrors], + }; + }; + + return parser as EventDetailParser; + } + + return undefined; +}; export class ZodEventType< TYPE extends string = string, - PAYLOAD_SCHEMA extends ZodType | undefined = ZodType | undefined, - PAYLOAD = ZodType extends PAYLOAD_SCHEMA - ? string extends TYPE - ? unknown - : never - : PAYLOAD_SCHEMA extends ZodType - ? z.infer + PAYLOAD_SCHEMA extends ZodType | undefined = string extends TYPE + ? ZodTypeAny | undefined : never, - METADATA_SCHEMA extends ZodType | undefined = ZodType | undefined, - METADATA = ZodType extends METADATA_SCHEMA - ? string extends TYPE - ? unknown - : never - : METADATA_SCHEMA extends ZodType - ? z.infer + METADATA_SCHEMA extends ZodType | undefined = string extends TYPE + ? ZodTypeAny | undefined : never, + PAYLOAD = string extends TYPE + ? never + : PAYLOAD_SCHEMA extends ZodType + ? Zod.infer + : unknown, + METADATA = string extends TYPE + ? never + : METADATA_SCHEMA extends ZodType + ? Zod.infer + : unknown, > extends EventType { + _types?: { + detail: EventDetail; + }; payloadSchema?: PAYLOAD_SCHEMA; metadataSchema?: METADATA_SCHEMA; + parseEventDetail?: EventDetailParser | undefined; constructor({ type, @@ -36,5 +146,9 @@ export class ZodEventType< super({ type }); this.payloadSchema = payloadSchema; this.metadataSchema = metadataSchema; + this.parseEventDetail = zodEventDetailParser( + payloadSchema, + metadataSchema, + ); } } diff --git a/packages/event-type-zod/src/eventType.unit.test.ts b/packages/event-type-zod/src/eventType.unit.test.ts index ce1ff4be..4b49760b 100644 --- a/packages/event-type-zod/src/eventType.unit.test.ts +++ b/packages/event-type-zod/src/eventType.unit.test.ts @@ -49,6 +49,7 @@ describe('zodEvent implementation', () => { expect(simpleEventType.type).toStrictEqual(type); expect(simpleEventType.payloadSchema).toStrictEqual(undefined); expect(simpleEventType.metadataSchema).toStrictEqual(undefined); + expect(simpleEventType.parseEventDetail).toStrictEqual(undefined); }); it('has correct properties (with payload, no metadata)', () => { @@ -76,6 +77,7 @@ describe('zodEvent implementation', () => { expect(payloadEventType.type).toStrictEqual(type); expect(payloadEventType.payloadSchema).toStrictEqual(payloadSchema); expect(payloadEventType.metadataSchema).toStrictEqual(undefined); + expect(payloadEventType.parseEventDetail).toBeDefined(); }); it('has correct properties (no payload, with metadata)', () => { @@ -103,6 +105,7 @@ describe('zodEvent implementation', () => { expect(metadataEventType.type).toStrictEqual(type); expect(metadataEventType.payloadSchema).toStrictEqual(undefined); expect(metadataEventType.metadataSchema).toStrictEqual(metadataSchema); + expect(metadataEventType.parseEventDetail).toBeDefined(); }); it('has correct properties (with payload, with metadata)', () => { @@ -135,5 +138,58 @@ describe('zodEvent implementation', () => { expect(fullEventType.type).toStrictEqual(type); expect(fullEventType.payloadSchema).toStrictEqual(payloadSchema); expect(fullEventType.metadataSchema).toStrictEqual(metadataSchema); + expect(fullEventType.parseEventDetail).toBeDefined(); + }); + + it('validates schemas', () => { + const fullEventType = new ZodEventType({ + type, + payloadSchema, + metadataSchema, + }); + + expect(fullEventType.parseEventDetail).toBeDefined(); + console.log( + fullEventType.parseEventDetail?.({ + payload: { message: 'ok' }, + metadata: { userEmail: 'email' }, + aggregateId: 'id', + version: 10, + timestamp: 'anyString', + type: 'anyType', + }), + ); + // Email not valid + expect( + fullEventType.parseEventDetail?.({ + payload: { message: 'ok' }, + metadata: { userEmail: 'email' }, + aggregateId: 'id', + version: 10, + timestamp: 'anyString', + type: 'anyType', + }), + ).toMatchObject({ isValid: false }); + // Payload key unknown + expect( + fullEventType.parseEventDetail?.({ + payload: { someKey: 'ok' }, + metadata: { userEmail: 'email@format.com' }, + aggregateId: 'id', + version: 10, + timestamp: 'anyString', + type: 'anyType', + }), + ).toMatchObject({ isValid: false }); + expect( + fullEventType.parseEventDetail?.({ + payload: { message: 'ok' }, + metadata: { userEmail: 'email@format.com' }, + aggregateId: 'id', + version: 10, + timestamp: 'anyString', + type: 'anyType', + }), + ).toMatchObject({ isValid: true }); }); });