feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View 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
);
}
}

View File

@@ -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
) {}
}

View File

@@ -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
) {
}

View File

@@ -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,

View File

@@ -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),
};
}
}

View 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()
]);
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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
];
}
}

View 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
);
}
}

View 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();
}
}

View 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
];
}
}

View File

@@ -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()
];
}
}

View 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;
}
}

View File

@@ -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;
}
}