From 92af6bc96e3d6d48fcd338733fbef2c96667b21d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:46 +0000 Subject: [PATCH 01/38] Add Queue::whenJobRunsLongerThan Applications had no direct way to react when a queued job attempt takes too long. QueueManager::whenJobRunsLongerThan() registers a JobAttempted listener that calls the handler with the job and its duration in milliseconds when the attempt exceeds the threshold. The threshold accepts a date, CarbonInterval or number, like Connection::whenQueryingForLongerThan(). Ports laravel/framework#62141 from 13.x at b20bf7f442. The method is natively typed and carries a Boot-only warning like the other listener registrars. Upstream has no docs, so the Job Events section of queues.md now describes it. Tests: ported QueueManagerTest::testWhenJobRunsLongerThan. tests/Queue passes; composer analyse is clean. --- src/docs/queues.md | 11 +++++++++++ src/queue/src/QueueManager.php | 28 ++++++++++++++++++++++++++++ tests/Queue/QueueManagerTest.php | 24 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/docs/queues.md b/src/docs/queues.md index 3cd090409..95100daf7 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -4144,6 +4144,17 @@ class AppServiceProvider extends ServiceProvider The `duration` property of the `JobProcessed` event contains the number of milliseconds the job took to run. It is `null` when the worker skips the job because it was already deleted, such as by a `JobProcessing` listener. +If you would like to be notified when a job takes too long to run, you may use the `whenJobRunsLongerThan` method. Provide a threshold in milliseconds, or a `CarbonInterval`, and a closure that receives the job and the number of milliseconds the attempt took. The closure is invoked for each attempt that exceeds the threshold, whether or not the attempt succeeds: + +```php +use Hypervel\Contracts\Queue\Job; +use Hypervel\Support\Facades\Queue; + +Queue::whenJobRunsLongerThan(1000, function (Job $job, float $duration) { + // Notify the development team... +}); +``` + Hypervel dispatches a `JobQueueing` event immediately before a job is sent to its queue and a `JobQueued` event after the queue accepts it. If the enqueue attempt throws an exception, a `JobQueueingFailed` event is dispatched with the original exception instead. This event is not dispatched when coroutine cancellation interrupts the attempt, including when the exception was caused by cancellation. Jobs deferred until a database transaction commits do not dispatch these events unless the enqueue attempt actually begins. The `JobPayloadFinalizing` event runs immediately before `JobQueueing` and may replace its encoded `payload`. It also provides the connection, queue, job, and normalized delay in seconds. Use this event for last-mile payload changes that must reach the queue backend. Listening to both events deliberately runs both listeners for each asynchronous job. diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 4a93fa973..4ec0b7844 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -4,6 +4,7 @@ namespace Hypervel\Queue; +use Carbon\CarbonInterval; use Closure; use DateInterval; use DateTimeInterface; @@ -16,6 +17,7 @@ use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Queue\Connectors\ConnectorInterface; use Hypervel\Queue\Connectors\FailoverConnector; +use Hypervel\Queue\Events\JobAttempted; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; @@ -28,6 +30,7 @@ use Hypervel\Queue\Events\WorkerStarting; use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Support\Arr; +use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use InvalidArgumentException; use UnitEnum; @@ -40,6 +43,7 @@ class QueueManager implements FactoryContract, MonitorContract { use HasPoolProxy; + use InteractsWithTime; use ResolvesQueueRoutes; /** @@ -164,6 +168,30 @@ public function stopping(mixed $callback): void ->listen(WorkerStopping::class, $callback); } + /** + * Register a callback to be invoked when a job runs for longer than a given amount of time. + * + * Boot-only. The listener persists on the singleton event dispatcher for + * the worker lifetime and runs for every subsequent matching queue event. + */ + public function whenJobRunsLongerThan(DateTimeInterface|CarbonInterval|float|int $threshold, callable $handler): void + { + $threshold = $threshold instanceof DateTimeInterface + ? $this->secondsUntil($threshold) * 1000 + : $threshold; + + $threshold = $threshold instanceof CarbonInterval + ? $threshold->totalMilliseconds + : $threshold; + + $this->app->make('events') + ->listen(JobAttempted::class, function (JobAttempted $event) use ($threshold, $handler): void { + if (! is_null($event->duration) && $event->duration > $threshold) { + $handler($event->job, $event->duration); + } + }); + } + /** * Register the default queue route for a given class. * diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index 41999d9c2..6b9206cee 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -19,6 +19,8 @@ use Hypervel\Queue\Connectors\FailoverConnector; use Hypervel\Queue\Connectors\NullConnector; use Hypervel\Queue\Connectors\SyncConnector; +use Hypervel\Queue\Events\JobAttempted; +use Hypervel\Queue\Jobs\FakeJob; use Hypervel\Queue\Jobs\Job; use Hypervel\Queue\NullQueue; use Hypervel\Queue\Queue as BaseQueue; @@ -179,6 +181,28 @@ public function testSetDefaultDriverAcceptsBackedEnum(): void $this->assertSame('sync', $container->make('config')->get('queue.default')); } + public function testWhenJobRunsLongerThan(): void + { + $app = new Container; + $app->instance('events', new Dispatcher($app)); + + $manager = new QueueManager($app); + + $called = false; + + $manager->whenJobRunsLongerThan(1000, function () use (&$called) { + $called = true; + }); + + $app->make('events')->dispatch(new JobAttempted('sync', new FakeJob, null, 500.0)); + + $this->assertFalse($called); + + $app->make('events')->dispatch(new JobAttempted('sync', new FakeJob, null, 1500.0)); + + $this->assertTrue($called); + } + #[DataProvider('circularConnections')] public function testRejectsCircularFailoverDefinitions(array $connections, string $cycle): void { From 2c034984e66b451e8f34b3c93d1676cb24174ed2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:57 +0000 Subject: [PATCH 02/38] Update Queue facade docblocks Regenerates the Queue facade for whenJobRunsLongerThan(), matching laravel/framework 6065d21b3e on 13.x at b20bf7f442. Tests: FacadeDocblocksTest passes. --- src/support/src/Facades/Queue.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index ca7102b5b..5ac2497a6 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -44,6 +44,7 @@ * @method static \Hypervel\Queue\QueueManager setReleaseCallback(string $driver, \Closure $callback) * @method static void starting(mixed $callback) * @method static void stopping(mixed $callback) + * @method static void whenJobRunsLongerThan(\DateTimeInterface|\Carbon\CarbonInterval|int|float $threshold, callable $handler) * @method static void withoutInterruptionPolling() * @method static mixed bulk(array $jobs, mixed $data = '', \UnitEnum|string|null $queue = null) * @method static int|null creationTimeOfOldestPendingJob(\UnitEnum|string|null $queue = null) From 716cfe873ffc278338ee4dfaf5989ca5cc41f161 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:57 +0000 Subject: [PATCH 03/38] Fix named arguments in Cookie::queue() CookieJar::queue() passed its variadic parameters through array_values() before spreading them into make(), so named arguments such as Cookie::queue('name', 'value', secure: true) lost their names and failed with a TypeError. The parameters are now spread directly. Ports laravel/framework#62246 from 13.x at b20bf7f442. Tests: ported testQueueWithNamedArguments to CookieJarTest; it fails before the fix. The file passes. --- src/cookie/src/CookieJar.php | 2 +- tests/Cookie/CookieJarTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/cookie/src/CookieJar.php b/src/cookie/src/CookieJar.php index 2eb202c87..c6ce2b801 100644 --- a/src/cookie/src/CookieJar.php +++ b/src/cookie/src/CookieJar.php @@ -145,7 +145,7 @@ public function queue(mixed ...$parameters): void if (isset($parameters[0]) && $parameters[0] instanceof Cookie) { $cookie = $parameters[0]; } else { - $cookie = $this->make(...array_values($parameters)); + $cookie = $this->make(...$parameters); } $cookies = $this->getQueuedCookiesRaw(); diff --git a/tests/Cookie/CookieJarTest.php b/tests/Cookie/CookieJarTest.php index ff84d1cec..54cf22ab1 100644 --- a/tests/Cookie/CookieJarTest.php +++ b/tests/Cookie/CookieJarTest.php @@ -330,6 +330,18 @@ public function testQueueWithCreatingNewCookie(): void ); } + public function testQueueWithNamedArguments(): void + { + $cookieJar = new CookieJar; + $cookieJar->queue('foo', 'bar', 60, secure: true, sameSite: 'none'); + $cookie = $cookieJar->queued('foo'); + + $this->assertSame('/', $cookie->getPath()); + $this->assertNull($cookie->getDomain()); + $this->assertTrue($cookie->isSecure()); + $this->assertSame('none', $cookie->getSameSite()); + } + public function testGetQueuedCookies(): void { $cookieJar = new CookieJar; From 71170455f4b2e7480046444b6c1a77492eed4f7d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:57 +0000 Subject: [PATCH 04/38] Test that ImageFile::default() returns an ImageFile rule Laravel fixed File::default() returning a plain File rule when called on ImageFile. Hypervel's File::default() already returns new static when no default is configured, so only the test is ported. Ports the test from laravel/framework#62244 on 13.x at b20bf7f442. Tests: ported testDefaultReturnsImageFileInstance; the file passes. --- .../Validation/ValidationImageFileRuleTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Validation/ValidationImageFileRuleTest.php b/tests/Validation/ValidationImageFileRuleTest.php index 71c849821..84600fa95 100644 --- a/tests/Validation/ValidationImageFileRuleTest.php +++ b/tests/Validation/ValidationImageFileRuleTest.php @@ -11,6 +11,7 @@ use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\File; +use Hypervel\Validation\Rules\ImageFile; use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -126,6 +127,22 @@ public function testDimensionWithTheRatioBetweenMethod(): void ); } + public function testDefaultReturnsImageFileInstance(): void + { + $this->assertInstanceOf(ImageFile::class, ImageFile::default()); + + $this->fails( + ImageFile::default(), + UploadedFile::fake()->createWithContent('foo.txt', 'Hello World!'), + ['validation.image'], + ); + + $this->passes( + ImageFile::default(), + UploadedFile::fake()->image('foo.png'), + ); + } + protected function fails($rule, $values, $messages) { $this->assertValidationRules($rule, $values, false, $messages); From e97447cb480737e7b36a9eb639b33fa4f5200435 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:57 +0000 Subject: [PATCH 05/38] Assert pagination contract render() types Laravel corrected the render() return type on the paginator contracts. Hypervel's contracts and paginators already return Htmlable natively, so only the type assertions for both contracts are ported. Ports the type tests from laravel/framework#62242 on 13.x at b20bf7f442. Tests: type analysis of types/ passes. --- types/Pagination/Paginator.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index 914fe165c..ba8a4e155 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; +use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; @@ -113,3 +115,9 @@ }); assertType('Hypervel\Pagination\CursorPaginator', $throughPaginator); + +/** @var PaginatorContract $paginatorContract */ +assertType('Hypervel\Contracts\Support\Htmlable', $paginatorContract->render()); + +/** @var CursorPaginatorContract $cursorPaginatorContract */ +assertType('Hypervel\Contracts\Support\Htmlable', $cursorPaginatorContract->render()); From a86353cad2a36606a68ed93dd5bb769121131596 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:58 +0000 Subject: [PATCH 06/38] Test update and delete with order and limit Laravel fixed SQL Server ignoring orderBy on update and delete queries with a limit. SQL Server is not a supported driver, so the grammar change and its unit cases are excluded; the driver-independent integration tests are ported. Ports the tests from laravel/framework#62239 on 13.x at b20bf7f442. Tests: testUpdateWithOrderAndLimit and testDeleteWithOrderAndLimit pass on SQLite, MySQL, MariaDB and PostgreSQL. --- .../Database/QueryBuilderUpdateTest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Integration/Database/QueryBuilderUpdateTest.php b/tests/Integration/Database/QueryBuilderUpdateTest.php index 6965966c7..9aaaf01cb 100644 --- a/tests/Integration/Database/QueryBuilderUpdateTest.php +++ b/tests/Integration/Database/QueryBuilderUpdateTest.php @@ -155,4 +155,32 @@ public function testSubqueryUpdate(): void 'credits' => null, ]); } + + public function testUpdateWithOrderAndLimit(): void + { + DB::table('example')->insert([ + ['name' => 'A', 'credits' => 30], + ['name' => 'B', 'credits' => 10], + ['name' => 'C', 'credits' => 40], + ['name' => 'D', 'credits' => 20], + ]); + + DB::table('example')->orderBy('credits')->limit(2)->update(['title' => 'Low']); + + $this->assertSame(['B', 'D'], DB::table('example')->where('title', 'Low')->orderBy('name')->pluck('name')->all()); + } + + public function testDeleteWithOrderAndLimit(): void + { + DB::table('example')->insert([ + ['name' => 'A', 'credits' => 30], + ['name' => 'B', 'credits' => 10], + ['name' => 'C', 'credits' => 40], + ['name' => 'D', 'credits' => 20], + ]); + + DB::table('example')->orderBy('credits')->limit(2)->delete(); + + $this->assertSame(['A', 'C'], DB::table('example')->orderBy('name')->pluck('name')->all()); + } } From 647f2473cfe3aad948b76ceb5743524084c40800 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:09:58 +0000 Subject: [PATCH 07/38] Preserve error_get_last() for suppressed errors HandleExceptions::handleError() swallowed errors excluded by error_reporting(), such as those silenced with @, so error_get_last() returned null afterwards. It now returns false for them, letting PHP's standard handler record the error. Ports laravel/framework#62228 from 13.x at b20bf7f442. The method returns native bool instead of upstream's false|void, returning true after reporting a deprecation. Tests: ported testSuppressedErrorsAreAvailableViaErrorGetLast; it fails before the fix. The file passes. --- .../src/Bootstrap/HandleExceptions.php | 13 ++++++++++-- .../Bootstrap/HandleExceptionsTest.php | 20 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index 6000490cb..c9e3887a6 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -108,15 +108,24 @@ public function bootstrap(Application $app): void /** * Report PHP deprecations, or convert PHP errors to ErrorException instances. * + * Errors excluded by error_reporting(), such as those silenced with @, are passed + * on to PHP's standard handler so error_get_last() still reports them. + * * @throws ErrorException */ - public function handleError(int $level, string $message, string $file = '', int $line = 0): void + public function handleError(int $level, string $message, string $file = '', int $line = 0): bool { if ($this->isDeprecation($level)) { $this->handleDeprecationError($message, $file, $line, $level); - } elseif (error_reporting() & $level) { + + return true; + } + + if (error_reporting() & $level) { throw new ErrorException($message, 0, $level, $file, $line); } + + return false; } /** diff --git a/tests/Foundation/Bootstrap/HandleExceptionsTest.php b/tests/Foundation/Bootstrap/HandleExceptionsTest.php index 04f7f6f68..a898947dd 100644 --- a/tests/Foundation/Bootstrap/HandleExceptionsTest.php +++ b/tests/Foundation/Bootstrap/HandleExceptionsTest.php @@ -442,6 +442,22 @@ public function testErrors(): void ); } + public function testSuppressedErrorsAreAvailableViaErrorGetLast(): void + { + set_error_handler([$this->handleExceptions(), 'handleError']); + + try { + error_clear_last(); + + $result = @file_get_contents(__DIR__ . '/missing-file.txt'); + } finally { + restore_error_handler(); + } + + $this->assertFalse($result); + $this->assertStringContainsString('Failed to open stream: No such file or directory', error_get_last()['message'] ?? ''); + } + public function testConsoleThrowableRendersToStandardError(): void { $exception = new RuntimeException('Test exception'); @@ -847,9 +863,11 @@ class RecordingHandleExceptions extends HandleExceptions /** * Record a PHP error. */ - public function handleError(int $level, string $message, string $file = '', int $line = 0): void + public function handleError(int $level, string $message, string $file = '', int $line = 0): bool { $this->errors[] = $message; + + return true; } /** From 1132e658d03e0881d10f19aedce37dc92acf43c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:07 +0000 Subject: [PATCH 08/38] Add array replacements and insert helpers to middleware configuration Middleware::replace() and replaceInGroup() now accept an array of replacements, and the new insertBefore(), insertAfter(), insertBeforeInGroup() and insertAfterInGroup() helpers place middleware next to an existing entry. Global and group middleware lists flatten the replacements as upstream. Ports laravel/framework#62230 and #62261 from 13.x at b20bf7f442. They share the same files, so they are committed together. Upstream has no docs; middleware.md now shows the insert helpers for global middleware and groups. Tests: ported the twelve upstream MiddlewareTest cases; the file passes. --- src/docs/middleware.md | 18 +++ .../src/Configuration/Middleware.php | 60 +++++-- .../Configuration/MiddlewareTest.php | 148 ++++++++++++++++++ 3 files changed, 217 insertions(+), 9 deletions(-) diff --git a/src/docs/middleware.md b/src/docs/middleware.md index 3254b6ec1..6720ce8eb 100644 --- a/src/docs/middleware.md +++ b/src/docs/middleware.md @@ -131,6 +131,15 @@ use Hypervel\Foundation\Configuration\Middleware; The `$middleware` object provided to the `withMiddleware` closure is an instance of `Hypervel\Foundation\Configuration\Middleware` and is responsible for managing the middleware assigned to your application's routes. The `append` method adds the middleware to the end of the list of global middleware. If you would like to add a middleware to the beginning of the list, you should use the `prepend` method. +To place a middleware next to an existing global middleware, you may use the `insertBefore` and `insertAfter` methods: + +```php +use App\Http\Middleware\EnsureTokenIsValid; +use Hypervel\Http\Middleware\TrustProxies; + +$middleware->insertAfter(TrustProxies::class, EnsureTokenIsValid::class); +``` + #### Preferring JSON Responses @@ -335,6 +344,15 @@ $middleware->web(remove: [ ]); ``` +The `insertBeforeInGroup` and `insertAfterInGroup` methods place middleware next to an existing entry in a group: + +```php +use App\Http\Middleware\EnsureUserIsSubscribed; +use Hypervel\Session\Middleware\StartSession; + +$middleware->insertAfterInGroup('web', StartSession::class, EnsureUserIsSubscribed::class); +``` + #### Manually Managing Hypervel's Default Middleware Groups diff --git a/src/foundation/src/Configuration/Middleware.php b/src/foundation/src/Configuration/Middleware.php index e4c2dc29b..53c2e9432 100644 --- a/src/foundation/src/Configuration/Middleware.php +++ b/src/foundation/src/Configuration/Middleware.php @@ -153,13 +153,35 @@ public function remove(array|string $middleware): static /** * Specify a middleware that should be replaced with another middleware. */ - public function replace(string $search, string $replace): static + public function replace(string $search, array|string $replace): static { $this->replacements[$search] = $replace; return $this; } + /** + * Insert middleware before the given middleware in the global middleware stack. + */ + public function insertBefore(string $search, array|string $middleware): static + { + return $this->replace($search, array_merge( + Arr::wrap($middleware), + Arr::wrap($this->replacements[$search] ?? $search) + )); + } + + /** + * Insert middleware after the given middleware in the global middleware stack. + */ + public function insertAfter(string $search, array|string $middleware): static + { + return $this->replace($search, array_merge( + Arr::wrap($this->replacements[$search] ?? $search), + Arr::wrap($middleware) + )); + } + /** * Define the global middleware for the application. */ @@ -222,13 +244,35 @@ public function removeFromGroup(string $group, array|string $middleware): static /** * Replace the given middleware in the specified group with another middleware. */ - public function replaceInGroup(string $group, string $search, string $replace): static + public function replaceInGroup(string $group, string $search, array|string $replace): static { $this->groupReplacements[$group][$search] = $replace; return $this; } + /** + * Insert middleware before the given middleware in the specified group. + */ + public function insertBeforeInGroup(string $group, string $search, array|string $middleware): static + { + return $this->replaceInGroup($group, $search, array_merge( + Arr::wrap($middleware), + Arr::wrap($this->groupReplacements[$group][$search] ?? $search) + )); + } + + /** + * Insert middleware after the given middleware in the specified group. + */ + public function insertAfterInGroup(string $group, string $search, array|string $middleware): static + { + return $this->replaceInGroup($group, $search, array_merge( + Arr::wrap($this->groupReplacements[$group][$search] ?? $search), + Arr::wrap($middleware) + )); + } + /** * Modify the middleware in the "web" group. */ @@ -330,9 +374,9 @@ public function getGlobalMiddleware(): array \Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ])); - $middleware = array_map(function ($middleware) { + $middleware = Arr::flatten(array_map(function ($middleware) { return $this->replacements[$middleware] ?? $middleware; - }, $middleware); + }, $middleware)); return array_values(array_filter( array_diff( @@ -368,11 +412,9 @@ public function getMiddlewareGroups(): array $middleware = array_merge($middleware, $this->groups); foreach ($middleware as $group => $groupedMiddleware) { - foreach ($groupedMiddleware as $index => $groupMiddleware) { - if (isset($this->groupReplacements[$group][$groupMiddleware])) { - $middleware[$group][$index] = $this->groupReplacements[$group][$groupMiddleware]; - } - } + $middleware[$group] = Arr::flatten(array_map(function ($groupMiddleware) use ($group) { + return $this->groupReplacements[$group][$groupMiddleware] ?? $groupMiddleware; + }, $groupedMiddleware)); } foreach ($this->groupRemovals as $group => $removals) { diff --git a/tests/Foundation/Configuration/MiddlewareTest.php b/tests/Foundation/Configuration/MiddlewareTest.php index 3bf9c0407..4f4e15dd2 100644 --- a/tests/Foundation/Configuration/MiddlewareTest.php +++ b/tests/Foundation/Configuration/MiddlewareTest.php @@ -20,6 +20,7 @@ use Hypervel\Http\Middleware\TrustProxies; use Hypervel\Http\Request; use Hypervel\Session\Middleware\AuthenticateSession; +use Hypervel\Session\Middleware\StartSession; use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionClass; @@ -340,6 +341,153 @@ public function testPreventRequestForgery(): void $this->assertTrue($reflection->getStaticPropertyValue('allowSameSite')); } + public function testReplace(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->replace('FirstMiddleware', 'ReplacedMiddleware'); + + $this->assertSame(['ReplacedMiddleware', 'SecondMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testReplaceWithArray(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware', 'ThirdMiddleware']); + $middleware->replace('FirstMiddleware', ['FirstMiddleware', 'AfterMiddleware']); + $middleware->replace('ThirdMiddleware', ['BeforeMiddleware', 'ThirdMiddleware']); + + $this->assertSame([ + 'FirstMiddleware', + 'AfterMiddleware', + 'SecondMiddleware', + 'BeforeMiddleware', + 'ThirdMiddleware', + ], $middleware->getGlobalMiddleware()); + } + + public function testReplaceDefaultMiddlewareWithArray(): void + { + $middleware = new Middleware; + + $middleware->replace(TrustProxies::class, [TrustProxies::class, 'AfterMiddleware']); + + $global = $middleware->getGlobalMiddleware(); + + $this->assertSame('AfterMiddleware', $global[array_search(TrustProxies::class, $global) + 1]); + } + + public function testReplaceMissingMiddlewareWithArray(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware']); + $middleware->replace('MissingMiddleware', ['MissingMiddleware', 'AfterMiddleware']); + + $this->assertSame(['FirstMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testRemoveReplacedMiddleware(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->replace('FirstMiddleware', ['FirstMiddleware', 'AfterMiddleware']); + $middleware->remove('FirstMiddleware'); + + $this->assertSame(['AfterMiddleware', 'SecondMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testReplaceInGroup(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->replaceInGroup('custom', 'FirstMiddleware', 'ReplacedMiddleware'); + + $this->assertSame(['ReplacedMiddleware', 'SecondMiddleware'], $middleware->getMiddlewareGroups()['custom']); + } + + public function testReplaceInGroupWithArray(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->replaceInGroup('custom', 'FirstMiddleware', ['BeforeMiddleware', 'FirstMiddleware', 'AfterMiddleware']); + + $this->assertSame([ + 'BeforeMiddleware', + 'FirstMiddleware', + 'AfterMiddleware', + 'SecondMiddleware', + ], $middleware->getMiddlewareGroups()['custom']); + } + + public function testReplaceInWebGroupWithArray(): void + { + $middleware = new Middleware; + + $middleware->web(replace: [ + StartSession::class => [StartSession::class, 'AfterMiddleware'], + ]); + + $web = $middleware->getMiddlewareGroups()['web']; + + $this->assertSame('AfterMiddleware', $web[array_search(StartSession::class, $web) + 1]); + } + + public function testInsertBefore(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertBefore('SecondMiddleware', ['BeforeMiddleware', 'AnotherBeforeMiddleware']); + + $this->assertSame([ + 'FirstMiddleware', + 'BeforeMiddleware', + 'AnotherBeforeMiddleware', + 'SecondMiddleware', + ], $middleware->getGlobalMiddleware()); + } + + public function testInsertAfter(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertAfter('FirstMiddleware', 'AfterMiddleware'); + + $this->assertSame(['FirstMiddleware', 'AfterMiddleware', 'SecondMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testInsertBeforeInGroup(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertBeforeInGroup('custom', 'SecondMiddleware', ['BeforeMiddleware', 'AnotherBeforeMiddleware']); + + $this->assertSame([ + 'FirstMiddleware', + 'BeforeMiddleware', + 'AnotherBeforeMiddleware', + 'SecondMiddleware', + ], $middleware->getMiddlewareGroups()['custom']); + } + + public function testInsertAfterInGroup(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertAfterInGroup('custom', 'FirstMiddleware', 'AfterMiddleware'); + + $this->assertSame(['FirstMiddleware', 'AfterMiddleware', 'SecondMiddleware'], $middleware->getMiddlewareGroups()['custom']); + } + public function testDefaultGlobalMiddleware(): void { $middleware = new Middleware; From 869382d2b142e4505a9270178701082ce55d5c44 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:07 +0000 Subject: [PATCH 09/38] Show released jobs as released in queue:work output queue:work printed DONE for an attempt that released its job back onto the queue. It now prints a yellow RELEASED, sizing the dots from the result label. JSON output already reported result: released. Ports laravel/framework#62180 from 13.x at b20bf7f442. Tests: ported testReleasedJobIsWrittenAsReleased and its ReleasingJob fixture; it passes on the database and Redis queues. The mocked job in Queue/WorkCommandTest now answers isReleased(). tests/Queue passes. --- src/queue/src/Console/WorkCommand.php | 15 ++++++----- tests/Integration/Queue/WorkCommandTest.php | 30 +++++++++++++++++++++ tests/Queue/WorkCommandTest.php | 1 + 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index a11b98ef7..ad0969ab1 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -346,18 +346,21 @@ protected function writeOutputForCli(Job $job, string $status): void $runTime = $this->runTimeForHumans($this->getLatestStartedAt()); $memory = $isVerbose ? round(memory_get_usage(true) / 1024 / 1024, 1) . 'MB' : ''; + [$result, $color] = match (true) { + $status === 'success' && $job->isReleased() => ['RELEASED', 'yellow'], + $status === 'success' => ['DONE', 'green'], + $status === 'released_after_exception' => ['FAIL', 'yellow'], + default => ['FAIL', 'red'], + }; + $dots = max(terminal()->width() - mb_strlen($jobName) - ( $isVerbose ? mb_strlen($displayJobId) + mb_strlen($job->getConnectionName()) + mb_strlen($job->getQueue()) + mb_strlen($memory) + 3 : 0 - ) - mb_strlen($runTime) - 31, 0); + ) - mb_strlen($runTime) - mb_strlen($result) - 27, 0); $this->output->write(' ' . str_repeat('.', $dots)); $this->output->write(" {$runTime}" . ($memory ? " {$memory}" : '') . ''); - $this->output->writeln(match ($status) { - 'success' => ' DONE', - 'released_after_exception' => ' FAIL', - default => ' FAIL', - }); + $this->output->writeln(" {$result}"); } /** diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 5b055736e..3474c8c68 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\UniqueConstraintViolationException; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Worker; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Artisan; @@ -365,6 +366,20 @@ public function testFailedJobListenerOnlyRunsOnce(): void $this->assertSame(2, substr_count(Artisan::output(), JobWillFail::class)); } + public function testReleasedJobIsWrittenAsReleased(): void + { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + + Queue::push(new ReleasingJob); + + $this->artisan('queue:work', [ + '--once' => true, + '--memory' => 1024, + ])->expectsOutputToContain('RELEASED') + ->doesntExpectOutputToContain('DONE') + ->assertExitCode(0); + } + public function testStopReasonIsWritten(): void { Queue::push(new FirstJob); @@ -484,6 +499,21 @@ public function handle(): void } } +class ReleasingJob implements ShouldQueue +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + + /** + * Release the job back onto the queue. + */ + public function handle(): void + { + $this->release(30); + } +} + class JobWillFail implements ShouldQueue { use Dispatchable; diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php index 6db08cce9..248cfaeba 100644 --- a/tests/Queue/WorkCommandTest.php +++ b/tests/Queue/WorkCommandTest.php @@ -43,6 +43,7 @@ public function testJobOutputIncludesVerboseDetailsOnlyWhenRequested(bool $verbo $job->shouldReceive('resolveName')->andReturn('App\Jobs\SendInvoice'); $job->shouldReceive('getConnectionName')->andReturn('redis'); $job->shouldReceive('getQueue')->andReturn('invoices'); + $job->shouldReceive('isReleased')->andReturnFalse(); $command = new WorkCommandOutputStub( $this->app, From c67c4fca4f5ec00b3d4aa6971b7337b1ae05f2ca Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:07 +0000 Subject: [PATCH 10/38] Fix forceIndex() generating invalid SQL on SQLite SQLite's INDEXED BY accepts a single named index, so forceIndex() with 'primary' or several comma-separated indexes produced invalid SQL. SQLiteGrammar::compileIndexHint() now validates each name and omits the hint in those cases. Ports laravel/framework#62255 from 13.x at b20bf7f442. Upstream has no docs; queries.md notes that SQLite ignores these hints. Tests: ported the three SQLite forceIndex cases to DatabaseQueryBuilderTest and the new Integration/Database/Sqlite/DatabaseSqliteIndexHintTest. Both files pass. --- .../src/Query/Grammars/SQLiteGrammar.php | 14 +++-- src/docs/queries.md | 2 + tests/Database/DatabaseQueryBuilderTest.php | 23 ++++++++ .../Sqlite/DatabaseSqliteIndexHintTest.php | 56 +++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/Database/Sqlite/DatabaseSqliteIndexHintTest.php diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index 3f5341f4f..96f2c10d1 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -162,13 +162,19 @@ protected function compileIndexHint(Builder $query, IndexHint $indexHint): strin return ''; } - $index = $indexHint->index; + $indexes = array_map('trim', explode(',', $indexHint->index)); - if (! preg_match('/^[a-zA-Z0-9_$]+$/', $index)) { - throw new InvalidArgumentException('Index name contains invalid characters.'); + foreach ($indexes as $index) { + if (! preg_match('/^[a-zA-Z0-9_$]+$/', $index)) { + throw new InvalidArgumentException('Index name contains invalid characters.'); + } + } + + if (count($indexes) > 1 || strtolower($indexes[0]) === 'primary') { + return ''; } - return "indexed by {$index}"; + return "indexed by {$indexes[0]}"; } /** diff --git a/src/docs/queries.md b/src/docs/queries.md index f89500c2d..57c6d249d 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -411,6 +411,8 @@ $users = DB::table('users') ->get(); ``` +Because SQLite's `indexed by` clause takes a single named index, SQLite ignores a `forceIndex` hint that names `primary` or several indexes. This lets queries written with MariaDB or MySQL hints run unchanged on SQLite. + Index hints apply to update statements as well. For example, MariaDB and MySQL may read a small table in full when an update names many of its rows, locking every row they read inside a transaction. Forcing the primary key keeps such an update to the rows it names: ```php diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 1af1eab5b..77889dc23 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -8974,6 +8974,29 @@ public function testForceIndexSqlite(): void $this->assertSame('select "foo" from "users" indexed by test_index', $builder->toSql()); } + public function testForceIndexSqliteIgnoresPrimaryKey(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->forceIndex('PRIMARY'); + $this->assertSame('select "foo" from "users"', $builder->toSql()); + } + + public function testForceIndexSqliteIgnoresMultipleIndexes(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->forceIndex('test_index, other_index'); + $this->assertSame('select "foo" from "users"', $builder->toSql()); + } + + public function testForceIndexSqliteRejectsInvalidIndexNames(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Index name contains invalid characters.'); + + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->forceIndex('test_index, other index')->toSql(); + } + public function testIgnoreIndexSqlite(): void { $builder = $this->getSQLiteBuilder(); diff --git a/tests/Integration/Database/Sqlite/DatabaseSqliteIndexHintTest.php b/tests/Integration/Database/Sqlite/DatabaseSqliteIndexHintTest.php new file mode 100644 index 000000000..938942a29 --- /dev/null +++ b/tests/Integration/Database/Sqlite/DatabaseSqliteIndexHintTest.php @@ -0,0 +1,56 @@ +id(); + $table->integer('user_id')->index(); + $table->integer('status')->index(); + }); + + DB::table('posts')->insert([ + ['user_id' => 1, 'status' => 1], + ['user_id' => 2, 'status' => 1], + ]); + } + + /** + * Drop the posts table. + */ + protected function destroyDatabaseMigrations(): void + { + Schema::drop('posts'); + } + + #[DataProvider('forceIndexDataProvider')] + public function testForceIndex(string $index): void + { + $this->assertSame(1, DB::table('posts')->forceIndex($index)->where('user_id', 1)->count()); + } + + /** + * Provide index hints that SQLite can run. + */ + public static function forceIndexDataProvider(): array + { + return [ + 'named index' => ['posts_user_id_index'], + 'primary key' => ['PRIMARY'], + 'multiple indexes' => ['posts_user_id_index, posts_status_index'], + ]; + } +} From d6d58b27bfe260e7ed8af05ed403e07d97fe811f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:21 +0000 Subject: [PATCH 11/38] Dispatch ScheduledTaskSkipped for tasks run elsewhere and add Schedule::whenTaskRunsLongerThan A single-server task that already ran on another server was skipped without a ScheduledTaskSkipped event. runSingleServerEvent() now dispatches it, and its message uses upstream's wording from laravel/framework#56776, which an earlier sync missed. Schedule::whenTaskRunsLongerThan() calls a handler with the task and its runtime in seconds when a run exceeds the threshold. Upstream listens to both ScheduledTaskFinished and ScheduledTaskFailed, but Failed follows Finished when a command exits with an error or a Finished listener throws, so upstream reports such runs twice. Each registration keeps a coroutine-local marker of the task it reported. Every run has its own coroutine, so concurrent runs and repeats are still reported once each. Ports laravel/framework#62256 and #62233 from 13.x at b20bf7f442. They share ScheduleRunCommandTest, so they are committed together. Upstream has no docs; scheduling.md now describes the duration handler. Tests: ported the integration skip-event test (on the worker-array cache, because the array store is coroutine-local and tasks run in their own coroutines) and both ScheduleTest duration tests. Added coverage for concurrent runs, a failing command reported once, and a throwing handler not called again for the Failed event; each fails without the marker. The files pass. --- .../src/Commands/ScheduleRunCommand.php | 4 +- src/console/src/Scheduling/Schedule.php | 57 ++++++++++++++ src/docs/scheduling.md | 11 +++ .../Scheduling/ScheduleRunCommandTest.php | 55 +++++++++++++- tests/Console/Scheduling/ScheduleTest.php | 76 +++++++++++++++++++ .../Scheduling/ScheduleRunCommandTest.php | 33 ++++++++ 6 files changed, 233 insertions(+), 3 deletions(-) diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 4a03ad7f7..5000105b8 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -448,8 +448,10 @@ protected function runSingleServerEvent(Event $event, CarbonInterface $startedAt if ($this->schedule->serverShouldRun($event, $startedAt)) { $this->runEvent($event); } else { + $this->dispatchTaskSkipped($event); + $this->components->info(sprintf( - 'Skipping [%s], as command already run on another server.', + 'Skipping [%s] because the command already ran on another server.', $event->getSummaryForDisplay() )); } diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index 740ba0075..9a995b59a 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -5,24 +5,31 @@ namespace Hypervel\Console\Scheduling; use BadMethodCallException; +use Carbon\CarbonInterval; use Closure; use DateTimeInterface; use DateTimeZone; use Hypervel\Bus\DispatchLockContext; use Hypervel\Bus\UniqueLock; +use Hypervel\Console\Events\ScheduledTaskFailed; +use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Container\Container; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Container\BindingResolutionException; +use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; +use Hypervel\Support\InteractsWithTime; use Hypervel\Support\ProcessUtils; use Hypervel\Support\Traits\Macroable; use RuntimeException; +use stdClass; use Symfony\Component\Console\Command\Command as SymfonyCommand; use UnitEnum; @@ -33,6 +40,7 @@ */ class Schedule { + use InteractsWithTime; use Macroable { __call as macroCall; } @@ -51,6 +59,11 @@ class Schedule public const int SATURDAY = 6; + /** + * The context key prefix for the task a duration handler has reported in the current run. + */ + protected const string TASK_DURATION_HANDLED_CONTEXT_KEY_PREFIX = '__console.scheduling_task_duration_handled.'; + /** * All of the events on the schedule. * @@ -523,6 +536,50 @@ public static function alwaysOnOneServer(bool $value = true): void static::$alwaysOnOneServer = $value; } + /** + * Register a callback to be invoked when a scheduled task runs for longer than a given amount of time. + * + * Boot-only. The listener persists on the singleton event dispatcher for + * the worker lifetime and runs for every subsequent matching scheduler event. + * + * @param callable(Event, float): mixed $handler + */ + public function whenTaskRunsLongerThan(DateTimeInterface|CarbonInterval|float|int $threshold, callable $handler): void + { + $threshold = $threshold instanceof DateTimeInterface + ? $this->secondsUntil($threshold) + : $threshold; + + $threshold = $threshold instanceof CarbonInterval + ? $threshold->totalSeconds + : $threshold; + + // Identifies this registration, even when the same handler is registered twice. The listener + // retains the object, so its id cannot be reused by another registration. + $registration = new stdClass; + + Container::getInstance()->make(EventDispatcher::class)->listen( + [ScheduledTaskFinished::class, ScheduledTaskFailed::class], + // ScheduledTaskFailed follows ScheduledTaskFinished when a command exits with an error or a + // Finished listener throws. Each run has its own coroutine, so this marker reports a run once. + function (ScheduledTaskFinished|ScheduledTaskFailed $event) use ($threshold, $handler, $registration): void { + if ($event->runtime === null || $event->runtime <= $threshold) { + return; + } + + $handledKey = self::TASK_DURATION_HANDLED_CONTEXT_KEY_PREFIX . spl_object_id($registration); + + if (CoroutineContext::get($handledKey) === $event->task) { + return; + } + + CoroutineContext::set($handledKey, $event->task); + + $handler($event->task, $event->runtime); + } + ); + } + /** * Flush all static state. */ diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index 9fe0e775f..0bb606cb5 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -788,3 +788,14 @@ Hypervel dispatches a variety of [events](/docs/{{version}}/events) during the s The `ScheduledTaskFailed` event's `runtime` property contains the elapsed execution time in seconds, or `null` when the runtime is unavailable. + +If you would like to be notified when a scheduled task takes too long to run, you may use the `whenTaskRunsLongerThan` method in your application's `routes/console.php` file. Provide a threshold in seconds, or a `CarbonInterval`, and a closure that receives the task and the number of seconds it ran. The closure is invoked once for each run that exceeds the threshold, including runs that fail: + +```php +use Hypervel\Console\Scheduling\Event; +use Hypervel\Support\Facades\Schedule; + +Schedule::whenTaskRunsLongerThan(60, function (Event $task, float $runtime) { + // Notify the development team... +}); +``` diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php index 7c83b95ef..8dbae28f7 100644 --- a/tests/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php @@ -393,7 +393,7 @@ public function testSkippedBackgroundTaskDoesNotDispatchCompletion(bool $onAnoth if ($onAnotherServer) { $this->assertStringContainsString( - 'Skipping [test:skipped-background], as command already run on another server.', + 'Skipping [test:skipped-background] because the command already ran on another server.', $output->fetch(), ); } @@ -678,6 +678,57 @@ public function testNonZeroExitDispatchesAndReportsFailure(): void $this->assertStringContainsString('Failed', $output->fetch()); } + public function testDurationHandlerReportsAFailingCommandOnce(): void + { + $this->dispatcher = $this->app->make(Dispatcher::class); + $this->handler->shouldReceive('report')->once(); + + $runtimes = []; + + // The fixture finishes instantly, so a negative threshold reports every run. + (new Schedule)->whenTaskRunsLongerThan(-1, function (Event $task, float $runtime) use (&$runtimes): void { + $runtimes[] = $runtime; + }); + + $command = $this->makeCommand(); + $this->captureOutput($command); + + $this->invokeRunEvent($command, new ScheduleRunExitCodeEvent(m::mock(EventMutex::class), 1, 'test:slow-failure')); + + $this->assertCount(1, $runtimes); + } + + public function testDurationHandlerThatThrowsIsNotCalledAgainForTheFailedEvent(): void + { + $exception = new RuntimeException('Alert transport failed.'); + $this->dispatcher = $this->app->make(Dispatcher::class); + $this->handler->shouldReceive('report')->once()->with($exception); + + $schedule = new Schedule; + $throwingCalls = 0; + $laterCalls = 0; + + $schedule->whenTaskRunsLongerThan(-1, function () use (&$throwingCalls, $exception): void { + ++$throwingCalls; + + throw $exception; + }); + + $schedule->whenTaskRunsLongerThan(-1, function () use (&$laterCalls): void { + ++$laterCalls; + }); + + $command = $this->makeCommand(); + $this->captureOutput($command); + + $this->invokeRunEvent($command, new ScheduleRunExitCodeEvent(m::mock(EventMutex::class), 0, 'test:throwing-handler')); + + $this->assertSame(1, $throwingCalls); + + // The exception stops the Finished dispatch, so the later registration reports the run through ScheduledTaskFailed. + $this->assertSame(1, $laterCalls); + } + public function testBackgroundNonZeroExitDispatchesAndReportsFailureBeforeBackgroundCompletion(): void { $this->handler->shouldReceive('report') @@ -1072,7 +1123,7 @@ public function testSingleServerEventClaimedElsewhereDoesNotReportThatNoCommands $display = $output->fetch(); $this->assertStringContainsString( - 'Skipping [test:single-server], as command already run on another server.', + 'Skipping [test:single-server] because the command already ran on another server.', $display, ); $this->assertStringNotContainsString('No scheduled commands are ready to run.', $display); diff --git a/tests/Console/Scheduling/ScheduleTest.php b/tests/Console/Scheduling/ScheduleTest.php index 7f1097d0b..e900242d3 100644 --- a/tests/Console/Scheduling/ScheduleTest.php +++ b/tests/Console/Scheduling/ScheduleTest.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use Hypervel\Bus\Queueable; use Hypervel\Console\Command; +use Hypervel\Console\Events\ScheduledTaskFailed; +use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Scheduling\CacheAware; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\CacheSchedulingMutex; @@ -16,8 +18,10 @@ use Hypervel\Console\Scheduling\SchedulingMutex; use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; use Hypervel\Support\CarbonImmutable; @@ -29,6 +33,9 @@ use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; + +use function Hypervel\Coroutine\parallel; enum ScheduleTestQueueStringEnum: string { @@ -578,6 +585,75 @@ public function testEveryEventMayRunOnOneServer(): void $this->assertFalse($unnamedClosure->onOneServer); } + public function testWhenTaskRunsLongerThan(): void + { + $this->container->instance(DispatcherContract::class, $events = new EventDispatcher($this->container)); + + $schedule = new Schedule; + $task = $schedule->command('inspire'); + + $called = false; + + $schedule->whenTaskRunsLongerThan(5, function () use (&$called) { + $called = true; + }); + + $events->dispatch(new ScheduledTaskFinished($task, 1.0)); + + $this->assertFalse($called); + + $events->dispatch(new ScheduledTaskFinished($task, 10.0)); + + $this->assertTrue($called); + } + + public function testWhenTaskRunsLongerThanHandlesFailedTasks(): void + { + $this->container->instance(DispatcherContract::class, $events = new EventDispatcher($this->container)); + + $schedule = new Schedule; + $task = $schedule->command('inspire'); + + $called = false; + + $schedule->whenTaskRunsLongerThan(5, function () use (&$called) { + $called = true; + }); + + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException)); + + $this->assertFalse($called); + + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException, 10.0)); + + $this->assertTrue($called); + } + + public function testWhenTaskRunsLongerThanReportsEachConcurrentRunOnce(): void + { + $this->container->instance(DispatcherContract::class, $events = new EventDispatcher($this->container)); + + $schedule = new Schedule; + $task = $schedule->command('inspire'); + + $calls = 0; + + $schedule->whenTaskRunsLongerThan(5, function () use (&$calls): void { + ++$calls; + }); + + // A failing run dispatches ScheduledTaskFailed after ScheduledTaskFinished. + $run = function () use ($events, $task): void { + $events->dispatch(new ScheduledTaskFinished($task, 10.0)); + usleep(5000); + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException, 10.0)); + }; + + parallel([$run, $run]); + + $this->assertSame(2, $calls); + } + public function testWithoutInterruptionPollingDisablesPauseAndInterruptChecks(): void { Schedule::withoutInterruptionPolling(); diff --git a/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php index 1562c7cc2..c313641ae 100644 --- a/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php @@ -7,7 +7,9 @@ use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; +use Hypervel\Console\Events\ScheduledTaskSkipped; use Hypervel\Console\Events\ScheduledTaskStarting; +use Hypervel\Console\Scheduling\CacheSchedulingMutex; use Hypervel\Console\Scheduling\Event as ScheduledEvent; use Hypervel\Console\Scheduling\EventMutex; use Hypervel\Console\Scheduling\Schedule; @@ -16,6 +18,7 @@ use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Event; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Console\Fixtures\FakeEventMutex; use ReflectionMethod; @@ -213,6 +216,36 @@ public function testOverlappingTaskFinishedEventIndicatesSkipped(): void $this->assertFalse($ran); } + // The array store is coroutine-local, so the other server's mutex would be invisible to the task's coroutine. + #[WithConfig('cache.default', 'worker-array')] + public function testTaskSkippedEventIsDispatchedWhenTaskRanOnAnotherServer(): void + { + Event::fake([ + ScheduledTaskStarting::class, + ScheduledTaskFinished::class, + ScheduledTaskSkipped::class, + ]); + + CarbonImmutable::setTestNow(CarbonImmutable::now()->startOfMinute()); + + $ran = false; + $schedule = $this->app->make(Schedule::class); + $task = $schedule->call(function () use (&$ran): void { + $ran = true; + })->name('test')->onOneServer()->everyMinute(); + + $this->app->make(CacheSchedulingMutex::class)->create($task, CarbonImmutable::now()); + + $this->runSchedule($task); + + Event::assertDispatched(ScheduledTaskSkipped::class, function (ScheduledTaskSkipped $event) use ($task): bool { + return $event->task === $task; + }); + Event::assertNotDispatched(ScheduledTaskStarting::class); + Event::assertNotDispatched(ScheduledTaskFinished::class); + $this->assertFalse($ran); + } + /** * @throws BindingResolutionException */ From 6b8ea2b94610195c25cd91f40adce66a1c1c8573 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:22 +0000 Subject: [PATCH 12/38] Update Schedule facade docblocks Regenerates the Schedule facade for whenTaskRunsLongerThan(), matching laravel/framework 63495736b1 on 13.x at b20bf7f442. Tests: FacadeDocblocksTest passes. --- src/support/src/Facades/Schedule.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php index ca058b41f..4e27cea40 100644 --- a/src/support/src/Facades/Schedule.php +++ b/src/support/src/Facades/Schedule.php @@ -27,6 +27,7 @@ * @method static void mixin(object $mixin, bool $replace = true) * @method static bool serverShouldRun(\Hypervel\Console\Scheduling\Event $event, \DateTimeInterface $time) * @method static \Hypervel\Console\Scheduling\Schedule useCache(\UnitEnum|string|null $store) + * @method static void whenTaskRunsLongerThan(\DateTimeInterface|\Carbon\CarbonInterval|int|float $threshold, callable $handler) * @method static void withoutInterruptionPolling() * @method static \Hypervel\Console\Scheduling\PendingEventAttributes at(string $time) * @method static \Hypervel\Console\Scheduling\PendingEventAttributes between(string $startTime, string $endTime) From 99d9b7b1853120d29c18f35f00946a7d3ad6f2b9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:22 +0000 Subject: [PATCH 13/38] Test savepoint rollback on nested concurrency errors Laravel now rolls back to the savepoint when a nested transaction hits a concurrency error. Hypervel's handleTransactionException() already wraps the failure in DeadlockException and rolls back to the savepoint, so only the test is ported. Ports the test from laravel/framework#62248 on 13.x at b20bf7f442. Its driver error is a PDOException, because Hypervel's ConcurrencyErrorDetector matches driver exceptions rather than the SQL in a QueryException message. Tests: testNestedTransactionIsRolledBackToItsSavepointOnConcurrencyError passes. --- tests/Database/DatabaseConnectionTest.php | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index f25869caf..9267e5bb1 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -570,6 +570,33 @@ public function testConnectionIsReestablishedAfterTheConnectionIsLostDuringRollB $this->assertSame($newPdo, $connection->getPdo()); } + public function testNestedTransactionIsRolledBackToItsSavepointOnConcurrencyError(): void + { + $pdo = $this->createMock(PDOStub::class); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->exactly(2))->method('exec')->willReturnCallback(function (string $sql): int { + static $calls = []; + $calls[] = $sql; + $this->assertSame(['SAVEPOINT trans2', 'ROLLBACK TO SAVEPOINT trans2'][count($calls) - 1], $sql); + + return 0; + }); + + $connection = $this->getMockConnection([], $pdo); + $connection->setQueryGrammar(new Grammar($connection)); + $connection->beginTransaction(); + + try { + $connection->transaction(function () { + throw new QueryException('conn', '', [], new PDOException('Lock wait timeout exceeded; try restarting transaction')); + }); + + $this->fail('A DeadlockException was not thrown.'); + } catch (DeadlockException) { + $this->assertSame(1, $connection->transactionLevel()); + } + } + public function testBeginTransactionMethodNeverRetriesIfWithinTransaction(): void { $pdo = $this->createMock(PDOStub::class); From 8c3b35874d2099f424632fd81424799036b5d44d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:22 +0000 Subject: [PATCH 14/38] Test toResourceCollection() on filtered collections Laravel fixed resource guessing for collections without a zero key. Hypervel's guessResourceCollection() already uses first(), so only the test is ported. Ports the test from laravel/framework#62266 on 13.x at b20bf7f442. Tests: testItCanGuessResourceForCollectionWithoutZeroKey passes. --- .../DatabaseEloquentResourceCollectionTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Database/DatabaseEloquentResourceCollectionTest.php b/tests/Database/DatabaseEloquentResourceCollectionTest.php index aa8ffac5c..81a7c437d 100644 --- a/tests/Database/DatabaseEloquentResourceCollectionTest.php +++ b/tests/Database/DatabaseEloquentResourceCollectionTest.php @@ -76,4 +76,22 @@ public function testItCanTransformToResourceViaUseResourceCollectionAttribute(): $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); $this->assertInstanceOf(EloquentResourceTestJsonResource::class, $resource[0]); } + + public function testItCanGuessResourceForCollectionWithoutZeroKey(): void + { + $first = new EloquentResourceTestResourceModelWithUseResourceAttribute; + $second = new EloquentResourceTestResourceModelWithUseResourceAttribute; + + $filtered = (new Collection([$first, $second]))->filter(fn ($model) => $model === $second); + $resource = $filtered->toResourceCollection(); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + $this->assertCount(1, $resource->collection); + + $keyed = (new Collection([$first, $second]))->keyBy(fn ($model) => spl_object_id($model)); + $resource = $keyed->toResourceCollection(); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + $this->assertCount(2, $resource->collection); + } } From 25c59ea39a973f2f132cc73174b180a6325b02f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:22 +0000 Subject: [PATCH 15/38] Test that lazy images are freed without the garbage collector Laravel fixed a reference cycle that kept lazily loaded images alive until garbage collection. Hypervel's lazy resolver closures are bound to the manager and cleared after resolving, so no cycle exists and only the test is ported. Ports the test from laravel/framework#62265 on 13.x at b20bf7f442. Tests: testLazyImagesAreFreedWithoutWaitingForTheGarbageCollector passes. --- tests/Image/ImageManagerTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Image/ImageManagerTest.php b/tests/Image/ImageManagerTest.php index 415cf0e90..5297a6abf 100644 --- a/tests/Image/ImageManagerTest.php +++ b/tests/Image/ImageManagerTest.php @@ -25,6 +25,7 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use TypeError; +use WeakReference; class ImageManagerTest extends TestCase { @@ -433,6 +434,26 @@ public function testLazyContentsAreNotSharedBetweenDifferentImages(): void $this->assertSame('first', $first->toBytes()); } + public function testLazyImagesAreFreedWithoutWaitingForTheGarbageCollector(): void + { + $app = $this->makeApp([]); + $manager = new ImageManager($app); + + gc_disable(); + + try { + $image = $manager->fromBase64(base64_encode('contents')); + $this->assertSame('contents', $image->toBytes()); + + $reference = WeakReference::create($image); + unset($image); + + $this->assertNull($reference->get()); + } finally { + gc_enable(); + } + } + public function testFromUrlIsOnlyFetchedOnce(): void { $contents = $this->fakeImageContents(); From 81576aab0f5bb0d79d9a565eaceb7808ff285a37 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:22 +0000 Subject: [PATCH 16/38] Clarify the key:generate --show help text Ports laravel/framework#62286 from 13.x at b20bf7f442: --show now reads "Display a new key instead of modifying files". Tests: the command signature fixture is updated; CommandSignatureTest passes. --- src/encryption/src/Commands/KeyGenerateCommand.php | 2 +- tests/Console/Fixtures/command_signatures.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/encryption/src/Commands/KeyGenerateCommand.php b/src/encryption/src/Commands/KeyGenerateCommand.php index 05772a3c8..3efad1869 100644 --- a/src/encryption/src/Commands/KeyGenerateCommand.php +++ b/src/encryption/src/Commands/KeyGenerateCommand.php @@ -23,7 +23,7 @@ class KeyGenerateCommand extends Command * The name and signature of the console command. */ protected ?string $signature = 'key:generate - {--show : Display the key instead of modifying files} + {--show : Display a new key instead of modifying files} {--force : Force the operation to run when in production}'; /** diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index 54c8818a5..f940e2670 100644 --- a/tests/Console/Fixtures/command_signatures.php +++ b/tests/Console/Fixtures/command_signatures.php @@ -2395,7 +2395,7 @@ 'isArray' => false, 'acceptValue' => false, 'default' => false, - 'description' => 'Display the key instead of modifying files', + 'description' => 'Display a new key instead of modifying files', ], [ 'name' => 'force', From 5b29a4362746c79457de8b27c25f38af87e9b57a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:38 +0000 Subject: [PATCH 17/38] Record failed batch jobs and release unique locks when the model is missing When a job's model was missing, CallQueuedHandler::failed() rethrew the ModelNotFoundException without recording the batch job as failed. It now records it before rethrowing. The same path also left the job's unique lock held. Worker failures such as max attempts, max exceptions and timeouts reach failed() without call(), so with uniqueFor = 0 the lock was never released. Laravel has the same defect. failed() now releases the lock before rethrowing: - With a job, it reads the lock store, key and owner from the job's payload, because the timeout monitor's ambient context belongs to another job. A payload without lock metadata releases nothing. - Without a job, it uses the ambient context as before. - An ownerless lock is force-released only for a regular unique class, or when this attempt failed before processing began. An ownerless until-processing lock may otherwise belong to a replacement dispatch. handleModelNotFound() marks the before-processing case with a coroutine-local marker around $job->fail(), and releases the lock itself only when deleting the job, so each failure releases once. Ports laravel/framework#62280 from 13.x at b20bf7f442. Tests: ported testBatchJobIsRecordedAsFailedWhenModelIsMissing. The via-context delete tests now use payload metadata. New cases cover payload release with unrelated ambient context, a payload without lock metadata, an ownerless until-processing lock kept for a replacement, a regular unique listener, release on the first attempt only, and context-based release without a job. Each fails with its part of the fix removed. Both CallQueuedHandlerTest files, tests/Queue and tests/Integration/Queue (database and Redis queues) pass. --- src/queue/src/CallQueuedHandler.php | 135 ++++++++- .../Queue/CallQueuedHandlerTest.php | 278 ++++++++++++++++-- 2 files changed, 390 insertions(+), 23 deletions(-) diff --git a/src/queue/src/CallQueuedHandler.php b/src/queue/src/CallQueuedHandler.php index 522f8c75a..6b420a003 100644 --- a/src/queue/src/CallQueuedHandler.php +++ b/src/queue/src/CallQueuedHandler.php @@ -11,6 +11,7 @@ use Hypervel\Bus\DebounceLock; use Hypervel\Bus\Queueable; use Hypervel\Bus\UniqueLock; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Cache\LockProvider; @@ -31,6 +32,11 @@ class CallQueuedHandler { + /** + * The context key for the job being failed because its command's models are missing. + */ + protected const string MISSING_MODEL_FAILURE_CONTEXT_KEY = '__queue.missing_model_failure'; + /** * The command currently being processed. */ @@ -304,9 +310,10 @@ protected function commandShouldBeUniqueUntilProcessing(mixed $command): bool */ protected function handleModelNotFound(Job $job, Throwable $e): void { - $this->ensureUniqueJobLockIsReleasedViaContext(); + $payload = $job->payload(); - if ($job->payload()['deleteWhenMissingModels'] ?? false) { + if ($payload['deleteWhenMissingModels'] ?? false) { + $this->ensureUniqueJobLockIsReleasedForMissingModel($job, $payload, beforeProcessing: true); $this->ensureSuccessfulBatchJobIsRecordedForMissingModel($job, $job->resolveQueuedJobClass()); $job->delete(); @@ -314,7 +321,95 @@ protected function handleModelNotFound(Job $job, Throwable $e): void return; } - $job->fail($e); + // failed() releases the unique lock; the marker tells it this attempt never began processing. + $previous = CoroutineContext::get(self::MISSING_MODEL_FAILURE_CONTEXT_KEY); + CoroutineContext::set(self::MISSING_MODEL_FAILURE_CONTEXT_KEY, $job); + + try { + $job->fail($e); + } finally { + if ($previous === null) { + CoroutineContext::forget(self::MISSING_MODEL_FAILURE_CONTEXT_KEY); + } else { + CoroutineContext::set(self::MISSING_MODEL_FAILURE_CONTEXT_KEY, $previous); + } + } + } + + /** + * Release the unique lock of a job whose command cannot be restored because its models are missing. + * + * @param array $payload the job's payload, or a partial payload containing only its data when the job is unavailable + * @param bool $beforeProcessing whether this attempt failed before its command began processing + */ + protected function ensureUniqueJobLockIsReleasedForMissingModel(?Job $job, array $payload, bool $beforeProcessing): void + { + if ($job === null) { + $owner = ContextRepository::hasInstance() + ? ContextRepository::getInstance()->getHidden('laravel_unique_job_lock_owner') + : null; + + if ((is_string($owner) && $owner !== '') + || $this->ownerlessUniqueLockMayBeReleasedForMissingModel(null, $payload, false) + ) { + $this->ensureUniqueJobLockIsReleasedViaContext(); + } + + return; + } + + // The payload identifies this job's lock even when the ambient context belongs to + // another job, such as in the worker's timeout monitor. + $hidden = $payload['illuminate:log:context']['hidden'] ?? []; + + [$store, $key, $owner] = array_map( + static fn (string $name): mixed => isset($hidden[$name]) + ? unserialize($hidden[$name], ['allowed_classes' => false]) + : null, + ['laravel_unique_job_cache_store', 'laravel_unique_job_key', 'laravel_unique_job_lock_owner'], + ); + + if (! is_string($store) || $store === '' + || ! is_string($key) || $key === '' + || ! $this->container->bound(CacheFactory::class) + ) { + return; + } + + $owner = is_string($owner) ? $owner : ''; + + if ($owner === '' && ! $this->ownerlessUniqueLockMayBeReleasedForMissingModel($job, $payload, $beforeProcessing)) { + return; + } + + UniqueLock::releaseOwned($this->container->make(CacheFactory::class)->store($store), $key, $owner); + } + + /** + * Determine if an ownerless unique lock still belongs to a job whose models are missing. + * + * @param array $payload the job's payload, or a partial payload containing only its data when the job is unavailable + * @param bool $beforeProcessing whether this attempt failed before its command began processing + */ + protected function ownerlessUniqueLockMayBeReleasedForMissingModel(?Job $job, array $payload, bool $beforeProcessing): bool + { + $class = $payload['data']['commandName'] ?? null; + + // Queued listeners take their uniqueness from the listener class named in the payload. + if (is_string($class) && is_a($class, CallQueuedListener::class, true)) { + $class = $payload['displayName'] ?? null; + } + + // A regular unique lock is held until the job finishes. An until-processing lock is released when + // an attempt begins processing, so after that an ownerless one may belong to a replacement dispatch. + if (is_string($class) + && is_a($class, ShouldBeUnique::class, true) + && ! is_a($class, ShouldBeUniqueUntilProcessing::class, true) + ) { + return true; + } + + return $beforeProcessing && $job !== null && $job->attempts() <= 1; } /** @@ -385,7 +480,19 @@ protected function ensureSuccessfulBatchJobIsRecordedForMissingModel(Job $job, s */ public function failed(array $data, ?Throwable $e, string $uuid, ?Job $job = null): void { - $command = $this->getCommand($data); + try { + $command = $this->getCommand($data); + } catch (ModelNotFoundException $exception) { + $this->ensureUniqueJobLockIsReleasedForMissingModel( + $job, + $job === null ? ['data' => $data] : $job->payload(), + beforeProcessing: $job !== null && CoroutineContext::get(self::MISSING_MODEL_FAILURE_CONTEXT_KEY) === $job, + ); + + $this->ensureFailedBatchJobIsRecordedForMissingModel($uuid, $data, $e); + + throw $exception; + } if (! is_null($job)) { $command = $this->setJobInstanceIfNecessary($job, $command); @@ -423,6 +530,26 @@ protected function ensureFailedBatchJobIsRecorded(string $uuid, mixed $command, } } + /** + * Record a potentially batched job as failed when its models are missing. + */ + protected function ensureFailedBatchJobIsRecordedForMissingModel(string $uuid, array $data, ?Throwable $e): void + { + if (! $this->container->bound(BatchRepository::class)) { + return; + } + + $batchId = $data['batchId'] ?? null; + + if ((! is_string($batchId) || $batchId === '') || $uuid === '') { + return; + } + + if ($batch = $this->container->make(BatchRepository::class)->find($batchId)) { + $batch->recordFailedJob($uuid, $e); + } + } + /** * Ensure the chained job catch callbacks are invoked. */ diff --git a/tests/Integration/Queue/CallQueuedHandlerTest.php b/tests/Integration/Queue/CallQueuedHandlerTest.php index 7aff02720..8140eccea 100644 --- a/tests/Integration/Queue/CallQueuedHandlerTest.php +++ b/tests/Integration/Queue/CallQueuedHandlerTest.php @@ -14,9 +14,14 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Cache\Lock; use Hypervel\Contracts\Cache\Repository as Cache; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldBeUniqueUntilProcessing; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\Eloquent\ModelNotFoundException; +use Hypervel\Events\CallQueuedListener; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Queue\Attributes\DeleteWhenMissingModels; use Hypervel\Queue\CallQueuedHandler; @@ -24,9 +29,12 @@ use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\FakeJob; +use Hypervel\Queue\Jobs\Job as BaseJob; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use stdClass; class CallQueuedHandlerTest extends TestCase @@ -186,7 +194,28 @@ public function testBatchJobIsRecordedWhenDeletedDueToMissingModel(): void Event::assertNotDispatched(JobFailed::class); } - public function testUniqueJobLockIsReleasedViaContextOnModelNotFound(): void + public function testBatchJobIsRecordedAsFailedWhenModelIsMissing(): void + { + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $exception = new ModelNotFoundException('Foo'); + + $batch = m::mock(Batch::class); + $batch->expects('recordFailedJob')->with('job-uuid', $exception); + + $repository = m::mock(BatchRepository::class); + $repository->expects('find')->with('test-batch-id')->andReturn($batch); + $this->app->instance(BatchRepository::class, $repository); + + $this->expectException(ModelNotFoundException::class); + + $instance->failed([ + 'batchId' => 'test-batch-id', + 'command' => serialize((new CallQueuedHandlerBatchableExceptionThrower)->withBatchId('test-batch-id')), + ], $exception, 'job-uuid'); + } + + public function testUniqueJobLockIsReleasedFromPayloadWhenDeletedForMissingModel(): void { $lock = m::mock(Lock::class); $lock->expects('forceRelease'); @@ -198,23 +227,23 @@ public function testUniqueJobLockIsReleasedViaContextOnModelNotFound(): void $cacheFactory->shouldReceive('store')->with('array')->andReturn($cache); $this->app->instance(CacheFactory::class, $cacheFactory); - ContextRepository::getInstance()->addHidden([ - 'laravel_unique_job_cache_store' => 'array', - 'laravel_unique_job_key' => 'laravel_unique_job:TestJob:42', - ]); - $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); $job = m::mock(Job::class); - $job->shouldReceive('payload')->andReturn(['deleteWhenMissingModels' => false]); - $job->expects('fail'); + $job->shouldReceive('payload')->andReturn([ + 'deleteWhenMissingModels' => true, + 'illuminate:log:context' => ['hidden' => $this->uniqueLockMetadata()], + ]); + $job->shouldReceive('attempts')->andReturn(1); + $job->expects('resolveQueuedJobClass')->andReturn(CallQueuedHandlerExceptionThrower::class); + $job->expects('delete'); $instance->call($job, [ - 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + 'command' => serialize(new CallQueuedHandlerExceptionThrower), ]); } - public function testUniqueJobLockOwnerIsRestoredViaContextOnModelNotFound(): void + public function testUniqueJobLockOwnerIsRestoredFromPayloadWhenDeletedForMissingModel(): void { $lock = m::mock(Lock::class); $lock->expects('release'); @@ -230,21 +259,142 @@ public function testUniqueJobLockOwnerIsRestoredViaContextOnModelNotFound(): voi $cacheFactory->shouldReceive('store')->with('array')->andReturn($cache); $this->app->instance(CacheFactory::class, $cacheFactory); - ContextRepository::getInstance()->addHidden([ - 'laravel_unique_job_cache_store' => 'array', - 'laravel_unique_job_key' => 'laravel_unique_job:TestJob:42', - 'laravel_unique_job_lock_owner' => 'owner-token', - ]); - $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); $job = m::mock(Job::class); - $job->shouldReceive('payload')->andReturn(['deleteWhenMissingModels' => false]); - $job->expects('fail'); + $job->shouldReceive('payload')->andReturn([ + 'deleteWhenMissingModels' => true, + 'illuminate:log:context' => ['hidden' => $this->uniqueLockMetadata('owner-token')], + ]); + $job->expects('resolveQueuedJobClass')->andReturn(CallQueuedHandlerExceptionThrower::class); + $job->expects('delete'); $instance->call($job, [ - 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + 'command' => serialize(new CallQueuedHandlerExceptionThrower), + ]); + } + + #[TestWith([true], 'payload metadata')] + #[TestWith([false], 'no payload metadata')] + public function testUniqueJobLockIsReleasedOnlyFromThePayloadWhenFailingWithMissingModel(bool $payloadHasLockMetadata): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + $this->assertTrue($cache->lock('laravel_unique_job:Other:1')->acquire()); + + // Another job's metadata in the ambient context, as in the worker's timeout monitor. + ContextRepository::getInstance()->addHidden([ + 'laravel_unique_job_cache_store' => 'worker-array', + 'laravel_unique_job_key' => 'laravel_unique_job:Other:1', + ]); + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $this->missingModelPayload( + CallQueuedHandlerMissingModelUniqueJob::class, + $payloadHasLockMetadata ? $this->uniqueLockMetadata(store: 'worker-array') : [], + )); + + try { + $this->app->make(CallQueuedHandler::class)->failed($job->payload()['data'], new RuntimeException, 'job-uuid', $job); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertSame($payloadHasLockMetadata, $cache->lock('laravel_unique_job:TestJob:42')->acquire()); + $this->assertFalse($cache->lock('laravel_unique_job:Other:1')->acquire()); + } + + public function testOwnerlessUniqueUntilProcessingLockIsKeptWhenFailingWithMissingModelAfterProcessingMayHaveStarted(): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + + // A replacement dispatch holds the lock the failed job released when its attempt began processing. + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $this->missingModelPayload( + CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, + $this->uniqueLockMetadata(store: 'worker-array'), + )); + + try { + $this->app->make(CallQueuedHandler::class)->failed($job->payload()['data'], new RuntimeException, 'job-uuid', $job); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertFalse($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + } + + public function testOwnerlessRegularUniqueListenerLockIsReleasedWhenFailingWithMissingModel(): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + + $payload = $this->missingModelPayload(CallQueuedListener::class, $this->uniqueLockMetadata(store: 'worker-array')); + $payload['displayName'] = CallQueuedHandlerUniqueListener::class; + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $payload); + + try { + $this->app->make(CallQueuedHandler::class)->failed($payload['data'], new RuntimeException, 'job-uuid', $job); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + } + + #[TestWith([1, 1])] + #[TestWith([2, 0])] + public function testMissingModelFailureReleasesAnOwnerlessUniqueUntilProcessingLockOnceBeforeProcessing(int $attempts, int $releases): void + { + $lock = m::mock(Lock::class); + $lock->expects('forceRelease')->times($releases); + + $cache = m::mock(Cache::class); + $cache->shouldReceive('lock')->with('laravel_unique_job:TestJob:42')->andReturn($lock); + + $cacheFactory = m::mock(CacheFactory::class); + $cacheFactory->shouldReceive('store')->with('array')->andReturn($cache); + $this->app->instance(CacheFactory::class, $cacheFactory); + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $this->missingModelPayload( + CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, + $this->uniqueLockMetadata(), + ), $attempts); + + try { + $this->app->make(CallQueuedHandler::class)->call($job, $job->payload()['data']); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertTrue($job->hasFailed()); + } + + #[TestWith([CallQueuedHandlerMissingModelUniqueJob::class, '', true], 'ownerless unique job')] + #[TestWith([CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, 'owner-token', true], 'owned unique until processing job')] + #[TestWith([CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, '', false], 'ownerless unique until processing job')] + public function testUniqueJobLockIsReleasedFromContextWhenFailingWithMissingModelWithoutAJob(string $commandName, string $owner, bool $released): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42', 0, 'owner-token')->acquire()); + + ContextRepository::getInstance()->addHidden([ + 'laravel_unique_job_cache_store' => 'worker-array', + 'laravel_unique_job_key' => 'laravel_unique_job:TestJob:42', + 'laravel_unique_job_lock_owner' => $owner, ]); + + try { + $this->app->make(CallQueuedHandler::class)->failed([ + 'commandName' => $commandName, + 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + ], new RuntimeException, 'job-uuid'); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertSame($released, $cache->lock('laravel_unique_job:TestJob:42')->acquire()); } public function testDebouncedJobEventIsSkippedWithoutListeners(): void @@ -303,6 +453,96 @@ public function testDebouncedJobEventRemainsVisibleToEventFake(): void && $event->command === $command; }); } + + /** + * Get the payload metadata of a unique job lock. + * + * @return array + */ + protected function uniqueLockMetadata(string $owner = '', string $store = 'array'): array + { + return [ + 'laravel_unique_job_cache_store' => serialize($store), + 'laravel_unique_job_key' => serialize('laravel_unique_job:TestJob:42'), + 'laravel_unique_job_lock_owner' => serialize($owner), + ]; + } + + /** + * Get the payload of a queued command whose model is missing. + * + * @param class-string $commandName + * @param array $hidden + */ + protected function missingModelPayload(string $commandName, array $hidden): array + { + return [ + 'job' => 'Illuminate\Queue\CallQueuedHandler@call', + 'uuid' => 'job-uuid', + 'displayName' => $commandName, + 'deleteWhenMissingModels' => false, + 'data' => [ + 'commandName' => $commandName, + 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + ], + 'illuminate:log:context' => ['hidden' => $hidden], + ]; + } +} + +class CallQueuedHandlerMissingModelQueueJob extends BaseJob +{ + /** + * Create a new job instance. + */ + public function __construct(ContainerContract $container, protected array $rawPayload, protected int $attemptCount = 1) + { + $this->container = $container; + $this->connectionName = 'sync'; + $this->queue = 'default'; + } + + /** + * Get the job identifier. + */ + public function getJobId(): string + { + return 'job-id'; + } + + /** + * Get the raw body of the job. + */ + public function getRawBody(): string + { + return json_encode($this->rawPayload, JSON_THROW_ON_ERROR); + } + + /** + * Get the number of times the job has been attempted. + */ + public function attempts(): int + { + return $this->attemptCount; + } +} + +class CallQueuedHandlerMissingModelUniqueJob implements ShouldBeUnique +{ +} + +class CallQueuedHandlerMissingModelUniqueUntilProcessingJob implements ShouldBeUniqueUntilProcessing +{ +} + +class CallQueuedHandlerUniqueListener implements ShouldBeUnique, ShouldQueue +{ + /** + * Handle the event. + */ + public function handle(): void + { + } } class TestableCallQueuedHandler extends CallQueuedHandler From 7ca0670d001f4d4ef802dea1733d70792310bd6a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:38 +0000 Subject: [PATCH 18/38] Fix broadcasting to multiple encrypted Pusher channels Pusher rejects a trigger that includes more than one encrypted channel. PusherBroadcaster::broadcast() now triggers each private-encrypted- channel separately and chunks the rest. Idle database sessions are released before every trigger, including the encrypted ones. Ports laravel/framework#62279 from 13.x at b20bf7f442. Tests: ported testBroadcastTriggersEncryptedChannelsSeparately. BroadcastConnectionReleaseTest now includes an encrypted channel; it fails without that release. Both files pass. --- .../src/Broadcasters/PusherBroadcaster.php | 10 ++++++++-- .../Broadcasting/BroadcastConnectionReleaseTest.php | 7 ++++--- tests/Broadcasting/PusherBroadcasterTest.php | 13 +++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php index e05957a2b..202e05662 100644 --- a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php @@ -133,12 +133,18 @@ public function broadcast(array $channels, string $event, array $payload = []): $parameters = $socket !== null ? ['socket_id' => $socket] : []; - $channels = new Collection($this->formatChannels($channels)); + [$encryptedChannels, $channels] = (new Collection($this->formatChannels($channels))) + ->partition(fn ($channel) => str_starts_with($channel, 'private-encrypted-')); try { $channels->chunk(100)->each(function ($channels) use ($event, $payload, $parameters) { ConnectionResolver::releaseIdleConnections(); - $this->pusher->trigger($channels->toArray(), $event, $payload, $parameters); + $this->pusher->trigger($channels->values()->all(), $event, $payload, $parameters); + }); + + $encryptedChannels->each(function ($channel) use ($event, $payload, $parameters) { + ConnectionResolver::releaseIdleConnections(); + $this->pusher->trigger([$channel], $event, $payload, $parameters); }); } catch (ApiErrorException $e) { throw new BroadcastException( diff --git a/tests/Broadcasting/BroadcastConnectionReleaseTest.php b/tests/Broadcasting/BroadcastConnectionReleaseTest.php index ce7e5ffc1..9a2ef66bf 100644 --- a/tests/Broadcasting/BroadcastConnectionReleaseTest.php +++ b/tests/Broadcasting/BroadcastConnectionReleaseTest.php @@ -43,7 +43,7 @@ public function testPusherReleasesIdleSessionsAndPreservesTransactionsAndPins(): { $observed = []; $pusher = m::mock(Pusher::class); - $pusher->shouldReceive('trigger')->times(9)->andReturnUsing(function () use (&$observed): object { + $pusher->shouldReceive('trigger')->times(12)->andReturnUsing(function () use (&$observed): object { $observed[] = $this->borrowedConnections(); DB::connection('broadcast')->selectOne('select 1'); @@ -51,10 +51,11 @@ public function testPusherReleasesIdleSessionsAndPreservesTransactionsAndPins(): }); $broadcaster = new PusherBroadcaster($this->app, $pusher); - $channels = array_map(static fn (int $id): string => 'orders.' . $id, range(1, 201)); + // Encrypted channels are triggered one at a time after the chunks. + $channels = [...array_map(static fn (int $id): string => 'orders.' . $id, range(1, 201)), 'private-encrypted-orders']; $this->sendInEachScope(fn () => $broadcaster->broadcast($channels, 'created')); - $this->assertSame([0, 0, 0, 1, 1, 1, 1, 1, 1], $observed); + $this->assertSame([0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1], $observed); } public function testAblyReleasesAfterPreparingTheMessage(): void diff --git a/tests/Broadcasting/PusherBroadcasterTest.php b/tests/Broadcasting/PusherBroadcasterTest.php index 9ab7e052b..d0e11966a 100644 --- a/tests/Broadcasting/PusherBroadcasterTest.php +++ b/tests/Broadcasting/PusherBroadcasterTest.php @@ -175,6 +175,19 @@ function (object $authenticatedUser, PusherBroadcasterTestEloquentModelStub $ord $this->assertSame('5', $boundOrder->boundValue); } + public function testBroadcastTriggersEncryptedChannelsSeparately(): void + { + $this->pusher->expects('trigger')->with(['private-a', 'b'], 'event', ['foo' => 'bar'], []); + $this->pusher->expects('trigger')->with(['private-encrypted-c'], 'event', ['foo' => 'bar'], []); + $this->pusher->expects('trigger')->with(['private-encrypted-d'], 'event', ['foo' => 'bar'], []); + + $this->broadcaster->broadcast( + ['private-a', 'private-encrypted-c', 'b', 'private-encrypted-d'], + 'event', + ['foo' => 'bar'] + ); + } + public function testValidAuthenticationResponseCallPusherSocketAuthMethodWithPrivateChannel(): void { $request = $this->getMockRequestWithUserForChannel('private-test'); From 68a510d82c6819430e1664bb64ee26c732fb097c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:38 +0000 Subject: [PATCH 19/38] Fix Str::remove() with a Traversable subject and Str::substrCount() offsets Hypervel had narrowed Str::remove()'s subject to string, dropping Laravel's documented iterable subject. It now accepts string|iterable, converts a Traversable subject, and returns string|array with upstream's conditional return type. Str::substrCount() treated offset and length as bytes. It now counts within Str::substr(), so they are characters as documented. Ports laravel/framework#62278 and #62277 from 13.x at b20bf7f442. They share Str.php and its test, so they are committed together. Tests: ported the remove() and multibyte substrCount() assertions. SupportStrTest and SupportStringableTest pass. --- src/support/src/Str.php | 14 ++++++++------ tests/Support/SupportStrTest.php | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 7ecd79508..ee83f46c6 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1284,13 +1284,19 @@ public static function replaceMatches(string|array $pattern, Closure|array|strin * Remove any occurrence of the given string in the subject. * * @param iterable|string $search + * @param iterable|string $subject + * @return ($subject is string ? string : string[]) */ - public static function remove(string|iterable $search, string $subject, bool $caseSensitive = true): string + public static function remove(string|iterable $search, string|iterable $subject, bool $caseSensitive = true): string|array { if ($search instanceof Traversable) { $search = iterator_to_array($search); } + if ($subject instanceof Traversable) { + $subject = iterator_to_array($subject); + } + return $caseSensitive ? str_replace($search, '', $subject) : static::replaceWhileIgnoringCase($search, '', $subject); @@ -1645,11 +1651,7 @@ public static function substr(string $string, int $start, ?int $length = null, s */ public static function substrCount(string $haystack, string $needle, int $offset = 0, ?int $length = null): int { - if (! is_null($length)) { - return substr_count($haystack, $needle, $offset, $length); - } - - return substr_count($haystack, $needle, $offset); + return substr_count(static::substr($haystack, $offset, $length), $needle); } /** diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 2da3f1e01..721f6398c 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1326,6 +1326,12 @@ public function testRemove(): void $this->assertSame('ltý', Str::remove('ž', 'Žltý', false)); $this->assertSame('žltý ', Str::remove('KÔŇ', 'žltý kôň', false)); + + $this->assertSame(['Fbar', 'Bar'], Str::remove('o', ['Foobar', 'Boar'])); + $this->assertSame(['Fbar', 'Bar'], Str::remove('o', collect(['Foobar', 'Boar']))); + $this->assertSame(['Fbr', 'Br'], Str::remove(collect(['o', 'a']), collect(['Foobar', 'Boar']))); + $this->assertSame(['Fooar', 'oar'], Str::remove('b', collect(['Foobar', 'Boar']), false)); + $this->assertSame(['ltý', 'ito'], Str::remove('ž', collect(['Žltý', 'žito']), false)); } public function testReverse(): void @@ -1659,6 +1665,15 @@ public function testSubstrCount(): void $this->assertSame(0, Str::substrCount('hypervelPHPFramework', 'a', 1, 2)); $this->assertSame(1, Str::substrCount('hypervelPHPFramework', 'a', 1, -2)); $this->assertSame(1, Str::substrCount('hypervelPHPFramework', 'a', -10, -3)); + + $this->assertSame(3, Str::substrCount('سلام سلام سلام', 'سلام')); + $this->assertSame(2, Str::substrCount('سلام سلام سلام', 'سلام', 5)); + $this->assertSame(3, Str::substrCount('سلام سلام سلام', 'سلام', 0, 14)); + $this->assertSame(1, Str::substrCount('سلام سلام سلام', 'سلام', 5, 4)); + $this->assertSame(1, Str::substrCount('سلام سلام سلام', 'سلام', -4)); + $this->assertSame(2, Str::substrCount('ab🙂cd🙂ef', '🙂', 0, 6)); + $this->assertSame(1, Str::substrCount('ab🙂cd🙂ef', '🙂', 3)); + $this->assertSame(2, Str::substrCount('ab🙂cd🙂ef', '🙂', 1, -1)); } public function testPosition(): void From a990b8a94d28bce57befe083e38abe9bc15c4d3e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:38 +0000 Subject: [PATCH 20/38] Add Grammar::compileExistsColumn() Laravel fixed withExists() and loadExists() on SQL Server by letting the grammar compile the exists column. SQL Server is not supported, so its override is excluded. The base compileExistsColumn() hook is ported, keeping Laravel's grammar API for custom drivers, and withAggregate() uses it. Ports laravel/framework#62273 from 13.x at b20bf7f442. Tests: ported testWithExists; it passes on SQLite, MySQL, MariaDB and PostgreSQL. --- .../src/Eloquent/Concerns/QueriesRelationships.php | 2 +- src/database/src/Query/Grammars/Grammar.php | 8 ++++++++ tests/Integration/Database/EloquentWithCountTest.php | 9 +++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 387ac6fb5..c58742ab5 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -898,7 +898,7 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum if ($function === 'exists') { $this->selectRaw( - sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrapIdentifier($alias)), + sprintf('%s as %s', $this->getQuery()->grammar->compileExistsColumn($query->toSql()), $this->getQuery()->grammar->wrapIdentifier($alias)), $query->getBindings() )->withCasts([$alias => 'bool']); // @phpstan-ignore method.notFound (selectRaw returns Eloquent\Builder $this, not Query\Builder) } else { diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 8de91e5f9..77772e820 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -1071,6 +1071,14 @@ public function compileExists(Builder $query): string ); } + /** + * Compile an "exists" subquery as a selectable column. + */ + public function compileExistsColumn(string $sql): string + { + return 'exists(' . $sql . ')'; + } + /** * Compile an insert statement into SQL. */ diff --git a/tests/Integration/Database/EloquentWithCountTest.php b/tests/Integration/Database/EloquentWithCountTest.php index d47fcf61f..a923f9c0a 100644 --- a/tests/Integration/Database/EloquentWithCountTest.php +++ b/tests/Integration/Database/EloquentWithCountTest.php @@ -78,6 +78,15 @@ public function testGlobalScopes(): void $this->assertEquals(1, $result->all_fours_count); } + public function testWithExists(): void + { + Model1::create()->twos()->create(); + Model1::create(); + + $this->assertSame([true, false], Model1::withExists('twos')->orderBy('id')->pluck('twos_exists')->all()); + $this->assertSame([true, false], Model1::orderBy('id')->get()->loadExists('twos')->pluck('twos_exists')->all()); + } + public function testSortingScopes(): void { $one = Model1::create(); From 99d694083696884d590bb986ef7e5bcc9d36693c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:38 +0000 Subject: [PATCH 21/38] Test cursorPaginate() on union queries with multiple orders Laravel fixed cursor pagination columns on union queries with several orders. Hypervel's paginateUsingCursor() already resolves each union member's columns by output position, so only the test is ported. Ports the test from laravel/framework#62272 on 13.x at b20bf7f442. Tests: testPaginationWithMultipleAliasesAndMultipleOrders passes on SQLite, MySQL, MariaDB and PostgreSQL. --- .../Database/EloquentCursorPaginateTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index cb2206bd1..00442122d 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -756,6 +756,25 @@ public function testPaginationWithMultipleAliases(): void $this->assertSame('B (post)', current($result->items())->alias, 'Expect the paginated query would return `B (post)`'); } + public function testPaginationWithMultipleAliasesAndMultipleOrders(): void + { + TestUser::fillAndInsert([['name' => 'A'], ['name' => 'C']]); + TestPost::fillAndInsert([['title' => 'A', 'user_id' => 2], ['title' => 'B', 'user_id' => 1]]); + + $table1 = TestPost::select(['title as label', 'user_id as rank']); + $table2 = TestUser::select(['name as label', 'id as rank']); + + $result = $table1->toBase() + ->union($table2->toBase()) + ->orderBy('label') + ->orderBy('rank') + ->cursorPaginate(1, ['*'], 'cursor', new Cursor(['label' => 'A', 'rank' => 1])); + + $this->assertCount(1, $result->items()); + $this->assertSame('A', current($result->items())->label); + $this->assertEquals(2, current($result->items())->rank); + } + public function testPaginationWithAliasedOrderBy(): void { TestUser::fillAndInsert([[], [], [], [], [], []]); From 8cbb712cc6946b6be8920e17f228bedc6372cddc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:10:38 +0000 Subject: [PATCH 22/38] Detect Vitess QueryList.TerminateAll() as a lost connection Ports laravel/framework#62270 from 13.x at b20bf7f442: the vttablet "QueryList.TerminateAll()" cancellation is now treated as a lost connection. Tests: added the case to DatabaseConnectionLostTest; the file passes. --- src/database/src/LostConnectionDetector.php | 1 + tests/Database/DatabaseConnectionLostTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/database/src/LostConnectionDetector.php b/src/database/src/LostConnectionDetector.php index b398fcda3..786e82dff 100644 --- a/src/database/src/LostConnectionDetector.php +++ b/src/database/src/LostConnectionDetector.php @@ -117,6 +117,7 @@ public function causedByLostConnection(Throwable $e): bool 'no healthy tablet available', 'transaction pool connection limit exceeded', 'SSL operation failed with code 5', + 'vttablet: rpc error: code = Canceled desc = QueryList.TerminateAll()', // PlanetScale PostgreSQL / pg_bouncer related things... 'no primary available for branch', diff --git a/tests/Database/DatabaseConnectionLostTest.php b/tests/Database/DatabaseConnectionLostTest.php index 4dd37215d..a67100237 100755 --- a/tests/Database/DatabaseConnectionLostTest.php +++ b/tests/Database/DatabaseConnectionLostTest.php @@ -36,6 +36,7 @@ public static function shouldBeLostProvider(): array 'PostgreSQL connection refused' => ["SQLSTATE[08006] [7] connection to server at \"example.database.com\" (10.0.1.7), port 5432 failed: Connection refused\nIs the server running on that host and accepting TCP/IP connections? (Connection: pgsql, Host: example.database.com, Port: 5432, Database: forge, SQL: select * from \"cache\" where \"key\" in (hypervel:queue:restart))"], 'MySQL server gone away' => ['SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'], 'SSL/TLS alert' => ['SSL error: ssl/tls alert unexpected message'], + 'Vitess query list terminated' => ['SQLSTATE[HY000]: General error: 1105 unknown: target: example.-.primary: vttablet: rpc error: code = Canceled desc = QueryList.TerminateAll(), elapsed time: 33.391648184s, killing connection ID 12345'], ]; } From dd30d4721933ec62322c85b5146c8a44687a8461 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:12:03 +0000 Subject: [PATCH 23/38] Add commit IDs for read-after-write checks on read replicas Laravel added getLastCommitId(), mergeCommitIds() and hasCommitId() so an application can tell whether a read replica has applied a write. The base Connection methods throw; MySQL uses GTIDs, MariaDB its binary log GTIDs and PostgreSQL WAL LSNs. Ports laravel/framework#62198 from 13.x at b20bf7f442. Upstream defects fixed in PostgreSQL: - The token is pg_current_wal_insert_lsn(). pg_current_wal_lsn() can precede an acknowledged asynchronous commit, so a replica could report a commit as applied before it was. - The PostgreSQL 19 check for waiting reads the server version from the read PDO that runs the wait. getServerVersion() contacted the primary. - mergeCommitIds() encodes array_values(), so keyed or sparse input (such as array_unique() output) no longer becomes a JSON object that json_array_elements_text() rejects. - The immediate check casts to bool, so PDO::ATTR_STRINGIFY_FETCHES no longer causes a TypeError. The methods live on Connection, not ConnectionInterface, like usingWritePdo(). The check is point-in-time; the new "Waiting for Read Replicas" docs show withPinnedSession() around the check and dependent reads. CI enables GTIDs on MySQL and the binary log on MariaDB through service commands, since upstream's docker exec steps cannot run in the job container. Tests: ported testLastCommitReadAfterWrite to the MySQL, MariaDB and PostgreSQL connection tests, with bounded waits for MySQL and MariaDB, plus PostgreSQL asynchronous-commit, sparse-merge, stringified-fetch and version tests and unit tests for the wait statuses. They pass on MySQL 9.5, MariaDB 10.11 with the binary log and PostgreSQL 17.11. The PostgreSQL 19 wait was checked against 19beta1; standby statuses are covered by unit tests only. --- .github/workflows/databases.yml | 4 + src/database/src/Connection.php | 32 ++++++ src/database/src/MariaDbConnection.php | 100 ++++++++++++++++++ src/database/src/MySqlConnection.php | 45 ++++++++ src/database/src/PostgresConnection.php | 52 +++++++++ src/docs/database.md | 42 ++++++++ .../DatabasePostgresConnectionTest.php | 88 +++++++++++++++ .../MariaDb/DatabaseMariaDbConnectionTest.php | 21 ++++ .../MySql/DatabaseMySqlConnectionTest.php | 21 ++++ .../DatabasePostgresConnectionTest.php | 60 +++++++++++ 10 files changed, 465 insertions(+) diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index 6883abded..9c49a3546 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -67,6 +67,7 @@ jobs: services: mysql: image: mysql:8.4 + command: --gtid-mode=ON --enforce-gtid-consistency=ON env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing @@ -167,6 +168,7 @@ jobs: services: mariadb: image: mariadb:10.11 + command: --log-bin --server-id=1 env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing @@ -214,6 +216,7 @@ jobs: services: mariadb: image: mariadb:11 + command: --log-bin --server-id=1 env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing @@ -261,6 +264,7 @@ jobs: services: mariadb: image: mariadb:12.3 + command: --log-bin --server-id=1 env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index afd6e80d4..6c647f512 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1921,6 +1921,38 @@ public function withoutTablePrefix(Closure $callback): mixed */ abstract public function getServerVersion(): string; + /** + * Get the ID of the last committed transaction. + * + * @throws RuntimeException + */ + public function getLastCommitId(): string + { + throw new RuntimeException('The database connection does not support retrieving the last commit ID.'); + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + throw new RuntimeException('The database connection does not support managing commit IDs.'); + } + + /** + * Determine if the given commit ID has been applied. + * + * @throws RuntimeException + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + throw new RuntimeException('The database connection does not support checking a commit ID.'); + } + /** * Determine whether the server supports skipping locked rows. */ diff --git a/src/database/src/MariaDbConnection.php b/src/database/src/MariaDbConnection.php index 1fcf3525a..caf1ffff1 100755 --- a/src/database/src/MariaDbConnection.php +++ b/src/database/src/MariaDbConnection.php @@ -4,6 +4,7 @@ namespace Hypervel\Database; +use Brick\Math\BigInteger; use Hypervel\Database\Query\Grammars\MariaDbGrammar; use Hypervel\Database\Query\Processors\MariaDbProcessor; use Hypervel\Database\Schema\Grammars\MariaDbGrammar as MariaDbSchemaGrammar; @@ -12,6 +13,7 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Str; use Override; +use RuntimeException; class MariaDbConnection extends MySqlConnection { @@ -47,6 +49,104 @@ public function getServerVersion(): string return Str::between(parent::getServerVersion(), '5.5.5-', '-MariaDB'); } + /** + * Get the ID of the last committed transaction. + * + * @throws RuntimeException + */ + public function getLastCommitId(): string + { + $gtid = $this->usingWritePdo(fn () => $this->selectOne('select @@log_bin as log_bin, @@gtid_binlog_pos as gtid_binlog_pos')); + + if (! $gtid->log_bin) { + throw new RuntimeException("The MariaDB configuration 'log_bin = ON' is required to retrieve the last commit ID."); + } + + return $gtid->gtid_binlog_pos; + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + if ($values === []) { + throw new RuntimeException("The list of commit IDs can't be empty."); + } + + return implode(',', array_keys($this->parseCommitIds($values))); + } + + /** + * Determine if the given commit ID has been applied. + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + // MASTER_GTID_WAIT only sees replicated GTIDs, so fall back to @@gtid_current_pos for the server's own writes... + $check = $this->selectOne('select master_gtid_wait(?, 0) as master_gtid_wait, @@gtid_current_pos as gtid_current_pos', [$value]); + + if ((int) $check->master_gtid_wait === 0) { + return true; + } + + $expected = array_column($this->parseCommitIds([$value]), 'sequence', 'domain'); + + foreach ($this->parseCommitIds([$check->gtid_current_pos]) as $gtid) { + if (isset($expected[$gtid['domain']]) && $gtid['sequence']->isGreaterThanOrEqualTo($expected[$gtid['domain']])) { + unset($expected[$gtid['domain']]); + } + } + + if ($expected === [] || $wait <= 0) { + return $expected === []; + } + + return (int) $this->scalar('select master_gtid_wait(?, ?)', [$value, $wait]) === 0; + } + + /** + * Parse a set of commit IDs (GTIDs) into the newest GTID of every MariaDB replication domain. + * + * @param string[] $values + * @return array + * + * @throws RuntimeException + */ + protected function parseCommitIds(array $values): array + { + $gtids = $parsed = []; + + foreach (explode(',', implode(',', $values)) as $gtid) { + $gtid = trim($gtid); + if ($gtid === '') { + continue; + } + + if (! preg_match('/^(?\d+)-(?\d+)-(?\d+)$/', $gtid, $matches)) { + throw new RuntimeException("The commit ID '{$gtid}' is not a valid MariaDB GTID."); + } + + $domain = $matches['domain']; + + // The sequence is an unsigned 64-bit integer and may exceed PHP's integer range... + $sequence = BigInteger::of($matches['sequence']); + + if (! isset($parsed[$domain]) || $sequence->isGreaterThan($parsed[$domain]['sequence'])) { + $gtids[$domain] = $gtid; + $parsed[$domain] = compact('domain', 'sequence'); + } + } + + ksort($gtids); + ksort($parsed); + + return array_combine($gtids, $parsed); + } + /** * Get the default query grammar instance. */ diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index 862412d17..c99a9e9de 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -337,6 +337,51 @@ protected function resolveMaxBindings(): int return 65_535; } + /** + * Get the ID of the last committed transaction. + * + * @throws RuntimeException + */ + public function getLastCommitId(): string + { + $gtid = $this->usingWritePdo(fn () => $this->selectOne('select @@log_bin as log_bin, @@global.gtid_mode as mode, @@global.gtid_executed as executed')); + + if (! $gtid->log_bin || $gtid->mode !== 'ON') { + throw new RuntimeException("The MySQL configuration 'log_bin = ON' and 'gtid_mode = ON' are required to retrieve the last commit ID."); + } + + return $gtid->executed; + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + if ($values === []) { + throw new RuntimeException("The list of commit IDs can't be empty."); + } + + // Normalize the joined GTID sets with gtid_subtract to keep the result as small as possible... + return $this->scalar("select gtid_subtract(?, '')", [implode(',', $values)]); + } + + /** + * Determine if the given commit ID has been applied. + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + if ($wait <= 0) { + return (bool) $this->scalar('select gtid_subset(?, @@global.gtid_executed)', [$value]); + } + + return (int) $this->scalar('select wait_for_executed_gtid_set(?, ?)', [$value, $wait]) === 0; + } + /** * Get the default query grammar instance. */ diff --git a/src/database/src/PostgresConnection.php b/src/database/src/PostgresConnection.php index f4eef2c6f..2b4097bbb 100755 --- a/src/database/src/PostgresConnection.php +++ b/src/database/src/PostgresConnection.php @@ -14,6 +14,7 @@ use Hypervel\Filesystem\Filesystem; use Override; use PDO; +use RuntimeException; class PostgresConnection extends PdoConnection { @@ -157,6 +158,57 @@ protected function resolveMaxBindings(): int return 65_535; } + /** + * Get the ID of the last committed transaction. + */ + public function getLastCommitId(): string + { + // pg_current_wal_lsn() can precede an acknowledged asynchronous commit. The insert position covers it; + // at a page boundary it points past the next page header, which a replica passes with the next WAL record. + return $this->usingWritePdo(fn () => $this->scalar('select pg_current_wal_insert_lsn()')); + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + if ($values === []) { + throw new RuntimeException("The list of commit IDs can't be empty."); + } + + // LSNs are linear, so the newest one includes all older ones... + return $this->scalar('select value::pg_lsn from json_array_elements_text(?) order by 1 desc limit 1', [json_encode(array_values($values))]); + } + + /** + * Determine if the given commit ID has been applied. + * + * @throws RuntimeException + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + if ($wait > 0) { + // Check the server that runs the wait; getServerVersion() would also contact the primary. + if (version_compare($this->getPdoForSelect()->getAttribute(PDO::ATTR_SERVER_VERSION), '19', '<')) { + throw new RuntimeException('The PostgreSQL version does not support waiting for a commit ID. PostgreSQL 19 or newer is required.'); + } + + $status = $this->scalar("wait for lsn {$this->escapeString($value)} with (timeout {$this->escapeString("{$wait}s")}, no_throw)"); + + // A primary can't wait for its own LSNs, so fall back to the immediate check... + if ($status !== 'not in recovery') { + return $status === 'success'; + } + } + + return (bool) $this->scalar('select case when pg_is_in_recovery() then pg_last_wal_replay_lsn() else pg_current_wal_insert_lsn() end >= ?::pg_lsn', [$value]); + } + /** * Get the default query grammar instance. */ diff --git a/src/docs/database.md b/src/docs/database.md index a560e7c0b..9c162e7d9 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -11,6 +11,7 @@ - [Read and Write Connections](#read-and-write-connections) - [Preventing Stale Database Reads](#preventing-stale-database-reads) - [Using the Write Connection for Reads](#using-the-write-connection-for-reads) + - [Waiting for Read Replicas](#waiting-for-read-replicas) - [Connection Pooling](#connection-pooling) - [Releasing and Pinning Connections](#releasing-and-pinning-connections) - [Configuring Database Session State](#configuring-database-session-state) @@ -291,6 +292,47 @@ $users = DB::connection()->usingWritePdo(fn ($connection) => $connection->table( This also applies to Eloquent queries and relationship queries executed within the callback using the same connection. The method returns the callback's result and restores the connection's previous read preference when the callback finishes, even if an exception is thrown. + +#### Waiting for Read Replicas + +Read replicas apply each write shortly after it is committed. When a later read must see a particular write, you may record the write's commit ID and check it before reading. Retrieve the ID using the `getLastCommitId` method once the write's transaction has committed: + +```php +use Hypervel\Support\Facades\DB; + +DB::table('orders')->insert($order); + +$commitId = DB::getLastCommitId(); +``` + +Commit IDs are strings, so you may store them in the user's session or pass them to a queued job. The `hasCommitId` method determines whether the read connection has applied the commit. Its optional `wait` argument specifies how many seconds to wait for the commit to arrive. If the replica has not caught up, you may read from the write connection instead: + +```php +$order = DB::hasCommitId($commitId, wait: 2) + ? DB::table('orders')->find($orderId) + : DB::connection()->usingWritePdo(fn ($connection) => $connection->table('orders')->find($orderId)); +``` + +If you have recorded several commit IDs, the `mergeCommitIds` method combines them into one ID that is applied once all of them are: + +```php +$commitId = DB::mergeCommitIds([$profileCommitId, $orderCommitId]); +``` + +Commit IDs require MySQL with `log_bin` and `gtid_mode` enabled, MariaDB with its binary log enabled, or PostgreSQL. Waiting on PostgreSQL requires PostgreSQL 19 or newer and should happen before you start a transaction. Commit IDs can only be compared within the same replication setup. + +The check applies to the database session your connection currently holds. Since Hypervel may return idle sessions to the pool during [external calls](#releasing-and-pinning-connections), a later query may use a session on another replica. If reads after an external call depend on the check, perform the check and those reads within `withPinnedSession`: + +```php +$connection = DB::connection(); + +$connection->withPinnedSession(function () use ($connection, $commitId) { + $applied = $connection->hasCommitId($commitId, wait: 2); + + // External calls, then reads that rely on $applied... +}); +``` + ### Connection Pooling diff --git a/tests/Database/DatabasePostgresConnectionTest.php b/tests/Database/DatabasePostgresConnectionTest.php index 4882d0a51..5f82ebd6f 100644 --- a/tests/Database/DatabasePostgresConnectionTest.php +++ b/tests/Database/DatabasePostgresConnectionTest.php @@ -7,7 +7,12 @@ use DateTimeImmutable; use Hypervel\Database\PostgresConnection; use Hypervel\Tests\TestCase; +use Mockery as m; +use Mockery\MockInterface; use PDO; +use PDOStatement; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; class DatabasePostgresConnectionTest extends TestCase { @@ -88,6 +93,89 @@ public function testEscapeUsesPostgresBooleanLiterals(): void $this->assertSame('false', $connection->escape(false)); } + #[DataProvider('commitIdWaitStatuses')] + public function testWaitingForACommitIdUsesOnlyTheReadConnection(string $status, ?bool $immediateResult, bool $expected): void + { + $readPdo = $this->commitIdReadPdo('19.0'); + $readPdo->expects('prepare') + ->with("wait for lsn '0/16B3748' with (timeout '5s', no_throw)") + ->andReturn($this->commitIdStatement($status)); + + if ($immediateResult !== null) { + $readPdo->expects('prepare') + ->with('select case when pg_is_in_recovery() then pg_last_wal_replay_lsn() else pg_current_wal_insert_lsn() end >= ?::pg_lsn') + ->andReturn($this->commitIdStatement($immediateResult)); + } + + $this->assertSame($expected, $this->newCommitIdConnection($readPdo)->hasCommitId('0/16B3748', 5)); + } + + /** + * Provide WAIT FOR LSN statuses and the resulting commit ID checks. + * + * @return array + */ + public static function commitIdWaitStatuses(): array + { + return [ + 'success' => ['success', null, true], + 'timeout' => ['timeout', null, false], + 'primary' => ['not in recovery', true, true], + ]; + } + + public function testWaitingForACommitIdRequiresPostgres19OnTheReadConnection(): void + { + $readPdo = $this->commitIdReadPdo('18.4'); + $readPdo->shouldNotReceive('prepare'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PostgreSQL 19 or newer is required.'); + + $this->newCommitIdConnection($readPdo)->hasCommitId('0/16B3748', 5); + } + + /** + * Create a read PDO reporting the given server version. + */ + protected function commitIdReadPdo(string $version): PDO&MockInterface + { + $pdo = m::mock(PDO::class); + $pdo->shouldReceive('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn($version); + $pdo->shouldReceive('quote')->andReturnUsing(fn (string $value): string => "'{$value}'"); + + return $pdo; + } + + /** + * Create a statement returning the given single-column result. + */ + protected function commitIdStatement(bool|string $value): PDOStatement + { + $statement = m::mock(PDOStatement::class); + $statement->shouldReceive('setFetchMode')->andReturnTrue(); + $statement->shouldReceive('bindValue')->andReturnTrue(); + $statement->expects('execute')->andReturnTrue(); + $statement->expects('fetchAll')->andReturn([(object) ['result' => $value]]); + + return $statement; + } + + /** + * Create a read / write connection whose write side fails the test when used. + */ + protected function newCommitIdConnection(PDO $readPdo): PostgresConnection + { + $connection = new PostgresConnection( + fn () => $this->fail('The write connection was used.'), + 'test_db', + '', + ['name' => 'test', 'driver' => 'pgsql'], + ); + + return $connection->setReadPdo($readPdo); + } + /** * Create a connection with the configured prepare mode. */ diff --git a/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php b/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php index d6af50b95..26ba12d99 100644 --- a/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php +++ b/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php @@ -231,4 +231,25 @@ public static function jsonContainsKeyDataProvider(): array 'null value' => [1, 'json_col->bar'], ]; } + + public function testLastCommitReadAfterWrite(): void + { + if (! DB::scalar('select @@log_bin')) { + $this->markTestSkipped('Commit IDs require log_bin = ON.'); + } + + $first = DB::getLastCommitId(); + DB::table(self::TABLE)->insert([self::JSON_COL => '{"commit":"read-after-write"}']); + $second = DB::getLastCommitId(); + $merged = DB::mergeCommitIds([$second, $first]); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $merged); + $this->assertTrue(DB::hasCommitId($first)); + $this->assertTrue(DB::hasCommitId($second)); + $this->assertTrue(DB::hasCommitId($merged)); + $this->assertFalse(DB::hasCommitId('4294967295-4294967295-18446744073709551615')); + $this->assertTrue(DB::hasCommitId($second, 1)); + $this->assertFalse(DB::hasCommitId('4294967295-4294967295-18446744073709551615', 1)); + } } diff --git a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php index 9a0ac11f1..0ccbc9dcb 100644 --- a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php +++ b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php @@ -293,4 +293,25 @@ public function testLastInsertIdIsPreserved(): void Schema::drop('auto_id_table'); } } + + public function testLastCommitReadAfterWrite(): void + { + if (! DB::scalar('select @@log_bin') || DB::scalar('select @@global.gtid_mode') !== 'ON') { + $this->markTestSkipped('Commit IDs require log_bin = ON and gtid_mode = ON.'); + } + + $first = DB::getLastCommitId(); + DB::table(self::TABLE)->insert([self::JSON_COL => '{"commit":"read-after-write"}']); + $second = DB::getLastCommitId(); + $merged = DB::mergeCommitIds([$second, $first]); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $merged); + $this->assertTrue(DB::hasCommitId($first)); + $this->assertTrue(DB::hasCommitId($second)); + $this->assertTrue(DB::hasCommitId($merged)); + $this->assertFalse(DB::hasCommitId('ffffffff-ffff-ffff-ffff-ffffffffffff:9223372036854775806')); + $this->assertTrue(DB::hasCommitId($second, 1)); + $this->assertFalse(DB::hasCommitId('ffffffff-ffff-ffff-ffff-ffffffffffff:9223372036854775806', 1)); + } } diff --git a/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php b/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php index 26b614576..9b6fe0533 100644 --- a/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php +++ b/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php @@ -9,9 +9,12 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use Hypervel\Testbench\Attributes\RequiresDatabase; +use PDO; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use RuntimeException; #[RequiresOperatingSystem('Linux|Darwin')] #[RequiresPhpExtension('pdo_pgsql')] @@ -201,4 +204,61 @@ public static function jsonContainsKeyDataProvider() 'null value' => [1, 'json_col->bar'], ]; } + + public function testLastCommitReadAfterWrite(): void + { + $first = DB::getLastCommitId(); + DB::table('json_table')->insert(['json_col' => '{"commit":"read-after-write"}']); + $second = DB::getLastCommitId(); + $merged = DB::mergeCommitIds([$second, $first]); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $merged); + $this->assertSame($second, DB::mergeCommitIds(array_unique([$first, $first, $second]))); + $this->assertTrue(DB::hasCommitId($first)); + $this->assertTrue(DB::hasCommitId($second)); + $this->assertTrue(DB::hasCommitId($merged)); + $this->assertFalse(DB::hasCommitId('FFFFFFFF/FFFFFFFF')); + } + + public function testCommitIdChecksReturnBooleansWhenFetchesAreStringified(): void + { + config()->set('database.connections.pgsql_stringified', [ + ...config()->array('database.connections.' . config()->string('database.default')), + 'options' => [PDO::ATTR_STRINGIFY_FETCHES => true], + ]); + $connection = DB::connection('pgsql_stringified'); + + $this->assertTrue($connection->hasCommitId($connection->getLastCommitId())); + $this->assertFalse($connection->hasCommitId('FFFFFFFF/FFFFFFFF')); + } + + public function testLastCommitIdIncludesAnAsynchronousCommit(): void + { + $beforeCommit = DB::transaction(function () { + DB::statement('set local synchronous_commit = off'); + DB::table('json_table')->insert(['json_col' => '{"commit":"asynchronous"}']); + + return DB::scalar('select pg_current_wal_insert_lsn()'); + }); + + // The commit record follows the transaction's last insert, so a token that includes it must be later. + $this->assertTrue(DB::scalar('select ?::pg_lsn > ?::pg_lsn', [DB::getLastCommitId(), $beforeCommit])); + } + + #[RequiresDatabase('pgsql', '<19')] + public function testWaitingForACommitIdRequiresPostgres19(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PostgreSQL 19 or newer is required.'); + + DB::hasCommitId(DB::getLastCommitId(), 1); + } + + #[RequiresDatabase('pgsql', '>=19')] + public function testWaitingForACommitIdOnThePrimaryFallsBackToTheImmediateCheck(): void + { + $this->assertTrue(DB::hasCommitId(DB::getLastCommitId(), 1)); + $this->assertFalse(DB::hasCommitId('FFFFFFFF/FFFFFFFF', 1)); + } } From 7511fac5bdd9eb296c02712907cd7e3059c41c85 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:12:03 +0000 Subject: [PATCH 24/38] Update the DB facade docblocks for commit IDs Ports laravel/framework@b20bf7f442 from 13.x: the DB facade documents getLastCommitId(), hasCommitId() and mergeCommitIds(). Tests: regenerated with composer facade; FacadeDocblocksTest passes. --- src/support/src/Facades/DB.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 87b1da7b6..9eee15680 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -62,6 +62,7 @@ * @method static string getDriverTitle() * @method static int getErrorCount() * @method static \Hypervel\Contracts\Events\Dispatcher|null getEventDispatcher() + * @method static string getLastCommitId() * @method static string|int getLastInsertId(string|null $sequence = null) * @method static string|null getName() * @method static string|null getNameWithReadWriteType() @@ -77,6 +78,7 @@ * @method static string getTablePrefix() * @method static \Hypervel\Database\DatabaseTransactionsManager|null getTransactionManager() * @method static string|null getWritableName() + * @method static bool hasCommitId(string $value, int $wait = 0) * @method static bool hasModifiedRecords() * @method static bool hasPinnedSession() * @method static bool insert(string $query, array $bindings = []) @@ -84,6 +86,7 @@ * @method static void listen(\Closure $callback) * @method static bool logging() * @method static void logQuery(string $query, array $bindings, float|null $time = null) + * @method static string mergeCommitIds(string[] $values) * @method static array prepareBindings(array $bindings) * @method static array[] pretend(\Closure $callback) * @method static bool pretending() From 83d0257acab4cc08eb2329813e2070a1618e4d54 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:18:26 +0000 Subject: [PATCH 25/38] Focus local verification on affected behavior --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 510310e20..cfd7f6652 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,11 +121,11 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation. -**At the end of a coherent implementation slice**, run `composer analyse` and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection. +At the end of a coherent implementation slice, run `composer analyse` and any outstanding tests needed to verify the changed behavior and affected callers. Use PHPUnit for one test file or `composer test:parallel -- --processes=4` for multiple files, selecting the relevant files or using `--filter`. Reuse passing results unless subsequent changes invalidate them. Run `composer lint:fix` before a scheduled code review or commit checkpoint, not after individual changes within a batch. -Run the full test suite only when the change has framework-wide impact. In that case, use `composer fix`, which includes formatting, analysis and all test suites; do not run those checks separately first. For focused changes, include separate Testbench or dogfood checks when the changed behavior affects those workflows. +Run only the local tests needed to give strong confidence in the change. Leave full suites and CI matrices to CI, unless a specific unresolved issue requires broader local testing. After a failure or review correction, fix the cause, repeat only checks affected by the correction, and finish any outstanding verification. Do not restart the entire sequence merely because another review round occurred. From fb91879937309001e4ad5fc584582afc511c4626 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:03 +0000 Subject: [PATCH 26/38] Remove unused reusable client methods from PendingRequest PendingRequest still had requestsReusableClient() and getReusableClient(), but nothing calls them: buildClient() already reuses $this->client. Port of laravel/framework#62292 at master 8448dc587e. There are no callers in src/, the tests or private packages, and upstream changed no tests or docs. --- src/http/src/Client/PendingRequest.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index b0a5dc2b8..af389d999 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -1812,22 +1812,6 @@ public function buildClient(): ClientInterface return $this->client ?? $this->createClient($this->buildHandlerStack()); } - /** - * Determine if a reusable client is required. - */ - protected function requestsReusableClient(): bool - { - return ! is_null($this->client) || $this->async; - } - - /** - * Retrieve a reusable Guzzle client. - */ - protected function getReusableClient(): ClientInterface - { - return $this->client ??= $this->createClient($this->buildHandlerStack()); - } - /** * Create a new Guzzle client. */ From c6b63b040fa4772762a150e808ccc3e1835b933b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:07 +0000 Subject: [PATCH 27/38] Add the migration duration to MigrationEnded Listeners for MigrationEnded had no way to tell how long a migration took. The event now has a public ?float $duration in milliseconds, set by Migrator::runMigration(). Port of laravel/framework#62269 at master 8448dc587e. The timing is rounded inside the existing hasMigrationEventListeners() guard, so migrations without listeners skip the work. The upstream is_float($event->duration) assertions are ported to MigratorEventsTest, which passes. Upstream has no docs for this; migrations.md now describes $duration. --- src/database/src/Events/MigrationEnded.php | 16 ++++++++++++++++ src/database/src/Migrations/Migrator.php | 6 +++++- src/docs/migrations.md | 2 +- .../Integration/Database/MigratorEventsTest.php | 6 ++++-- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/database/src/Events/MigrationEnded.php b/src/database/src/Events/MigrationEnded.php index cd9e766f4..d8d3d482f 100644 --- a/src/database/src/Events/MigrationEnded.php +++ b/src/database/src/Events/MigrationEnded.php @@ -4,6 +4,22 @@ namespace Hypervel\Database\Events; +use Hypervel\Database\Migrations\Migration; + class MigrationEnded extends MigrationEvent { + /** + * The number of milliseconds the migration took to run. + */ + public ?float $duration; + + /** + * Create a new event instance. + */ + public function __construct(Migration $migration, string $method, ?string $name = null, ?float $duration = null) + { + parent::__construct($migration, $method, $name); + + $this->duration = $duration; + } } diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 439d11dc1..20922b379 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -440,10 +440,14 @@ protected function runMigration(object $migration, string $method, ?string $name $this->fireMigrationEvent(new MigrationStarted($migration, $method, $name)); } + $startedAt = microtime(true); + $this->runMethod($connection, $migration, $method); if ($this->hasMigrationEventListeners(MigrationEnded::class)) { - $this->fireMigrationEvent(new MigrationEnded($migration, $method, $name)); + $duration = round((microtime(true) - $startedAt) * 1000, 2); + + $this->fireMigrationEvent(new MigrationEnded($migration, $method, $name, $duration)); } } }; diff --git a/src/docs/migrations.md b/src/docs/migrations.md index bb9cfb5b5..d10d9fad8 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -1884,4 +1884,4 @@ For convenience, each migration operation will dispatch an [event](/docs/{{versi -The `MigrationStarted` and `MigrationEnded` events expose the migration filename through their `$name` property. +The `MigrationStarted` and `MigrationEnded` events expose the migration filename through their `$name` property. The `MigrationEnded` event also exposes how long the migration took to run, in milliseconds, through its `$duration` property. diff --git a/tests/Integration/Database/MigratorEventsTest.php b/tests/Integration/Database/MigratorEventsTest.php index d28cd68ea..07b7860a0 100644 --- a/tests/Integration/Database/MigratorEventsTest.php +++ b/tests/Integration/Database/MigratorEventsTest.php @@ -154,12 +154,14 @@ public function testMigrationEventsContainTheMigrationAndMethod(): void Event::assertDispatched(MigrationEnded::class, function ($event) { return $event->method === 'up' && $event->migration instanceof Migration - && $event->name === '2014_10_12_000000_create_members_table'; + && $event->name === '2014_10_12_000000_create_members_table' + && is_float($event->duration); }); Event::assertDispatched(MigrationEnded::class, function ($event) { return $event->method === 'down' && $event->migration instanceof Migration - && $event->name === '2014_10_12_000000_create_members_table'; + && $event->name === '2014_10_12_000000_create_members_table' + && is_float($event->duration); }); } From 6b9df181a85253f12337a5dad3eb66ebae2f7827 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:07 +0000 Subject: [PATCH 28/38] Bump follow-redirects to 1.16.2 Moves axios 1.20.0's follow-redirects dependency in the root pnpm lock from 1.16.1 to 1.16.2. No manifests change. Port of laravel/horizon#1844 at e168e56b97 (also covers laravel/telescope#1786). Ran pnpm up -r follow-redirects. Horizon's dist is not rebuilt: axios's browser field replaces the Node HTTP adapter, so follow-redirects is not in the bundle. No tests. --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790220bbb..972388451 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2445,8 +2445,8 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - follow-redirects@1.16.1: - resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -5090,7 +5090,7 @@ snapshots: axios@1.20.0(debug@4.4.3): dependencies: - follow-redirects: 1.16.1(debug@4.4.3) + follow-redirects: 1.16.2(debug@4.4.3) form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -5335,7 +5335,7 @@ snapshots: flatted@3.4.4: {} - follow-redirects@1.16.1(debug@4.4.3): + follow-redirects@1.16.2(debug@4.4.3): optionalDependencies: debug: 4.4.3 From 94bac2c788a5a05770f78f41d43c303fc43d3af3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:28 +0000 Subject: [PATCH 29/38] Treat empty and numeric-key JSON objects as objects in type assertions assertJsonIsArray() and assertJsonIsObject() decoded the response associatively and then re-encoded the result. That turns {} and {"0": "value"} into PHP lists, so both were reported as arrays and never as objects. Laravel has the same defect. AssertableJsonString::object() now decodes the original JSON with objects kept as stdClass, mirroring the HTTP client's Response::object(). Both assertions read from it and keep upstream's withResponse() failure context. json() keeps its associative contract. Found while porting laravel/mcp#384, whose structured content assertions copied these methods. TestResponseTest adds empty, numeric-key and nested object cases for both assertions; the file passes. --- src/testing/src/AssertableJsonString.php | 14 ++++++++++++++ src/testing/src/TestResponse.php | 23 +++++++---------------- tests/Testing/TestResponseTest.php | 20 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 770a3476f..8251c0efc 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -61,6 +61,20 @@ public function json(?string $key = null): mixed return data_get($this->decoded, $key); } + /** + * Return the decoded JSON with objects as stdClass instances. + * + * Unlike json(), this keeps empty and numeric-key JSON objects distinct from arrays. + * + * @throws JsonException + */ + public function object(?string $key = null): mixed + { + $json = is_string($this->json) ? $this->json : Json::encode($this->json); + + return data_get(Json::decode($json, false), $key); + } + /** * Assert that the response JSON has the expected count of items at the given key. */ diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php index 102ac9676..cc037b60c 100644 --- a/src/testing/src/TestResponse.php +++ b/src/testing/src/TestResponse.php @@ -32,6 +32,7 @@ use Hypervel\Testing\TestResponseAssert as PHPUnit; use JsonException; use LogicException; +use stdClass; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -1063,15 +1064,10 @@ public function assertJsonMissingValidationErrors(array|string|null $keys = null */ public function assertJsonIsArray(?string $key = null): static { - $data = $this->json($key); + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->decodeResponseJson()->object($key); - $encodedData = json_encode($data); - - PHPUnit::withResponse($this)->assertTrue( - is_array($data) - && str_starts_with($encodedData, '[') - && str_ends_with($encodedData, ']') - ); + PHPUnit::withResponse($this)->assertTrue(is_array($data)); return $this; } @@ -1081,15 +1077,10 @@ public function assertJsonIsArray(?string $key = null): static */ public function assertJsonIsObject(?string $key = null): static { - $data = $this->json($key); + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->decodeResponseJson()->object($key); - $encodedData = json_encode($data); - - PHPUnit::withResponse($this)->assertTrue( - is_array($data) - && str_starts_with($encodedData, '{') - && str_ends_with($encodedData, '}') - ); + PHPUnit::withResponse($this)->assertTrue($data instanceof stdClass); return $this; } diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index 64f17dd4c..b2850449d 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -2612,6 +2612,17 @@ public function testAssertJsonIsNotArray(): void $responseObject->assertJsonIsArray(); } + #[TestWith(['{}', null])] + #[TestWith(['{"0":"value"}', null])] + #[TestWith(['{"data":{}}', 'data'])] + #[TestWith(['{"data":{"0":"value"}}', 'data'])] + public function testAssertJsonIsNotArrayForObjectsThatDecodeToLists(string $json, ?string $key): void + { + $this->expectException(ExpectationFailedException::class); + + TestResponse::fromBaseResponse(new Response($json))->assertJsonIsArray($key); + } + public function testAssertJsonIsObject(): void { $responseObject = TestResponse::fromBaseResponse(new Response([ @@ -2620,6 +2631,15 @@ public function testAssertJsonIsObject(): void $responseObject->assertJsonIsObject(); } + #[TestWith(['{}', null])] + #[TestWith(['{"0":"value"}', null])] + #[TestWith(['{"data":{}}', 'data'])] + #[TestWith(['{"data":{"0":"value"}}', 'data'])] + public function testAssertJsonIsObjectForObjectsThatDecodeToLists(string $json, ?string $key): void + { + TestResponse::fromBaseResponse(new Response($json))->assertJsonIsObject($key); + } + public function testAssertJsonIsNotObject(): void { $this->expectException(ExpectationFailedException::class); From 151028be0bbc5cf48557ff82bdadae4d79c8c5bd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:28 +0000 Subject: [PATCH 30/38] Port MCP app domain opt-out, OAuth discovery API and structured content assertions These three upstream changes share src/docs/mcp.md, so they land together. Ported from laravel/mcp at c0dfbecb32. laravel/mcp#381: apps can opt out of the derived ui.domain with #[AppMeta(domain: false)] or AppMeta::make()->withoutDomain(). domain() re-enables it, and AppResource skips derivation when it is disabled. The upstream AppResourceTest, AppMetaTest and ReadAppResourceTest cases are ported. mcp.md documents the derived domain and the opt-out. laravel/mcp#378: upstream's public OAuth discovery API is kept on top of Hypervel's request-time OAuthDestinationPolicy: - pin() attaches the per-hop middleware that resolves, validates and pins each request when it is sent. Metadata, probe, registration and token requests all use it at upstream's call sites. - resolveHostsUsing() supplies the policy's hostname answers in tests, and AfterEachTestSubscriber flushes it. - requireFetchable() is public and rechecks the stored token endpoint during the code exchange. - DynamicClientRegistration takes the discovery instance. - requireSecure() exempts localhost only over http. Upstream's discovery-time DNS cache is omitted because the policy resolves every request. Private resources still need an explicit policy allowance, and DNS stays local behind HTTPS proxies; both are README differences with REMOVED test notes. Also fixed: the scheme checks in requireSecure() and the client ID metadata document check are now case-insensitive (RFC 3986). The client ID is still sent as written. The new AuthServerDiscoveryTest ports upstream's cases through resolveHostsUsing(). OAuthClientTest adds the stored-endpoint, pinning, scheme, custom-discovery and uppercase client ID cases. laravel/mcp#384: the 17 structured content JSON assertions are ported, typed like the HTTP TestResponse assertions, with all 37 upstream cases. IsArray/IsObject read the JSON's own types through AssertableJsonString::object(), with added empty and numeric-key object cases. mcp.md documents the assertions. Validation: Unit/Client/OAuth (160 tests), OAuthCallbackRouteTest, the AppMeta/AppResource files and AssertStructuredContentTest pass. composer analyse is clean. --- src/docs/mcp.md | 49 +- src/mcp/README.md | 2 +- .../src/Client/OAuth/AuthServerDiscovery.php | 95 +++- .../OAuth/DynamicClientRegistration.php | 29 +- src/mcp/src/Client/OAuth/OAuthClient.php | 16 +- .../Client/OAuth/OAuthDestinationPolicy.php | 14 + src/mcp/src/Server/AppResource.php | 7 +- src/mcp/src/Server/Attributes/AppMeta.php | 7 +- src/mcp/src/Server/Testing/TestResponse.php | 252 ++++++++++ src/mcp/src/Server/Ui/AppMeta.php | 22 + .../src/PHPUnit/AfterEachTestSubscriber.php | 1 + .../Tools/AssertStructuredContentTest.php | 434 ++++++++++++++++++ .../Client/OAuth/AuthServerDiscoveryTest.php | 221 +++++++++ .../Mcp/Unit/Client/OAuth/OAuthClientTest.php | 204 ++++++++ .../Mcp/Unit/Methods/ReadAppResourceTest.php | 41 ++ tests/Mcp/Unit/Resources/AppResourceTest.php | 40 ++ tests/Mcp/Unit/Ui/AppMetaTest.php | 27 ++ 17 files changed, 1401 insertions(+), 60 deletions(-) create mode 100644 tests/Mcp/Unit/Client/OAuth/AuthServerDiscoveryTest.php diff --git a/src/docs/mcp.md b/src/docs/mcp.md index c56140e43..de83db2ce 100644 --- a/src/docs/mcp.md +++ b/src/docs/mcp.md @@ -1697,6 +1697,27 @@ The `Library` enum includes pre-configured CDN scripts for common front-end libr For computed or dynamic configuration, override the `appMeta` method on your resource using the fluent `AppMeta`, `Csp`, and `Permissions` builders from the `Hypervel\Mcp\Server\Ui` namespace. +By default, the app's sandbox domain is derived from the current server URL in the format Claude expects. You may set the `domain` argument to use a specific sandbox domain. If the host rejects the derived domain, for example because the connector URL was saved with a trailing slash, set `domain` to `false` to leave it out so the host uses its default sandbox origin: + +```php +#[AppMeta(domain: false)] +class WeatherDashboardApp extends AppResource +{ + // ... +} +``` + +When overriding the `appMeta` method, call `withoutDomain` on the builder instead: + +```php +use Hypervel\Mcp\Server\Ui\AppMeta; + +public function appMeta(): AppMeta +{ + return AppMeta::make()->withoutDomain(); +} +``` + For the complete protocol reference, including the full client-side API and schema details, see the official [MCP Apps documentation](https://modelcontextprotocol.io/extensions/apps/overview). @@ -2490,6 +2511,18 @@ $response->assertTitle('Current Weather Tool'); $response->assertDescription('Fetches the current weather forecast for a specified location.'); ``` +You may assert a tool's structured content using the `assertStructuredContent` method, which accepts the expected array or a closure that receives an `AssertableJson` instance. Structured content equivalents of the HTTP test response's JSON assertions are also available, such as `assertStructuredContentCount`, `assertStructuredContentPath`, `assertExactStructuredContent`, `assertStructuredContentFragment`, `assertStructuredContentMissingPath`, and `assertStructuredContentStructure`: + +```php +$response + ->assertStructuredContentCount(2, 'data') + ->assertStructuredContentPath('data.0.id', 123) + ->assertStructuredContentStructure([ + 'data' => ['*' => ['id', 'status']], + 'meta' => ['total'], + ]); +``` + You may assert that notifications were sent using the `assertSentNotification` and `assertNotificationCount` methods: ```php @@ -2516,23 +2549,13 @@ $response->dump(); ### Testing OAuth Clients -`Http::fake()` replaces HTTP responses, but OAuth destination validation still runs before sending. When a test uses an invented hostname, replace `OAuthDestinationPolicy` with a test policy that supplies controlled DNS answers. The normal address validation still applies: +`Http::fake()` replaces HTTP responses, but OAuth destination validation still runs before sending. When a test uses an invented hostname, supply its DNS answers with `AuthServerDiscovery::resolveHostsUsing()`. The normal address validation still applies to the returned addresses: ```php -use Hypervel\Mcp\Client\OAuth\OAuthDestinationPolicy; +use Hypervel\Mcp\Client\OAuth\AuthServerDiscovery; use Hypervel\Support\Facades\Http; -$this->app->instance(OAuthDestinationPolicy::class, new class extends OAuthDestinationPolicy { - /** - * Resolve test endpoints without external DNS. - * - * @return list - */ - protected function resolveHost(string $host, float $timeoutSeconds): array - { - return ['93.184.216.34']; - } -}); +AuthServerDiscovery::resolveHostsUsing(fn (string $host): array => ['93.184.216.34']); Http::fake([ 'https://auth.test/token' => Http::response(['access_token' => 'test-token']), diff --git a/src/mcp/README.md b/src/mcp/README.md index 132e81af8..3a55af00f 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -15,7 +15,7 @@ Documentation: https://hypervel.org/docs/mcp - Anonymous registration persists an explicit scope ceiling from Passport's anonymous allowance plus `mcp:use`; custom client schemas require `scopes` and `third_party`. Wildcard callback domains permit HTTPS and HTTP loopback only; explicit administrator allowances and configured native schemes remain supported. See [registration policy](https://hypervel.org/docs/mcp#oauth). - Connect URLs containing `resource_metadata` or `scope` must be signed with `redirect()->signedRoute()` or `URL::signedRoute()` to prevent discovery-hint tampering; see [authorization challenges](https://hypervel.org/docs/mcp#client-oauth-scopes). - Custom client headers stay on the configured origin across redirects because they may contain credentials. Connect directly to a server on another origin if it needs those headers; see [custom client headers](https://hypervel.org/docs/mcp#client-custom-headers). -- OAuth clients require an explicit policy allowance for private DNS destinations and a DNS substitute for unresolved hostnames in faked tests. See [OAuth destinations](https://hypervel.org/docs/mcp#oauth-destinations) and [testing OAuth clients](https://hypervel.org/docs/mcp#testing-oauth). +- OAuth clients resolve DNS locally and pin each OAuth request through `OAuthDestinationPolicy`, including behind ordinary proxies. Private DNS destinations require an explicit policy allowance, even when the resource itself is private. See [OAuth destinations](https://hypervel.org/docs/mcp#oauth-destinations). - OAuth requests use capped response streams instead of Laravel's protected `oAuthCurlOptions()` helper, which is omitted because the transport owns protocol restrictions and rejects its raw cURL options. Customize `$maxOAuthResponseSize` or the request through `oAuthRequest()`. - OAuth clients use the remote server's advertised scopes instead of assuming `mcp:use`. Set `withOAuth(scope: 'mcp:use')` when that scope is required but not advertised; see [OAuth scopes](https://hypervel.org/docs/mcp#client-oauth-scopes). - The Inspector verifies HTTPS certificates. Configure `NODE_EXTRA_CA_CERTS` for locally issued certificates; see [MCP Inspector](https://hypervel.org/docs/mcp#mcp-inspector). diff --git a/src/mcp/src/Client/OAuth/AuthServerDiscovery.php b/src/mcp/src/Client/OAuth/AuthServerDiscovery.php index 4156135d8..a77370eee 100644 --- a/src/mcp/src/Client/OAuth/AuthServerDiscovery.php +++ b/src/mcp/src/Client/OAuth/AuthServerDiscovery.php @@ -25,6 +25,35 @@ class AuthServerDiscovery { use InteractsWithOAuthEndpoints; + // Upstream's discovery-time DNS cache and address checks are omitted: OAuthDestinationPolicy + // resolves, validates and pins each request when pin() sends it. + + protected static ?Closure $hostResolver = null; + + /** + * Resolve OAuth destination hostnames with the given callback instead of DNS. + * + * Tests only. The callback persists in a static property for the worker lifetime and supplies the addresses for every OAuth request. + * + * @param null|Closure(string): array $callback + */ + public static function resolveHostsUsing(?Closure $callback): void + { + static::$hostResolver = $callback; + } + + /** + * Get the addresses the resolveHostsUsing() callback supplies for a hostname. + * + * @internal + * + * @return null|list + */ + public static function resolvedHostAddresses(string $host): ?array + { + return static::$hostResolver === null ? null : array_values((static::$hostResolver)($host)); + } + /** * Discover and validate the resource's authorization server. */ @@ -108,6 +137,27 @@ public function requireAdvertised(string $resourceUrl, string $resourceMetadataU } } + /** + * Validate each request hop against the OAuth destination policy and pin it to the vetted addresses. + */ + public function pin(PendingRequest $request): PendingRequest + { + return $request->withMiddleware(function (callable $handler): Closure { + return function (RequestInterface $request, array $options) use ($handler): PromiseInterface { + // Resolve every hop here instead of through the generic destination-policy + // handler, which rejects the ordinary proxies OAuth requests may use. + $transportOptions = ['curl', 'proxy', 'timeout', 'connect_timeout']; + $hop = new PendingRequest(options: Arr::only($options, $transportOptions)); + $destination = $this->resolveOAuthDestination($hop, (string) $request->getUri()); + + return $handler($request->withUri($destination->uri, true), array_replace( + $options, + Arr::only($hop->getOptions(), $transportOptions), + )); + }; + }); + } + /** * Probe the resource for its unauthenticated authorization challenge. * @@ -115,24 +165,12 @@ public function requireAdvertised(string $resourceUrl, string $resourceMetadataU */ protected function probe(string $resourceUrl, array $headers): Response { + // Compare each hop's origin before pin() replaces its URI with the normalized destination. + $request = $this->oAuthRequest()->withMiddleware(new RestrictHeadersToOrigin($resourceUrl, array_keys($headers))); + try { - return $this->oAuthRequest() + return $this->pin($request) ->withHeaders($headers) - ->withMiddleware(new RestrictHeadersToOrigin($resourceUrl, array_keys($headers))) - ->withMiddleware(function (callable $handler): Closure { - return function (RequestInterface $request, array $options) use ($handler): PromiseInterface { - // Resolve every hop through the same boundary as other OAuth requests, which - // preserves ordinary proxies that the generic destination-policy handler rejects. - $transportOptions = ['curl', 'proxy', 'timeout', 'connect_timeout']; - $hop = new PendingRequest(options: Arr::only($options, $transportOptions)); - $destination = $this->resolveOAuthDestination($hop, (string) $request->getUri()); - - return $handler($request->withUri($destination->uri, true), array_replace( - $options, - Arr::only($hop->getOptions(), $transportOptions), - )); - }; - }) ->withOptions(['allow_redirects' => [ 'max' => 5, 'strict' => true, @@ -158,9 +196,7 @@ protected function probe(string $resourceUrl, array $headers): Response */ protected function fetchResourceMetadata(string $metadataUrl, bool $explicit = false): array { - $request = $this->oAuthRequest(); - $destination = $this->resolveOAuthDestination($request, $metadataUrl); - $response = $request->get((string) $destination->uri); + $response = $this->pin($this->oAuthRequest())->get($metadataUrl); if (! $response->successful()) { if ($explicit) { @@ -226,9 +262,7 @@ protected function issuerFrom(array $resourceMetadata): ?string protected function fetchMetadata(string $issuer): AuthServerMetadata { foreach ($this->metadataUrls($issuer) as $metadataUrl) { - $request = $this->oAuthRequest(); - $destination = $this->resolveOAuthDestination($request, $metadataUrl); - $response = $request->get((string) $destination->uri); + $response = $this->pin($this->oAuthRequest())->get($metadataUrl); if (! $response->successful()) { continue; @@ -303,11 +337,14 @@ protected function requireSecure(string $url): void { $parts = $this->parse($url); - if ($parts['scheme'] === 'https') { + // URL schemes are case-insensitive, and Guzzle sends HTTP:// and HTTPS:// URLs as http and https. + $scheme = strtolower($parts['scheme']); + + if ($scheme === 'https') { return; } - if ($this->isLocalhost($this->normalizedHost($parts['host']))) { + if ($scheme === 'http' && $this->isLocalhost($this->normalizedHost($parts['host']))) { return; } @@ -317,7 +354,7 @@ protected function requireSecure(string $url): void /** * Validate a metadata endpoint before fetching it. */ - protected function requireFetchable(string $url, string $resourceUrl): void + public function requireFetchable(string $url, string $resourceUrl): void { $this->requireSecure($url); $this->requireNotInternal($url, $resourceUrl); @@ -394,4 +431,12 @@ protected function parse(string $url): array /** @var array{scheme: string, host: string, port?: int, path?: string, query?: string} $parts */ return $parts; } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$hostResolver = null; + } } diff --git a/src/mcp/src/Client/OAuth/DynamicClientRegistration.php b/src/mcp/src/Client/OAuth/DynamicClientRegistration.php index d71633189..3b8770446 100644 --- a/src/mcp/src/Client/OAuth/DynamicClientRegistration.php +++ b/src/mcp/src/Client/OAuth/DynamicClientRegistration.php @@ -12,6 +12,13 @@ class DynamicClientRegistration { use InteractsWithOAuthEndpoints; + /** + * Create a new dynamic client registration instance. + */ + public function __construct(protected AuthServerDiscovery $discovery = new AuthServerDiscovery) + { + } + /** * Register a client with the authorization server. */ @@ -23,17 +30,17 @@ public function register( string $applicationType = 'web', TokenEndpointAuthMethod $tokenEndpointAuthMethod = TokenEndpointAuthMethod::ClientSecretPost, ): ClientRegistration { - $request = $this->oAuthRequest()->asJson(); - $destination = $this->resolveOAuthDestination($request, $registrationEndpoint); - $response = $request->post((string) $destination->uri, array_filter([ - 'client_name' => $clientName, - 'redirect_uris' => [$redirectUri], - 'grant_types' => ['authorization_code', 'refresh_token'], - 'response_types' => ['code'], - 'token_endpoint_auth_method' => $tokenEndpointAuthMethod->value, - 'application_type' => $applicationType, - 'scope' => $scope, - ], static fn (mixed $value): bool => $value !== null)); + $response = $this->discovery->pin($this->oAuthRequest()) + ->asJson() + ->post($registrationEndpoint, array_filter([ + 'client_name' => $clientName, + 'redirect_uris' => [$redirectUri], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + 'token_endpoint_auth_method' => $tokenEndpointAuthMethod->value, + 'application_type' => $applicationType, + 'scope' => $scope, + ], static fn (mixed $value): bool => $value !== null)); if (! $response->successful()) { throw new OAuthException("Dynamic client registration failed with status [{$response->status()}]."); diff --git a/src/mcp/src/Client/OAuth/OAuthClient.php b/src/mcp/src/Client/OAuth/OAuthClient.php index a48450dec..bb071fdd6 100644 --- a/src/mcp/src/Client/OAuth/OAuthClient.php +++ b/src/mcp/src/Client/OAuth/OAuthClient.php @@ -216,8 +216,12 @@ protected function exchangeAuthorizationCode( $clientId = (string) $stored['client_id']; $clientSecret = isset($stored['client_secret']) ? (string) $stored['client_secret'] : null; + $tokenEndpoint = (string) $stored['token_endpoint']; + + $this->discovery->requireFetchable($tokenEndpoint, $this->resourceUrl); + $token = $this->requestToken( - (string) $stored['token_endpoint'], + $tokenEndpoint, [ 'grant_type' => 'authorization_code', 'code' => $code, @@ -271,7 +275,8 @@ protected function usesClientIdMetadataDocument(AuthServerMetadata $metadata): b $url = parse_url($this->clientIdMetadataUrl); - if (! is_array($url) || ($url['scheme'] ?? null) !== 'https' || ($url['path'] ?? '/') === '/') { + // Schemes are case-insensitive; the original URL is still sent as the exact client identifier. + if (! is_array($url) || strtolower($url['scheme'] ?? '') !== 'https' || ($url['path'] ?? '/') === '/') { return false; } @@ -309,7 +314,7 @@ protected function register(AuthServerMetadata $metadata, string $redirectUri): throw new OAuthException('No client_id was configured, no https client ID metadata document URL is available, and the authorization server does not support dynamic client registration.'); } - return (new DynamicClientRegistration)->register( + return (new DynamicClientRegistration($this->discovery))->register( $metadata->registrationEndpoint, $redirectUri, $this->authorizationScope(), @@ -331,7 +336,7 @@ protected function requestToken( ?string $clientSecret, TokenEndpointAuthMethod $authMethod, ): TokenSet { - $request = $this->oAuthRequest()->asForm(); + $request = $this->discovery->pin($this->oAuthRequest())->asForm(); $credentials = match ($authMethod) { TokenEndpointAuthMethod::ClientSecretBasic => [], @@ -344,8 +349,7 @@ protected function requestToken( $request = $request->withBasicAuth(rawurlencode((string) $clientId), rawurlencode((string) $clientSecret)); } - $destination = $this->resolveOAuthDestination($request, $tokenEndpoint); - $response = $request->post((string) $destination->uri, array_filter([...$params, ...$credentials], static fn (mixed $value): bool => $value !== null)); + $response = $request->post($tokenEndpoint, array_filter([...$params, ...$credentials], static fn (mixed $value): bool => $value !== null)); if (! $response->successful()) { throw new OAuthException("Token request to [{$tokenEndpoint}] failed with status [{$response->status()}]."); diff --git a/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php b/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php index fd4e77df1..0da7d0cdf 100644 --- a/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php +++ b/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php @@ -20,4 +20,18 @@ protected function allowsAddress(UriInterface $uri, string $address): bool return in_array(trim($uri->getHost(), '[]'), self::LOCALHOSTS, true) && IpUtils::checkIp($address, ['127.0.0.0/8', '::1']); } + + /** + * Resolve hostnames through AuthServerDiscovery::resolveHostsUsing() when a callback is set. + * + * @return list + */ + protected function resolveHost(string $host, float $timeoutSeconds): array + { + if (filter_var($host, FILTER_VALIDATE_IP) === false && ($addresses = AuthServerDiscovery::resolvedHostAddresses($host)) !== null) { + return $addresses; + } + + return parent::resolveHost($host, $timeoutSeconds); + } } diff --git a/src/mcp/src/Server/AppResource.php b/src/mcp/src/Server/AppResource.php index 3fd4d4bba..057a04282 100644 --- a/src/mcp/src/Server/AppResource.php +++ b/src/mcp/src/Server/AppResource.php @@ -33,9 +33,12 @@ public function appMeta(): AppMeta */ public function resolvedAppMeta(): array { - $appMeta = $this->appMeta()->toArray(); + $meta = $this->appMeta(); + $appMeta = $meta->toArray(); - $appMeta['domain'] ??= $this->toClaudeDomain(url()->current()); + if ($meta->usesDomain()) { + $appMeta['domain'] ??= $this->toClaudeDomain(url()->current()); + } return $appMeta; } diff --git a/src/mcp/src/Server/Attributes/AppMeta.php b/src/mcp/src/Server/Attributes/AppMeta.php index 7866cde22..a28430052 100644 --- a/src/mcp/src/Server/Attributes/AppMeta.php +++ b/src/mcp/src/Server/Attributes/AppMeta.php @@ -22,6 +22,7 @@ class AppMeta * @param null|array $frameDomains domains the app may embed as nested iframes (CSP frame-src) * @param null|array $baseUriDomains allowed URLs for the document's base element (CSP base-uri) * @param null|array $permissions + * @param null|false|string $domain dedicated sandbox domain, or false to leave ui.domain out entirely * @param array $libraries */ public function __construct( @@ -31,7 +32,7 @@ public function __construct( public readonly ?array $baseUriDomains = null, public readonly ?array $permissions = null, public readonly ?bool $prefersBorder = null, - public readonly ?string $domain = null, + public readonly string|false|null $domain = null, public readonly array $libraries = [], ) { } @@ -55,7 +56,9 @@ public function toAppMeta(): AppMetaData $meta->prefersBorder($this->prefersBorder); } - if ($this->domain !== null) { + if ($this->domain === false) { + $meta->withoutDomain(); + } elseif ($this->domain !== null) { $meta->domain($this->domain); } diff --git a/src/mcp/src/Server/Testing/TestResponse.php b/src/mcp/src/Server/Testing/TestResponse.php index 354ff5196..3ef744d8b 100644 --- a/src/mcp/src/Server/Testing/TestResponse.php +++ b/src/mcp/src/Server/Testing/TestResponse.php @@ -13,13 +13,17 @@ use Hypervel\Mcp\Server\Resource; use Hypervel\Mcp\Server\Tool; use Hypervel\Mcp\Transport\JsonRpcResponse; +use Hypervel\Support\Json; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; +use Hypervel\Testing\AssertableJsonString; use Hypervel\Testing\Fluent\AssertableJson; use PHPUnit\Framework\Assert; use RuntimeException; use stdClass; +use function Hypervel\Support\enum_value; + class TestResponse { use Conditionable; @@ -146,6 +150,238 @@ public function assertStructuredContent(Closure|array $structuredContent): stati return $this; } + /** + * Assert that the structured content has the expected count of items at the given key. + */ + public function assertStructuredContentCount(int $count, ?string $key = null): static + { + $actual = $this->structuredContent(); + + Assert::assertNotNull($actual, 'The response does not contain any structured content.'); + + Assert::assertCount($count, data_get($actual, $key), "The expected structured content count [{$count}] does not match the actual count."); + + return $this; + } + + /** + * Assert that the expected value and type exists at the given path in the structured content. + * + * @param (Closure(mixed): bool)|mixed $expect + */ + public function assertStructuredContentPath(string $path, mixed $expect): static + { + $actual = $this->structuredContent(); + + Assert::assertNotNull($actual, 'The response does not contain any structured content.'); + + $value = data_get($actual, $path); + + if ($expect instanceof Closure) { + Assert::assertTrue($expect($value), "The structured content at [{$path}] does not pass the given truth test."); + + return $this; + } + + Assert::assertSame( + enum_value($expect), + $value, + "The expected structured content at [{$path}] does not match the actual value." + ); + + return $this; + } + + /** + * Assert that the expected values and types exist at the given paths in the structured content. + * + * @param array $paths + */ + public function assertStructuredContentPaths(array $paths): static + { + foreach ($paths as $path => $expected) { + $this->assertStructuredContentPath($path, $expected); + } + + return $this; + } + + /** + * Assert that the given path in the structured content contains all of the expected values without looking at the order. + * + * @param array $expect + */ + public function assertStructuredContentPathCanonicalizing(string $path, array $expect): static + { + $this->assertableStructuredContent()->assertPathCanonicalizing($path, $expect); + + return $this; + } + + /** + * Assert that the given paths in the structured content contain all of the expected values without looking at the order. + * + * @param array> $paths + */ + public function assertStructuredContentPathsCanonicalizing(array $paths): static + { + foreach ($paths as $path => $expected) { + $this->assertStructuredContentPathCanonicalizing($path, $expected); + } + + return $this; + } + + /** + * Assert that the structured content is exactly the given JSON. + * + * @param array $data + */ + public function assertExactStructuredContent(array $data): static + { + $this->assertableStructuredContent()->assertExact($data); + + return $this; + } + + /** + * Assert that the structured content has the similar JSON as given. + * + * @param array $data + */ + public function assertSimilarStructuredContent(array $data): static + { + $this->assertableStructuredContent()->assertSimilar($data); + + return $this; + } + + /** + * Assert that the structured content contains the given JSON fragment. + * + * @param array $data + */ + public function assertStructuredContentFragment(array $data): static + { + $this->assertableStructuredContent()->assertFragment($data); + + return $this; + } + + /** + * Assert that the structured content contains the given JSON fragments. + * + * @param array> $data + */ + public function assertStructuredContentFragments(array $data): static + { + foreach ($data as $fragment) { + $this->assertStructuredContentFragment($fragment); + } + + return $this; + } + + /** + * Assert that the structured content does not contain the given JSON fragment. + * + * @param array $data + */ + public function assertStructuredContentMissing(array $data, bool $exact = false): static + { + $this->assertableStructuredContent()->assertMissing($data, $exact); + + return $this; + } + + /** + * Assert that the structured content does not contain the exact JSON fragment. + * + * @param array $data + */ + public function assertStructuredContentMissingExact(array $data): static + { + $this->assertableStructuredContent()->assertMissingExact($data); + + return $this; + } + + /** + * Assert that the structured content does not contain the given path. + */ + public function assertStructuredContentMissingPath(string $path): static + { + $this->assertableStructuredContent()->assertMissingPath($path); + + return $this; + } + + /** + * Assert that the structured content does not contain the given paths. + * + * @param array $paths + */ + public function assertStructuredContentMissingPaths(array $paths): static + { + foreach ($paths as $path) { + $this->assertStructuredContentMissingPath($path); + } + + return $this; + } + + /** + * Assert that the structured content has a given JSON structure. + * + * @param null|array $structure + * @param null|array $responseData + */ + public function assertStructuredContentStructure(?array $structure = null, ?array $responseData = null): static + { + $this->assertableStructuredContent()->assertStructure($structure, $responseData); + + return $this; + } + + /** + * Assert that the structured content has the exact JSON structure. + * + * @param null|array $structure + * @param null|array $responseData + */ + public function assertExactStructuredContentStructure(?array $structure = null, ?array $responseData = null): static + { + $this->assertableStructuredContent()->assertStructure($structure, $responseData, true); + + return $this; + } + + /** + * Assert that the given key in the structured content is a JSON array. + */ + public function assertStructuredContentIsArray(?string $key = null): static + { + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->assertableStructuredContent()->object($key); + + Assert::assertTrue(is_array($data), 'The structured content is not an array.'); + + return $this; + } + + /** + * Assert that the given key in the structured content is a JSON object. + */ + public function assertStructuredContentIsObject(?string $key = null): static + { + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->assertableStructuredContent()->object($key); + + Assert::assertTrue($data instanceof stdClass, 'The structured content is not an object.'); + + return $this; + } + /** * Assert the number of notifications received. */ @@ -488,6 +724,22 @@ protected function structuredContent(): ?array return $decoded; } + /** + * Get the response's structured content for JSON assertions. + */ + protected function assertableStructuredContent(): AssertableJsonString + { + $structuredContent = $this->response->toArray()['result']['structuredContent'] ?? null; + + Assert::assertTrue( + is_array($structuredContent) || $structuredContent instanceof stdClass, + 'The response does not contain any structured content.' + ); + + // Pass encoded JSON so object() can still tell empty and numeric-key objects from arrays. + return new AssertableJsonString(Json::encode($structuredContent)); + } + /** * Normalize a value through its JSON representation. */ diff --git a/src/mcp/src/Server/Ui/AppMeta.php b/src/mcp/src/Server/Ui/AppMeta.php index e8e918ddb..e1777f7d4 100644 --- a/src/mcp/src/Server/Ui/AppMeta.php +++ b/src/mcp/src/Server/Ui/AppMeta.php @@ -12,6 +12,8 @@ */ class AppMeta implements Arrayable { + protected bool $usesDomain = true; + /** * Create application metadata. * @@ -60,10 +62,30 @@ public function permissions(Permissions $permissions): static public function domain(string $domain): static { $this->domain = $domain; + $this->usesDomain = true; return $this; } + /** + * Leave the application domain out so the host uses its default sandbox origin. + */ + public function withoutDomain(): static + { + $this->domain = null; + $this->usesDomain = false; + + return $this; + } + + /** + * Determine if the application uses a domain. + */ + public function usesDomain(): bool + { + return $this->usesDomain; + } + /** * Set whether the application prefers a visible border. */ diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 95c497bba..470af9554 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -455,6 +455,7 @@ protected function flushMcpState(): void $this->callIfExists(\Hypervel\Mcp\Response::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\ResponseFactory::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\Client\ClientManager::class, 'flushMacros'); + $this->callIfExists(\Hypervel\Mcp\Client\OAuth\AuthServerDiscovery::class, 'flushState'); $this->callIfExists(\Hypervel\Mcp\Server\Registrar::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\Server\Testing\TestResponse::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\Server\Testing\TestListResponse::class, 'flushMacros'); diff --git a/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php b/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php index 29f9e6bdd..293681b54 100644 --- a/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php +++ b/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php @@ -10,7 +10,10 @@ use Hypervel\Mcp\Server\Tool; use Hypervel\Testing\Fluent\AssertableJson; use Hypervel\Tests\Mcp\TestCase; +use PHPUnit\Framework\AssertionFailedError; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\ExpectationFailedException; +use stdClass; enum BookingStatus: string { @@ -23,10 +26,37 @@ class BookingServer extends Server GetBookingTool::class, GetBookingWithoutStructuredContentTool::class, GetBookingWithSerializableValuesTool::class, + ListBookingsTool::class, EmptyStructuredContentTool::class, + GetBookingFiltersTool::class, ]; } +class ListBookingsTool extends Tool +{ + protected string $name = 'booking/list'; + + protected string $title = 'List bookings'; + + protected string $description = 'List bookings'; + + /** + * Return a structured list of bookings. + */ + public function handle(): ResponseFactory + { + return Response::structured([ + 'data' => [ + ['id' => 123, 'status' => BookingStatus::Confirmed], + ['id' => 124, 'status' => BookingStatus::Confirmed], + ], + 'meta' => [ + 'total' => 2, + ], + ]); + } +} + class GetBookingWithSerializableValuesTool extends Tool { protected string $name = 'booking/get/serializable'; @@ -96,6 +126,20 @@ public function handle(): ResponseFactory } } +class GetBookingFiltersTool extends Tool +{ + /** + * Return structured content with JSON objects that decode to PHP lists. + */ + public function handle(): ResponseFactory + { + return Response::structured([ + 'filters' => new stdClass, + 'labels' => (object) ['priority'], + ]); + } +} + class AssertStructuredContentTest extends TestCase { public function testMayAssertEmptyStructuredContent(): void @@ -184,4 +228,394 @@ public function testFailsToAssertTheStructuredContentWithAClosureWhenItIsNotPres $this->expectException(ExpectationFailedException::class); $response->assertStructuredContent(fn (AssertableJson $json): AssertableJson => $json->etc()); } + + public function testMayAssertTheStructuredContentCount(): void + { + $response = BookingServer::tool(GetBookingTool::class); + + $response->assertStructuredContentCount(3); + } + + public function testMayAssertTheStructuredContentCountAtAKey(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentCount(2, 'data'); + } + + public function testFailsToAssertTheStructuredContentCountIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentCount(3, 'data'); + } + + public function testFailsToAssertTheStructuredContentCountWhenItIsNotPresent(): void + { + $response = BookingServer::tool(GetBookingWithoutStructuredContentTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentCount(0); + } + + public function testMayAssertTheStructuredContentPath(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response + ->assertStructuredContentPath('data.1.id', 124) + ->assertStructuredContentPath('meta', ['total' => 2]); + } + + public function testMayAssertTheStructuredContentPathWithClosure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPath('meta.total', fn (int $total): bool => $total === 2); + } + + public function testFailsToAssertTheStructuredContentPathIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPath('data.1.id', 123); + } + + public function testFailsToAssertTheStructuredContentPathWithClosure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPath('meta.total', fn (int $total): bool => $total === 3); + } + + public function testMayAssertTheStructuredContentPathWithAnEnum(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPath('data.0.status', BookingStatus::Confirmed); + } + + public function testFailsToAssertTheStructuredContentPathWhenItIsNotPresent(): void + { + $response = BookingServer::tool(GetBookingWithoutStructuredContentTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPath('id', 123); + } + + public function testMayAssertTheStructuredContentPaths(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPaths([ + 'data.0.id' => 123, + 'data.1.status' => BookingStatus::Confirmed, + 'meta.total' => 2, + ]); + } + + public function testFailsToAssertTheStructuredContentPathsAreWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPaths([ + 'data.0.id' => 123, + 'meta.total' => 3, + ]); + } + + public function testMayAssertTheStructuredContentPathCanonicalizing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPathCanonicalizing('data.*.id', [124, 123]); + } + + public function testFailsToAssertTheStructuredContentPathCanonicalizingIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPathCanonicalizing('data.*.id', [124, 125]); + } + + public function testMayAssertTheExactStructuredContentRegardlessOfKeyOrder(): void + { + $response = BookingServer::tool(GetBookingTool::class); + + $response->assertExactStructuredContent([ + 'status' => 'confirmed', + 'id' => 123, + 'type' => 'booking', + ]); + } + + public function testFailsToAssertTheExactStructuredContentIsWrong(): void + { + $response = BookingServer::tool(GetBookingTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertExactStructuredContent([ + 'id' => 123, + 'status' => 'confirmed', + ]); + } + + public function testMayAssertAStructuredContentFragment(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentFragment(['id' => 124]); + } + + public function testFailsToAssertAStructuredContentFragmentThatIsNotPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentFragment(['id' => 125]); + } + + public function testFailsToAssertAStructuredContentFragmentWhenItIsNotPresent(): void + { + $response = BookingServer::tool(GetBookingWithoutStructuredContentTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentFragment(['id' => 123]); + } + + public function testMayAssertAStructuredContentPathIsMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissingPath('data.2'); + } + + public function testFailsToAssertAStructuredContentPathIsMissingWhenItIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentMissingPath('meta.total'); + } + + public function testMayAssertTheStructuredContentStructure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentStructure([ + 'data' => [ + '*' => ['id', 'status'], + ], + 'meta' => ['total'], + ]); + } + + public function testFailsToAssertTheStructuredContentStructureIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentStructure([ + 'meta' => ['per_page'], + ]); + } + + public function testMayAssertTheStructuredContentPathsCanonicalizing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPathsCanonicalizing([ + 'data.*.id' => [124, 123], + 'data.*.status' => ['confirmed', 'confirmed'], + ]); + } + + public function testFailsToAssertTheStructuredContentPathsCanonicalizingAreWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPathsCanonicalizing([ + 'data.*.id' => [124, 123], + 'data.*.status' => ['confirmed', 'pending'], + ]); + } + + public function testMayAssertSimilarStructuredContentRegardlessOfOrder(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertSimilarStructuredContent([ + 'meta' => [ + 'total' => 2, + ], + 'data' => [ + ['status' => 'confirmed', 'id' => 124], + ['status' => 'confirmed', 'id' => 123], + ], + ]); + } + + public function testFailsToAssertSimilarStructuredContentIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertSimilarStructuredContent([ + 'data' => [ + ['id' => 123, 'status' => 'confirmed'], + ], + 'meta' => [ + 'total' => 2, + ], + ]); + } + + public function testMayAssertStructuredContentFragments(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentFragments([ + ['id' => 123], + ['total' => 2], + ]); + } + + public function testFailsToAssertStructuredContentFragmentsThatAreNotPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentFragments([ + ['id' => 123], + ['total' => 3], + ]); + } + + public function testMayAssertStructuredContentIsMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissing(['id' => 125]); + } + + public function testFailsToAssertStructuredContentIsMissingWhenItIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentMissing(['id' => 124]); + } + + public function testMayAssertExactStructuredContentIsMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissingExact(['id' => 124, 'status' => 'pending']); + } + + public function testFailsToAssertExactStructuredContentIsMissingWhenItIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(AssertionFailedError::class); + $response->assertStructuredContentMissingExact(['id' => 124, 'status' => 'confirmed']); + } + + public function testMayAssertStructuredContentPathsAreMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissingPaths(['data.2', 'meta.per_page']); + } + + public function testFailsToAssertStructuredContentPathsAreMissingWhenOneIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentMissingPaths(['data.2', 'meta.total']); + } + + public function testMayAssertTheExactStructuredContentStructure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertExactStructuredContentStructure([ + 'data' => [ + '*' => ['id', 'status'], + ], + 'meta' => ['total'], + ]); + } + + public function testFailsToAssertTheExactStructuredContentStructureWhenKeysAreNotListed(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertExactStructuredContentStructure([ + 'data' => [ + '*' => ['id'], + ], + 'meta' => ['total'], + ]); + } + + public function testMayAssertTheStructuredContentIsAnArray(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentIsArray('data'); + } + + public function testFailsToAssertTheStructuredContentIsAnArrayWhenItIsAnObject(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentIsArray('meta'); + } + + #[TestWith([EmptyStructuredContentTool::class, null])] + #[TestWith([GetBookingFiltersTool::class, 'filters'])] + #[TestWith([GetBookingFiltersTool::class, 'labels'])] + public function testFailsToAssertTheStructuredContentIsAnArrayWhenAnObjectDecodesToAList(string $tool, ?string $key): void + { + $response = BookingServer::tool($tool); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentIsArray($key); + } + + public function testMayAssertTheStructuredContentIsAnObject(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response + ->assertStructuredContentIsObject() + ->assertStructuredContentIsObject('meta'); + } + + public function testMayAssertTheStructuredContentIsAnObjectWhenItDecodesToAList(): void + { + BookingServer::tool(EmptyStructuredContentTool::class)->assertStructuredContentIsObject(); + + BookingServer::tool(GetBookingFiltersTool::class) + ->assertStructuredContentIsObject('filters') + ->assertStructuredContentIsObject('labels'); + } + + public function testFailsToAssertTheStructuredContentIsAnObjectWhenItIsAnArray(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentIsObject('data'); + } } diff --git a/tests/Mcp/Unit/Client/OAuth/AuthServerDiscoveryTest.php b/tests/Mcp/Unit/Client/OAuth/AuthServerDiscoveryTest.php new file mode 100644 index 000000000..b8ffe19b4 --- /dev/null +++ b/tests/Mcp/Unit/Client/OAuth/AuthServerDiscoveryTest.php @@ -0,0 +1,221 @@ +> $hosts + */ + private function resolveHosts(array $hosts): void + { + AuthServerDiscovery::resolveHostsUsing(fn (string $host): array => $hosts[$host] ?? []); + } + + /** + * Fake the resource and authorization server metadata responses. + */ + private function fakeAuthServer( + string $resource = 'https://mcp.test', + string $authorizationEndpoint = 'https://auth.test/authorize', + ?string $registrationEndpoint = null, + ): void { + Http::fake([ + $resource . '/.well-known/oauth-protected-resource/mcp' => Http::response([ + 'authorization_servers' => ['https://auth.test'], + ]), + 'https://auth.test/.well-known/oauth-authorization-server' => Http::response(array_filter([ + 'issuer' => 'https://auth.test', + 'authorization_endpoint' => $authorizationEndpoint, + 'token_endpoint' => 'https://auth.test/token', + 'registration_endpoint' => $registrationEndpoint, + ])), + ]); + } + + /** + * Assert that discovery rejects a disallowed address before sending to it. + */ + private function assertDiscoveryRejects(string $address, string $resourceUrl, ?string $resourceMetadataUrl = null): void + { + try { + (new AuthServerDiscovery)->discover($resourceUrl, $resourceMetadataUrl); + $this->fail('Expected discovery to reject the disallowed address.'); + } catch (OAuthException $exception) { + $this->assertStringContainsString("disallowed address [{$address}]", $exception->getMessage()); + } + } + + #[DataProvider('nonPublicAddresses')] + public function testRejectsHostsThatResolveToNonPublicAddresses(string $address): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'meta.test' => ['93.184.216.34', $address]]); + + Http::fake(); + + $this->assertDiscoveryRejects($address, 'https://mcp.test/mcp', 'https://meta.test/prm'); + + Http::assertNothingSent(); + } + + /** + * Provide addresses outside the public internet. + */ + public static function nonPublicAddresses(): array + { + return [ + 'rfc1918 10/8' => ['10.0.0.5'], + 'rfc1918 172.16/12' => ['172.16.0.1'], + 'rfc1918 192.168/16' => ['192.168.1.1'], + 'cloud metadata' => ['169.254.169.254'], + 'loopback' => ['127.0.0.1'], + 'carrier grade nat' => ['100.64.0.1'], + 'ipv4 multicast' => ['224.0.0.1'], + 'ipv6 loopback' => ['::1'], + 'ipv6 link local' => ['fe80::1'], + 'ipv6 unique local' => ['fc00::1'], + 'ipv4 mapped ipv6' => ['::ffff:10.0.0.1'], + 'ipv6 multicast' => ['ff02::1'], + 'nat64' => ['64:ff9b::a9fe:a9fe'], + 'local use nat64' => ['64:ff9b:1::a9fe:a9fe'], + '6to4' => ['2002:7f00:1::'], + 'teredo' => ['2001:0:4136:e378:8000:63bf:3fff:fdd2'], + 'benchmarking' => ['198.18.0.1'], + 'ietf protocol assignments' => ['192.0.0.1'], + 'ipv4 compatible ipv6' => ['::7f00:1'], + 'ipv4 translated ipv6' => ['::ffff:0:7f00:1'], + 'ipv6 site local' => ['fec0::1'], + ]; + } + + public function testRejectsInternalEndpointsWhenTheResourceAlsoResolvesToPublicAddresses(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34', '10.0.0.1'], 'meta.test' => ['10.0.0.5']]); + + Http::fake(); + + $this->assertDiscoveryRejects('10.0.0.5', 'https://mcp.test/mcp', 'https://meta.test/prm'); + + Http::assertNothingSent(); + } + + // REMOVED: the IPv4-mapped case. The HTTP client's destination policy rejects every IPv4-mapped + // address; resolvers only return them from nonstandard AAAA records. + #[TestWith(['64:ff9b::5db8:d822'], 'nat64')] + public function testAllowsHostsThatResolveToAWrappedPublicIpv4Address(string $address): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'auth.test' => [$address]]); + + $this->fakeAuthServer(); + + $this->assertSame('https://auth.test', (new AuthServerDiscovery)->discover('https://mcp.test/mcp')->server->issuer); + } + + public function testRejectsHostsThatDoNotResolve(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'meta.test' => []]); + + // DNS failures pass through unchanged rather than as OAuth policy errors. + $this->expectException(DestinationResolutionException::class); + $this->expectExceptionMessage('The host [meta.test] did not resolve to an address.'); + + (new AuthServerDiscovery)->discover('https://mcp.test/mcp', 'https://meta.test/prm'); + } + + // REMOVED: allowing private endpoints because the resource itself is private, on the same or + // another host. Private destinations always require an explicit OAuthDestinationPolicy allowance. + #[TestWith(['https://intranet.test', ['intranet.test' => ['10.0.0.5']], '10.0.0.5'], 'same host')] + #[TestWith(['https://mcp.test', ['mcp.test' => ['127.0.0.1'], 'auth.test' => ['127.0.0.1']], '127.0.0.1'], 'other host')] + public function testPrivateResourcesStillRequireAnAllowanceForPrivateEndpoints(string $resource, array $hosts, string $address): void + { + $this->resolveHosts($hosts); + + $this->fakeAuthServer($resource); + + $this->assertDiscoveryRejects($address, "{$resource}/mcp"); + } + + // REMOVED: rejecting registration endpoints during discovery. Each endpoint is checked when a request + // is sent to it; see OAuthClientTest::testRejectsPrivateDnsTargetsBeforeSendingAtEveryOAuthBoundary(). + + public function testPinsEachRequestToTheAddressesThatWereChecked(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'auth.test' => ['93.184.216.35', '2606:4700::1111']]); + + $pins = []; + + Http::fake(function (Request $request, array $options) use (&$pins): void { + $pins[$request->url()] = $options['curl'][CURLOPT_RESOLVE] ?? null; + }); + + $this->fakeAuthServer(); + + (new AuthServerDiscovery)->discover('https://mcp.test/mcp'); + + $this->assertSame([ + 'https://mcp.test/.well-known/oauth-protected-resource/mcp' => ['mcp.test:443:93.184.216.34'], + 'https://auth.test/.well-known/oauth-authorization-server' => ['auth.test:443:[2606:4700::1111],93.184.216.35'], + ], $pins); + } + + public function testLooksUpEachHostOncePerDiscovery(): void + { + $lookups = []; + + AuthServerDiscovery::resolveHostsUsing(function (string $host) use (&$lookups): array { + $lookups[$host] = ($lookups[$host] ?? 0) + 1; + + return ['93.184.216.34']; + }); + + $this->fakeAuthServer(); + + (new AuthServerDiscovery)->discover('https://mcp.test/mcp'); + + $this->assertSame(['mcp.test' => 1, 'auth.test' => 1], $lookups); + } + + public function testDoesNotResolveTheAuthorizationEndpointTheBrowserOpens(): void + { + $this->resolveHosts([ + 'mcp.test' => ['93.184.216.34'], + 'auth.test' => ['93.184.216.34'], + 'login.corp.test' => ['10.0.0.5'], + ]); + + $this->fakeAuthServer(authorizationEndpoint: 'https://login.corp.test/authorize'); + + $this->assertSame( + 'https://login.corp.test/authorize', + (new AuthServerDiscovery)->discover('https://mcp.test/mcp')->server->authorizationEndpoint, + ); + } + + // REMOVED: skipping resolution behind an HTTPS proxy, and rejecting local names unresolved there. + // DNS is resolved and validated locally behind ordinary proxies; a policy selects proxies that resolve themselves. + + public function testStillResolvesHostsWhenOnlyAnHttpProxyIsConfigured(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'meta.test' => ['10.0.0.5']]); + + Http::fake(); + + $this->withEnvironmentValues( + ['HTTP_PROXY' => 'http://proxy.test:3128'], + fn () => $this->assertDiscoveryRejects('10.0.0.5', 'https://mcp.test/mcp', 'https://meta.test/prm'), + ); + } +} diff --git a/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php b/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php index 59cb296ef..52269ee1c 100644 --- a/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php +++ b/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php @@ -255,6 +255,129 @@ public static function tokenScopes(): array ]; } + public function testRejectsAStoredTokenEndpointThatNowResolvesToAnInternalHost(): void + { + $this->app->instance(OAuthDestinationPolicy::class, new FakeDestinationPolicy(['auth.test' => ['10.0.0.5']])); + + Http::fake(); + + Session::put($this->pendingSessionKey(), [ + 'state' => 'the-state', + 'verifier' => 'the-verifier', + 'client_id' => 'client-123', + 'client_secret' => null, + 'token_endpoint' => 'https://auth.test/token', + 'redirect_uri' => 'https://app.test/callback', + 'return_to' => null, + 'resource' => 'https://mcp.test/mcp', + ]); + + RequestContext::set(Request::create('https://app.test/callback', 'GET', [ + 'code' => 'auth-code', + 'state' => 'the-state', + ])); + + try { + Client::web('https://mcp.test/mcp') + ->withOAuth(clientId: 'client-123') + ->oAuthClient() + ->exchangeCallback(); + $this->fail('Expected the internal token endpoint to be rejected.'); + } catch (OAuthException $exception) { + $this->assertStringContainsString('disallowed address [10.0.0.5]', $exception->getMessage()); + } + + Http::assertNothingSent(); + } + + public function testChecksTheStoredTokenEndpointThroughTheConfiguredDiscovery(): void + { + Http::fake(); + + Session::put($this->pendingSessionKey(), [ + 'state' => 'the-state', + 'verifier' => 'the-verifier', + 'client_id' => 'client-123', + 'client_secret' => null, + 'token_endpoint' => 'https://auth.test/token', + 'redirect_uri' => 'https://app.test/callback', + 'return_to' => null, + 'resource' => 'https://mcp.test/mcp', + ]); + + RequestContext::set(Request::create('https://app.test/callback', 'GET', [ + 'code' => 'auth-code', + 'state' => 'the-state', + ])); + + $rejection = new OAuthException('The custom discovery rejected the token endpoint.'); + $discovery = new class($rejection) extends AuthServerDiscovery { + /** + * @var list + */ + public array $checked = []; + + /** + * Create a discovery that rejects every endpoint. + */ + public function __construct(private OAuthException $rejection) + { + } + + /** + * Record and reject the endpoint. + */ + public function requireFetchable(string $url, string $resourceUrl): void + { + $this->checked[] = [$url, $resourceUrl]; + + throw $this->rejection; + } + }; + + $client = new OAuthClient( + new OAuthConfig(clientId: 'client-123'), + 'https://mcp.test/mcp', + discovery: $discovery, + ); + + try { + $client->exchangeCallback(); + $this->fail('Expected the custom discovery to reject the token endpoint.'); + } catch (OAuthException $exception) { + $this->assertSame($rejection, $exception); + } + + $this->assertSame([['https://auth.test/token', 'https://mcp.test/mcp']], $discovery->checked); + Http::assertNothingSent(); + } + + public function testPinsTheTokenAndRegistrationRequestsToTheCheckedAddresses(): void + { + $pins = []; + + Http::fake(function ($request, array $options) use (&$pins): void { + $pins[$request->url()] = $options['curl'][CURLOPT_RESOLVE] ?? null; + }); + + $this->fakeDiscovery(); + + Http::fake([ + 'https://auth.test/register' => Http::response(['client_id' => 'dcr-999']), + 'https://auth.test/token' => Http::response(['access_token' => 'access-token', 'token_type' => 'Bearer']), + ]); + + $oauth = Client::web('https://mcp.test/mcp') + ->withOAuth(redirectUri: 'https://app.test/callback') + ->oAuthClient(); + + $oauth->redirect(); + $oauth->refreshCredentials('refresh-token', 'dcr-999'); + + $this->assertSame(['auth.test:443:93.184.216.34'], $pins['https://auth.test/register']); + $this->assertSame(['auth.test:443:93.184.216.34'], $pins['https://auth.test/token']); + } + public function testNamedClientsForTheSameResourceKeepIndependentPendingAuthorizations(): void { $this->fakeDiscovery(); @@ -966,6 +1089,72 @@ public static function insecureAuthorizationMetadata(): array ]; } + #[TestWith(['javascript://localhost/%0aalert(1)'], 'javascript')] + #[TestWith(['file://localhost/etc/passwd'], 'file')] + #[TestWith(['data://localhost/text'], 'data')] + public function testRejectsANonHttpAuthorizationServerOnALocalhostHost(string $issuer): void + { + Http::fake([ + 'http://localhost/.well-known/oauth-protected-resource/mcp' => Http::response([ + 'authorization_servers' => [$issuer], + ]), + ]); + + $this->expectException(OAuthException::class); + $this->expectExceptionMessage('must be served over HTTPS'); + Client::web('http://localhost/mcp') + ->withOAuth(clientId: 'client-123', redirectUri: 'https://app.test/callback') + ->oAuthClient() + ->redirect(); + } + + public function testRejectsANonHttpAuthorizationEndpointOnALocalhostHost(): void + { + Http::fake([ + 'http://localhost/.well-known/oauth-protected-resource/mcp' => Http::response([ + 'authorization_servers' => ['http://localhost'], + ]), + 'http://localhost/.well-known/oauth-authorization-server' => Http::response([ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'javascript://localhost/%0aalert(1)', + 'token_endpoint' => 'http://localhost/token', + 'code_challenge_methods_supported' => ['S256'], + ]), + ]); + + $this->expectException(OAuthException::class); + $this->expectExceptionMessage('must be served over HTTPS'); + Client::web('http://localhost/mcp') + ->withOAuth(clientId: 'client-123', redirectUri: 'https://app.test/callback') + ->oAuthClient() + ->redirect(); + } + + #[TestWith(['http://localhost', 'http://localhost', 'HTTP://localhost/authorize', 'http://localhost/authorize'], 'local http')] + #[TestWith(['https://mcp.test', 'https://auth.test', 'HTTPS://auth.test/authorize', 'https://auth.test/authorize'], 'remote https')] + public function testAcceptsUppercaseAuthorizationEndpointSchemes(string $resourceOrigin, string $issuer, string $authorizationEndpoint, string $expectedTarget): void + { + Http::fake([ + "{$resourceOrigin}/.well-known/oauth-protected-resource/mcp" => Http::response([ + 'authorization_servers' => [$issuer], + ]), + "{$issuer}/.well-known/oauth-authorization-server" => Http::response([ + 'issuer' => $issuer, + 'authorization_endpoint' => $authorizationEndpoint, + 'token_endpoint' => "{$issuer}/token", + 'code_challenge_methods_supported' => ['S256'], + ]), + ]); + + $target = Client::web("{$resourceOrigin}/mcp") + ->withOAuth(clientId: 'client-123', redirectUri: 'https://app.test/callback') + ->oAuthClient() + ->redirect() + ->getTargetUrl(); + + $this->assertStringStartsWith("{$expectedTarget}?", $target); + } + public function testFallsBackToOpenidConnectDiscoveryWhenOauthMetadataIsAbsent(): void { Http::fake([ @@ -1626,6 +1815,21 @@ public function testAcceptsAClientIdMetadataDocumentUrlWithAPort(): void $this->assertSame('https://app.example.com:8443/client-metadata.json', $query['client_id']); } + public function testAcceptsAClientIdMetadataDocumentUrlWithAnUppercaseScheme(): void + { + $this->fakeCimdDiscovery(); + + $target = $this->cimdClient('HTTPS://app.example.com/client-metadata.json') + ->redirect() + ->getTargetUrl(); + + parse_str((string) parse_url($target, PHP_URL_QUERY), $query); + + $this->assertSame('HTTPS://app.example.com/client-metadata.json', $query['client_id']); + + Http::assertNotSent(fn ($request): bool => $request->url() === 'https://auth.test/register'); + } + public function testNeverSendsAClientSecretAlongsideAClientIdMetadataDocument(): void { $this->fakeCimdDiscovery(); diff --git a/tests/Mcp/Unit/Methods/ReadAppResourceTest.php b/tests/Mcp/Unit/Methods/ReadAppResourceTest.php index e921315df..dd7222208 100644 --- a/tests/Mcp/Unit/Methods/ReadAppResourceTest.php +++ b/tests/Mcp/Unit/Methods/ReadAppResourceTest.php @@ -14,6 +14,7 @@ use Hypervel\Mcp\Server\Attributes\Cacheable; use Hypervel\Mcp\Server\Contracts\HasUriTemplate; use Hypervel\Mcp\Server\Contracts\Transport; +use Hypervel\Mcp\Server\Methods\ListResources; use Hypervel\Mcp\Server\Methods\ReadResource; use Hypervel\Mcp\Server\Ui\AppMeta; use Hypervel\Mcp\Server\Ui\Csp; @@ -139,6 +140,46 @@ public function testDoesNotIncludeUiMetaOnContentItemsForRegularResources(): voi $this->assertArrayNotHasKey('_meta', $payload['contents'][0]); } + public function testExcludesUiDomainFromReadAndListWhenAppMetaIsWithoutADomain(): void + { + $resource = new class extends AppResource { + /** + * Get the application metadata. + */ + public function appMeta(): AppMeta + { + return AppMeta::make()->prefersBorder()->withoutDomain(); + } + + /** + * Handle the resource request. + */ + public function handle(Request $request): Response + { + return Response::text('Hello'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$resource], + ]); + + $read = (new ReadResource)->handle(new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $resource->uri()] + ), $context)->toArray()['result']; + + $list = (new ListResources)->handle(new JsonRpcRequest( + id: 2, + method: 'resources/list', + params: [] + ), $context)->toArray()['result']; + + $this->assertSame(['prefersBorder' => true], $read['contents'][0]['_meta']['ui']); + $this->assertSame(['prefersBorder' => true], $list['resources'][0]['_meta']['ui']); + } + #[DataProvider('responseModes')] public function testResourceMetadataAndCacheHintsUseTheSameRequestAsTheHandler(bool $lazy): void { diff --git a/tests/Mcp/Unit/Resources/AppResourceTest.php b/tests/Mcp/Unit/Resources/AppResourceTest.php index fbbfc6615..ea15d0124 100644 --- a/tests/Mcp/Unit/Resources/AppResourceTest.php +++ b/tests/Mcp/Unit/Resources/AppResourceTest.php @@ -232,4 +232,44 @@ public function handle(Request $request): Response 'domain' => 'sandbox.example.com', ], $array['_meta']['ui']); } + + public function testDoesNotIncludeTheDomainWhenAppMetaIsWithoutADomain(): void + { + $resource = new class extends AppResource { + /** + * Get the application metadata. + */ + public function appMeta(): AppMeta + { + return AppMeta::make()->prefersBorder()->withoutDomain(); + } + + /** + * Handle the resource request. + */ + public function handle(Request $request): Response + { + return Response::text(''); + } + }; + + $this->assertSame(['prefersBorder' => true], $resource->resolvedAppMeta()); + $this->assertArrayNotHasKey('domain', $resource->toArray()['_meta']['ui']); + } + + public function testDoesNotIncludeTheDomainWhenTheAttributeDisablesIt(): void + { + $resource = new #[AppMetaAttribute(prefersBorder: true, domain: false)] class extends AppResource { + /** + * Handle the resource request. + */ + public function handle(Request $request): Response + { + return Response::text(''); + } + }; + + $this->assertSame(['prefersBorder' => true], $resource->resolvedAppMeta()); + $this->assertArrayNotHasKey('domain', $resource->toArray()['_meta']['ui']); + } } diff --git a/tests/Mcp/Unit/Ui/AppMetaTest.php b/tests/Mcp/Unit/Ui/AppMetaTest.php index 1685ec486..d79cea3ca 100644 --- a/tests/Mcp/Unit/Ui/AppMetaTest.php +++ b/tests/Mcp/Unit/Ui/AppMetaTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Mcp\Unit\Ui; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Mcp\Server\Attributes\AppMeta as AppMetaAttribute; use Hypervel\Mcp\Server\Ui\AppMeta; use Hypervel\Mcp\Server\Ui\Csp; use Hypervel\Mcp\Server\Ui\Enums\Library; @@ -137,4 +138,30 @@ public function testReturnsLibrariesViaGetLibraries(): void $this->assertSame([Library::Tailwind, Library::Alpine], $meta->getLibraries()); } + + public function testUsesTheDomainByDefault(): void + { + $this->assertTrue(AppMeta::make()->usesDomain()); + } + + public function testCanBeCreatedWithoutADomain(): void + { + $meta = AppMeta::make()->domain('sandbox.example.com')->withoutDomain(); + + $this->assertFalse($meta->usesDomain()); + $this->assertArrayNotHasKey('domain', $meta->toArray()); + } + + public function testUsesAnExplicitDomainSetAfterWithoutDomain(): void + { + $meta = AppMeta::make()->withoutDomain()->domain('sandbox.example.com'); + + $this->assertTrue($meta->usesDomain()); + $this->assertSame('sandbox.example.com', $meta->toArray()['domain']); + } + + public function testMapsDomainFalseOnTheAttributeToWithoutDomain(): void + { + $this->assertFalse((new AppMetaAttribute(domain: false))->toAppMeta()->usesDomain()); + } } From a719833419abc4222fd0723b2b2d7da5a9759374 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:41 +0000 Subject: [PATCH 31/38] Handle insufficient credits and non-string provider error messages OpenAI reports exhausted credits and quotas as 429 errors, which were mapped to rate limits and retried. A provider error whose error.message was not a string, such as an array, failed in strtolower() with a TypeError. isInsufficientCredits() now treats 402, or 429 with a billing error code or type, as InsufficientCreditsException. It is checked before the 429 rate-limit mapping, and such errors return no retry-after delay. insufficientCreditCodes() holds OpenAI's codes and can be overridden. Credit patterns are matched only when error.message is a string. Ports laravel/ai#1157 and laravel/ai#1174 at c8495608a1. They share HandlesFailoverErrors. Hypervel's RequestException always has a response, so upstream's null checks are omitted. HandlesFailoverErrorsTest gains the non-string message case, the billing-code cases and the slow-down, 529, empty-hook, non-JSON and not-retried cases. The OpenAI ErrorHandlingTest gains the insufficient-quota case. Both files pass. --- .../Concerns/HandlesFailoverErrors.php | 56 ++++++++- .../Providers/OpenAi/ErrorHandlingTest.php | 17 +++ .../Concerns/HandlesFailoverErrorsTest.php | 112 ++++++++++++++++++ 3 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php b/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php index 23d5ececa..1a32f36f9 100644 --- a/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php +++ b/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php @@ -41,16 +41,16 @@ protected function withErrorHandling(string $providerName, Closure $callback): m } catch (RequestException $requestException) { $status = $requestException->response->status(); - if ($status === 429) { - throw RateLimitedException::forProvider( + if ($this->isInsufficientCredits($requestException)) { + throw InsufficientCreditsException::forProvider( $providerName, $requestException->getCode(), $requestException ); } - if ($status === 402) { - throw InsufficientCreditsException::forProvider( + if ($status === 429) { + throw RateLimitedException::forProvider( $providerName, $requestException->getCode(), $requestException @@ -65,8 +65,10 @@ protected function withErrorHandling(string $providerName, Closure $callback): m ); } - if ($patterns = $this->insufficientCreditPatterns()) { - $message = strtolower($requestException->response->json('error.message', '')); + $message = $requestException->response->json('error.message'); + + if (is_string($message) && $patterns = $this->insufficientCreditPatterns()) { + $message = strtolower($message); foreach ($patterns as $pattern) { if (str_contains($message, (string) $pattern)) { @@ -92,6 +94,10 @@ protected function retryAfterSeconds(Throwable $exception): ?int return null; } + if ($this->isInsufficientCredits($exception)) { + return null; + } + $status = $exception->response->status(); if ($status !== 429 && ! in_array($status, $this->overloadedStatusCodes(), true)) { @@ -103,6 +109,27 @@ protected function retryAfterSeconds(Throwable $exception): ?int return ctype_digit($retryAfter) && (int) $retryAfter <= 10 ? (int) $retryAfter : null; } + /** + * Determine if the response is a 402, or a 429 insufficient credits or quota error rather than a rate limit. + */ + protected function isInsufficientCredits(RequestException $exception): bool + { + $status = $exception->response->status(); + + if ($status === 402) { + return true; + } + + if ($status !== 429) { + return false; + } + + $codes = $this->insufficientCreditCodes(); + + return in_array($exception->response->json('error.code'), $codes, true) + || in_array($exception->response->json('error.type'), $codes, true); + } + /** * Get the status codes that indicate a provider is transiently unavailable and the request should fail over. * @@ -122,4 +149,21 @@ protected function insufficientCreditPatterns(): array { return []; } + + /** + * Get the error codes or types that indicate insufficient credits or quota on a 429 response. + * + * @return list + */ + protected function insufficientCreditCodes(): array + { + // OpenAI sends these with a 429 status, and may still use "insufficient_quota" as the error type. + return [ + 'insufficient_quota', + 'credit_balance_exhausted', + 'organization_spend_limit_exceeded', + 'project_spend_limit_exceeded', + 'organization_usage_limit_exceeded', + ]; + } } diff --git a/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php b/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php index ea8a2d6a7..886d56191 100644 --- a/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php +++ b/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Ai\Feature\Providers\OpenAi; use Hypervel\Ai\Exceptions\AiException; +use Hypervel\Ai\Exceptions\InsufficientCreditsException; use Hypervel\Ai\Exceptions\ProviderOverloadedException; use Hypervel\Ai\Exceptions\RateLimitedException; use Hypervel\Contracts\Foundation\Application; @@ -53,6 +54,22 @@ public function testRateLimitResponseThrowsRateLimitedException(): void (new AssistantAgent)->prompt('Hi', provider: 'openai'); } + public function testInsufficientQuotaResponseThrowsInsufficientCreditsException(): void + { + $this->expectException(InsufficientCreditsException::class); + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'error' => [ + 'type' => 'insufficient_quota', + 'code' => 'insufficient_quota', + 'message' => 'You exceeded your current quota, please check your plan and billing details.', + ], + ], 429), + ]); + + (new AssistantAgent)->prompt('Hi', provider: 'openai'); + } + public function testOverloadedResponseThrowsProviderOverloadedException(): void { $this->expectException(ProviderOverloadedException::class); diff --git a/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php b/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php index f905e440c..6e5fa7f50 100644 --- a/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php +++ b/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php @@ -44,6 +44,104 @@ public function test429TakesPrecedenceOverInsufficientCreditPatternMatching(): v ])); } + #[DataProvider('openAiBillingErrors')] + public function test429WithAnOpenAiBillingErrorThrowsInsufficientCreditsException(array $error): void + { + $this->expectException(InsufficientCreditsException::class); + + $this->withErrorHandling('openai', fn (): never => throw $this->failoverableException(429, ['error' => $error])); + } + + /** + * Provide OpenAI's billing and quota error codes and types. + */ + public static function openAiBillingErrors(): array + { + return [ + 'insufficient_quota code' => [['code' => 'insufficient_quota']], + 'insufficient_quota type' => [['type' => 'insufficient_quota']], + 'credit_balance_exhausted' => [['code' => 'credit_balance_exhausted']], + 'organization_spend_limit_exceeded' => [['code' => 'organization_spend_limit_exceeded']], + 'project_spend_limit_exceeded' => [['code' => 'project_spend_limit_exceeded']], + 'organization_usage_limit_exceeded' => [['code' => 'organization_usage_limit_exceeded']], + ]; + } + + public function test429WithAnOpenAiSlowDownErrorStillThrowsRateLimitedException(): void + { + $this->expectException(RateLimitedException::class); + + $this->withErrorHandling('openai', fn (): never => throw $this->failoverableException(429, [ + 'error' => ['code' => 'slow_down', 'type' => 'rate_limit_error'], + ])); + } + + public function testBillingErrorCodesAreOnlyMappedOnA429Response(): void + { + $this->expectException(ProviderOverloadedException::class); + + $this->withErrorHandling('openai', fn (): never => throw $this->failoverableException(529, [ + 'error' => ['code' => 'insufficient_quota'], + ])); + } + + public function testBillingErrorCodesAreIgnoredWhenInsufficientCreditCodesIsOverriddenEmpty(): void + { + $gateway = new class { + use HandlesFailoverErrors { + withErrorHandling as public; + } + + /** + * Disable the billing error codes. + */ + protected function insufficientCreditCodes(): array + { + return []; + } + }; + + $this->expectException(RateLimitedException::class); + + $gateway->withErrorHandling('groq', fn (): never => throw $this->failoverableException(429, [ + 'error' => ['code' => 'insufficient_quota', 'type' => 'insufficient_quota'], + ])); + } + + public function test429WithANonJsonBodyStillThrowsRateLimitedException(): void + { + $response = new Psr7Response(429, ['Content-Type' => 'text/html'], 'Too Many Requests'); + + $this->expectException(RateLimitedException::class); + + $this->withErrorHandling('openai', fn (): never => throw new RequestException(new Response($response))); + } + + public function test429WithAnInsufficientQuotaErrorIsNotRetried(): void + { + Sleep::fake(); + + $attempts = 0; + + $request = function () use (&$attempts): never { + ++$attempts; + + throw $this->failoverableException(429, [ + 'error' => ['code' => 'insufficient_quota'], + ], ['Retry-After' => '1']); + }; + + try { + $this->withErrorHandling('openai', $request); + $this->fail('Expected the insufficient credits exception.'); + } catch (InsufficientCreditsException) { + } + + $this->assertSame(1, $attempts); + + Sleep::assertNeverSlept(); + } + public function test402ThrowsInsufficientCreditsExceptionWithoutRequiringPatterns(): void { $gateway = new class { @@ -73,6 +171,20 @@ public function testNonMatchingMessageIsRethrownAsTheOriginalRequestException(): } } + public function testNonStringMessageIsRethrownAsTheOriginalRequestException(): void + { + $failure = $this->failoverableException(400, [ + 'error' => ['message' => ['code' => 'invalid_prompt']], + ]); + + try { + $this->withErrorHandling('anthropic', static fn (): never => throw $failure); + $this->fail('Expected the original request exception.'); + } catch (RequestException $exception) { + $this->assertSame($failure, $exception); + } + } + #[DataProvider('retryableStatuses')] public function testARateLimitedOrOverloadedRequestIsRetriedOnceAfterItsRetryAfterDelay(int $status): void { From bdb2c612696ca07815d95c242714bf87dd92464e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:41 +0000 Subject: [PATCH 32/38] Report stored message IDs in RUN_FINISHED metadata and expose raw classification responses These two upstream changes share src/docs/ai-sdk.md, so they land together. Ported from laravel/ai at c8495608a1. laravel/ai#1165: when the conversation is stored, the AG-UI RUN_FINISHED event repeats the stored messageId and userMessageId in its metadata, merged with any existing metadata. Ported testAStoredRunAlsoReportsItsMessageIdsInTheRunFinishedMetadata. Upstream has no docs for this; ai-sdk.md now describes it after the AG-UI example. laravel/ai#1178: ClassificationResponse uses HasRawResponse. The shared classify() path and the OpenAI classification gateway attach the provider's HTTP response, and serialization still drops it. The fake gateway leaves it null, as upstream does. Ported the OpenAI and OpenRouter raw-response tests and the serialization test. ai-sdk.md's raw HTTP responses section now mentions classification responses. All four test files pass. --- .../src/Gateway/Concerns/AnswersQuestions.php | 4 ++-- .../OpenAi/OpenAiClassificationGateway.php | 4 ++-- .../src/Responses/ClassificationResponse.php | 3 +++ .../AgentUserInteractionProtocol.php | 10 ++++---- src/docs/ai-sdk.md | 4 ++++ ...AgentUserInteractionProtocolStreamTest.php | 23 +++++++++++++++++++ .../Providers/OpenAi/ClassificationTest.php | 11 +++++++++ .../OpenRouter/ClassificationTest.php | 9 ++++++++ .../Ai/Feature/ResponseSerializationTest.php | 15 ++++++++++++ 9 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/ai/src/Gateway/Concerns/AnswersQuestions.php b/src/ai/src/Gateway/Concerns/AnswersQuestions.php index d1e819859..1595018b4 100644 --- a/src/ai/src/Gateway/Concerns/AnswersQuestions.php +++ b/src/ai/src/Gateway/Concerns/AnswersQuestions.php @@ -65,14 +65,14 @@ public function classify( } } - return new ClassificationResponse( + return (new ClassificationResponse( $answers, new TextUsage( inputTokens: $data['usage']['input_tokens'] ?? 0, outputTokens: $data['usage']['output_tokens'] ?? 0, ), new Meta($provider->name(), $this->answeringModel($data, $model)), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php b/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php index 20da95169..4748b47a2 100644 --- a/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php +++ b/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php @@ -73,14 +73,14 @@ public function classify( } } - return new ClassificationResponse( + return (new ClassificationResponse( $answers, new TextUsage( inputTokens: $data['usage']['input_tokens'] ?? 0, outputTokens: $data['usage']['output_tokens'] ?? 0, ), new Meta($provider->name(), $data['model'] ?? $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Responses/ClassificationResponse.php b/src/ai/src/Responses/ClassificationResponse.php index 3cc7701ad..1f9d4da09 100644 --- a/src/ai/src/Responses/ClassificationResponse.php +++ b/src/ai/src/Responses/ClassificationResponse.php @@ -6,6 +6,7 @@ use ArrayAccess; use Countable; +use Hypervel\Ai\Responses\Concerns\HasRawResponse; use Hypervel\Ai\Responses\Data\Answer; use Hypervel\Ai\Responses\Data\Meta; use Hypervel\Ai\Responses\Data\TextUsage; @@ -19,6 +20,8 @@ class ClassificationResponse implements Arrayable, ArrayAccess, Countable, IteratorAggregate, JsonSerializable { + use HasRawResponse; + /** * Create a new classification response instance. * diff --git a/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php b/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php index 6bcafe48e..6f84e9107 100644 --- a/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php +++ b/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php @@ -234,16 +234,18 @@ protected function stepFinishedPart(): array */ protected function runFinishedPart(array $attributes = []): array { - $assistantMessageId = $this->response?->assistantMessageId; - $userMessageId = $this->response?->userMessageId; + $messageIds = array_filter([ + 'messageId' => $this->response?->assistantMessageId, + 'userMessageId' => $this->response?->userMessageId, + ]); return [ 'type' => 'RUN_FINISHED', 'threadId' => $this->threadId, 'runId' => $this->runId, - ...($assistantMessageId ? ['messageId' => $assistantMessageId] : []), - ...($userMessageId ? ['userMessageId' => $userMessageId] : []), + ...$messageIds, ...$attributes, + ...($messageIds ? ['metadata' => [...($attributes['metadata'] ?? []), ...$messageIds]] : []), ]; } diff --git a/src/docs/ai-sdk.md b/src/docs/ai-sdk.md index 9ac2ad957..3122d4308 100644 --- a/src/docs/ai-sdk.md +++ b/src/docs/ai-sdk.md @@ -536,6 +536,8 @@ foreach ($response->steps as $step) { } ``` +[Classification](#classification) responses also expose the provider's raw HTTP response through their `raw` property. + > **Note:** The `raw` property is `null` when streaming a response, when using the Bedrock provider, and on faked responses unless one is provided explicitly via `withRawResponse`. @@ -1208,6 +1210,8 @@ The `threadId` and `runId` arguments are optional and default to the conversatio ); ``` +When the conversation is stored, the final `RUN_FINISHED` event includes the stored assistant and user message IDs as `messageId` and `userMessageId`, and repeats them in its `metadata`. + To use a protocol that the AI SDK does not implement, you may pass your own `Hypervel\Ai\Streaming\Protocols\StreamProtocol` implementation to the `usingProtocol` method: ```php diff --git a/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php b/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php index b81e32497..ebc27a635 100644 --- a/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php +++ b/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php @@ -243,6 +243,29 @@ public function testAStoredRunReportsTheRowItWroteForThePrompt(): void $this->assertNotSame($response->assistantMessageId, $response->userMessageId); } + public function testAStoredRunAlsoReportsItsMessageIdsInTheRunFinishedMetadata(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../../src/ai/database/migrations'); + RememberingAssistantAgent::fake(['Fake response']); + + $user = new class { + public int $id = 1; + }; + + $response = (new RememberingAssistantAgent)->forUser($user)->stream('Hello'); + + $events = agUiEvents($response->usingProtocol(new AgentUserInteractionProtocol)->toResponse(request())); + $finished = end($events); + + $this->assertSame([ + 'finishReason' => 'stop', + 'messageId' => $response->assistantMessageId, + 'userMessageId' => $response->userMessageId, + ], $finished['metadata']); + $this->assertSame($response->assistantMessageId, $finished['messageId']); + $this->assertSame($response->userMessageId, $finished['userMessageId']); + } + public function testAStreamThatPersistsNothingOmitsTheMessageId(): void { $events = agUiProtocolEvents([ diff --git a/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php b/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php index f05059d4e..541b45377 100644 --- a/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php +++ b/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php @@ -12,6 +12,7 @@ use Hypervel\Ai\Files\Image; use Hypervel\Contracts\Foundation\Application; use Hypervel\Http\Client\Request; +use Hypervel\Http\Client\Response; use Hypervel\Http\UploadedFile; use Hypervel\Support\Facades\Http; use Hypervel\Support\Facades\Storage; @@ -67,6 +68,16 @@ public function testClassificationPostsQuestionsToTheDecisionsEndpointInTheOpenA }); } + public function testTheRawDecisionsResponseIsExposedOnTheClassificationResponse(): void + { + Http::fake(['*' => Http::response($this->fakeOpenAiDecisionsResponse())]); + + $response = Classification::of('text')->question('is_urgent', new Boolean('Is this urgent?'))->classify(provider: 'openai'); + + $this->assertInstanceOf(Response::class, $response->raw); + $this->assertSame('gpt-6-luna', $response->raw->json('model')); + } + public function testStructuredStateIsSentAsJsonText(): void { Http::fake(['*' => Http::response($this->fakeOpenAiDecisionsResponse())]); diff --git a/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php b/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php index 23e02410b..6ed4d1352 100644 --- a/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php +++ b/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php @@ -129,6 +129,15 @@ public function testDecisionsResponseIsParsedIntoTypedAnswers(): void $this->assertSame('typesafe/jev-1.13', $response->meta->model); } + public function testTheRawDecisionsResponseIsExposedOnTheClassificationResponse(): void + { + Http::fake(['*' => Http::response($this->fakeDecisionsResponse())]); + + $response = Classification::of('text')->question('is_urgent', new Boolean('Urgent?'))->classify(provider: 'openrouter'); + + $this->assertSame(0.000013, $response->raw->json('usage.cost')); + } + public function testClassificationUsesTheDefaultDecisionsModelWhenNoneIsSpecified(): void { Http::fake(['*' => Http::response($this->fakeDecisionsResponse())]); diff --git a/tests/Ai/Feature/ResponseSerializationTest.php b/tests/Ai/Feature/ResponseSerializationTest.php index 1c2dc1258..a6b2cd0a7 100644 --- a/tests/Ai/Feature/ResponseSerializationTest.php +++ b/tests/Ai/Feature/ResponseSerializationTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Ai\Feature; use GuzzleHttp\Psr7\Response as Psr7Response; +use Hypervel\Ai\Responses\ClassificationResponse; +use Hypervel\Ai\Responses\Data\BooleanAnswer; use Hypervel\Ai\Responses\Data\FinishReason; use Hypervel\Ai\Responses\Data\Meta; use Hypervel\Ai\Responses\Data\TextUsage; @@ -40,4 +42,17 @@ public function testSerializationPreservesPrivatePropertiesDeclaredOnStepSubclas $this->assertSame('Hello', $restored->text); $this->assertNull($restored->raw); } + + public function testSerializationPreservesClassificationAnswersAndDropsTheRawResponse(): void + { + $response = (new ClassificationResponse(['is_urgent' => new BooleanAnswer(0.9)], new TextUsage(1, 2), new Meta('openai', 'gpt-6-luna'))) + ->withRawResponse(new Response(new Psr7Response(200, [], '{}'))); + + $restored = unserialize(serialize($response)); + + $this->assertSame(0.9, $restored->answer('is_urgent')->probability); + $this->assertSame(2, $restored->usage->outputTokens); + $this->assertSame('gpt-6-luna', $restored->meta->model); + $this->assertNull($restored->raw); + } } From 4caa7a0eb035316abd1036c01a4205f4a5c152f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:12:41 +0000 Subject: [PATCH 33/38] Update the default Mistral models Mistral's default models were out of date. The text and smartest defaults move to mistral-large-4-0, audio to voxtral-mini-tts-3-0 and transcription to voxtral-mini-transcribe-3-0. The cheapest default is unchanged. Port of laravel/ai#1173 at c8495608a1. The model names in the Mistral AudioTest and TranscriptionTest are updated, and both pass. No config or docs reference these defaults. --- src/ai/src/Providers/MistralProvider.php | 8 +++--- .../Feature/Providers/Mistral/AudioTest.php | 26 +++++++++---------- .../Providers/Mistral/TranscriptionTest.php | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/ai/src/Providers/MistralProvider.php b/src/ai/src/Providers/MistralProvider.php index 050559a8f..2645315e0 100644 --- a/src/ai/src/Providers/MistralProvider.php +++ b/src/ai/src/Providers/MistralProvider.php @@ -90,7 +90,7 @@ public function transcriptionGateway(): TranscriptionGateway */ public function defaultTextModel(): string { - return $this->config['models']['text']['default'] ?? 'mistral-large-2512'; + return $this->config['models']['text']['default'] ?? 'mistral-large-4-0'; } /** @@ -106,7 +106,7 @@ public function cheapestTextModel(): string */ public function smartestTextModel(): string { - return $this->config['models']['text']['smartest'] ?? 'mistral-medium-3-5'; + return $this->config['models']['text']['smartest'] ?? 'mistral-large-4-0'; } /** @@ -114,7 +114,7 @@ public function smartestTextModel(): string */ public function defaultAudioModel(): string { - return $this->config['models']['audio']['default'] ?? 'voxtral-mini-tts-2603'; + return $this->config['models']['audio']['default'] ?? 'voxtral-mini-tts-3-0'; } /** @@ -122,7 +122,7 @@ public function defaultAudioModel(): string */ public function defaultTranscriptionModel(): string { - return $this->config['models']['transcription']['default'] ?? 'voxtral-mini-2602'; + return $this->config['models']['transcription']['default'] ?? 'voxtral-mini-transcribe-3-0'; } /** diff --git a/tests/Ai/Feature/Providers/Mistral/AudioTest.php b/tests/Ai/Feature/Providers/Mistral/AudioTest.php index b52efa441..4f4352a64 100644 --- a/tests/Ai/Feature/Providers/Mistral/AudioTest.php +++ b/tests/Ai/Feature/Providers/Mistral/AudioTest.php @@ -30,13 +30,13 @@ public function testAudioRequestIncludesModelInputVoiceIdAndResolvesDefaultFemal { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello world')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello world')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $request->url() === 'https://api.mistral.ai/v1/audio/speech' - && $body['model'] === 'voxtral-mini-tts-2603' + && $body['model'] === 'voxtral-mini-tts-3-0' && $body['input'] === 'Hello world' && $body['voice_id'] === 'gb_jane_neutral' && $body['response_format'] === 'mp3'; @@ -47,7 +47,7 @@ public function testAudioRequestResolvesDefaultMaleVoiceAlias(): void { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->male()->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->male()->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice_id'] === 'en_paul_neutral'); } @@ -56,7 +56,7 @@ public function testAudioRequestPassesCustomVoiceIdThroughUnchanged(): void { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->voice('my-custom-voice-id')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->voice('my-custom-voice-id')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice_id'] === 'my-custom-voice-id'); } @@ -65,7 +65,7 @@ public function testAudioRequestOmitsInstructionsSinceTheMistralApiDoesNotAccept { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->instructions('Speak warmly')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->instructions('Speak warmly')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => ! str_contains($request->body(), 'Speak warmly') && ! array_key_exists('instructions', json_decode($request->body(), true))); @@ -75,7 +75,7 @@ public function testAudioRequestSendsBearerToken(): void { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => $request->hasHeader('Authorization', 'Bearer test-key')); } @@ -84,12 +84,12 @@ public function testAudioResponsePassesBase64AudioDataThroughWithAudioMpegMimeTy { Http::fake(['*' => $this->fakeMistralAudioResponse(base64_encode('raw-audio-bytes'))]); - $response = Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + $response = Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); $this->assertSame(base64_encode('raw-audio-bytes'), $response->audio); $this->assertSame('audio/mpeg', $response->mimeType()); $this->assertSame('mistral', $response->meta->provider); - $this->assertSame('voxtral-mini-tts-2603', $response->meta->model); + $this->assertSame('voxtral-mini-tts-3-0', $response->meta->model); } public function testAudioUsesDefaultModelWhenNoneSpecified(): void @@ -98,7 +98,7 @@ public function testAudioUsesDefaultModelWhenNoneSpecified(): void Audio::of('Hello')->generate(provider: 'mistral'); - Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'voxtral-mini-tts-2603'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'voxtral-mini-tts-3-0'); } public function testAudioThrowsWhenTheResponseContainsNoAudioData(): void @@ -107,7 +107,7 @@ public function testAudioThrowsWhenTheResponseContainsNoAudioData(): void $this->expectExceptionMessage('No audio data received from Mistral API.'); Http::fake(['*' => Http::response(['audio_data' => null])]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioThrowsWhenTheApiReturnsAnError(): void @@ -115,7 +115,7 @@ public function testAudioThrowsWhenTheApiReturnsAnError(): void $this->expectException(RequestException::class); Http::fake(['*' => Http::response(['message' => 'unauthorized'], 401)]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioRateLimitResponseThrowsRateLimitedException(): void @@ -123,7 +123,7 @@ public function testAudioRateLimitResponseThrowsRateLimitedException(): void $this->expectException(RateLimitedException::class); Http::fake(['api.mistral.ai/*' => Http::response(['message' => 'rate limit exceeded'], 429)]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioOverloadedResponseThrowsProviderOverloadedException(): void @@ -131,7 +131,7 @@ public function testAudioOverloadedResponseThrowsProviderOverloadedException(): $this->expectException(ProviderOverloadedException::class); Http::fake(['api.mistral.ai/*' => Http::response(['message' => 'service unavailable'], 503)]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioGatewayIsSharedWithTheOtherMistralGateways(): void diff --git a/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php b/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php index dffb1a48d..63e60f892 100644 --- a/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php +++ b/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php @@ -51,7 +51,7 @@ public function testTranscriptionIncludesModelInRequest(): void Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'mistral'); - Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'voxtral-mini-2602')); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'voxtral-mini-transcribe-3-0')); } public function testTranscriptionSendsLanguageWhenProvided(): void From 8379602eeb24ecb0eb7b3929a609d5db1d1ed59a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:27:59 +0000 Subject: [PATCH 34/38] Wait only for missing MariaDB domains when checking a commit ID hasCommitId() falls back to @@gtid_current_pos because MASTER_GTID_WAIT only sees replicated GTIDs, not the server's own writes. With a wait, it still passed the whole commit ID to MASTER_GTID_WAIT. When one domain held this server's own write and another domain was still replicating, the wait could never succeed for the local domain, so the check timed out and returned false even after the replicated domain arrived. It now waits only for the domains @@gtid_current_pos has not reached. Follow-up to "Add commit IDs for read-after-write checks on read replicas". DatabaseMariaDbConnectionTest covers a mixed local and replicated commit ID; it fails on the previous code and passes. The MariaDB integration connection tests pass. --- src/database/src/MariaDbConnection.php | 8 ++++-- .../DatabaseMariaDbConnectionTest.php | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/Database/DatabaseMariaDbConnectionTest.php diff --git a/src/database/src/MariaDbConnection.php b/src/database/src/MariaDbConnection.php index caf1ffff1..186779576 100755 --- a/src/database/src/MariaDbConnection.php +++ b/src/database/src/MariaDbConnection.php @@ -93,7 +93,8 @@ public function hasCommitId(string $value, int $wait = 0): bool return true; } - $expected = array_column($this->parseCommitIds([$value]), 'sequence', 'domain'); + $commitIds = $this->parseCommitIds([$value]); + $expected = array_column($commitIds, 'sequence', 'domain'); foreach ($this->parseCommitIds([$check->gtid_current_pos]) as $gtid) { if (isset($expected[$gtid['domain']]) && $gtid['sequence']->isGreaterThanOrEqualTo($expected[$gtid['domain']])) { @@ -105,7 +106,10 @@ public function hasCommitId(string $value, int $wait = 0): bool return $expected === []; } - return (int) $this->scalar('select master_gtid_wait(?, ?)', [$value, $wait]) === 0; + // Wait only for the missing domains, because MASTER_GTID_WAIT would never see the server's own writes. + $missing = array_filter($commitIds, fn (array $gtid): bool => isset($expected[$gtid['domain']])); + + return (int) $this->scalar('select master_gtid_wait(?, ?)', [implode(',', array_keys($missing)), $wait]) === 0; } /** diff --git a/tests/Database/DatabaseMariaDbConnectionTest.php b/tests/Database/DatabaseMariaDbConnectionTest.php new file mode 100644 index 000000000..986692634 --- /dev/null +++ b/tests/Database/DatabaseMariaDbConnectionTest.php @@ -0,0 +1,27 @@ +makePartial(); + $connection->expects('selectOne') + ->with('select master_gtid_wait(?, 0) as master_gtid_wait, @@gtid_current_pos as gtid_current_pos', ['0-1-5,1-2-9']) + ->andReturn((object) ['master_gtid_wait' => -1, 'gtid_current_pos' => '0-1-5,1-2-7']); + + // Domain 0 holds this server's own write, which MASTER_GTID_WAIT never sees. + $connection->expects('scalar') + ->with('select master_gtid_wait(?, ?)', ['1-2-9', 5]) + ->andReturn(0); + + $this->assertTrue($connection->hasCommitId('0-1-5,1-2-9', 5)); + } +} From 8bccadaa3610af1965226f195029cc77d5482d05 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:27:59 +0000 Subject: [PATCH 35/38] Report the task's own runtime when a scheduled task fails after finishing ScheduledTaskFailed follows ScheduledTaskFinished when a task exits with a failing code or a Finished listener throws. The failure measured its runtime again at that point, so it included the time the Finished listeners took and disagreed with the runtime ScheduledTaskFinished had just reported. Laravel behaves the same way. runEvent() now records the runtime once the task returns and reuses it for both events. A task that throws before finishing still measures its runtime when it fails. Follow-up to "Dispatch ScheduledTaskSkipped for tasks run elsewhere and add Schedule::whenTaskRunsLongerThan". testFailureAfterTheTaskFinishesReportsTheTaskRuntime fails on the previous code and passes. The console scheduling unit and integration tests pass. --- .../src/Commands/ScheduleRunCommand.php | 11 ++++---- .../Scheduling/ScheduleRunCommandTest.php | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 5000105b8..2a2d0af2d 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -490,16 +490,15 @@ protected function runEvent(Event $event): void $failed = false; $skippedBecauseOverlapping = false; + $runtime = null; try { $event->run($this->hypervel); + $runtime = round(microtime(true) - $start, 2); $skippedBecauseOverlapping = $event->wasSkippedDueToOverlapping(); if ($this->dispatcher->hasListeners(ScheduledTaskFinished::class)) { - $this->dispatcher->dispatch(new ScheduledTaskFinished( - $event, - round(microtime(true) - $start, 2) - )); + $this->dispatcher->dispatch(new ScheduledTaskFinished($event, $runtime)); } $this->eventsRan = true; @@ -526,10 +525,12 @@ protected function runEvent(Event $event): void CoroutineContext::set(self::TASK_FAILED_CONTEXT_KEY, true); if ($this->dispatcher->hasListeners(ScheduledTaskFailed::class)) { + // Unlike upstream, a run that fails after the task finished (a failing exit code or a throwing + // Finished listener) reports the task's own runtime, without the time its Finished listeners took. $this->dispatcher->dispatch(new ScheduledTaskFailed( $event, $e, - round(microtime(true) - $start, 2) + $runtime ?? round(microtime(true) - $start, 2) )); } diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php index 8dbae28f7..dbe13f8dc 100644 --- a/tests/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php @@ -678,6 +678,34 @@ public function testNonZeroExitDispatchesAndReportsFailure(): void $this->assertStringContainsString('Failed', $output->fetch()); } + public function testFailureAfterTheTaskFinishesReportsTheTaskRuntime(): void + { + $finishedEvent = null; + $failedEvent = null; + $dispatcher = $this->app->make(Dispatcher::class); + $dispatcher->listen(ScheduledTaskFinished::class, function (ScheduledTaskFinished $event) use (&$finishedEvent): void { + $finishedEvent = $event; + + // A slow listener, such as one sending an alert, is not part of the task's runtime. + usleep(50_000); + }); + $dispatcher->listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $event) use (&$failedEvent): void { + $failedEvent = $event; + }); + $this->dispatcher = $dispatcher; + + $this->handler->shouldReceive('report')->once(); + + $command = $this->makeCommand(); + $this->captureOutput($command); + + $this->invokeRunEvent($command, new ScheduleRunExitCodeEvent(m::mock(EventMutex::class), 1, 'test:slow-listener')); + + $this->assertInstanceOf(ScheduledTaskFinished::class, $finishedEvent); + $this->assertInstanceOf(ScheduledTaskFailed::class, $failedEvent); + $this->assertSame($finishedEvent->runtime, $failedEvent->runtime); + } + public function testDurationHandlerReportsAFailingCommandOnce(): void { $this->dispatcher = $this->app->make(Dispatcher::class); From 7196188c070c312b555f84d4cf6fcf95653a56f7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:27:59 +0000 Subject: [PATCH 36/38] Correct the suppressed-error note on HandleExceptions::handleError() The docblock said every error excluded by error_reporting(), including those silenced with @, is passed on to PHP's standard handler. Deprecations are handled by the framework even when silenced, so the note now says that and limits the pass-through to other errors. Follow-up to "Preserve error_get_last() for suppressed errors". Documentation only. --- src/foundation/src/Bootstrap/HandleExceptions.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index c9e3887a6..45563a0fe 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -108,8 +108,9 @@ public function bootstrap(Application $app): void /** * Report PHP deprecations, or convert PHP errors to ErrorException instances. * - * Errors excluded by error_reporting(), such as those silenced with @, are passed - * on to PHP's standard handler so error_get_last() still reports them. + * Deprecations are handled here even when silenced with @. Other errors excluded by + * error_reporting() are passed on to PHP's standard handler so error_get_last() still + * reports them. * * @throws ErrorException */ From cae1c808d0c08f33f61955c3e8c15ec995446ff2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:28:11 +0000 Subject: [PATCH 37/38] Support stdClass targets in data_get, data_set and data_forget wildcards The docs say wildcards may target any key of an array or object, and named segments already work on objects. The wildcard branches only accepted arrays and other iterables, so with stdClass maps such as json_decode() output: - data_get() returned the default. Since assertJsonIsArray() and assertJsonIsObject() now read objects as stdClass, this also broke wildcard selections such as assertJsonIsArray('data.*') on a JSON object. - data_set() replaced the object with an empty array, losing its data. - data_forget() did nothing. The three wildcard branches now also accept stdClass. The check is limited to stdClass so other objects' internal properties are not iterated. data_forget() also removes a named object property whose value is null; it previously checked isset() and left it behind. A path that continues past a null or missing property still leaves it in place. Laravel has the same gaps. Follow-up to "Treat empty and numeric-key JSON objects as objects in type assertions". SupportHelpersTest extends its wildcard get, set and remove tests with stdClass maps and a null property, and TestResponseTest covers the wildcard selection. All fail on the previous helpers and pass. The Support, Testing and Mcp suites pass. --- src/collections/src/helpers.php | 12 ++++++++---- tests/Support/SupportHelpersTest.php | 28 ++++++++++++++++++++++++++++ tests/Testing/TestResponseTest.php | 5 +++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/collections/src/helpers.php b/src/collections/src/helpers.php index ab113dadb..fdccb44e8 100644 --- a/src/collections/src/helpers.php +++ b/src/collections/src/helpers.php @@ -91,9 +91,10 @@ function data_get($target, $key, $default = null) } if ($segment === '*') { + // Unlike upstream, wildcards also select stdClass properties, since they may target any key of an array or object. if ($target instanceof Collection) { $target = $target->all(); - } elseif (! is_iterable($target)) { + } elseif (! is_iterable($target) && ! $target instanceof stdClass) { return value($default); } @@ -143,7 +144,8 @@ function data_set(&$target, $key, $value, $overwrite = true) $segments = is_array($key) ? $key : explode('.', (string) $key); if (($segment = array_shift($segments)) === '*') { - if (! Arr::accessible($target)) { + // Unlike upstream, wildcards also set stdClass properties instead of replacing the object with an array. + if (! Arr::accessible($target) && ! $target instanceof stdClass) { $target = []; } @@ -202,7 +204,8 @@ function data_forget(&$target, $key) { $segments = is_array($key) ? $key : explode('.', (string) $key); - if (($segment = array_shift($segments)) === '*' && Arr::accessible($target)) { + // Unlike upstream, wildcards also remove values from stdClass properties. + if (($segment = array_shift($segments)) === '*' && (Arr::accessible($target) || $target instanceof stdClass)) { if ($segments) { foreach ($target as &$inner) { data_forget($inner, $segments); @@ -217,7 +220,8 @@ function data_forget(&$target, $key) } elseif (is_object($target)) { if ($segments && isset($target->{$segment})) { data_forget($target->{$segment}, $segments); - } elseif (isset($target->{$segment})) { + } elseif (! $segments) { + // Unlike upstream, this does not check isset(), so null properties are removed too. unset($target->{$segment}); } } diff --git a/tests/Support/SupportHelpersTest.php b/tests/Support/SupportHelpersTest.php index c25613444..ad91874f3 100644 --- a/tests/Support/SupportHelpersTest.php +++ b/tests/Support/SupportHelpersTest.php @@ -502,6 +502,12 @@ public function testDataGetStar(): void $data = collect(['foo' => 'bar']); $this->assertEquals(['bar'], data_get($data, '*')); + + $taylor = (object) ['name' => 'Taylor']; + $abigail = (object) ['name' => 'Abigail']; + $data = (object) ['first' => $taylor, 'second' => $abigail]; + $this->assertSame([$taylor, $abigail], data_get($data, '*')); + $this->assertSame(['Taylor', 'Abigail'], data_get($data, '*.name')); } public function testDataGetNullKey(): void @@ -651,6 +657,11 @@ public function testDataSetWithStar(): void ['foo' => [], 'bar' => ['overwritten', 'overwritten']], data_set($data, 'bar.*', 'overwritten') ); + + $data = (object) ['one' => (object) ['price' => 1], 'two' => (object) ['price' => 2]]; + data_set($data, '*.price', 0); + + $this->assertEquals((object) ['one' => (object) ['price' => 0], 'two' => (object) ['price' => 0]], $data); } public function testDataSetWithDoubleStar(): void @@ -714,6 +725,15 @@ public function testDataRemove(): void ['one' => ['two' => ['four' => ['five']]]], data_forget($data, 'one.two.three') ); + + $data = (object) ['price' => null, 'name' => 'Desk']; + data_forget($data, 'price.amount'); + + $this->assertEquals((object) ['price' => null, 'name' => 'Desk'], $data); + + data_forget($data, 'price'); + + $this->assertEquals((object) ['name' => 'Desk'], $data); } public function testDataRemoveWithStar(): void @@ -740,6 +760,14 @@ public function testDataRemoveWithStar(): void ], data_forget($data, 'article.comments.*.name') ); + + $data = (object) [ + 'one' => (object) ['price' => 1, 'name' => 'Desk'], + 'two' => (object) ['price' => null, 'name' => 'Chair'], + ]; + data_forget($data, '*.price'); + + $this->assertEquals((object) ['one' => (object) ['name' => 'Desk'], 'two' => (object) ['name' => 'Chair']], $data); } public function testDataRemoveWithDoubleStar(): void diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index b2850449d..6c7bec992 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -2623,6 +2623,11 @@ public function testAssertJsonIsNotArrayForObjectsThatDecodeToLists(string $json TestResponse::fromBaseResponse(new Response($json))->assertJsonIsArray($key); } + public function testAssertJsonIsArrayForWildcardSelectionOfAnObject(): void + { + TestResponse::fromBaseResponse(new Response('{"data":{"first":[]}}'))->assertJsonIsArray('data.*'); + } + public function testAssertJsonIsObject(): void { $responseObject = TestResponse::fromBaseResponse(new Response([ From 21c968eac261772716ffeac967978185b030114f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:28:11 +0000 Subject: [PATCH 38/38] Stop wildcard validation from changing objects in the input ValidationData::initializeAttributeOnData() copies the attribute's data and fills missing wildcard leaves with null through data_set(). The copy shares objects with the caller's input, so data_set() wrote null into them. A validator with a compilable wildcard rule such as 'items.*.options.*.price' => Rule::forEach(...) cleared the price of any object under that path, even though objects get no expanded rules. Laravel does the same for objects inside lists. With the stdClass wildcard support in data_set(), keyed objects were affected too. The initializer now expands the attribute with the existing array-only wildcard walk and fills each key with Arr::set() in the copy. Validated missing leaves still get null placeholders. The data passed to forEach callbacks no longer contains empty arrays invented for missing steps or scalars before a wildcard, and partial-wildcard segments expand as the rule parser expands them. testForEachLeavesObjectsInTheInputUnchanged covers an object in a list and a keyed object; it fails on the previous initializer and passes. The Validation and Foundation suites pass. --- src/validation/src/ValidationData.php | 7 ++++++- tests/Validation/ValidationForEachTest.php | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/validation/src/ValidationData.php b/src/validation/src/ValidationData.php index b16c98251..59f622b93 100644 --- a/src/validation/src/ValidationData.php +++ b/src/validation/src/ValidationData.php @@ -155,7 +155,12 @@ protected static function initializeAttributeOnData(string $attribute, array $ma return $data; } - return data_set($data, $attribute, null, true); + // Unlike upstream, fill only the copied arrays. data_set() would write through objects shared with the caller's input. + foreach (static::expandWildcardKeys($attribute, $data) as $key) { + Arr::set($data, $key, null); + } + + return $data; } /** diff --git a/tests/Validation/ValidationForEachTest.php b/tests/Validation/ValidationForEachTest.php index e7b2e3e4c..cfb67270f 100644 --- a/tests/Validation/ValidationForEachTest.php +++ b/tests/Validation/ValidationForEachTest.php @@ -371,6 +371,23 @@ public function testForEachRulesAreAppliedToEmptyKeys(): void ], $v->getMessageBag()->toArray()); } + public function testForEachLeavesObjectsInTheInputUnchanged(): void + { + $listed = (object) ['price' => 2]; + $keyed = (object) ['one' => (object) ['price' => 3]]; + + $v = new Validator($this->getArrayTranslator(), [ + 'items' => [ + ['options' => [['price' => 1], $listed]], + ['options' => $keyed], + ], + ], ['items.*.options.*.price' => Rule::forEach(fn (): array => ['integer'])]); + + $this->assertSame(['items.0.options.0.price', 'items.0.options.1.price'], array_keys($v->getRules())); + $this->assertSame(2, $listed->price); + $this->assertSame(3, $keyed->one->price); + } + public function getArrayTranslator() { return new Translator(