TenantAtlas/app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php
ahmido d98dc30520 feat: add request-scoped derived state memoization (#198)
## 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
2026-03-28 14:58:30 +00:00

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 === [];
}
}