fix: remove static keyword from Saga getName() methods
AbstractSaga::getName() is non-static, but child classes (UserOnboardingSaga, OrderFulfillmentSaga) tried to override it as static. Also fixed ConsoleCommand usage: - ConsoleCommand is an attribute, not an interface - SagaCommands and ReplayCommands now use #[ConsoleCommand] attributes - All command methods properly annotated
This commit is contained in:
275
src/Framework/EventSourcing/Replay/ReplayCommands.php
Normal file
275
src/Framework/EventSourcing/Replay/ReplayCommands.php
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\EventSourcing\Replay;
|
||||||
|
|
||||||
|
use App\Framework\Console\ConsoleCommand;
|
||||||
|
use App\Framework\Console\ConsoleInput;
|
||||||
|
use App\Framework\Console\ExitCode;
|
||||||
|
use App\Framework\EventSourcing\AggregateId;
|
||||||
|
use App\Framework\Core\ValueObjects\Timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event Replay Console Commands
|
||||||
|
*
|
||||||
|
* Manage event replay and time travel debugging via CLI
|
||||||
|
*/
|
||||||
|
final readonly class ReplayCommands
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EventReplayer $replayer,
|
||||||
|
private TimeTravelDebugger $timeTravelDebugger,
|
||||||
|
private EventHistoryVisualizer $visualizer,
|
||||||
|
private ProjectionRebuilder $projectionRebuilder
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay events for aggregate
|
||||||
|
*
|
||||||
|
* Usage: php console.php replay:events <aggregate-id> [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 <aggregate-id> [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 <aggregate-id> <timestamp>
|
||||||
|
*/
|
||||||
|
#[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 <aggregate-id> <timestamp>\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 <aggregate-id>
|
||||||
|
*/
|
||||||
|
#[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 <aggregate-id>
|
||||||
|
*/
|
||||||
|
#[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 <projection-name>
|
||||||
|
*/
|
||||||
|
#[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 <projection-name>\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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\EventSourcing\Sagas\Examples;
|
||||||
|
|
||||||
|
use App\Framework\EventSourcing\Sagas\AbstractSaga;
|
||||||
|
use App\Framework\EventSourcing\DomainEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order Fulfillment Saga Example
|
||||||
|
*
|
||||||
|
* Orchestrates order fulfillment process across multiple steps:
|
||||||
|
* 1. Payment processing
|
||||||
|
* 2. Inventory reservation
|
||||||
|
* 3. Shipping
|
||||||
|
* 4. Notification
|
||||||
|
*
|
||||||
|
* Demonstrates compensation logic for failed steps
|
||||||
|
*/
|
||||||
|
final readonly class OrderFulfillmentSaga extends AbstractSaga
|
||||||
|
{
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'order_fulfillment';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subscribedTo(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
OrderPlacedEvent::class,
|
||||||
|
PaymentProcessedEvent::class,
|
||||||
|
InventoryReservedEvent::class,
|
||||||
|
OrderShippedEvent::class,
|
||||||
|
PaymentFailedEvent::class,
|
||||||
|
InventoryNotAvailableEvent::class,
|
||||||
|
ShippingFailedEvent::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleEvent(DomainEvent $event): array
|
||||||
|
{
|
||||||
|
return match ($event::class) {
|
||||||
|
OrderPlacedEvent::class => $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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\EventSourcing\Sagas\Examples;
|
||||||
|
|
||||||
|
use App\Framework\EventSourcing\Sagas\AbstractSaga;
|
||||||
|
use App\Framework\EventSourcing\DomainEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Onboarding Saga Example
|
||||||
|
*
|
||||||
|
* Orchestrates user onboarding process:
|
||||||
|
* 1. Email verification
|
||||||
|
* 2. Profile setup
|
||||||
|
* 3. Welcome email
|
||||||
|
* 4. Feature tour activation
|
||||||
|
*
|
||||||
|
* Demonstrates time-based saga with multi-step coordination
|
||||||
|
*/
|
||||||
|
final readonly class UserOnboardingSaga extends AbstractSaga
|
||||||
|
{
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'user_onboarding';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subscribedTo(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
UserRegisteredEvent::class,
|
||||||
|
EmailVerifiedEvent::class,
|
||||||
|
ProfileCompletedEvent::class,
|
||||||
|
WelcomeEmailSentEvent::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleEvent(DomainEvent $event): array
|
||||||
|
{
|
||||||
|
return match ($event::class) {
|
||||||
|
UserRegisteredEvent::class => $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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
277
src/Framework/EventSourcing/Sagas/SagaCommands.php
Normal file
277
src/Framework/EventSourcing/Sagas/SagaCommands.php
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\EventSourcing\Sagas;
|
||||||
|
|
||||||
|
use App\Framework\Console\ConsoleCommand;
|
||||||
|
use App\Framework\Console\ConsoleInput;
|
||||||
|
use App\Framework\Console\ExitCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saga Console Commands
|
||||||
|
*
|
||||||
|
* Manage sagas via CLI
|
||||||
|
*/
|
||||||
|
final readonly class SagaCommands
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SagaManager $sagaManager,
|
||||||
|
private SagaRepository $repository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new saga
|
||||||
|
*
|
||||||
|
* Usage: php console.php saga:start <saga-name> [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 <saga-name> [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 <saga-id>
|
||||||
|
*/
|
||||||
|
#[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 <saga-id>
|
||||||
|
*/
|
||||||
|
#[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 <saga-id> <reason>
|
||||||
|
*/
|
||||||
|
#[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 <saga-id>
|
||||||
|
*/
|
||||||
|
#[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user