lthn.io/app/Core/Config/ConfigResolver.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

639 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Config;
use Core\Config\Contracts\ConfigProvider;
use Core\Config\Enums\ScopeType;
use Core\Config\Models\Channel;
use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue;
use Illuminate\Support\Collection;
/**
* Configuration resolution engine.
*
* Single static hash for all config values:
* - Runtime values from modules
* - Resolved values from database
* - All in one place, zero-DB reads after warmup
*
* Read path: $values[$key] ?? compute and store
*
* Resolution dimensions (when computing):
* - Scope: workspace → org → system (most specific wins)
* - Channel: specific → parent → null (most specific wins)
*
* Respects FINAL/locked declarations from parent scopes.
*/
class ConfigResolver
{
/**
* The hash. Key → value. That's it.
*
* @var array<string, mixed>
*/
protected static array $values = [];
/**
* Whether the hash has been loaded.
*/
protected static bool $loaded = false;
/**
* Registered virtual providers.
*
* Supports both ConfigProvider instances and callable functions.
*
* @var array<string, ConfigProvider|callable>
*/
protected array $providers = [];
// =========================================================================
// THE HASH
// =========================================================================
/**
* Get a value from the hash.
*/
public static function get(string $key): mixed
{
return static::$values[$key] ?? null;
}
/**
* Set a value in the hash.
*/
public static function set(string $key, mixed $value): void
{
static::$values[$key] = $value;
}
/**
* Check if a value exists in the hash.
*/
public static function has(string $key): bool
{
return array_key_exists($key, static::$values);
}
/**
* Clear keys matching a pattern (bi-directional).
*/
public static function clear(string $pattern): void
{
static::$values = array_filter(
static::$values,
fn ($k) => ! str_contains($k, $pattern),
ARRAY_FILTER_USE_KEY
);
}
/**
* Clear entire hash.
*/
public static function clearAll(): void
{
static::$values = [];
static::$loaded = false;
}
/**
* Get all values (for debugging).
*
* @return array<string, mixed>
*/
public static function all(): array
{
return static::$values;
}
/**
* Check if hash has been loaded.
*/
public static function isLoaded(): bool
{
return static::$loaded;
}
/**
* Mark hash as loaded.
*/
public static function markLoaded(): void
{
static::$loaded = true;
}
// =========================================================================
// RESOLUTION ENGINE (only runs during lazy prime, not normal reads)
// =========================================================================
/**
* Resolve a single key for a workspace and optional channel.
*
* NOTE: This is the expensive path - only called when lazy-priming.
* Normal reads hit the hash directly via ConfigService.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @param string|Channel|null $channel Channel code or object
*/
public function resolve(
string $keyCode,
?object $workspace = null,
string|Channel|null $channel = null,
): ConfigResult {
// Get key definition (DB query - only during resolve, not normal reads)
$key = ConfigKey::byCode($keyCode);
if ($key === null) {
// Try JSON sub-key extraction
return $this->resolveJsonSubKey($keyCode, $workspace, $channel);
}
// Build chains
$profileChain = $this->buildProfileChain($workspace);
$channelChain = $this->buildChannelChain($channel, $workspace);
// Batch load all values for this key
$values = $this->batchLoadValues(
$key->id,
$profileChain->pluck('id')->all(),
$channelChain->pluck('id')->all()
);
// Build resolution matrix (profile × channel combinations)
$matrix = $this->buildResolutionMatrix($profileChain, $channelChain);
// First pass: check for FINAL locks (from least specific scope)
$lockedResult = $this->findFinalLock($matrix, $values, $keyCode, $key);
if ($lockedResult !== null) {
return $lockedResult;
}
// Second pass: find most specific value
foreach ($matrix as $combo) {
$value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
if ($value !== null) {
return ConfigResult::found(
key: $keyCode,
value: $value->value,
type: $key->type,
locked: false,
resolvedFrom: $combo['scope_type'],
profileId: $combo['profile_id'],
channelId: $combo['channel_id'],
);
}
}
// Check virtual providers
$virtualValue = $this->resolveFromProviders($keyCode, $workspace, $channel);
if ($virtualValue !== null) {
return ConfigResult::virtual(
key: $keyCode,
value: $virtualValue,
type: $key->type,
);
}
// No value found - return default
return ConfigResult::notFound($keyCode, $key->getTypedDefault(), $key->type);
}
/**
* Maximum recursion depth for JSON sub-key resolution.
*/
protected const MAX_SUBKEY_DEPTH = 10;
/**
* Current recursion depth for sub-key resolution.
*/
protected int $subKeyDepth = 0;
/**
* Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
*/
/**
* @param object|null $workspace Workspace model instance or null for system scope
*/
protected function resolveJsonSubKey(
string $keyCode,
?object $workspace,
string|Channel|null $channel,
): ConfigResult {
// Guard against stack overflow from deep nesting
if ($this->subKeyDepth >= self::MAX_SUBKEY_DEPTH) {
return ConfigResult::unconfigured($keyCode);
}
$this->subKeyDepth++;
try {
$parts = explode('.', $keyCode);
// Try progressively shorter parent keys
for ($i = count($parts) - 1; $i > 0; $i--) {
$parentKey = implode('.', array_slice($parts, 0, $i));
$subPath = implode('.', array_slice($parts, $i));
$parentResult = $this->resolve($parentKey, $workspace, $channel);
if ($parentResult->found && is_array($parentResult->value)) {
$subValue = data_get($parentResult->value, $subPath);
if ($subValue !== null) {
return ConfigResult::found(
key: $keyCode,
value: $subValue,
type: $parentResult->type, // Inherit parent type
locked: $parentResult->locked,
resolvedFrom: $parentResult->resolvedFrom,
profileId: $parentResult->profileId,
channelId: $parentResult->channelId,
);
}
}
}
return ConfigResult::unconfigured($keyCode);
} finally {
$this->subKeyDepth--;
}
}
/**
* Build the channel inheritance chain.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, Channel|null>
*/
public function buildChannelChain(
string|Channel|null $channel,
?object $workspace = null,
): Collection {
$chain = new Collection;
if ($channel === null) {
// No channel specified - just null (applies to all)
$chain->push(null);
return $chain;
}
// Resolve channel code to model
if (is_string($channel)) {
$channel = Channel::byCode($channel, $workspace?->id);
}
if ($channel !== null) {
// Add channel inheritance chain
$chain = $chain->merge($channel->inheritanceChain());
}
// Always include null (all-channels fallback)
$chain->push(null);
return $chain;
}
/**
* Batch load all values for a key across profiles and channels.
*
* @param array<int> $profileIds
* @param array<int|null> $channelIds
* @return Collection<int, ConfigValue>
*/
protected function batchLoadValues(int $keyId, array $profileIds, array $channelIds): Collection
{
// Separate null from actual channel IDs for query
$actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
return ConfigValue::where('key_id', $keyId)
->whereIn('profile_id', $profileIds)
->where(function ($query) use ($actualChannelIds) {
$query->whereNull('channel_id');
if (! empty($actualChannelIds)) {
$query->orWhereIn('channel_id', $actualChannelIds);
}
})
->get();
}
/**
* Build resolution matrix (profile × channel combinations).
*
* Order: most specific first (workspace + specific channel)
* to least specific (system + null channel).
*
* @return array<array{profile_id: int, channel_id: int|null, scope_type: ScopeType}>
*/
protected function buildResolutionMatrix(Collection $profileChain, Collection $channelChain): array
{
$matrix = [];
foreach ($profileChain as $profile) {
foreach ($channelChain as $channel) {
$matrix[] = [
'profile_id' => $profile->id,
'channel_id' => $channel?->id,
'scope_type' => $profile->scope_type,
];
}
}
return $matrix;
}
/**
* Find a FINAL lock in the resolution matrix.
*
* Checks from least specific (system) to find any lock that
* would prevent more specific values from being used.
*/
protected function findFinalLock(
array $matrix,
Collection $values,
string $keyCode,
ConfigKey $key,
): ?ConfigResult {
// Reverse to check from least specific (system)
$reversed = array_reverse($matrix);
foreach ($reversed as $combo) {
$value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
if ($value !== null && $value->isLocked()) {
return ConfigResult::found(
key: $keyCode,
value: $value->value,
type: $key->type,
locked: true,
resolvedFrom: $combo['scope_type'],
profileId: $combo['profile_id'],
channelId: $combo['channel_id'],
);
}
}
return null;
}
/**
* Find a value in the batch-loaded collection.
*/
protected function findValueInBatch(Collection $values, int $profileId, ?int $channelId): ?ConfigValue
{
return $values->first(function (ConfigValue $value) use ($profileId, $channelId) {
return $value->profile_id === $profileId
&& $value->channel_id === $channelId;
});
}
/**
* Register a virtual provider for a key pattern.
*
* Providers supply values from module data without database storage.
* Accepts either a ConfigProvider instance or a callable.
*
* @param string|ConfigProvider $patternOrProvider Key pattern (supports * wildcard) or ConfigProvider instance
* @param ConfigProvider|callable|null $provider ConfigProvider instance or fn(string $key, ?object $workspace, ?Channel $channel): mixed
*/
public function registerProvider(string|ConfigProvider $patternOrProvider, ConfigProvider|callable|null $provider = null): void
{
// Support both new interface-based and legacy callable patterns
if ($patternOrProvider instanceof ConfigProvider) {
$this->providers[$patternOrProvider->pattern()] = $patternOrProvider;
} elseif ($provider !== null) {
$this->providers[$patternOrProvider] = $provider;
}
}
/**
* Resolve value from virtual providers.
*
* Supports both ConfigProvider instances and legacy callables.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function resolveFromProviders(
string $keyCode,
?object $workspace,
string|Channel|null $channel,
): mixed {
foreach ($this->providers as $pattern => $provider) {
if ($this->matchesPattern($keyCode, $pattern)) {
// Support both ConfigProvider interface and legacy callable
$value = $provider instanceof ConfigProvider
? $provider->resolve($keyCode, $workspace, $channel)
: $provider($keyCode, $workspace, $channel);
if ($value !== null) {
return $value;
}
}
}
return null;
}
/**
* Check if a key matches a provider pattern.
*/
protected function matchesPattern(string $key, string $pattern): bool
{
if ($pattern === $key) {
return true;
}
// Convert pattern to regex (e.g., "bio.*" → "^bio\..*$")
$regex = '/^'.str_replace(['.', '*'], ['\.', '.*'], $pattern).'$/';
return (bool) preg_match($regex, $key);
}
/**
* Resolve all keys for a workspace.
*
* NOTE: Only called during prime, not normal reads.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, ConfigResult>
*/
public function resolveAll(?object $workspace = null, string|Channel|null $channel = null): array
{
$results = [];
// Query all keys from DB (only during prime)
foreach (ConfigKey::all() as $key) {
$results[$key->code] = $this->resolve($key->code, $workspace, $channel);
}
return $results;
}
/**
* Resolve all keys in a category.
*
* NOTE: Only called during prime, not normal reads.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, ConfigResult>
*/
public function resolveCategory(
string $category,
?object $workspace = null,
string|Channel|null $channel = null,
): array {
$results = [];
// Query keys by category from DB (only during prime)
foreach (ConfigKey::where('category', $category)->get() as $key) {
$results[$key->code] = $this->resolve($key->code, $workspace, $channel);
}
return $results;
}
/**
* Build the profile chain for resolution.
*
* Returns profiles ordered from most specific (workspace) to least (system).
* Chain: workspace → org → system
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, ConfigProfile>
*/
public function buildProfileChain(?object $workspace = null): Collection
{
$chain = new Collection;
// Workspace profiles (most specific)
if ($workspace !== null) {
$workspaceProfiles = ConfigProfile::forScope(ScopeType::WORKSPACE, $workspace->id);
$chain = $chain->merge($workspaceProfiles);
// Org layer - workspace belongs to organisation
$orgId = $this->resolveOrgId($workspace);
if ($orgId !== null) {
$orgProfiles = ConfigProfile::forScope(ScopeType::ORG, $orgId);
$chain = $chain->merge($orgProfiles);
}
}
// System profiles (least specific)
$systemProfiles = ConfigProfile::forScope(ScopeType::SYSTEM, null);
$chain = $chain->merge($systemProfiles);
// Add parent profile inheritance
$chain = $this->expandParentProfiles($chain);
return $chain;
}
/**
* Resolve organisation ID from workspace.
*
* Stub for now - will connect to Tenant module when org model exists.
* Organisation = multi-workspace grouping (agency accounts, teams).
*
* @param object|null $workspace Workspace model instance or null
*/
protected function resolveOrgId(?object $workspace): ?int
{
if ($workspace === null) {
return null;
}
// Workspace::organisation_id when model has org support
// For now, return null (no org layer)
return $workspace->organisation_id ?? null;
}
/**
* Expand chain to include parent profiles.
*
* @param Collection<int, ConfigProfile> $chain
* @return Collection<int, ConfigProfile>
*/
protected function expandParentProfiles(Collection $chain): Collection
{
$expanded = new Collection;
$seen = [];
foreach ($chain as $profile) {
$this->addProfileWithParents($profile, $expanded, $seen);
}
return $expanded;
}
/**
* Add a profile and its parents to the chain.
*
* @param Collection<int, ConfigProfile> $chain
* @param array<int, bool> $seen
*/
protected function addProfileWithParents(ConfigProfile $profile, Collection $chain, array &$seen): void
{
if (isset($seen[$profile->id])) {
return;
}
$seen[$profile->id] = true;
$chain->push($profile);
// Follow parent chain
if ($profile->parent_profile_id !== null) {
$parent = $profile->parent;
if ($parent !== null) {
$this->addProfileWithParents($parent, $chain, $seen);
}
}
}
/**
* Check if a key prefix is configured.
*
* Optimised to use EXISTS query instead of resolving each key.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function isPrefixConfigured(
string $prefix,
?object $workspace = null,
string|Channel|null $channel = null,
): bool {
// Get profile IDs for this workspace
$profileChain = $this->buildProfileChain($workspace);
$profileIds = $profileChain->pluck('id')->all();
// Get channel IDs
$channelChain = $this->buildChannelChain($channel, $workspace);
$channelIds = $channelChain->map(fn ($c) => $c?->id)->all();
$actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
// Single EXISTS query
return ConfigValue::whereIn('profile_id', $profileIds)
->where(function ($query) use ($actualChannelIds) {
$query->whereNull('channel_id');
if (! empty($actualChannelIds)) {
$query->orWhereIn('channel_id', $actualChannelIds);
}
})
->whereHas('key', function ($query) use ($prefix) {
$query->where('code', 'LIKE', "{$prefix}.%");
})
->exists();
}
}