Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6483087
Cover atomic configuration, event and route cache publication
binaryfire Nov 1, 2026
5ac328c
Preserve database transactions when acquiring existing cache locks
binaryfire Nov 1, 2026
ebbe8d3
Retry unrestorable jobs and distinguish string-job data from object m…
binaryfire Nov 1, 2026
31f71cd
Clear the current route before each coroutine-local dispatch
binaryfire Nov 1, 2026
2a0c7c0
Cover Redis tag pruning with SCAN_PREFIX
binaryfire Nov 1, 2026
10156b9
Add the session garbage collection command
binaryfire Nov 1, 2026
9f8892c
Update the shared Vite toolchain and rebuild frontend assets
binaryfire Nov 1, 2026
35e9c84
Forward after-response dispatch settings through BusFake
binaryfire Nov 1, 2026
e604bcd
Complete relationship creation, pivot constraints and pagination totals
binaryfire Nov 1, 2026
e53615b
Cover database queue reservation concurrency failures
binaryfire Nov 1, 2026
1fad9b9
Use weak references to validate memoized object identities
binaryfire Nov 1, 2026
6a02d85
Validate associative values against plain anyOf rule lists
binaryfire Nov 1, 2026
1310e8a
Resolve middleware exclusions with parameters and closure aliases
binaryfire Nov 1, 2026
20583bb
Reject foreign PHAR paths while allowing the running archive
binaryfire Nov 1, 2026
2006cb7
Avoid embedding images twice in Markdown notifications
binaryfire Nov 1, 2026
3b0423b
Cover raw, HTML and plain messages on MailFake
binaryfire Nov 1, 2026
d2060b8
Cover remember cookies with empty tokens
binaryfire Nov 1, 2026
13acc4b
Validate password confirmation input before checking credentials
binaryfire Nov 1, 2026
05798e3
Validate MCP OAuth advertisements and bound registration and responses
binaryfire Nov 1, 2026
69ab4a9
Show the validated full MCP OAuth redirect URI
binaryfire Nov 1, 2026
a5d954f
Prevent framing of MCP authorization and device pages
binaryfire Nov 1, 2026
2acc04e
Preserve batch embedding input order across indexed providers
binaryfire Nov 1, 2026
c53d45d
Preserve all Vary values on Inertia and precognitive responses
binaryfire Nov 1, 2026
8469e1d
Invalidate cached Inertia SSR results when the page changes
binaryfire Nov 1, 2026
1d14982
Allow SSR startup without stopping an existing server
binaryfire Nov 1, 2026
cacc510
Prevent MCP custom headers from leaking across redirect origins
binaryfire Nov 1, 2026
e89b060
Preserve AnyOf field values and reject nested exists candidates
binaryfire Nov 1, 2026
e6836a1
Validate and order indexed embedding responses in one shared parser
binaryfire Nov 1, 2026
195277f
Reuse existing pivot links when createOrFirst recovers a model
binaryfire Nov 1, 2026
4b5771f
Retain retry options when object payloads omit commandName
binaryfire Nov 1, 2026
3c49a5c
Clear current route before listeners and direct route dispatch
binaryfire Nov 1, 2026
820c414
Return failure when session garbage collection fails
binaryfire Nov 1, 2026
909f1f5
Clarify shared after-response settings and commit timing
binaryfire Nov 1, 2026
7681730
Preserve numeric AnyOf fields and null wildcard values
binaryfire Nov 1, 2026
64fcd6c
Reject non-stringable array values in exists and in validation
binaryfire Nov 1, 2026
951f902
Reset route state at request dispatch boundaries
binaryfire Nov 1, 2026
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
895 changes: 528 additions & 367 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ allowBuilds:
# report a peer version mismatch despite using the bundled Vite runtime.
# Update both core aliases with the vite-plus catalog pin; match Vitest to `vp toolchain vitest`.
overrides:
'vite@*': npm:@voidzero-dev/vite-plus-core@1.3.0
'vite@*': npm:@voidzero-dev/vite-plus-core@1.4.0
'vitest@*': 5.0.5

# Supply-chain defense: require packages to be at least 7 days old before
Expand All @@ -39,8 +39,8 @@ catalog:
sass: ^1.106.0
tailwindcss: ^4.3.3
typescript: ^7.0.2
vite: npm:@voidzero-dev/vite-plus-core@1.3.0
vite-plus: 1.3.0
vite: npm:@voidzero-dev/vite-plus-core@1.4.0
vite-plus: 1.4.0
vue: ^3.5.44
vue-json-pretty: ^2.6.0
vue-router: ^4.6.4
4 changes: 3 additions & 1 deletion src/ai/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Hypervel\Ai\Files\Image;
use Hypervel\Ai\Gateway\AzureOpenAi\Concerns\CreatesAzureOpenAiClient;
use Hypervel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Hypervel\Ai\Gateway\Concerns\ParsesIndexedEmbeddings;
use Hypervel\Ai\Gateway\Concerns\ParsesServerSentEvents;
use Hypervel\Ai\Gateway\OpenAi\Concerns\BuildsTextRequests;
use Hypervel\Ai\Gateway\OpenAi\Concerns\HandlesTextGeneration;
Expand Down Expand Up @@ -46,6 +47,7 @@ class AzureOpenAiGateway implements EmbeddingGateway, ImageGateway, StepTextGate
use MapsAttachments;
use MapsMessages;
use MapsTools;
use ParsesIndexedEmbeddings;
use ParsesServerSentEvents;
use ParsesTextResponses;

Expand Down Expand Up @@ -80,7 +82,7 @@ public function generateEmbeddings(
$data = $response->json();

return new EmbeddingsResponse(
collect($data['data'] ?? [])->pluck('embedding')->all(),
$this->parseIndexedEmbeddings($data['data'] ?? [], count($inputs), $provider->name()),
new Usage($data['usage']['prompt_tokens'] ?? 0),
new Meta($provider->name(), $model),
);
Expand Down
40 changes: 40 additions & 0 deletions src/ai/src/Gateway/Concerns/ParsesIndexedEmbeddings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Hypervel\Ai\Gateway\Concerns;

use Hypervel\Ai\Exceptions\AiException;

trait ParsesIndexedEmbeddings
{
/**
* Return embedding values in input order without accepting ambiguous indices.
*
* @param array<array-key, mixed> $records
* @return list<mixed>
*/
protected function parseIndexedEmbeddings(array $records, int $expectedCount, string $providerName): array
{
if (count($records) !== $expectedCount) {
throw new AiException("{$providerName} Error: [invalid_response] The response did not contain the expected number of embeddings.");
}

$ordered = array_fill(0, $expectedCount, null);

foreach ($records as $position => $record) {
// Some compatible servers omit indices; retain their response order.
$index = is_array($record) ? ($record['index'] ?? $position) : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (! is_int($index) || $index < 0 || $index >= $expectedCount || $ordered[$index] !== null) {
throw new AiException("{$providerName} Error: [invalid_response] The response contained invalid embedding indices.");
}

$ordered[$index] = $record;
}

// Matching counts and unique in-range indices ensure every slot contains a record.
/** @var list<array<string, mixed>> $ordered */
return array_map(static fn (array $record): mixed => $record['embedding'] ?? null, $ordered);
}
}
4 changes: 3 additions & 1 deletion src/ai/src/Gateway/OpenAi/OpenAiGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Hypervel\Ai\Contracts\Providers\TranscriptionProvider;
use Hypervel\Ai\Files\Image;
use Hypervel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Hypervel\Ai\Gateway\Concerns\ParsesIndexedEmbeddings;
use Hypervel\Ai\Gateway\Concerns\ParsesServerSentEvents;
use Hypervel\Ai\Gateway\Concerns\ResolvesAudioFilenames;
use Hypervel\Ai\Gateway\OpenAi\Concerns\BuildsTextRequests;
Expand Down Expand Up @@ -52,6 +53,7 @@ class OpenAiGateway implements Gateway, StepTextGateway
use MapsTools;
use ParsesTextResponses;
use HandlesFailoverErrors;
use ParsesIndexedEmbeddings;
use ParsesServerSentEvents;
use ResolvesAudioFilenames;

Expand Down Expand Up @@ -287,7 +289,7 @@ public function generateEmbeddings(
$data = $response->json();

return new EmbeddingsResponse(
collect($data['data'] ?? [])->pluck('embedding')->all(),
$this->parseIndexedEmbeddings($data['data'] ?? [], count($inputs), $provider->name()),
new Usage($data['usage']['prompt_tokens'] ?? 0),
new Meta($provider->name(), $model),
);
Expand Down
13 changes: 4 additions & 9 deletions src/ai/src/Gateway/OpenAiCompatible/OpenAiCompatibleGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Hypervel\Ai\Contracts\Providers\TranscriptionProvider;
use Hypervel\Ai\Exceptions\AiException;
use Hypervel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Hypervel\Ai\Gateway\Concerns\ParsesIndexedEmbeddings;
use Hypervel\Ai\Gateway\Concerns\ParsesServerSentEvents;
use Hypervel\Ai\Gateway\Concerns\ResolvesAudioFilenames;
use Hypervel\Ai\Gateway\OpenAiCompatible\Concerns\BuildsTextRequests;
Expand All @@ -31,7 +32,6 @@
use Hypervel\Ai\Responses\TranscriptionResponse;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
use InvalidArgumentException;

class OpenAiCompatibleGateway implements EmbeddingGateway, StepTextGateway, TranscriptionGateway
Expand All @@ -45,6 +45,7 @@ class OpenAiCompatibleGateway implements EmbeddingGateway, StepTextGateway, Tran
use ParsesTextResponses;
use PerformsChatCompletionSteps;
use HandlesFailoverErrors;
use ParsesIndexedEmbeddings;
use ParsesServerSentEvents;
use ResolvesAudioFilenames;

Expand Down Expand Up @@ -100,19 +101,13 @@ public function generateEmbeddings(
*/
protected function parseEmbeddings(array $data, int $expectedCount): array
{
$embeddings = (new Collection($data['data'] ?? []))->pluck('embedding');

if ($embeddings->count() !== $expectedCount) {
throw new AiException('OpenAI-compatible Error: [invalid_response] The response did not contain the expected number of embeddings.');
}

return $embeddings->map(function (mixed $embedding): array {
return array_map(static function (mixed $embedding): array {
if (! is_array($embedding) || $embedding === [] || array_filter($embedding, 'is_numeric') !== $embedding) {
throw new AiException('OpenAI-compatible Error: [invalid_response] The response contained an invalid embedding vector.');
}

return array_map(floatval(...), $embedding);
})->all();
}, $this->parseIndexedEmbeddings($data['data'] ?? [], $expectedCount, 'OpenAI-compatible'));
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/ai/src/Gateway/OpenRouter/OpenRouterGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Hypervel\Ai\Contracts\Providers\SupportsWebSearch;
use Hypervel\Ai\Contracts\Providers\TranscriptionProvider;
use Hypervel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Hypervel\Ai\Gateway\Concerns\ParsesIndexedEmbeddings;
use Hypervel\Ai\Gateway\Concerns\ParsesServerSentEvents;
use Hypervel\Ai\Gateway\Concerns\WrapsPcmAudio;
use Hypervel\Ai\Gateway\OpenAiCompatible\Concerns\MapsChatCompletionTools;
Expand Down Expand Up @@ -59,6 +60,7 @@ class OpenRouterGateway implements Gateway, RerankingGateway, StepTextGateway
use ParsesTextResponses;
use HandlesFailoverErrors;
use MapsChatCompletionTools;
use ParsesIndexedEmbeddings;
use ParsesServerSentEvents;
use PerformsChatCompletionSteps;
use WrapsPcmAudio;
Expand Down Expand Up @@ -349,7 +351,7 @@ public function generateEmbeddings(
$this->validateTextResponse($data);

return new EmbeddingsResponse(
(new Collection($data['data'] ?? []))->pluck('embedding')->all(),
$this->parseIndexedEmbeddings($data['data'] ?? [], count($inputs), $provider->name()),
new Usage($data['usage']['prompt_tokens'] ?? 0),
new Meta($provider->name(), $model),
);
Expand Down
26 changes: 17 additions & 9 deletions src/cache/src/DatabaseLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,11 @@ protected function connection(): ConnectionInterface
public function acquire(): bool
{
$connection = $this->connection();
$driver = $connection->getDriverName();

// Every SQLite write takes the database-wide write lock, even an insert that fails on the existing key or an
// Every SQLite write takes the database-wide write lock, even an insert that ignores the existing key or an
// update that matches nothing, so a live lock held by another owner is refused with a read instead.
if ($connection->getDriverName() === 'sqlite') {
if ($driver === 'sqlite') {
$owner = $connection->table($this->table)
->useWritePdo()
->where('key', $this->name)
Expand All @@ -98,16 +99,23 @@ public function acquire(): bool
}
}

try {
$connection->table($this->table)->insert([
'key' => $this->name,
'owner' => $this->owner,
'expiration' => $this->expiresAt(),
]);
$record = [
'key' => $this->name,
'owner' => $this->owner,
'expiration' => $this->expiresAt(),
];

$acquired = true;
try {
// MySQL's INSERT IGNORE truncates overlong names, leaving locks that cannot be released by their full name.
$acquired = in_array($driver, ['mysql', 'mariadb'], true)
? $connection->table($this->table)->insert($record)
: $connection->table($this->table)->insertOrIgnore($record) > 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch (UniqueConstraintViolationException) {
// Only an existing row means the lock may still be taken over; other failures, such as a busy database, propagate.
$acquired = false;
}

if (! $acquired) {
$updated = $connection->table($this->table)
->where('key', $this->name)
->where(function ($query) {
Expand Down
105 changes: 101 additions & 4 deletions src/database/src/Eloquent/Relations/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ public function wherePivot(Closure|Expression|string $column, mixed $operator =

$column($pivotQuery);

$this->qualifyPivotWheres($pivotQuery->getQuery());

return $this->addCompiledPivotConstraint(
$this->newPivotStatement()->addNestedWhereQuery($pivotQuery->getQuery(), $boolean)
);
Expand Down Expand Up @@ -659,7 +661,7 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = []
$this->getQuery()->ensureCanCreateOrFirst();

if (is_null($instance = (clone $this)->where($attributes)->first())) {
if (is_null($instance = $this->related->where($attributes)->first())) {
if (is_null($instance = $this->related->newQuery()->withoutGlobalScopes($this->query->removedScopes())->where($attributes)->first())) {
$instance = $this->createOrFirst($attributes, $values, $joining, $touch);
} else {
try {
Expand Down Expand Up @@ -693,7 +695,13 @@ public function createOrFirst(array $attributes = [], Closure|array $values = []
// ...
}

$instance = $this->related->where($attributes)->useWritePdo()->first() ?? throw $exception;
$instance = $this->related->newQuery()->withoutGlobalScopes($this->query->removedScopes())
->where($attributes)->useWritePdo()->first() ?? throw $exception;

// The recovered model may already be linked even without a unique constraint on the pivot table.
if ($this->hasAttachedPivot($instance)) {
return $instance;
}

try {
$this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch));
Expand All @@ -716,6 +724,38 @@ protected function hasAttachedPivot(Model $instance): bool
->exists();
}

/**
* Get the first record matching the attributes, including trashed records, and restore it. If the record is not found, create it.
*
* @param array|(Closure(): array) $values
* @return TRelatedModel
*/
public function restoreOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model
{
$this->withTrashed();

return tap($this->firstOrCreate($attributes, $values, $joining, $touch), function ($instance) {
$instance->restore();
});
}

/**
* Attempt to create the record. If a unique constraint violation occurs, find the matching record, including trashed records, and restore it.
*
* @param array|(Closure(): array) $values
* @return TRelatedModel
*
* @throws UniqueConstraintViolationException
*/
public function createOrRestore(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model
{
$this->withTrashed();

return tap($this->createOrFirst($attributes, $values, $joining, $touch), function ($instance) {
$instance->restore();
});
}

/**
* Create or update a related record matching the attributes, and fill it with values.
*
Expand Down Expand Up @@ -1036,11 +1076,11 @@ protected function aliasedPivotColumns(): array
*
* @return LengthAwarePaginator<int, object{pivot: TPivotModel}&TRelatedModel>
*/
public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null): mixed
public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null, Closure|int|null $total = null): mixed
{
$this->query->addSelect($this->shouldSelect($columns));

return tap($this->query->paginate($perPage, $columns, $pageName, $page), function ($paginator) {
return tap($this->query->paginate($perPage, $columns, $pageName, $page, $total), function ($paginator) {
$this->hydratePivotRelation($paginator->items());
});
}
Expand Down Expand Up @@ -1494,6 +1534,34 @@ public function createManyQuietly(iterable $records, array $joinings = []): arra
return Model::withoutEvents(fn (): array => $this->createMany($records, $joinings));
}

/**
* Create a new instance of the related model and attach it to the parent model. Allow mass-assignment.
*
* @return TRelatedModel
*/
public function forceCreate(array $attributes = [], array $joining = [], bool $touch = true): Model
{
$attributes = array_merge($this->getQuery()->pendingAttributes, $attributes);

$instance = $this->related->newInstance()->forceFill($attributes);

$instance->save(['touch' => false]);

$this->attach($instance, $joining, $touch);

return $instance;
}

/**
* Create a new instance of the related model with mass assignment without raising any events and attach it to the parent model.
*
* @return TRelatedModel
*/
public function forceCreateQuietly(array $attributes = [], array $joining = [], bool $touch = true): Model
{
return Model::withoutEvents(fn (): Model => $this->forceCreate($attributes, $joining, $touch));
}

public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder
{
if ($parentQuery->getQuery()->from == $query->getQuery()->from) {
Expand Down Expand Up @@ -1703,6 +1771,35 @@ public function getPivotColumns(): array
return $this->pivotColumns;
}

/**
* Qualify the column names in the given query's where clauses by the pivot table.
*/
protected function qualifyPivotWheres(QueryBuilder $query): void
{
foreach ($query->wheres as &$where) {
if ($where['type'] === 'Nested') {
$this->qualifyPivotWheres($where['query']);

continue;
}

foreach (['column', 'first', 'second'] as $key) {
if (isset($where[$key]) && is_string($where[$key])) {
$where[$key] = $this->qualifyPivotColumn($where[$key]);
}
}

// Only betweenColumns stores column names in "values"; other forms store comparison values.
$key = $where['type'] === 'betweenColumns' ? 'values' : 'columns';

foreach ($where[$key] ?? [] as $index => $column) {
if (is_string($column)) {
$where[$key][$index] = $this->qualifyPivotColumn($column);
}
}
}
}

/**
* Qualify the given column name by the pivot table.
*
Expand Down
Loading
Loading