From 872cebb87b8ea35ffda5bad028d1b437e868d14a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:41:00 +0000 Subject: [PATCH 01/39] Allow text columns for opaque serialized values in migrations The semantic column rule required jsonb() over json() for every JSON value. That is wrong for values the database never inspects: jsonb and MySQL's JSON type normalize object key order and PostgreSQL's jsonb rejects \u0000, so a value read back no longer matches what was written. Reword the rule so jsonb() is for JSON the database queries, indexes or validates, and text() is for opaque serialized values that are only read back whole. Group the existing UUID and network address guidance in the same sentence. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 51abb42f9..4f616a716 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,7 +202,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan - **Prefer union types over `mixed` when all types are known** — `mixed` is only for truly unconstrained values or cases that cannot be safely narrowed after control-flow analysis. - **Type decisions must be evidence-based** — check corresponding Laravel/Hyperf signatures and docblocks as a reference, then trace the real control flow through method bodies across all callers and callees to confirm the types are correct. - **Fail fast with framework and PHP exceptions** — don't add guards, wrapping, or defensive checks unless they handle a reachable invalid case or materially improve the error, and never swallow exceptions. If code would fail anyway (e.g. null passed to a typed parameter), let it fail naturally instead of adding a check that throws a custom exception — the stack trace is enough to diagnose. -- **Use semantic column types in migrations** — `jsonb()` over `json()`, `uuid()` / `foreignUuid()` / `uuidMorphs()` over strings for UUIDs, `ipAddress()` and `macAddress()` over `string()`. PostgreSQL gets the real types (`jsonb`, `uuid`, `inet`, `macaddr`); the other supported databases fall back automatically to compatible types. +- **Use semantic column types in migrations** — `uuid()` / `foreignUuid()` / `uuidMorphs()` for UUIDs, `ipAddress()` / `macAddress()` for network addresses, and `jsonb()` for JSON the database queries, indexes or validates. Use `text()` for opaque serialized values that are only read back whole. PostgreSQL gets the real types (`jsonb`, `uuid`, `inet`, `macaddr`); the other supported databases fall back automatically to compatible types. - **No arbitrary string lengths** — use `->string('name')`, not `->string('name', 100)`. Don't invent limits; specify a length only when the domain or protocol defines one — exact (UUID: 36, ULID: 26, sha-256 hex token: 64) or a defined maximum (IPv6 address: 45). - **No database enums** — use string columns plus PHP enums. Adding a value to a database enum requires a migration. - **Use `dateTime` for arbitrary dates** — use `dateTime` instead of `timestamp` for scheduled dates, expiry dates, historical dates, and similar arbitrary dates. Keep `timestamp` for dates recorded when an action happens, such as `created_at`, `updated_at`, and `deleted_at`. On MySQL and MariaDB, `timestamp` only supports 1970–2038. From 23ad7971aadb4267a288a831279231712b188efb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:41:12 +0000 Subject: [PATCH 02/39] Batch stack cache reads and writes per layer StackStore::many() and putMany() looped over keys and went through the single-key paths, so a cold batch on a Swoole and Redis stack cost one Redis round trip per key for reads and two writes per key for puts. many() now calls each layer's many() once for the keys still missing. As before, any non-null record ends a key's search and only records with a value are restored into the layers above. restoreRecords() fills the deepest layer first, grouping records by absolute expiration into one putMany() per group, and measures the remaining TTL just before each write because earlier reads and writes can yield. putMany() normalizes the records once and calls each layer's putMany() once through callStores(). A batch write can store some keys before it reports failure, so the new rollbackFailedStore option also compensates the failing layer on a false result. The rollback closure finishes a layer's cleanup after cancellation, but stops at the first ordinary failure rather than paying a timeout per key against an unreachable layer; a saved cancellation is still rethrown. Records whose expiration has passed are no longer written. A worker-array layer above Redis could read a record in the second after its integer expiration while Redis still held it, and a non-positive TTL made the array store keep it forever. putToStore() and putToStoreTagged() now forget the key instead, and the batch restore skips such records. normalizeRecord() replaces the normalization duplicated in putToStore() and putToStoreTagged(), and is shared with the restore path. The tests cover batched reads with partial hits in every layer, TTL grouping, forever records, malformed records, expiry measured as time moves during reads and writes, failure compensation of the failed and completed layers, partial writes before an exception, and cleanup after cancellation. Cross-layer order assertions now use globally()->ordered(): plain ordered() only orders calls within one mock, so the existing reverse-order tests did not check order across layers. --- src/cache/src/StackStore.php | 179 +++++++++++++++++++--- tests/Cache/CacheStackStoreTest.php | 220 ++++++++++++++++++++++++---- 2 files changed, 347 insertions(+), 52 deletions(-) diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php index ee799a179..972705a44 100644 --- a/src/cache/src/StackStore.php +++ b/src/cache/src/StackStore.php @@ -78,9 +78,40 @@ public function getAuthoritativeRaw(UnitEnum|string $key): mixed return ((array) $record)['value'] ?? null; } + /** + * Retrieve multiple items from the cache by key. + */ public function many(array $keys): array { - return array_map(fn ($key) => $this->get($key), array_combine($keys, $keys)); + $records = []; + $layers = []; + $missing = array_values(array_unique($keys)); + + foreach ($this->stores as $index => $store) { + if ($missing === []) { + break; + } + + $results = $store->many($missing); + $remaining = []; + + foreach ($missing as $key) { + if (is_null($record = $results[$key] ?? null)) { + $remaining[] = $key; + + continue; + } + + $records[$key] = (array) $record; + $layers[$key] = $index; + } + + $missing = $remaining; + } + + $this->restoreRecords($records, $layers); + + return array_map(static fn ($key) => $records[$key]['value'] ?? null, array_combine($keys, $keys)); } public function put(string $key, mixed $value, int $seconds): bool @@ -93,15 +124,43 @@ public function put(string $key, mixed $value, int $seconds): bool return $this->putRecord($key, $record); } + /** + * Store multiple items in the cache for a given number of seconds. + */ public function putMany(array $values, int $seconds): bool { - $result = true; - - foreach ($values as $key => $value) { - $result = $this->put((string) $key, $value, $seconds) && $result; + if ($values === []) { + return true; } - return $result; + $expiration = CarbonImmutable::now()->getTimestamp() + $seconds; + $records = array_map(static fn (mixed $value): array => ['value' => $value, 'expiration' => $expiration], $values); + + return $this->callStores( + static fn (StackStoreProxy $store) => $store->putMany($records, $seconds), + static function (StackStoreProxy $store) use ($records): void { + $cancellation = null; + + foreach (array_keys($records) as $key) { + try { + $store->forget((string) $key); + } catch (CanceledException $exception) { + // Finish this layer's cleanup, as callStores() does across layers. + $cancellation ??= $exception; + } catch (Throwable) { + // An ordinary failure usually means the layer is unreachable, so stop + // instead of paying a timeout per key. callStores() discards these. + break; + } + } + + if ($cancellation !== null) { + throw $cancellation; + } + }, + // A layer can store some keys before its batch write reports failure. + rollbackFailedStore: true, + ); } public function increment(string $key, int $value = 1): bool|int @@ -306,6 +365,54 @@ function (StackStoreProxy $store, Closure $next) use ($key): ?array { ); } + /** + * Restore records found in lower layers into the layers above them. + * + * Mirrors getOrRestoreRecord(), filling the deepest layer first, but writes + * each layer's records with one putMany() per remaining TTL. + * + * @param array $records + * @param array $layers the index of the layer each record was found in + */ + protected function restoreRecords(array $records, array $layers): void + { + $restorable = []; + + foreach ($layers as $key => $layer) { + if ($layer > 0 && array_key_exists('value', $records[$key])) { + $restorable[$key] = [$layer, $this->normalizeRecord($records[$key])[0]]; + } + } + + for ($index = count($this->stores) - 2; $index >= 0 && $restorable !== []; --$index) { + $groups = []; + $forever = []; + + foreach ($restorable as $key => [$layer, $record]) { + if ($layer <= $index) { + continue; + } + + if (array_key_exists('expiration', $record)) { + $groups[$record['expiration']][$key] = $record; + } else { + $forever[$key] = $record; + } + } + + foreach ($groups as $expiration => $group) { + // Reads and earlier writes can yield, so measure the remaining TTL just before each write. + if (($ttl = $expiration - CarbonImmutable::now()->getTimestamp()) > 0) { + $this->stores[$index]->putMany($group, $ttl); + } + } + + foreach ($forever as $key => $record) { + $this->stores[$index]->forever((string) $key, $record); + } + } + } + protected function putRecord(string $key, array $record): bool { return $this->callStores( @@ -327,23 +434,46 @@ public function putRecordTagged(array $tags, string $key, array $record): bool ); } + /** + * Store a record in a single layer. + */ protected function putToStore(StackStoreProxy $store, string $key, array $record): bool { if (! array_key_exists('value', $record)) { return false; } + [$record, $ttl] = $this->normalizeRecord($record); + + return match (true) { + is_null($ttl) => $store->forever($key, $record), + $ttl > 0 => $store->put($key, $record, $ttl), + default => $store->forget($key), + }; + } + + /** + * Normalize a record to an absolute expiration and its remaining TTL. + * + * Records without an expiration or TTL are returned unchanged with a null TTL. + * A non-positive TTL means the record has no remaining lifetime (for example, it + * expired while a lower layer was read), so callers must not write it: array + * stores keep non-positive TTL writes forever. + * + * @return array{array, ?int} + */ + protected function normalizeRecord(array $record): array + { if (! array_key_exists('expiration', $record) && ! array_key_exists('ttl', $record)) { - return $store->forever($key, $record); + return [$record, null]; } $currentTimestamp = CarbonImmutable::now()->getTimestamp(); $value = $record['value']; $expiration = $record['expiration'] ?? $currentTimestamp + $record['ttl']; $ttl = $record['ttl'] ?? $record['expiration'] - $currentTimestamp; - $normalizedRecord = compact('value', 'expiration'); - return $store->put($key, $normalizedRecord, $ttl); + return [compact('value', 'expiration'), $ttl]; } /** @@ -365,24 +495,22 @@ protected function putToStoreTagged(StackStoreProxy $proxy, array $tags, string return false; } - if (! array_key_exists('expiration', $record) && ! array_key_exists('ttl', $record)) { - if (is_null($proxyTtl = $proxy->getTtl())) { - return $store->tags($tags)->forever($key, $record); - } + [$record, $ttl] = $this->normalizeRecord($record); + $proxyTtl = $proxy->getTtl(); - return $store->tags($tags)->put($key, $record, $proxyTtl); + if (is_null($ttl)) { + return is_null($proxyTtl) + ? $store->tags($tags)->forever($key, $record) + : $store->tags($tags)->put($key, $record, $proxyTtl); } - $currentTimestamp = CarbonImmutable::now()->getTimestamp(); - $value = $record['value']; - $expiration = $record['expiration'] ?? $currentTimestamp + $record['ttl']; - $ttl = $record['ttl'] ?? $record['expiration'] - $currentTimestamp; - $normalizedRecord = compact('value', 'expiration'); + if ($ttl <= 0) { + return $store->forget($key); + } - $proxyTtl = $proxy->getTtl(); $effectiveTtl = is_null($proxyTtl) || $ttl < $proxyTtl ? $ttl : $proxyTtl; - return $store->tags($tags)->put($key, $normalizedRecord, $effectiveTtl); + return $store->tags($tags)->put($key, $record, $effectiveTtl); } /** @@ -510,7 +638,10 @@ protected function callStoresStacked(Closure $handler, Closure $bottomLayer): mi }, $bottomLayer)(); } - protected function callStores(Closure $handler, ?Closure $rollback = null): bool + /** + * Call the handler for each store in order, rolling back the completed stores when one fails. + */ + protected function callStores(Closure $handler, ?Closure $rollback = null, bool $rollbackFailedStore = false): bool { $completed = []; $result = true; @@ -521,6 +652,10 @@ protected function callStores(Closure $handler, ?Closure $rollback = null): bool if (! $handler($store)) { $result = false; + if ($rollbackFailedStore) { + $completed[] = $store; + } + break; } diff --git a/tests/Cache/CacheStackStoreTest.php b/tests/Cache/CacheStackStoreTest.php index 26933ebd5..221223cdf 100644 --- a/tests/Cache/CacheStackStoreTest.php +++ b/tests/Cache/CacheStackStoreTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Cache; +use Closure; use Hypervel\Cache\ArrayStore; use Hypervel\Cache\CacheManager; use Hypervel\Cache\FailoverStore; @@ -354,8 +355,8 @@ public function testPutCompensatesEarlierLayerWhenLowerLayerThrows(): void $top->shouldReceive('put')->once()->andReturnTrue(); $middle->shouldReceive('put')->once()->andThrow($exception); $bottom->shouldNotReceive('put'); - $middle->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue(); - $top->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue(); + $middle->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andReturnTrue(); + $top->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andReturnTrue(); try { (new StackStore([$top, $middle, $bottom]))->put('foo', 'bar', 60); @@ -374,8 +375,8 @@ public function testPutCompensatesCompletedLayersInReverseOrder(): void $top->shouldReceive('put')->once()->andReturnTrue(); $middle->shouldReceive('put')->once()->andReturnTrue(); $bottom->shouldReceive('put')->once()->andReturnFalse(); - $middle->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue(); - $top->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue(); + $middle->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andReturnTrue(); + $top->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andReturnTrue(); $this->assertFalse((new StackStore([$top, $middle, $bottom]))->put('foo', 'bar', 60)); } @@ -496,8 +497,8 @@ public function testPutPreservesOriginalCancellationAndCompletesCompensation(): $top->shouldReceive('put')->once()->andReturnTrue(); $bottom->shouldReceive('put')->once()->andThrow($cancellation); - $bottom->shouldReceive('forget')->once()->with('foo')->ordered()->andThrow($cleanupCancellation); - $top->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue(); + $bottom->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andThrow($cleanupCancellation); + $top->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andReturnTrue(); try { (new StackStore([$top, $bottom]))->put('foo', 'bar', 60); @@ -515,8 +516,8 @@ public function testPutCleanupCancellationSupersedesOrdinaryWriteFailure(): void $top->shouldReceive('put')->once()->andReturnTrue(); $bottom->shouldReceive('put')->once()->andThrow(new RuntimeException('write failed')); - $bottom->shouldReceive('forget')->once()->with('foo')->ordered()->andThrow($cleanupCancellation); - $top->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue(); + $bottom->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andThrow($cleanupCancellation); + $top->shouldReceive('forget')->once()->with('foo')->globally()->ordered()->andReturnTrue(); try { (new StackStore([$top, $bottom]))->put('foo', 'bar', 60); @@ -548,23 +549,130 @@ public function testMany(): void { $this->createStores(); - $this->swoole->shouldReceive('get')->once()->with('foo')->andReturn(['value' => 'bar']); - $this->swoole->shouldReceive('get')->once()->with('bar')->andReturn(['value' => 'baz']); + $this->swoole->shouldReceive('many')->once()->with(['foo', 'bar'])->andReturn([ + 'foo' => ['value' => 'bar'], + 'bar' => ['value' => 'baz'], + ]); + $this->redis->shouldNotReceive('many'); $this->assertEquals(['foo' => 'bar', 'bar' => 'baz'], $this->store->many(['foo', 'bar'])); } + public function testManyReadsEachLayerOnceAndRestoresLowerHitsWithBatchedWrites(): void + { + $top = m::mock(ArrayStore::class); + $middle = m::mock(ArrayStore::class); + $bottom = m::mock(ArrayStore::class); + $expiration = CarbonImmutable::now()->getTimestamp() + 100; + $shorterExpiration = CarbonImmutable::now()->getTimestamp() + 50; + $middleRecord = ['value' => 'middle', 'expiration' => $expiration]; + $bottomRecord = ['value' => 'bottom', 'expiration' => $expiration]; + $shorterRecord = ['value' => 'shorter', 'expiration' => $shorterExpiration]; + $foreverRecord = ['value' => 'forever']; + + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds(30)); + + $top->shouldReceive('many')->once()->with(['top', 'middle', 'bottom', 'shorter', 'forever', 'missing', 'malformed'])->andReturn([ + 'top' => ['value' => 'top'], + 'middle' => null, + 'bottom' => null, + 'shorter' => null, + 'forever' => null, + 'missing' => null, + 'malformed' => null, + ]); + $middle->shouldReceive('many')->once()->with(['middle', 'bottom', 'shorter', 'forever', 'missing', 'malformed'])->andReturn([ + 'middle' => $middleRecord, + 'bottom' => null, + 'shorter' => null, + 'forever' => null, + 'missing' => null, + 'malformed' => ['expiration' => 123], + ]); + $bottom->shouldReceive('many')->once()->with(['bottom', 'shorter', 'forever', 'missing'])->andReturn([ + 'bottom' => $bottomRecord, + 'shorter' => $shorterRecord, + 'forever' => $foreverRecord, + 'missing' => null, + ]); + + $middle->shouldReceive('putMany')->once()->with(['bottom' => $bottomRecord], 70)->globally()->ordered()->andReturnTrue(); + $middle->shouldReceive('putMany')->once()->with(['shorter' => $shorterRecord], 20)->andReturnTrue(); + $middle->shouldReceive('forever')->once()->with('forever', $foreverRecord)->andReturnTrue(); + $top->shouldReceive('putMany')->once()->with(['middle' => $middleRecord, 'bottom' => $bottomRecord], 70)->globally()->ordered()->andReturnTrue(); + $top->shouldReceive('putMany')->once()->with(['shorter' => $shorterRecord], 20)->andReturnTrue(); + $top->shouldReceive('forever')->once()->with('forever', $foreverRecord)->andReturnTrue(); + + $this->assertSame([ + 'top' => 'top', + 'middle' => 'middle', + 'bottom' => 'bottom', + 'shorter' => 'shorter', + 'forever' => 'forever', + 'missing' => null, + 'malformed' => null, + ], (new StackStore([$top, $middle, $bottom]))->many(['top', 'middle', 'bottom', 'shorter', 'forever', 'missing', 'malformed'])); + } + + public function testManyMeasuresRemainingTtlBeforeEachRestoreAndSkipsRecordsThatExpired(): void + { + $top = m::mock(ArrayStore::class); + $middle = m::mock(ArrayStore::class); + $bottom = m::mock(ArrayStore::class); + $expiringRecord = ['value' => 'expiring', 'expiration' => CarbonImmutable::now()->getTimestamp() + 1]; + $bottomRecord = ['value' => 'bottom', 'expiration' => CarbonImmutable::now()->getTimestamp() + 100]; + $advance = static function (int $seconds, mixed $result): Closure { + return static function () use ($seconds, $result): mixed { + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds($seconds)); + + return $result; + }; + }; + + $top->shouldReceive('many')->once()->with(['expiring', 'bottom'])->andReturn(['expiring' => null, 'bottom' => null]); + $middle->shouldReceive('many')->once()->with(['expiring', 'bottom'])->andReturn(['expiring' => $expiringRecord, 'bottom' => null]); + $bottom->shouldReceive('many')->once()->with(['bottom'])->andReturnUsing($advance(5, ['bottom' => $bottomRecord])); + $middle->shouldReceive('putMany')->once()->with(['bottom' => $bottomRecord], 95)->andReturnUsing($advance(10, true)); + $top->shouldReceive('putMany')->once()->with(['bottom' => $bottomRecord], 85)->andReturnTrue(); + $top->shouldNotReceive('forever'); + + $this->assertSame( + ['expiring' => 'expiring', 'bottom' => 'bottom'], + (new StackStore([$top, $middle, $bottom]))->many(['expiring', 'bottom']) + ); + } + + public function testReadForgetsUpperLayerInsteadOfRestoringRecordThatExpiredDuringLowerRead(): void + { + $this->createStores(); + + $record = ['value' => 'bar', 'expiration' => CarbonImmutable::now()->getTimestamp() + 1]; + + $this->swoole->shouldReceive('get')->once()->with('foo')->andReturnNull(); + $this->redis->shouldReceive('get')->once()->with('foo')->andReturnUsing(function () use ($record): array { + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds(2)); + + return $record; + }); + $this->swoole->shouldReceive('forget')->once()->with('foo')->andReturnTrue(); + $this->swoole->shouldNotReceive('put'); + + $this->assertSame('bar', $this->store->get('foo')); + } + public function testPutMany(): void { $this->createStores(); $ttl = 100; $expiration = CarbonImmutable::now()->getTimestamp() + $ttl; + $records = [ + 'foo' => ['value' => 'bar', 'expiration' => $expiration], + 'bar' => ['value' => 'baz', 'expiration' => $expiration], + ]; - $this->swoole->shouldReceive('put')->once()->with('foo', ['value' => 'bar', 'expiration' => $expiration], $ttl)->andReturn(true); - $this->redis->shouldReceive('put')->once()->with('foo', ['value' => 'bar', 'expiration' => $expiration], $ttl)->andReturn(true); - $this->swoole->shouldReceive('put')->once()->with('bar', ['value' => 'baz', 'expiration' => $expiration], $ttl)->andReturn(true); - $this->redis->shouldReceive('put')->once()->with('bar', ['value' => 'baz', 'expiration' => $expiration], $ttl)->andReturn(true); + $this->swoole->shouldReceive('putMany')->once()->with($records, $ttl)->andReturn(true); + $this->redis->shouldReceive('putMany')->once()->with($records, $ttl)->andReturn(true); $this->assertTrue($this->store->putMany(['foo' => 'bar', 'bar' => 'baz'], $ttl)); } @@ -576,25 +684,77 @@ public function testPutManyReturnsTrueForEmptyInput(): void $this->assertTrue($this->store->putMany([], 100)); } - public function testPutManyReturnsFalseForFailedKeyAndAttemptsLaterKeys(): void + public function testPutManyFailureForgetsTheBatchFromTheFailedAndCompletedLayersDeepestFirst(): void { - $this->createStores(); + $top = m::mock(ArrayStore::class); + $middle = m::mock(ArrayStore::class); + $bottom = m::mock(ArrayStore::class); - $ttl = 100; - $expiration = CarbonImmutable::now()->getTimestamp() + $ttl; + $top->shouldReceive('putMany')->once()->andReturnTrue(); + $middle->shouldReceive('putMany')->once()->andReturnFalse(); + $bottom->shouldNotReceive('putMany'); + $middle->shouldReceive('forget')->once()->with('first')->globally()->ordered()->andReturnTrue(); + $middle->shouldReceive('forget')->once()->with('second')->globally()->ordered()->andReturnTrue(); + $top->shouldReceive('forget')->once()->with('first')->globally()->ordered()->andReturnTrue(); + $top->shouldReceive('forget')->once()->with('second')->globally()->ordered()->andReturnTrue(); + + $this->assertFalse((new StackStore([$top, $middle, $bottom]))->putMany(['first' => 'one', 'second' => 'two'], 60)); + } + + public function testPutManyForgetsKeysALayerStoredBeforeThrowing(): void + { + $exception = new RuntimeException('write failed'); + $top = new ArrayStore; + $bottom = new class($exception) extends ArrayStore { + public function __construct(private readonly RuntimeException $exception) + { + parent::__construct(); + } - $this->swoole->shouldReceive('put')->once()->with('first', ['value' => 'one', 'expiration' => $expiration], $ttl)->andReturn(true); - $this->redis->shouldReceive('put')->once()->with('first', ['value' => 'one', 'expiration' => $expiration], $ttl)->andReturn(true); - $this->swoole->shouldReceive('put')->once()->with('fail', ['value' => 'two', 'expiration' => $expiration], $ttl)->andReturn(false); - $this->redis->shouldNotReceive('put')->with('fail', m::any(), m::any()); - $this->swoole->shouldReceive('put')->once()->with('after', ['value' => 'three', 'expiration' => $expiration], $ttl)->andReturn(true); - $this->redis->shouldReceive('put')->once()->with('after', ['value' => 'three', 'expiration' => $expiration], $ttl)->andReturn(true); - - $this->assertFalse($this->store->putMany([ - 'first' => 'one', - 'fail' => 'two', - 'after' => 'three', - ], $ttl)); + /** + * Store the first item before simulating a failure. + */ + public function putMany(array $values, int $seconds): bool + { + $this->put((string) array_key_first($values), reset($values), $seconds); + + throw $this->exception; + } + }; + $stack = new StackStore([$top, $bottom]); + + try { + $stack->putMany(['first' => 'one', 'second' => 'two'], 60); + $this->fail('Expected the write failure to be thrown.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertSame([null, null], [$top->get('first'), $top->get('second')]); + $this->assertSame([null, null], [$bottom->get('first'), $bottom->get('second')]); + } + + public function testPutManyCleanupContinuesAfterCancellationStopsAtOrdinaryFailureAndKeepsCancellation(): void + { + $top = m::mock(ArrayStore::class); + $bottom = m::mock(ArrayStore::class); + $cleanupCancellation = new CanceledException('cleanup canceled'); + + $top->shouldReceive('putMany')->once()->andReturnTrue(); + $bottom->shouldReceive('putMany')->once()->andThrow(new RuntimeException('write failed')); + $bottom->shouldReceive('forget')->once()->with('first')->globally()->ordered()->andThrow($cleanupCancellation); + $bottom->shouldReceive('forget')->once()->with('second')->globally()->ordered()->andThrow(new RuntimeException('cleanup failed')); + $bottom->shouldNotReceive('forget')->with('third'); + $top->shouldReceive('forget')->once()->with('first')->globally()->ordered()->andReturnTrue(); + $top->shouldReceive('forget')->once()->with('second')->globally()->ordered()->andReturnTrue(); + $top->shouldReceive('forget')->once()->with('third')->globally()->ordered()->andReturnTrue(); + + try { + (new StackStore([$top, $bottom]))->putMany(['first' => 'one', 'second' => 'two', 'third' => 'three'], 60); + $this->fail('Expected the cleanup cancellation to be thrown.'); + } catch (CanceledException $exception) { + $this->assertSame($cleanupCancellation, $exception); + } } #[DataProvider('proxyPutManyTtlProvider')] From 74259c2eae0edf55f79b541de37ad3fefbe7defc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:41:12 +0000 Subject: [PATCH 03/39] Test tagged stack writes of expired records and cross-layer cleanup order Cover the tagged increment path when the record expires while it is being read: the layer forgets the key instead of writing an expired record through tags. The lower-layer failure test asserted cleanup order with plain ordered(), which only orders calls within one mock. Use globally()->ordered() so it checks that the failed layer is cleaned up before the layer above it. --- tests/Cache/CacheStackStoreTagsTest.php | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php index 094b704c0..e69b741d9 100644 --- a/tests/Cache/CacheStackStoreTagsTest.php +++ b/tests/Cache/CacheStackStoreTagsTest.php @@ -30,6 +30,7 @@ use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; @@ -216,8 +217,8 @@ public function testTaggedWriteRollsBackWrittenLayersWhenLowerLayerThrows(): voi $plain->shouldReceive('put')->once()->andReturnTrue(); $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); $taggedCache->shouldReceive('put')->once()->andThrow($exception); - $taggable->shouldReceive('forget')->once()->with('key')->ordered()->andReturnTrue(); - $plain->shouldReceive('forget')->once()->with('key')->ordered()->andReturnTrue(); + $taggable->shouldReceive('forget')->once()->with('key')->globally()->ordered()->andReturnTrue(); + $plain->shouldReceive('forget')->once()->with('key')->globally()->ordered()->andReturnTrue(); $stack = new StackStore([ new StackStoreProxy($plain), @@ -246,6 +247,26 @@ public function testTaggedIncrementWritesThroughTaggedPath(): void $this->assertSame(3, $stack->tags(['tag'])->increment('counter', 2)); } + public function testTaggedIncrementForgetsRecordThatExpiredDuringItsRead(): void + { + CarbonImmutable::setTestNow('2000-01-01 00:00:00'); + + $taggable = $this->anyModeTaggableStore(); + $record = ['value' => 1, 'expiration' => CarbonImmutable::now()->getTimestamp() + 1]; + + $taggable->shouldReceive('get')->once()->with('counter')->andReturnUsing(function () use ($record): array { + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds(2)); + + return $record; + }); + $taggable->shouldReceive('forget')->once()->with('counter')->andReturnTrue(); + $taggable->shouldNotReceive('tags'); + + $stack = new StackStore([$taggable]); + + $this->assertSame(2, $stack->tags(['tag'])->increment('counter')); + } + public function testTaggedIncrementDoesNotTreatFailedRepairAsMissingAndPreservesLowerValue(): void { $plain = $this->nonTaggableStore(); From a1125216db5ed7e727159490982c5d389d50ae10 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:41:12 +0000 Subject: [PATCH 04/39] Add the Pennant port plan Plan for porting laravel/pennant as hypervel/pennant: coroutine-scoped memo and array storage, transaction-aware memo invalidation, tenant partitioning, batched and conflict-safe database reads and first resolution, an optional shared stored-value cache with token invalidation, StackStore batching, the Sentry integration, upstream fixes, tests and documentation, plus the owner-approved departures. --- docs/plans/2026-10-21-0030-pennant-port.md | 276 +++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 docs/plans/2026-10-21-0030-pennant-port.md diff --git a/docs/plans/2026-10-21-0030-pennant-port.md b/docs/plans/2026-10-21-0030-pennant-port.md new file mode 100644 index 000000000..3c20d7fe9 --- /dev/null +++ b/docs/plans/2026-10-21-0030-pennant-port.md @@ -0,0 +1,276 @@ +# Pennant port + +Port `laravel/pennant` (reference: `/home/binaryfire/workspace/references/laravel/pennant`, commit `d9ea593`) as `hypervel/pennant` (`src/pennant`, namespace `Hypervel\Pennant`). New package port: follow AGENTS.md *Porting Packages* (cp each file, alphabetical, one at a time) plus the adaptations below. + +Status: implementation authorized. Steps 1–2 (AGENTS.md rule, `StackStore` batching) are reviewed and committed; `src/docs/pennant.md` is copied from Laravel, unadapted. Next step: implementation order step 3 (package skeleton). Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. + +## Owner decisions (approved) + +| Decision | Why | +|---|---| +| `value` stays `text` (exception to `jsonb`) | `jsonb`/MySQL `JSON` reorder object keys (breaks `===` in `@feature('x', $value)` between first resolution and later reads) and `jsonb` rejects `\u0000`. Pennant never queries inside values. | +| `name`/`scope` use `VARBINARY` on MySQL/MariaDB | Default `utf8mb4_unicode_ci` treats `Team-A`/`team-a`/`Team-A `/`café`/`cafe` as equal; upstream `get()` silently returns another scope's value. `utf8mb4_bin` is PAD SPACE (verified: `'scope '` duplicates `'scope'` on MySQL 9.5 and MariaDB 10.11). | +| Reject keyless model scopes | `Class|` is shared by every unsaved model (one stored assignment, one lottery draw). | +| Constructor/protected-member departures (table under Database driver) | Required by coroutine-safe state and the new database path. Custom drivers use the unchanged `Driver` contract. | +| AGENTS.md semantic-column rule rewording (exact wording approved) | Allows `text()` for opaque serialized values (wording under Framework changes). | + +## Upstream inventory and disposition + +| Upstream | Hypervel | +|---|---| +| `src/Attributes/Name.php`, `Concerns/HasFeatures.php`, `Contracts/*`, `Events/*`, `Feature.php` (facade, accessor `FeatureManager::class`), `GlobalScope.php`, `LazilyResolvedFeature.php`, `helpers.php` | Port; types/docblocks only (plus items below). | +| `src/functions.php` | Keep the internal `Hypervel\Pennant\enum_value()`; drop its `function_exists` wrappers (namespaced, like `Hypervel\Support` functions) and the fallback branch: `return $value instanceof UnitEnum ? (string) Support\enum_value($value) : $value;` (the cast keeps int-backed names strings; `EnumTest::test_an_integer_backed_enum_resolves_to_its_string_backing_value`). | +| `src/FeatureManager.php`, `Drivers/*`, `PendingScopedFeatureInteraction.php`, `PennantServiceProvider.php`, `Middleware/EnsureFeaturesAreActive.php` | Port with the redesign below. | +| `Migrations/PennantMigration.php` | Port as is (keeps its `null`/`'null'` check). | +| `src/Commands/*`, `stubs/feature.stub`, `config/pennant.php`, migration | Port; stub drops its unused `Lottery` import. | +| `tests/*`, `workbench/*` | Port (see Tests). | +| `resources/boost` | Not ported (global Boost exclusion). | +| Octane listeners, `laravel/octane` dev requirement, `JobProcessed` flush, `illuminate/queue` requirement | Not ported (coroutine state replaces global resets; see Lifetime). One-line comment in `listenForEvents()` saying why, for future syncs. | +| `art`, `ide.json`, `CHANGELOG.md`, `UPGRADE.md`, `phpstan.neon.dist`, `phpunit.xml.dist`, `testbench.yaml` | Not ported (repo tooling; no Hypervel package ships them). | + +Rename `__laravel_null` / `__laravel_global` to `__hypervel_null` / `__hypervel_global` (framework-name rule). + +## Package skeleton + +- Use `src/cache` as the template for `composer.json`, `LICENSE.md`, `README.md`. Declare only real dependencies, derived from the final imports (`symfony/finder` for `discover()`; no queue or view requirement: the Blade closure's `BladeCompiler` type only loads when `blade.compiler` resolves). +- Package `composer.json`: psr-4 `Hypervel\Pennant\` → `src/`, `files` `src/helpers.php`, `src/functions.php`. Root `composer.json`: psr-4 `Hypervel\Pennant\` → `src/pennant/src/`, `files` `src/pennant/src/helpers.php`, `src/pennant/src/functions.php`, `replace` entry. Both: `extra.hypervel.providers` `Hypervel\Pennant\PennantServiceProvider`, `extra.hypervel.aliases` `Feature` → `Hypervel\Pennant\Feature`. Package discovery only (not `DefaultProviders`). +- README: header, `Documentation: https://hypervel.org/docs/pennant`, `Differences From Laravel` (keyless model scopes rejected), `Ported from: https://github.com/laravel/pennant`. +- Provider: no `FeatureManager` binding (auto-singleton; upstream's closure binding is redundant); `mergeableOptions('pennant')` returns `['stores']` (named group, like `cache.stores`); `publishesMigrations()` directly; keep commands, config publishing, the `PublishingStubs` listener and the Blade directives. + +## Lifetime and coroutine state + +Worker-lifetime (unchanged services): `FeatureManager` (stores, custom creators, default scope resolver, morph-map flag, partition resolver), `Decorator` (definitions `nameMap`, per-feature metadata), driver definitions (`featureStateResolvers`). Feature classes resolve through `make()` (auto-singleton, like policies and listeners); docs tell users to keep resolver classes stateless. + +Mutator docblocks: +- `Boot-only.`: `extend`, `useMorphMap`, `resolveScopeUsing`, `resolvePartitionUsing`, `setDefaultDriver` (mutates process-global config), `EnsureFeaturesAreActive::whenInactive` (static). +- `Boot or tests only.`: `define`, `discover`, `forgetDriver`, `forgetDrivers`. +- `Tests only.`: `setContainer` (kept, matches `Support\Manager`). +- Runtime-safe: `flushCache` (current coroutine only); internal dynamic class registration in `ensureDynamicFeatureIsDefined` (idempotent, bounded by feature classes; `DynamicallyRegisteringFeatureClass` fires once per worker per class). + +`EnsureFeaturesAreActive` gets `flushState()`. Add `flushPennantState()` to the optional-package section of `AfterEachTestSubscriber` (alphabetical, between Passport and Permission): `callIfExists(EnsureFeaturesAreActive::class, 'flushState')` and `callIfExists(Decorator::class, 'flushMacros')` (Macroable keeps macros statically; `FeatureManagerTest::test_it_can_apply_macros`). Decorator metadata is instance-owned (reset with the test container), so no other `flushState()`. + +### `PennantState` (`src/PennantState.php`, `CONTEXT_KEY = '__pennant.state'`, implements `ReplicableContext`) + +```php +/** @var array>>>> [connection][store][partition][feature][scope] */ +public array $values = []; // Decorator memo, including before() results; '' connection for non-database drivers +/** @var array>>> [store][partition][feature][scope] */ +public array $arrayValues = []; // ArrayDriver storage (per execution, like Laravel's per-request array driver) +/** @var array> spl_object_id(DatabaseTransactionRecord) => set of memo connections */ +public array $transactions = []; + +public static function current(): static; // CoroutineContext::get(), set a new state on miss (one lookup on hits; getOrSet() would build a state per call) +public function replicate(): static; // copy $values except connections present in $this->transactions, copy $arrayValues, empty $transactions +``` + +- Presence uses `array_key_exists` (stored `null`/`false` are hits). +- `replicate()` uses only the source object's own `$transactions`; it never reads ambient context (`CoroutineContext::copyFrom()` runs it in the destination coroutine). +- Transaction tracking (Decorator, for drivers implementing `UsesDatabaseConnection`): on any memo write for that connection (driver result, first resolution, mutation, or `before()` result, which may read uncommitted rows), if `DatabaseManager::hasApplicableTransaction($connection)` (held-state check by requested name, no pool borrow), take the record the manager would use: `db.transactions` → `callbackApplicableTransactions()` filtered by `->connection === ConnectionName::parse($connection)->base` (transaction records use the base name), `last()`. Add `$connection` to `$state->transactions[spl_object_id($record)]`; when the id is new, register on the record directly: `addCallback` → remove the id; `addCallbackForRollback` → unset `$state->values[...]` for every connection in the id's set, then remove the id. One record can cover several memo connections (in-memory SQLite shares one owner across base and `::write` aliases). Closures capture `$state` and the scalar id, not the record. Savepoints work: a released savepoint's record keeps its callbacks until the outermost commit, and rolling back an outer level runs its committed children's rollback callbacks. Under `RefreshDatabase` the testing manager excludes the wrapping transaction, so top-level test code registers nothing. +- `flushCache()` clears `$values` and `$arrayValues` buckets for the store, never `$transactions`. `forgetDriver()` / `forgetDrivers()` call it on each resolved store they discard, so a rebuilt store can't inherit the old one's memo or array values (current coroutine; these methods are boot/test-only). +- No Octane/`JobProcessed` listeners: requests, daemon jobs (`Worker` child coroutines) and lifecycle callbacks (`Waiter::wait()` children) each own their state; sync and deferred jobs share the caller's memo. +- Per-store memo semantics stay upstream's: a write through store A does not refresh store B's memo in the same request. +- Mutations (`set`, `setAll`, `delete`, `setForAllScopes`, `purge`) remove the affected memo entries of the current store before calling the driver and write new values only after it returns, so a post-commit cache failure that the app catches can't leave the old value in the local memo. A failed shared-cache rotation keeps the TTL-bound limitation described under Shared cache. + +### Hot path + +A memo-hit `Feature::active('x')` currently allocates ~25 Collections, scans the memo linearly three times, rebuilds `array_keys()` of every definition twice and always dispatches `FeatureRetrieved`. Target: one `PennantState` lookup and array reads. + +- `PendingScopedFeatureInteraction` and `Decorator` work on plain arrays. Normalize features and scopes once per operation; serialize each effective scope once. `getAllMissing()` returns early with no driver call when every pair is memoized. +- Default scope resolves per operation (auth may change mid-request). +- Feature-name resolution: `isset($this->nameMap[$name])` fast path; for class strings cache the resolved name (class → `#[Name]`/`$name`/class) in `$this->classNames`; cache `before()` presence and null-acceptance per implementation class; cache resolver parameter types per feature for `definedFeaturesForScope()`. Clear these maps in `define()`. Never cache misses for non-class input. +- Guard every event dispatch with `hasListeners()` (`FeatureRetrieved` still fires on memo hits when listened, e.g. Sentry). All Pennant event dispatch uses the `Event` facade (`Event::hasListeners()` / `Event::dispatch()`), as the Decorator does upstream; the drivers stop capturing a dispatcher. `Event::fake()` only refreshes the Model and Cache dispatchers, so a captured dispatcher misses fakes installed after a store resolves (stores usually resolve at boot, where features are defined). +- `typeAllowsScope()` matches builtin names with PHP predicates (`mixed`, `object`, `iterable`, `callable`, `array`, `string`, `int`, `float` accepting int, `bool`, `true`, `false`), resolves `self`/`parent` against the declaring class, and uses `instanceof` for classes; a `null` scope still defers to `canHandleNullScope()`. + +## Partitioning + +`FeatureManager::resolvePartitionUsing(string $column, Closure $resolver): void` (Boot-only, instance state; register before resolving stores, like Permission's resolver; survives `forgetDriver()`). `$resolver(string $store): int|string|null`. Column validated as a simple SQL identifier (same regex as `PermissionRegistrar::resolvePartitionUsing()`). + +- Store config `'partitioned' => false` opts a store out (central flags; tenancy central surfaces have no tenant). Default: partitioned when a resolver is registered. Documented, not in shipped config. +- `FeatureManager::resolve()` builds a `?Closure(): FeaturePartition` per store (protected `partitionResolver($name, $config)`, mirroring upstream's `defaultScopeResolver($driver)`; null when unpartitioned) and passes it to the Decorator and the built-in drivers. Each layer calls it once per operation and captures the result. `FeaturePartition` (`src/FeaturePartition.php`, readonly `column`, `value`). Its `key()` length-prefixes the column and `(string) $value`, so `1` and `'1'` (the same SQL partition) share memo and cache identity, as `PermissionPartition::encodeCacheSegment()` does; unpartitioned stores use `''`. A `null`/`''` value throws `Exceptions\FeaturePartitionNotResolved` (fail closed). +- The Decorator memo always includes the partition key. Custom drivers scope their own storage (documented). +- Applies to every driver operation: reads, first-resolution inserts, upserts, deletes, `stored()`, `purge()`, `setForAllScopes()`, `pennant:purge`, array values, memo, shared-cache identities. `globally()` stays a scope inside the partition. Cross-partition maintenance uses the integrating package (e.g. tenancy `runForEach`), not a bypass API. +- Partitioned schema is app/package-owned (like Permission): partition column non-null, unique `(partition, name, scope)`. + +## Configuration + +```php +'stores' => [ + 'array' => ['driver' => 'array'], + 'database' => [ + 'driver' => 'database', + 'connection' => null, + 'table' => 'features', + 'cache' => null, + ], +], +``` + +- The section comment explains `cache` (`store`, optional `ttl` seconds, default 3600). +- `FeatureManager::getDefaultDriver()` reads `config->string('pennant.default')` (no `?? 'database'`). Config via named methods (no `ArrayAccess`). +- Database factory normalizes once: `connection` (`null` or `''` → `null`, the runtime default, as `DatabaseManager::connection()` treats them), `table` (optional, default `'features'`), `cache` (`store` required when present; `ttl` defaults to `StoredValueCache::DEFAULT_TTL = 3600` and must be a positive integer, since the repository treats `null` as forever and non-positive values as deletes, and the finite TTL bounds staleness; a test asserts the documented default matches). +- `connectionName()` rejects a `::read` connection, whether configured or the runtime default (`DB::usingConnection('mysql::read', ...)`), before any memo, cache or SQL access: Pennant writes on first resolution, and with read config `DatabasePool::connectionRole()` sends every operation on a read alias to the replica. +- Invalidation targets (config-only, at factory): the distinct `cache` profiles of every configured `database` store on the same table whose connection overlaps this one (`null` overlaps every connection, since `DB::usingConnection()` can select any; explicit names compare after `withoutWriteType()`), including its own. Mutations rotate tokens in every target, so writes through an uncached or differently cached alias still invalidate cached readers. Rejecting overlapping stores with different cache config instead would always throw, because `stores` merges by name and the default uncached `database` store stays configured. Docs: physical aliases under different connection names must use one canonical name. + +## Database driver + +Constructor: `DatabaseManager $db, ?string $connection, string $table, ?Closure $partitionResolver, ?StoredValueCache $cache, array $featureStateResolvers`. Implements `CanListStoredFeatures`, `CanSetManyFeaturesForScopes`, `Driver`, `UsesDatabaseConnection` (`src/Contracts/UsesDatabaseConnection.php`: `connectionName(): string`, the requested name, resolved per operation via `getDefaultConnection()` when null; database-specific because the Decorator needs the connection for transaction tracking, not just an opaque identity). Scopes serialize through upstream's `Feature::serializeScope()`. Timestamps use `CarbonImmutable::now()`. No borrowed connections held on the worker. + +Constructor and protected departures (everything else keeps upstream signatures): + +| Class | Departure | +|---|---| +| `Decorator` | Constructor drops `$cache`, adds `?Closure $partitionResolver` (`$name, $driver, $defaultScopeResolver, $partitionResolver, $container`); `$cache` removed; `isCached()` / `putInCache()` / `removeFromCache()` take the operation's state and partition key; `normalizeFeaturesToLoad()` returns arrays. | +| `ArrayDriver` | Constructor `(string $name, ?Closure $partitionResolver, array $featureStateResolvers)`; `$events` removed; `$resolvedFeatureStates` moves to `PennantState`; implements `CanSetManyFeaturesForScopes` (upstream has `setAll()` without the interface, so the Decorator falls back to per-entry `set()`), reading its state and partition once. | +| `DatabaseDriver` | Constructor above; `$config`, `$events`, `$name`, `$retryDepth` removed; `retrieve()`, `insert()` and the unused `update()` removed; `insertMany()` becomes the conflict-safe insert returning stored values. | +| `FeatureManager` | `createArrayDriver(array $config, string $name)` (upstream takes none; `resolve()` already passes both). | + +### Schema (`database/migrations/2022_11_01_000001_create_features_table.php`) + +```php +Schema::create('features', function (Blueprint $table) { + $table->id(); + + // MySQL and MariaDB string collations ignore case, accents and trailing spaces. + if (in_array(Schema::getConnection()->getDriverName(), ['mysql', 'mariadb'], true)) { + $table->binary('name', length: Builder::$defaultStringLength * 4); + $table->binary('scope', length: Builder::$defaultStringLength * 4); + } else { + $table->string('name'); + $table->string('scope'); + } + + $table->text('value'); + $table->timestamps(); + + $table->unique(['name', 'scope']); +}); +``` + +`Builder` is `Schema\Builder` (`DEFAULT_STRING_LENGTH` is protected; the public static keeps any app override). The migrator sets the migration's connection as the coroutine default, so `Schema::getConnection()` is the target. `VARBINARY(1020)`: 2,040-byte unique key, under InnoDB's 3,072 limit; PHP receives plain strings. PostgreSQL (deterministic collation) and SQLite (BINARY) already compare exactly. The migration keeps extending the unchanged `PennantMigration`. + +### Reads (`getAll`) + +Constants: `QUERY_GROUP_LIMIT = 500` (half SQLite's default `SQLITE_MAX_EXPR_DEPTH` 1000; verified 990 OR groups pass, 1000 fail), `QUERY_BINDING_LIMIT = 10_000` (under SQLite 3.32's 32,766; partition binding counted), `WRITE_ROW_LIMIT = 1000`. + +1. Dedupe canonical `(feature, serialized scope)` pairs, keeping caller positions; skip features with empty scope lists; no query when nothing remains. +2. Group features by their sorted, length-prefixed scope-key list (hash map; O(S log S) sorting). +3. Split a group exceeding the binding limit by scope (then feature) chunks; pack groups greedily into statements under both limits. +4. Each statement: `select name, scope, value ... where [partition = ? and] ((name in (...) and scope in (...)) or ...)`, the OR chain always inside a nested `where` (a top-level `orWhere` would escape the partition). Index rows by exact `[name][scope]` (no `Collection::where()` loose `==`, which matches `'01'` to `'1'`). +5. Read connection: normal read PDO; write PDO when the store publishes to its own cache (a replica-lagged value must not be published under a post-rotation token). + +A rectangular `load()` (F features × S scopes) is one group and F+S bindings. + +### First resolution + +For missing pairs: resolve (unknown features → `false`, not stored, `UnknownFeatureResolved`), then insert candidates in `WRITE_ROW_LIMIT` chunks: + +- PostgreSQL, SQLite: `insertOrIgnore($rows)`. PostgreSQL compiles `ON CONFLICT DO NOTHING` (only unique/exclusion conflicts are skipped). SQLite's `INSERT OR IGNORE` also skips NOT NULL/CHECK violations, but Pennant supplies every non-null column and the schema has no CHECK constraint; a row skipped by an app-altered schema still surfaces through the reread, retry and exception below. The SQLite no-op upsert is not used because SQLite counts its duplicate rows as changes (verified on 3.45), which would force a reread on every first resolution. +- MySQL, MariaDB: `upsert($rows, $uniqueBy, ['name' => new Expression($query->getGrammar()->wrap("{$table}.name"))])`: a table-qualified no-op (`wrap()` applies the table prefix; the unqualified form is ambiguous on PostgreSQL, 42702). `INSERT IGNORE` would silently truncate over-long values in strict mode. +- If affected rows equal the candidate count, every candidate won: return them (the uncontended path keeps upstream's one select plus one insert). Otherwise, or on MySQL/MariaDB when FOUND_ROWS may be on (no-op duplicates then count as affected), reread. FOUND_ROWS is known off only when no `db.connector.{driver}` binding replaces the built-in connector (built-ins are created fresh with fixed defaults) and the connection's `options` don't set `Pdo\Mysql::ATTR_FOUND_ROWS`; a bound custom connector always rereads. The reread fetches the candidate batch through the grouped read path from the write PDO and returns stored values. Only MySQL/MariaDB add `sharedLock()`: a REPEATABLE READ snapshot can't see the winner without a locking read, while PostgreSQL READ COMMITTED rereads see it and stricter levels fail on the insert. +- Pairs still missing after the reread (concurrent purge/delete) are re-resolved and retried, at most 2 times per operation (local loop variable), then `RuntimeException('Unable to insert feature values into the database.')`. Never return a candidate not known to have won. +- PostgreSQL REPEATABLE READ/SERIALIZABLE serialization failures propagate (the caller's transaction must restart). + +`get()` returns `getAll([$feature => [$scope]])[$feature][0]`, so the single-pair path shares the same insert, retry budget and message (upstream's separate one-retry path and singular message go away; its query counts are unchanged). + +### Mutations + +- `set` / `setAll`: `upsert` keyed by `[partition,] name, scope`. `setAll` first collapses duplicate `(feature, serialized scope)` pairs, keeping the last value like the sequential fallback (PostgreSQL rejects an upsert that updates one row twice), then chunks; multi-chunk runs inside one transaction. +- `setForAllScopes`, `delete`, `purge`: single statements with the partition predicate. +- With invalidation targets, via `$connection->afterCommitOrNow()`: `set`/`delete` rotate the scope token, `setAll` rotates its distinct scope tokens with one `putMany` per target, `setForAllScopes`/`purge` rotate the partition token. Every target is attempted; the first ordinary failure is rethrown after the loop, while `CanceledException` passes through at once (the `executeCommitCallbacks()` pattern). + +## Shared cache (`src/Drivers/StoredValueCache.php`) + +Opt-in per database store. Caches stored row values only (never `before()` results, never unknown-feature `false`). Database stays authoritative; eviction never rerolls an assignment. Built by the factory when the store has its own `cache` or any invalidation target: it reads and publishes through the store's own cache (if any) and rotates tokens in every target. + +Identity: `ConnectionName::withoutWriteType($requested)` + table + partition key (no store name; aliases on one cache store share entries and invalidation). Keys use length-prefixed raw segments (no hashing; process-seeded hashes can't be shared through Redis): + +```text +P pennant:{storage}{table}{partition}:p → partition token +S(scope) pennant:{storage}{table}{partition}:s{scope} → scope token +V(f, scope) pennant:{storage}{table}{partition}:v{scope}{f} → ['p' => token, 's' => token, 'v' => stored JSON text] +``` + +`v` holds the row's `value` column text (or the JSON just inserted), and hits decode it exactly like rows (`JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR`). Later requests therefore get the database representation (arrays, `1` not `1.0`) while the first resolution still returns the resolver's own value, as upstream does. Tokens are `(string) Str::ulid()`, never counters, so an evicted token is never reused. Tokens and values use the profile's TTL. + +Read (memo misses only; skipped entirely while `DatabaseManager::hasApplicableTransaction()` is true): +1. One `many()` for `[P, S(scope)…, V(pair)…]`; an entry is valid when `v.p === P && v.s === S(scope)`. Tokens are operation-local (not memoized). +2. Missing tokens: generate and `putMany` them before the database read; if that returns `false`, skip publishing for this operation. +3. Database path for the remaining pairs (reads, first resolution). +4. `putMany` stored/winning values under the tokens captured in step 1. + +A fill that captured tokens before a writer's commit publishes under rotated-away tokens, so readers ignore it: no locks, tombstones or CAS. Rotation `put` exceptions propagate; a `false` return throws `RuntimeException` (invalidation failed after commit; cached values may be stale until TTL). Oversized values follow normal cache errors (`ValueTooLargeForColumnException` on Swoole layers); no chunking. + +Docs: TTL bounds staleness after a crash or failed write between commit and rotation; with a stack store the Swoole layer's TTL bounds cross-server staleness; use a Redis store for prompt changes. + +## Upstream fixes (each with a regression test) + +1. `ArrayDriver::get()` `isset` treats stored `null` as missing → `array_key_exists`. +2. `typeAllowsScope()` rejects valid scopes for `object`, `iterable`, `callable`, `self`/`parent`, `float` (int scopes are allowed under `strict_types`) and standalone `true`/`false` → matcher under Hot path. Keep union/intersection/nullable handling. Fix "encoutered" typo. +3. `getAll()` loose matching (`'01'` vs `'1'`) and duplicate scopes producing duplicate inserts (fixed by Reads steps 1 and 4); duplicate `setAll()` pairs failing on PostgreSQL (fixed under Mutations). +4. `loadAll()` uses only `scope()[0]`'s features for every scope (mixed `User`/`Team` scopes pass a `Team` to user-only resolvers). Compute valid features per distinct scope and submit one sparse batched `getAll()`, keeping each scope's original position as its key. +5. Keyless models: `serializeScope()` throws `RuntimeException` for a `Model` with a null key (after `FeatureScopeSerializeable`). Assign a key or pass an explicit string/serializable scope. +6. Exception-driven insert retry (shared `$retryDepth`, PostgreSQL transaction abort on unique violation) → First resolution. +7. Rolled-back values remaining in the memo → transaction tracking. + +Unexpected-null fallback stays persisted (Pennant persists first outcomes); docs say to `forget()`/`purge()` after changing a definition. + +## Framework changes + +1. **AGENTS.md** (*Code conventions*), replace the semantic-column bullet with: + > **Use semantic column types in migrations** — `uuid()` / `foreignUuid()` / `uuidMorphs()` for UUIDs, `ipAddress()` / `macAddress()` for network addresses, and `jsonb()` for JSON the database queries, indexes or validates. Use `text()` for opaque serialized values that are only read back whole. PostgreSQL gets the real types (`jsonb`, `uuid`, `inet`, `macaddr`); the other supported databases fall back automatically to compatible types. +2. **`StackStore::many()` / `putMany()`** batch per layer. Both currently loop per key, so a cold Pennant batch on a Swoole + Redis stack costs one Redis round trip per key. `StackStoreProxy` already forwards `many()`/`putMany()` (with its layer TTL clamp). + - `many()`: call each layer's `many()` top-down for the keys still missing, with `getOrRestoreRecord()`'s rules (any non-null record ends that key's search; a lower record is backfilled into the layers above only when it has `value`). Backfill uses `putToStore()`'s normalization (absolute expiration kept, records without expiry use `forever()`), grouped per destination layer by remaining TTL into one `putMany()` per group; the proxy still applies layer TTL caps. Return values in input order. + - `putMany()`: normalize records once and call each layer's `putMany()` once. If a layer returns `false` or throws, stop and compensate by forgetting the batch keys from the completed layers and the failing one (deepest-first, same cancellation handling as `callStores()`), then return `false` or rethrow. The failing layer is included because a layer's `putMany()` can write some keys before failing. Compensation is best-effort invalidation; it doesn't restore earlier values. + - Update `CacheStackStoreTest`'s `testMany`/`testPutMany` expectations to the batched calls. `testPutManyReturnsFalseForFailedKeyAndAttemptsLaterKeys` becomes a batch-failure compensation test (per-key partial success is gone). Add tests for partial hits per layer with batched backfill and preserved expiration, and lower-layer failure compensation. +3. **Sentry**: port `PennantPackageIntegration` (keeps upstream name) to `src/sentry/src/Features/`. Upstream injects the dispatcher into `onBoot(Dispatcher $events)`; Hypervel's `onBoot()` takes none, so resolve `events` from `$this->container` as `CacheIntegration` does. `isApplicable()` checks `class_exists(Feature::class)` (like `AiIntegration`). Add it to the shipped `features` list, port the upstream test to `tests/Sentry/Features/PennantPackageIntegrationTest.php`, mention feature flags in the Sentry docs introduction, and remove the Pennant item from `docs/todo.md`. + +## Tests + +Port each upstream file, run it immediately, fix, move on (AGENTS cadence): + +| Upstream | Hypervel | +|---|---| +| `workbench/app/Models/{User,Team}.php`, `Features/NewApi.php`, `database/factories/UserFactory.php` | `tests/Pennant/Fixtures/...` (`discover()` tests point at `Fixtures/Features`) | +| `tests/TestCase.php` | `tests/Pennant/TestCase.php`: Testbench base whose `defineDatabaseMigrations()` loads `src/pennant/database/migrations` (replaces upstream's `WithWorkbench` + `testbench.yaml`). Upstream's unused `createManager()` is dropped. | +| `tests/Feature/*Test.php` | `tests/Pennant/Feature/*Test.php` (upstream placement, as Passport does; `RefreshDatabase`) | +| `tests/IntersectionTypeTest.php` | `tests/Pennant/IntersectionTypeTest.php` | + +Adaptations: +- `__hypervel_global`; setUp config changes (`pennant.default`) move to `defineEnvironment()`. +- Every incidental keyless `new User` / `new Team` scope gets an explicit id, assertions unchanged: `ArrayDriverTest` (686, 919–1005, 1238), `DatabaseDriverTest` (826, 1771, 1782, 1821), `IntersectionTypeTest` (42, 54). A separate intentional-difference test asserts that a keyless model scope throws. +- The migration-connection test instantiates `new class extends PennantMigration {}` instead of copying the implementation into a plain `Migration`. +- The unknown-feature event test also fakes `UnknownFeatureResolved` after the store has resolved. +- The four `UniqueConstraintViolationException` listener tests simulate conflicts the new insert no longer raises. Rewrite them as: + - concurrent-winner tests, batch and `get()`: a listener on the initial select inserts the winning row; assert the winner's value is returned and one insert ran; + - vanished-winner tests, batch and `get()`: a competitor row forces the insert to be ignored and is deleted before the reread; assert three insert attempts, then `'Unable to insert feature values into the database.'` with no previous exception. +- Query-count assertions stay as upstream on the uncontended path. + +New tests (only behavior that can regress): +- `tests/Pennant/CoroutineIsolationTest.php`: concurrent memo/array isolation; copied context gets independent values; values (including `before()` results) from an open transaction are not copied, both when the parent forks and when the child copies from the parent with `copyFrom()`; partition switch inside one coroutine uses separate buckets; a null-connection store under `DB::usingConnection()` keeps separate memo per connection. +- `tests/Pennant/PartitionTest.php`: every operation partitioned (including grouped OR reads), null partition throws, `partitioned => false`, `globally()` per partition, int `1` and string `'1'` share memo and cache identity. +- `tests/Pennant/TransactionTest.php`: rollback (and savepoint rollback) flushes the memo, including `before()` results and base plus `::write` aliases sharing one in-memory SQLite transaction; commit keeps it; `flushCache()` keeps registrations; multi-chunk `setAll()` rolls back together; shared cache bypassed inside a transaction; rotation waits for the outer commit and is dropped on rollback. +- `tests/Pennant/StoredValueCacheTest.php` (array cache): capture → rotate → publish is ignored; scope vs partition rotation; a write through an uncached alias rotates the cached alias's tokens; unknown and `before()` values never published; rich values (array, object, `JsonSerializable`, float, `false`, `null`) read back from cache exactly as from the database (data provider); warm memo hits touch neither database nor cache, and warm cache hits run no query and hold no connection; token-write `false` skips publishing; after a committed write through an uncached alias whose rotation fails, the writer's next read reaches the updated database row (no stale local memo); a failing target doesn't stop the other targets and its failure is rethrown; non-positive `ttl` is rejected; documented TTL matches `DEFAULT_TTL`. +- Additions to `Feature/DatabaseDriverTest`: rectangular vs sparse batching query counts, binding/group splitting, read dedupe, duplicate `setAll()` pairs (last value wins), `'01'`/`'1'`, `::read` rejection (configured and via `DB::usingConnection()`), `loadAll()` mixed scopes; `Feature/ArrayDriverTest`: stored `null`, and (data provider over `forgetDriver()` / `forgetDrivers()`) a rebuilt store resolves a redefined value instead of the forgotten store's; `Feature/FeatureManagerTest`: one `isResolverValidForScope()` data provider for the builtin, `self`/`parent` and int→float cases. +- `tests/Integration/Pennant/Database/{Postgres,MySql,MariaDb}/`: exact identity (case, accents, composed/decomposed, trailing spaces, 255 four-byte characters) on MySQL/MariaDB; conflict paths per engine (MySQL with and without `use_upsert_alias`, FOUND_ROWS on via options and via a bound connector, REPEATABLE READ locking reread); duplicate `setAll()` pairs on PostgreSQL; PostgreSQL serialization failure propagates. +- `tests/Integration/Pennant/Redis/StoredValueCacheTest.php` (`InteractsWithRedis`): public-API publish, mutation invalidation and bulk rotation seen from a fresh memo. Add `tests/Integration/Pennant/Redis` to all three jobs in `.github/workflows/redis.yml` (Redis, Redis Cluster, Valkey). Database directories are discovered by convention. + +## Documentation + +- `src/docs/pennant.md` (copied from `references/laravel/docs/pennant.md`; edit it alongside each source change rather than at the end): keep Laravel-docs prose; adapt *In-Memory Cache* (per request/coroutine, rolled back with transactions), add *Partitioning* (including `pennant:purge` running per partition) and *Shared Cache* sections; note stateless feature classes, resetting stored values after changing definitions, keyless model scopes, canonical connection names, and that custom drivers keep coroutine-safe state and scope their own storage. *Intercepting Feature Checks* notes that a `before()` hook can delegate to an entitlement or billing service: its result is never stored, using it is optional, and Pennant adds no entitlement system. Add to the Packages list in `src/docs/documentation.md` between Passport and Precognition. +- `src/docs/porting-from-laravel.md`: keyless model scopes now throw; use Hypervel's Pennant migration (byte-exact identifiers on MySQL/MariaDB). +- Regenerate the `Feature` facade docblock: `composer facade -- 'Hypervel\Pennant\Feature'`. + +## Integrating packages (requirements only; not in this change) + +Tenancy (private): register `Feature::resolvePartitionUsing('tenant_id', fn () => $tenancy->tenantKey())` at boot; publish the migration with non-null `tenant_id` (UUID, FK) and unique `(tenant_id, name, scope)`; classify `features` as tenant-owned for purge/export/import; tenant purge calls `purge()` inside the tenant (rotates the partition token); central flags use a `'partitioned' => false` store with its own table. + +## Implementation order + +1. AGENTS.md rule edit. +2. `StackStore` batching + tests. +3. Package skeleton and metadata. +4. Source files alphabetically (cp → read → adapt), then `PennantState`, `FeaturePartition`, `StoredValueCache`, `Contracts/UsesDatabaseConnection`, `Exceptions/FeaturePartitionNotResolved`. +5. Tests per file, then new tests and integration directories/workflows. +6. Sentry integration and test. +7. Finish `pennant.md`, README, porting guide, todo cleanup, facade docblock, `AfterEachTestSubscriber`. +8. Verification (sequential): each test file runs as it is written; then `composer fix` (the StackStore and test-subscriber changes reach beyond Pennant, and it already runs lint, analysis and all suites); then `./bin/run-database-tests.sh pgsql|mysql|mariadb` and the Redis integration directories. No benchmarks without owner confirmation. From bb26cce49524ab3a1881893d3313afe5842bdb7c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:17:19 +0000 Subject: [PATCH 05/39] Port Pennant contracts, events and feature attributes Start hypervel/pennant from laravel/pennant d9ea593 with the parts of the public surface that don't change shape: the Name attribute, the HasFeatures concern, the driver contracts, the events, GlobalScope, LazilyResolvedFeature and the helpers. Each file was copied from upstream and adapted for native types, method docblocks and imported class names. enum_value() stays an internal namespaced function without the function_exists() wrappers or the fallback branch, and casts integer-backed enum values to strings because feature names are strings. UsesDatabaseConnection is new. The decorator needs the connection a database driver uses for the current operation so values memoized inside a transaction can be forgotten when it rolls back. It is database-specific on purpose: an opaque identity wouldn't let the decorator find the transaction. FeaturePartitionNotResolved is new. A partitioned store whose resolver returns null or an empty string throws it instead of reading or writing across partitions. The "encoutered" typo in the null-scope event wording is fixed. --- src/pennant/src/Attributes/Name.php | 28 ++++++++++ src/pennant/src/Concerns/HasFeatures.php | 19 +++++++ .../src/Contracts/CanListStoredFeatures.php | 15 +++++ .../Contracts/CanSetManyFeaturesForScopes.php | 15 +++++ .../Contracts/DefinesFeaturesExternally.php | 15 +++++ src/pennant/src/Contracts/Driver.php | 55 +++++++++++++++++++ .../Contracts/FeatureScopeSerializeable.php | 13 +++++ .../src/Contracts/FeatureScopeable.php | 13 +++++ .../src/Contracts/HasFlushableCache.php | 13 +++++ .../src/Contracts/UsesDatabaseConnection.php | 15 +++++ src/pennant/src/Events/AllFeaturesPurged.php | 9 +++ .../DynamicallyRegisteringFeatureClass.php | 18 ++++++ src/pennant/src/Events/FeatureDeleted.php | 24 ++++++++ src/pennant/src/Events/FeatureResolved.php | 26 +++++++++ src/pennant/src/Events/FeatureRetrieved.php | 26 +++++++++ src/pennant/src/Events/FeatureUpdated.php | 26 +++++++++ .../src/Events/FeatureUpdatedForAllScopes.php | 24 ++++++++ src/pennant/src/Events/FeaturesPurged.php | 18 ++++++ .../Events/UnexpectedNullScopeEncountered.php | 18 ++++++ .../src/Events/UnknownFeatureResolved.php | 24 ++++++++ .../FeaturePartitionNotResolved.php | 18 ++++++ src/pennant/src/GlobalScope.php | 18 ++++++ src/pennant/src/LazilyResolvedFeature.php | 25 +++++++++ src/pennant/src/functions.php | 22 ++++++++ src/pennant/src/helpers.php | 24 ++++++++ 25 files changed, 521 insertions(+) create mode 100644 src/pennant/src/Attributes/Name.php create mode 100644 src/pennant/src/Concerns/HasFeatures.php create mode 100644 src/pennant/src/Contracts/CanListStoredFeatures.php create mode 100644 src/pennant/src/Contracts/CanSetManyFeaturesForScopes.php create mode 100644 src/pennant/src/Contracts/DefinesFeaturesExternally.php create mode 100644 src/pennant/src/Contracts/Driver.php create mode 100644 src/pennant/src/Contracts/FeatureScopeSerializeable.php create mode 100644 src/pennant/src/Contracts/FeatureScopeable.php create mode 100644 src/pennant/src/Contracts/HasFlushableCache.php create mode 100644 src/pennant/src/Contracts/UsesDatabaseConnection.php create mode 100644 src/pennant/src/Events/AllFeaturesPurged.php create mode 100644 src/pennant/src/Events/DynamicallyRegisteringFeatureClass.php create mode 100644 src/pennant/src/Events/FeatureDeleted.php create mode 100644 src/pennant/src/Events/FeatureResolved.php create mode 100644 src/pennant/src/Events/FeatureRetrieved.php create mode 100644 src/pennant/src/Events/FeatureUpdated.php create mode 100644 src/pennant/src/Events/FeatureUpdatedForAllScopes.php create mode 100644 src/pennant/src/Events/FeaturesPurged.php create mode 100644 src/pennant/src/Events/UnexpectedNullScopeEncountered.php create mode 100644 src/pennant/src/Events/UnknownFeatureResolved.php create mode 100644 src/pennant/src/Exceptions/FeaturePartitionNotResolved.php create mode 100644 src/pennant/src/GlobalScope.php create mode 100644 src/pennant/src/LazilyResolvedFeature.php create mode 100644 src/pennant/src/functions.php create mode 100644 src/pennant/src/helpers.php diff --git a/src/pennant/src/Attributes/Name.php b/src/pennant/src/Attributes/Name.php new file mode 100644 index 000000000..a3266acfa --- /dev/null +++ b/src/pennant/src/Attributes/Name.php @@ -0,0 +1,28 @@ +name = (string) enum_value($name); + } +} diff --git a/src/pennant/src/Concerns/HasFeatures.php b/src/pennant/src/Concerns/HasFeatures.php new file mode 100644 index 000000000..f2cbe77bb --- /dev/null +++ b/src/pennant/src/Concerns/HasFeatures.php @@ -0,0 +1,19 @@ +for($this); + } +} diff --git a/src/pennant/src/Contracts/CanListStoredFeatures.php b/src/pennant/src/Contracts/CanListStoredFeatures.php new file mode 100644 index 000000000..62c5bc621 --- /dev/null +++ b/src/pennant/src/Contracts/CanListStoredFeatures.php @@ -0,0 +1,15 @@ + + */ + public function stored(): array; +} diff --git a/src/pennant/src/Contracts/CanSetManyFeaturesForScopes.php b/src/pennant/src/Contracts/CanSetManyFeaturesForScopes.php new file mode 100644 index 000000000..69fd53013 --- /dev/null +++ b/src/pennant/src/Contracts/CanSetManyFeaturesForScopes.php @@ -0,0 +1,15 @@ + $features + */ + public function setAll(array $features): void; +} diff --git a/src/pennant/src/Contracts/DefinesFeaturesExternally.php b/src/pennant/src/Contracts/DefinesFeaturesExternally.php new file mode 100644 index 000000000..5f72bd6dd --- /dev/null +++ b/src/pennant/src/Contracts/DefinesFeaturesExternally.php @@ -0,0 +1,15 @@ + + */ + public function definedFeaturesForScope(mixed $scope): array; +} diff --git a/src/pennant/src/Contracts/Driver.php b/src/pennant/src/Contracts/Driver.php new file mode 100644 index 000000000..19d28716f --- /dev/null +++ b/src/pennant/src/Contracts/Driver.php @@ -0,0 +1,55 @@ + + */ + public function defined(): array; + + /** + * Get multiple feature flag values. + * + * @param array> $features + * @return array> + */ + public function getAll(array $features): array; + + /** + * Retrieve a feature flag's value. + */ + public function get(string $feature, mixed $scope): mixed; + + /** + * Set a feature flag's value. + */ + public function set(string $feature, mixed $scope, mixed $value): void; + + /** + * Set a feature flag's value for all scopes. + */ + public function setForAllScopes(string $feature, mixed $value): void; + + /** + * Delete a feature flag's value. + */ + public function delete(string $feature, mixed $scope): void; + + /** + * Purge the given features from storage. + */ + public function purge(?array $features): void; +} diff --git a/src/pennant/src/Contracts/FeatureScopeSerializeable.php b/src/pennant/src/Contracts/FeatureScopeSerializeable.php new file mode 100644 index 000000000..fadcca61b --- /dev/null +++ b/src/pennant/src/Contracts/FeatureScopeSerializeable.php @@ -0,0 +1,13 @@ + $features the feature names + */ + public function __construct( + public array $features, + ) { + } +} diff --git a/src/pennant/src/Events/UnexpectedNullScopeEncountered.php b/src/pennant/src/Events/UnexpectedNullScopeEncountered.php new file mode 100644 index 000000000..661c0badd --- /dev/null +++ b/src/pennant/src/Events/UnexpectedNullScopeEncountered.php @@ -0,0 +1,18 @@ +feature = $feature; + } +} diff --git a/src/pennant/src/functions.php b/src/pennant/src/functions.php new file mode 100644 index 000000000..5d3c1d78d --- /dev/null +++ b/src/pennant/src/functions.php @@ -0,0 +1,22 @@ +make(FeatureManager::class); + + return match (func_num_args()) { + 0 => $manager, + 1 => $manager->value(...func_get_args()), + default => $manager->when(...func_get_args()), + }; + } +} From 460e286ae24219e3e3d5a6e29f985ac0a92e1697 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:21:15 +0000 Subject: [PATCH 06/39] Add coroutine-scoped Pennant state and feature partitions Laravel keeps memoized feature values on the store decorator and the array driver's values on the driver, and resets them between Octane requests and queued jobs. Hypervel stores are worker-lifetime services shared by concurrent requests, so that state moves to PennantState in CoroutineContext. Each request, job and child coroutine gets its own values, and no reset listeners are needed. PennantState holds the decorator memo, keyed by connection, store, partition, feature and scope; the array driver's values, keyed by store, partition, feature and scope; and the open transactions whose rollback must forget memoized values. forgetOnRollback() registers commit and rollback callbacks directly on the transaction record the first time a record is seen, and adds further connections to the same entry, since one record can cover several memo connections (an in-memory SQLite connection and its ::write alias share one owner). The callbacks capture the state and the record's object ID, not the record. replicate() copies the memo and array values into a copied context, except values for connections with an open transaction: the copy can't see that transaction roll back. It reads only its own tracked transactions, never the ambient context, because copyFrom() runs it in the destination coroutine. Transactions are never inherited. FeaturePartition is the resolved partition column and value for one operation. Its key() length-prefixes both parts, and casts the value to a string so 1 and '1', which select the same rows, share memo and cache identity. --- src/pennant/src/FeaturePartition.php | 29 ++++++++ src/pennant/src/PennantState.php | 103 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/pennant/src/FeaturePartition.php create mode 100644 src/pennant/src/PennantState.php diff --git a/src/pennant/src/FeaturePartition.php b/src/pennant/src/FeaturePartition.php new file mode 100644 index 000000000..66209273a --- /dev/null +++ b/src/pennant/src/FeaturePartition.php @@ -0,0 +1,29 @@ +value; + + return strlen($this->column) . ':' . $this->column . strlen($value) . ':' . $value; + } +} diff --git a/src/pennant/src/PennantState.php b/src/pennant/src/PennantState.php new file mode 100644 index 000000000..d953138b0 --- /dev/null +++ b/src/pennant/src/PennantState.php @@ -0,0 +1,103 @@ +>>>> + */ + public array $values = []; + + /** + * The values held by array drivers, keyed by store, partition, feature and scope. + * + * @var array>>> + */ + public array $arrayValues = []; + + /** + * The connections whose memoized values are forgotten if an open transaction rolls back, keyed by the transaction record's object ID. + * + * @var array> + */ + public array $transactions = []; + + /** + * Get the state of the current coroutine. + */ + public static function current(): self + { + return CoroutineContext::get(self::CONTEXT_KEY) ?? CoroutineContext::set(self::CONTEXT_KEY, new self); + } + + /** + * Forget the values memoized for the connection if the given transaction rolls back. + */ + public function forgetOnRollback(DatabaseTransactionRecord $record, string $connection): void + { + $id = spl_object_id($record); + + if (isset($this->transactions[$id])) { + $this->transactions[$id][$connection] = true; + + return; + } + + $this->transactions[$id] = [$connection => true]; + + // Commit and rollback callbacks always run once the record is detached, so its ID is never reused while tracked. + $record->addCallback(function () use ($id): void { + unset($this->transactions[$id]); + }); + + $record->addCallbackForRollback(function () use ($id): void { + foreach ($this->transactions[$id] ?? [] as $connection => $tracked) { + unset($this->values[$connection]); + } + + unset($this->transactions[$id]); + }); + } + + /** + * Create the state for a copied coroutine context. + * + * The copy keeps the memoized and array values, except values for connections in an open + * transaction, which the copy can't see roll back. Transactions are never inherited. + */ + public function replicate(): static + { + $state = clone $this; + $state->transactions = []; + + foreach ($this->transactions as $connections) { + foreach ($connections as $connection => $tracked) { + unset($state->values[$connection]); + } + } + + return $state; + } +} From 707b6ac72bee1f01c5ca758b42ecb2ac221e4173 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:21:38 +0000 Subject: [PATCH 07/39] Port the Pennant decorator, array driver and scoped interactions The decorator memoizes values in PennantState instead of on the worker-shared instance. Its memo is keyed by connection, store, partition, feature and serialized scope, and presence uses array_key_exists() so stored null and false are hits. For drivers that implement UsesDatabaseConnection, every memo write made while the connection has an applicable transaction registers that connection on the transaction record, so a rollback (including a savepoint rollback) forgets values the request may have read or written inside it. Mutations remove the affected memo entries before calling the driver and write the new values only after it returns. flushCache() clears the store's memo and array values in the current coroutine only. The hot path works on plain arrays instead of Collections. Features and scopes are normalized once per operation, list-form entries share one default-scope resolution, each scope is serialized once, and a fully memoized getAllMissing() returns without calling the driver. Every event dispatch is guarded by Event::hasListeners(). Feature names for class strings are cached in classNames when the class is defined and cleared by setContainer(), since names come from container-built instances. Only Closures and feature classes are resolvers. Every other defined value is a constant, even when it looks like a callable or a class name. Upstream treated 'next', [$object, 'method'] and class-name values as resolvers in definedFeaturesForScope(), hasBeforeHook() and instance(), so they could drop out of all() and loadAll(), run a before() hook or return the wrong instance. define() now records class-backed features in featureClasses, and those methods read it. instance() also returns a closure that produces the defined value for null and class-name constants instead of the feature name. typeAllowsScope() matches builtin names with PHP predicates (mixed, object, iterable, callable, array, string, int, float accepting int, bool, true, false), resolves self and parent against the declaring class and uses instanceof for classes. Upstream rejected valid scopes for several of these. The "encoutered" typo in its exception message is fixed. Reflection is cached per feature: the resolver function in resolverFunctions and whether a before() hook accepts a null scope in beforeHookAcceptsNull. Both are cleared when the feature is defined again, and setContainer() clears the hook entries. A non-null before() result skips the driver entirely, so without the cache the reflection would repeat on every request. The array driver keeps its values in PennantState per store and partition, reads its state and partition once per operation through retrieve(), and implements CanSetManyFeaturesForScopes so setAll() no longer falls back to one set() per entry. It no longer captures a dispatcher: events go through the Event facade so fakes installed after the store resolves still apply. get() uses array_key_exists(), so a stored null is no longer resolved again. loadAll() used the first scope's features for every scope, so mixed User and Team scopes passed a Team to user-only resolvers. It now computes the valid features per scope and submits one sparse getAll(), keeping each scope's original position. Constructor departures for subclasses: the decorator drops $cache and takes a partition resolver; the array driver takes (string $name, ?Closure $partitionResolver, array $featureStateResolvers) and drops $events and $resolvedFeatureStates. --- src/pennant/src/Drivers/ArrayDriver.php | 247 ++++ src/pennant/src/Drivers/Decorator.php | 1074 +++++++++++++++++ .../src/PendingScopedFeatureInteraction.php | 361 ++++++ 3 files changed, 1682 insertions(+) create mode 100644 src/pennant/src/Drivers/ArrayDriver.php create mode 100644 src/pennant/src/Drivers/Decorator.php create mode 100644 src/pennant/src/PendingScopedFeatureInteraction.php diff --git a/src/pennant/src/Drivers/ArrayDriver.php b/src/pennant/src/Drivers/ArrayDriver.php new file mode 100644 index 000000000..1832f978a --- /dev/null +++ b/src/pennant/src/Drivers/ArrayDriver.php @@ -0,0 +1,247 @@ + + */ + protected array $featureStateResolvers; + + /** + * The sentinel value for unknown features. + */ + protected stdClass $unknownFeatureValue; + + /** + * Create a new driver instance. + * + * Resolved feature states live in the current coroutine's PennantState, so + * each request, job and child coroutine starts with its own values. + * + * @param null|(Closure(): FeaturePartition) $partitionResolver + * @param array $featureStateResolvers + */ + public function __construct(string $name, ?Closure $partitionResolver, array $featureStateResolvers) + { + $this->name = $name; + $this->partitionResolver = $partitionResolver; + $this->featureStateResolvers = $featureStateResolvers; + + $this->unknownFeatureValue = new stdClass; + } + + /** + * Define an initial feature flag state resolver. + * + * Boot or tests only. The definition persists on the store for the worker + * lifetime and applies to every subsequent request. + * + * @param (callable(mixed $scope): mixed) $resolver + */ + public function define(string $feature, callable $resolver): void + { + $this->featureStateResolvers[$feature] = $resolver; + } + + /** + * Retrieve the names of all defined features. + * + * @return array + */ + public function defined(): array + { + return array_keys($this->featureStateResolvers); + } + + /** + * Retrieve the names of all stored features. + * + * @return array + */ + public function stored(): array + { + return array_keys(PennantState::current()->arrayValues[$this->name][$this->partitionKey()] ?? []); + } + + /** + * Get multiple feature flag values. + * + * @param array> $features + * @return array> + */ + public function getAll(array $features): array + { + $state = PennantState::current(); + $partition = $this->partitionKey(); + $results = []; + + foreach ($features as $feature => $scopes) { + $results[$feature] = []; + + foreach ($scopes as $index => $scope) { + $results[$feature][$index] = $this->retrieve($state, $partition, (string) $feature, $scope); + } + } + + return $results; + } + + /** + * Retrieve a feature flag's value. + */ + public function get(string $feature, mixed $scope): mixed + { + return $this->retrieve(PennantState::current(), $this->partitionKey(), $feature, $scope); + } + + /** + * Retrieve a feature flag's value from the given state, resolving and storing it when missing. + */ + protected function retrieve(PennantState $state, string $partition, string $feature, mixed $scope): mixed + { + $scopeKey = Feature::serializeScope($scope); + + if (array_key_exists($scopeKey, $state->arrayValues[$this->name][$partition][$feature] ?? [])) { + return $state->arrayValues[$this->name][$partition][$feature][$scopeKey]; + } + + $value = $this->resolveValue($feature, $scope); + + if ($value === $this->unknownFeatureValue) { + return false; + } + + return $state->arrayValues[$this->name][$partition][$feature][$scopeKey] = $value; + } + + /** + * Determine the initial value for a given feature and scope. + */ + protected function resolveValue(string $feature, mixed $scope): mixed + { + if ($this->missingResolver($feature)) { + if (Event::hasListeners(UnknownFeatureResolved::class)) { + Event::dispatch(new UnknownFeatureResolved($feature, $scope)); + } + + return $this->unknownFeatureValue; + } + + return $this->featureStateResolvers[$feature]($scope); + } + + /** + * Set a feature flag's value. + */ + public function set(string $feature, mixed $scope, mixed $value): void + { + PennantState::current()->arrayValues[$this->name][$this->partitionKey()][$feature][Feature::serializeScope($scope)] = $value; + } + + /** + * Set multiple feature flag values. + * + * @param list $features + */ + public function setAll(array $features): void + { + $state = PennantState::current(); + $partition = $this->partitionKey(); + + foreach ($features as $featureData) { + $state->arrayValues[$this->name][$partition][$featureData['feature']][Feature::serializeScope($featureData['scope'])] = $featureData['value']; + } + } + + /** + * Set a feature flag's value for all scopes. + */ + public function setForAllScopes(string $feature, mixed $value): void + { + $state = PennantState::current(); + $partition = $this->partitionKey(); + + foreach ($state->arrayValues[$this->name][$partition][$feature] ?? [] as $scope => $currentValue) { + $state->arrayValues[$this->name][$partition][$feature][$scope] = $value; + } + } + + /** + * Delete a feature flag's value. + */ + public function delete(string $feature, mixed $scope): void + { + unset(PennantState::current()->arrayValues[$this->name][$this->partitionKey()][$feature][Feature::serializeScope($scope)]); + } + + /** + * Purge the given feature from storage. + */ + public function purge(?array $features): void + { + $state = PennantState::current(); + $partition = $this->partitionKey(); + + if ($features === null) { + unset($state->arrayValues[$this->name][$partition]); + } else { + foreach ($features as $feature) { + unset($state->arrayValues[$this->name][$partition][$feature]); + } + } + } + + /** + * Determine if the feature does not have a resolver available. + */ + protected function missingResolver(string $feature): bool + { + return ! array_key_exists($feature, $this->featureStateResolvers); + } + + /** + * Get the key of the store's current partition. + */ + protected function partitionKey(): string + { + return $this->partitionResolver === null ? '' : ($this->partitionResolver)()->key(); + } + + /** + * Flush the resolved feature states. + */ + public function flushCache(): void + { + unset(PennantState::current()->arrayValues[$this->name]); + } +} diff --git a/src/pennant/src/Drivers/Decorator.php b/src/pennant/src/Drivers/Decorator.php new file mode 100644 index 000000000..8e5f9a107 --- /dev/null +++ b/src/pennant/src/Drivers/Decorator.php @@ -0,0 +1,1074 @@ + + */ + protected array $nameMap = []; + + /** + * The feature names of feature classes. + * + * @var array + */ + protected array $classNames = []; + + /** + * The implementation classes of features defined from feature classes. + * + * Other defined values are constants, even when they look like class names or callables. + * + * @var array + */ + protected array $featureClasses = []; + + /** + * The reflected resolvers of defined features. + * + * @var array + */ + protected array $resolverFunctions = []; + + /** + * Whether the before hooks of defined features accept a null scope. + * + * @var array + */ + protected array $beforeHookAcceptsNull = []; + + /** + * Create a new driver decorator instance. + * + * Feature values are memoized in the current coroutine's PennantState instead of + * on the decorator, which every request in the worker shares. + * + * @param Closure(): mixed $defaultScopeResolver + * @param null|(Closure(): FeaturePartition) $partitionResolver + */ + public function __construct(string $name, Driver $driver, Closure $defaultScopeResolver, ?Closure $partitionResolver, Container $container) + { + $this->name = $name; + $this->driver = $driver; + $this->defaultScopeResolver = $defaultScopeResolver; + $this->partitionResolver = $partitionResolver; + $this->container = $container; + } + + /** + * Discover and register the application's feature classes. + * + * Boot or tests only. The definitions persist on the store for the worker + * lifetime and apply to every subsequent request. + */ + public function discover(string $namespace = 'App\Features', ?string $path = null): void + { + $namespace = Str::finish($namespace, '\\'); + + Collection::make((new Finder) + ->files() + ->name('*.php') + ->depth(0) + ->in($path ?? base_path('app/Features'))) + ->each(fn ($file) => $this->define("{$namespace}{$file->getBasename('.php')}")); + } + + /** + * Define an initial feature flag state resolver. + * + * Boot or tests only. The definition persists on the store for the worker + * lifetime and applies to every subsequent request. + * + * @param class-string|string|UnitEnum $feature + * @param mixed $resolver a resolver closure, a lottery, or the feature's value + */ + public function define(UnitEnum|string $feature, mixed $resolver = null): void + { + $feature = enum_value($feature); + + if (func_num_args() === 1) { + [$feature, $resolver] = [ + $this->resolveFeatureName($feature, $this->container->make($feature)), + new LazilyResolvedFeature($feature), + ]; + + $this->nameMap[$feature] = $resolver->feature; + $this->featureClasses[$feature] = $resolver->feature; + $this->classNames[$resolver->feature] = $feature; + } else { + $this->nameMap[$feature] = $resolver; + + unset($this->featureClasses[$feature]); + } + + unset($this->resolverFunctions[$feature], $this->beforeHookAcceptsNull[$feature]); + + $this->driver->define($feature, function (mixed $scope) use ($feature, $resolver): mixed { + if ($resolver instanceof LazilyResolvedFeature) { + $resolver = with($this->container->make($resolver->feature), fn ($instance) => method_exists($instance, 'resolve') + ? $instance->resolve(...) // @phpstan-ignore callable.nonNativeMethod + : $instance(...)); + } + + if (! $resolver instanceof Closure) { + return $this->resolve($feature, fn () => $resolver, $scope); + } + + if ($scope !== null) { + return $this->resolve($feature, $resolver, $scope); + } + + if ($this->canHandleNullScope($resolver)) { + return $this->resolve($feature, $resolver, $scope); + } + + if (Event::hasListeners(UnexpectedNullScopeEncountered::class)) { + Event::dispatch(new UnexpectedNullScopeEncountered($feature)); + } + + return $this->resolve($feature, fn () => false, $scope); + }); + } + + /** + * Resolve the feature value. + */ + protected function resolve(string $feature, callable $resolver, mixed $scope): mixed + { + $value = $resolver($scope); + + $value = $value instanceof Lottery ? $value() : $value; + + if (Event::hasListeners(FeatureResolved::class)) { + Event::dispatch(new FeatureResolved($feature, $scope, $value)); + } + + return $value; + } + + /** + * Determine if the resolver can handle the scope. + * + * @param callable|class-string $resolver + */ + public function isResolverValidForScope(callable|string $resolver, mixed $scope): bool + { + return $this->functionAcceptsScope($this->resolverFunction($resolver), $scope); + } + + /** + * Reflect the function that resolves a feature. + * + * @param callable|class-string $resolver + */ + protected function resolverFunction(callable|string $resolver): ReflectionFunctionAbstract + { + if (is_string($resolver) && class_exists($resolver)) { + $class = new ReflectionClass($resolver); + + return $class->hasMethod('resolve') + ? $class->getMethod('resolve') + : $class->getMethod('__invoke'); + } + + return new ReflectionFunction(Closure::fromCallable($resolver)); + } + + /** + * Determine if the function's scope parameter accepts the scope. + */ + protected function functionAcceptsScope(ReflectionFunctionAbstract $function, mixed $scope): bool + { + if ($function->getNumberOfParameters() === 0) { + return true; + } + + $type = $function->getParameters()[0]->getType(); + + if ($type === null) { + return true; + } + + return $this->typeAllowsScope($type, $scope, $function); + } + + /** + * Determine if the type can handle the scope. + * + * @throws RuntimeException when the reflection type is unknown + */ + protected function typeAllowsScope(ReflectionType $type, mixed $scope, ReflectionFunctionAbstract $function): bool + { + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $type) { + if ($this->typeAllowsScope($type, $scope, $function)) { + return true; + } + } + + return false; + } + + if ($type instanceof ReflectionIntersectionType) { + foreach ($type->getTypes() as $type) { + if (! $this->typeAllowsScope($type, $scope, $function)) { + return false; + } + } + + return true; + } + + if ($type instanceof ReflectionNamedType) { + if ($scope === null) { + return $this->canHandleNullScope($function); + } + + $typeName = $type->getName(); + + if (! $type->isBuiltin()) { + $class = $function instanceof ReflectionMethod + ? $function->getDeclaringClass() + : $function->getClosureScopeClass(); + + $typeName = match (strtolower($typeName)) { + 'self' => $class?->getName(), + 'parent' => ($class?->getParentClass() ?: null)?->getName(), + default => $typeName, + }; + + return $typeName !== null && $scope instanceof $typeName; + } + + // Under strict types an int is still accepted by a float parameter. + return match ($typeName) { + 'mixed' => true, + 'object' => is_object($scope), + 'iterable' => is_iterable($scope), + 'callable' => is_callable($scope), + 'array' => is_array($scope), + 'string' => is_string($scope), + 'int' => is_int($scope), + 'float' => is_float($scope) || is_int($scope), + 'bool' => is_bool($scope), + 'true' => $scope === true, + 'false' => $scope === false, + default => false, + }; + } + + throw new RuntimeException('Unknown reflection type encountered.'); + } + + /** + * Determine if the resolver accepts null scope. + */ + protected function canHandleNullScope(callable|ReflectionFunctionAbstract $resolver): bool + { + $function = $resolver instanceof ReflectionFunctionAbstract + ? $resolver + : new ReflectionFunction(Closure::fromCallable($resolver)); + + return $function->getNumberOfParameters() === 0 + || ! $function->getParameters()[0]->hasType() + || $function->getParameters()[0]->getType()->allowsNull(); + } + + /** + * Retrieve the names of all defined features. + * + * @return array + */ + public function defined(): array + { + return $this->driver->defined(); + } + + /** + * Retrieve the names of all stored features. + * + * @return array + */ + public function stored(): array + { + if (! $this->driver instanceof CanListStoredFeatures) { + throw new RuntimeException("The [{$this->name}] driver does not support listing stored features."); + } + + return $this->driver->stored(); + } + + /** + * Get multiple feature flag values. + * + * @internal + * + * @param array|string $features + * @return array> + */ + public function getAll(array|string $features): array + { + $features = $this->normalizeFeaturesToLoad($features); + + if ($features === []) { + return []; + } + + return $this->resolveFeatures($features, PennantState::current(), $this->connectionName(), $this->partitionKey()); + } + + /** + * Get multiple feature flag values that are missing. + * + * @internal + * + * @param array|string $features + * @return array> + */ + public function getAllMissing(array|string $features): array + { + $features = $this->normalizeFeaturesToLoad($features); + $state = PennantState::current(); + $connection = $this->connectionName(); + $partition = $this->partitionKey(); + + foreach ($features as $feature => $scopes) { + foreach ($scopes as $index => $scope) { + if ($this->isCached($state, $connection, $partition, (string) $feature, Feature::serializeScope($scope))) { + unset($features[$feature][$index]); + } + } + + if ($features[$feature] === []) { + unset($features[$feature]); + } + } + + if ($features === []) { + return []; + } + + return $this->resolveFeatures($features, $state, $connection, $partition); + } + + /** + * Resolve the values of the given features through their before hooks or the driver, and memoize them. + * + * @param array> $features + * @return array> + */ + protected function resolveFeatures(array $features, PennantState $state, string $connection, string $partition): array + { + $results = $features; + $unresolved = []; + + foreach ($features as $feature => $scopes) { + $feature = (string) $feature; + + if (! $this->hasBeforeHook($feature)) { + $unresolved[$feature] = $scopes; + + continue; + } + + $before = $this->container->make($this->implementationClass($feature))->before(...); + + foreach ($scopes as $index => $scope) { + $value = $this->resolveBeforeHook($feature, $scope, $before); + + if ($value !== null) { + $results[$feature][$index] = $value; + } else { + $unresolved[$feature][$index] = $scope; + } + } + } + + if ($unresolved !== []) { + foreach ($this->driver->getAll($unresolved) as $feature => $values) { + foreach ($values as $index => $value) { + $results[$feature][$index] = $value; + } + } + } + + $this->trackTransaction($state, $connection); + + foreach ($results as $feature => $values) { + foreach ($values as $index => $value) { + $this->putInCache($state, $connection, $partition, (string) $feature, Feature::serializeScope($features[$feature][$index]), $value); + } + } + + return $results; + } + + /** + * Normalize the features to load. + * + * @param array|string $features + * @return array> + */ + protected function normalizeFeaturesToLoad(array|string $features): array + { + $normalized = []; + $defaultScopes = null; + + foreach (Arr::wrap($features) as $key => $value) { + if (is_array($value) && array_key_exists('feature', $value) && array_key_exists('scope', $value)) { + [$feature, $scopes] = [$value['feature'], $value['scope']]; + } elseif (is_int($key)) { + [$feature, $scopes] = [$value, $defaultScopes ??= [$this->defaultScope()]]; + } else { + [$feature, $scopes] = [$key, $value]; + } + + $scopes = $scopes instanceof Enumerable ? $scopes->all() : Arr::wrap($scopes); + + $normalized[$this->resolveFeature($feature)] = array_map($this->resolveScope(...), $scopes); + } + + return $normalized; + } + + /** + * Retrieve a feature flag's value. + * + * @internal + */ + public function get(UnitEnum|string $feature, mixed $scope): mixed + { + $feature = $this->resolveFeature($feature); + + $scope = $this->resolveScope($scope); + + $state = PennantState::current(); + $connection = $this->connectionName(); + $partition = $this->partitionKey(); + $scopeKey = Feature::serializeScope($scope); + + if ($this->isCached($state, $connection, $partition, $feature, $scopeKey)) { + $value = $state->values[$connection][$this->name][$partition][$feature][$scopeKey]; + } else { + $value = $this->hasBeforeHook($feature) + ? $this->resolveBeforeHook($feature, $scope, $this->container->make($this->implementationClass($feature))->before(...)) + : null; + + $value ??= $this->driver->get($feature, $scope); + + $this->trackTransaction($state, $connection); + + $this->putInCache($state, $connection, $partition, $feature, $scopeKey, $value); + } + + if (Event::hasListeners(FeatureRetrieved::class)) { + Event::dispatch(new FeatureRetrieved($feature, $scope, $value)); + } + + return $value; + } + + /** + * Resolve the before hook value. + */ + protected function resolveBeforeHook(string $feature, mixed $scope, callable $hook): mixed + { + if ($scope === null && ! ($this->beforeHookAcceptsNull[$feature] ??= $this->canHandleNullScope($hook))) { + if (Event::hasListeners(UnexpectedNullScopeEncountered::class)) { + Event::dispatch(new UnexpectedNullScopeEncountered($feature)); + } + + return null; + } + + return $hook($scope); + } + + /** + * Set a feature flag's value. + * + * @internal + */ + public function set(UnitEnum|string $feature, mixed $scope, mixed $value): void + { + $feature = $this->resolveFeature($feature); + + $scope = $this->resolveScope($scope); + + $state = PennantState::current(); + $connection = $this->connectionName(); + $partition = $this->partitionKey(); + $scopeKey = Feature::serializeScope($scope); + + $this->removeFromCache($state, $connection, $partition, $feature, $scopeKey); + + $this->driver->set($feature, $scope, $value); + + $this->trackTransaction($state, $connection); + + $this->putInCache($state, $connection, $partition, $feature, $scopeKey, $value); + + if (Event::hasListeners(FeatureUpdated::class)) { + Event::dispatch(new FeatureUpdated($feature, $scope, $value)); + } + } + + /** + * Set multiple feature flag values. + * + * @internal + * + * @param list $features + */ + public function setAll(array $features): void + { + $features = array_map(fn ($feature) => [ + 'feature' => $this->resolveFeature($feature['feature']), + 'scope' => $this->resolveScope($feature['scope']), + 'value' => $feature['value'], + ], $features); + + $state = PennantState::current(); + $connection = $this->connectionName(); + $partition = $this->partitionKey(); + $scopeKeys = []; + + foreach ($features as $index => $feature) { + $scopeKeys[$index] = Feature::serializeScope($feature['scope']); + + $this->removeFromCache($state, $connection, $partition, $feature['feature'], $scopeKeys[$index]); + } + + $updated = false; + + if ($this->driver instanceof CanSetManyFeaturesForScopes) { + $this->driver->setAll($features); + + $updated = true; + } + + $this->trackTransaction($state, $connection); + + $dispatch = Event::hasListeners(FeatureUpdated::class); + + foreach ($features as $index => $feature) { + if (! $updated) { + $this->driver->set($feature['feature'], $feature['scope'], $feature['value']); + } + + $this->putInCache($state, $connection, $partition, $feature['feature'], $scopeKeys[$index], $feature['value']); + + if ($dispatch) { + Event::dispatch(new FeatureUpdated( + $feature['feature'], + $feature['scope'], + $feature['value'], + )); + } + } + } + + /** + * Create a pending feature interaction for the global scope, ignoring the default scope resolver. + */ + public function globally(): PendingScopedFeatureInteraction + { + return $this->for(new GlobalScope); + } + + /** + * Activate the feature for everyone. + * + * @param array|string|UnitEnum $feature + */ + public function activateForEveryone(UnitEnum|string|array $feature, mixed $value = true): void + { + Collection::wrap($feature) + ->each(fn ($name) => $this->setForAllScopes($name, $value)); + } + + /** + * Deactivate the feature for everyone. + * + * @param array|string|UnitEnum $feature + */ + public function deactivateForEveryone(UnitEnum|string|array $feature): void + { + Collection::wrap($feature) + ->each(fn ($name) => $this->setForAllScopes($name, false)); + } + + /** + * Set a feature flag's value for all scopes. + * + * @internal + */ + public function setForAllScopes(UnitEnum|string $feature, mixed $value): void + { + $feature = $this->resolveFeature($feature); + + unset(PennantState::current()->values[$this->connectionName()][$this->name][$this->partitionKey()][$feature]); + + $this->driver->setForAllScopes($feature, $value); + + if (Event::hasListeners(FeatureUpdatedForAllScopes::class)) { + Event::dispatch(new FeatureUpdatedForAllScopes($feature, $value)); + } + } + + /** + * Delete a feature flag's value. + * + * @internal + */ + public function delete(UnitEnum|string $feature, mixed $scope): void + { + $feature = $this->resolveFeature($feature); + + $scope = $this->resolveScope($scope); + + $this->removeFromCache(PennantState::current(), $this->connectionName(), $this->partitionKey(), $feature, Feature::serializeScope($scope)); + + $this->driver->delete($feature, $scope); + + if (Event::hasListeners(FeatureDeleted::class)) { + Event::dispatch(new FeatureDeleted($feature, $scope)); + } + } + + /** + * Purge the given feature from storage. + * + * @param null|array|string|UnitEnum $features + */ + public function purge(UnitEnum|string|array|null $features = null): void + { + $state = PennantState::current(); + $connection = $this->connectionName(); + $partition = $this->partitionKey(); + + if ($features === null) { + unset($state->values[$connection][$this->name][$partition]); + + $this->driver->purge(null); + + if (Event::hasListeners(AllFeaturesPurged::class)) { + Event::dispatch(new AllFeaturesPurged); + } + } else { + $features = array_map($this->resolveFeature(...), Arr::wrap($features)); + + foreach ($features as $feature) { + unset($state->values[$connection][$this->name][$partition][$feature]); + } + + $this->driver->purge($features); + + if (Event::hasListeners(FeaturesPurged::class)) { + Event::dispatch(new FeaturesPurged($features)); + } + } + } + + /** + * Retrieve the feature's name. + */ + public function name(UnitEnum|string $feature): string + { + return $this->resolveFeature($feature); + } + + /** + * Retrieve the map of feature names to their implementations. + * + * @return array + */ + public function nameMap(): array + { + return $this->nameMap; + } + + /** + * Retrieve the feature's class. + */ + public function instance(UnitEnum|string $name): mixed + { + $name = enum_value($name); + + if (isset($this->featureClasses[$name])) { + return $this->container->make($this->featureClasses[$name]); + } + + if (! array_key_exists($name, $this->nameMap)) { + return class_exists($name) ? $this->container->make($name) : fn () => $name; + } + + $feature = $this->nameMap[$name]; + + if ($feature instanceof Closure || $feature instanceof Lottery) { + return $feature; + } + + return fn () => $feature; + } + + /** + * Retrieve the defined features for the given scope. + * + * @internal + * + * @return Collection + */ + public function definedFeaturesForScope(mixed $scope): Collection + { + $scope = $this->resolveScope($scope); + + if ($this->driver instanceof DefinesFeaturesExternally) { + return new Collection($this->driver->definedFeaturesForScope($scope)); + } + + $defined = array_flip($this->defined()); + $features = []; + + foreach ($this->nameMap as $feature => $resolver) { + if (! isset($defined[$feature])) { + continue; + } + + if (isset($this->featureClasses[$feature]) || $resolver instanceof Closure) { + $function = $this->resolverFunctions[$feature] ??= $this->resolverFunction($resolver); + + if (! $this->functionAcceptsScope($function, $scope)) { + continue; + } + } + + $features[] = (string) $feature; + } + + return new Collection($features); + } + + /** + * Resolve the feature name and ensure it is defined. + */ + protected function resolveFeature(UnitEnum|string $feature): string + { + $feature = (string) enum_value($feature); + + if (array_key_exists($feature, $this->nameMap)) { + return $feature; + } + + if (isset($this->classNames[$feature])) { + return $this->classNames[$feature]; + } + + return $this->shouldDynamicallyDefine($feature) + ? $this->ensureDynamicFeatureIsDefined($feature) + : $feature; + } + + /** + * Determine if the feature should be dynamically defined. + */ + protected function shouldDynamicallyDefine(string $feature): bool + { + return ! in_array($feature, $this->defined()) + && class_exists($feature) + && (method_exists($feature, 'resolve') || method_exists($feature, '__invoke')); + } + + /** + * Dynamically define the feature. + * + * @param class-string $feature + */ + protected function ensureDynamicFeatureIsDefined(string $feature): string + { + return $this->classNames[$feature] = tap($this->resolveFeatureName($feature, $this->container->make($feature)), function ($name) use ($feature) { + if (! in_array($name, $this->defined())) { + if (Event::hasListeners(DynamicallyRegisteringFeatureClass::class)) { + Event::dispatch(new DynamicallyRegisteringFeatureClass($feature)); + } + + $this->define($feature); + } + }); + } + + /** + * Determine if the given feature has a before hook. + */ + protected function hasBeforeHook(string $feature): bool + { + $implementation = $this->implementationClass($feature); + + return $implementation !== null && method_exists($implementation, 'before'); + } + + /** + * Retrieve the implementation feature class for the given feature name. + * + * @return null|class-string + */ + protected function implementationClass(string $feature): ?string + { + return $this->featureClasses[$feature] ?? null; + } + + /** + * Resolve the name for a feature class. + */ + protected function resolveFeatureName(string $class, object $instance): string + { + $attribute = (new ReflectionClass($instance))->getAttributes(Name::class)[0] ?? null; + + if ($attribute !== null) { + return $attribute->newInstance()->name; + } + + return $instance->name ?? $class; + } + + /** + * Resolve the scope. + */ + protected function resolveScope(mixed $scope): mixed + { + return $scope instanceof FeatureScopeable + ? $scope->toFeatureIdentifier($this->name) + : $scope; + } + + /** + * Determine if a feature's value is in the cache for the given scope. + */ + protected function isCached(PennantState $state, string $connection, string $partition, string $feature, string $scope): bool + { + return array_key_exists($scope, $state->values[$connection][$this->name][$partition][$feature] ?? []); + } + + /** + * Put the given feature's value into the cache. + */ + protected function putInCache(PennantState $state, string $connection, string $partition, string $feature, string $scope, mixed $value): void + { + $state->values[$connection][$this->name][$partition][$feature][$scope] = $value; + } + + /** + * Remove the given feature's value from the cache. + */ + protected function removeFromCache(PennantState $state, string $connection, string $partition, string $feature, string $scope): void + { + unset($state->values[$connection][$this->name][$partition][$feature][$scope]); + } + + /** + * Forget the values memoized for the connection if its current transaction rolls back. + * + * Values read or written inside a transaction may never be committed. + */ + protected function trackTransaction(PennantState $state, string $connection): void + { + if ($connection === '') { + return; + } + + /** @var DatabaseManager $db */ + $db = $this->container->make('db'); + + if (! $db->hasApplicableTransaction($connection)) { + return; + } + + $base = ConnectionName::parse($connection)->base; + + /** @var DatabaseTransactionsManager $transactions */ + $transactions = $this->container->make('db.transactions'); + + $state->forgetOnRollback( + $transactions->callbackApplicableTransactions()->last(fn (DatabaseTransactionRecord $record) => $record->connection === $base), + $connection, + ); + } + + /** + * Get the name of the connection the driver currently uses, or an empty string for drivers without one. + */ + protected function connectionName(): string + { + return $this->driver instanceof UsesDatabaseConnection ? $this->driver->connectionName() : ''; + } + + /** + * Get the key of the store's current partition. + */ + protected function partitionKey(): string + { + return $this->partitionResolver === null ? '' : ($this->partitionResolver)()->key(); + } + + /** + * Retrieve the default scope. + */ + protected function defaultScope(): mixed + { + return ($this->defaultScopeResolver)(); + } + + /** + * Flush the in-memory cache of feature values. + * + * Only the current coroutine's values are flushed. + */ + public function flushCache(): void + { + $state = PennantState::current(); + + foreach (array_keys($state->values) as $connection) { + unset($state->values[$connection][$this->name]); + } + + if ($this->driver instanceof HasFlushableCache) { + $this->driver->flushCache(); + } + } + + /** + * Get the underlying feature driver. + */ + public function getDriver(): Driver + { + return $this->driver; + } + + /** + * Set the container instance used by the decorator. + * + * Tests only. The container persists on the store for the worker lifetime. + */ + public function setContainer(Container $container): static + { + $this->container = $container; + + // Feature class names and before hooks are read from container-built instances. + $this->classNames = []; + $this->beforeHookAcceptsNull = []; + + return $this; + } + + /** + * Dynamically create a pending feature interaction. + * + * @param array $parameters + */ + public function __call(string $name, array $parameters): mixed + { + if (static::hasMacro($name)) { + return $this->macroCall($name, $parameters); + } + + $interaction = new PendingScopedFeatureInteraction($this); + + if ($name !== 'for' && ($scope = $this->defaultScope()) !== null) { + $interaction->for($scope); + } + + return $interaction->{$name}(...$parameters); + } +} diff --git a/src/pennant/src/PendingScopedFeatureInteraction.php b/src/pennant/src/PendingScopedFeatureInteraction.php new file mode 100644 index 000000000..1e5214908 --- /dev/null +++ b/src/pennant/src/PendingScopedFeatureInteraction.php @@ -0,0 +1,361 @@ + + */ + protected array $scope = []; + + /** + * Create a new Pending Scoped Feature Interaction instance. + */ + public function __construct(Decorator $driver) + { + $this->driver = $driver; + } + + /** + * Add scope to the feature interaction. + */ + public function for(mixed $scope): static + { + $this->scope = array_merge($this->scope, $scope instanceof Enumerable ? $scope->all() : Arr::wrap($scope)); + + return $this; + } + + /** + * Load the feature into memory. + * + * @param array|string|UnitEnum $features + * @return array> + */ + public function load(UnitEnum|string|array $features): array + { + $scope = $this->scope(); + + return $this->driver->getAll(array_map( + fn ($feature) => ['feature' => $feature, 'scope' => $scope], + $this->normalize($features), + )); + } + + /** + * Load the missing features into memory. + * + * @param array|string|UnitEnum $features + * @return array> + */ + public function loadMissing(UnitEnum|string|array $features): array + { + $scope = $this->scope(); + + return $this->driver->getAllMissing(array_map( + fn ($feature) => ['feature' => $feature, 'scope' => $scope], + $this->normalize($features), + )); + } + + /** + * Load all defined features into memory. + * + * Each scope loads only the features whose resolvers accept it. + * + * @return array> + */ + public function loadAll(): array + { + $features = []; + + foreach ($this->scope() as $index => $scope) { + foreach ($this->driver->definedFeaturesForScope($scope) as $feature) { + $features[$feature]['feature'] = $feature; + $features[$feature]['scope'][$index] = $scope; + } + } + + return $this->driver->getAll(array_values($features)); + } + + /** + * Get the value of the flag. + */ + public function value(UnitEnum|string $feature): mixed + { + return head($this->values([$feature])); + } + + /** + * Get the values of the flag. + * + * @param array $features + * @return array + */ + public function values(array $features): array + { + if (count($this->scope()) > 1) { + throw new RuntimeException('It is not possible to retrieve the values for multiple scopes.'); + } + + $features = $this->normalize($features); + $scope = $this->scope()[0]; + + $this->loadMissingBeforeReading($features); + + $values = []; + + foreach ($features as $feature) { + $values[$this->driver->name($feature)] = $this->driver->get($feature, $scope); + } + + return $values; + } + + /** + * Retrieve all the features and their values. + * + * @return array + */ + public function all(): array + { + return $this->values( + $this->driver->definedFeaturesForScope($this->scope()[0])->all() + ); + } + + /** + * Determine if the feature is active. + */ + public function active(UnitEnum|string $feature): bool + { + return $this->allAreActive([$feature]); + } + + /** + * Determine if all the features are active. + * + * @param array $features + */ + public function allAreActive(array $features): bool + { + $features = $this->normalize($features); + + $this->loadMissingBeforeReading($features); + + foreach ($features as $feature) { + foreach ($this->scope() as $scope) { + if ($this->driver->get($feature, $scope) === false) { + return false; + } + } + } + + return true; + } + + /** + * Determine if any of the features are active. + * + * @param array $features + */ + public function someAreActive(array $features): bool + { + $features = $this->normalize($features); + + $this->loadMissingBeforeReading($features); + + foreach ($this->scope() as $scope) { + foreach ($features as $feature) { + if ($this->driver->get($feature, $scope) !== false) { + continue 2; + } + } + + return false; + } + + return true; + } + + /** + * Determine if the feature is inactive. + */ + public function inactive(UnitEnum|string $feature): bool + { + return $this->allAreInactive([$feature]); + } + + /** + * Determine if all the features are inactive. + * + * @param array $features + */ + public function allAreInactive(array $features): bool + { + $features = $this->normalize($features); + + $this->loadMissingBeforeReading($features); + + foreach ($features as $feature) { + foreach ($this->scope() as $scope) { + if ($this->driver->get($feature, $scope) !== false) { + return false; + } + } + } + + return true; + } + + /** + * Determine if any of the features are inactive. + * + * @param array $features + */ + public function someAreInactive(array $features): bool + { + $features = $this->normalize($features); + + $this->loadMissingBeforeReading($features); + + foreach ($this->scope() as $scope) { + foreach ($features as $feature) { + if ($this->driver->get($feature, $scope) === false) { + continue 2; + } + } + + return false; + } + + return true; + } + + /** + * Apply the callback if the feature is active. + */ + public function when(UnitEnum|string $feature, Closure $whenActive, ?Closure $whenInactive = null): mixed + { + if ($this->active($feature)) { + return $whenActive($this->value($feature), $this); + } + + if ($whenInactive !== null) { + return $whenInactive($this); + } + + return null; + } + + /** + * Apply the callback if the feature is inactive. + */ + public function unless(UnitEnum|string $feature, Closure $whenInactive, ?Closure $whenActive = null): mixed + { + return $this->when($feature, $whenActive ?? fn () => null, $whenInactive); + } + + /** + * Activate the feature. + * + * @param array|string|UnitEnum $feature + */ + public function activate(UnitEnum|string|array $feature, mixed $value = true): void + { + $features = []; + + foreach ($this->normalize($feature) as $name) { + foreach ($this->scope() as $scope) { + $features[] = ['feature' => $name, 'scope' => $scope, 'value' => $value]; + } + } + + $this->driver->setAll($features); + } + + /** + * Deactivate the feature. + * + * @param array|string|UnitEnum $feature + */ + public function deactivate(UnitEnum|string|array $feature): void + { + $features = []; + + foreach ($this->normalize($feature) as $name) { + foreach ($this->scope() as $scope) { + $features[] = ['feature' => $name, 'scope' => $scope, 'value' => false]; + } + } + + $this->driver->setAll($features); + } + + /** + * Forget the flags value. + * + * @param array|string|UnitEnum $features + */ + public function forget(UnitEnum|string|array $features): void + { + foreach ($this->normalize($features) as $feature) { + foreach ($this->scope() as $scope) { + $this->driver->delete($feature, $scope); + } + } + } + + /** + * Load the missing values of the given features before they are read one at a time. + * + * A single feature and scope is left to the read itself, which loads it the same way. + * + * @param array $features + */ + protected function loadMissingBeforeReading(array $features): void + { + if (count($features) > 1 || count($this->scope()) > 1) { + $this->loadMissing($features); + } + } + + /** + * Normalize the given features to their scalar names. + * + * @param array|string|UnitEnum $features + * @return array + */ + protected function normalize(UnitEnum|string|array $features): array + { + return array_map(enum_value(...), Arr::wrap($features)); + } + + /** + * The scope to pass to the driver. + * + * @return array + */ + protected function scope(): array + { + return $this->scope ?: [null]; + } +} From a378133acf3501b981155dec894d3de2d729fdbd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:22:00 +0000 Subject: [PATCH 08/39] Port the Pennant database driver with batched, conflict-safe writes Reads. getAll() dedupes canonical (feature, serialized scope) pairs and groups features that share the same scope list, so a rectangular load() is one statement with F + S bindings. Groups are split and packed under QUERY_GROUP_LIMIT (500 OR groups, half SQLite's default expression depth) and QUERY_BINDING_LIMIT (10,000). The OR chain always sits inside a nested where so it can't escape the partition predicate, and rows are indexed by exact name and scope. Upstream's Collection::where() matched '01' to '1', and duplicate scopes produced duplicate inserts. First resolution. Missing pairs are resolved and inserted in WRITE_ROW_LIMIT chunks with insertOrIgnore() on PostgreSQL and SQLite, and a table-qualified no-op upsert on MySQL and MariaDB (INSERT IGNORE would truncate over-long values in strict mode). When the affected row count proves every candidate won, the candidates are returned, so the uncontended path keeps upstream's one select and one insert. Otherwise, or when MySQL may count duplicates as affected (CLIENT_FOUND_ROWS set in the connection options or a custom connector bound), the batch is reread from the write PDO and the decoded stored values are returned. MySQL and MariaDB add a shared lock because a REPEATABLE READ snapshot can't see the winner otherwise. Pairs that vanish before the reread are retried at most twice per operation, then 'Unable to insert feature values into the database.' is thrown. This replaces upstream's exception-driven retry, which shared a retry depth across calls and aborted PostgreSQL transactions on the unique violation. get() goes through getAll(), so the single-pair path shares the same insert and retry budget. Mutations. setAll() collapses duplicate pairs (last value wins, since PostgreSQL rejects an upsert that updates one row twice) and runs multi-chunk writes inside one transaction. setForAllScopes(), delete() and purge() are single statements with the partition predicate. Partitions. Every operation resolves the store's partition once and passes it down. newQuery() keeps upstream's base table query and newPartitionQuery() adds the partition predicate. Inserts and upserts key on [partition,] name, scope. connectionName() returns the requested connection, or the runtime default when the store's connection is null, so a null-connection store follows DB::usingConnection(). It rejects ::read connections before any memo, cache or SQL access: first resolution writes, and every statement on a read alias goes to a replica. StoredValueCache is an opt-in shared cache for stored row values, never before() results or unknown-feature false. Entries are keyed by the connection without its write suffix, the table and the partition, and are valid only while their partition and scope tokens match. Tokens are ULIDs written with the profile's TTL (DEFAULT_TTL 3600 seconds). Reads skip the cache inside a transaction and read from the write PDO when they publish, so a replica-lagged value is never published under a post-rotation token. Mutations rotate scope or partition tokens after commit in every invalidation target, attempt every target, and rethrow the first ordinary failure after the loop. A fill that captured tokens before a writer's commit publishes under rotated-away tokens, so readers ignore it without locks. The migration keeps text for value: jsonb and MySQL JSON reorder object keys, which breaks === comparisons between first resolution and later reads, and jsonb rejects \u0000. On MySQL and MariaDB, name and scope are VARBINARY so case, accents and trailing spaces can't make one scope read another's value. PennantMigration is ported as is. Constructor departures for subclasses: the driver takes (DatabaseManager $db, ?string $connection, string $table, ?Closure $partitionResolver, ?StoredValueCache $cache, array $featureStateResolvers) and drops $config, $events, $name and $retryDepth; retrieve(), insert() and the unused update() are gone and insertMany() is the conflict-safe insert. --- ...022_11_01_000001_create_features_table.php | 42 ++ src/pennant/src/Drivers/DatabaseDriver.php | 708 ++++++++++++++++++ src/pennant/src/Drivers/StoredValueCache.php | 236 ++++++ .../src/Migrations/PennantMigration.php | 20 + 4 files changed, 1006 insertions(+) create mode 100644 src/pennant/database/migrations/2022_11_01_000001_create_features_table.php create mode 100644 src/pennant/src/Drivers/DatabaseDriver.php create mode 100644 src/pennant/src/Drivers/StoredValueCache.php create mode 100644 src/pennant/src/Migrations/PennantMigration.php diff --git a/src/pennant/database/migrations/2022_11_01_000001_create_features_table.php b/src/pennant/database/migrations/2022_11_01_000001_create_features_table.php new file mode 100644 index 000000000..eea6597ad --- /dev/null +++ b/src/pennant/database/migrations/2022_11_01_000001_create_features_table.php @@ -0,0 +1,42 @@ +id(); + + // MySQL and MariaDB string collations ignore case, accents and trailing spaces. + if (in_array(Schema::getConnection()->getDriverName(), ['mysql', 'mariadb'], true)) { + $table->binary('name', length: Builder::$defaultStringLength * 4); + $table->binary('scope', length: Builder::$defaultStringLength * 4); + } else { + $table->string('name'); + $table->string('scope'); + } + + $table->text('value'); + $table->timestamps(); + + $table->unique(['name', 'scope']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('features'); + } +}; diff --git a/src/pennant/src/Drivers/DatabaseDriver.php b/src/pennant/src/Drivers/DatabaseDriver.php new file mode 100644 index 000000000..bdb73417a --- /dev/null +++ b/src/pennant/src/Drivers/DatabaseDriver.php @@ -0,0 +1,708 @@ + + */ + protected array $featureStateResolvers; + + /** + * The sentinel value for unknown features. + */ + protected stdClass $unknownFeatureValue; + + /** + * The name of the "created at" column. + */ + public const string CREATED_AT = 'created_at'; + + /** + * The name of the "updated at" column. + */ + public const string UPDATED_AT = 'updated_at'; + + /** + * The maximum number of feature groups in one read query. + * + * Half of SQLite's default expression depth limit of 1000. + */ + public const int QUERY_GROUP_LIMIT = 500; + + /** + * The maximum number of bindings in one read query. + */ + public const int QUERY_BINDING_LIMIT = 10_000; + + /** + * The maximum number of rows in one write statement. + */ + public const int WRITE_ROW_LIMIT = 1000; + + /** + * Create a new driver instance. + * + * @param null|(Closure(): FeaturePartition) $partitionResolver + * @param array $featureStateResolvers + */ + public function __construct( + DatabaseManager $db, + ?string $connection, + string $table, + ?Closure $partitionResolver, + ?StoredValueCache $cache, + array $featureStateResolvers, + ) { + $this->db = $db; + $this->connection = $connection; + $this->table = $table; + $this->partitionResolver = $partitionResolver; + $this->cache = $cache; + $this->featureStateResolvers = $featureStateResolvers; + + $this->unknownFeatureValue = new stdClass; + } + + /** + * Define an initial feature flag state resolver. + * + * Boot or tests only. The definition persists on the store for the worker + * lifetime and applies to every subsequent request. + * + * @param (callable(mixed $scope): mixed) $resolver + */ + public function define(string $feature, callable $resolver): void + { + $this->featureStateResolvers[$feature] = $resolver; + } + + /** + * Retrieve the names of all defined features. + * + * @return array + */ + public function defined(): array + { + return array_keys($this->featureStateResolvers); + } + + /** + * Retrieve the names of all stored features. + * + * @return array + */ + public function stored(): array + { + return $this->newPartitionQuery($this->partition()) + ->select('name') + ->distinct() + ->get() + ->pluck('name') + ->all(); + } + + /** + * Get multiple feature flag values. + * + * @param array> $features + * @return array> + */ + public function getAll(array $features): array + { + $pairs = []; + $scopeKeys = []; + + foreach ($features as $feature => $scopes) { + foreach ($scopes as $index => $scope) { + $scopeKey = Feature::serializeScope($scope); + + $scopeKeys[$feature][$index] = $scopeKey; + $pairs[$feature][$scopeKey] = $scope; + } + } + + $values = $pairs === [] ? [] : $this->retrieveValues($pairs); + $results = []; + + foreach ($features as $feature => $scopes) { + $results[$feature] = []; + + foreach ($scopeKeys[$feature] ?? [] as $index => $scopeKey) { + $results[$feature][$index] = $values[$feature][$scopeKey]; + } + } + + return $results; + } + + /** + * Retrieve a feature flag's value. + */ + public function get(string $feature, mixed $scope): mixed + { + return $this->getAll([$feature => [$scope]])[$feature][0]; + } + + /** + * Retrieve the values of the given features and scopes, storing initial values for any that are missing. + * + * @param array> $pairs scopes keyed by their serialized identifiers + * @return array> + */ + protected function retrieveValues(array $pairs): array + { + $connection = $this->connectionName(); + $partition = $this->partition(); + $values = []; + $tokens = null; + + // Inside a transaction the database may hold uncommitted rows the cache must not see. + if ($this->cache?->storesValues() && ! $this->db->hasApplicableTransaction($connection)) { + [$values, $tokens] = $this->cache->read($connection, $partition, $pairs); + + foreach ($values as $feature => $scopes) { + $pairs[$feature] = array_diff_key($pairs[$feature], $scopes); + + if ($pairs[$feature] === []) { + unset($pairs[$feature]); + } + } + } + + if ($pairs === []) { + return $values; + } + + $stored = []; + + // A replica may lag behind the write that rotated the cache tokens, so values published under them are read from the primary. + foreach ($this->readStoredValues($pairs, $partition, useWritePdo: $tokens !== null) as $feature => $scopes) { + foreach ($scopes as $scopeKey => $value) { + $values[$feature][$scopeKey] = json_decode($value, flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR); + $stored[$feature][$scopeKey] = $value; + + unset($pairs[$feature][$scopeKey]); + } + + if ($pairs[$feature] === []) { + unset($pairs[$feature]); + } + } + + if ($pairs !== []) { + [$inserted, $insertedValues] = $this->insertMany($partition, $pairs); + + foreach ($inserted as $feature => $scopes) { + foreach ($scopes as $scopeKey => $value) { + $values[$feature][$scopeKey] = $value; + } + } + + foreach ($insertedValues as $feature => $scopes) { + foreach ($scopes as $scopeKey => $value) { + $stored[$feature][$scopeKey] = $value; + } + } + } + + if ($tokens !== null && $stored !== []) { + $this->cache->publish($connection, $partition, $tokens, $stored); + } + + return $values; + } + + /** + * Read the stored JSON values of the given features and scopes. + * + * Features that share the same scopes are combined into one predicate, and the + * predicates are packed into as few statements as the group and binding limits allow. + * + * @param array> $pairs scopes keyed by their serialized identifiers + * @return array> + */ + protected function readStoredValues(array $pairs, ?FeaturePartition $partition, bool $useWritePdo = false, bool $lock = false): array + { + $bindingLimit = self::QUERY_BINDING_LIMIT - ($partition === null ? 0 : 1); + $groups = []; + + foreach ($pairs as $feature => $scopes) { + $scopeKeys = array_map(strval(...), array_keys($scopes)); + + sort($scopeKeys, SORT_STRING); + + $group = implode('', array_map(static fn (string $scope): string => strlen($scope) . ':' . $scope, $scopeKeys)); + + $groups[$group] ??= ['features' => [], 'scopes' => $scopeKeys]; + $groups[$group]['features'][] = (string) $feature; + } + + $predicates = []; + + foreach ($groups as ['features' => $features, 'scopes' => $scopes]) { + if (count($features) + count($scopes) <= $bindingLimit) { + $predicates[] = [$features, $scopes]; + + continue; + } + + foreach (array_chunk($features, intdiv($bindingLimit, 2)) as $featureChunk) { + foreach (array_chunk($scopes, $bindingLimit - count($featureChunk)) as $scopeChunk) { + $predicates[] = [$featureChunk, $scopeChunk]; + } + } + } + + $statements = []; + $statement = []; + $bindings = 0; + + foreach ($predicates as $predicate) { + $size = count($predicate[0]) + count($predicate[1]); + + if ($statement !== [] && (count($statement) === self::QUERY_GROUP_LIMIT || $bindings + $size > $bindingLimit)) { + $statements[] = $statement; + $statement = []; + $bindings = 0; + } + + $statement[] = $predicate; + $bindings += $size; + } + + if ($statement !== []) { + $statements[] = $statement; + } + + $values = []; + + foreach ($statements as $statement) { + $query = $this->newPartitionQuery($partition) + ->select(['name', 'scope', 'value']) + ->where(function (Builder $query) use ($statement): void { + foreach ($statement as [$features, $scopes]) { + $query->orWhere(fn (Builder $query) => $query->whereIn('name', $features)->whereIn('scope', $scopes)); + } + }); + + if ($useWritePdo) { + $query->useWritePdo(); + } + + if ($lock) { + $query->sharedLock(); + } + + foreach ($query->get() as $record) { + $values[$record->name][$record->scope] = $record->value; + } + } + + return $values; + } + + /** + * Determine the initial value for a given feature and scope. + */ + protected function resolveValue(string $feature, mixed $scope): mixed + { + if (! array_key_exists($feature, $this->featureStateResolvers)) { + if (Event::hasListeners(UnknownFeatureResolved::class)) { + Event::dispatch(new UnknownFeatureResolved($feature, $scope)); + } + + return $this->unknownFeatureValue; + } + + return $this->featureStateResolvers[$feature]($scope); + } + + /** + * Set a feature flag's value. + */ + public function set(string $feature, mixed $scope, mixed $value): void + { + $partition = $this->partition(); + $scope = Feature::serializeScope($scope); + $now = CarbonImmutable::now(); + + $this->newQuery()->upsert( + $this->row($partition, $feature, $scope, json_encode($value, flags: JSON_THROW_ON_ERROR), $now), + uniqueBy: $this->uniqueBy($partition), + update: ['value', static::UPDATED_AT], + ); + + $this->invalidate($partition, [$scope]); + } + + /** + * Set multiple feature flag values. + * + * @param list $features + */ + public function setAll(array $features): void + { + $partition = $this->partition(); + $now = CarbonImmutable::now(); + $rows = []; + $scopes = []; + + foreach ($features as $feature) { + $scope = Feature::serializeScope($feature['scope']); + + // Like sequential writes, the last value for a repeated feature and scope wins. + // PostgreSQL also rejects an upsert that updates the same row twice. + $rows[strlen($feature['feature']) . ':' . $feature['feature'] . $scope] = $this->row( + $partition, + $feature['feature'], + $scope, + json_encode($feature['value'], flags: JSON_THROW_ON_ERROR), + $now, + ); + + $scopes[$scope] = $scope; + } + + $chunks = array_chunk($rows, self::WRITE_ROW_LIMIT); + + $write = function () use ($chunks, $partition): void { + foreach ($chunks as $chunk) { + $this->newQuery()->upsert($chunk, uniqueBy: $this->uniqueBy($partition), update: ['value', static::UPDATED_AT]); + } + }; + + count($chunks) > 1 ? $this->connection()->transaction($write) : $write(); + + $this->invalidate($partition, array_values($scopes)); + } + + /** + * Set a feature flag's value for all scopes. + */ + public function setForAllScopes(string $feature, mixed $value): void + { + $partition = $this->partition(); + + $this->newPartitionQuery($partition) + ->where('name', $feature) + ->update([ + 'value' => json_encode($value, flags: JSON_THROW_ON_ERROR), + static::UPDATED_AT => CarbonImmutable::now(), + ]); + + $this->invalidate($partition); + } + + /** + * Insert initial values for the given features and scopes, returning the values that are stored. + * + * When every candidate is known to be inserted, the resolved values are returned as they are. + * Otherwise the rows are read back and their stored values returned, since a matching row may + * belong to a concurrent insert. Rows that vanish before they can be read back, such as through + * a concurrent purge, are resolved and inserted again, at most twice. + * + * @param array> $pairs scopes keyed by their serialized identifiers + * @return array{array>, array>} the values, and the stored JSON of those that are stored + * + * @throws RuntimeException when rows keep vanishing + */ + protected function insertMany(?FeaturePartition $partition, array $pairs): array + { + $values = []; + $stored = []; + + for ($attempt = 0; $pairs !== []; ++$attempt) { + if ($attempt > 2) { + throw new RuntimeException('Unable to insert feature values into the database.'); + } + + $now = CarbonImmutable::now(); + $candidates = []; + $rows = []; + + foreach ($pairs as $feature => $scopes) { + $feature = (string) $feature; + + foreach ($scopes as $scopeKey => $scope) { + $value = $this->resolveValue($feature, $scope); + + if ($value === $this->unknownFeatureValue) { + $values[$feature][$scopeKey] = false; + + continue; + } + + $json = json_encode($value, flags: JSON_THROW_ON_ERROR); + + $candidates[$feature][$scopeKey] = [$scope, $value, $json]; + $rows[] = $this->row($partition, $feature, (string) $scopeKey, $json, $now); + } + } + + if ($rows === []) { + break; + } + + if ($this->insertCandidates($partition, $rows)) { + foreach ($candidates as $feature => $scopes) { + foreach ($scopes as $scopeKey => [, $value, $json]) { + $values[$feature][$scopeKey] = $value; + $stored[$feature][$scopeKey] = $json; + } + } + + break; + } + + // A MySQL REPEATABLE READ snapshot hides the competing row from a plain read. + $winners = $this->readStoredValues( + $candidates, + $partition, + useWritePdo: true, + lock: in_array($this->connection()->getDriverName(), ['mysql', 'mariadb'], true), + ); + + $pairs = []; + + foreach ($candidates as $feature => $scopes) { + foreach ($scopes as $scopeKey => [$scope]) { + if (! isset($winners[$feature][$scopeKey])) { + $pairs[$feature][$scopeKey] = $scope; + + continue; + } + + $values[$feature][$scopeKey] = json_decode($winners[$feature][$scopeKey], flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR); + $stored[$feature][$scopeKey] = $winners[$feature][$scopeKey]; + } + } + } + + return [$values, $stored]; + } + + /** + * Insert the candidate rows, skipping those that already exist, and determine whether every candidate was inserted. + * + * @param list> $rows + */ + protected function insertCandidates(?FeaturePartition $partition, array $rows): bool + { + $connection = $this->connection(); + $driver = $connection->getDriverName(); + $inserted = 0; + + foreach (array_chunk($rows, self::WRITE_ROW_LIMIT) as $chunk) { + $query = $this->newQuery(); + + // MySQL's INSERT IGNORE would also hide truncation and other data errors, so a table-qualified + // no-op upsert skips duplicates instead (an unqualified column is ambiguous on PostgreSQL). + $inserted += in_array($driver, ['mysql', 'mariadb'], true) + ? $query->upsert($chunk, $this->uniqueBy($partition), ['name' => new Expression($query->getGrammar()->wrap("{$this->table}.name"))]) + : $query->insertOrIgnore($chunk); + } + + // With CLIENT_FOUND_ROWS, MySQL counts duplicate rows as affected. Built-in connectors don't + // enable it unless the connection options do; a custom connector may set it in its defaults. + if (in_array($driver, ['mysql', 'mariadb'], true) + && (Container::getInstance()->bound("db.connector.{$driver}") || ! empty($connection->getConfig('options')[Mysql::ATTR_FOUND_ROWS] ?? false))) { + return false; + } + + return $inserted === count($rows); + } + + /** + * Get a row for the given feature value. + * + * @return array + */ + protected function row(?FeaturePartition $partition, string $feature, string $scope, string $value, CarbonImmutable $now): array + { + $row = [ + 'name' => $feature, + 'scope' => $scope, + 'value' => $value, + static::CREATED_AT => $now, + static::UPDATED_AT => $now, + ]; + + if ($partition !== null) { + $row[$partition->column] = $partition->value; + } + + return $row; + } + + /** + * Get the columns that identify a stored value. + * + * @return list + */ + protected function uniqueBy(?FeaturePartition $partition): array + { + return $partition === null ? ['name', 'scope'] : [$partition->column, 'name', 'scope']; + } + + /** + * Delete a feature flag's value. + */ + public function delete(string $feature, mixed $scope): void + { + $partition = $this->partition(); + $scope = Feature::serializeScope($scope); + + $this->newPartitionQuery($partition) + ->where('name', $feature) + ->where('scope', $scope) + ->delete(); + + $this->invalidate($partition, [$scope]); + } + + /** + * Purge the given feature from storage. + */ + public function purge(?array $features): void + { + $partition = $this->partition(); + + if ($features === null) { + $this->newPartitionQuery($partition)->delete(); + } else { + $this->newPartitionQuery($partition) + ->whereIn('name', $features) + ->delete(); + } + + $this->invalidate($partition); + } + + /** + * Rotate the shared cache tokens of the given scopes, or of the whole partition, once the write commits. + * + * @param null|list $scopes + */ + protected function invalidate(?FeaturePartition $partition, ?array $scopes = null): void + { + if ($this->cache === null) { + return; + } + + $connection = $this->connectionName(); + + $this->connection()->afterCommitOrNow(fn () => $scopes === null + ? $this->cache->rotatePartition($connection, $partition) + : $this->cache->rotateScopes($connection, $partition, $scopes)); + } + + /** + * Get the name of the store's current connection. + * + * @throws InvalidArgumentException when the connection is a read connection + */ + public function connectionName(): string + { + $connection = $this->connection ?? $this->db->getDefaultConnection(); + + // First resolution writes, and every statement on a read connection goes to a replica. + if (str_ends_with($connection, '::' . ConnectionName::READ)) { + throw new InvalidArgumentException("Pennant can't use the read connection [{$connection}]. Use its base or write connection instead."); + } + + return $connection; + } + + /** + * Get the store's current partition. + */ + protected function partition(): ?FeaturePartition + { + return $this->partitionResolver === null ? null : ($this->partitionResolver)(); + } + + /** + * Create a new table query. + */ + protected function newQuery(): Builder + { + return $this->connection()->table($this->table); + } + + /** + * Create a new table query limited to the given partition. + */ + protected function newPartitionQuery(?FeaturePartition $partition): Builder + { + $query = $this->newQuery(); + + if ($partition !== null) { + $query->where($partition->column, $partition->value); + } + + return $query; + } + + /** + * The database connection. + */ + protected function connection(): Connection + { + /** @var Connection */ + return $this->db->connection($this->connectionName()); + } +} diff --git a/src/pennant/src/Drivers/StoredValueCache.php b/src/pennant/src/Drivers/StoredValueCache.php new file mode 100644 index 000000000..5f75f655b --- /dev/null +++ b/src/pennant/src/Drivers/StoredValueCache.php @@ -0,0 +1,236 @@ + $targets the caches and TTLs whose tokens are replaced on writes + * @param null|Repository $cache the cache values are read from and published to + */ + public function __construct( + protected string $table, + protected array $targets, + protected ?Repository $cache = null, + protected int $ttl = self::DEFAULT_TTL, + ) { + } + + /** + * Determine if values are read from and published to the cache. + */ + public function storesValues(): bool + { + return $this->cache !== null; + } + + /** + * Read the cached values of the given features and scopes. + * + * Missing tokens are created before the database is read, so a write that commits + * afterwards replaces them. Without tokens, nothing read now may be published. + * + * @param array> $pairs scopes keyed by their serialized identifiers + * @return array{array>, null|array{p: string, s: array}} the cached values, and the tokens to publish the database values under + */ + public function read(string $connection, ?FeaturePartition $partition, array $pairs): array + { + $prefix = $this->prefix($connection, $partition); + $partitionKey = $this->partitionTokenKey($prefix); + $scopeKeys = []; + $valueKeys = []; + $keys = [$partitionKey]; + + foreach ($pairs as $feature => $scopes) { + foreach ($scopes as $scope => $unused) { + if (! isset($scopeKeys[$scope])) { + $keys[] = $scopeKeys[$scope] = $this->scopeTokenKey($prefix, $scope); + } + + $keys[] = $valueKeys[$feature][$scope] = $this->valueKey($prefix, $feature, $scope); + } + } + + $entries = $this->cache->get($keys); + $tokens = ['p' => $entries[$partitionKey], 's' => []]; + $missing = []; + + if ($tokens['p'] === null) { + $missing[$partitionKey] = $tokens['p'] = (string) Str::ulid(); + } + + foreach ($scopeKeys as $scope => $key) { + $tokens['s'][$scope] = $entries[$key]; + + if ($tokens['s'][$scope] === null) { + $missing[$key] = $tokens['s'][$scope] = (string) Str::ulid(); + } + } + + $values = []; + + foreach ($valueKeys as $feature => $scopes) { + foreach ($scopes as $scope => $key) { + $entry = $entries[$key]; + + if (is_array($entry) && $entry['p'] === $tokens['p'] && $entry['s'] === $tokens['s'][$scope]) { + $values[$feature][$scope] = json_decode($entry['v'], flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR); + } + } + } + + if ($missing !== [] && ! $this->cache->put($missing, $this->ttl)) { + return [$values, null]; + } + + return [$values, $tokens]; + } + + /** + * Publish the stored JSON values of the given features and scopes under the tokens they were read with. + * + * @param array{p: string, s: array} $tokens + * @param array> $stored + */ + public function publish(string $connection, ?FeaturePartition $partition, array $tokens, array $stored): void + { + $prefix = $this->prefix($connection, $partition); + $entries = []; + + foreach ($stored as $feature => $scopes) { + foreach ($scopes as $scope => $json) { + $entries[$this->valueKey($prefix, $feature, $scope)] = ['p' => $tokens['p'], 's' => $tokens['s'][$scope], 'v' => $json]; + } + } + + $this->cache->put($entries, $this->ttl); + } + + /** + * Invalidate the cached values of the given scopes. + * + * @param list $scopes + */ + public function rotateScopes(string $connection, ?FeaturePartition $partition, array $scopes): void + { + $prefix = $this->prefix($connection, $partition); + $tokens = []; + + foreach ($scopes as $scope) { + $tokens[$this->scopeTokenKey($prefix, $scope)] = (string) Str::ulid(); + } + + $this->rotate($tokens); + } + + /** + * Invalidate every cached value in the partition. + */ + public function rotatePartition(string $connection, ?FeaturePartition $partition): void + { + $this->rotate([$this->partitionTokenKey($this->prefix($connection, $partition)) => (string) Str::ulid()]); + } + + /** + * Replace the given tokens in every target cache. + * + * Every target is attempted, since a failure in one doesn't make the others stale. + * + * @param array $tokens + * + * @throws RuntimeException when a target fails to store the tokens + */ + protected function rotate(array $tokens): void + { + $exception = null; + + foreach ($this->targets as [$cache, $ttl]) { + try { + if (! $cache->put($tokens, $ttl)) { + $exception ??= new RuntimeException('Unable to invalidate cached feature values. They may be stale until they expire.'); + } + } catch (CanceledException $canceledException) { + throw $canceledException; + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } + + /** + * Get the key prefix of the given connection and partition. + * + * Stores reading the same rows share entries, so the prefix names the storage, not the store. + */ + protected function prefix(string $connection, ?FeaturePartition $partition): string + { + return 'pennant:' + . $this->segment(ConnectionName::withoutWriteType($connection)) + . $this->segment($this->table) + . $this->segment($partition?->key() ?? '') + . ':'; + } + + /** + * Get the key of the partition token. + */ + protected function partitionTokenKey(string $prefix): string + { + return $prefix . 'p'; + } + + /** + * Get the key of a scope's token. + */ + protected function scopeTokenKey(string $prefix, int|string $scope): string + { + return $prefix . 's' . $this->segment($scope); + } + + /** + * Get the key of a feature's cached value for a scope. + */ + protected function valueKey(string $prefix, int|string $feature, int|string $scope): string + { + return $prefix . 'v' . $this->segment($scope) . $this->segment($feature); + } + + /** + * Encode a key segment so adjacent segments can't run together. + */ + protected function segment(int|string $value): string + { + $value = (string) $value; + + return strlen($value) . ':' . $value; + } +} diff --git a/src/pennant/src/Migrations/PennantMigration.php b/src/pennant/src/Migrations/PennantMigration.php new file mode 100644 index 000000000..471025c50 --- /dev/null +++ b/src/pennant/src/Migrations/PennantMigration.php @@ -0,0 +1,20 @@ + Date: Tue, 20 Oct 2026 17:22:28 +0000 Subject: [PATCH 09/39] Port the Pennant manager, facade, middleware and commands FeatureManager stays a worker-lifetime auto-singleton. Upstream's closure binding is dropped because the container already shares unbound concrete classes. Config is read through named methods, and getDefaultDriver() reads pennant.default as a string with no code-level fallback, since the shipped config defines it. Stores read their pennant.stores config once, when built. Upstream (laravel/pennant#81) rereads the database store's connection and table on every query so a store kept across Octane requests follows config edits. Config is process-global in Hypervel, so request-time edits aren't supported anyway, and reading once matches CacheManager and skips the per-query lookups. Per-request connections use a store with a null connection under DB::usingConnection(), and shared tables use partitioning. After reconfiguring stores at boot or in tests, call forgetDrivers() and define features again. forgetDriver() and forgetDrivers() flush each discarded store's memo and array values so a rebuilt store can't inherit them. The database factory normalizes its config once: an empty or null connection means the runtime default, the table defaults to features, and a cache profile needs a store and a positive TTL (default 3600 seconds), because the cache treats null as forever and non-positive values as deletes. Each database store's shared cache targets the cache profiles of every configured database store on the same table whose connection overlaps it, so a write through an uncached alias still invalidates cached readers. resolvePartitionUsing() registers a column and a resolver at boot. It throws once any store is resolved, because an earlier store holds no partition resolver and would read and write across partitions. The column must be a plain SQL identifier. resolve() builds a per-store partition resolver, or none for stores configured with 'partitioned' => false, and passes it to the decorator and the built-in drivers. A null or empty partition value throws FeaturePartitionNotResolved. createArrayDriver() takes the config and store name, which resolve() already passed upstream. serializeScope() throws for a model without a key. Upstream stored every unsaved model under the same Class| scope, so they shared one stored value and one lottery draw. Worker-lifetime mutators carry Boot-only (extend, useMorphMap, resolveScopeUsing, resolvePartitionUsing, setDefaultDriver, EnsureFeaturesAreActive::whenInactive), Boot or tests only (forgetDriver, forgetDrivers) or Tests only (setContainer) warnings. EnsureFeaturesAreActive gets flushState() for its static response callback and reads app.debug with config()->boolean(). The provider merges pennant.stores by name like cache.stores, publishes the config and migration, registers the commands, the PublishingStubs listener and the Blade directives. Laravel's Octane and JobProcessed flush listeners aren't ported because feature values are memoized per coroutine. The feature stub drops its unused Lottery import. --- src/pennant/config/pennant.php | 48 ++ .../src/Commands/FeatureMakeCommand.php | 56 ++ src/pennant/src/Commands/PurgeCommand.php | 70 +++ src/pennant/src/Feature.php | 73 +++ src/pennant/src/FeatureManager.php | 506 ++++++++++++++++++ .../Middleware/EnsureFeaturesAreActive.php | 70 +++ src/pennant/src/PennantServiceProvider.php | 87 +++ src/pennant/stubs/feature.stub | 16 + 8 files changed, 926 insertions(+) create mode 100644 src/pennant/config/pennant.php create mode 100644 src/pennant/src/Commands/FeatureMakeCommand.php create mode 100644 src/pennant/src/Commands/PurgeCommand.php create mode 100644 src/pennant/src/Feature.php create mode 100644 src/pennant/src/FeatureManager.php create mode 100644 src/pennant/src/Middleware/EnsureFeaturesAreActive.php create mode 100644 src/pennant/src/PennantServiceProvider.php create mode 100644 src/pennant/stubs/feature.stub diff --git a/src/pennant/config/pennant.php b/src/pennant/config/pennant.php new file mode 100644 index 000000000..b9e3422b1 --- /dev/null +++ b/src/pennant/config/pennant.php @@ -0,0 +1,48 @@ + env('PENNANT_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Pennant Stores + |-------------------------------------------------------------------------- + | + | Here you may configure each of the stores that should be available to + | Pennant. These stores shall be used to store resolved feature flag + | values - you may configure as many as your application requires. + | + | A database store may share its stored values between servers through a + | cache store. Set "cache" to an array containing the cache "store" name + | and an optional "ttl" in seconds. + | + */ + + 'stores' => [ + 'array' => [ + 'driver' => 'array', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => null, + 'table' => 'features', + 'cache' => null, + ], + ], +]; diff --git a/src/pennant/src/Commands/FeatureMakeCommand.php b/src/pennant/src/Commands/FeatureMakeCommand.php new file mode 100644 index 000000000..3ddad3711 --- /dev/null +++ b/src/pennant/src/Commands/FeatureMakeCommand.php @@ -0,0 +1,56 @@ +hypervel->basePath('stubs/feature.stub')) + ? $customPath + : __DIR__ . '/../../stubs/feature.stub'; + } + + /** + * Get the default namespace for the class. + */ + protected function getDefaultNamespace(string $rootNamespace): string + { + return "{$rootNamespace}\\Features"; + } + + /** + * Get the console command arguments. + */ + protected function getOptions(): array + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the feature already exists'], + ]; + } +} diff --git a/src/pennant/src/Commands/PurgeCommand.php b/src/pennant/src/Commands/PurgeCommand.php new file mode 100644 index 000000000..0ef8928b7 --- /dev/null +++ b/src/pennant/src/Commands/PurgeCommand.php @@ -0,0 +1,70 @@ +store($this->option('store')); + + $features = $this->argument('features') ?: null; + + $except = collect($this->option('except')) + ->when($this->option('except-registered'), fn ($except) => $except->merge($store->defined())) + ->unique() + ->all(); + + if ($except) { + $features = collect($features ?: $store->stored()) + ->flip() + ->forget($except) + ->flip() + ->values() + ->all(); + } + + $store->purge($features); + + if ($features) { + $this->components->info(implode(', ', $features) . ' successfully purged from storage.'); + } elseif ($except) { + $this->components->info('No features to purge from storage.'); + } else { + $this->components->info('All features successfully purged from storage.'); + } + + return self::SUCCESS; + } +} diff --git a/src/pennant/src/Feature.php b/src/pennant/src/Feature.php new file mode 100644 index 000000000..4c00a45ec --- /dev/null +++ b/src/pennant/src/Feature.php @@ -0,0 +1,73 @@ + $config, string $name) + * @method static \Hypervel\Pennant\Drivers\DatabaseDriver createDatabaseDriver(array $config, string $name) + * @method static \Hypervel\Pennant\Drivers\Decorator driver(string|null $name = null) + * @method static \Hypervel\Pennant\FeatureManager extend(string $driver, \Closure $callback) + * @method static void flushCache() + * @method static \Hypervel\Pennant\FeatureManager forgetDriver(null|array|string $name = null) + * @method static \Hypervel\Pennant\FeatureManager forgetDrivers() + * @method static string getDefaultDriver() + * @method static void resolvePartitionUsing(string $column, \Closure $resolver) + * @method static void resolveScopeUsing(callable $resolver) + * @method static string serializeScope(mixed $scope) + * @method static \Hypervel\Pennant\FeatureManager setContainer(\Hypervel\Contracts\Container\Container $container) + * @method static void setDefaultDriver(string $name) + * @method static \Hypervel\Pennant\Drivers\Decorator store(string|null $store = null) + * @method static \Hypervel\Pennant\FeatureManager useMorphMap(bool $value = true) + * @method static void activateForEveryone(array|string|\UnitEnum $feature, mixed $value = true) + * @method static void deactivateForEveryone(array|string|\UnitEnum $feature) + * @method static void define(string|\UnitEnum $feature, mixed $resolver = null) + * @method static array defined() + * @method static void discover(string $namespace = 'App\\Features', string|null $path = null) + * @method static void flushMacros() + * @method static \Hypervel\Pennant\Contracts\Driver getDriver() + * @method static \Hypervel\Pennant\PendingScopedFeatureInteraction globally() + * @method static bool hasMacro(string $name) + * @method static mixed instance(\UnitEnum|string $name) + * @method static bool isResolverValidForScope(callable|string $resolver, mixed $scope) + * @method static void macro(string $name, callable|object $macro) + * @method static mixed macroCall(string $method, array $parameters) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static string name(\UnitEnum|string $feature) + * @method static array nameMap() + * @method static void purge(null|array|string|\UnitEnum $features = null) + * @method static array stored() + * @method static void activate(array|string|\UnitEnum $feature, mixed $value = true) + * @method static bool active(\UnitEnum|string $feature) + * @method static array all() + * @method static bool allAreActive(array $features) + * @method static bool allAreInactive(array $features) + * @method static void deactivate(array|string|\UnitEnum $feature) + * @method static \Hypervel\Pennant\PendingScopedFeatureInteraction for(mixed $scope) + * @method static void forget(array|string|\UnitEnum $features) + * @method static bool inactive(\UnitEnum|string $feature) + * @method static array> load(array|string|\UnitEnum $features) + * @method static array> loadAll() + * @method static array> loadMissing(array|string|\UnitEnum $features) + * @method static bool someAreActive(array $features) + * @method static bool someAreInactive(array $features) + * @method static mixed unless(\UnitEnum|string $feature, \Closure $whenInactive, \Closure|null $whenActive = null) + * @method static mixed value(\UnitEnum|string $feature) + * @method static array values(array $features) + * @method static mixed when(\UnitEnum|string $feature, \Closure $whenActive, \Closure|null $whenInactive = null) + * + * @see \Hypervel\Pennant\FeatureManager + */ +class Feature extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return FeatureManager::class; + } +} diff --git a/src/pennant/src/FeatureManager.php b/src/pennant/src/FeatureManager.php new file mode 100644 index 000000000..c16da450b --- /dev/null +++ b/src/pennant/src/FeatureManager.php @@ -0,0 +1,506 @@ + + */ + protected array $stores = []; + + /** + * The registered custom driver creators. + * + * @var array + */ + protected array $customCreators = []; + + /** + * The default scope resolver. + * + * @var null|(Closure(string): mixed) + */ + protected ?Closure $defaultScopeResolver = null; + + /** + * Indicates if the Eloquent "morph map" should be used when serializing. + */ + protected bool $useMorphMap = false; + + /** + * The column that partitions stored feature values. + */ + protected ?string $partitionColumn = null; + + /** + * The resolver of the current partition value. + * + * @var null|(Closure(string): (null|int|string)) + */ + protected ?Closure $partitionResolver = null; + + /** + * Create a new Pennant manager instance. + */ + public function __construct(Container $container) + { + $this->container = $container; + } + + /** + * Get a Pennant store instance. + * + * @throws InvalidArgumentException + */ + public function store(?string $store = null): Decorator + { + return $this->driver($store); + } + + /** + * Get a Pennant store instance by name. + * + * @throws InvalidArgumentException + */ + public function driver(?string $name = null): Decorator + { + $name = $name ?: $this->getDefaultDriver(); + + return $this->stores[$name] = $this->get($name); + } + + /** + * Attempt to get the store from the local cache. + */ + protected function get(string $name): Decorator + { + return $this->stores[$name] ?? $this->resolve($name); + } + + /** + * Resolve the given store. + * + * @throws InvalidArgumentException + */ + protected function resolve(string $name): Decorator + { + $config = $this->getConfig($name); + + if (is_null($config)) { + throw new InvalidArgumentException("Pennant store [{$name}] is not defined."); + } + + if (isset($this->customCreators[$config['driver']])) { + $driver = $this->callCustomCreator($config); + } else { + $driverMethod = 'create' . ucfirst($config['driver']) . 'Driver'; + + if (method_exists($this, $driverMethod)) { + $driver = $this->{$driverMethod}($config, $name); + } else { + throw new InvalidArgumentException("Driver [{$config['driver']}] is not supported."); + } + } + + return new Decorator( + $name, + $driver, + $this->defaultScopeResolver($name), + $this->partitionResolver($name, $config), + $this->container, + ); + } + + /** + * Call a custom driver creator. + * + * @param array $config + */ + protected function callCustomCreator(array $config): Driver + { + return $this->customCreators[$config['driver']]($this->container, $config); + } + + /** + * Create an instance of the array driver. + * + * @param array $config + */ + public function createArrayDriver(array $config, string $name): ArrayDriver + { + return new ArrayDriver($name, $this->partitionResolver($name, $config), []); + } + + /** + * Create an instance of the database driver. + * + * @param array $config + */ + public function createDatabaseDriver(array $config, string $name): DatabaseDriver + { + return new DatabaseDriver( + $this->container->make('db'), + $this->databaseConnection($config), + $this->databaseTable($config), + $this->partitionResolver($name, $config), + $this->storedValueCache($config, $name), + [], + ); + } + + /** + * Create the shared cache of a database store's stored values. + * + * Writes through any database store must invalidate the values cached by every other store + * reading the same rows, so each cache configured on an overlapping store is a target. A + * null connection overlaps every connection because the runtime default can select any. + * + * @param array $config + */ + protected function storedValueCache(array $config, string $name): ?StoredValueCache + { + $table = $this->databaseTable($config); + $connection = $this->databaseConnection($config); + $own = isset($config['cache']) ? $this->cacheProfile($name, $config['cache']) : null; + $profiles = []; + + if ($own !== null) { + $profiles["{$own[0]}:{$own[1]}"] = $own; + } + + foreach ($this->container->make('config')->array('pennant.stores') as $storeName => $storeConfig) { + if ($storeConfig['driver'] !== 'database' || ! isset($storeConfig['cache']) || $this->databaseTable($storeConfig) !== $table) { + continue; + } + + $storeConnection = $this->databaseConnection($storeConfig); + + if ($connection !== null && $storeConnection !== null + && ConnectionName::withoutWriteType($connection) !== ConnectionName::withoutWriteType($storeConnection)) { + continue; + } + + $profile = $this->cacheProfile($storeName, $storeConfig['cache']); + + $profiles["{$profile[0]}:{$profile[1]}"] = $profile; + } + + if ($profiles === []) { + return null; + } + + $cache = $this->container->make('cache'); + + $targets = array_map(fn (array $profile) => [$cache->store($profile[0]), $profile[1]], array_values($profiles)); + + return $own === null + ? new StoredValueCache($table, $targets) + : new StoredValueCache($table, $targets, $cache->store($own[0]), $own[1]); + } + + /** + * Get the cache store name and TTL of a database store's shared cache. + * + * @param array $cache + * @return array{string, int} + * + * @throws InvalidArgumentException when the TTL is not positive + */ + protected function cacheProfile(string $name, array $cache): array + { + $ttl = $cache['ttl'] ?? StoredValueCache::DEFAULT_TTL; + + // The cache treats a non-positive TTL as a delete, and values must not be cached forever. + if ($ttl < 1) { + throw new InvalidArgumentException("The cache TTL of Pennant store [{$name}] must be a positive number of seconds."); + } + + return [$cache['store'], $ttl]; + } + + /** + * Get a database store's connection name, or null for the default connection. + * + * @param array $config + */ + protected function databaseConnection(array $config): ?string + { + $connection = $config['connection'] ?? null; + + return $connection === '' ? null : $connection; + } + + /** + * Get a database store's table. + * + * @param array $config + */ + protected function databaseTable(array $config): string + { + return $config['table'] ?? 'features'; + } + + /** + * Serialize the given scope for storage. + * + * @throws RuntimeException when the scope can't be serialized + */ + public function serializeScope(mixed $scope): string + { + return match (true) { + $scope instanceof FeatureScopeSerializeable => $scope->featureScopeSerialize(), + $scope === null => '__hypervel_null', + is_string($scope) => $scope, + is_numeric($scope) => (string) $scope, + // Every unsaved model would otherwise share one stored value. + $scope instanceof Model && $scope->getKey() === null => throw new RuntimeException('Unable to serialize a model without a key as the feature scope. Save the model or pass a scope that identifies it.'), + $scope instanceof Model && $this->useMorphMap => $scope->getMorphClass() . '|' . $scope->getKey(), + $scope instanceof Model && ! $this->useMorphMap => $scope::class . '|' . $scope->getKey(), + default => throw new RuntimeException('Unable to serialize the feature scope to a string. You should implement the FeatureScopeSerializeable contract.') + }; + } + + /** + * Specify that the Eloquent morph map should be used when serializing. + * + * Boot-only. The setting persists on the manager for the worker lifetime and + * changes how every subsequent request serializes model scopes. + */ + public function useMorphMap(bool $value = true): static + { + $this->useMorphMap = $value; + + return $this; + } + + /** + * Flush the driver caches. + * + * Only the current coroutine's values are flushed. + */ + public function flushCache(): void + { + foreach ($this->stores as $driver) { + $driver->flushCache(); + } + } + + /** + * The default scope resolver. + * + * @return Closure(): mixed + */ + protected function defaultScopeResolver(string $driver): Closure + { + return function () use ($driver) { + if ($this->defaultScopeResolver !== null) { + return ($this->defaultScopeResolver)($driver); + } + + return $this->container->make('auth')->guard()->user(); + }; + } + + /** + * Set the default scope resolver. + * + * Boot-only. The resolver persists on the manager for the worker lifetime and + * applies to every subsequent request. + * + * @param callable(string): mixed $resolver + */ + public function resolveScopeUsing(callable $resolver): void + { + $this->defaultScopeResolver = $resolver(...); + } + + /** + * Partition stored feature values by the given column. + * + * The resolver receives the store name and returns the current partition value. Stores + * configured with "partitioned" set to false are not partitioned. + * + * Boot-only. Register before any store is resolved. The resolver persists on the manager + * for the worker lifetime and applies to every subsequent request. + * + * @param Closure(string): (null|int|string) $resolver + * + * @throws InvalidArgumentException when the column is not a simple SQL identifier + * @throws RuntimeException when a store has already been resolved + */ + public function resolvePartitionUsing(string $column, Closure $resolver): void + { + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $column) !== 1) { + throw new InvalidArgumentException("Pennant partition column [{$column}] must be a simple SQL identifier."); + } + + // A store resolved earlier would keep reading and writing across every partition. + if ($this->stores !== []) { + throw new RuntimeException('Register the Pennant partition resolver before resolving any Pennant store.'); + } + + $this->partitionColumn = $column; + $this->partitionResolver = $resolver; + } + + /** + * The resolver of a store's current partition. + * + * @param array $config + * @return null|(Closure(): FeaturePartition) + */ + protected function partitionResolver(string $name, array $config): ?Closure + { + if ($this->partitionResolver === null || ($config['partitioned'] ?? true) === false) { + return null; + } + + $column = $this->partitionColumn; + $resolver = $this->partitionResolver; + + return static function () use ($name, $column, $resolver): FeaturePartition { + $value = $resolver($name); + + if ($value === null || $value === '') { + throw FeaturePartitionNotResolved::forStore($name, $column); + } + + return new FeaturePartition($column, $value); + }; + } + + /** + * Get the Pennant store configuration. + * + * @return null|array + */ + protected function getConfig(string $name): ?array + { + return $this->container->make('config')->get("pennant.stores.{$name}"); + } + + /** + * Get the default store name. + */ + public function getDefaultDriver(): string + { + return $this->container->make('config')->string('pennant.default'); + } + + /** + * Set the default store name. + * + * Boot-only. The default store is process-global config shared by every + * request in the worker. + */ + public function setDefaultDriver(string $name): void + { + $this->container->make('config')->set('pennant.default', $name); + } + + /** + * Unset the given store instances. + * + * Boot or tests only. Stores are shared by every request in the worker, and + * the current coroutine's values for a forgotten store are flushed. + * + * @param null|array|string $name + */ + public function forgetDriver(array|string|null $name = null): static + { + $name ??= $this->getDefaultDriver(); + + foreach ((array) $name as $storeName) { + if (isset($this->stores[$storeName])) { + $this->stores[$storeName]->flushCache(); + + unset($this->stores[$storeName]); + } + } + + return $this; + } + + /** + * Forget all of the resolved store instances. + * + * Boot or tests only. Stores are shared by every request in the worker, and + * the current coroutine's values for the forgotten stores are flushed. + */ + public function forgetDrivers(): static + { + foreach ($this->stores as $store) { + $store->flushCache(); + } + + $this->stores = []; + + return $this; + } + + /** + * Register a custom driver creator Closure. + * + * Boot-only. The creator persists on the manager for the worker lifetime. + */ + public function extend(string $driver, Closure $callback): static + { + $this->customCreators[$driver] = $callback->bindTo($this, $this); + + return $this; + } + + /** + * Set the container instance used by the manager. + * + * Tests only. The container persists on the manager and its stores for the + * worker lifetime. + */ + public function setContainer(Container $container): static + { + $this->container = $container; + + foreach ($this->stores as $store) { + $store->setContainer($container); + } + + return $this; + } + + /** + * Dynamically call the default store instance. + * + * @param array $parameters + */ + public function __call(string $method, array $parameters): mixed + { + return $this->store()->{$method}(...$parameters); + } +} diff --git a/src/pennant/src/Middleware/EnsureFeaturesAreActive.php b/src/pennant/src/Middleware/EnsureFeaturesAreActive.php new file mode 100644 index 000000000..137b39fa1 --- /dev/null +++ b/src/pennant/src/Middleware/EnsureFeaturesAreActive.php @@ -0,0 +1,70 @@ +boolean('app.debug') + ? 'Required features [' . implode(', ', $features) . '] not enabled.' + : ''; + + return static::$respondUsing + ? call_user_func(static::$respondUsing, $request, $features) + : abort(400, $error); + } + + return $next($request); + } + + /** + * Specify the features for the middleware. + */ + public static function using(BackedEnum|UnitEnum|string ...$features): string + { + return static::class . ':' . implode(',', array_map(enum_value(...), $features)); + } + + /** + * Specify a callback that should be used to generate responses for failed feature checks. + * + * Boot-only. The callback persists in a static property for the worker lifetime + * and affects every subsequent request. + */ + public static function whenInactive(?Closure $callback): void + { + static::$respondUsing = $callback; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$respondUsing = null; + } +} diff --git a/src/pennant/src/PennantServiceProvider.php b/src/pennant/src/PennantServiceProvider.php new file mode 100644 index 000000000..c4e81783f --- /dev/null +++ b/src/pennant/src/PennantServiceProvider.php @@ -0,0 +1,87 @@ +mergeConfigFrom(__DIR__ . '/../config/pennant.php', 'pennant'); + } + + /** + * Get configuration arrays whose entries should be merged by name. + */ + protected function mergeableOptions(string $name): array + { + return $name === 'pennant' ? ['stores'] : []; + } + + /** + * Bootstrap the package's services. + */ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->offerPublishing(); + + $this->commands([ + FeatureMakeCommand::class, + PurgeCommand::class, + ]); + } + + $this->callAfterResolving('blade.compiler', function (BladeCompiler $blade): void { + $blade->if('feature', function (UnitEnum|string $feature, mixed $value = null): bool { + if (func_num_args() === 2) { + return Feature::value($feature) === $value; + } + + return Feature::active($feature); + }); + + $blade->if('featureany', function (array $features): bool { + return Feature::someAreActive($features); + }); + }); + + $this->listenForEvents(); + } + + /** + * Listen for the events relevant to the package. + */ + protected function listenForEvents(): void + { + // Laravel's Octane and JobProcessed flush listeners aren't needed: feature values are memoized per coroutine. + $this->app->make('events')->listen([ + PublishingStubs::class, + ], fn (PublishingStubs $event) => $event->add(__DIR__ . '/../stubs/feature.stub', 'feature.stub')); + } + + /** + * Register the migrations and publishing for the package. + */ + protected function offerPublishing(): void + { + $this->publishes([ + __DIR__ . '/../config/pennant.php' => config_path('pennant.php'), + ], 'pennant-config'); + + $this->publishesMigrations([ + __DIR__ . '/../database/migrations' => $this->app->databasePath('migrations'), + ], 'pennant-migrations'); + } +} diff --git a/src/pennant/stubs/feature.stub b/src/pennant/stubs/feature.stub new file mode 100644 index 000000000..d14dd6aa3 --- /dev/null +++ b/src/pennant/stubs/feature.stub @@ -0,0 +1,16 @@ + Date: Tue, 20 Oct 2026 17:22:37 +0000 Subject: [PATCH 10/39] Register the hypervel/pennant package Add the split package metadata and wire the package into the monorepo root: the replace entry, the Hypervel\Pennant\ PSR-4 mapping, the helpers.php and functions.php autoload files, the PennantServiceProvider discovery entry and the Feature alias. The provider is discovered rather than added to DefaultProviders, since not every application uses feature flags. The package declares only the dependencies its code imports: symfony/finder for discover(), hypervel/queue for the events' SerializesModels and hypervel/http for the middleware. It has no view requirement because the Blade directive closure's BladeCompiler type only loads when blade.compiler resolves. The README lists the deliberate differences from Laravel: keyless model scopes throw; stores read pennant.stores when first resolved, with DB::usingConnection() and partitioning in place of runtime config edits; and the built-in drivers and decorator take different constructor arguments, which affects subclasses but not custom drivers implementing the Driver contract. --- composer.json | 6 ++++ src/pennant/LICENSE.md | 23 +++++++++++++ src/pennant/README.md | 16 +++++++++ src/pennant/composer.json | 72 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/pennant/LICENSE.md create mode 100644 src/pennant/README.md create mode 100644 src/pennant/composer.json diff --git a/composer.json b/composer.json index c11a071cd..38bbbd8de 100644 --- a/composer.json +++ b/composer.json @@ -195,6 +195,7 @@ "hypervel/pagination": "self.version", "hypervel/passkeys": "self.version", "hypervel/passport": "self.version", + "hypervel/pennant": "self.version", "hypervel/permission": "self.version", "hypervel/pipeline": "self.version", "hypervel/process": "self.version", @@ -286,6 +287,7 @@ "Hypervel\\Passkeys\\": "src/passkeys/src/", "Hypervel\\Passport\\": "src/passport/src/", "Hypervel\\Passport\\Database\\Factories\\": "src/passport/database/factories/", + "Hypervel\\Pennant\\": "src/pennant/src/", "Hypervel\\Permission\\": "src/permission/src/", "Hypervel\\Pipeline\\": "src/pipeline/src/", "Hypervel\\Process\\": "src/process/src/", @@ -336,6 +338,8 @@ "src/log/src/functions.php", "src/prompts/src/helpers.php", "src/reflection/src/helpers.php", + "src/pennant/src/helpers.php", + "src/pennant/src/functions.php", "src/permission/src/helpers.php", "src/collections/src/functions.php", "src/collections/src/helpers.php", @@ -373,6 +377,7 @@ }, "hypervel": { "aliases": { + "Feature": "Hypervel\\Pennant\\Feature", "Mcp": "Hypervel\\Mcp\\Facades\\Mcp", "OpenTelemetry": "Hypervel\\OpenTelemetry\\Facades\\OpenTelemetry", "Saloon": "Hypervel\\Saloon\\Facades\\Saloon", @@ -415,6 +420,7 @@ "Hypervel\\Fortify\\FortifyServiceProvider", "Hypervel\\Grpc\\GrpcServiceProvider", "Hypervel\\Passport\\PassportServiceProvider", + "Hypervel\\Pennant\\PennantServiceProvider", "Hypervel\\Permission\\PermissionServiceProvider", "Hypervel\\Pipeline\\PipelineServiceProvider", "Hypervel\\Queue\\QueueServiceProvider", diff --git a/src/pennant/LICENSE.md b/src/pennant/LICENSE.md new file mode 100644 index 000000000..670aace44 --- /dev/null +++ b/src/pennant/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/pennant/README.md b/src/pennant/README.md new file mode 100644 index 000000000..bf8bfd869 --- /dev/null +++ b/src/pennant/README.md @@ -0,0 +1,16 @@ +Pennant for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/pennant) + +Documentation: https://hypervel.org/docs/pennant + +## Differences From Laravel + +Eloquent models without a key can't be used as feature scopes. Laravel stores every unsaved model under the same scope identifier, so they all share one stored value. Hypervel throws a `RuntimeException` instead; save the model first or pass an explicit scope identifier. + +Stores read their `pennant.stores` configuration when they're first resolved. Laravel's database store rereads its connection and table on every query, so changing config at runtime takes effect there. In Hypervel, config is shared by every request in a worker, so it should only change during boot or in tests, followed by `Feature::forgetDrivers()` and redefining your features. To choose a connection per request, leave the store's `connection` null and use `DB::usingConnection()`. To keep tenants apart in a shared table, use partitioning. + +The built-in drivers and the store decorator take different constructor arguments than Laravel's, because feature values live in coroutine state and stores can be partitioned. Subclasses of `ArrayDriver`, `DatabaseDriver` or `Decorator` need updating; custom drivers that implement the `Driver` contract are unaffected. + +Ported from: https://github.com/laravel/pennant diff --git a/src/pennant/composer.json b/src/pennant/composer.json new file mode 100644 index 000000000..e7646310b --- /dev/null +++ b/src/pennant/composer.json @@ -0,0 +1,72 @@ +{ + "name": "hypervel/pennant", + "description": "A simple, lightweight library for managing feature flags in Hypervel.", + "license": "MIT", + "type": "library", + "keywords": [ + "php", + "pennant", + "feature", + "flags", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "require": { + "php": "^8.4", + "ext-swoole": "^6.3.0RC1", + "hypervel/cache": "^0.4", + "hypervel/collections": "^0.4", + "hypervel/console": "^0.4", + "hypervel/container": "^0.4", + "hypervel/context": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/database": "^0.4", + "hypervel/events": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/http": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/queue": "^0.4", + "hypervel/support": "^0.4", + "symfony/console": "^8.1.2", + "symfony/finder": "^8.1" + }, + "autoload": { + "psr-4": { + "Hypervel\\Pennant\\": "src/" + }, + "files": [ + "src/helpers.php", + "src/functions.php" + ] + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + }, + "hypervel": { + "providers": [ + "Hypervel\\Pennant\\PennantServiceProvider" + ], + "aliases": { + "Feature": "Hypervel\\Pennant\\Feature" + } + } + } +} From 321396851ef0c0af1b7c6a36509f08449dff96a2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:22:41 +0000 Subject: [PATCH 11/39] Flush Pennant static state between tests Pennant keeps two pieces of process-global state that outlive the test application: the decorator's macros (Macroable stores them statically) and EnsureFeaturesAreActive's whenInactive() callback. Add flushPennantState() to the optional-package section, between Passport and Permission, and call both through callIfExists() so the subscriber still works when the package isn't installed. Decorator metadata and coroutine values need no reset here: the decorator belongs to the test container, and PennantState lives in the coroutine context the subscriber already flushes. --- src/testing/src/PHPUnit/AfterEachTestSubscriber.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index d143112a8..c04291cc7 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -342,6 +342,7 @@ protected function flushFrameworkState(): void $this->flushNestedSetState(); $this->flushPasskeysState(); $this->flushPassportState(); + $this->flushPennantState(); $this->flushPermissionState(); $this->flushReverbState(); $this->flushSaloonState(); @@ -467,6 +468,15 @@ protected function flushPassportState(): void $this->callIfExists(\Hypervel\Passport\Guards\TokenGuard::class, 'flushState'); } + /** + * Flush Pennant state. + */ + protected function flushPennantState(): void + { + $this->callIfExists(\Hypervel\Pennant\Drivers\Decorator::class, 'flushMacros'); + $this->callIfExists(\Hypervel\Pennant\Middleware\EnsureFeaturesAreActive::class, 'flushState'); + } + /** * Flush Permission state. */ From c9daafe7d3b7d7fa481ec5c713332e2a0804dc52 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:22:58 +0000 Subject: [PATCH 12/39] Port the Pennant driver tests and fixtures Port upstream's workbench models, factory and NewApi feature into tests/Pennant/Fixtures, and its TestCase as a Testbench base that loads the package migration. Upstream's unused createManager() is dropped. Every upstream ArrayDriverTest and DatabaseDriverTest case is kept. Adaptations: - Config changes move from setUp() to defineEnvironment(), and database setup runs in setUpInCoroutine() with RefreshDatabase. - Incidental keyless User and Team scopes get explicit ids with unchanged assertions; testItRejectsModelsWithoutAKeyAsScope covers the intentional difference. - The unknown-feature event tests also fake the event after the store has resolved, since the drivers dispatch through the Event facade. - The updated-connection test calls forgetDriver('database') instead of a second flushCache(), because stores read their config when built. - The migration-connection test extends PennantMigration instead of copying it into a plain Migration. - @laravel.com emails become @hypervel.com. - The four UniqueConstraintViolationException listener tests simulated conflicts the new insert no longer raises. They now cover a concurrent winner stored before the insert (its decoded value is returned, including an array winner for an object candidate) and a winner that vanishes before the reread (three attempts, then the insert failure message with no previous exception), for both batch and get(). - testItHandlesNullScopeForBeforeHook asserted the first read twice; its second assertion now checks the second read. Regression tests cover the upstream fixes in this port: constant values that look like callables or class names stay constants in all(), loadAll(), before() hooks and instance(); instance() of a null constant returns a closure producing null; a null resolver result is stored and not resolved again across getAll(); redefining a feature class uses its current name; and redefining a feature rechecks whether its before() hook accepts a null scope. --- tests/Pennant/Feature/ArrayDriverTest.php | 1579 +++++++++++++ tests/Pennant/Feature/DatabaseDriverTest.php | 2145 ++++++++++++++++++ tests/Pennant/Fixtures/Features/NewApi.php | 16 + tests/Pennant/Fixtures/Team.php | 12 + tests/Pennant/Fixtures/User.php | 12 + tests/Pennant/Fixtures/UserFactory.php | 23 + tests/Pennant/TestCase.php | 28 + 7 files changed, 3815 insertions(+) create mode 100644 tests/Pennant/Feature/ArrayDriverTest.php create mode 100644 tests/Pennant/Feature/DatabaseDriverTest.php create mode 100644 tests/Pennant/Fixtures/Features/NewApi.php create mode 100644 tests/Pennant/Fixtures/Team.php create mode 100644 tests/Pennant/Fixtures/User.php create mode 100644 tests/Pennant/Fixtures/UserFactory.php create mode 100644 tests/Pennant/TestCase.php diff --git a/tests/Pennant/Feature/ArrayDriverTest.php b/tests/Pennant/Feature/ArrayDriverTest.php new file mode 100644 index 000000000..b6359c0f0 --- /dev/null +++ b/tests/Pennant/Feature/ArrayDriverTest.php @@ -0,0 +1,1579 @@ +make('config')->set('pennant.default', 'array'); + } + + public function testItDefaultsToFalseForUnknownValues(): void + { + $result = Feature::active('foo'); + + $this->assertFalse($result); + } + + public function testItDispatchesEventsOnUnknownFeatureChecks(): void + { + Event::fake([UnknownFeatureResolved::class]); + + Feature::active('foo'); + + Event::assertDispatchedTimes(UnknownFeatureResolved::class, 1); + Event::assertDispatched(function (UnknownFeatureResolved $event) { + $this->assertSame('foo', $event->feature); + $this->assertNull($event->scope); + + return true; + }); + + // The store is resolved now, so this checks the driver doesn't hold on to the earlier dispatcher. + Event::fake([UnknownFeatureResolved::class]); + + Feature::active('bar'); + + Event::assertDispatchedTimes(UnknownFeatureResolved::class, 1); + Event::assertDispatched(fn (UnknownFeatureResolved $event) => $event->feature === 'bar'); + } + + public function testItCanRegisterDefaultBooleanValues(): void + { + Feature::define('true', fn () => true); + Feature::define('false', fn () => false); + + $true = Feature::active('true'); + $false = Feature::active('false'); + + $this->assertTrue($true); + $this->assertFalse($false); + } + + public function testItCanRegisterComplexValues(): void + { + Feature::define('config', fn () => [ + 'color' => 'red', + 'default' => 'api', + ]); + + $active = Feature::active('config'); + $value = Feature::value('config'); + + $this->assertTrue($active); + $this->assertSame([ + 'color' => 'red', + 'default' => 'api', + ], $value); + + Feature::for('tim')->activate('new-api', 'foo'); + + $active = Feature::for('tim')->active('new-api'); + $value = Feature::for('tim')->value('new-api'); + + $this->assertTrue($active); + $this->assertSame('foo', $value); + } + + public function testItCachesStateAfterResolving(): void + { + $called = 0; + Feature::define('foo', function () use (&$called) { + ++$called; + + return true; + }); + + $this->assertSame(0, $called); + + Feature::active('foo'); + $this->assertSame(1, $called); + + Feature::active('foo'); + $this->assertSame(1, $called); + } + + public function testNonFalseRegisteredValuesAreConsideredActive(): void + { + Feature::define('true', fn () => true); + Feature::define('false', fn () => false); + Feature::define('one', fn () => 1); + Feature::define('zero', fn () => 0); + Feature::define('null', fn () => null); + Feature::define('empty-string', fn () => ''); + + $this->assertTrue(Feature::active('true')); + $this->assertFalse(Feature::active('false')); + $this->assertTrue(Feature::active('one')); + $this->assertTrue(Feature::active('zero')); + $this->assertTrue(Feature::active('null')); + $this->assertTrue(Feature::active('empty-string')); + + $this->assertFalse(Feature::inactive('true')); + $this->assertTrue(Feature::inactive('false')); + $this->assertFalse(Feature::inactive('one')); + $this->assertFalse(Feature::inactive('zero')); + $this->assertFalse(Feature::inactive('null')); + $this->assertFalse(Feature::inactive('empty-string')); + } + + public function testItCanProgramaticallyActivateAndDeativateFeatures(): void + { + Feature::activate('foo'); + $this->assertTrue(Feature::active('foo')); + + Feature::deactivate('foo'); + $this->assertFalse(Feature::active('foo')); + + Feature::activate('foo'); + $this->assertTrue(Feature::active('foo')); + } + + public function testItDispatchesEventsWhenResolvingFeatureIntoMemory(): void + { + Event::fake([FeatureResolved::class]); + Feature::define('foo', fn () => true); + + Feature::active('foo'); + Feature::active('foo'); + + Event::assertDispatchedTimes(FeatureResolved::class, 1); + Event::assertDispatched(function (FeatureResolved $event) { + return $event->feature === 'foo' && $event->scope === null; + }); + } + + public function testItCanActivateAndDeactivateSeveralFeaturesAtOnce(): void + { + Feature::activate(['foo', 'bar']); + + $this->assertTrue(Feature::active('foo')); + $this->assertTrue(Feature::active('bar')); + $this->assertFalse(Feature::active('baz')); + + Feature::deactivate(['foo', 'bar']); + + $this->assertFalse(Feature::active('foo')); + $this->assertFalse(Feature::active('bar')); + $this->assertFalse(Feature::active('bar')); + + Feature::activate(['bar', 'baz']); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::active('bar')); + $this->assertTrue(Feature::active('bar')); + } + + public function testItCanCheckIfMultipleFeaturesAreActiveAtOnce(): void + { + Feature::activate(['foo', 'bar']); + + $this->assertTrue(Feature::allAreActive(['foo'])); + $this->assertTrue(Feature::allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::allAreActive(['foo', 'bar', 'baz'])); + + Feature::deactivate('baz'); + + $this->assertTrue(Feature::allAreActive(['foo'])); + $this->assertTrue(Feature::allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::allAreActive(['foo', 'bar', 'baz'])); + } + + public function testItCanScopeFeatures(): void + { + $active = new User(['id' => 1]); + $inactive = new User(['id' => 2]); + $captured = []; + + Feature::define('foo', function ($scope) use (&$captured) { + $captured[] = $scope; + + return $scope?->id === 1; + }); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($active)->active('foo')); + $this->assertFalse(Feature::for($inactive)->active('foo')); + $this->assertSame([null, $active, $inactive], $captured); + } + + public function testItCanActivateAndDeactivateFeaturesWithScope(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for($first)->activate('foo'); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($first)->active('foo')); + $this->assertFalse(Feature::for($second)->active('foo')); + } + + public function testItCanActivateAndDeactivateFeaturesForMultipleScopeAtOnce(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + $third = new User(['id' => 3]); + + Feature::for([$first, $second])->activate('foo'); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($first)->active('foo')); + $this->assertTrue(Feature::for($second)->active('foo')); + $this->assertFalse(Feature::for($third)->active('foo')); + } + + public function testItCanActivateAndDeactivateMultipleFeaturesForMultipleScopeAtOnce(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + $third = new User(['id' => 3]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($first)->active('foo')); + $this->assertTrue(Feature::for($second)->active('foo')); + $this->assertFalse(Feature::for($third)->active('foo')); + + $this->assertFalse(Feature::active('bar')); + $this->assertTrue(Feature::for($first)->active('bar')); + $this->assertTrue(Feature::for($second)->active('bar')); + $this->assertFalse(Feature::for($third)->active('bar')); + } + + public function testItCanCheckMultipleFeaturesForMultipleScopeAtOnce(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + $third = new User(['id' => 3]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + $this->assertFalse(Feature::allAreActive(['foo', 'bar'])); + $this->assertTrue(Feature::for($first)->allAreActive(['foo', 'bar'])); + $this->assertTrue(Feature::for($second)->allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::for($third)->allAreActive(['foo', 'bar'])); + + $this->assertTrue(Feature::for([$first, $second])->allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::for([$second, $third])->allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::for([$first, $second, $third])->allAreActive(['foo', 'bar'])); + } + + public function testNullIsSameAsNoScope(): void + { + Feature::activate('foo'); + + $this->assertTrue(Feature::for(null)->active('foo')); + } + + public function testItSeesNullAndEmptyStringAsDifferentThings(): void + { + Feature::activate('foo'); + + $this->assertFalse(Feature::for('')->active('foo')); + $this->assertTrue(Feature::for(null)->active('foo')); + $this->assertTrue(Feature::active('foo')); + + Feature::for('')->activate('bar'); + + $this->assertTrue(Feature::for('')->active('bar')); + $this->assertFalse(Feature::for(null)->active('bar')); + $this->assertFalse(Feature::active('bar')); + } + + public function testScopeCanBeStringsLikeEmailAddresses(): void + { + Feature::for('tim@hypervel.com')->activate('foo'); + + $this->assertFalse(Feature::for('james@hypervel.com')->active('foo')); + $this->assertTrue(Feature::for('tim@hypervel.com')->active('foo')); + } + + public function testItCanHandleFeatureScopeableObjects(): void + { + $scopeable = fn () => new class extends User implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'tim@hypervel.com'; + } + }; + + Feature::for($scopeable())->activate('foo'); + + $this->assertFalse(Feature::for('james@hypervel.com')->active('foo')); + $this->assertTrue(Feature::for('tim@hypervel.com')->active('foo')); + $this->assertTrue(Feature::for($scopeable())->active('foo')); + } + + // The resolvers below return null, which is stored like any other value, so loading + // again doesn't resolve again. + public function testItCanLoadFeatureStateIntoMemory(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function () use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::load('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::load(['foo']); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::load('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::load(['bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::load(['foo', 'bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::allAreActive(['foo', 'bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + } + + public function testItCanLoadScopedFeatureStateIntoMemory(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function ($scope) use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load(['foo']); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('loaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('noloaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(2, $called['bar']); + + Feature::getAll([ + 'foo' => [1, 2, 3], + 'bar' => [2], + ]); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + + Feature::for([1, 2, 3])->active('foo'); + Feature::for([2])->active('bar'); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + } + + public function testItCanLoadAgainstScope(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function ($scope) use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load(['foo']); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('loaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('noloaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(2, $called['bar']); + + Feature::for([1, 2, 3])->load(['foo']); + Feature::for(2)->load(['bar']); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + + Feature::for([1, 2, 3])->active('foo'); + Feature::for([2])->active('bar'); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + } + + public function testItCanLoadMissingFeatureStateIntoMemory(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function () use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + + Feature::loadMissing('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::loadMissing('foo'); + $this->assertSame(0, $called['bar']); + + Feature::active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::loadMissing(['bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::getAll([ + 'foo' => [1, 2, 3], + 'bar' => [2], + ]); + $this->assertSame(4, $called['foo']); + $this->assertSame(2, $called['bar']); + + Feature::for([1, 2, 3])->active('foo'); + Feature::for([2])->active('bar'); + $this->assertSame(4, $called['foo']); + $this->assertSame(2, $called['bar']); + } + + public function testItThrowsWhenCallingValueWithMultipleScope(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('It is not possible to retrieve the values for multiple scopes.'); + + Feature::for([1, 2, 3])->value('foo'); + } + + public function testItCanRetriveValueForMultipleScopes(): void + { + Feature::define('foo', fn ($scope) => $scope); + + $values = Feature::for(1)->values(['foo']); + + $this->assertSame([ + 'foo' => 1, + ], $values); + } + + public function testItCanRetriveValueForMultipleScopesAndFeatures(): void + { + Feature::define('foo', fn ($scope) => $scope); + Feature::define('bar', fn ($scope) => $scope * 2); + + $values = Feature::for(2)->values(['foo', 'bar']); + + $this->assertSame([ + 'foo' => 2, + 'bar' => 4, + ], $values); + } + + public function testItMayRegisterShorthandFeatureValues(): void + { + Feature::define('foo', 'value'); + + $value = Feature::value('foo'); + + $this->assertSame('value', $value); + } + + public function testItCanUseLottery(): void + { + Feature::define('foo', Lottery::odds(1, 1)); + Feature::define('bar', Lottery::odds(0, 1)); + Feature::define('baz', fn () => Lottery::odds(0, 1)); + + $this->assertTrue(Feature::value('foo')); + $this->assertFalse(Feature::value('bar')); + $this->assertFalse(Feature::value('baz')); + } + + public function testItCanRetrieveRegisteredFeatures(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => false); + Feature::define('baz', fn () => false); + + $registered = Feature::defined(); + + $this->assertSame(['foo', 'bar', 'baz'], $registered); + } + + public function testItCanClearTheCache(): void + { + $called = 0; + Feature::define('foo', function () use (&$called) { + ++$called; + }); + + Feature::active('foo'); + Feature::flushCache(); + Feature::active('foo'); + + $this->assertSame(2, $called); + } + + public function testItCanGetAllFeatures(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => false); + + $all = Feature::all(); + + $this->assertSame([ + 'foo' => true, + 'bar' => false, + ], $all); + } + + public function testItCanRegisterFeatureViaClass(): void + { + Feature::define(MyFeature::class); + + $value = Feature::for('shared')->value('my-feature'); + + $this->assertSame('shared-123', $value); + } + + public function testItCanRegisterFeaturesViaClassWithoutName(): void + { + Feature::define(MyUnnamedFeature::class); + + $value = Feature::for('shared')->value(MyUnnamedFeature::class); + + $this->assertSame('shared-123', $value); + } + + public function testItCanRegisterFeatureViaClassWithNameAttribute(): void + { + Feature::define(MyFeatureWithNameAttribute::class); + + $value = Feature::for('shared')->value('my-feature-with-name-attribute'); + + $this->assertSame('shared-attribute', $value); + } + + public function testNameAttributeTakesPrecedenceOverNameProperty(): void + { + Feature::define(MyFeatureWithBothNameAttributeAndProperty::class); + + $defined = Feature::defined(); + $value = Feature::for('shared')->value('attribute-takes-priority'); + + $this->assertSame(['attribute-takes-priority'], $defined); + $this->assertSame('shared-both', $value); + } + + public function testItCanRegisterFeatureViaClassWithResolve(): void + { + Feature::define(MyFeatureWithResolveMethod::class); + + $value = Feature::for('shared')->value(MyFeatureWithResolveMethod::class); + + $this->assertSame('shared-resolve-123', $value); + } + + public function testItCanReevaluateFeatureState(): void + { + Feature::define('foo', fn () => false); + $this->assertFalse(Feature::for('tim')->value('foo')); + + Feature::for('tim')->forget('foo'); + + Feature::define('foo', fn () => true); + $this->assertTrue(Feature::for('tim')->value('foo')); + } + + public function testItCanCustomiseDefaultScope(): void + { + $scopes = []; + Feature::define('foo', function ($scope) use (&$scopes) { + $scopes[] = $scope; + }); + + Feature::active('foo'); + + Auth::login($user = new User(['id' => 1])); + Feature::active('foo'); + + Feature::resolveScopeUsing(fn () => 'bar'); + Feature::active('foo'); + + $this->assertSame([ + null, + $user, + 'bar', + ], $scopes); + } + + public function testItDoesntIncludeDefaultScopeWhenNull(): void + { + $scopes = []; + Feature::define('foo', function ($scope) use (&$scopes) { + $scopes[] = $scope; + }); + + Feature::resolveScopeUsing(fn () => null); + Feature::active('foo'); + + $this->assertSame([ + null, + ], $scopes); + + $resolved = 0; + Feature::resolveScopeUsing(function () use (&$resolved) { + ++$resolved; + + return null; + }); + + Feature::getAll(['foo', 'bar']); + + $this->assertSame(1, $resolved); + } + + public function testItCanInteractGloballyIgnoringTheDefaultScope(): void + { + $scopes = []; + Feature::define('foo', function ($scope) use (&$scopes) { + $scopes[] = $scope; + + return true; + }); + Feature::resolveScopeUsing(fn () => 'default-scope'); + + $this->assertTrue(Feature::globally()->active('foo')); + + $this->assertCount(1, $scopes); + $this->assertInstanceOf(GlobalScope::class, $scopes[0]); + } + + public function testItUsesDefaultScopeForLoadingWithString(): void + { + Feature::define('feature', fn () => false); + Feature::for('tim')->activate('feature'); + + $loaded = Feature::load('feature'); + $this->assertSame(['feature' => [false]], $loaded); + + Feature::resolveScopeUsing(fn () => 'tim'); + + $loaded = Feature::load('feature'); + $this->assertSame(['feature' => [true]], $loaded); + } + + public function testRetrievingValuesAfterPurging(): void + { + Feature::define('foo', false); + + Feature::for('tim')->activate('foo'); + + $this->assertTrue(Feature::for('tim')->active('foo')); + + Feature::purge('foo'); + + $this->assertFalse(Feature::for('tim')->active('foo')); + } + + public function testItCanConditionallyExecuteCodeBlockForInactiveFeature(): void + { + $active = $inactive = null; + + Feature::when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertNull($active); + $this->assertTrue($inactive); + } + + public function testItCanConditionallyExecuteCodeBlockProvidingClosureForOnlyActive(): void + { + $active = null; + + Feature::when('foo', function () use (&$active) { + $active = true; + }); + + $this->assertNull($active); + } + + public function testItCanConditionallyExecuteCodeBlockForActiveFeature(): void + { + $active = $inactive = null; + Feature::activate('foo'); + + Feature::when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertTrue($active); + $this->assertNull($inactive); + } + + public function testItReceivesValueForFeatureInConditionalCodeExecution(): void + { + $active = $inactive = null; + Feature::activate('foo', ['hello' => 'world']); + + Feature::when( + 'foo', + function ($value) use (&$active) { + $active = $value; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertSame(['hello' => 'world'], $active); + $this->assertNull($inactive); + } + + public function testConditionallyExecutingCodeRespectsScope(): void + { + $active = $inactive = null; + Feature::for('tim')->activate('foo'); + + Feature::when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertNull($active); + $this->assertTrue($inactive); + + $active = $inactive = null; + + Feature::for('tim')->when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertTrue($active); + $this->assertNull($inactive); + } + + public function testConditionalClosuresReceiveCurrentFeatureInteraction(): void + { + $active = $inactive = null; + Feature::for('tim')->activate('foo', ['hello' => 'tim']); + + Feature::for('tim')->when( + 'foo', + function ($value, $feature) { + $feature->deactivate('foo'); + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + Feature::flushCache(); + $this->assertFalse(Feature::for('tim')->active('foo')); + $this->assertNull($inactive); + } + + public function testItCanSetForAll(): void + { + Feature::define('foo', fn () => false); + + Feature::for('tim')->activate('foo'); + Feature::for('taylor')->activate('foo'); + + $this->assertTrue(Feature::for('tim')->value('foo')); + $this->assertTrue(Feature::for('taylor')->value('foo')); + $this->assertTrue(Feature::getDriver()->get('foo', 'tim')); + $this->assertTrue(Feature::getDriver()->get('foo', 'taylor')); + + Feature::deactivateForEveryone('foo'); + + $this->assertFalse(Feature::for('tim')->value('foo')); + $this->assertFalse(Feature::for('taylor')->value('foo')); + $this->assertFalse(Feature::getDriver()->get('foo', 'tim')); + $this->assertFalse(Feature::getDriver()->get('foo', 'taylor')); + + Feature::activateForEveryone('foo'); + + $this->assertTrue(Feature::for('tim')->value('foo')); + $this->assertTrue(Feature::for('taylor')->value('foo')); + $this->assertTrue(Feature::getDriver()->get('foo', 'tim')); + $this->assertTrue(Feature::getDriver()->get('foo', 'taylor')); + } + + public function testItCanAutoRegisterFeatureClasses(): void + { + Feature::define('marketing-design', 'marketing-design-value'); + Feature::discover('Hypervel\Tests\Pennant\Fixtures\Features', __DIR__ . '/../Fixtures/Features'); + + $all = Feature::all(); + + $this->assertSame([ + 'marketing-design' => 'marketing-design-value', + 'Hypervel\Tests\Pennant\Fixtures\Features\NewApi' => 'new-api-value', + ], $all); + } + + public function testItAcceptsNullScopeForParameterlessFeature(): void + { + Feature::define('foo', fn () => true); + + $result = Feature::for(null)->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(null)->active(MyFeatureWithNoScope::class); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active(MyFeatureWithNoScope::class); + $this->assertTrue($result); + } + + public function testItAcceptsNullScopeForUntypedFeature(): void + { + Feature::define('foo', fn ($user) => true); + + $result = Feature::for(null)->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(null)->active(MyFeatureWithUntypedScope::class); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active(MyFeatureWithUntypedScope::class); + $this->assertTrue($result); + } + + public function testItAcceptsNullScopeForMixedTypedFeature(): void + { + Feature::define('foo', fn (mixed $user) => true); + + $result = Feature::for(null)->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(null)->active(MyFeatureWithMixedScope::class); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active(MyFeatureWithMixedScope::class); + $this->assertTrue($result); + } + + public function testItAcceptsNullScopeForNullableTypedFeature(): void + { + Feature::define('foo', fn (?User $user) => true); + + $result = Feature::for(null)->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active('foo'); + $this->assertTrue($result); + + $result = Feature::for(null)->active(MyFeatureWithNullableScope::class); + $this->assertTrue($result); + + $result = Feature::for(new User(['id' => 1]))->active(MyFeatureWithNullableScope::class); + $this->assertTrue($result); + } + + public function testItGracefullyHandlesNullScopeForNonNullableFeature(): void + { + Event::fake([UnexpectedNullScopeEncountered::class]); + Feature::define('foo', function (User $user) { + return true; + }); + + $result = Feature::for(null)->active('foo'); + $this->assertFalse($result); + Event::assertDispatchedTimes(UnexpectedNullScopeEncountered::class, 1); + Event::assertDispatched(function (UnexpectedNullScopeEncountered $event) { + return $event->feature === 'foo'; + }); + + $result = Feature::for(new User(['id' => 1]))->active('foo'); + $this->assertTrue($result); + Event::assertDispatchedTimes(UnexpectedNullScopeEncountered::class, 1); + + $result = Feature::for(null)->active(MyFeatureWithUserScope::class); + $this->assertFalse($result); + Event::assertDispatchedTimes(UnexpectedNullScopeEncountered::class, 2); + Event::assertDispatched(function (UnexpectedNullScopeEncountered $event) { + return $event->feature === MyFeatureWithUserScope::class; + }); + + $result = Feature::for(new User(['id' => 1]))->active(MyFeatureWithUserScope::class); + $this->assertTrue($result); + Event::assertDispatchedTimes(UnexpectedNullScopeEncountered::class, 2); + } + + public function testItDoesNotInterpretArrayAsCallable(): void + { + $class = new class { + public function foo(): string + { + return 'xxxx'; + } + + public function bar(string $value): string + { + return $value; + } + }; + Feature::define('foo', [$class, 'foo']); + + $result = Feature::value('foo'); + + $this->assertSame([$class, 'foo'], $result); + + // Callable-looking values are constants, so a signature that rejects the scope doesn't hide them. + Feature::define('bar', [$class, 'bar']); + + $this->assertSame(['foo' => [$class, 'foo'], 'bar' => [$class, 'bar']], Feature::all()); + $this->assertSame(['foo' => [[$class, 'foo']], 'bar' => [[$class, 'bar']]], Feature::loadAll()); + } + + public function testFeatureClassDependenciesDoNotGoStale(): void + { + $createContainer = function () { + $container = new Container; + $container->singleton(FeatureDependency::class); + $container->instance('events', new class { + public function dispatch(): void + { + } + }); + + return $container; + }; + $first = $createContainer(); + $firstDependency = null; + $second = $createContainer(); + $secondDependency = null; + + Feature::define(MyFeatureWithDependency::class); + + Feature::store()->setContainer($first); + $first->resolving(MyFeatureWithDependency::class, function (MyFeatureWithDependency $feature) use (&$firstDependency) { + $firstDependency = $feature->dependency; + }); + + Feature::active(MyFeatureWithDependency::class); + Feature::flushCache(); + + Feature::store()->setContainer($second); + $second->resolving(MyFeatureWithDependency::class, function (MyFeatureWithDependency $feature) use (&$secondDependency) { + $secondDependency = $feature->dependency; + }); + + Feature::active(MyFeatureWithDependency::class); + + $this->assertInstanceOf(FeatureDependency::class, $first->make(FeatureDependency::class)); + $this->assertInstanceOf(FeatureDependency::class, $second->make(FeatureDependency::class)); + $this->assertNotSame($first->make(FeatureDependency::class), $second->make(FeatureDependency::class)); + $this->assertSame($first->make(FeatureDependency::class), $firstDependency); + $this->assertSame($second->make(FeatureDependency::class), $secondDependency); + } + + public function testItDispatchesEventsWhenPurgingFeatures(): void + { + Event::fake([FeaturesPurged::class]); + + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::purge(['foo', 'bar', 'baz']); + + Event::assertDispatchedTimes(FeaturesPurged::class, 1); + Event::assertDispatched(function (FeaturesPurged $event) { + return $event->features === ['foo', 'bar', 'baz']; + }); + } + + public function testItDispatchesEventsWhenPurgingAllFeatures(): void + { + Event::fake([AllFeaturesPurged::class]); + + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::purge(); + + Event::assertDispatchedTimes(AllFeaturesPurged::class, 1); + } + + public function testItDispatchesEventsWhenUpdatingAScopedFeature(): void + { + Event::fake([FeatureUpdated::class]); + + Feature::define('foo', fn () => false); + + Feature::for('tim')->activate('foo'); + + Event::assertDispatchedTimes(FeatureUpdated::class, 1); + Event::assertDispatched(function (FeatureUpdated $event) { + return $event->feature === 'foo' + && $event->scope === 'tim' + && $event->value === true; + }); + } + + public function testItDispatchesEventsWhenUpdatingAFeatureForAllScopes(): void + { + Event::fake([FeatureUpdatedForAllScopes::class]); + + Feature::define('foo', fn () => false); + + Feature::activateForEveryone('foo', true); + + Event::assertDispatchedTimes(FeatureUpdatedForAllScopes::class, 1); + Event::assertDispatched(function (FeatureUpdatedForAllScopes $event) { + return $event->feature === 'foo' + && $event->value === true; + }); + } + + public function testItDispatchesEventsWhenDeletingAFeatureValue(): void + { + Event::fake([FeatureDeleted::class]); + + Feature::define('foo', fn () => false); + + Feature::for('tim')->forget('foo'); + + Event::assertDispatchedTimes(FeatureDeleted::class, 1); + Event::assertDispatched(function (FeatureDeleted $event) { + return $event->feature === 'foo' + && $event->scope === 'tim'; + }); + } + + public function testItCachesByIdentifierForFeatureScopableObjects(): void + { + $factory = fn () => new class implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'foo'; + } + }; + + Feature::for($object = $factory())->activate('foo'); + + $this->assertTrue(Feature::for($object)->active('foo')); + $this->assertTrue(Feature::for($factory())->active('foo')); + } + + public function testItCachesByIdentificationForOtherObjects(): void + { + $factory = fn ($name) => new User(['id' => 123, 'name' => $name]); + + Feature::for($object = $factory('Tim'))->activate('foo'); + + $this->assertTrue(Feature::for($object)->active('foo')); + $this->assertTrue(Feature::for($factory('Taylor'))->active('foo')); + } + + public function testCachingOfFeatures(): void + { + $factory = fn () => new User(['id' => 123]); + $user1 = $factory(); + $user2 = $factory(); + + Feature::for($user1)->activate('myflag', 2); + $this->assertEquals(2, Feature::for($user1)->active('myflag')); + + Feature::for($user1)->activate('myflag', 3); + $this->assertEquals(3, Feature::for($user1)->active('myflag')); + + Feature::for($user2)->activate('myflag', 4); + + $this->assertEquals(4, Feature::for($user1)->value('myflag')); + $this->assertEquals(4, Feature::for($user2)->value('myflag')); + } + + public function testItCanListStoredFeatures(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::for('Tim')->active('bar'); + + $this->assertSame(Feature::stored(), ['bar']); + } + + public function testItCanResolveFeatureInstance(): void + { + Feature::define(MyFeature::class); + $this->assertInstanceOf(MyFeature::class, Feature::instance('my-feature')); + $this->assertInstanceOf(MyFeature::class, Feature::instance(MyFeature::class)); + + Feature::define(MyUnnamedFeature::class); + $this->assertInstanceOf(MyUnnamedFeature::class, Feature::instance(MyUnnamedFeature::class)); + + Feature::define('closure-based-feature', $closure = fn () => true); + $this->assertSame($closure, Feature::instance('closure-based-feature')); + + Feature::define('value-based-feature', '123'); + $feature = Feature::instance('value-based-feature'); + $this->assertInstanceOf(Closure::class, $feature); + $this->assertSame('123', $feature()); + + Feature::define('lottery-based-feature', Lottery::odds(1, 1)->winner(fn () => '345')); + $feature = Feature::instance('lottery-based-feature'); + $this->assertInstanceOf(Lottery::class, $feature); + $this->assertSame('345', $feature()); + + Feature::define('class-name-value-feature', MyFeature::class); + $feature = Feature::instance('class-name-value-feature'); + $this->assertInstanceOf(Closure::class, $feature); + $this->assertSame(MyFeature::class, $feature()); + + Feature::define('null-value-feature', null); + $feature = Feature::instance('null-value-feature'); + $this->assertInstanceOf(Closure::class, $feature); + $this->assertNull($feature()); + } + + public function testRedefiningAFeatureClassUsesItsCurrentName(): void + { + $this->app->instance(MyFeature::class, $instance = new MyFeature); + + Feature::define(MyFeature::class); + $this->assertSame('my-feature', Feature::name(MyFeature::class)); + + $instance->name = 'renamed-feature'; + Feature::define(MyFeature::class); + + $this->assertSame('renamed-feature', Feature::name(MyFeature::class)); + } + + public function testCanRetrieveScalarValuesWithoutInMemoryCache(): void + { + $data = [ + 'one' => 1, + 'ten' => 10, + 'zero' => 0, + 'minus-one' => -1, + 'minus-ten' => -10, + 'one-point-five' => 1.5, + 'ten-point-five' => 10.5, + 'zero-point-five' => 0.5, + 'minus-one-point-five' => -1.5, + 'minus-ten-point-five' => -10.5, + 'bool-true' => true, + 'bool-false' => false, + 'null' => null, + ]; + + $user = new User(['id' => 1]); + + foreach ($data as $name => $expectedValue) { + $feature = 'scalar-feature:' . $name; + Feature::define($feature, fn (User $user) => $expectedValue); + + $generated = Feature::for($user)->value($feature); + + Feature::flushCache(); + + $retrieved = Feature::for($user)->value($feature); + + $this->assertEquals($expectedValue, $generated); + $this->assertEquals($expectedValue, $retrieved); + } + } + + public function testItHandlesIntegerScopesCorrectly(): void + { + Feature::define(IntScopeFeature::class); + + $this->assertSame([ + IntScopeFeature::class => true, + ], Feature::for(1)->all()); + + $this->assertSame([ + IntScopeFeature::class => false, + ], Feature::for(10)->all()); + } + + public function testItCanHandlesDoubleScopesCorrectly(): void + { + Feature::define(DoubleScopeFeature::class); + + $this->assertSame([ + DoubleScopeFeature::class => true, + ], Feature::for(1.1)->all()); + + $this->assertSame([ + DoubleScopeFeature::class => true, + ], Feature::for(1.10)->all()); + + $this->assertSame([ + DoubleScopeFeature::class => false, + ], Feature::for(1.11)->all()); + + $this->assertSame([ + DoubleScopeFeature::class => false, + ], Feature::for(10.00)->all()); + } + + public function testItCanHandlesFloatScopesCorrectly(): void + { + Feature::define(FloatScopeFeature::class); + + $this->assertSame([ + FloatScopeFeature::class => true, + ], Feature::for(1.1)->all()); + + $this->assertSame([ + FloatScopeFeature::class => true, + ], Feature::for(1.10)->all()); + + $this->assertSame([ + FloatScopeFeature::class => false, + ], Feature::for(1.11)->all()); + + $this->assertSame([ + FloatScopeFeature::class => false, + ], Feature::for(10.00)->all()); + } + + public function testItDispatchesEventsWhenActivatingMultipleFeaturesAndScopes(): void + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => true, + ], $events); + } + + public function testItDispatchesEventsWhenDeactivatingMultipleFeaturesAndScopes(): void + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->deactivate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => false, + ], $events); + } +} + +class MyFeature +{ + public string $name = 'my-feature'; + + public function __invoke($scope): string + { + return "{$scope}-123"; + } +} + +class MyUnnamedFeature +{ + public function __invoke($scope): string + { + return "{$scope}-123"; + } +} + +class MyFeatureWithResolveMethod +{ + public function resolve($scope): string + { + return "{$scope}-resolve-123"; + } +} + +class MyFeatureWithUserScope +{ + public function resolve(User $user): bool + { + return true; + } +} + +class MyFeatureWithNoScope +{ + public function resolve(): bool + { + return true; + } +} + +class MyFeatureWithUntypedScope +{ + public function resolve($scope): bool + { + return true; + } +} + +class MyFeatureWithMixedScope +{ + public function resolve(mixed $scope): bool + { + return true; + } +} + +class MyFeatureWithNullableScope +{ + public function resolve(?User $scope): bool + { + return true; + } +} + +class MyFeatureWithDependency +{ + public function __construct(public FeatureDependency $dependency) + { + } + + public function resolve(): bool + { + return true; + } +} + +class FeatureDependency +{ +} + +class IntScopeFeature +{ + public function resolve(int $scope): bool + { + return in_array($scope, [1, 2, 3], true); + } +} + +class DoubleScopeFeature +{ + public function resolve(float $scope): bool + { + return in_array($scope, [1.10, 2.20, 3.30], true); + } +} + +class FloatScopeFeature +{ + public function resolve(float $scope): bool + { + return in_array($scope, [1.1, 2.2, 3.3], true); + } +} + +#[Name('my-feature-with-name-attribute')] +class MyFeatureWithNameAttribute +{ + public function __invoke($scope): string + { + return "{$scope}-attribute"; + } +} + +#[Name('attribute-takes-priority')] +class MyFeatureWithBothNameAttributeAndProperty +{ + public string $name = 'property-name'; + + public function __invoke($scope): string + { + return "{$scope}-both"; + } +} diff --git a/tests/Pennant/Feature/DatabaseDriverTest.php b/tests/Pennant/Feature/DatabaseDriverTest.php new file mode 100644 index 000000000..b32787bf9 --- /dev/null +++ b/tests/Pennant/Feature/DatabaseDriverTest.php @@ -0,0 +1,2145 @@ +make('config')->set('pennant.default', 'database'); + } + + protected function setUpInCoroutine(): void + { + DB::enableQueryLog(); + } + + public function testItDefaultsToFalseForUnknownValues(): void + { + $result = Feature::active('foo'); + + $this->assertFalse($result); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function testItDispatchesEventsOnUnknownFeatureChecks(): void + { + Event::fake([UnknownFeatureResolved::class]); + + Feature::active('foo'); + + Event::assertDispatchedTimes(UnknownFeatureResolved::class, 1); + Event::assertDispatched(function (UnknownFeatureResolved $event) { + $this->assertSame('foo', $event->feature); + $this->assertNull($event->scope); + + return true; + }); + + $this->assertCount(1, DB::getQueryLog()); + + // The store is resolved now, so this checks the driver doesn't hold on to the earlier dispatcher. + Event::fake([UnknownFeatureResolved::class]); + + Feature::active('bar'); + + Event::assertDispatchedTimes(UnknownFeatureResolved::class, 1); + Event::assertDispatched(fn (UnknownFeatureResolved $event) => $event->feature === 'bar'); + } + + public function testItCanRegisterDefaultBooleanValues(): void + { + Feature::define('true', fn () => true); + Feature::define('false', fn () => false); + + $true = Feature::active('true'); + $false = Feature::active('false'); + + $this->assertTrue($true); + $this->assertFalse($false); + + $this->assertCount(4, DB::getQueryLog()); + } + + public function testItCanRegisterComplexValues(): void + { + Feature::define('config', fn () => [ + 'color' => 'red', + 'default' => 'api', + ]); + + $active = Feature::active('config'); + $value = Feature::value('config'); + + $this->assertTrue($active); + $this->assertSame([ + 'color' => 'red', + 'default' => 'api', + ], $value); + + Feature::for('tim')->activate('new-api', 'foo'); + + $active = Feature::for('tim')->active('new-api'); + $value = Feature::for('tim')->value('new-api'); + + $this->assertTrue($active); + $this->assertSame('foo', $value); + + $this->assertCount(3, DB::getQueryLog()); + } + + public function testItCachesStateAfterResolving(): void + { + $called = 0; + Feature::define('foo', function () use (&$called) { + ++$called; + + return true; + }); + + $this->assertSame(0, $called); + + Feature::active('foo'); + $this->assertSame(1, $called); + + Feature::active('foo'); + $this->assertSame(1, $called); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function testNonFalseRegisteredValuesAreConsideredActive(): void + { + Feature::define('true', fn () => true); + Feature::define('false', fn () => false); + Feature::define('one', fn () => 1); + Feature::define('zero', fn () => 0); + Feature::define('null', fn () => null); + Feature::define('empty-string', fn () => ''); + + $this->assertTrue(Feature::active('true')); + $this->assertFalse(Feature::active('false')); + $this->assertTrue(Feature::active('one')); + $this->assertTrue(Feature::active('zero')); + $this->assertTrue(Feature::active('null')); + $this->assertTrue(Feature::active('empty-string')); + + $this->assertFalse(Feature::inactive('true')); + $this->assertTrue(Feature::inactive('false')); + $this->assertFalse(Feature::inactive('one')); + $this->assertFalse(Feature::inactive('zero')); + $this->assertFalse(Feature::inactive('null')); + $this->assertFalse(Feature::inactive('empty-string')); + + $this->assertCount(12, DB::getQueryLog()); + } + + public function testItCanProgramaticallyActivateAndDeativateFeatures(): void + { + Feature::activate('foo'); + $this->assertTrue(Feature::active('foo')); + + Feature::deactivate('foo'); + $this->assertFalse(Feature::active('foo')); + + Feature::activate('foo'); + $this->assertTrue(Feature::active('foo')); + + $this->assertCount(3, DB::getQueryLog()); + } + + public function testItDispatchesEventsWhenCheckingKnownFeatures(): void + { + Event::fake([FeatureResolved::class]); + Feature::define('foo', fn () => true); + + Feature::active('foo'); + Feature::active('foo'); + + Event::assertDispatchedTimes(FeatureResolved::class, 1); + Event::assertDispatched(function (FeatureResolved $event) { + return $event->feature === 'foo' && $event->scope === null; + }); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItCanActivateAndDeactivateSeveralFeaturesAtOnce(): void + { + Feature::activate(['foo', 'bar']); + + $this->assertTrue(Feature::active('foo')); + $this->assertTrue(Feature::active('bar')); + $this->assertFalse(Feature::active('baz')); + + Feature::deactivate(['foo', 'bar']); + + $this->assertFalse(Feature::active('foo')); + $this->assertFalse(Feature::active('bar')); + $this->assertFalse(Feature::active('bar')); + + Feature::activate(['bar', 'baz']); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::active('bar')); + $this->assertTrue(Feature::active('bar')); + + $this->assertCount(4, DB::getQueryLog()); + } + + public function testItCanCheckIfMultipleFeaturesAreActiveAtOnce(): void + { + Feature::activate(['foo', 'bar']); + + $this->assertTrue(Feature::allAreActive(['foo'])); + $this->assertTrue(Feature::allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::allAreActive(['foo', 'bar', 'baz'])); + + Feature::deactivate('baz'); + + $this->assertTrue(Feature::allAreActive(['foo'])); + $this->assertTrue(Feature::allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::allAreActive(['foo', 'bar', 'baz'])); + + $this->assertCount(3, DB::getQueryLog()); + } + + public function testItCanScopeFeatures(): void + { + $active = new User(['id' => 1]); + $inactive = new User(['id' => 2]); + $captured = []; + + Feature::define('foo', function ($scope) use (&$captured) { + $captured[] = $scope; + + return $scope?->id === 1; + }); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($active)->active('foo')); + $this->assertFalse(Feature::for($inactive)->active('foo')); + $this->assertSame([null, $active, $inactive], $captured); + + $this->assertCount(6, DB::getQueryLog()); + } + + public function testItCanActivateAndDeactivateFeaturesWithScope(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for($first)->activate('foo'); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($first)->active('foo')); + $this->assertFalse(Feature::for($second)->active('foo')); + + $this->assertCount(3, DB::getQueryLog()); + } + + public function testItCanActivateAndDeactivateFeaturesForMultipleScopeAtOnce(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + $third = new User(['id' => 3]); + + Feature::for([$first, $second])->activate('foo'); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($first)->active('foo')); + $this->assertTrue(Feature::for($second)->active('foo')); + $this->assertFalse(Feature::for($third)->active('foo')); + + $this->assertCount(3, DB::getQueryLog()); + } + + public function testItCanActivateAndDeactivateMultipleFeaturesForMultipleScopeAtOnce(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + $third = new User(['id' => 3]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for($first)->active('foo')); + $this->assertTrue(Feature::for($second)->active('foo')); + $this->assertFalse(Feature::for($third)->active('foo')); + + $this->assertFalse(Feature::active('bar')); + $this->assertTrue(Feature::for($first)->active('bar')); + $this->assertTrue(Feature::for($second)->active('bar')); + $this->assertFalse(Feature::for($third)->active('bar')); + + $this->assertCount(5, DB::getQueryLog()); + } + + public function testItCanCheckMultipleFeaturesForMultipleScopeAtOnce(): void + { + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + $third = new User(['id' => 3]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + $this->assertFalse(Feature::allAreActive(['foo', 'bar'])); + $this->assertTrue(Feature::for($first)->allAreActive(['foo', 'bar'])); + $this->assertTrue(Feature::for($second)->allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::for($third)->allAreActive(['foo', 'bar'])); + + $this->assertTrue(Feature::for([$first, $second])->allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::for([$second, $third])->allAreActive(['foo', 'bar'])); + $this->assertFalse(Feature::for([$first, $second, $third])->allAreActive(['foo', 'bar'])); + + $this->assertCount(3, DB::getQueryLog()); + } + + public function testNullIsSameAsNoScope(): void + { + Feature::activate('foo'); + + $this->assertTrue(Feature::for(null)->active('foo')); + + $this->assertCount(1, DB::getQueryLog()); + } + + public function testItSeesNullAndEmptyStringAsDifferentThings(): void + { + Feature::activate('foo'); + + $this->assertFalse(Feature::for('')->active('foo')); + $this->assertTrue(Feature::for(null)->active('foo')); + $this->assertTrue(Feature::active('foo')); + + Feature::for('')->activate('bar'); + + $this->assertTrue(Feature::for('')->active('bar')); + $this->assertFalse(Feature::for(null)->active('bar')); + $this->assertFalse(Feature::active('bar')); + + $this->assertCount(4, DB::getQueryLog()); + } + + public function testScopeCanBeStringsLikeEmailAddresses(): void + { + Feature::for('tim@hypervel.com')->activate('foo'); + + $this->assertFalse(Feature::for('james@hypervel.com')->active('foo')); + $this->assertTrue(Feature::for('tim@hypervel.com')->active('foo')); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItCanHandleFeatureScopeableObjects(): void + { + Feature::define('foo', function ($scope) { + return $scope === 'tim@hypervel.com'; + }); + $scopeable = fn ($email) => new class(['email' => $email]) extends User implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return $this->email; + } + }; + + $this->assertTrue(Feature::for($scopeable('tim@hypervel.com'))->active('foo')); + $this->assertFalse(Feature::for($scopeable('james@hypervel.com'))->active('foo')); + + $this->assertCount(4, DB::getQueryLog()); + + $scope = DB::table('features')->get('scope'); + $this->assertSame([ + 'james@hypervel.com', + 'tim@hypervel.com', + ], $scope->pluck('scope')->all()); + } + + public function testItSerializesEloquentModels(): void + { + Feature::for(UserFactory::new()->create())->activate('foo'); + + $scope = DB::table('features')->value('scope'); + + $this->assertStringContainsString('Hypervel\Tests\Pennant\Fixtures\User|1', $scope); + } + + public function testItRejectsModelsWithoutAKeyAsScope(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to serialize a model without a key as the feature scope. Save the model or pass a scope that identifies it.'); + + Feature::for(new User)->active('foo'); + } + + public function testItCanManuallySerializeScope(): void + { + Feature::define('foo', function ($scope) { + return $scope->email === 'tim@hypervel.com'; + }); + $scopeable = fn ($email) => new class(['email' => $email]) extends User implements FeatureScopeSerializeable { + public function featureScopeSerialize(): string + { + return $this->email; + } + }; + + $this->assertTrue(Feature::for($scopeable('tim@hypervel.com'))->active('foo')); + $this->assertFalse(Feature::for($scopeable('james@hypervel.com'))->active('foo')); + + $this->assertCount(4, DB::getQueryLog()); + + $scope = DB::table('features')->get('scope'); + $this->assertSame([ + 'james@hypervel.com', + 'tim@hypervel.com', + ], $scope->pluck('scope')->all()); + } + + public function testItCanLoadFeatureStateIntoMemory(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function () use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::load('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::load(['foo']); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::load('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::load(['bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::load(['foo', 'bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::allAreActive(['foo', 'bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + $this->assertCount(7, DB::getQueryLog()); + } + + public function testItCanLoadScopedFeatureStateIntoMemory(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function ($scope) use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load(['foo']); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load(['bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('loaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('noloaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(2, $called['bar']); + + Feature::getAll([ + 'foo' => [1, 2, 3], + 'bar' => [2], + ]); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + + Feature::for([1, 2, 3])->active('foo'); + Feature::for([2])->active('bar'); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + + $this->assertCount(9, DB::getQueryLog()); + } + + public function testItCanLoadAgainstScope(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function ($scope) use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load(['foo']); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::for('loaded')->load('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('loaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::for('noloaded')->active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(2, $called['bar']); + + Feature::for([1, 2, 3])->load(['foo']); + Feature::for(2)->load(['bar']); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + + Feature::for([1, 2, 3])->active('foo'); + Feature::for([2])->active('bar'); + $this->assertSame(4, $called['foo']); + $this->assertSame(3, $called['bar']); + + $this->assertCount(11, DB::getQueryLog()); + } + + public function testItCanLoadMissingFeatureStateIntoMemory(): void + { + $called = ['foo' => 0, 'bar' => 0]; + Feature::define('foo', function () use (&$called) { + ++$called['foo']; + }); + Feature::define('bar', function () use (&$called) { + ++$called['bar']; + }); + + $this->assertSame(0, $called['foo']); + + Feature::loadMissing('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::loadMissing('foo'); + $this->assertSame(0, $called['bar']); + + Feature::active('foo'); + $this->assertSame(1, $called['foo']); + $this->assertSame(0, $called['bar']); + + Feature::active('bar'); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::loadMissing(['bar']); + $this->assertSame(1, $called['foo']); + $this->assertSame(1, $called['bar']); + + Feature::getAllMissing([ + 'foo' => [1, 2, 3], + 'bar' => [2], + ]); + $this->assertSame(4, $called['foo']); + $this->assertSame(2, $called['bar']); + + Feature::for([1, 2, 3])->active('foo'); + Feature::for([2])->active('bar'); + $this->assertSame(4, $called['foo']); + $this->assertSame(2, $called['bar']); + + $this->assertCount(6, DB::getQueryLog()); + } + + public function testItDoesNotHitDbWhenFeaturesAreEmpty(): void + { + Feature::load([]); + Feature::loadMissing([]); + $this->assertCount(0, DB::getQueryLog()); + + Feature::active('foo'); + Feature::loadMissing(['foo']); + $this->assertCount(1, DB::getQueryLog()); + } + + public function testUnknownFeaturesAreNoPersistedWhenLoading(): void + { + Event::fake([UnknownFeatureResolved::class]); + Feature::load(['foo', 'bar']); + + Event::assertDispatchedTimes(UnknownFeatureResolved::class, 2); + $this->assertCount(1, DB::getQueryLog()); + $this->assertCount(0, DB::table('features')->get()); + } + + public function testMissingResultsAreInsertedOnLoad(): void + { + Feature::define('foo', function () { + return 1; + }); + Feature::define('bar', function () { + return 2; + }); + + Feature::for('taylor@hypervel.com')->activate('foo', 99); + Feature::for(['tim@hypervel.com', 'jess@hypervel.com', 'taylor@hypervel.com'])->load(['foo', 'bar']); + + $this->assertCount(3, DB::getQueryLog()); + $this->assertDatabaseHas('features', [ + 'name' => 'foo', + 'scope' => 'tim@hypervel.com', + 'value' => '1', + ]); + $this->assertDatabaseHas('features', [ + 'name' => 'foo', + 'scope' => 'jess@hypervel.com', + 'value' => '1', + ]); + $this->assertDatabaseHas('features', [ + 'name' => 'foo', + 'scope' => 'taylor@hypervel.com', + 'value' => '99', + ]); + $this->assertDatabaseHas('features', [ + 'name' => 'bar', + 'scope' => 'tim@hypervel.com', + 'value' => '2', + ]); + $this->assertDatabaseHas('features', [ + 'name' => 'bar', + 'scope' => 'jess@hypervel.com', + 'value' => '2', + ]); + $this->assertDatabaseHas('features', [ + 'name' => 'bar', + 'scope' => 'taylor@hypervel.com', + 'value' => '2', + ]); + } + + public function testItCanRetrieveRegisteredFeatures(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => false); + Feature::define('baz', fn () => false); + + $registered = Feature::defined(); + + $this->assertSame(['foo', 'bar', 'baz'], $registered); + $this->assertCount(0, DB::getQueryLog()); + } + + public function testItCanClearTheCache(): void + { + Feature::define('foo', fn () => true); + + Feature::active('foo'); + Feature::flushCache(); + Feature::active('foo'); + + $this->assertCount(3, DB::getQueryLog()); + } + + public function testItCanGetAllFeatures(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => false); + + $all = Feature::all(); + + $this->assertSame([ + 'foo' => true, + 'bar' => false, + ], $all); + } + + public function testItCanReevaluateFeatureState(): void + { + Feature::define('foo', fn () => false); + $this->assertFalse(Feature::for('tim')->value('foo')); + + Feature::for('tim')->forget('foo'); + + Feature::define('foo', fn () => true); + $this->assertTrue(Feature::for('tim')->value('foo')); + } + + public function testItCanPurgeFlags(): void + { + Feature::define('foo', true); + Feature::define('bar', false); + + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + Feature::for('taylor')->active('bar'); + + $this->assertSame(3, DB::table('features')->count()); + + Feature::purge('foo'); + + $this->assertSame(1, DB::table('features')->count()); + + Feature::purge('bar'); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testItCanPurgeMultipleFlagsAtOnce(): void + { + Feature::define('foo', true); + Feature::define('bar', false); + Feature::define('baz', false); + + Feature::for('tim')->active('foo'); + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + Feature::for('taylor')->active('bar'); + Feature::for('taylor')->active('baz'); + + $this->assertSame(4, DB::table('features')->count()); + + Feature::purge(['foo', 'bar']); + + $this->assertSame(1, DB::table('features')->count()); + + Feature::purge(['baz']); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testRetrievingValuesAfterPurging(): void + { + Feature::define('foo', false); + + Feature::for('tim')->activate('foo'); + + $this->assertTrue(Feature::for('tim')->active('foo')); + $this->assertSame(1, DB::table('features')->count()); + + Feature::purge('foo'); + + $this->assertSame(0, DB::table('features')->count()); + + $this->assertFalse(Feature::for('tim')->active('foo')); + } + + public function testItCanPurgeAllFeatureFlags(): void + { + Feature::define('foo', true); + Feature::define('bar', false); + + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + Feature::for('taylor')->active('bar'); + + $this->assertSame(3, DB::table('features')->count()); + + Feature::purge(); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testItCanCustomiseDefaultScope(): void + { + $scopes = []; + Feature::define('foo', function ($scope) use (&$scopes) { + $scopes[] = $scope; + }); + + Feature::active('foo'); + + Auth::login($user = new User(['id' => 1])); + Feature::active('foo'); + + Feature::resolveScopeUsing(fn () => 'bar'); + Feature::active('foo'); + + $this->assertSame([ + null, + $user, + 'bar', + ], $scopes); + } + + public function testItDoesntIncludeDefaultScopeWhenNull(): void + { + $scopes = []; + Feature::define('foo', function ($scope) use (&$scopes) { + $scopes[] = $scope; + }); + + Feature::resolveScopeUsing(fn () => null); + Feature::active('foo'); + + $this->assertSame([ + null, + ], $scopes); + } + + public function testItCanInteractGloballyIgnoringTheDefaultScope(): void + { + $scopes = []; + Feature::define('foo', function ($scope) use (&$scopes) { + $scopes[] = $scope; + + return true; + }); + Feature::resolveScopeUsing(fn () => 'default-scope'); + + $this->assertTrue(Feature::globally()->active('foo')); + + $this->assertCount(1, $scopes); + $this->assertInstanceOf(GlobalScope::class, $scopes[0]); + $this->assertSame('__hypervel_global', DB::table('features')->soleValue('scope')); + } + + public function testItDoesNotStoreUnknownFeatures(): void + { + Event::fake([UnknownFeatureResolved::class]); + + Feature::active('foo'); + Feature::active('foo'); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testItCanUseUnregisteredClassFeatures(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::value(UnregisteredFeature::class); + $value = Feature::value(UnregisteredFeature::class); + $registered = Feature::defined(); + + $this->assertSame('unregistered-value', $value); + $this->assertSame([UnregisteredFeature::class], $registered); + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 1); + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeature::class); + + return true; + }); + } + + public function testItCanUseUnregisteredClassFeaturesWithResolveMethod(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::value(UnregisteredFeatureWithResolve::class); + $value = Feature::value(UnregisteredFeatureWithResolve::class); + $registered = Feature::defined(); + + $this->assertSame('unregistered-value.resolve', $value); + $this->assertSame([UnregisteredFeatureWithResolve::class], $registered); + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 1); + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeatureWithResolve::class); + + return true; + }); + } + + public function testItCanUseUnregisteredClassFeaturesWithNameProperty(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::value(UnregisteredFeatureWithName::class); + $value = Feature::value(UnregisteredFeatureWithName::class); + $registered = Feature::defined(); + + $this->assertSame('unregistered-value', $value); + $this->assertSame(['feature-name'], $registered); + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 1); + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeatureWithName::class); + + return true; + }); + } + + public function testItCanDeleteUnregisteredClassFeaturesWithNameProperty(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::value(UnregisteredFeatureWithName::class); + $this->assertSame(1, DB::table('features')->where('name', 'feature-name')->count()); + + Feature::forgetDrivers(); + + Feature::forget(UnregisteredFeatureWithName::class); + $this->assertSame(0, DB::table('features')->where('name', 'feature-name')->count()); + + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 2); + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeatureWithName::class); + + return true; + }); + } + + public function testItCanActivateUnregisteredClassFeaturesWithNameProperty(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::activate(UnregisteredFeatureWithName::class, 'expected-value'); + $this->assertSame(1, DB::table('features')->where('name', 'feature-name')->where('value', '"expected-value"')->count()); + + Feature::forgetDrivers(); + + Feature::forget(UnregisteredFeatureWithName::class); + $this->assertSame(0, DB::table('features')->where('name', 'feature-name')->where('value', '"expected-value"')->count()); + + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 2); + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeatureWithName::class); + + return true; + }); + } + + public function testItCanUseUnregisteredClassFeaturesWithNameAttribute(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::value(UnregisteredFeatureWithNameAttribute::class); + $value = Feature::value(UnregisteredFeatureWithNameAttribute::class); + $registered = Feature::defined(); + + $this->assertSame('unregistered-value', $value); + $this->assertSame(['feature-name-attribute'], $registered); + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 1); + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeatureWithNameAttribute::class); + + return true; + }); + } + + public function testItCanDeleteUnregisteredClassFeaturesWithNameAttribute(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::value(UnregisteredFeatureWithNameAttribute::class); + $this->assertSame(1, DB::table('features')->where('name', 'feature-name-attribute')->count()); + + Feature::forgetDrivers(); + + Feature::forget(UnregisteredFeatureWithNameAttribute::class); + $this->assertSame(0, DB::table('features')->where('name', 'feature-name-attribute')->count()); + + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 2); + + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeatureWithNameAttribute::class); + + return true; + }); + } + + public function testItCanActivateUnregisteredClassFeaturesWithNameAttribute(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + Feature::activate(UnregisteredFeatureWithNameAttribute::class, 'expected-value'); + $this->assertSame(1, DB::table('features')->where('name', 'feature-name-attribute')->where('value', '"expected-value"')->count()); + + Feature::forgetDrivers(); + + Feature::forget(UnregisteredFeatureWithNameAttribute::class); + $this->assertSame(0, DB::table('features')->where('name', 'feature-name-attribute')->where('value', '"expected-value"')->count()); + + Event::assertDispatched(DynamicallyRegisteringFeatureClass::class, 2); + + Event::assertDispatched(function (DynamicallyRegisteringFeatureClass $event) { + $this->assertSame($event->feature, UnregisteredFeatureWithNameAttribute::class); + + return true; + }); + } + + public function testNameAttributeTakesPrecedenceOverNameProperty(): void + { + Event::fake([DynamicallyRegisteringFeatureClass::class]); + + $value = Feature::value(FeatureWithNameAttributeAndProperty::class); + $registered = Feature::defined(); + + $this->assertSame('from-attribute-and-property', $value); + $this->assertSame(['attribute-name'], $registered); + } + + public function testItCanConditionallyExecuteCodeBlockForInactiveFeature(): void + { + $active = $inactive = null; + + Feature::when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertNull($active); + $this->assertTrue($inactive); + } + + public function testItCanConditionallyExecuteCodeBlockForActiveFeature(): void + { + $active = $inactive = null; + Feature::activate('foo'); + + Feature::when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertTrue($active); + $this->assertNull($inactive); + } + + public function testItReceivesValueForFeatureInConditionalCodeExecution(): void + { + $active = $inactive = null; + Feature::activate('foo', ['hello' => 'world']); + + Feature::when( + 'foo', + function ($value) use (&$active) { + $active = $value; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertSame(['hello' => 'world'], $active); + $this->assertNull($inactive); + } + + public function testConditionallyExecutingCodeRespectsScope(): void + { + $active = $inactive = null; + Feature::for('tim')->activate('foo'); + + Feature::when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertNull($active); + $this->assertTrue($inactive); + + $active = $inactive = null; + + Feature::for('tim')->when( + 'foo', + function () use (&$active) { + $active = true; + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + $this->assertTrue($active); + $this->assertNull($inactive); + } + + public function testConditionalClosuresReceiveCurrentFeatureInteraction(): void + { + $active = $inactive = null; + Feature::for('tim')->activate('foo', ['hello' => 'tim']); + + Feature::for('tim')->when( + 'foo', + function ($value, $feature) { + $feature->deactivate('foo'); + }, + function () use (&$inactive) { + $inactive = true; + }, + ); + + Feature::flushCache(); + $this->assertFalse(Feature::for('tim')->active('foo')); + $this->assertNull($inactive); + } + + public function testItCanSetForAll(): void + { + Feature::define('foo', fn () => false); + + Feature::for('tim')->activate('foo'); + Feature::for('taylor')->activate('foo'); + + $this->assertTrue(Feature::for('tim')->value('foo')); + $this->assertTrue(Feature::for('taylor')->value('foo')); + $this->assertTrue(Feature::getDriver()->get('foo', 'tim')); + $this->assertTrue(Feature::getDriver()->get('foo', 'taylor')); + + Feature::deactivateForEveryone('foo'); + + $this->assertFalse(Feature::for('tim')->value('foo')); + $this->assertFalse(Feature::for('taylor')->value('foo')); + $this->assertFalse(Feature::getDriver()->get('foo', 'tim')); + $this->assertFalse(Feature::getDriver()->get('foo', 'taylor')); + + Feature::activateForEveryone('foo'); + + $this->assertTrue(Feature::for('tim')->value('foo')); + $this->assertTrue(Feature::for('taylor')->value('foo')); + $this->assertTrue(Feature::getDriver()->get('foo', 'tim')); + $this->assertTrue(Feature::getDriver()->get('foo', 'taylor')); + } + + public function testItCanAutoRegisterFeatureClasses(): void + { + Feature::define('marketing-design', 'marketing-design-value'); + Feature::discover('Hypervel\Tests\Pennant\Fixtures\Features', __DIR__ . '/../Fixtures/Features'); + + $all = Feature::all(); + + $this->assertSame([ + 'marketing-design' => 'marketing-design-value', + 'Hypervel\Tests\Pennant\Fixtures\Features\NewApi' => 'new-api-value', + ], $all); + } + + public function testItHandlesMultiscopeChecks(): void + { + Feature::define('foo', false); + + $result = Feature::for(['tim', 'taylor'])->allAreInactive(['foo', 'bar']); + $this->assertTrue($result); + + $result = Feature::for(['tim', 'taylor'])->someAreInactive(['foo', 'bar']); + $this->assertTrue($result); + + $result = Feature::for(['tim', 'taylor'])->someAreActive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->allAreActive(['foo', 'bar']); + $this->assertFalse($result); + + Feature::for('tim')->activate('foo'); + + $result = Feature::for(['tim', 'taylor'])->allAreInactive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->someAreInactive(['foo', 'bar']); + $this->assertTrue($result); + + $result = Feature::for(['tim', 'taylor'])->someAreActive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->allAreActive(['foo', 'bar']); + $this->assertFalse($result); + + Feature::for('taylor')->activate('foo'); + + $result = Feature::for(['tim', 'taylor'])->allAreInactive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->someAreInactive(['foo', 'bar']); + $this->assertTrue($result); + + $result = Feature::for(['tim', 'taylor'])->someAreActive(['foo', 'bar']); + $this->assertTrue($result); + + $result = Feature::for(['tim', 'taylor'])->allAreActive(['foo', 'bar']); + $this->assertFalse($result); + + Feature::for('tim')->activate('bar'); + + $result = Feature::for(['tim', 'taylor'])->allAreInactive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->someAreInactive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->someAreActive(['foo', 'bar']); + $this->assertTrue($result); + + $result = Feature::for(['tim', 'taylor'])->allAreActive(['foo', 'bar']); + $this->assertFalse($result); + + Feature::for('taylor')->activate('bar'); + + $result = Feature::for(['tim', 'taylor'])->allAreInactive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->someAreInactive(['foo', 'bar']); + $this->assertFalse($result); + + $result = Feature::for(['tim', 'taylor'])->someAreActive(['foo', 'bar']); + $this->assertTrue($result); + + $result = Feature::for(['tim', 'taylor'])->allAreActive(['foo', 'bar']); + $this->assertTrue($result); + } + + public function testBulkInsertAddsTimestamps(): void + { + Feature::define('foo', true); + + Feature::values(['foo']); + $record = DB::table('features')->first(); + + $this->assertNotNull($record->updated_at); + $this->assertNotNull($record->created_at); + } + + public function testStoresMayBeConfigured(): void + { + $this->app->make('config')->set('database.connections.foo_connection', $this->app->make('config')->get('database.connections.testing')); + $this->app->make('config')->set('database.connections.bar_connection', $this->app->make('config')->get('database.connections.testing')); + $this->app->make('config')->set('pennant.stores.foo', [ + 'driver' => 'database', + 'connection' => 'foo_connection', + 'table' => 'foo_features', + ]); + $this->app->make('config')->set('pennant.stores.bar', [ + 'driver' => 'database', + 'connection' => 'bar_connection', + 'table' => 'bar_features', + ]); + $connectionResolver = function () { + return $this->newQuery()->connection->getName(); + }; + $tableResolver = function () { + return $this->newQuery()->from; + }; + + $driver = Feature::store('foo')->getDriver(); + $this->assertSame('foo_connection', $connectionResolver->bindTo($driver, $driver)()); + $this->assertSame('foo_features', $tableResolver->bindTo($driver, $driver)()); + + $driver = Feature::store('bar')->getDriver(); + $this->assertSame('bar_connection', $connectionResolver->bindTo($driver, $driver)()); + $this->assertSame('bar_features', $tableResolver->bindTo($driver, $driver)()); + } + + public function testItUsesDefaultConnectionIfNull(): void + { + Config::set('pennant.stores.database.connection', 'null'); + + $migration = new class extends PennantMigration {}; + + $connection = $migration->getConnection(); + $this->assertSame(config('database.default'), $connection); + } + + public function testItDispatchesEventsWhenPurgingFeatures(): void + { + Event::fake([FeaturesPurged::class]); + + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::purge(['foo', 'bar', 'baz']); + + Event::assertDispatchedTimes(FeaturesPurged::class, 1); + Event::assertDispatched(function (FeaturesPurged $event) { + return $event->features === ['foo', 'bar', 'baz']; + }); + } + + public function testItDispatchesEventsWhenPurgingAllFeatures(): void + { + Event::fake([AllFeaturesPurged::class]); + + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::purge(); + + Event::assertDispatchedTimes(AllFeaturesPurged::class, 1); + } + + public function testItDispatchesEventsWhenUpdatingAScopedFeature(): void + { + Event::fake([FeatureUpdated::class]); + + Feature::define('foo', fn () => false); + + Feature::for('tim')->activate('foo'); + + Event::assertDispatchedTimes(FeatureUpdated::class, 1); + Event::assertDispatched(function (FeatureUpdated $event) { + return $event->feature === 'foo' + && $event->scope === 'tim' + && $event->value === true; + }); + } + + public function testItDispatchesEventsWhenUpdatingAFeatureForAllScopes(): void + { + Event::fake([FeatureUpdatedForAllScopes::class]); + + Feature::define('foo', fn () => false); + + Feature::activateForEveryone('foo', true); + + Event::assertDispatchedTimes(FeatureUpdatedForAllScopes::class, 1); + Event::assertDispatched(function (FeatureUpdatedForAllScopes $event) { + return $event->feature === 'foo' + && $event->value === true; + }); + } + + public function testItDispatchesEventsWhenDeletingAFeatureValue(): void + { + Event::fake([FeatureDeleted::class]); + + Feature::define('foo', fn () => false); + + Feature::for('tim')->forget('foo'); + + Event::assertDispatchedTimes(FeatureDeleted::class, 1); + Event::assertDispatched(function (FeatureDeleted $event) { + return $event->feature === 'foo' + && $event->scope === 'tim'; + }); + } + + public function testItCanUseEloquentMorphMapForScopeSerialization(): void + { + $model = new User(['id' => 6]); + Relation::morphMap([ + 'user-morph' => $model::class, + ]); + $scopes = []; + Feature::define('foo', fn () => true); + + Feature::useMorphMap(); + Feature::for($model)->active('foo'); + + $this->assertDatabaseHas('features', [ + 'name' => 'foo', + 'scope' => 'user-morph|6', + 'value' => 'true', + ]); + } + + public function testItRespectsUpdatedConnectionConfiguration(): void + { + Feature::flushCache(); + Config::set('pennant.stores.database.connection', null); + Feature::store('database')->active('feature-name'); + + // Stores read their configuration when they're built. + Feature::forgetDriver('database'); + Config::set('pennant.stores.database.connection', 'xxxx'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Database connection [xxxx] not configured.'); + Feature::store('database')->active('feature-name'); + } + + public function testItCanListStoredFeatures(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + + Feature::for('tim')->active('bar'); + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->assertSame(Feature::stored(), ['bar', 'baz']); + } + + public function testItRetries3TimesAndThenFails(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + $insertAttempts = $this->competeForEveryInsert(['foo', 'bar'], 'tim'); + + try { + Feature::for('tim')->loadMissing(['foo', 'bar']); + $this->fail('Should have failed.'); + } catch (Exception $e) { + $this->assertInstanceOf(RuntimeException::class, $e); + $this->assertSame('Unable to insert feature values into the database.', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + + $this->assertSame(3, $insertAttempts->count); + } + + public function testItOnlyRetriesOnConflicts(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + $insertAttempts = $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $values = Feature::for('tim')->loadMissing(['foo', 'bar']); + + $this->assertSame(['foo' => ['winner'], 'bar' => [true]], $values); + $this->assertSame('winner', Feature::for('tim')->value('foo')); + $this->assertSame(1, $insertAttempts->count); + } + + public function testItRetries3TimesAndThenFailsForIndividualQueries(): void + { + Feature::define('foo', fn () => true); + Feature::define('bar', fn () => true); + $insertAttempts = $this->competeForEveryInsert(['foo'], 'tim'); + + try { + Feature::driver('database')->get('foo', 'tim'); + $this->fail('Should have failed.'); + } catch (Exception $e) { + $this->assertInstanceOf(RuntimeException::class, $e); + $this->assertSame('Unable to insert feature values into the database.', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + + $this->assertSame(3, $insertAttempts->count); + } + + public function testItOnlyRetriesOnConflictsForIndividualQueries(): void + { + // The winner's JSON matches the losing value's, but the stored value is still what's returned. + Feature::define('foo', fn () => (object) ['enabled' => true]); + Feature::define('bar', fn () => true); + $insertAttempts = $this->storeWinnerBeforeInsert('foo', 'tim', ['enabled' => true]); + + $this->assertSame(['enabled' => true], Feature::driver('database')->get('foo', 'tim')); + $this->assertSame(1, $insertAttempts->count); + } + + /** + * Store a competing row after the first read, as a concurrent request would, so the insert loses to it. + */ + protected function storeWinnerBeforeInsert(string $feature, string $scope, mixed $value): stdClass + { + $insertAttempts = (object) ['count' => 0]; + $stored = false; + + DB::listen(function (QueryExecuted $event) use ($feature, $scope, $value, $insertAttempts, &$stored) { + if (! $stored && Str::startsWith($event->sql, 'select')) { + $stored = true; + + DB::table('features')->insert([ + 'name' => $feature, + 'scope' => $scope, + 'value' => json_encode($value), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } elseif (Str::startsWith($event->sql, 'insert or ignore into "features"')) { + ++$insertAttempts->count; + } + }); + + return $insertAttempts; + } + + /** + * Store competing rows after every read and delete them after every insert, as a concurrent purge would. + * + * Each insert is then ignored, and the reread that follows finds nothing. + * + * @param list $features + */ + protected function competeForEveryInsert(array $features, string $scope): stdClass + { + $insertAttempts = (object) ['count' => 0]; + $competing = false; + + DB::listen(function (QueryExecuted $event) use ($features, $scope, $insertAttempts, &$competing) { + if ($competing) { + return; + } + + $competing = true; + + if (Str::startsWith($event->sql, 'select')) { + DB::table('features')->insert(array_map(fn (string $feature) => [ + 'name' => $feature, + 'scope' => $scope, + 'value' => '"competitor"', + 'created_at' => now(), + 'updated_at' => now(), + ], $features)); + } elseif (Str::startsWith($event->sql, 'insert or ignore into "features"')) { + ++$insertAttempts->count; + + DB::table('features')->delete(); + } + + $competing = false; + }); + + return $insertAttempts; + } + + public function testItCanInterceptFeatureValuesUsingBeforeHook(): void + { + $queries = 0; + FeatureWithBeforeHook::$before = fn ($scope) => 'before'; + Feature::define(FeatureWithBeforeHook::class); + Feature::activate(FeatureWithBeforeHook::class, 'stored-value'); + Feature::flushCache(); + DB::listen(function (QueryExecuted $event) use (&$queries) { + ++$queries; + }); + + $value = Feature::for(null)->value(FeatureWithBeforeHook::class); + + $this->assertSame('before', $value); + $this->assertSame(0, $queries); + + // A class name defined as a value is a constant, so the class's before hook doesn't run. + Feature::define('class-name-value', FeatureWithBeforeHook::class); + + $this->assertSame(FeatureWithBeforeHook::class, Feature::value('class-name-value')); + } + + public function testItCanMergesBeforeWithResolvedFeatures(): void + { + $queries = 0; + DB::listen(function (QueryExecuted $event) use (&$queries) { + ++$queries; + }); + FeatureWithBeforeHook::$before = fn ($scope) => ['before' => 'value']; + Feature::define('foo', 'bar'); + Feature::define(FeatureWithBeforeHook::class); + + $values = Feature::for('user:1')->all(); + + $this->assertSame([ + 'foo' => 'bar', + 'Hypervel\Tests\Pennant\Feature\FeatureWithBeforeHook' => ['before' => 'value'], + ], $values); + $this->assertSame(2, $queries); + } + + public function testItCanGetFeaturesWithBeforeHook(): void + { + $queries = 0; + DB::listen(function (QueryExecuted $event) use (&$queries) { + ++$queries; + }); + FeatureWithBeforeHook::$before = fn ($scope) => ['before' => 'value']; + FeatureWithBeforeHookAndCustomName::$before = fn ($scope) => ['before' => 'value 2']; + + $value = Feature::get(FeatureWithBeforeHook::class, null); + + $this->assertSame(['before' => 'value'], $value); + $this->assertSame(0, $queries); + + $value = Feature::get(FeatureWithBeforeHookAndCustomName::class, null); + + $this->assertSame(['before' => 'value 2'], $value); + $this->assertSame(0, $queries); + + $value = Feature::get('feature-with-before-hook-and-custom-name', null); + + $this->assertSame(['before' => 'value 2'], $value); + $this->assertSame(0, $queries); + } + + public function testItHandlesNullScopeForBeforeHook(): void + { + Event::fake(UnexpectedNullScopeEncountered::class); + $queries = 0; + DB::listen(function (QueryExecuted $event) use (&$queries) { + ++$queries; + }); + FeatureWithTypedBeforeHook::$before = fn ($scope) => 'before-value'; + Feature::define(FeatureWithTypedBeforeHook::class); + + $values = Feature::for(null)->value(FeatureWithTypedBeforeHook::class); + + $this->assertSame('feature-value', $values); + $this->assertSame(2, $queries); + + Feature::flushCache(); + + $value = Feature::get(FeatureWithTypedBeforeHook::class, null); + + $this->assertSame('feature-value', $value); + $this->assertSame(3, $queries); + Event::assertDispatchedTimes(UnexpectedNullScopeEncountered::class, 2); + } + + public function testRedefiningAFeatureRechecksWhetherItsBeforeHookAcceptsNullScope(): void + { + Event::fake(UnexpectedNullScopeEncountered::class); + FeatureWithBeforeHookAndCustomName::$before = fn () => 'before-value'; + Feature::define(FeatureWithBeforeHookAndCustomName::class); + + $this->assertSame('before-value', Feature::for(null)->value('feature-with-before-hook-and-custom-name')); + + Feature::define(FeatureWithTypedBeforeHookAndCustomName::class); + Feature::flushCache(); + + $this->assertSame('feature-value', Feature::for(null)->value('feature-with-before-hook-and-custom-name')); + Event::assertDispatchedTimes(UnexpectedNullScopeEncountered::class, 1); + } + + public function testItCanUseBeforeHookWhenUsingFeatureNameProperty(): void + { + $queries = 0; + FeatureWithBeforeHookAndCustomName::$before = fn ($scope) => 'before'; + Feature::define(FeatureWithBeforeHookAndCustomName::class); + Feature::activate(FeatureWithBeforeHookAndCustomName::class, 'stored-value'); + Feature::flushCache(); + DB::listen(function (QueryExecuted $event) use (&$queries) { + ++$queries; + }); + + $value = Feature::for(null)->value(FeatureWithBeforeHookAndCustomName::class); + + $this->assertSame('before', $value); + $this->assertSame(0, $queries); + } + + public function testItCanUseBeforeHookWhenUsingFeatureNamePropertyOnADynamicallyRegisteredFeature(): void + { + $queries = 0; + FeatureWithBeforeHookAndCustomName::$before = fn ($scope) => 'before'; + Feature::activate(FeatureWithBeforeHookAndCustomName::class, 'stored-value'); + Feature::activate('blah', 'stored-value'); + Feature::flushCache(); + DB::listen(function (QueryExecuted $event) use (&$queries) { + ++$queries; + }); + + $value = Feature::for(null)->value(FeatureWithBeforeHookAndCustomName::class); + + $this->assertSame('before', $value); + $this->assertSame(0, $queries); + } + + public function testItMaintainsScopeFeatureKeys(): void + { + $count = 0; + FeatureWithBeforeHook::$before = function ($scope) use (&$count) { + if ($scope === 'user:2') { + return null; + } + + ++$count; + + return "before-value-{$count}"; + }; + Feature::define(FeatureWithBeforeHook::class); + + $values = Feature::getAll([ + FeatureWithBeforeHook::class => ['user:1', 'user:2', 'user:3'], + ]); + + $this->assertSame([ + 'Hypervel\Tests\Pennant\Feature\FeatureWithBeforeHook' => ['before-value-1', 'feature-value', 'before-value-2'], + ], $values); + } + + public function testItKeysByFeatureName(): void + { + Feature::define(FeatureWithName::class); + Feature::define(FeatureWithoutName::class); + + $this->assertSame([ + 'feature-with-name' => 'feature-with-name-value', + 'Hypervel\Tests\Pennant\Feature\FeatureWithoutName' => 'feature-without-name-value', + ], Feature::all()); + + $this->assertSame([ + 'feature-with-name' => 'feature-with-name-value', + 'feature-with-name' => 'feature-with-name-value', + 'Hypervel\Tests\Pennant\Feature\FeatureWithoutName' => 'feature-without-name-value', + ], Feature::values([ + 'feature-with-name', + FeatureWithName::class, + FeatureWithoutName::class, + ])); + + $this->assertSame([ + 'feature-with-name' => ['feature-with-name-value'], + 'Hypervel\Tests\Pennant\Feature\FeatureWithoutName' => ['feature-without-name-value'], + ], Feature::load([ + 'feature-with-name', + FeatureWithoutName::class, + ])); + + $this->assertSame([ + 'feature-with-name' => ['feature-with-name-value'], + 'Hypervel\Tests\Pennant\Feature\FeatureWithoutName' => ['feature-without-name-value'], + ], Feature::load([ + FeatureWithName::class, + FeatureWithoutName::class, + ])); + + Feature::flushCache(); + + $this->assertSame([ + 'feature-with-name' => ['feature-with-name-value'], + 'Hypervel\Tests\Pennant\Feature\FeatureWithoutName' => ['feature-without-name-value'], + ], Feature::loadMissing([ + 'feature-with-name', + FeatureWithoutName::class, + ])); + + Feature::flushCache(); + + $this->assertSame([ + 'feature-with-name' => ['feature-with-name-value'], + 'Hypervel\Tests\Pennant\Feature\FeatureWithoutName' => ['feature-without-name-value'], + ], Feature::loadMissing([ + FeatureWithName::class, + FeatureWithoutName::class, + ])); + } + + public function testItCanLoadAllFeaturesForScope(): void + { + Feature::define('bar', fn ($scope) => $scope === 'taylor'); + Feature::define('foo', fn ($scope) => $scope === 'tim'); + + Feature::for(['tim', 'taylor'])->loadAll(); + + $records = DB::table('features')->orderBy('scope')->orderBy('name')->get(['name', 'scope', 'value'])->all(); + $this->assertCount(4, $records); + $this->assertEquals((object) [ + 'scope' => 'taylor', + 'value' => 'true', + 'name' => 'bar', + ], $records[0]); + $this->assertEquals((object) [ + 'scope' => 'taylor', + 'value' => 'false', + 'name' => 'foo', + ], $records[1]); + $this->assertEquals((object) [ + 'scope' => 'tim', + 'value' => 'false', + 'name' => 'bar', + ], $records[2]); + $this->assertEquals((object) [ + 'scope' => 'tim', + 'value' => 'true', + 'name' => 'foo', + ], $records[3]); + } + + public function testCanRetrieveAllFeaturesForDifferingScopeTypes(): void + { + Feature::define('user', fn (User $user) => 1); + Feature::define('nullable-user', fn (?User $user) => 2); + Feature::define('team', fn (Team $team) => 3); + Feature::define('nullable-team', fn (?Team $team) => 4); + Feature::define('mixed', fn (mixed $v) => 5); + Feature::define('none', fn ($v) => 6); + Feature::define('array', fn (array $t) => 7); + Feature::define('string', fn (string $str) => 8); + Feature::define('team-or-user', fn (Team|User $model) => 9); + + $features = Feature::for(new User(['id' => 1]))->all(); + $this->assertSame([ + 'user' => 1, + 'nullable-user' => 2, + 'mixed' => 5, + 'none' => 6, + 'team-or-user' => 9, + ], $features); + $this->assertCount(2, DB::getQueryLog()); + $this->assertCount(5, DB::table('features')->get()); // query + + $features = Feature::for(new Team(['id' => 1]))->all(); + $this->assertSame([ + 'team' => 3, + 'nullable-team' => 4, + 'mixed' => 5, + 'none' => 6, + 'team-or-user' => 9, + ], $features); + $this->assertCount(5, DB::getQueryLog()); + $this->assertCount(10, DB::table('features')->get()); // query + + $features = Feature::for('scope')->all(); + $this->assertSame([ + 'mixed' => 5, + 'none' => 6, + 'string' => 8, + ], $features); + $this->assertCount(8, DB::getQueryLog()); + $this->assertCount(13, DB::table('features')->get()); + } + + public function testCanRetrieveScalarValuesWithoutInMemoryCache(): void + { + $data = [ + 'one' => 1, + 'ten' => 10, + 'zero' => 0, + 'minus-one' => -1, + 'minus-ten' => -10, + 'one-point-five' => 1.5, + 'ten-point-five' => 10.5, + 'zero-point-five' => 0.5, + 'minus-one-point-five' => -1.5, + 'minus-ten-point-five' => -10.5, + 'bool-true' => true, + 'bool-false' => false, + 'null' => null, + ]; + + $user = new User(['id' => 1]); + + foreach ($data as $name => $expectedValue) { + $feature = 'scalar-feature:' . $name; + Feature::define($feature, fn (User $user) => $expectedValue); + + $generated = Feature::for($user)->value($feature); + + Feature::flushCache(); + + $retrieved = Feature::for($user)->value($feature); + + $this->assertEquals($expectedValue, $generated); + $this->assertEquals($expectedValue, $retrieved); + } + } + + public function testItDispatchesEventsWhenActivatingMultipleFeaturesAndScopes(): void + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => true, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => true, + ], $events); + } + + public function testItDispatchesEventsWhenDeactivatingMultipleFeaturesAndScopes(): void + { + Event::fake([FeatureUpdated::class]); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->deactivate(['foo', 'bar']); + + Event::assertDispatchedTimes(FeatureUpdated::class, 4); + + $events = []; + Event::assertDispatched(function (FeatureUpdated $event) use (&$events) { + $events[] = [ + 'feature' => $event->feature, + 'scope' => $event->scope, + 'value' => $event->value, + ]; + + return true; + }); + + $this->assertCount(4, $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'foo', + 'scope' => $second, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $first, + 'value' => false, + ], $events); + $this->assertContains([ + 'feature' => 'bar', + 'scope' => $second, + 'value' => false, + ], $events); + } + + public function testItUsesSingleQueryWhenActivatingMultipleFeaturesAndScopes(): void + { + DB::enableQueryLog(); + + $first = new User(['id' => 1]); + $second = new User(['id' => 2]); + + Feature::for([$first, $second])->activate(['foo', 'bar']); + + $this->assertCount(1, DB::getQueryLog()); + } +} + +class UnregisteredFeature +{ + public function __invoke(): string + { + return 'unregistered-value'; + } +} + +class UnregisteredFeatureWithResolve +{ + public function resolve(): string + { + return 'unregistered-value.resolve'; + } +} + +class UnregisteredFeatureWithName +{ + public string $name = 'feature-name'; + + public function __invoke(): string + { + return 'unregistered-value'; + } +} + +class FeatureWithBeforeHook +{ + public static ?Closure $before = null; + + public function resolve(): string + { + return 'feature-value'; + } + + public function before(): mixed + { + return (static::$before)(...func_get_args()); + } +} + +class FeatureWithBeforeHookAndCustomName +{ + public string $name = 'feature-with-before-hook-and-custom-name'; + + public static ?Closure $before = null; + + public function resolve(): string + { + return 'feature-value'; + } + + public function before(): mixed + { + return (static::$before)(...func_get_args()); + } +} + +class FeatureWithTypedBeforeHookAndCustomName +{ + public string $name = 'feature-with-before-hook-and-custom-name'; + + public function resolve(): string + { + return 'feature-value'; + } + + public function before(string $scope): string + { + return 'before-value'; + } +} + +class FeatureWithTypedBeforeHook +{ + public static ?Closure $before = null; + + public function resolve(): string + { + return 'feature-value'; + } + + public function before(string $scope): mixed + { + return (static::$before)(...func_get_args()); + } +} + +class FeatureWithoutName +{ + public function __invoke(): string + { + return 'feature-without-name-value'; + } +} + +class FeatureWithName +{ + public string $name = 'feature-with-name'; + + public function __invoke(): string + { + return 'feature-with-name-value'; + } +} + +#[Name('feature-name-attribute')] +class UnregisteredFeatureWithNameAttribute +{ + public function __invoke(): string + { + return 'unregistered-value'; + } +} + +#[Name('attribute-name')] +class FeatureWithNameAttributeAndProperty +{ + public string $name = 'property-name'; + + public function __invoke(): string + { + return 'from-attribute-and-property'; + } +} diff --git a/tests/Pennant/Fixtures/Features/NewApi.php b/tests/Pennant/Fixtures/Features/NewApi.php new file mode 100644 index 000000000..a329e3277 --- /dev/null +++ b/tests/Pennant/Fixtures/Features/NewApi.php @@ -0,0 +1,16 @@ + + */ +class UserFactory extends TestbenchUserFactory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected ?string $model = User::class; +} diff --git a/tests/Pennant/TestCase.php b/tests/Pennant/TestCase.php new file mode 100644 index 000000000..6330a0793 --- /dev/null +++ b/tests/Pennant/TestCase.php @@ -0,0 +1,28 @@ +loadMigrationsFrom(__DIR__ . '/../../src/pennant/database/migrations'); + } +} From 4a93ba5657894bb41d8facdd3abca243dcd033f6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:23:23 +0000 Subject: [PATCH 13/39] Port the remaining Pennant tests Port every upstream case from DefinesFeaturesExternallyTest, EnumTest, FeatureDirectiveTest, FeatureHelperTest, FeatureManagerTest, FeatureMiddlewareTest, HasFeatureTest, PurgeCommandTest and IntersectionTypeTest, with these adaptations: - The external-definition drivers extend ArrayDriver through its Hypervel constructor ('external', null, []) instead of passing the event dispatcher. - PurgeCommandTest's custom store was an anonymous object that didn't implement Driver, which the typed store creator rejects. It is now an ArrayDriver subclass with a no-op purge(). - IntersectionTypeTest drops the PHP 8.2 RequiresPhp attribute, since Hypervel needs PHP 8.4, and gives its User and Team scopes ids. --- .../Feature/DefinesFeaturesExternallyTest.php | 72 +++++ tests/Pennant/Feature/EnumTest.php | 275 ++++++++++++++++++ .../Pennant/Feature/FeatureDirectiveTest.php | 102 +++++++ tests/Pennant/Feature/FeatureHelperTest.php | 57 ++++ tests/Pennant/Feature/FeatureManagerTest.php | 86 ++++++ .../Pennant/Feature/FeatureMiddlewareTest.php | 128 ++++++++ tests/Pennant/Feature/HasFeatureTest.php | 36 +++ tests/Pennant/Feature/PurgeCommandTest.php | 209 +++++++++++++ tests/Pennant/IntersectionTypeTest.php | 76 +++++ 9 files changed, 1041 insertions(+) create mode 100644 tests/Pennant/Feature/DefinesFeaturesExternallyTest.php create mode 100644 tests/Pennant/Feature/EnumTest.php create mode 100644 tests/Pennant/Feature/FeatureDirectiveTest.php create mode 100644 tests/Pennant/Feature/FeatureHelperTest.php create mode 100644 tests/Pennant/Feature/FeatureManagerTest.php create mode 100644 tests/Pennant/Feature/FeatureMiddlewareTest.php create mode 100644 tests/Pennant/Feature/HasFeatureTest.php create mode 100644 tests/Pennant/Feature/PurgeCommandTest.php create mode 100644 tests/Pennant/IntersectionTypeTest.php diff --git a/tests/Pennant/Feature/DefinesFeaturesExternallyTest.php b/tests/Pennant/Feature/DefinesFeaturesExternallyTest.php new file mode 100644 index 000000000..1816c35b5 --- /dev/null +++ b/tests/Pennant/Feature/DefinesFeaturesExternallyTest.php @@ -0,0 +1,72 @@ + + */ + public function definedFeaturesForScope(mixed $scope): array + { + return [ + 'feature-1', + 'feature-2', + ]; + } + }; + Feature::extend('external', fn () => $driver); + Config::set('pennant.stores.external', ['driver' => 'external']); + + $features = Feature::driver('external')->all(); + + $this->assertSame([ + 'feature-1' => false, + 'feature-2' => false, + ], $features); + } + + public function testWhenFeaturesAreDefinedExternallyThatScopeIsCorrectlyResolved(): void + { + $driver = new class('external', null, []) extends ArrayDriver implements DefinesFeaturesExternally { + /** + * Retrieve the defined features for the given scope. + * + * @return list + */ + public function definedFeaturesForScope(mixed $scope): array + { + return [ + $scope, + ]; + } + }; + Feature::extend('external', fn () => $driver); + Config::set('pennant.stores.external', ['driver' => 'external']); + + $features = Feature::driver('external')->for(new class implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'scope-value'; + } + })->all(); + + $this->assertSame([ + 'scope-value' => false, + ], $features); + } +} diff --git a/tests/Pennant/Feature/EnumTest.php b/tests/Pennant/Feature/EnumTest.php new file mode 100644 index 000000000..fd1409b2e --- /dev/null +++ b/tests/Pennant/Feature/EnumTest.php @@ -0,0 +1,275 @@ +make('config')->set('pennant.default', 'array'); + } + + public function testActiveAndInactiveAcceptEnums(): void + { + Feature::define(FeatureEnum::NewApi, fn () => true); + + $this->assertTrue(Feature::active(FeatureEnum::NewApi)); + $this->assertFalse(Feature::inactive(FeatureEnum::NewApi)); + } + + public function testAnEnumAndItsBackingValueReferenceTheSameFeature(): void + { + Feature::activate(FeatureEnum::NewApi, 'foo'); + + $this->assertSame('foo', Feature::value(FeatureEnum::NewApi)); + $this->assertSame('foo', Feature::value('new-api')); + $this->assertTrue(Feature::active('new-api')); + } + + public function testAPureEnumResolvesToItsCaseName(): void + { + Feature::activate(PureFeatureEnum::NewApi); + + $this->assertTrue(Feature::active(PureFeatureEnum::NewApi)); + $this->assertTrue(Feature::active('NewApi')); + } + + public function testAnIntegerBackedEnumResolvesToItsStringBackingValue(): void + { + Feature::activate(IntegerFeatureEnum::NewApi, 'foo'); + + $this->assertSame('foo', Feature::value(IntegerFeatureEnum::NewApi)); + $this->assertSame('foo', Feature::value('1')); + $this->assertSame(['1' => 'foo'], Feature::values([IntegerFeatureEnum::NewApi])); + $this->assertSame(['1' => ['foo']], Feature::load([IntegerFeatureEnum::NewApi])); + } + + public function testDefineAcceptsAnEnumWithAResolver(): void + { + Feature::define(FeatureEnum::NewApi, fn () => 'resolved-value'); + + $this->assertSame('resolved-value', Feature::value(FeatureEnum::NewApi)); + $this->assertSame('resolved-value', Feature::value('new-api')); + } + + public function testValueAndValuesAcceptEnums(): void + { + Feature::define(FeatureEnum::NewApi, fn () => true); + Feature::define(FeatureEnum::Purchasing, fn () => false); + + $this->assertSame([ + 'new-api' => true, + 'purchasing' => false, + ], Feature::values([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + } + + public function testAllAreActiveAndSomeAreActiveAcceptEnums(): void + { + Feature::activate(FeatureEnum::NewApi); + Feature::activate(FeatureEnum::Purchasing); + + $this->assertTrue(Feature::allAreActive([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + $this->assertTrue(Feature::someAreActive([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + + // Enums and their string values may be freely mixed in the same call. + $this->assertTrue(Feature::allAreActive([FeatureEnum::NewApi, 'purchasing'])); + + Feature::deactivate(FeatureEnum::Purchasing); + + $this->assertFalse(Feature::allAreActive([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + $this->assertTrue(Feature::someAreActive([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + } + + public function testAllAreInactiveAndSomeAreInactiveAcceptEnums(): void + { + Feature::activate(FeatureEnum::NewApi); + Feature::deactivate(FeatureEnum::Purchasing); + + $this->assertTrue(Feature::allAreInactive([FeatureEnum::Purchasing])); + $this->assertFalse(Feature::allAreInactive([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + $this->assertTrue(Feature::someAreInactive([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + } + + public function testWhenAndUnlessAcceptEnums(): void + { + Feature::activate(FeatureEnum::NewApi, 'foo'); + + $this->assertSame('active-foo', Feature::when( + FeatureEnum::NewApi, + fn ($value) => "active-{$value}", + fn () => 'inactive', + )); + + $this->assertSame('inactive', Feature::unless( + FeatureEnum::Purchasing, + fn () => 'inactive', + fn () => 'active', + )); + } + + public function testActivateDeactivateAndForgetAcceptEnums(): void + { + Feature::activate(FeatureEnum::NewApi); + $this->assertTrue(Feature::active(FeatureEnum::NewApi)); + + Feature::deactivate(FeatureEnum::NewApi); + $this->assertFalse(Feature::active(FeatureEnum::NewApi)); + + Feature::define(FeatureEnum::NewApi, fn () => true); + Feature::forget(FeatureEnum::NewApi); + $this->assertTrue(Feature::active(FeatureEnum::NewApi)); + } + + public function testLoadAndLoadMissingAcceptEnums(): void + { + Feature::define(FeatureEnum::NewApi, fn () => true); + Feature::define(FeatureEnum::Purchasing, fn () => false); + + $this->assertSame([ + 'new-api' => [true], + 'purchasing' => [false], + ], Feature::loadMissing([FeatureEnum::NewApi, FeatureEnum::Purchasing])); + + $this->assertSame([ + 'new-api' => [true], + ], Feature::load([FeatureEnum::NewApi])); + } + + public function testActivateForEveryoneAndPurgeAcceptEnums(): void + { + Feature::define(FeatureEnum::NewApi, fn () => false); + + // Resolve the scope so the array driver tracks it for "all scopes" updates. + Feature::for('tim')->active(FeatureEnum::NewApi); + + Feature::activateForEveryone(FeatureEnum::NewApi); + $this->assertTrue(Feature::for('tim')->active(FeatureEnum::NewApi)); + + Feature::deactivateForEveryone(FeatureEnum::NewApi); + $this->assertFalse(Feature::for('tim')->active(FeatureEnum::NewApi)); + + Feature::activate(FeatureEnum::NewApi); + Feature::purge(FeatureEnum::NewApi); + $this->assertFalse(Feature::active(FeatureEnum::NewApi)); + } + + public function testScopedInteractionsAcceptEnums(): void + { + Feature::for('tim')->activate(FeatureEnum::NewApi); + + $this->assertTrue(Feature::for('tim')->active(FeatureEnum::NewApi)); + $this->assertTrue(Feature::for('tim')->active('new-api')); + $this->assertFalse(Feature::for('jess')->active(FeatureEnum::NewApi)); + } + + public function testInstanceAcceptsAnEnum(): void + { + Feature::define(FeatureEnum::NewApi, $resolver = fn () => 'resolved'); + + $this->assertSame($resolver, Feature::instance(FeatureEnum::NewApi)); + $this->assertSame($resolver, Feature::instance('new-api')); + } + + public function testTheNameAttributeAcceptsAnEnum(): void + { + $this->assertSame('purchasing', Feature::name(FeatureWithEnumName::class)); + + $this->assertTrue(Feature::active(FeatureWithEnumName::class)); + $this->assertTrue(Feature::active(FeatureEnum::Purchasing)); + $this->assertTrue(Feature::active('purchasing')); + } + + public function testTheBladeDirectiveAcceptsAnEnum(): void + { + $blade = <<<'BLADE' + @feature(\Hypervel\Tests\Pennant\Feature\FeatureEnum::NewApi) + new api is active + @else + new api is inactive + @endfeature + BLADE; + + $this->assertSame('new api is inactive', trim(Blade::render($blade))); + + Feature::activate(FeatureEnum::NewApi); + + $this->assertSame('new api is active', trim(Blade::render($blade))); + } + + public function testTheMiddlewareUsingMethodAcceptsEnums(): void + { + $this->assertSame( + EnsureFeaturesAreActive::class . ':new-api,purchasing', + EnsureFeaturesAreActive::using(FeatureEnum::NewApi, FeatureEnum::Purchasing), + ); + } + + public function testTheMiddlewareHandleMethodAcceptsEnums(): void + { + Feature::define(FeatureEnum::NewApi, fn () => true); + + $response = (new EnsureFeaturesAreActive)->handle( + $this->createRequest('test', 'get'), + fn () => new Response, + FeatureEnum::NewApi, + ); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $this->expectException(HttpException::class); + + (new EnsureFeaturesAreActive)->handle( + $this->createRequest('test', 'get'), + fn () => new Response, + FeatureEnum::Purchasing, + ); + } + + /** + * Create a request for the given URI and method. + */ + protected function createRequest(string $uri, string $method): Request + { + return Request::createFromBase(SymfonyRequest::create(uri: $uri, method: $method)); + } +} + +enum FeatureEnum: string +{ + case NewApi = 'new-api'; + case Purchasing = 'purchasing'; +} + +enum IntegerFeatureEnum: int +{ + case NewApi = 1; + case Purchasing = 2; +} + +enum PureFeatureEnum +{ + case NewApi; + case Purchasing; +} + +#[Name(FeatureEnum::Purchasing)] +class FeatureWithEnumName +{ + public function resolve(): bool + { + return true; + } +} diff --git a/tests/Pennant/Feature/FeatureDirectiveTest.php b/tests/Pennant/Feature/FeatureDirectiveTest.php new file mode 100644 index 000000000..449a0f2b8 --- /dev/null +++ b/tests/Pennant/Feature/FeatureDirectiveTest.php @@ -0,0 +1,102 @@ +make('config')->set('pennant.default', 'database'); + } + + public function testItRendersActiveFeatures(): void + { + $blade = <<< 'BLADE' + @feature("foo") + foo is active + @else + foo is inactive + @endfeature + BLADE; + + $output = trim(Blade::render($blade)); + $this->assertSame('foo is inactive', $output); + + Feature::activate('foo'); + $output = trim(Blade::render($blade)); + $this->assertSame('foo is active', $output); + } + + public function testItChecksFeatureValue(): void + { + $blade = <<< 'BLADE' + @feature("foo", 888) + foo is 888 + @else + foo is not 888 + @endfeature + BLADE; + + Feature::activate('foo', 999); + $output = trim(Blade::render($blade)); + $this->assertSame('foo is not 888', $output); + + Feature::activate('foo', 888); + $output = trim(Blade::render($blade)); + $this->assertSame('foo is 888', $output); + } + + public function testItChecksFeaturesAgainstDefaultScopeByDefault(): void + { + $blade = <<< 'BLADE' + @feature("foo", 888) + foo is 888 + @else + foo is not 888 + @endfeature + BLADE; + + Feature::resolveScopeUsing(fn () => 'tim'); + + Feature::for('tim')->activate('foo', 999); + $output = trim(Blade::render($blade)); + $this->assertSame('foo is not 888', $output); + + Feature::for('tim')->activate('foo', 888); + $output = trim(Blade::render($blade)); + $this->assertSame('foo is 888', $output); + } + + public function testItRendersAnyActiveFeatures(): void + { + $blade = <<<'BLADE' + @featureany(['foo', 'bar']) + foo or bar is active + @else + foo and bar are inactive + @endfeatureany + BLADE; + + $output = trim(Blade::render($blade)); + $this->assertSame('foo and bar are inactive', $output); + + Feature::activate('foo'); + $output = trim(Blade::render($blade)); + $this->assertSame('foo or bar is active', $output); + + Feature::deactivate('foo'); + Feature::activate('bar'); + $output = trim(Blade::render($blade)); + $this->assertSame('foo or bar is active', $output); + } +} diff --git a/tests/Pennant/Feature/FeatureHelperTest.php b/tests/Pennant/Feature/FeatureHelperTest.php new file mode 100644 index 000000000..8312ee9f3 --- /dev/null +++ b/tests/Pennant/Feature/FeatureHelperTest.php @@ -0,0 +1,57 @@ +make('config')->set('pennant.default', 'array'); + } + + public function testItReturnsFeatureManager(): void + { + $this->assertNotNull(feature()); + $this->assertSame(Feature::getFacadeRoot(), feature()); + } + + public function testItReturnsTheFeatureValue(): void + { + Feature::activate('foo', 'bar'); + + $this->assertSame('bar', feature('foo')); + } + + public function testItConditionallyExecutesCodeBlocks(): void + { + Feature::activate('foo'); + $inactive = $active = null; + + feature('foo', function () use (&$active) { + $active = true; + }, function () use (&$inactive) { + $inactive = true; + }); + + $this->assertTrue($active); + $this->assertNull($inactive); + + Feature::deactivate('foo'); + $inactive = $active = null; + + feature('foo', function () use (&$active) { + $active = true; + }, function () use (&$inactive) { + $inactive = true; + }); + + $this->assertNull($active); + $this->assertTrue($inactive); + } +} diff --git a/tests/Pennant/Feature/FeatureManagerTest.php b/tests/Pennant/Feature/FeatureManagerTest.php new file mode 100644 index 000000000..6fe270c35 --- /dev/null +++ b/tests/Pennant/Feature/FeatureManagerTest.php @@ -0,0 +1,86 @@ +for('jess@hypervel.com')->activate('foo'); + + $this->assertFalse(Feature::active('foo')); + $this->assertTrue(Feature::for('tim@hypervel.com')->active('foo')); + $this->assertTrue(Feature::for('jess@hypervel.com')->active('foo')); + $this->assertFalse(Feature::for('taylor@hypervel.com')->active('foo')); + $this->assertTrue(Feature::for('jess@hypervel.com')->for('tim@hypervel.com')->active('foo')); + $this->assertFalse(Feature::for('tim@hypervel.com')->for('jess@hypervel.com')->for('taylor@hypervel.com')->active('foo')); + + Feature::for('taylor@hypervel.com')->activate('foo'); + $this->assertTrue(Feature::for('tim@hypervel.com')->for('jess@hypervel.com')->for('taylor@hypervel.com')->active('foo')); + } + + public function testTheAuthenticatedUserIsTheDefaultScope(): void + { + $user = new User(['id' => 2]); + Auth::login($user); + + Feature::activate('foo'); + + $this->assertFalse(Feature::for('misc')->active('foo')); + $this->assertTrue(Feature::active('foo')); + $this->assertTrue(Feature::for($user)->active('foo')); + } + + public function testItCanReturnLotteryAsValue(): void + { + Lottery::fix([true, true, true, true, false]); + + Feature::define('foo', Lottery::odds(1, 100)); + + Feature::load('foo'); + $this->assertTrue(Feature::active('foo')); + Feature::forget('foo'); + + Feature::load('foo'); + $this->assertTrue(Feature::active('foo')); + Feature::forget('foo'); + + Feature::load(['foo']); + $this->assertTrue(Feature::active('foo')); + Feature::forget('foo'); + + Feature::load(['foo']); + $this->assertTrue(Feature::active('foo')); + Feature::forget('foo'); + + Feature::load(['foo']); + $this->assertFalse(Feature::active('foo')); + } + + public function testItCanApplyMacros(): void + { + Feature::resolveScopeUsing(fn () => 'default-scope'); + Feature::macro('forSession', function () { + return $this->for('session|{Session::getId()'); + }); + + $this->assertFalse(Feature::forSession()->active('my-feature')); + $this->assertFalse(Feature::for('default-scope')->active('my-feature')); + + Feature::for('session|{Session::getId()')->activate('my-feature'); + + $this->assertTrue(Feature::forSession()->active('my-feature')); + $this->assertFalse(Feature::for('default-scope')->active('my-feature')); + } +} diff --git a/tests/Pennant/Feature/FeatureMiddlewareTest.php b/tests/Pennant/Feature/FeatureMiddlewareTest.php new file mode 100644 index 000000000..c0a60b7e6 --- /dev/null +++ b/tests/Pennant/Feature/FeatureMiddlewareTest.php @@ -0,0 +1,128 @@ +middleware = new EnsureFeaturesAreActive; + } + + public function testItThrowsAHttpExceptionIfFeatureIsNotDefined(): void + { + $this->expectException(HttpException::class); + $this->assertFalse(Feature::active('test')); + + $this->middleware->handle( + request: $this->createRequest('test', 'get'), + next: fn () => new Response, + features: 'test', + ); + } + + public function testItPassesIfFeatureIsDefined(): void + { + Feature::define('test', true); + + $this->assertFalse(Feature::active('foo')); + + $this->assertEquals( + Response::HTTP_OK, + $this->middleware->handle( + request: $this->createRequest('test', 'get'), + next: fn () => new Response, + features: 'test', + )->getStatusCode(), + ); + } + + public function testItThrowsAnExceptionIfOneOfTheFeaturesIsNotActive(): void + { + $this->expectException(HttpException::class); + $this->assertFalse(Feature::active('test')); + + $this->middleware->handle( + $this->createRequest('test', 'get'), + fn () => new Response, + 'test', + 'another', + ); + } + + public function testItAllowsCustomResponses(): void + { + $this->assertFalse(Feature::active('test')); + + EnsureFeaturesAreActive::whenInactive(fn (Request $request, array $features) => 'test-response'); + + $response = $this->middleware->handle( + $this->createRequest('test', 'get'), + fn () => new Response, + 'test', + 'another', + ); + + $this->assertEquals('test-response', $response); + + EnsureFeaturesAreActive::whenInactive(null); + } + + public function testItPassesIfAllFeaturesAreEnabled(): void + { + Feature::define('test', true); + Feature::define('another', true); + + $this->assertFalse(Feature::active('foo')); + + $this->assertEquals( + Response::HTTP_OK, + $this->middleware->handle( + $this->createRequest('test', 'get'), + fn () => new Response, + 'test', + 'another', + )->getStatusCode(), + ); + } + + public function testMiddlewareStringCanBeReturned(): void + { + $this->assertEquals( + 'Hypervel\Pennant\Middleware\EnsureFeaturesAreActive:test,another', + EnsureFeaturesAreActive::using('test', 'another'), + ); + } + + /** + * Create a request for the given URI and method. + */ + protected function createRequest(string $uri, string $method): Request + { + $request = SymfonyRequest::create( + uri: $uri, + method: $method, + ); + + return Request::createFromBase( + request: $request, + ); + } +} diff --git a/tests/Pennant/Feature/HasFeatureTest.php b/tests/Pennant/Feature/HasFeatureTest.php new file mode 100644 index 000000000..9fe7abab6 --- /dev/null +++ b/tests/Pennant/Feature/HasFeatureTest.php @@ -0,0 +1,36 @@ +make('config')->set('pennant.default', 'array'); + } + + public function testItCanCheckForActiveFeatures(): void + { + Feature::define('foo', 'foo-value'); + $class = new class implements FeatureScopeable { + use HasFeatures; + + public function toFeatureIdentifier(string $driver): mixed + { + return 'scope'; + } + }; + + $value = $class->features()->value('foo'); + + $this->assertSame('foo-value', $value); + } +} diff --git a/tests/Pennant/Feature/PurgeCommandTest.php b/tests/Pennant/Feature/PurgeCommandTest.php new file mode 100644 index 000000000..9338712fe --- /dev/null +++ b/tests/Pennant/Feature/PurgeCommandTest.php @@ -0,0 +1,209 @@ +active('foo'); + Feature::for('taylor')->active('bar'); + Feature::for('taylor')->active('foo'); + + $this->assertSame(3, DB::table('features')->count()); + + $this->artisan('pennant:purge foo')->expectsOutputToContain('foo successfully purged from storage.'); + + $this->assertSame(1, DB::table('features')->count()); + + $this->artisan('pennant:purge bar'); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testItCanPurgeMultipleFeatures(): void + { + Feature::define('foo', true); + Feature::define('bar', true); + Feature::define('baz', true); + + Feature::for('tim')->active('foo'); + Feature::for('tim')->active('bar'); + Feature::for('taylor')->active('bar'); + Feature::for('taylor')->active('baz'); + + $this->assertSame(4, DB::table('features')->count()); + + $this->artisan('pennant:purge foo bar')->expectsOutputToContain('foo, bar successfully purged from storage.'); + + $this->assertSame(1, DB::table('features')->count()); + + $this->artisan('pennant:purge baz'); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testItCanPurgeAllFeatureFlags(): void + { + Feature::define('foo', true); + Feature::define('bar', false); + + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + Feature::for('taylor')->active('bar'); + + $this->assertSame(3, DB::table('features')->count()); + + $this->artisan('pennant:purge')->expectsOutputToContain('All features successfully purged from storage.'); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testItCanSpecifyADriver(): void + { + config(['pennant.stores.custom' => ['driver' => 'custom']]); + + Feature::extend('custom', fn () => new class('custom', null, []) extends ArrayDriver { + public function purge(?array $features): void + { + } + }); + + Feature::store('database')->define('foo', true); + Feature::store('database')->define('bar', false); + + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + Feature::for('taylor')->active('bar'); + + $this->assertSame(3, DB::table('features')->count()); + + $this->artisan('pennant:purge --store=custom'); + + $this->assertSame(3, DB::table('features')->count()); + + $this->artisan('pennant:purge --store=database'); + + $this->assertSame(0, DB::table('features')->count()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Pennant store [foo] is not defined.'); + $this->artisan('pennant:purge --store=foo'); + } + + public function testItCanExcludeFeaturesToPurgeFromStorage(): void + { + Feature::define('foo', true); + Feature::define('bar', false); + + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + + Feature::for('taylor')->active('bar'); + + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->assertCount(3, DB::table('features')->get()->unique('name')); + + $this->artisan('pennant:purge --except=foo')->expectsOutputToContain('bar, baz successfully purged from storage.'); + + $this->assertCount(1, DB::table('features')->get()->unique('name')); + + $this->artisan('pennant:purge foo')->expectsOutputToContain('foo successfully purged from storage.'); + + $this->assertSame(0, DB::table('features')->count()); + } + + public function testItOutputsThatNoFeaturesHaveBeenPurged(): void + { + Feature::define('foo', true); + + Feature::for('tim')->active('foo'); + Feature::for('taylor')->active('foo'); + + $this->assertSame(2, DB::table('features')->where('name', 'foo')->count()); + + $this->artisan('pennant:purge --except=foo')->expectsOutputToContain('No features to purge from storage.'); + + $this->assertSame(2, DB::table('features')->where('name', 'foo')->count()); + } + + public function testItCanCombineExceptAndFeaturesAsArguments(): void + { + DB::table('features')->insert([ + 'name' => 'foo', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'bar', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->artisan('pennant:purge foo bar --except=bar')->expectsOutputToContain('foo successfully purged from storage.'); + + $this->assertSame(['bar', 'baz'], DB::table('features')->pluck('name')->all()); + } + + public function testItCanPurgeFeaturesExceptThoseRegistered(): void + { + Feature::define('foo', fn () => true); + DB::table('features')->insert([ + 'name' => 'foo', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'bar', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + DB::table('features')->insert([ + 'name' => 'baz', + 'scope' => 'Tim', + 'value' => true, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + + $this->artisan('pennant:purge --except-registered')->expectsOutputToContain('bar, baz successfully purged from storage.'); + + $this->assertSame(['foo'], DB::table('features')->pluck('name')->all()); + } +} diff --git a/tests/Pennant/IntersectionTypeTest.php b/tests/Pennant/IntersectionTypeTest.php new file mode 100644 index 000000000..eaff64f2f --- /dev/null +++ b/tests/Pennant/IntersectionTypeTest.php @@ -0,0 +1,76 @@ +make('config')->set('pennant.default', 'database'); + } + + protected function setUpInCoroutine(): void + { + DB::enableQueryLog(); + } + + public function testCanRetrieveAllFeaturesForDifferingScopeTypes(): void + { + Feature::define('user', fn (User $user) => 1); + Feature::define('nullable-user', fn (?User $user) => 2); + Feature::define('team', fn (Team $team) => 3); + Feature::define('nullable-team', fn (?Team $team) => 4); + Feature::define('mixed', fn (mixed $v) => 5); + Feature::define('none', fn ($v) => 6); + Feature::define('array', fn (array $t) => 7); + Feature::define('string', fn (string $str) => 8); + Feature::define('team-or-user', fn (Team|User $model) => 9); + Feature::define('team-or-user-intersection', fn (Team|(Authenticatable&Authorizable) $model) => 10); + + $features = Feature::for(new User(['id' => 1]))->all(); + $this->assertSame([ + 'user' => 1, + 'nullable-user' => 2, + 'mixed' => 5, + 'none' => 6, + 'team-or-user' => 9, + 'team-or-user-intersection' => 10, + ], $features); + $this->assertCount(2, DB::getQueryLog()); + $this->assertCount(6, DB::table('features')->get()); // query + + $features = Feature::for(new Team(['id' => 1]))->all(); + $this->assertSame([ + 'team' => 3, + 'nullable-team' => 4, + 'mixed' => 5, + 'none' => 6, + 'team-or-user' => 9, + 'team-or-user-intersection' => 10, + ], $features); + $this->assertCount(5, DB::getQueryLog()); + $this->assertCount(12, DB::table('features')->get()); // query + + $features = Feature::for('scope')->all(); + $this->assertSame([ + 'mixed' => 5, + 'none' => 6, + 'string' => 8, + ], $features); + $this->assertCount(8, DB::getQueryLog()); + $this->assertCount(15, DB::table('features')->get()); // query + } +} From 3a3cb52dc8590092ddd072f8e965a52d44b041c5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:23:32 +0000 Subject: [PATCH 14/39] Update the Pennant plan for the reviewed source port Record the design settled while porting the source and upstream tests: - Stores read pennant.stores when built, with forgetDrivers() and redefinition after boot or test reconfiguration, in place of upstream's per-query config reads. - The partition is resolved once per operation and passed down (newPartitionQuery(), ArrayDriver::retrieve()), and resolvePartitionUsing() throws once a store is resolved. - Only Closures and feature classes are resolvers; featureClasses replaces guessing from nameMap values (upstream fix 8). - Feature names and before-hook null acceptance are cached per feature and cleared on redefinition and container replacement. - The conflict reread returns decoded stored values. - The default TTL is documented in pennant.md rather than the config comment. - The test adaptations and the README differences. The status now points at the new tests. --- docs/plans/2026-10-21-0030-pennant-port.md | 37 ++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-10-21-0030-pennant-port.md b/docs/plans/2026-10-21-0030-pennant-port.md index 3c20d7fe9..6f3fca928 100644 --- a/docs/plans/2026-10-21-0030-pennant-port.md +++ b/docs/plans/2026-10-21-0030-pennant-port.md @@ -2,7 +2,7 @@ Port `laravel/pennant` (reference: `/home/binaryfire/workspace/references/laravel/pennant`, commit `d9ea593`) as `hypervel/pennant` (`src/pennant`, namespace `Hypervel\Pennant`). New package port: follow AGENTS.md *Porting Packages* (cp each file, alphabetical, one at a time) plus the adaptations below. -Status: implementation authorized. Steps 1–2 (AGENTS.md rule, `StackStore` batching) are reviewed and committed; `src/docs/pennant.md` is copied from Laravel, unadapted. Next step: implementation order step 3 (package skeleton). Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. +Status: implementation authorized. Steps 1–4 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`) and all upstream test ports are reviewed and committed; `src/docs/pennant.md` is copied from Laravel, unadapted and uncommitted. Next step: the new tests (step 5: `CoroutineIsolationTest`, `PartitionTest`, `TransactionTest`, `StoredValueCacheTest`, the additions to existing tests, then the integration directories and workflow). Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. ## Owner decisions (approved) @@ -32,9 +32,9 @@ Rename `__laravel_null` / `__laravel_global` to `__hypervel_null` / `__hypervel_ ## Package skeleton -- Use `src/cache` as the template for `composer.json`, `LICENSE.md`, `README.md`. Declare only real dependencies, derived from the final imports (`symfony/finder` for `discover()`; no queue or view requirement: the Blade closure's `BladeCompiler` type only loads when `blade.compiler` resolves). +- Use `src/cache` as the template for `composer.json`, `LICENSE.md`, `README.md`. Declare only real dependencies, derived from the final imports (`symfony/finder` for `discover()`; `hypervel/queue` for the events' `SerializesModels`; no view requirement: the Blade closure's `BladeCompiler` type only loads when `blade.compiler` resolves). - Package `composer.json`: psr-4 `Hypervel\Pennant\` → `src/`, `files` `src/helpers.php`, `src/functions.php`. Root `composer.json`: psr-4 `Hypervel\Pennant\` → `src/pennant/src/`, `files` `src/pennant/src/helpers.php`, `src/pennant/src/functions.php`, `replace` entry. Both: `extra.hypervel.providers` `Hypervel\Pennant\PennantServiceProvider`, `extra.hypervel.aliases` `Feature` → `Hypervel\Pennant\Feature`. Package discovery only (not `DefaultProviders`). -- README: header, `Documentation: https://hypervel.org/docs/pennant`, `Differences From Laravel` (keyless model scopes rejected), `Ported from: https://github.com/laravel/pennant`. +- README: header, `Documentation: https://hypervel.org/docs/pennant`, `Differences From Laravel` (keyless model scopes rejected; stores read `pennant.stores` config when built, see Configuration; driver/decorator constructor departures for subclasses), `Ported from: https://github.com/laravel/pennant`. - Provider: no `FeatureManager` binding (auto-singleton; upstream's closure binding is redundant); `mergeableOptions('pennant')` returns `['stores']` (named group, like `cache.stores`); `publishesMigrations()` directly; keep commands, config publishing, the `PublishingStubs` listener and the Blade directives. ## Lifetime and coroutine state @@ -75,18 +75,19 @@ public function replicate(): static; // copy $values except connections pres A memo-hit `Feature::active('x')` currently allocates ~25 Collections, scans the memo linearly three times, rebuilds `array_keys()` of every definition twice and always dispatches `FeatureRetrieved`. Target: one `PennantState` lookup and array reads. -- `PendingScopedFeatureInteraction` and `Decorator` work on plain arrays. Normalize features and scopes once per operation; serialize each effective scope once. `getAllMissing()` returns early with no driver call when every pair is memoized. +- `PendingScopedFeatureInteraction` and `Decorator` work on plain arrays. Normalize features and scopes once per operation (list-form entries share one default-scope resolution); serialize each scope once, except that `getAllMissing()` serializes a missed scope again for the memo write (accepted; a miss already costs a driver call). `getAllMissing()` returns early with no driver call when every pair is memoized. Reading one feature for one scope skips the preload, since the read loads it the same way. - Default scope resolves per operation (auth may change mid-request). -- Feature-name resolution: `isset($this->nameMap[$name])` fast path; for class strings cache the resolved name (class → `#[Name]`/`$name`/class) in `$this->classNames`; cache `before()` presence and null-acceptance per implementation class; cache resolver parameter types per feature for `definedFeaturesForScope()`. Clear these maps in `define()`. Never cache misses for non-class input. +- Feature-name resolution: `nameMap` key fast path; for class strings cache the resolved name (class → `#[Name]`/`$name`/class) in `$this->classNames`, updated when the class is defined and cleared by `setContainer()` (names come from container-built instances). Never cache misses for non-class input. +- Resolver metadata: only Closures and feature classes are resolvers; every other defined value is a constant, even when it looks like a callable or class name. `define()` records class-defined features in `$this->featureClasses` (name → class) and removes the entry for value definitions. `definedFeaturesForScope()` filters only Closures and `featureClasses` entries (reflected functions cached per feature in `$resolverFunctions`, cleared in `define()`); `implementationClass()`/`hasBeforeHook()` and `instance()` use `featureClasses` instead of guessing from `nameMap` values. No before-hook presence cache (`method_exists` on the recorded class is cheap); `get()` skips the hook path entirely when there is no hook. Whether a feature's before hook accepts a null scope is cached per feature in `$beforeHookAcceptsNull` inside the unchanged `resolveBeforeHook()` (a non-null hook result skips the driver, so the reflection would otherwise repeat every request); cleared for the feature in `define()` and entirely by `setContainer()`. - Guard every event dispatch with `hasListeners()` (`FeatureRetrieved` still fires on memo hits when listened, e.g. Sentry). All Pennant event dispatch uses the `Event` facade (`Event::hasListeners()` / `Event::dispatch()`), as the Decorator does upstream; the drivers stop capturing a dispatcher. `Event::fake()` only refreshes the Model and Cache dispatchers, so a captured dispatcher misses fakes installed after a store resolves (stores usually resolve at boot, where features are defined). - `typeAllowsScope()` matches builtin names with PHP predicates (`mixed`, `object`, `iterable`, `callable`, `array`, `string`, `int`, `float` accepting int, `bool`, `true`, `false`), resolves `self`/`parent` against the declaring class, and uses `instanceof` for classes; a `null` scope still defers to `canHandleNullScope()`. ## Partitioning -`FeatureManager::resolvePartitionUsing(string $column, Closure $resolver): void` (Boot-only, instance state; register before resolving stores, like Permission's resolver; survives `forgetDriver()`). `$resolver(string $store): int|string|null`. Column validated as a simple SQL identifier (same regex as `PermissionRegistrar::resolvePartitionUsing()`). +`FeatureManager::resolvePartitionUsing(string $column, Closure $resolver): void` (Boot-only, instance state; survives `forgetDriver()`). Throws `RuntimeException` if any store is already resolved: an earlier store holds no partition resolver and would read and write across partitions. `$resolver(string $store): int|string|null`. Column validated as a simple SQL identifier (same regex as `PermissionRegistrar::resolvePartitionUsing()`). - Store config `'partitioned' => false` opts a store out (central flags; tenancy central surfaces have no tenant). Default: partitioned when a resolver is registered. Documented, not in shipped config. -- `FeatureManager::resolve()` builds a `?Closure(): FeaturePartition` per store (protected `partitionResolver($name, $config)`, mirroring upstream's `defaultScopeResolver($driver)`; null when unpartitioned) and passes it to the Decorator and the built-in drivers. Each layer calls it once per operation and captures the result. `FeaturePartition` (`src/FeaturePartition.php`, readonly `column`, `value`). Its `key()` length-prefixes the column and `(string) $value`, so `1` and `'1'` (the same SQL partition) share memo and cache identity, as `PermissionPartition::encodeCacheSegment()` does; unpartitioned stores use `''`. A `null`/`''` value throws `Exceptions\FeaturePartitionNotResolved` (fail closed). +- `FeatureManager::resolve()` builds a `?Closure(): FeaturePartition` per store (protected `partitionResolver($name, $config)`, mirroring upstream's `defaultScopeResolver($driver)`; null when unpartitioned) and passes it to the Decorator and the built-in drivers. Each layer calls it once per operation and passes the captured result down: `DatabaseDriver` keeps upstream's no-argument `newQuery()` as the base table query and adds `newPartitionQuery(?FeaturePartition)`; its read, insert, write and invalidation helpers take the partition. `ArrayDriver::get()`/`getAll()` share a protected `retrieve($state, $partition, $feature, $scope)` that writes through the captured state. `FeaturePartition` (`src/FeaturePartition.php`, readonly `column`, `value`). Its `key()` length-prefixes the column and `(string) $value`, so `1` and `'1'` (the same SQL partition) share memo and cache identity, as `PermissionPartition::encodeCacheSegment()` does; unpartitioned stores use `''`. A `null`/`''` value throws `Exceptions\FeaturePartitionNotResolved` (fail closed). - The Decorator memo always includes the partition key. Custom drivers scope their own storage (documented). - Applies to every driver operation: reads, first-resolution inserts, upserts, deletes, `stored()`, `purge()`, `setForAllScopes()`, `pennant:purge`, array values, memo, shared-cache identities. `globally()` stays a scope inside the partition. Cross-partition maintenance uses the integrating package (e.g. tenancy `runForEach`), not a bypass API. - Partitioned schema is app/package-owned (like Permission): partition column non-null, unique `(partition, name, scope)`. @@ -105,9 +106,10 @@ A memo-hit `Feature::active('x')` currently allocates ~25 Collections, scans the ], ``` -- The section comment explains `cache` (`store`, optional `ttl` seconds, default 3600). +- The section comment explains `cache` (`store`, optional `ttl` seconds). The default TTL is stated in `pennant.md`, not the config comment, so there's no comment-vs-constant test. - `FeatureManager::getDefaultDriver()` reads `config->string('pennant.default')` (no `?? 'database'`). Config via named methods (no `ArrayAccess`). -- Database factory normalizes once: `connection` (`null` or `''` → `null`, the runtime default, as `DatabaseManager::connection()` treats them), `table` (optional, default `'features'`), `cache` (`store` required when present; `ttl` defaults to `StoredValueCache::DEFAULT_TTL = 3600` and must be a positive integer, since the repository treats `null` as forever and non-positive values as deletes, and the finite TTL bounds staleness; a test asserts the documented default matches). +- Database factory normalizes once: `connection` (`null` or `''` → `null`, the runtime default, as `DatabaseManager::connection()` treats them), `table` (optional, default `'features'`), `cache` (`store` required when present; `ttl` defaults to `StoredValueCache::DEFAULT_TTL = 3600` and must be a positive integer, since the repository treats `null` as forever and non-positive values as deletes, and the finite TTL bounds staleness). +- Stores read their config once, when built (upstream laravel/pennant#81 rereads the database store's `connection`/`table` on every query so a store kept across Octane requests follows config edits). Config is process-global in Hypervel, so request-time edits aren't supported: per-request connections use a `connection => null` store with `DB::usingConnection()`, shared tables use partitioning. After boot/test reconfiguration of store definitions, call `forgetDrivers()` (not just the changed store: other resolved stores captured its cache profile as an invalidation target), then define features again (rebuilt stores start with no definitions). No config watchers or rebuild machinery. - `connectionName()` rejects a `::read` connection, whether configured or the runtime default (`DB::usingConnection('mysql::read', ...)`), before any memo, cache or SQL access: Pennant writes on first resolution, and with read config `DatabasePool::connectionRole()` sends every operation on a read alias to the replica. - Invalidation targets (config-only, at factory): the distinct `cache` profiles of every configured `database` store on the same table whose connection overlaps this one (`null` overlaps every connection, since `DB::usingConnection()` can select any; explicit names compare after `withoutWriteType()`), including its own. Mutations rotate tokens in every target, so writes through an uncached or differently cached alias still invalidate cached readers. Rejecting overlapping stores with different cache config instead would always throw, because `stores` merges by name and the default uncached `database` store stays configured. Docs: physical aliases under different connection names must use one canonical name. @@ -119,7 +121,7 @@ Constructor and protected departures (everything else keeps upstream signatures) | Class | Departure | |---|---| -| `Decorator` | Constructor drops `$cache`, adds `?Closure $partitionResolver` (`$name, $driver, $defaultScopeResolver, $partitionResolver, $container`); `$cache` removed; `isCached()` / `putInCache()` / `removeFromCache()` take the operation's state and partition key; `normalizeFeaturesToLoad()` returns arrays. | +| `Decorator` | Constructor drops `$cache`, adds `?Closure $partitionResolver` (`$name, $driver, $defaultScopeResolver, $partitionResolver, $container`); `$cache` removed; `isCached()` / `putInCache()` / `removeFromCache()` take `(state, connection, partition key, feature, serialized scope)`; `normalizeFeaturesToLoad()` returns arrays; `implementationClass()` reads `featureClasses`. | | `ArrayDriver` | Constructor `(string $name, ?Closure $partitionResolver, array $featureStateResolvers)`; `$events` removed; `$resolvedFeatureStates` moves to `PennantState`; implements `CanSetManyFeaturesForScopes` (upstream has `setAll()` without the interface, so the Decorator falls back to per-entry `set()`), reading its state and partition once. | | `DatabaseDriver` | Constructor above; `$config`, `$events`, `$name`, `$retryDepth` removed; `retrieve()`, `insert()` and the unused `update()` removed; `insertMany()` becomes the conflict-safe insert returning stored values. | | `FeatureManager` | `createArrayDriver(array $config, string $name)` (upstream takes none; `resolve()` already passes both). | @@ -166,7 +168,7 @@ For missing pairs: resolve (unknown features → `false`, not stored, `UnknownFe - PostgreSQL, SQLite: `insertOrIgnore($rows)`. PostgreSQL compiles `ON CONFLICT DO NOTHING` (only unique/exclusion conflicts are skipped). SQLite's `INSERT OR IGNORE` also skips NOT NULL/CHECK violations, but Pennant supplies every non-null column and the schema has no CHECK constraint; a row skipped by an app-altered schema still surfaces through the reread, retry and exception below. The SQLite no-op upsert is not used because SQLite counts its duplicate rows as changes (verified on 3.45), which would force a reread on every first resolution. - MySQL, MariaDB: `upsert($rows, $uniqueBy, ['name' => new Expression($query->getGrammar()->wrap("{$table}.name"))])`: a table-qualified no-op (`wrap()` applies the table prefix; the unqualified form is ambiguous on PostgreSQL, 42702). `INSERT IGNORE` would silently truncate over-long values in strict mode. -- If affected rows equal the candidate count, every candidate won: return them (the uncontended path keeps upstream's one select plus one insert). Otherwise, or on MySQL/MariaDB when FOUND_ROWS may be on (no-op duplicates then count as affected), reread. FOUND_ROWS is known off only when no `db.connector.{driver}` binding replaces the built-in connector (built-ins are created fresh with fixed defaults) and the connection's `options` don't set `Pdo\Mysql::ATTR_FOUND_ROWS`; a bound custom connector always rereads. The reread fetches the candidate batch through the grouped read path from the write PDO and returns stored values. Only MySQL/MariaDB add `sharedLock()`: a REPEATABLE READ snapshot can't see the winner without a locking read, while PostgreSQL READ COMMITTED rereads see it and stricter levels fail on the insert. +- If affected rows equal the candidate count, every candidate won: return them (the uncontended path keeps upstream's one select plus one insert). Otherwise, or on MySQL/MariaDB when FOUND_ROWS may be on (no-op duplicates then count as affected), reread. FOUND_ROWS is known off only when no `db.connector.{driver}` binding replaces the built-in connector (built-ins are created fresh with fixed defaults) and the connection's `options` don't set `Pdo\Mysql::ATTR_FOUND_ROWS`; a bound custom connector always rereads. The reread fetches the candidate batch through the grouped read path from the write PDO and returns the decoded stored values (a winner whose JSON matches the candidate's doesn't prove the candidate won, and its PHP form can differ). Only MySQL/MariaDB add `sharedLock()`: a REPEATABLE READ snapshot can't see the winner without a locking read, while PostgreSQL READ COMMITTED rereads see it and stricter levels fail on the insert. - Pairs still missing after the reread (concurrent purge/delete) are re-resolved and retried, at most 2 times per operation (local loop variable), then `RuntimeException('Unable to insert feature values into the database.')`. Never return a candidate not known to have won. - PostgreSQL REPEATABLE READ/SERIALIZABLE serialization failures propagate (the caller's transaction must restart). @@ -211,6 +213,7 @@ Docs: TTL bounds staleness after a crash or failed write between commit and rota 5. Keyless models: `serializeScope()` throws `RuntimeException` for a `Model` with a null key (after `FeatureScopeSerializeable`). Assign a key or pass an explicit string/serializable scope. 6. Exception-driven insert retry (shared `$retryDepth`, PostgreSQL transaction abort on unique violation) → First resolution. 7. Rolled-back values remaining in the memo → transaction tracking. +8. Constant values that look like callables or class names (`'next'`, `[$object, 'method']`, `SomeClass::class`) are treated as resolvers by `definedFeaturesForScope()` (dropped from `all()`/`loadAll()` when the signature rejects the scope), `hasBeforeHook()` and `instance()` → resolver metadata under Hot path. Unexpected-null fallback stays persisted (Pennant persists first outcomes); docs say to `forget()`/`purge()` after changing a definition. @@ -239,7 +242,9 @@ Adaptations: - `__hypervel_global`; setUp config changes (`pennant.default`) move to `defineEnvironment()`. - Every incidental keyless `new User` / `new Team` scope gets an explicit id, assertions unchanged: `ArrayDriverTest` (686, 919–1005, 1238), `DatabaseDriverTest` (826, 1771, 1782, 1821), `IntersectionTypeTest` (42, 54). A separate intentional-difference test asserts that a keyless model scope throws. - The migration-connection test instantiates `new class extends PennantMigration {}` instead of copying the implementation into a plain `Migration`. -- The unknown-feature event test also fakes `UnknownFeatureResolved` after the store has resolved. +- The unknown-feature event tests (`ArrayDriverTest`, `DatabaseDriverTest`) also fake `UnknownFeatureResolved` after the store has resolved. +- `test_it_respects_updated_connection_configuration` replaces its second `flushCache()` with `forgetDriver('database')` (stores read config when built). +- `@laravel.com` test emails become `@hypervel.com` (framework-name rule). - The four `UniqueConstraintViolationException` listener tests simulate conflicts the new insert no longer raises. Rewrite them as: - concurrent-winner tests, batch and `get()`: a listener on the initial select inserts the winning row; assert the winner's value is returned and one insert ran; - vanished-winner tests, batch and `get()`: a competitor row forces the insert to be ignored and is deleted before the reread; assert three insert attempts, then `'Unable to insert feature values into the database.'` with no previous exception. @@ -249,15 +254,15 @@ New tests (only behavior that can regress): - `tests/Pennant/CoroutineIsolationTest.php`: concurrent memo/array isolation; copied context gets independent values; values (including `before()` results) from an open transaction are not copied, both when the parent forks and when the child copies from the parent with `copyFrom()`; partition switch inside one coroutine uses separate buckets; a null-connection store under `DB::usingConnection()` keeps separate memo per connection. - `tests/Pennant/PartitionTest.php`: every operation partitioned (including grouped OR reads), null partition throws, `partitioned => false`, `globally()` per partition, int `1` and string `'1'` share memo and cache identity. - `tests/Pennant/TransactionTest.php`: rollback (and savepoint rollback) flushes the memo, including `before()` results and base plus `::write` aliases sharing one in-memory SQLite transaction; commit keeps it; `flushCache()` keeps registrations; multi-chunk `setAll()` rolls back together; shared cache bypassed inside a transaction; rotation waits for the outer commit and is dropped on rollback. -- `tests/Pennant/StoredValueCacheTest.php` (array cache): capture → rotate → publish is ignored; scope vs partition rotation; a write through an uncached alias rotates the cached alias's tokens; unknown and `before()` values never published; rich values (array, object, `JsonSerializable`, float, `false`, `null`) read back from cache exactly as from the database (data provider); warm memo hits touch neither database nor cache, and warm cache hits run no query and hold no connection; token-write `false` skips publishing; after a committed write through an uncached alias whose rotation fails, the writer's next read reaches the updated database row (no stale local memo); a failing target doesn't stop the other targets and its failure is rethrown; non-positive `ttl` is rejected; documented TTL matches `DEFAULT_TTL`. -- Additions to `Feature/DatabaseDriverTest`: rectangular vs sparse batching query counts, binding/group splitting, read dedupe, duplicate `setAll()` pairs (last value wins), `'01'`/`'1'`, `::read` rejection (configured and via `DB::usingConnection()`), `loadAll()` mixed scopes; `Feature/ArrayDriverTest`: stored `null`, and (data provider over `forgetDriver()` / `forgetDrivers()`) a rebuilt store resolves a redefined value instead of the forgotten store's; `Feature/FeatureManagerTest`: one `isResolverValidForScope()` data provider for the builtin, `self`/`parent` and int→float cases. +- `tests/Pennant/StoredValueCacheTest.php` (array cache): capture → rotate → publish is ignored; scope vs partition rotation; a write through an uncached alias rotates the cached alias's tokens; unknown and `before()` values never published; rich values (array, object, `JsonSerializable`, float, `false`, `null`) read back from cache exactly as from the database (data provider); warm memo hits touch neither database nor cache, and warm cache hits run no query and hold no connection; token-write `false` skips publishing; after a committed write through an uncached alias whose rotation fails, the writer's next read reaches the updated database row (no stale local memo); a failing target doesn't stop the other targets and its failure is rethrown; non-positive `ttl` is rejected. +- Additions to `Feature/DatabaseDriverTest`: rectangular vs sparse batching query counts, binding/group splitting, read dedupe, duplicate `setAll()` pairs (last value wins), `'01'`/`'1'`, `::read` rejection (configured and via `DB::usingConnection()`), `loadAll()` mixed scopes; `Feature/ArrayDriverTest`: (data provider over `forgetDriver()` / `forgetDrivers()`) a rebuilt store resolves a redefined value instead of the forgotten store's; `Feature/FeatureManagerTest`: one `isResolverValidForScope()` data provider for the builtin, `self`/`parent` and int→float cases. - `tests/Integration/Pennant/Database/{Postgres,MySql,MariaDb}/`: exact identity (case, accents, composed/decomposed, trailing spaces, 255 four-byte characters) on MySQL/MariaDB; conflict paths per engine (MySQL with and without `use_upsert_alias`, FOUND_ROWS on via options and via a bound connector, REPEATABLE READ locking reread); duplicate `setAll()` pairs on PostgreSQL; PostgreSQL serialization failure propagates. - `tests/Integration/Pennant/Redis/StoredValueCacheTest.php` (`InteractsWithRedis`): public-API publish, mutation invalidation and bulk rotation seen from a fresh memo. Add `tests/Integration/Pennant/Redis` to all three jobs in `.github/workflows/redis.yml` (Redis, Redis Cluster, Valkey). Database directories are discovered by convention. ## Documentation -- `src/docs/pennant.md` (copied from `references/laravel/docs/pennant.md`; edit it alongside each source change rather than at the end): keep Laravel-docs prose; adapt *In-Memory Cache* (per request/coroutine, rolled back with transactions), add *Partitioning* (including `pennant:purge` running per partition) and *Shared Cache* sections; note stateless feature classes, resetting stored values after changing definitions, keyless model scopes, canonical connection names, and that custom drivers keep coroutine-safe state and scope their own storage. *Intercepting Feature Checks* notes that a `before()` hook can delegate to an entitlement or billing service: its result is never stored, using it is optional, and Pennant adds no entitlement system. Add to the Packages list in `src/docs/documentation.md` between Passport and Precognition. -- `src/docs/porting-from-laravel.md`: keyless model scopes now throw; use Hypervel's Pennant migration (byte-exact identifiers on MySQL/MariaDB). +- `src/docs/pennant.md` (copied from `references/laravel/docs/pennant.md`; edit it alongside each source change rather than at the end): keep Laravel-docs prose; adapt *In-Memory Cache* (per request/coroutine, rolled back with transactions), add *Partitioning* (including `pennant:purge` running per partition) and *Shared Cache* sections; note stateless feature classes, resetting stored values after changing definitions, keyless model scopes, canonical connection names, store config read when built (configure stores before defining features; after reconfiguring, `forgetDrivers()` and define features again; per-request connections and tenants use `DB::usingConnection()` and partitioning), and that custom drivers keep coroutine-safe state and scope their own storage. *Intercepting Feature Checks* notes that a `before()` hook can delegate to an entitlement or billing service: its result is never stored, using it is optional, and Pennant adds no entitlement system. Add to the Packages list in `src/docs/documentation.md` between Passport and Precognition. +- `src/docs/porting-from-laravel.md`: keyless model scopes now throw; use Hypervel's Pennant migration (byte-exact identifiers on MySQL/MariaDB); one sentence that changing `pennant.stores` config at runtime has no effect on resolved stores, linking to the canonical configuration guidance. - Regenerate the `Feature` facade docblock: `composer facade -- 'Hypervel\Pennant\Feature'`. ## Integrating packages (requirements only; not in this change) From 43f37692833770f3eb859cc16e15a8e667003b56 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:04:16 +0000 Subject: [PATCH 15/39] Reject non-integer Pennant cache TTLs cacheProfile() only rejected TTLs below one, so a float such as 1.5 or a numeric string such as '60' passed. The cache repository's put() and putMany() take int|null and StoredValueCache calls them under strict types, so these values raised a TypeError later. For a store's own cache, the TypeError came from the StoredValueCache constructor when the store was built. A profile that another store only uses as an invalidation target reached a typed parameter only during rotation, which runs after the write commits: every mutation through that other store committed and then threw. Every profile, including invalidation-only targets, is now checked to be an integer of at least one while the store is built, before anything can be written. The message now says "positive integer". --- src/pennant/src/FeatureManager.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pennant/src/FeatureManager.php b/src/pennant/src/FeatureManager.php index c16da450b..e9a0aefe3 100644 --- a/src/pennant/src/FeatureManager.php +++ b/src/pennant/src/FeatureManager.php @@ -231,15 +231,15 @@ protected function storedValueCache(array $config, string $name): ?StoredValueCa * @param array $cache * @return array{string, int} * - * @throws InvalidArgumentException when the TTL is not positive + * @throws InvalidArgumentException when the TTL is not a positive integer */ protected function cacheProfile(string $name, array $cache): array { $ttl = $cache['ttl'] ?? StoredValueCache::DEFAULT_TTL; // The cache treats a non-positive TTL as a delete, and values must not be cached forever. - if ($ttl < 1) { - throw new InvalidArgumentException("The cache TTL of Pennant store [{$name}] must be a positive number of seconds."); + if (! is_int($ttl) || $ttl < 1) { + throw new InvalidArgumentException("The cache TTL of Pennant store [{$name}] must be a positive integer."); } return [$cache['store'], $ttl]; From 10fd73902c8f8542d55ffbc91e208f8f06ee25b4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:04:22 +0000 Subject: [PATCH 16/39] Add Pennant coroutine isolation and transaction tests CoroutineIsolationTest covers the per-coroutine PennantState: - Concurrent coroutines don't see the parent's array values, resolve and activate independently, and leave the parent's value unchanged. - A copied context starts from the parent's values, and the child's writes don't reach the parent. - Forking with a copied context, or calling copyFrom(), inside an open transaction doesn't copy values memoized in that transaction, both for driver values and for before() results. - Switching partitions inside one coroutine keeps separate values. - A null-connection database store keeps separate values for each connection selected with DB::usingConnection(). TransactionTest covers the memo's transaction tracking and the shared cache's commit handling: - Rolling back forgets values memoized inside the transaction, inside a rolled-back savepoint, and inside savepoints committed under an outer transaction that rolls back. - before() results are forgotten on rollback too. - Values memoized through base and ::write stores that share one in-memory SQLite transaction are all forgotten. - Committing keeps memoized values without another query. - Flushing one store keeps the rollback tracking of other stores. - A multi-chunk setAll() rolls back together when a later chunk fails. - The shared cache is bypassed inside a transaction, invalidated only after the outermost commit, and left valid when the transaction rolls back. --- tests/Pennant/CoroutineIsolationTest.php | 202 +++++++++++++++++++++ tests/Pennant/TransactionTest.php | 213 +++++++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 tests/Pennant/CoroutineIsolationTest.php create mode 100644 tests/Pennant/TransactionTest.php diff --git a/tests/Pennant/CoroutineIsolationTest.php b/tests/Pennant/CoroutineIsolationTest.php new file mode 100644 index 000000000..f8833ef36 --- /dev/null +++ b/tests/Pennant/CoroutineIsolationTest.php @@ -0,0 +1,202 @@ +make('config'); + + $config->set('pennant.default', 'array'); + $config->set('database.connections.other', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + } + + public function testValuesAreIsolatedBetweenConcurrentCoroutines(): void + { + Feature::define('resolved', fn () => CoroutineContext::get('__pennant_test.value')); + Feature::define('activated', fn () => 'default'); + Feature::activate('activated', 'parent'); + + $results = parallel([ + function () { + CoroutineContext::set('__pennant_test.value', 'first'); + $inherited = Feature::value('activated'); + Feature::value('resolved'); + Feature::activate('activated', 'first'); + usleep(5000); + + return [$inherited, Feature::value('resolved'), Feature::value('activated')]; + }, + function () { + CoroutineContext::set('__pennant_test.value', 'second'); + $inherited = Feature::value('activated'); + Feature::value('resolved'); + Feature::activate('activated', 'second'); + usleep(5000); + + return [$inherited, Feature::value('resolved'), Feature::value('activated')]; + }, + ]); + + $this->assertSame(['default', 'first', 'first'], $results[0]); + $this->assertSame(['default', 'second', 'second'], $results[1]); + $this->assertSame('parent', Feature::value('activated')); + } + + public function testCopiedContextGetsIndependentValues(): void + { + Feature::define('foo', fn () => 'default'); + Feature::activate('foo', 'parent'); + + [$child] = parallel([ + function () { + $inherited = Feature::value('foo'); + Feature::activate('foo', 'child'); + + return [$inherited, Feature::value('foo')]; + }, + ], copyContext: true); + + $this->assertSame(['parent', 'child'], $child); + $this->assertSame('parent', Feature::value('foo')); + } + + #[DataProvider('transactionFeatures')] + public function testForkedCoroutinesDontCopyValuesFromAnOpenTransaction(string $feature): void + { + $this->defineTransactionFeatures(); + + $copied = DB::transaction(function () use ($feature) { + $this->resolveTransactionFeatures($feature); + + return parallel([fn () => PennantState::current()->values], copyContext: true)[0]; + }); + + $this->assertSame($this->valuesOutsideTransaction(), $copied); + } + + public static function transactionFeatures(): array + { + return [ + 'driver value' => ['database-feature'], + 'before hook value' => [FeatureWithBeforeHookInTransaction::class], + ]; + } + + public function testCopyingFromAParentDoesntCopyValuesFromItsOpenTransaction(): void + { + $this->defineTransactionFeatures(); + + $copied = DB::transaction(function () { + $this->resolveTransactionFeatures('database-feature'); + $parent = Coroutine::id(); + + return parallel([function () use ($parent) { + CoroutineContext::copyFrom($parent, [PennantState::CONTEXT_KEY]); + + return PennantState::current()->values; + }])[0]; + }); + + $this->assertSame($this->valuesOutsideTransaction(), $copied); + } + + public function testSwitchingPartitionsUsesSeparateValues(): void + { + $partition = 1; + Feature::resolvePartitionUsing('team_id', function () use (&$partition) { + return $partition; + }); + Feature::define('resolved', function () use (&$partition) { + return "team-{$partition}"; + }); + Feature::define('activated', fn () => 'default'); + + $this->assertSame('team-1', Feature::value('resolved')); + Feature::activate('activated', 'team-1'); + + $partition = 2; + + $this->assertSame('team-2', Feature::value('resolved')); + $this->assertSame('default', Feature::value('activated')); + + $partition = 1; + + $this->assertSame('team-1', Feature::value('resolved')); + $this->assertSame('team-1', Feature::value('activated')); + } + + public function testNullConnectionStoreKeepsSeparateValuesPerConnection(): void + { + DB::usingConnection('other', function () { + (require __DIR__ . '/../../src/pennant/database/migrations/2022_11_01_000001_create_features_table.php')->up(); + }); + Feature::store('database')->define('foo', fn () => 'default'); + + DB::usingConnection('other', fn () => Feature::store('database')->activate('foo', 'other')); + + $this->assertSame('default', Feature::store('database')->value('foo')); + $this->assertSame('other', DB::usingConnection('other', fn () => Feature::store('database')->value('foo'))); + } + + /** + * Define features on the array and database stores. + */ + protected function defineTransactionFeatures(): void + { + Feature::define('array-feature', fn () => 'array-value'); + Feature::store('database')->define('database-feature', fn () => 'database-value'); + Feature::store('database')->define(FeatureWithBeforeHookInTransaction::class); + } + + /** + * Resolve the array feature and the given database store feature. + */ + protected function resolveTransactionFeatures(string $feature): void + { + Feature::value('array-feature'); + Feature::store('database')->value($feature); + + $this->assertArrayHasKey(DB::getDefaultConnection(), PennantState::current()->values); + } + + /** + * Get the memoized values that don't belong to the open transaction. + */ + protected function valuesOutsideTransaction(): array + { + return ['' => ['array' => ['' => ['array-feature' => [Feature::serializeScope(null) => 'array-value']]]]]; + } +} + +class FeatureWithBeforeHookInTransaction +{ + public function resolve(): string + { + return 'resolved'; + } + + public function before(): string + { + return 'before'; + } +} diff --git a/tests/Pennant/TransactionTest.php b/tests/Pennant/TransactionTest.php new file mode 100644 index 000000000..651f1f63d --- /dev/null +++ b/tests/Pennant/TransactionTest.php @@ -0,0 +1,213 @@ +make('config'); + + $config->set('pennant.default', 'database'); + $config->set('pennant.stores.secondary', [ + 'driver' => 'database', + ]); + $config->set('pennant.stores.writer', [ + 'driver' => 'database', + 'connection' => 'testing::write', + ]); + $config->set('pennant.stores.cached', [ + 'driver' => 'database', + 'cache' => ['store' => 'array'], + ]); + } + + protected function setUpInCoroutine(): void + { + Feature::define('foo', fn () => 'resolved'); + } + + public function testRollingBackForgetsValuesMemoizedInsideTheTransaction(): void + { + DB::beginTransaction(); + Feature::for('taylor')->activate('foo', 'inside'); + $this->assertSame('inside', Feature::for('taylor')->value('foo')); + DB::rollBack(); + + $this->assertSame('resolved', Feature::for('taylor')->value('foo')); + } + + public function testRollingBackASavepointForgetsValuesMemoizedInsideIt(): void + { + DB::transaction(function () { + DB::beginTransaction(); + Feature::for('taylor')->activate('foo', 'inside'); + DB::rollBack(); + + $this->assertSame('resolved', Feature::for('taylor')->value('foo')); + }); + } + + public function testRollingBackForgetsValuesMemoizedInsideCommittedSavepoints(): void + { + DB::beginTransaction(); + DB::transaction(fn () => Feature::for('taylor')->activate('foo', 'inside')); + DB::rollBack(); + + $this->assertSame('resolved', Feature::for('taylor')->value('foo')); + } + + public function testRollingBackForgetsBeforeHookValues(): void + { + $this->app->instance(TransactionFeatureWithBeforeHook::class, $feature = new TransactionFeatureWithBeforeHook); + Feature::define(TransactionFeatureWithBeforeHook::class); + $feature->value = 'inside'; + + DB::beginTransaction(); + Feature::for('taylor')->value(TransactionFeatureWithBeforeHook::class); + DB::rollBack(); + + $feature->value = 'after'; + + $this->assertSame('after', Feature::for('taylor')->value(TransactionFeatureWithBeforeHook::class)); + } + + public function testRollingBackForgetsValuesOfEveryConnectionSharingTheTransaction(): void + { + Feature::store('writer')->define('foo', fn () => 'resolved'); + + DB::beginTransaction(); + Feature::for('taylor')->activate('foo', 'inside'); + Feature::store('writer')->for('otwell')->activate('foo', 'inside'); + DB::rollBack(); + + $this->assertSame('resolved', Feature::for('taylor')->value('foo')); + $this->assertSame('resolved', Feature::store('writer')->for('otwell')->value('foo')); + } + + public function testCommittingKeepsValuesMemoizedInsideTheTransaction(): void + { + DB::transaction(fn () => Feature::for('taylor')->activate('foo', 'inside')); + $queries = $this->countQueries(); + + $this->assertSame('inside', Feature::for('taylor')->value('foo')); + $this->assertSame(0, $queries->count); + } + + public function testFlushingAStoreKeepsOtherStoresValuesTrackedForRollback(): void + { + Feature::store('secondary')->define('foo', fn () => 'resolved'); + + DB::beginTransaction(); + Feature::store('secondary')->for('taylor')->activate('foo', 'inside'); + Feature::store('database')->flushCache(); + DB::rollBack(); + + $this->assertSame('resolved', Feature::store('secondary')->for('taylor')->value('foo')); + } + + public function testSettingManyValuesAcrossChunksRollsBackTogether(): void + { + $exception = new RuntimeException('The second chunk failed.'); + $inserts = 0; + DB::listen(function (QueryExecuted $query) use (&$inserts, $exception) { + if (str_starts_with($query->sql, 'insert') && ++$inserts === 2) { + throw $exception; + } + }); + + try { + Feature::for(range(1, DatabaseDriver::WRITE_ROW_LIMIT + 1))->activate('foo'); + $this->fail('The second chunk should have failed.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame(2, $inserts); + $this->assertSame(0, DB::table('features')->count()); + } + + public function testSharedCacheIsBypassedInsideATransaction(): void + { + Feature::store('cached')->define('foo', fn () => 'resolved'); + Feature::store('cached')->for('taylor')->value('foo'); + DB::table('features')->update(['value' => json_encode('changed')]); + Feature::store('cached')->flushCache(); + + DB::transaction(function () { + $this->assertSame('changed', Feature::store('cached')->for('taylor')->value('foo')); + }); + + Feature::store('cached')->flushCache(); + + $this->assertSame('resolved', Feature::store('cached')->for('taylor')->value('foo')); + } + + public function testSharedCacheIsInvalidatedAfterTheOutermostCommit(): void + { + Feature::store('cached')->define('foo', fn () => 'resolved'); + Feature::store('cached')->for('taylor')->value('foo'); + + DB::transaction(function () { + DB::transaction(fn () => Feature::store('cached')->for('taylor')->activate('foo', 'inside')); + }); + Feature::store('cached')->flushCache(); + + $this->assertSame('inside', Feature::store('cached')->for('taylor')->value('foo')); + } + + public function testSharedCacheIsNotInvalidatedWhenTheTransactionRollsBack(): void + { + Feature::store('cached')->define('foo', fn () => 'resolved'); + Feature::store('cached')->for('taylor')->value('foo'); + + DB::beginTransaction(); + DB::transaction(fn () => Feature::store('cached')->for('taylor')->activate('foo', 'inside')); + DB::rollBack(); + $queries = $this->countQueries(); + + $this->assertSame('resolved', Feature::store('cached')->for('taylor')->value('foo')); + $this->assertSame(0, $queries->count); + } + + /** + * Count the queries executed from now on. + */ + protected function countQueries(): object + { + $queries = (object) ['count' => 0]; + + DB::listen(function () use ($queries) { + ++$queries->count; + }); + + return $queries; + } +} + +class TransactionFeatureWithBeforeHook +{ + public ?string $value = null; + + public function resolve(): string + { + return 'resolved'; + } + + public function before(): ?string + { + return $this->value; + } +} From ca3f61d5f11c5f3c831638fd539ed6742a330ddb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:04:28 +0000 Subject: [PATCH 17/39] Add Pennant partition tests The tests use a team_features table partitioned by team_id, a resolver that returns the current team, a cached store on the same table and an unpartitioned store on the features table. They check that: - Reads, grouped reads and first resolutions stay in the current partition, and only partitioned stores call the resolver. - set, set many, set for all scopes, delete, purge of named features, purge of everything and pennant:purge only change the current partition's rows. - stored() lists only the current partition's features. - A store with partitioned => false never calls the resolver. - A null or empty-string partition throws FeaturePartitionNotResolved. - Integer 1 and string '1' share memo and shared cache identity. - Different partitions don't share cached values or invalidation: after one team changes through the uncached store, the other team's value is still a cache hit. - Registering the resolver after a store has resolved throws. - Qualified names and expressions are rejected as partition columns. --- tests/Pennant/PartitionTest.php | 282 ++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 tests/Pennant/PartitionTest.php diff --git a/tests/Pennant/PartitionTest.php b/tests/Pennant/PartitionTest.php new file mode 100644 index 000000000..7f975b501 --- /dev/null +++ b/tests/Pennant/PartitionTest.php @@ -0,0 +1,282 @@ + + */ + protected array $resolvedStores = []; + + protected function defineEnvironment(Application $app): void + { + $config = $app->make('config'); + + $config->set('pennant.default', 'database'); + $config->set('pennant.stores.database.table', 'team_features'); + $config->set('pennant.stores.cached', [ + 'driver' => 'database', + 'table' => 'team_features', + 'cache' => ['store' => 'array'], + ]); + $config->set('pennant.stores.central', [ + 'driver' => 'database', + 'table' => 'features', + 'partitioned' => false, + ]); + } + + protected function setUpInCoroutine(): void + { + Schema::create('team_features', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('team_id'); + $table->string('name'); + $table->string('scope'); + $table->text('value'); + $table->timestamps(); + + $table->unique(['team_id', 'name', 'scope']); + }); + + Feature::resolvePartitionUsing('team_id', function (string $store) { + $this->resolvedStores[] = $store; + + return $this->team; + }); + + Feature::define('foo', fn () => "team-{$this->team}"); + Feature::define('bar', fn () => "team-{$this->team}"); + } + + public function testReadsAndFirstResolutionStayInThePartition(): void + { + $this->assertSame('team-1', Feature::for('taylor')->value('foo')); + + $this->team = 2; + + $this->assertSame('team-2', Feature::for('taylor')->value('foo')); + + Feature::flushCache(); + $this->team = 1; + + $this->assertSame('team-1', Feature::for('taylor')->value('foo')); + $this->assertSame([ + [1, 'foo', 'taylor', '"team-1"'], + [2, 'foo', 'taylor', '"team-2"'], + ], $this->rows()); + $this->assertSame(['database'], array_values(array_unique($this->resolvedStores))); + } + + public function testGroupedReadsStayInThePartition(): void + { + Feature::getAll(['foo' => ['taylor'], 'bar' => ['otwell']]); + + $this->team = 2; + + $this->assertSame( + ['foo' => ['team-2'], 'bar' => ['team-2']], + Feature::getAll(['foo' => ['taylor'], 'bar' => ['otwell']]), + ); + } + + #[DataProvider('partitionedWrites')] + public function testWritesStayInThePartition(Closure $write, array $expected): void + { + Feature::for(['taylor', 'otwell'])->load(['foo', 'bar']); + $this->team = 2; + Feature::for(['taylor', 'otwell'])->load(['foo', 'bar']); + + $write(); + + $this->assertSame([ + [1, 'bar', 'otwell', '"team-1"'], + [1, 'bar', 'taylor', '"team-1"'], + [1, 'foo', 'otwell', '"team-1"'], + [1, 'foo', 'taylor', '"team-1"'], + ...$expected, + ], $this->rows()); + } + + public static function partitionedWrites(): array + { + $bar = [ + [2, 'bar', 'otwell', '"team-2"'], + [2, 'bar', 'taylor', '"team-2"'], + ]; + + return [ + 'set' => [fn () => Feature::store('database')->set('foo', 'taylor', 'changed'), [ + ...$bar, + [2, 'foo', 'otwell', '"team-2"'], + [2, 'foo', 'taylor', '"changed"'], + ]], + 'set many' => [fn () => Feature::for(['taylor', 'otwell'])->activate('foo', 'changed'), [ + ...$bar, + [2, 'foo', 'otwell', '"changed"'], + [2, 'foo', 'taylor', '"changed"'], + ]], + 'set for all scopes' => [fn () => Feature::activateForEveryone('foo', 'changed'), [ + ...$bar, + [2, 'foo', 'otwell', '"changed"'], + [2, 'foo', 'taylor', '"changed"'], + ]], + 'delete' => [fn () => Feature::for('taylor')->forget('foo'), [ + ...$bar, + [2, 'foo', 'otwell', '"team-2"'], + ]], + 'purge features' => [fn () => Feature::purge('foo'), $bar], + 'purge everything' => [fn () => Feature::purge(), []], + 'purge command' => [fn () => Artisan::call('pennant:purge', ['features' => ['foo']]), $bar], + ]; + } + + public function testStoredFeaturesStayInThePartition(): void + { + Feature::for('taylor')->value('foo'); + + $this->team = 2; + Feature::for('taylor')->value('bar'); + + $this->assertSame(['bar'], Feature::stored()); + } + + public function testUnpartitionedStoresDontResolveThePartition(): void + { + $this->team = null; + + Feature::store('central')->for('taylor')->activate('foo', 'central'); + Feature::store('central')->flushCache(); + + $this->assertSame('central', Feature::store('central')->for('taylor')->value('foo')); + $this->assertSame([], $this->resolvedStores); + } + + #[DataProvider('unresolvedPartitions')] + public function testUnresolvedPartitionThrows(?string $team): void + { + $this->team = $team; + + $this->expectExceptionObject(new FeaturePartitionNotResolved('The [team_id] partition of Pennant store [database] could not be resolved.')); + + Feature::for('taylor')->value('foo'); + } + + public static function unresolvedPartitions(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + ]; + } + + public function testIntegerAndStringPartitionValuesShareMemoAndCacheIdentity(): void + { + $queries = 0; + DB::listen(function () use (&$queries) { + ++$queries; + }); + Feature::store('cached')->define('foo', fn () => "team-{$this->team}"); + + Feature::store('cached')->for('taylor')->value('foo'); + $this->team = '1'; + $queries = 0; + + $this->assertSame('team-1', Feature::store('cached')->for('taylor')->value('foo')); + $this->assertSame(0, $queries); + + Feature::store('cached')->flushCache(); + + $this->assertSame('team-1', Feature::store('cached')->for('taylor')->value('foo')); + $this->assertSame(0, $queries); + } + + public function testPartitionsDontShareCachedValuesOrInvalidation(): void + { + Feature::store('cached')->define('foo', fn () => "team-{$this->team}"); + Feature::store('cached')->for('taylor')->value('foo'); + $this->team = 2; + Feature::store('cached')->for('taylor')->value('foo'); + Feature::store('cached')->flushCache(); + $queries = 0; + DB::listen(function () use (&$queries) { + ++$queries; + }); + + $this->assertSame('team-2', Feature::store('cached')->for('taylor')->value('foo')); + $this->team = 1; + $this->assertSame('team-1', Feature::store('cached')->for('taylor')->value('foo')); + $this->assertSame(0, $queries); + + Feature::store('database')->for('taylor')->activate('foo', 'changed'); + Feature::store('cached')->flushCache(); + $this->team = 2; + $queries = 0; + + $this->assertSame('team-2', Feature::store('cached')->for('taylor')->value('foo')); + $this->assertSame(0, $queries); + + $this->team = 1; + + $this->assertSame('changed', Feature::store('cached')->for('taylor')->value('foo')); + } + + public function testPartitionResolverCantBeRegisteredAfterAStoreIsResolved(): void + { + $this->expectExceptionObject(new RuntimeException('Register the Pennant partition resolver before resolving any Pennant store.')); + + Feature::resolvePartitionUsing('team_id', fn () => 2); + } + + #[DataProvider('invalidPartitionColumns')] + public function testPartitionColumnMustBeASimpleIdentifier(string $column): void + { + $this->expectExceptionObject(new InvalidArgumentException("Pennant partition column [{$column}] must be a simple SQL identifier.")); + + Feature::resolvePartitionUsing($column, fn () => 1); + } + + public static function invalidPartitionColumns(): array + { + return [ + 'qualified' => ['teams.id'], + 'expression' => ['team_id = 1 or 1'], + ]; + } + + /** + * Get the stored rows of every partition. + * + * @return list + */ + protected function rows(): array + { + return DB::table('team_features') + ->orderBy('team_id') + ->orderBy('name') + ->orderBy('scope') + ->get() + ->map(fn (object $row) => [(int) $row->team_id, $row->name, $row->scope, $row->value]) + ->all(); + } +} From 1ef3706084be7d5ba4147a1fd2f0e251d0b16838 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:04:37 +0000 Subject: [PATCH 18/39] Add Pennant shared cache tests The tests use worker-array caches so they run without Redis: - A value read before a write but published after it is ignored, since the write replaced the tokens it was read under. - Writes through an uncached store invalidate only their own scope. A data provider covers the default connection and base/::write reader-writer pairs with explicit connection names. The reader uses its own cache store, so target matching and cache key identity must both strip ::write; the null-connection store's profile would otherwise be a target of every writer and hide a broken match. - Writes to every scope invalidate the whole partition. - Unknown feature values are never cached. - Objects and JsonSerializable values read back from the cache as the same arrays the database returns, and false and null are still cache hits that run no query. - Memo hits touch neither the database nor the cache, and cache hits run no query and hold no pooled connection. - When new tokens can't be stored, nothing read in that operation is published. - After a committed write whose invalidation fails, the writer's next read returns the updated row rather than a stale memo value. - A target that returns false or throws doesn't stop the remaining targets, and its failure is rethrown. - Zero, negative, float and numeric-string TTLs are rejected. --- tests/Pennant/StoredValueCacheTest.php | 295 +++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/Pennant/StoredValueCacheTest.php diff --git a/tests/Pennant/StoredValueCacheTest.php b/tests/Pennant/StoredValueCacheTest.php new file mode 100644 index 000000000..5a4eddd55 --- /dev/null +++ b/tests/Pennant/StoredValueCacheTest.php @@ -0,0 +1,295 @@ +make('config'); + + $config->set('cache.stores.shared', ['driver' => 'worker-array']); + $config->set('cache.stores.reader', ['driver' => 'worker-array']); + $config->set('cache.stores.failing', ['driver' => 'null']); + $config->set('database.connections.pooled', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'pool' => ['testing_enabled' => true, 'min_retained_connections' => 1, 'max_connections' => 1], + ]); + $config->set('pennant.default', 'cached'); + $config->set('pennant.stores.cached', [ + 'driver' => 'database', + 'cache' => ['store' => 'shared'], + ]); + $config->set('pennant.stores.pooled', [ + 'driver' => 'database', + 'connection' => 'pooled', + 'cache' => ['store' => 'shared'], + ]); + } + + protected function setUpInCoroutine(): void + { + Feature::define('foo', fn () => 'resolved'); + Feature::store('database')->define('foo', fn () => 'resolved'); + } + + public function testValuesReadBeforeAWriteAreIgnoredWhenCachedAfterIt(): void + { + Feature::store('database')->for('taylor')->activate('foo', 'old'); + $written = false; + DB::listen(function (QueryExecuted $query) use (&$written) { + if (! $written && str_starts_with($query->sql, 'select')) { + $written = true; + Feature::store('database')->for('taylor')->activate('foo', 'new'); + } + }); + + $this->assertSame('old', Feature::for('taylor')->value('foo')); + + Feature::flushCache(); + + $this->assertSame('new', Feature::for('taylor')->value('foo')); + } + + #[DataProvider('uncachedWriters')] + public function testWritesThroughAnUncachedStoreInvalidateOnlyTheirScope(?string $readerRole, ?string $writerRole): void + { + $default = DB::getDefaultConnection(); + $connection = fn (?string $role) => match ($role) { + null => null, + 'base' => $default, + 'write' => "{$default}::write", + }; + config([ + 'pennant.stores.reader' => ['driver' => 'database', 'connection' => $connection($readerRole), 'cache' => ['store' => 'reader']], + 'pennant.stores.writer' => ['driver' => 'database', 'connection' => $connection($writerRole)], + ]); + Feature::store('reader')->define('foo', fn () => 'resolved'); + Feature::store('reader')->for(['taylor', 'otwell'])->load('foo'); + Feature::store('writer')->for('taylor')->activate('foo', 'changed'); + Feature::store('reader')->flushCache(); + $queries = $this->countQueries(); + + $this->assertSame('resolved', Feature::store('reader')->for('otwell')->value('foo')); + $this->assertSame(0, $queries->count); + $this->assertSame('changed', Feature::store('reader')->for('taylor')->value('foo')); + } + + public static function uncachedWriters(): array + { + return [ + 'default connection' => [null, null], + 'base reader and write writer' => ['base', 'write'], + 'write reader and base writer' => ['write', 'base'], + ]; + } + + public function testWritesToEveryScopeInvalidateThePartition(): void + { + Feature::for(['taylor', 'otwell'])->load('foo'); + Feature::store('database')->activateForEveryone('foo', 'everyone'); + Feature::flushCache(); + + $this->assertSame(['everyone', 'everyone'], Feature::for(['taylor', 'otwell'])->load('foo')['foo']); + } + + public function testUnknownFeatureValuesAreNotCached(): void + { + $this->assertFalse(Feature::for('taylor')->value('unknown')); + + Feature::define('unknown', fn () => 'resolved'); + Feature::flushCache(); + + $this->assertSame('resolved', Feature::for('taylor')->value('unknown')); + } + + #[DataProvider('richValues')] + public function testValuesReadFromTheCacheMatchTheDatabase(mixed $value, mixed $expected): void + { + Feature::define('rich', fn () => $value); + Feature::for('taylor')->value('rich'); + Feature::flushCache(); + $queries = $this->countQueries(); + + $this->assertSame($expected, Feature::for('taylor')->value('rich')); + $this->assertSame(0, $queries->count); + $this->assertSame($expected, Feature::store('database')->for('taylor')->value('rich')); + } + + public static function richValues(): array + { + return [ + 'object' => [(object) ['a' => 1], ['a' => 1]], + 'json serializable' => [new class implements JsonSerializable { + public function jsonSerialize(): array + { + return ['b' => 2]; + } + }, ['b' => 2]], + 'false' => [false, false], + 'null' => [null, null], + ]; + } + + public function testMemoizedValuesTouchNeitherTheDatabaseNorTheCache(): void + { + Feature::for('taylor')->value('foo'); + $queries = $this->countQueries(); + $cacheReads = 0; + Event::listen(RetrievingManyKeys::class, function () use (&$cacheReads) { + ++$cacheReads; + }); + + $this->assertSame('resolved', Feature::for('taylor')->value('foo')); + $this->assertSame(0, $queries->count); + $this->assertSame(0, $cacheReads); + } + + public function testCachedValuesRunNoQueryAndHoldNoConnection(): void + { + Feature::store('pooled')->define('foo', fn () => 'resolved'); + parallel([function () { + DB::usingConnection('pooled', function () { + (require __DIR__ . '/../../src/pennant/database/migrations/2022_11_01_000001_create_features_table.php')->up(); + }); + Feature::store('pooled')->for('taylor')->value('foo'); + }]); + $pool = $this->app->make(PoolManager::class)->pool('pooled'); + $queries = $this->countQueries(); + + [$result] = parallel([fn () => [Feature::store('pooled')->for('taylor')->value('foo'), $pool->getIdleCount()]]); + + $this->assertSame(['resolved', 1], $result); + $this->assertSame(0, $queries->count); + } + + public function testValuesAreNotCachedWhenNewTokensCantBeStored(): void + { + $cache = m::mock(Repository::class); + $cache->shouldReceive('get')->once()->andReturnUsing(fn (array $keys) => array_fill_keys($keys, null)); + $cache->shouldReceive('put')->once()->andReturnFalse(); + $this->app->make('cache')->extend('mocked', fn () => $cache); + config([ + 'cache.stores.mocked' => ['driver' => 'mocked'], + 'pennant.stores.cached.cache.store' => 'mocked', + ]); + Feature::forgetDrivers(); + Feature::define('foo', fn () => 'resolved'); + + $this->assertSame('resolved', Feature::for('taylor')->value('foo')); + } + + #[DefineEnvironment('withFailingInvalidationTarget')] + public function testWriterReadsTheUpdatedValueAfterAFailedInvalidation(): void + { + $this->assertSame('resolved', Feature::store('database')->for('taylor')->value('foo')); + + try { + Feature::store('database')->for('taylor')->activate('foo', 'changed'); + $this->fail('The invalidation should have failed.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to invalidate cached feature values. They may be stale until they expire.', $exception->getMessage()); + } + + $this->assertSame('changed', Feature::store('database')->for('taylor')->value('foo')); + } + + /** + * Add a store whose cache can't store invalidation tokens. + */ + protected function withFailingInvalidationTarget(Application $app): void + { + $app->make('config')->set('pennant.stores.broken', [ + 'driver' => 'database', + 'cache' => ['store' => 'failing'], + ]); + } + + public function testATargetThatCantStoreTokensDoesntStopTheOthers(): void + { + $failing = m::mock(Repository::class); + $failing->shouldReceive('put')->once()->andReturnFalse(); + $working = m::mock(Repository::class); + $working->shouldReceive('put')->once()->andReturnTrue(); + + $this->expectExceptionObject(new RuntimeException('Unable to invalidate cached feature values. They may be stale until they expire.')); + + (new StoredValueCache('features', [[$failing, 60], [$working, 60]]))->rotateScopes('testing', null, ['taylor']); + } + + public function testATargetThatThrowsDoesntStopTheOthers(): void + { + $exception = new RuntimeException('The cache is unavailable.'); + $failing = m::mock(Repository::class); + $failing->shouldReceive('put')->once()->andThrow($exception); + $working = m::mock(Repository::class); + $working->shouldReceive('put')->once()->andReturnTrue(); + + try { + (new StoredValueCache('features', [[$failing, 60], [$working, 60]]))->rotatePartition('testing', null); + $this->fail('The failing target should have thrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + } + + #[DataProvider('invalidTtls')] + public function testCacheTtlMustBeAPositiveInteger(mixed $ttl): void + { + config(['pennant.stores.cached.cache.ttl' => $ttl]); + Feature::forgetDrivers(); + + $this->expectExceptionObject(new InvalidArgumentException('The cache TTL of Pennant store [cached] must be a positive integer.')); + + Feature::store('cached'); + } + + public static function invalidTtls(): array + { + return [ + 'zero' => [0], + 'negative' => [-1], + 'float' => [1.5], + 'numeric string' => ['60'], + ]; + } + + /** + * Count the queries executed from now on. + */ + protected function countQueries(): object + { + $queries = (object) ['count' => 0]; + + DB::listen(function () use ($queries) { + ++$queries->count; + }); + + return $queries; + } +} From 9b1c850165e4d8a51eb72a9e26deaf5f14ecbf85 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:04:45 +0000 Subject: [PATCH 19/39] Extend the ported Pennant driver and manager tests DatabaseDriverTest: - '1' and '01' are matched as different scopes, not loosely compared. - Features sharing a scope list are read together: a rectangular batch uses one query with F+S bindings and a sparse batch one query with the bindings of each group. - Reads split into a second query past QUERY_GROUP_LIMIT groups and past QUERY_BINDING_LIMIT bindings. - A repeated scope is read and inserted once. - A ::read connection is rejected before any query, whether configured on the store or selected with DB::usingConnection(). - loadAll() with User and Team scopes gives each scope only the features whose resolvers accept it, keeping the scope positions. - setAll() with a repeated feature and scope keeps the last value. ArrayDriverTest: after forgetDriver() or forgetDrivers(), a rebuilt store resolves the redefined value instead of the forgotten store's memoized one. FeatureManagerTest: one data provider checks isResolverValidForScope() for object, iterable, callable, float with an int, standalone true and false, and self/parent parameter types, with negative cases. --- tests/Pennant/Feature/ArrayDriverTest.php | 21 ++++ tests/Pennant/Feature/DatabaseDriverTest.php | 110 +++++++++++++++++++ tests/Pennant/Feature/FeatureManagerTest.php | 47 ++++++++ 3 files changed, 178 insertions(+) diff --git a/tests/Pennant/Feature/ArrayDriverTest.php b/tests/Pennant/Feature/ArrayDriverTest.php index b6359c0f0..dbd406399 100644 --- a/tests/Pennant/Feature/ArrayDriverTest.php +++ b/tests/Pennant/Feature/ArrayDriverTest.php @@ -24,6 +24,7 @@ use Hypervel\Support\Lottery; use Hypervel\Tests\Pennant\Fixtures\User; use Hypervel\Tests\Pennant\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class ArrayDriverTest extends TestCase @@ -1271,6 +1272,26 @@ public function testRedefiningAFeatureClassUsesItsCurrentName(): void $this->assertSame('renamed-feature', Feature::name(MyFeature::class)); } + #[DataProvider('forgetStoreMethods')] + public function testARebuiltStoreDoesntReuseTheForgottenStoresValues(Closure $forget): void + { + Feature::define('foo', fn () => 'first'); + $this->assertSame('first', Feature::value('foo')); + + $forget(); + Feature::define('foo', fn () => 'second'); + + $this->assertSame('second', Feature::value('foo')); + } + + public static function forgetStoreMethods(): array + { + return [ + 'forgetDriver' => [fn () => Feature::forgetDriver('array')], + 'forgetDrivers' => [fn () => Feature::forgetDrivers()], + ]; + } + public function testCanRetrieveScalarValuesWithoutInMemoryCache(): void { $data = [ diff --git a/tests/Pennant/Feature/DatabaseDriverTest.php b/tests/Pennant/Feature/DatabaseDriverTest.php index b32787bf9..ca5b14d2f 100644 --- a/tests/Pennant/Feature/DatabaseDriverTest.php +++ b/tests/Pennant/Feature/DatabaseDriverTest.php @@ -13,6 +13,7 @@ use Hypervel\Pennant\Attributes\Name; use Hypervel\Pennant\Contracts\FeatureScopeable; use Hypervel\Pennant\Contracts\FeatureScopeSerializeable; +use Hypervel\Pennant\Drivers\DatabaseDriver; use Hypervel\Pennant\Events\AllFeaturesPurged; use Hypervel\Pennant\Events\DynamicallyRegisteringFeatureClass; use Hypervel\Pennant\Events\FeatureDeleted; @@ -36,6 +37,7 @@ use Hypervel\Tests\Pennant\Fixtures\UserFactory; use Hypervel\Tests\Pennant\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use stdClass; @@ -362,6 +364,15 @@ public function testItSeesNullAndEmptyStringAsDifferentThings(): void $this->assertCount(4, DB::getQueryLog()); } + public function testItMatchesScopesExactly(): void + { + Feature::define('foo', fn () => 'resolved'); + Feature::for('1')->activate('foo', 'one'); + Feature::flushCache(); + + $this->assertSame(['foo' => ['one', 'resolved']], Feature::getAll(['foo' => ['1', '01']])); + } + public function testScopeCanBeStringsLikeEmailAddresses(): void { Feature::for('tim@hypervel.com')->activate('foo'); @@ -711,6 +722,54 @@ public function testMissingResultsAreInsertedOnLoad(): void ]); } + #[DataProvider('groupedReads')] + public function testItReadsFeaturesThatShareScopesTogether(array $features, int $bindings): void + { + Feature::getAll($features); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertCount($bindings, DB::getQueryLog()[0]['bindings']); + } + + public static function groupedReads(): array + { + return [ + 'rectangular' => [['foo' => ['tim', 'taylor', 'jess'], 'bar' => ['tim', 'taylor', 'jess']], 5], + 'sparse' => [['foo' => ['tim'], 'bar' => ['tim'], 'baz' => ['taylor', 'jess']], 6], + ]; + } + + public function testItSplitsReadsAtTheGroupLimit(): void + { + $features = []; + + for ($index = 0; $index <= DatabaseDriver::QUERY_GROUP_LIMIT; ++$index) { + $features["feature-{$index}"] = ["scope-{$index}"]; + } + + Feature::getAll($features); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItSplitsReadsAtTheBindingLimit(): void + { + Feature::getAll(['foo' => array_map(fn (int $index) => "scope-{$index}", range(1, DatabaseDriver::QUERY_BINDING_LIMIT))]); + + $this->assertSame( + [DatabaseDriver::QUERY_BINDING_LIMIT, 2], + array_map(fn (array $query) => count($query['bindings']), DB::getQueryLog()), + ); + } + + public function testItReadsAndInsertsRepeatedScopesOnce(): void + { + Feature::define('foo', fn () => 'resolved'); + + $this->assertSame(['foo' => ['resolved', 'resolved']], Feature::getAll(['foo' => ['tim', 'tim']])); + $this->assertSame([2, 5], array_map(fn (array $query) => count($query['bindings']), DB::getQueryLog())); + } + public function testItCanRetrieveRegisteredFeatures(): void { Feature::define('foo', fn () => true); @@ -1336,6 +1395,34 @@ public function testItUsesDefaultConnectionIfNull(): void $this->assertSame(config('database.default'), $connection); } + #[DataProvider('readConnections')] + public function testItRejectsReadConnections(Closure $read): void + { + $connection = DB::getDefaultConnection() . '::read'; + Feature::define('foo', fn () => true); + + try { + $read($connection); + $this->fail('The read connection should have been rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame("Pennant can't use the read connection [{$connection}]. Use its base or write connection instead.", $exception->getMessage()); + } + + $this->assertCount(0, DB::getQueryLog()); + } + + public static function readConnections(): array + { + return [ + 'configured' => [function (string $connection) { + Config::set('pennant.stores.replica', ['driver' => 'database', 'connection' => $connection]); + + Feature::store('replica')->active('foo'); + }], + 'runtime default' => [fn (string $connection) => DB::usingConnection($connection, fn () => Feature::active('foo'))], + ]; + } + public function testItDispatchesEventsWhenPurgingFeatures(): void { Event::fake([FeaturesPurged::class]); @@ -1836,6 +1923,18 @@ public function testItCanLoadAllFeaturesForScope(): void ], $records[3]); } + public function testItCanLoadAllFeaturesForScopesOfDifferentTypes(): void + { + Feature::define('user', fn (User $user) => 'user'); + Feature::define('team', fn (Team $team) => 'team'); + + $this->assertSame( + ['user' => [0 => 'user'], 'team' => [1 => 'team']], + Feature::for([new User(['id' => 1]), new Team(['id' => 1])])->loadAll(), + ); + $this->assertCount(2, DB::getQueryLog()); + } + public function testCanRetrieveAllFeaturesForDifferingScopeTypes(): void { Feature::define('user', fn (User $user) => 1); @@ -2016,6 +2115,17 @@ public function testItUsesSingleQueryWhenActivatingMultipleFeaturesAndScopes(): $this->assertCount(1, DB::getQueryLog()); } + + public function testItKeepsTheLastValueOfARepeatedFeatureAndScope(): void + { + Feature::store()->setAll([ + ['feature' => 'foo', 'scope' => 'tim', 'value' => 'first'], + ['feature' => 'foo', 'scope' => 'tim', 'value' => 'last'], + ]); + Feature::flushCache(); + + $this->assertSame('last', Feature::for('tim')->value('foo')); + } } class UnregisteredFeature diff --git a/tests/Pennant/Feature/FeatureManagerTest.php b/tests/Pennant/Feature/FeatureManagerTest.php index 6fe270c35..ccce86658 100644 --- a/tests/Pennant/Feature/FeatureManagerTest.php +++ b/tests/Pennant/Feature/FeatureManagerTest.php @@ -10,6 +10,8 @@ use Hypervel\Support\Lottery; use Hypervel\Tests\Pennant\Fixtures\User; use Hypervel\Tests\Pennant\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; class FeatureManagerTest extends TestCase { @@ -83,4 +85,49 @@ public function testItCanApplyMacros(): void $this->assertTrue(Feature::forSession()->active('my-feature')); $this->assertFalse(Feature::for('default-scope')->active('my-feature')); } + + #[DataProvider('scopeTypes')] + public function testItMatchesScopesAgainstTheResolverParameterType(callable|string $resolver, mixed $scope, bool $expected): void + { + $this->assertSame($expected, Feature::isResolverValidForScope($resolver, $scope)); + } + + public static function scopeTypes(): array + { + return [ + 'object' => [fn (object $scope) => true, new stdClass, true], + 'object with a string' => [fn (object $scope) => true, 'taylor', false], + 'iterable' => [fn (iterable $scope) => true, ['taylor'], true], + 'callable' => [fn (callable $scope) => true, fn () => true, true], + 'float with an integer' => [fn (float $scope) => true, 1, true], + 'float with a numeric string' => [fn (float $scope) => true, '1', false], + 'true' => [fn (true $scope) => true, true, true], + 'true with false' => [fn (true $scope) => true, false, false], + 'false' => [fn (false $scope) => true, false, true], + 'self on a feature class' => [SelfScopedFeature::class, new SelfScopedFeature, true], + 'self on a method' => [[new SelfScopedFeature, 'resolve'], new SelfScopedFeature, true], + 'self with another object' => [SelfScopedFeature::class, new stdClass, false], + 'parent' => [ParentScopedFeature::class, new ScopedFeatureParent, true], + ]; + } +} + +class SelfScopedFeature +{ + public function resolve(self $scope): bool + { + return true; + } +} + +class ScopedFeatureParent +{ +} + +class ParentScopedFeature extends ScopedFeatureParent +{ + public function resolve(parent $scope): bool + { + return true; + } } From b94b0185eccaa80e149b9b9cec298824b1676993 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:04:52 +0000 Subject: [PATCH 20/39] Add Pennant database engine and Redis integration tests The database tests run Pennant's own migration on each engine. Shared by MySQL, MariaDB and PostgreSQL: - Identifiers that differ only by case, accents, trailing spaces or composed/decomposed form are stored and read separately, as names and as scopes. This covers the VARBINARY columns on MySQL and MariaDB, whose default collations treat these as equal. - 255 four-byte characters fit in both name and scope. - An uncontended first resolution uses one read and one insert. - A row stored through a competing connection between the read and the insert is returned as the winner. MySQL and MariaDB also return the winner when FOUND_ROWS is enabled through the connection options or through a bound connector, and inside a REPEATABLE READ transaction, where only the locking reread can see it. MySQL also covers use_upsert_alias. PostgreSQL covers activating a repeated scope in one batch, which it would reject as an upsert touching one row twice, and checks that a serialization failure inside a REPEATABLE READ transaction propagates. The Redis test covers publishing, scope invalidation and bulk invalidation (setAll, activateForEveryone and purge) through the public API with a fresh memo. Its directory is added to the Redis, Redis Cluster and Valkey workflow jobs; the database directories are found by convention. --- .github/workflows/redis.yml | 3 + .../Database/DatabaseDriverTestCase.php | 111 ++++++++++++++++++ .../Database/MariaDb/DatabaseDriverTest.php | 13 ++ .../Database/MySql/DatabaseDriverTest.php | 34 ++++++ .../MySqlFamilyDatabaseDriverTestCase.php | 74 ++++++++++++ .../Database/Postgres/DatabaseDriverTest.php | 40 +++++++ .../Pennant/Redis/StoredValueCacheTest.php | 89 ++++++++++++++ 7 files changed, 364 insertions(+) create mode 100644 tests/Integration/Pennant/Database/DatabaseDriverTestCase.php create mode 100644 tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php create mode 100644 tests/Integration/Pennant/Database/MySql/DatabaseDriverTest.php create mode 100644 tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php create mode 100644 tests/Integration/Pennant/Database/Postgres/DatabaseDriverTest.php create mode 100644 tests/Integration/Pennant/Redis/StoredValueCacheTest.php diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 57dc46b51..1d532ac77 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -61,6 +61,7 @@ jobs: vendor/bin/paratest --max-processes=15 tests/Integration/Horizon vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis + vendor/bin/paratest --max-processes=15 tests/Integration/Pennant/Redis CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Permission QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis @@ -209,6 +210,7 @@ jobs: vendor/bin/phpunit --no-progress tests/Integration/Horizon vendor/bin/phpunit --no-progress tests/Integration/Http/Redis vendor/bin/phpunit --no-progress tests/Integration/OpenTelemetry/Redis + vendor/bin/phpunit --no-progress tests/Integration/Pennant/Redis QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue vendor/bin/phpunit --no-progress tests/Integration/RateLimiter/Redis vendor/bin/phpunit --no-progress tests/Integration/Session/Redis @@ -279,6 +281,7 @@ jobs: vendor/bin/paratest --max-processes=15 tests/Integration/Horizon vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis + vendor/bin/paratest --max-processes=15 tests/Integration/Pennant/Redis QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis diff --git a/tests/Integration/Pennant/Database/DatabaseDriverTestCase.php b/tests/Integration/Pennant/Database/DatabaseDriverTestCase.php new file mode 100644 index 000000000..9e9fd0d70 --- /dev/null +++ b/tests/Integration/Pennant/Database/DatabaseDriverTestCase.php @@ -0,0 +1,111 @@ +make('config'); + + $config->set('pennant.default', 'database'); + $config->set('database.connections.competitor', $config->get('database.connections.' . $config->string('database.default'))); + } + + protected function afterRefreshingDatabase(): void + { + (require __DIR__ . '/../../../../src/pennant/database/migrations/2022_11_01_000001_create_features_table.php')->up(); + } + + #[DataProvider('similarIdentifiers')] + public function testSimilarIdentifiersAreStoredSeparately(string $first, string $second): void + { + Feature::for($first)->activate('foo', 'first'); + Feature::for($second)->activate('foo', 'second'); + Feature::for('shared-scope')->activate($first, 'first'); + Feature::for('shared-scope')->activate($second, 'second'); + Feature::flushCache(); + + $this->assertSame('first', Feature::for($first)->value('foo')); + $this->assertSame('second', Feature::for($second)->value('foo')); + $this->assertSame('first', Feature::for('shared-scope')->value($first)); + $this->assertSame('second', Feature::for('shared-scope')->value($second)); + $this->assertSame(4, DB::table('features')->count()); + } + + public static function similarIdentifiers(): array + { + return [ + 'case' => ['Team-A', 'team-a'], + 'accents' => ['café', 'cafe'], + 'trailing spaces' => ['scope', 'scope '], + 'composed and decomposed' => ["caf\u{e9}", "cafe\u{301}"], + ]; + } + + public function testLongFourByteIdentifiersAreStored(): void + { + $identifier = str_repeat("\u{1F600}", 255); + + Feature::for($identifier)->activate($identifier, 'stored'); + Feature::flushCache(); + + $this->assertSame('stored', Feature::for($identifier)->value($identifier)); + } + + public function testFirstResolutionUsesOneReadAndOneInsert(): void + { + Feature::define('foo', fn () => 'resolved'); + DB::enableQueryLog(); + + $this->assertSame(['resolved', 'resolved'], Feature::for(['tim', 'taylor'])->load('foo')['foo']); + $this->assertCount(2, DB::getQueryLog()); + } + + public function testConcurrentWinnerIsReturned(): void + { + Feature::define('foo', fn () => 'candidate'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $this->assertSame('winner', Feature::for('tim')->value('foo')); + } + + /** + * Store a competing row through another connection after the next read, before Pennant inserts its own. + */ + protected function storeWinnerBeforeInsert(string $feature, string $scope, mixed $value): void + { + $stored = false; + + DB::listen(function (QueryExecuted $event) use ($feature, $scope, $value, &$stored) { + if (! $stored && str_starts_with($event->sql, 'select')) { + $stored = true; + + DB::connection('competitor')->table('features')->insert([ + 'name' => $feature, + 'scope' => $scope, + 'value' => json_encode($value), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + } +} diff --git a/tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php b/tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php new file mode 100644 index 000000000..21915d402 --- /dev/null +++ b/tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php @@ -0,0 +1,13 @@ + 'candidate'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $this->assertSame('winner', Feature::for('tim')->value('foo')); + } + + /** + * Compile upserts with a row alias instead of VALUES(). + */ + protected function usingTheUpsertAlias(ApplicationContract $app): void + { + $config = $app->make('config'); + + $config->set('database.connections.' . $config->string('database.default') . '.use_upsert_alias', true); + } +} diff --git a/tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php b/tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php new file mode 100644 index 000000000..1b0cbc400 --- /dev/null +++ b/tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php @@ -0,0 +1,74 @@ + 'candidate'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $this->assertSame('winner', Feature::for('tim')->value('foo')); + } + + /** + * Count matched rather than changed rows through the connection options. + */ + protected function countingFoundRowsInTheConnectionOptions(ApplicationContract $app): void + { + $config = $app->make('config'); + + $config->set('database.connections.' . $config->string('database.default') . '.options', [Mysql::ATTR_FOUND_ROWS => true]); + } + + #[DefineEnvironment('countingFoundRowsInABoundConnector')] + public function testConcurrentWinnerIsReturnedWhenABoundConnectorCountsFoundRows(): void + { + Feature::define('foo', fn () => 'candidate'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $this->assertSame('winner', Feature::for('tim')->value('foo')); + } + + /** + * Count matched rather than changed rows through a bound connector's default options. + */ + protected function countingFoundRowsInABoundConnector(ApplicationContract $app): void + { + $config = $app->make('config'); + $driver = $config->string('database.connections.' . $config->string('database.default') . '.driver'); + + // Environment methods run before RequiresDatabase skips the test on other drivers. + if (! in_array($driver, ['mysql', 'mariadb'], true)) { + return; + } + + $app->bind("db.connector.{$driver}", function () use ($driver) { + $connector = $driver === 'mariadb' ? new MariaDbConnector : new MySqlConnector; + $connector->setDefaultOptions($connector->getDefaultOptions() + [Mysql::ATTR_FOUND_ROWS => true]); + + return $connector; + }); + } + + public function testConcurrentWinnerIsReturnedInsideARepeatableReadTransaction(): void + { + Feature::define('foo', fn () => 'candidate'); + DB::statement('set session transaction isolation level repeatable read'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $this->assertSame('winner', DB::transaction(fn () => Feature::for('tim')->value('foo'))); + } +} diff --git a/tests/Integration/Pennant/Database/Postgres/DatabaseDriverTest.php b/tests/Integration/Pennant/Database/Postgres/DatabaseDriverTest.php new file mode 100644 index 000000000..4e37539f1 --- /dev/null +++ b/tests/Integration/Pennant/Database/Postgres/DatabaseDriverTest.php @@ -0,0 +1,40 @@ +activate('foo', 'on'); + Feature::flushCache(); + + $this->assertSame('on', Feature::for('tim')->value('foo')); + } + + public function testSerializationFailuresInsideARepeatableReadTransactionPropagate(): void + { + Feature::define('foo', fn () => 'candidate'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + try { + DB::transaction(function () { + DB::statement('set transaction isolation level repeatable read'); + + Feature::for('tim')->value('foo'); + }); + $this->fail('The insert should have failed to serialize.'); + } catch (QueryException $exception) { + $this->assertSame('40001', $exception->getCode()); + } + } +} diff --git a/tests/Integration/Pennant/Redis/StoredValueCacheTest.php b/tests/Integration/Pennant/Redis/StoredValueCacheTest.php new file mode 100644 index 000000000..910d43d1e --- /dev/null +++ b/tests/Integration/Pennant/Redis/StoredValueCacheTest.php @@ -0,0 +1,89 @@ +make('config'); + + $config->set('pennant.default', 'cached'); + $config->set('pennant.stores.cached', [ + 'driver' => 'database', + 'cache' => ['store' => 'redis'], + ]); + } + + protected function setUpInCoroutine(): void + { + Feature::define('foo', fn () => 'resolved'); + } + + public function testStoredValuesAreReadFromRedis(): void + { + Feature::for(['taylor', 'otwell'])->load('foo'); + Feature::flushCache(); + $queries = $this->countQueries(); + + $this->assertSame(['resolved', 'resolved'], Feature::for(['taylor', 'otwell'])->load('foo')['foo']); + $this->assertSame(0, $queries->count); + } + + public function testWritesInvalidateTheirScope(): void + { + Feature::for(['taylor', 'otwell'])->load('foo'); + Feature::for('taylor')->activate('foo', 'changed'); + Feature::flushCache(); + $queries = $this->countQueries(); + + $this->assertSame('resolved', Feature::for('otwell')->value('foo')); + $this->assertSame(0, $queries->count); + $this->assertSame('changed', Feature::for('taylor')->value('foo')); + } + + public function testBulkWritesInvalidateEveryAffectedScope(): void + { + Feature::for(['taylor', 'otwell'])->load('foo'); + Feature::for(['taylor', 'otwell'])->activate('foo', 'changed'); + Feature::flushCache(); + + $this->assertSame(['changed', 'changed'], Feature::for(['taylor', 'otwell'])->load('foo')['foo']); + + Feature::activateForEveryone('foo', 'everyone'); + Feature::flushCache(); + + $this->assertSame(['everyone', 'everyone'], Feature::for(['taylor', 'otwell'])->load('foo')['foo']); + + Feature::purge('foo'); + Feature::flushCache(); + + $this->assertSame(['resolved', 'resolved'], Feature::for(['taylor', 'otwell'])->load('foo')['foo']); + } + + /** + * Count the queries executed from now on. + */ + protected function countQueries(): object + { + $queries = (object) ['count' => 0]; + + DB::listen(function () use ($queries) { + ++$queries->count; + }); + + return $queries; + } +} From b8dce17f86918842e42efb951852560d70a6d69c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:04:59 +0000 Subject: [PATCH 21/39] Update the Pennant port plan after checkpoint 3 Step 5 is complete. The test list now describes the partition cache isolation case, the base/::write reader-writer pairs and their separate reader cache, and how cached objects, JsonSerializable values, false, null and floats read back. The next step is the Sentry integration. --- docs/plans/2026-10-21-0030-pennant-port.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-10-21-0030-pennant-port.md b/docs/plans/2026-10-21-0030-pennant-port.md index 6f3fca928..b199a5dd0 100644 --- a/docs/plans/2026-10-21-0030-pennant-port.md +++ b/docs/plans/2026-10-21-0030-pennant-port.md @@ -2,7 +2,7 @@ Port `laravel/pennant` (reference: `/home/binaryfire/workspace/references/laravel/pennant`, commit `d9ea593`) as `hypervel/pennant` (`src/pennant`, namespace `Hypervel\Pennant`). New package port: follow AGENTS.md *Porting Packages* (cp each file, alphabetical, one at a time) plus the adaptations below. -Status: implementation authorized. Steps 1–4 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`) and all upstream test ports are reviewed and committed; `src/docs/pennant.md` is copied from Laravel, unadapted and uncommitted. Next step: the new tests (step 5: `CoroutineIsolationTest`, `PartitionTest`, `TransactionTest`, `StoredValueCacheTest`, the additions to existing tests, then the integration directories and workflow). Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. +Status: implementation authorized. Steps 1–5 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`, upstream test ports, new tests, integration directories and workflow) are reviewed and committed; `src/docs/pennant.md` is copied from Laravel, unadapted and uncommitted. Next step: the Sentry integration (step 6). Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. ## Owner decisions (approved) @@ -252,9 +252,9 @@ Adaptations: New tests (only behavior that can regress): - `tests/Pennant/CoroutineIsolationTest.php`: concurrent memo/array isolation; copied context gets independent values; values (including `before()` results) from an open transaction are not copied, both when the parent forks and when the child copies from the parent with `copyFrom()`; partition switch inside one coroutine uses separate buckets; a null-connection store under `DB::usingConnection()` keeps separate memo per connection. -- `tests/Pennant/PartitionTest.php`: every operation partitioned (including grouped OR reads), null partition throws, `partitioned => false`, `globally()` per partition, int `1` and string `'1'` share memo and cache identity. +- `tests/Pennant/PartitionTest.php`: every operation partitioned (including grouped OR reads and `pennant:purge`), null and `''` partitions throw, `partitioned => false` never calls the resolver, int `1` and string `'1'` share memo and cache identity, different partitions don't share cached values or invalidation (cached store warmed for two teams, one mutated through the uncached store), registering the resolver after a store resolves throws, invalid partition columns throw. (`globally()` is an ordinary scope on the same path, so it has no separate test.) - `tests/Pennant/TransactionTest.php`: rollback (and savepoint rollback) flushes the memo, including `before()` results and base plus `::write` aliases sharing one in-memory SQLite transaction; commit keeps it; `flushCache()` keeps registrations; multi-chunk `setAll()` rolls back together; shared cache bypassed inside a transaction; rotation waits for the outer commit and is dropped on rollback. -- `tests/Pennant/StoredValueCacheTest.php` (array cache): capture → rotate → publish is ignored; scope vs partition rotation; a write through an uncached alias rotates the cached alias's tokens; unknown and `before()` values never published; rich values (array, object, `JsonSerializable`, float, `false`, `null`) read back from cache exactly as from the database (data provider); warm memo hits touch neither database nor cache, and warm cache hits run no query and hold no connection; token-write `false` skips publishing; after a committed write through an uncached alias whose rotation fails, the writer's next read reaches the updated database row (no stale local memo); a failing target doesn't stop the other targets and its failure is rethrown; non-positive `ttl` is rejected. +- `tests/Pennant/StoredValueCacheTest.php` (`worker-array` cache): capture → rotate → publish is ignored; writes through an uncached store rotate only their scopes (data provider: default connection, plus base/`::write` reader–writer pairs with explicit connection names and a separate reader cache store, so target matching and cache identity both apply `withoutWriteType()`), and writes to every scope rotate the partition; unknown values never published (`before()` results never reach the driver, so they need no test); values whose cached form could differ (object and `JsonSerializable` decode to arrays; `false` and `null` must still be hits) read back from cache with no query, exactly as from the database (data provider; floats share the same decode path); warm memo hits touch neither database nor cache, and warm cache hits run no query and hold no pooled connection; token-write `false` skips publishing; after a committed write through an uncached alias whose rotation fails, the writer's next read reaches the updated database row (no stale local memo); a failing target (false or exception, tested on `StoredValueCache` with mock targets so the order is controlled) doesn't stop the other targets and its failure is rethrown; a non-positive or non-integer `ttl` is rejected. - Additions to `Feature/DatabaseDriverTest`: rectangular vs sparse batching query counts, binding/group splitting, read dedupe, duplicate `setAll()` pairs (last value wins), `'01'`/`'1'`, `::read` rejection (configured and via `DB::usingConnection()`), `loadAll()` mixed scopes; `Feature/ArrayDriverTest`: (data provider over `forgetDriver()` / `forgetDrivers()`) a rebuilt store resolves a redefined value instead of the forgotten store's; `Feature/FeatureManagerTest`: one `isResolverValidForScope()` data provider for the builtin, `self`/`parent` and int→float cases. - `tests/Integration/Pennant/Database/{Postgres,MySql,MariaDb}/`: exact identity (case, accents, composed/decomposed, trailing spaces, 255 four-byte characters) on MySQL/MariaDB; conflict paths per engine (MySQL with and without `use_upsert_alias`, FOUND_ROWS on via options and via a bound connector, REPEATABLE READ locking reread); duplicate `setAll()` pairs on PostgreSQL; PostgreSQL serialization failure propagates. - `tests/Integration/Pennant/Redis/StoredValueCacheTest.php` (`InteractsWithRedis`): public-API publish, mutation invalidation and bulk rotation seen from a fresh memo. Add `tests/Integration/Pennant/Redis` to all three jobs in `.github/workflows/redis.yml` (Redis, Redis Cluster, Valkey). Database directories are discovered by convention. From 8d3da3effb8cf8bf10b6a5eaad4ca74505c8c406 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:20:00 +0000 Subject: [PATCH 22/39] Add Sentry's Pennant feature flag integration Ports PennantPackageIntegration from sentry-laravel, including the upstream fix that replaces backslashes with dots so class-based feature names are accepted by Relay. Each FeatureRetrieved event records the flag on the current Sentry scope, as active unless its value is false, so error events show which flags were checked and their results. Hypervel features take no arguments in onBoot(), so the integration resolves the event dispatcher from its container like the other features. The listener parameter is typed, and the unused FEATURE_KEY constant and FeatureResolved import are not ported. Sentry's scope is stored per coroutine, so flags never cross requests, and the SDK keeps at most 100 flags per scope. The integration is added to the shipped features list after NotificationsIntegration, following upstream's order. It applies only when Pennant is installed, so the Sentry package gains no dependency. All five upstream tests are ported; the class_exists skip is dropped because Pennant is always installed in this repository. The Sentry docs introduction mentions the flags, and the Pennant item is removed from docs/todo.md. --- docs/todo.md | 4 - src/docs/sentry.md | 2 +- src/sentry/config/sentry.php | 2 + .../Features/PennantPackageIntegration.php | 45 ++++++ .../PennantPackageIntegrationTest.php | 152 ++++++++++++++++++ 5 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 src/sentry/src/Features/PennantPackageIntegration.php create mode 100644 tests/Sentry/Features/PennantPackageIntegrationTest.php diff --git a/docs/todo.md b/docs/todo.md index 98bad2fcc..2a734bac7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -90,10 +90,6 @@ - Remove pipe-separated validation rule strings in favor of arrays of rules across both `hypervel/validation` and `hypervel/data`, including Data's rule normalizer, denormalizer and `#[Rule]` handling. Update callers, tests and documentation together; preserve pipes inside individual regex patterns. - Adopt Brick Math 0.20's bounded parsing API for untrusted numeric validation inputs once the supported dependency graph permits it. Ramsey UUID currently limits Brick Math to 0.18, which has neither bounded parsing nor 0.20's parser-backtracking fix, so an intermediate upgrade would not address this issue. Preserve the configurable exponent-range behavior and cover delegated and compiled rules, oversized mantissas and exponents, malformed input, and `multiple_of`; do not add a local parsing workaround or disguise an incompatible Brick version with a Composer alias. -## Pennant - -- Port `laravel/pennant` as a first-party package. As part of the port, add Sentry's `PennantPackageIntegration` and its upstream test and register the integration in Sentry's shipped `features` config. The integration records each retrieved feature flag on the current Sentry scope, so error events show which flags were active. - ## Sentry - Make Sentry profiles record only the profiled transaction's work. Excimer samples whichever coroutine's PHP stack is running when it takes a sample, so a transaction profile currently includes work from other coroutines in the same worker, such as concurrent requests. Profiling stays disabled by default, with a config comment and documentation warning, until this is fixed. Use released dependencies only. diff --git a/src/docs/sentry.md b/src/docs/sentry.md index 0be7e2035..05740741d 100644 --- a/src/docs/sentry.md +++ b/src/docs/sentry.md @@ -30,7 +30,7 @@ ## Introduction -[Sentry](https://sentry.io) provides error tracking and performance monitoring for your Hypervel application. Hypervel's Sentry integration captures exceptions, logs, requests, database queries, cache operations, queued jobs, notifications, Redis commands, scheduled tasks, and filesystem operations. +[Sentry](https://sentry.io) provides error tracking and performance monitoring for your Hypervel application. Hypervel's Sentry integration captures exceptions, logs, requests, database queries, cache operations, queued jobs, notifications, Redis commands, scheduled tasks, and filesystem operations. When your application uses [Pennant](/docs/{{version}}/pennant), each feature flag it checks is attached to the current scope, so error events show the flags and whether each was active. The integration is designed for Hypervel's long-running Swoole workers. Sentry state is isolated across requests, queued jobs, scheduled tasks, and WebSocket callbacks, while HTTP connections are pooled and reused across executions. An execution remains active until the child coroutines sharing its runtime context have finished. diff --git a/src/sentry/config/sentry.php b/src/sentry/config/sentry.php index ec5511733..c4c8ab8f8 100644 --- a/src/sentry/config/sentry.php +++ b/src/sentry/config/sentry.php @@ -10,6 +10,7 @@ use Hypervel\Sentry\Features\HttpClientIntegration; use Hypervel\Sentry\Features\LogIntegration; use Hypervel\Sentry\Features\NotificationsIntegration; +use Hypervel\Sentry\Features\PennantPackageIntegration; use Hypervel\Sentry\Features\QueueIntegration; use Hypervel\Sentry\Features\Storage\Integration as StorageIntegration; @@ -218,6 +219,7 @@ StorageIntegration::class, HttpClientIntegration::class, NotificationsIntegration::class, + PennantPackageIntegration::class, ConsoleSchedulingIntegration::class, AiIntegration::class, ClassificationIntegration::class, diff --git a/src/sentry/src/Features/PennantPackageIntegration.php b/src/sentry/src/Features/PennantPackageIntegration.php new file mode 100644 index 000000000..908723de9 --- /dev/null +++ b/src/sentry/src/Features/PennantPackageIntegration.php @@ -0,0 +1,45 @@ +container->make('events')->listen(FeatureRetrieved::class, [$this, 'handleFeatureRetrieved']); + } + + /** + * Record the retrieved feature flag on the current scope. + */ + public function handleFeatureRetrieved(FeatureRetrieved $feature): void + { + SentrySdk::getCurrentHub()->configureScope(function (Scope $scope) use ($feature) { + // backslashes are not allowed in feature flag names, so we replace them with dots to allow class + // based feature flags + $featureName = str_replace('\\', '.', ltrim($feature->feature, '\\')); + + // The value of the feature is not always a bool (Rich Feature Values) but only bools are supported. + // The feature is considered "active" if its value is not explicitly false following Pennant's logic. + $scope->addFeatureFlag($featureName, $feature->value !== false); + }); + } +} diff --git a/tests/Sentry/Features/PennantPackageIntegrationTest.php b/tests/Sentry/Features/PennantPackageIntegrationTest.php new file mode 100644 index 000000000..18b884f1e --- /dev/null +++ b/tests/Sentry/Features/PennantPackageIntegrationTest.php @@ -0,0 +1,152 @@ +make('config'), static function (Repository $config) { + // Force Pennant to use the array driver instead of the database which is the default + $config->set('pennant.default', 'array'); + $config->set('pennant.stores.array', ['driver' => 'array']); + }); + } + + public function testPennantFeatureIsRecorded(): void + { + Feature::define('new-dashboard', static function () { + return true; + }); + + Feature::active('new-dashboard'); + + $scope = $this->getCurrentSentryScope(); + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertArrayHasKey('flags', $event->getContexts()); + + $flags = $event->getContexts()['flags']['values']; + + $this->assertEquals([ + [ + 'flag' => 'new-dashboard', + 'result' => true, + ], + ], $flags); + } + + public function testPennantFeatureWithLeadingNamespaceSeparatorIsRecordedWithNormalizedName(): void + { + Feature::define('\App\Features\NewApi', static function () { + return true; + }); + + Feature::active('\App\Features\NewApi'); + + $scope = $this->getCurrentSentryScope(); + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertArrayHasKey('flags', $event->getContexts()); + + $flags = $event->getContexts()['flags']['values']; + + $this->assertEquals([ + [ + 'flag' => 'App.Features.NewApi', + 'result' => true, + ], + ], $flags); + } + + public function testPennantClassBasedFeatureIsRecordedWithNormalizedName(): void + { + Feature::active(PennantClassBasedFeature::class); + + $scope = $this->getCurrentSentryScope(); + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertArrayHasKey('flags', $event->getContexts()); + + $flags = $event->getContexts()['flags']['values']; + + $this->assertEquals([ + [ + 'flag' => 'Hypervel.Tests.Sentry.Features.PennantClassBasedFeature', + 'result' => true, + ], + ], $flags); + } + + public function testPennantRichFeatureIsRecordedAsActive(): void + { + Feature::define('dashboard-version', static function () { + return 'dark'; + }); + + Feature::value('dashboard-version'); + + $scope = $this->getCurrentSentryScope(); + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertArrayHasKey('flags', $event->getContexts()); + + $flags = $event->getContexts()['flags']['values']; + + $this->assertEquals([ + [ + 'flag' => 'dashboard-version', + 'result' => true, + ], + ], $flags); + } + + public function testPennantRichFeatureIsRecordedAsInactive(): void + { + Feature::define('dashboard-version', static function () { + return false; + }); + + Feature::value('dashboard-version'); + + $scope = $this->getCurrentSentryScope(); + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertArrayHasKey('flags', $event->getContexts()); + + $flags = $event->getContexts()['flags']['values']; + + $this->assertEquals([ + [ + 'flag' => 'dashboard-version', + 'result' => false, + ], + ], $flags); + } +} + +class PennantClassBasedFeature +{ + public function resolve(): bool + { + return true; + } +} From df612f342fe9100f0e1a82bd1b779258559cf8ee Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:20:04 +0000 Subject: [PATCH 23/39] Document that feature scopes receive the store name FeatureScopeable::toFeatureIdentifier() receives the name of the store being used: FeatureManager passes the store name to the Decorator, and resolveScope() passes that name on. The docblock said "driver", so a store named "tenant" using the database driver would not match code written against driver names. Laravel behaves the same way. The docblock now describes the store name. The parameter keeps its upstream name, $driver, so named arguments stay compatible. --- src/pennant/src/Contracts/FeatureScopeable.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pennant/src/Contracts/FeatureScopeable.php b/src/pennant/src/Contracts/FeatureScopeable.php index 8d0e7cfc5..ff71633b5 100644 --- a/src/pennant/src/Contracts/FeatureScopeable.php +++ b/src/pennant/src/Contracts/FeatureScopeable.php @@ -7,7 +7,9 @@ interface FeatureScopeable { /** - * Cast the object to a feature scope identifier for the given driver. + * Cast the object to a feature scope identifier for the given store. + * + * @param string $driver the name of the store, not its driver */ public function toFeatureIdentifier(string $driver): mixed; } From ec1ac7358004b7b9b701ea79fa9f76e9329ee5ca Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:20:12 +0000 Subject: [PATCH 24/39] Add the Pennant documentation pennant.md starts from Laravel's Pennant docs, with namespaces, package names and source links pointing at Hypervel, date examples using the Date facade and missing example imports added. The before() examples now return null explicitly, since a mixed return type can't fall through, and read config through typed getters. Hypervel-specific additions: - Installation: binary name and scope columns on MySQL and MariaDB. - Configuration: null connections follow DB::usingConnection(), ::read connections are rejected, stores read their config once, and reconfiguring needs forgetDrivers() and new definitions. - Feature classes are shared by the worker, so they hold no request state, and before() can defer to an entitlement or billing service. - In-memory cache: per coroutine, copied only with the context, and for database stores forgotten on rollback and not copied while a transaction is open. - Scopes: the supported scope types, keyless models throwing, the store name passed to toFeatureIdentifier(), and the stored false of unexpected null scopes. - all() can include classes registered by earlier requests on the worker; discover() gives a stable set. - New Partitioning and Shared Cache sections, plus custom driver guidance, worker-wide DynamicallyRegisteringFeatureClass, and Credits. The README's differences now link to the configuration docs and name the changed protected methods and FeatureManager. The porting guide gains a Pennant entry, and the documentation index lists Pennant. --- src/docs/documentation.md | 1 + src/docs/pennant.md | 1335 ++++++++++++++++++++++++++++++ src/docs/porting-from-laravel.md | 6 + src/pennant/README.md | 4 +- 4 files changed, 1344 insertions(+), 2 deletions(-) create mode 100644 src/docs/pennant.md diff --git a/src/docs/documentation.md b/src/docs/documentation.md index 5dc8a0675..3550c286a 100644 --- a/src/docs/documentation.md +++ b/src/docs/documentation.md @@ -107,6 +107,7 @@ - [Nested Set](/docs/{{version}}/nested-set) - [OpenTelemetry](/docs/{{version}}/opentelemetry) - [Passport](/docs/{{version}}/passport) + - [Pennant](/docs/{{version}}/pennant) - [Precognition](/docs/{{version}}/precognition) - [Prompts](/docs/{{version}}/prompts) - [Reverb](/docs/{{version}}/reverb) diff --git a/src/docs/pennant.md b/src/docs/pennant.md new file mode 100644 index 000000000..0c112b44b --- /dev/null +++ b/src/docs/pennant.md @@ -0,0 +1,1335 @@ +# Hypervel Pennant + +- [Introduction](#introduction) +- [Installation](#installation) +- [Configuration](#configuration) +- [Defining Features](#defining-features) + - [Class Based Features](#class-based-features) +- [Checking Features](#checking-features) + - [Conditional Execution](#conditional-execution) + - [The `HasFeatures` Trait](#the-has-features-trait) + - [Blade Directive](#blade-directive) + - [Middleware](#middleware) + - [Intercepting Feature Checks](#intercepting-feature-checks) + - [In-Memory Cache](#in-memory-cache) +- [Scope](#scope) + - [Specifying the Scope](#specifying-the-scope) + - [Global Scope](#global-scope) + - [Default Scope](#default-scope) + - [Nullable Scope](#nullable-scope) + - [Identifying Scope](#identifying-scope) + - [Serializing Scope](#serializing-scope) +- [Rich Feature Values](#rich-feature-values) +- [Retrieving Multiple Features](#retrieving-multiple-features) +- [Eager Loading](#eager-loading) +- [Updating Values](#updating-values) + - [Bulk Updates](#bulk-updates) + - [Purging Features](#purging-features) +- [Partitioning](#partitioning) + - [Registering a Partition](#registering-a-partition) + - [Partitioned Schema](#partitioned-schema) +- [Shared Cache](#shared-cache) +- [Testing](#testing) +- [Adding Custom Pennant Drivers](#adding-custom-pennant-drivers) + - [Implementing the Driver](#implementing-the-driver) + - [Registering the Driver](#registering-the-driver) + - [Defining Features Externally](#defining-features-externally) +- [Events](#events) +- [Credits](#credits) + + +## Introduction + +[Hypervel Pennant](https://github.com/hypervel/pennant) is a simple and light-weight feature flag package - without the cruft. Feature flags enable you to incrementally roll out new application features with confidence, A/B test new interface designs, complement a trunk-based development strategy, and much more. + + +## Installation + +First, install Pennant into your project using the Composer package manager: + +```shell +composer require hypervel/pennant +``` + +Next, you should publish the Pennant configuration and migration files using the `vendor:publish` Artisan command: + +```shell +php artisan vendor:publish --provider="Hypervel\Pennant\PennantServiceProvider" +``` + +Finally, you should run your application's database migrations. This will create a `features` table that Pennant uses to power its `database` driver: + +```shell +php artisan migrate +``` + +On MySQL and MariaDB, the migration stores feature names and scopes in binary columns, so names and scopes that differ only by case, accents, or trailing spaces are stored separately. + + +## Configuration + +After publishing Pennant's assets, its configuration file will be located at `config/pennant.php`. This configuration file allows you to specify the default storage mechanism that will be used by Pennant to store resolved feature flag values. + +Pennant includes support for storing resolved feature flag values in an in-memory array via the `array` driver. Or, Pennant can store resolved feature flag values persistently in a relational database via the `database` driver, which is the default storage mechanism used by Pennant. + +A `database` store uses the default database connection when its `connection` option is `null`, including a connection selected with `DB::usingConnection()`. Since Pennant stores the first value it resolves for a scope, a store can't use a [`::read` connection](/docs/{{version}}/database#read-and-write-connections). + +Each store reads its configuration once, when it is first used, and keeps it for the life of the worker. Configure your stores before defining features, and don't change `pennant.stores` while handling requests: [configuration](/docs/{{version}}/configuration) is shared by every request a worker handles, so stores that have already been built won't see the change. To store each request's values on a different connection, leave the store's `connection` set to `null` and select the connection with `DB::usingConnection()`. To keep each tenant's values apart in one table, use [partitioning](#partitioning). + +If you reconfigure stores while your application boots or within a test, call `Feature::forgetDrivers()` and then define your features again, since rebuilt stores start without feature definitions. Forget every store rather than just the one you changed, because database stores reading the same table also use each other's [shared cache](#shared-cache) settings. + + +## Defining Features + +To define a feature, you may use the `define` method offered by the `Feature` facade. You will need to provide a name for the feature, as well as a closure that will be invoked to resolve the feature's initial value. + +Typically, features are defined in a service provider using the `Feature` facade. The closure will receive the "scope" for the feature check. Most commonly, the scope is the currently authenticated user. In this example, we will define a feature for incrementally rolling out a new API to our application's users: + +```php + match (true) { + $user->isInternalTeamMember() => true, + $user->isHighTrafficCustomer() => false, + default => Lottery::odds(1 / 100), + }); + } +} +``` + +As you can see, we have the following rules for our feature: + +- All internal team members should be using the new API. +- Any high traffic customers should not be using the new API. +- Otherwise, the feature should be randomly assigned to users with a 1 in 100 chance of being active. + +The first time the `new-api` feature is checked for a given user, the result of the closure will be stored by the storage driver. The next time the feature is checked against the same user, the value will be retrieved from storage and the closure will not be invoked. + +For convenience, if a feature definition only returns a lottery, you may omit the closure completely: + + Feature::define('site-redesign', Lottery::odds(1, 1000)); + + +### Class Based Features + +Pennant also allows you to define class-based features. Unlike closure-based feature definitions, there is no need to register a class-based feature in a service provider. To create a class-based feature, you may invoke the `pennant:feature` Artisan command. By default, the feature class will be placed in your application's `app/Features` directory: + +```shell +php artisan pennant:feature NewApi +``` + +When writing a feature class, you only need to define a `resolve` method, which will be invoked to resolve the feature's initial value for a given scope. Again, the scope will typically be the currently authenticated user: + +```php +isInternalTeamMember() => true, + $user->isHighTrafficCustomer() => false, + default => Lottery::odds(1 / 100), + }; + } +} +``` + +If you would like to manually resolve an instance of a class-based feature, you may invoke the `instance` method on the `Feature` facade: + +```php +use App\Features\NewApi; +use Hypervel\Pennant\Feature; + +$instance = Feature::instance(NewApi::class); +``` + +> [!NOTE] +> Feature classes are resolved via the [container](/docs/{{version}}/container), so you may inject dependencies into the feature class's constructor when needed. By default, each feature class is resolved once and shared by every request a worker handles, so don't store request-specific state in its properties. + +#### Customizing the Stored Feature Name + +By default, Pennant will store the feature class's fully qualified class name. If you would like to decouple the stored feature name from the application's internal structure, you may add the `Name` attribute on the feature class. The value of this attribute will be stored in place of the class name: + +```php + +## Checking Features + +To determine if a feature is active, you may use the `active` method on the `Feature` facade. By default, features are checked against the currently authenticated user: + +```php +resolveNewApiResponse($request) + : $this->resolveLegacyApiResponse($request); + } + + // ... +} +``` + +Although features are checked against the currently authenticated user by default, you may easily check the feature against another user or [scope](#scope). To accomplish this, use the `for` method offered by the `Feature` facade: + +```php +return Feature::for($user)->active('new-api') + ? $this->resolveNewApiResponse($request) + : $this->resolveLegacyApiResponse($request); +``` + +Pennant also offers some additional convenience methods that may prove useful when determining if a feature is active or not: + +```php +// Determine if all of the given features are active... +Feature::allAreActive(['new-api', 'site-redesign']); + +// Determine if any of the given features are active... +Feature::someAreActive(['new-api', 'site-redesign']); + +// Determine if a feature is inactive... +Feature::inactive('new-api'); + +// Determine if all of the given features are inactive... +Feature::allAreInactive(['new-api', 'site-redesign']); + +// Determine if any of the given features are inactive... +Feature::someAreInactive(['new-api', 'site-redesign']); +``` + +> [!NOTE] +> When using Pennant outside of an HTTP context, such as in an Artisan command or a queued job, you should typically [explicitly specify the feature's scope](#specifying-the-scope). Alternatively, you may define a [default scope](#default-scope) that accounts for both authenticated HTTP contexts and unauthenticated contexts. + + +#### Checking Class Based Features + +For class-based features, you should provide the class name when checking the feature: + +```php +resolveNewApiResponse($request) + : $this->resolveLegacyApiResponse($request); + } + + // ... +} +``` + + +### Conditional Execution + +The `when` method may be used to fluently execute a given closure if a feature is active. Additionally, a second closure may be provided and will be executed if the feature is inactive: + +```php + $this->resolveNewApiResponse($request), + fn () => $this->resolveLegacyApiResponse($request), + ); + } + + // ... +} +``` + +The `unless` method serves as the inverse of the `when` method, executing the first closure if the feature is inactive: + +```php +return Feature::unless(NewApi::class, + fn () => $this->resolveLegacyApiResponse($request), + fn () => $this->resolveNewApiResponse($request), +); +``` + + +### The `HasFeatures` Trait + +Pennant's `HasFeatures` trait may be added to your application's `User` model (or any other model that has features) to provide a fluent, convenient way to check features directly from the model: + +```php +features()->active('new-api')) { + // ... +} +``` + +Of course, the `features` method provides access to many other convenient methods for interacting with features: + +```php +// Values... +$value = $user->features()->value('purchase-button'); +$values = $user->features()->values(['new-api', 'purchase-button']); + +// State... +$user->features()->active('new-api'); +$user->features()->allAreActive(['new-api', 'server-api']); +$user->features()->someAreActive(['new-api', 'server-api']); + +$user->features()->inactive('new-api'); +$user->features()->allAreInactive(['new-api', 'server-api']); +$user->features()->someAreInactive(['new-api', 'server-api']); + +// Conditional execution... +$user->features()->when('new-api', + fn () => /* ... */, + fn () => /* ... */, +); + +$user->features()->unless('new-api', + fn () => /* ... */, + fn () => /* ... */, +); +``` + + +### Blade Directive + +To make checking features in Blade a seamless experience, Pennant offers the `@feature` and `@featureany` directive: + +```blade +@feature('site-redesign') + +@else + +@endfeature + +@featureany(['site-redesign', 'beta']) + +@endfeatureany +``` + + +### Middleware + +Pennant also includes a [middleware](/docs/{{version}}/middleware) that may be used to verify the currently authenticated user has access to a feature before a route is even invoked. You may assign the middleware to a route and specify the features that are required to access the route. If any of the specified features are inactive for the currently authenticated user, a `400 Bad Request` HTTP response will be returned by the route. Multiple features may be passed to the static `using` method. + +```php +use Hypervel\Pennant\Middleware\EnsureFeaturesAreActive; +use Hypervel\Support\Facades\Route; + +Route::get('/api/servers', function () { + // ... +})->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api')); +``` + + +#### Customizing the Response + +If you would like to customize the response that is returned by the middleware when one of the listed features is inactive, you may use the `whenInactive` method provided by the `EnsureFeaturesAreActive` middleware. Typically, this method should be invoked within the `boot` method of one of your application's service providers: + +```php +use Hypervel\Http\Request; +use Hypervel\Http\Response; +use Hypervel\Pennant\Middleware\EnsureFeaturesAreActive; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + EnsureFeaturesAreActive::whenInactive( + function (Request $request, array $features) { + return new Response(status: 403); + } + ); + + // ... +} +``` + + +### Intercepting Feature Checks + +Sometimes it can be useful to perform some in-memory checks before retrieving the stored value of a given feature. Imagine you are developing a new API behind a feature flag and want the ability to disable the new API without losing any of the resolved feature values in storage. If you notice a bug in the new API, you could easily disable it for everyone except internal team members, fix the bug, and then re-enable the new API for the users that previously had access to the feature. + +You can achieve this with a [class-based feature's](#class-based-features) `before` method. When present, the `before` method is always run in-memory before retrieving the value from storage. If a non-`null` value is returned from the method, it will be used in place of the feature's stored value for the duration of the request: + +```php +isInternalTeamMember(); + } + + return null; + } + + /** + * Resolve the feature's initial value. + */ + public function resolve(User $user): mixed + { + return match (true) { + $user->isInternalTeamMember() => true, + $user->isHighTrafficCustomer() => false, + default => Lottery::odds(1 / 100), + }; + } +} +``` + +You could also use this feature to schedule the global rollout of a feature that was previously behind a feature flag: + +```php +isInternalTeamMember(); + } + + if (Date::parse(Config::string('features.new-api.rollout-date'))->isPast()) { + return true; + } + + return null; + } + + // ... +} +``` + +Since the `before` method's result is never stored, it is also a good place to defer to another source of truth, such as your billing or entitlement service, when your application has one. Pennant doesn't provide an entitlement system of its own. + + +### In-Memory Cache + +When checking a feature, Pennant will create an in-memory cache of the result. If you are using the `database` driver, this means that re-checking the same feature flag within a single request will not trigger additional database queries. This also ensures that the feature has a consistent result for the duration of the request. + +The in-memory cache belongs to the current coroutine. Since Hypervel handles each request and each job processed by a queue worker in its own coroutine, concurrent requests handled by the same worker never see each other's values. A child coroutine starts with an empty cache, unless it copies its parent's context, in which case it starts with a copy of the parent's cache. The `array` driver's stored values follow the same rules, so they only last for the current request or job. + +For `database` stores, values cached while a database transaction is open are forgotten if the transaction rolls back, and they aren't copied to child coroutines until it commits. + +If you need to manually flush the in-memory cache, you may use the `flushCache` method offered by the `Feature` facade. Only the current coroutine's cache is flushed: + +```php +Feature::flushCache(); +``` + + +## Scope + + +### Specifying the Scope + +As discussed, features are typically checked against the currently authenticated user. However, this may not always suit your needs. Therefore, it is possible to specify the scope you would like to check a given feature against via the `Feature` facade's `for` method: + +```php +return Feature::for($user)->active('new-api') + ? $this->resolveNewApiResponse($request) + : $this->resolveLegacyApiResponse($request); +``` + +Of course, feature scopes are not limited to "users". Imagine you have built a new billing experience that you are rolling out to entire teams rather than individual users. Perhaps you would like the oldest teams to have a slower rollout than the newer teams. Your feature resolution closure might look something like the following: + +```php +use App\Models\Team; +use Hypervel\Pennant\Feature; +use Hypervel\Support\Facades\Date; +use Hypervel\Support\Lottery; + +Feature::define('billing-v2', function (Team $team) { + if ($team->created_at->isAfter(Date::parse('1st Jan, 2023'))) { + return true; + } + + if ($team->created_at->isAfter(Date::parse('1st Jan, 2019'))) { + return Lottery::odds(1 / 100); + } + + return Lottery::odds(1 / 1000); +}); +``` + +You will notice that the closure we have defined is not expecting a `User`, but is instead expecting a `Team` model. To determine if this feature is active for a user's team, you should pass the team to the `for` method offered by the `Feature` facade: + +```php +if (Feature::for($user->team)->active('billing-v2')) { + return redirect('/billing/v2'); +} + +// ... +``` + + +### Global Scope + +To check or interact with a feature using a global scope, regardless of the configured default scope resolver, use the `globally` method. This is useful for application-wide feature flags, such as temporarily enabling maintenance behavior or rolling out a feature to every user: + +```php +Feature::globally()->active('new-api'); + +Feature::globally()->activate('new-api'); +``` + + +### Default Scope + +It is also possible to customize the default scope Pennant uses to check features. For example, maybe all of your features are checked against the currently authenticated user's team instead of the user. Instead of having to call `Feature::for($user->team)` every time you check a feature, you may instead specify the team as the default scope. Typically, this should be done in one of your application's service providers: + +```php + Auth::user()?->team); + + // ... + } +} +``` + +If no scope is explicitly provided via the `for` method, the feature check will now use the currently authenticated user's team as the default scope: + +```php +Feature::active('billing-v2'); + +// Is now equivalent to... + +Feature::for($user->team)->active('billing-v2'); +``` + + +### Nullable Scope + +If the scope you provide when checking a feature is `null` and the feature's definition does not support `null` via a nullable type or by including `null` in a union type, Pennant will automatically return `false` as the feature's result value. This `false` result is stored like any other value, so if you later change the definition to support `null`, [purge](#purging-features) the feature or `forget` its stored value for the `null` scope. + +So, if the scope you are passing to a feature is potentially `null` and you want the feature's value resolver to be invoked, you should account for that in your feature's definition. A `null` scope may occur if you check a feature within an Artisan command, queued job, or unauthenticated route. Since there is usually not an authenticated user in these contexts, the default scope will be `null`. + +If you do not always [explicitly specify your feature scope](#specifying-the-scope) then you should ensure the scope's type is "nullable" and handle the `null` scope value within your feature definition logic: + +```php +use App\Models\User; +use Hypervel\Pennant\Feature; +use Hypervel\Support\Lottery; + +Feature::define('new-api', fn (User $user) => match (true) {// [tl! remove] +Feature::define('new-api', fn (User|null $user) => match (true) {// [tl! add] + $user === null => true,// [tl! add] + $user->isInternalTeamMember() => true, + $user->isHighTrafficCustomer() => false, + default => Lottery::odds(1 / 100), +}); +``` + + +### Identifying Scope + +Pennant identifies each scope by a string. `null`, strings, numbers, and saved Eloquent models are converted automatically, and any other object used as a scope must implement the `Hypervel\Pennant\Contracts\FeatureScopeSerializeable` contract, whose `featureScopeSerialize` method returns a string that uniquely identifies it. Other values, such as arrays and booleans, throw an exception. However, if your application utilizes a third-party Pennant driver, that driver may not know how to properly store an identifier for an Eloquent model or other custom types in your application. + +In light of this, Pennant allows you to format scope values for storage by implementing the `FeatureScopeable` contract on the objects in your application that are used as Pennant scopes. + +For example, imagine you are using two different feature drivers in a single application: the built-in `database` driver and a third-party "Flag Rocket" driver. The "Flag Rocket" driver does not know how to properly store an Eloquent model. Instead, it requires a `FlagRocketUser` instance. By implementing the `toFeatureIdentifier` method defined by the `FeatureScopeable` contract, we can customize the storable scope value provided to each store used by our application. The method receives the name of the store being used: + +```php + $this, + 'flag-rocket' => FlagRocketUser::fromId($this->flag_rocket_id), + }; + } +} +``` + +Since Pennant's in-memory cache also identifies scopes by a string, the `FlagRocketUser` instance must implement the `FeatureScopeSerializeable` contract. + + +### Serializing Scope + +Pennant identifies an Eloquent model scope by its class and key, so a model must have a key before you use it as a scope. Checking a feature for an unsaved model without a key throws an exception, since every unsaved model would otherwise share the same stored value. Save the model first, or pass a scope that identifies it. + +By default, Pennant will use a fully qualified class name when storing a feature associated with an Eloquent model. If you are already using an [Eloquent morph map](/docs/{{version}}/eloquent-relationships#custom-polymorphic-types), you may choose to have Pennant also use the morph map to decouple the stored feature from your application structure. + +To achieve this, after defining your Eloquent morph map in a service provider, you may invoke the `Feature` facade's `useMorphMap` method: + +```php +use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Pennant\Feature; + +Relation::enforceMorphMap([ + 'post' => 'App\Models\Post', + 'video' => 'App\Models\Video', +]); + +Feature::useMorphMap(); +``` + + +## Rich Feature Values + +Until now, we have primarily shown features as being in a binary state, meaning they are either "active" or "inactive", but Pennant also allows you to store rich values as well. + +For example, imagine you are testing three new colors for the "Buy now" button of your application. Instead of returning `true` or `false` from the feature definition, you may instead return a string: + +```php +use App\Models\User; +use Hypervel\Pennant\Feature; +use Hypervel\Support\Arr; + +Feature::define('purchase-button', fn (User $user) => Arr::random([ + 'blue-sapphire', + 'seafoam-green', + 'tart-orange', +])); +``` + +You may retrieve the value of the `purchase-button` feature using the `value` method: + +```php +$color = Feature::value('purchase-button'); +``` + +Pennant's included Blade directive also makes it easy to conditionally render content based on the current value of the feature: + +```blade +@feature('purchase-button', 'blue-sapphire') + +@elsefeature('purchase-button', 'seafoam-green') + +@elsefeature('purchase-button', 'tart-orange') + +@endfeature +``` + +> [!NOTE] +> When using rich values, it is important to know that a feature is considered "active" when it has any value other than `false`. + +When calling the [conditional `when`](#conditional-execution) method, the feature's rich value will be provided to the first closure: + +```php +Feature::when('purchase-button', + fn ($color) => /* ... */, + fn () => /* ... */, +); +``` + +Likewise, when calling the conditional `unless` method, the feature's rich value will be provided to the optional second closure: + +```php +Feature::unless('purchase-button', + fn () => /* ... */, + fn ($color) => /* ... */, +); +``` + + +## Retrieving Multiple Features + +The `values` method allows the retrieval of multiple features for a given scope: + +```php +Feature::values(['billing-v2', 'purchase-button']); + +// [ +// 'billing-v2' => false, +// 'purchase-button' => 'blue-sapphire', +// ] +``` + +Or, you may use the `all` method to retrieve the values of all defined features for a given scope: + +```php +Feature::all(); + +// [ +// 'billing-v2' => false, +// 'purchase-button' => 'blue-sapphire', +// 'site-redesign' => true, +// ] +``` + +However, class-based features are dynamically registered and are not known by Pennant until they are explicitly checked. Since a registered class stays registered for the life of the worker, the results returned by the `all` method may include class-based features first checked by an earlier request on the same worker, while leaving out those that haven't been checked yet. + +If you would like the `all` method to always include the same feature classes, you may use Pennant's feature discovery capabilities. To get started, invoke the `discover` method in one of your application's service providers: + +```php + true, +// 'billing-v2' => false, +// 'purchase-button' => 'blue-sapphire', +// 'site-redesign' => true, +// ] +``` + + +## Eager Loading + +Although Pennant keeps an in-memory cache of all resolved features for a single request, it is still possible to encounter performance issues. To alleviate this, Pennant offers the ability to eager load feature values. + +To illustrate this, imagine that we are checking if a feature is active within a loop: + +```php +use Hypervel\Pennant\Feature; + +foreach ($users as $user) { + if (Feature::for($user)->active('notifications-beta')) { + $user->notify(new RegistrationSuccess); + } +} +``` + +Assuming we are using the database driver, this code will execute a database query for every user in the loop - executing potentially hundreds of queries. However, using Pennant's `load` method, we can remove this potential performance bottleneck by eager loading the feature values for a collection of users or scopes: + +```php +Feature::for($users)->load(['notifications-beta']); + +foreach ($users as $user) { + if (Feature::for($user)->active('notifications-beta')) { + $user->notify(new RegistrationSuccess); + } +} +``` + +To load feature values only when they have not already been loaded, you may use the `loadMissing` method: + +```php +Feature::for($users)->loadMissing([ + 'new-api', + 'purchase-button', + 'notifications-beta', +]); +``` + +You may load all defined features using the `loadAll` method: + +```php +Feature::for($users)->loadAll(); +``` + + +## Updating Values + +When a feature's value is resolved for the first time, the underlying driver will store the result in storage. This is often necessary to ensure a consistent experience for your users across requests. However, at times, you may want to manually update the feature's stored value. + +To accomplish this, you may use the `activate` and `deactivate` methods to toggle a feature "on" or "off": + +```php +use Hypervel\Pennant\Feature; + +// Activate the feature for the default scope... +Feature::activate('new-api'); + +// Deactivate the feature for the given scope... +Feature::for($user->team)->deactivate('billing-v2'); +``` + +It is also possible to manually set a rich value for a feature by providing a second argument to the `activate` method: + +```php +Feature::activate('purchase-button', 'seafoam-green'); +``` + +To instruct Pennant to forget the stored value for a feature, you may use the `forget` method. When the feature is checked again, Pennant will resolve the feature's value from its feature definition: + +```php +Feature::forget('purchase-button'); +``` + + +### Bulk Updates + +To update stored feature values in bulk, you may use the `activateForEveryone` and `deactivateForEveryone` methods. + +For example, imagine you are now confident in the `new-api` feature's stability and have landed on the best `'purchase-button'` color for your checkout flow - you can update the stored value for all users accordingly: + +```php +use Hypervel\Pennant\Feature; + +Feature::activateForEveryone('new-api'); + +Feature::activateForEveryone('purchase-button', 'seafoam-green'); +``` + +Alternatively, you may deactivate the feature for all users: + +```php +Feature::deactivateForEveryone('new-api'); +``` + +> [!NOTE] +> This will only update the resolved feature values that have been stored by Pennant's storage driver. You will also need to update the feature definition in your application. + + +### Purging Features + +Sometimes, it can be useful to purge an entire feature from storage. This is typically necessary if you have removed the feature from your application or you have made adjustments to the feature's definition that you would like to rollout to all users. + +You may remove all stored values for a feature using the `purge` method: + +```php +// Purging a single feature... +Feature::purge('new-api'); + +// Purging multiple features... +Feature::purge(['new-api', 'purchase-button']); +``` + +If you would like to purge _all_ features from storage, you may invoke the `purge` method without any arguments: + +```php +Feature::purge(); +``` + +As it can be useful to purge features as part of your application's deployment pipeline, Pennant includes a `pennant:purge` Artisan command which will purge the provided features from storage: + +```shell +php artisan pennant:purge new-api + +php artisan pennant:purge new-api purchase-button +``` + +It is also possible to purge all features _except_ those in a given feature list. For example, imagine you wanted to purge all features but keep the values for the "new-api" and "purchase-button" features in storage. To accomplish this, you can pass those feature names to the `--except` option: + +```shell +php artisan pennant:purge --except=new-api --except=purchase-button +``` + +For convenience, the `pennant:purge` command also supports an `--except-registered` flag. This flag indicates that all features except those explicitly registered in a service provider should be purged: + +```shell +php artisan pennant:purge --except-registered +``` + + +## Partitioning + +Partitioning keeps the feature values of separate tenants, workspaces, or teams apart while storing them in the same table. Once you register a partition, Pennant applies the current partition value to every read and write, including first resolutions, updates, bulk updates, purges, the in-memory cache, the `array` driver, and the [shared cache](#shared-cache). [Global scope](#global-scope) values belong to a partition too, so `Feature::globally()` reads and writes the current partition's value. + + +### Registering a Partition + +You may register a partition using the `Feature` facade's `resolvePartitionUsing` method. Pennant must know about the partition before any store is used, so register it in the `register` method of one of your application's service providers. Registering it after a store has been resolved throws an exception: + +```php +use Hypervel\Pennant\Feature; +use Hypervel\Support\Facades\Context; + +/** + * Register any application services. + */ +public function register(): void +{ + Feature::resolvePartitionUsing('team_id', fn (string $store) => Context::get('team_id')); +} +``` + +The first argument is the partition column, which must be a simple SQL identifier. The closure receives the store name and returns the current partition value. Since it runs every time a feature is checked, it should read a value your application has already placed in the current request's, job's, or command's [context](/docs/{{version}}/context) rather than query the database. It may return an integer or a non-empty string. When it returns `null` or an empty string, Pennant throws a `Hypervel\Pennant\Exceptions\FeaturePartitionNotResolved` exception instead of reading or writing values outside a partition. + +Purging features, including with the `pennant:purge` command, only affects the current partition, so a partition value must be available when the command runs. To purge a feature from every partition, run the purge once within each partition. + +If some flags should be shared by every partition, such as flags for your application's central administration area, you may give them their own store with `partitioned` set to `false`. Since that store doesn't write a partition value, it needs a table of its own: + +```php +'stores' => [ + 'central' => [ + 'driver' => 'database', + 'table' => 'central_features', + 'partitioned' => false, + ], + + // ... +], +``` + +Partitioning applies to the built-in `array` and `database` drivers. A [custom driver](#adding-custom-pennant-drivers) is responsible for keeping each partition's values apart in its own storage. + + +### Partitioned Schema + +Pennant's migration is not partitioned. When you use partitioning, add a non-null partition column to the published migration and include it in the table's unique index in place of the `name` and `scope` index: + +```php +Schema::create('features', function (Blueprint $table) { + $table->id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + + // ... + + $table->unique(['team_id', 'name', 'scope']); +}); +``` + +Use a column type that matches your partition values, such as `foreignUuid` for UUIDs. + + +## Shared Cache + +The `database` driver can share stored values between your servers through a [cache](/docs/{{version}}/cache) store, so checking a feature that isn't already in the [in-memory cache](#in-memory-cache) doesn't need a database query. A cache hit runs no query and doesn't take a connection from the database pool. To enable the shared cache, set the store's `cache` option to an array containing the name of a cache `store` and, optionally, a `ttl` in seconds: + +```php +'database' => [ + 'driver' => 'database', + 'connection' => null, + 'table' => 'features', + 'cache' => [ + 'store' => 'redis', + 'ttl' => 3600, + ], +], +``` + +The `ttl` defaults to one hour and must be a positive integer. + +The database remains the source of truth. Only values stored in the database are cached: values returned by [`before` methods](#intercepting-feature-checks) and the `false` result of unknown features are never cached. When a cached value expires or is evicted, Pennant reads it from the database again rather than resolving it again, so the cache never changes a feature's stored value. + +Updating values through any `database` store invalidates the cached values of every store that reads the same table on the same connection, even when the store making the change has no cache of its own. Invalidation happens after the surrounding database transaction commits. Changes made to the table without Pennant, such as manual queries, aren't seen until the cached values expire. While a transaction is open, Pennant reads from the database and doesn't use the shared cache, so it never caches values that might be rolled back. + +A store whose `connection` is `null` shares invalidation with stores on every connection, since it may use any connection selected with `DB::usingConnection()`. A connection's `::write` side shares cached values with the connection itself. If several connection names point to the same database, use the same name for all of your Pennant stores, since Pennant can't tell that they share data. + +If invalidating the cache fails after a change has been committed, Pennant throws an exception, and other servers may keep reading the old value until it expires. The same applies if a worker stops between committing a change and invalidating the cache. The `ttl` limits how long such a value may be served. When you use a [cache stack](/docs/{{version}}/cache#building-cache-stacks) with a [Swoole table](/docs/{{version}}/cache#swoole-table-cache) layer, each server's layer may also keep serving a value until that layer's TTL expires, so use a Redis store when changes must reach every server promptly. + + +## Testing + +When testing code that interacts with feature flags, the easiest way to control the feature flag's returned value in your tests is to simply re-define the feature. For example, imagine you have the following feature defined in one of your application's service provider: + +```php +use Hypervel\Pennant\Feature; +use Hypervel\Support\Arr; + +Feature::define('purchase-button', fn () => Arr::random([ + 'blue-sapphire', + 'seafoam-green', + 'tart-orange', +])); +``` + +To modify the feature's returned value in your tests, you may re-define the feature at the beginning of the test. The following test will always pass, even though the `Arr::random()` implementation is still present in the service provider: + +```php tab=Pest +use Hypervel\Pennant\Feature; + +test('it can control feature values', function () { + Feature::define('purchase-button', 'seafoam-green'); + + expect(Feature::value('purchase-button'))->toBe('seafoam-green'); +}); +``` + +```php tab=PHPUnit +use Hypervel\Pennant\Feature; + +public function test_it_can_control_feature_values() +{ + Feature::define('purchase-button', 'seafoam-green'); + + $this->assertSame('seafoam-green', Feature::value('purchase-button')); +} +``` + +The same approach may be used for class-based features: + +```php tab=Pest +use App\Features\NewApi; +use Hypervel\Pennant\Feature; + +test('it can control feature values', function () { + Feature::define(NewApi::class, true); + + expect(Feature::value(NewApi::class))->toBeTrue(); +}); +``` + +```php tab=PHPUnit +use App\Features\NewApi; +use Hypervel\Pennant\Feature; + +public function test_it_can_control_feature_values() +{ + Feature::define(NewApi::class, true); + + $this->assertTrue(Feature::value(NewApi::class)); +} +``` + +If your feature is returning a `Lottery` instance, there are a handful of useful [testing helpers available](/docs/{{version}}/helpers#testing-lotteries). + + +#### Store Configuration + +You may configure the store that Pennant will use during testing by defining the `PENNANT_STORE` environment variable in your application's `phpunit.xml` file: + +```xml + + + + + + + + +``` + + +## Adding Custom Pennant Drivers + + +#### Implementing the Driver + +If none of Pennant's existing storage drivers fit your application's needs, you may write your own storage driver. Your custom driver should implement the `Hypervel\Pennant\Contracts\Driver` interface: + +```php + [!NOTE] +> Hypervel does not ship with a directory to contain your extensions. You are free to place them anywhere you like. In this example, we have created an `Extensions` directory to house the `RedisFeatureDriver`. + + +#### Registering the Driver + +Once your driver has been implemented, you are ready to register it with Hypervel. To add additional drivers to Pennant, you may use the `extend` method provided by the `Feature` facade. You should call the `extend` method from the `boot` method of one of your application's [service provider](/docs/{{version}}/providers): + +```php +make('redis'), $app->make('events'), []); + }); + } +} +``` + +Once the driver has been registered, you may use the `redis` driver in your application's `config/pennant.php` configuration file: + +```php +'stores' => [ + + 'redis' => [ + 'driver' => 'redis', + 'connection' => null, + ], + + // ... + +], +``` + + +### Defining Features Externally + +If your driver is a wrapper around a third-party feature flag platform, you will likely define features on the platform rather than using Pennant's `Feature::define` method. If that is the case, your custom driver should also implement the `Hypervel\Pennant\Contracts\DefinesFeaturesExternally` interface: + +```php + +## Events + +Pennant dispatches a variety of events that can be useful when tracking feature flags throughout your application. + +### `Hypervel\Pennant\Events\FeatureRetrieved` + +This event is dispatched whenever a [feature is checked](#checking-features). This event may be useful for creating and tracking metrics against a feature flag's usage throughout your application. + +### `Hypervel\Pennant\Events\FeatureResolved` + +This event is dispatched the first time a feature's value is resolved for a specific scope. + +### `Hypervel\Pennant\Events\UnknownFeatureResolved` + +This event is dispatched the first time an unknown feature is resolved for a specific scope. Listening to this event may be useful if you have intended to remove a feature flag but have accidentally left stray references to it throughout your application: + +```php +feature}]."); + }); + } +} +``` + +### `Hypervel\Pennant\Events\DynamicallyRegisteringFeatureClass` + +This event is dispatched when a [class-based feature](#class-based-features) is dynamically checked for the first time through a store. Since stores are shared by every request a worker handles, it is dispatched once per worker rather than once per request. + +### `Hypervel\Pennant\Events\UnexpectedNullScopeEncountered` + +This event is dispatched when a `null` scope is passed to a feature definition that [doesn't support null](#nullable-scope). + +This situation is handled gracefully and the feature will return `false`. However, if you would like to opt out of this feature's default graceful behavior, you may register a listener for this event in the `boot` method of your application's `AppServiceProvider`: + +```php +use Hypervel\Pennant\Events\UnexpectedNullScopeEncountered; +use Hypervel\Support\Facades\Event; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + Event::listen(UnexpectedNullScopeEncountered::class, fn () => abort(500)); +} +``` + +### `Hypervel\Pennant\Events\FeatureUpdated` + +This event is dispatched when updating a feature for a scope, usually by calling `activate` or `deactivate`. + +### `Hypervel\Pennant\Events\FeatureUpdatedForAllScopes` + +This event is dispatched when updating a feature for all scopes, usually by calling `activateForEveryone` or `deactivateForEveryone`. + +### `Hypervel\Pennant\Events\FeatureDeleted` + +This event is dispatched when deleting a feature for a scope, usually by calling `forget`. + +### `Hypervel\Pennant\Events\FeaturesPurged` + +This event is dispatched when purging specific features. + +### `Hypervel\Pennant\Events\AllFeaturesPurged` + +This event is dispatched when purging all features. + + +## Credits + +Hypervel Pennant began as a port of [Laravel Pennant](https://github.com/laravel/pennant) and has been adapted for Hypervel's framework architecture and coroutine runtime. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 6a6b6081b..b9b8da7fc 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -35,6 +35,7 @@ - [CSRF Protection](#csrf-protection) - [Fortify](#fortify) - [Passport](#passport) + - [Pennant](#pennant) - [Permission](#permission) - [JWT Authentication](#jwt-authentication) - [Scout](#scout) @@ -629,6 +630,11 @@ Restart the server and other long-running processes after changing file or envir Custom guards, controllers, middleware and cookie-factory subclasses must match Hypervel's constructor and protected method signatures. In particular, guards read `RequestContext` instead of accepting `setRequest()`, `CreateFreshApiToken` passes its guard explicitly, and server-building methods live on `ServerFactory`. Consult the package's [API differences](https://github.com/hypervel/passport#differences-from-laravel) and [Passport documentation](/docs/{{version}}/passport) when porting extensions. + +### Pennant + +Replace `laravel/pennant` with `hypervel/pennant` and the `Laravel\Pennant` namespace with `Hypervel\Pennant`. Use Hypervel's Pennant migration, or convert an existing table's `name` and `scope` columns to match it: on MySQL and MariaDB, it uses binary columns so names and scopes that differ only by case, accents, or trailing spaces are stored separately. Checking a feature for an Eloquent model without a key throws an exception, so pass a saved model or an explicit scope. Changing `pennant.stores` configuration at runtime has no effect on stores that have already been resolved; see [configuration](/docs/{{version}}/pennant#configuration). Subclasses of Pennant's drivers, decorator, or manager must match Hypervel's constructors and protected methods; see the package's [API differences](https://github.com/hypervel/pennant#differences-from-laravel). + ### Permission diff --git a/src/pennant/README.md b/src/pennant/README.md index bf8bfd869..f8c95d9a7 100644 --- a/src/pennant/README.md +++ b/src/pennant/README.md @@ -9,8 +9,8 @@ Documentation: https://hypervel.org/docs/pennant Eloquent models without a key can't be used as feature scopes. Laravel stores every unsaved model under the same scope identifier, so they all share one stored value. Hypervel throws a `RuntimeException` instead; save the model first or pass an explicit scope identifier. -Stores read their `pennant.stores` configuration when they're first resolved. Laravel's database store rereads its connection and table on every query, so changing config at runtime takes effect there. In Hypervel, config is shared by every request in a worker, so it should only change during boot or in tests, followed by `Feature::forgetDrivers()` and redefining your features. To choose a connection per request, leave the store's `connection` null and use `DB::usingConnection()`. To keep tenants apart in a shared table, use partitioning. +Stores read their `pennant.stores` configuration once, when they're first resolved, while Laravel's database store rereads its connection and table on every query. Config is shared by every request in a Hypervel worker, so change it only during boot or in tests, then call `Feature::forgetDrivers()` and define your features again. See the [configuration documentation](https://hypervel.org/docs/pennant#configuration) for per-request connections and partitioning. -The built-in drivers and the store decorator take different constructor arguments than Laravel's, because feature values live in coroutine state and stores can be partitioned. Subclasses of `ArrayDriver`, `DatabaseDriver` or `Decorator` need updating; custom drivers that implement the `Driver` contract are unaffected. +The built-in drivers and the store decorator take different constructor arguments than Laravel's, and some of their protected methods differ, because feature values live in coroutine state and stores can be partitioned. Subclasses of `ArrayDriver`, `DatabaseDriver`, `Decorator` or `FeatureManager` that override them need updating; custom drivers that implement the `Driver` contract are unaffected. Ported from: https://github.com/laravel/pennant From b882ab1ebc6cb785e0d78c0ebb0c11c1b710cdb9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:20:16 +0000 Subject: [PATCH 25/39] Update the Pennant port plan after checkpoint 4 Steps 6 and 7 are complete. The next step is the step 8 verification and the full package review. --- docs/plans/2026-10-21-0030-pennant-port.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-0030-pennant-port.md b/docs/plans/2026-10-21-0030-pennant-port.md index b199a5dd0..cc4e62cf8 100644 --- a/docs/plans/2026-10-21-0030-pennant-port.md +++ b/docs/plans/2026-10-21-0030-pennant-port.md @@ -2,7 +2,7 @@ Port `laravel/pennant` (reference: `/home/binaryfire/workspace/references/laravel/pennant`, commit `d9ea593`) as `hypervel/pennant` (`src/pennant`, namespace `Hypervel\Pennant`). New package port: follow AGENTS.md *Porting Packages* (cp each file, alphabetical, one at a time) plus the adaptations below. -Status: implementation authorized. Steps 1–5 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`, upstream test ports, new tests, integration directories and workflow) are reviewed and committed; `src/docs/pennant.md` is copied from Laravel, unadapted and uncommitted. Next step: the Sentry integration (step 6). Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. +Status: implementation authorized. Steps 1–5 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`, upstream test ports, new tests, integration directories and workflow) and steps 6 (Sentry) and 7 (documentation) are reviewed and committed. Next step: step 8 verification, then the full package review. Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. ## Owner decisions (approved) From 36807c688a27b84e3f8666480d3b2fa3d0aaa564 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:47:29 +0000 Subject: [PATCH 26/39] Narrow the Pennant port plan's integration checks Step 8 named the shared database test script, which reruns the whole database integration suite and every package's engine directories. The change only adds Pennant's engine and Redis directories, so the step now names those. Verification is complete; the next step is the full package review. --- docs/plans/2026-10-21-0030-pennant-port.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-10-21-0030-pennant-port.md b/docs/plans/2026-10-21-0030-pennant-port.md index cc4e62cf8..67834abd0 100644 --- a/docs/plans/2026-10-21-0030-pennant-port.md +++ b/docs/plans/2026-10-21-0030-pennant-port.md @@ -2,7 +2,7 @@ Port `laravel/pennant` (reference: `/home/binaryfire/workspace/references/laravel/pennant`, commit `d9ea593`) as `hypervel/pennant` (`src/pennant`, namespace `Hypervel\Pennant`). New package port: follow AGENTS.md *Porting Packages* (cp each file, alphabetical, one at a time) plus the adaptations below. -Status: implementation authorized. Steps 1–5 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`, upstream test ports, new tests, integration directories and workflow) and steps 6 (Sentry) and 7 (documentation) are reviewed and committed. Next step: step 8 verification, then the full package review. Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. +Status: implementation authorized. Steps 1–5 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`, upstream test ports, new tests, integration directories and workflow) and steps 6 (Sentry) and 7 (documentation) are reviewed and committed. Step 8 verification is done. Next step: the full package review. Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. ## Owner decisions (approved) @@ -278,4 +278,4 @@ Tenancy (private): register `Feature::resolvePartitionUsing('tenant_id', fn () = 5. Tests per file, then new tests and integration directories/workflows. 6. Sentry integration and test. 7. Finish `pennant.md`, README, porting guide, todo cleanup, facade docblock, `AfterEachTestSubscriber`. -8. Verification (sequential): each test file runs as it is written; then `composer fix` (the StackStore and test-subscriber changes reach beyond Pennant, and it already runs lint, analysis and all suites); then `./bin/run-database-tests.sh pgsql|mysql|mariadb` and the Redis integration directories. No benchmarks without owner confirmation. +8. Verification (sequential): each test file runs as it is written; then `composer fix` (the StackStore and test-subscriber changes reach beyond Pennant, and it already runs lint, analysis and all suites); then Pennant's integration directories (`tests/Integration/Pennant/Database/{Postgres,MySql,MariaDb}` on each engine and `tests/Integration/Pennant/Redis`); nothing else in the change touches database or Redis integration code. No benchmarks without owner confirmation. From ac45a893ab8aaa67ad9a1d9381da546313f3b64c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:50:28 +0000 Subject: [PATCH 27/39] Mark the Pennant port plan complete Every step is implemented, verified and committed, and the full package review is signed off. The next step is owner review. --- docs/plans/2026-10-21-0030-pennant-port.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-21-0030-pennant-port.md b/docs/plans/2026-10-21-0030-pennant-port.md index 67834abd0..7cd31417c 100644 --- a/docs/plans/2026-10-21-0030-pennant-port.md +++ b/docs/plans/2026-10-21-0030-pennant-port.md @@ -2,7 +2,7 @@ Port `laravel/pennant` (reference: `/home/binaryfire/workspace/references/laravel/pennant`, commit `d9ea593`) as `hypervel/pennant` (`src/pennant`, namespace `Hypervel\Pennant`). New package port: follow AGENTS.md *Porting Packages* (cp each file, alphabetical, one at a time) plus the adaptations below. -Status: implementation authorized. Steps 1–5 (AGENTS.md rule, `StackStore` batching, skeleton, source port, `AfterEachTestSubscriber`, upstream test ports, new tests, integration directories and workflow) and steps 6 (Sentry) and 7 (documentation) are reviewed and committed. Step 8 verification is done. Next step: the full package review. Checkpoint reviews with codex-pennant at coherent slices, then a full package review at the end. +Status: complete. Steps 1–8 are implemented, verified and committed, and the full package review is signed off. Next step: owner review. ## Owner decisions (approved) From 87cc30923de448c082a95b8bddc4384452053d0f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:50:58 +0000 Subject: [PATCH 28/39] Match the Pennant engine tests to each server's behavior The upsert alias test enabled use_upsert_alias on every MySQL server, but the INSERT ... AS alias syntax only exists from MySQL 8.0.19, so MySQL 5.7 rejected it with a syntax error. The test now requires 8.0.19 or later. The REPEATABLE READ winner test was shared by MySQL and MariaDB. MariaDB 11.6.2 and later turn innodb_snapshot_isolation on by default, and with it a REPEATABLE READ transaction that runs into a row committed after its snapshot fails with error 1020. That is the same contract as PostgreSQL's serialization failure: the caller's transaction has to restart, and the framework already treats the error as retryable. The driver is right to let it through, so only the test was wrong. The shared test now lives in the MySQL test class. MariaDB gets two tests that set the variable explicitly: with it off, the locking reread returns the concurrent winner; with it on, the 1020 error reaches the caller. Releases that predate the variable behave as if it were off, so the first test runs there without setting it and the second is skipped. --- .../Database/MariaDb/DatabaseDriverTest.php | 44 +++++++++++++++++++ .../Database/MySql/DatabaseDriverTest.php | 11 +++++ .../MySqlFamilyDatabaseDriverTestCase.php | 10 ----- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php b/tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php index 21915d402..ae89ee899 100644 --- a/tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php +++ b/tests/Integration/Pennant/Database/MariaDb/DatabaseDriverTest.php @@ -4,10 +4,54 @@ namespace Hypervel\Tests\Integration\Pennant\Database\MariaDb; +use Hypervel\Database\QueryException; +use Hypervel\Pennant\Feature; +use Hypervel\Support\Facades\DB; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\Pennant\Database\MySqlFamilyDatabaseDriverTestCase; #[RequiresDatabase('mariadb')] class DatabaseDriverTest extends MySqlFamilyDatabaseDriverTestCase { + public function testConcurrentWinnerIsReturnedInsideARepeatableReadTransactionWithoutSnapshotIsolation(): void + { + Feature::define('foo', fn () => 'candidate'); + DB::statement('set session transaction isolation level repeatable read'); + + // Releases without the setting behave as if it were off. + if ($this->hasSnapshotIsolationSetting()) { + DB::statement('set session innodb_snapshot_isolation = off'); + } + + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $this->assertSame('winner', DB::transaction(fn () => Feature::for('tim')->value('foo'))); + } + + public function testSnapshotConflictsInsideARepeatableReadTransactionPropagate(): void + { + if (! $this->hasSnapshotIsolationSetting()) { + $this->markTestSkipped('This MariaDB release has no innodb_snapshot_isolation setting.'); + } + + Feature::define('foo', fn () => 'candidate'); + DB::statement('set session transaction isolation level repeatable read'); + DB::statement('set session innodb_snapshot_isolation = on'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + try { + DB::transaction(fn () => Feature::for('tim')->value('foo')); + $this->fail('The insert should have conflicted with the transaction snapshot.'); + } catch (QueryException $exception) { + $this->assertSame(1020, $exception->errorInfo[1]); + } + } + + /** + * Determine whether the server has the innodb_snapshot_isolation setting. + */ + protected function hasSnapshotIsolationSetting(): bool + { + return DB::selectOne("show variables like 'innodb_snapshot_isolation'") !== null; + } } diff --git a/tests/Integration/Pennant/Database/MySql/DatabaseDriverTest.php b/tests/Integration/Pennant/Database/MySql/DatabaseDriverTest.php index 41513fcc7..f37f92c61 100644 --- a/tests/Integration/Pennant/Database/MySql/DatabaseDriverTest.php +++ b/tests/Integration/Pennant/Database/MySql/DatabaseDriverTest.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Pennant\Feature; +use Hypervel\Support\Facades\DB; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\Integration\Pennant\Database\MySqlFamilyDatabaseDriverTestCase; @@ -13,7 +14,17 @@ #[RequiresDatabase('mysql')] class DatabaseDriverTest extends MySqlFamilyDatabaseDriverTestCase { + public function testConcurrentWinnerIsReturnedInsideARepeatableReadTransaction(): void + { + Feature::define('foo', fn () => 'candidate'); + DB::statement('set session transaction isolation level repeatable read'); + $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); + + $this->assertSame('winner', DB::transaction(fn () => Feature::for('tim')->value('foo'))); + } + #[DefineEnvironment('usingTheUpsertAlias')] + #[RequiresDatabase('mysql', '>=8.0.19')] public function testConcurrentWinnerIsReturnedWithTheUpsertAlias(): void { Feature::define('foo', fn () => 'candidate'); diff --git a/tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php b/tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php index 1b0cbc400..3317736b1 100644 --- a/tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php +++ b/tests/Integration/Pennant/Database/MySqlFamilyDatabaseDriverTestCase.php @@ -8,7 +8,6 @@ use Hypervel\Database\Connectors\MariaDbConnector; use Hypervel\Database\Connectors\MySqlConnector; use Hypervel\Pennant\Feature; -use Hypervel\Support\Facades\DB; use Hypervel\Testbench\Attributes\DefineEnvironment; use Pdo\Mysql; @@ -62,13 +61,4 @@ protected function countingFoundRowsInABoundConnector(ApplicationContract $app): return $connector; }); } - - public function testConcurrentWinnerIsReturnedInsideARepeatableReadTransaction(): void - { - Feature::define('foo', fn () => 'candidate'); - DB::statement('set session transaction isolation level repeatable read'); - $this->storeWinnerBeforeInsert('foo', 'tim', 'winner'); - - $this->assertSame('winner', DB::transaction(fn () => Feature::for('tim')->value('foo'))); - } } From 86a4cdfbcf66238e2b0c0a2c3981b7075bff4f18 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:50:58 +0000 Subject: [PATCH 29/39] Read keyed Pennant scopes by position for() merged the given scopes with array_merge(), which keeps string keys. A keyed collection such as collect(['admin' => $user]) therefore left no scope at index 0, and value() and all() failed with an undefined key. A second for() call with the same key also replaced the earlier scope instead of adding another one. The incoming scopes are now reindexed before they're merged. The array driver test also fixes a copy-paste mistake inherited from upstream: the activate-and-deactivate test checked bar twice and never checked baz. --- .../src/PendingScopedFeatureInteraction.php | 3 ++- tests/Pennant/Feature/ArrayDriverTest.php | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pennant/src/PendingScopedFeatureInteraction.php b/src/pennant/src/PendingScopedFeatureInteraction.php index 1e5214908..819192e42 100644 --- a/src/pennant/src/PendingScopedFeatureInteraction.php +++ b/src/pennant/src/PendingScopedFeatureInteraction.php @@ -38,7 +38,8 @@ public function __construct(Decorator $driver) */ public function for(mixed $scope): static { - $this->scope = array_merge($this->scope, $scope instanceof Enumerable ? $scope->all() : Arr::wrap($scope)); + // Scopes are read by position, so string keys from a keyed collection must not survive the merge. + $this->scope = array_merge($this->scope, array_values($scope instanceof Enumerable ? $scope->all() : Arr::wrap($scope))); return $this; } diff --git a/tests/Pennant/Feature/ArrayDriverTest.php b/tests/Pennant/Feature/ArrayDriverTest.php index dbd406399..e6834be9e 100644 --- a/tests/Pennant/Feature/ArrayDriverTest.php +++ b/tests/Pennant/Feature/ArrayDriverTest.php @@ -19,6 +19,7 @@ use Hypervel\Pennant\Events\UnknownFeatureResolved; use Hypervel\Pennant\Feature; use Hypervel\Pennant\GlobalScope; +use Hypervel\Support\Collection; use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Event; use Hypervel\Support\Lottery; @@ -181,13 +182,13 @@ public function testItCanActivateAndDeactivateSeveralFeaturesAtOnce(): void $this->assertFalse(Feature::active('foo')); $this->assertFalse(Feature::active('bar')); - $this->assertFalse(Feature::active('bar')); + $this->assertFalse(Feature::active('baz')); Feature::activate(['bar', 'baz']); $this->assertFalse(Feature::active('foo')); $this->assertTrue(Feature::active('bar')); - $this->assertTrue(Feature::active('bar')); + $this->assertTrue(Feature::active('baz')); } public function testItCanCheckIfMultipleFeaturesAreActiveAtOnce(): void @@ -316,6 +317,18 @@ public function testScopeCanBeStringsLikeEmailAddresses(): void $this->assertTrue(Feature::for('tim@hypervel.com')->active('foo')); } + public function testKeyedScopesAreReadByPosition(): void + { + Feature::define('foo', fn (string $scope) => "{$scope}-value"); + + $this->assertSame('tim-value', Feature::for(new Collection(['admin' => 'tim']))->value('foo')); + $this->assertSame(['foo' => 'tim-value'], Feature::for(['admin' => 'tim'])->all()); + $this->assertSame( + ['foo' => ['tim-value', 'taylor-value']], + Feature::for(['admin' => 'tim'])->for(['admin' => 'taylor'])->load('foo'), + ); + } + public function testItCanHandleFeatureScopeableObjects(): void { $scopeable = fn () => new class extends User implements FeatureScopeable { From 61b293c6e8a4dc93a8a894b9b295c409d8d494f2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:50:58 +0000 Subject: [PATCH 30/39] Match pennant:purge exclusions exactly The command deduplicated --except with a loose unique(), so --except=01 --except=1 kept only one of the two names and the other feature was purged. It then flipped the feature list into array keys, which turned a numeric-string name such as "2" into an integer that was passed on to the driver and the FeaturesPurged event. The exclusions are now applied with array_diff(), which compares names as exact strings and returns them unchanged. --- src/pennant/src/Commands/PurgeCommand.php | 9 ++------- tests/Pennant/Feature/PurgeCommandTest.php | 13 +++++++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/pennant/src/Commands/PurgeCommand.php b/src/pennant/src/Commands/PurgeCommand.php index 0ef8928b7..d790b13a6 100644 --- a/src/pennant/src/Commands/PurgeCommand.php +++ b/src/pennant/src/Commands/PurgeCommand.php @@ -43,16 +43,11 @@ public function handle(FeatureManager $manager): int $except = collect($this->option('except')) ->when($this->option('except-registered'), fn ($except) => $except->merge($store->defined())) - ->unique() ->all(); if ($except) { - $features = collect($features ?: $store->stored()) - ->flip() - ->forget($except) - ->flip() - ->values() - ->all(); + // Feature names must match exactly, so "01" and "1" stay distinct. + $features = array_values(array_diff($features ?: $store->stored(), $except)); } $store->purge($features); diff --git a/tests/Pennant/Feature/PurgeCommandTest.php b/tests/Pennant/Feature/PurgeCommandTest.php index 9338712fe..840eac44b 100644 --- a/tests/Pennant/Feature/PurgeCommandTest.php +++ b/tests/Pennant/Feature/PurgeCommandTest.php @@ -6,8 +6,10 @@ use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Pennant\Drivers\ArrayDriver; +use Hypervel\Pennant\Events\FeaturesPurged; use Hypervel\Pennant\Feature; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Event; use Hypervel\Tests\Pennant\TestCase; use InvalidArgumentException; @@ -134,6 +136,17 @@ public function testItCanExcludeFeaturesToPurgeFromStorage(): void $this->assertSame(0, DB::table('features')->count()); } + public function testExcludedFeatureNamesMatchExactly(): void + { + Feature::activate(['01', '1', '2']); + Event::fake([FeaturesPurged::class]); + + $this->artisan('pennant:purge --except=01 --except=1')->expectsOutputToContain('2 successfully purged from storage.'); + + $this->assertSame(['01', '1'], DB::table('features')->orderBy('name')->pluck('name')->all()); + Event::assertDispatched(FeaturesPurged::class, fn (FeaturesPurged $event) => $event->features === ['2']); + } + public function testItOutputsThatNoFeaturesHaveBeenPurged(): void { Feature::define('foo', true); From 5cd9a814bf75d2d3f02cb26f0fd2cad969602b68 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:50:58 +0000 Subject: [PATCH 31/39] Skip empty Pennant purges and bulk writes purge([]) ran a delete that matched nothing, then rotated the partition's shared-cache token, which invalidated every cached value in the partition on every server. pennant:purge reaches it when --except leaves nothing to purge. setAll([]), which Feature::activate([]) reaches, borrowed a connection to schedule an invalidation with no scopes. Both now return early. The decorator still dispatches its events as upstream does. --- src/pennant/src/Drivers/DatabaseDriver.php | 9 +++++++ tests/Pennant/StoredValueCacheTest.php | 29 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/pennant/src/Drivers/DatabaseDriver.php b/src/pennant/src/Drivers/DatabaseDriver.php index bdb73417a..ab00b5e2b 100644 --- a/src/pennant/src/Drivers/DatabaseDriver.php +++ b/src/pennant/src/Drivers/DatabaseDriver.php @@ -396,6 +396,10 @@ public function set(string $feature, mixed $scope, mixed $value): void */ public function setAll(array $features): void { + if ($features === []) { + return; + } + $partition = $this->partition(); $now = CarbonImmutable::now(); $rows = []; @@ -619,6 +623,11 @@ public function delete(string $feature, mixed $scope): void */ public function purge(?array $features): void { + // Rotating the partition token would invalidate every cached value for nothing. + if ($features === []) { + return; + } + $partition = $this->partition(); if ($features === null) { diff --git a/tests/Pennant/StoredValueCacheTest.php b/tests/Pennant/StoredValueCacheTest.php index 5a4eddd55..cbe87fa5e 100644 --- a/tests/Pennant/StoredValueCacheTest.php +++ b/tests/Pennant/StoredValueCacheTest.php @@ -188,6 +188,35 @@ public function testCachedValuesRunNoQueryAndHoldNoConnection(): void $this->assertSame(0, $queries->count); } + #[DataProvider('emptyWrites')] + public function testEmptyWritesRunNoQueryAndHoldNoConnection(string $method): void + { + parallel([function () { + DB::usingConnection('pooled', function () { + (require __DIR__ . '/../../src/pennant/database/migrations/2022_11_01_000001_create_features_table.php')->up(); + }); + }]); + $pool = $this->app->make(PoolManager::class)->pool('pooled'); + $queries = $this->countQueries(); + + [$idle] = parallel([function () use ($method, $pool) { + Feature::store('pooled')->{$method}([]); + + return $pool->getIdleCount(); + }]); + + $this->assertSame(1, $idle); + $this->assertSame(0, $queries->count); + } + + public static function emptyWrites(): array + { + return [ + 'purge' => ['purge'], + 'activate' => ['activate'], + ]; + } + public function testValuesAreNotCachedWhenNewTokensCantBeStored(): void { $cache = m::mock(Repository::class); From 3adc75ba4cb52e04a4f0ef562e001ffe8e09353e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:51:15 +0000 Subject: [PATCH 32/39] Start each stack layer's batch expiration when it's written putMany() computed one absolute expiration before writing the first layer and gave every layer the full TTL. A lower layer written later could therefore keep a record past the expiration stored inside it; increment() would then delete the key instead of updating it, and backfill would skip it. Each layer's records are now built when that layer is written, from the current time and the full TTL. That is what put() already does for a single key, so each layer's stored expiration matches its own TTL and every layer still receives the new value. Compensation after a failed layer still forgets the original batch keys. --- src/cache/src/StackStore.php | 17 +++++++++++------ tests/Cache/CacheStackStoreTest.php | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php index 972705a44..37194a26f 100644 --- a/src/cache/src/StackStore.php +++ b/src/cache/src/StackStore.php @@ -133,15 +133,20 @@ public function putMany(array $values, int $seconds): bool return true; } - $expiration = CarbonImmutable::now()->getTimestamp() + $seconds; - $records = array_map(static fn (mixed $value): array => ['value' => $value, 'expiration' => $expiration], $values); - return $this->callStores( - static fn (StackStoreProxy $store) => $store->putMany($records, $seconds), - static function (StackStoreProxy $store) use ($records): void { + // Like put(), each layer's expiration starts when that layer is written, matching its TTL. + static function (StackStoreProxy $store) use ($values, $seconds): bool { + $expiration = CarbonImmutable::now()->getTimestamp() + $seconds; + + return $store->putMany( + array_map(static fn (mixed $value): array => ['value' => $value, 'expiration' => $expiration], $values), + $seconds, + ); + }, + static function (StackStoreProxy $store) use ($values): void { $cancellation = null; - foreach (array_keys($records) as $key) { + foreach (array_keys($values) as $key) { try { $store->forget((string) $key); } catch (CanceledException $exception) { diff --git a/tests/Cache/CacheStackStoreTest.php b/tests/Cache/CacheStackStoreTest.php index 221223cdf..dd21b2970 100644 --- a/tests/Cache/CacheStackStoreTest.php +++ b/tests/Cache/CacheStackStoreTest.php @@ -677,6 +677,23 @@ public function testPutMany(): void $this->assertTrue($this->store->putMany(['foo' => 'bar', 'bar' => 'baz'], $ttl)); } + public function testPutManyStartsEachLayersExpirationWhenThatLayerIsWritten(): void + { + $this->createStores(); + + $ttl = 100; + $now = CarbonImmutable::now()->getTimestamp(); + + $this->swoole->shouldReceive('putMany')->once()->with(['foo' => ['value' => 'bar', 'expiration' => $now + $ttl]], $ttl)->andReturnUsing(function (): bool { + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds(5)); + + return true; + }); + $this->redis->shouldReceive('putMany')->once()->with(['foo' => ['value' => 'bar', 'expiration' => $now + 5 + $ttl]], $ttl)->andReturnTrue(); + + $this->assertTrue($this->store->putMany(['foo' => 'bar'], $ttl)); + } + public function testPutManyReturnsTrueForEmptyInput(): void { $this->createStores(); From 9e696eebe9c0ad8085c7c0af38500d52d717e3ee Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:51:15 +0000 Subject: [PATCH 33/39] Let migrate --database choose the Pennant migration's connection PennantMigration::getConnection() returned the default connection's name when the database store had no connection. The migrator treats any non-null name as the migration's own connection, so migrate --database=X created or dropped the features table on the default database instead of X. It also treated the string 'null' as unset, which the store itself doesn't, so the two could disagree. It now returns null for a null or empty connection, matching how the store normalizes it, and keeps explicit names. Without --database the migration still runs on the default connection. Upstream's test now covers null and empty values through the real migrator, plus an explicit connection. The driver test also fixes the same upstream copy-paste mistake as the array driver test: the second bar assertion in each block now checks baz. --- .../src/Migrations/PennantMigration.php | 3 +- tests/Pennant/Feature/DatabaseDriverTest.php | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/pennant/src/Migrations/PennantMigration.php b/src/pennant/src/Migrations/PennantMigration.php index 471025c50..b264a70bc 100644 --- a/src/pennant/src/Migrations/PennantMigration.php +++ b/src/pennant/src/Migrations/PennantMigration.php @@ -15,6 +15,7 @@ public function getConnection(): ?string { $connection = config('pennant.stores.database.connection'); - return ($connection === null || $connection === 'null') ? config('database.default') : $connection; + // Without a connection of its own, the migration runs on the migrator's connection, so --database applies. + return $connection === '' ? null : $connection; } } diff --git a/tests/Pennant/Feature/DatabaseDriverTest.php b/tests/Pennant/Feature/DatabaseDriverTest.php index ca5b14d2f..8a600f007 100644 --- a/tests/Pennant/Feature/DatabaseDriverTest.php +++ b/tests/Pennant/Feature/DatabaseDriverTest.php @@ -219,13 +219,13 @@ public function testItCanActivateAndDeactivateSeveralFeaturesAtOnce(): void $this->assertFalse(Feature::active('foo')); $this->assertFalse(Feature::active('bar')); - $this->assertFalse(Feature::active('bar')); + $this->assertFalse(Feature::active('baz')); Feature::activate(['bar', 'baz']); $this->assertFalse(Feature::active('foo')); $this->assertTrue(Feature::active('bar')); - $this->assertTrue(Feature::active('bar')); + $this->assertTrue(Feature::active('baz')); $this->assertCount(4, DB::getQueryLog()); } @@ -1385,14 +1385,32 @@ public function testStoresMayBeConfigured(): void $this->assertSame('bar_features', $tableResolver->bindTo($driver, $driver)()); } - public function testItUsesDefaultConnectionIfNull(): void + #[DataProvider('unsetConnections')] + public function testItUsesDefaultConnectionIfNull(?string $connection): void { - Config::set('pennant.stores.database.connection', 'null'); + Config::set('database.connections.other', config('database.connections.' . config('database.default'))); + Config::set('pennant.stores.database.connection', $connection); $migration = new class extends PennantMigration {}; + $migrator = $this->app->make('migrator'); + + $this->assertNull($migration->getConnection()); + $this->assertSame('other', $migrator->usingConnection('other', fn () => $migrator->resolveConnection($migration->getConnection())->getName())); + } + + public static function unsetConnections(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + ]; + } + + public function testItUsesThePennantConnectionForMigrations(): void + { + Config::set('pennant.stores.database.connection', 'pennant'); - $connection = $migration->getConnection(); - $this->assertSame(config('database.default'), $connection); + $this->assertSame('pennant', (new class extends PennantMigration {})->getConnection()); } #[DataProvider('readConnections')] From 665d1d764d3c0515352e51dbf5e56dfa06f96f6c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:51:15 +0000 Subject: [PATCH 34/39] Don't record feature flags checked while providers boot Until the application has booted, Hub::configureScope() edits the baseline scope that every later request and job copies. A feature checked in a provider's boot() method was therefore recorded on that baseline and appeared on every request's Sentry events. The integration now registers its listener once the application has booted, so boot-time checks are never recorded and feature checks pay no extra cost. --- .../Features/PennantPackageIntegration.php | 7 +++- .../PennantPackageIntegrationTest.php | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/sentry/src/Features/PennantPackageIntegration.php b/src/sentry/src/Features/PennantPackageIntegration.php index 908723de9..3f2eb4169 100644 --- a/src/sentry/src/Features/PennantPackageIntegration.php +++ b/src/sentry/src/Features/PennantPackageIntegration.php @@ -4,6 +4,7 @@ namespace Hypervel\Sentry\Features; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Pennant\Events\FeatureRetrieved; use Hypervel\Pennant\Feature as Pennant; use Sentry\SentrySdk; @@ -24,7 +25,11 @@ public function isApplicable(): bool */ public function onBoot(): void { - $this->container->make('events')->listen(FeatureRetrieved::class, [$this, 'handleFeatureRetrieved']); + // Until the application has booted, the current Sentry scope is the baseline that every + // request copies, so flags checked while providers boot would appear on every request. + $this->container->make(Application::class)->booted(function (): void { + $this->container->make('events')->listen(FeatureRetrieved::class, [$this, 'handleFeatureRetrieved']); + }); } /** diff --git a/tests/Sentry/Features/PennantPackageIntegrationTest.php b/tests/Sentry/Features/PennantPackageIntegrationTest.php index 18b884f1e..0357ff392 100644 --- a/tests/Sentry/Features/PennantPackageIntegrationTest.php +++ b/tests/Sentry/Features/PennantPackageIntegrationTest.php @@ -7,6 +7,8 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Pennant\Feature; +use Hypervel\Support\ServiceProvider; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Tests\Sentry\TestCase; use Sentry\Event; @@ -50,6 +52,37 @@ public function testPennantFeatureIsRecorded(): void ], $flags); } + #[DefineEnvironment('checkingAFeatureWhileProvidersBoot')] + public function testFeaturesCheckedWhileProvidersBootAreNotRecorded(): void + { + Feature::define('new-dashboard', true); + + Feature::active('new-dashboard'); + + $event = $this->getCurrentSentryScope()->applyToEvent(Event::createEvent()); + + $this->assertEquals([ + [ + 'flag' => 'new-dashboard', + 'result' => true, + ], + ], $event->getContexts()['flags']['values']); + } + + /** + * Register a provider that checks a feature while it boots. + */ + protected function checkingAFeatureWhileProvidersBoot(ApplicationContract $app): void + { + $app->register(new class($app) extends ServiceProvider { + public function boot(): void + { + Feature::define('boot-flag', true); + Feature::active('boot-flag'); + } + }); + } + public function testPennantFeatureWithLeadingNamespaceSeparatorIsRecordedWithNormalizedName(): void { Feature::define('\App\Features\NewApi', static function () { From 555f747c212b5b2b1ff6e7f153d17e9a185ccaae Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:51:15 +0000 Subject: [PATCH 35/39] Use fixed-length keys for Pennant's shared cache Shared-cache keys contained the raw scope and feature name. A long scope or feature, which the features table allows, pushed the key past the database cache store's 255-character key column, and every feature check through that store failed with a database error. Keys are now the entry kind plus a SHA-256 hash of the same unambiguous, length-prefixed identity: connection, table, partition, and the scope and feature where they apply. SHA-256 rather than a fast hash keeps one scope from being crafted to share another scope's cached value, and an unseeded hash is shared by every server. The new integration test caches a 255-character feature and scope through the database cache store on each engine. --- src/pennant/src/Drivers/StoredValueCache.php | 53 +++++++++++-------- .../Database/DatabaseDriverTestCase.php | 25 +++++++++ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/pennant/src/Drivers/StoredValueCache.php b/src/pennant/src/Drivers/StoredValueCache.php index 5f75f655b..a3d3e0670 100644 --- a/src/pennant/src/Drivers/StoredValueCache.php +++ b/src/pennant/src/Drivers/StoredValueCache.php @@ -60,8 +60,8 @@ public function storesValues(): bool */ public function read(string $connection, ?FeaturePartition $partition, array $pairs): array { - $prefix = $this->prefix($connection, $partition); - $partitionKey = $this->partitionTokenKey($prefix); + $storage = $this->storage($connection, $partition); + $partitionKey = $this->partitionTokenKey($storage); $scopeKeys = []; $valueKeys = []; $keys = [$partitionKey]; @@ -69,10 +69,10 @@ public function read(string $connection, ?FeaturePartition $partition, array $pa foreach ($pairs as $feature => $scopes) { foreach ($scopes as $scope => $unused) { if (! isset($scopeKeys[$scope])) { - $keys[] = $scopeKeys[$scope] = $this->scopeTokenKey($prefix, $scope); + $keys[] = $scopeKeys[$scope] = $this->scopeTokenKey($storage, $scope); } - $keys[] = $valueKeys[$feature][$scope] = $this->valueKey($prefix, $feature, $scope); + $keys[] = $valueKeys[$feature][$scope] = $this->valueKey($storage, $feature, $scope); } } @@ -119,12 +119,12 @@ public function read(string $connection, ?FeaturePartition $partition, array $pa */ public function publish(string $connection, ?FeaturePartition $partition, array $tokens, array $stored): void { - $prefix = $this->prefix($connection, $partition); + $storage = $this->storage($connection, $partition); $entries = []; foreach ($stored as $feature => $scopes) { foreach ($scopes as $scope => $json) { - $entries[$this->valueKey($prefix, $feature, $scope)] = ['p' => $tokens['p'], 's' => $tokens['s'][$scope], 'v' => $json]; + $entries[$this->valueKey($storage, $feature, $scope)] = ['p' => $tokens['p'], 's' => $tokens['s'][$scope], 'v' => $json]; } } @@ -138,11 +138,11 @@ public function publish(string $connection, ?FeaturePartition $partition, array */ public function rotateScopes(string $connection, ?FeaturePartition $partition, array $scopes): void { - $prefix = $this->prefix($connection, $partition); + $storage = $this->storage($connection, $partition); $tokens = []; foreach ($scopes as $scope) { - $tokens[$this->scopeTokenKey($prefix, $scope)] = (string) Str::ulid(); + $tokens[$this->scopeTokenKey($storage, $scope)] = (string) Str::ulid(); } $this->rotate($tokens); @@ -153,7 +153,7 @@ public function rotateScopes(string $connection, ?FeaturePartition $partition, a */ public function rotatePartition(string $connection, ?FeaturePartition $partition): void { - $this->rotate([$this->partitionTokenKey($this->prefix($connection, $partition)) => (string) Str::ulid()]); + $this->rotate([$this->partitionTokenKey($this->storage($connection, $partition)) => (string) Str::ulid()]); } /** @@ -187,41 +187,50 @@ protected function rotate(array $tokens): void } /** - * Get the key prefix of the given connection and partition. + * Get the identity of the rows read through the given connection and partition. * - * Stores reading the same rows share entries, so the prefix names the storage, not the store. + * Stores reading the same rows share entries, so the identity names the storage, not the store. */ - protected function prefix(string $connection, ?FeaturePartition $partition): string + protected function storage(string $connection, ?FeaturePartition $partition): string { - return 'pennant:' - . $this->segment(ConnectionName::withoutWriteType($connection)) + return $this->segment(ConnectionName::withoutWriteType($connection)) . $this->segment($this->table) - . $this->segment($partition?->key() ?? '') - . ':'; + . $this->segment($partition?->key() ?? ''); } /** * Get the key of the partition token. */ - protected function partitionTokenKey(string $prefix): string + protected function partitionTokenKey(string $storage): string { - return $prefix . 'p'; + return $this->key('p', $storage); } /** * Get the key of a scope's token. */ - protected function scopeTokenKey(string $prefix, int|string $scope): string + protected function scopeTokenKey(string $storage, int|string $scope): string { - return $prefix . 's' . $this->segment($scope); + return $this->key('s', $storage . $this->segment($scope)); } /** * Get the key of a feature's cached value for a scope. */ - protected function valueKey(string $prefix, int|string $feature, int|string $scope): string + protected function valueKey(string $storage, int|string $feature, int|string $scope): string { - return $prefix . 'v' . $this->segment($scope) . $this->segment($feature); + return $this->key('v', $storage . $this->segment($scope) . $this->segment($feature)); + } + + /** + * Get the fixed-length cache key of an entry. + * + * Scopes and feature names can be longer than some cache stores allow in a key. SHA-256 + * keeps one scope from being crafted to share another scope's key. + */ + protected function key(string $kind, string $identity): string + { + return "pennant:{$kind}:" . hash('sha256', $identity); } /** diff --git a/tests/Integration/Pennant/Database/DatabaseDriverTestCase.php b/tests/Integration/Pennant/Database/DatabaseDriverTestCase.php index 9e9fd0d70..6f0590e46 100644 --- a/tests/Integration/Pennant/Database/DatabaseDriverTestCase.php +++ b/tests/Integration/Pennant/Database/DatabaseDriverTestCase.php @@ -9,6 +9,7 @@ use Hypervel\Pennant\Feature; use Hypervel\Pennant\PennantServiceProvider; use Hypervel\Support\Facades\DB; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Tests\Integration\Database\DatabaseTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -70,6 +71,30 @@ public function testLongFourByteIdentifiersAreStored(): void $this->assertSame('stored', Feature::for($identifier)->value($identifier)); } + #[DefineEnvironment('cachingStoredValuesInTheDatabase')] + public function testLongIdentifiersCanBeCachedInADatabaseCacheStore(): void + { + (require __DIR__ . '/../../../../src/testbench/hypervel/migrations/0001_01_01_000003_testbench_create_cache_table.php')->up(); + $feature = str_repeat('f', 255); + $scope = str_repeat('s', 255); + Feature::define($feature, fn () => 'resolved'); + + $this->assertSame('resolved', Feature::for($scope)->value($feature)); + + Feature::flushCache(); + DB::table('features')->update(['value' => json_encode('changed outside Pennant')]); + + $this->assertSame('resolved', Feature::for($scope)->value($feature)); + } + + /** + * Share the default store's values through the database cache store. + */ + protected function cachingStoredValuesInTheDatabase(ApplicationContract $app): void + { + $app->make('config')->set('pennant.stores.database.cache', ['store' => 'database']); + } + public function testFirstResolutionUsesOneReadAndOneInsert(): void { Feature::define('foo', fn () => 'resolved'); From f8bff90d7352236cad90980bbee789d57cd3d302 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:51:15 +0000 Subject: [PATCH 36/39] Give each non-coroutine task its own context With task_enable_coroutine off, which is the default, task listeners run outside a coroutine and share the worker's single non-coroutine context. Anything a task kept in coroutine context, such as Pennant's feature values, authentication state or scoped instances, carried over into every later task in that worker. Coroutine tasks don't have this problem because each starts in a fresh coroutine. TaskCallback now saves the worker's context, runs the task and its TaskTerminated listeners in an empty context, then puts the worker's context back before rethrowing any failure. TaskTerminated listeners such as the database and Redis connection releases still see the task's own state. Core declares its new context and coroutine dependencies. --- src/core/composer.json | 2 + src/core/src/Bootstrap/TaskCallback.php | 15 +++ .../TaskCallbackNonCoroutineTest.php | 95 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/Core/Bootstrap/TaskCallbackNonCoroutineTest.php diff --git a/src/core/composer.json b/src/core/composer.json index 907c88b79..3782fb57a 100644 --- a/src/core/composer.json +++ b/src/core/composer.json @@ -31,8 +31,10 @@ "require": { "php": "^8.4", "ext-swoole": "^6.3.0RC1", + "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", + "hypervel/coroutine": "^0.4", "psr/log": "^3.0", "symfony/console": "^8.1.2" }, diff --git a/src/core/src/Bootstrap/TaskCallback.php b/src/core/src/Bootstrap/TaskCallback.php index 6936434bc..245fec6c0 100644 --- a/src/core/src/Bootstrap/TaskCallback.php +++ b/src/core/src/Bootstrap/TaskCallback.php @@ -4,10 +4,12 @@ namespace Hypervel\Core\Bootstrap; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Core\Events\OnTask; use Hypervel\Core\Events\TaskTerminated; +use Hypervel\Coroutine\Coroutine; use Swoole\Constant; use Swoole\Server; use Swoole\Server\Task; @@ -44,6 +46,14 @@ public function onTask(Server $server, mixed ...$arguments): void $task->data = $data; } + // Tasks run outside a coroutine share the worker's context, so each one gets an + // empty context of its own, as a coroutine task would, until it has terminated. + $workerContext = Coroutine::inCoroutine() ? null : CoroutineContext::getContainer(); + + if ($workerContext !== null) { + CoroutineContext::flush(); + } + $exception = null; try { @@ -69,6 +79,11 @@ public function onTask(Server $server, mixed ...$arguments): void $exception ??= $throwable; } + if ($workerContext !== null) { + CoroutineContext::flush(); + CoroutineContext::setMany($workerContext); + } + if ($exception !== null) { throw $exception; } diff --git a/tests/Core/Bootstrap/TaskCallbackNonCoroutineTest.php b/tests/Core/Bootstrap/TaskCallbackNonCoroutineTest.php new file mode 100644 index 000000000..8ef27bc91 --- /dev/null +++ b/tests/Core/Bootstrap/TaskCallbackNonCoroutineTest.php @@ -0,0 +1,95 @@ +shouldReceive('hasListeners')->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->twice() + ->with(m::type(OnTask::class)) + ->andReturnUsing(function (OnTask $event) use (&$seen): void { + $seen[] = [CoroutineContext::get('worker'), CoroutineContext::get('task')]; + CoroutineContext::set('task', $event->task->data); + }); + $dispatcher->shouldReceive('dispatch') + ->twice() + ->with(m::type(TaskTerminated::class)) + ->andReturnUsing(function () use (&$seen): void { + $seen[] = CoroutineContext::get('task'); + }); + + $callback = $this->makeCallback($dispatcher); + $callback->onTask(m::mock(Server::class), 1, 0, 'first'); + $callback->onTask(m::mock(Server::class), 2, 0, 'second'); + + $this->assertSame([[null, null], 'first', [null, null], 'second'], $seen); + $this->assertSame('outer', CoroutineContext::get('worker')); + $this->assertFalse(CoroutineContext::has('task')); + } + + public function testTheWorkerContextIsRestoredWhenATaskFails(): void + { + CoroutineContext::set('worker', 'outer'); + $exception = new RuntimeException('Task failed.'); + $terminatedWith = null; + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('hasListeners')->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnTask::class)) + ->andReturnUsing(function () use ($exception): void { + CoroutineContext::set('task', 'failed'); + + throw $exception; + }); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(TaskTerminated::class)) + ->andReturnUsing(function () use (&$terminatedWith): void { + $terminatedWith = CoroutineContext::get('task'); + }); + + try { + $this->makeCallback($dispatcher)->onTask(m::mock(Server::class), 1, 0, 'payload'); + $this->fail('Expected the task failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertSame('failed', $terminatedWith); + $this->assertSame('outer', CoroutineContext::get('worker')); + $this->assertFalse(CoroutineContext::has('task')); + } + + /** + * Create a task callback for tasks that run outside a coroutine. + */ + private function makeCallback(Dispatcher $dispatcher): TaskCallback + { + return new TaskCallback($dispatcher, new Repository([ + 'server' => ['settings' => [Constant::OPTION_TASK_ENABLE_COROUTINE => false]], + ])); + } +} From 17af73233c699615f7912295e6baf9bae7df892a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:51:15 +0000 Subject: [PATCH 37/39] Clarify Pennant's purge contract and rich value storage The Driver contract now says that purge(null) purges every feature, which both built-in drivers already do and custom drivers need to know. The rich values docs explain that the database driver stores values as JSON, so reading one back can change its PHP type. The shared cache docs no longer promise that a cache hit runs no query: with the database cache store it still queries the cache table. They now say that a hit avoids reading Pennant's feature table, and that with Redis or Swoole it also avoids taking a database connection. The middleware test for one inactive feature now activates the other feature, so it can tell "all active" apart from "any active". --- src/docs/pennant.md | 4 +++- src/pennant/src/Contracts/Driver.php | 2 +- tests/Pennant/Feature/FeatureMiddlewareTest.php | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/docs/pennant.md b/src/docs/pennant.md index 0c112b44b..8bce02234 100644 --- a/src/docs/pennant.md +++ b/src/docs/pennant.md @@ -740,6 +740,8 @@ Pennant's included Blade directive also makes it easy to conditionally render co > [!NOTE] > When using rich values, it is important to know that a feature is considered "active" when it has any value other than `false`. +The `database` driver stores rich values as JSON. Reading a stored value can change its PHP type. For example, objects become arrays and `1.0` becomes `1`. + When calling the [conditional `when`](#conditional-execution) method, the feature's rich value will be provided to the first closure: ```php @@ -1026,7 +1028,7 @@ Use a column type that matches your partition values, such as `foreignUuid` for ## Shared Cache -The `database` driver can share stored values between your servers through a [cache](/docs/{{version}}/cache) store, so checking a feature that isn't already in the [in-memory cache](#in-memory-cache) doesn't need a database query. A cache hit runs no query and doesn't take a connection from the database pool. To enable the shared cache, set the store's `cache` option to an array containing the name of a cache `store` and, optionally, a `ttl` in seconds: +The `database` driver can share stored values between your servers through a [cache](/docs/{{version}}/cache) store, so checking a feature that isn't already in the [in-memory cache](#in-memory-cache) doesn't need to read Pennant's feature table. With a Redis or Swoole cache store, a cache hit also doesn't take a connection from the database pool. To enable the shared cache, set the store's `cache` option to an array containing the name of a cache `store` and, optionally, a `ttl` in seconds: ```php 'database' => [ diff --git a/src/pennant/src/Contracts/Driver.php b/src/pennant/src/Contracts/Driver.php index 19d28716f..3cd2d6948 100644 --- a/src/pennant/src/Contracts/Driver.php +++ b/src/pennant/src/Contracts/Driver.php @@ -49,7 +49,7 @@ public function setForAllScopes(string $feature, mixed $value): void; public function delete(string $feature, mixed $scope): void; /** - * Purge the given features from storage. + * Purge the given features from storage, or every feature when null is given. */ public function purge(?array $features): void; } diff --git a/tests/Pennant/Feature/FeatureMiddlewareTest.php b/tests/Pennant/Feature/FeatureMiddlewareTest.php index c0a60b7e6..88073808a 100644 --- a/tests/Pennant/Feature/FeatureMiddlewareTest.php +++ b/tests/Pennant/Feature/FeatureMiddlewareTest.php @@ -56,8 +56,11 @@ public function testItPassesIfFeatureIsDefined(): void public function testItThrowsAnExceptionIfOneOfTheFeaturesIsNotActive(): void { + Feature::define('test', true); + $this->expectException(HttpException::class); - $this->assertFalse(Feature::active('test')); + $this->assertTrue(Feature::active('test')); + $this->assertFalse(Feature::active('another')); $this->middleware->handle( $this->createRequest('test', 'get'), From aac7806b9adb51afe137f1aa384a2bf6b0a68141 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:20:43 +0000 Subject: [PATCH 38/39] Match pennant:purge exclusions by registered name pennant:purge compared the raw command input against the names Pennant stores, but Feature::purge() resolves each feature to its registered name before deleting it. A feature class with a custom name was therefore purged even when it was explicitly excluded: - `--except='App\Features\NewApi'` didn't match the stored name `new-api`, so `new-api` was purged. - `'App\Features\NewApi' --except-registered` kept the class string, which purge() then resolved to `new-api` and deleted. Both sides of the comparison now go through the store's name() method, the same resolution purge() uses. Stored names are resolved too, so rows written under a class string before the class gained a custom name can't delete an excluded registered name. The list of registered features for --except-registered is still taken before any input is resolved. Names are still compared exactly, so "01" and "1" stay distinct. When exclusions apply, the success message now lists the registered names that were purged. --- src/pennant/src/Commands/PurgeCommand.php | 8 +++-- tests/Pennant/Feature/PurgeCommandTest.php | 38 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/pennant/src/Commands/PurgeCommand.php b/src/pennant/src/Commands/PurgeCommand.php index d790b13a6..6dc26a666 100644 --- a/src/pennant/src/Commands/PurgeCommand.php +++ b/src/pennant/src/Commands/PurgeCommand.php @@ -46,8 +46,12 @@ public function handle(FeatureManager $manager): int ->all(); if ($except) { - // Feature names must match exactly, so "01" and "1" stay distinct. - $features = array_values(array_diff($features ?: $store->stored(), $except)); + // Compare registered names, so a feature class matches the name it's stored under. + // Names must match exactly, so "01" and "1" stay distinct. + $features = array_values(array_diff( + array_map($store->name(...), $features ?: $store->stored()), + array_map($store->name(...), $except), + )); } $store->purge($features); diff --git a/tests/Pennant/Feature/PurgeCommandTest.php b/tests/Pennant/Feature/PurgeCommandTest.php index 840eac44b..9387051f9 100644 --- a/tests/Pennant/Feature/PurgeCommandTest.php +++ b/tests/Pennant/Feature/PurgeCommandTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Pennant\Feature; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; +use Hypervel\Pennant\Attributes\Name; use Hypervel\Pennant\Drivers\ArrayDriver; use Hypervel\Pennant\Events\FeaturesPurged; use Hypervel\Pennant\Feature; @@ -12,6 +13,7 @@ use Hypervel\Support\Facades\Event; use Hypervel\Tests\Pennant\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; class PurgeCommandTest extends TestCase { @@ -147,6 +149,33 @@ public function testExcludedFeatureNamesMatchExactly(): void Event::assertDispatched(FeaturesPurged::class, fn (FeaturesPurged $event) => $event->features === ['2']); } + #[DataProvider('featureClassExclusions')] + public function testExcludedFeatureClassesMatchTheirRegisteredNames(array $arguments, string $output, array $remaining): void + { + Feature::define(NamedPurgeCommandFeature::class); + Feature::activate([NamedPurgeCommandFeature::class, 'other']); + + $this->artisan('pennant:purge', $arguments)->expectsOutputToContain($output); + + $this->assertSame($remaining, DB::table('features')->orderBy('name')->pluck('name')->all()); + } + + public static function featureClassExclusions(): array + { + return [ + 'excluded class' => [ + ['--except' => [NamedPurgeCommandFeature::class]], + 'other successfully purged from storage.', + ['purge-command-feature'], + ], + 'class argument with registered features excluded' => [ + ['features' => [NamedPurgeCommandFeature::class], '--except-registered' => true], + 'No features to purge from storage.', + ['other', 'purge-command-feature'], + ], + ]; + } + public function testItOutputsThatNoFeaturesHaveBeenPurged(): void { Feature::define('foo', true); @@ -220,3 +249,12 @@ public function testItCanPurgeFeaturesExceptThoseRegistered(): void $this->assertSame(['foo'], DB::table('features')->pluck('name')->all()); } } + +#[Name('purge-command-feature')] +class NamedPurgeCommandFeature +{ + public function resolve(): bool + { + return false; + } +} From 4982ecc553a92911e92d781ba281198440a8bb7f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:20:43 +0000 Subject: [PATCH 39/39] Return Pennant feature names as strings The driver contracts promise string feature names, but the built-in drivers built defined() (and the array driver's stored()) from array keys. PHP turns numeric keys such as "1" into integers, so a feature named "1", including one named by an integer-backed enum, came back as the integer 1. The Decorator compared names against defined() with a loose in_array(), which hid this but treated "01" and "1" as the same name. A feature class named "1" was never registered when a feature named "01" already existed, so checking it returned the unknown-feature result instead of its own value. Switching to a strict comparison alone would have made a feature class named "1" replace an existing "1" definition. The drivers now cast the names they return to strings, and both Decorator checks compare strictly. A feature class with an integer-backed name is registered beside "01" and keeps using an existing "1" definition. --- src/pennant/src/Drivers/ArrayDriver.php | 5 ++-- src/pennant/src/Drivers/DatabaseDriver.php | 3 ++- src/pennant/src/Drivers/Decorator.php | 4 +-- tests/Pennant/Feature/ArrayDriverTest.php | 7 +++-- tests/Pennant/Feature/DatabaseDriverTest.php | 3 ++- tests/Pennant/Feature/EnumTest.php | 27 ++++++++++++++++++++ 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/pennant/src/Drivers/ArrayDriver.php b/src/pennant/src/Drivers/ArrayDriver.php index 1832f978a..53418efdf 100644 --- a/src/pennant/src/Drivers/ArrayDriver.php +++ b/src/pennant/src/Drivers/ArrayDriver.php @@ -80,7 +80,8 @@ public function define(string $feature, callable $resolver): void */ public function defined(): array { - return array_keys($this->featureStateResolvers); + // PHP stores numeric names such as "1" as integer keys. + return array_map(strval(...), array_keys($this->featureStateResolvers)); } /** @@ -90,7 +91,7 @@ public function defined(): array */ public function stored(): array { - return array_keys(PennantState::current()->arrayValues[$this->name][$this->partitionKey()] ?? []); + return array_map(strval(...), array_keys(PennantState::current()->arrayValues[$this->name][$this->partitionKey()] ?? [])); } /** diff --git a/src/pennant/src/Drivers/DatabaseDriver.php b/src/pennant/src/Drivers/DatabaseDriver.php index ab00b5e2b..d44b97225 100644 --- a/src/pennant/src/Drivers/DatabaseDriver.php +++ b/src/pennant/src/Drivers/DatabaseDriver.php @@ -137,7 +137,8 @@ public function define(string $feature, callable $resolver): void */ public function defined(): array { - return array_keys($this->featureStateResolvers); + // PHP stores numeric names such as "1" as integer keys. + return array_map(strval(...), array_keys($this->featureStateResolvers)); } /** diff --git a/src/pennant/src/Drivers/Decorator.php b/src/pennant/src/Drivers/Decorator.php index 8e5f9a107..7de16f1eb 100644 --- a/src/pennant/src/Drivers/Decorator.php +++ b/src/pennant/src/Drivers/Decorator.php @@ -866,7 +866,7 @@ protected function resolveFeature(UnitEnum|string $feature): string */ protected function shouldDynamicallyDefine(string $feature): bool { - return ! in_array($feature, $this->defined()) + return ! in_array($feature, $this->defined(), true) && class_exists($feature) && (method_exists($feature, 'resolve') || method_exists($feature, '__invoke')); } @@ -879,7 +879,7 @@ protected function shouldDynamicallyDefine(string $feature): bool protected function ensureDynamicFeatureIsDefined(string $feature): string { return $this->classNames[$feature] = tap($this->resolveFeatureName($feature, $this->container->make($feature)), function ($name) use ($feature) { - if (! in_array($name, $this->defined())) { + if (! in_array($name, $this->defined(), true)) { if (Event::hasListeners(DynamicallyRegisteringFeatureClass::class)) { Event::dispatch(new DynamicallyRegisteringFeatureClass($feature)); } diff --git a/tests/Pennant/Feature/ArrayDriverTest.php b/tests/Pennant/Feature/ArrayDriverTest.php index e6834be9e..f5cbc1d9c 100644 --- a/tests/Pennant/Feature/ArrayDriverTest.php +++ b/tests/Pennant/Feature/ArrayDriverTest.php @@ -604,10 +604,11 @@ public function testItCanRetrieveRegisteredFeatures(): void Feature::define('foo', fn () => true); Feature::define('bar', fn () => false); Feature::define('baz', fn () => false); + Feature::define('1', fn () => false); $registered = Feature::defined(); - $this->assertSame(['foo', 'bar', 'baz'], $registered); + $this->assertSame(['foo', 'bar', 'baz', '1'], $registered); } public function testItCanClearTheCache(): void @@ -1233,10 +1234,12 @@ public function testItCanListStoredFeatures(): void { Feature::define('foo', fn () => true); Feature::define('bar', fn () => true); + Feature::define('1', fn () => true); Feature::for('Tim')->active('bar'); + Feature::for('Tim')->active('1'); - $this->assertSame(Feature::stored(), ['bar']); + $this->assertSame(Feature::stored(), ['bar', '1']); } public function testItCanResolveFeatureInstance(): void diff --git a/tests/Pennant/Feature/DatabaseDriverTest.php b/tests/Pennant/Feature/DatabaseDriverTest.php index 8a600f007..7938d35dd 100644 --- a/tests/Pennant/Feature/DatabaseDriverTest.php +++ b/tests/Pennant/Feature/DatabaseDriverTest.php @@ -775,10 +775,11 @@ public function testItCanRetrieveRegisteredFeatures(): void Feature::define('foo', fn () => true); Feature::define('bar', fn () => false); Feature::define('baz', fn () => false); + Feature::define('1', fn () => false); $registered = Feature::defined(); - $this->assertSame(['foo', 'bar', 'baz'], $registered); + $this->assertSame(['foo', 'bar', 'baz', '1'], $registered); $this->assertCount(0, DB::getQueryLog()); } diff --git a/tests/Pennant/Feature/EnumTest.php b/tests/Pennant/Feature/EnumTest.php index fd1409b2e..c602b613a 100644 --- a/tests/Pennant/Feature/EnumTest.php +++ b/tests/Pennant/Feature/EnumTest.php @@ -11,6 +11,7 @@ use Hypervel\Pennant\Middleware\EnsureFeaturesAreActive; use Hypervel\Support\Facades\Blade; use Hypervel\Tests\Pennant\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -192,6 +193,23 @@ public function testTheNameAttributeAcceptsAnEnum(): void $this->assertTrue(Feature::active('purchasing')); } + #[DataProvider('numericNameDefinitions')] + public function testAnIntegerBackedNameAttributeMatchesDefinedNamesExactly(string $defined, string $expected): void + { + Feature::define($defined, 'defined'); + + $this->assertSame($expected, Feature::value(FeatureWithIntegerEnumName::class)); + $this->assertSame('defined', Feature::value($defined)); + } + + public static function numericNameDefinitions(): array + { + return [ + 'a different numeric name' => ['01', 'resolved'], + 'the same name' => ['1', 'defined'], + ]; + } + public function testTheBladeDirectiveAcceptsAnEnum(): void { $blade = <<<'BLADE' @@ -273,3 +291,12 @@ public function resolve(): bool return true; } } + +#[Name(IntegerFeatureEnum::NewApi)] +class FeatureWithIntegerEnumName +{ + public function resolve(): string + { + return 'resolved'; + } +}