feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
94
src/Framework/LiveComponents/Attributes/Poll.php
Normal file
94
src/Framework/LiveComponents/Attributes/Poll.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Marks a method as pollable for LiveComponents system.
|
||||
*
|
||||
* The method will be automatically called at specified intervals when attached to a component.
|
||||
*
|
||||
* Method Requirements:
|
||||
* - Must be public
|
||||
* - Return type must be serializable (array, SerializableState, scalar)
|
||||
* - Should be idempotent (safe to call multiple times)
|
||||
*
|
||||
* @example Basic polling
|
||||
* ```php
|
||||
* #[Poll(interval: 1000)]
|
||||
* public function checkNotifications(): array
|
||||
* {
|
||||
* return ['count' => $this->notificationService->getUnreadCount()];
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example With event dispatch
|
||||
* ```php
|
||||
* #[Poll(interval: 5000, event: 'notifications.updated')]
|
||||
* public function pollNotifications(): NotificationState
|
||||
* {
|
||||
* return $this->notificationService->getCurrentState();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_METHOD)]
|
||||
final readonly class Poll
|
||||
{
|
||||
/**
|
||||
* @param int $interval Polling interval in milliseconds (minimum: 100ms)
|
||||
* @param bool $enabled Whether polling is enabled by default
|
||||
* @param string|null $event Optional event name to dispatch on poll
|
||||
* @param bool $stopOnError Whether to stop polling if method throws exception
|
||||
*/
|
||||
public function __construct(
|
||||
public int $interval = 1000,
|
||||
public bool $enabled = true,
|
||||
public ?string $event = null,
|
||||
public bool $stopOnError = false
|
||||
) {
|
||||
if ($interval < 100) {
|
||||
throw new \InvalidArgumentException('Poll interval must be at least 100ms to prevent performance issues');
|
||||
}
|
||||
|
||||
if ($interval > 300000) {
|
||||
throw new \InvalidArgumentException('Poll interval cannot exceed 5 minutes (300000ms)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interval as Duration value object.
|
||||
*/
|
||||
public function getInterval(): \App\Framework\Core\ValueObjects\Duration
|
||||
{
|
||||
return \App\Framework\Core\ValueObjects\Duration::fromMilliseconds($this->interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with different enabled state.
|
||||
*/
|
||||
public function withEnabled(bool $enabled): self
|
||||
{
|
||||
return new self(
|
||||
interval: $this->interval,
|
||||
enabled: $enabled,
|
||||
event: $this->event,
|
||||
stopOnError: $this->stopOnError
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with different interval.
|
||||
*/
|
||||
public function withInterval(int $interval): self
|
||||
{
|
||||
return new self(
|
||||
interval: $interval,
|
||||
enabled: $this->enabled,
|
||||
event: $this->event,
|
||||
stopOnError: $this->stopOnError
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Attributes;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Track State History Attribute
|
||||
*
|
||||
* Marks a LiveComponent to track state changes in component_state_history table.
|
||||
* Use this for components where debugging, analytics, or audit trails are needed.
|
||||
*
|
||||
* Example:
|
||||
* #[LiveComponent(name: 'order-form')]
|
||||
* #[TrackStateHistory]
|
||||
* final readonly class OrderFormComponent
|
||||
* {
|
||||
* // State changes will be tracked in history
|
||||
* }
|
||||
*
|
||||
* Performance Impact:
|
||||
* - One additional INSERT per state change
|
||||
* - Minimal overhead (~1-2ms per change)
|
||||
* - Consider cleanup strategy for old history entries
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_CLASS)]
|
||||
final readonly class TrackStateHistory
|
||||
{
|
||||
public function __construct(
|
||||
public bool $trackIpAddress = true,
|
||||
public bool $trackUserAgent = true,
|
||||
public bool $trackChangedProperties = true,
|
||||
public ?int $maxHistoryEntries = null, // Auto-cleanup after N entries
|
||||
) {}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Http\Enums\Method;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\LiveComponents\Services\ChunkedUploadManager;
|
||||
use App\Framework\LiveComponents\Services\UploadProgressTracker;
|
||||
use App\Framework\LiveComponents\Services\UploadProgressTrackerInterface;
|
||||
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
|
||||
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
|
||||
use App\Framework\Router\Result\JsonResponse;
|
||||
@@ -29,7 +29,7 @@ final readonly class ChunkedUploadController
|
||||
{
|
||||
public function __construct(
|
||||
private ChunkedUploadManager $uploadManager,
|
||||
private UploadProgressTracker $progressTracker
|
||||
private UploadProgressTrackerInterface $progressTracker
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
|
||||
use App\Framework\LiveComponents\Exceptions\StateValidationException;
|
||||
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
|
||||
use App\Framework\LiveComponents\ParameterBinding\ParameterBinder;
|
||||
use App\Framework\LiveComponents\Persistence\LiveComponentStatePersistence;
|
||||
use App\Framework\LiveComponents\Security\ActionAuthorizationChecker;
|
||||
use App\Framework\LiveComponents\Services\LiveComponentRateLimiter;
|
||||
use App\Framework\LiveComponents\Validation\DefaultStateValidator;
|
||||
@@ -31,6 +32,7 @@ use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
|
||||
use App\Framework\LiveComponents\ValueObjects\ReservedActionName;
|
||||
use App\Framework\Performance\NestedPerformanceTracker;
|
||||
use App\Framework\Performance\PerformanceCategory;
|
||||
use App\Framework\StateManagement\SerializableState;
|
||||
|
||||
/**
|
||||
* Handles LiveComponent action execution and state updates
|
||||
@@ -60,7 +62,8 @@ final readonly class LiveComponentHandler
|
||||
private IdempotencyService $idempotency,
|
||||
private ParameterBinder $parameterBinder,
|
||||
private EventDispatcherInterface $frameworkEventDispatcher,
|
||||
private NestedPerformanceTracker $performanceTracker
|
||||
private NestedPerformanceTracker $performanceTracker,
|
||||
private ?LiveComponentStatePersistence $statePersistence = null,
|
||||
) {
|
||||
$this->stateValidator = new DefaultStateValidator();
|
||||
}
|
||||
@@ -216,10 +219,20 @@ final readonly class LiveComponentHandler
|
||||
['component' => $component->id->name]
|
||||
);
|
||||
|
||||
// 9. Convert State VO to array for serialization
|
||||
// 9. Persist state to database with optional history tracking
|
||||
if ($this->statePersistence !== null && $stateObject instanceof SerializableState) {
|
||||
$this->performanceTracker->measure(
|
||||
"livecomponent.state.persist",
|
||||
PerformanceCategory::DATABASE,
|
||||
fn() => $this->statePersistence->persistState($component, $stateObject, $method),
|
||||
['component' => $component->id->name, 'action' => $method]
|
||||
);
|
||||
}
|
||||
|
||||
// 10. Convert State VO to array for serialization
|
||||
$stateArray = $stateObject->toArray();
|
||||
|
||||
// 10. Build ComponentUpdate
|
||||
// 11. Build ComponentUpdate
|
||||
// The LiveComponentController will render HTML using ComponentRegistry
|
||||
$componentUpdate = new ComponentUpdate(
|
||||
html: '', // Will be populated by controller
|
||||
@@ -231,7 +244,7 @@ final readonly class LiveComponentHandler
|
||||
)
|
||||
);
|
||||
|
||||
// 11. Dispatch domain event for SSE broadcasting
|
||||
// 12. Dispatch domain event for SSE broadcasting
|
||||
// This enables real-time updates via Server-Sent Events
|
||||
$this->frameworkEventDispatcher->dispatch(
|
||||
new ComponentUpdatedEvent(
|
||||
@@ -328,10 +341,15 @@ final readonly class LiveComponentHandler
|
||||
// 6. Call onUpdate() lifecycle hook if component implements LifecycleAware
|
||||
$this->callUpdateHook($component, $stateObject);
|
||||
|
||||
// 7. Convert State VO to array
|
||||
// 7. Persist state to database with optional history tracking
|
||||
if ($this->statePersistence !== null && $stateObject instanceof SerializableState) {
|
||||
$this->statePersistence->persistState($component, $stateObject, 'handleUpload');
|
||||
}
|
||||
|
||||
// 8. Convert State VO to array
|
||||
$stateArray = $stateObject->toArray();
|
||||
|
||||
// 8. Build ComponentUpdate
|
||||
// 9. Build ComponentUpdate
|
||||
$componentUpdate = new ComponentUpdate(
|
||||
html: '', // Will be populated by controller
|
||||
events: $events,
|
||||
@@ -342,7 +360,7 @@ final readonly class LiveComponentHandler
|
||||
)
|
||||
);
|
||||
|
||||
// 9. Dispatch domain event for SSE broadcasting
|
||||
// 10. Dispatch domain event for SSE broadcasting
|
||||
$this->frameworkEventDispatcher->dispatch(
|
||||
new ComponentUpdatedEvent(
|
||||
componentId: $componentId,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Persistence;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Http\RequestContext;
|
||||
use App\Framework\LiveComponents\Attributes\TrackStateHistory;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\Database\StateChangeType;
|
||||
use App\Framework\StateManagement\SerializableState;
|
||||
use App\Framework\StateManagement\StateHistoryManager;
|
||||
use App\Framework\StateManagement\StateManager;
|
||||
|
||||
/**
|
||||
* LiveComponent State Persistence Handler
|
||||
*
|
||||
* Handles automatic state persistence to database with optional history tracking.
|
||||
* Integrates DatabaseStateManager and StateHistoryManager.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic state persistence after action execution
|
||||
* - Optional history tracking via #[TrackStateHistory] attribute
|
||||
* - Changed properties detection
|
||||
* - Context capture (userId, sessionId, ipAddress, userAgent)
|
||||
* - Checksum-based integrity verification
|
||||
*/
|
||||
final readonly class LiveComponentStatePersistence
|
||||
{
|
||||
public function __construct(
|
||||
private StateManager $stateManager,
|
||||
private StateHistoryManager $historyManager,
|
||||
private ?RequestContext $requestContext = null,
|
||||
private ?Logger $logger = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Persist component state after action execution
|
||||
*
|
||||
* Steps:
|
||||
* 1. Get previous state for comparison (if exists)
|
||||
* 2. Persist new state via StateManager
|
||||
* 3. If #[TrackStateHistory] present: Add history entry
|
||||
* 4. Calculate changed properties
|
||||
* 5. Capture request context
|
||||
*
|
||||
* @param LiveComponentContract $component Component instance
|
||||
* @param SerializableState $newState New state after action
|
||||
* @param string $action Action name that triggered the change
|
||||
*/
|
||||
public function persistState(
|
||||
LiveComponentContract $component,
|
||||
SerializableState $newState,
|
||||
string $action
|
||||
): void {
|
||||
$componentId = $component->id;
|
||||
$componentClass = get_class($component);
|
||||
|
||||
// 1. Get previous state for comparison (if exists)
|
||||
$previousState = $this->stateManager->getState($componentId->toString());
|
||||
$previousChecksum = $previousState !== null
|
||||
? $this->calculateChecksum($previousState)
|
||||
: null;
|
||||
|
||||
// 2. Persist new state via StateManager
|
||||
$this->stateManager->setState($componentId->toString(), $newState);
|
||||
|
||||
$currentChecksum = $this->calculateChecksum($newState);
|
||||
|
||||
// 3. Check if history tracking is enabled
|
||||
if (!$this->historyManager->isHistoryEnabled($componentClass)) {
|
||||
$this->log('debug', "State persisted for {$componentId->toString()}, history tracking disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Get TrackStateHistory attribute for configuration
|
||||
$historyConfig = $this->getHistoryConfig($componentClass);
|
||||
|
||||
// 5. Calculate changed properties (if enabled)
|
||||
$changedProperties = null;
|
||||
if ($historyConfig?->trackChangedProperties && $previousState !== null) {
|
||||
$changedProperties = $this->calculateChangedProperties($previousState, $newState);
|
||||
}
|
||||
|
||||
// 6. Capture request context
|
||||
$context = $this->captureContext($historyConfig);
|
||||
|
||||
// 7. Determine change type
|
||||
$changeType = $previousState === null
|
||||
? StateChangeType::CREATED->value
|
||||
: StateChangeType::UPDATED->value;
|
||||
|
||||
// 8. Get version from StateManager (assumes DatabaseStateManager tracks versions)
|
||||
$version = $this->getStateVersion($componentId);
|
||||
|
||||
// 9. Add history entry
|
||||
$this->historyManager->addHistoryEntry(
|
||||
componentId: $componentId->toString(),
|
||||
stateData: json_encode($newState->toArray()),
|
||||
stateClass: get_class($newState),
|
||||
version: $version,
|
||||
changeType: $changeType,
|
||||
context: $context,
|
||||
changedProperties: $changedProperties,
|
||||
previousChecksum: $previousChecksum,
|
||||
currentChecksum: $currentChecksum
|
||||
);
|
||||
|
||||
$this->log('info', "State persisted with history for {$componentId->toString()}", [
|
||||
'action' => $action,
|
||||
'change_type' => $changeType,
|
||||
'version' => $version,
|
||||
'changed_properties_count' => $changedProperties ? count($changedProperties) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate checksum for state integrity verification
|
||||
*
|
||||
* Uses SHA256 hash of serialized state data.
|
||||
*/
|
||||
private function calculateChecksum(SerializableState $state): string
|
||||
{
|
||||
return hash('sha256', json_encode($state->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate changed properties between old and new state
|
||||
*
|
||||
* Compares state arrays and returns list of changed property names.
|
||||
*
|
||||
* @return array<string> List of changed property names
|
||||
*/
|
||||
private function calculateChangedProperties(
|
||||
SerializableState $oldState,
|
||||
SerializableState $newState
|
||||
): array {
|
||||
$oldData = $oldState->toArray();
|
||||
$newData = $newState->toArray();
|
||||
|
||||
$changed = [];
|
||||
|
||||
// Check for modified or removed properties
|
||||
foreach ($oldData as $key => $oldValue) {
|
||||
if (!array_key_exists($key, $newData)) {
|
||||
$changed[] = $key; // Property removed
|
||||
} elseif ($oldValue !== $newData[$key]) {
|
||||
$changed[] = $key; // Property changed
|
||||
}
|
||||
}
|
||||
|
||||
// Check for added properties
|
||||
foreach ($newData as $key => $newValue) {
|
||||
if (!array_key_exists($key, $oldData)) {
|
||||
$changed[] = $key; // Property added
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($changed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture request context based on history configuration
|
||||
*
|
||||
* @return array{user_id?: string, session_id?: string, ip_address?: string, user_agent?: string}
|
||||
*/
|
||||
private function captureContext(?TrackStateHistory $config): array
|
||||
{
|
||||
if ($config === null || $this->requestContext === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$context = [];
|
||||
|
||||
// Always capture userId and sessionId if available
|
||||
if ($this->requestContext->getUserId() !== null) {
|
||||
$context['user_id'] = $this->requestContext->getUserId();
|
||||
}
|
||||
|
||||
if ($this->requestContext->getSessionId() !== null) {
|
||||
$context['session_id'] = $this->requestContext->getSessionId();
|
||||
}
|
||||
|
||||
// Capture IP address if configured
|
||||
if ($config->trackIpAddress && $this->requestContext->getIpAddress() !== null) {
|
||||
$context['ip_address'] = $this->requestContext->getIpAddress();
|
||||
}
|
||||
|
||||
// Capture user agent if configured
|
||||
if ($config->trackUserAgent && $this->requestContext->getUserAgent() !== null) {
|
||||
$context['user_agent'] = $this->requestContext->getUserAgent();
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TrackStateHistory attribute configuration
|
||||
*/
|
||||
private function getHistoryConfig(string $componentClass): ?TrackStateHistory
|
||||
{
|
||||
if (!class_exists($componentClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($componentClass);
|
||||
$attributes = $reflection->getAttributes(TrackStateHistory::class);
|
||||
|
||||
if (empty($attributes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $attributes[0]->newInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version for component state
|
||||
*
|
||||
* Note: This assumes DatabaseStateManager provides version tracking.
|
||||
* Falls back to 1 if version cannot be determined.
|
||||
*/
|
||||
private function getStateVersion(ComponentId $componentId): int
|
||||
{
|
||||
$currentState = $this->stateManager->getState($componentId->toString());
|
||||
|
||||
// If state has version property, use it
|
||||
if ($currentState !== null && property_exists($currentState, 'version')) {
|
||||
return $currentState->version + 1;
|
||||
}
|
||||
|
||||
// Otherwise, try to get from history
|
||||
$history = $this->historyManager->getHistory($componentId->toString(), limit: 1);
|
||||
|
||||
if (!empty($history)) {
|
||||
return $history[0]->version + 1;
|
||||
}
|
||||
|
||||
// First version
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message if logger available
|
||||
*/
|
||||
private function log(string $level, string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($level) {
|
||||
'debug' => $this->logger->debug($message, $context),
|
||||
'info' => $this->logger->info($message, $context),
|
||||
'warning' => $this->logger->warning($message, $context),
|
||||
'error' => $this->logger->error($message, $context),
|
||||
default => $this->logger->info($message, $context),
|
||||
};
|
||||
}
|
||||
}
|
||||
198
src/Framework/LiveComponents/Polling/PollController.php
Normal file
198
src/Framework/LiveComponents/Polling/PollController.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\JsonResult;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Controller handling polling requests for both attribute-based and closure-based polls.
|
||||
*
|
||||
* Unified endpoint: /poll/{pollId}
|
||||
* - Handles PollableClosure polls (pollId starts with "closure.")
|
||||
* - Handles #[Poll] attribute polls (pollId starts with "attribute.")
|
||||
*/
|
||||
final readonly class PollController
|
||||
{
|
||||
public function __construct(
|
||||
private PollableClosureRegistry $closureRegistry,
|
||||
private PollExecutor $pollExecutor,
|
||||
private Logger $logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Unified polling endpoint for both poll types.
|
||||
*/
|
||||
#[Route(path: '/poll/{pollId}', method: Method::GET)]
|
||||
public function poll(string $pollId): JsonResult
|
||||
{
|
||||
$pollIdVO = PollId::fromString($pollId);
|
||||
|
||||
try {
|
||||
// Route to appropriate handler based on poll ID type
|
||||
if ($pollIdVO->isClosure()) {
|
||||
return $this->handleClosurePoll($pollIdVO);
|
||||
}
|
||||
|
||||
if ($pollIdVO->isAttribute()) {
|
||||
return $this->handleAttributePoll($pollIdVO);
|
||||
}
|
||||
|
||||
// Unknown poll ID format
|
||||
$this->logger->warning('Unknown poll ID format', [
|
||||
'poll_id' => $pollId
|
||||
]);
|
||||
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Unknown poll type'],
|
||||
status: Status::NOT_FOUND
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Poll execution failed', [
|
||||
'poll_id' => $pollId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Poll execution failed'],
|
||||
status: Status::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle closure-based poll execution.
|
||||
*/
|
||||
private function handleClosurePoll(PollId $pollId): JsonResult
|
||||
{
|
||||
$registration = $this->closureRegistry->get($pollId);
|
||||
|
||||
if ($registration === null) {
|
||||
$this->logger->warning('Closure poll not found', [
|
||||
'poll_id' => (string) $pollId
|
||||
]);
|
||||
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Poll not found'],
|
||||
status: Status::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
if (!$registration->shouldPoll()) {
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Poll is disabled'],
|
||||
status: Status::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->closureRegistry->execute($pollId);
|
||||
|
||||
return new JsonResult(
|
||||
data: $result,
|
||||
status: Status::OK
|
||||
);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Closure poll execution failed', [
|
||||
'poll_id' => (string) $pollId,
|
||||
'template_key' => $registration->templateKey,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
if ($registration->closure->stopOnError) {
|
||||
// Unregister poll if stopOnError is enabled
|
||||
$this->closureRegistry->unregister($pollId);
|
||||
}
|
||||
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Execution failed'],
|
||||
status: Status::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle attribute-based poll execution.
|
||||
*/
|
||||
private function handleAttributePoll(PollId $pollId): JsonResult
|
||||
{
|
||||
// Parse poll ID format: "attribute.ClassName::methodName"
|
||||
$pollIdString = (string) $pollId;
|
||||
$parts = explode('attribute.', $pollIdString, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Invalid attribute poll ID format'],
|
||||
status: Status::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$classMethod = $parts[1];
|
||||
$classMethodParts = explode('::', $classMethod, 2);
|
||||
|
||||
if (count($classMethodParts) !== 2) {
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Invalid class::method format'],
|
||||
status: Status::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
[$className, $methodName] = $classMethodParts;
|
||||
|
||||
// Check if poll exists
|
||||
if (!$this->pollExecutor->isPollable($className, $methodName)) {
|
||||
$this->logger->warning('Attribute poll not found', [
|
||||
'poll_id' => $pollIdString,
|
||||
'class' => $className,
|
||||
'method' => $methodName
|
||||
]);
|
||||
|
||||
return new JsonResult(
|
||||
data: ['error' => 'Poll not found'],
|
||||
status: Status::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
// Execute poll
|
||||
$result = $this->pollExecutor->executePoll($className, $methodName);
|
||||
|
||||
if ($result->isFailed()) {
|
||||
$this->logger->error('Attribute poll execution failed', [
|
||||
'poll_id' => $pollIdString,
|
||||
'class' => $className,
|
||||
'method' => $methodName,
|
||||
'error' => $result->error?->getMessage(),
|
||||
'reason' => $result->reason
|
||||
]);
|
||||
|
||||
return new JsonResult(
|
||||
data: ['error' => $result->reason ?? 'Execution failed'],
|
||||
status: Status::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResult(
|
||||
data: $result->data,
|
||||
status: Status::OK
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get polling metadata for debugging.
|
||||
*/
|
||||
#[Route(path: '/poll/metadata', method: Method::GET)]
|
||||
public function metadata(): JsonResult
|
||||
{
|
||||
return new JsonResult([
|
||||
'closure_polls' => $this->closureRegistry->getPollingMetadata(),
|
||||
'attribute_polls' => $this->pollExecutor->getExecutionStats()
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
src/Framework/LiveComponents/Polling/PollExecutedEvent.php
Normal file
29
src/Framework/LiveComponents/Polling/PollExecutedEvent.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\LiveComponents\Attributes\Poll;
|
||||
|
||||
/**
|
||||
* Event dispatched after a poll has been executed successfully.
|
||||
*/
|
||||
final readonly class PollExecutedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ClassName $className,
|
||||
public MethodName $methodName,
|
||||
public Poll $poll,
|
||||
public mixed $result,
|
||||
public Duration $executionTime
|
||||
) {}
|
||||
|
||||
public function getPollId(): string
|
||||
{
|
||||
return $this->className->getFullyQualified() . '::' . $this->methodName->toString();
|
||||
}
|
||||
}
|
||||
208
src/Framework/LiveComponents/Polling/PollExecutor.php
Normal file
208
src/Framework/LiveComponents/Polling/PollExecutor.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Core\Events\EventDispatcher;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\LiveComponents\Attributes\Poll;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Executes pollable methods at their configured intervals.
|
||||
*
|
||||
* Manages poll execution lifecycle, error handling, and event dispatching.
|
||||
*/
|
||||
final readonly class PollExecutor
|
||||
{
|
||||
public function __construct(
|
||||
private PollService $pollService,
|
||||
private EventDispatcher $eventDispatcher,
|
||||
private Logger $logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute a specific poll by class and method name.
|
||||
*
|
||||
* @return PollResult
|
||||
*/
|
||||
public function executePoll(string $className, string $methodName): PollResult
|
||||
{
|
||||
$classNameVO = ClassName::create($className);
|
||||
$methodNameVO = MethodName::create($methodName);
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Find Poll attribute
|
||||
$poll = $this->pollService->findPoll($className, $methodName);
|
||||
|
||||
if ($poll === null) {
|
||||
return PollResult::notFound($classNameVO, $methodNameVO);
|
||||
}
|
||||
|
||||
if (!$poll->enabled) {
|
||||
return PollResult::disabled($classNameVO, $methodNameVO);
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the poll method
|
||||
$result = $this->pollService->executePoll($className, $methodName);
|
||||
|
||||
$executionTime = Duration::fromSeconds(microtime(true) - $startTime);
|
||||
|
||||
// Dispatch event if configured
|
||||
if ($poll->event !== null) {
|
||||
$this->eventDispatcher->dispatch(
|
||||
new PollExecutedEvent(
|
||||
className: $classNameVO,
|
||||
methodName: $methodNameVO,
|
||||
poll: $poll,
|
||||
result: $result,
|
||||
executionTime: $executionTime
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->debug('Poll executed successfully', [
|
||||
'poll' => "{$className}::{$methodName}",
|
||||
'execution_time_ms' => $executionTime->toMilliseconds()
|
||||
]);
|
||||
|
||||
return PollResult::success($classNameVO, $methodNameVO, $result, $executionTime);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$executionTime = Duration::fromSeconds(microtime(true) - $startTime);
|
||||
|
||||
$this->logger->error('Poll execution failed', [
|
||||
'poll' => "{$className}::{$methodName}",
|
||||
'error' => $e->getMessage(),
|
||||
'execution_time_ms' => $executionTime->toMilliseconds()
|
||||
]);
|
||||
|
||||
return PollResult::failed($classNameVO, $methodNameVO, $e, $executionTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all enabled polls.
|
||||
*
|
||||
* @return array<PollResult>
|
||||
*/
|
||||
public function executeAllEnabledPolls(): array
|
||||
{
|
||||
$enabledPolls = $this->pollService->getEnabledPolls();
|
||||
$results = [];
|
||||
|
||||
foreach ($enabledPolls as $item) {
|
||||
$discovered = $item['discovered'];
|
||||
$className = $discovered->className->getFullyQualified();
|
||||
$methodName = $discovered->methodName?->toString() ?? 'unknown';
|
||||
|
||||
$results[] = $this->executePoll($className, $methodName);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute polls for a specific component class.
|
||||
*
|
||||
* @return array<PollResult>
|
||||
*/
|
||||
public function executePollsForClass(string $className): array
|
||||
{
|
||||
$polls = $this->pollService->getPollsForClass($className);
|
||||
$results = [];
|
||||
|
||||
foreach ($polls as $item) {
|
||||
$poll = $item['poll'];
|
||||
|
||||
if (!$poll->enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = $this->executePoll($className, $item['method']);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single poll and handle errors according to stopOnError setting.
|
||||
*
|
||||
* @return PollResult
|
||||
*/
|
||||
public function executePollSafe(string $className, string $methodName): PollResult
|
||||
{
|
||||
$classNameVO = ClassName::create($className);
|
||||
$methodNameVO = MethodName::create($methodName);
|
||||
$poll = $this->pollService->findPoll($className, $methodName);
|
||||
|
||||
if ($poll === null) {
|
||||
return PollResult::notFound($classNameVO, $methodNameVO);
|
||||
}
|
||||
|
||||
$result = $this->executePoll($className, $methodName);
|
||||
|
||||
// Handle stopOnError flag
|
||||
if (!$result->success && $poll->stopOnError) {
|
||||
$this->logger->warning('Poll failed with stopOnError enabled', [
|
||||
'poll' => "{$className}::{$methodName}",
|
||||
'error' => $result->error?->getMessage()
|
||||
]);
|
||||
|
||||
// Dispatch event to notify poll has been stopped
|
||||
$this->eventDispatcher->dispatch(
|
||||
new PollStoppedEvent(
|
||||
className: $classNameVO,
|
||||
methodName: $methodNameVO,
|
||||
poll: $poll,
|
||||
reason: $result->error?->getMessage() ?? 'Unknown error'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution statistics for all polls.
|
||||
*/
|
||||
public function getExecutionStats(): array
|
||||
{
|
||||
$allPolls = $this->pollService->getAllPolls();
|
||||
|
||||
return [
|
||||
'total_polls' => count($allPolls),
|
||||
'enabled_polls' => count($this->pollService->getEnabledPolls()),
|
||||
'polls_by_interval' => $this->groupPollsByInterval($allPolls)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group polls by their interval for statistics.
|
||||
*
|
||||
* @param array<array{poll: Poll, discovered: DiscoveredAttribute}> $polls
|
||||
*/
|
||||
private function groupPollsByInterval(array $polls): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($polls as $item) {
|
||||
$interval = $item['poll']->interval;
|
||||
|
||||
if (!isset($grouped[$interval])) {
|
||||
$grouped[$interval] = 0;
|
||||
}
|
||||
|
||||
$grouped[$interval]++;
|
||||
}
|
||||
|
||||
ksort($grouped);
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
76
src/Framework/LiveComponents/Polling/PollId.php
Normal file
76
src/Framework/LiveComponents/Polling/PollId.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
/**
|
||||
* Value Object representing a unique poll identifier.
|
||||
*/
|
||||
final readonly class PollId
|
||||
{
|
||||
private function __construct(
|
||||
public string $value
|
||||
) {
|
||||
if (empty($value)) {
|
||||
throw new \InvalidArgumentException('Poll ID cannot be empty');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from string value.
|
||||
*/
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate for closure poll.
|
||||
*/
|
||||
public static function forClosure(string $key, ?string $componentId = null): self
|
||||
{
|
||||
if ($componentId !== null) {
|
||||
return new self("closure.{$componentId}.{$key}");
|
||||
}
|
||||
|
||||
return new self("closure.{$key}." . bin2hex(random_bytes(4)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate for attribute poll.
|
||||
*/
|
||||
public static function forAttribute(string $className, string $methodName): self
|
||||
{
|
||||
return new self("attribute.{$className}::{$methodName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a closure poll.
|
||||
*/
|
||||
public function isClosure(): bool
|
||||
{
|
||||
return str_starts_with($this->value, 'closure.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an attribute poll.
|
||||
*/
|
||||
public function isAttribute(): bool
|
||||
{
|
||||
return str_starts_with($this->value, 'attribute.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another PollId.
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
128
src/Framework/LiveComponents/Polling/PollResult.php
Normal file
128
src/Framework/LiveComponents/Polling/PollResult.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
/**
|
||||
* Value Object representing the result of a poll execution.
|
||||
*/
|
||||
final readonly class PollResult
|
||||
{
|
||||
private function __construct(
|
||||
public ClassName $className,
|
||||
public MethodName $methodName,
|
||||
public bool $success,
|
||||
public mixed $data = null,
|
||||
public ?\Throwable $error = null,
|
||||
public ?Duration $executionTime = null,
|
||||
public ?string $reason = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create successful result.
|
||||
*/
|
||||
public static function success(
|
||||
ClassName $className,
|
||||
MethodName $methodName,
|
||||
mixed $data,
|
||||
Duration $executionTime
|
||||
): self {
|
||||
return new self(
|
||||
className: $className,
|
||||
methodName: $methodName,
|
||||
success: true,
|
||||
data: $data,
|
||||
executionTime: $executionTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failed result.
|
||||
*/
|
||||
public static function failed(
|
||||
ClassName $className,
|
||||
MethodName $methodName,
|
||||
\Throwable $error,
|
||||
Duration $executionTime
|
||||
): self {
|
||||
return new self(
|
||||
className: $className,
|
||||
methodName: $methodName,
|
||||
success: false,
|
||||
error: $error,
|
||||
executionTime: $executionTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create result for poll not found.
|
||||
*/
|
||||
public static function notFound(ClassName $className, MethodName $methodName): self
|
||||
{
|
||||
return new self(
|
||||
className: $className,
|
||||
methodName: $methodName,
|
||||
success: false,
|
||||
reason: 'Poll not found'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create result for disabled poll.
|
||||
*/
|
||||
public static function disabled(ClassName $className, MethodName $methodName): self
|
||||
{
|
||||
return new self(
|
||||
className: $className,
|
||||
methodName: $methodName,
|
||||
success: false,
|
||||
reason: 'Poll is disabled'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get poll identifier.
|
||||
*/
|
||||
public function getPollId(): string
|
||||
{
|
||||
return $this->className->getFullyQualified() . '::' . $this->methodName->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if execution was successful.
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if execution failed.
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return !$this->success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'poll_id' => $this->getPollId(),
|
||||
'class' => $this->className->getFullyQualified(),
|
||||
'method' => $this->methodName->toString(),
|
||||
'success' => $this->success,
|
||||
'data' => $this->data,
|
||||
'error' => $this->error?->getMessage(),
|
||||
'execution_time_ms' => $this->executionTime?->toMilliseconds(),
|
||||
'reason' => $this->reason
|
||||
];
|
||||
}
|
||||
}
|
||||
149
src/Framework/LiveComponents/Polling/PollService.php
Normal file
149
src/Framework/LiveComponents/Polling/PollService.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\LiveComponents\Attributes\Poll;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
||||
|
||||
/**
|
||||
* Service to discover and execute methods marked with #[Poll] attribute.
|
||||
*
|
||||
* Leverages the framework's DiscoveryRegistry to find all pollable methods.
|
||||
*/
|
||||
final readonly class PollService
|
||||
{
|
||||
public function __construct(
|
||||
private DiscoveryRegistry $discoveryRegistry,
|
||||
private Container $container
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all discovered Poll attributes from registry.
|
||||
*
|
||||
* @return array<array{poll: Poll, discovered: DiscoveredAttribute}>
|
||||
*/
|
||||
public function getAllPolls(): array
|
||||
{
|
||||
$pollAttributeClass = Poll::class;
|
||||
$discoveredPolls = $this->discoveryRegistry->attributes->get($pollAttributeClass);
|
||||
|
||||
$results = [];
|
||||
foreach ($discoveredPolls as $discovered) {
|
||||
// Create Poll attribute instance from stored arguments
|
||||
$poll = $discovered->createAttributeInstance();
|
||||
|
||||
if ($poll instanceof Poll) {
|
||||
$results[] = [
|
||||
'poll' => $poll,
|
||||
'discovered' => $discovered
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Poll attributes for a specific class.
|
||||
*
|
||||
* @return array<array{poll: Poll, method: string}>
|
||||
*/
|
||||
public function getPollsForClass(string $className): array
|
||||
{
|
||||
$allPolls = $this->getAllPolls();
|
||||
$classPolls = [];
|
||||
|
||||
foreach ($allPolls as $item) {
|
||||
$discovered = $item['discovered'];
|
||||
|
||||
if ($discovered->className->getFullyQualified() === $className) {
|
||||
$classPolls[] = [
|
||||
'poll' => $item['poll'],
|
||||
'method' => $discovered->methodName ?? 'unknown'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $classPolls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a specific poll method.
|
||||
*
|
||||
* @param string $className Fully qualified class name
|
||||
* @param string $methodName Method name to poll
|
||||
* @return mixed Result from the polled method
|
||||
* @throws \Exception if poll execution fails
|
||||
*/
|
||||
public function executePoll(string $className, string $methodName): mixed
|
||||
{
|
||||
// Resolve class instance from container
|
||||
$instance = $this->container->get($className);
|
||||
|
||||
// Validate method exists and is callable
|
||||
if (!method_exists($instance, $methodName)) {
|
||||
throw new \BadMethodCallException(
|
||||
"Method {$methodName} does not exist on {$className}"
|
||||
);
|
||||
}
|
||||
|
||||
// Execute poll method
|
||||
try {
|
||||
return $instance->{$methodName}();
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException(
|
||||
"Failed to execute poll {$className}::{$methodName}: {$e->getMessage()}",
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Poll attribute for specific class and method.
|
||||
*/
|
||||
public function findPoll(string $className, string $methodName): ?Poll
|
||||
{
|
||||
$polls = $this->getPollsForClass($className);
|
||||
|
||||
foreach ($polls as $item) {
|
||||
if ($item['method'] === $methodName) {
|
||||
return $item['poll'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method is pollable.
|
||||
*/
|
||||
public function isPollable(string $className, string $methodName): bool
|
||||
{
|
||||
return $this->findPoll($className, $methodName) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of registered polls.
|
||||
*/
|
||||
public function getPollCount(): int
|
||||
{
|
||||
return count($this->getAllPolls());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled polls.
|
||||
*
|
||||
* @return array<array{poll: Poll, discovered: DiscoveredAttribute}>
|
||||
*/
|
||||
public function getEnabledPolls(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->getAllPolls(),
|
||||
fn($item) => $item['poll']->enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/Framework/LiveComponents/Polling/PollStoppedEvent.php
Normal file
27
src/Framework/LiveComponents/Polling/PollStoppedEvent.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\LiveComponents\Attributes\Poll;
|
||||
|
||||
/**
|
||||
* Event dispatched when a poll is stopped due to error (with stopOnError enabled).
|
||||
*/
|
||||
final readonly class PollStoppedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ClassName $className,
|
||||
public MethodName $methodName,
|
||||
public Poll $poll,
|
||||
public string $reason
|
||||
) {}
|
||||
|
||||
public function getPollId(): string
|
||||
{
|
||||
return $this->className->getFullyQualified() . '::' . $this->methodName->toString();
|
||||
}
|
||||
}
|
||||
130
src/Framework/LiveComponents/Polling/PollableClosure.php
Normal file
130
src/Framework/LiveComponents/Polling/PollableClosure.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* Value Object wrapping a closure with polling configuration.
|
||||
*
|
||||
* Allows inline poll definitions in templates without requiring separate methods.
|
||||
*
|
||||
* @example
|
||||
* ```php
|
||||
* return new ViewResult('dashboard', [
|
||||
* 'notifications' => new PollableClosure(
|
||||
* closure: fn() => $this->notificationService->getUnread(),
|
||||
* interval: 1000
|
||||
* )
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
final readonly class PollableClosure
|
||||
{
|
||||
/**
|
||||
* @param Closure $closure The closure to execute
|
||||
* @param int $interval Polling interval in milliseconds (minimum: 100ms)
|
||||
* @param bool $enabled Whether polling is enabled
|
||||
* @param string|null $event Optional event name to dispatch on poll
|
||||
* @param bool $stopOnError Whether to stop polling if closure throws
|
||||
*/
|
||||
public function __construct(
|
||||
public Closure $closure,
|
||||
public int $interval = 1000,
|
||||
public bool $enabled = true,
|
||||
public ?string $event = null,
|
||||
public bool $stopOnError = false
|
||||
) {
|
||||
if ($interval < 100) {
|
||||
throw new \InvalidArgumentException('Poll interval must be at least 100ms');
|
||||
}
|
||||
|
||||
if ($interval > 300000) {
|
||||
throw new \InvalidArgumentException('Poll interval cannot exceed 5 minutes (300000ms)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the closure.
|
||||
*/
|
||||
public function __invoke(): mixed
|
||||
{
|
||||
return ($this->closure)();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the closure (explicit method).
|
||||
*/
|
||||
public function execute(): mixed
|
||||
{
|
||||
return ($this->closure)();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interval as Duration value object.
|
||||
*/
|
||||
public function getInterval(): Duration
|
||||
{
|
||||
return Duration::fromMilliseconds($this->interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with different enabled state.
|
||||
*/
|
||||
public function withEnabled(bool $enabled): self
|
||||
{
|
||||
return new self(
|
||||
closure: $this->closure,
|
||||
interval: $this->interval,
|
||||
enabled: $enabled,
|
||||
event: $this->event,
|
||||
stopOnError: $this->stopOnError
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with different interval.
|
||||
*/
|
||||
public function withInterval(int $interval): self
|
||||
{
|
||||
return new self(
|
||||
closure: $this->closure,
|
||||
interval: $interval,
|
||||
enabled: $this->enabled,
|
||||
event: $this->event,
|
||||
stopOnError: $this->stopOnError
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create simple pollable closure with just interval.
|
||||
*/
|
||||
public static function simple(Closure $closure, int $interval = 1000): self
|
||||
{
|
||||
return new self(closure: $closure, interval: $interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if closure should be polled.
|
||||
*/
|
||||
public function shouldPoll(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get polling metadata for JavaScript generation.
|
||||
*/
|
||||
public function getMetadata(): array
|
||||
{
|
||||
return [
|
||||
'interval' => $this->interval,
|
||||
'enabled' => $this->enabled,
|
||||
'event' => $this->event,
|
||||
'stopOnError' => $this->stopOnError
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
|
||||
/**
|
||||
* Value Object representing a registered pollable closure.
|
||||
*/
|
||||
final readonly class PollableClosureRegistration
|
||||
{
|
||||
public function __construct(
|
||||
public PollId $pollId,
|
||||
public PollableClosure $closure,
|
||||
public string $templateKey,
|
||||
public ?ComponentId $componentId = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if this registration is for a specific component.
|
||||
*/
|
||||
public function isForComponent(ComponentId $componentId): bool
|
||||
{
|
||||
return $this->componentId !== null && $this->componentId->equals($componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if polling should be active.
|
||||
*/
|
||||
public function shouldPoll(): bool
|
||||
{
|
||||
return $this->closure->shouldPoll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the closure.
|
||||
*/
|
||||
public function execute(): mixed
|
||||
{
|
||||
return $this->closure->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get polling endpoint URL.
|
||||
*/
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
return "/poll/{$this->pollId}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for JavaScript generation.
|
||||
*/
|
||||
public function getMetadata(): array
|
||||
{
|
||||
return [
|
||||
'poll_id' => (string) $this->pollId,
|
||||
'template_key' => $this->templateKey,
|
||||
'interval' => $this->closure->interval,
|
||||
'enabled' => $this->closure->enabled,
|
||||
'event' => $this->closure->event,
|
||||
'stop_on_error' => $this->closure->stopOnError,
|
||||
'endpoint' => $this->getEndpoint(),
|
||||
'component_id' => $this->componentId?->toString()
|
||||
];
|
||||
}
|
||||
}
|
||||
177
src/Framework/LiveComponents/Polling/PollableClosureRegistry.php
Normal file
177
src/Framework/LiveComponents/Polling/PollableClosureRegistry.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
|
||||
/**
|
||||
* Registry for tracking PollableClosure instances in templates.
|
||||
*
|
||||
* Maintains a mapping of PollIds to their pollable closure registrations
|
||||
* for automatic JavaScript generation and endpoint registration.
|
||||
*/
|
||||
final class PollableClosureRegistry
|
||||
{
|
||||
/** @var array<string, PollableClosureRegistration> */
|
||||
private array $registrations = [];
|
||||
|
||||
/**
|
||||
* Register a pollable closure.
|
||||
*
|
||||
* @param string $templateKey Template variable key (e.g., 'notifications')
|
||||
* @param PollableClosure $closure The pollable closure
|
||||
* @param ComponentId|null $componentId Optional component ID for scoping
|
||||
* @return PollableClosureRegistration
|
||||
*/
|
||||
public function register(
|
||||
string $templateKey,
|
||||
PollableClosure $closure,
|
||||
?ComponentId $componentId = null
|
||||
): PollableClosureRegistration {
|
||||
$pollId = $this->generatePollId($templateKey, $componentId);
|
||||
|
||||
$registration = new PollableClosureRegistration(
|
||||
pollId: $pollId,
|
||||
closure: $closure,
|
||||
templateKey: $templateKey,
|
||||
componentId: $componentId
|
||||
);
|
||||
|
||||
$this->registrations[$pollId->value] = $registration;
|
||||
|
||||
return $registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registration by poll ID.
|
||||
*/
|
||||
public function get(PollId $pollId): ?PollableClosureRegistration
|
||||
{
|
||||
return $this->registrations[$pollId->value] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a poll ID exists.
|
||||
*/
|
||||
public function has(PollId $pollId): bool
|
||||
{
|
||||
return isset($this->registrations[$pollId->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered closures.
|
||||
*
|
||||
* @return array<PollableClosureRegistration>
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
return array_values($this->registrations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled registrations.
|
||||
*
|
||||
* @return array<PollableClosureRegistration>
|
||||
*/
|
||||
public function getEnabled(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->registrations,
|
||||
fn(PollableClosureRegistration $reg) => $reg->shouldPoll()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registrations for specific component.
|
||||
*
|
||||
* @return array<PollableClosureRegistration>
|
||||
*/
|
||||
public function getForComponent(ComponentId $componentId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->registrations,
|
||||
fn(PollableClosureRegistration $reg) => $reg->isForComponent($componentId)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a closure by poll ID.
|
||||
*
|
||||
* @throws \RuntimeException if poll ID not found or closure throws
|
||||
*/
|
||||
public function execute(PollId $pollId): mixed
|
||||
{
|
||||
$registration = $this->get($pollId);
|
||||
|
||||
if ($registration === null) {
|
||||
throw new \RuntimeException("Poll ID '{$pollId}' not found");
|
||||
}
|
||||
|
||||
try {
|
||||
return $registration->execute();
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException(
|
||||
"Failed to execute pollable closure '{$pollId}': {$e->getMessage()}",
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a closure.
|
||||
*/
|
||||
public function unregister(PollId $pollId): void
|
||||
{
|
||||
unset($this->registrations[$pollId->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered closures.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->registrations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of registered closures.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->registrations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique poll ID.
|
||||
*/
|
||||
private function generatePollId(string $templateKey, ?ComponentId $componentId): PollId
|
||||
{
|
||||
if ($componentId !== null) {
|
||||
return PollId::forClosure($templateKey, $componentId->toString());
|
||||
}
|
||||
|
||||
return PollId::forClosure($templateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get polling metadata for JavaScript generation.
|
||||
*
|
||||
* @return array<string, array{template_key: string, interval: int, event: ?string, endpoint: string, component_id: ?string}>
|
||||
*/
|
||||
public function getPollingMetadata(): array
|
||||
{
|
||||
$metadata = [];
|
||||
|
||||
foreach ($this->registrations as $pollIdString => $registration) {
|
||||
if (!$registration->shouldPoll()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadata[$pollIdString] = $registration->getMetadata();
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\LiveComponents\Polling;
|
||||
|
||||
use App\Framework\Template\Processing\AstTransformer;
|
||||
use App\Framework\View\Dom\DocumentNode;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
|
||||
/**
|
||||
* AST Transformer that detects and processes PollableClosure instances.
|
||||
*
|
||||
* Automatically finds PollableClosure instances in template data, registers them
|
||||
* for polling, executes them for initial render, and injects polling JavaScript.
|
||||
*/
|
||||
final readonly class PollableClosureTransformer implements AstTransformer
|
||||
{
|
||||
public function __construct(
|
||||
private PollableClosureRegistry $registry
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Transform AST to inject polling JavaScript for PollableClosure instances.
|
||||
*/
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
{
|
||||
// 1. Extract ComponentId if available from context
|
||||
$componentId = $this->extractComponentId($context);
|
||||
|
||||
// 2. Process template data and find PollableClosure instances
|
||||
$registrations = $this->processTemplateData($context->data, $componentId);
|
||||
|
||||
// 3. If no pollable closures found, return document as-is
|
||||
if (empty($registrations)) {
|
||||
return $document;
|
||||
}
|
||||
|
||||
// 4. Generate polling script element
|
||||
$scriptElement = $this->createPollingScriptElement($registrations);
|
||||
|
||||
// 5. Inject script before closing body tag
|
||||
$this->injectScriptBeforeBodyClose($document, $scriptElement);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process template data to find and register PollableClosure instances.
|
||||
*
|
||||
* @return array<PollableClosureRegistration>
|
||||
*/
|
||||
private function processTemplateData(array $data, ?ComponentId $componentId): array
|
||||
{
|
||||
$registrations = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if ($value instanceof PollableClosure) {
|
||||
// Register for polling
|
||||
$registration = $this->registry->register($key, $value, $componentId);
|
||||
$registrations[] = $registration;
|
||||
|
||||
// Execute closure for initial render and update context data
|
||||
$data[$key] = $value->execute();
|
||||
} elseif (is_array($value)) {
|
||||
// Recursively process nested arrays
|
||||
$nestedRegistrations = $this->processTemplateData($value, $componentId);
|
||||
$registrations = array_merge($registrations, $nestedRegistrations);
|
||||
}
|
||||
}
|
||||
|
||||
return $registrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create script element with polling JavaScript.
|
||||
*
|
||||
* @param array<PollableClosureRegistration> $registrations
|
||||
*/
|
||||
private function createPollingScriptElement(array $registrations): ElementNode
|
||||
{
|
||||
$scriptContent = $this->generatePollingScript($registrations);
|
||||
|
||||
$scriptElement = new ElementNode('script');
|
||||
$scriptElement->children[] = new TextNode($scriptContent);
|
||||
|
||||
return $scriptElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JavaScript for polling registered closures.
|
||||
*
|
||||
* @param array<PollableClosureRegistration> $registrations
|
||||
*/
|
||||
private function generatePollingScript(array $registrations): string
|
||||
{
|
||||
$scripts = [];
|
||||
|
||||
foreach ($registrations as $registration) {
|
||||
if (!$registration->shouldPoll()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pollId = (string) $registration->pollId;
|
||||
$endpoint = $registration->getEndpoint();
|
||||
$interval = $registration->closure->interval;
|
||||
$templateKey = $registration->templateKey;
|
||||
$stopOnError = $registration->closure->stopOnError ? 'true' : 'false';
|
||||
|
||||
// Generate polling script for this closure
|
||||
$scripts[] = <<<JS
|
||||
|
||||
// Poll: {$templateKey}
|
||||
(function() {
|
||||
let pollInterval_{$pollId} = null;
|
||||
let errorCount_{$pollId} = 0;
|
||||
const maxErrors_{$pollId} = 3;
|
||||
const stopOnError_{$pollId} = {$stopOnError};
|
||||
|
||||
function poll_{$pollId}() {
|
||||
fetch('{$endpoint}', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Poll request failed: ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Reset error count on success
|
||||
errorCount_{$pollId} = 0;
|
||||
|
||||
// Update DOM element(s) with template key
|
||||
const elements = document.querySelectorAll('[data-poll="{$templateKey}"]');
|
||||
elements.forEach(el => {
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
// If data is object, update as JSON or use data-poll-property
|
||||
const property = el.getAttribute('data-poll-property');
|
||||
if (property && data[property] !== undefined) {
|
||||
el.textContent = data[property];
|
||||
} else {
|
||||
el.textContent = JSON.stringify(data);
|
||||
}
|
||||
} else {
|
||||
// Otherwise update as text
|
||||
el.textContent = data;
|
||||
}
|
||||
});
|
||||
|
||||
// Dispatch custom event
|
||||
document.dispatchEvent(new CustomEvent('poll:updated', {
|
||||
detail: {
|
||||
pollId: '{$pollId}',
|
||||
templateKey: '{$templateKey}',
|
||||
data: data
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Poll error for {$templateKey}:', error);
|
||||
errorCount_{$pollId}++;
|
||||
|
||||
// Stop polling if max errors reached or stopOnError enabled
|
||||
if (stopOnError_{$pollId} || errorCount_{$pollId} >= maxErrors_{$pollId}) {
|
||||
clearInterval(pollInterval_{$pollId});
|
||||
console.warn('Polling stopped for {$templateKey} due to errors');
|
||||
|
||||
// Dispatch error event
|
||||
document.dispatchEvent(new CustomEvent('poll:stopped', {
|
||||
detail: {
|
||||
pollId: '{$pollId}',
|
||||
templateKey: '{$templateKey}',
|
||||
reason: 'error',
|
||||
error: error.message
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start polling
|
||||
pollInterval_{$pollId} = setInterval(poll_{$pollId}, {$interval});
|
||||
|
||||
// Initial poll
|
||||
poll_{$pollId}();
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearInterval(pollInterval_{$pollId});
|
||||
});
|
||||
})();
|
||||
JS;
|
||||
}
|
||||
|
||||
return implode("\n", $scripts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject script element before closing body tag in AST.
|
||||
*/
|
||||
private function injectScriptBeforeBodyClose(DocumentNode $document, ElementNode $script): void
|
||||
{
|
||||
// Find body element
|
||||
$bodyElement = $this->findBodyElement($document);
|
||||
|
||||
if ($bodyElement !== null) {
|
||||
// Add script as last child of body
|
||||
$bodyElement->children[] = $script;
|
||||
} else {
|
||||
// No body element found, add to document root
|
||||
$document->children[] = $script;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find body element in AST.
|
||||
*/
|
||||
private function findBodyElement(DocumentNode $document): ?ElementNode
|
||||
{
|
||||
foreach ($document->children as $child) {
|
||||
if ($child instanceof ElementNode && strtolower($child->tag) === 'body') {
|
||||
return $child;
|
||||
}
|
||||
|
||||
// Search recursively
|
||||
if ($child instanceof ElementNode) {
|
||||
$result = $this->findBodyElementRecursive($child);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively search for body element.
|
||||
*/
|
||||
private function findBodyElementRecursive(ElementNode $element): ?ElementNode
|
||||
{
|
||||
if (strtolower($element->tag) === 'body') {
|
||||
return $element;
|
||||
}
|
||||
|
||||
foreach ($element->children as $child) {
|
||||
if ($child instanceof ElementNode) {
|
||||
$result = $this->findBodyElementRecursive($child);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ComponentId from RenderContext if available.
|
||||
*/
|
||||
private function extractComponentId(RenderContext $context): ?ComponentId
|
||||
{
|
||||
// Check if context has component ID in metadata
|
||||
if (isset($context->data['_componentId']) && $context->data['_componentId'] instanceof ComponentId) {
|
||||
return $context->data['_componentId'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user