Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,7 @@ An exceptionally large shared work unit may receive its own linked detail plan w

This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md).

- **Active package or work unit:** none — Reverb is complete and the next package is not selected.
- **Active package or work unit:** none — Scout is complete and the next package is not selected.
- **Ledger entries required for the active work:** none.
- **Pending revalidation carried into the active work:** none.

Expand Down Expand Up @@ -1076,6 +1076,8 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `http-03` | `http`, `foundation` | `contracts` and `foundation` (revalidation complete); later full `http` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `http-03` |
| `auth-02` | `auth` | `foundation` (revalidation complete); later full `auth` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `auth-02` |
| `database-03` | `database` | `foundation` and `database` (revalidation complete); later full `testbench` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `database-03` |
| `foundation-17` | `foundation` | `foundation` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `foundation-17` |
| `foundation-18` | `foundation` | `foundation` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `foundation-18` |
| `database-04` | `database` | `console` and `database` (revalidation complete) | `Complete Console command, scheduling, and generator lifecycles`; finding `database-04` |
| `reverb-04` | `reverb` | `reverb` (revalidation complete) | `Complete Console command, scheduling, and generator lifecycles`; finding `reverb-04` |
| `watcher-10` | `support` | `watcher`, `foundation`, and `horizon` (revalidation complete) | `Make Watcher drivers and managed processes lifecycle-safe`; finding `watcher-10` |
Expand Down Expand Up @@ -1149,6 +1151,9 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `http-06` | `http` | `http` (targeted correction complete); later full `http` audit | `Harden Eloquent identity and partial-projection safety`; finding `http-06` |
| `testing-01` | `testing` | `testing` (targeted correction complete); later full `testing` audit | `Harden Eloquent identity and partial-projection safety`; finding `testing-01` |
| `testing-02` | `testing` | `testing` (targeted correction complete); later full `testing` audit | `Harden Eloquent identity and partial-projection safety`; finding `testing-02` |
| `database-21` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-21` |
| `database-22` | `database` | `database` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-22` |
| `database-23` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-23` |

## Package checklist

Expand Down Expand Up @@ -1245,7 +1250,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
- [ ] `passkeys`
- [ ] `permission`
- [ ] `jwt`
- [ ] `scout`
- [x] `scout`
- [ ] `telescope`
- [ ] `sentry`
- [ ] `inertia`
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

63 changes: 55 additions & 8 deletions src/boost/docs/scout.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [Driver Prerequisites](#driver-prerequisites)
- [Configuration](#configuration)
- [Configuring Searchable Data](#configuring-searchable-data)
- [Customizing the Scout Builder](#customizing-the-scout-builder)
- [Database, Collection, and Null Engines](#database-and-collection-engines)
- [Database Engine](#database-engine)
- [Collection Engine](#collection-engine)
Expand Down Expand Up @@ -91,20 +92,32 @@ Of course, if you customize the connection and queue that Scout jobs utilize, yo
php artisan queue:work redis --queue=scout
```

Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, `SCOUT_QUEUE_NAME`, and `SCOUT_QUEUE_AFTER_COMMIT` environment variables.
Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, and `SCOUT_QUEUE_NAME` environment variables.

#### Transaction-Safe Dispatch

If your indexing happens inside a database transaction, set `after_commit` so the queued job is only dispatched once the transaction commits:

```php
'queue' => [
'enabled' => true,
'after_commit' => true,
],
'after_commit' => true,
```

This stops the queue worker from picking up an indexing job for a record that no longer exists because the transaction was rolled back.
This option may also be set using the `SCOUT_AFTER_COMMIT` environment variable. It stops the queue worker from picking up an indexing job for a record that no longer exists because the transaction was rolled back.

#### Unique Jobs

In write-heavy applications, you may prevent Scout from queueing duplicate jobs for the same model records by registering the unique job classes in a service provider:

```php
use Hypervel\Scout\Jobs\MakeSearchableUniquely;
use Hypervel\Scout\Jobs\RemoveFromSearchUniquely;
use Hypervel\Scout\Scout;

Scout::makeSearchableUsing(MakeSearchableUniquely::class);
Scout::removeFromSearchUsing(RemoveFromSearchUniquely::class);
```

These jobs use [unique job locks](/docs/{{version}}/queues#unique-jobs) while a matching indexing operation is waiting to be processed.

#### When to Use Each Mode

Expand Down Expand Up @@ -259,6 +272,19 @@ class User extends Model implements SearchableInterface
}
```

<a name="customizing-the-scout-builder"></a>
### Customizing the Scout Builder

To use a custom Scout query builder for a model, define the `$scoutBuilder` property on the model:

```php
use App\Scout\CustomScoutBuilder;

protected static string $scoutBuilder = CustomScoutBuilder::class;
```

The custom class should extend `Hypervel\Scout\Builder` and will be resolved through the service container for each search.

<a name="database-and-collection-engines"></a>
## Database, Collection, and Null Engines

Expand Down Expand Up @@ -421,6 +447,9 @@ class User extends Model implements SearchableInterface
> [!NOTE]
> The `getScoutKey` and `getScoutKeyName` methods have no effect when using the database engine, which always uses the model's primary key.

> [!WARNING]
> Meilisearch document identifiers may contain only letters, numbers, hyphens, and underscores, so custom Scout keys must follow that format when using Meilisearch.

<a name="algolia-configuration"></a>
### Algolia

Expand Down Expand Up @@ -583,7 +612,7 @@ public function toSearchableArray(): array
}
```

You may define your Typesense collection schema in your application's `config/scout.php` file under `typesense.model-settings`, or directly on the model by defining a `typesenseCollectionSchema` method. A collection schema describes the data types of each field that is searchable via Typesense. For more information on all available schema options, please consult the [Typesense documentation](https://typesense.org/docs/latest/api/collections.html#schema-parameters).
You may define your Typesense collection schema in your application's `config/scout.php` file under `typesense.model-settings`, or directly on the model by defining a `typesenseCollectionSchema` method. A collection schema describes the data types of each field that is searchable via Typesense. Scout derives the collection name from the model's index name, so a `name` key in the schema is ignored. For more information on all available schema options, please consult the [Typesense documentation](https://typesense.org/docs/latest/api/collections.html#schema-parameters).

If you need to change your Typesense collection's schema after it has been defined, you may either run `scout:flush` and `scout:import`, which will delete all existing indexed data and recreate the schema. Or, you may use Typesense's API to modify the collection's schema without removing any indexed data.

Expand Down Expand Up @@ -650,6 +679,12 @@ You may optionally control the chunk size, the ID range, and the destination que
php artisan scout:queue-import "App\Models\Post" --chunk=500 --min=1000 --max=50000 --queue=imports
```

Imports run in ascending key order by default. To process the range from the highest key to the lowest, pass `--order=desc`:

```shell
php artisan scout:queue-import "App\Models\Post" --order=desc
```

The `--min` and `--max` options are useful for resuming a partial import, or for running several imports in parallel against different ranges. There is no `--fresh` option — to rebuild the index from scratch, run `scout:flush` first.

The `flush` command may be used to remove all of a model's records from your search indexes:
Expand Down Expand Up @@ -892,6 +927,18 @@ public function boot(): void

Custom job classes should extend the corresponding default job and override only the methods you need to change. These overrides only affect queue-mode indexing — in the default mode, indexing runs inline via `Coroutine::defer` and does not pass through a job class.

You may configure the attempts, retry delay, and maximum unhandled exceptions for the default jobs in `config/scout.php`:

```php
'jobs' => [
'tries' => 3,
'backoff' => [1, 5, 10],
'max_exceptions' => 2,
],
```

Values defined by a custom job class take precedence over these settings.

<a name="searching"></a>
## Searching

Expand Down Expand Up @@ -942,7 +989,7 @@ use App\Models\Order;
$orders = Order::search('Star Trek')->where('user_id', 1)->get();
```

You may also use the `=`, `!=`, `<`, `>`, `>=`, `<=` comparsion operators to build more advanced queries:
You may also use the `=`, `!=`, `<`, `>`, `>=`, `<=` comparison operators to build more advanced queries:

```php
Order::search('Star Trek')
Expand Down
8 changes: 8 additions & 0 deletions src/database/src/Concerns/BuildsQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ trait BuildsQueries
*/
public function chunk(int $count, callable $callback): bool
{
if ($count < 1) {
throw new InvalidArgumentException('The chunk size should be at least 1');
}

$this->enforceOrderBy();

$skip = $this->getOffset();
Expand Down Expand Up @@ -143,6 +147,10 @@ public function chunkByIdDesc(int $count, callable $callback, ?string $column =
*/
public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool
{
if ($count < 1) {
throw new InvalidArgumentException('The chunk size should be at least 1');
}

$column ??= $this->defaultKeyName();
$alias ??= $column;
$lastId = null;
Expand Down
9 changes: 6 additions & 3 deletions src/database/src/Eloquent/HasCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ public function newCollection(array $models = []): Collection
// @phpstan-ignore assign.propertyType (generic type narrowing loss with static property)
static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? static::$collectionClass);

/** @var TCollection $collection */
$collection = new static::$resolvedCollectionClasses[static::class]($models);

if (Model::isAutomaticallyEagerLoadingRelationships()) {
$collection->withRelationshipAutoloading();
}

// @phpstan-ignore return.type (dynamic class instantiation from static property loses generic type)
return $collection;
}

Expand All @@ -52,8 +52,11 @@ public function resolveCollectionFromAttribute(): ?string
do {
$attributes = $reflection->getAttributes(CollectedBy::class);

if (isset($attributes[0], $attributes[0]->getArguments()[0])) {
return $attributes[0]->getArguments()[0];
if (isset($attributes[0])) {
/** @var class-string<TCollection> $collectionClass */
$collectionClass = $attributes[0]->newInstance()->collectionClass;

return $collectionClass;
}
} while ($reflection = $reflection->getParentClass());

Expand Down
2 changes: 1 addition & 1 deletion src/database/src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -2604,7 +2604,7 @@ public function forPage(int $page, int $perPage = 15): static
/**
* Constrain the query to the previous "page" of results before a given ID.
*/
public function forPageBeforeId(int $perPage = 15, ?int $lastId = 0, string $column = 'id'): static
public function forPageBeforeId(int $perPage = 15, string|int|null $lastId = 0, string $column = 'id'): static
{
$this->orders = $this->removeExistingOrdersFor($column);

Expand Down
27 changes: 17 additions & 10 deletions src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ protected function tearDownInteractsWithAlgolia(): void
{
try {
if ($this->algolia !== null) {
$this->cleanupAlgoliaIndices();
try {
$this->cleanupAlgoliaIndices();
} catch (Throwable) {
// Ignore cleanup errors
}
}
} finally {
$this->algolia = null;
Expand Down Expand Up @@ -121,18 +125,21 @@ protected function cleanupAlgoliaIndices(): void
return;
}

try {
$indices = $this->algolia->listIndices();
$tasks = [];
$indices = $this->algolia->listIndices();

foreach ($indices['items'] ?? [] as $index) {
$name = $index['name'] ?? null;
foreach ($indices['items'] ?? [] as $index) {
$name = $index['name'] ?? null;

if (is_string($name) && str_starts_with($name, $this->algoliaTestPrefix)) {
$this->algolia->deleteIndex($name);
}
if (is_string($name) && str_starts_with($name, $this->algoliaTestPrefix)) {
/** @var array{taskID: int} $task */
$task = $this->algolia->deleteIndex($name);
$tasks[] = ['indexName' => $name, 'taskID' => $task['taskID']];
}
} catch (Throwable) {
// Ignore errors during cleanup
}

foreach ($tasks as $task) {
$this->algolia->waitForTask($task['indexName'], $task['taskID']);
}
}
}
52 changes: 36 additions & 16 deletions src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Hypervel\Foundation\Testing\Concerns;

use Meilisearch\Client as MeilisearchClient;
use RuntimeException;
use Throwable;

/**
Expand Down Expand Up @@ -113,16 +114,21 @@ protected function cleanupMeilisearchIndexes(): void
return;
}

try {
$indexes = $this->meilisearch->getIndexes();
$taskUids = [];
$indexes = $this->meilisearch->getIndexes();

foreach ($indexes->getResults() as $index) {
if (str_starts_with($index->getUid(), $this->meilisearchTestPrefix)) {
$task = $this->meilisearch->deleteIndex($index->getUid());

foreach ($indexes->getResults() as $index) {
if (str_starts_with($index->getUid(), $this->meilisearchTestPrefix)) {
$this->meilisearch->deleteIndex($index->getUid());
}
/** @var int $taskUid */
$taskUid = $task['taskUid'];
$taskUids[] = $taskUid;
}
} catch (Throwable) {
// Ignore errors during cleanup
}

foreach ($taskUids as $taskUid) {
$this->waitForMeilisearchTask($taskUid);
}
}

Expand All @@ -135,15 +141,29 @@ protected function waitForMeilisearchTasks(int $timeoutMs = 5000): void
return;
}

try {
$tasks = $this->meilisearch->getTasks();
foreach ($tasks->getResults() as $task) {
if (in_array($task['status'], ['enqueued', 'processing'], true)) {
$this->meilisearch->waitForTask($task['uid'], $timeoutMs);
}
$tasks = $this->meilisearch->getTasks();

foreach ($tasks->getResults() as $task) {
if (in_array($task['status'], ['enqueued', 'processing'], true)) {
/** @var int $taskUid */
$taskUid = $task['uid'];
$this->waitForMeilisearchTask($taskUid, $timeoutMs);
}
} catch (Throwable) {
// Ignore timeout errors
}
}

/**
* Wait for a Meilisearch task to complete successfully.
*/
protected function waitForMeilisearchTask(int $taskUid, int $timeoutMs = 5000): void
{
$task = $this->meilisearch->waitForTask($taskUid, $timeoutMs);

if ($task['status'] !== 'succeeded') {
throw new RuntimeException(
$task['error']['message']
?? "Meilisearch task [{$task['uid']}] ended with status [{$task['status']}]."
);
}
}
}
13 changes: 12 additions & 1 deletion src/scout/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
Scout for Hypervel
===

[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/scout)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/scout)

Ported from: https://github.com/laravel/scout

Differences From Laravel
---

- Algolia 4 is the only supported Algolia client.
- Queue mode supports dedicated connection and queue selection; nonqueued indexing runs after the response in a coroutine.
- Command imports use bounded coroutine concurrency.
- Meilisearch requests use bounded retries and support tenant tokens.
- Destructive index deletion requires the configured Scout prefix.
2 changes: 2 additions & 0 deletions src/scout/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"require": {
"php": "^8.4",
"guzzlehttp/guzzle": "^7.15.1",
"hypervel/collections": "^0.4",
"hypervel/conditionable": "^0.4",
"hypervel/config": "^0.4",
Expand All @@ -49,6 +50,7 @@
"hypervel/pagination": "^0.4",
"hypervel/queue": "^0.4",
"hypervel/support": "^0.4",
"psr/http-message": "^2.0",
"symfony/console": "^8.1"
},
"suggest": {
Expand Down
Loading