## 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
191 lines
5.3 KiB
PHP
191 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Ui\DerivedState;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use JsonException;
|
|
|
|
final class DerivedStateKey
|
|
{
|
|
public function __construct(
|
|
public readonly DerivedStateFamily $family,
|
|
public readonly string $recordClass,
|
|
public readonly string $recordKey,
|
|
public readonly string $variant,
|
|
public readonly ?int $workspaceId = null,
|
|
public readonly ?int $tenantId = null,
|
|
public readonly ?string $contextHash = null,
|
|
) {
|
|
if (trim($this->recordClass) === '') {
|
|
throw new \InvalidArgumentException('Derived state keys require a non-empty record class.');
|
|
}
|
|
|
|
if (trim($this->recordKey) === '') {
|
|
throw new \InvalidArgumentException('Derived state keys require a non-empty record key.');
|
|
}
|
|
|
|
if (trim($this->variant) === '') {
|
|
throw new \InvalidArgumentException('Derived state keys require a non-empty variant.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|string|null $context
|
|
*/
|
|
public static function fromModel(
|
|
DerivedStateFamily $family,
|
|
Model $record,
|
|
string $variant,
|
|
array|string|null $context = null,
|
|
?int $workspaceId = null,
|
|
?int $tenantId = null,
|
|
): self {
|
|
return new self(
|
|
family: $family,
|
|
recordClass: $record::class,
|
|
recordKey: (string) $record->getKey(),
|
|
variant: $variant,
|
|
workspaceId: $workspaceId ?? self::normalizeScopeId($record->getAttribute('workspace_id')),
|
|
tenantId: $tenantId ?? self::normalizeScopeId($record->getAttribute('tenant_id')),
|
|
contextHash: self::hashContext($context),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* family: string,
|
|
* record_class: string,
|
|
* record_key: string,
|
|
* variant: string,
|
|
* workspace_id: ?int,
|
|
* tenant_id: ?int,
|
|
* context_hash: ?string
|
|
* }
|
|
*/
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'family' => $this->family->value,
|
|
'record_class' => $this->recordClass,
|
|
'record_key' => $this->recordKey,
|
|
'variant' => $this->variant,
|
|
'workspace_id' => $this->workspaceId,
|
|
'tenant_id' => $this->tenantId,
|
|
'context_hash' => $this->contextHash,
|
|
];
|
|
}
|
|
|
|
public function fingerprint(): string
|
|
{
|
|
try {
|
|
/** @var string $json */
|
|
$json = json_encode($this->toArray(), JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $exception) {
|
|
throw new \RuntimeException('Unable to encode derived state key fingerprint.', previous: $exception);
|
|
}
|
|
|
|
return $json;
|
|
}
|
|
|
|
public function matches(
|
|
DerivedStateFamily $family,
|
|
?string $recordClass = null,
|
|
string|int|null $recordKey = null,
|
|
?string $variant = null,
|
|
?int $workspaceId = null,
|
|
?int $tenantId = null,
|
|
): bool {
|
|
if ($this->family !== $family) {
|
|
return false;
|
|
}
|
|
|
|
if ($recordClass !== null && $this->recordClass !== $recordClass) {
|
|
return false;
|
|
}
|
|
|
|
if ($recordKey !== null && $this->recordKey !== (string) $recordKey) {
|
|
return false;
|
|
}
|
|
|
|
if ($variant !== null && $this->variant !== $variant) {
|
|
return false;
|
|
}
|
|
|
|
if ($workspaceId !== null && $this->workspaceId !== $workspaceId) {
|
|
return false;
|
|
}
|
|
|
|
if ($tenantId !== null && $this->tenantId !== $tenantId) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|string|null $context
|
|
*/
|
|
public static function hashContext(array|string|null $context): ?string
|
|
{
|
|
if ($context === null) {
|
|
return null;
|
|
}
|
|
|
|
if (is_string($context)) {
|
|
$context = trim($context);
|
|
|
|
return $context === '' ? null : sha1($context);
|
|
}
|
|
|
|
if ($context === []) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = self::normalizeContext($context);
|
|
|
|
try {
|
|
/** @var string $json */
|
|
$json = json_encode($normalized, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $exception) {
|
|
throw new \RuntimeException('Unable to encode derived state context.', previous: $exception);
|
|
}
|
|
|
|
return sha1($json);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function normalizeContext(array $context): array
|
|
{
|
|
ksort($context);
|
|
|
|
foreach ($context as $key => $value) {
|
|
if (is_array($value)) {
|
|
/** @var mixed $normalized */
|
|
$normalized = array_is_list($value)
|
|
? array_map(static fn (mixed $item): mixed => is_array($item) ? self::normalizeContext($item) : $item, $value)
|
|
: self::normalizeContext($value);
|
|
|
|
$context[$key] = $normalized;
|
|
}
|
|
}
|
|
|
|
return $context;
|
|
}
|
|
|
|
private static function normalizeScopeId(mixed $value): ?int
|
|
{
|
|
if (! is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = (int) $value;
|
|
|
|
return $normalized > 0 ? $normalized : null;
|
|
}
|
|
}
|