lthn.io/app/Core/Config/ConfigService.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

822 lines
26 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\Enums\ConfigType;
use Core\Config\Events\ConfigChanged;
use Core\Config\Events\ConfigInvalidated;
use Core\Config\Events\ConfigLocked;
use Core\Config\Models\Channel;
use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigResolved;
use Core\Config\Models\ConfigValue;
/**
* Configuration service - main API.
*
* Single hash: ConfigResolver::$values
* Read path: hash lookup → lazy load scope → compute if needed
*
* ## Usage
*
* ```php
* $config = app(ConfigService::class);
* $value = $config->get('cdn.bunny.api_key', $workspace);
* $config->set('cdn.bunny.api_key', 'new-value', $profile);
*
* // Module Boot.php - provide runtime value (no DB)
* $config->provide('mymodule.api_key', env('MYMODULE_API_KEY'));
* ```
*
* ## Cache Invalidation Strategy
*
* The Config module uses a two-tier caching system:
*
* ### Tier 1: In-Memory Hash (Process-Scoped)
* - `ConfigResolver::$values` - Static array holding all config values
* - Cleared on process termination (dies with the request)
* - Cleared explicitly via `ConfigResolver::clearAll()` or `ConfigResolver::clear($key)`
*
* ### Tier 2: Database Resolved Table (Persistent)
* - `config_resolved` table - Materialised config resolution
* - Survives across requests, shared between all processes
* - Cleared via `ConfigResolved::clearScope()`, `clearWorkspace()`, or `clearKey()`
*
* ### Invalidation Triggers
*
* 1. **On Config Change (`set()`):**
* - Clears the specific key from both hash and database
* - Re-primes the key for the affected scope
* - Dispatches `ConfigChanged` event for module hooks
*
* 2. **On Lock/Unlock:**
* - Re-primes the key (lock affects all child scopes)
* - Dispatches `ConfigLocked` event
*
* 3. **Manual Invalidation:**
* - `invalidateWorkspace($workspace)` - Clears all config for a workspace
* - `invalidateKey($key)` - Clears a key across all scopes
* - Both dispatch `ConfigInvalidated` event
*
* 4. **Full Re-prime:**
* - `prime($workspace)` - Clears and recomputes all config for a scope
* - `primeAll()` - Primes system config + all workspaces (scheduled job)
*
* ### Lazy Loading
*
* When a key is not found in the hash:
* 1. If scope not loaded, `loadScope()` loads all resolved values for the scope
* 2. If still not found, `resolve()` computes and stores the value
* 3. Result is stored in both hash (for current request) and database (persistent)
*
* ### Events for Module Integration
*
* Modules can listen to cache events to refresh their own caches:
* - `ConfigChanged` - Fired when a config value is set/updated
* - `ConfigLocked` - Fired when a config value is locked
* - `ConfigInvalidated` - Fired when cache is manually invalidated
*
* ```php
* // In your module's Boot.php
* public static array $listens = [
* ConfigChanged::class => 'onConfigChanged',
* ];
*
* public function onConfigChanged(ConfigChanged $event): void
* {
* if ($event->keyCode === 'mymodule.api_key') {
* $this->refreshApiClient();
* }
* }
* ```
*
* @see ConfigResolver For the caching hash implementation
* @see ConfigResolved For the database cache model
* @see ConfigChanged Event fired on config changes
* @see ConfigInvalidated Event fired on cache invalidation
*/
class ConfigService
{
/**
* Current workspace context (Workspace model instance or null for system scope).
*/
protected ?object $workspace = null;
protected ?Channel $channel = null;
public function __construct(
protected ConfigResolver $resolver,
) {}
/**
* Set the current context (called by middleware).
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function setContext(?object $workspace, ?Channel $channel = null): void
{
$this->workspace = $workspace;
$this->channel = $channel;
}
/**
* Get current workspace context.
*
* @return object|null Workspace model instance or null
*/
public function getWorkspace(): ?object
{
return $this->workspace;
}
/**
* Get a config value.
*
* Context (workspace/channel) is set by middleware via setContext().
* This is just key/value - simple.
*/
public function get(string $key, mixed $default = null): mixed
{
$result = $this->resolve($key, $this->workspace, $this->channel);
return $result->get($default);
}
/**
* Get config for a specific workspace (admin use only).
*
* Use this when you need another workspace's settings - requires explicit intent.
*
* @param object $workspace Workspace model instance
*/
public function getForWorkspace(string $key, object $workspace, mixed $default = null): mixed
{
$result = $this->resolve($key, $workspace, null);
return $result->get($default);
}
/**
* Get a resolved ConfigResult.
*
* Read path:
* 1. Hash lookup (O(1))
* 2. Lazy load scope if not loaded (1 query)
* 3. Hash lookup again
* 4. Compute via resolver if still not found (lazy prime)
*
* @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 $key,
?object $workspace = null,
string|Channel|null $channel = null,
): ConfigResult {
$workspaceId = $workspace?->id;
$channelId = $this->resolveChannelId($channel, $workspace);
// 1. Check hash (O(1))
if (ConfigResolver::has($key)) {
// Get full result from ConfigResolved (indexed lookup with metadata)
$resolved = ConfigResolved::lookup($key, $workspaceId, $channelId);
if ($resolved !== null) {
return $resolved->toResult();
}
// Fallback: value in hash but not in DB (runtime provided)
return ConfigResult::found(
key: $key,
value: ConfigResolver::get($key),
type: ConfigType::STRING,
locked: false,
);
}
// 2. Scope not loaded - lazy load entire scope
if (! ConfigResolver::isLoaded()) {
$this->loadScope($workspaceId, $channelId);
// Check hash again
if (ConfigResolver::has($key)) {
$resolved = ConfigResolved::lookup($key, $workspaceId, $channelId);
if ($resolved !== null) {
return $resolved->toResult();
}
return ConfigResult::found(
key: $key,
value: ConfigResolver::get($key),
type: ConfigType::STRING,
locked: false,
);
}
}
// 3. Try JSON sub-key extraction
$subKeyResult = $this->resolveJsonSubKey($key, $workspace, $channel);
if ($subKeyResult->found) {
return $subKeyResult;
}
// 4. Check virtual providers
$virtualValue = $this->resolver->resolveFromProviders($key, $workspace, $channel);
if ($virtualValue !== null) {
$keyModel = ConfigKey::byCode($key);
$type = $keyModel?->type ?? ConfigType::STRING;
// Store in hash for next read
ConfigResolver::set($key, $virtualValue);
return ConfigResult::virtual(
key: $key,
value: $virtualValue,
type: $type,
);
}
// 5. Lazy prime: compute via resolver
$result = $this->resolver->resolve($key, $workspace, $channel);
// Store in hash
ConfigResolver::set($key, $result->value);
// Store in DB for future requests
if ($result->isConfigured()) {
ConfigResolved::store(
keyCode: $key,
value: $result->value,
type: $result->type,
workspaceId: $workspaceId,
channelId: $channelId,
locked: $result->locked,
sourceProfileId: $result->profileId,
sourceChannelId: $result->channelId,
virtual: $result->virtual,
);
}
return $result;
}
/**
* Load a scope into the hash from database.
*/
protected function loadScope(?int $workspaceId, ?int $channelId): void
{
$resolved = ConfigResolved::forScope($workspaceId, $channelId);
foreach ($resolved as $row) {
ConfigResolver::set($row->key_code, $row->getTypedValue());
}
ConfigResolver::markLoaded();
}
/**
* 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 {
$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));
$workspaceId = $workspace?->id;
$channelId = $this->resolveChannelId($channel, $workspace);
$resolved = ConfigResolved::lookup($parentKey, $workspaceId, $channelId);
if ($resolved !== null && is_array($resolved->value)) {
$subValue = data_get($resolved->value, $subPath);
if ($subValue !== null) {
$result = $resolved->toResult();
return new ConfigResult(
key: $keyCode,
value: $subValue,
type: $result->type,
found: true,
locked: $result->locked,
virtual: $result->virtual,
resolvedFrom: $result->resolvedFrom,
profileId: $result->profileId,
channelId: $result->channelId,
);
}
}
}
return ConfigResult::unconfigured($keyCode);
}
/**
* Check if a key (or prefix) is configured.
*
* Uses current context set by middleware.
*/
public function isConfigured(string $keyOrPrefix): bool
{
$workspaceId = $this->workspace?->id;
$channelId = $this->channel?->id;
// Check if it's a direct key
$resolved = ConfigResolved::lookup($keyOrPrefix, $workspaceId, $channelId);
if ($resolved !== null && $resolved->value !== null) {
return true;
}
// Check as prefix - single EXISTS query
// Escape LIKE wildcards to prevent unintended pattern matching
$escapedPrefix = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $keyOrPrefix);
return ConfigResolved::where('workspace_id', $workspaceId)
->where('channel_id', $channelId)
->where('key_code', 'LIKE', "{$escapedPrefix}.%")
->whereNotNull('value')
->exists();
}
/**
* Set a config value.
*
* Updates config_values (source of truth), then re-primes affected scope.
* Fires ConfigChanged event for invalidation hooks.
*
* @param string|Channel|null $channel Channel code or object
*
* @throws \InvalidArgumentException If key is unknown or value type is invalid
*/
public function set(
string $keyCode,
mixed $value,
ConfigProfile $profile,
bool $locked = false,
string|Channel|null $channel = null,
): void {
// Get key from DB (only during set, not reads)
$key = ConfigKey::byCode($keyCode);
if ($key === null) {
throw new \InvalidArgumentException("Unknown config key: {$keyCode}");
}
// Validate value type against schema
$this->validateValueType($value, $key->type, $keyCode);
$channelId = $this->resolveChannelId($channel, null);
// Capture previous value for event
$previousValue = ConfigValue::findValue($profile->id, $key->id, $channelId)?->value;
// Update source of truth
ConfigValue::setValue($profile->id, $key->id, $value, $locked, null, $channelId);
// Re-prime affected scope
$workspaceId = match ($profile->scope_type) {
Enums\ScopeType::WORKSPACE => $profile->scope_id,
default => null,
};
$this->primeKey($keyCode, $workspaceId, $channelId);
// Fire event for module hooks
ConfigChanged::dispatch($keyCode, $value, $previousValue, $profile, $channelId);
}
/**
* Lock a config value (FINAL - child cannot override).
* Fires ConfigLocked event.
*/
public function lock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void
{
// Get key from DB (only during lock, not reads)
$key = ConfigKey::byCode($keyCode);
if ($key === null) {
throw new \InvalidArgumentException("Unknown config key: {$keyCode}");
}
$channelId = $this->resolveChannelId($channel, null);
$value = ConfigValue::findValue($profile->id, $key->id, $channelId);
if ($value === null) {
throw new \InvalidArgumentException("No value set for {$keyCode} in profile {$profile->id}");
}
$value->update(['locked' => true]);
// Re-prime - lock affects all child scopes
$this->primeKey($keyCode);
// Fire event for module hooks
ConfigLocked::dispatch($keyCode, $profile, $channelId);
}
/**
* Unlock a config value.
*/
public function unlock(string $keyCode, ConfigProfile $profile, string|Channel|null $channel = null): void
{
// Get key from DB (only during unlock, not reads)
$key = ConfigKey::byCode($keyCode);
if ($key === null) {
return;
}
$channelId = $this->resolveChannelId($channel, null);
$value = ConfigValue::findValue($profile->id, $key->id, $channelId);
if ($value === null) {
return;
}
$value->update(['locked' => false]);
// Re-prime
$this->primeKey($keyCode);
}
/**
* Register a virtual provider.
*
* Virtual providers supply config values from module data
* without database storage.
*
* @param string $pattern Key pattern (supports * wildcard)
* @param callable $provider fn(string $key, ?Workspace, ?Channel): mixed
*/
public function virtual(string $pattern, callable $provider): void
{
$this->resolver->registerProvider($pattern, $provider);
}
/**
* Get all config values for a workspace.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, mixed>
*/
public function all(?object $workspace = null, string|Channel|null $channel = null): array
{
$workspaceId = $workspace?->id;
$channelId = $this->resolveChannelId($channel, $workspace);
$resolved = ConfigResolved::forScope($workspaceId, $channelId);
$values = [];
foreach ($resolved as $row) {
$values[$row->key_code] = $row->getTypedValue();
}
return $values;
}
/**
* Get all config values for a category.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, mixed>
*/
public function category(
string $category,
?object $workspace = null,
string|Channel|null $channel = null,
): array {
$workspaceId = $workspace?->id;
$channelId = $this->resolveChannelId($channel, $workspace);
// Escape LIKE wildcards to prevent unintended pattern matching
$escapedCategory = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $category);
$resolved = ConfigResolved::where('workspace_id', $workspaceId)
->where('channel_id', $channelId)
->where('key_code', 'LIKE', "{$escapedCategory}.%")
->get();
$values = [];
foreach ($resolved as $row) {
$values[$row->key_code] = $row->getTypedValue();
}
return $values;
}
/**
* Prime the resolved table for a workspace.
*
* This is THE computation - runs full resolution and stores results.
* Call after workspace creation, config changes, or on schedule.
*
* Populates both hash (process-scoped) and database (persistent).
*
* ## When to Call Prime
*
* - After creating a new workspace
* - After bulk config changes (migrations, imports)
* - From a scheduled job (`config:prime` command)
* - After significant profile hierarchy changes
*
* ## What Prime Does
*
* 1. Clears existing resolved values (hash + DB) for the scope
* 2. Runs full resolution for all config keys
* 3. Stores results in both hash and database
* 4. Marks hash as "loaded" to prevent re-loading
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function prime(?object $workspace = null, string|Channel|null $channel = null): void
{
$workspaceId = $workspace?->id;
$channelId = $this->resolveChannelId($channel, $workspace);
// Clear existing resolved values (hash + DB)
ConfigResolver::clearAll();
ConfigResolved::clearScope($workspaceId, $channelId);
// Run full resolution
$results = $this->resolver->resolveAll($workspace, $channel);
// Store all resolved values (hash + DB)
foreach ($results as $code => $result) {
// Store in hash (process-scoped)
ConfigResolver::set($code, $result->value);
// Store in database (persistent)
ConfigResolved::store(
keyCode: $code,
value: $result->value,
type: $result->type,
workspaceId: $workspaceId,
channelId: $channelId,
locked: $result->locked,
sourceProfileId: $result->profileId,
sourceChannelId: $result->channelId,
virtual: $result->virtual,
);
}
// Mark hash as loaded
ConfigResolver::markLoaded();
}
/**
* Prime a single key across all affected scopes.
*
* Clears and re-computes a specific key in both hash and database.
*/
public function primeKey(string $keyCode, ?int $workspaceId = null, ?int $channelId = null): void
{
// Clear from hash (pattern match)
ConfigResolver::clear($keyCode);
// Clear from database
ConfigResolved::where('key_code', $keyCode)
->when($workspaceId !== null, fn ($q) => $q->where('workspace_id', $workspaceId))
->when($channelId !== null, fn ($q) => $q->where('channel_id', $channelId))
->delete();
// Re-compute this key for the affected scope
$workspace = null;
if ($workspaceId !== null && class_exists(\Core\Tenant\Models\Workspace::class)) {
$workspace = \Core\Tenant\Models\Workspace::find($workspaceId);
}
$channel = $channelId ? Channel::find($channelId) : null;
$result = $this->resolver->resolve($keyCode, $workspace, $channel);
// Store in hash (process-scoped)
ConfigResolver::set($keyCode, $result->value);
// Store in database (persistent)
ConfigResolved::store(
keyCode: $keyCode,
value: $result->value,
type: $result->type,
workspaceId: $workspaceId,
channelId: $channelId,
locked: $result->locked,
sourceProfileId: $result->profileId,
sourceChannelId: $result->channelId,
virtual: $result->virtual,
);
}
/**
* Prime cache for all workspaces.
*
* Run this from a scheduled command or queue job.
* Requires Core\Tenant module to prime workspace-level config.
*/
public function primeAll(): void
{
// Prime system config
$this->prime(null);
// Prime each workspace (requires Tenant module)
if (class_exists(\Core\Tenant\Models\Workspace::class)) {
\Core\Tenant\Models\Workspace::chunk(100, function ($workspaces) {
foreach ($workspaces as $workspace) {
$this->prime($workspace);
}
});
}
}
/**
* Invalidate (clear) resolved config for a workspace.
*
* Clears both hash and database. Next read will lazy-prime.
* Fires ConfigInvalidated event.
*
* ## Cache Invalidation Behaviour
*
* This method performs a "soft" invalidation:
* - Clears the in-memory hash (immediate effect)
* - Clears the database resolved table (persistent effect)
* - Does NOT re-compute values immediately
* - Values are lazy-loaded on next read (lazy-prime)
*
* Use `prime()` instead if you need immediate re-computation.
*
* ## Listening for Invalidation
*
* ```php
* use Core\Config\Events\ConfigInvalidated;
*
* public function handle(ConfigInvalidated $event): void
* {
* if ($event->isFull()) {
* // Full invalidation - clear all module caches
* } elseif ($event->affectsKey('mymodule.setting')) {
* // Specific key was invalidated
* }
* }
* ```
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function invalidateWorkspace(?object $workspace = null): void
{
$workspaceId = $workspace?->id;
// Clear hash (process-scoped)
ConfigResolver::clearAll();
// Clear database (persistent)
ConfigResolved::clearWorkspace($workspaceId);
ConfigInvalidated::dispatch(null, $workspaceId, null);
}
/**
* Invalidate (clear) resolved config for a key.
*
* Clears both hash and database. Next read will lazy-prime.
* Fires ConfigInvalidated event.
*/
public function invalidateKey(string $key): void
{
// Clear hash (process-scoped)
ConfigResolver::clear($key);
// Clear database (persistent)
ConfigResolved::clearKey($key);
ConfigInvalidated::dispatch($key, null, null);
}
/**
* Resolve channel to ID.
*/
protected function resolveChannelId(string|Channel|null $channel, ?Workspace $workspace): ?int
{
if ($channel === null) {
return null;
}
if ($channel instanceof Channel) {
return $channel->id;
}
$channelModel = Channel::byCode($channel, $workspace?->id);
return $channelModel?->id;
}
/**
* Ensure a config key exists (for dynamic registration).
*/
public function ensureKey(
string $code,
ConfigType $type,
string $category,
?string $description = null,
mixed $default = null,
): ConfigKey {
return ConfigKey::firstOrCreate(
['code' => $code],
[
'type' => $type,
'category' => $category,
'description' => $description,
'default_value' => $default,
]
);
}
/**
* Register a config key if it doesn't exist.
*
* Convenience method for Boot.php files.
* Note: This persists to database. For runtime-only values, use provide().
*/
public function register(
string $code,
string $type,
string $category,
?string $description = null,
mixed $default = null,
): void {
$this->ensureKey($code, ConfigType::from($type), $category, $description, $default);
}
/**
* Provide a runtime value.
*
* Modules call this to share settings with other code in the process.
* Process-scoped, not persisted to database. Dies with the request.
*
* Usage in Boot.php:
* $config->provide('mymodule.api_key', env('MYMODULE_API_KEY'));
* $config->provide('mymodule.timeout', 30, 'int');
*
* @param string $code Key code (e.g., 'mymodule.api_key')
* @param mixed $value The value
* @param string|ConfigType $type Value type for casting (currently unused, value stored as-is)
*/
public function provide(string $code, mixed $value, string|ConfigType $type = 'string'): void
{
// Runtime values just go in the hash (system scope)
ConfigResolver::set($code, $value);
}
/**
* Check if a runtime value has been provided.
*/
public function hasProvided(string $code): bool
{
return ConfigResolver::has($code);
}
/**
* Validate that a value matches the expected config type.
*
* @throws \InvalidArgumentException If value type is invalid
*/
protected function validateValueType(mixed $value, ConfigType $type, string $keyCode): void
{
// Null is allowed for any type (represents unset)
if ($value === null) {
return;
}
$valid = match ($type) {
ConfigType::STRING => is_string($value) || is_numeric($value),
ConfigType::BOOL => is_bool($value) || in_array($value, [0, 1, '0', '1', 'true', 'false'], true),
ConfigType::INT => is_int($value) || (is_string($value) && ctype_digit(ltrim($value, '-'))),
ConfigType::FLOAT => is_float($value) || is_int($value) || is_numeric($value),
ConfigType::ARRAY, ConfigType::JSON => is_array($value),
};
if (! $valid) {
$actualType = get_debug_type($value);
throw new \InvalidArgumentException(
"Invalid value type for config key '{$keyCode}': expected {$type->value}, got {$actualType}"
);
}
}
}