diff --git a/src/Framework/EventSourcing/Replay/ReplayCommands.php b/src/Framework/EventSourcing/Replay/ReplayCommands.php new file mode 100644 index 00000000..6259dbc6 --- /dev/null +++ b/src/Framework/EventSourcing/Replay/ReplayCommands.php @@ -0,0 +1,275 @@ + [strategy] + */ + #[ConsoleCommand('replay:events', 'Replay events for aggregate')] + public function events(ConsoleInput $input): int + { + $aggregateIdStr = $input->getArgument('aggregate-id'); + $strategyType = $input->getArgument('strategy') ?? 'full'; + + if (!$aggregateIdStr) { + echo "Error: Aggregate ID required\n"; + echo "Usage: replay:events [strategy]\n"; + echo "Strategies: full, from-version, time-range, event-type\n"; + return ExitCode::ERROR; + } + + $aggregateId = $this->createAggregateId($aggregateIdStr); + $strategy = $this->createStrategy($strategyType, $input); + + echo "Replaying events for aggregate: {$aggregateIdStr}\n"; + echo "Strategy: {$strategy->getDescription()}\n\n"; + + $count = 0; + $this->replayer->replayWithCallback( + aggregateId: $aggregateId, + strategy: $strategy, + callback: function($envelope) use (&$count) { + $count++; + echo "[{$count}] Version {$envelope->version}: "; + echo class_basename(get_class($envelope->event)); + echo " @ {$envelope->timestamp->format('Y-m-d H:i:s')}\n"; + } + ); + + echo "\nTotal events replayed: {$count}\n"; + + return ExitCode::SUCCESS; + } + + /** + * Time travel to specific point + * + * Usage: php console.php replay:time-travel + */ + #[ConsoleCommand('replay:time-travel', 'Time travel to specific point')] + public function timeTravel(ConsoleInput $input): int + { + $aggregateIdStr = $input->getArgument('aggregate-id'); + $timestampStr = $input->getArgument('timestamp'); + + if (!$aggregateIdStr || !$timestampStr) { + echo "Error: Aggregate ID and timestamp required\n"; + echo "Usage: replay:time-travel \n"; + echo "Example: replay:time-travel order-123 '2024-01-15 14:30:00'\n"; + return ExitCode::ERROR; + } + + $aggregateId = $this->createAggregateId($aggregateIdStr); + $targetTime = Timestamp::fromString($timestampStr); + + echo "Time traveling to: {$targetTime->format('Y-m-d H:i:s')}\n\n"; + + // Note: Would need aggregate factory + echo "State reconstruction would happen here\n"; + echo "(Requires aggregate factory implementation)\n"; + + return ExitCode::SUCCESS; + } + + /** + * Show event history timeline + * + * Usage: php console.php replay:timeline + */ + #[ConsoleCommand('replay:timeline', 'Show event history timeline')] + public function timeline(ConsoleInput $input): int + { + $aggregateIdStr = $input->getArgument('aggregate-id'); + + if (!$aggregateIdStr) { + echo "Error: Aggregate ID required\n"; + return ExitCode::ERROR; + } + + $aggregateId = $this->createAggregateId($aggregateIdStr); + + $result = $this->visualizer->generateTimeline($aggregateId); + + echo "Event Timeline for: {$aggregateIdStr}\n"; + echo str_repeat('=', 80) . "\n\n"; + + // Statistics + $stats = $result['statistics']; + echo "Statistics:\n"; + echo " Total Events: {$stats['total_events']}\n"; + echo " Time Span: {$stats['time_span_hours']} hours\n"; + echo " Avg Events/Day: {$stats['average_events_per_day']}\n"; + echo " Unique Event Types: {$stats['unique_event_types']}\n\n"; + + // Timeline + echo "Timeline:\n"; + echo str_repeat('-', 80) . "\n"; + + foreach ($result['timeline'] as $entry) { + printf( + "v%-5d %s %s\n", + $entry['version'], + $entry['timestamp'], + $entry['event_type'] + ); + } + + return ExitCode::SUCCESS; + } + + /** + * Show event statistics + * + * Usage: php console.php replay:stats + */ + #[ConsoleCommand('replay:stats', 'Show event statistics')] + public function stats(ConsoleInput $input): int + { + $aggregateIdStr = $input->getArgument('aggregate-id'); + + if (!$aggregateIdStr) { + echo "Error: Aggregate ID required\n"; + return ExitCode::ERROR; + } + + $aggregateId = $this->createAggregateId($aggregateIdStr); + + $summary = $this->visualizer->getStreamSummary($aggregateId); + + echo "Event Stream Summary\n"; + echo str_repeat('=', 80) . "\n\n"; + + echo "Aggregate ID: {$summary['aggregate_id']}\n"; + echo "Total Events: {$summary['total_events']}\n\n"; + + echo "First Event:\n"; + echo " Version: {$summary['first_event']['version']}\n"; + echo " Type: {$summary['first_event']['type']}\n"; + echo " Timestamp: {$summary['first_event']['timestamp']}\n\n"; + + echo "Last Event:\n"; + echo " Version: {$summary['last_event']['version']}\n"; + echo " Type: {$summary['last_event']['type']}\n"; + echo " Timestamp: {$summary['last_event']['timestamp']}\n\n"; + + echo "Time Span:\n"; + echo " Duration: {$summary['time_span']['duration_hours']} hours\n"; + echo " Avg Events/Day: {$summary['average_events_per_day']}\n\n"; + + echo "Event Type Distribution:\n"; + foreach ($summary['event_types'] as $type => $count) { + $percentage = round(($count / $summary['total_events']) * 100, 1); + printf(" %-30s %5d (%5.1f%%)\n", $type, $count, $percentage); + } + + return ExitCode::SUCCESS; + } + + /** + * Rebuild projection + * + * Usage: php console.php replay:rebuild-projection + */ + #[ConsoleCommand('replay:rebuild-projection', 'Rebuild projection')] + public function rebuildProjection(ConsoleInput $input): int + { + $projectionName = $input->getArgument('projection-name'); + + if (!$projectionName) { + echo "Error: Projection name required\n"; + echo "Usage: replay:rebuild-projection \n"; + return ExitCode::ERROR; + } + + echo "Rebuilding projection: {$projectionName}...\n"; + + try { + $result = $this->projectionRebuilder->rebuildProjection($projectionName); + + echo "✓ Projection rebuilt successfully\n"; + echo "Strategy: {$result['strategy']}\n"; + echo "Status: {$result['status']}\n"; + echo "Last Version: {$result['last_version']}\n"; + + return ExitCode::SUCCESS; + } catch (\Exception $e) { + echo "Error rebuilding projection: {$e->getMessage()}\n"; + return ExitCode::ERROR; + } + } + + /** + * Rebuild all projections + * + * Usage: php console.php replay:rebuild-all + */ + #[ConsoleCommand('replay:rebuild-all', 'Rebuild all projections')] + public function rebuildAll(ConsoleInput $input): int + { + echo "Rebuilding all projections...\n"; + + try { + $results = $this->projectionRebuilder->rebuildAllProjections(); + + echo "✓ All projections rebuilt successfully\n"; + echo "Total: " . count($results) . " projection(s)\n"; + + foreach ($results as $result) { + echo " - {$result->projectionName}: {$result->status->value}\n"; + } + + return ExitCode::SUCCESS; + } catch (\Exception $e) { + echo "Error rebuilding projections: {$e->getMessage()}\n"; + return ExitCode::ERROR; + } + } + + private function createAggregateId(string $id): AggregateId + { + return new class($id) implements AggregateId { + public function __construct(private readonly string $id) {} + public function toString(): string { return $this->id; } + }; + } + + private function createStrategy(string $type, ConsoleInput $input): ReplayStrategy + { + return match ($type) { + 'full' => new FullReplayStrategy(), + 'from-version' => new FromVersionReplayStrategy( + (int) ($input->getArgument('version') ?? 0) + ), + 'time-range' => TimeRangeReplayStrategy::lastDays( + (int) ($input->getArgument('days') ?? 7) + ), + 'today' => TimeRangeReplayStrategy::today(), + 'yesterday' => TimeRangeReplayStrategy::yesterday(), + default => new FullReplayStrategy() + }; + } +} diff --git a/src/Framework/EventSourcing/Sagas/Examples/OrderFulfillmentSaga.php b/src/Framework/EventSourcing/Sagas/Examples/OrderFulfillmentSaga.php new file mode 100644 index 00000000..c82447ed --- /dev/null +++ b/src/Framework/EventSourcing/Sagas/Examples/OrderFulfillmentSaga.php @@ -0,0 +1,325 @@ + $this->handleOrderPlaced($event), + PaymentProcessedEvent::class => $this->handlePaymentProcessed($event), + InventoryReservedEvent::class => $this->handleInventoryReserved($event), + OrderShippedEvent::class => $this->handleOrderShipped($event), + PaymentFailedEvent::class => throw new \RuntimeException('Payment failed'), + InventoryNotAvailableEvent::class => throw new \RuntimeException('Inventory not available'), + ShippingFailedEvent::class => throw new \RuntimeException('Shipping failed'), + default => [] + }; + } + + private function handleOrderPlaced(OrderPlacedEvent $event): array + { + // Initialize saga data + $this->updateData([ + 'orderId' => $event->orderId, + 'customerId' => $event->customerId, + 'totalAmount' => $event->totalAmount, + 'items' => $event->items, + 'step' => 'payment_processing' + ]); + + $this->setStepCount(4); // 4 steps: payment, inventory, shipping, notification + + // Step 1: Process payment + return [ + new ProcessPaymentCommand( + orderId: $event->orderId, + amount: $event->totalAmount, + customerId: $event->customerId + ) + ]; + } + + private function handlePaymentProcessed(PaymentProcessedEvent $event): array + { + // Payment successful - update saga data + $this->updateData([ + 'paymentId' => $event->paymentId, + 'step' => 'inventory_reservation' + ]); + + // Step 2: Reserve inventory + $items = $this->getData('items'); + + return [ + new ReserveInventoryCommand( + orderId: $event->orderId, + items: $items + ) + ]; + } + + private function handleInventoryReserved(InventoryReservedEvent $event): array + { + // Inventory reserved - update saga data + $this->updateData([ + 'reservationId' => $event->reservationId, + 'step' => 'shipping' + ]); + + // Step 3: Ship order + $orderId = $this->getData('orderId'); + $customerId = $this->getData('customerId'); + + return [ + new ShipOrderCommand( + orderId: $orderId, + customerId: $customerId, + items: $event->reservedItems + ) + ]; + } + + private function handleOrderShipped(OrderShippedEvent $event): array + { + // Order shipped - update saga data + $this->updateData([ + 'trackingNumber' => $event->trackingNumber, + 'step' => 'notification' + ]); + + // Step 4: Send notification + $customerId = $this->getData('customerId'); + $orderId = $this->getData('orderId'); + + $commands = [ + new SendOrderConfirmationCommand( + customerId: $customerId, + orderId: $orderId, + trackingNumber: $event->trackingNumber + ) + ]; + + // Mark saga as completed + $this->complete(); + + return $commands; + } + + protected function getCompensationCommands(): array + { + $compensationCommands = []; + $currentStep = $this->getData('step'); + + // Compensate in reverse order of execution + match ($currentStep) { + 'shipping' => $compensationCommands = array_merge( + $compensationCommands, + $this->compensateInventoryReservation(), + $this->compensatePayment() + ), + 'inventory_reservation' => $compensationCommands = array_merge( + $compensationCommands, + $this->compensatePayment() + ), + 'payment_processing' => $compensationCommands = $this->compensatePayment(), + default => [] + }; + + // Always notify customer about cancellation + $compensationCommands[] = new SendOrderCancellationCommand( + customerId: $this->getData('customerId'), + orderId: $this->getData('orderId'), + reason: $this->state->errorMessage ?? 'Order fulfillment failed' + ); + + return $compensationCommands; + } + + private function compensatePayment(): array + { + if (!$this->hasData('paymentId')) { + return []; + } + + return [ + new RefundPaymentCommand( + paymentId: $this->getData('paymentId'), + orderId: $this->getData('orderId'), + amount: $this->getData('totalAmount') + ) + ]; + } + + private function compensateInventoryReservation(): array + { + if (!$this->hasData('reservationId')) { + return []; + } + + return [ + new ReleaseInventoryCommand( + reservationId: $this->getData('reservationId'), + orderId: $this->getData('orderId') + ) + ]; + } +} + +// Example Events (would be in separate files) +final readonly class OrderPlacedEvent implements DomainEvent +{ + public function __construct( + public string $orderId, + public string $customerId, + public int $totalAmount, + public array $items + ) {} +} + +final readonly class PaymentProcessedEvent implements DomainEvent +{ + public function __construct( + public string $orderId, + public string $paymentId, + public int $amount + ) {} +} + +final readonly class InventoryReservedEvent implements DomainEvent +{ + public function __construct( + public string $orderId, + public string $reservationId, + public array $reservedItems + ) {} +} + +final readonly class OrderShippedEvent implements DomainEvent +{ + public function __construct( + public string $orderId, + public string $trackingNumber + ) {} +} + +final readonly class PaymentFailedEvent implements DomainEvent +{ + public function __construct( + public string $orderId, + public string $reason + ) {} +} + +final readonly class InventoryNotAvailableEvent implements DomainEvent +{ + public function __construct( + public string $orderId, + public array $unavailableItems + ) {} +} + +final readonly class ShippingFailedEvent implements DomainEvent +{ + public function __construct( + public string $orderId, + public string $reason + ) {} +} + +// Example Commands (would be in separate files) +final readonly class ProcessPaymentCommand +{ + public function __construct( + public string $orderId, + public int $amount, + public string $customerId + ) {} +} + +final readonly class ReserveInventoryCommand +{ + public function __construct( + public string $orderId, + public array $items + ) {} +} + +final readonly class ShipOrderCommand +{ + public function __construct( + public string $orderId, + public string $customerId, + public array $items + ) {} +} + +final readonly class SendOrderConfirmationCommand +{ + public function __construct( + public string $customerId, + public string $orderId, + public string $trackingNumber + ) {} +} + +final readonly class RefundPaymentCommand +{ + public function __construct( + public string $paymentId, + public string $orderId, + public int $amount + ) {} +} + +final readonly class ReleaseInventoryCommand +{ + public function __construct( + public string $reservationId, + public string $orderId + ) {} +} + +final readonly class SendOrderCancellationCommand +{ + public function __construct( + public string $customerId, + public string $orderId, + public string $reason + ) {} +} diff --git a/src/Framework/EventSourcing/Sagas/Examples/UserOnboardingSaga.php b/src/Framework/EventSourcing/Sagas/Examples/UserOnboardingSaga.php new file mode 100644 index 00000000..3612a04e --- /dev/null +++ b/src/Framework/EventSourcing/Sagas/Examples/UserOnboardingSaga.php @@ -0,0 +1,235 @@ + $this->handleUserRegistered($event), + EmailVerifiedEvent::class => $this->handleEmailVerified($event), + ProfileCompletedEvent::class => $this->handleProfileCompleted($event), + WelcomeEmailSentEvent::class => $this->handleWelcomeEmailSent($event), + default => [] + }; + } + + private function handleUserRegistered(UserRegisteredEvent $event): array + { + // Initialize saga + $this->updateData([ + 'userId' => $event->userId, + 'email' => $event->email, + 'registeredAt' => $event->registeredAt, + 'step' => 'email_verification' + ]); + + $this->setStepCount(4); // 4 steps total + + // Step 1: Send verification email + return [ + new SendVerificationEmailCommand( + userId: $event->userId, + email: $event->email + ) + ]; + } + + private function handleEmailVerified(EmailVerifiedEvent $event): array + { + // Email verified - prompt for profile setup + $this->updateData([ + 'emailVerifiedAt' => $event->verifiedAt, + 'step' => 'profile_setup' + ]); + + // Step 2: Send profile setup reminder + return [ + new SendProfileSetupReminderCommand( + userId: $this->getData('userId'), + email: $this->getData('email') + ) + ]; + } + + private function handleProfileCompleted(ProfileCompletedEvent $event): array + { + // Profile completed - send welcome email + $this->updateData([ + 'profileCompletedAt' => $event->completedAt, + 'profileData' => $event->profileData, + 'step' => 'welcome_email' + ]); + + // Step 3: Send welcome email + return [ + new SendWelcomeEmailCommand( + userId: $this->getData('userId'), + email: $this->getData('email'), + userName: $event->profileData['name'] ?? 'User' + ) + ]; + } + + private function handleWelcomeEmailSent(WelcomeEmailSentEvent $event): array + { + // Welcome email sent - activate feature tour + $this->updateData([ + 'welcomeEmailSentAt' => $event->sentAt, + 'step' => 'feature_tour' + ]); + + // Step 4: Activate feature tour + $commands = [ + new ActivateFeatureTourCommand( + userId: $this->getData('userId') + ) + ]; + + // Onboarding complete + $this->complete(); + + return $commands; + } + + protected function getCompensationCommands(): array + { + // User onboarding has limited compensation + // Mainly just cleanup and notifications + + $compensationCommands = []; + $userId = $this->getData('userId'); + + // Deactivate any features that were enabled + if ($this->hasData('welcomeEmailSentAt')) { + $compensationCommands[] = new DeactivateFeatureTourCommand($userId); + } + + // Send onboarding failure notification to admins + $compensationCommands[] = new NotifyOnboardingFailureCommand( + userId: $userId, + email: $this->getData('email'), + failedStep: $this->getData('step'), + reason: $this->state->errorMessage ?? 'Unknown error' + ); + + return $compensationCommands; + } +} + +// Example Events +final readonly class UserRegisteredEvent implements DomainEvent +{ + public function __construct( + public string $userId, + public string $email, + public string $registeredAt + ) {} +} + +final readonly class EmailVerifiedEvent implements DomainEvent +{ + public function __construct( + public string $userId, + public string $verifiedAt + ) {} +} + +final readonly class ProfileCompletedEvent implements DomainEvent +{ + public function __construct( + public string $userId, + public array $profileData, + public string $completedAt + ) {} +} + +final readonly class WelcomeEmailSentEvent implements DomainEvent +{ + public function __construct( + public string $userId, + public string $sentAt + ) {} +} + +// Example Commands +final readonly class SendVerificationEmailCommand +{ + public function __construct( + public string $userId, + public string $email + ) {} +} + +final readonly class SendProfileSetupReminderCommand +{ + public function __construct( + public string $userId, + public string $email + ) {} +} + +final readonly class SendWelcomeEmailCommand +{ + public function __construct( + public string $userId, + public string $email, + public string $userName + ) {} +} + +final readonly class ActivateFeatureTourCommand +{ + public function __construct( + public string $userId + ) {} +} + +final readonly class DeactivateFeatureTourCommand +{ + public function __construct( + public string $userId + ) {} +} + +final readonly class NotifyOnboardingFailureCommand +{ + public function __construct( + public string $userId, + public string $email, + public string $failedStep, + public string $reason + ) {} +} diff --git a/src/Framework/EventSourcing/Sagas/SagaCommands.php b/src/Framework/EventSourcing/Sagas/SagaCommands.php new file mode 100644 index 00000000..7e34ff9d --- /dev/null +++ b/src/Framework/EventSourcing/Sagas/SagaCommands.php @@ -0,0 +1,277 @@ + [data-json] + */ + #[ConsoleCommand('saga:start', 'Start a new saga')] + public function start(ConsoleInput $input): int + { + $sagaName = $input->getArgument('saga-name'); + $dataJson = $input->getArgument('data-json'); + + if (!$sagaName) { + echo "Error: Saga name required\n"; + echo "Usage: saga:start [data-json]\n"; + return ExitCode::ERROR; + } + + $initialData = []; + if ($dataJson) { + $initialData = json_decode($dataJson, true); + if (json_last_error() !== JSON_ERROR_NONE) { + echo "Error: Invalid JSON data\n"; + return ExitCode::ERROR; + } + } + + try { + $sagaId = $this->sagaManager->startSaga($sagaName, $initialData); + + echo "✓ Saga started successfully\n"; + echo "Saga ID: {$sagaId->toString()}\n"; + echo "Saga Name: {$sagaName}\n"; + + return ExitCode::SUCCESS; + } catch (\Exception $e) { + echo "Error starting saga: {$e->getMessage()}\n"; + return ExitCode::ERROR; + } + } + + /** + * Show saga status + * + * Usage: php console.php saga:status + */ + #[ConsoleCommand('saga:status', 'Show saga status')] + public function status(ConsoleInput $input): int + { + $sagaIdStr = $input->getArgument('saga-id'); + + if (!$sagaIdStr) { + echo "Error: Saga ID required\n"; + return ExitCode::ERROR; + } + + $sagaId = SagaId::fromString($sagaIdStr); + $state = $this->sagaManager->getSagaState($sagaId); + + if (!$state) { + echo "Saga not found: {$sagaIdStr}\n"; + return ExitCode::ERROR; + } + + echo "Saga Status:\n"; + echo str_repeat('=', 60) . "\n"; + echo "Saga ID: {$state->sagaId->toString()}\n"; + echo "Saga Name: {$state->sagaName}\n"; + echo "Status: {$state->status->value}\n"; + echo "Progress: {$state->getProgress()}% ({$state->completedSteps}/{$state->stepCount} steps)\n"; + echo "Started: {$state->startedAt->format('Y-m-d H:i:s')}\n"; + + if ($state->completedAt) { + echo "Completed: {$state->completedAt->format('Y-m-d H:i:s')}\n"; + } + + if ($state->errorMessage) { + echo "Error: {$state->errorMessage}\n"; + } + + echo "\nSaga Data:\n"; + echo json_encode($state->data, JSON_PRETTY_PRINT) . "\n"; + + return ExitCode::SUCCESS; + } + + /** + * List all sagas + * + * Usage: php console.php saga:list [status] + */ + #[ConsoleCommand('saga:list', 'List all sagas')] + public function list(ConsoleInput $input): int + { + $statusFilter = $input->getArgument('status'); + + $sagas = $statusFilter + ? $this->repository->findByStatus(SagaStatus::from($statusFilter)) + : $this->repository->findAll(); + + if (empty($sagas)) { + echo "No sagas found\n"; + return ExitCode::SUCCESS; + } + + echo "Sagas:\n"; + echo str_repeat('=', 80) . "\n"; + printf("%-36s %-25s %-12s %-10s\n", 'ID', 'Name', 'Status', 'Progress'); + echo str_repeat('-', 80) . "\n"; + + foreach ($sagas as $saga) { + $state = $saga->getState(); + printf( + "%-36s %-25s %-12s %6.1f%%\n", + $state->sagaId->toString(), + $state->sagaName, + $state->status->value, + $state->getProgress() + ); + } + + echo "\nTotal: " . count($sagas) . " saga(s)\n"; + + return ExitCode::SUCCESS; + } + + /** + * Show running sagas + * + * Usage: php console.php saga:running + */ + #[ConsoleCommand('saga:running', 'Show running sagas')] + public function running(ConsoleInput $input): int + { + $runningSagas = $this->sagaManager->getRunningSagas(); + + if (empty($runningSagas)) { + echo "No running sagas\n"; + return ExitCode::SUCCESS; + } + + echo "Running Sagas:\n"; + echo str_repeat('=', 80) . "\n"; + + foreach ($runningSagas as $saga) { + $state = $saga->getState(); + + echo "ID: {$state->sagaId->toString()}\n"; + echo "Name: {$state->sagaName}\n"; + echo "Progress: {$state->getProgress()}% ({$state->completedSteps}/{$state->stepCount} steps)\n"; + echo "Started: {$state->startedAt->format('Y-m-d H:i:s')}\n"; + echo str_repeat('-', 80) . "\n"; + } + + echo "\nTotal: " . count($runningSagas) . " running saga(s)\n"; + + return ExitCode::SUCCESS; + } + + /** + * Complete saga manually + * + * Usage: php console.php saga:complete + */ + #[ConsoleCommand('saga:complete', 'Complete saga manually')] + public function complete(ConsoleInput $input): int + { + $sagaIdStr = $input->getArgument('saga-id'); + + if (!$sagaIdStr) { + echo "Error: Saga ID required\n"; + return ExitCode::ERROR; + } + + try { + $sagaId = SagaId::fromString($sagaIdStr); + $this->sagaManager->completeSaga($sagaId); + + echo "✓ Saga completed successfully\n"; + + return ExitCode::SUCCESS; + } catch (\Exception $e) { + echo "Error completing saga: {$e->getMessage()}\n"; + return ExitCode::ERROR; + } + } + + /** + * Compensate (rollback) saga + * + * Usage: php console.php saga:compensate + */ + #[ConsoleCommand('saga:compensate', 'Compensate (rollback) saga')] + public function compensate(ConsoleInput $input): int + { + $sagaIdStr = $input->getArgument('saga-id'); + $reason = $input->getArgument('reason') ?? 'Manual compensation'; + + if (!$sagaIdStr) { + echo "Error: Saga ID required\n"; + return ExitCode::ERROR; + } + + try { + $sagaId = SagaId::fromString($sagaIdStr); + $saga = $this->sagaManager->getSaga($sagaId); + + if (!$saga) { + echo "Saga not found: {$sagaIdStr}\n"; + return ExitCode::ERROR; + } + + echo "Compensating saga: {$saga->getName()}...\n"; + + $this->sagaManager->compensateSaga($saga, $reason); + + echo "✓ Saga compensated successfully\n"; + + return ExitCode::SUCCESS; + } catch (\Exception $e) { + echo "Error compensating saga: {$e->getMessage()}\n"; + return ExitCode::ERROR; + } + } + + /** + * Delete saga + * + * Usage: php console.php saga:delete + */ + #[ConsoleCommand('saga:delete', 'Delete saga')] + public function delete(ConsoleInput $input): int + { + $sagaIdStr = $input->getArgument('saga-id'); + + if (!$sagaIdStr) { + echo "Error: Saga ID required\n"; + return ExitCode::ERROR; + } + + try { + $sagaId = SagaId::fromString($sagaIdStr); + + echo "Deleting saga: {$sagaIdStr}...\n"; + + $this->repository->delete($sagaId); + + echo "✓ Saga deleted successfully\n"; + + return ExitCode::SUCCESS; + } catch (\Exception $e) { + echo "Error deleting saga: {$e->getMessage()}\n"; + return ExitCode::ERROR; + } + } +}