UNPKG

@codeforbreakfast/eventsourcing-commands

Version:

Wire command validation and dispatch for event sourcing systems - External boundary layer with schema validation

257 lines (229 loc) 9.05 kB
import { Schema, Data, Effect, pipe } from 'effect'; import type { ReadonlyDeep } from 'type-fest'; import { EventStreamPosition } from '@codeforbreakfast/eventsourcing-store'; // ============================================================================ // Wire Commands - External Transport Layer // ============================================================================ /** * Wire command for transport/serialization * Used by APIs, message queues, and other external interfaces */ export const WireCommand = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String, payload: Schema.Unknown, }); export type WireCommand = typeof WireCommand.Type; // ============================================================================ // Domain Command Types // ============================================================================ /** * Base domain command interface * The validated internal representation */ export interface DomainCommand<TPayload> { readonly id: string; readonly target: string; readonly name: string; readonly payload: TPayload; } // ============================================================================ // Command Errors // ============================================================================ export class CommandValidationError extends Data.TaggedError('CommandValidationError')<{ readonly commandId: string; readonly commandName: string; readonly validationErrors: ReadonlyArray<string>; }> {} export class CommandHandlerNotFoundError extends Data.TaggedError('CommandHandlerNotFoundError')<{ readonly commandId: string; readonly commandName: string; readonly availableHandlers: ReadonlyArray<string>; }> {} export class CommandExecutionError extends Data.TaggedError('CommandExecutionError')<{ readonly commandId: string; readonly commandName: string; readonly cause: unknown; }> {} export class AggregateNotFoundError extends Data.TaggedError('AggregateNotFoundError')<{ readonly commandId: string; readonly aggregateId: string; readonly aggregateType: string; }> {} export class ConcurrencyConflictError extends Data.TaggedError('ConcurrencyConflictError')<{ readonly commandId: string; readonly expectedVersion: number; readonly actualVersion: number; }> {} // ============================================================================ // Command Results // ============================================================================ export const CommandSuccess = Schema.Struct({ _tag: Schema.Literal('Success'), position: EventStreamPosition, }); export type CommandSuccess = typeof CommandSuccess.Type; export const CommandFailure = Schema.Struct({ _tag: Schema.Literal('Failure'), error: Schema.Union( Schema.Struct({ _tag: Schema.Literal('ValidationError'), commandId: Schema.String, commandName: Schema.String, validationErrors: Schema.Array(Schema.String), }), Schema.Struct({ _tag: Schema.Literal('HandlerNotFound'), commandId: Schema.String, commandName: Schema.String, availableHandlers: Schema.Array(Schema.String), }), Schema.Struct({ _tag: Schema.Literal('ExecutionError'), commandId: Schema.String, commandName: Schema.String, message: Schema.String, }), Schema.Struct({ _tag: Schema.Literal('AggregateNotFound'), commandId: Schema.String, aggregateId: Schema.String, aggregateType: Schema.String, }), Schema.Struct({ _tag: Schema.Literal('ConcurrencyConflict'), commandId: Schema.String, expectedVersion: Schema.Number, actualVersion: Schema.Number, }), Schema.Struct({ _tag: Schema.Literal('UnknownError'), commandId: Schema.String, message: Schema.String, }) ), }); export type CommandFailure = typeof CommandFailure.Type; export const CommandResult = Schema.Union(CommandSuccess, CommandFailure); export type CommandResult = typeof CommandResult.Type; // ============================================================================ // Command Result Type Guards // ============================================================================ export const isCommandSuccess = Schema.is(CommandSuccess); export const isCommandFailure = Schema.is(CommandFailure); // ============================================================================ // Command Validation Helpers // ============================================================================ /** * Command definition that pairs a name with its payload schema */ export interface CommandDefinition<TName extends string, TPayload> { readonly name: TName; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema input type can be any, TypeScript will infer the correct type from schema definition readonly payloadSchema: Schema.Schema<TPayload, any>; } /** * Creates a command definition */ export const defineCommand = <TName extends string, TPayload, TPayloadInput>( name: TName, payloadSchema: Schema.Schema<TPayload, TPayloadInput> ): CommandDefinition<TName, TPayload> => ({ name, payloadSchema, }); /** * Helper type to extract command union from command definitions */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic constraint requires any to accept command definitions with any payload type export type CommandFromDefinitions<T extends readonly CommandDefinition<string, any>[]> = { readonly [K in keyof T]: T[K] extends CommandDefinition<infer Name, infer Payload> ? { readonly id: string; readonly target: string; readonly name: Name; readonly payload: Payload; } : never; }[number]; const buildSchemaFromCommandList = < // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic constraint requires any to accept command definitions with any payload type const T extends readonly CommandDefinition<string, any>[], >( commands: ReadonlyDeep<T> ) => { const schemas = commands.map((cmd) => Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.Literal(cmd.name), payload: cmd.payloadSchema, }) ); return schemas.length === 1 ? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Type assertion required to match return type when single schema (schemas[0]! as any) : schemas.length >= 2 ? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Type assertion required to match return type for union of variable schemas (Schema.Union(schemas[0]!, schemas[1]!, ...schemas.slice(2)) as any) : (() => { throw new Error('Unexpected state: should have at least 1 schema'); })(); }; /** * Builds a discriminated union schema from command definitions * This creates an exhaustive schema that can parse any registered command */ export const buildCommandSchema = < // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic constraint requires any to accept command definitions with any payload type const T extends readonly CommandDefinition<string, any>[], >( commands: ReadonlyDeep<T> ): Schema.Schema< CommandFromDefinitions<T>, { readonly id: string; readonly target: string; readonly name: string; readonly payload: unknown } > => // eslint-disable-next-line effect/prefer-match-over-ternary -- Synchronous input validation guard, not pattern matching commands.length === 0 ? (() => { throw new Error('At least one command definition is required'); })() : buildSchemaFromCommandList(commands); /** * Validates and transforms a wire command into a domain command */ export const validateCommand = <TPayload, TPayloadInput>(payloadSchema: Schema.Schema<TPayload, TPayloadInput>) => (wireCommand: ReadonlyDeep<WireCommand>) => pipe( wireCommand.payload, Schema.decodeUnknown(payloadSchema), Effect.mapError( (parseError) => new CommandValidationError({ commandId: wireCommand.id, commandName: wireCommand.name, validationErrors: [parseError.message || 'Payload validation failed'], }) ), Effect.map( (validatedPayload): DomainCommand<TPayload> => ({ id: wireCommand.id, target: wireCommand.target, name: wireCommand.name, payload: validatedPayload, }) ) ); // ============================================================================ // Command Matcher Types // ============================================================================ /** * Command matcher function type * Uses Effect's pattern matching for exhaustive command handling */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic constraint requires any to accept all command payload types export type CommandMatcher<TCommands extends DomainCommand<any>> = ( command: ReadonlyDeep<TCommands> ) => Effect.Effect<CommandResult, never, never>;