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>
639 lines
20 KiB
PHP
639 lines
20 KiB
PHP
<?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();
|
||
}
|
||
}
|