Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8bd611a
Harden schema execution and session restoration
binaryfire Aug 9, 2026
336883d
Make PostgreSQL Blueprint execution atomic
binaryfire Aug 9, 2026
e58a04f
Preserve SQLite schema changes exactly and atomically
binaryfire Aug 9, 2026
542afc9
Discard unsafe test database sessions completely
binaryfire Aug 9, 2026
55a870d
Clarify foreign-key constraint behavior in migrations
binaryfire Aug 9, 2026
b080d40
Document the database schema execution safety design
binaryfire Aug 9, 2026
5244798
Merge branch '0.4' into fix/database-schema-execution-safety
binaryfire Aug 9, 2026
fb2a16f
fix(database): harden MySQL schema cleanup
binaryfire Aug 9, 2026
26ccf4b
fix(database): use writer-owned schema state
binaryfire Aug 9, 2026
78fe9d9
test(database): make SQLite rebuild coverage portable
binaryfire Aug 9, 2026
fc810ec
docs(database): clarify foreign-key constraint toggles
binaryfire Aug 9, 2026
0afd65c
docs(plans): record schema write-connection invariants
binaryfire Aug 9, 2026
c37334d
test(database): cover post-commit restoration failure
binaryfire Aug 9, 2026
375dc85
Merge pull request #497 from hypervel/fix/database-schema-execution-s…
binaryfire Aug 9, 2026
2bd4176
Clarify full typing for class constants
binaryfire Aug 9, 2026
a8a10d7
fix(database): order foreign key target commands
binaryfire Aug 9, 2026
99bfedb
fix(database): compare schema command names strictly
binaryfire Aug 9, 2026
f0f5433
test(database): cover foreign key target ordering
binaryfire Aug 9, 2026
1ac8dd8
test(database): preserve single SQLite rebuild
binaryfire Aug 9, 2026
416ec55
docs(database): fix online index example
binaryfire Aug 9, 2026
ae332ba
docs(plans): record foreign key ordering design
binaryfire Aug 9, 2026
e7598e5
test(database): preserve promoted command identity
binaryfire Aug 9, 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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan
### Code conventions

- **New Hypervel-owned code and packages must be Laravel-style** — Design new packages and public surfaces as if they were first-party Laravel packages ported to and enhanced for Hypervel. APIs, naming, class responsibilities, code patterns, and directory structure must be ergonomic, intuitive, and immediately familiar to Laravel developers, while internals remain coroutine-safe and optimized for Hypervel's long-lived Swoole runtime and high-performance requirements. Apply the requirements under [Audit changes during modification and code review](#audit-changes-during-modification-and-code-review) from initial design onward.
- **Modern PHP 8.4+ with full typing** — use constructor property promotion, readonly properties, enums, match expressions, named arguments, and attributes where they fit. Every file declares `strict_types=1`; parameters, return types, and properties are natively typed wherever PHP and the inherited API permit (e.g. `resource` cannot be represented as a native PHP type). PHP does not allow return types on `__construct()` or `__destruct()`.
- **Modern PHP 8.4+ with full typing** — use constructor property promotion, readonly properties, enums, match expressions, named arguments, and attributes where they fit. Every file declares `strict_types=1`; parameters, return types, properties, and class constants are natively typed wherever PHP and the inherited API permit (e.g. `resource` cannot be represented as a native PHP type). PHP does not allow return types on `__construct()` or `__destruct()`.
- **Newly written classes use dependency injection** — inject contracts (e.g. `Repository $config`, `CacheRepository $cache`) via constructor or method injection rather than helpers, facades, or `new` for framework services. Dependencies become explicit in signatures and tests swap them in directly, without facade-mocking machinery. Fall back to `Container::getInstance()->make(...)` only where injection isn't possible — static contexts and traits, like the testing package's Concerns. Helpers (`config()`, `cache()`) are fine in non-class contexts such as route and config files.
- **Never convert ported code to dependency injection** — ported code keeps its upstream facade, helper, and instantiation style. Converting it restructures classes and breaks 1:1 upstream mergeability.
- **Import classes, don't use FQCNs** — always add a `use` statement and reference the short name. The only exceptions are places where FQCNs genuinely make more sense, such as middleware arrays and similar config-style identifier lists.
Expand Down Expand Up @@ -720,7 +720,7 @@ Full PHPStan runs through `composer fix` at checkpoints. During implementation,
### Policy

When porting Laravel packages, whether first-party or third-party, keep them as close to 1:1 with upstream as possible so future changes are easy to merge. The exceptions are:
- Modernizing PHP types (PHP 8.4+ features, strict types, strict comparisons)
- Modernizing PHP types, including native parameter, return, property, and class-constant types, plus other appropriate PHP 8.4+ features, strict types, and strict comparisons
- Converting mutable Laravel date construction to Hypervel's immutable date conventions, typing configurable factory output as `CarbonInterface`, and capturing date-modifier return values
- Converting container array access (`$app['events']`) to `make()`, and untyped `$config->get()` calls to the typed getters where the key isn't nullable (see Container and the typed-getter rule under Development Conventions)
- Adding Laravel-style title docblocks to methods (not classes — see Development Conventions)
Expand Down
114 changes: 114 additions & 0 deletions docs/plans/2026-08-09-0555-database-schema-execution-safety.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions src/boost/docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -1514,7 +1514,7 @@ When using PostgreSQL, chaining `index` onto a `vector` column definition will c
By default, creating an index on a large table can lock the table and block reads or writes while the index is being built. When using PostgreSQL, you may chain the `online` method onto an index definition to create the index without locking the table, allowing your application to continue reading and writing data during index creation:

```php
$table->string('email')->unique()->online();
$table->unique('email')->online();
```

When using PostgreSQL, this adds the `CONCURRENTLY` option to the index creation statement.
Expand Down Expand Up @@ -1657,8 +1657,11 @@ Schema::withoutForeignKeyConstraints(function () {
});
```

> [!NOTE]
> Hypervel's default SQLite connection enables foreign key constraints. Custom SQLite connections may control this behavior using the `foreign_key_constraints` configuration option.

> [!WARNING]
> SQLite disables foreign key constraints by default. When using SQLite, make sure to [enable foreign key support](/docs/{{version}}/database#configuration) in your database configuration before attempting to create them in your migrations.
> SQLite cannot enable or disable foreign key constraints while a transaction is active, so call these methods before beginning the transaction. On PostgreSQL, they defer only foreign keys created with `deferrable()`, and only within a transaction. Other foreign keys stay enforced, and on PostgreSQL calling them outside a transaction has no effect.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<a name="events"></a>
## Events
Expand Down
58 changes: 53 additions & 5 deletions src/database/src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Hypervel\Support\Arr;
use Hypervel\Support\InteractsWithTime;
use Hypervel\Support\Traits\Macroable;
use LogicException;
use PDO;
use PDOStatement;
use RuntimeException;
Expand Down Expand Up @@ -114,6 +115,11 @@ class Connection implements ConnectionInterface
*/
protected int $transactions = 0;

/**
* The depth of the active foreign key constraint suppression scope.
*/
protected int $foreignKeyConstraintSuppressionDepth = 0;

/**
* The transaction manager instance.
*/
Expand Down Expand Up @@ -1014,14 +1020,43 @@ public function clearBeforeExecutingCallbacks(): void
$this->beforeExecutingCallbacks = [];
}

/**
* Begin a foreign key constraint suppression scope.
*
* @internal
*/
public function beginForeignKeyConstraintSuppression(): bool
{
return ++$this->foreignKeyConstraintSuppressionDepth === 1;
}

/**
* End a foreign key constraint suppression scope.
*
* @internal
*/
public function endForeignKeyConstraintSuppression(): void
{
if ($this->foreignKeyConstraintSuppressionDepth === 0) {
throw new LogicException('No foreign key constraint suppression scope is active.');
}

--$this->foreignKeyConstraintSuppressionDepth;
}

/**
* Reset all wrapper state for pool release.
*
* Physical database session state is preserved and synchronized against
* Trustworthy physical session state is preserved and synchronized against
* the next coroutine's desired state when the PDO is handed out again.
*/
public function resetForPool(): void
{
if ($this->foreignKeyConstraintSuppressionDepth > 0) {
$this->markCurrentSessionStateUnknown();
$this->foreignKeyConstraintSuppressionDepth = 0;
}

// Clear registered callbacks
$this->beforeExecutingCallbacks = [];
$this->beforeStartingTransaction = [];
Expand Down Expand Up @@ -1424,15 +1459,28 @@ protected function invalidateSessionState(PDO $pdo): void
*/
protected function markSessionStateUnknown(PDO $pdo): void
{
if (static::$sessionConfigurators === []) {
return;
}

$sessionState = static::physicalSessionState($pdo);
$sessionState->appliedStates = [];
$sessionState->unknown = true;
}

/**
* Mark the current write session's state as unknown.
*
* @internal
*/
public function markCurrentSessionStateUnknown(): void
{
$pdo = $this->getRawPdo();

if (! $pdo instanceof PDO) {
// Cleanup must not resolve a lazy connection merely to invalidate a session that does not yet exist.
return;
}

$this->markSessionStateUnknown($pdo);
}

/**
* Determine whether an open PDO has unknown session state.
*
Expand Down
12 changes: 12 additions & 0 deletions src/database/src/Pool/PooledConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ public function reconnect(): bool
$this->connection = $this->factory->make($this->config, $this->config['name'] ?? null);
}

if ($this->connection->hasUnknownSessionState()) {
$this->markInvalid();

if ($sharedPdo !== null) {
throw new RuntimeException(
'The shared in-memory SQLite database session is unknown and its sole connection cannot be replaced without discarding the database.'
);
}

throw new RuntimeException('Database session state remains unknown after reconnecting.');
}

// Configure event dispatcher for query events
if ($this->container->bound('events')) {
$this->connection->setEventDispatcher($this->container->make('events'));
Expand Down
59 changes: 57 additions & 2 deletions src/database/src/Query/Processors/SQLiteProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace Hypervel\Database\Query\Processors;

use Hypervel\Support\Arr;
use Override;
use UnexpectedValueException;

class SQLiteProcessor extends Processor
{
Expand Down Expand Up @@ -57,6 +59,23 @@ public function processColumns(array $results, string $sql = ''): array

#[Override]
public function processIndexes(array $results): array
{
return array_map(
static fn (array $index): array => Arr::only(
$index,
['name', 'columns', 'type', 'unique', 'primary'],
),
$this->processIndexesForSchemaState($results),
);
}

/**
* Process indexes with the metadata required to reconstruct SQLite schema state.
*
* @internal
* @return list<array{name: string, physical_name: string, columns: list<string>, type: null|string, unique: bool, primary: bool, sql: null|string, origin: null|string, reconstructible: bool, collations: null|list<string>, descending: null|list<bool>}>
*/
public function processIndexesForSchemaState(array $results): array
{
$primaryCount = 0;

Expand All @@ -69,18 +88,54 @@ public function processIndexes(array $results): array

return [
'name' => strtolower($result->name),
'columns' => $result->columns ? explode(',', $result->columns) : [],
'physical_name' => (string) $result->name,
'columns' => $this->decodeHexList($result->columns),
'type' => null,
'unique' => (bool) $result->unique,
'primary' => $isPrimary,
'sql' => $result->sql,
'origin' => $result->origin,
'reconstructible' => (bool) $result->reconstructible,
'collations' => is_null($result->collations)
? null
: $this->decodeHexList($result->collations),
'descending' => is_null($result->descending)
? null
: array_map(
static fn (string $value): bool => $value === '1',
explode(',', $result->descending),
),
];
}, $results);

if ($primaryCount > 1) {
$indexes = array_filter($indexes, fn ($index) => $index['name'] !== 'primary');
}

return $indexes;
return array_values($indexes);
}

/**
* Decode a comma-separated list of hexadecimal SQLite schema values.
*
* @return list<string>
*/
protected function decodeHexList(?string $values): array
{
if (is_null($values) || $values === '') {
return [];
}

return array_map(static function (string $value): string {
if (strlen($value) % 2 !== 0 || ! ctype_xdigit($value)) {
throw new UnexpectedValueException('The SQLite schema metadata contains invalid hexadecimal text.');
}

/** @var string $decoded */
$decoded = hex2bin($value);

return $decoded;
}, explode(',', $values));
}

#[Override]
Expand Down
Loading