From 38a128288c59cf0e4af58ea13099a82c065ce675 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sat, 14 Nov 2026 05:11:18 -0500 Subject: [PATCH 1/2] Add runtime overrides held in memory for one request or job --- src/Console/ShowCommand.php | 11 +- src/Contracts/Settings.php | 37 ++ src/Facades/Settings.php | 11 +- src/Services/Resolution/Overrides.php | 93 +++++ src/Services/Resolution/Resolver.php | 41 +- src/Services/Values/ValuePipeline.php | 38 +- src/SettingsManager.php | 79 +++- src/Testing/SettingsFake.php | 26 ++ src/Typed/TypedSettings.php | 17 +- .../Database/RuntimeOverridesDatabaseTest.php | 135 +++++++ tests/Feature/RuntimeOverridesTest.php | 361 ++++++++++++++++++ 11 files changed, 832 insertions(+), 17 deletions(-) create mode 100644 src/Services/Resolution/Overrides.php create mode 100644 tests/Feature/Database/RuntimeOverridesDatabaseTest.php create mode 100644 tests/Feature/RuntimeOverridesTest.php diff --git a/src/Console/ShowCommand.php b/src/Console/ShowCommand.php index 36e53f0..cb5535a 100644 --- a/src/Console/ShowCommand.php +++ b/src/Console/ShowCommand.php @@ -23,6 +23,7 @@ use Simtabi\Laranail\Settings\Actions\Reads\ExplainSetting; use Simtabi\Laranail\Settings\Exceptions\InvalidIdentifier; use Simtabi\Laranail\Settings\Exceptions\SettingsException; +use Simtabi\Laranail\Settings\Services\Resolution\Overrides; use Simtabi\Laranail\Settings\Services\Definitions\Definitions; use Simtabi\Laranail\Settings\Services\Resolution\SettingTargets; use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames; @@ -30,7 +31,8 @@ /** * Show how one setting resolves: value, layer, type, version, flags and the latest audit entry. * For a cached answer it also reads the stored row past the cache and flags a stale cache. - * Sensitive values print as `[protected]` unless `--reveal` is given. + * Sensitive values print as `[protected]` unless `--reveal` is given. An answer from a runtime + * override, set earlier in the same process, is marked as one. */ final class ShowCommand extends Command { @@ -72,6 +74,7 @@ public function handle(Settings $settings, ExplainSetting $explain, SettingTarge ['Locale', $address->locale === '' ? '-' : $address->locale], ['Value', $this->value($explanation, $sensitive)], ['Layer', $lookup->layer() ?? '-'], + ...$this->overrideRows($lookup->layer()), ['From scope', $lookup->scope()->value ?? '-'], ['Type', $record->type ?? $definition->type ?? '-'], ['Version', $record instanceof Record ? (string) $record->version : '-'], @@ -168,6 +171,12 @@ private function cacheRows(?Record $cached, Record|false|null $stored): array ]; } + /** @return list a row marking an answer from a runtime override, which no store holds */ + private function overrideRows(?string $layer): array + { + return $layer === Overrides::LAYER ? [['Override', 'yes: held in memory for this request or job, not stored']] : []; + } + private function value(Explanation $explanation, bool $sensitive): string { $lookup = $explanation->lookup; diff --git a/src/Contracts/Settings.php b/src/Contracts/Settings.php index 2ff1b5d..9454e97 100644 --- a/src/Contracts/Settings.php +++ b/src/Contracts/Settings.php @@ -93,6 +93,43 @@ public function configuration(): RuntimeConfiguration; */ public function withConfiguration(callable $change, callable $run): mixed; + /** + * Override a value in memory for the rest of the request or job: reads of this handle's store, + * owner and locale answer with it ahead of every stored layer, and owners below a global + * override see it too unless they hold their own. Nothing is written, cached, audited or + * versioned, and no ability is checked. The value is validated and cast as a write is. + * + * @param string|array $key one key, or `key => value` pairs + * + * @throws InvalidKey when a key is not a valid setting key + * @throws InvalidSettingValue when a value fails the definition's rules or `values.max_bytes` + * @throws UnknownSetting when the store is strict and has no definition for a key + * @throws OwnerNotAllowed when the store does not accept the handle's owner + */ + public function override(string|array $key, mixed $value = null): void; + + /** Drop this handle's own override of the key; reads fall back to what is stored. */ + public function forgetOverride(string $key): void; + + /** Whether this handle (its store, owner and locale) holds its own override of the key. */ + public function hasOverride(string $key): bool; + + /** Drop every override held for the current request or job, whatever handle set it. */ + public function clearOverrides(): void; + + /** + * Override values while `$run` runs, then put every override back as it was, also when `$run` + * throws. Calls nest. + * + * @template TResult + * + * @param array $values `key => value` pairs, as {@see override()} takes + * @param callable(): TResult $run + * + * @return TResult + */ + public function withOverrides(array $values, callable $run): mixed; + /** The value, or `$default` when nothing is stored and the definition has no default. */ public function get(string $key, mixed $default = null): mixed; diff --git a/src/Facades/Settings.php b/src/Facades/Settings.php index e8ac7bb..a43887f 100644 --- a/src/Facades/Settings.php +++ b/src/Facades/Settings.php @@ -41,6 +41,7 @@ use Simtabi\Laranail\Settings\Data\Transfer\ExportDocument; use Simtabi\Laranail\Settings\Services\Resolution\Resolver; use Simtabi\Laranail\Settings\Actions\Reads\ListDefinitions; +use Simtabi\Laranail\Settings\Services\Resolution\Overrides; use Simtabi\Laranail\Settings\Services\Stores\SettingWriter; use Simtabi\Laranail\Settings\Services\Stores\StoreRegistry; use Simtabi\Laranail\Settings\Services\Values\ValuePipeline; @@ -63,6 +64,11 @@ * @method static Actor|null actor() * @method static RuntimeConfiguration configuration() * @method static mixed withConfiguration(callable $change, callable $run) + * @method static void override(string|array $key, mixed $value = null) + * @method static void forgetOverride(string $key) + * @method static bool hasOverride(string $key) + * @method static void clearOverrides() + * @method static mixed withOverrides(array $values, callable $run) * @method static mixed get(string $key, mixed $default = null) * @method static Lookup lookup(string $key) * @method static bool has(string $key) @@ -125,9 +131,12 @@ public static function fake(): SettingsFake $access = $access->withAuditor($access->auditor()->alsoTo($log->audit)); $guard = new WriteGuard($access, $app->make(SettingWriter::class), $app->make(ProposeChange::class)); + // The engine and its resolver share the request's overrides, so they end with the request. + $overrides = $app->make(Overrides::class); $fake = new SettingsFake($app->make(SettingsManager::class, [ 'stores' => $stores, - 'resolver' => new Resolver($stores, $app->make(ValuePipeline::class), self::fallbackLocale($app->make('config')->get('app.fallback_locale'))), + 'overrides' => $overrides, + 'resolver' => new Resolver($stores, $app->make(ValuePipeline::class), $overrides, self::fallbackLocale($app->make('config')->get('app.fallback_locale'))), 'access' => $access, 'set' => $app->make(SetSetting::class, ['guard' => $guard]), 'forget' => $app->make(ForgetSetting::class, ['guard' => $guard]), diff --git a/src/Services/Resolution/Overrides.php b/src/Services/Resolution/Overrides.php new file mode 100644 index 0000000..0554e04 --- /dev/null +++ b/src/Services/Resolution/Overrides.php @@ -0,0 +1,93 @@ +>>> tenant|store => scope => key => locale => value */ + private array $values = []; + + public function __construct(private readonly ?TenantDiscriminator $tenant = null) {} + + public function put(StoreName $store, Scope $scope, Key $key, Locale $locale, mixed $value): void + { + $this->values[$this->slot($store)][$scope->value][$key->value][$locale->value] = $value; + } + + public function has(StoreName $store, Scope $scope, Key $key, Locale $locale): bool + { + return array_key_exists($locale->value, $this->values[$this->slot($store)][$scope->value][$key->value] ?? []); + } + + /** The overriding value; check {@see has()} first, since null is a value. */ + public function get(StoreName $store, Scope $scope, Key $key, Locale $locale): mixed + { + return $this->values[$this->slot($store)][$scope->value][$key->value][$locale->value] ?? null; + } + + public function forget(StoreName $store, Scope $scope, Key $key, Locale $locale): void + { + unset($this->values[$this->slot($store)][$scope->value][$key->value][$locale->value]); + } + + /** @return list the keys overridden in one store and scope, in any locale */ + public function keys(StoreName $store, Scope $scope): array + { + $keys = array_keys(array_filter($this->values[$this->slot($store)][$scope->value] ?? [], static fn (array $locales): bool => $locales !== [])); + + return array_map(strval(...), $keys); + } + + public function isEmpty(): bool + { + return $this->values === []; + } + + /** Drop every override, for every store, scope and tenant. */ + public function clear(): void + { + $this->values = []; + } + + /** + * Every override, to hand back to {@see restore()}. + * + * @return array>>> + */ + public function snapshot(): array + { + return $this->values; + } + + /** @param array>>> $snapshot from {@see snapshot()} */ + public function restore(array $snapshot): void + { + $this->values = $snapshot; + } + + /** One slot per tenant and store; `|` cannot occur in a store name. */ + private function slot(StoreName $store): string + { + return ($this->tenant?->current() ?? '') . '|' . $store->value; + } +} diff --git a/src/Services/Resolution/Resolver.php b/src/Services/Resolution/Resolver.php index 5219760..5359974 100644 --- a/src/Services/Resolution/Resolver.php +++ b/src/Services/Resolution/Resolver.php @@ -23,8 +23,9 @@ /** * Resolves a read through the cascade: owner, parents, global value, then the definition default. * - * Layers are tried in order at each step, and a stored null is an answer. Each store is read - * once per resolution. + * Runtime overrides are tried first, along the same owner, parents and global walk, and answer + * without reading a store. Then layers are tried in order at each step, and a stored null is an + * answer. Each store is read once per resolution. */ #[Scoped] final readonly class Resolver @@ -32,6 +33,7 @@ public function __construct( private StoreRegistry $stores, private ValuePipeline $pipeline, + private Overrides $overrides, #[Config('app.fallback_locale', 'en')] private string $fallbackLocale, private PreloadedRecords $preloaded = new PreloadedRecords, @@ -60,9 +62,15 @@ public function forgetPreloaded(Store $store, Scope $scope): void public function resolve(Store $store, Key $key, Locale $locale, ?OwnerRef $owner, array $parents, ?Definition $definition): Explanation { $plan = $this->plan($store, $owner, $parents, $definition); + $steps = []; + $overridden = $this->overridden($plan, $key, $locale, $definition, $steps); + + if ($overridden instanceof Lookup) { + return new Explanation($overridden, $steps); + } + $loaded = $this->loadAll($plan); $precedence = $definition->precedence ?? Precedence::Store; - $steps = []; foreach ($plan as [$stepStore, $scope]) { foreach ($this->locales($locale, $definition) as $candidate) { @@ -89,6 +97,33 @@ public function resolve(Store $store, Key $key, Locale $locale, ?OwnerRef $owner ); } + /** + * The runtime override that answers this read, walking the plan's scopes and locales in order; + * null when none does. Adds a step per override tried, and none when no override is held, so + * an explanation reads as before for code that never overrides. + * + * @param list $plan + * @param list $steps + */ + private function overridden(array $plan, Key $key, Locale $locale, ?Definition $definition, array &$steps): ?Lookup + { + if ($this->overrides->isEmpty()) { + return null; + } + + foreach ($plan as [$stepStore, $scope]) { + foreach ($this->locales($locale, $definition) as $candidate) { + $steps[] = sprintf('%s / %s / %s / %s', $stepStore->name, $scope, $candidate->value === '' ? '(none)' : $candidate, Overrides::LAYER); + + if ($this->overrides->has($stepStore->name, $scope, $key, $candidate)) { + return Lookup::found($this->overrides->get($stepStore->name, $scope, $key, $candidate), Overrides::LAYER, $scope); + } + } + } + + return null; + } + /** * The store and scope steps to try, in order. * diff --git a/src/Services/Values/ValuePipeline.php b/src/Services/Values/ValuePipeline.php index 7721b15..4067222 100644 --- a/src/Services/Values/ValuePipeline.php +++ b/src/Services/Values/ValuePipeline.php @@ -34,18 +34,21 @@ public function __construct( /** The encoded, and where defined encrypted, payload; refused when larger than `values.max_bytes`. */ public function toPayload(Key $key, mixed $value, ?Definition $definition): string { - $this->validator->validate($key, $value, $definition->rules ?? []); + $payload = $this->encoded($key, $value, $definition); - $type = $definition?->type; - $stored = $type === null ? $value : $this->casters->get($type)->toStorage($key, $value); - $payload = $this->encoder->encode($key, $stored); - $limit = is_numeric($this->maxBytes) ? (int) $this->maxBytes : null; + return $definition instanceof Definition && $definition->encrypted ? $this->encrypter->encrypt($payload) : $payload; + } - if ($limit !== null && strlen($payload) > $limit) { - throw new InvalidSettingValue(sprintf('Setting [%s]: the value is %d bytes, over the %d-byte limit in laranail.settings.values.max_bytes.', $key, strlen($payload), $limit)); - } + /** + * The value a read returns after writing this one, without storing or encrypting it: validated, + * cast, encoded, held to `values.max_bytes` and read back, as a runtime override holds it. + */ + public function inMemory(Key $key, mixed $value, ?Definition $definition): mixed + { + $decoded = $this->encoder->decode($key, $this->encoded($key, $value, $definition)); + $type = $definition?->type; - return $definition instanceof Definition && $definition->encrypted ? $this->encrypter->encrypt($payload) : $payload; + return $type === null ? $decoded : $this->casters->get($type)->fromStorage($decoded); } public function fromRecord(Record $record, ?Definition $definition): mixed @@ -56,4 +59,21 @@ public function fromRecord(Record $record, ?Definition $definition): mixed return $type === null ? $decoded : $this->casters->get($type)->fromStorage($decoded); } + + /** Validate raw, cast and encode; refused when larger than `values.max_bytes`. */ + private function encoded(Key $key, mixed $value, ?Definition $definition): string + { + $this->validator->validate($key, $value, $definition->rules ?? []); + + $type = $definition?->type; + $stored = $type === null ? $value : $this->casters->get($type)->toStorage($key, $value); + $payload = $this->encoder->encode($key, $stored); + $limit = is_numeric($this->maxBytes) ? (int) $this->maxBytes : null; + + if ($limit !== null && strlen($payload) > $limit) { + throw new InvalidSettingValue(sprintf('Setting [%s]: the value is %d bytes, over the %d-byte limit in laranail.settings.values.max_bytes.', $key, strlen($payload), $limit)); + } + + return $payload; + } } diff --git a/src/SettingsManager.php b/src/SettingsManager.php index edcbce2..641e69a 100644 --- a/src/SettingsManager.php +++ b/src/SettingsManager.php @@ -68,8 +68,10 @@ use Simtabi\Laranail\Settings\Data\Transfer\ExportDocument; use Simtabi\Laranail\Settings\Services\Resolution\Resolver; use Simtabi\Laranail\Settings\Actions\Reads\ListDefinitions; +use Simtabi\Laranail\Settings\Services\Resolution\Overrides; use Simtabi\Laranail\Settings\Services\Resolution\ScopeKeys; use Simtabi\Laranail\Settings\Services\Stores\StoreRegistry; +use Simtabi\Laranail\Settings\Services\Values\ValuePipeline; use Simtabi\Laranail\Settings\Actions\Approvals\ProposeChange; use Simtabi\Laranail\Settings\Configuration\PromotionSettings; use Simtabi\Laranail\Settings\Services\Definitions\Definitions; @@ -124,6 +126,8 @@ public function __construct( private readonly Approvals $approvals, private readonly PersistedConfigurationEditor $configurationEditor, private readonly PromoteWhenReady $promoteWhenReady, + private readonly Overrides $overrides, + private readonly ValuePipeline $pipeline, ) {} public function store(string $name): static @@ -184,6 +188,47 @@ public function withConfiguration(callable $change, callable $run): mixed return $this->configuration()->temporarily($change, $run); } + public function override(string|array $key, mixed $value = null): void + { + foreach (is_array($key) ? $key : [$key => $value] as $name => $one) { + [$store, $settingKey, $locale, $definition] = $this->overrideTarget(is_string($name) ? $name : throw new InvalidKey('override() takes a key and a value, or key => value pairs.')); + + $this->overrides->put($store->name, $this->scope(), $settingKey, $locale, $this->pipeline->inMemory($settingKey, $one, $definition)); + } + } + + public function forgetOverride(string $key): void + { + [$store, $settingKey, $locale] = $this->overrideTarget($key); + + $this->overrides->forget($store->name, $this->scope(), $settingKey, $locale); + } + + public function hasOverride(string $key): bool + { + [$store, $settingKey, $locale] = $this->overrideTarget($key); + + return $this->overrides->has($store->name, $this->scope(), $settingKey, $locale); + } + + public function clearOverrides(): void + { + $this->overrides->clear(); + } + + public function withOverrides(array $values, callable $run): mixed + { + $before = $this->overrides->snapshot(); + + try { + $this->override($values); + + return $run(); + } finally { + $this->overrides->restore($before); + } + } + public function get(string $key, mixed $default = null): mixed { return $this->lookup($key)->valueOr($default); @@ -239,7 +284,11 @@ public function all(?string $prefix = null): array $prefix = $prefix === null ? null : new Key(rtrim($prefix, '.'))->value; $values = []; - foreach (new ScopeKeys($this->registry)->keys($store, $this->scope()) as $key) { + $keys = new ScopeKeys($this->registry)->keys($store, $this->scope()); + $keys = array_values(array_unique([...$keys, ...$this->overrides->keys($this->overrideStore($store)->name, $this->scope())])); + sort($keys); + + foreach ($keys as $key) { if ($prefix !== null && $key !== $prefix && ! str_starts_with($key, $prefix . '.')) { continue; } @@ -571,6 +620,34 @@ private function resolve(Store $store, Key $key, ?Definition $definition): Expla return $this->resolver->resolve($store, $key, $this->activeLocale(), $this->owner, $this->parents, $definition); } + /** + * Where an override of the key is held: the store a read of this handle's scope looks in (a + * dedicated store's global step reads its fallback store), the key, the locale (none for a + * key whose definition is not localised) and the definition. + * + * @return array{Store, Key, Locale, ?Definition} + */ + private function overrideTarget(string $key): array + { + $settingKey = new Key($key); + $store = $this->targetStore(); + $definition = $this->definition($store, $settingKey); + + if (! $definition instanceof Definition && $store->strict) { + throw new UnknownSetting(sprintf('Store [%s] is strict and has no definition for [%s].', $store->name, $settingKey)); + } + + $locale = $definition instanceof Definition && ! $definition->localised ? Locale::none() : $this->activeLocale(); + + return [$this->overrideStore($store), $settingKey, $locale, $definition]; + } + + /** The store the resolver reads this handle's scope from. */ + private function overrideStore(Store $store): Store + { + return $this->owner instanceof OwnerRef ? $store : $this->stores->globalOf($store); + } + private function writeTarget(string $key): SettingTarget { $settingKey = new Key($key); diff --git a/src/Testing/SettingsFake.php b/src/Testing/SettingsFake.php index 7e74033..fc869fd 100644 --- a/src/Testing/SettingsFake.php +++ b/src/Testing/SettingsFake.php @@ -96,6 +96,32 @@ public function withConfiguration(callable $change, callable $run): mixed return $this->inner->withConfiguration($change, $run); } + /** Held in memory by the real engine; not recorded, so assertSaved() never sees it. */ + public function override(string|array $key, mixed $value = null): void + { + $this->inner->override($key, $value); + } + + public function forgetOverride(string $key): void + { + $this->inner->forgetOverride($key); + } + + public function hasOverride(string $key): bool + { + return $this->inner->hasOverride($key); + } + + public function clearOverrides(): void + { + $this->inner->clearOverrides(); + } + + public function withOverrides(array $values, callable $run): mixed + { + return $this->inner->withOverrides($values, $run); + } + public function get(string $key, mixed $default = null): mixed { return $this->inner->get($key, $default); diff --git a/src/Typed/TypedSettings.php b/src/Typed/TypedSettings.php index f27d446..ef97bbd 100644 --- a/src/Typed/TypedSettings.php +++ b/src/Typed/TypedSettings.php @@ -14,6 +14,7 @@ use Simtabi\Laranail\Settings\Typed\Attributes\Store; use Simtabi\Laranail\Settings\Exceptions\TypeMismatch; use Simtabi\Laranail\Settings\Exceptions\InvalidDefinition; +use Simtabi\Laranail\Settings\Services\Resolution\Overrides; use Simtabi\Laranail\Settings\Services\Definitions\DefinitionRegistry; /** @@ -41,6 +42,9 @@ abstract class TypedSettings /** @var array each property's stored form as loaded */ private array $loaded = []; + /** @var array the properties a runtime override answered, which save() leaves unwritten until changed */ + private array $overridden = []; + final public function __construct() {} /** @@ -61,6 +65,10 @@ public static function load(Settings $settings): static $property->property->setValue($instance, $property->fromStored($lookup->value())); } + if ($lookup->layer() === Overrides::LAYER) { + $instance->overridden[$name] = true; + } + if ($property->property->isInitialized($instance)) { $instance->loaded[$name] = $property->toStored($property->property->getValue($instance)); } @@ -133,7 +141,8 @@ public static function properties(): array /** * Write the properties changed since `load()`. Given a handle, write every property through it - * instead. + * instead, except one a runtime override answered and that is unchanged: an override is never + * persisted. */ public function save(?Settings $settings = null): void { @@ -147,10 +156,14 @@ public function save(?Settings $settings = null): void $stored = $property->toStored($property->property->getValue($this)); - if (! $given && array_key_exists($name, $this->loaded) && $this->loaded[$name] === $stored) { + $unchanged = array_key_exists($name, $this->loaded) && $this->loaded[$name] === $stored; + + if ($unchanged && (! $given || isset($this->overridden[$name]))) { continue; } + unset($this->overridden[$name]); + $handle->set($property->key, $stored); $this->loaded[$name] = $stored; } diff --git a/tests/Feature/Database/RuntimeOverridesDatabaseTest.php b/tests/Feature/Database/RuntimeOverridesDatabaseTest.php new file mode 100644 index 0000000..c721250 --- /dev/null +++ b/tests/Feature/Database/RuntimeOverridesDatabaseTest.php @@ -0,0 +1,135 @@ + ['user'], 'typed.classes' => [MailSettings::class]]); +}); + +/** How many queries the callback sends to the default connection. */ +function overrideQueriesDuring(Closure $callback): int +{ + DB::flushQueryLog(); + DB::enableQueryLog(); + $callback(); + $count = count(DB::getQueryLog()); + DB::disableQueryLog(); + + return $count; +} + +/** The raw cached bag of the default store's global scope, or null when nothing is cached. */ +function overrideCachedBag(): mixed +{ + $driver = app(StoreRegistry::class)->default()->layers[0]->driver; + assert($driver instanceof CachingDriver); + $database = $driver->database(); + $store = new StoreName('settings'); + $cache = app(SettingsCache::class); + + $name = config('laranail.settings.cache.store'); + + return Cache::store(is_string($name) ? $name : null)->get($cache->bagKey(new CacheTarget($database->connectionName(), (string) $database->connection()->getDatabaseName(), $store, $database->tableFor($store)), Scope::global())); +} + +it('answers ahead of the database layer, and without a query', function (): void { + settings()->set('mail.from', 'stored@example.test'); + app()->forgetScopedInstances(); + + $cold = overrideQueriesDuring(static fn (): mixed => settings()->get('mail.from')); + app()->forgetScopedInstances(); + settings()->override('mail.from', 'override@example.test'); + $hit = overrideQueriesDuring(static function (): void { + expect(settings()->get('mail.from'))->toBe('override@example.test') + ->and(settings()->lookup('mail.from')->layer())->toBe('override'); + }); + + expect($cold)->toBeGreaterThan(0) + ->and($hit)->toBe(0); +}); + +it('never reaches the cache: the cached bag stays the stored one, and clearing the override returns it', function (): void { + settings()->set('mail.from', 'stored@example.test'); + app()->forgetScopedInstances(); + expect(settings()->get('mail.from'))->toBe('stored@example.test'); + $bag = overrideCachedBag(); + + settings()->override('mail.from', 'override@example.test'); + + expect($bag)->not->toBeNull() + ->and(settings()->get('mail.from'))->toBe('override@example.test') + ->and(overrideCachedBag())->toBe($bag); + + settings()->forgetOverride('mail.from'); + + expect(settings()->get('mail.from'))->toBe('stored@example.test') + ->and(overrideQueriesDuring(static fn (): mixed => settings()->get('mail.from')))->toBe(0); + + app()->forgetScopedInstances(); + + expect(settings()->get('mail.from'))->toBe('stored@example.test') + ->and(DB::table('settings')->where('key', 'mail.from')->count())->toBe(1); +}); + +it('lets typed settings load an override, and never saves it', function (): void { + settings()->set('mail.port', 25); + settings()->override(['mail.port' => 2525, 'site.name' => 'Override']); + + $mail = MailSettings::load(settings()); + + expect($mail->port)->toBe(2525) + ->and($mail->siteName)->toBe('Override'); + + $mail->save(); + $mail->save(settings()); + settings()->clearOverrides(); + + expect(settings()->get('mail.port'))->toBe(25) + ->and(settings()->lookup('site.name')->layer())->toBe('default') + ->and(DB::table('settings')->where('key', 'site.name')->exists())->toBeFalse(); +}); + +it('saves an overridden typed property once it is changed', function (): void { + settings()->override('mail.port', 2525); + $mail = MailSettings::load(settings()); + + $mail->port = 587; + $mail->save(); + settings()->clearOverrides(); + + expect(settings()->get('mail.port'))->toBe(587); +}); + +it('marks an override in laranail::settings.show', function (): void { + settings()->set('mail.from', 'stored@example.test'); + settings()->override('mail.from', 'override@example.test'); + + expect(Artisan::call('laranail::settings.show', ['key' => 'mail.from']))->toBe(0); + $output = Artisan::output(); + + expect($output)->toContain('override@example.test') + ->and($output)->toMatch('/Layer\s+\|\s+override/') + ->and($output)->toContain('held in memory for this request or job, not stored') + ->and($output)->toContain('settings / global / (none) / override'); + + settings()->clearOverrides(); + Artisan::call('laranail::settings.show', ['key' => 'mail.from']); + + expect(Artisan::output())->not->toContain('held in memory'); +}); diff --git a/tests/Feature/RuntimeOverridesTest.php b/tests/Feature/RuntimeOverridesTest.php new file mode 100644 index 0000000..f27726a --- /dev/null +++ b/tests/Feature/RuntimeOverridesTest.php @@ -0,0 +1,361 @@ +set('laranail.settings.stores.settings.owners', ['user', 'team']); +}); + +final readonly class OverrideMember implements Settable +{ + public function __construct(private int $id, private ?int $teamId) {} + + public function settingsOwner(): OwnerRef + { + return new OwnerRef('user', $this->id); + } + + public function settingsParents(): array + { + return OwnerRef::list(['team' => $this->teamId]); + } +} + +describe('precedence', function (): void { + it('answers ahead of every stored layer and the definition default, and forgetting it restores them', function (): void { + shipDefaults(['mail.from' => 'shipped@example.test', 'site.name' => 'Shipped']); + app(DefinitionRegistry::class)->define('ui.theme')->withDefault('light')->register(); + settings()->set('mail.from', 'stored@example.test'); + + settings()->override(['mail.from' => 'override@example.test', 'site.name' => 'Override', 'ui.theme' => 'dark']); + + expect(settings()->get('mail.from'))->toBe('override@example.test') + ->and(settings()->get('site.name'))->toBe('Override') + ->and(settings()->get('ui.theme'))->toBe('dark') + ->and(settings()->lookup('mail.from')->layer())->toBe(Overrides::LAYER); + + settings()->forgetOverride('mail.from'); + settings()->forgetOverride('site.name'); + settings()->forgetOverride('ui.theme'); + + expect(settings()->get('mail.from'))->toBe('stored@example.test') + ->and(settings()->lookup('mail.from')->layer())->toBe('runtime') + ->and(settings()->get('site.name'))->toBe('Shipped') + ->and(settings()->get('ui.theme'))->toBe('light'); + }); + + it('treats a null override as an answer, ahead of a stored value', function (): void { + settings()->set('mail.from', 'stored@example.test'); + settings()->override('mail.from'); + + expect(settings()->has('mail.from'))->toBeTrue() + ->and(settings()->get('mail.from', 'default'))->toBeNull(); + }); + + it('applies a global override to every owner, unless the owner has its own', function (): void { + app(DefinitionRegistry::class)->define('ui.theme')->inherits()->register(); + $alice = new OverrideMember(1, teamId: 7); + $bob = new OverrideMember(2, teamId: 7); + settings()->for($alice)->set('ui.theme', 'stored-for-alice'); + + settings()->override('ui.theme', 'global-override'); + + expect(settings()->for($alice)->get('ui.theme'))->toBe('global-override') + ->and(settings()->for($bob)->get('ui.theme'))->toBe('global-override'); + + settings()->for($bob)->override('ui.theme', 'bob-override'); + + expect(settings()->for($bob)->get('ui.theme'))->toBe('bob-override') + ->and(settings()->for($alice)->get('ui.theme'))->toBe('global-override') + ->and(settings()->get('ui.theme'))->toBe('global-override'); + }); + + it('walks the parents: a team override reaches its members ahead of the global one', function (): void { + app(DefinitionRegistry::class)->define('ui.density')->inherits()->register(); + settings()->override('ui.density', 'global'); + settings()->for(new OwnerRef('team', 7))->override('ui.density', 'team'); + + expect(settings()->for(new OverrideMember(1, teamId: 7))->get('ui.density'))->toBe('team') + ->and(settings()->for(new OverrideMember(2, teamId: 8))->get('ui.density'))->toBe('global') + ->and(settings()->for(new OverrideMember(1, teamId: 7))->lookup('ui.density')->scope()?->value)->toBe('team:7'); + }); + + it('keeps a global override from an owner whose definition does not inherit', function (): void { + app(DefinitionRegistry::class)->define('mail.signature')->withDefault('none')->register(); + settings()->override('mail.signature', 'global'); + + expect(settings()->for(new OverrideMember(1, teamId: null))->get('mail.signature'))->toBe('none'); + }); + + it('keys an override by locale, and falls back as a stored value does', function (): void { + config()->set('app.fallback_locale', 'en'); + app()->forgetScopedInstances(); + app(DefinitionRegistry::class)->define('site.tagline')->localised()->register(); + app(DefinitionRegistry::class)->define('site.name')->register(); + settings()->locale('fr')->set('site.tagline', 'Bonjour (stored)'); + + settings()->locale('fr')->override('site.tagline', 'Bonjour'); + settings()->locale('en')->override('site.tagline', 'Hello'); + settings()->locale('fr')->override('site.name', 'Acme'); + + expect(settings()->locale('fr')->get('site.tagline'))->toBe('Bonjour') + ->and(settings()->locale('de')->get('site.tagline'))->toBe('Hello') + ->and(settings()->locale('fr')->hasOverride('site.tagline'))->toBeTrue() + ->and(settings()->locale('de')->hasOverride('site.tagline'))->toBeFalse() + // A key whose definition is not localised holds one override, whatever the locale. + ->and(settings()->get('site.name'))->toBe('Acme'); + }); + + it('wins over a locked setting, which a write could not change', function (): void { + settings()->set('billing.plan', 'free'); + settings()->lock('billing.plan'); + + settings()->override('billing.plan', 'pro'); + + expect(settings()->get('billing.plan'))->toBe('pro'); + + settings()->clearOverrides(); + + expect(settings()->get('billing.plan'))->toBe('free'); + }); +}); + +describe('values', function (): void { + it('casts an override as a write is cast, so typed getters return the right type', function (): void { + app(DefinitionRegistry::class)->define('limits.seats')->ofType('int')->register(); + app(DefinitionRegistry::class)->define('features.beta')->ofType('bool')->register(); + + settings()->override(['limits.seats' => '12', 'features.beta' => 'true']); + + expect(settings()->integer('limits.seats'))->toBe(12) + ->and(settings()->boolean('features.beta'))->toBeTrue() + ->and(fn (): string => settings()->string('limits.seats'))->toThrow(TypeMismatch::class); + }); + + it('refuses a value its definition refuses, and one over values.max_bytes', function (): void { + app(DefinitionRegistry::class)->define('limits.seats')->ofType('int')->register(); + app(DefinitionRegistry::class)->define('mail.from')->rules(['email'])->register(); + + expect(fn () => settings()->override('limits.seats', 'many'))->toThrow(InvalidSettingValue::class, 'expects an integer') + ->and(fn () => settings()->override('mail.from', 'not-an-email'))->toThrow(InvalidSettingValue::class, 'failed validation: email'); + + config()->set('laranail.settings.values.max_bytes', 16); + app()->forgetScopedInstances(); + app()->forgetInstance(ValuePipeline::class); + + expect(fn () => settings()->override('site.name', str_repeat('x', 64)))->toThrow(InvalidSettingValue::class, 'values.max_bytes') + ->and(settings()->hasOverride('limits.seats'))->toBeFalse() + ->and(settings()->hasOverride('site.name'))->toBeFalse(); + }); + + it('refuses a list entry with no key, and an undefined key in a strict store', function (): void { + expect(fn () => settings()->override(['site.name']))->toThrow(InvalidKey::class, 'override()'); + + config()->set('laranail.settings.stores.settings.strict', true); + app()->forgetScopedInstances(); + + expect(fn () => settings()->override('nowhere.defined', 1))->toThrow(UnknownSetting::class); + }); +}); + +describe('withOverrides', function (): void { + it('holds the overrides while the callback runs and returns its result', function (): void { + settings()->set('mail.from', 'stored@example.test'); + + $seen = settings()->withOverrides(['mail.from' => 'inside@example.test'], static fn (): mixed => settings()->get('mail.from')); + + expect($seen)->toBe('inside@example.test') + ->and(settings()->get('mail.from'))->toBe('stored@example.test') + ->and(settings()->hasOverride('mail.from'))->toBeFalse(); + }); + + it('restores the previous overrides when the callback throws', function (): void { + settings()->override('mail.from', 'outer@example.test'); + + expect(fn (): mixed => settings()->withOverrides(['mail.from' => 'inner@example.test', 'site.name' => 'Inner'], static function (): never { + throw new RuntimeException('boom'); + }))->toThrow(RuntimeException::class, 'boom'); + + expect(settings()->get('mail.from'))->toBe('outer@example.test') + ->and(settings()->hasOverride('site.name'))->toBeFalse(); + }); + + it('nests, each level restoring the one around it', function (): void { + $trail = settings()->withOverrides(['site.name' => 'Outer'], static function (): array { + $inner = settings()->withOverrides(['site.name' => 'Inner', 'mail.from' => 'inner@example.test'], static fn (): array => [settings()->get('site.name'), settings()->get('mail.from')]); + + return [...$inner, settings()->get('site.name'), settings()->hasOverride('mail.from')]; + }); + + expect($trail)->toBe(['Inner', 'inner@example.test', 'Outer', false]) + ->and(settings()->hasOverride('site.name'))->toBeFalse(); + }); + + it('also drops an override the callback set itself', function (): void { + settings()->withOverrides([], static function (): void { + settings()->override('site.name', 'Set inside'); + }); + + expect(settings()->hasOverride('site.name'))->toBeFalse(); + }); +}); + +describe('nothing durable', function (): void { + it('writes, audits and dispatches nothing', function (): void { + $sink = auditInMemory(); + $configured = count($sink->entries()); + Event::fake([SettingSaving::class, SettingSaved::class]); + + settings()->override('site.name', 'Override'); + settings()->forgetOverride('site.name'); + settings()->withOverrides(['site.name' => 'Inner'], static fn (): mixed => settings()->get('site.name')); + settings()->clearOverrides(); + + expect($sink->entries())->toHaveCount($configured) + ->and(settings()->has('site.name'))->toBeFalse(); + Event::assertNotDispatched(SettingSaving::class); + Event::assertNotDispatched(SettingSaved::class); + }); + + it('needs no write ability, while reading still needs view and view_sensitive', function (): void { + app(DefinitionRegistry::class)->define('mail.api_key')->sensitive()->register(); + settings()->override('mail.api_key', TestCredentials::secret()); + allowGates(Ability::View); + + // Overriding is a code-level call: an actor without update still holds an override. + settings()->as(user())->override('site.name', 'Acme'); + + expect(settings()->as(user())->get('site.name'))->toBe('Acme') + ->and(fn (): Lookup => settings()->as(user())->lookup('mail.api_key'))->toThrow(AccessDenied::class, '[view_sensitive]') + ->and(settings()->as(user())->explain('mail.api_key')->isRedacted())->toBeTrue(); + }); + + it('ends with the request or job: a flush of scoped instances drops every override', function (): void { + settings()->set('mail.from', 'stored@example.test'); + + // Request one. + settings()->override('mail.from', 'request-one@example.test'); + expect(settings()->get('mail.from'))->toBe('request-one@example.test'); + + // What Octane and the queue worker run between lifecycles. + app()->forgetScopedInstances(); + + // Request two, in the same container. + expect(settings()->get('mail.from'))->toBe('stored@example.test') + ->and(settings()->hasOverride('mail.from'))->toBeFalse() + ->and(app(Overrides::class)->isEmpty())->toBeTrue(); + }); + + it('is shared by every handle of the request, and clearOverrides() drops them all', function (): void { + settings()->override('site.name', 'Global'); + settings()->for(new OwnerRef('user', 1))->override('site.name', 'User'); + + expect(app(Simtabi\Laranail\Settings\Contracts\Settings::class)->for(new OwnerRef('user', 1))->hasOverride('site.name'))->toBeTrue(); + + settings()->for(new OwnerRef('user', 9))->clearOverrides(); + + expect(settings()->hasOverride('site.name'))->toBeFalse() + ->and(settings()->for(new OwnerRef('user', 1))->hasOverride('site.name'))->toBeFalse(); + }); +}); + +describe('visibility', function (): void { + it('lists overridden keys in all()', function (): void { + settings()->set('mail.from', 'stored@example.test'); + settings()->override('site.name', 'Override'); + + expect(settings()->all())->toBe(['mail.from' => 'stored@example.test', 'site.name' => 'Override']); + }); + + it('reports the override as the source in explain(), and leaves the steps alone without one', function (): void { + settings()->set('site.name', 'Stored'); + $before = settings()->explain('site.name'); + + settings()->override('site.name', 'Override'); + $explained = settings()->explain('site.name'); + + expect($before->steps)->each->not->toContain(Overrides::LAYER) + ->and($explained->lookup->layer())->toBe('override') + ->and($explained->lookup->scope()?->value)->toBe('global') + ->and($explained->lookup->value())->toBe('Override') + ->and($explained->steps)->toBe(['settings / global / (none) / override']); + }); +}); + +describe('the fake', function (): void { + it('answers with an override and never records it as saved', function (): void { + $fake = Settings::fake(); + Settings::set('site.name', 'Stored'); + + Settings::override('site.name', 'Override'); + + expect(Settings::get('site.name'))->toBe('Override') + ->and(Settings::hasOverride('site.name'))->toBeTrue() + ->and(Settings::withOverrides(['site.name' => 'Inner'], static fn (): mixed => Settings::get('site.name')))->toBe('Inner'); + + $fake->assertSaved('site.name', 'Stored'); + $fake->assertNotSaved('mail.from'); + Settings::override('mail.from', 'override@example.test'); + $fake->assertNotSaved('mail.from'); + + Settings::clearOverrides(); + + expect(Settings::get('site.name'))->toBe('Stored'); + }); + + it('drops the fake\'s overrides with the request, like the engine\'s', function (): void { + Settings::fake(); + Settings::override('site.name', 'Override'); + app()->forgetScopedInstances(); + + expect(app(Overrides::class)->isEmpty())->toBeTrue(); + }); +}); + +describe('the REST API', function (): void { + it('exposes no override route and calls no override method', function (): void { + $this->rebootWith(['laranail.settings.http.enabled' => true]); + $routes = array_filter(Route::getRoutes()->getRoutes(), static fn (Illuminate\Routing\Route $route): bool => str_starts_with((string) $route->getName(), 'laranail-settings.api.')); + $offending = array_filter($routes, static fn (Illuminate\Routing\Route $route): bool => str_contains(strtolower($route->uri() . ' ' . $route->getName()), 'override') && ! str_contains($route->uri(), 'override-lock')); + $sources = []; + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator(dirname(__DIR__, 2) . '/src/Http', FilesystemIterator::SKIP_DOTS)) as $file) { + if ($file instanceof SplFileInfo && $file->getExtension() === 'php') { + $sources[$file->getPathname()] = (string) file_get_contents($file->getPathname()); + } + } + + $calls = array_keys(array_filter($sources, static fn (string $code): bool => preg_match('/->(override|forgetOverride|hasOverride|clearOverrides|withOverrides)\(/', $code) === 1)); + + expect(count($routes))->toBeGreaterThanOrEqual(39) + ->and(count($sources))->toBeGreaterThanOrEqual(20) + ->and($offending)->toBe([]) + ->and($calls)->toBe([]); + }); +}); From f6cf8287a5018965ac892340b7f9171cb149724e Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sat, 14 Nov 2026 05:11:19 -0500 Subject: [PATCH 2/2] Document runtime overrides, with a reference page and a recipe --- CHANGELOG.md | 14 + README.md | 2 + docs/architecture.md | 23 +- docs/getting-started.md | 3 + .../overriding-a-setting-for-one-request.md | 117 +++++++++ docs/recipes/testing-with-the-fake.md | 14 + docs/tools/commands.md | 5 + docs/tools/runtime-configuration.md | 2 + docs/tools/runtime-overrides.md | 242 ++++++++++++++++++ docs/tools/settings-api.md | 64 ++++- tests/Unit/DocsConformanceTest.php | 4 +- tests/Unit/MermaidDiagramsTest.php | 6 +- 12 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 docs/recipes/overriding-a-setting-for-one-request.md create mode 100644 docs/tools/runtime-overrides.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c427cda..a7468f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Runtime overrides.** `override()`, `forgetOverride()`, `hasOverride()`, `clearOverrides()` and + `withOverrides()` on the `Settings` handle hold a value in memory for the current request or job, + ahead of every stored layer. An override is keyed like a stored value (store, owner, key, locale) + and follows the same owner, parents and global walk, so a global override reaches every owner + that has none of its own. It is validated and cast as a write is (including `values.max_bytes`), + never written, cached, audited, versioned or encrypted, needs no write ability, wins over locks + and precedence, and is not exposed over the REST API. `explain()` reports the layer `override`, + `laranail::settings.show` marks it, typed settings `load()` reads it and `save()` never persists + it, and the fake supports it. The new `Services\Resolution\Overrides` is bound scoped, so Octane + and the queue worker drop overrides between requests and jobs. See + `docs/tools/runtime-overrides.md`. + ## [0.1.1] - 2026-11-14 ### Added diff --git a/README.md b/README.md index b51fc20..bde8550 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ The hosted docs live at owner["owner scope, user:7"] + read["get('ui.theme') on a handle for user:7"] --> override["runtime overrides for this request or job, owner then parents then global"] + override -->|no override| owner["owner scope, user:7"] owner -->|nothing stored| parents["parent scopes, e.g. team:3, when the setting inherits"] parents -->|nothing stored| global["global scope"] global -->|nothing stored| definition["the definition's default"] @@ -399,6 +405,19 @@ the queries saved. On a local SQLite file a query is cheap, so against a databas each saved query is a round trip and the gain is larger. A shared cache store (Redis) costs two round trips per store on the first read of a request and none after it. +## Why runtime overrides sit in memory, first + +An override is for a value that should differ for one request, job or block of code: a preview, an +import that must not send mail, a support session, a test. Writing it to a store would make it +durable, audited, versioned and visible to every other request, which is the opposite of what the +caller asked for. So `Overrides` is a plain scoped object (one instance per request or job, dropped +by `forgetScopedInstances()` under Octane and the queue worker), the resolver asks it before any +layer, and it is keyed like a stored value so the owner, parents, global and locale walk is the +same. It is a different thing from `RuntimeConfiguration`, which changes the configuration (stores, +layers, definitions, access) rather than a value. Overrides are a code-level call: they need no +write ability, ignore locks and precedence, and are not exposed over the REST API, because a value +that is never stored changes nothing another caller can see. + ## Why headless? The package ships no views, Blade components, Livewire, Filament or assets. A settings engine is consumed by many interfaces (an admin panel, a CLI, an API client, another package), and any UI bundled here would be the wrong one for most of them and would drag view dependencies into every host. Its surfaces are the PHP API, events, Artisan commands and an opt-in JSON API; an admin UI is a separate companion package. An architecture test fails on any view, Blade, Livewire or Filament reference in `src/` and on a `resources/views` directory. diff --git a/docs/getting-started.md b/docs/getting-started.md index 72f027e..4b26734 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -101,6 +101,9 @@ errors and what to run. - [Caching](tools/caching.md), [History](tools/history.md) and [Approvals](tools/approvals.md) for production use. - [Tenancy](tools/tenancy.md) before the first migration of a multi-tenant application. +- [Runtime overrides](tools/runtime-overrides.md) to change what settings read for one request or + job without writing anything; the [recipe](recipes/overriding-a-setting-for-one-request.md) shows + middleware, a queued job and a feature switch. --- diff --git a/docs/recipes/overriding-a-setting-for-one-request.md b/docs/recipes/overriding-a-setting-for-one-request.md new file mode 100644 index 0000000..ee6359b --- /dev/null +++ b/docs/recipes/overriding-a-setting-for-one-request.md @@ -0,0 +1,117 @@ +# Override a setting for one request + +Change what settings read for one request, one queued job or one block of code, without writing anything. + +An override is held in memory and answers ahead of every stored value; it ends with the request or job. The full reference is [Runtime overrides](../tools/runtime-overrides.md). + +## For one request, in middleware + +A preview of a theme a user has not saved yet, passed as a signed query parameter: + +```php +namespace App\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; +use Simtabi\Laranail\Settings\Contracts\Settings; + +final readonly class PreviewTheme +{ + public function __construct(private Settings $settings) {} + + public function handle(Request $request, Closure $next): Response + { + $theme = $request->query('preview_theme'); + + if ($request->hasValidSignature() && is_string($theme) && $request->user() !== null) { + $this->settings->for($request->user())->override('ui.theme', $theme); + } + + return $next($request); + } +} +``` + +Everything that reads `ui.theme` for that user in this request (views, view composers, an API resource) sees the preview. Nothing is saved, the next request reads the stored theme, and Octane drops the override between requests. The key is named in code: never take the key from the request. + +## For one queued job + +A backfill that must not send mail while it runs, whatever the application's mail setting says: + +```php +namespace App\Jobs; + +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Queue\Queueable; +use Simtabi\Laranail\Settings\Contracts\Settings; + +final class BackfillInvoices implements ShouldQueue +{ + use Queueable; + + public function __construct(public int $tenantId) {} + + public function handle(Settings $settings, InvoiceBackfill $backfill): void + { + $settings->withOverrides( + ['notifications.mail_enabled' => false, 'limits.batch_size' => 500], + fn () => $backfill->run($this->tenantId), + ); + } +} +``` + +The queue worker starts every job with no overrides, so set them inside `handle()`: a job does not inherit the overrides of the request that dispatched it. `withOverrides()` restores the previous state when the callback returns or throws, so a retry starts clean too. + +## As a feature-flag style switch + +Turn a feature on for staff for the request, while the stored value stays off for everyone else: + +```php +namespace App\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; +use Simtabi\Laranail\Settings\Contracts\Settings; + +final readonly class StaffSeesBetaFeatures +{ + public function __construct(private Settings $settings) {} + + public function handle(Request $request, Closure $next): Response + { + if ($request->user()?->is_staff === true) { + $this->settings->for($request->user())->override([ + 'features.new_checkout' => true, + 'features.ai_summary' => true, + ]); + } + + return $next($request); + } +} +``` + +Code reads the flag as it always does, `$settings->for($user)->boolean('features.new_checkout')`, and cannot tell an override from a stored value. `explain()` can: its layer is `override`. Overriding needs no write ability and is not audited, so keep it for decisions your code makes; a flag a person turns on for someone belongs in `set()`, where it is authorized and audited. + +## In a test + +When one test needs a single value different, an override is lighter than the fake: + +```php +use Simtabi\Laranail\Settings\Contracts\Settings; + +it('hides the beta banner when the feature is off', function () { + app(Settings::class)->override('features.beta', false); + + $this->get('/')->assertDontSee('Try the beta'); +}); +``` + +Use [`Settings::fake()`](testing-with-the-fake.md) instead when you need to assert on what the code wrote. + +--- + +[← Docs index](../../README.md#documentation) diff --git a/docs/recipes/testing-with-the-fake.md b/docs/recipes/testing-with-the-fake.md index 3a7b8d4..9b2e165 100644 --- a/docs/recipes/testing-with-the-fake.md +++ b/docs/recipes/testing-with-the-fake.md @@ -45,6 +45,20 @@ it('refuses a non-admin', function () { `auditEntries()` returns every recorded entry. +## Fake or override? + +Use the fake when the test is about what the code **writes**: it replaces every layer with memory and records each save, forget and audit entry for the assertions above. Use a [runtime override](../tools/runtime-overrides.md) when the test only needs one value to **read** differently and the engine can stay real: + +```php +it('hides the beta banner when the feature is off', function () { + Settings::override('features.beta', false); + + $this->get('/')->assertDontSee('Try the beta'); +}); +``` + +An override is never recorded as saved, so with the fake installed, `assertNotSaved()` still passes for an overridden key. The two combine: `Settings::fake()` first, then `Settings::override()` or `Settings::withOverrides()` on top. The [override recipe](overriding-a-setting-for-one-request.md) covers overrides outside tests. + --- [← Docs index](../../README.md#documentation) diff --git a/docs/tools/commands.md b/docs/tools/commands.md index 4394783..2ecd813 100644 --- a/docs/tools/commands.md +++ b/docs/tools/commands.md @@ -106,6 +106,11 @@ definition, or show `-`. `Last audit` is the newest entry for that key and scope audit trail is not stored in the database. The command runs as the system actor, through the same `Actions\Reads\ExplainSetting` action as `explain()` and the REST API. +When a [runtime override](runtime-overrides.md) answered, `Layer` reads `override` and an +`Override` row says the value is held in memory for the request or job and not stored. Overrides +belong to the process that set them, so this shows only when the command is called from that code +(for example `Artisan::call()` in a test); a separate `php artisan` run has none. + When the answer came through the settings cache, `Stored` is the version of the row read past the cache, and `Cache` says `stale: run laranail::settings.clear-cache` when the two differ (a row edited by hand, say). diff --git a/docs/tools/runtime-configuration.md b/docs/tools/runtime-configuration.md index 1151137..9895d36 100644 --- a/docs/tools/runtime-configuration.md +++ b/docs/tools/runtime-configuration.md @@ -2,6 +2,8 @@ `$settings->configuration()` changes stores, layers, definitions, drivers and access for the current request only. +To change a setting's **value** for the request instead, use a [runtime override](runtime-overrides.md). + ## Scope `RuntimeConfiguration` is bound `scoped`: Octane and queue workers flush it between requests and jobs, so nothing set here outlives the request that set it. Store and layer entries merge over `laranail.settings.stores` and `.layers`; the runtime entry wins. diff --git a/docs/tools/runtime-overrides.md b/docs/tools/runtime-overrides.md new file mode 100644 index 0000000..b2a1163 --- /dev/null +++ b/docs/tools/runtime-overrides.md @@ -0,0 +1,242 @@ +# Runtime overrides + +Five methods on the `Settings` handle (`Simtabi\Laranail\Settings\Contracts\Settings`) hold a setting's value in memory for the current request or job, ahead of every stored layer. + +An override changes what a read returns, here, for now, and nothing else. It is not a write: nothing reaches the database, the runtime file, the cache, the audit trail or the history, no event fires, and no other request or job sees it. It is not [runtime configuration](runtime-configuration.md) either: `configuration()` changes how settings are stored and governed (stores, layers, drivers, definitions, access), while an override changes one setting's value and leaves the configuration alone. And it is not the [test fake](../recipes/testing-with-the-fake.md): the fake swaps the whole engine for in-memory layers and records writes for assertions, while an override sits on top of whichever engine is bound, real or fake. + +## When to use which + +| You want to | Use | Durable | Audited | Seen by other requests | +|---|---|---|---|---| +| Change a value for everyone, from now on | [`set()`](settings-api.md#set) | yes | yes | yes | +| Change a value for this request, job or block of code only | `override()` / `withOverrides()` | no | no | no | +| Change stores, layers, definitions or who may do what, for this request | [`configuration()`](runtime-configuration.md) / `withConfiguration()` | no | the change is logged | no | +| Test code that reads and writes settings, and assert on its writes | [`Settings::fake()`](../recipes/testing-with-the-fake.md) | no (in memory) | to the fake's log | no | +| Give one owner a value of its own, kept | [`for($owner)->set()`](owners.md) | yes | yes | yes | + +Typical overrides: a preview of unsaved changes, an import or backfill that must not send mail, a support session that sees a feature turned on, a queued job that runs one tenant's work with a different limit, and a test that needs one value different without faking the engine. + +## Methods + +Every method runs on any handle, so `store()`, `for()` and `locale()` choose where the override is held, exactly as they choose where `set()` writes. None of them needs an ability, and none is reachable over the REST API. + +### `override()` + +```php +public function override(string|array $key, mixed $value = null): void +``` + +```php +use Simtabi\Laranail\Settings\Contracts\Settings; + +public function handle(Settings $settings): void +{ + // One key, global: every owner reads it, unless the owner has an override of its own. + $settings->override('mail.default', 'log'); + + // Several keys at once. + $settings->override([ + 'features.beta' => true, + 'limits.seats' => 50, + ]); + + // One owner, one locale. + $settings->for($user)->locale('fr')->override('site.tagline', 'Bienvenue'); +} +``` + +Returns nothing. The value goes through the same validation and casting as a write (the definition's `rules`, its type's caster and `laranail.settings.values.max_bytes`), so a later `integer()` or `boolean()` returns the definition's type. A second `override()` of the same key, owner and locale replaces the first. `null` is a value: a read answers `null` and `has()` is true. + +| Throws | When | +|---|---| +| `InvalidKey` | a key is not a valid setting key, or the array has an entry without a key | +| `InvalidSettingValue` | a value fails the definition's rules or caster, or encodes to more than `values.max_bytes` | +| `UnknownSetting` | the store is strict and has no definition for the key | +| `OwnerNotAllowed` | the handle names a store that does not accept its owner | + +With an array, keys are applied in order and the first refusal stops the rest; earlier keys stay overridden. + +### `forgetOverride()` + +```php +public function forgetOverride(string $key): void +``` + +```php +$settings->for($user)->forgetOverride('ui.theme'); + +$settings->for($user)->get('ui.theme'); // the stored value again, or a parent or global override +``` + +Returns nothing. Drops this handle's own override of the key (its store, owner and locale) and nothing else: an override held at a parent or the global scope still answers. Forgetting a key that has no override does nothing. Throws `InvalidKey`, `UnknownSetting` and `OwnerNotAllowed` as `override()` does. + +### `hasOverride()` + +```php +public function hasOverride(string $key): bool +``` + +```php +if ($settings->for($user)->hasOverride('ui.theme')) { + // this user holds their own override of ui.theme +} +``` + +Whether this handle holds its own override of the key. It does not walk to parents or the global scope; to know whether a read **is answered** by an override, ask the read itself: + +```php +$settings->for($user)->lookup('ui.theme')->layer() === 'override'; +``` + +Throws `InvalidKey`, `UnknownSetting` and `OwnerNotAllowed` as `override()` does. + +### `clearOverrides()` + +```php +public function clearOverrides(): void +``` + +```php +$settings->clearOverrides(); +``` + +Returns nothing. Drops every override held for the current request or job, whichever handle set it: every store, owner, locale and tenant. Use it at the end of a long-running loop that set overrides as it went; a request or job does not need it, because its overrides end with it. + +### `withOverrides()` + +```php +public function withOverrides(array $values, callable $run): mixed +``` + +```php +$html = $settings->withOverrides( + ['site.name' => $draft->name, 'ui.theme' => $draft->theme], + fn (): string => view('home')->render(), +); +``` + +Overrides `$values` (on this handle's store, owner and locale, as `override()` would), runs `$run`, and returns what it returns. Afterwards every override is put back exactly as it was before the call, in a `finally`, so this also holds when `$run` throws: overrides the callback set itself are dropped, and overrides that existed before come back. Calls nest, each restoring the state around it: + +```php +$settings->withOverrides(['site.name' => 'Outer'], function () use ($settings): void { + $settings->withOverrides(['site.name' => 'Inner'], function () use ($settings): void { + $settings->get('site.name'); // 'Inner' + }); + + $settings->get('site.name'); // 'Outer' +}); + +$settings->get('site.name'); // the stored value +``` + +Throws what `override()` throws, before `$run` runs (and then nothing is overridden), and whatever `$run` throws, after restoring. + +## Precedence + +An override answers before any store is read. The resolver walks the same scopes and locales for overrides as it does for stored values, and the first override it finds wins; only when no override applies does it read the layers. + +```mermaid +flowchart TD + read["get('ui.theme') on a handle for user:7"] --> overrides + subgraph overrides ["Runtime overrides, in memory"] + direction LR + o_owner["user:7"] --> o_parents["parents, when the setting inherits"] --> o_global["global"] + end + overrides -->|no override| stored + subgraph stored ["Stored values"] + direction LR + s_owner["user:7"] --> s_parents["parents"] --> s_global["global"] + end + stored -->|nothing stored| definition["the definition's default, then the caller's default"] +``` + +Three consequences worth knowing: + +- **A global override beats a stored owner value.** `$settings->override('ui.theme', 'dark')` makes every user read `dark`, including a user who saved `light`, unless that user has an override of their own. This is what "override" means here: it sits above the database, not beside the global row. +- **Inheritance is the definition's.** An owner reaches parent and global overrides only when the setting inherits (its definition's `inherits()`, or else the store's `inherits`), exactly as it reaches parent and global stored values. A setting that does not inherit sees only the owner's own override. +- **Precedence and locks do not apply.** A definition's `precedence` (`file` or `config`) chooses between stored layers, and a lock stops writes; an override is neither, so it answers for a locked setting and for one whose shipped file normally wins. That is deliberate: the override is a code-level decision made by the application for this request, and it changes nothing anyone else can see. If a value must never differ, do not override it. + +## Scoping and lifetime + +Overrides live in one object, `Services\Resolution\Overrides`, bound `scoped`. Every handle of the request shares it, so an override set through the facade is seen by a `Settings` injected elsewhere, and by the [typed settings](typed-settings.md) classes. + +- **HTTP request.** An override lasts until the response is sent. +- **Queued job.** The queue worker flushes scoped instances before each job, so an override set in one job is never seen by the next, even on a long-running worker. A job does not inherit the overrides of the request that dispatched it; set them inside the job. With the `sync` driver the job runs inside the dispatching request, so it does see that request's overrides. +- **Octane.** Octane flushes scoped instances between requests, so nothing leaks from one request into the next on the same worker. +- **A command or a long-running loop.** One process is one lifecycle: an override lasts until the command ends. Use `withOverrides()` around each unit of work, or `clearOverrides()` after it. +- **Tenancy.** Overrides are held per tenant, as preloaded rows are: an override set while one tenant is current does not answer while another is. + +`withOverrides()` is the safe form whenever the override belongs to a block of code rather than to the whole request: it cannot outlive the block, even when the block throws. + +## Owners and locales + +An override is keyed exactly like a stored value: store, owner scope, key and locale. + +```php +$settings->override('ui.theme', 'dark'); // global, default store +$settings->for($team)->override('ui.theme', 'blue'); // team:3, its members inherit it when ui.theme inherits +$settings->for($user)->override('ui.theme', 'high-contrast'); // user:7 only +$settings->store('product_settings')->for($product)->override('pricing.currency', 'EUR'); +``` + +The store is chosen as for a write: the named store, else the store that accepts the owner's alias, else the default store. A global override on a dedicated store is held where that store's reads look for global values, its fallback store. + +Locales follow the read's fallback: a read in `de` tries the `de` override, then the `app.fallback_locale` override, then the unlocalised one. A key whose definition is not `localised()` holds one override whatever locale the handle has, as it holds one stored value. + +```php +$settings->locale('en')->override('site.tagline', 'Welcome'); +$settings->locale('de')->get('site.tagline'); // 'Welcome', through the fallback locale +``` + +## Typed settings and casting + +Because an override is validated and cast like a write, the typed getters and typed settings classes see the definition's type: + +```php +$settings->override('limits.seats', '50'); // an `int` definition casts the numeric string +$settings->integer('limits.seats'); // 50 + +$mail = MailSettings::load($settings); // properties read overrides like any other value +``` + +`load()` reads overrides. `save()` never persists them: a property answered by an override and left unchanged is skipped, also when `save($settings)` is given a handle and would otherwise write every property. A property you change after loading is a real change, and `save()` writes it. + +## How overrides meet the rest of the package + +| Feature | With an override | +|---|---| +| **Cache** | Not involved. An override answers before the database layer is asked, so the cache is neither read nor filled for that key, and an override hit runs no query. Forgetting the override returns the cached or stored value unchanged. | +| **Locks** | Ignored: an override is not a write. A lock still stops `set()` and `forget()`. | +| **Encryption** | Not applied. An override is never at rest, so it is held as the plain value; it is never written, logged or put in an exception message. | +| **Sensitive values** | Reads are guarded as before: a sensitive key still needs `view_sensitive` for an actor, and `explain()` redacts it. | +| **Approvals** | Not involved: an override is not a change request and needs no approval. | +| **History and audit** | Nothing is recorded. There is no version, no history row and no audit entry. | +| **Events** | None fires: no `SettingSaving`, `SettingSaved` or any other event. | +| **`explain()`** | The answer's layer is `override` and its scope is where the override was found; each override tried is a step ending in `/ override`. With no override held, the steps are exactly as before. | +| **`laranail::settings.show`** | Shows `Layer: override` and an `Override` row, when the command runs in the process that set it (for example in a test, or from code calling `Artisan::call()`). A separate `php artisan` run starts a new process with no overrides. | +| **`all()`** | Lists overridden keys of the handle's store and scope beside the stored and defined ones. | +| **The fake** | `Settings::fake()` supports every method, and an override is never recorded as saved, so `assertNotSaved()` still passes. | + +## Security + +- **Code only.** Overriding is a call your application makes. It needs no ability, because it changes nothing durable and nothing another request sees; it is not a way for a user to change a setting. +- **Not on the REST API.** No route sets, lists or clears overrides, and none will: a value held for one HTTP request would vanish before a client could read it back, and exposing it would let a client change what server-side code reads in the same request. A test asserts that no route and no controller reaches these methods. +- **Reads stay guarded.** An override does not widen what an actor may read: `view` and `view_sensitive` apply as for a stored value. +- **Do not override from user input without checking it.** `override($request->input('key'), …)` would let a request change any setting your code reads in that request. Name the keys in code. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| An owner does not see a global override | the setting does not inherit | define it with `inherits()`, or override on the owner's handle | +| A locale does not see an override | the override was set for another locale, and neither the fallback nor the unlocalised one is set | override without a locale, or in `app.fallback_locale` | +| `hasOverride()` is false but `get()` returns the override | `hasOverride()` asks about this handle's own override; the answer came from a parent or the global scope | check `lookup($key)->layer() === 'override'` | +| The override is gone in a queued job | each job starts with no overrides | set it inside the job (see the [recipe](../recipes/overriding-a-setting-for-one-request.md)) | +| A long-running command keeps an old override | one process is one lifecycle | wrap each unit of work in `withOverrides()`, or call `clearOverrides()` | +| `show` does not mark the override | `php artisan` runs in a new process, with no overrides | call the command from the code that set the override, or use `explain()` | +| `override()` throws `InvalidSettingValue` | the value fails the definition's rules or type, or is over `values.max_bytes` | pass a value a `set()` would accept | +| `override()` throws `UnknownSetting` | the store is strict and the key is not defined | define the key first | + +--- + +[← Docs index](../../README.md#documentation) diff --git a/docs/tools/settings-api.md b/docs/tools/settings-api.md index 2227726..d7b83b3 100644 --- a/docs/tools/settings-api.md +++ b/docs/tools/settings-api.md @@ -35,6 +35,7 @@ $settings->store('user_settings')->for($user)->locale('fr')->as($admin)->because | Scoping (return a handle) | [`store()`](#store), [`for()`](#for), [`locale()`](#locale), [`as()`](#as), [`asSystem()`](#assystem), [`asCurrentActor()`](#ascurrentactor), [`because()`](#because) | | Reading | [`get()`](#get), [`lookup()`](#lookup), [`has()`](#has), [`missing()`](#missing), [`many()`](#many), [`all()`](#all), [`string()`, `integer()`, `float()`, `boolean()`, `array()`](#typed-getters), [`explain()`](#explain), [`definitions()`](#definitions) | | Writing | [`set()`](#set), [`setMany()`](#setmany), [`remember()`](#remember), [`forget()`](#forget), [`forgetMany()`](#forgetmany), [`lock()`, `unlock()`](#lock-and-unlock), [`flush()`](#flush) | +| Runtime overrides (in memory, this request or job) | [`override()`](#override), [`forgetOverride()`](#forgetoverride), [`hasOverride()`](#hasoverride), [`clearOverrides()`](#clearoverrides), [`withOverrides()`](#withoverrides) | | Cache and loading | [`preload()`](#preload), [`warmCache()`](#warmcache), [`clearCache()`](#clearcache) | | History | [`history()`](#history), [`historyDiff()`](#historydiff), [`rollback()`](#rollback) | | Approvals | [`propose()`](#propose), [`approvals()`](#approvals) | @@ -216,7 +217,7 @@ $explanation->lookup; // the answer, as lookup() returns it $explanation->steps; // every layer and scope tried, in order, as text ``` -How a read resolves: every step tried and the answer. For an actor without `view_sensitive` on a sensitive key, the answer is redacted (`isRedacted()`), the steps are kept. +How a read resolves: every step tried and the answer. For an actor without `view_sensitive` on a sensitive key, the answer is redacted (`isRedacted()`), the steps are kept. When a [runtime override](runtime-overrides.md) answered, `lookup->layer()` is `override` and the override steps come first. ### `definitions()` @@ -316,6 +317,67 @@ $settings->for($user)->asSystem('account closed')->flush(); Removes every value the handle's owner holds in its store, as when an owner is deleted (`HasSettings` does this for you). Needs `for($owner)` (a `LogicException` otherwise) and `forget`. Throws `AccessDenied`, `StoreNotWritable`. Fires `ScopeFlushing` and `ScopeFlushed`. +## Runtime overrides + +An override is a value held in memory for the current request or job that answers ahead of every stored layer. Nothing is written, cached, audited or versioned, no ability is checked, and no event fires; reads still need `view` and, for a sensitive key, `view_sensitive`. The full reference, with precedence and lifetime, is [Runtime overrides](runtime-overrides.md). + +### `override()` + +```php +public function override(string|array $key, mixed $value = null): void + +$settings->override('mail.default', 'log'); // global: every owner sees it, unless it has its own +$settings->for($user)->override('ui.theme', 'high-contrast'); // this owner only +$settings->override(['features.beta' => true, 'limits.seats' => 50]); +``` + +Holds the value for this handle's store, owner and locale until the request or job ends. Validated and cast as `set()` would, including `values.max_bytes`, so typed getters return the definition's type. Overrides a locked setting too. + +- **Throws** `InvalidKey`, `InvalidSettingValue`, `UnknownSetting` (strict store), `OwnerNotAllowed`. + +### `forgetOverride()` + +```php +public function forgetOverride(string $key): void + +$settings->for($user)->forgetOverride('ui.theme'); // reads fall back to the stored value +``` + +Drops this handle's own override of the key; an override at a parent or the global scope still answers. Throws as `override()` does for the key. + +### `hasOverride()` + +```php +public function hasOverride(string $key): bool + +$settings->for($user)->hasOverride('ui.theme'); +``` + +Whether this handle (store, owner, locale) holds its own override. It does not walk to parents or the global scope; `lookup()->layer() === 'override'` says whether a read is answered by one. + +### `clearOverrides()` + +```php +public function clearOverrides(): void + +$settings->clearOverrides(); +``` + +Drops every override held for the request or job, whichever handle set it. + +### `withOverrides()` + +```php +public function withOverrides(array $values, callable $run): mixed + +$preview = $settings->withOverrides( + ['site.name' => $draft->name, 'ui.theme' => $draft->theme], + fn (): string => view('home')->render(), +); +``` + +Overrides the values while `$run` runs, then puts every override back exactly as it was, also when `$run` throws; calls nest. Returns `$run`'s result. + ## Cache and loading ### `preload()` diff --git a/tests/Unit/DocsConformanceTest.php b/tests/Unit/DocsConformanceTest.php index 3f0f18a..6535d88 100644 --- a/tests/Unit/DocsConformanceTest.php +++ b/tests/Unit/DocsConformanceTest.php @@ -6,10 +6,10 @@ uses(AssertsDocsConformance::class); -// Floor = the 53 pages shipped today (7 guides, 26 reference, 20 recipes; counted 2026-11-14). Raise it as pages +// Floor = the 55 pages shipped today (7 guides, 27 reference, 21 recipes; counted 2026-11-14). Raise it as pages // are added; a glob that suddenly matches fewer has broken rather than found a clean tree. it('conforms to the org docs standard', function (): void { - $this->assertDocsConformToStandard(dirname(__DIR__, 2), 53); + $this->assertDocsConformToStandard(dirname(__DIR__, 2), 55); expect(true)->toBeTrue(); }); diff --git a/tests/Unit/MermaidDiagramsTest.php b/tests/Unit/MermaidDiagramsTest.php index 18491d2..088349e 100644 --- a/tests/Unit/MermaidDiagramsTest.php +++ b/tests/Unit/MermaidDiagramsTest.php @@ -61,9 +61,9 @@ function mermaidProblem(string $block): ?string $blocks = mermaidBlocks(); $count = array_sum(array_map(count(...), $blocks)); - // 54 files (README and 53 pages) and 8 diagrams, counted 2026-11-14; fewer means a broken glob. - expect(count($blocks))->toBeGreaterThanOrEqual(54) - ->and($count)->toBeGreaterThanOrEqual(8); + // 56 files (README and 55 pages) and 9 diagrams, counted 2026-11-14; fewer means a broken glob. + expect(count($blocks))->toBeGreaterThanOrEqual(56) + ->and($count)->toBeGreaterThanOrEqual(9); foreach ($blocks as $file => $diagrams) { foreach ($diagrams as $index => $diagram) {