feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
131
src/Framework/StateManagement/Database/ComponentStateEntity.php
Normal file
131
src/Framework/StateManagement/Database/ComponentStateEntity.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\StateManagement\Database;
|
||||
|
||||
use App\Framework\Attributes\Entity;
|
||||
use App\Framework\Attributes\Id;
|
||||
use App\Framework\Attributes\Column;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Component State Database Entity
|
||||
*
|
||||
* Represents current state of a LiveComponent stored in database.
|
||||
* EntityManager-compatible entity with attribute-based mapping.
|
||||
*/
|
||||
#[Entity(table: 'component_state')]
|
||||
final readonly class ComponentStateEntity
|
||||
{
|
||||
public function __construct(
|
||||
#[Id]
|
||||
#[Column(name: 'component_id')]
|
||||
public string $componentId,
|
||||
|
||||
#[Column(name: 'state_data', type: 'text')]
|
||||
public string $stateData, // Encrypted state
|
||||
|
||||
#[Column(name: 'state_class')]
|
||||
public string $stateClass,
|
||||
|
||||
#[Column(name: 'component_name')]
|
||||
public string $componentName,
|
||||
|
||||
#[Column(name: 'user_id')]
|
||||
public ?string $userId,
|
||||
|
||||
#[Column(name: 'session_id')]
|
||||
public ?string $sessionId,
|
||||
|
||||
#[Column(name: 'version', type: 'integer')]
|
||||
public int $version,
|
||||
|
||||
#[Column(name: 'checksum')]
|
||||
public string $checksum, // SHA256 of state_data
|
||||
|
||||
#[Column(name: 'created_at', type: 'datetime')]
|
||||
public Timestamp $createdAt,
|
||||
|
||||
#[Column(name: 'updated_at', type: 'datetime')]
|
||||
public Timestamp $updatedAt,
|
||||
|
||||
#[Column(name: 'expires_at', type: 'datetime')]
|
||||
public ?Timestamp $expiresAt,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if state is expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if ($this->expiresAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Timestamp::now()->isAfter($this->expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from database row
|
||||
*/
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
componentId: $row['component_id'],
|
||||
stateData: $row['state_data'],
|
||||
stateClass: $row['state_class'],
|
||||
componentName: $row['component_name'],
|
||||
userId: $row['user_id'] ?? null,
|
||||
sessionId: $row['session_id'] ?? null,
|
||||
version: (int) $row['version'],
|
||||
checksum: $row['checksum'],
|
||||
createdAt: Timestamp::fromString($row['created_at']),
|
||||
updatedAt: Timestamp::fromString($row['updated_at']),
|
||||
expiresAt: isset($row['expires_at']) ? Timestamp::fromString($row['expires_at']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to database row
|
||||
*/
|
||||
public function toRow(): array
|
||||
{
|
||||
return [
|
||||
'component_id' => $this->componentId,
|
||||
'state_data' => $this->stateData,
|
||||
'state_class' => $this->stateClass,
|
||||
'component_name' => $this->componentName,
|
||||
'user_id' => $this->userId,
|
||||
'session_id' => $this->sessionId,
|
||||
'version' => $this->version,
|
||||
'checksum' => $this->checksum,
|
||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updatedAt->format('Y-m-d H:i:s'),
|
||||
'expires_at' => $this->expiresAt?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new version with updated state
|
||||
*/
|
||||
public function withUpdatedState(
|
||||
string $stateData,
|
||||
string $checksum,
|
||||
?Timestamp $expiresAt = null
|
||||
): self {
|
||||
return new self(
|
||||
componentId: $this->componentId,
|
||||
stateData: $stateData,
|
||||
stateClass: $this->stateClass,
|
||||
componentName: $this->componentName,
|
||||
userId: $this->userId,
|
||||
sessionId: $this->sessionId,
|
||||
version: $this->version + 1,
|
||||
checksum: $checksum,
|
||||
createdAt: $this->createdAt,
|
||||
updatedAt: Timestamp::now(),
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\StateManagement\Database;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\StateHistoryManager;
|
||||
use App\Framework\LiveComponents\Attributes\TrackStateHistory;
|
||||
|
||||
/**
|
||||
* Database-backed State History Manager
|
||||
*
|
||||
* Stores state change history in component_state_history table via EntityManager.
|
||||
* Integrates with #[TrackStateHistory] attribute for opt-in tracking.
|
||||
*/
|
||||
final class DatabaseStateHistoryManager implements StateHistoryManager
|
||||
{
|
||||
private int $entriesAdded = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManager $entityManager,
|
||||
private readonly ?Logger $logger = null,
|
||||
) {}
|
||||
|
||||
public function addHistoryEntry(
|
||||
string $componentId,
|
||||
string $stateData,
|
||||
string $stateClass,
|
||||
int $version,
|
||||
string $changeType,
|
||||
array $context = [],
|
||||
?array $changedProperties = null,
|
||||
?string $previousChecksum = null,
|
||||
string $currentChecksum = ''
|
||||
): void {
|
||||
// Create history entry entity
|
||||
$entry = new StateHistoryEntry(
|
||||
id: 0, // Auto-increment will handle this
|
||||
componentId: $componentId,
|
||||
stateData: $stateData,
|
||||
stateClass: $stateClass,
|
||||
version: $version,
|
||||
changeType: StateChangeType::from($changeType),
|
||||
changedProperties: $changedProperties,
|
||||
userId: $context['user_id'] ?? null,
|
||||
sessionId: $context['session_id'] ?? null,
|
||||
ipAddress: $context['ip_address'] ?? null,
|
||||
userAgent: $context['user_agent'] ?? null,
|
||||
previousChecksum: $previousChecksum,
|
||||
currentChecksum: $currentChecksum,
|
||||
createdAt: Timestamp::now(),
|
||||
);
|
||||
|
||||
// Persist via EntityManager
|
||||
$this->entityManager->unitOfWork->persist($entry);
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
|
||||
$this->entriesAdded++;
|
||||
$this->log('debug', "History entry added for component: {$componentId}, version: {$version}");
|
||||
}
|
||||
|
||||
public function getHistory(string $componentId, int $limit = 100, int $offset = 0): array
|
||||
{
|
||||
// EntityManager findBy with criteria, orderBy, limit, offset
|
||||
$entries = $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
['component_id' => $componentId],
|
||||
['created_at' => 'DESC'],
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
|
||||
$this->log('debug', "Retrieved {count} history entries for component: {$componentId}", [
|
||||
'count' => count($entries),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset
|
||||
]);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function getHistoryByVersion(string $componentId, int $version): ?StateHistoryEntry
|
||||
{
|
||||
$entries = $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
[
|
||||
'component_id' => $componentId,
|
||||
'version' => $version
|
||||
],
|
||||
limit: 1
|
||||
);
|
||||
|
||||
return !empty($entries) ? $entries[0] : null;
|
||||
}
|
||||
|
||||
public function getHistorySince(string $componentId, Timestamp $since, int $limit = 100): array
|
||||
{
|
||||
// Note: EntityManager findBy might need extension for timestamp comparison
|
||||
// For now, get all entries and filter in PHP
|
||||
$allEntries = $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
['component_id' => $componentId],
|
||||
['created_at' => 'ASC']
|
||||
);
|
||||
|
||||
$filtered = array_filter($allEntries, function (StateHistoryEntry $entry) use ($since) {
|
||||
return $entry->createdAt->isAfter($since) || $entry->createdAt->equals($since);
|
||||
});
|
||||
|
||||
return array_slice($filtered, 0, $limit);
|
||||
}
|
||||
|
||||
public function getHistoryByUser(string $userId, int $limit = 100): array
|
||||
{
|
||||
return $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
['user_id' => $userId],
|
||||
['created_at' => 'DESC'],
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
public function cleanup(string $componentId, int $keepLast): int
|
||||
{
|
||||
// Get all entries for component ordered by created_at DESC
|
||||
$entries = $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
['component_id' => $componentId],
|
||||
['created_at' => 'DESC']
|
||||
);
|
||||
|
||||
// Keep first $keepLast entries, delete the rest
|
||||
$toDelete = array_slice($entries, $keepLast);
|
||||
$deletedCount = 0;
|
||||
|
||||
foreach ($toDelete as $entry) {
|
||||
$this->entityManager->unitOfWork->remove($entry);
|
||||
$deletedCount++;
|
||||
}
|
||||
|
||||
if ($deletedCount > 0) {
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
$this->log('info', "Cleaned up {$deletedCount} history entries for component: {$componentId}");
|
||||
}
|
||||
|
||||
return $deletedCount;
|
||||
}
|
||||
|
||||
public function cleanupOlderThan(Timestamp $olderThan): int
|
||||
{
|
||||
// Get all entries (EntityManager limitation - need to filter in PHP)
|
||||
$allEntries = $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
[]
|
||||
);
|
||||
|
||||
$toDelete = array_filter($allEntries, function (StateHistoryEntry $entry) use ($olderThan) {
|
||||
return $entry->createdAt->isBefore($olderThan);
|
||||
});
|
||||
|
||||
$deletedCount = 0;
|
||||
foreach ($toDelete as $entry) {
|
||||
$this->entityManager->unitOfWork->remove($entry);
|
||||
$deletedCount++;
|
||||
}
|
||||
|
||||
if ($deletedCount > 0) {
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
$this->log('info', "Cleaned up {$deletedCount} history entries older than {$olderThan->format('Y-m-d H:i:s')}");
|
||||
}
|
||||
|
||||
return $deletedCount;
|
||||
}
|
||||
|
||||
public function deleteHistory(string $componentId): int
|
||||
{
|
||||
$entries = $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
['component_id' => $componentId]
|
||||
);
|
||||
|
||||
$deletedCount = 0;
|
||||
foreach ($entries as $entry) {
|
||||
$this->entityManager->unitOfWork->remove($entry);
|
||||
$deletedCount++;
|
||||
}
|
||||
|
||||
if ($deletedCount > 0) {
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
$this->log('info', "Deleted all {$deletedCount} history entries for component: {$componentId}");
|
||||
}
|
||||
|
||||
return $deletedCount;
|
||||
}
|
||||
|
||||
public function isHistoryEnabled(string $componentClass): bool
|
||||
{
|
||||
// Check if class has #[TrackStateHistory] attribute
|
||||
if (!class_exists($componentClass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($componentClass);
|
||||
$attributes = $reflection->getAttributes(TrackStateHistory::class);
|
||||
|
||||
return !empty($attributes);
|
||||
}
|
||||
|
||||
public function getStatistics(): array
|
||||
{
|
||||
// Get all history entries
|
||||
$allEntries = $this->entityManager->findBy(
|
||||
StateHistoryEntry::class,
|
||||
[]
|
||||
);
|
||||
|
||||
if (empty($allEntries)) {
|
||||
return [
|
||||
'total_entries' => 0,
|
||||
'total_components' => 0,
|
||||
'oldest_entry' => null,
|
||||
'newest_entry' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
$componentIds = array_unique(array_map(
|
||||
fn(StateHistoryEntry $entry) => $entry->componentId,
|
||||
$allEntries
|
||||
));
|
||||
|
||||
$timestamps = array_map(
|
||||
fn(StateHistoryEntry $entry) => $entry->createdAt,
|
||||
$allEntries
|
||||
);
|
||||
|
||||
usort($timestamps, fn(Timestamp $a, Timestamp $b) => $a->compareTo($b));
|
||||
|
||||
return [
|
||||
'total_entries' => count($allEntries),
|
||||
'total_components' => count($componentIds),
|
||||
'oldest_entry' => $timestamps[0] ?? null,
|
||||
'newest_entry' => $timestamps[count($timestamps) - 1] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message if logger available
|
||||
*/
|
||||
private function log(string $level, string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context['entries_added'] = $this->entriesAdded;
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
298
src/Framework/StateManagement/Database/DatabaseStateManager.php
Normal file
298
src/Framework/StateManagement/Database/DatabaseStateManager.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\StateManagement\Database;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\SerializableState;
|
||||
use App\Framework\StateManagement\StateManager;
|
||||
use App\Framework\StateManagement\StateManagerStatistics;
|
||||
|
||||
/**
|
||||
* Database-backed State Manager with Cache Layer
|
||||
*
|
||||
* Two-tier architecture:
|
||||
* - L1 Cache: Hot state (~1h TTL)
|
||||
* - L2 Database: Persistent storage via EntityManager
|
||||
*
|
||||
* Write-Through Strategy:
|
||||
* - Writes go to both cache and database
|
||||
* - Reads from cache, fallback to database
|
||||
* - Cache invalidation on updates
|
||||
*/
|
||||
final class DatabaseStateManager implements StateManager
|
||||
{
|
||||
private int $cacheHits = 0;
|
||||
private int $cacheMisses = 0;
|
||||
private int $dbReads = 0;
|
||||
private int $dbWrites = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManager $entityManager,
|
||||
private readonly Cache $cache,
|
||||
private readonly string $stateClass,
|
||||
private readonly ?Logger $logger = null,
|
||||
private readonly Duration $cacheTtl = new Duration(3600), // 1 hour
|
||||
) {}
|
||||
|
||||
public function getState(string $key): mixed
|
||||
{
|
||||
// Try cache first (L1)
|
||||
$cacheKey = $this->getCacheKey($key);
|
||||
$cached = $this->cache->get($cacheKey);
|
||||
|
||||
if ($cached->isHit) {
|
||||
$this->cacheHits++;
|
||||
$this->log('debug', "Cache hit for key: {$key}");
|
||||
|
||||
// Deserialize from cache
|
||||
return $this->deserializeState($cached->value);
|
||||
}
|
||||
|
||||
$this->cacheMisses++;
|
||||
$this->log('debug', "Cache miss for key: {$key}, falling back to database");
|
||||
|
||||
// Fallback to database (L2) via EntityManager
|
||||
$entity = $this->entityManager->find(ComponentStateEntity::class, $key);
|
||||
|
||||
if ($entity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->dbReads++;
|
||||
|
||||
// Populate cache for next access
|
||||
$state = $this->deserializeState($entity->stateData);
|
||||
$this->cache->set($cacheKey, $entity->stateData, $this->cacheTtl);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public function setState(string $key, mixed $state, ?Duration $ttl = null): void
|
||||
{
|
||||
if (!$state instanceof SerializableState) {
|
||||
throw new \InvalidArgumentException("State must implement SerializableState");
|
||||
}
|
||||
|
||||
// Serialize state
|
||||
$stateData = $this->serializeState($state);
|
||||
$checksum = $this->calculateChecksum($stateData);
|
||||
$expiresAt = $ttl ? Timestamp::now()->add($ttl) : null;
|
||||
|
||||
// Load existing entity or create new
|
||||
$existing = $this->entityManager->find(ComponentStateEntity::class, $key);
|
||||
|
||||
if ($existing === null) {
|
||||
// Create new entity
|
||||
$entity = new ComponentStateEntity(
|
||||
componentId: $key,
|
||||
stateData: $stateData,
|
||||
stateClass: $this->stateClass,
|
||||
componentName: $this->extractComponentName($key),
|
||||
userId: null, // Could be set by context
|
||||
sessionId: null,
|
||||
version: 1,
|
||||
checksum: $checksum,
|
||||
createdAt: Timestamp::now(),
|
||||
updatedAt: Timestamp::now(),
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
|
||||
// Persist via EntityManager
|
||||
$this->entityManager->unitOfWork->persist($entity);
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
} else {
|
||||
// Update existing entity
|
||||
$updated = $existing->withUpdatedState($stateData, $checksum, $expiresAt);
|
||||
|
||||
// Persist updated entity
|
||||
$this->entityManager->unitOfWork->persist($updated);
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
}
|
||||
|
||||
// Write-through to cache
|
||||
$cacheKey = $this->getCacheKey($key);
|
||||
$this->cache->set($cacheKey, $stateData, $ttl ?? $this->cacheTtl);
|
||||
|
||||
$this->dbWrites++;
|
||||
$this->log('debug', "State saved for key: {$key}");
|
||||
}
|
||||
|
||||
public function hasState(string $key): bool
|
||||
{
|
||||
// Check cache first
|
||||
$cacheKey = $this->getCacheKey($key);
|
||||
if ($this->cache->get($cacheKey)->isHit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check database via EntityManager
|
||||
return $this->entityManager->find(ComponentStateEntity::class, $key) !== null;
|
||||
}
|
||||
|
||||
public function removeState(string $key): void
|
||||
{
|
||||
// Delete from database via EntityManager
|
||||
$entity = $this->entityManager->find(ComponentStateEntity::class, $key);
|
||||
|
||||
if ($entity !== null) {
|
||||
$this->entityManager->unitOfWork->remove($entity);
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
$cacheKey = $this->getCacheKey($key);
|
||||
$this->cache->forget($cacheKey);
|
||||
|
||||
$this->log('debug', "State removed for key: {$key}");
|
||||
}
|
||||
|
||||
public function updateState(string $key, callable $updater, ?Duration $ttl = null): mixed
|
||||
{
|
||||
// Get current state
|
||||
$currentState = $this->getState($key);
|
||||
|
||||
// Apply updater
|
||||
$newState = $updater($currentState);
|
||||
|
||||
// Save updated state
|
||||
$this->setState($key, $newState, $ttl);
|
||||
|
||||
return $newState;
|
||||
}
|
||||
|
||||
public function getAllStates(): array
|
||||
{
|
||||
// Use EntityManager to fetch all states
|
||||
$entities = $this->entityManager->findBy(
|
||||
ComponentStateEntity::class,
|
||||
['state_class' => $this->stateClass]
|
||||
);
|
||||
|
||||
$states = [];
|
||||
foreach ($entities as $entity) {
|
||||
$states[$entity->componentId] = $this->deserializeState($entity->stateData);
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
public function clearAll(): void
|
||||
{
|
||||
// Delete all states via EntityManager
|
||||
$entities = $this->entityManager->findBy(
|
||||
ComponentStateEntity::class,
|
||||
['state_class' => $this->stateClass]
|
||||
);
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$this->entityManager->unitOfWork->remove($entity);
|
||||
}
|
||||
|
||||
$this->entityManager->unitOfWork->commit();
|
||||
|
||||
// Note: Cache clearing is limited by Cache interface
|
||||
$this->log('warning', "clearAll() executed - cache may contain stale entries");
|
||||
}
|
||||
|
||||
public function getStatistics(): StateManagerStatistics
|
||||
{
|
||||
// Count total keys via EntityManager
|
||||
$entities = $this->entityManager->findBy(
|
||||
ComponentStateEntity::class,
|
||||
['state_class' => $this->stateClass]
|
||||
);
|
||||
$totalKeys = count($entities);
|
||||
|
||||
$totalRequests = $this->cacheHits + $this->cacheMisses;
|
||||
|
||||
return new StateManagerStatistics(
|
||||
totalKeys: $totalKeys,
|
||||
hitCount: $this->cacheHits,
|
||||
missCount: $this->cacheMisses,
|
||||
setCount: $this->dbWrites,
|
||||
removeCount: 0,
|
||||
updateCount: 0,
|
||||
averageSetTime: Duration::zero(),
|
||||
averageGetTime: Duration::zero(),
|
||||
expiredKeys: 0,
|
||||
memoryUsage: Byte::fromBytes(0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize state to string
|
||||
*/
|
||||
private function serializeState(SerializableState $state): string
|
||||
{
|
||||
// Note: Encryption should be handled by StateTransformer pipeline
|
||||
return json_encode($state->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize state from string
|
||||
*/
|
||||
private function deserializeState(string $data): SerializableState
|
||||
{
|
||||
// Note: Decryption should be handled by StateTransformer pipeline
|
||||
$array = json_decode($data, true);
|
||||
return call_user_func([$this->stateClass, 'fromArray'], $array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate checksum for integrity verification
|
||||
*/
|
||||
private function calculateChecksum(string $data): string
|
||||
{
|
||||
return hash('sha256', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract component name from component ID
|
||||
*/
|
||||
private function extractComponentName(string $componentId): string
|
||||
{
|
||||
// ComponentId format: "{componentName}_{uniqueId}"
|
||||
$parts = explode('_', $componentId, 2);
|
||||
return $parts[0] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key for component ID
|
||||
*/
|
||||
private function getCacheKey(string $key): CacheKey
|
||||
{
|
||||
return CacheKey::from("component_state:{$key}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message if logger available
|
||||
*/
|
||||
private function log(string $level, string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context['state_class'] = $this->stateClass;
|
||||
$context['cache_hits'] = $this->cacheHits;
|
||||
$context['cache_misses'] = $this->cacheMisses;
|
||||
$context['db_reads'] = $this->dbReads;
|
||||
$context['db_writes'] = $this->dbWrites;
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
src/Framework/StateManagement/Database/StateChangeType.php
Normal file
17
src/Framework/StateManagement/Database/StateChangeType.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\StateManagement\Database;
|
||||
|
||||
/**
|
||||
* State Change Type Enum
|
||||
*
|
||||
* Represents the type of state change in history.
|
||||
*/
|
||||
enum StateChangeType: string
|
||||
{
|
||||
case CREATED = 'created';
|
||||
case UPDATED = 'updated';
|
||||
case DELETED = 'deleted';
|
||||
}
|
||||
136
src/Framework/StateManagement/Database/StateHistoryEntry.php
Normal file
136
src/Framework/StateManagement/Database/StateHistoryEntry.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\StateManagement\Database;
|
||||
|
||||
use App\Framework\Attributes\Entity;
|
||||
use App\Framework\Attributes\Id;
|
||||
use App\Framework\Attributes\Column;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* State History Entry Entity
|
||||
*
|
||||
* Represents a historical snapshot of component state change.
|
||||
* EntityManager-compatible entity for audit trails and debugging.
|
||||
*/
|
||||
#[Entity(table: 'component_state_history')]
|
||||
final readonly class StateHistoryEntry
|
||||
{
|
||||
public function __construct(
|
||||
#[Id]
|
||||
#[Column(name: 'id', type: 'integer')]
|
||||
public int $id,
|
||||
|
||||
#[Column(name: 'component_id')]
|
||||
public string $componentId,
|
||||
|
||||
#[Column(name: 'state_data', type: 'text')]
|
||||
public string $stateData, // Encrypted state snapshot
|
||||
|
||||
#[Column(name: 'state_class')]
|
||||
public string $stateClass,
|
||||
|
||||
#[Column(name: 'version', type: 'integer')]
|
||||
public int $version,
|
||||
|
||||
#[Column(name: 'change_type')]
|
||||
public StateChangeType $changeType,
|
||||
|
||||
#[Column(name: 'changed_properties', type: 'json')]
|
||||
public ?array $changedProperties, // Which properties changed
|
||||
|
||||
#[Column(name: 'user_id')]
|
||||
public ?string $userId,
|
||||
|
||||
#[Column(name: 'session_id')]
|
||||
public ?string $sessionId,
|
||||
|
||||
#[Column(name: 'ip_address')]
|
||||
public ?string $ipAddress,
|
||||
|
||||
#[Column(name: 'user_agent', type: 'text')]
|
||||
public ?string $userAgent,
|
||||
|
||||
#[Column(name: 'previous_checksum')]
|
||||
public ?string $previousChecksum,
|
||||
|
||||
#[Column(name: 'current_checksum')]
|
||||
public string $currentChecksum,
|
||||
|
||||
#[Column(name: 'created_at', type: 'datetime')]
|
||||
public Timestamp $createdAt,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create from database row
|
||||
*/
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
id: (int) $row['id'],
|
||||
componentId: $row['component_id'],
|
||||
stateData: $row['state_data'],
|
||||
stateClass: $row['state_class'],
|
||||
version: (int) $row['version'],
|
||||
changeType: StateChangeType::from($row['change_type']),
|
||||
changedProperties: isset($row['changed_properties'])
|
||||
? json_decode($row['changed_properties'], true)
|
||||
: null,
|
||||
userId: $row['user_id'] ?? null,
|
||||
sessionId: $row['session_id'] ?? null,
|
||||
ipAddress: $row['ip_address'] ?? null,
|
||||
userAgent: $row['user_agent'] ?? null,
|
||||
previousChecksum: $row['previous_checksum'] ?? null,
|
||||
currentChecksum: $row['current_checksum'],
|
||||
createdAt: Timestamp::fromString($row['created_at']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to database row
|
||||
*/
|
||||
public function toRow(): array
|
||||
{
|
||||
return [
|
||||
'component_id' => $this->componentId,
|
||||
'state_data' => $this->stateData,
|
||||
'state_class' => $this->stateClass,
|
||||
'version' => $this->version,
|
||||
'change_type' => $this->changeType->value,
|
||||
'changed_properties' => $this->changedProperties ? json_encode($this->changedProperties) : null,
|
||||
'user_id' => $this->userId,
|
||||
'session_id' => $this->sessionId,
|
||||
'ip_address' => $this->ipAddress,
|
||||
'user_agent' => $this->userAgent,
|
||||
'previous_checksum' => $this->previousChecksum,
|
||||
'current_checksum' => $this->currentChecksum,
|
||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a creation event
|
||||
*/
|
||||
public function isCreation(): bool
|
||||
{
|
||||
return $this->changeType === StateChangeType::CREATED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an update event
|
||||
*/
|
||||
public function isUpdate(): bool
|
||||
{
|
||||
return $this->changeType === StateChangeType::UPDATED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a deletion event
|
||||
*/
|
||||
public function isDeletion(): bool
|
||||
{
|
||||
return $this->changeType === StateChangeType::DELETED;
|
||||
}
|
||||
}
|
||||
122
src/Framework/StateManagement/StateHistoryManager.php
Normal file
122
src/Framework/StateManagement/StateHistoryManager.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\StateManagement;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\StateManagement\Database\StateHistoryEntry;
|
||||
|
||||
/**
|
||||
* State History Manager Interface
|
||||
*
|
||||
* Manages historical snapshots of component state changes.
|
||||
* Used for audit trails, debugging, and state recovery.
|
||||
*/
|
||||
interface StateHistoryManager
|
||||
{
|
||||
/**
|
||||
* Add a history entry for a state change
|
||||
*
|
||||
* @param string $componentId Component identifier
|
||||
* @param string $stateData Serialized state data
|
||||
* @param string $stateClass State class name
|
||||
* @param int $version State version number
|
||||
* @param string $changeType Type of change (created/updated/deleted)
|
||||
* @param array $context Additional context (user_id, session_id, ip_address, user_agent, etc.)
|
||||
* @param array|null $changedProperties List of changed property names
|
||||
* @param string|null $previousChecksum Checksum before change
|
||||
* @param string $currentChecksum Checksum after change
|
||||
*/
|
||||
public function addHistoryEntry(
|
||||
string $componentId,
|
||||
string $stateData,
|
||||
string $stateClass,
|
||||
int $version,
|
||||
string $changeType,
|
||||
array $context = [],
|
||||
?array $changedProperties = null,
|
||||
?string $previousChecksum = null,
|
||||
string $currentChecksum = ''
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Get history for a component
|
||||
*
|
||||
* @param string $componentId Component identifier
|
||||
* @param int $limit Maximum number of entries to return
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array<StateHistoryEntry> History entries ordered by created_at DESC
|
||||
*/
|
||||
public function getHistory(string $componentId, int $limit = 100, int $offset = 0): array;
|
||||
|
||||
/**
|
||||
* Get specific version of state from history
|
||||
*
|
||||
* @param string $componentId Component identifier
|
||||
* @param int $version Version number
|
||||
* @return StateHistoryEntry|null History entry or null if not found
|
||||
*/
|
||||
public function getHistoryByVersion(string $componentId, int $version): ?StateHistoryEntry;
|
||||
|
||||
/**
|
||||
* Get history entries since a specific timestamp
|
||||
*
|
||||
* @param string $componentId Component identifier
|
||||
* @param Timestamp $since Timestamp to start from
|
||||
* @param int $limit Maximum number of entries
|
||||
* @return array<StateHistoryEntry> History entries ordered by created_at ASC
|
||||
*/
|
||||
public function getHistorySince(string $componentId, Timestamp $since, int $limit = 100): array;
|
||||
|
||||
/**
|
||||
* Get history entries for a specific user
|
||||
*
|
||||
* @param string $userId User identifier
|
||||
* @param int $limit Maximum number of entries
|
||||
* @return array<StateHistoryEntry> History entries ordered by created_at DESC
|
||||
*/
|
||||
public function getHistoryByUser(string $userId, int $limit = 100): array;
|
||||
|
||||
/**
|
||||
* Cleanup old history entries for a component
|
||||
*
|
||||
* Keep only the last N entries, delete older ones.
|
||||
*
|
||||
* @param string $componentId Component identifier
|
||||
* @param int $keepLast Number of entries to keep
|
||||
* @return int Number of entries deleted
|
||||
*/
|
||||
public function cleanup(string $componentId, int $keepLast): int;
|
||||
|
||||
/**
|
||||
* Cleanup all history entries older than a specific timestamp
|
||||
*
|
||||
* @param Timestamp $olderThan Delete entries older than this timestamp
|
||||
* @return int Number of entries deleted
|
||||
*/
|
||||
public function cleanupOlderThan(Timestamp $olderThan): int;
|
||||
|
||||
/**
|
||||
* Delete all history for a component
|
||||
*
|
||||
* @param string $componentId Component identifier
|
||||
* @return int Number of entries deleted
|
||||
*/
|
||||
public function deleteHistory(string $componentId): int;
|
||||
|
||||
/**
|
||||
* Check if history tracking is enabled for a component
|
||||
*
|
||||
* @param string $componentClass Component class name
|
||||
* @return bool True if component has #[TrackStateHistory] attribute
|
||||
*/
|
||||
public function isHistoryEnabled(string $componentClass): bool;
|
||||
|
||||
/**
|
||||
* Get statistics about history storage
|
||||
*
|
||||
* @return array{total_entries: int, total_components: int, oldest_entry: ?Timestamp, newest_entry: ?Timestamp}
|
||||
*/
|
||||
public function getStatistics(): array;
|
||||
}
|
||||
71
src/Framework/StateManagement/StateManagementInitializer.php
Normal file
71
src/Framework/StateManagement/StateManagementInitializer.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\StateManagement;
|
||||
|
||||
use App\Framework\Attributes\Initializer;
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\Database\DatabaseStateHistoryManager;
|
||||
use App\Framework\StateManagement\Database\DatabaseStateManager;
|
||||
|
||||
/**
|
||||
* State Management System Initializer
|
||||
*
|
||||
* Registers state management services in DI container:
|
||||
* - DatabaseStateManager for persistent state storage
|
||||
* - DatabaseStateHistoryManager for audit trails
|
||||
* - LiveComponentStateManager wrapper (application layer)
|
||||
*/
|
||||
final readonly class StateManagementInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Cache $cache,
|
||||
private ?Logger $logger = null,
|
||||
) {}
|
||||
|
||||
#[Initializer]
|
||||
public function initialize(Container $container): void
|
||||
{
|
||||
// Register StateHistoryManager
|
||||
$historyManager = new DatabaseStateHistoryManager(
|
||||
entityManager: $this->entityManager,
|
||||
logger: $this->logger
|
||||
);
|
||||
$container->singleton(StateHistoryManager::class, $historyManager);
|
||||
|
||||
// Register StateManager with database backend
|
||||
// Note: The stateClass will be set by LiveComponentStateManager
|
||||
// This is a generic StateManager for framework-wide use
|
||||
$stateManager = new DatabaseStateManager(
|
||||
entityManager: $this->entityManager,
|
||||
cache: $this->cache,
|
||||
stateClass: '', // Will be overridden per component
|
||||
logger: $this->logger
|
||||
);
|
||||
$container->singleton(StateManager::class, $stateManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create StateManager for specific state class
|
||||
*
|
||||
* Factory method for creating state managers with specific state class configuration.
|
||||
* Useful for components that need typed state managers.
|
||||
*
|
||||
* @param string $stateClass Fully qualified state class name
|
||||
* @return StateManager State manager configured for specific state class
|
||||
*/
|
||||
public function createForStateClass(string $stateClass): StateManager
|
||||
{
|
||||
return new DatabaseStateManager(
|
||||
entityManager: $this->entityManager,
|
||||
cache: $this->cache,
|
||||
stateClass: $stateClass,
|
||||
logger: $this->logger
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user