## Summary - add a request-scoped derived-state store with deterministic keying and freshness controls - adopt the shared contract in ArtifactTruthPresenter, OperationUxPresenter, and RelatedNavigationResolver plus the covered Filament consumers - add spec, plan, contracts, guardrails, and focused memoization and freshness test coverage for spec 167 ## Verification - vendor/bin/sail artisan test --compact tests/Feature/078/RelatedLinksOnDetailTest.php - vendor/bin/sail artisan test --compact tests/Feature/078/ tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Verification/VerificationAuthorizationTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php tests/Feature/Verification/VerificationReportRedactionTest.php tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php tests/Feature/OpsUx/FailureSanitizationTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php - vendor/bin/sail bin pint --dirty --format agent ## Notes - Livewire v4.0+ compliance preserved - provider registration remains in bootstrap/providers.php - no Filament assets or panel registration changes - no global-search behavior changes - no destructive action behavior changes in this PR Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #198
187 lines
4.8 KiB
PHP
187 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Ui\DerivedState;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class RequestScopedDerivedStateStore
|
|
{
|
|
public const string FRESHNESS_REQUEST_STABLE = 'request_stable';
|
|
|
|
public const string FRESHNESS_INVALIDATE_AFTER_MUTATION = 'invalidate_after_mutation';
|
|
|
|
public const string FRESHNESS_NO_REUSE = 'no_reuse';
|
|
|
|
private string $requestScopeId;
|
|
|
|
/**
|
|
* @var array<string, array{
|
|
* key: DerivedStateKey,
|
|
* value: mixed,
|
|
* negative_result: bool,
|
|
* freshness_policy: string,
|
|
* resolved_at: int
|
|
* }>
|
|
*/
|
|
private array $entries = [];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private array $invalidations = [];
|
|
|
|
private int $resolutionSequence = 0;
|
|
|
|
public function __construct(?string $requestScopeId = null)
|
|
{
|
|
$this->requestScopeId = $requestScopeId ?? (string) Str::uuid();
|
|
}
|
|
|
|
public function requestScopeId(): string
|
|
{
|
|
return $this->requestScopeId;
|
|
}
|
|
|
|
public function resolve(
|
|
DerivedStateKey $key,
|
|
callable $resolver,
|
|
?string $freshnessPolicy = null,
|
|
?bool $allowNegativeResultCache = null,
|
|
): mixed {
|
|
$freshnessPolicy ??= $key->family->defaultFreshnessPolicy();
|
|
|
|
if ($freshnessPolicy === self::FRESHNESS_NO_REUSE) {
|
|
return $resolver();
|
|
}
|
|
|
|
$fingerprint = $key->fingerprint();
|
|
|
|
if (array_key_exists($fingerprint, $this->entries)) {
|
|
return $this->entries[$fingerprint]['value'];
|
|
}
|
|
|
|
$value = $resolver();
|
|
$negativeResult = $this->isNegativeResult($value);
|
|
$allowNegativeResultCache ??= $key->family->allowsNegativeResultCache();
|
|
|
|
if ($negativeResult && ! $allowNegativeResultCache) {
|
|
return $value;
|
|
}
|
|
|
|
$this->entries[$fingerprint] = [
|
|
'key' => $key,
|
|
'value' => $value,
|
|
'negative_result' => $negativeResult,
|
|
'freshness_policy' => $freshnessPolicy,
|
|
'resolved_at' => ++$this->resolutionSequence,
|
|
];
|
|
|
|
return $value;
|
|
}
|
|
|
|
public function resolveFresh(
|
|
DerivedStateKey $key,
|
|
callable $resolver,
|
|
?string $freshnessPolicy = null,
|
|
?bool $allowNegativeResultCache = null,
|
|
): mixed {
|
|
$this->invalidateKey($key);
|
|
|
|
return $this->resolve($key, $resolver, $freshnessPolicy, $allowNegativeResultCache);
|
|
}
|
|
|
|
public function invalidateKey(DerivedStateKey $key): int
|
|
{
|
|
$fingerprint = $key->fingerprint();
|
|
|
|
if (! array_key_exists($fingerprint, $this->entries)) {
|
|
return 0;
|
|
}
|
|
|
|
unset($this->entries[$fingerprint]);
|
|
$this->invalidations[] = $fingerprint;
|
|
|
|
return 1;
|
|
}
|
|
|
|
public function invalidateFamily(
|
|
DerivedStateFamily $family,
|
|
?string $recordClass = null,
|
|
string|int|null $recordKey = null,
|
|
?string $variant = null,
|
|
?int $workspaceId = null,
|
|
?int $tenantId = null,
|
|
): int {
|
|
$invalidated = 0;
|
|
|
|
foreach ($this->entries as $fingerprint => $record) {
|
|
if (! $record['key']->matches($family, $recordClass, $recordKey, $variant, $workspaceId, $tenantId)) {
|
|
continue;
|
|
}
|
|
|
|
unset($this->entries[$fingerprint]);
|
|
$this->invalidations[] = $fingerprint;
|
|
$invalidated++;
|
|
}
|
|
|
|
return $invalidated;
|
|
}
|
|
|
|
public function invalidateModel(DerivedStateFamily $family, Model $record, ?string $variant = null): int
|
|
{
|
|
return $this->invalidateFamily(
|
|
family: $family,
|
|
recordClass: $record::class,
|
|
recordKey: $record->getKey(),
|
|
variant: $variant,
|
|
);
|
|
}
|
|
|
|
public function entryCount(): int
|
|
{
|
|
return count($this->entries);
|
|
}
|
|
|
|
public function countStored(
|
|
DerivedStateFamily $family,
|
|
?string $recordClass = null,
|
|
string|int|null $recordKey = null,
|
|
?string $variant = null,
|
|
): int {
|
|
return count(array_filter(
|
|
$this->entries,
|
|
static fn (array $record): bool => $record['key']->matches($family, $recordClass, $recordKey, $variant),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* key: DerivedStateKey,
|
|
* value: mixed,
|
|
* negative_result: bool,
|
|
* freshness_policy: string,
|
|
* resolved_at: int
|
|
* }|null
|
|
*/
|
|
public function resolutionRecord(DerivedStateKey $key): ?array
|
|
{
|
|
return $this->entries[$key->fingerprint()] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function invalidations(): array
|
|
{
|
|
return $this->invalidations;
|
|
}
|
|
|
|
private function isNegativeResult(mixed $value): bool
|
|
{
|
|
return $value === null || $value === [];
|
|
}
|
|
}
|