From 126a613f5f9bfaa52a87f0cd2508a3054c4cac6e Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 10:39:37 -0600 Subject: [PATCH 01/81] Add initial lock implementation --- AGENTS.md | 1 + README.md | 1 + composer.json | 2 + src/Lock/.gitattributes | 7 + .../.github/workflows/close-pull-request.yml | 13 ++ src/Lock/.gitignore | 2 + src/Lock/Contracts/Clock.php | 16 ++ src/Lock/Contracts/Lock.php | 54 +++++ src/Lock/InMemoryLock.php | 131 ++++++++++++ src/Lock/LockToken.php | 59 ++++++ src/Lock/README.md | 34 +++ src/Lock/SystemClock.php | 16 ++ src/Lock/composer.json | 23 ++ tests/Support/Fixtures/Lock/MutableClock.php | 23 ++ tests/Unit/Lock/InMemoryLockTest.php | 196 ++++++++++++++++++ tests/Unit/Lock/LockTokenTest.php | 82 ++++++++ tests/Unit/Lock/SystemClockTest.php | 19 ++ 17 files changed, 679 insertions(+) create mode 100644 src/Lock/.gitattributes create mode 100644 src/Lock/.github/workflows/close-pull-request.yml create mode 100644 src/Lock/.gitignore create mode 100644 src/Lock/Contracts/Clock.php create mode 100644 src/Lock/Contracts/Lock.php create mode 100644 src/Lock/InMemoryLock.php create mode 100644 src/Lock/LockToken.php create mode 100644 src/Lock/README.md create mode 100644 src/Lock/SystemClock.php create mode 100644 src/Lock/composer.json create mode 100644 tests/Support/Fixtures/Lock/MutableClock.php create mode 100644 tests/Unit/Lock/InMemoryLockTest.php create mode 100644 tests/Unit/Lock/LockTokenTest.php create mode 100644 tests/Unit/Lock/SystemClockTest.php diff --git a/AGENTS.md b/AGENTS.md index ffdb8b6..cb30b7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ Initial packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` +- `stellarwp/foundation-lock` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` diff --git a/README.md b/README.md index 11db6f1..adf151e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container) - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) +- [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) - [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) diff --git a/composer.json b/composer.json index 8a27e39..6a30c6c 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", "stellarwp/foundation-log": "self.version", + "stellarwp/foundation-lock": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-wpcli": "self.version" }, @@ -44,6 +45,7 @@ "psr-4": { "StellarWP\\Foundation\\Cli\\": "src/Cli/", "StellarWP\\Foundation\\Container\\": "src/Container/", + "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" diff --git a/src/Lock/.gitattributes b/src/Lock/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Lock/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Lock/.github/workflows/close-pull-request.yml b/src/Lock/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Lock/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Lock/.gitignore b/src/Lock/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Lock/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Lock/Contracts/Clock.php b/src/Lock/Contracts/Clock.php new file mode 100644 index 0000000..7fc8329 --- /dev/null +++ b/src/Lock/Contracts/Clock.php @@ -0,0 +1,16 @@ + + */ + private array $locks = []; + + public function __construct( + private readonly Clock $clock = new SystemClock() + ) { + } + + /** + * {@inheritDoc} + * + * @throws RandomException + * @throws DateMalformedIntervalStringException + */ + public function acquire(string $name, int $ttl): ?LockToken { + $this->assertValidName($name); + $this->assertValidTtl($ttl); + $this->releaseIfExpired($name); + + if (isset($this->locks[$name])) { + return null; + } + + $token = new LockToken( + name: $name, + owner: bin2hex(random_bytes(16)), + expiresAt: $this->expiresAt($ttl) + ); + + $this->locks[$name] = $token; + + return $token; + } + + public function release(LockToken $token): bool { + $this->releaseIfExpired($token->name); + + if (! isset($this->locks[$token->name]) || ! $this->locks[$token->name]->matches($token)) { + return false; + } + + unset($this->locks[$token->name]); + + return true; + } + + /** + * @throws DateMalformedIntervalStringException + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidTtl($ttl); + $this->releaseIfExpired($token->name); + + if (! isset($this->locks[$token->name]) || ! $this->locks[$token->name]->matches($token)) { + return null; + } + + $refreshed = $token->refresh($this->expiresAt($ttl)); + + $this->locks[$token->name] = $refreshed; + + return $refreshed; + } + + public function isAcquired(string $name): bool { + $this->assertValidName($name); + $this->releaseIfExpired($name); + + return isset($this->locks[$name]); + } + + /** + * @throws DateMalformedIntervalStringException + * @throws InvalidArgumentException + */ + private function expiresAt(int $ttl): DateTimeImmutable { + $this->assertValidTtl($ttl); + + return $this->clock->now()->add(new DateInterval(sprintf('PT%dS', $ttl))); + } + + private function assertValidTtl(int $ttl): void { + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); + } + } + + private function releaseIfExpired(string $name): void { + if (! isset($this->locks[$name])) { + return; + } + + if (! $this->locks[$name]->isExpired($this->clock->now())) { + return; + } + + unset($this->locks[$name]); + } + + /** + * @throws InvalidArgumentException + */ + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + } +} diff --git a/src/Lock/LockToken.php b/src/Lock/LockToken.php new file mode 100644 index 0000000..91c2bd7 --- /dev/null +++ b/src/Lock/LockToken.php @@ -0,0 +1,59 @@ +name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + + if (trim($this->owner) === '') { + throw new InvalidArgumentException('Lock owner cannot be empty.'); + } + } + + /** + * Determine whether the token has expired at the provided time. + * + * When no time is provided, the local system clock is used as a convenience + * check. Lock implementations should pass their authoritative clock value. + */ + public function isExpired(?DateTimeImmutable $now = null): bool { + return $this->expiresAt <= ($now ?? new DateTimeImmutable()); + } + + /** + * Determine whether another token represents the same lock owner. + */ + public function matches(self $token): bool { + return $this->name === $token->name && $this->owner === $token->owner; + } + + /** + * Return a new token for the same owner with a later expiration time. + */ + public function refresh(DateTimeImmutable $expiresAt): self { + return new self( + name: $this->name, + owner: $this->owner, + expiresAt: $expiresAt + ); + } +} diff --git a/src/Lock/README.md b/src/Lock/README.md new file mode 100644 index 0000000..efa98f0 --- /dev/null +++ b/src/Lock/README.md @@ -0,0 +1,34 @@ +# Foundation Lock + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +## Installation + +```shell +composer require stellarwp/foundation-lock +``` + +## Usage + +`foundation-lock` defines portable lock contracts and a process-local in-memory implementation. The in-memory lock is useful for tests and single-process work, but it is not a cross-request or distributed lock. + +```php +use StellarWP\Foundation\Lock\InMemoryLock; + +$lock = new InMemoryLock(); + +$token = $lock->acquire('queue:sync', 60); + +if ($token === null) { + return; +} + +try { + // Run exclusive work here. +} finally { + $lock->release($token); +} +``` + +Persistent implementations, such as database-backed locks, should implement `StellarWP\Foundation\Lock\Contracts\Lock` and use `LockToken` ownership checks before releasing or refreshing locks. diff --git a/src/Lock/SystemClock.php b/src/Lock/SystemClock.php new file mode 100644 index 0000000..5d03eea --- /dev/null +++ b/src/Lock/SystemClock.php @@ -0,0 +1,16 @@ +=8.3" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Lock\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.2.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/Lock/MutableClock.php b/tests/Support/Fixtures/Lock/MutableClock.php new file mode 100644 index 0000000..8c32563 --- /dev/null +++ b/tests/Support/Fixtures/Lock/MutableClock.php @@ -0,0 +1,23 @@ +now; + } + + public function advance(int $seconds): void { + $this->now = $this->now->add(new DateInterval(sprintf('PT%dS', $seconds))); + } +} diff --git a/tests/Unit/Lock/InMemoryLockTest.php b/tests/Unit/Lock/InMemoryLockTest.php new file mode 100644 index 0000000..21325b9 --- /dev/null +++ b/tests/Unit/Lock/InMemoryLockTest.php @@ -0,0 +1,196 @@ +clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->lock = new InMemoryLock($this->clock); + } + + public function test_it_acquires_a_named_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertTrue($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_acquire_a_lock_that_is_already_owned(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertNull($this->lock->acquire('queue:sync', 60)); + } + + public function test_it_tracks_named_locks_independently(): void { + $sync = $this->lock->acquire('queue:sync', 60); + $cleanup = $this->lock->acquire('queue:cleanup', 60); + + $this->assertInstanceOf(LockToken::class, $sync); + $this->assertInstanceOf(LockToken::class, $cleanup); + + $this->assertTrue($this->lock->release($sync)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + $this->assertTrue($this->lock->isAcquired('queue:cleanup')); + } + + public function test_it_expires_named_locks_independently(): void { + $sync = $this->lock->acquire('queue:sync', 30); + $cleanup = $this->lock->acquire('queue:cleanup', 90); + + $this->clock->advance(31); + + $this->assertInstanceOf(LockToken::class, $sync); + $this->assertInstanceOf(LockToken::class, $cleanup); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + $this->assertTrue($this->lock->isAcquired('queue:cleanup')); + } + + public function test_it_allows_a_lock_to_be_acquired_after_it_expires(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $second = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertInstanceOf(LockToken::class, $second); + $this->assertNotSame($first->owner, $second->owner); + } + + public function test_it_treats_a_lock_as_expired_at_the_expiration_boundary(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(60); + + $second = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertFalse($this->lock->release($first)); + $this->assertInstanceOf(LockToken::class, $second); + $this->assertNotSame($first->owner, $second->owner); + } + + public function test_it_releases_a_lock_with_the_matching_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertTrue($this->lock->release($token)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_release_a_lock_with_a_different_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertFalse($this->lock->release(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: $token->expiresAt + ))); + $this->assertTrue($this->lock->isAcquired('queue:sync')); + } + + public function test_it_does_not_release_an_expired_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertFalse($this->lock->release($token)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refreshes_a_lock_with_the_matching_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(30); + + $this->assertInstanceOf(LockToken::class, $token); + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame($token->name, $refreshed->name); + $this->assertSame($token->owner, $refreshed->owner); + $this->assertSame('2026-01-01 00:02:30', $refreshed->expiresAt->format('Y-m-d H:i:s')); + + $this->clock->advance(31); + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + + $this->clock->advance(89); + + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_refresh_a_lock_with_a_different_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertNull($this->lock->refresh(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: $token->expiresAt + ), 120)); + } + + public function test_it_refuses_to_refresh_an_expired_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertNull($this->lock->refresh($token, 120)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->lock->acquire('', 60); + } + + public function test_it_rejects_an_invalid_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_ttl_when_the_lock_is_already_owned(): void { + $this->lock->acquire('queue:sync', 60); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_ttl_when_refreshing_a_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->refresh($token, 0); + } +} diff --git a/tests/Unit/Lock/LockTokenTest.php b/tests/Unit/Lock/LockTokenTest.php new file mode 100644 index 0000000..2f0425f --- /dev/null +++ b/tests/Unit/Lock/LockTokenTest.php @@ -0,0 +1,82 @@ +assertFalse($token->isExpired(new DateTimeImmutable('2026-01-01 00:00:59'))); + $this->assertTrue($token->isExpired(new DateTimeImmutable('2026-01-01 00:01:00'))); + } + + public function test_it_matches_tokens_for_the_same_lock_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->assertTrue($token->matches(new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:02:00') + ))); + $this->assertFalse($token->matches(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ))); + $this->assertFalse($token->matches(new LockToken( + name: 'queue:cleanup', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ))); + } + + public function test_it_refreshes_with_the_same_lock_name_and_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $refreshed = $token->refresh(new DateTimeImmutable('2026-01-01 00:02:00')); + + $this->assertSame($token->name, $refreshed->name); + $this->assertSame($token->owner, $refreshed->owner); + $this->assertSame('2026-01-01 00:02:00', $refreshed->expiresAt->format('Y-m-d H:i:s')); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + new LockToken( + name: '', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } + + public function test_it_rejects_an_empty_owner(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock owner cannot be empty.'); + + new LockToken( + name: 'queue:sync', + owner: '', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } +} diff --git a/tests/Unit/Lock/SystemClockTest.php b/tests/Unit/Lock/SystemClockTest.php new file mode 100644 index 0000000..03ec926 --- /dev/null +++ b/tests/Unit/Lock/SystemClockTest.php @@ -0,0 +1,19 @@ +now(); + $after = new DateTimeImmutable(); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual($after, $now); + } +} From 3fbad29f73de2d773624f7a661aee69bb9fed13f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 15:32:09 -0600 Subject: [PATCH 02/81] WIP: foundation-database --- AGENTS.md | 7 + README.md | 1 + composer.json | 5 +- phpstan.neon.dist | 3 + src/Database/.gitattributes | 7 + .../.github/workflows/close-pull-request.yml | 13 + src/Database/.gitignore | 2 + src/Database/Cli/Migrate.php | 173 +++++++++++++ src/Database/Contracts/Database.php | 59 +++++ src/Database/Contracts/Migration.php | 24 ++ src/Database/Contracts/Repository.php | 31 +++ src/Database/Contracts/Schema.php | 32 +++ src/Database/Contracts/Table.php | 17 ++ src/Database/Database.php | 244 ++++++++++++++++++ src/Database/DatabaseProvider.php | 126 +++++++++ src/Database/Exceptions/DatabaseException.php | 12 + .../Exceptions/DuplicateMigration.php | 13 + .../Exceptions/IrreversibleMigration.php | 13 + src/Database/Exceptions/MigrationFailed.php | 19 ++ .../Exceptions/MigrationLockFailed.php | 13 + src/Database/Exceptions/QueryException.php | 39 +++ src/Database/Lock/DatabaseLock.php | 144 +++++++++++ src/Database/Migration/Record.php | 19 ++ src/Database/Migration/Repository.php | 123 +++++++++ src/Database/Migration/Result.php | 25 ++ src/Database/Migration/Runner.php | 208 +++++++++++++++ src/Database/Migration/Status.php | 32 +++ src/Database/Query/Query.php | 54 ++++ src/Database/Query/QueryBuilder.php | 180 +++++++++++++ src/Database/README.md | 143 ++++++++++ src/Database/Schema.php | 102 ++++++++ src/Database/Table/Collection.php | 78 ++++++ src/Database/Table/Column.php | 49 ++++ src/Database/Table/CreateTable.php | 32 +++ src/Database/Table/Index.php | 36 +++ src/Database/Table/IndexType.php | 16 ++ src/Database/Table/TableDefinition.php | 133 ++++++++++ src/Database/Table/Tables/LockTable.php | 40 +++ src/Database/Table/Tables/MigrationTable.php | 39 +++ src/Database/composer.json | 26 ++ .../Fixtures/Database/FailingMigration.php | 37 +++ .../Fixtures/Database/FakeDatabase.php | 159 ++++++++++++ .../Fixtures/Database/InMemoryRepository.php | 73 ++++++ .../Fixtures/Database/RecordingSchema.php | 66 +++++ .../Fixtures/Database/TestMigration.php | 26 ++ tests/Support/Fixtures/Database/TestTable.php | 28 ++ .../register-wpcli-migrate-command.php | 89 +++++++ tests/Unit/Database/Cli/MigrateTest.php | 102 ++++++++ tests/Unit/Database/DatabaseProviderTest.php | 89 +++++++ tests/Unit/Database/Lock/DatabaseLockTest.php | 102 ++++++++ .../Database/Migration/RepositoryTest.php | 80 ++++++ tests/Unit/Database/Migration/RunnerTest.php | 222 ++++++++++++++++ .../Unit/Database/Query/QueryBuilderTest.php | 38 +++ tests/Unit/Database/Query/QueryTest.php | 32 +++ tests/Unit/Database/SchemaTest.php | 48 ++++ tests/Unit/Database/Table/CollectionTest.php | 57 ++++ tests/Unit/Database/Table/ColumnTest.php | 33 +++ tests/Unit/Database/Table/CreateTableTest.php | 51 ++++ .../Database/Table/TableDefinitionTest.php | 36 +++ .../Database/Table/Tables/LockTableTest.php | 38 +++ .../Table/Tables/MigrationTableTest.php | 37 +++ tests/wpunit.suite.dist.yml | 11 +- .../Database/Cli/DatabaseMigrateCest.php | 76 ++++++ .../Database/DatabaseIntegrationTest.php | 242 +++++++++++++++++ 64 files changed, 4102 insertions(+), 2 deletions(-) create mode 100644 src/Database/.gitattributes create mode 100644 src/Database/.github/workflows/close-pull-request.yml create mode 100644 src/Database/.gitignore create mode 100644 src/Database/Cli/Migrate.php create mode 100644 src/Database/Contracts/Database.php create mode 100644 src/Database/Contracts/Migration.php create mode 100644 src/Database/Contracts/Repository.php create mode 100644 src/Database/Contracts/Schema.php create mode 100644 src/Database/Contracts/Table.php create mode 100644 src/Database/Database.php create mode 100644 src/Database/DatabaseProvider.php create mode 100644 src/Database/Exceptions/DatabaseException.php create mode 100644 src/Database/Exceptions/DuplicateMigration.php create mode 100644 src/Database/Exceptions/IrreversibleMigration.php create mode 100644 src/Database/Exceptions/MigrationFailed.php create mode 100644 src/Database/Exceptions/MigrationLockFailed.php create mode 100644 src/Database/Exceptions/QueryException.php create mode 100644 src/Database/Lock/DatabaseLock.php create mode 100644 src/Database/Migration/Record.php create mode 100644 src/Database/Migration/Repository.php create mode 100644 src/Database/Migration/Result.php create mode 100644 src/Database/Migration/Runner.php create mode 100644 src/Database/Migration/Status.php create mode 100644 src/Database/Query/Query.php create mode 100644 src/Database/Query/QueryBuilder.php create mode 100644 src/Database/README.md create mode 100644 src/Database/Schema.php create mode 100644 src/Database/Table/Collection.php create mode 100644 src/Database/Table/Column.php create mode 100644 src/Database/Table/CreateTable.php create mode 100644 src/Database/Table/Index.php create mode 100644 src/Database/Table/IndexType.php create mode 100644 src/Database/Table/TableDefinition.php create mode 100644 src/Database/Table/Tables/LockTable.php create mode 100644 src/Database/Table/Tables/MigrationTable.php create mode 100644 src/Database/composer.json create mode 100644 tests/Support/Fixtures/Database/FailingMigration.php create mode 100644 tests/Support/Fixtures/Database/FakeDatabase.php create mode 100644 tests/Support/Fixtures/Database/InMemoryRepository.php create mode 100644 tests/Support/Fixtures/Database/RecordingSchema.php create mode 100644 tests/Support/Fixtures/Database/TestMigration.php create mode 100644 tests/Support/Fixtures/Database/TestTable.php create mode 100644 tests/Support/Fixtures/Database/register-wpcli-migrate-command.php create mode 100644 tests/Unit/Database/Cli/MigrateTest.php create mode 100644 tests/Unit/Database/DatabaseProviderTest.php create mode 100644 tests/Unit/Database/Lock/DatabaseLockTest.php create mode 100644 tests/Unit/Database/Migration/RepositoryTest.php create mode 100644 tests/Unit/Database/Migration/RunnerTest.php create mode 100644 tests/Unit/Database/Query/QueryBuilderTest.php create mode 100644 tests/Unit/Database/Query/QueryTest.php create mode 100644 tests/Unit/Database/SchemaTest.php create mode 100644 tests/Unit/Database/Table/CollectionTest.php create mode 100644 tests/Unit/Database/Table/ColumnTest.php create mode 100644 tests/Unit/Database/Table/CreateTableTest.php create mode 100644 tests/Unit/Database/Table/TableDefinitionTest.php create mode 100644 tests/Unit/Database/Table/Tables/LockTableTest.php create mode 100644 tests/Unit/Database/Table/Tables/MigrationTableTest.php create mode 100644 tests/wpunit/Database/Cli/DatabaseMigrateCest.php create mode 100644 tests/wpunit/Database/DatabaseIntegrationTest.php diff --git a/AGENTS.md b/AGENTS.md index cb30b7a..8520fde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Initial packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` - `stellarwp/foundation-lock` +- `stellarwp/foundation-database` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` @@ -44,6 +45,8 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. +Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. + Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. Shared generation infrastructure that is not itself a console command should live under `src/Cli/Generation/`. Default stubs should live with the package that owns the generated class shape. For example, WP-CLI command stubs live in `src/WPCli/stubs/` because the WPCli package owns the base `Command` API. The CLI package owns resolving, rendering, and writing generated files. @@ -78,6 +81,8 @@ Use contextual bindings with `$this->container->when()->needs()->give()` for sca Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. +`stellarwp/foundation-database` is a WordPress-backed database package. Keep its runtime implementation centered on `wpdb`, `dbDelta()`, WordPress table prefixes, and WP-CLI integration. If the project later needs file storage, Redis storage, PDO database support, or another non-WordPress backend, prefer a separate package or explicit driver package instead of making `foundation-database` a generic DBAL-style abstraction. + When adding a new split package, set its package `composer.json` PHP constraint to `>=8.3` unless the user explicitly says otherwise. PHP 7.4 release compatibility will be handled later by an automated Rector downgrade workflow, not by lowering the package PHP constraint during development. When adding external dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. @@ -156,6 +161,8 @@ Codeception tests run through SLIC. Use `.env.testing.slic` as the SLIC/Codecept Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, and `wpunit` is WordPress-loaded behavior through wp-browser. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. +Use `wpunit` for behavior that depends on WordPress runtime APIs such as `wpdb`, `dbDelta()`, hooks, global WordPress state, or real WP-CLI execution. Keep unit tests focused on portable package behavior and pure collaborators; do not build large fake WordPress runtimes in unit tests when the behavior can be covered with wp-browser. + Use `tests/WPUnitSupport/WPTestCase.php` as the base class for wpunit tests instead of extending Codeception's `WPTestCase` directly. Keep Codeception-generated actor files in `tests/CodeceptionSupport/`; that directory is ignored and excluded from lint/static analysis. After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. diff --git a/README.md b/README.md index adf151e..3d83bca 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) - [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) +- [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) - [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) diff --git a/composer.json b/composer.json index 6a30c6c..5ac936f 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "monorepo-php/monorepo": "^12.7", "nunomaduro/collision": "^8.9", "php-mock/php-mock-mockery": "^1.5", + "php-stubs/wordpress-stubs": "^7.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^11.5", @@ -34,8 +35,9 @@ "replace": { "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", - "stellarwp/foundation-log": "self.version", + "stellarwp/foundation-database": "self.version", "stellarwp/foundation-lock": "self.version", + "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-wpcli": "self.version" }, @@ -45,6 +47,7 @@ "psr-4": { "StellarWP\\Foundation\\Cli\\": "src/Cli/", "StellarWP\\Foundation\\Container\\": "src/Container/", + "StellarWP\\Foundation\\Database\\": "src/Database/", "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 44a650b..ec016cc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,6 +3,9 @@ parameters: paths: - src - tests + scanFiles: + - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + - vendor/wp-cli/wp-cli/php/utils.php excludePaths: analyse: - src/*/vendor/* diff --git a/src/Database/.gitattributes b/src/Database/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Database/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Database/.github/workflows/close-pull-request.yml b/src/Database/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Database/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Database/.gitignore b/src/Database/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Database/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php new file mode 100644 index 0000000..e292e81 --- /dev/null +++ b/src/Database/Cli/Migrate.php @@ -0,0 +1,173 @@ + $migrations + */ + public function __construct( + protected Container $container, + string $commandPrefix, + private readonly Runner $runner, + private readonly iterable $migrations, + private readonly Collection $tables + ) { + parent::__construct($this->container, $commandPrefix); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function runCommand(array $args = [], array $assocArgs = []): int { + $run = (bool) get_flag_value($assocArgs, self::FLAG_RUN, false); + $rollback = (bool) get_flag_value($assocArgs, self::FLAG_ROLLBACK, false); + $refresh = (bool) get_flag_value($assocArgs, self::FLAG_REFRESH, false); + $drop = (bool) get_flag_value($assocArgs, self::FLAG_DROP, false); + $createTable = (bool) get_flag_value($assocArgs, self::FLAG_CREATE_TABLE, false); + + if ($drop) { + WP_CLI::confirm('Are you sure you want to drop the Foundation database tables? This cannot be undone.', $assocArgs); + $this->tables->drop(); + WP_CLI::success('Foundation database tables were dropped.'); + + return self::SUCCESS; + } + + if ($createTable) { + $this->tables->create(); + WP_CLI::success('Foundation database tables are ready.'); + + return self::SUCCESS; + } + + if ($refresh) { + WP_CLI::confirm('Are you sure you want to roll back and rerun all Foundation database migrations?', $assocArgs); + $this->tables->create(); + $result = $this->runner->refresh($this->migrations); + WP_CLI::success(sprintf('Rolled back %d migrations and ran %d migrations.', count($result->rolledBack), count($result->ran))); + + return self::SUCCESS; + } + + if ($rollback) { + $this->tables->create(); + $result = $this->runner->rollback($this->migrations); + WP_CLI::success(sprintf('Rolled back %d migrations.', count($result->rolledBack))); + + return self::SUCCESS; + } + + if ($run) { + $this->tables->create(); + $result = $this->runner->run($this->migrations); + WP_CLI::success(sprintf('Ran %d migrations.', count($result->ran))); + + return self::SUCCESS; + } + + $this->showStatus(); + + return self::SUCCESS; + } + + protected function subcommand(): string { + return 'migrate'; + } + + protected function description(): string { + return 'List and manage database migrations.'; + } + + protected function arguments(): array { + return [ + [ + 'type' => self::FLAG, + 'name' => self::FLAG_RUN, + 'description' => 'Run pending migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_ROLLBACK, + 'description' => 'Rollback the latest migration batch.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_REFRESH, + 'description' => 'Rollback and rerun all migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_DROP, + 'description' => 'Drop Foundation database tables.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_CREATE_TABLE, + 'description' => 'Create Foundation database tables without running migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_YES, + 'description' => 'Skip confirmation prompts for destructive actions.', + 'optional' => true, + 'default' => false, + ], + ]; + } + + private function showStatus(): void { + if (! $this->tables->exists()) { + WP_CLI::warning('The Foundation database tables do not exist. Run this command with --create-table or --run.'); + + return; + } + + format_items('table', array_map( + static fn ($status): array => [ + 'migration' => $status->migration, + 'status' => $status->ran ? 'ran' : 'pending', + 'batch' => $status->batch ?? '', + 'ran_at' => $status->ranAt?->format('Y-m-d H:i:s') ?? '', + ], + $this->runner->status($this->migrations) + ), [ + 'migration', + 'status', + 'batch', + 'ran_at', + ]); + } +} diff --git a/src/Database/Contracts/Database.php b/src/Database/Contracts/Database.php new file mode 100644 index 0000000..8cc0027 --- /dev/null +++ b/src/Database/Contracts/Database.php @@ -0,0 +1,59 @@ +|null + */ + public function row(string $sql, mixed ...$bindings): ?array; + + /** + * @return list> + */ + public function rows(string $sql, mixed ...$bindings): array; + + public function value(string $sql, mixed ...$bindings): mixed; + + public function execute(string $sql, mixed ...$bindings): int; + + /** + * @param array $data + */ + public function insert(Table|string $table, array $data): int; + + /** + * @param array $data + * @param array $where + */ + public function update(Table|string $table, array $data, array $where): int; + + /** + * @param array $where + */ + public function delete(Table|string $table, array $where): int; + + public function quoteIdentifier(string $identifier): string; + + public function escLike(string $value): string; + + public function charsetCollate(): string; +} diff --git a/src/Database/Contracts/Migration.php b/src/Database/Contracts/Migration.php new file mode 100644 index 0000000..488938a --- /dev/null +++ b/src/Database/Contracts/Migration.php @@ -0,0 +1,24 @@ + + */ + public function all(): array; + + public function hasRun(string $migration): bool; + + public function recordRun(string $migration, int $batch): Record; + + public function deleteRun(string $migration): bool; + + public function nextBatch(): int; + + public function latestBatch(): ?int; + + /** + * @return list + */ + public function recordsForBatch(int $batch): array; +} diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php new file mode 100644 index 0000000..a755ab6 --- /dev/null +++ b/src/Database/Contracts/Schema.php @@ -0,0 +1,32 @@ +name(); + } + + if (str_starts_with($table, $this->wpdb->prefix)) { + return $table; + } + + return $this->wpdb->prefix . $table; + } + + public function tableExists(Table|string $table): bool { + $tableName = $this->tableName($table); + + return $this->row( + 'SHOW TABLES LIKE %s', + $this->escLike($tableName) + ) !== null; + } + + public function columnExists(Table|string $table, string $column): bool { + return $this->row( + 'SHOW COLUMNS FROM %i LIKE %s', + $this->tableName($table), + $this->escLike($column) + ) !== null; + } + + public function indexExists(Table|string $table, string $index): bool { + return $this->row( + 'SHOW INDEX FROM %i WHERE Key_name = %s', + $this->tableName($table), + $index + ) !== null; + } + + public function prepare(string $sql, mixed ...$bindings): string { + if ($bindings === []) { + return $sql; + } + + $bindings = array_values($bindings); + $prepared = $this->prepareWithWpdb($sql, $bindings); + + if (! is_string($prepared) || $prepared === '') { + throw new QueryException('Unable to prepare SQL statement.', $sql, $bindings, $this->lastError()); + } + + return $prepared; + } + + /** + * @return array|null + */ + public function row(string $sql, mixed ...$bindings): ?array { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $result = $this->wpdb->get_row($query, self::ARRAY_A); + + if ($result === null) { + $this->throwIfLastError('Unable to retrieve database row.', $sql, $bindings); + + return null; + } + + return $this->stringKeyedRow($result, $sql, $bindings); + } + + /** + * @return list> + */ + public function rows(string $sql, mixed ...$bindings): array { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $results = $this->wpdb->get_results($query, self::ARRAY_A); + + if ($results === null) { + $this->throwIfLastError('Unable to retrieve database rows.', $sql, $bindings); + + return []; + } + + $rows = []; + + foreach ($results as $result) { + $rows[] = $this->stringKeyedRow($result, $sql, $bindings); + } + + return $rows; + } + + public function value(string $sql, mixed ...$bindings): mixed { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $result = $this->wpdb->get_var($query); + + if ($result === null) { + $this->throwIfLastError('Unable to retrieve database value.', $sql, $bindings); + } + + return $result; + } + + public function execute(string $sql, mixed ...$bindings): int { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $result = $this->wpdb->query($query); + + if ($result === false) { + throw new QueryException($this->message('Unable to execute SQL statement.'), $sql, $bindings, $this->lastError()); + } + + return (int) $result; + } + + /** + * @param array $data + */ + public function insert(Table|string $table, array $data): int { + $result = $this->wpdb->insert($this->tableName($table), $data); + + if ($result === false) { + throw new QueryException($this->message('Unable to insert database row.'), 'INSERT', [], $this->lastError()); + } + + return (int) $this->wpdb->insert_id; + } + + /** + * @param array $data + * @param array $where + */ + public function update(Table|string $table, array $data, array $where): int { + $result = $this->wpdb->update($this->tableName($table), $data, $where); + + if ($result === false) { + throw new QueryException($this->message('Unable to update database rows.'), 'UPDATE', [], $this->lastError()); + } + + return (int) $result; + } + + /** + * @param array $where + */ + public function delete(Table|string $table, array $where): int { + $result = $this->wpdb->delete($this->tableName($table), $where); + + if ($result === false) { + throw new QueryException($this->message('Unable to delete database rows.'), 'DELETE', [], $this->lastError()); + } + + return (int) $result; + } + + public function quoteIdentifier(string $identifier): string { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + public function escLike(string $value): string { + return $this->wpdb->esc_like($value); + } + + public function charsetCollate(): string { + return $this->wpdb->get_charset_collate(); + } + + /** + * @param list $bindings + */ + private function throwIfLastError(string $fallback, string $sql, array $bindings): void { + $error = $this->lastError(); + + if ($error !== null) { + throw new QueryException($error, $sql, $bindings, $error); + } + } + + private function message(string $fallback): string { + return $this->lastError() ?? $fallback; + } + + private function lastError(): ?string { + $error = $this->wpdb->last_error; + + return $error !== '' ? $error : null; + } + + /** + * @param list $bindings + */ + private function prepareWithWpdb(string $sql, array $bindings): mixed { + $method = 'prepare'; + + return call_user_func_array([$this->wpdb, $method], array_merge([$sql], $bindings)); + } + + /** + * @param array $result + * @param list $bindings + * + * @return array + */ + private function stringKeyedRow(array $result, string $sql, array $bindings): array { + $row = []; + + foreach ($result as $key => $value) { + if (! is_string($key)) { + throw new DatabaseException('Database row result contained a non-string key.'); + } + + $row[$key] = $value; + } + + return $row; + } +} diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php new file mode 100644 index 0000000..75d5468 --- /dev/null +++ b/src/Database/DatabaseProvider.php @@ -0,0 +1,126 @@ +singletonIfMissing(self::MIGRATIONS, []); + $this->singletonIfMissing(self::MIGRATIONS_TABLE, $this->tableName('migrations_table', 'nexcess_foundation_migrations')); + $this->singletonIfMissing(self::LOCKS_TABLE, $this->tableName('locks_table', 'nexcess_foundation_locks')); + $this->singletonIfMissing(self::COMMAND_PREFIX, $this->config->get('database.command_prefix', 'foundation')); + $this->singletonIfMissing(self::LOCK_NAME, $this->config->get('database.lock_name', 'foundation-database-migrations')); + $this->singletonIfMissing(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); + + $this->configureContextualBindings(); + + $this->container->singleton(Database::class, static function (): Database { + $wpdb = $GLOBALS['wpdb'] ?? null; + + if (! $wpdb instanceof \wpdb) { + throw new DatabaseException('The global wpdb instance is not available.'); + } + + return new Database($wpdb); + }); + $this->container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); + $this->container->singleton(Schema::class, static fn (C $c): Schema => new Schema($c->get(DatabaseContract::class))); + $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); + $this->container->singleton(TableCollection::class); + $this->container->singleton(MigrationRecordRepository::class); + $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); + $this->container->singleton(DatabaseLock::class); + $this->container->singleton(MigrationTable::class); + $this->container->singleton(LockTable::class); + $this->container->singleton(Runner::class); + $this->container->singleton(Migrate::class); + } + + private function configureContextualBindings(): void { + $this->container->when(MigrationRecordRepository::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); + + $this->container->when(MigrationTable::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); + + $this->container->when(DatabaseLock::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + + $this->container->when(LockTable::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + + $this->container->when(Runner::class) + ->needs('$lockName') + ->give(static fn (C $c): string => $c->get(self::LOCK_NAME)); + + $this->container->when(Runner::class) + ->needs('$lockTtl') + ->give(static fn (C $c): int => $c->get(self::LOCK_TTL)); + + $this->container->when(Runner::class) + ->needs(Lock::class) + ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); + + $this->container->when(Migrate::class) + ->needs('$commandPrefix') + ->give(static fn (C $c): string => $c->get(self::COMMAND_PREFIX)); + + $this->container->when(Migrate::class) + ->needs('$migrations') + ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + + $this->container->when(TableCollection::class) + ->needs('$tables') + ->give(static fn (C $c): array => [ + $c->get(MigrationTable::class), + $c->get(LockTable::class), + ]); + } + + private function tableName(string $key, string $default): mixed { + $configured = $this->config->get('database.' . $key); + + if (is_string($configured) && $configured !== '') { + return $configured; + } + + return static fn (C $c): string => $c->get(DatabaseContract::class)->tableName($default); + } + + private function singletonIfMissing(string $id, mixed $implementation): void { + if ($this->container->has($id)) { + return; + } + + $this->container->singleton($id, $implementation); + } +} diff --git a/src/Database/Exceptions/DatabaseException.php b/src/Database/Exceptions/DatabaseException.php new file mode 100644 index 0000000..c4d0280 --- /dev/null +++ b/src/Database/Exceptions/DatabaseException.php @@ -0,0 +1,12 @@ + $bindings + */ + public function __construct( + string $message, + private readonly string $sql, + private readonly array $bindings = [], + private readonly ?string $databaseError = null, + ?Throwable $previous = null + ) { + parent::__construct($message, 0, $previous); + } + + public function sql(): string { + return $this->sql; + } + + /** + * @return list + */ + public function bindings(): array { + return $this->bindings; + } + + public function databaseError(): ?string { + return $this->databaseError; + } +} diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php new file mode 100644 index 0000000..1b6dad5 --- /dev/null +++ b/src/Database/Lock/DatabaseLock.php @@ -0,0 +1,144 @@ +assertValidName($name); + $this->assertValidTtl($ttl); + + $owner = bin2hex(random_bytes(16)); + $now = $this->format($this->clock->now()); + $expiresAt = $this->expiresAt($ttl); + + $this->database->execute( + 'INSERT INTO %i (name, owner, expires_at, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + owner = IF(expires_at <= %s, VALUES(owner), owner), + updated_at = IF(expires_at <= %s, VALUES(updated_at), updated_at), + expires_at = IF(expires_at <= %s, VALUES(expires_at), expires_at)', + $this->database->tableName($this->table), + $name, + $owner, + $this->format($expiresAt), + $now, + $now, + $now, + $now, + $now + ); + + $row = $this->database->row( + 'SELECT owner, expires_at FROM %i WHERE name = %s LIMIT 1', + $this->database->tableName($this->table), + $name + ); + + if ($row === null || ($row['owner'] ?? '') !== $owner) { + return null; + } + + return new LockToken( + name: $name, + owner: $owner, + expiresAt: new DateTimeImmutable((string) $row['expires_at']) + ); + } + + public function release(LockToken $token): bool { + return $this->database->execute( + 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > %s', + $this->database->tableName($this->table), + $token->name, + $token->owner, + $this->format($this->clock->now()) + ) > 0; + } + + /** + * @throws DateMalformedIntervalStringException + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidTtl($ttl); + + $expiresAt = $this->expiresAt($ttl); + $updated = $this->database->execute( + 'UPDATE %i SET expires_at = %s, updated_at = %s WHERE name = %s AND owner = %s AND expires_at > %s', + $this->database->tableName($this->table), + $this->format($expiresAt), + $this->format($this->clock->now()), + $token->name, + $token->owner, + $this->format($this->clock->now()) + ); + + if ($updated < 1) { + return null; + } + + return $token->refresh($expiresAt); + } + + public function isAcquired(string $name): bool { + $this->assertValidName($name); + + return $this->database->row( + 'SELECT name FROM %i WHERE name = %s AND expires_at > %s LIMIT 1', + $this->database->tableName($this->table), + $name, + $this->format($this->clock->now()) + ) !== null; + } + + /** + * @throws DateMalformedIntervalStringException + */ + private function expiresAt(int $ttl): DateTimeImmutable { + $this->assertValidTtl($ttl); + + return $this->clock->now()->add(new DateInterval(sprintf('PT%dS', $ttl))); + } + + private function assertValidTtl(int $ttl): void { + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); + } + } + + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + } + + private function format(DateTimeImmutable $date): string { + return $date->format('Y-m-d H:i:s'); + } +} diff --git a/src/Database/Migration/Record.php b/src/Database/Migration/Record.php new file mode 100644 index 0000000..21beef3 --- /dev/null +++ b/src/Database/Migration/Record.php @@ -0,0 +1,19 @@ + + */ + public function all(): array { + $records = []; + + foreach ($this->database->rows(sprintf( + 'SELECT id, migration, batch, ran_at FROM %s ORDER BY id ASC', + $this->database->quoteIdentifier($this->database->tableName($this->table)) + )) as $row) { + $record = $this->recordFromRow($row); + + $records[$record->migration] = $record; + } + + return $records; + } + + public function hasRun(string $migration): bool { + return $this->database->row( + 'SELECT id FROM %i WHERE migration = %s LIMIT 1', + $this->database->tableName($this->table), + $migration + ) !== null; + } + + public function recordRun(string $migration, int $batch): Record { + $ranAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); + + $this->database->execute( + 'INSERT INTO %i (migration, batch, ran_at) VALUES (%s, %d, %s)', + $this->database->tableName($this->table), + $migration, + $batch, + $ranAt->format('Y-m-d H:i:s') + ); + + $row = $this->database->row( + 'SELECT id, migration, batch, ran_at FROM %i WHERE migration = %s LIMIT 1', + $this->database->tableName($this->table), + $migration + ); + + if ($row === null) { + return new Record(0, $migration, $batch, $ranAt); + } + + return $this->recordFromRow($row); + } + + public function deleteRun(string $migration): bool { + return $this->database->execute( + 'DELETE FROM %i WHERE migration = %s', + $this->database->tableName($this->table), + $migration + ) > 0; + } + + public function nextBatch(): int { + $latest = $this->latestBatch(); + + return $latest === null ? 1 : $latest + 1; + } + + public function latestBatch(): ?int { + $row = $this->database->row(sprintf( + 'SELECT MAX(batch) AS batch FROM %s', + $this->database->quoteIdentifier($this->database->tableName($this->table)) + )); + + if ($row === null || $row['batch'] === null) { + return null; + } + + return (int) $row['batch']; + } + + /** + * @return list + */ + public function recordsForBatch(int $batch): array { + return array_map( + fn (array $row): Record => $this->recordFromRow($row), + $this->database->rows( + 'SELECT id, migration, batch, ran_at FROM %i WHERE batch = %d ORDER BY id ASC', + $this->database->tableName($this->table), + $batch + ) + ); + } + + /** + * @param array $row + */ + private function recordFromRow(array $row): Record { + return new Record( + id: (int) $row['id'], + migration: (string) $row['migration'], + batch: (int) $row['batch'], + ranAt: new DateTimeImmutable((string) $row['ran_at'], new DateTimeZone('UTC')) + ); + } +} diff --git a/src/Database/Migration/Result.php b/src/Database/Migration/Result.php new file mode 100644 index 0000000..1c5193d --- /dev/null +++ b/src/Database/Migration/Result.php @@ -0,0 +1,25 @@ + $ran + * @param list $rolledBack + * @param list $skipped + */ + public function __construct( + public array $ran = [], + public array $rolledBack = [], + public array $skipped = [] + ) { + } + + public function count(): int { + return count($this->ran) + count($this->rolledBack); + } +} diff --git a/src/Database/Migration/Runner.php b/src/Database/Migration/Runner.php new file mode 100644 index 0000000..6ec2871 --- /dev/null +++ b/src/Database/Migration/Runner.php @@ -0,0 +1,208 @@ + $migrations + */ + public function run(iterable $migrations): Result { + return $this->locked(function () use ($migrations): Result { + $ran = []; + $skipped = []; + $batch = $this->repository->nextBatch(); + $migrations = $this->normalize($migrations); + + foreach ($migrations as $migration) { + if ($this->repository->hasRun($migration->id())) { + $skipped[] = $migration->id(); + continue; + } + + try { + $migration->up($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRunning($migration->id(), $throwable); + } + + $this->repository->recordRun($migration->id(), $batch); + $ran[] = $migration->id(); + } + + return new Result(ran: $ran, skipped: $skipped); + }); + } + + /** + * @param iterable $migrations + */ + public function rollback(iterable $migrations, ?int $batch = null): Result { + return $this->locked(function () use ($migrations, $batch): Result { + $batch ??= $this->repository->latestBatch(); + + if ($batch === null) { + return new Result(); + } + + return $this->rollbackRecords( + $this->normalize($migrations), + $this->repository->recordsForBatch($batch) + ); + }); + } + + /** + * @param iterable $migrations + */ + public function refresh(iterable $migrations): Result { + return $this->locked(function () use ($migrations): Result { + $normalized = $this->normalize($migrations); + $rollback = $this->rollbackRecords($normalized, array_values($this->repository->all())); + $run = $this->runWithoutLock($normalized); + + return new Result( + ran: $run->ran, + rolledBack: $rollback->rolledBack, + skipped: $run->skipped + ); + }); + } + + /** + * @param iterable $migrations + * + * @return list + */ + public function status(iterable $migrations): array { + $records = $this->repository->all(); + $statuses = []; + + foreach ($this->normalize($migrations) as $migration) { + $statuses[] = isset($records[$migration->id()]) + ? Status::fromRecord($records[$migration->id()]) + : Status::pending($migration->id()); + } + + return $statuses; + } + + /** + * @param array $migrations + * @param list $records + */ + private function rollbackRecords(array $migrations, array $records): Result { + usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); + + $rolledBack = []; + $skipped = []; + + foreach ($records as $record) { + $migration = $migrations[$record->migration] ?? null; + + if ($migration === null) { + $skipped[] = $record->migration; + continue; + } + + try { + $migration->down($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRollingBack($migration->id(), $throwable); + } + + $this->repository->deleteRun($migration->id()); + $rolledBack[] = $migration->id(); + } + + return new Result(rolledBack: $rolledBack, skipped: $skipped); + } + + /** + * @param array $migrations + */ + private function runWithoutLock(array $migrations): Result { + $ran = []; + $skipped = []; + $batch = $this->repository->nextBatch(); + + foreach ($migrations as $migration) { + if ($this->repository->hasRun($migration->id())) { + $skipped[] = $migration->id(); + continue; + } + + try { + $migration->up($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRunning($migration->id(), $throwable); + } + + $this->repository->recordRun($migration->id(), $batch); + $ran[] = $migration->id(); + } + + return new Result(ran: $ran, skipped: $skipped); + } + + /** + * @param iterable $migrations + * + * @return array + */ + private function normalize(iterable $migrations): array { + $normalized = []; + + foreach ($migrations as $migration) { + if (isset($normalized[$migration->id()])) { + throw DuplicateMigration::forMigration($migration->id()); + } + + $normalized[$migration->id()] = $migration; + } + + return $normalized; + } + + /** + * @template T + * + * @param callable(): T $callback + * + * @return T + */ + private function locked(callable $callback): mixed { + $token = $this->lock->acquire($this->lockName, $this->lockTtl); + + if ($token === null) { + throw MigrationLockFailed::forLock($this->lockName); + } + + try { + return $callback(); + } finally { + $this->lock->release($token); + } + } +} diff --git a/src/Database/Migration/Status.php b/src/Database/Migration/Status.php new file mode 100644 index 0000000..7e13399 --- /dev/null +++ b/src/Database/Migration/Status.php @@ -0,0 +1,32 @@ +migration, + ran: true, + batch: $record->batch, + ranAt: $record->ranAt + ); + } +} diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php new file mode 100644 index 0000000..4278b3a --- /dev/null +++ b/src/Database/Query/Query.php @@ -0,0 +1,54 @@ + $bindings + */ + public function __construct( + private Database $database, + private string $sql, + private array $bindings = [] + ) { + } + + public function toSql(): string { + return $this->sql; + } + + /** + * @return list + */ + public function bindings(): array { + return $this->bindings; + } + + public function toPreparedSql(): string { + return $this->database->prepare($this->sql, ...$this->bindings); + } + + /** + * @return list> + */ + public function get(): array { + return $this->database->rows($this->sql, ...$this->bindings); + } + + /** + * @return array|null + */ + public function first(): ?array { + return $this->database->row($this->sql, ...$this->bindings); + } + + public function value(): mixed { + return $this->database->value($this->sql, ...$this->bindings); + } +} diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php new file mode 100644 index 0000000..b8c90fe --- /dev/null +++ b/src/Database/Query/QueryBuilder.php @@ -0,0 +1,180 @@ + + */ + private array $columns = ['*']; + + /** + * @var list + */ + private array $where = []; + + /** + * @var list + */ + private array $bindings = []; + + /** + * @var list + */ + private array $orderBy = []; + + private ?int $limit = null; + + private ?int $offset = null; + + public function __construct( + private readonly Database $database, + private readonly Table|string $table, + private readonly ?string $alias = null + ) { + } + + public function select(string ...$columns): self { + $this->columns = $columns === [] ? ['*'] : array_values($columns); + + return $this; + } + + public function where(string $column, string $operator, mixed $value): self { + $this->where[] = sprintf('%s %s %%s', $this->database->quoteIdentifier($column), $this->operator($operator)); + $this->bindings[] = $value; + + return $this; + } + + public function orderBy(string $column, string $direction = 'ASC'): self { + $direction = strtoupper($direction); + + if (! in_array($direction, ['ASC', 'DESC'], true)) { + throw new InvalidArgumentException('Order direction must be ASC or DESC.'); + } + + $this->orderBy[] = sprintf('%s %s', $this->database->quoteIdentifier($column), $direction); + + return $this; + } + + public function limit(int $limit, ?int $offset = null): self { + if ($limit < 1) { + throw new InvalidArgumentException('Query limit must be greater than zero.'); + } + + if ($offset !== null && $offset < 0) { + throw new InvalidArgumentException('Query offset cannot be negative.'); + } + + $this->limit = $limit; + $this->offset = $offset; + + return $this; + } + + public function query(): Query { + return new Query($this->database, $this->toSql(), $this->bindings()); + } + + public function toSql(): string { + $sql = sprintf( + 'SELECT %s FROM %s%s', + $this->selectSql(), + $this->database->quoteIdentifier($this->database->tableName($this->table)), + $this->aliasSql() + ); + + if ($this->where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $this->where); + } + + if ($this->orderBy !== []) { + $sql .= ' ORDER BY ' . implode(', ', $this->orderBy); + } + + if ($this->limit !== null) { + $sql .= ' LIMIT %d'; + + if ($this->offset !== null) { + $sql .= ' OFFSET %d'; + } + } + + return $sql; + } + + /** + * @return list + */ + public function bindings(): array { + $bindings = $this->bindings; + + if ($this->limit !== null) { + $bindings[] = $this->limit; + + if ($this->offset !== null) { + $bindings[] = $this->offset; + } + } + + return $bindings; + } + + public function toPreparedSql(): string { + return $this->database->prepare($this->toSql(), ...$this->bindings()); + } + + /** + * @return list> + */ + public function get(): array { + return $this->queryWithLimitBindings()->get(); + } + + /** + * @return array|null + */ + public function first(): ?array { + return $this->queryWithLimitBindings()->first(); + } + + private function queryWithLimitBindings(): Query { + return new Query($this->database, $this->toSql(), $this->bindings()); + } + + private function selectSql(): string { + if ($this->columns === ['*']) { + return '*'; + } + + return implode(', ', array_map($this->database->quoteIdentifier(...), $this->columns)); + } + + private function aliasSql(): string { + if ($this->alias === null || $this->alias === '') { + return ''; + } + + return ' AS ' . $this->database->quoteIdentifier($this->alias); + } + + private function operator(string $operator): string { + $operator = strtoupper(trim($operator)); + + if (! in_array($operator, ['=', '!=', '<>', '>', '>=', '<', '<=', 'LIKE'], true)) { + throw new InvalidArgumentException(sprintf('Unsupported query operator: %s.', $operator)); + } + + return $operator; + } +} diff --git a/src/Database/README.md b/src/Database/README.md new file mode 100644 index 0000000..5ab23c6 --- /dev/null +++ b/src/Database/README.md @@ -0,0 +1,143 @@ +# Foundation Database + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +## Installation + +```shell +composer require stellarwp/foundation-database +``` + +## Overview + +Foundation Database is a WordPress-backed database package. It provides a small migration runner, migration and table collections, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. + +This package intentionally targets WordPress runtime APIs instead of acting as a generic database abstraction. Migration classes depend on a small schema contract so application packages can define migration behavior without calling `wpdb` directly. + +## Registering The Provider + +Register `DatabaseProvider` in the application container when the project needs Foundation-managed migrations: + +```php +use StellarWP\Foundation\Database\DatabaseProvider; + +$container->register(DatabaseProvider::class); +``` + +The provider registers: + +- `StellarWP\Foundation\Database\Database` +- `StellarWP\Foundation\Database\Contracts\Database` +- `StellarWP\Foundation\Database\Schema` +- `StellarWP\Foundation\Database\Table\Collection` +- `StellarWP\Foundation\Database\Table\Tables\MigrationTable` +- `StellarWP\Foundation\Database\Table\Tables\LockTable` +- `StellarWP\Foundation\Database\Contracts\Repository` for the migration ledger +- `StellarWP\Foundation\Database\Migration\Runner` +- `StellarWP\Foundation\Database\Lock\DatabaseLock` for the migration runner + +By default, WordPress tables are named: + +- `nexcess_foundation_migrations` +- `nexcess_foundation_locks` + +Configure these through the Foundation config keys `database.migrations_table` and `database.locks_table` when an application needs different table names. Configured table names are treated as full table names, so include the WordPress prefix yourself when overriding them. + +## Running Queries + +Application services can inject `StellarWP\Foundation\Database\Contracts\Database` when they need to run queries: + +```php +use StellarWP\Foundation\Database\Contracts\Database; + +final readonly class ReportRepository +{ + public function __construct( + private Database $database + ) { + } + + public function published(): array + { + return $this->database + ->table('reports') + ->select('id', 'title') + ->where('status', '=', 'published') + ->orderBy('id', 'DESC') + ->limit(25) + ->get(); + } +} +``` + +Queries can be inspected before they are executed: + +```php +$query = $database + ->table('reports') + ->where('status', '=', 'published') + ->limit(25); + +$query->toSql(); +$query->bindings(); +$query->toPreparedSql(); +``` + +## Defining Migrations + +Migrations implement `StellarWP\Foundation\Database\Contracts\Migration`: + +```php +use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Contracts\Schema; + +final readonly class CreateReportsTable implements Migration +{ + public function id(): string + { + return '2026_06_23_000001_create_reports_table'; + } + + public function up(Schema $schema): void + { + $schema->createOrUpdate( + sprintf( + 'CREATE TABLE %s ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + title varchar(191) NOT NULL, + PRIMARY KEY (id) + );', + $schema->quoteIdentifier('wp_reports') + ) + ); + } + + public function down(Schema $schema): void + { + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $schema->quoteIdentifier('wp_reports') + )); + } +} +``` + +Applications should bind `DatabaseProvider::MIGRATIONS` to the ordered list of `Migration` instances they want the runner to manage. If migrations are bound before registering `DatabaseProvider`, the provider will preserve the existing binding. + +Foundation's own migration infrastructure tables implement `StellarWP\Foundation\Database\Contracts\Table` and are wired into `Table\Collection`. Applications can use the same `Table` contract for their own custom tables. When a table should be recorded in the migration ledger, wrap it in `StellarWP\Foundation\Database\Table\CreateTable` and add that migration instance to `DatabaseProvider::MIGRATIONS`. + +## WP-CLI + +The package includes a `migrate` command class for projects using `stellarwp/foundation-wpcli`. Register it from the consuming application's CLI provider with the rest of the project's commands. + +Available flags: + +- `--run` runs pending migrations. +- `--rollback` rolls back the latest migration batch. +- `--refresh` rolls back all known migrations and runs them again. +- `--drop` drops the migrations and lock tables after confirmation. +- `--create-table` creates the migrations and lock tables without running migrations. +- `--yes` skips confirmation prompts for destructive actions. + +Running the command without a flag prints migration status. diff --git a/src/Database/Schema.php b/src/Database/Schema.php new file mode 100644 index 0000000..c41c648 --- /dev/null +++ b/src/Database/Schema.php @@ -0,0 +1,102 @@ +dbDelta ?? $this->loadDbDelta(); + + $dbDelta($sql ?? $this->createTableSql($table)); + } + + public function execute(string $sql): void { + $this->database->execute($sql); + } + + public function hasTable(Table|string $table): bool { + return $this->database->tableExists($table); + } + + public function hasIndex(Table|string $table, string $index): bool { + return $this->database->indexExists($table, $index); + } + + public function dropIndex(Table|string $table, string $index): void { + $this->database->execute(sprintf( + 'ALTER TABLE %s DROP INDEX %s', + $this->database->quoteIdentifier($this->database->tableName($table)), + $this->database->quoteIdentifier($index) + )); + } + + public function drop(Table|string $table): void { + $this->database->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($this->database->tableName($table)) + )); + } + + public function quoteIdentifier(string $identifier): string { + return $this->database->quoteIdentifier($identifier); + } + + private function createTableSql(Table|string $table): string { + if (is_string($table)) { + return $table; + } + + $definition = $table->definition(); + $definition->assertValid(); + + $parts = []; + + foreach ($definition->columns() as $column) { + $parts[] = ' ' . $column->sql(); + } + + foreach ($definition->indexes() as $index) { + $parts[] = ' ' . $index->sql(); + } + + return sprintf( + "CREATE TABLE %s (\n%s\n) %s;", + $this->database->quoteIdentifier($table->name()), + implode(",\n", $parts), + $this->database->charsetCollate() + ); + } + + /** + * @return Closure(string): mixed + */ + private function loadDbDelta(): Closure { + if (! function_exists('dbDelta') && defined('ABSPATH')) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + if (! function_exists('dbDelta')) { + throw new DatabaseException('WordPress dbDelta() is not available.'); + } + + return dbDelta(...); + } +} diff --git a/src/Database/Table/Collection.php b/src/Database/Table/Collection.php new file mode 100644 index 0000000..5738dc8 --- /dev/null +++ b/src/Database/Table/Collection.php @@ -0,0 +1,78 @@ + + */ +final class Collection implements IteratorAggregate +{ + /** + * @var list + */ + private array $tables = []; + + /** + * @param iterable
$tables + */ + public function __construct( + private readonly Schema $schema, + iterable $tables = [] + ) { + foreach ($tables as $table) { + $this->add($table); + } + } + + public function add(Table ...$tables): void { + foreach ($tables as $table) { + $this->tables[] = $table; + } + } + + /** + * @return list
+ */ + public function all(): array { + return $this->tables; + } + + public function create(): void { + foreach ($this->tables as $table) { + if (! $this->schema->hasTable($table)) { + $this->schema->createOrUpdate($table); + } + } + } + + public function drop(): void { + foreach ($this->tables as $table) { + $this->schema->drop($table); + } + } + + public function exists(): bool { + foreach ($this->tables as $table) { + if (! $this->schema->hasTable($table)) { + return false; + } + } + + return true; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable { + return new ArrayIterator($this->tables); + } +} diff --git a/src/Database/Table/Column.php b/src/Database/Table/Column.php new file mode 100644 index 0000000..728febb --- /dev/null +++ b/src/Database/Table/Column.php @@ -0,0 +1,49 @@ +name), + $this->type, + $this->length === null ? '' : sprintf('(%d)', $this->length), + $this->unsigned ? ' unsigned' : '', + $this->nullable ? ' NULL' : ' NOT NULL' + ); + + if ($this->default !== null) { + $sql .= sprintf(' DEFAULT %s', $this->formatDefault($this->default)); + } + + if ($this->extra !== '') { + $sql .= ' ' . $this->extra; + } + + return $sql; + } + + private function formatDefault(mixed $default): string { + if (is_int($default) || is_float($default)) { + return (string) $default; + } + + return "'" . addslashes((string) $default) . "'"; + } +} diff --git a/src/Database/Table/CreateTable.php b/src/Database/Table/CreateTable.php new file mode 100644 index 0000000..17910ef --- /dev/null +++ b/src/Database/Table/CreateTable.php @@ -0,0 +1,32 @@ +table->id(); + } + + public function up(Schema $schema): void { + if (! $schema->hasTable($this->table)) { + $schema->createOrUpdate($this->table); + } + } + + public function down(Schema $schema): void { + $schema->drop($this->table); + } +} diff --git a/src/Database/Table/Index.php b/src/Database/Table/Index.php new file mode 100644 index 0000000..ed8a97f --- /dev/null +++ b/src/Database/Table/Index.php @@ -0,0 +1,36 @@ + $columns + */ + public function __construct( + public string $name, + public array $columns, + public string $type = IndexType::KEY + ) { + } + + public function sql(): string { + $columns = implode(', ', array_map( + static fn (string $column): string => '`' . str_replace('`', '``', $column) . '`', + $this->columns + )); + + return match ($this->type) { + IndexType::PRIMARY => sprintf('PRIMARY KEY (%s)', $columns), + IndexType::UNIQUE => sprintf('UNIQUE KEY %s (%s)', $this->quotedName(), $columns), + default => sprintf('KEY %s (%s)', $this->quotedName(), $columns), + }; + } + + private function quotedName(): string { + return '`' . str_replace('`', '``', $this->name) . '`'; + } +} diff --git a/src/Database/Table/IndexType.php b/src/Database/Table/IndexType.php new file mode 100644 index 0000000..88d816a --- /dev/null +++ b/src/Database/Table/IndexType.php @@ -0,0 +1,16 @@ + + */ + private array $columns = []; + + /** + * @var list + */ + private array $indexes = []; + + private function __construct( + private readonly Table $table + ) { + } + + public static function for(Table $table): self { + return new self($table); + } + + public function bigIncrements(string $name): self { + return $this + ->column(new Column($name, 'bigint', 20, unsigned: true, extra: 'AUTO_INCREMENT')) + ->primary($name); + } + + public function string(string $name, int $length = 191, ?string $default = null): self { + return $this->column(new Column($name, 'varchar', $length, default: $default)); + } + + public function unsignedInteger(string $name, int $length = 10, ?int $default = null): self { + return $this->column(new Column($name, 'int', $length, unsigned: true, default: $default)); + } + + public function dateTime(string $name): self { + return $this->column(new Column($name, 'datetime')); + } + + public function text(string $name): self { + return $this->column(new Column($name, 'text')); + } + + public function column(Column $column): self { + $this->columns[$column->name] = $column; + + return $this; + } + + public function primary(string ...$columns): self { + $this->indexes[] = new Index('primary', $this->nonEmptyColumns(array_values($columns)), IndexType::PRIMARY); + + return $this; + } + + public function unique(string $name, string ...$columns): self { + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::UNIQUE); + + return $this; + } + + public function index(string $name, string ...$columns): self { + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::KEY); + + return $this; + } + + /** + * @return list + */ + public function columns(): array { + return array_values($this->columns); + } + + /** + * @return list + */ + public function indexes(): array { + return $this->indexes; + } + + /** + * @return list + */ + public function validationErrors(): array { + $errors = []; + + if ($this->columns === []) { + $errors[] = sprintf('Table %s does not define any columns.', $this->table->id()); + } + + foreach ($this->indexes as $index) { + foreach ($index->columns as $column) { + if (! isset($this->columns[$column])) { + $errors[] = sprintf('Index %s references missing column %s.', $index->name, $column); + } + } + } + + return $errors; + } + + public function assertValid(): void { + $errors = $this->validationErrors(); + + if ($errors !== []) { + throw new InvalidArgumentException(implode(' ', $errors)); + } + } + + /** + * @param list $columns + * + * @return non-empty-list + */ + private function nonEmptyColumns(array $columns): array { + if ($columns === []) { + throw new InvalidArgumentException('An index must define at least one column.'); + } + + return $columns; + } +} diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php new file mode 100644 index 0000000..16e903d --- /dev/null +++ b/src/Database/Table/Tables/LockTable.php @@ -0,0 +1,40 @@ +database->tableName($this->table); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->string('name', 191) + ->string('owner', 64) + ->dateTime('expires_at') + ->dateTime('created_at') + ->dateTime('updated_at') + ->primary('name') + ->index('expires_at', 'expires_at'); + } +} diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php new file mode 100644 index 0000000..788652d --- /dev/null +++ b/src/Database/Table/Tables/MigrationTable.php @@ -0,0 +1,39 @@ +database->tableName($this->table); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('migration', 191) + ->unsignedInteger('batch') + ->dateTime('ran_at') + ->unique('migration', 'migration') + ->index('batch', 'batch'); + } +} diff --git a/src/Database/composer.json b/src/Database/composer.json new file mode 100644 index 0000000..737d124 --- /dev/null +++ b/src/Database/composer.json @@ -0,0 +1,26 @@ +{ + "name": "stellarwp/foundation-database", + "type": "library", + "description": "Foundation Database package.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^1.2", + "stellarwp/foundation-lock": "^1.2", + "stellarwp/foundation-wpcli": "^1.2" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Database\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.2.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/Database/FailingMigration.php b/tests/Support/Fixtures/Database/FailingMigration.php new file mode 100644 index 0000000..fff6556 --- /dev/null +++ b/tests/Support/Fixtures/Database/FailingMigration.php @@ -0,0 +1,37 @@ +id; + } + + public function up(Schema $schema): void { + if ($this->failUp) { + throw new RuntimeException('Migration up failed.'); + } + + $schema->execute('up:' . $this->id); + } + + public function down(Schema $schema): void { + if ($this->failDown) { + throw new RuntimeException('Migration down failed.'); + } + + $schema->execute('down:' . $this->id); + } +} diff --git a/tests/Support/Fixtures/Database/FakeDatabase.php b/tests/Support/Fixtures/Database/FakeDatabase.php new file mode 100644 index 0000000..33553e6 --- /dev/null +++ b/tests/Support/Fixtures/Database/FakeDatabase.php @@ -0,0 +1,159 @@ + + */ + public array $executed = []; + + /** + * @var list + */ + public array $rowQueries = []; + + /** + * @var list + */ + public array $rowsQueries = []; + + /** + * @var list|callable(string, self): (array|null)|null> + */ + public array $rowResults = []; + + /** + * @var list>> + */ + public array $rowsResults = []; + + /** + * @var list + */ + public array $executeResults = []; + + public int $insertId = 1; + + public function table(Table|string $table, ?string $alias = null): QueryBuilder { + return new QueryBuilder($this, $table, $alias); + } + + public function tableName(Table|string $table): string { + if ($table instanceof Table) { + return $table->name(); + } + + if (str_starts_with($table, 'wp_')) { + return $table; + } + + return 'wp_' . $table; + } + + public function tableExists(Table|string $table): bool { + return $this->row('SHOW TABLES LIKE %s', $this->escLike($this->tableName($table))) !== null; + } + + public function columnExists(Table|string $table, string $column): bool { + return $this->row('SHOW COLUMNS FROM %i LIKE %s', $this->tableName($table), $this->escLike($column)) !== null; + } + + public function indexExists(Table|string $table, string $index): bool { + return $this->row('SHOW INDEX FROM %i WHERE Key_name = %s', $this->tableName($table), $index) !== null; + } + + public function execute(string $sql, mixed ...$bindings): int { + $this->executed[] = $this->prepare($sql, ...$bindings); + + return array_shift($this->executeResults) ?? 1; + } + + /** + * @return array|null + */ + public function row(string $sql, mixed ...$bindings): ?array { + $query = $this->prepare($sql, ...$bindings); + $this->rowQueries[] = $query; + + $result = array_shift($this->rowResults); + + if (is_callable($result)) { + return $result($query, $this); + } + + return $result; + } + + /** + * @return list> + */ + public function rows(string $sql, mixed ...$bindings): array { + $this->rowsQueries[] = $this->prepare($sql, ...$bindings); + + return array_shift($this->rowsResults) ?? []; + } + + public function value(string $sql, mixed ...$bindings): mixed { + $row = $this->row($sql, ...$bindings); + + if ($row === null) { + return null; + } + + return reset($row); + } + + public function insert(Table|string $table, array $data): int { + $this->executed[] = 'INSERT ' . $this->tableName($table); + + return $this->insertId; + } + + public function update(Table|string $table, array $data, array $where): int { + $this->executed[] = 'UPDATE ' . $this->tableName($table); + + return array_shift($this->executeResults) ?? 1; + } + + public function delete(Table|string $table, array $where): int { + $this->executed[] = 'DELETE ' . $this->tableName($table); + + return array_shift($this->executeResults) ?? 1; + } + + public function prepare(string $sql, mixed ...$bindings): string { + $position = 0; + + return preg_replace_callback('/(? + */ + private array $records = []; + + private int $nextId = 1; + + /** + * @return array + */ + public function all(): array { + return $this->records; + } + + public function hasRun(string $migration): bool { + return isset($this->records[$migration]); + } + + public function recordRun(string $migration, int $batch): Record { + $record = new Record( + id: $this->nextId++, + migration: $migration, + batch: $batch, + ranAt: new DateTimeImmutable('2026-01-01 00:00:00') + ); + + $this->records[$migration] = $record; + + return $record; + } + + public function deleteRun(string $migration): bool { + if (! isset($this->records[$migration])) { + return false; + } + + unset($this->records[$migration]); + + return true; + } + + public function nextBatch(): int { + $latest = $this->latestBatch(); + + return $latest === null ? 1 : $latest + 1; + } + + public function latestBatch(): ?int { + $batches = array_map(static fn (Record $record): int => $record->batch, $this->records); + + return $batches === [] ? null : max($batches); + } + + /** + * @return list + */ + public function recordsForBatch(int $batch): array { + return array_values(array_filter( + $this->records, + static fn (Record $record): bool => $record->batch === $batch + )); + } +} diff --git a/tests/Support/Fixtures/Database/RecordingSchema.php b/tests/Support/Fixtures/Database/RecordingSchema.php new file mode 100644 index 0000000..9363bb9 --- /dev/null +++ b/tests/Support/Fixtures/Database/RecordingSchema.php @@ -0,0 +1,66 @@ + + */ + public array $statements = []; + + /** + * @var array + */ + public array $tables = []; + + /** + * @var array> + */ + public array $indexes = []; + + public function createOrUpdate(Table|string $table, ?string $sql = null): void { + $name = $table instanceof Table ? $table->name() : $table; + $this->tables[$name] = true; + $this->statements[] = 'createOrUpdate:' . ($sql ?? $name); + } + + public function execute(string $sql): void { + $this->statements[] = $sql; + } + + public function hasTable(Table|string $table): bool { + $name = $table instanceof Table ? $table->name() : $table; + + return $this->tables[$name] ?? false; + } + + public function hasIndex(Table|string $table, string $index): bool { + $name = $table instanceof Table ? $table->name() : $table; + + return $this->indexes[$name][$index] ?? false; + } + + public function dropIndex(Table|string $table, string $index): void { + $name = $table instanceof Table ? $table->name() : $table; + + unset($this->indexes[$name][$index]); + + $this->statements[] = sprintf('dropIndex:%s:%s', $name, $index); + } + + public function drop(Table|string $table): void { + $name = $table instanceof Table ? $table->name() : $table; + + unset($this->tables[$name]); + + $this->statements[] = 'drop:' . $name; + } + + public function quoteIdentifier(string $identifier): string { + return '`' . str_replace('`', '``', $identifier) . '`'; + } +} diff --git a/tests/Support/Fixtures/Database/TestMigration.php b/tests/Support/Fixtures/Database/TestMigration.php new file mode 100644 index 0000000..917f47f --- /dev/null +++ b/tests/Support/Fixtures/Database/TestMigration.php @@ -0,0 +1,26 @@ +id; + } + + public function up(Schema $schema): void { + $schema->execute('up:' . $this->id); + } + + public function down(Schema $schema): void { + $schema->execute('down:' . $this->id); + } +} diff --git a/tests/Support/Fixtures/Database/TestTable.php b/tests/Support/Fixtures/Database/TestTable.php new file mode 100644 index 0000000..585bd4f --- /dev/null +++ b/tests/Support/Fixtures/Database/TestTable.php @@ -0,0 +1,28 @@ +id; + } + + public function name(): string { + return $this->name; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id'); + } +} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php new file mode 100644 index 0000000..90e080e --- /dev/null +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -0,0 +1,89 @@ +bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + + $database = new Database($wpdb); + $schema = new Schema($database); + $migrationTable = $wpdb->prefix . 'foundation_cli_migrations'; + $lockTable = $wpdb->prefix . 'foundation_cli_locks'; + $exampleTable = $wpdb->prefix . 'foundation_cli_example'; + + $migration = new class($exampleTable) implements Migration { + public function __construct( + private readonly string $exampleTable + ) { + } + + public function id(): string { + return '2026_06_23_000001_create_foundation_cli_example'; + } + + public function up(SchemaContract $schema): void { + $schema->createOrUpdate(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + );', + $schema->quoteIdentifier($this->exampleTable) + )); + } + + public function down(SchemaContract $schema): void { + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $schema->quoteIdentifier($this->exampleTable) + )); + } + }; + + $command = new Migrate( + $container, + 'foundation', + new Runner( + new Repository($database, $migrationTable), + $schema, + new DatabaseLock($database, $lockTable) + ), + [$migration], + new TableCollection($schema, [ + new MigrationTable($database, $migrationTable), + new LockTable($database, $lockTable), + ]) + ); + + $command->register(); +}); diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php new file mode 100644 index 0000000..dbcda85 --- /dev/null +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -0,0 +1,102 @@ + []); + $command = new Migrate( + $this->container, + 'foundation', + new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock()), + [], + new TableCollection($wpSchema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ]) + ); + + $command->register(); + + $deferredAdditions = WP_CLI::get_deferred_additions(); + + $this->assertArrayHasKey('foundation migrate', $deferredAdditions); + $this->assertSame('foundation', $deferredAdditions['foundation migrate']['parent']); + $this->assertSame('List and manage database migrations.', $deferredAdditions['foundation migrate']['args']['shortdesc']); + $this->assertSame([ + [ + 'type' => 'flag', + 'name' => 'run', + 'description' => 'Run pending migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'rollback', + 'description' => 'Rollback the latest migration batch.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'refresh', + 'description' => 'Rollback and rerun all migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'drop', + 'description' => 'Drop Foundation database tables.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'create-table', + 'description' => 'Create Foundation database tables without running migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'yes', + 'description' => 'Skip confirmation prompts for destructive actions.', + 'optional' => true, + 'default' => false, + ], + ], $deferredAdditions['foundation migrate']['args']['synopsis']); + } +} diff --git a/tests/Unit/Database/DatabaseProviderTest.php b/tests/Unit/Database/DatabaseProviderTest.php new file mode 100644 index 0000000..97d38dd --- /dev/null +++ b/tests/Unit/Database/DatabaseProviderTest.php @@ -0,0 +1,89 @@ +newContainer(); + + $container->register(DatabaseProvider::class); + + $this->assertSame([], $container->get(DatabaseProvider::MIGRATIONS)); + $this->assertSame('foundation', $container->get(DatabaseProvider::COMMAND_PREFIX)); + $this->assertSame('foundation-database-migrations', $container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame(300, $container->get(DatabaseProvider::LOCK_TTL)); + } + + public function test_it_registers_configured_database_configuration(): void { + $container = $this->newContainer([ + 'database' => [ + 'migrations_table' => 'custom_migrations', + 'locks_table' => 'custom_locks', + 'command_prefix' => 'custom', + 'lock_name' => 'custom-migrations', + 'lock_ttl' => '120', + ], + ]); + + $container->register(DatabaseProvider::class); + + $this->assertSame('custom_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame('custom_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertSame('custom', $container->get(DatabaseProvider::COMMAND_PREFIX)); + $this->assertSame('custom-migrations', $container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame(120, $container->get(DatabaseProvider::LOCK_TTL)); + } + + public function test_it_does_not_overwrite_preconfigured_migrations(): void { + $migration = new TestMigration('2026_06_23_000001_create_example'); + $container = $this->newContainer(); + $container->singleton(DatabaseProvider::MIGRATIONS, [$migration]); + + $container->register(DatabaseProvider::class); + + $this->assertSame([$migration], $container->get(DatabaseProvider::MIGRATIONS)); + } + + public function test_it_fails_clearly_when_wordpress_database_is_not_available(): void { + $previous = $GLOBALS['wpdb'] ?? null; + unset($GLOBALS['wpdb']); + + $container = $this->newContainer(); + $container->register(DatabaseProvider::class); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('the global wpdb instance is not available.'); + + try { + $container->get(Database::class); + } finally { + if ($previous !== null) { + $GLOBALS['wpdb'] = $previous; + } + } + } + + /** + * @param array $config + */ + private function newContainer(array $config = []): Container { + $container = new ContainerAdapter(new DI52Container()); + $container->bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot($config)); + + return $container; + } +} diff --git a/tests/Unit/Database/Lock/DatabaseLockTest.php b/tests/Unit/Database/Lock/DatabaseLockTest.php new file mode 100644 index 0000000..7be661e --- /dev/null +++ b/tests/Unit/Database/Lock/DatabaseLockTest.php @@ -0,0 +1,102 @@ +database = new FakeDatabase(); + $this->clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->lock = new DatabaseLock($this->database, 'wp_nexcess_foundation_locks', $this->clock); + } + + public function test_it_acquires_a_database_lock_when_the_written_owner_matches(): void { + $this->database->rowResults[] = fn (string $sql, FakeDatabase $database): array => [ + 'owner' => $this->extractOwnerFromInsert($database->executed[0]), + 'expires_at' => '2026-01-01 00:01:00', + ]; + + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $this->database->executed[0]); + } + + public function test_it_releases_a_lock_for_the_matching_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 1; + + $this->assertTrue($this->lock->release($token)); + $this->assertStringContainsString('DELETE FROM `wp_nexcess_foundation_locks`', $this->database->executed[0]); + $this->assertStringContainsString('owner', $this->database->executed[0]); + } + + public function test_it_refreshes_a_lock_for_the_matching_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 1; + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame('2026-01-01 00:02:00', $refreshed->expiresAt->format('Y-m-d H:i:s')); + $this->assertStringContainsString('UPDATE `wp_nexcess_foundation_locks`', $this->database->executed[0]); + } + + public function test_it_returns_null_when_refresh_does_not_update_a_row(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 0; + + $this->assertNull($this->lock->refresh($token, 120)); + } + + public function test_it_checks_whether_a_lock_is_acquired(): void { + $this->database->rowResults[] = ['name' => 'queue:sync']; + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + $this->assertStringContainsString("expires_at > '2026-01-01 00:00:00'", $this->database->rowQueries[0]); + } + + public function test_it_rejects_an_invalid_ttl(): void { + $this->expectException(InvalidArgumentException::class); + + $this->lock->acquire('queue:sync', 0); + } + + private function extractOwnerFromInsert(string $sql): string { + preg_match("/VALUES \\('queue:sync', '([a-f0-9]{32})', /", $sql, $matches); + + return $matches[1] ?? ''; + } +} diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php new file mode 100644 index 0000000..2f77b79 --- /dev/null +++ b/tests/Unit/Database/Migration/RepositoryTest.php @@ -0,0 +1,80 @@ +database = new FakeDatabase(); + $this->repository = new Repository($this->database, 'wp_nexcess_foundation_migrations'); + } + + public function test_it_returns_all_migration_records_indexed_by_migration_id(): void { + $this->database->rowsResults[] = [ + [ + 'id' => 1, + 'migration' => '2026_01_01_000001_create_users', + 'batch' => 1, + 'ran_at' => '2026-01-01 00:00:00', + ], + ]; + + $records = $this->repository->all(); + + $this->assertArrayHasKey('2026_01_01_000001_create_users', $records); + $this->assertSame(1, $records['2026_01_01_000001_create_users']->id); + } + + public function test_it_records_a_migration_run(): void { + $this->database->rowResults[] = [ + 'id' => 1, + 'migration' => '2026_01_01_000001_create_users', + 'batch' => 2, + 'ran_at' => '2026-01-01 00:00:00', + ]; + + $record = $this->repository->recordRun('2026_01_01_000001_create_users', 2); + + $this->assertSame(2, $record->batch); + $this->assertStringContainsString('INSERT INTO `wp_nexcess_foundation_migrations`', $this->database->executed[0]); + } + + public function test_it_deletes_a_migration_run(): void { + $this->database->executeResults[] = 1; + + $this->assertTrue($this->repository->deleteRun('2026_01_01_000001_create_users')); + $this->assertStringContainsString('DELETE FROM `wp_nexcess_foundation_migrations`', $this->database->executed[0]); + } + + public function test_it_calculates_the_next_batch(): void { + $this->database->rowResults[] = ['batch' => 4]; + + $this->assertSame(5, $this->repository->nextBatch()); + } + + public function test_it_returns_records_for_a_batch(): void { + $this->database->rowsResults[] = [ + [ + 'id' => 2, + 'migration' => '2026_01_01_000002_create_posts', + 'batch' => 3, + 'ran_at' => '2026-01-01 00:00:00', + ], + ]; + + $records = $this->repository->recordsForBatch(3); + + $this->assertCount(1, $records); + $this->assertSame('2026_01_01_000002_create_posts', $records[0]->migration); + } +} diff --git a/tests/Unit/Database/Migration/RunnerTest.php b/tests/Unit/Database/Migration/RunnerTest.php new file mode 100644 index 0000000..30a1655 --- /dev/null +++ b/tests/Unit/Database/Migration/RunnerTest.php @@ -0,0 +1,222 @@ +repository = new InMemoryRepository(); + $this->schema = new RecordingSchema(); + $this->lock = new InMemoryLock(new MutableClock(new \DateTimeImmutable('2026-01-01 00:00:00'))); + $this->runner = new Runner($this->repository, $this->schema, $this->lock); + } + + public function test_it_runs_pending_migrations_in_order(): void { + $result = $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $this->assertSame([ + '2026_01_01_000001_create_users', + '2026_01_01_000002_create_posts', + ], $result->ran); + $this->assertSame([ + 'up:2026_01_01_000001_create_users', + 'up:2026_01_01_000002_create_posts', + ], $this->schema->statements); + $this->assertSame(1, $this->repository->all()['2026_01_01_000001_create_users']->batch); + $this->assertSame(1, $this->repository->all()['2026_01_01_000002_create_posts']->batch); + } + + public function test_it_skips_migrations_that_have_already_run(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $result = $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $this->assertSame(['2026_01_01_000002_create_posts'], $result->ran); + $this->assertSame(['2026_01_01_000001_create_users'], $result->skipped); + $this->assertSame(2, $this->repository->all()['2026_01_01_000002_create_posts']->batch); + } + + public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + $this->runner->run([ + new TestMigration('2026_01_01_000002_create_posts'), + new TestMigration('2026_01_01_000003_create_comments'), + ]); + + $this->schema->statements = []; + + $result = $this->runner->rollback([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + new TestMigration('2026_01_01_000003_create_comments'), + ]); + + $this->assertSame([ + '2026_01_01_000003_create_comments', + '2026_01_01_000002_create_posts', + ], $result->rolledBack); + $this->assertSame([ + 'down:2026_01_01_000003_create_comments', + 'down:2026_01_01_000002_create_posts', + ], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); + } + + public function test_it_returns_an_empty_result_when_there_is_no_batch_to_roll_back(): void { + $result = $this->runner->rollback([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $this->assertSame([], $result->rolledBack); + $this->assertSame(0, $result->count()); + } + + public function test_it_skips_rollback_records_without_a_matching_migration(): void { + $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); + + $result = $this->runner->rollback([ + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $this->assertSame([], $result->rolledBack); + $this->assertSame(['2026_01_01_000001_missing_migration'], $result->skipped); + } + + public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): void { + $migrations = [ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]; + + $this->runner->run($migrations); + $this->schema->statements = []; + + $result = $this->runner->refresh($migrations); + + $this->assertSame([ + '2026_01_01_000002_create_posts', + '2026_01_01_000001_create_users', + ], $result->rolledBack); + $this->assertSame([ + '2026_01_01_000001_create_users', + '2026_01_01_000002_create_posts', + ], $result->ran); + $this->assertSame([ + 'down:2026_01_01_000002_create_posts', + 'down:2026_01_01_000001_create_users', + 'up:2026_01_01_000001_create_users', + 'up:2026_01_01_000002_create_posts', + ], $this->schema->statements); + } + + public function test_it_returns_status_for_configured_migrations(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $statuses = $this->runner->status([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ]); + + $this->assertTrue($statuses[0]->ran); + $this->assertSame(1, $statuses[0]->batch); + $this->assertFalse($statuses[1]->ran); + $this->assertNull($statuses[1]->batch); + } + + public function test_migration_results_count_ran_and_rolled_back_migrations(): void { + $result = new Result( + ran: ['2026_01_01_000001_create_users'], + rolledBack: ['2026_01_01_000002_create_posts'], + skipped: ['2026_01_01_000003_create_comments'] + ); + + $this->assertSame(2, $result->count()); + } + + public function test_it_rejects_duplicate_migration_ids(): void { + $this->expectException(DuplicateMigration::class); + $this->expectExceptionMessage('Duplicate migration ID'); + + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000001_create_users'), + ]); + } + + public function test_it_fails_when_the_migration_lock_is_already_owned(): void { + $this->lock->acquire('foundation-database-migrations', 300); + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not acquire migration lock'); + + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + } + + public function test_it_does_not_record_a_failed_migration(): void { + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while running'); + + try { + $this->runner->run([ + new FailingMigration('2026_01_01_000001_create_users', failUp: true), + ]); + } finally { + $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_does_not_delete_a_record_when_rollback_fails(): void { + $this->runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while rolling back'); + + try { + $this->runner->rollback([ + new FailingMigration('2026_01_01_000001_create_users', failDown: true), + ]); + } finally { + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } +} diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php new file mode 100644 index 0000000..e44a495 --- /dev/null +++ b/tests/Unit/Database/Query/QueryBuilderTest.php @@ -0,0 +1,38 @@ +table($table, 'r') + ->select('id', 'title') + ->where('status', '=', 'published') + ->orderBy('id', 'DESC') + ->limit(10, 5); + + $this->assertSame( + 'SELECT `id`, `title` FROM `wp_reports` AS `r` WHERE `status` = %s ORDER BY `id` DESC LIMIT %d OFFSET %d', + $query->toSql() + ); + $this->assertSame(['published', 10, 5], $query->bindings()); + $this->assertSame( + "SELECT `id`, `title` FROM `wp_reports` AS `r` WHERE `status` = 'published' ORDER BY `id` DESC LIMIT 10 OFFSET 5", + $query->toPreparedSql() + ); + } + + public function test_it_rejects_unsupported_operators(): void { + $this->expectException(InvalidArgumentException::class); + + (new FakeDatabase())->table('reports')->where('status', 'BETWEEN', ['a', 'z']); + } +} diff --git a/tests/Unit/Database/Query/QueryTest.php b/tests/Unit/Database/Query/QueryTest.php new file mode 100644 index 0000000..089af6c --- /dev/null +++ b/tests/Unit/Database/Query/QueryTest.php @@ -0,0 +1,32 @@ +assertSame('SELECT * FROM %i WHERE status = %s', $query->toSql()); + $this->assertSame(['wp_reports', 'published'], $query->bindings()); + $this->assertSame("SELECT * FROM `wp_reports` WHERE status = 'published'", $query->toPreparedSql()); + } + + public function test_it_executes_rows_first_and_value_queries(): void { + $database = new FakeDatabase(); + $query = new Query($database, 'SELECT name FROM %i WHERE id = %d', ['wp_reports', 1]); + + $database->rowsResults[] = [['name' => 'first']]; + $database->rowResults[] = ['name' => 'first']; + $database->rowResults[] = ['count' => 3]; + + $this->assertSame([['name' => 'first']], $query->get()); + $this->assertSame(['name' => 'first'], $query->first()); + $this->assertSame(3, $query->value()); + } +} diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php new file mode 100644 index 0000000..23da7af --- /dev/null +++ b/tests/Unit/Database/SchemaTest.php @@ -0,0 +1,48 @@ +createOrUpdate('CREATE TABLE example (id bigint)'); + + $this->assertSame(['CREATE TABLE example (id bigint)'], $statements); + } + + public function test_it_checks_tables_and_indexes(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['table' => 'wp_example']; + $database->rowResults[] = ['Key_name' => 'example_key']; + $schema = new Schema($database, static fn (string $sql): array => []); + + $this->assertTrue($schema->hasTable('wp_example%')); + $this->assertTrue($schema->hasIndex('wp_example', 'example_key')); + $this->assertStringContainsString("SHOW TABLES LIKE 'wp\\\\_example\\\\%'", $database->rowQueries[0]); + $this->assertStringContainsString('SHOW INDEX FROM `wp_example`', $database->rowQueries[1]); + } + + public function test_it_drops_indexes(): void { + $database = new FakeDatabase(); + $schema = new Schema($database, static fn (string $sql): array => []); + + $schema->dropIndex('wp_example', 'example_key'); + + $this->assertSame('ALTER TABLE `wp_example` DROP INDEX `example_key`', $database->executed[0]); + } + + public function test_it_exposes_identifier_helpers(): void { + $schema = new Schema(new FakeDatabase(), static fn (string $sql): array => []); + + $this->assertSame('`weird``table`', $schema->quoteIdentifier('weird`table')); + } +} diff --git a/tests/Unit/Database/Table/CollectionTest.php b/tests/Unit/Database/Table/CollectionTest.php new file mode 100644 index 0000000..cc6e58f --- /dev/null +++ b/tests/Unit/Database/Table/CollectionTest.php @@ -0,0 +1,57 @@ +tables['existing'] = true; + $collection = new Collection($schema, [$existing, $missing]); + + $collection->create(); + + $this->assertSame(['createOrUpdate:missing'], $schema->statements); + $this->assertTrue($schema->hasTable($existing)); + $this->assertTrue($schema->hasTable($missing)); + } + + public function test_it_drops_all_tables(): void { + $first = new TestTable('first_table', 'first'); + $second = new TestTable('second_table', 'second'); + $schema = new RecordingSchema(); + + $collection = new Collection($schema, [$first]); + $collection->add($second); + $collection->drop(); + + $this->assertSame(['drop:first', 'drop:second'], $schema->statements); + $this->assertSame([$first, $second], $collection->all()); + $this->assertSame([$first, $second], iterator_to_array($collection)); + } + + public function test_it_checks_whether_all_tables_exist(): void { + $schema = new RecordingSchema(); + $first = new TestTable('first_table', 'first'); + $second = new TestTable('second_table', 'second'); + + $schema->tables = [ + 'first' => true, + 'second' => true, + ]; + + $this->assertTrue((new Collection($schema, [$first, $second]))->exists()); + + unset($schema->tables['second']); + + $this->assertFalse((new Collection($schema, [$first, $second]))->exists()); + } +} diff --git a/tests/Unit/Database/Table/ColumnTest.php b/tests/Unit/Database/Table/ColumnTest.php new file mode 100644 index 0000000..4b63c74 --- /dev/null +++ b/tests/Unit/Database/Table/ColumnTest.php @@ -0,0 +1,33 @@ +assertSame('`queue_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $column->sql()); + } + + public function test_it_renders_nullable_and_default_values(): void { + $this->assertSame( + "`status` varchar(20) NULL DEFAULT 'pending'", + (new Column('status', 'varchar', 20, nullable: true, default: 'pending'))->sql() + ); + + $this->assertSame( + '`attempts` int(10) unsigned NOT NULL DEFAULT 0', + (new Column('attempts', 'int', 10, unsigned: true, default: 0))->sql() + ); + } +} diff --git a/tests/Unit/Database/Table/CreateTableTest.php b/tests/Unit/Database/Table/CreateTableTest.php new file mode 100644 index 0000000..0ab960b --- /dev/null +++ b/tests/Unit/Database/Table/CreateTableTest.php @@ -0,0 +1,51 @@ +assertSame('foundation_example_table', $migration->id()); + } + + public function test_it_creates_missing_tables(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $migration->up($schema); + + $this->assertTrue($schema->hasTable($table)); + } + + public function test_it_does_not_create_existing_tables(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $schema->tables['wp_example'] = true; + + $migration->up($schema); + + $this->assertSame([], $schema->statements); + } + + public function test_it_drops_tables_when_rolled_back(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $schema->tables['wp_example'] = true; + + $migration->down($schema); + + $this->assertFalse($schema->hasTable($table)); + } +} diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php new file mode 100644 index 0000000..8e75f59 --- /dev/null +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -0,0 +1,36 @@ +bigIncrements('id') + ->string('status', 20) + ->text('payload') + ->dateTime('created_at') + ->index('status', 'status'); + + $this->assertCount(4, $definition->columns()); + $this->assertCount(2, $definition->indexes()); + $this->assertSame([], $definition->validationErrors()); + } + + public function test_it_rejects_indexes_that_reference_missing_columns(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('status', 20) + ->index('missing_index', 'missing'); + + $this->assertSame(['Index missing_index references missing column missing.'], $definition->validationErrors()); + + $this->expectException(InvalidArgumentException::class); + + $definition->assertValid(); + } +} diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php new file mode 100644 index 0000000..c63e689 --- /dev/null +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -0,0 +1,38 @@ +createOrUpdate($table); + + $this->assertSame(LockTable::ID, $table->id()); + $this->assertSame('wp_nexcess_foundation_locks', $table->name()); + $this->assertStringContainsString('CREATE TABLE `wp_nexcess_foundation_locks`', $statements[0]); + $this->assertStringContainsString('PRIMARY KEY (`name`)', $statements[0]); + $this->assertStringContainsString('KEY `expires_at`', $statements[0]); + } + + public function test_it_drops_the_lock_table(): void { + $database = new FakeDatabase(); + $schema = new DatabaseSchema($database, static fn (string $sql): array => []); + $table = new LockTable($database, 'wp_nexcess_foundation_locks'); + + $schema->drop($table); + + $this->assertSame('DROP TABLE IF EXISTS `wp_nexcess_foundation_locks`', $database->executed[0]); + } +} diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php new file mode 100644 index 0000000..ca53661 --- /dev/null +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -0,0 +1,37 @@ +createOrUpdate($table); + + $this->assertSame(MigrationTable::ID, $table->id()); + $this->assertSame('wp_nexcess_foundation_migrations', $table->name()); + $this->assertStringContainsString('CREATE TABLE `wp_nexcess_foundation_migrations`', $statements[0]); + $this->assertStringContainsString('UNIQUE KEY `migration`', $statements[0]); + } + + public function test_it_drops_the_migration_table(): void { + $database = new FakeDatabase(); + $schema = new DatabaseSchema($database, static fn (string $sql): array => []); + $table = new MigrationTable($database, 'wp_nexcess_foundation_migrations'); + + $schema->drop($table); + + $this->assertSame('DROP TABLE IF EXISTS `wp_nexcess_foundation_migrations`', $database->executed[0]); + } +} diff --git a/tests/wpunit.suite.dist.yml b/tests/wpunit.suite.dist.yml index 0b6007e..c6b6192 100644 --- a/tests/wpunit.suite.dist.yml +++ b/tests/wpunit.suite.dist.yml @@ -6,6 +6,7 @@ modules: enabled: - lucatume\WPBrowser\Module\WPLoader - lucatume\WPBrowser\Module\WPQueries + - lucatume\WPBrowser\Module\WPCLI config: lucatume\WPBrowser\Module\WPLoader: wpRootFolder: %WP_ROOT_FOLDER% @@ -13,8 +14,16 @@ modules: dbHost: %WP_TEST_DB_HOST% dbUser: %WP_TEST_DB_USER% dbPassword: %WP_TEST_DB_PASSWORD% - tablePrefix: test_ + tablePrefix: %WP_TABLE_PREFIX% domain: %WP_DOMAIN% adminEmail: admin@stellarwp.com title: 'Foundation Tests' theme: twentytwentythree + lucatume\WPBrowser\Module\WPCLI: + path: %WP_ROOT_FOLDER% + url: %WP_URL% + user: %WP_ADMIN_USERNAME% + require: + - /var/www/html/wp-content/plugins/foundation/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php + throw: false + allow-root: true diff --git a/tests/wpunit/Database/Cli/DatabaseMigrateCest.php b/tests/wpunit/Database/Cli/DatabaseMigrateCest.php new file mode 100644 index 0000000..524569a --- /dev/null +++ b/tests/wpunit/Database/Cli/DatabaseMigrateCest.php @@ -0,0 +1,76 @@ +dropTables($I); + } + + public function _after(WPUnitTester $I): void { + $this->dropTables($I); + } + + public function test_it_runs_database_migrations_through_wp_cli(WPUnitTester $I): void { + $I->cli(['foundation', 'migrate', '--create-table']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Foundation database tables are ready.'); + + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Ran 1 migrations.'); + + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('2026_06_23_000001_create_foundation_cli_example'); + $I->seeInShellOutput('ran'); + + $I->cli(['foundation', 'migrate', '--rollback']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Rolled back 1 migrations.'); + } + + public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPUnitTester $I): void { + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Ran 1 migrations.'); + + $I->cli(['foundation', 'migrate', '--refresh', '--yes']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Rolled back 1 migrations and ran 1 migrations.'); + + $I->cli(['foundation', 'migrate', '--drop', '--yes']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Foundation database tables were dropped.'); + + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); + } + + public function test_it_warns_when_showing_status_before_tables_exist(WPUnitTester $I): void { + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); + } + + private function dropTables(WPUnitTester $I): void { + $I->cli(['db', 'prefix']); + $I->seeResultCodeIs(0); + + $prefix = trim($I->grabLastShellOutput()); + + $I->cli([ + 'db', + 'query', + sprintf( + 'DROP TABLE IF EXISTS %sfoundation_cli_migrations, %sfoundation_cli_locks, %sfoundation_cli_example', + $prefix, + $prefix, + $prefix + ), + ]); + $I->seeResultCodeIs(0); + } +} diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php new file mode 100644 index 0000000..1bca520 --- /dev/null +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -0,0 +1,242 @@ + + */ + private array $tables = []; + + protected function setUp(): void { + parent::setUp(); + + $this->database = new Database($GLOBALS['wpdb']); + } + + protected function tearDown(): void { + foreach (array_reverse($this->tables) as $table) { + $this->database->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($table) + )); + } + + parent::tearDown(); + } + + public function test_database_executes_and_reads_rows_through_wpdb(): void { + $table = $this->table('database'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->database->execute( + 'INSERT INTO %i (name) VALUES (%s), (%s)', + $table, + 'first', + 'second' + ); + + $this->assertSame($GLOBALS['wpdb']->prefix . 'example', $this->database->tableName('example')); + $this->assertSame(['name' => 'first'], $this->database->row(sprintf( + 'SELECT name FROM %s WHERE id = 1', + $this->database->quoteIdentifier($table) + ))); + $this->assertSame([ + ['name' => 'first'], + ['name' => 'second'], + ], $this->database->rows(sprintf( + 'SELECT name FROM %s ORDER BY id ASC', + $this->database->quoteIdentifier($table) + ))); + $this->assertSame([ + ['name' => 'first'], + ], $this->database->table($table)->select('name')->where('id', '=', 1)->get()); + } + + public function test_database_crud_helpers_and_schema_inspection_use_wordpress(): void { + $table = $this->table('crud'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + status varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY status (status) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertTrue($this->database->tableExists($table)); + $this->assertTrue($this->database->columnExists($table, 'status')); + $this->assertTrue($this->database->indexExists($table, 'status')); + $this->assertFalse($this->database->columnExists($table, 'missing')); + $this->assertFalse($this->database->indexExists($table, 'missing')); + + $id = $this->database->insert($table, [ + 'name' => 'draft report', + 'status' => 'draft', + ]); + + $this->assertGreaterThan(0, $id); + $this->assertSame('draft', $this->database->value('SELECT status FROM %i WHERE id = %d', $table, $id)); + $this->assertSame(1, $this->database->update($table, ['status' => 'published'], ['id' => $id])); + $this->assertSame('published', $this->database->value('SELECT status FROM %i WHERE id = %d', $table, $id)); + $this->assertSame(1, $this->database->delete($table, ['id' => $id])); + $this->assertSame('0', (string) $this->database->value('SELECT COUNT(*) FROM %i', $table)); + } + + public function test_schema_creates_inspects_and_changes_tables_through_wordpress(): void { + $table = $this->table('schema'); + $schema = new Schema($this->database); + + $schema->createOrUpdate(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id), + KEY name (name) + ) %s;', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertTrue($schema->hasTable($table)); + $this->assertTrue($schema->hasIndex($table, 'name')); + + $schema->dropIndex($table, 'name'); + + $this->assertFalse($schema->hasIndex($table, 'name')); + + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($table) + )); + + $this->assertFalse($schema->hasTable($table)); + } + + public function test_migration_repository_persists_records_in_wordpress(): void { + $table = $this->table('migrations'); + $schema = new Schema($this->database); + $migrationTable = new MigrationTable($this->database, $table); + $repository = new Repository($this->database, $table); + + $this->assertFalse($schema->hasTable($migrationTable)); + + $schema->createOrUpdate($migrationTable); + + $this->assertTrue($schema->hasTable($migrationTable)); + $this->assertSame($table, $migrationTable->name()); + $this->assertSame(1, $repository->nextBatch()); + + $record = $repository->recordRun('2026_06_23_000001_create_example_table', 1); + + $this->assertGreaterThan(0, $record->id); + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example_table')); + $this->assertSame(2, $repository->nextBatch()); + $this->assertSame(1, $repository->latestBatch()); + $this->assertArrayHasKey('2026_06_23_000001_create_example_table', $repository->all()); + $this->assertCount(1, $repository->recordsForBatch(1)); + $this->assertTrue($repository->deleteRun('2026_06_23_000001_create_example_table')); + $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example_table')); + + $schema->drop($migrationTable); + + $this->assertFalse($schema->hasTable($migrationTable)); + } + + public function test_database_lock_coordinates_ownership_in_wordpress(): void { + $table = $this->table('locks'); + $wpSchema = new Schema($this->database); + $lockTable = new LockTable($this->database, $table); + $lock = new DatabaseLock($this->database, $table); + + $this->assertFalse($wpSchema->hasTable($lockTable)); + + $wpSchema->createOrUpdate($lockTable); + + $this->assertTrue($wpSchema->hasTable($lockTable)); + $this->assertSame($table, $lockTable->name()); + + $token = $lock->acquire('foundation:database:test', 60); + + $this->assertNotNull($token); + $this->assertNull($lock->acquire('foundation:database:test', 60)); + $this->assertTrue($lock->isAcquired('foundation:database:test')); + $this->assertNotNull($lock->refresh($token, 120)); + $this->assertTrue($lock->release($token)); + $this->assertFalse($lock->isAcquired('foundation:database:test')); + + $wpSchema->drop($lockTable); + + $this->assertFalse($wpSchema->hasTable($lockTable)); + } + + public function test_provider_registers_wordpress_prefixed_database_services(): void { + $container = $this->newContainer(); + + $container->register(DatabaseProvider::class); + + $this->assertSame($GLOBALS['wpdb']->prefix . 'nexcess_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame($GLOBALS['wpdb']->prefix . 'nexcess_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertInstanceOf(Database::class, $container->get(Database::class)); + $this->assertInstanceOf(Database::class, $container->get(DatabaseContract::class)); + $this->assertInstanceOf(Schema::class, $container->get(Schema::class)); + $this->assertInstanceOf(TableCollection::class, $container->get(TableCollection::class)); + $this->assertInstanceOf(MigrationTable::class, $container->get(MigrationTable::class)); + $this->assertInstanceOf(LockTable::class, $container->get(LockTable::class)); + $this->assertInstanceOf(Repository::class, $container->get(MigrationRecordRepositoryContract::class)); + $this->assertInstanceOf(Runner::class, $container->get(Runner::class)); + $this->assertFalse($container->has(Lock::class)); + } + + private function table(string $suffix): string { + $table = $GLOBALS['wpdb']->prefix . 'foundation_' . $suffix . '_' . str_replace('.', '_', uniqid('', true)); + + $this->tables[] = $table; + + return $table; + } + + private function newContainer(): Container { + $container = new ContainerAdapter(new DI52Container()); + $container->bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + + return $container; + } +} From b83f270f2247c6c1b0bf0d4aeedc930caafbbc02 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 15:42:23 -0600 Subject: [PATCH 03/81] Add missing phpstan stubfile --- phpstan.neon.dist | 2 ++ tests/Support/PHPStan/WPUnitTester.stub | 28 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/Support/PHPStan/WPUnitTester.stub diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ec016cc..9a9d3fc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,6 +6,8 @@ parameters: scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php - vendor/wp-cli/wp-cli/php/utils.php + stubFiles: + - tests/Support/PHPStan/WPUnitTester.stub excludePaths: analyse: - src/*/vendor/* diff --git a/tests/Support/PHPStan/WPUnitTester.stub b/tests/Support/PHPStan/WPUnitTester.stub new file mode 100644 index 0000000..8dba8f3 --- /dev/null +++ b/tests/Support/PHPStan/WPUnitTester.stub @@ -0,0 +1,28 @@ + $command + * @param array $args + */ + public function cli($command, array $args = []): void + { + } + + public function seeResultCodeIs(int $code): void + { + } + + public function seeInShellOutput(string $text): void + { + } + + public function grabLastShellOutput(): string + { + } + + public function grabLastShellErrorOutput(): string + { + } +} From 51469bd1715615bfa95d89f8339739e82cee6541 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 15:55:30 -0600 Subject: [PATCH 04/81] Properly fix phpstan issues --- .gitignore | 1 - phpstan.neon.dist | 2 -- tests/CodeceptionSupport/.gitignore | 1 + tests/CodeceptionSupport/FeatureTester.php | 28 ++++++++++++++++++++++ tests/CodeceptionSupport/UnitTester.php | 28 ++++++++++++++++++++++ tests/CodeceptionSupport/WPUnitTester.php | 28 ++++++++++++++++++++++ tests/Support/PHPStan/WPUnitTester.stub | 28 ---------------------- 7 files changed, 85 insertions(+), 31 deletions(-) create mode 100644 tests/CodeceptionSupport/.gitignore create mode 100644 tests/CodeceptionSupport/FeatureTester.php create mode 100644 tests/CodeceptionSupport/UnitTester.php create mode 100644 tests/CodeceptionSupport/WPUnitTester.php delete mode 100644 tests/Support/PHPStan/WPUnitTester.stub diff --git a/.gitignore b/.gitignore index 0229a22..b2b827c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,5 @@ vendor /coverage/ /tests/_output/* !/tests/_output/.gitkeep -/tests/CodeceptionSupport/ /tests/_data/temp/* !/tests/_data/temp/.gitkeep diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 9a9d3fc..ec016cc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,8 +6,6 @@ parameters: scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php - vendor/wp-cli/wp-cli/php/utils.php - stubFiles: - - tests/Support/PHPStan/WPUnitTester.stub excludePaths: analyse: - src/*/vendor/* diff --git a/tests/CodeceptionSupport/.gitignore b/tests/CodeceptionSupport/.gitignore new file mode 100644 index 0000000..36e264c --- /dev/null +++ b/tests/CodeceptionSupport/.gitignore @@ -0,0 +1 @@ +_generated diff --git a/tests/CodeceptionSupport/FeatureTester.php b/tests/CodeceptionSupport/FeatureTester.php new file mode 100644 index 0000000..34ad51b --- /dev/null +++ b/tests/CodeceptionSupport/FeatureTester.php @@ -0,0 +1,28 @@ + $command - * @param array $args - */ - public function cli($command, array $args = []): void - { - } - - public function seeResultCodeIs(int $code): void - { - } - - public function seeInShellOutput(string $text): void - { - } - - public function grabLastShellOutput(): string - { - } - - public function grabLastShellErrorOutput(): string - { - } -} From 92bbcc1d8362471a1d726dfe6aedd25337205af9 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 15:55:41 -0600 Subject: [PATCH 05/81] Add more test coverage --- tests/Unit/Database/Cli/MigrateTest.php | 129 +++++++++++++++++- .../Unit/Database/Query/QueryBuilderTest.php | 46 +++++++ .../Database/Table/TableDefinitionTest.php | 13 ++ .../Database/DatabaseIntegrationTest.php | 47 +++++++ 4 files changed, 228 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index dbcda85..52f194e 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -14,6 +14,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\InMemoryRepository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\TestCase; use WP_CLI; @@ -26,13 +27,7 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { define('WP_CLI', true); } - $wpCliRoot = dirname(__DIR__, 4) . '/vendor/wp-cli/wp-cli'; - - if (! defined('WP_CLI_ROOT')) { - define('WP_CLI_ROOT', $wpCliRoot); - } - - require_once $wpCliRoot . '/php/utils.php'; + $this->loadWpCliUtilities(); $database = new FakeDatabase(); $wpSchema = new DatabaseSchema($database, static fn (string $sql): array => []); @@ -99,4 +94,124 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { ], ], $deferredAdditions['foundation migrate']['args']['synopsis']); } + + public function test_it_creates_database_tables_without_running_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(0, $command->runCommand([], ['create-table' => true])); + + $this->assertSame([], $repository->all()); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + ], $schema->statements); + } + + public function test_it_runs_pending_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(0, $command->runCommand([], ['run' => true])); + + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + 'up:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_rolls_back_the_latest_migration_batch(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $command->runCommand([], ['run' => true]); + $schema->statements = []; + + $this->assertSame(0, $command->runCommand([], ['rollback' => true])); + + $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertContains('down:2026_06_23_000001_create_example', $schema->statements); + } + + public function test_it_refreshes_database_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $command->runCommand([], ['run' => true]); + $schema->statements = []; + + $this->assertSame(0, $command->runCommand([], [ + 'refresh' => true, + 'yes' => true, + ])); + + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertContains('down:2026_06_23_000001_create_example', $schema->statements); + $this->assertContains('up:2026_06_23_000001_create_example', $schema->statements); + } + + public function test_it_drops_database_tables(): void { + [$command, , $schema] = $this->newCommand(); + + $command->runCommand([], ['create-table' => true]); + + $this->assertSame(0, $command->runCommand([], [ + 'drop' => true, + 'yes' => true, + ])); + + $this->assertSame([], $schema->tables); + $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); + $this->assertContains('drop:wp_nexcess_foundation_locks', $schema->statements); + } + + public function test_it_shows_a_warning_when_status_tables_do_not_exist(): void { + [$command] = $this->newCommand(); + + $this->assertSame(0, $command->runCommand()); + } + + public function test_it_shows_migration_status_when_tables_exist(): void { + [$command] = $this->newCommand(); + + $command->runCommand([], ['run' => true]); + + $this->expectOutputRegex('/2026_06_23_000001_create_example\s+ran\s+1\s+2026-01-01 00:00:00/'); + + $this->assertSame(0, $command->runCommand()); + } + + /** + * @return array{Migrate, InMemoryRepository, RecordingSchema} + */ + private function newCommand(): array { + $this->loadWpCliUtilities(); + + $database = new FakeDatabase(); + $wpSchema = new RecordingSchema(); + $repository = new InMemoryRepository(); + $runner = new Runner($repository, $wpSchema, new InMemoryLock()); + $command = new Migrate( + $this->container, + 'foundation', + $runner, + [ + new TestMigration('2026_06_23_000001_create_example'), + ], + new TableCollection($wpSchema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ]) + ); + + return [$command, $repository, $wpSchema]; + } + + private function loadWpCliUtilities(): void { + $wpCliRoot = dirname(__DIR__, 4) . '/vendor/wp-cli/wp-cli'; + + if (! defined('WP_CLI_ROOT')) { + define('WP_CLI_ROOT', $wpCliRoot); + } + + require_once $wpCliRoot . '/php/utils.php'; + } } diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php index e44a495..d8b63dc 100644 --- a/tests/Unit/Database/Query/QueryBuilderTest.php +++ b/tests/Unit/Database/Query/QueryBuilderTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Query; use InvalidArgumentException; +use StellarWP\Foundation\Database\Query\Query; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\TestCase; @@ -35,4 +36,49 @@ public function test_it_rejects_unsupported_operators(): void { (new FakeDatabase())->table('reports')->where('status', 'BETWEEN', ['a', 'z']); } + + public function test_it_rejects_invalid_order_directions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Order direction must be ASC or DESC.'); + + (new FakeDatabase())->table('reports')->orderBy('id', 'SIDEWAYS'); + } + + public function test_it_rejects_invalid_limits(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Query limit must be greater than zero.'); + + (new FakeDatabase())->table('reports')->limit(0); + } + + public function test_it_rejects_negative_offsets(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Query offset cannot be negative.'); + + (new FakeDatabase())->table('reports')->limit(10, -1); + } + + public function test_it_reads_the_first_row(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['name' => 'first']; + + $this->assertSame( + ['name' => 'first'], + $database->table('reports')->where('id', '=', 1)->first() + ); + } + + public function test_it_selects_all_columns_by_default(): void { + $query = (new FakeDatabase())->table('reports'); + + $this->assertSame('SELECT * FROM `wp_reports`', $query->toSql()); + } + + public function test_it_builds_query_objects(): void { + $query = (new FakeDatabase())->table('reports')->where('id', '=', 10)->query(); + + $this->assertInstanceOf(Query::class, $query); + $this->assertSame('SELECT * FROM `wp_reports` WHERE `id` = %s', $query->toSql()); + $this->assertSame([10], $query->bindings()); + } } diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php index 8e75f59..8f8e38c 100644 --- a/tests/Unit/Database/Table/TableDefinitionTest.php +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -33,4 +33,17 @@ public function test_it_rejects_indexes_that_reference_missing_columns(): void { $definition->assertValid(); } + + public function test_it_rejects_tables_without_columns(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')); + + $this->assertSame(['Table reports_table does not define any columns.'], $definition->validationErrors()); + } + + public function test_it_rejects_indexes_without_columns(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An index must define at least one column.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports'))->index('empty_index'); + } } diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 1bca520..288fae8 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -11,6 +11,7 @@ use StellarWP\Foundation\Database\Contracts\Repository as MigrationRecordRepositoryContract; use StellarWP\Foundation\Database\Database; use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Exceptions\QueryException; use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Database\Migration\Runner; @@ -118,6 +119,37 @@ public function test_database_crud_helpers_and_schema_inspection_use_wordpress() $this->assertSame('0', (string) $this->database->value('SELECT COUNT(*) FROM %i', $table)); } + public function test_database_returns_null_for_missing_values_without_query_errors(): void { + $table = $this->table('missing_value'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertNull($this->database->value('SELECT name FROM %i WHERE id = %d', $table, 999)); + } + + public function test_database_wraps_wordpress_query_failures(): void { + $previous = $GLOBALS['wpdb']->suppress_errors(true); + + try { + $this->assertQueryFails(fn (): mixed => $this->database->row('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertSame([], $this->database->rows('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertQueryFails(fn (): mixed => $this->database->execute('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertQueryFails(fn (): mixed => $this->database->insert('missing_foundation_table', ['name' => 'test'])); + $this->assertQueryFails(fn (): mixed => $this->database->update('missing_foundation_table', ['name' => 'updated'], ['id' => 1])); + $this->assertQueryFails(fn (): mixed => $this->database->delete('missing_foundation_table', ['id' => 1])); + } finally { + $GLOBALS['wpdb']->suppress_errors($previous); + } + } + public function test_schema_creates_inspects_and_changes_tables_through_wordpress(): void { $table = $this->table('schema'); $schema = new Schema($this->database); @@ -231,6 +263,21 @@ private function table(string $suffix): string { return $table; } + /** + * @param callable(): mixed $callback + */ + private function assertQueryFails(callable $callback): void { + try { + $callback(); + } catch (QueryException $exception) { + $this->assertNotSame('', $exception->getMessage()); + + return; + } + + $this->fail('Expected the database operation to throw a query exception.'); + } + private function newContainer(): Container { $container = new ContainerAdapter(new DI52Container()); $container->bind(Container::class, $container); From 8bdae5d7e45fc0d678f18dfa95007cc5db54037d Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 16:00:41 -0600 Subject: [PATCH 06/81] Ignore generated codeception files when linting --- .github/workflows/quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2861477..4998b36 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -28,6 +28,7 @@ jobs: filters: | php: - added|modified: '**/*.php' + - added|modified: '!tests/CodeceptionSupport/**' - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -40,4 +41,3 @@ jobs: - name: Run static analysis run: composer analyze - From 07ecb429fe21e83d1069687ef159d294f9f00e45 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 16:04:06 -0600 Subject: [PATCH 07/81] Lint everything --- .github/workflows/quality.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 4998b36..540e0ab 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -28,7 +28,6 @@ jobs: filters: | php: - added|modified: '**/*.php' - - added|modified: '!tests/CodeceptionSupport/**' - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -37,7 +36,7 @@ jobs: - name: Run code style checks if: ${{ steps.filter.outputs.php == 'true' }} - run: composer lint ${{ steps.filter.outputs.php_files }} + run: composer lint - name: Run static analysis run: composer analyze From 7ce88ed1fb910cf54c3a71ff23f8c03fa0ff187f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 23 Jun 2026 16:09:24 -0600 Subject: [PATCH 08/81] Add missing methods --- tests/CodeceptionSupport/WPUnitTester.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/CodeceptionSupport/WPUnitTester.php b/tests/CodeceptionSupport/WPUnitTester.php index 38e7bb0..3e57c47 100644 --- a/tests/CodeceptionSupport/WPUnitTester.php +++ b/tests/CodeceptionSupport/WPUnitTester.php @@ -15,6 +15,11 @@ * @method void lookForwardTo($achieveValue) * @method void comment($description) * @method void pause($vars = []) + * @method void cli(string|array $command, array $args = []) + * @method void seeResultCodeIs(int $code) + * @method void seeInShellOutput(string $text) + * @method string grabLastShellOutput() + * @method string grabLastShellErrorOutput() * * @SuppressWarnings(PHPMD) */ From 35af811fac742e7bbda548b3dd5872bf47be82a2 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 24 Jun 2026 14:05:21 -0600 Subject: [PATCH 09/81] Bump di52 in container to >=4.1 --- src/Container/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Container/composer.json b/src/Container/composer.json index 9e083a6..06734e7 100644 --- a/src/Container/composer.json +++ b/src/Container/composer.json @@ -10,7 +10,7 @@ "require": { "php": ">=8.3", "adbario/php-dot-notation": ">=2.5", - "lucatume/di52": ">=3.0", + "lucatume/di52": ">=4.1", "stellarwp/container-contract": "^1.1", "vlucas/phpdotenv": ">=4.3" }, From ab635422374bb25e8226f45ec51ebe5b3f33486a Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 24 Jun 2026 15:06:19 -0600 Subject: [PATCH 10/81] Add wpcli and integration test suites, use phpcov to combine code coverage due to wp-browser limitations of running all suites at once --- .env.testing.slic | 1 + .gitattributes | 1 + .github/workflows/tests.yml | 23 +++++- .gitignore | 2 + AGENTS.md | 8 +-- README.md | 8 ++- composer.json | 52 ++++++++++++-- dev/bin/.gitignore | 1 + src/Container/ContainerAdapter.php | 9 +++ src/Container/Contracts/Container.php | 9 +++ src/Database/DatabaseProvider.php | 43 ++++++----- src/Database/Lock/DatabaseLock.php | 2 + src/Database/README.md | 2 +- src/WPCli/README.md | 45 ++---------- src/WPCli/WPCliProvider.php | 39 ++++++++++ .../CodeceptionSupport/IntegrationTester.php | 28 ++++++++ tests/CodeceptionSupport/WPCLITester.php | 33 +++++++++ .../Fixtures/WPCli/RecordingCommand.php | 30 ++++++++ tests/Unit/Container/ContainerAdapterTest.php | 9 +++ .../Exceptions/QueryExceptionTest.php | 34 +++++++++ tests/WPUnitSupport/WPTestCase.php | 14 ++++ tests/_output/coverage/.gitignore | 2 + tests/config.php | 6 +- tests/integration.suite.dist.yml | 20 ++++++ .../Database/DatabaseProviderTest.php | 71 ++++++++++--------- tests/integration/WPCli/WPCliProviderTest.php | 28 ++++++++ tests/wpcli.suite.dist.yml | 16 +++++ .../Database/Cli/DatabaseMigrateCest.php | 12 ++-- tests/wpunit.suite.dist.yml | 9 --- 29 files changed, 430 insertions(+), 127 deletions(-) create mode 100644 dev/bin/.gitignore create mode 100644 src/WPCli/WPCliProvider.php create mode 100644 tests/CodeceptionSupport/IntegrationTester.php create mode 100644 tests/CodeceptionSupport/WPCLITester.php create mode 100644 tests/Support/Fixtures/WPCli/RecordingCommand.php create mode 100644 tests/Unit/Database/Exceptions/QueryExceptionTest.php create mode 100644 tests/_output/coverage/.gitignore create mode 100644 tests/integration.suite.dist.yml rename tests/{Unit => integration}/Database/DatabaseProviderTest.php (53%) create mode 100644 tests/integration/WPCli/WPCliProviderTest.php create mode 100644 tests/wpcli.suite.dist.yml rename tests/{wpunit => wpcli}/Database/Cli/DatabaseMigrateCest.php (89%) diff --git a/.env.testing.slic b/.env.testing.slic index db7a2a7..bb361c6 100644 --- a/.env.testing.slic +++ b/.env.testing.slic @@ -3,6 +3,7 @@ SLIC_PHP_VERSION=8.3 ENVIRONMENT=tests TEST_LOG_CHANNEL=stack TEST_LOG_LEVEL=debug +TEST_COMMAND_PREFIX=nxtest WP_VERSION=latest WP_ROOT_FOLDER=/var/www/html diff --git a/.gitattributes b/.gitattributes index dfb08e8..8fcef21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ /.gitignore export-ignore /.github export-ignore /docs export-ignore +/dev export-ignore /.env.testing.slic export-ignore /.env.slic.local export-ignore /.env.slic.run export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85e5e86..834617f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -106,11 +106,21 @@ jobs: run: | "${SLIC_BIN}" run feature --ext DotReporter + - name: Run integration tests + if: github.event_name != 'pull_request' + run: | + "${SLIC_BIN}" run integration --ext DotReporter + - name: Run wpunit tests if: github.event_name != 'pull_request' run: | "${SLIC_BIN}" run wpunit --ext DotReporter + - name: Run wpcli tests + if: github.event_name != 'pull_request' + run: | + "${SLIC_BIN}" run wpcli --ext DotReporter + - name: Enable Xdebug for coverage if: github.event_name == 'pull_request' run: | @@ -119,7 +129,16 @@ jobs: - name: Run Codeception tests with coverage if: github.event_name == 'pull_request' run: | - "${SLIC_BIN}" run --coverage --coverage-xml clover.xml --disable-coverage-php --ext DotReporter + composer run coverage:phpcov-install + composer run coverage:prepare + trap '"${SLIC_BIN}" xdebug off' EXIT + "${SLIC_BIN}" xdebug on + "${SLIC_BIN}" run unit --coverage coverage/unit.cov --ext DotReporter + "${SLIC_BIN}" run feature --coverage coverage/feature.cov --ext DotReporter + "${SLIC_BIN}" run integration --coverage coverage/integration.cov --ext DotReporter + "${SLIC_BIN}" run wpunit --coverage coverage/wpunit.cov --ext DotReporter + "${SLIC_BIN}" run wpcli --coverage coverage/wpcli.cov --ext DotReporter + "${SLIC_BIN}" composer run coverage:merge - name: Monitor coverage if: github.event_name == 'pull_request' @@ -128,7 +147,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} comment_footer: false coverage_format: clover - coverage_path: tests/_output/clover.xml + coverage_path: clover.xml threshold_alert: 90 threshold_warning: 95 threshold_metric: "lines" diff --git a/.gitignore b/.gitignore index b2b827c..7cb9e19 100644 --- a/.gitignore +++ b/.gitignore @@ -59,5 +59,7 @@ vendor /coverage/ /tests/_output/* !/tests/_output/.gitkeep +!/tests/_output/coverage/ +!/tests/_output/coverage/.gitignore /tests/_data/temp/* !/tests/_data/temp/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 8520fde..e82ff85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,15 +157,15 @@ Reusable test fixtures, sample classes, and test doubles should live under `test Tests that need writable temporary files or directories should use a test-specific subdirectory under `tests/_data/temp` instead of `sys_get_temp_dir()`. Use `$this->temp_dir('')` when only the path is needed; it mirrors `codecept_data_dir()` and does not create the directory. Use `$this->prepare_temp_dir('')` in `setUp()` to create a unique clean directory under that name and register it for automatic cleanup by the base test case. Only call `$this->remove_temp_dir('')` manually when a test needs to remove the prepared directories before teardown. -Codeception tests run through SLIC. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, and `composer test:wpunit` or `slic run wpunit`. +Codeception tests run through SLIC. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. -Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, and `wpunit` is WordPress-loaded behavior through wp-browser. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. +Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, `wpdb`, `dbDelta()`, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. -Use `wpunit` for behavior that depends on WordPress runtime APIs such as `wpdb`, `dbDelta()`, hooks, global WordPress state, or real WP-CLI execution. Keep unit tests focused on portable package behavior and pure collaborators; do not build large fake WordPress runtimes in unit tests when the behavior can be covered with wp-browser. +Use `integration` for behavior where multiple providers/packages must be registered together to prove the container graph works. Use `wpunit` for a single package/class where the main concern is direct WordPress API behavior. Use `wpcli` for real WP-CLI command execution shared across packages. Keep unit tests focused on portable package behavior and pure collaborators; do not build large fake WordPress runtimes in unit tests when the behavior can be covered with wp-browser. Use `tests/WPUnitSupport/WPTestCase.php` as the base class for wpunit tests instead of extending Codeception's `WPTestCase` directly. Keep Codeception-generated actor files in `tests/CodeceptionSupport/`; that directory is ignored and excluded from lint/static analysis. -After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. +After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. Coverage is generated by running each SLIC suite separately and merging the serialized `.cov` artifacts with `phpcov`; run the merge through `slic composer run coverage:merge` or `slic composer run coverage:merge-html` because the coverage files contain container paths like `/var/www/html/wp-content/plugins/foundation`. ## Releases diff --git a/README.md b/README.md index 3d83bca..a4e1630 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ Run the Codeception suites with SLIC: ```bash slic run unit slic run feature +composer test:integration composer test:wpunit +composer test:wpcli ``` The first time you run the WordPress suite locally, point SLIC at the directory that contains this repository and select the `foundation` project: @@ -66,7 +68,9 @@ cd foundation slic use foundation slic composer install slic cc build +composer test:integration composer test:wpunit +composer test:wpcli ``` If dependencies were installed on a different host PHP version and the SLIC container reports Composer platform conflicts, refresh them inside SLIC: @@ -77,7 +81,7 @@ slic composer update --with-all-dependencies Run `slic cc build` again after changing Codeception suite configuration or modules. Generated Codeception actor files are written to `tests/CodeceptionSupport/` and are intentionally ignored. -The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `wpunit` suite runs WordPress-loaded tests through wp-browser. +The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `integration` suite covers multi-provider/container behavior that needs WordPress runtime APIs. The `wpunit` suite runs lower-level WordPress-loaded tests through wp-browser. The `wpcli` suite is shared across the monorepo for WP-CLI command tests and uses wp-browser's WPCLI module without the full wpunit module stack. Generate the test coverage HTML dashboard (XDEBUG required to be enabled on your machine): @@ -85,6 +89,8 @@ Generate the test coverage HTML dashboard (XDEBUG required to be enabled on your composer test:coverage-html ``` +Coverage runs the `feature`, `unit`, `wpcli`, and `wpunit` suites in one Codeception coverage process. Run `composer test:integration` separately when you need to validate provider composition; it is intentionally outside the combined coverage command because wp-browser cannot reliably bootstrap multiple WPLoader suites in the same coverage process. + ### Code Quality Check your code style: diff --git a/composer.json b/composer.json index 5ac936f..016d19c 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "ext-curl": "*", "ext-exif": "*", "adbario/php-dot-notation": ">=2.5", - "lucatume/di52": ">=3.0", + "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", "psr/log": ">=1.0", "stellarwp/container-contract": "^1.1", @@ -60,9 +60,16 @@ }, "autoload-dev": { "psr-4": { - "StellarWP\\Foundation\\Tests\\": "tests/", + "StellarWP\\Foundation\\Tests\\Feature\\": "tests/Feature/", + "StellarWP\\Foundation\\Tests\\Integration\\": "tests/integration/", + "StellarWP\\Foundation\\Tests\\Support\\": "tests/Support/", + "StellarWP\\Foundation\\Tests\\Unit\\": "tests/Unit/", + "StellarWP\\Foundation\\Tests\\WPUnitSupport\\": "tests/WPUnitSupport/", "StellarWP\\Foundation\\Tests\\WPUnit\\": "tests/wpunit/" - } + }, + "classmap": [ + "tests/TestCase.php" + ] }, "bin": [ "src/Cli/bin/foundation" @@ -84,9 +91,31 @@ "test:slic": "slic run", "test:slic:unit": "slic run unit", "test:slic:feature": "slic run feature", + "test:integration": "slic run integration", "test:wpunit": "slic run wpunit", - "test:coverage": "slic xdebug on && slic run --coverage --coverage-xml clover.xml --disable-coverage-php; status=$?; slic xdebug off; exit $status", - "test:coverage-html": "slic xdebug on && slic run --coverage --coverage-html coverage --disable-coverage-php; status=$?; slic xdebug off; exit $status", + "test:wpcli": "slic run wpcli", + "test:coverage": "@test:coverage:split", + "test:coverage-html": "@test:coverage-html:split", + "test:clean": "rm -f clover.xml tests/_output/coverage/*.cov && rm -rf ./coverage", + "coverage:phpcov-install": [ + "mkdir -p dev/bin", + "test -f ./dev/bin/phpcov.phar || curl -L -o ./dev/bin/phpcov.phar https://phar.phpunit.de/phpcov-10.0.1.phar" + ], + "coverage:prepare": "rm -f tests/_output/coverage/*.cov", + "coverage:merge": "@php dev/bin/phpcov.phar merge tests/_output/coverage --clover clover.xml", + "coverage:merge-html": "@php dev/bin/phpcov.phar merge tests/_output/coverage --clover clover.xml --html coverage", + "test:coverage:split": [ + "@coverage:phpcov-install", + "@coverage:prepare", + "slic xdebug on && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic xdebug off; exit $rc", + "slic composer run coverage:merge" + ], + "test:coverage-html:split": [ + "@coverage:phpcov-install", + "@coverage:prepare", + "slic xdebug on && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic xdebug off; exit $rc", + "slic composer run coverage:merge-html" + ], "analyze": "@php vendor/bin/phpstan analyse --ansi --memory-limit 2G", "lint": "@php vendor/bin/pinte --test -v", "format": "@php vendor/bin/pinte -v" @@ -100,9 +129,18 @@ "test:slic": "Run all Codeception suites through SLIC.", "test:slic:unit": "Run the Codeception unit suite through SLIC.", "test:slic:feature": "Run the Codeception feature suite through SLIC.", + "test:integration": "Run the WordPress-loaded integration suite through SLIC.", "test:wpunit": "Run the WordPress-loaded wpunit suite through SLIC.", - "test:coverage": "Generate combined SLIC/Codeception Clover coverage, then turn Xdebug off.", - "test:coverage-html": "Generate combined SLIC/Codeception HTML coverage, then turn Xdebug off.", + "test:wpcli": "Run the shared WP-CLI command suite through SLIC.", + "test:coverage": "Generate merged Clover coverage from split SLIC suite artifacts.", + "test:coverage-html": "Generate merged Clover and HTML coverage from split SLIC suite artifacts.", + "test:clean": "Remove generated test coverage reports and serialized coverage artifacts.", + "coverage:phpcov-install": "Download the pinned phpcov PHAR used to merge split coverage artifacts.", + "coverage:prepare": "Create and clear the split coverage artifact directory.", + "coverage:merge": "Merge split coverage artifacts into clover.xml. Run through SLIC so container paths resolve.", + "coverage:merge-html": "Merge split coverage artifacts into clover.xml and HTML coverage. Run through SLIC so container paths resolve.", + "test:coverage:split": "Run SLIC suites separately, merge PHPUnit coverage artifacts, and generate clover.xml.", + "test:coverage-html:split": "Run SLIC suites separately, merge PHPUnit coverage artifacts, and generate clover.xml plus HTML coverage.", "analyze": "Run PHPStan static analysis.", "lint": "Check code style with Pinte.", "format": "Fix code style with Pinte." diff --git a/dev/bin/.gitignore b/dev/bin/.gitignore new file mode 100644 index 0000000..86d8465 --- /dev/null +++ b/dev/bin/.gitignore @@ -0,0 +1 @@ +*.phar diff --git a/src/Container/ContainerAdapter.php b/src/Container/ContainerAdapter.php index 91d728a..4a497cd 100644 --- a/src/Container/ContainerAdapter.php +++ b/src/Container/ContainerAdapter.php @@ -90,6 +90,15 @@ public function give(mixed $implementation): void { $this->container->give($implementation); } + /** + * {@inheritDoc} + * + * @throws ContainerException + */ + public function mergeArrayVar(string $id, mixed $implementation): void { + $this->container->mergeArrayVar($id, $implementation); + } + public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMethods = null): Closure { // @phpstan-ignore-next-line invalid DocBlock comments in DI52 return $this->container->instance($id, $buildArgs, $afterBuildMethods); diff --git a/src/Container/Contracts/Container.php b/src/Container/Contracts/Container.php index 5502a94..3c1aca2 100644 --- a/src/Container/Contracts/Container.php +++ b/src/Container/Contracts/Container.php @@ -37,6 +37,15 @@ public function needs(string $id): Container; public function give(mixed $implementation): void; + /** + * Add array values to an existing or future binding without replacing previous values. + * + * @param class-string|string $id + * + * @throws \lucatume\DI52\ContainerException + */ + public function mergeArrayVar(string $id, mixed $implementation): void; + /** * Returns a callable object (Closure) that will build an instance of the specified * class using the specified arguments when called. diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 75d5468..e433e79 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -16,6 +16,7 @@ use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\WPCli\WPCliProvider; /** * Registers Foundation database services for WordPress environments. @@ -25,17 +26,15 @@ final class DatabaseProvider extends Provider public const string MIGRATIONS = 'foundation.database.migrations'; public const string MIGRATIONS_TABLE = 'foundation.database.migrations_table'; public const string LOCKS_TABLE = 'foundation.database.locks_table'; - public const string COMMAND_PREFIX = 'foundation.database.command_prefix'; public const string LOCK_NAME = 'foundation.database.lock_name'; public const string LOCK_TTL = 'foundation.database.lock_ttl'; public function register(): void { - $this->singletonIfMissing(self::MIGRATIONS, []); - $this->singletonIfMissing(self::MIGRATIONS_TABLE, $this->tableName('migrations_table', 'nexcess_foundation_migrations')); - $this->singletonIfMissing(self::LOCKS_TABLE, $this->tableName('locks_table', 'nexcess_foundation_locks')); - $this->singletonIfMissing(self::COMMAND_PREFIX, $this->config->get('database.command_prefix', 'foundation')); - $this->singletonIfMissing(self::LOCK_NAME, $this->config->get('database.lock_name', 'foundation-database-migrations')); - $this->singletonIfMissing(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); + $this->container->mergeArrayVar(self::MIGRATIONS, []); + $this->container->singleton(self::MIGRATIONS_TABLE, $this->tableName('migrations_table', 'nexcess_foundation_migrations')); + $this->container->singleton(self::LOCKS_TABLE, $this->tableName('locks_table', 'nexcess_foundation_locks')); + $this->container->singleton(self::LOCK_NAME, $this->config->get('database.lock_name', 'foundation-database-migrations')); + $this->container->singleton(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); $this->configureContextualBindings(); @@ -59,6 +58,8 @@ public function register(): void { $this->container->singleton(LockTable::class); $this->container->singleton(Runner::class); $this->container->singleton(Migrate::class); + + $this->registerCliCommands(); } private function configureContextualBindings(): void { @@ -90,20 +91,26 @@ private function configureContextualBindings(): void { ->needs(Lock::class) ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); + $this->container->when(TableCollection::class) + ->needs('$tables') + ->give(static fn (C $c): array => [ + $c->get(MigrationTable::class), + $c->get(LockTable::class), + ]); + } + + private function registerCliCommands(): void { $this->container->when(Migrate::class) ->needs('$commandPrefix') - ->give(static fn (C $c): string => $c->get(self::COMMAND_PREFIX)); + ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); $this->container->when(Migrate::class) ->needs('$migrations') ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); - $this->container->when(TableCollection::class) - ->needs('$tables') - ->give(static fn (C $c): array => [ - $c->get(MigrationTable::class), - $c->get(LockTable::class), - ]); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(Migrate::class), + ]); } private function tableName(string $key, string $default): mixed { @@ -115,12 +122,4 @@ private function tableName(string $key, string $default): mixed { return static fn (C $c): string => $c->get(DatabaseContract::class)->tableName($default); } - - private function singletonIfMissing(string $id, mixed $implementation): void { - if ($this->container->has($id)) { - return; - } - - $this->container->singleton($id, $implementation); - } } diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php index 1b6dad5..9216170 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/Database/Lock/DatabaseLock.php @@ -4,6 +4,7 @@ use DateInterval; use DateMalformedIntervalStringException; +use DateMalformedStringException; use DateTimeImmutable; use InvalidArgumentException; use Random\RandomException; @@ -28,6 +29,7 @@ public function __construct( /** * @throws DateMalformedIntervalStringException * @throws RandomException + * @throws DateMalformedStringException */ public function acquire(string $name, int $ttl): ?LockToken { $this->assertValidName($name); diff --git a/src/Database/README.md b/src/Database/README.md index 5ab23c6..8b485de 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -129,7 +129,7 @@ Foundation's own migration infrastructure tables implement `StellarWP\Foundation ## WP-CLI -The package includes a `migrate` command class for projects using `stellarwp/foundation-wpcli`. Register it from the consuming application's CLI provider with the rest of the project's commands. +The package includes a `migrate` command class for projects using `stellarwp/foundation-wpcli`. `DatabaseProvider` adds that command to `StellarWP\Foundation\WPCli\Provider::COMMANDS`; register the WP-CLI provider once in the consuming application so merged commands are registered on `cli_init`. Available flags: diff --git a/src/WPCli/README.md b/src/WPCli/README.md index d3f2413..15ee7ce 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -113,7 +113,7 @@ final class {{ class }} extends Command ## Provider Setup -Applications should register their own provider so they control the command namespace and command list. +Applications should register `StellarWP\Foundation\WPCli\Provider` once in the application provider list. Feature-specific providers can then add command classes to the shared command list with `mergeArrayVar()`. Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin. That provider belongs to the developer-facing `foundation` console binary, not plugin runtime bootstrap. @@ -125,10 +125,7 @@ Generated command classes use Strauss-prefixed Foundation imports automatically namespace Acme\App\Cli; use StellarWP\Foundation\Container\Contracts\Provider; -use StellarWP\Foundation\WPCli\Command; -use StellarWP\Foundation\WPCli\TimestampedLogger; -use WP_CLI; -use WP_CLI\Loggers\Regular; +use StellarWP\Foundation\WPCli\WPCliProvider as WPCliProvider; final class Wp_Cli_Provider extends Provider { @@ -142,42 +139,12 @@ final class Wp_Cli_Provider extends Provider ]; public function register(): void { - if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { - return; - } - - $this->configureCommands(); - $this->registerTimestampedLogger(); - - add_action( 'cli_init', function (): void { - foreach ( self::COMMANDS as $commandClass ) { - $command = $this->container->get( $commandClass ); - - if ( $command instanceof Command ) { - $command->register(); - } - } - }, 0, 0 ); - } - - private function configureCommands(): void { - foreach ( self::COMMANDS as $commandClass ) { - $this->container->when( $commandClass ) - ->needs( '$commandPrefix' ) - ->give( self::COMMAND_PREFIX ); - } - } - - private function registerTimestampedLogger(): void { - $wpCliLogger = WP_CLI::get_logger(); - - if ( $wpCliLogger instanceof Regular ) { - WP_CLI::set_logger( new TimestampedLogger( $wpCliLogger ) ); - } + $this->container->singleton( WPCliProvider::COMMAND_PREFIX, self::COMMAND_PREFIX ); + $this->container->mergeArrayVar( WPCliProvider::COMMANDS, self::COMMANDS ); } } ``` -Use `cli_init` so commands are registered only during WP-CLI command bootstrap, after WordPress has loaded enough for plugin providers and hooks to be available. +The Foundation WP-CLI provider uses `cli_init` internally so commands are registered only during WP-CLI command bootstrap, after all application providers have had a chance to add command classes. -If your application does not use WordPress hooks during bootstrap, call the command registration loop at the point where WP-CLI is active and your container has been configured. +If your application wants a different default command prefix without a feature-specific CLI provider, bind `WPCliProvider::COMMAND_PREFIX` before WP-CLI's `cli_init` hook runs. diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php new file mode 100644 index 0000000..005e24a --- /dev/null +++ b/src/WPCli/WPCliProvider.php @@ -0,0 +1,39 @@ +container->mergeArrayVar(self::COMMANDS, []); + $this->container->bind(self::COMMAND_PREFIX, $this->config->get('wpcli.command_prefix', 'nx')); + + add_action('cli_init', function (): void { + $this->registerCommands(); + }, 0, 0); + } + + private function registerCommands(): void { + $commands = $this->container->get(self::COMMANDS); + + foreach ($commands as $command) { + if (! $command instanceof Command) { + continue; + } + + $command->register(); + } + } +} diff --git a/tests/CodeceptionSupport/IntegrationTester.php b/tests/CodeceptionSupport/IntegrationTester.php new file mode 100644 index 0000000..3899015 --- /dev/null +++ b/tests/CodeceptionSupport/IntegrationTester.php @@ -0,0 +1,28 @@ +registered = true; + } + + protected function subcommand(): string { + return 'recording'; + } + + protected function description(): string { + return 'Recording command.'; + } + + protected function arguments(): array { + return []; + } +} diff --git a/tests/Unit/Container/ContainerAdapterTest.php b/tests/Unit/Container/ContainerAdapterTest.php index bafaea2..5cffc02 100644 --- a/tests/Unit/Container/ContainerAdapterTest.php +++ b/tests/Unit/Container/ContainerAdapterTest.php @@ -24,6 +24,15 @@ public function test_it_returns_callbacks_from_the_wrapped_container(): void { $this->assertSame('value', $callback()); } + public function test_it_merges_array_bindings_on_the_wrapped_container(): void { + $adapter = new ContainerAdapter(new DI52Container()); + + $adapter->mergeArrayVar('values', ['first']); + $adapter->mergeArrayVar('values', static fn (): array => ['second']); + + $this->assertSame(['first', 'second'], $adapter->get('values')); + } + public function test_it_forwards_unknown_method_calls_to_the_wrapped_container(): void { $adapter = new ContainerAdapter(new DI52Container()); diff --git a/tests/Unit/Database/Exceptions/QueryExceptionTest.php b/tests/Unit/Database/Exceptions/QueryExceptionTest.php new file mode 100644 index 0000000..cf53e54 --- /dev/null +++ b/tests/Unit/Database/Exceptions/QueryExceptionTest.php @@ -0,0 +1,34 @@ +assertSame('Query failed.', $exception->getMessage()); + $this->assertSame('SELECT * FROM %i WHERE id = %d', $exception->sql()); + $this->assertSame(['foundation_table', 23], $exception->bindings()); + $this->assertSame('Table does not exist.', $exception->databaseError()); + $this->assertSame($previous, $exception->getPrevious()); + } + + public function test_it_allows_missing_bindings_and_database_error(): void { + $exception = new QueryException('Query failed.', 'SELECT 1'); + + $this->assertSame([], $exception->bindings()); + $this->assertNull($exception->databaseError()); + } +} diff --git a/tests/WPUnitSupport/WPTestCase.php b/tests/WPUnitSupport/WPTestCase.php index 910d806..92be16d 100644 --- a/tests/WPUnitSupport/WPTestCase.php +++ b/tests/WPUnitSupport/WPTestCase.php @@ -2,7 +2,11 @@ namespace StellarWP\Foundation\Tests\WPUnitSupport; +use Adbar\Dot; use lucatume\WPBrowser\TestCase\WPTestCase as CodeceptionWPTestCase; +use StellarWP\ContainerContract\ContainerInterface; +use StellarWP\Foundation\Container\ContainerAdapter; +use StellarWP\Foundation\Container\Contracts\Container; /** * Base test case for WordPress integration tests. @@ -12,4 +16,14 @@ */ abstract class WPTestCase extends CodeceptionWPTestCase { + protected Container $container; + + protected function setUp(): void { + parent::setUp(); + + $this->container = new ContainerAdapter(new \lucatume\DI52\Container()); + $this->container->bind(Container::class, $this->container); + $this->container->bind(ContainerInterface::class, $this->container); + $this->container->singleton(Dot::class, new Dot(require dirname(__DIR__) . '/config.php')); + } } diff --git a/tests/_output/coverage/.gitignore b/tests/_output/coverage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/_output/coverage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/config.php b/tests/config.php index e5d30d6..33b8903 100644 --- a/tests/config.php +++ b/tests/config.php @@ -6,9 +6,10 @@ * @see \StellarWP\Foundation\Tests\TestCase::setUp() * @see \Adbar\Dot * @see phpunit.xml.dist + * @see .env.testing.slic */ return [ - 'log' => [ + 'log' => [ 'level' => $_ENV['TEST_LOG_LEVEL'] ?? 'debug', 'channel' => $_ENV['TEST_LOG_CHANNEL'] ?? 'null', 'channels' => [ @@ -25,4 +26,7 @@ ], ], ], + 'wpcli' => [ + 'command_prefix' => $_ENV['TEST_COMMAND_PREFIX'] ?? 'nxtest', + ], ]; diff --git a/tests/integration.suite.dist.yml b/tests/integration.suite.dist.yml new file mode 100644 index 0000000..8788dab --- /dev/null +++ b/tests/integration.suite.dist.yml @@ -0,0 +1,20 @@ +# Codeception Test Suite Configuration + +actor: IntegrationTester +path: integration +modules: + enabled: + - lucatume\WPBrowser\Module\WPLoader + - lucatume\WPBrowser\Module\WPQueries + config: + lucatume\WPBrowser\Module\WPLoader: + wpRootFolder: %WP_ROOT_FOLDER% + dbName: %WP_TEST_DB_NAME% + dbHost: %WP_TEST_DB_HOST% + dbUser: %WP_TEST_DB_USER% + dbPassword: %WP_TEST_DB_PASSWORD% + tablePrefix: %WP_TABLE_PREFIX% + domain: %WP_DOMAIN% + adminEmail: admin@stellarwp.com + title: 'Foundation Tests' + theme: twentytwentythree diff --git a/tests/Unit/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php similarity index 53% rename from tests/Unit/Database/DatabaseProviderTest.php rename to tests/integration/Database/DatabaseProviderTest.php index 97d38dd..63f85fd 100644 --- a/tests/Unit/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -1,29 +1,32 @@ newContainer(); + $this->container->register(WPCliProvider::class); + $this->container->register(DatabaseProvider::class); - $container->register(DatabaseProvider::class); + $commands = $this->container->get(WPCliProvider::COMMANDS); - $this->assertSame([], $container->get(DatabaseProvider::MIGRATIONS)); - $this->assertSame('foundation', $container->get(DatabaseProvider::COMMAND_PREFIX)); - $this->assertSame('foundation-database-migrations', $container->get(DatabaseProvider::LOCK_NAME)); - $this->assertSame(300, $container->get(DatabaseProvider::LOCK_TTL)); + $this->assertSame([], $this->container->get(DatabaseProvider::MIGRATIONS)); + $this->assertSame('foundation-database-migrations', $this->container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame(300, $this->container->get(DatabaseProvider::LOCK_TTL)); + $this->assertContainsOnlyInstancesOf(Command::class, $commands); + $this->assertTrue($this->containsMigrateCommand((array) $commands)); } public function test_it_registers_configured_database_configuration(): void { @@ -31,52 +34,37 @@ public function test_it_registers_configured_database_configuration(): void { 'database' => [ 'migrations_table' => 'custom_migrations', 'locks_table' => 'custom_locks', - 'command_prefix' => 'custom', 'lock_name' => 'custom-migrations', 'lock_ttl' => '120', ], + 'wpcli' => [ + 'command_prefix' => 'custom', + ], ]); + $container->register(WPCliProvider::class); $container->register(DatabaseProvider::class); $this->assertSame('custom_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); $this->assertSame('custom_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); - $this->assertSame('custom', $container->get(DatabaseProvider::COMMAND_PREFIX)); $this->assertSame('custom-migrations', $container->get(DatabaseProvider::LOCK_NAME)); $this->assertSame(120, $container->get(DatabaseProvider::LOCK_TTL)); + $this->assertSame('custom', $container->get(WPCliProvider::COMMAND_PREFIX)); } - public function test_it_does_not_overwrite_preconfigured_migrations(): void { + public function test_it_preserves_preconfigured_migrations(): void { $migration = new TestMigration('2026_06_23_000001_create_example'); $container = $this->newContainer(); - $container->singleton(DatabaseProvider::MIGRATIONS, [$migration]); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + $container->register(WPCliProvider::class); $container->register(DatabaseProvider::class); $this->assertSame([$migration], $container->get(DatabaseProvider::MIGRATIONS)); } - public function test_it_fails_clearly_when_wordpress_database_is_not_available(): void { - $previous = $GLOBALS['wpdb'] ?? null; - unset($GLOBALS['wpdb']); - - $container = $this->newContainer(); - $container->register(DatabaseProvider::class); - - $this->expectException(ContainerException::class); - $this->expectExceptionMessage('the global wpdb instance is not available.'); - - try { - $container->get(Database::class); - } finally { - if ($previous !== null) { - $GLOBALS['wpdb'] = $previous; - } - } - } - /** - * @param array $config + * @param array $config */ private function newContainer(array $config = []): Container { $container = new ContainerAdapter(new DI52Container()); @@ -86,4 +74,17 @@ private function newContainer(array $config = []): Container { return $container; } + + /** + * @param array $commands + */ + private function containsMigrateCommand(array $commands): bool { + foreach ($commands as $command) { + if ($command instanceof Migrate) { + return true; + } + } + + return false; + } } diff --git a/tests/integration/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php new file mode 100644 index 0000000..cdefac8 --- /dev/null +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -0,0 +1,28 @@ +container->when(RecordingCommand::class) + ->needs('$commandPrefix') + ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); + + $this->container->singleton(RecordingCommand::class); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(RecordingCommand::class), + ]); + + $this->container->register(WPCliProvider::class); + + do_action('cli_init'); + + $this->assertTrue($this->container->get(RecordingCommand::class)->registered); + } +} diff --git a/tests/wpcli.suite.dist.yml b/tests/wpcli.suite.dist.yml new file mode 100644 index 0000000..f5305da --- /dev/null +++ b/tests/wpcli.suite.dist.yml @@ -0,0 +1,16 @@ +# Codeception Test Suite Configuration + +actor: WPCLITester +path: wpcli +modules: + enabled: + - lucatume\WPBrowser\Module\WPCLI + config: + lucatume\WPBrowser\Module\WPCLI: + path: %WP_ROOT_FOLDER% + url: %WP_URL% + user: %WP_ADMIN_USERNAME% + require: + - /var/www/html/wp-content/plugins/foundation/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php + throw: false + allow-root: true diff --git a/tests/wpunit/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php similarity index 89% rename from tests/wpunit/Database/Cli/DatabaseMigrateCest.php rename to tests/wpcli/Database/Cli/DatabaseMigrateCest.php index 524569a..975c8bd 100644 --- a/tests/wpunit/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -4,15 +4,15 @@ final class DatabaseMigrateCest { - public function _before(WPUnitTester $I): void { + public function _before(WPCLITester $I): void { $this->dropTables($I); } - public function _after(WPUnitTester $I): void { + public function _after(WPCLITester $I): void { $this->dropTables($I); } - public function test_it_runs_database_migrations_through_wp_cli(WPUnitTester $I): void { + public function test_it_runs_database_migrations_through_wp_cli(WPCLITester $I): void { $I->cli(['foundation', 'migrate', '--create-table']); $I->seeResultCodeIs(0); $I->seeInShellOutput('Foundation database tables are ready.'); @@ -31,7 +31,7 @@ public function test_it_runs_database_migrations_through_wp_cli(WPUnitTester $I) $I->seeInShellOutput('Rolled back 1 migrations.'); } - public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPUnitTester $I): void { + public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLITester $I): void { $I->cli(['foundation', 'migrate', '--run']); $I->seeResultCodeIs(0); $I->seeInShellOutput('Ran 1 migrations.'); @@ -49,13 +49,13 @@ public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPUni Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); } - public function test_it_warns_when_showing_status_before_tables_exist(WPUnitTester $I): void { + public function test_it_warns_when_showing_status_before_tables_exist(WPCLITester $I): void { $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); } - private function dropTables(WPUnitTester $I): void { + private function dropTables(WPCLITester $I): void { $I->cli(['db', 'prefix']); $I->seeResultCodeIs(0); diff --git a/tests/wpunit.suite.dist.yml b/tests/wpunit.suite.dist.yml index c6b6192..1792056 100644 --- a/tests/wpunit.suite.dist.yml +++ b/tests/wpunit.suite.dist.yml @@ -6,7 +6,6 @@ modules: enabled: - lucatume\WPBrowser\Module\WPLoader - lucatume\WPBrowser\Module\WPQueries - - lucatume\WPBrowser\Module\WPCLI config: lucatume\WPBrowser\Module\WPLoader: wpRootFolder: %WP_ROOT_FOLDER% @@ -19,11 +18,3 @@ modules: adminEmail: admin@stellarwp.com title: 'Foundation Tests' theme: twentytwentythree - lucatume\WPBrowser\Module\WPCLI: - path: %WP_ROOT_FOLDER% - url: %WP_URL% - user: %WP_ADMIN_USERNAME% - require: - - /var/www/html/wp-content/plugins/foundation/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php - throw: false - allow-root: true From ca2d996118f712b040945e6de8055e6c6ec5800c Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 25 Jun 2026 13:26:22 -0600 Subject: [PATCH 11/81] Use pcov for code coverage now that slic 2.3.0 is out --- .github/workflows/tests.yml | 8 +------- composer.json | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 834617f..139812b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -121,18 +121,12 @@ jobs: run: | "${SLIC_BIN}" run wpcli --ext DotReporter - - name: Enable Xdebug for coverage - if: github.event_name == 'pull_request' - run: | - "${SLIC_BIN}" xdebug on - - name: Run Codeception tests with coverage if: github.event_name == 'pull_request' run: | composer run coverage:phpcov-install composer run coverage:prepare - trap '"${SLIC_BIN}" xdebug off' EXIT - "${SLIC_BIN}" xdebug on + "${SLIC_BIN}" pcov on --yes "${SLIC_BIN}" run unit --coverage coverage/unit.cov --ext DotReporter "${SLIC_BIN}" run feature --coverage coverage/feature.cov --ext DotReporter "${SLIC_BIN}" run integration --coverage coverage/integration.cov --ext DotReporter diff --git a/composer.json b/composer.json index 016d19c..1673ed2 100644 --- a/composer.json +++ b/composer.json @@ -107,13 +107,13 @@ "test:coverage:split": [ "@coverage:phpcov-install", "@coverage:prepare", - "slic xdebug on && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic xdebug off; exit $rc", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", "slic composer run coverage:merge" ], "test:coverage-html:split": [ "@coverage:phpcov-install", "@coverage:prepare", - "slic xdebug on && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic xdebug off; exit $rc", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", "slic composer run coverage:merge-html" ], "analyze": "@php vendor/bin/phpstan analyse --ansi --memory-limit 2G", From 3e2fe482b7e684f223c475bee97508602be36df7 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 25 Jun 2026 13:30:58 -0600 Subject: [PATCH 12/81] Update docs to remove xdebug coverage in place of pcov --- AGENTS.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e82ff85..502c3c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,7 +157,7 @@ Reusable test fixtures, sample classes, and test doubles should live under `test Tests that need writable temporary files or directories should use a test-specific subdirectory under `tests/_data/temp` instead of `sys_get_temp_dir()`. Use `$this->temp_dir('')` when only the path is needed; it mirrors `codecept_data_dir()` and does not create the directory. Use `$this->prepare_temp_dir('')` in `setUp()` to create a unique clean directory under that name and register it for automatic cleanup by the base test case. Only call `$this->remove_temp_dir('')` manually when a test needs to remove the prepared directories before teardown. -Codeception tests run through SLIC. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. +Codeception tests run through SLIC. Use SLIC 2.3.0 or newer so PCOV-backed coverage commands are available. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, `wpdb`, `dbDelta()`, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. @@ -165,7 +165,7 @@ Use `integration` for behavior where multiple providers/packages must be registe Use `tests/WPUnitSupport/WPTestCase.php` as the base class for wpunit tests instead of extending Codeception's `WPTestCase` directly. Keep Codeception-generated actor files in `tests/CodeceptionSupport/`; that directory is ignored and excluded from lint/static analysis. -After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. Coverage is generated by running each SLIC suite separately and merging the serialized `.cov` artifacts with `phpcov`; run the merge through `slic composer run coverage:merge` or `slic composer run coverage:merge-html` because the coverage files contain container paths like `/var/www/html/wp-content/plugins/foundation`. +After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. Coverage uses SLIC 2.3.0+ PCOV support, runs each SLIC suite separately, and merges the serialized `.cov` artifacts with `phpcov`; run the merge through `slic composer run coverage:merge` or `slic composer run coverage:merge-html` because the coverage files contain container paths like `/var/www/html/wp-content/plugins/foundation`. ## Releases diff --git a/README.md b/README.md index a4e1630..44e713f 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,13 @@ Run `slic cc build` again after changing Codeception suite configuration or modu The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `integration` suite covers multi-provider/container behavior that needs WordPress runtime APIs. The `wpunit` suite runs lower-level WordPress-loaded tests through wp-browser. The `wpcli` suite is shared across the monorepo for WP-CLI command tests and uses wp-browser's WPCLI module without the full wpunit module stack. -Generate the test coverage HTML dashboard (XDEBUG required to be enabled on your machine): +Generate the test coverage HTML dashboard: ```bash composer test:coverage-html ``` -Coverage runs the `feature`, `unit`, `wpcli`, and `wpunit` suites in one Codeception coverage process. Run `composer test:integration` separately when you need to validate provider composition; it is intentionally outside the combined coverage command because wp-browser cannot reliably bootstrap multiple WPLoader suites in the same coverage process. +Coverage uses SLIC 2.3.0+ PCOV support for faster collection. It runs each SLIC suite separately, writes serialized `.cov` artifacts, and merges them with `phpcov` so multiple WordPress-loaded suites can contribute to one Clover or HTML report. ### Code Quality From 49c8a4b803a54c5e096fb00430e790af957b4d7a Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 26 Jun 2026 10:01:20 -0600 Subject: [PATCH 13/81] Clean up DatabaseProvider + add agents rules --- AGENTS.md | 4 ++ src/Database/DatabaseProvider.php | 69 +++++++++++++++++++------------ 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 502c3c4..06d11a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,8 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. +Avoid `use ... as ...` import aliases unless they resolve a real class-name collision or ambiguity. Prefer importing the class by its actual short name. The standing exception is `use lucatume\DI52\Container as C;`, which may be used for concise container factory callbacks. + Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. Shared generation infrastructure that is not itself a console command should live under `src/Cli/Generation/`. @@ -77,6 +79,8 @@ When writing providers or container registration code, prefer container-driven c Use contextual bindings with `$this->container->when()->needs()->give()` for scalar constructor arguments, command lists, or feature-specific substitutions. Use a factory closure only when the value must be computed or resolved from the container, and keep that closure focused on supplying the constructor dependency rather than constructing the full object. +Organize provider registration by feature or capability, not by container mechanism. The main `register()` method should call focused private methods such as `registerConfiguration()`, `registerMigrations()`, `registerLocks()`, or `registerCliCommands()`. Keep each feature's contextual bindings beside the classes they configure. Avoid generic methods such as `configureContextualBindings()` that group unrelated bindings only because they use the same container API. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index e433e79..b4af954 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -12,7 +12,7 @@ use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Repository as MigrationRecordRepository; use StellarWP\Foundation\Database\Migration\Runner; -use StellarWP\Foundation\Database\Table\Collection as TableCollection; +use StellarWP\Foundation\Database\Table\Collection; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; @@ -30,14 +30,23 @@ final class DatabaseProvider extends Provider public const string LOCK_TTL = 'foundation.database.lock_ttl'; public function register(): void { + $this->registerConfiguration(); + $this->registerDatabase(); + $this->registerTables(); + $this->registerMigrations(); + $this->registerLocks(); + $this->registerCliCommands(); + } + + private function registerConfiguration(): void { $this->container->mergeArrayVar(self::MIGRATIONS, []); $this->container->singleton(self::MIGRATIONS_TABLE, $this->tableName('migrations_table', 'nexcess_foundation_migrations')); $this->container->singleton(self::LOCKS_TABLE, $this->tableName('locks_table', 'nexcess_foundation_locks')); $this->container->singleton(self::LOCK_NAME, $this->config->get('database.lock_name', 'foundation-database-migrations')); $this->container->singleton(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); + } - $this->configureContextualBindings(); - + private function registerDatabase(): void { $this->container->singleton(Database::class, static function (): Database { $wpdb = $GLOBALS['wpdb'] ?? null; @@ -50,34 +59,33 @@ public function register(): void { $this->container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); $this->container->singleton(Schema::class, static fn (C $c): Schema => new Schema($c->get(DatabaseContract::class))); $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); - $this->container->singleton(TableCollection::class); - $this->container->singleton(MigrationRecordRepository::class); - $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); - $this->container->singleton(DatabaseLock::class); - $this->container->singleton(MigrationTable::class); - $this->container->singleton(LockTable::class); - $this->container->singleton(Runner::class); - $this->container->singleton(Migrate::class); - - $this->registerCliCommands(); } - private function configureContextualBindings(): void { - $this->container->when(MigrationRecordRepository::class) - ->needs('$table') - ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); - + private function registerTables(): void { $this->container->when(MigrationTable::class) ->needs('$table') ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); - $this->container->when(DatabaseLock::class) + $this->container->when(LockTable::class) ->needs('$table') ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); - $this->container->when(LockTable::class) + $this->container->when(Collection::class) + ->needs('$tables') + ->give(static fn (C $c): array => [ + $c->get(MigrationTable::class), + $c->get(LockTable::class), + ]); + + $this->container->singleton(MigrationTable::class); + $this->container->singleton(LockTable::class); + $this->container->singleton(Collection::class); + } + + private function registerMigrations(): void { + $this->container->when(MigrationRecordRepository::class) ->needs('$table') - ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); $this->container->when(Runner::class) ->needs('$lockName') @@ -91,12 +99,17 @@ private function configureContextualBindings(): void { ->needs(Lock::class) ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); - $this->container->when(TableCollection::class) - ->needs('$tables') - ->give(static fn (C $c): array => [ - $c->get(MigrationTable::class), - $c->get(LockTable::class), - ]); + $this->container->singleton(MigrationRecordRepository::class); + $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); + $this->container->singleton(Runner::class); + } + + private function registerLocks(): void { + $this->container->when(DatabaseLock::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + + $this->container->singleton(DatabaseLock::class); } private function registerCliCommands(): void { @@ -108,6 +121,8 @@ private function registerCliCommands(): void { ->needs('$migrations') ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + $this->container->singleton(Migrate::class); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ $c->get(Migrate::class), ]); From 6c7e8ac599466a4772466d6b3cea38f2d577c4a2 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 26 Jun 2026 10:35:06 -0600 Subject: [PATCH 14/81] Convert migrations to a collection --- src/Database/Cli/Migrate.php | 11 ++-- src/Database/DatabaseProvider.php | 10 ++-- src/Database/Migration/Collection.php | 52 +++++++++++++++++++ .../register-wpcli-migrate-command.php | 3 +- tests/Unit/Database/Cli/MigrateTest.php | 7 +-- .../Database/Migration/CollectionTest.php | 21 ++++++++ .../Database/DatabaseProviderTest.php | 2 + 7 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 src/Database/Migration/Collection.php create mode 100644 tests/Unit/Database/Migration/CollectionTest.php diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index e292e81..474820e 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -3,9 +3,9 @@ namespace StellarWP\Foundation\Database\Cli; use StellarWP\Foundation\Container\Contracts\Container; -use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Runner; -use StellarWP\Foundation\Database\Table\Collection; +use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\WPCli\Command; use WP_CLI; @@ -24,15 +24,12 @@ final class Migrate extends Command private const string FLAG_CREATE_TABLE = 'create-table'; private const string FLAG_YES = 'yes'; - /** - * @param iterable $migrations - */ public function __construct( protected Container $container, string $commandPrefix, private readonly Runner $runner, - private readonly iterable $migrations, - private readonly Collection $tables + private readonly MigrationCollection $migrations, + private readonly TableCollection $tables ) { parent::__construct($this->container, $commandPrefix); } diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index b4af954..df3fc0b 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -10,6 +10,7 @@ use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Lock\DatabaseLock; +use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Repository as MigrationRecordRepository; use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Table\Collection; @@ -83,6 +84,10 @@ private function registerTables(): void { } private function registerMigrations(): void { + $this->container->when(MigrationCollection::class) + ->needs('$migrations') + ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + $this->container->when(MigrationRecordRepository::class) ->needs('$table') ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); @@ -99,6 +104,7 @@ private function registerMigrations(): void { ->needs(Lock::class) ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); + $this->container->singleton(MigrationCollection::class); $this->container->singleton(MigrationRecordRepository::class); $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); $this->container->singleton(Runner::class); @@ -117,10 +123,6 @@ private function registerCliCommands(): void { ->needs('$commandPrefix') ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); - $this->container->when(Migrate::class) - ->needs('$migrations') - ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); - $this->container->singleton(Migrate::class); $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ diff --git a/src/Database/Migration/Collection.php b/src/Database/Migration/Collection.php new file mode 100644 index 0000000..0c02b3e --- /dev/null +++ b/src/Database/Migration/Collection.php @@ -0,0 +1,52 @@ + + */ +final class Collection implements IteratorAggregate +{ + /** + * @var list + */ + private array $migrations = []; + + /** + * @param iterable $migrations + */ + public function __construct( + iterable $migrations = [] + ) { + foreach ($migrations as $migration) { + $this->add($migration); + } + } + + public function add(Migration ...$migrations): void { + foreach ($migrations as $migration) { + $this->migrations[] = $migration; + } + } + + /** + * @return list + */ + public function all(): array { + return $this->migrations; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable { + return new ArrayIterator($this->migrations); + } +} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 90e080e..068e777 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -10,6 +10,7 @@ use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; use StellarWP\Foundation\Database\Database; use StellarWP\Foundation\Database\Lock\DatabaseLock; +use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Schema; @@ -78,7 +79,7 @@ public function down(SchemaContract $schema): void { $schema, new DatabaseLock($database, $lockTable) ), - [$migration], + new MigrationCollection([$migration]), new TableCollection($schema, [ new MigrationTable($database, $migrationTable), new LockTable($database, $lockTable), diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index 52f194e..8b704a0 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use StellarWP\Foundation\Database\Cli\Migrate; +use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Schema as DatabaseSchema; use StellarWP\Foundation\Database\Table\Collection as TableCollection; @@ -35,7 +36,7 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $this->container, 'foundation', new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock()), - [], + new MigrationCollection(), new TableCollection($wpSchema, [ new MigrationTable($database, 'wp_nexcess_foundation_migrations'), new LockTable($database, 'wp_nexcess_foundation_locks'), @@ -193,9 +194,9 @@ private function newCommand(): array { $this->container, 'foundation', $runner, - [ + new MigrationCollection([ new TestMigration('2026_06_23_000001_create_example'), - ], + ]), new TableCollection($wpSchema, [ new MigrationTable($database, 'wp_nexcess_foundation_migrations'), new LockTable($database, 'wp_nexcess_foundation_locks'), diff --git a/tests/Unit/Database/Migration/CollectionTest.php b/tests/Unit/Database/Migration/CollectionTest.php new file mode 100644 index 0000000..fa14be8 --- /dev/null +++ b/tests/Unit/Database/Migration/CollectionTest.php @@ -0,0 +1,21 @@ +add($second); + + $this->assertSame([$first, $second], $collection->all()); + $this->assertSame([$first, $second], iterator_to_array($collection)); + } +} diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index 63f85fd..c77005f 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -9,6 +9,7 @@ use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Database\Cli\Migrate; use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; use StellarWP\Foundation\WPCli\Command; @@ -61,6 +62,7 @@ public function test_it_preserves_preconfigured_migrations(): void { $container->register(DatabaseProvider::class); $this->assertSame([$migration], $container->get(DatabaseProvider::MIGRATIONS)); + $this->assertSame([$migration], $container->get(Collection::class)->all()); } /** From 9c064de51cd0bd7067150e603cafb06bebbad957 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 26 Jun 2026 11:16:01 -0600 Subject: [PATCH 15/81] Convert to single point of entry, Migrator to run migrations --- src/Database/Cli/Migrate.php | 70 +++++++--- src/Database/DatabaseProvider.php | 4 + src/Database/Migration/Collection.php | 10 ++ src/Database/Migration/Migrator.php | 87 +++++++++++++ src/Database/Migration/Store.php | 37 ++++++ src/Database/README.md | 104 ++++++++++++++- .../register-wpcli-migrate-command.php | 24 ++-- tests/Unit/Database/Cli/MigrateTest.php | 69 +++++++--- .../Database/Migration/CollectionTest.php | 10 ++ .../Unit/Database/Migration/MigratorTest.php | 123 ++++++++++++++++++ .../Database/DatabaseProviderTest.php | 14 ++ 11 files changed, 499 insertions(+), 53 deletions(-) create mode 100644 src/Database/Migration/Migrator.php create mode 100644 src/Database/Migration/Store.php create mode 100644 tests/Unit/Database/Migration/MigratorTest.php diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index 474820e..8f54aa2 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -3,9 +3,7 @@ namespace StellarWP\Foundation\Database\Cli; use StellarWP\Foundation\Container\Contracts\Container; -use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; -use StellarWP\Foundation\Database\Migration\Runner; -use StellarWP\Foundation\Database\Table\Collection as TableCollection; +use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\WPCli\Command; use WP_CLI; @@ -21,15 +19,14 @@ final class Migrate extends Command private const string FLAG_ROLLBACK = 'rollback'; private const string FLAG_REFRESH = 'refresh'; private const string FLAG_DROP = 'drop'; + private const string FLAG_PREPARE = 'prepare'; private const string FLAG_CREATE_TABLE = 'create-table'; private const string FLAG_YES = 'yes'; public function __construct( protected Container $container, string $commandPrefix, - private readonly Runner $runner, - private readonly MigrationCollection $migrations, - private readonly TableCollection $tables + private readonly Migrator $migrator ) { parent::__construct($this->container, $commandPrefix); } @@ -43,18 +40,29 @@ public function runCommand(array $args = [], array $assocArgs = []): int { $rollback = (bool) get_flag_value($assocArgs, self::FLAG_ROLLBACK, false); $refresh = (bool) get_flag_value($assocArgs, self::FLAG_REFRESH, false); $drop = (bool) get_flag_value($assocArgs, self::FLAG_DROP, false); + $prepare = (bool) get_flag_value($assocArgs, self::FLAG_PREPARE, false); $createTable = (bool) get_flag_value($assocArgs, self::FLAG_CREATE_TABLE, false); + if (! $this->hasSingleOperation([ + self::FLAG_RUN => $run, + self::FLAG_ROLLBACK => $rollback, + self::FLAG_REFRESH => $refresh, + self::FLAG_DROP => $drop, + self::FLAG_PREPARE => $prepare || $createTable, + ])) { + return self::ERROR; + } + if ($drop) { WP_CLI::confirm('Are you sure you want to drop the Foundation database tables? This cannot be undone.', $assocArgs); - $this->tables->drop(); + $this->migrator->drop(); WP_CLI::success('Foundation database tables were dropped.'); return self::SUCCESS; } - if ($createTable) { - $this->tables->create(); + if ($prepare || $createTable) { + $this->migrator->prepare(); WP_CLI::success('Foundation database tables are ready.'); return self::SUCCESS; @@ -62,24 +70,21 @@ public function runCommand(array $args = [], array $assocArgs = []): int { if ($refresh) { WP_CLI::confirm('Are you sure you want to roll back and rerun all Foundation database migrations?', $assocArgs); - $this->tables->create(); - $result = $this->runner->refresh($this->migrations); + $result = $this->migrator->refresh(); WP_CLI::success(sprintf('Rolled back %d migrations and ran %d migrations.', count($result->rolledBack), count($result->ran))); return self::SUCCESS; } if ($rollback) { - $this->tables->create(); - $result = $this->runner->rollback($this->migrations); + $result = $this->migrator->rollback(); WP_CLI::success(sprintf('Rolled back %d migrations.', count($result->rolledBack))); return self::SUCCESS; } if ($run) { - $this->tables->create(); - $result = $this->runner->run($this->migrations); + $result = $this->migrator->run(); WP_CLI::success(sprintf('Ran %d migrations.', count($result->ran))); return self::SUCCESS; @@ -128,10 +133,17 @@ protected function arguments(): array { 'optional' => true, 'default' => false, ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_PREPARE, + 'description' => 'Prepare Foundation migration storage without running migrations.', + 'optional' => true, + 'default' => false, + ], [ 'type' => self::FLAG, 'name' => self::FLAG_CREATE_TABLE, - 'description' => 'Create Foundation database tables without running migrations.', + 'description' => 'Alias for --prepare.', 'optional' => true, 'default' => false, ], @@ -146,10 +158,8 @@ protected function arguments(): array { } private function showStatus(): void { - if (! $this->tables->exists()) { - WP_CLI::warning('The Foundation database tables do not exist. Run this command with --create-table or --run.'); - - return; + if (! $this->migrator->exists()) { + WP_CLI::warning('The Foundation database tables do not exist. Run this command with --prepare or --run.'); } format_items('table', array_map( @@ -159,7 +169,7 @@ private function showStatus(): void { 'batch' => $status->batch ?? '', 'ran_at' => $status->ranAt?->format('Y-m-d H:i:s') ?? '', ], - $this->runner->status($this->migrations) + $this->migrator->status() ), [ 'migration', 'status', @@ -167,4 +177,22 @@ private function showStatus(): void { 'ran_at', ]); } + + /** + * @param array $operations + */ + private function hasSingleOperation(array $operations): bool { + $selected = array_keys(array_filter($operations)); + + if (count($selected) <= 1) { + return true; + } + + WP_CLI::error(sprintf( + 'Only one migration operation can be used at a time. Received: --%s.', + implode(', --', $selected) + ), false); + + return false; + } } diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index df3fc0b..ce4117d 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -11,8 +11,10 @@ use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; +use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Repository as MigrationRecordRepository; use StellarWP\Foundation\Database\Migration\Runner; +use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Table\Collection; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; @@ -108,6 +110,8 @@ private function registerMigrations(): void { $this->container->singleton(MigrationRecordRepository::class); $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); $this->container->singleton(Runner::class); + $this->container->singleton(Store::class); + $this->container->singleton(Migrator::class); } private function registerLocks(): void { diff --git a/src/Database/Migration/Collection.php b/src/Database/Migration/Collection.php index 0c02b3e..e7d9e53 100644 --- a/src/Database/Migration/Collection.php +++ b/src/Database/Migration/Collection.php @@ -5,6 +5,7 @@ use ArrayIterator; use IteratorAggregate; use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use Traversable; /** @@ -30,8 +31,17 @@ public function __construct( } } + /** + * @throws DuplicateMigration + */ public function add(Migration ...$migrations): void { foreach ($migrations as $migration) { + foreach ($this->migrations as $registered) { + if ($registered->id() === $migration->id()) { + throw DuplicateMigration::forMigration($migration->id()); + } + } + $this->migrations[] = $migration; } } diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php new file mode 100644 index 0000000..ee0eef6 --- /dev/null +++ b/src/Database/Migration/Migrator.php @@ -0,0 +1,87 @@ +store->prepare(); + } + + /** + * Drop the migration subsystem storage. + */ + public function drop(): void { + $this->store->drop(); + } + + /** + * Determine whether the migration subsystem storage is ready. + */ + public function exists(): bool { + return $this->store->exists(); + } + + /** + * Run all pending configured migrations. + */ + public function run(): Result { + return $this->withPreparedStore(fn (): Result => $this->runner->run($this->migrations)); + } + + /** + * Roll back the latest configured migration batch. + */ + public function rollback(?int $batch = null): Result { + return $this->withPreparedStore(fn (): Result => $this->runner->rollback($this->migrations, $batch)); + } + + /** + * Roll back and rerun all configured migrations. + */ + public function refresh(): Result { + return $this->withPreparedStore(fn (): Result => $this->runner->refresh($this->migrations)); + } + + /** + * @return list + */ + public function status(): array { + if (! $this->store->exists()) { + return array_map( + static fn (Migration $migration): Status => Status::pending($migration->id()), + $this->migrations->all() + ); + } + + return $this->runner->status($this->migrations); + } + + /** + * @template T + * + * @param callable(): T $callback + * + * @return T + */ + private function withPreparedStore(callable $callback): mixed { + $this->store->prepare(); + + return $callback(); + } +} diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php new file mode 100644 index 0000000..19e2326 --- /dev/null +++ b/src/Database/Migration/Store.php @@ -0,0 +1,37 @@ +tables->create(); + } + + /** + * Drop the migration subsystem tables. + */ + public function drop(): void { + $this->tables->drop(); + } + + /** + * Determine whether the migration subsystem tables are ready. + */ + public function exists(): bool { + return $this->tables->exists(); + } +} diff --git a/src/Database/README.md b/src/Database/README.md index 8b485de..649e524 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -11,7 +11,7 @@ composer require stellarwp/foundation-database ## Overview -Foundation Database is a WordPress-backed database package. It provides a small migration runner, migration and table collections, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. +Foundation Database is a WordPress-backed database package. It provides a configured migrator, migration runner, migration and table collections, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. This package intentionally targets WordPress runtime APIs instead of acting as a generic database abstraction. Migration classes depend on a small schema contract so application packages can define migration behavior without calling `wpdb` directly. @@ -34,7 +34,9 @@ The provider registers: - `StellarWP\Foundation\Database\Table\Tables\MigrationTable` - `StellarWP\Foundation\Database\Table\Tables\LockTable` - `StellarWP\Foundation\Database\Contracts\Repository` for the migration ledger +- `StellarWP\Foundation\Database\Migration\Store` - `StellarWP\Foundation\Database\Migration\Runner` +- `StellarWP\Foundation\Database\Migration\Migrator` - `StellarWP\Foundation\Database\Lock\DatabaseLock` for the migration runner By default, WordPress tables are named: @@ -42,7 +44,37 @@ By default, WordPress tables are named: - `nexcess_foundation_migrations` - `nexcess_foundation_locks` -Configure these through the Foundation config keys `database.migrations_table` and `database.locks_table` when an application needs different table names. Configured table names are treated as full table names, so include the WordPress prefix yourself when overriding them. +Configure these through the Foundation config keys `database.migrations_table` and `database.locks_table` when an application needs different table names. Configured table names are treated as exact full table names and are not passed through `Database::tableName()`, so include the WordPress prefix yourself when overriding them. + +Example `config.php` values: + +```php + [ + // Leave empty or omit these keys to use the default WordPress-prefixed names. + 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? '', + 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? '', + 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? 'foundation-database-migrations', + 'lock_ttl' => (int) ($_ENV['FOUNDATION_DATABASE_LOCK_TTL'] ?? 300), + ], + 'wpcli' => [ + 'command_prefix' => $_ENV['FOUNDATION_WPCLI_COMMAND_PREFIX'] ?? 'nx', + ], +]; +``` + +If overriding table names, provide the full table name: + +```php +return [ + 'database' => [ + 'migrations_table' => 'wp_custom_foundation_migrations', + 'locks_table' => 'wp_custom_foundation_locks', + ], +]; +``` ## Running Queries @@ -123,13 +155,68 @@ final readonly class CreateReportsTable implements Migration } ``` -Applications should bind `DatabaseProvider::MIGRATIONS` to the ordered list of `Migration` instances they want the runner to manage. If migrations are bound before registering `DatabaseProvider`, the provider will preserve the existing binding. +Applications should add migrations to `DatabaseProvider::MIGRATIONS` with `mergeArrayVar()` so multiple providers/packages can contribute migrations: -Foundation's own migration infrastructure tables implement `StellarWP\Foundation\Database\Contracts\Table` and are wired into `Table\Collection`. Applications can use the same `Table` contract for their own custom tables. When a table should be recorded in the migration ledger, wrap it in `StellarWP\Foundation\Database\Table\CreateTable` and add that migration instance to `DatabaseProvider::MIGRATIONS`. +```php +use lucatume\DI52\Container as C; +use StellarWP\Foundation\Database\DatabaseProvider; + +$this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + $c->get(CreateReportsTable::class), +]); +``` + +If migrations are added before registering `DatabaseProvider`, the provider will preserve the existing values. Other providers may also add migrations after `DatabaseProvider` is registered, as long as they do so before the migration collection or migrator is resolved. + +Application feature tables should usually be represented by migrations. If a table only needs normal create/drop behavior, define it with `StellarWP\Foundation\Database\Contracts\Table`, wrap it in `StellarWP\Foundation\Database\Table\CreateTable`, and add that migration instance to `DatabaseProvider::MIGRATIONS`. + +```php +use lucatume\DI52\Container as C; +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Table\CreateTable; + +$this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ + new CreateTable($c->get(ReportsTable::class)), // ReportsTable implements Contracts\Table. +]); +``` + +Application code that needs to run migrations should inject `StellarWP\Foundation\Database\Migration\Migrator`. It is the configured entry point for preparing the migration store, running pending migrations, rolling back, refreshing, dropping migration storage, and reading migration status. + +```php +use StellarWP\Foundation\Database\Migration\Migrator; + +final readonly class PluginUpdater +{ + public function __construct( + private Migrator $migrator + ) { + } + + public function update(): void { + $this->migrator->run(); + } +} +``` + +`run()`, `rollback()`, and `refresh()` prepare the migration store automatically before executing migrations. ## WP-CLI -The package includes a `migrate` command class for projects using `stellarwp/foundation-wpcli`. `DatabaseProvider` adds that command to `StellarWP\Foundation\WPCli\Provider::COMMANDS`; register the WP-CLI provider once in the consuming application so merged commands are registered on `cli_init`. +The package includes a `migrate` command class for projects using `stellarwp/foundation-wpcli`. `DatabaseProvider` adds that command to `StellarWP\Foundation\WPCli\WPCliProvider::COMMANDS`; register the WP-CLI provider once in the consuming application so merged commands are registered on `cli_init`. + +```php +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\WPCli\WPCliProvider; + +$container->register(WPCliProvider::class); +$container->register(DatabaseProvider::class); +``` + +Run the command under the configured WP-CLI prefix: + +```bash +wp nx migrate --run +``` Available flags: @@ -137,7 +224,10 @@ Available flags: - `--rollback` rolls back the latest migration batch. - `--refresh` rolls back all known migrations and runs them again. - `--drop` drops the migrations and lock tables after confirmation. -- `--create-table` creates the migrations and lock tables without running migrations. +- `--prepare` prepares the migration store without running migrations. +- `--create-table` is an alias for `--prepare`. - `--yes` skips confirmation prompts for destructive actions. -Running the command without a flag prints migration status. +Use only one operation flag at a time. `--yes` is a modifier for confirmation prompts and can be combined with destructive operations. + +Running the command without a flag prints migration status. If the migration store does not exist yet, the command warns first and shows all configured migrations as pending. diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 068e777..f43b005 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -11,8 +11,10 @@ use StellarWP\Foundation\Database\Database; use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; +use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Database\Migration\Runner; +use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\Tables\LockTable; @@ -74,16 +76,18 @@ public function down(SchemaContract $schema): void { $command = new Migrate( $container, 'foundation', - new Runner( - new Repository($database, $migrationTable), - $schema, - new DatabaseLock($database, $lockTable) - ), - new MigrationCollection([$migration]), - new TableCollection($schema, [ - new MigrationTable($database, $migrationTable), - new LockTable($database, $lockTable), - ]) + new Migrator( + new Store(new TableCollection($schema, [ + new MigrationTable($database, $migrationTable), + new LockTable($database, $lockTable), + ])), + new Runner( + new Repository($database, $migrationTable), + $schema, + new DatabaseLock($database, $lockTable) + ), + new MigrationCollection([$migration]) + ) ); $command->register(); diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index 8b704a0..26a72db 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -6,7 +6,9 @@ use PHPUnit\Framework\Attributes\RunInSeparateProcess; use StellarWP\Foundation\Database\Cli\Migrate; use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; +use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Runner; +use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema as DatabaseSchema; use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\Tables\LockTable; @@ -35,12 +37,14 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $command = new Migrate( $this->container, 'foundation', - new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock()), - new MigrationCollection(), - new TableCollection($wpSchema, [ - new MigrationTable($database, 'wp_nexcess_foundation_migrations'), - new LockTable($database, 'wp_nexcess_foundation_locks'), - ]) + new Migrator( + new Store(new TableCollection($wpSchema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ])), + new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock()), + new MigrationCollection() + ) ); $command->register(); @@ -79,10 +83,17 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { 'optional' => true, 'default' => false, ], + [ + 'type' => 'flag', + 'name' => 'prepare', + 'description' => 'Prepare Foundation migration storage without running migrations.', + 'optional' => true, + 'default' => false, + ], [ 'type' => 'flag', 'name' => 'create-table', - 'description' => 'Create Foundation database tables without running migrations.', + 'description' => 'Alias for --prepare.', 'optional' => true, 'default' => false, ], @@ -99,6 +110,18 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { public function test_it_creates_database_tables_without_running_migrations(): void { [$command, $repository, $schema] = $this->newCommand(); + $this->assertSame(0, $command->runCommand([], ['prepare' => true])); + + $this->assertSame([], $repository->all()); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + ], $schema->statements); + } + + public function test_it_supports_create_table_as_an_alias_for_prepare(): void { + [$command, $repository, $schema] = $this->newCommand(); + $this->assertSame(0, $command->runCommand([], ['create-table' => true])); $this->assertSame([], $repository->all()); @@ -108,6 +131,18 @@ public function test_it_creates_database_tables_without_running_migrations(): vo ], $schema->statements); } + public function test_it_rejects_conflicting_migration_operations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(1, $command->runCommand([], [ + 'run' => true, + 'prepare' => true, + ])); + + $this->assertSame([], $repository->all()); + $this->assertSame([], $schema->statements); + } + public function test_it_runs_pending_migrations(): void { [$command, $repository, $schema] = $this->newCommand(); @@ -167,6 +202,8 @@ public function test_it_drops_database_tables(): void { public function test_it_shows_a_warning_when_status_tables_do_not_exist(): void { [$command] = $this->newCommand(); + $this->expectOutputRegex('/2026_06_23_000001_create_example\s+pending/'); + $this->assertSame(0, $command->runCommand()); } @@ -193,14 +230,16 @@ private function newCommand(): array { $command = new Migrate( $this->container, 'foundation', - $runner, - new MigrationCollection([ - new TestMigration('2026_06_23_000001_create_example'), - ]), - new TableCollection($wpSchema, [ - new MigrationTable($database, 'wp_nexcess_foundation_migrations'), - new LockTable($database, 'wp_nexcess_foundation_locks'), - ]) + new Migrator( + new Store(new TableCollection($wpSchema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ])), + $runner, + new MigrationCollection([ + new TestMigration('2026_06_23_000001_create_example'), + ]) + ) ); return [$command, $repository, $wpSchema]; diff --git a/tests/Unit/Database/Migration/CollectionTest.php b/tests/Unit/Database/Migration/CollectionTest.php index fa14be8..d3f8dc3 100644 --- a/tests/Unit/Database/Migration/CollectionTest.php +++ b/tests/Unit/Database/Migration/CollectionTest.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; +use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\TestCase; @@ -18,4 +19,13 @@ public function test_it_collects_migrations_in_order(): void { $this->assertSame([$first, $second], $collection->all()); $this->assertSame([$first, $second], iterator_to_array($collection)); } + + public function test_it_rejects_duplicate_migration_ids(): void { + $this->expectException(DuplicateMigration::class); + + new Collection([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000001_create_users'), + ]); + } } diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php new file mode 100644 index 0000000..a271adf --- /dev/null +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -0,0 +1,123 @@ +newMigrator(); + + $result = $migrator->run(); + + $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + 'up:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_prepares_the_store_before_rolling_back_configured_migrations(): void { + [$migrator, $repository, $schema] = $this->newMigrator(); + + $migrator->run(); + $migrator->drop(); + $schema->statements = []; + + $result = $migrator->rollback(); + + $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); + $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + 'down:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_prepares_the_store_before_refreshing_configured_migrations(): void { + [$migrator, $repository, $schema] = $this->newMigrator(); + + $migrator->run(); + $migrator->drop(); + $schema->statements = []; + + $result = $migrator->refresh(); + + $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); + $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nexcess_foundation_locks', + 'down:2026_06_23_000001_create_example', + 'up:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_exposes_migration_status_for_configured_migrations(): void { + [$migrator, , $schema] = $this->newMigrator(); + + $this->assertFalse($migrator->status()[0]->ran); + $this->assertSame([], $schema->statements); + + $migrator->run(); + + $this->assertTrue($migrator->status()[0]->ran); + } + + public function test_it_prepares_and_drops_the_migration_store(): void { + [$migrator, , $schema] = $this->newMigrator(); + + $this->assertFalse($migrator->exists()); + + $migrator->prepare(); + + $this->assertTrue($migrator->exists()); + + $migrator->drop(); + + $this->assertFalse($migrator->exists()); + $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); + $this->assertContains('drop:wp_nexcess_foundation_locks', $schema->statements); + } + + /** + * @return array{Migrator, InMemoryRepository, RecordingSchema} + */ + private function newMigrator(): array { + $database = new FakeDatabase(); + $schema = new RecordingSchema(); + $repository = new InMemoryRepository(); + + return [ + new Migrator( + new Store(new TableCollection($schema, [ + new MigrationTable($database, 'wp_nexcess_foundation_migrations'), + new LockTable($database, 'wp_nexcess_foundation_locks'), + ])), + new Runner($repository, $schema, new InMemoryLock()), + new Collection([ + new TestMigration('2026_06_23_000001_create_example'), + ]) + ), + $repository, + $schema, + ]; + } +} diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index c77005f..9dda621 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -10,6 +10,7 @@ use StellarWP\Foundation\Database\Cli\Migrate; use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\Database\Migration\Collection; +use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; use StellarWP\Foundation\WPCli\Command; @@ -28,6 +29,8 @@ public function test_it_registers_default_database_configuration(): void { $this->assertSame(300, $this->container->get(DatabaseProvider::LOCK_TTL)); $this->assertContainsOnlyInstancesOf(Command::class, $commands); $this->assertTrue($this->containsMigrateCommand((array) $commands)); + $this->assertInstanceOf(Migrator::class, $this->container->get(Migrator::class)); + $this->assertInstanceOf(Migrate::class, $this->container->get(Migrate::class)); } public function test_it_registers_configured_database_configuration(): void { @@ -65,6 +68,17 @@ public function test_it_preserves_preconfigured_migrations(): void { $this->assertSame([$migration], $container->get(Collection::class)->all()); } + public function test_it_collects_migrations_added_after_provider_registration(): void { + $migration = new TestMigration('2026_06_23_000001_create_example'); + $container = $this->newContainer(); + + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + + $this->assertSame([$migration], $container->get(Collection::class)->all()); + } + /** * @param array $config */ From 9f8fc550766bd61f4a8c69e3683129f24a3f4936 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 26 Jun 2026 11:28:32 -0600 Subject: [PATCH 16/81] Add more column types to TableDefinition.php --- src/Database/README.md | 34 +++++++ src/Database/Table/Column.php | 73 +++++++++++++- src/Database/Table/TableDefinition.php | 97 ++++++++++++++++++- tests/Unit/Database/Table/ColumnTest.php | 26 +++++ .../Database/Table/TableDefinitionTest.php | 83 ++++++++++++++++ .../Database/DatabaseIntegrationTest.php | 48 +++++++++ 6 files changed, 355 insertions(+), 6 deletions(-) diff --git a/src/Database/README.md b/src/Database/README.md index 649e524..0c4b660 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -170,6 +170,40 @@ If migrations are added before registering `DatabaseProvider`, the provider will Application feature tables should usually be represented by migrations. If a table only needs normal create/drop behavior, define it with `StellarWP\Foundation\Database\Contracts\Table`, wrap it in `StellarWP\Foundation\Database\Table\CreateTable`, and add that migration instance to `DatabaseProvider::MIGRATIONS`. +```php +use StellarWP\Foundation\Database\Contracts\Database; +use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\Table\TableDefinition; + +final readonly class ReportsTable implements Table +{ + public const string ID = 'reports_table'; + + public function __construct( + private Database $database + ) { + } + + public function id(): string { + return self::ID; + } + + public function name(): string { + return $this->database->tableName('reports'); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('status', 20)->default('draft') + ->longText('payload') + ->dateTime('published_at')->nullable() + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('status', 'status'); + } +} +``` + ```php use lucatume\DI52\Container as C; use StellarWP\Foundation\Database\DatabaseProvider; diff --git a/src/Database/Table/Column.php b/src/Database/Table/Column.php index 728febb..ce63034 100644 --- a/src/Database/Table/Column.php +++ b/src/Database/Table/Column.php @@ -14,7 +14,8 @@ public function __construct( public bool $unsigned = false, public bool $nullable = false, public mixed $default = null, - public string $extra = '' + public string $extra = '', + public bool $hasDefault = false ) { } @@ -28,7 +29,7 @@ public function sql(): string { $this->nullable ? ' NULL' : ' NOT NULL' ); - if ($this->default !== null) { + if ($this->default !== null || $this->hasDefault) { $sql .= sprintf(' DEFAULT %s', $this->formatDefault($this->default)); } @@ -39,7 +40,75 @@ public function sql(): string { return $sql; } + public function unsigned(bool $unsigned = true): self { + return new self( + $this->name, + $this->type, + $this->length, + $unsigned, + $this->nullable, + $this->default, + $this->extra, + $this->hasDefault + ); + } + + public function nullable(bool $nullable = true): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $nullable, + $this->default, + $this->extra, + $this->hasDefault + ); + } + + public function default(mixed $default): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $default === null ? true : $this->nullable, + $default, + $this->extra, + true + ); + } + + public function extra(string $extra): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $this->nullable, + $this->default, + $extra, + $this->hasDefault + ); + } + + public function autoIncrement(): self { + if (preg_match('/(?:^|\s)AUTO_INCREMENT(?:\s|$)/i', $this->extra) === 1) { + return $this; + } + + return $this->extra(trim($this->extra . ' AUTO_INCREMENT')); + } + private function formatDefault(mixed $default): string { + if ($default === null) { + return 'NULL'; + } + + if (is_bool($default)) { + return $default ? '1' : '0'; + } + if (is_int($default) || is_float($default)) { return (string) $default; } diff --git a/src/Database/Table/TableDefinition.php b/src/Database/Table/TableDefinition.php index d0a5e10..69c083f 100644 --- a/src/Database/Table/TableDefinition.php +++ b/src/Database/Table/TableDefinition.php @@ -20,6 +20,8 @@ final class TableDefinition */ private array $indexes = []; + private ?string $currentColumn = null; + private function __construct( private readonly Table $table ) { @@ -31,7 +33,9 @@ public static function for(Table $table): self { public function bigIncrements(string $name): self { return $this - ->column(new Column($name, 'bigint', 20, unsigned: true, extra: 'AUTO_INCREMENT')) + ->column(new Column($name, 'bigint', 20)) + ->unsigned() + ->autoIncrement() ->primary($name); } @@ -43,6 +47,18 @@ public function unsignedInteger(string $name, int $length = 10, ?int $default = return $this->column(new Column($name, 'int', $length, unsigned: true, default: $default)); } + public function integer(string $name, int $length = 10): self { + return $this->column(new Column($name, 'int', $length)); + } + + public function tinyInteger(string $name, int $length = 3): self { + return $this->column(new Column($name, 'tinyint', $length)); + } + + public function bigInteger(string $name, int $length = 20): self { + return $this->column(new Column($name, 'bigint', $length)); + } + public function dateTime(string $name): self { return $this->column(new Column($name, 'datetime')); } @@ -51,26 +67,64 @@ public function text(string $name): self { return $this->column(new Column($name, 'text')); } + public function longText(string $name): self { + return $this->column(new Column($name, 'longtext')); + } + public function column(Column $column): self { $this->columns[$column->name] = $column; + $this->currentColumn = $column->name; + + return $this; + } + + public function unsigned(bool $unsigned = true): self { + return $this->replaceCurrentColumn($this->currentColumn()->unsigned($unsigned)); + } + + public function nullable(bool $nullable = true): self { + return $this->replaceCurrentColumn($this->currentColumn()->nullable($nullable)); + } + + public function notNull(): self { + return $this->nullable(false); + } + + public function default(mixed $default): self { + return $this->replaceCurrentColumn($this->currentColumn()->default($default)); + } + + public function autoIncrement(): self { + return $this->replaceCurrentColumn($this->currentColumn()->autoIncrement()); + } + + public function extra(string $extra): self { + return $this->replaceCurrentColumn($this->currentColumn()->extra($extra)); + } + + private function replaceCurrentColumn(Column $column): self { + $this->columns[$column->name] = $column; return $this; } public function primary(string ...$columns): self { - $this->indexes[] = new Index('primary', $this->nonEmptyColumns(array_values($columns)), IndexType::PRIMARY); + $this->indexes[] = new Index('primary', $this->nonEmptyColumns(array_values($columns)), IndexType::PRIMARY); + $this->currentColumn = null; return $this; } public function unique(string $name, string ...$columns): self { - $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::UNIQUE); + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::UNIQUE); + $this->currentColumn = null; return $this; } public function index(string $name, string ...$columns): self { - $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::KEY); + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::KEY); + $this->currentColumn = null; return $this; } @@ -99,6 +153,23 @@ public function validationErrors(): array { $errors[] = sprintf('Table %s does not define any columns.', $this->table->id()); } + foreach ($this->indexes as $index) { + if ($index->type === IndexType::PRIMARY) { + continue; + } + + foreach ($this->indexesByName($index->name) as $duplicate) { + if ($duplicate !== $index && $duplicate->type !== IndexType::PRIMARY) { + $errors[] = sprintf('Index %s is defined more than once.', $index->name); + break 2; + } + } + } + + if (count(array_filter($this->indexes, static fn (Index $index): bool => $index->type === IndexType::PRIMARY)) > 1) { + $errors[] = 'A table can define only one primary key.'; + } + foreach ($this->indexes as $index) { foreach ($index->columns as $column) { if (! isset($this->columns[$column])) { @@ -130,4 +201,22 @@ private function nonEmptyColumns(array $columns): array { return $columns; } + + private function currentColumn(): Column { + if ($this->currentColumn === null || ! isset($this->columns[$this->currentColumn])) { + throw new InvalidArgumentException('A column modifier must follow a column definition.'); + } + + return $this->columns[$this->currentColumn]; + } + + /** + * @return list + */ + private function indexesByName(string $name): array { + return array_values(array_filter( + $this->indexes, + static fn (Index $index): bool => $index->name === $name + )); + } } diff --git a/tests/Unit/Database/Table/ColumnTest.php b/tests/Unit/Database/Table/ColumnTest.php index 4b63c74..051947c 100644 --- a/tests/Unit/Database/Table/ColumnTest.php +++ b/tests/Unit/Database/Table/ColumnTest.php @@ -30,4 +30,30 @@ public function test_it_renders_nullable_and_default_values(): void { (new Column('attempts', 'int', 10, unsigned: true, default: 0))->sql() ); } + + public function test_it_renders_explicit_null_and_boolean_defaults(): void { + $this->assertSame( + '`completed_at` datetime NULL DEFAULT NULL', + (new Column('completed_at', 'datetime'))->nullable()->default(null)->sql() + ); + + $this->assertSame( + '`enabled` tinyint(1) unsigned NOT NULL DEFAULT 1', + (new Column('enabled', 'tinyint', 1))->unsigned()->default(true)->sql() + ); + } + + public function test_it_returns_modified_column_copies(): void { + $column = new Column('id', 'bigint', 20); + + $this->assertSame('`id` bigint(20) NOT NULL', $column->sql()); + $this->assertSame('`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $column->unsigned()->autoIncrement()->sql()); + } + + public function test_auto_increment_is_idempotent(): void { + $this->assertSame( + '`id` bigint(20) NOT NULL AUTO_INCREMENT', + (new Column('id', 'bigint', 20))->autoIncrement()->autoIncrement()->sql() + ); + } } diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php index 8f8e38c..acc5c90 100644 --- a/tests/Unit/Database/Table/TableDefinitionTest.php +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -22,6 +22,52 @@ public function test_it_collects_columns_and_indexes(): void { $this->assertSame([], $definition->validationErrors()); } + public function test_it_defines_queue_style_columns_with_modifiers(): void { + $definition = TableDefinition::for(new TestTable('queue_table', 'wp_queue')) + ->bigIncrements('id') + ->string('queue', 255) + ->string('task_handler', 255) + ->longText('args') + ->integer('priority', 3)->nullable() + ->dateTime('run_after')->default('0000-00-00 00:00:00') + ->integer('taken')->default(0) + ->integer('done')->nullable()->default(0) + ->tinyInteger('tries')->unsigned()->default(0) + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('done', 'done') + ->index('taken_failed', 'taken', 'failed') + ->index('taken_failed_done', 'taken', 'failed', 'done'); + + $this->assertSame([ + '`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', + '`queue` varchar(255) NOT NULL', + '`task_handler` varchar(255) NOT NULL', + '`args` longtext NOT NULL', + '`priority` int(3) NULL', + "`run_after` datetime NOT NULL DEFAULT '0000-00-00 00:00:00'", + '`taken` int(10) NOT NULL DEFAULT 0', + '`done` int(10) NULL DEFAULT 0', + '`tries` tinyint(3) unsigned NOT NULL DEFAULT 0', + '`failed` tinyint(1) unsigned NOT NULL DEFAULT 0', + ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); + + $this->assertCount(4, $definition->indexes()); + $this->assertSame([], $definition->validationErrors()); + } + + public function test_it_defines_less_common_column_helpers(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigInteger('remote_id')->unsigned() + ->string('status')->nullable()->notNull()->default('draft') + ->text('payload')->extra('COMMENT \'json payload\''); + + $this->assertSame([ + '`remote_id` bigint(20) unsigned NOT NULL', + "`status` varchar(191) NOT NULL DEFAULT 'draft'", + "`payload` text NOT NULL COMMENT 'json payload'", + ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); + } + public function test_it_rejects_indexes_that_reference_missing_columns(): void { $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) ->string('status', 20) @@ -46,4 +92,41 @@ public function test_it_rejects_indexes_without_columns(): void { TableDefinition::for(new TestTable('reports_table', 'wp_reports'))->index('empty_index'); } + + public function test_it_rejects_column_modifiers_without_a_column(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A column modifier must follow a column definition.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports'))->nullable(); + } + + public function test_it_rejects_column_modifiers_after_index_definitions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A column modifier must follow a column definition.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('status') + ->index('status', 'status') + ->default('draft'); + } + + public function test_it_reports_duplicate_primary_keys(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigIncrements('id') + ->string('status') + ->primary('status'); + + $this->assertContains('A table can define only one primary key.', $definition->validationErrors()); + } + + public function test_it_reports_duplicate_index_names(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigIncrements('id') + ->string('status') + ->string('type') + ->index('status_lookup', 'status') + ->index('status_lookup', 'type'); + + $this->assertContains('Index status_lookup is defined more than once.', $definition->validationErrors()); + } } diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 288fae8..54a2654 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -9,6 +9,7 @@ use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Database\Contracts\Database as DatabaseContract; use StellarWP\Foundation\Database\Contracts\Repository as MigrationRecordRepositoryContract; +use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Database; use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\Database\Exceptions\QueryException; @@ -17,6 +18,7 @@ use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Database\Table\Collection as TableCollection; +use StellarWP\Foundation\Database\Table\TableDefinition; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; @@ -180,6 +182,52 @@ public function test_schema_creates_inspects_and_changes_tables_through_wordpres $this->assertFalse($schema->hasTable($table)); } + public function test_schema_creates_queue_style_table_definitions_through_wordpress(): void { + $table = $this->table('queue_schema'); + $schema = new Schema($this->database); + $queue = new class($this->database, $table) implements Table { + public function __construct( + private DatabaseContract $database, + private string $table + ) { + } + + public function id(): string { + return 'queue_schema_table'; + } + + public function name(): string { + return $this->database->tableName($this->table); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('queue', 255) + ->string('task_handler', 255) + ->longText('args') + ->integer('priority', 3)->nullable() + ->dateTime('run_after')->default('0000-00-00 00:00:00') + ->integer('taken')->default(0) + ->integer('done')->nullable()->default(0) + ->tinyInteger('tries')->unsigned()->default(0) + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('done', 'done') + ->index('taken_failed', 'taken', 'failed') + ->index('taken_failed_done', 'taken', 'failed', 'done'); + } + }; + + $schema->createOrUpdate($queue); + + $this->assertTrue($schema->hasTable($queue)); + $this->assertTrue($this->database->columnExists($queue, 'args')); + $this->assertTrue($this->database->columnExists($queue, 'priority')); + $this->assertTrue($this->database->columnExists($queue, 'failed')); + $this->assertTrue($schema->hasIndex($queue, 'taken_failed')); + $this->assertTrue($schema->hasIndex($queue, 'taken_failed_done')); + } + public function test_migration_repository_persists_records_in_wordpress(): void { $table = $this->table('migrations'); $schema = new Schema($this->database); From 6d5425dfe444659d67bc3a00983fc87a4b1a1be4 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 26 Jun 2026 11:46:59 -0600 Subject: [PATCH 17/81] Add foundation cli `make:database-migration` and `make:database-table` commands --- src/Cli/CliProvider.php | 14 + .../Make/DatabaseMigrationCommand.php | 231 +++++++++++ .../Commands/Make/DatabaseTableCommand.php | 206 ++++++++++ .../Generation/WordPressClassNameResolver.php | 58 +++ src/Cli/README.md | 9 +- src/Cli/composer.json | 1 + src/Database/DatabaseStubPath.php | 17 + src/Database/README.md | 39 ++ src/Database/stubs/migration.stub | 31 ++ src/Database/stubs/table.stub | 37 ++ tests/Unit/Cli/CliProviderTest.php | 6 + .../Cli/Commands/Make/DatabaseCommandTest.php | 372 ++++++++++++++++++ .../WordPressClassNameResolverTest.php | 20 + 13 files changed, 1040 insertions(+), 1 deletion(-) create mode 100644 src/Cli/Commands/Make/DatabaseMigrationCommand.php create mode 100644 src/Cli/Commands/Make/DatabaseTableCommand.php create mode 100644 src/Database/DatabaseStubPath.php create mode 100644 src/Database/stubs/migration.stub create mode 100644 src/Database/stubs/table.stub create mode 100644 tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index f56297d..89419b6 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -3,6 +3,8 @@ namespace StellarWP\Foundation\Cli; use lucatume\DI52\Container; +use StellarWP\Foundation\Cli\Commands\Make\DatabaseMigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\DatabaseTableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -53,10 +55,20 @@ public function register(): void { ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(DatabaseMigrationCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(DatabaseTableCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(Application::class) ->needs('$commands') ->give(static fn (Container $c): array => [ $c->get(CreateCommand::class), + $c->get(DatabaseMigrationCommand::class), + $c->get(DatabaseTableCommand::class), $c->get(WPCliCommand::class), ]); @@ -73,6 +85,8 @@ public function register(): void { $this->container->singleton(GeneratedFileWriter::class); $this->container->singleton(StubRenderer::class); $this->container->singleton(StubResolver::class); + $this->container->singleton(DatabaseMigrationCommand::class); + $this->container->singleton(DatabaseTableCommand::class); $this->container->singleton(WPCliCommand::class); $this->container->singleton(Application::class); } diff --git a/src/Cli/Commands/Make/DatabaseMigrationCommand.php b/src/Cli/Commands/Make/DatabaseMigrationCommand.php new file mode 100644 index 0000000..ebac7ad --- /dev/null +++ b/src/Cli/Commands/Make/DatabaseMigrationCommand.php @@ -0,0 +1,231 @@ +setDescription('Generate a Foundation database migration class.') + ->addArgument('name', InputArgument::REQUIRED, 'Migration class name, e.g. Create_Reports_Table, Bump_Version, or create-reports-table.') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated migration class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the migration class should be written.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier.') + ->addOption('table-class', null, InputOption::VALUE_REQUIRED, 'Table class used by the migration.') + ->addOption('table-namespace', null, InputOption::VALUE_REQUIRED, 'Namespace containing the table class.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Register this migration with DatabaseProvider::MIGRATIONS using mergeArrayVar().'); + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $tableNamespace = $this->tableNamespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $stub = $this->stubResolver->resolve('database', 'migration', DatabaseStubPath::migration()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $id = $this->optionOrDefault($input, 'id', $this->classNameResolver->migrationId($className)); + $tableClass = $this->tableClass($input, $className); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_class' => $tableClass, + 'table_namespace' => $tableNamespace, + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + 'foundation_database_create_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\CreateTable'), + ]) + ); + } + + private function tableClass(InputInterface $input, string $migrationClass): string { + $tableClass = $input->getOption('table-class'); + + if (is_string($tableClass) && trim($tableClass) !== '') { + return $this->classNameResolver->tableClass($tableClass); + } + + $name = (string) preg_replace('/^Create_?/', '', $migrationClass); + + return $this->classNameResolver->tableClass($name); + } + + private function optionOrDefault(InputInterface $input, string $option, string $default): string { + $value = $input->getOption($option); + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + return $default; + } + + private function phpString(string $value): string { + return var_export($value, true); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Migrations'; + } + + private function tableNamespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('table-namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + if ($this->hasFoundationRuntimeDependency($requireDev)) { + return 'this migration uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this migration.'; + } + + return 'this migration uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this migration.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation', $dependencies); + } +} diff --git a/src/Cli/Commands/Make/DatabaseTableCommand.php b/src/Cli/Commands/Make/DatabaseTableCommand.php new file mode 100644 index 0000000..0fc328a --- /dev/null +++ b/src/Cli/Commands/Make/DatabaseTableCommand.php @@ -0,0 +1,206 @@ +setDescription('Generate a Foundation database table class.') + ->addArgument('name', InputArgument::REQUIRED, 'Table class name, e.g. Reports_Table, Reports, or reports.') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated table class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the table class should be written.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable table identifier used by migrations.') + ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Add this table to a migration, usually with StellarWP\Foundation\Database\Table\CreateTable.'); + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $stub = $this->stubResolver->resolve('database', 'table', DatabaseStubPath::table()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $table = $this->optionOrDefault($input, 'table', $this->classNameResolver->tableName($className)); + $id = $this->optionOrDefault($input, 'id', $table . '_table'); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_php' => $this->phpString($table), + 'foundation_database_contract' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Database'), + 'foundation_database_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Table'), + 'foundation_database_table_definition' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\TableDefinition'), + ]) + ); + } + + private function optionOrDefault(InputInterface $input, string $option, string $default): string { + $value = $input->getOption($option); + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + return $default; + } + + private function phpString(string $value): string { + return var_export($value, true); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + if ($this->hasFoundationRuntimeDependency($requireDev)) { + return 'this table uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this table.'; + } + + return 'this table uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this table.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation', $dependencies); + } +} diff --git a/src/Cli/Generation/WordPressClassNameResolver.php b/src/Cli/Generation/WordPressClassNameResolver.php index 343b0c8..8b30b1c 100644 --- a/src/Cli/Generation/WordPressClassNameResolver.php +++ b/src/Cli/Generation/WordPressClassNameResolver.php @@ -9,6 +9,10 @@ */ final class WordPressClassNameResolver { + public function className(string $input): string { + return $this->classNameFromWords($input); + } + public function commandClass(string $input): string { $words = $this->words($input); @@ -33,6 +37,42 @@ public function commandClass(string $input): string { return $className; } + public function tableClass(string $input): string { + $words = $this->words($input); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a table class name from "%s".', $input)); + } + + if (strtolower((string) end($words)) !== 'table') { + $words[] = 'table'; + } + + return $this->validClassName(implode('_', array_map($this->pascalWord(...), $words)), $input); + } + + public function tableName(string $className): string { + $words = $this->words((string) preg_replace('/_?Table$/', '', $className)); + + if ($words === []) { + return strtolower($className); + } + + return implode('_', array_map(strtolower(...), $words)); + } + + public function migrationId(string $className, ?\DateTimeImmutable $now = null): string { + $words = $this->words($className); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a migration id from "%s".', $className)); + } + + $now ??= new \DateTimeImmutable(); + + return $now->format('Y_m_d_His') . '_' . implode('_', array_map(strtolower(...), $words)); + } + public function subcommand(string $className): string { $words = $this->words((string) preg_replace('/_?Command$/', '', $className)); @@ -49,6 +89,16 @@ public function description(string $className): string { return ucfirst(implode(' ', array_map(strtolower(...), $words))) . '.'; } + private function classNameFromWords(string $input): string { + $words = $this->words($input); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a class name from "%s".', $input)); + } + + return $this->validClassName(implode('_', array_map($this->pascalWord(...), $words)), $input); + } + /** * @return list */ @@ -69,4 +119,12 @@ private function pascalWord(string $word): string { return ucfirst($word); } + + private function validClassName(string $className, string $input): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $className)) { + throw new RuntimeException(sprintf('Could not create a valid PHP class name from "%s".', $input)); + } + + return $className; + } } diff --git a/src/Cli/README.md b/src/Cli/README.md index 6f331c9..dac4486 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -35,9 +35,16 @@ Foundation CLI includes generators for packages that own generated class shapes. vendor/bin/foundation make:wpcli-command Sync_Products_Command ``` +The Database package provides generators for table definitions and migrations: + +```bash +vendor/bin/foundation make:database-table Reports_Table +vendor/bin/foundation make:database-migration Create_Reports_Table +``` + Do not add `StellarWP\Foundation\Cli\CliProvider` to the consuming WordPress plugin's provider list. That provider only boots the Foundation Symfony Console application for the `foundation` binary. Register generated WP-CLI commands from the plugin's own WP-CLI provider using `stellarwp/foundation-wpcli`. -See the WPCli package README for WP-CLI generator behavior, options, and stub overrides. +See the WPCli and Database package READMEs for generator behavior, options, and stub overrides. ## Foundation Monorepo Maintenance diff --git a/src/Cli/composer.json b/src/Cli/composer.json index 4959c43..f2e878a 100644 --- a/src/Cli/composer.json +++ b/src/Cli/composer.json @@ -10,6 +10,7 @@ "require": { "php": ">=8.3", "stellarwp/foundation-container": "^1.2", + "stellarwp/foundation-database": "^1.2", "stellarwp/foundation-wpcli": "^1.2", "symfony/console": ">=5.4" }, diff --git a/src/Database/DatabaseStubPath.php b/src/Database/DatabaseStubPath.php new file mode 100644 index 0000000..a4c79e9 --- /dev/null +++ b/src/Database/DatabaseStubPath.php @@ -0,0 +1,17 @@ +table ) )->up( $schema ); + } + + public function down( Schema $schema ): void { + ( new CreateTable( $this->table ) )->down( $schema ); + } + +} diff --git a/src/Database/stubs/table.stub b/src/Database/stubs/table.stub new file mode 100644 index 0000000..c90ebb7 --- /dev/null +++ b/src/Database/stubs/table.stub @@ -0,0 +1,37 @@ +database->tableName( self::TABLE ); + } + + public function definition(): TableDefinition { + return TableDefinition::for( $this ) + ->bigIncrements( 'id' ) + ->string( 'status', 20 )->default( 'draft' ) + ->longText( 'payload' ) + ->dateTime( 'created_at' ) + ->dateTime( 'updated_at' )->nullable() + ->index( 'status', 'status' ); + } + +} diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index a802d90..5393f88 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -7,6 +7,8 @@ use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Cli\Application; use StellarWP\Foundation\Cli\CliProvider; +use StellarWP\Foundation\Cli\Commands\Make\DatabaseMigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\DatabaseTableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -33,6 +35,8 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(Application::class, $container->get(Application::class)); $this->assertInstanceOf(CreateCommand::class, $container->get(CreateCommand::class)); + $this->assertInstanceOf(DatabaseMigrationCommand::class, $container->get(DatabaseMigrationCommand::class)); + $this->assertInstanceOf(DatabaseTableCommand::class, $container->get(DatabaseTableCommand::class)); $this->assertInstanceOf(WPCliCommand::class, $container->get(WPCliCommand::class)); $this->assertInstanceOf(PackageResolver::class, $container->get(PackageResolver::class)); $this->assertInstanceOf(PackageScaffolder::class, $container->get(PackageScaffolder::class)); @@ -43,6 +47,8 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(StubResolver::class, $container->get(StubResolver::class)); $this->assertInstanceOf(GitHubPackageRepositoryCreator::class, $container->get(PackageRepositoryCreator::class)); $this->assertTrue($container->get(Application::class)->has('package:create')); + $this->assertTrue($container->get(Application::class)->has('make:database-migration')); + $this->assertTrue($container->get(Application::class)->has('make:database-table')); $this->assertTrue($container->get(Application::class)->has('make:wpcli-command')); } } diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php new file mode 100644 index 0000000..87870bc --- /dev/null +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -0,0 +1,372 @@ + + */ + private array $temporaryRoots = []; + + private string $tempDir; + + protected function setUp(): void { + parent::setUp(); + + $this->tempDir = $this->prepare_temp_dir('make-database-command'); + } + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + + public function test_it_generates_a_database_table_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $path = $root . '/src/Database/Tables/Reports_Table.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Tables/Reports_Table.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Tables;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Table;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Table\\TableDefinition;', $contents); + $this->assertStringContainsString('final readonly class Reports_Table implements Table {', $contents); + $this->assertStringContainsString("public const string ID = 'reports_table';", $contents); + $this->assertStringContainsString("public const string TABLE = 'reports';", $contents); + $this->assertStringContainsString('return $this->database->tableName( self::TABLE );', $contents); + $this->assertStringContainsString("->longText( 'payload' )", $contents); + } + + public function test_it_generates_a_database_migration_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $path = $root . '/src/Database/Migrations/Create_Reports_Table.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Migrations/Create_Reports_Table.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Migrations;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Schema;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Table\\CreateTable;', $contents); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); + $this->assertStringContainsString('final readonly class Create_Reports_Table implements Migration {', $contents); + $this->assertStringContainsString("public const string ID = '2026_06_26_000001_create_reports_table';", $contents); + $this->assertStringContainsString('private Reports_Table $table', $contents); + $this->assertStringContainsString('( new CreateTable( $this->table ) )->up( $schema );', $contents); + } + + public function test_database_migrations_default_to_timestamped_ids(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertMatchesRegularExpression( + "/public const string ID = '\\d{4}_\\d{2}_\\d{2}_\\d{6}_create_reports_table';/", + $contents + ); + } + + public function test_database_generators_accept_generation_options(): void { + $root = $this->temporaryProject(); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'Audit_Log', + '--namespace' => 'Acme\\Plugin\\Storage', + '--path' => 'custom/tables', + '--id' => 'audit_log_storage', + '--table' => 'custom_audit_log', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'Create_Audit_Log_Table', + '--namespace' => 'Acme\\Plugin\\Storage\\Migrations', + '--path' => 'custom/migrations', + '--id' => '2026_06_26_000002_create_audit_log_table', + '--table-class' => 'Audit_Log', + '--table-namespace' => 'Acme\\Plugin\\Storage', + ]); + + $tableContents = (string) file_get_contents($root . '/custom/tables/Audit_Log_Table.php'); + $migrationContents = (string) file_get_contents($root . '/custom/migrations/Create_Audit_Log_Table.php'); + + $this->assertSame(Command::SUCCESS, $tableStatus); + $this->assertStringContainsString('namespace Acme\\Plugin\\Storage;', $tableContents); + $this->assertStringContainsString("public const string ID = 'audit_log_storage';", $tableContents); + $this->assertStringContainsString("public const string TABLE = 'custom_audit_log';", $tableContents); + $this->assertSame(Command::SUCCESS, $migrationStatus); + $this->assertStringContainsString('namespace Acme\\Plugin\\Storage\\Migrations;', $migrationContents); + $this->assertStringContainsString('use Acme\\Plugin\\Storage\\Audit_Log_Table;', $migrationContents); + $this->assertStringContainsString("public const string ID = '2026_06_26_000002_create_audit_log_table';", $migrationContents); + } + + public function test_database_table_generator_accepts_an_absolute_output_path(): void { + $root = $this->temporaryProject(); + $outputRoot = $this->temporaryRoot('foundation-make-database-output-'); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--path' => $outputRoot, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($outputRoot . '/Reports_Table.php'); + $this->assertStringContainsString('Created: ' . $outputRoot . '/Reports_Table.php', $tester->getDisplay()); + } + + public function test_database_generators_use_strauss_namespace_prefix_for_foundation_imports(): void { + $root = $this->temporaryProject([ + 'extra' => [ + 'strauss' => [ + 'namespace_prefix' => 'Acme\\Product\\', + ], + ], + ]); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableTester->execute([ + 'name' => 'reports', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $tableContents = (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php'); + $migrationContents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); + + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Database;', $tableContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $tableContents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); + } + + public function test_database_generators_use_project_stub_overrides(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/foundation/stubs/database', 0777, true); + file_put_contents($root . '/foundation/stubs/database/table.stub', 'Generated table {{ class }} in {{ namespace }}'); + file_put_contents($root . '/foundation/stubs/database/migration.stub', 'Generated migration {{ class }} with {{ table_class }}'); + + (new CommandTester($this->tableCommand($root)))->execute([ + 'name' => 'reports', + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $this->assertSame( + 'Generated table Reports_Table in Acme\\Plugin\\Database\\Tables', + (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php') + ); + $this->assertSame( + 'Generated migration Create_Reports_Table with Reports_Table', + (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php') + ); + } + + public function test_database_generators_warn_when_the_runtime_dependency_is_missing_from_production_requirements(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('composer require stellarwp/foundation-database', $tester->getDisplay()); + } + + public function test_database_generators_warn_when_the_runtime_dependency_is_only_a_development_dependency(): void { + $root = $this->temporaryProject([ + 'require-dev' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('only in require-dev', $tester->getDisplay()); + } + + public function test_database_generators_do_not_warn_when_the_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); + } + + public function test_database_generators_reject_invalid_namespaces_before_writing_files(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--namespace' => 'Acme Plugin\\Database\\Tables', + '--path' => 'custom/tables', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme Plugin\\Database\\Tables" is not a valid PHP namespace.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/custom/tables/Reports_Table.php'); + } + + public function test_database_generators_reject_namespaces_outside_the_autoload_root(): void { + $root = $this->temporaryProject(); + $tableTester = new CommandTester($this->tableCommand($root)); + + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + '--namespace' => 'Acme\\PluginTools\\Database\\Tables', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--namespace' => 'Acme\\PluginTools\\Database\\Migrations', + ]); + + $this->assertSame(Command::FAILURE, $tableStatus); + $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database\\Tables" is outside the Composer PSR-4 namespaces in composer.json.', $tableTester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Tools/Database/Tables/Reports_Table.php'); + $this->assertSame(Command::FAILURE, $migrationStatus); + $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database\\Migrations" is outside the Composer PSR-4 namespaces in composer.json.', $migrationTester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Tools/Database/Migrations/Create_Reports_Table.php'); + } + + private function tableCommand(string $root): DatabaseTableCommand { + return new DatabaseTableCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: new GeneratedFileWriter() + ); + } + + private function migrationCommand(string $root): DatabaseMigrationCommand { + return new DatabaseMigrationCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: new GeneratedFileWriter() + ); + } + + /** + * @param array $composer + */ + private function temporaryProject(array $composer = []): string { + $root = $this->temporaryRoot('foundation-make-database-test-'); + + file_put_contents($root . '/composer.json', json_encode(array_replace_recursive([ + 'autoload' => [ + 'psr-4' => [ + 'Acme\\Plugin\\' => 'src', + ], + ], + ], $composer), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $root; + } + + private function temporaryRoot(string $prefix): string { + $root = $this->tempDir . '/' . $prefix . bin2hex(random_bytes(8)); + + if (! mkdir($root, 0777, true) && ! is_dir($root)) { + $this->fail(sprintf('Could not create temporary root "%s".', $root)); + } + + $this->temporaryRoots[] = $root; + + return $root; + } + + private function removeDirectory(string $directory): void { + if (! is_dir($directory)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php index 0270214..54920ec 100644 --- a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php +++ b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php @@ -53,6 +53,26 @@ public function test_it_creates_a_description_from_a_command_class(): void { $this->assertSame('Sync products.', (new WordPressClassNameResolver())->description('Sync_Products_Command')); } + public function test_it_normalizes_generic_wordpress_class_names(): void { + $this->assertSame('Bump_Version', (new WordPressClassNameResolver())->className('bump-version')); + } + + public function test_it_normalizes_table_class_names(): void { + $this->assertSame('Reports_Table', (new WordPressClassNameResolver())->tableClass('reports')); + $this->assertSame('Reports_Table', (new WordPressClassNameResolver())->tableClass('Reports_Table')); + } + + public function test_it_creates_table_names_from_table_classes(): void { + $this->assertSame('reports', (new WordPressClassNameResolver())->tableName('Reports_Table')); + } + + public function test_it_creates_timestamped_migration_ids_from_migration_classes(): void { + $this->assertSame( + '2026_06_26_120000_create_reports_table', + (new WordPressClassNameResolver())->migrationId('Create_Reports_Table', new \DateTimeImmutable('2026-06-26 12:00:00')) + ); + } + public function test_it_fails_when_input_cannot_be_normalized_to_a_class_name(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a class name from "@@@".'); From 0fb1370e42af8b20297111ec7c49f85bffc747ae Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 26 Jun 2026 15:28:18 -0600 Subject: [PATCH 18/81] Add foundation cli `make:database-provider`, refactor namespaces, add ability to update existing providers when generating stubs --- AGENTS.md | 4 +- composer.json | 1 + src/Cli/CliProvider.php | 31 +- .../MigrationCommand.php} | 177 +++- .../ProviderCommand.php} | 61 +- .../Database/ProviderRegistrationEditor.php | 187 ++++ .../Commands/Make/Database/TableCommand.php | 313 ++++++ src/Cli/Generation/Php/PhpSourceEditor.php | 551 ++++++++++ .../Php/ValueObjects/LineComment.php | 16 + .../Php/ValueObjects/LineInsertion.php | 15 + .../Php/ValueObjects/MergeArrayVarTarget.php | 17 + src/Cli/README.md | 9 +- src/Cli/composer.json | 1 + src/Database/DatabaseStubPath.php | 8 + src/Database/README.md | 29 +- src/Database/stubs/migration.stub | 12 +- src/Database/stubs/provider.stub | 25 + src/Database/stubs/table-migration.stub | 31 + tests/Unit/Cli/CliProviderTest.php | 11 +- .../Cli/Commands/Make/DatabaseCommandTest.php | 942 +++++++++++++++++- .../Generation/GeneratedFileWriterTest.php | 103 ++ .../Cli/Generation/PhpSourceEditorTest.php | 137 +++ .../WordPressClassNameResolverTest.php | 39 + ...ow-callback-without-registration-list.stub | 13 + .../closure-without-array-return.stub | 15 + .../closure-without-container-parameter.stub | 15 + .../php-source-editor/existing-import.stub | 7 + .../php-source-editor/function-import.stub | 7 + .../not-container-merge-array-var.stub | 12 + .../space-indented-registration-list.stub | 14 + .../strauss-prefixed-database-provider.stub | 11 + .../wrong-constant-merge-array-var.stub | 12 + .../wrong-first-argument-merge-array-var.stub | 10 + 33 files changed, 2741 insertions(+), 95 deletions(-) rename src/Cli/Commands/Make/{DatabaseMigrationCommand.php => Database/MigrationCommand.php} (51%) rename src/Cli/Commands/Make/{DatabaseTableCommand.php => Database/ProviderCommand.php} (65%) create mode 100644 src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php create mode 100644 src/Cli/Commands/Make/Database/TableCommand.php create mode 100644 src/Cli/Generation/Php/PhpSourceEditor.php create mode 100644 src/Cli/Generation/Php/ValueObjects/LineComment.php create mode 100644 src/Cli/Generation/Php/ValueObjects/LineInsertion.php create mode 100644 src/Cli/Generation/Php/ValueObjects/MergeArrayVarTarget.php create mode 100644 src/Database/stubs/provider.stub create mode 100644 src/Database/stubs/table-migration.stub create mode 100644 tests/Unit/Cli/Generation/GeneratedFileWriterTest.php create mode 100644 tests/Unit/Cli/Generation/PhpSourceEditorTest.php create mode 100644 tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub create mode 100644 tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub create mode 100644 tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub create mode 100644 tests/_data/cli/generation/php-source-editor/existing-import.stub create mode 100644 tests/_data/cli/generation/php-source-editor/function-import.stub create mode 100644 tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub create mode 100644 tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub create mode 100644 tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub create mode 100644 tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub create mode 100644 tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub diff --git a/AGENTS.md b/AGENTS.md index 06d11a2..08f76d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,9 @@ Avoid `use ... as ...` import aliases unless they resolve a real class-name coll Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. -Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. Shared generation infrastructure that is not itself a console command should live under `src/Cli/Generation/`. +Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. When a make feature grows beyond a single command class or needs private collaborators, group that feature under its own namespace such as `src/Cli/Commands/Make/Database/`. Command-specific collaborators should live inside that feature namespace, not beside unrelated command classes in `Commands/Make/`. + +Shared generation infrastructure that is not itself a console command and is reused across command features should live under `src/Cli/Generation/`. Default stubs should live with the package that owns the generated class shape. For example, WP-CLI command stubs live in `src/WPCli/stubs/` because the WPCli package owns the base `Command` API. The CLI package owns resolving, rendering, and writing generated files. diff --git a/composer.json b/composer.json index 1673ed2..8d4c5c0 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "adbario/php-dot-notation": ">=2.5", "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", + "nikic/php-parser": ">=5.0 <6.0", "psr/log": ">=1.0", "stellarwp/container-contract": "^1.1", "symfony/console": ">=5.4", diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index 89419b6..0c25edf 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -3,8 +3,12 @@ namespace StellarWP\Foundation\Cli; use lucatume\DI52\Container; -use StellarWP\Foundation\Cli\Commands\Make\DatabaseMigrationCommand; -use StellarWP\Foundation\Cli\Commands\Make\DatabaseTableCommand; +use PhpParser\Lexer; +use PhpParser\ParserFactory; +use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderRegistrationEditor; +use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -15,6 +19,7 @@ use StellarWP\Foundation\Cli\Commands\Package\PackageScaffolder; use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; @@ -55,11 +60,15 @@ public function register(): void { ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); - $this->container->when(DatabaseMigrationCommand::class) + $this->container->when(MigrationCommand::class) ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); - $this->container->when(DatabaseTableCommand::class) + $this->container->when(ProviderCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(TableCommand::class) ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); @@ -67,8 +76,9 @@ public function register(): void { ->needs('$commands') ->give(static fn (Container $c): array => [ $c->get(CreateCommand::class), - $c->get(DatabaseMigrationCommand::class), - $c->get(DatabaseTableCommand::class), + $c->get(MigrationCommand::class), + $c->get(ProviderCommand::class), + $c->get(TableCommand::class), $c->get(WPCliCommand::class), ]); @@ -83,10 +93,15 @@ public function register(): void { $this->container->singleton(WordPressClassNameResolver::class); $this->container->singleton(ComposerAutoloadResolver::class); $this->container->singleton(GeneratedFileWriter::class); + $this->container->singleton(Lexer::class); + $this->container->singleton(ParserFactory::class); + $this->container->singleton(PhpSourceEditor::class); $this->container->singleton(StubRenderer::class); $this->container->singleton(StubResolver::class); - $this->container->singleton(DatabaseMigrationCommand::class); - $this->container->singleton(DatabaseTableCommand::class); + $this->container->singleton(MigrationCommand::class); + $this->container->singleton(ProviderCommand::class); + $this->container->singleton(ProviderRegistrationEditor::class); + $this->container->singleton(TableCommand::class); $this->container->singleton(WPCliCommand::class); $this->container->singleton(Application::class); } diff --git a/src/Cli/Commands/Make/DatabaseMigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php similarity index 51% rename from src/Cli/Commands/Make/DatabaseMigrationCommand.php rename to src/Cli/Commands/Make/Database/MigrationCommand.php index ebac7ad..43ef3d0 100644 --- a/src/Cli/Commands/Make/DatabaseMigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -1,6 +1,6 @@ addArgument('name', InputArgument::REQUIRED, 'Migration class name, e.g. Create_Reports_Table, Bump_Version, or create-reports-table.') ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated migration class.') ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the migration class should be written.') + ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier.') - ->addOption('table-class', null, InputOption::VALUE_REQUIRED, 'Table class used by the migration.') + ->addOption('table-class', null, InputOption::VALUE_REQUIRED, 'Table class or base name used by a table-backed migration.') ->addOption('table-namespace', null, InputOption::VALUE_REQUIRED, 'Namespace containing the table class.') ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); } protected function execute(InputInterface $input, OutputInterface $output): int { try { + $this->validateExplicitProviderUpdate($input); $file = $this->generatedFile($input); $this->fileWriter->write($file, (bool) $input->getOption('force')); + $providerPath = $this->updateProvider($input); } catch (RuntimeException $exception) { $output->writeln('' . $exception->getMessage() . ''); @@ -64,6 +68,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(''); $output->writeln('Register this migration with DatabaseProvider::MIGRATIONS using mergeArrayVar().'); + if ($providerPath !== null) { + $output->writeln(sprintf('Updated: %s', $this->relativePath($providerPath))); + } + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); if ($runtimeDependencyWarning !== null) { @@ -75,32 +83,123 @@ protected function execute(InputInterface $input, OutputInterface $output): int } private function generatedFile(InputInterface $input): GeneratedFile { - $className = $this->classNameResolver->className((string) $input->getArgument('name')); - $project = $this->autoloadResolver->project(); - $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); - $tableNamespace = $this->tableNamespace($input, $project->defaultPsr4Namespace()); - $path = $this->path($input, $namespace, $project); - $stub = $this->stubResolver->resolve('database', 'migration', DatabaseStubPath::migration()); - $relative = $this->relativePath($path . '/' . $className . '.php'); - $id = $this->optionOrDefault($input, 'id', $this->classNameResolver->migrationId($className)); - $tableClass = $this->tableClass($input, $className); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $id = $this->optionOrDefault($input, 'id', $this->classNameResolver->migrationId($className)); + + if ($this->isTableMigration($input, $className)) { + $stub = $this->stubResolver->resolve('database', 'table-migration', DatabaseStubPath::tableMigration()); + $tableNamespace = $this->tableNamespace($input, $project->defaultPsr4Namespace()); + $tableClass = $this->tableClass($input, $className); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_class' => $tableClass, + 'table_namespace' => $tableNamespace, + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + 'foundation_database_create_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\CreateTable'), + ]) + ); + } + + $stub = $this->stubResolver->resolve('database', 'migration', DatabaseStubPath::migration()); return new GeneratedFile( path: $path . '/' . $className . '.php', relativePath: $relative, contents: $this->stubRenderer->render($stub, [ - 'namespace' => $namespace, - 'class' => $className, - 'id_php' => $this->phpString($id), - 'table_class' => $tableClass, - 'table_namespace' => $tableNamespace, - 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), - 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), - 'foundation_database_create_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\CreateTable'), + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + 'foundation_database_irreversible_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Exceptions\\IrreversibleMigration'), ]) ); } + private function validateExplicitProviderUpdate(InputInterface $input): void { + if (! $this->hasExplicitProvider($input)) { + return; + } + + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + + if (! is_file($providerPath)) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + $status = $this->providerUpdater->checkMigration($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED || $status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return; + } + + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + private function updateProvider(InputInterface $input): ?string { + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + $explicit = $this->hasExplicitProvider($input); + + if (! is_file($providerPath)) { + if ($explicit) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + return null; + } + + $status = $this->providerUpdater->addMigration($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED) { + return $providerPath; + } + + if ($status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return null; + } + + if ($explicit) { + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + return null; + } + + private function isTableMigration(InputInterface $input, string $className): bool { + $tableClass = $input->getOption('table-class'); + + if (is_string($tableClass) && trim($tableClass) !== '') { + return true; + } + + return preg_match('/^Create_.*_Table$/', $className) === 1; + } + private function tableClass(InputInterface $input, string $migrationClass): string { $tableClass = $input->getOption('table-class'); @@ -166,6 +265,42 @@ private function path(InputInterface $input, string $namespace, ComposerProject return $this->rootPath . '/' . $autoload->pathFor($namespace); } + private function providerPath(InputInterface $input, ComposerProject $project): string { + $provider = $input->getOption('provider'); + + if (is_string($provider) && trim($provider) !== '') { + return $this->absolutePath($provider); + } + + $namespace = trim($project->defaultPsr4Namespace()->namespace, '\\') . '\\Database'; + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + return $this->rootPath . '/src/Database/Provider.php'; + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace) . '/Provider.php'; + } + + private function hasExplicitProvider(InputInterface $input): bool { + $provider = $input->getOption('provider'); + + return is_string($provider) && trim($provider) !== ''; + } + + private function providerUpdateFailure(string $status): string { + return match ($status) { + ProviderRegistrationEditor::NOT_FOUND => 'file does not exist or is not readable', + ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', + ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', + ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', + ProviderRegistrationEditor::IMPORT_COLLISION => 'a different imported class uses the same short class name', + ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', + ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', + default => 'provider could not be updated', + }; + } + private function absolutePath(string $path): string { $path = trim($path); diff --git a/src/Cli/Commands/Make/DatabaseTableCommand.php b/src/Cli/Commands/Make/Database/ProviderCommand.php similarity index 65% rename from src/Cli/Commands/Make/DatabaseTableCommand.php rename to src/Cli/Commands/Make/Database/ProviderCommand.php index 0fc328a..7247449 100644 --- a/src/Cli/Commands/Make/DatabaseTableCommand.php +++ b/src/Cli/Commands/Make/Database/ProviderCommand.php @@ -1,6 +1,6 @@ setDescription('Generate a Foundation database table class.') - ->addArgument('name', InputArgument::REQUIRED, 'Table class name, e.g. Reports_Table, Reports, or reports.') - ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated table class.') - ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the table class should be written.') - ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable table identifier used by migrations.') - ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name.') + $this->setDescription('Generate a Foundation database provider class.') + ->addArgument('name', InputArgument::OPTIONAL, 'Provider class name, e.g. Provider or Database_Provider.', 'Provider') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated provider class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the provider class should be written.') ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); } @@ -61,7 +59,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Created: %s', $file->relativePath)); $output->writeln(''); - $output->writeln('Add this table to a migration, usually with StellarWP\Foundation\Database\Table\CreateTable.'); + $output->writeln('Register this provider in your application provider list before adding generated tables and migrations.'); $runtimeDependencyWarning = $this->runtimeDependencyWarning(); @@ -74,44 +72,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int } private function generatedFile(InputInterface $input): GeneratedFile { - $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); $project = $this->autoloadResolver->project(); $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); $path = $this->path($input, $namespace, $project); - $stub = $this->stubResolver->resolve('database', 'table', DatabaseStubPath::table()); + $stub = $this->stubResolver->resolve('database', 'provider', DatabaseStubPath::provider()); $relative = $this->relativePath($path . '/' . $className . '.php'); - $table = $this->optionOrDefault($input, 'table', $this->classNameResolver->tableName($className)); - $id = $this->optionOrDefault($input, 'id', $table . '_table'); return new GeneratedFile( path: $path . '/' . $className . '.php', relativePath: $relative, contents: $this->stubRenderer->render($stub, [ - 'namespace' => $namespace, - 'class' => $className, - 'id_php' => $this->phpString($id), - 'table_php' => $this->phpString($table), - 'foundation_database_contract' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Database'), - 'foundation_database_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Table'), - 'foundation_database_table_definition' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\TableDefinition'), + 'namespace' => $namespace, + 'class' => $className, + 'foundation_database_provider' => $project->foundationClass('StellarWP\\Foundation\\Database\\DatabaseProvider'), + 'foundation_service_provider' => $project->foundationClass('StellarWP\\Foundation\\Container\\Contracts\\Provider'), ]) ); } - private function optionOrDefault(InputInterface $input, string $option, string $default): string { - $value = $input->getOption($option); - - if (is_string($value) && trim($value) !== '') { - return trim($value); - } - - return $default; - } - - private function phpString(string $value): string { - return var_export($value, true); - } - private function namespace(InputInterface $input, Psr4Namespace $autoload): string { $namespace = $input->getOption('namespace'); @@ -119,7 +98,7 @@ private function namespace(InputInterface $input, Psr4Namespace $autoload): stri return $this->validNamespace(trim($namespace, '\\')); } - return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + return trim($autoload->namespace, '\\') . '\\Database'; } private function path(InputInterface $input, string $namespace, ComposerProject $project): string { @@ -190,10 +169,10 @@ private function runtimeDependencyWarning(): ?string { } if ($this->hasFoundationRuntimeDependency($requireDev)) { - return 'this table uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this table.'; + return 'this provider uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this provider.'; } - return 'this table uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this table.'; + return 'this provider uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this provider.'; } /** diff --git a/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php new file mode 100644 index 0000000..e634f11 --- /dev/null +++ b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php @@ -0,0 +1,187 @@ +addRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + marker: self::TABLE_MARKER, + registration: sprintf('$this->container->singleton(%s::class);', $class), + write: true + ); + } + + public function addMigration(string $providerPath, string $class, string $classNamespace): string { + return $this->addMergeArrayVarRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + write: true + ); + } + + public function checkTable(string $providerPath, string $class, string $classNamespace): string { + return $this->addRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + marker: self::TABLE_MARKER, + registration: sprintf('$this->container->singleton(%s::class);', $class), + write: false + ); + } + + public function checkMigration(string $providerPath, string $class, string $classNamespace): string { + return $this->addMergeArrayVarRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + write: false + ); + } + + private function addRegistration(string $providerPath, string $class, string $classNamespace, string $marker, string $registration, bool $write): string { + if (! is_file($providerPath) || ! is_readable($providerPath)) { + return self::NOT_FOUND; + } + + if ($write && ! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + + $contents = (string) file_get_contents($providerPath); + + if (! $this->sourceEditor->canParse($contents)) { + return self::PARSE_FAILED; + } + + if (! $this->sourceEditor->hasLineComment($contents, $marker)) { + return self::MISSING_MARKER; + } + + $fullyQualifiedClass = $classNamespace . '\\' . $class; + + if ($this->sourceEditor->hasImport($contents, $fullyQualifiedClass) && str_contains($contents, $registration)) { + return self::ALREADY_REGISTERED; + } + + if ($this->sourceEditor->hasImportShortNameCollision($contents, $class, $fullyQualifiedClass)) { + return self::IMPORT_COLLISION; + } + + if (! $write) { + return self::UPDATED; + } + + $contents = $this->sourceEditor->addImport($contents, $fullyQualifiedClass); + + if ($contents === null) { + return self::PARSE_FAILED; + } + + $contents = $this->sourceEditor->insertBeforeLineComment($contents, $marker, $registration); + + if ($contents === null) { + return self::MISSING_MARKER; + } + + if (file_put_contents($providerPath, $contents) === false) { + return self::WRITE_FAILED; + } + + return self::UPDATED; + } + + private function addMergeArrayVarRegistration(string $providerPath, string $class, string $classNamespace, bool $write): string { + if (! is_file($providerPath) || ! is_readable($providerPath)) { + return self::NOT_FOUND; + } + + if ($write && ! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + + $contents = (string) file_get_contents($providerPath); + + if (! $this->sourceEditor->canParse($contents)) { + return self::PARSE_FAILED; + } + + $containerExpression = $this->sourceEditor->mergeArrayVarContainerExpression($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST); + + if ($containerExpression === null || ! $this->sourceEditor->canInsertIntoMergeArrayVar($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST, self::MIGRATION_MARKER)) { + return self::MISSING_ANCHOR; + } + + $fullyQualifiedClass = $classNamespace . '\\' . $class; + $registration = sprintf('%s->get(%s::class),', $containerExpression, $class); + + if ($this->sourceEditor->hasImport($contents, $fullyQualifiedClass) && str_contains($contents, $registration)) { + return self::ALREADY_REGISTERED; + } + + if ($this->sourceEditor->hasImportShortNameCollision($contents, $class, $fullyQualifiedClass)) { + return self::IMPORT_COLLISION; + } + + if (! $write) { + return self::UPDATED; + } + + $contents = $this->sourceEditor->addImport($contents, $fullyQualifiedClass); + + if ($contents === null) { + return self::PARSE_FAILED; + } + + $contents = $this->sourceEditor->insertIntoMergeArrayVar( + contents: $contents, + class: self::MIGRATIONS_CLASS, + constant: self::MIGRATIONS_CONST, + statement: $registration, + beforeComment: self::MIGRATION_MARKER + ); + + if ($contents === null) { + return self::MISSING_ANCHOR; + } + + if (file_put_contents($providerPath, $contents) === false) { + return self::WRITE_FAILED; + } + + return self::UPDATED; + } +} diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php new file mode 100644 index 0000000..4120a4d --- /dev/null +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -0,0 +1,313 @@ +setDescription('Generate a Foundation database table class.') + ->addArgument('name', InputArgument::REQUIRED, 'Table class name, e.g. Reports_Table, Reports, or reports.') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated table class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the table class should be written.') + ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable table identifier used by migrations.') + ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->validateExplicitProviderUpdate($input); + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + $providerPath = $this->updateProvider($input); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Add this table to a migration, usually with StellarWP\Foundation\Database\Table\CreateTable.'); + + if ($providerPath !== null) { + $output->writeln(sprintf('Updated: %s', $this->relativePath($providerPath))); + } + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $stub = $this->stubResolver->resolve('database', 'table', DatabaseStubPath::table()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $table = $this->optionOrDefault($input, 'table', $this->classNameResolver->tableName($className)); + $id = $this->optionOrDefault($input, 'id', $table . '_table'); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_php' => $this->phpString($table), + 'foundation_database_contract' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Database'), + 'foundation_database_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Table'), + 'foundation_database_table_definition' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\TableDefinition'), + ]) + ); + } + + private function validateExplicitProviderUpdate(InputInterface $input): void { + if (! $this->hasExplicitProvider($input)) { + return; + } + + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + + if (! is_file($providerPath)) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + $status = $this->providerUpdater->checkTable($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED || $status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return; + } + + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + private function updateProvider(InputInterface $input): ?string { + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + $explicit = $this->hasExplicitProvider($input); + + if (! is_file($providerPath)) { + if ($explicit) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + return null; + } + + $status = $this->providerUpdater->addTable($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED) { + return $providerPath; + } + + if ($status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return null; + } + + if ($explicit) { + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + return null; + } + + private function optionOrDefault(InputInterface $input, string $option, string $default): string { + $value = $input->getOption($option); + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + return $default; + } + + private function phpString(string $value): string { + return var_export($value, true); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function providerPath(InputInterface $input, ComposerProject $project): string { + $provider = $input->getOption('provider'); + + if (is_string($provider) && trim($provider) !== '') { + return $this->absolutePath($provider); + } + + $namespace = trim($project->defaultPsr4Namespace()->namespace, '\\') . '\\Database'; + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + return $this->rootPath . '/src/Database/Provider.php'; + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace) . '/Provider.php'; + } + + private function hasExplicitProvider(InputInterface $input): bool { + $provider = $input->getOption('provider'); + + return is_string($provider) && trim($provider) !== ''; + } + + private function providerUpdateFailure(string $status): string { + return match ($status) { + ProviderRegistrationEditor::NOT_FOUND => 'file does not exist or is not readable', + ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', + ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', + ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', + ProviderRegistrationEditor::IMPORT_COLLISION => 'a different imported class uses the same short class name', + ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', + ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', + default => 'provider could not be updated', + }; + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + if ($this->hasFoundationRuntimeDependency($requireDev)) { + return 'this table uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this table.'; + } + + return 'this table uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this table.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation', $dependencies); + } +} diff --git a/src/Cli/Generation/Php/PhpSourceEditor.php b/src/Cli/Generation/Php/PhpSourceEditor.php new file mode 100644 index 0000000..71b6a9c --- /dev/null +++ b/src/Cli/Generation/Php/PhpSourceEditor.php @@ -0,0 +1,551 @@ +parse($contents) !== null; + } + + public function hasImport(string $contents, string $fullyQualifiedClass): bool { + $target = trim($fullyQualifiedClass, '\\'); + $alias = basename(str_replace('\\', '/', $target)); + + foreach ($this->imports($contents) as $import) { + if ($import['class'] === $target && $import['alias'] === $alias) { + return true; + } + } + + return false; + } + + public function hasImportShortNameCollision(string $contents, string $class, string $fullyQualifiedClass): bool { + $target = trim($fullyQualifiedClass, '\\'); + + foreach ($this->imports($contents) as $import) { + if ($import['alias'] === $class && $import['class'] !== $target) { + return true; + } + } + + return false; + } + + public function hasLineComment(string $contents, string $comment): bool { + return $this->lineComment($contents, $comment) !== null; + } + + public function addImport(string $contents, string $fullyQualifiedClass): ?string { + if ($this->hasImport($contents, $fullyQualifiedClass)) { + return $contents; + } + + $offset = $this->importInsertionOffset($contents); + + if ($offset === null) { + return null; + } + + $import = 'use ' . trim($fullyQualifiedClass, '\\') . ';'; + + return substr($contents, 0, $offset) . $this->importSeparator($contents, $offset) . $import . substr($contents, $offset); + } + + public function insertBeforeLineComment(string $contents, string $comment, string $statement): ?string { + $lineComment = $this->lineComment($contents, $comment); + + if ($lineComment === null) { + return null; + } + + return substr($contents, 0, $lineComment->lineStartOffset) + . $lineComment->indent . $statement . "\n" + . substr($contents, $lineComment->lineStartOffset); + } + + public function canInsertIntoMergeArrayVar(string $contents, string $class, string $constant, ?string $beforeComment = null): bool { + $target = $this->mergeArrayVarTarget($contents, $class, $constant); + + if ($target === null) { + return false; + } + + $insertion = $beforeComment === null ? null : $this->lineComment($contents, $beforeComment, $target->registrationList); + $insertion ??= $this->arrayInsertion($contents, $target->registrationList); + + return $insertion !== null; + } + + public function mergeArrayVarContainerExpression(string $contents, string $class, string $constant): ?string { + return $this->mergeArrayVarTarget($contents, $class, $constant)?->containerExpression; + } + + public function insertIntoMergeArrayVar(string $contents, string $class, string $constant, string $statement, ?string $beforeComment = null): ?string { + $target = $this->mergeArrayVarTarget($contents, $class, $constant); + + if ($target === null) { + return null; + } + + $insertion = $beforeComment === null ? null : $this->lineComment($contents, $beforeComment, $target->registrationList); + $insertion ??= $this->arrayInsertion($contents, $target->registrationList); + + if ($insertion === null) { + return null; + } + + return substr($contents, 0, $insertion->lineStartOffset) + . $insertion->indent . $statement . "\n" + . substr($contents, $insertion->lineStartOffset); + } + + /** + * @return list + */ + private function imports(string $contents): array { + $statements = $this->parse($contents); + + if ($statements === null) { + return []; + } + + $imports = []; + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\Use_) { + $imports = array_merge($imports, $this->useImports($statement->uses, $statement->type)); + } + + if ($statement instanceof Stmt\GroupUse) { + $imports = array_merge($imports, $this->groupUseImports($statement)); + } + } + + return $imports; + } + + /** + * @param array $uses + * + * @return list + */ + private function useImports(array $uses, int $type): array { + if ($type !== Stmt\Use_::TYPE_NORMAL) { + return []; + } + + $imports = []; + + foreach ($uses as $use) { + $imports[] = [ + 'class' => $use->name->toString(), + 'alias' => $use->getAlias()->toString(), + ]; + } + + return $imports; + } + + /** + * @return list + */ + private function groupUseImports(Stmt\GroupUse $groupUse): array { + $imports = []; + $prefix = $groupUse->prefix->toString(); + + foreach ($groupUse->uses as $use) { + $type = $use->type === Stmt\Use_::TYPE_UNKNOWN ? $groupUse->type : $use->type; + + if ($type !== Stmt\Use_::TYPE_NORMAL) { + continue; + } + + $imports[] = [ + 'class' => $prefix . '\\' . $use->name->toString(), + 'alias' => $use->getAlias()->toString(), + ]; + } + + return $imports; + } + + private function importInsertionOffset(string $contents): ?int { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $lastUse = null; + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\Use_ || $statement instanceof Stmt\GroupUse) { + $lastUse = $statement; + } + } + + if ($lastUse instanceof Node) { + return $this->lineEndOffset($contents, $lastUse->getEndFilePos() + 1); + } + + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Namespace_) { + return $this->namespaceDeclarationEndOffset($contents, $statement); + } + } + + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Declare_) { + return $statement->getEndFilePos() + 1; + } + } + + return $this->openingTagEndOffset($contents); + } + + private function namespaceDeclarationEndOffset(string $contents, Stmt\Namespace_ $namespace): ?int { + foreach ($this->lexer->tokenize($contents) as $token) { + if ($token->pos < $namespace->getStartFilePos()) { + continue; + } + + if ($token->id === ord(';') || $token->id === ord('{')) { + return $token->getEndPos(); + } + } + + return null; + } + + private function openingTagEndOffset(string $contents): int { + foreach ($this->lexer->tokenize($contents) as $token) { + if ($token->id === T_OPEN_TAG) { + return $token->getEndPos(); + } + } + + return 0; + } + + private function importSeparator(string $contents, int $offset): string { + $before = substr($contents, 0, $offset); + + if (preg_match('/^use\s/m', $before) === 1) { + return "\n"; + } + + return "\n\n"; + } + + private function lineComment(string $contents, string $comment, ?Node $within = null): ?LineComment { + foreach ($this->lexer->tokenize($contents) as $token) { + if (! $token->is(T_COMMENT) || trim($token->text) !== $comment) { + continue; + } + + if ($within !== null && ($token->pos < $within->getStartFilePos() || $token->pos > $within->getEndFilePos())) { + continue; + } + + $lineStartOffset = $this->lineStartOffset($contents, $token->pos); + $indent = substr($contents, $lineStartOffset, $token->pos - $lineStartOffset); + + if (trim($indent) !== '') { + continue; + } + + return new LineComment( + indent: $indent, + lineStartOffset: $lineStartOffset, + commentStartOffset: $token->pos + ); + } + + return null; + } + + private function mergeArrayVarTarget(string $contents, string $class, string $constant): ?MergeArrayVarTarget { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $aliases = $this->classAliases($contents, $class); + $call = $this->findNode($statements, fn (Node $node): bool => $this->isMergeArrayVarCall($node, $class, $constant, $aliases)); + + if (! $call instanceof Expr\MethodCall) { + return null; + } + + return $this->mergeArrayVarTargetFromCall($call); + } + + /** + * @return list + */ + private function classAliases(string $contents, string $class): array { + $class = trim($class, '\\'); + $aliases = []; + + foreach ($this->imports($contents) as $import) { + if ($import['class'] === $class || str_ends_with($import['class'], '\\' . $class)) { + $aliases[] = $import['alias']; + } + } + + return $aliases; + } + + /** + * @param list $aliases + */ + private function isMergeArrayVarCall(Node $node, string $class, string $constant, array $aliases): bool { + if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'mergeArrayVar') { + return false; + } + + if (! $this->isThisContainer($node->var)) { + return false; + } + + $firstArgument = $node->args[0]->value ?? null; + + if (! $firstArgument instanceof Expr\ClassConstFetch || ! $firstArgument->class instanceof Node\Name || ! $firstArgument->name instanceof Node\Identifier) { + return false; + } + + if ($firstArgument->name->toString() !== $constant) { + return false; + } + + $referencedClass = $firstArgument->class->toString(); + + if (str_contains($referencedClass, '\\')) { + $referencedClass = trim($referencedClass, '\\'); + + return $referencedClass === $class || str_ends_with($referencedClass, '\\' . $class); + } + + return in_array($referencedClass, $aliases, true); + } + + private function isThisContainer(Node $node): bool { + return $node instanceof Expr\PropertyFetch + && $node->var instanceof Expr\Variable + && $node->var->name === 'this' + && $node->name instanceof Node\Identifier + && $node->name->toString() === 'container'; + } + + private function mergeArrayVarTargetFromCall(Expr\MethodCall $call): ?MergeArrayVarTarget { + $callback = $call->args[1]->value ?? null; + + if ($callback instanceof Expr\Array_) { + return new MergeArrayVarTarget( + registrationList: $callback, + containerExpression: '$this->container' + ); + } + + if ($callback instanceof Expr\ArrowFunction && $callback->expr instanceof Expr\Array_) { + $containerExpression = $this->callbackContainerExpression($callback); + + if ($containerExpression === null) { + return null; + } + + return new MergeArrayVarTarget( + registrationList: $callback->expr, + containerExpression: $containerExpression + ); + } + + if (! $callback instanceof Expr\Closure) { + return null; + } + + foreach ($callback->stmts as $statement) { + if ($statement instanceof Stmt\Return_ && $statement->expr instanceof Expr\Array_) { + $containerExpression = $this->callbackContainerExpression($callback); + + if ($containerExpression === null) { + return null; + } + + return new MergeArrayVarTarget( + registrationList: $statement->expr, + containerExpression: $containerExpression + ); + } + } + + return null; + } + + private function callbackContainerExpression(Expr\Closure|Expr\ArrowFunction $callback): ?string { + $parameter = $callback->params[0] ?? null; + + if ($parameter === null || ! $parameter->var instanceof Expr\Variable || ! is_string($parameter->var->name)) { + return null; + } + + return '$' . $parameter->var->name; + } + + private function arrayInsertion(string $contents, Expr\Array_ $array): ?LineInsertion { + $indent = null; + + if ($array->items !== []) { + $firstItem = $array->items[0]; + $lineStartOffset = $this->lineStartOffset($contents, $firstItem->getStartFilePos()); + $firstItemIndent = substr($contents, $lineStartOffset, $firstItem->getStartFilePos() - $lineStartOffset); + + if (trim($firstItemIndent) === '') { + $indent = $firstItemIndent; + } + } + + $closingOffset = $array->getEndFilePos(); + $lineStartOffset = $this->lineStartOffset($contents, $closingOffset); + $closingIndent = substr($contents, $lineStartOffset, $closingOffset - $lineStartOffset); + + if (trim($closingIndent) !== '') { + return null; + } + + return new LineInsertion( + lineStartOffset: $lineStartOffset, + indent: $indent ?? $this->childIndent($closingIndent) + ); + } + + private function childIndent(string $indent): string { + if ($indent === '' || str_contains($indent, "\t")) { + return $indent . "\t"; + } + + return $indent . ' '; + } + + /** + * @param array $statements + * @param callable(Node): bool $predicate + */ + private function findNode(array $statements, callable $predicate): ?Node { + foreach ($statements as $statement) { + $match = $this->findMatchingNode($statement, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + + return null; + } + + /** + * @param callable(Node): bool $predicate + */ + private function findMatchingNode(Node $node, callable $predicate): ?Node { + if ($predicate($node)) { + return $node; + } + + foreach ($node->getSubNodeNames() as $name) { + $value = $node->{$name}; + + if ($value instanceof Node) { + $match = $this->findMatchingNode($value, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + + if (is_array($value)) { + foreach ($value as $item) { + if (! $item instanceof Node) { + continue; + } + + $match = $this->findMatchingNode($item, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + } + } + + return null; + } + + private function lineStartOffset(string $contents, int $offset): int { + $previousNewline = strrpos(substr($contents, 0, $offset), "\n"); + + if ($previousNewline === false) { + return 0; + } + + return $previousNewline + 1; + } + + private function lineEndOffset(string $contents, int $offset): int { + $nextNewline = strpos($contents, "\n", $offset); + + if ($nextNewline === false) { + return strlen($contents); + } + + return $nextNewline; + } + + /** + * @param array $statements + * + * @return array + */ + private function topLevelStatements(array $statements): array { + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Namespace_) { + return $statement->stmts; + } + } + + return $statements; + } + + /** + * @return array|null + */ + private function parse(string $contents): ?array { + try { + return $this->parserFactory->createForNewestSupportedVersion()->parse($contents); + } catch (Error) { + return null; + } + } +} diff --git a/src/Cli/Generation/Php/ValueObjects/LineComment.php b/src/Cli/Generation/Php/ValueObjects/LineComment.php new file mode 100644 index 0000000..65a9c82 --- /dev/null +++ b/src/Cli/Generation/Php/ValueObjects/LineComment.php @@ -0,0 +1,16 @@ +=8.3", + "nikic/php-parser": ">=5.0 <6.0", "stellarwp/foundation-container": "^1.2", "stellarwp/foundation-database": "^1.2", "stellarwp/foundation-wpcli": "^1.2", diff --git a/src/Database/DatabaseStubPath.php b/src/Database/DatabaseStubPath.php index a4c79e9..43031cc 100644 --- a/src/Database/DatabaseStubPath.php +++ b/src/Database/DatabaseStubPath.php @@ -7,10 +7,18 @@ */ final class DatabaseStubPath { + public static function provider(): string { + return __DIR__ . '/stubs/provider.stub'; + } + public static function migration(): string { return __DIR__ . '/stubs/migration.stub'; } + public static function tableMigration(): string { + return __DIR__ . '/stubs/table-migration.stub'; + } + public static function table(): string { return __DIR__ . '/stubs/table.stub'; } diff --git a/src/Database/README.md b/src/Database/README.md index 1ce1db8..9d9c9cc 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -236,27 +236,50 @@ final readonly class PluginUpdater ## Generators -If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a table class and matching migration in a consuming WordPress project: +If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a database provider, table class, and matching migration in a consuming WordPress project: ```bash +vendor/bin/foundation make:database-provider vendor/bin/foundation make:database-table Reports_Table vendor/bin/foundation make:database-migration Create_Reports_Table ``` -The table generator reads the project's first `autoload.psr-4` namespace from `composer.json` and writes a Snake_Case table class under `src/Database/Tables` by default. The migration generator writes under `src/Database/Migrations` by default and references the matching table class. +The provider generator reads the project's first `autoload.psr-4` namespace from `composer.json` and writes `src/Database/Provider.php` by default. Register the Foundation `DatabaseProvider` first, then the generated application provider: + +```php +use Acme\Plugin\Database\Provider; +use StellarWP\Foundation\Database\DatabaseProvider; + +protected array $providers = [ + DatabaseProvider::class, + Provider::class, +]; +``` + +The table generator writes a Snake_Case table class under `src/Database/Tables` by default. The migration generator writes under `src/Database/Migrations` by default and references the matching table class. + +Migration names matching `Create_*_Table`, or migrations generated with `--table-class`, use the table-backed migration stub and wrap the table in `CreateTable`. Other migration names use the generic migration stub. + +If `src/Database/Provider.php` exists and contains the generated provider markers, the table and migration generators automatically add imports and registrations to that provider. Pass `--provider=path/to/Provider.php` to update a non-standard provider file. Re-running a generator does not duplicate existing provider imports or registrations. If you generate a custom provider class name or location, pass `--provider` when generating later tables or migrations. Common options: ```bash +vendor/bin/foundation make:database-provider Provider \ + --namespace="Acme\\Plugin\\Database" \ + --path=src/Database + vendor/bin/foundation make:database-table Reports_Table \ --namespace="Acme\\Plugin\\Database\\Tables" \ --path=src/Database/Tables \ + --provider=src/Database/Provider.php \ --id=reports_table \ --table=reports vendor/bin/foundation make:database-migration Create_Reports_Table \ --namespace="Acme\\Plugin\\Database\\Migrations" \ --path=src/Database/Migrations \ + --provider=src/Database/Provider.php \ --id=2026_06_26_000001_create_reports_table \ --table-class=Reports_Table \ --table-namespace="Acme\\Plugin\\Database\\Tables" @@ -267,6 +290,8 @@ Project-specific stub overrides live in: ```text foundation/stubs/database/table.stub foundation/stubs/database/migration.stub +foundation/stubs/database/table-migration.stub +foundation/stubs/database/provider.stub ``` When present, overrides are used instead of the default stubs from the `foundation-database` package. diff --git a/src/Database/stubs/migration.stub b/src/Database/stubs/migration.stub index 08afb1d..6784a25 100644 --- a/src/Database/stubs/migration.stub +++ b/src/Database/stubs/migration.stub @@ -4,28 +4,22 @@ namespace {{ namespace }}; use {{ foundation_database_migration }}; use {{ foundation_database_schema }}; -use {{ foundation_database_create_table }}; -use {{ table_namespace }}\{{ table_class }}; +use {{ foundation_database_irreversible_migration }}; final readonly class {{ class }} implements Migration { public const string ID = {{ id_php }}; - public function __construct( - private {{ table_class }} $table - ) { - } - public function id(): string { return self::ID; } public function up( Schema $schema ): void { - ( new CreateTable( $this->table ) )->up( $schema ); + // Add migration behavior here. } public function down( Schema $schema ): void { - ( new CreateTable( $this->table ) )->down( $schema ); + throw new IrreversibleMigration( self::ID ); } } diff --git a/src/Database/stubs/provider.stub b/src/Database/stubs/provider.stub new file mode 100644 index 0000000..8c8bb84 --- /dev/null +++ b/src/Database/stubs/provider.stub @@ -0,0 +1,25 @@ +register_tables(); + $this->register_migrations(); + } + + private function register_tables(): void { + // foundation:database-tables + } + + private function register_migrations(): void { + $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + ] ); + } + +} diff --git a/src/Database/stubs/table-migration.stub b/src/Database/stubs/table-migration.stub new file mode 100644 index 0000000..08afb1d --- /dev/null +++ b/src/Database/stubs/table-migration.stub @@ -0,0 +1,31 @@ +table ) )->up( $schema ); + } + + public function down( Schema $schema ): void { + ( new CreateTable( $this->table ) )->down( $schema ); + } + +} diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index 5393f88..b94fa6a 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -7,8 +7,9 @@ use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Cli\Application; use StellarWP\Foundation\Cli\CliProvider; -use StellarWP\Foundation\Cli\Commands\Make\DatabaseMigrationCommand; -use StellarWP\Foundation\Cli\Commands\Make\DatabaseTableCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -35,8 +36,9 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(Application::class, $container->get(Application::class)); $this->assertInstanceOf(CreateCommand::class, $container->get(CreateCommand::class)); - $this->assertInstanceOf(DatabaseMigrationCommand::class, $container->get(DatabaseMigrationCommand::class)); - $this->assertInstanceOf(DatabaseTableCommand::class, $container->get(DatabaseTableCommand::class)); + $this->assertInstanceOf(MigrationCommand::class, $container->get(MigrationCommand::class)); + $this->assertInstanceOf(ProviderCommand::class, $container->get(ProviderCommand::class)); + $this->assertInstanceOf(TableCommand::class, $container->get(TableCommand::class)); $this->assertInstanceOf(WPCliCommand::class, $container->get(WPCliCommand::class)); $this->assertInstanceOf(PackageResolver::class, $container->get(PackageResolver::class)); $this->assertInstanceOf(PackageScaffolder::class, $container->get(PackageScaffolder::class)); @@ -48,6 +50,7 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(GitHubPackageRepositoryCreator::class, $container->get(PackageRepositoryCreator::class)); $this->assertTrue($container->get(Application::class)->has('package:create')); $this->assertTrue($container->get(Application::class)->has('make:database-migration')); + $this->assertTrue($container->get(Application::class)->has('make:database-provider')); $this->assertTrue($container->get(Application::class)->has('make:database-table')); $this->assertTrue($container->get(Application::class)->has('make:wpcli-command')); } diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index 87870bc..bbfb404 100644 --- a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -2,10 +2,15 @@ namespace StellarWP\Foundation\Tests\Unit\Cli\Commands\Make; -use StellarWP\Foundation\Cli\Commands\Make\DatabaseMigrationCommand; -use StellarWP\Foundation\Cli\Commands\Make\DatabaseTableCommand; +use PhpParser\Lexer; +use PhpParser\ParserFactory; +use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderRegistrationEditor; +use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; @@ -91,6 +96,51 @@ public function test_it_generates_a_database_migration_from_project_autoload_def $this->assertStringContainsString('( new CreateTable( $this->table ) )->up( $schema );', $contents); } + public function test_it_generates_a_generic_database_migration_for_non_table_names(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'bump-version', + '--id' => '2026_06_26_000003_bump_version', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('final readonly class Bump_Version implements Migration {', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Exceptions\\IrreversibleMigration;', $contents); + $this->assertStringContainsString("public const string ID = '2026_06_26_000003_bump_version';", $contents); + $this->assertStringContainsString('throw new IrreversibleMigration( self::ID );', $contents); + $this->assertStringNotContainsString('CreateTable', $contents); + $this->assertStringNotContainsString('Bump_Version_Table', $contents); + } + + public function test_it_generates_a_database_provider_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $path = $root . '/src/Database/Provider.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Provider.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database;', $contents); + $this->assertStringContainsString('use lucatume\\DI52\\Container as C;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', $contents); + $this->assertStringContainsString('final class Provider extends Service_Provider {', $contents); + $this->assertStringContainsString('$this->register_tables();', $contents); + $this->assertStringContainsString('$this->register_migrations();', $contents); + $this->assertStringContainsString('// foundation:database-tables', $contents); + $this->assertStringNotContainsString('// foundation:database-migrations', $contents); + } + public function test_database_migrations_default_to_timestamped_ids(): void { $root = $this->temporaryProject(); $tester = new CommandTester($this->migrationCommand($root)); @@ -143,6 +193,745 @@ public function test_database_generators_accept_generation_options(): void { $this->assertStringContainsString("public const string ID = '2026_06_26_000002_create_audit_log_table';", $migrationContents); } + public function test_database_provider_generator_accepts_generation_options(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'Database_Provider', + '--namespace' => 'Acme\\Plugin\\Storage', + '--path' => 'custom/providers', + ]); + + $contents = (string) file_get_contents($root . '/custom/providers/Database_Provider.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('namespace Acme\\Plugin\\Storage;', $contents); + $this->assertStringContainsString('final class Database_Provider extends Service_Provider {', $contents); + } + + public function test_database_provider_generator_accepts_an_absolute_output_path(): void { + $root = $this->temporaryProject(); + $outputRoot = $this->temporaryRoot('foundation-make-database-provider-output-'); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme\\External\\Database', + '--path' => $outputRoot, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($outputRoot . '/Provider.php'); + $this->assertStringContainsString('Created: ' . $outputRoot . '/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('namespace Acme\\External\\Database;', (string) file_get_contents($outputRoot . '/Provider.php')); + } + + public function test_table_and_migration_generators_update_the_conventional_database_provider_when_it_exists(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); + + $this->assertSame(Command::SUCCESS, $tableStatus); + $this->assertStringContainsString('Updated: src/Database/Provider.php', $tableTester->getDisplay()); + $this->assertSame(Command::SUCCESS, $migrationStatus); + $this->assertStringContainsString('Updated: src/Database/Provider.php', $migrationTester->getDisplay()); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); + $this->assertStringContainsString('$this->container->singleton(Reports_Table::class);', $contents); + $this->assertStringContainsString('$c->get(Create_Reports_Table::class),', $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + $this->assertStringContainsString("\t\t\t\$c->get(Create_Reports_Table::class),\n\t\t] );", $contents); + $this->assertStringNotContainsString('Array$this', $contents); + $this->assertStringNotContainsString('Array$c', $contents); + + (new CommandTester($this->tableCommand($root)))->execute([ + 'name' => 'reports', + '--force' => true, + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--force' => true, + ]); + + $this->assertSame($contents, (string) file_get_contents($root . '/src/Database/Provider.php')); + } + + public function test_database_migration_generator_appends_to_existing_provider_migrations_in_order(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-orders-table', + '--id' => '2026_06_26_000002_create_orders_table', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); + + $reportsOffset = strpos($contents, '$c->get(Create_Reports_Table::class),'); + $ordersOffset = strpos($contents, '$c->get(Create_Orders_Table::class),'); + + $this->assertIsInt($reportsOffset); + $this->assertIsInt($ordersOffset); + $this->assertGreaterThan($reportsOffset, $ordersOffset); + } + + public function test_table_and_migration_generators_update_an_explicit_database_provider_when_it_exists(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([ + '--path' => 'custom/providers', + ]); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + '--provider' => 'custom/providers/Provider.php', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $contents = (string) file_get_contents($root . '/custom/providers/Provider.php'); + + $this->assertSame(Command::SUCCESS, $tableStatus); + $this->assertStringContainsString('Updated: custom/providers/Provider.php', $tableTester->getDisplay()); + $this->assertSame(Command::SUCCESS, $migrationStatus); + $this->assertStringContainsString('Updated: custom/providers/Provider.php', $migrationTester->getDisplay()); + $this->assertStringContainsString('$this->container->singleton(Reports_Table::class);', $contents); + $this->assertStringContainsString('$c->get(Create_Reports_Table::class),', $contents); + } + + public function test_explicit_database_provider_update_fails_when_the_provider_has_no_markers(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', 'tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain the generated database provider markers', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_migration_update_fails_when_the_provider_has_no_migration_anchor(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' +migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_database_provider_migration_update_preserves_legacy_migration_marker_position(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + // foundation:database-migrations + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); + $this->assertStringContainsString("\t\t\t\$c->get(Create_Reports_Table::class),\n\t\t\t// foundation:database-migrations", $contents); + } + + public function test_database_provider_migration_update_supports_direct_array_registrations(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, [ + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('$this->container->get(Create_Reports_Table::class),', $contents); + } + + public function test_database_provider_migration_update_supports_closure_callbacks_returning_arrays(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function ( C $c ): array { + return [ + ]; + } ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); + $this->assertStringContainsString("\t\t\t\t\$c->get(Create_Reports_Table::class),\n\t\t\t];", $contents); + } + + public function test_database_provider_migration_update_uses_the_callback_parameter_name(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $container ): array => [ + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('$container->get(Create_Reports_Table::class),', $contents); + } + + public function test_explicit_database_provider_migration_update_fails_before_writing_when_the_array_cannot_be_safely_edited(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [] ); + } +} +PHP); + + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_explicit_database_provider_migration_update_fails_when_the_callback_has_no_container_parameter(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn (): array => [ + ] ); + } +} +PHP); + + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_database_provider_migration_update_ignores_unrelated_database_provider_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $this->assertSame(ProviderRegistrationEditor::MISSING_ANCHOR, $status); + $this->assertStringNotContainsString('Create_Reports_Table', (string) file_get_contents($providerPath)); + } + + public function test_explicit_database_provider_update_fails_when_the_provider_cannot_be_parsed(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', 'tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file could not be parsed as PHP', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_database_provider_updates_ignore_marker_text_that_is_not_on_a_marker_line(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'private function register_tables(): void {', + "/**\n\t * Example text: // foundation:database-tables\n\t */\n\tprivate function register_tables(): void {", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertSame(1, substr_count($contents, '$this->container->singleton(Reports_Table::class);')); + $this->assertStringContainsString('Example text: // foundation:database-tables', $contents); + } + + public function test_database_provider_updater_adds_import_after_namespace_when_no_imports_exist(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString("namespace Acme\\Plugin\\Database;\n\nuse Acme\\Plugin\\Database\\Tables\\Reports_Table;\n\nfinal class Provider", $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + $this->assertStringNotContainsString('Array$this', $contents); + } + + public function test_database_provider_updater_adds_import_when_same_class_uses_a_different_alias(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table as Existing_Reports_Table;', $contents); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + } + + public function test_database_provider_updater_preserves_inline_comments_when_adding_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString("use Acme\\Plugin\\Database\\Existing_Table; // keep this comment here\nuse Acme\\Plugin\\Database\\Tables\\Reports_Table;", $contents); + } + + public function test_database_provider_updater_ignores_marker_text_inside_non_marker_line_comments(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $this->assertSame(ProviderRegistrationEditor::MISSING_MARKER, $status); + $this->assertSame(0, substr_count((string) file_get_contents($providerPath), '$this->container->singleton(Reports_Table::class);')); + } + + public function test_database_provider_updater_is_idempotent_with_grouped_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->singleton(Reports_Table::class); + // foundation:database-tables + } +} +PHP); + + $contents = (string) file_get_contents($providerPath); + $status = $this->providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $status); + $this->assertSame($contents, (string) file_get_contents($providerPath)); + } + + public function test_explicit_database_provider_update_fails_on_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', + "use Acme\\Other\\Reports_Table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_update_fails_on_grouped_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', + "use Acme\\Other\\{Reports as Reports_Table};\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_update_fails_on_aliased_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', + "use Acme\\Other\\Reports as Reports_Table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + public function test_database_table_generator_accepts_an_absolute_output_path(): void { $root = $this->temporaryProject(); $outputRoot = $this->temporaryRoot('foundation-make-database-output-'); @@ -187,12 +976,33 @@ public function test_database_generators_use_strauss_namespace_prefix_for_founda $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); } + public function test_database_provider_generator_uses_strauss_namespace_prefix_for_foundation_imports(): void { + $root = $this->temporaryProject([ + 'extra' => [ + 'strauss' => [ + 'namespace_prefix' => 'Acme\\Product\\', + ], + ], + ]); + + $statusCode = (new CommandTester($this->providerCommand($root)))->execute([]); + + $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', $contents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); + } + public function test_database_generators_use_project_stub_overrides(): void { $root = $this->temporaryProject(); mkdir($root . '/foundation/stubs/database', 0777, true); file_put_contents($root . '/foundation/stubs/database/table.stub', 'Generated table {{ class }} in {{ namespace }}'); - file_put_contents($root . '/foundation/stubs/database/migration.stub', 'Generated migration {{ class }} with {{ table_class }}'); + file_put_contents($root . '/foundation/stubs/database/table-migration.stub', 'Generated migration {{ class }} with {{ table_class }}'); + file_put_contents($root . '/foundation/stubs/database/migration.stub', 'Generated migration {{ class }}'); + file_put_contents($root . '/foundation/stubs/database/provider.stub', 'Generated provider {{ class }} in {{ namespace }}'); (new CommandTester($this->tableCommand($root)))->execute([ 'name' => 'reports', @@ -201,6 +1011,11 @@ public function test_database_generators_use_project_stub_overrides(): void { 'name' => 'create-reports-table', '--id' => '2026_06_26_000001_create_reports_table', ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'bump-version', + '--id' => '2026_06_26_000003_bump_version', + ]); + (new CommandTester($this->providerCommand($root)))->execute([]); $this->assertSame( 'Generated table Reports_Table in Acme\\Plugin\\Database\\Tables', @@ -210,6 +1025,14 @@ public function test_database_generators_use_project_stub_overrides(): void { 'Generated migration Create_Reports_Table with Reports_Table', (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php') ); + $this->assertSame( + 'Generated migration Bump_Version', + (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php') + ); + $this->assertSame( + 'Generated provider Provider in Acme\\Plugin\\Database', + (string) file_get_contents($root . '/src/Database/Provider.php') + ); } public function test_database_generators_warn_when_the_runtime_dependency_is_missing_from_production_requirements(): void { @@ -225,6 +1048,60 @@ public function test_database_generators_warn_when_the_runtime_dependency_is_mis $this->assertStringContainsString('composer require stellarwp/foundation-database', $tester->getDisplay()); } + public function test_database_provider_generator_warns_when_the_runtime_dependency_is_missing_from_production_requirements(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('composer require stellarwp/foundation-database', $tester->getDisplay()); + } + + public function test_database_provider_generator_warns_when_the_runtime_dependency_is_only_a_development_dependency(): void { + $root = $this->temporaryProject([ + 'require-dev' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('only in require-dev', $tester->getDisplay()); + } + + public function test_database_provider_generator_does_not_warn_when_the_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); + } + + public function test_database_provider_generator_does_not_warn_when_the_aggregate_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); + } + public function test_database_generators_warn_when_the_runtime_dependency_is_only_a_development_dependency(): void { $root = $this->temporaryProject([ 'require-dev' => [ @@ -275,6 +1152,20 @@ public function test_database_generators_reject_invalid_namespaces_before_writin $this->assertFileDoesNotExist($root . '/custom/tables/Reports_Table.php'); } + public function test_database_provider_generator_rejects_invalid_namespaces_before_writing_files(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme Plugin\\Database', + '--path' => 'custom/providers', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme Plugin\\Database" is not a valid PHP namespace.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/custom/providers/Provider.php'); + } + public function test_database_generators_reject_namespaces_outside_the_autoload_root(): void { $root = $this->temporaryProject(); $tableTester = new CommandTester($this->tableCommand($root)); @@ -298,19 +1189,45 @@ public function test_database_generators_reject_namespaces_outside_the_autoload_ $this->assertFileDoesNotExist($root . '/src/Tools/Database/Migrations/Create_Reports_Table.php'); } - private function tableCommand(string $root): DatabaseTableCommand { - return new DatabaseTableCommand( + public function test_database_provider_generator_rejects_namespaces_outside_the_autoload_root(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme\\PluginTools\\Database', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database" is outside the Composer PSR-4 namespaces in composer.json.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Tools/Database/Provider.php'); + } + + private function tableCommand(string $root): TableCommand { + return new TableCommand( rootPath: $root, autoloadResolver: new ComposerAutoloadResolver($root), classNameResolver: new WordPressClassNameResolver(), stubResolver: new StubResolver($root), stubRenderer: new StubRenderer(), - fileWriter: new GeneratedFileWriter() + fileWriter: new GeneratedFileWriter(), + providerUpdater: $this->providerUpdater() ); } - private function migrationCommand(string $root): DatabaseMigrationCommand { - return new DatabaseMigrationCommand( + private function migrationCommand(string $root): MigrationCommand { + return new MigrationCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: new GeneratedFileWriter(), + providerUpdater: $this->providerUpdater() + ); + } + + private function providerCommand(string $root): ProviderCommand { + return new ProviderCommand( rootPath: $root, autoloadResolver: new ComposerAutoloadResolver($root), classNameResolver: new WordPressClassNameResolver(), @@ -320,6 +1237,15 @@ classNameResolver: new WordPressClassNameResolver(), ); } + private function providerUpdater(): ProviderRegistrationEditor { + return new ProviderRegistrationEditor( + sourceEditor: new PhpSourceEditor( + parserFactory: new ParserFactory(), + lexer: new Lexer() + ) + ); + } + /** * @param array $composer */ diff --git a/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php new file mode 100644 index 0000000..2a32cc2 --- /dev/null +++ b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php @@ -0,0 +1,103 @@ +tempDir = $this->prepare_temp_dir('generated-file-writer'); + } + + public function test_it_writes_generated_files_to_nested_directories(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/nested/Generated.php', + relativePath: 'nested/Generated.php', + contents: 'write($file); + + $this->assertFileExists($file->path); + $this->assertSame($file->contents, (string) file_get_contents($file->path)); + } + + public function test_it_refuses_to_overwrite_existing_files_without_force(): void { + $path = $this->tempDir . '/Generated.php'; + + file_put_contents($path, 'existing'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('File already exists: Generated.php. Use --force to overwrite it.'); + + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'replacement' + )); + } + + public function test_it_overwrites_existing_files_when_forced(): void { + $path = $this->tempDir . '/Generated.php'; + + file_put_contents($path, 'existing'); + + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'replacement' + ), force: true); + + $this->assertSame('replacement', (string) file_get_contents($path)); + } + + public function test_it_fails_when_the_target_directory_cannot_be_created(): void { + $path = $this->tempDir . '/blocked'; + + file_put_contents($path, 'file'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Could not create directory "%s/Generated.php".', $path)); + + set_error_handler(static fn (): bool => true); + + try { + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path . '/Generated.php/File.php', + relativePath: 'blocked/Generated.php/File.php', + contents: 'content' + )); + } finally { + restore_error_handler(); + } + } + + public function test_it_fails_when_the_generated_file_cannot_be_written(): void { + $path = $this->tempDir . '/Generated.php'; + + mkdir($path); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not write generated file "Generated.php".'); + + set_error_handler(static fn (): bool => true); + + try { + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'content' + ), force: true); + } finally { + restore_error_handler(); + } + } +} diff --git a/tests/Unit/Cli/Generation/PhpSourceEditorTest.php b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php new file mode 100644 index 0000000..28f56ff --- /dev/null +++ b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php @@ -0,0 +1,137 @@ +editor(); + + $this->assertFalse($editor->canParse('assertNull($editor->addImport('assertFalse($editor->canInsertIntoMergeArrayVar( + 'assertNull($editor->insertIntoMergeArrayVar( + 'get(Generated::class),' + )); + } + + public function test_it_returns_existing_source_when_an_import_already_exists(): void { + $contents = $this->fixture('existing-import'); + + $this->assertSame($contents, $this->editor()->addImport($contents, 'Acme\\Generated')); + } + + public function test_it_returns_null_when_a_line_comment_cannot_be_found(): void { + $this->assertNull($this->editor()->insertBeforeLineComment( + 'container->singleton(Generated::class);' + )); + } + + public function test_it_adds_imports_to_files_without_namespaces_or_opening_tags(): void { + $editor = $this->editor(); + + $this->assertStringStartsWith( + "addImport('assertStringContainsString( + 'use Acme\\Generated;final class Provider {}', + (string) $editor->addImport('final class Provider {}', 'Acme\\Generated') + ); + } + + public function test_it_adds_imports_after_a_last_use_without_a_trailing_newline(): void { + $contents = <<<'PHP' +assertStringContainsString( + "use Acme\\Existing;\nuse Acme\\Generated;\nfinal class Provider", + (string) $this->editor()->addImport($contents, 'Acme\\Generated') + ); + } + + public function test_it_ignores_function_imports_when_resolving_class_imports(): void { + $this->assertFalse($this->editor()->hasImport($this->fixture('function-import'), 'Acme\\Generated')); + } + + public function test_it_rejects_merge_array_var_calls_that_are_not_foundation_database_migration_lists(): void { + $editor = $this->editor(); + $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('not-container-merge-array-var'), $class, 'MIGRATIONS')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('wrong-first-argument-merge-array-var'), $class, 'MIGRATIONS')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('wrong-constant-merge-array-var'), $class, 'MIGRATIONS')); + } + + public function test_it_matches_fully_qualified_strauss_prefixed_database_provider_references(): void { + $updated = $this->editor()->insertIntoMergeArrayVar( + $this->fixture('strauss-prefixed-database-provider'), + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS', + '$this->container->get(Generated::class),' + ); + + $this->assertStringContainsString('$this->container->get(Generated::class),', (string) $updated); + } + + public function test_it_rejects_merge_array_var_callbacks_that_do_not_expose_a_registration_list(): void { + $this->assertFalse($this->editor()->canInsertIntoMergeArrayVar( + $this->fixture('arrow-callback-without-registration-list'), + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS' + )); + } + + public function test_it_rejects_merge_array_var_closures_without_container_parameters_or_array_returns(): void { + $editor = $this->editor(); + $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-container-parameter'), $class, 'MIGRATIONS')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-array-return'), $class, 'MIGRATIONS')); + } + + public function test_it_uses_space_indentation_when_inserting_into_space_indented_arrays(): void { + $updated = $this->editor()->insertIntoMergeArrayVar( + $this->fixture('space-indented-registration-list'), + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS', + '$this->container->get(Generated::class),' + ); + + $this->assertStringContainsString( + " \$this->container->get(Existing::class),\n \$this->container->get(Generated::class),", + (string) $updated + ); + } + + private function editor(): PhpSourceEditor { + return new PhpSourceEditor( + parserFactory: new ParserFactory(), + lexer: new Lexer() + ); + } + + private function fixture(string $name): string { + return (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/' . $name . '.stub')); + } +} diff --git a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php index 54920ec..a18b11b 100644 --- a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php +++ b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php @@ -66,6 +66,10 @@ public function test_it_creates_table_names_from_table_classes(): void { $this->assertSame('reports', (new WordPressClassNameResolver())->tableName('Reports_Table')); } + public function test_it_uses_the_lowercase_class_name_when_a_table_name_has_no_words(): void { + $this->assertSame('@@@', (new WordPressClassNameResolver())->tableName('@@@')); + } + public function test_it_creates_timestamped_migration_ids_from_migration_classes(): void { $this->assertSame( '2026_06_26_120000_create_reports_table', @@ -80,6 +84,13 @@ public function test_it_fails_when_input_cannot_be_normalized_to_a_class_name(): (new WordPressClassNameResolver())->commandClass('@@@'); } + public function test_it_fails_when_generic_class_input_cannot_be_normalized_to_a_class_name(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a class name from "@@@".'); + + (new WordPressClassNameResolver())->className('@@@'); + } + public function test_it_fails_when_the_generated_class_name_would_start_with_a_number(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa-sync".'); @@ -87,6 +98,13 @@ public function test_it_fails_when_the_generated_class_name_would_start_with_a_n (new WordPressClassNameResolver())->commandClass('2fa-sync'); } + public function test_it_fails_when_the_generic_class_name_would_start_with_a_number(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa-sync".'); + + (new WordPressClassNameResolver())->className('2fa-sync'); + } + public function test_it_fails_when_the_generated_class_name_would_conflict_with_the_base_command(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a command class named "Command" from "Command"'); @@ -94,6 +112,27 @@ public function test_it_fails_when_the_generated_class_name_would_conflict_with_ (new WordPressClassNameResolver())->commandClass('Command'); } + public function test_it_fails_when_table_input_cannot_be_normalized_to_a_class_name(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a table class name from "@@@".'); + + (new WordPressClassNameResolver())->tableClass('@@@'); + } + + public function test_it_fails_when_the_table_class_name_would_start_with_a_number(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa".'); + + (new WordPressClassNameResolver())->tableClass('2fa'); + } + + public function test_it_fails_when_migration_input_cannot_be_normalized_to_an_id(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a migration id from "@@@".'); + + (new WordPressClassNameResolver())->migrationId('@@@'); + } + public function test_it_uses_a_default_description_when_the_class_has_no_words(): void { $this->assertSame('Run the command.', (new WordPressClassNameResolver())->description('Command')); } diff --git a/tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub b/tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub new file mode 100644 index 0000000..0a87e88 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/arrow-callback-without-registration-list.stub @@ -0,0 +1,13 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): object => new \stdClass() ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub b/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub new file mode 100644 index 0000000..b5a838a --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub @@ -0,0 +1,15 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function ( C $c ): array { + return $c->get(Generated::class); + } ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub b/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub new file mode 100644 index 0000000..eeebc5a --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub @@ -0,0 +1,15 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function (): array { + return [ + ]; + } ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/existing-import.stub b/tests/_data/cli/generation/php-source-editor/existing-import.stub new file mode 100644 index 0000000..66bad60 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/existing-import.stub @@ -0,0 +1,7 @@ +mergeArrayVar( DatabaseProvider::MIGRATIONS, [ ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub b/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub new file mode 100644 index 0000000..2cc95e8 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub @@ -0,0 +1,14 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, [ + $this->container->get(Existing::class), + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub b/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub new file mode 100644 index 0000000..f0b7482 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub @@ -0,0 +1,11 @@ +container->mergeArrayVar( \Acme\Product\StellarWP\Foundation\Database\DatabaseProvider::MIGRATIONS, [ + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub new file mode 100644 index 0000000..8870933 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub @@ -0,0 +1,12 @@ +container->mergeArrayVar( DatabaseProvider::TABLES, [ ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub new file mode 100644 index 0000000..5399fc6 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub @@ -0,0 +1,10 @@ +container->mergeArrayVar( 'migrations', [ ] ); + } +} From cc63d8a9237ba019922fa33c4bf53e0f9e5ed646 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 29 Jun 2026 11:09:26 -0600 Subject: [PATCH 19/81] Add `foundation-identifier` package --- AGENTS.md | 3 + README.md | 1 + composer.json | 3 + src/Identifier/.gitattributes | 7 ++ .../.github/workflows/close-pull-request.yml | 13 +++ src/Identifier/.gitignore | 2 + .../Contracts/IdentifierGenerator.php | 11 +++ src/Identifier/IdentifierProvider.php | 39 ++++++++ src/Identifier/README.md | 52 ++++++++++ src/Identifier/Ulid/Contracts/Entropy.php | 11 +++ .../Ulid/Contracts/MillisecondClock.php | 11 +++ .../Ulid/Contracts/UlidGenerator.php | 12 +++ src/Identifier/Ulid/RandomizerEntropy.php | 21 ++++ .../Ulid/SystemMillisecondClock.php | 15 +++ src/Identifier/Ulid/UlidGenerator.php | 69 +++++++++++++ src/Identifier/Ulid/UlidValidator.php | 15 +++ src/Identifier/composer.json | 25 +++++ .../Fixtures/Identifier/Ulid/FixedEntropy.php | 31 ++++++ .../Identifier/Ulid/FixedMillisecondClock.php | 17 ++++ .../Identifier/IdentifierProviderTest.php | 54 +++++++++++ .../Identifier/Ulid/RandomizerEntropyTest.php | 15 +++ .../Ulid/SystemMillisecondClockTest.php | 18 ++++ .../Identifier/Ulid/UlidGeneratorTest.php | 96 +++++++++++++++++++ .../Identifier/Ulid/UlidValidatorTest.php | 41 ++++++++ 24 files changed, 582 insertions(+) create mode 100644 src/Identifier/.gitattributes create mode 100644 src/Identifier/.github/workflows/close-pull-request.yml create mode 100644 src/Identifier/.gitignore create mode 100644 src/Identifier/Contracts/IdentifierGenerator.php create mode 100644 src/Identifier/IdentifierProvider.php create mode 100644 src/Identifier/README.md create mode 100644 src/Identifier/Ulid/Contracts/Entropy.php create mode 100644 src/Identifier/Ulid/Contracts/MillisecondClock.php create mode 100644 src/Identifier/Ulid/Contracts/UlidGenerator.php create mode 100644 src/Identifier/Ulid/RandomizerEntropy.php create mode 100644 src/Identifier/Ulid/SystemMillisecondClock.php create mode 100644 src/Identifier/Ulid/UlidGenerator.php create mode 100644 src/Identifier/Ulid/UlidValidator.php create mode 100644 src/Identifier/composer.json create mode 100644 tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php create mode 100644 tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php create mode 100644 tests/Unit/Identifier/IdentifierProviderTest.php create mode 100644 tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php create mode 100644 tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php create mode 100644 tests/Unit/Identifier/Ulid/UlidGeneratorTest.php create mode 100644 tests/Unit/Identifier/Ulid/UlidValidatorTest.php diff --git a/AGENTS.md b/AGENTS.md index 08f76d1..7f94da1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Initial packages: - `stellarwp/foundation-log` - `stellarwp/foundation-lock` - `stellarwp/foundation-database` +- `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` @@ -81,6 +82,8 @@ When writing providers or container registration code, prefer container-driven c Use contextual bindings with `$this->container->when()->needs()->give()` for scalar constructor arguments, command lists, or feature-specific substitutions. Use a factory closure only when the value must be computed or resolved from the container, and keep that closure focused on supplying the constructor dependency rather than constructing the full object. +Classes should take the dependencies they need directly. Do not make constructor dependencies nullable just to instantiate fallback concrete classes internally, for example `?Dependency $dependency = null` with `$this->dependency = $dependency ?? new Dependency()`. Register default implementations and aliases in a provider instead so consumers can replace them through container configuration. + Organize provider registration by feature or capability, not by container mechanism. The main `register()` method should call focused private methods such as `registerConfiguration()`, `registerMigrations()`, `registerLocks()`, or `registerCliCommands()`. Keep each feature's contextual bindings beside the classes they configure. Avoid generic methods such as `configureContextualBindings()` that group unrelated bindings only because they use the same container API. ## Split Packages diff --git a/README.md b/README.md index 44e713f..669bea0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) - [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) - [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) +- [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) - [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) diff --git a/composer.json b/composer.json index 8d4c5c0..2f22745 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "ext-curl": "*", "ext-exif": "*", "adbario/php-dot-notation": ">=2.5", + "arokettu/random-polyfill": ">=1.0.6 <1.99", "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", "nikic/php-parser": ">=5.0 <6.0", @@ -37,6 +38,7 @@ "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", "stellarwp/foundation-database": "self.version", + "stellarwp/foundation-identifier": "self.version", "stellarwp/foundation-lock": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", @@ -49,6 +51,7 @@ "StellarWP\\Foundation\\Cli\\": "src/Cli/", "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Database\\": "src/Database/", + "StellarWP\\Foundation\\Identifier\\": "src/Identifier/", "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", diff --git a/src/Identifier/.gitattributes b/src/Identifier/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Identifier/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Identifier/.github/workflows/close-pull-request.yml b/src/Identifier/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Identifier/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Identifier/.gitignore b/src/Identifier/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Identifier/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Identifier/Contracts/IdentifierGenerator.php b/src/Identifier/Contracts/IdentifierGenerator.php new file mode 100644 index 0000000..c7c44da --- /dev/null +++ b/src/Identifier/Contracts/IdentifierGenerator.php @@ -0,0 +1,11 @@ +container->singleton(RandomizerEntropy::class); + $this->container->singleton(SystemMillisecondClock::class); + $this->container->singleton(UlidGenerator::class); + $this->container->singleton(UlidValidator::class); + + $this->container->when(RandomizerEntropy::class) + ->needs(Randomizer::class) + ->give(static fn (): Randomizer => new Randomizer(new Secure())); + + $this->container->bind(Entropy::class, static fn (C $c): RandomizerEntropy => $c->get(RandomizerEntropy::class)); + $this->container->bind(MillisecondClock::class, static fn (C $c): SystemMillisecondClock => $c->get(SystemMillisecondClock::class)); + $this->container->bind(UlidGeneratorContract::class, static fn (C $c): UlidGenerator => $c->get(UlidGenerator::class)); + } +} diff --git a/src/Identifier/README.md b/src/Identifier/README.md new file mode 100644 index 0000000..630438d --- /dev/null +++ b/src/Identifier/README.md @@ -0,0 +1,52 @@ +# Foundation Identifier + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +## Installation + +```shell +composer require stellarwp/foundation-identifier +``` + +## Usage + +`foundation-identifier` provides injectable identifier generation contracts and a ULID implementation. + +```php +use StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator; + +final class CreateJob +{ + public function __construct( + private readonly UlidGenerator $identifiers + ) { + } + + public function __invoke(): string { + return $this->identifiers->generate(); + } +} +``` + +Consumers using Foundation's container can register `StellarWP\Foundation\Identifier\IdentifierProvider` to make the ULID services available. The provider does not bind `IdentifierGenerator` globally; applications should decide which identifier strategy satisfies that contract. + +The provider binds `StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator` to the default ULID implementation. If ULIDs should be the application's default identifier strategy, bind the broader `IdentifierGenerator` contract in an application provider: + +```php +use lucatume\DI52\Container as C; +use StellarWP\Foundation\Container\Contracts\Provider as ServiceProvider; +use StellarWP\Foundation\Identifier\Contracts\IdentifierGenerator; +use StellarWP\Foundation\Identifier\IdentifierProvider as FoundationIdentifierProvider; +use StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator; + +final class IdentifierProvider extends ServiceProvider +{ + public function register(): void { + $this->container->register(FoundationIdentifierProvider::class); + $this->container->bind(IdentifierGenerator::class, static fn (C $c): UlidGenerator => $c->get(UlidGenerator::class)); + } +} +``` + +The default generator returns canonical uppercase ULIDs, such as `01ARYZ6S410000000000000000`. Use `StellarWP\Foundation\Identifier\Ulid\UlidValidator` when accepting ULIDs from external input. diff --git a/src/Identifier/Ulid/Contracts/Entropy.php b/src/Identifier/Ulid/Contracts/Entropy.php new file mode 100644 index 0000000..5c83a7b --- /dev/null +++ b/src/Identifier/Ulid/Contracts/Entropy.php @@ -0,0 +1,11 @@ +randomizer->getBytes($length); + } +} diff --git a/src/Identifier/Ulid/SystemMillisecondClock.php b/src/Identifier/Ulid/SystemMillisecondClock.php new file mode 100644 index 0000000..a064299 --- /dev/null +++ b/src/Identifier/Ulid/SystemMillisecondClock.php @@ -0,0 +1,15 @@ +encodeTimestamp($this->clock->milliseconds()) + . $this->encodeRandomness($this->entropy->bytes(self::RANDOM_BYTES)); + } + + private function encodeTimestamp(int $timestamp): string { + if ($timestamp < 0 || $timestamp > self::MAX_TIMESTAMP) { + throw new OutOfRangeException(sprintf('ULID timestamps must be between 0 and %d milliseconds.', self::MAX_TIMESTAMP)); + } + + $encoded = ''; + + for ($i = 0; $i < self::TIMESTAMP_LENGTH; $i++) { + $encoded = self::ALPHABET[$timestamp % 32] . $encoded; + $timestamp = intdiv($timestamp, 32); + } + + return $encoded; + } + + private function encodeRandomness(string $bytes): string { + if (strlen($bytes) !== self::RANDOM_BYTES) { + throw new RuntimeException(sprintf('ULID generation requires exactly %d random bytes.', self::RANDOM_BYTES)); + } + + $encoded = ''; + $buffer = 0; + $bitCount = 0; + + for ($i = 0; $i < self::RANDOM_BYTES; $i++) { + $buffer = ($buffer << 8) | ord($bytes[$i]); + $bitCount += 8; + + while ($bitCount >= 5) { + $bitCount -= 5; + $encoded .= self::ALPHABET[($buffer >> $bitCount) & 31]; + $buffer &= (1 << $bitCount) - 1; + } + } + + return $encoded; + } +} diff --git a/src/Identifier/Ulid/UlidValidator.php b/src/Identifier/Ulid/UlidValidator.php new file mode 100644 index 0000000..e111db4 --- /dev/null +++ b/src/Identifier/Ulid/UlidValidator.php @@ -0,0 +1,15 @@ +=8.3", + "arokettu/random-polyfill": ">=1.0.6 <1.99", + "stellarwp/foundation-container": "^1.2" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Identifier\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.2.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php b/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php new file mode 100644 index 0000000..fbe8533 --- /dev/null +++ b/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php @@ -0,0 +1,31 @@ +requestedLengths[] = $length; + + return $this->bytes; + } + + /** + * @return int[] + */ + public function requestedLengths(): array { + return $this->requestedLengths; + } +} diff --git a/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php b/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php new file mode 100644 index 0000000..d28e990 --- /dev/null +++ b/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php @@ -0,0 +1,17 @@ +milliseconds; + } +} diff --git a/tests/Unit/Identifier/IdentifierProviderTest.php b/tests/Unit/Identifier/IdentifierProviderTest.php new file mode 100644 index 0000000..5f162fa --- /dev/null +++ b/tests/Unit/Identifier/IdentifierProviderTest.php @@ -0,0 +1,54 @@ +container->register(IdentifierProvider::class); + + $this->assertInstanceOf(RandomizerEntropy::class, $this->container->get(Entropy::class)); + $this->assertInstanceOf(SystemMillisecondClock::class, $this->container->get(MillisecondClock::class)); + $this->assertInstanceOf(UlidGenerator::class, $this->container->get(UlidGeneratorContract::class)); + $this->assertInstanceOf(UlidGenerator::class, $this->container->get(UlidGenerator::class)); + $this->assertInstanceOf(UlidValidator::class, $this->container->get(UlidValidator::class)); + } + + public function test_consumers_can_bind_ulids_as_the_default_identifier_strategy(): void { + $this->container->register(IdentifierProvider::class); + $this->container->bind(IdentifierGenerator::class, static fn (C $c): UlidGeneratorContract => $c->get(UlidGeneratorContract::class)); + + $this->assertInstanceOf(UlidGeneratorContract::class, $this->container->get(IdentifierGenerator::class)); + } + + public function test_it_does_not_bind_the_generic_identifier_contract_by_default(): void { + $this->container->register(IdentifierProvider::class); + + $this->assertFalse($this->container->has(IdentifierGenerator::class)); + } + + public function test_it_generates_ulids_with_configured_provider_dependencies(): void { + $entropy = new FixedEntropy(str_repeat("\0", 10)); + + $this->container->register(IdentifierProvider::class); + $this->container->bind(Entropy::class, $entropy); + $this->container->bind(MillisecondClock::class, new FixedMillisecondClock(1_469_918_176_385)); + + $this->assertSame('01ARYZ6S410000000000000000', $this->container->get(UlidGeneratorContract::class)->generate()); + $this->assertSame([10], $entropy->requestedLengths()); + } +} diff --git a/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php b/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php new file mode 100644 index 0000000..4f3e8e0 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php @@ -0,0 +1,15 @@ +assertSame(10, strlen((new RandomizerEntropy(new Randomizer(new Secure())))->bytes(10))); + } +} diff --git a/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php b/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php new file mode 100644 index 0000000..c35418e --- /dev/null +++ b/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php @@ -0,0 +1,18 @@ +milliseconds(); + $after = (int) floor(microtime(true) * 1000); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual($after, $now); + } +} diff --git a/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php b/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php new file mode 100644 index 0000000..905e7c4 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php @@ -0,0 +1,96 @@ +assertInstanceOf(IdentifierGenerator::class, $generator); + $this->assertInstanceOf(UlidGeneratorContract::class, $generator); + } + + public function test_it_generates_valid_ulids(): void { + $identifier = (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(0) + ))->generate(); + + $this->assertSame(26, strlen($identifier)); + $this->assertTrue((new UlidValidator())->isValid($identifier)); + } + + public function test_it_encodes_the_timestamp_before_randomness(): void { + $entropy = new FixedEntropy(str_repeat("\0", 10)); + + $generator = new UlidGenerator( + $entropy, + new FixedMillisecondClock(1_469_918_176_385) + ); + + $this->assertSame('01ARYZ6S410000000000000000', $generator->generate()); + $this->assertSame([10], $entropy->requestedLengths()); + } + + public function test_it_accepts_the_maximum_ulid_timestamp(): void { + $generator = new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(281_474_976_710_655) + ); + + $this->assertSame('7ZZZZZZZZZ0000000000000000', $generator->generate()); + } + + public function test_it_encodes_randomness(): void { + $generator = new UlidGenerator( + new FixedEntropy(str_repeat("\xff", 10)), + new FixedMillisecondClock(0) + ); + + $this->assertSame('0000000000ZZZZZZZZZZZZZZZZ', $generator->generate()); + } + + public function test_it_rejects_timestamps_outside_the_ulid_range(): void { + $this->expectException(OutOfRangeException::class); + $this->expectExceptionMessage('ULID timestamps must be between 0 and 281474976710655 milliseconds.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(-1) + ))->generate(); + } + + public function test_it_rejects_timestamps_above_the_ulid_range(): void { + $this->expectException(OutOfRangeException::class); + $this->expectExceptionMessage('ULID timestamps must be between 0 and 281474976710655 milliseconds.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(281_474_976_710_656) + ))->generate(); + } + + public function test_it_rejects_entropy_that_does_not_return_ten_bytes(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ULID generation requires exactly 10 random bytes.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 9)), + new FixedMillisecondClock(0) + ))->generate(); + } +} diff --git a/tests/Unit/Identifier/Ulid/UlidValidatorTest.php b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php new file mode 100644 index 0000000..db19fcc --- /dev/null +++ b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php @@ -0,0 +1,41 @@ + + */ + public static function invalidUlidProvider(): array { + return [ + 'empty' => ['identifier' => ''], + 'too short' => ['identifier' => '01ARYZ6S41000000000000000'], + 'too long' => ['identifier' => '01ARYZ6S4100000000000000000'], + 'lowercase' => ['identifier' => '01aryz6s410000000000000000'], + 'ambiguous i' => ['identifier' => '01ARYZ6S41000000000000000I'], + 'ambiguous l' => ['identifier' => '01ARYZ6S41000000000000000L'], + 'ambiguous o' => ['identifier' => '01ARYZ6S41000000000000000O'], + 'excluded u' => ['identifier' => '01ARYZ6S41000000000000000U'], + 'timestamp above' => ['identifier' => '81ARYZ6S410000000000000000'], + ]; + } + + public function test_it_accepts_canonical_ulids(): void { + $this->assertTrue((new UlidValidator())->isValid('01ARYZ6S410000000000000000')); + } + + public function test_it_accepts_canonical_ulids_at_the_maximum_timestamp(): void { + $this->assertTrue((new UlidValidator())->isValid('7ZZZZZZZZZ0000000000000000')); + } + + /** + * @dataProvider invalidUlidProvider + */ + public function test_it_rejects_invalid_ulids(string $identifier): void { + $this->assertFalse((new UlidValidator())->isValid($identifier)); + } +} From df126acb6d0f5d5abb7087259084b68bba87e4b9 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 29 Jun 2026 11:28:29 -0600 Subject: [PATCH 20/81] Bring in dataprovider attribute --- tests/Unit/Identifier/Ulid/UlidValidatorTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Unit/Identifier/Ulid/UlidValidatorTest.php b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php index db19fcc..3e69363 100644 --- a/tests/Unit/Identifier/Ulid/UlidValidatorTest.php +++ b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Tests\Unit\Identifier\Ulid; +use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Identifier\Ulid\UlidValidator; use StellarWP\Foundation\Tests\TestCase; @@ -35,6 +36,7 @@ public function test_it_accepts_canonical_ulids_at_the_maximum_timestamp(): void /** * @dataProvider invalidUlidProvider */ + #[DataProvider('invalidUlidProvider')] public function test_it_rejects_invalid_ulids(string $identifier): void { $this->assertFalse((new UlidValidator())->isValid($identifier)); } From 4f0247d2d8e692e05a70c8c3eadee27f1f8ec7bd Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 13:57:51 -0600 Subject: [PATCH 21/81] Add foundation-shutdown --- README.md | 1 + composer.json | 2 + src/Shutdown/.gitattributes | 7 + .../.github/workflows/close-pull-request.yml | 13 ++ src/Shutdown/.gitignore | 2 + src/Shutdown/Contracts/Terminable.php | 11 ++ src/Shutdown/README.md | 118 ++++++++++++++ src/Shutdown/ShutdownProvider.php | 35 ++++ src/Shutdown/ShutdownRunner.php | 107 ++++++++++++ src/Shutdown/ShutdownTask.php | 17 ++ src/Shutdown/composer.json | 25 +++ .../Fixtures/Shutdown/CallbackTerminable.php | 18 ++ tests/Unit/Shutdown/ShutdownRunnerTest.php | 154 ++++++++++++++++++ .../wpunit/Shutdown/ShutdownProviderTest.php | 99 +++++++++++ 14 files changed, 609 insertions(+) create mode 100644 src/Shutdown/.gitattributes create mode 100644 src/Shutdown/.github/workflows/close-pull-request.yml create mode 100644 src/Shutdown/.gitignore create mode 100644 src/Shutdown/Contracts/Terminable.php create mode 100644 src/Shutdown/README.md create mode 100644 src/Shutdown/ShutdownProvider.php create mode 100644 src/Shutdown/ShutdownRunner.php create mode 100644 src/Shutdown/ShutdownTask.php create mode 100644 src/Shutdown/composer.json create mode 100644 tests/Support/Fixtures/Shutdown/CallbackTerminable.php create mode 100644 tests/Unit/Shutdown/ShutdownRunnerTest.php create mode 100644 tests/wpunit/Shutdown/ShutdownProviderTest.php diff --git a/README.md b/README.md index 3380a10..7595167 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-container-wordpress](https://github.com/stellarwp/foundation-container-wordpress) - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) +- [stellarwp/foundation-shutdown](https://github.com/stellarwp/foundation-shutdown) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) - [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) diff --git a/composer.json b/composer.json index 25cd000..d6b6942 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "stellarwp/foundation-container-wordpress": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", + "stellarwp/foundation-shutdown": "self.version", "stellarwp/foundation-wpcli": "self.version" }, "minimum-stability": "dev", @@ -49,6 +50,7 @@ "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", + "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/", "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" }, "exclude-from-classmap": [ diff --git a/src/Shutdown/.gitattributes b/src/Shutdown/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Shutdown/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Shutdown/.github/workflows/close-pull-request.yml b/src/Shutdown/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Shutdown/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Shutdown/.gitignore b/src/Shutdown/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Shutdown/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Shutdown/Contracts/Terminable.php b/src/Shutdown/Contracts/Terminable.php new file mode 100644 index 0000000..a6ef41f --- /dev/null +++ b/src/Shutdown/Contracts/Terminable.php @@ -0,0 +1,11 @@ + [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Run application termination work once, in a predictable order, without allowing +one failed task to prevent the remaining tasks from running. + +## Installation + +```shell +composer require stellarwp/foundation-shutdown +``` + +## Register the provider + +Register `ShutdownProvider` through your application's normal Foundation provider +list: + +```php +use StellarWP\Foundation\Shutdown\ShutdownProvider; + +private array $providers = [ + ShutdownProvider::class, +]; +``` + +The provider has no custom constructor and uses the application's existing +Foundation container and configuration. Package installation alone has no side +effects; consumers may omit this provider and construct the public runner directly +or supply their own provider. + +## Create and contribute tasks + +Termination work implements the small `Terminable` contract: + +```php +use StellarWP\Foundation\Shutdown\Contracts\Terminable; + +final class FlushTelemetry implements Terminable +{ + public function terminate(): void { + // Flush bounded application telemetry. + } +} +``` + +Contribute an application's termination work from one provider. Resolve the +concrete tasks lazily so all providers can finish registering before termination +services are constructed. Contributions must be registered before the runner is +resolved: + +```php +use lucatume\DI52\Container; +use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Shutdown\ShutdownProvider as FoundationShutdownProvider; +use StellarWP\Foundation\Shutdown\ShutdownTask; + +final class ApplicationShutdownProvider extends Provider +{ + public function register(): void { + $this->container->singleton(CloseRequestLog::class); + $this->container->singleton(FlushTelemetry::class); + + $this->container->mergeArrayVar( + FoundationShutdownProvider::TASKS, + static fn (Container $container): array => [ + new ShutdownTask($container->get(CloseRequestLog::class), 10), + new ShutdownTask($container->get(FlushTelemetry::class), 100), + ] + ); + } +} +``` + +Register both providers through the application's provider list: + +```php +private array $providers = [ + FoundationShutdownProvider::class, + ApplicationShutdownProvider::class, +]; +``` + +Lower priority values run first. Tasks with the same priority retain their +registration order. + +## WordPress shutdown + +`ShutdownProvider` automatically attaches the runner to WordPress's `shutdown` +action at the latest priority. The runner is resolved lazily when the action fires, +so features may contribute tasks after the provider is registered. + +Applications that need a different lifecycle boundary may omit the default provider +and register their own provider or invoke the runner directly: + +```php +use StellarWP\Foundation\Shutdown\ShutdownRunner; + +$container->get(ShutdownRunner::class)->terminate(); +``` + +Each runner instance executes only once, including when termination is invoked +recursively. A `Throwable` from one task is isolated so later tasks still run. + +## Logging + +`ShutdownRunner` accepts an optional PSR-3 logger. When the application binds a +`Psr\Log\LoggerInterface`—including through `foundation-log`—the container injects +it automatically. Applications without a logger require no additional setup. + +The runner logs the task count and each task at `debug` level. Task failures are +logged at `error` level with the task class, priority, exception class, and code. +Logger failures are isolated so diagnostics cannot interrupt termination work. + +Framework hooks, response finishing, output-buffer management, hard task timeouts, +and asynchronous execution beyond the default WordPress shutdown action belong to +the consuming application or a dedicated framework integration. diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php new file mode 100644 index 0000000..28d7b4f --- /dev/null +++ b/src/Shutdown/ShutdownProvider.php @@ -0,0 +1,35 @@ +container->has(self::REGISTERED)) { + return; + } + + $this->container->singleton(self::REGISTERED, true); + + $this->container->when(ShutdownRunner::class) + ->needs('$tasks') + ->give(static fn (Container $container): array => $container->getVar(self::TASKS, [])); + + $this->container->singleton(ShutdownRunner::class); + + add_action( + 'shutdown', + $this->container->callback(ShutdownRunner::class, 'terminate'), + PHP_INT_MAX + ); + } +} diff --git a/src/Shutdown/ShutdownRunner.php b/src/Shutdown/ShutdownRunner.php new file mode 100644 index 0000000..ba9d425 --- /dev/null +++ b/src/Shutdown/ShutdownRunner.php @@ -0,0 +1,107 @@ + */ + private array $tasks; + + private bool $terminated = false; + + /** + * @param array $tasks + */ + public function __construct( + array $tasks = [], + private readonly ?LoggerInterface $logger = null + ) { + foreach ($tasks as $task) { + if (! $task instanceof ShutdownTask) { + throw new InvalidArgumentException('Shutdown tasks must be instances of ShutdownTask.'); + } + } + + $this->tasks = array_values($tasks); + } + + public function terminate(): void { + if ($this->terminated) { + return; + } + + $this->terminated = true; + + $tasks = $this->orderedTasks(); + + $this->log(LogLevel::DEBUG, 'Running shutdown tasks.', [ + 'task_count' => count($tasks), + ]); + + foreach ($tasks as $task) { + $context = [ + 'task' => $task->terminable::class, + 'priority' => $task->priority, + ]; + + $this->log(LogLevel::DEBUG, 'Running shutdown task.', $context); + + try { + $task->terminable->terminate(); + } catch (Throwable $exception) { + $this->log(LogLevel::ERROR, 'Shutdown task failed.', $context + [ + 'exception' => $exception::class, + 'code' => $exception->getCode(), + ]); + } + } + } + + /** + * Logging must not interrupt application termination. + * + * @param array $context + */ + private function log(string $level, string $message, array $context = []): void { + try { + $this->logger?->log($level, $message, $context); + } catch (Throwable) { + // The remaining termination work is more important than diagnostics. + } + } + + /** + * @return list + */ + private function orderedTasks(): array { + /** @var list $indexedTasks */ + $indexedTasks = []; + + foreach ($this->tasks as $index => $task) { + $indexedTasks[] = [ + 'index' => $index, + 'task' => $task, + ]; + } + + usort( + $indexedTasks, + static fn (array $left, array $right): int => ($left['task']->priority <=> $right['task']->priority) + ?: ($left['index'] <=> $right['index']) + ); + + return array_map( + static fn (array $entry): ShutdownTask => $entry['task'], + $indexedTasks + ); + } +} diff --git a/src/Shutdown/ShutdownTask.php b/src/Shutdown/ShutdownTask.php new file mode 100644 index 0000000..39ba450 --- /dev/null +++ b/src/Shutdown/ShutdownTask.php @@ -0,0 +1,17 @@ +=8.3", + "psr/log": ">=1.0", + "stellarwp/foundation-container": "^1.4" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Shutdown\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.4.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/Shutdown/CallbackTerminable.php b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php new file mode 100644 index 0000000..5ea7b66 --- /dev/null +++ b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php @@ -0,0 +1,18 @@ +callback)(); + } +} diff --git a/tests/Unit/Shutdown/ShutdownRunnerTest.php b/tests/Unit/Shutdown/ShutdownRunnerTest.php new file mode 100644 index 0000000..daec649 --- /dev/null +++ b/tests/Unit/Shutdown/ShutdownRunnerTest.php @@ -0,0 +1,154 @@ +recordingTask($calls, 'last', 100), + $this->recordingTask($calls, 'second', 10), + $this->recordingTask($calls, 'third', 10), + $this->recordingTask($calls, 'first', 0), + ]); + + $runner->terminate(); + + $this->assertSame(['first', 'second', 'third', 'last'], $calls); + } + + public function test_it_runs_each_task_only_once(): void { + $calls = []; + $runner = new ShutdownRunner([$this->recordingTask($calls, 'task')]); + + $runner->terminate(); + $runner->terminate(); + + $this->assertSame(['task'], $calls); + } + + public function test_it_is_safe_to_invoke_recursively(): void { + $calls = []; + $runner = new ShutdownRunner(); + + $recursive = new CallbackTerminable(static function () use (&$calls, &$runner): void { + $calls[] = 'recursive'; + $runner->terminate(); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($recursive), + $this->recordingTask($calls, 'next'), + ]); + + $runner->terminate(); + + $this->assertSame(['recursive', 'next'], $calls); + } + + public function test_a_failed_task_does_not_prevent_later_tasks(): void { + $calls = []; + + $failing = new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'failed'; + + throw new Error('Expected test failure.'); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($failing), + $this->recordingTask($calls, 'completed'), + ]); + + $runner->terminate(); + + $this->assertSame(['failed', 'completed'], $calls); + } + + public function test_it_logs_task_execution_and_failures_when_a_logger_is_available(): void { + $handler = new TestHandler(); + $logger = new Logger('shutdown', [$handler]); + $failing = new CallbackTerminable(static function (): void { + throw new Error('Expected test failure.', 42); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($failing, 10), + ], $logger); + + $runner->terminate(); + + $records = $handler->getRecords(); + + $this->assertCount(3, $records); + $this->assertSame('Running shutdown tasks.', $records[0]['message']); + $this->assertSame(['task_count' => 1], $records[0]['context']); + $this->assertSame('Running shutdown task.', $records[1]['message']); + $this->assertSame([ + 'task' => CallbackTerminable::class, + 'priority' => 10, + ], $records[1]['context']); + $this->assertSame('Shutdown task failed.', $records[2]['message']); + $this->assertSame([ + 'task' => CallbackTerminable::class, + 'priority' => 10, + 'exception' => Error::class, + 'code' => 42, + ], $records[2]['context']); + } + + public function test_a_failed_logger_does_not_prevent_termination_work(): void { + $calls = []; + $logger = $this->createMock(LoggerInterface::class); + + $logger->method('log')->willThrowException(new Error('Expected logger failure.')); + + $runner = new ShutdownRunner([ + $this->recordingTask($calls, 'completed'), + ], $logger); + + $runner->terminate(); + + $this->assertSame(['completed'], $calls); + } + + public function test_it_accepts_an_empty_task_list(): void { + $runner = new ShutdownRunner(); + + $runner->terminate(); + $runner->terminate(); + + $this->addToAssertionCount(1); + } + + public function test_it_rejects_invalid_task_contributions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Shutdown tasks must be instances of ShutdownTask.'); + + $runner = new ShutdownRunner(['invalid']); + } + + /** + * @param list $calls + */ + private function recordingTask(array &$calls, string $name, int $priority = 0): ShutdownTask { + return new ShutdownTask( + new CallbackTerminable(static function () use (&$calls, $name): void { + $calls[] = $name; + }), + $priority + ); + } +} diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php new file mode 100644 index 0000000..6855044 --- /dev/null +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -0,0 +1,99 @@ +container = new ContainerAdapter(new DI52Container()); + $this->container->bind(Container::class, $this->container); + $this->container->singleton(Dot::class, new Dot()); + } + + protected function tearDown(): void { + if ($this->container->has(ShutdownRunner::class)) { + remove_action( + 'shutdown', + $this->container->callback(ShutdownRunner::class, 'terminate'), + PHP_INT_MAX + ); + } + + parent::tearDown(); + } + + public function test_it_registers_a_singleton_runner_with_contributed_tasks(): void { + $calls = []; + + $this->container->register(ShutdownProvider::class); + $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ + new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'terminated'; + })), + ]); + + $runner = $this->container->get(ShutdownRunner::class); + + $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + + $runner->terminate(); + + $this->assertSame(['terminated'], $calls); + } + + public function test_duplicate_provider_registration_does_not_replace_the_runner(): void { + $this->container->register(ShutdownProvider::class); + $runner = $this->container->get(ShutdownRunner::class); + + $this->container->register(ShutdownProvider::class); + + $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + } + + public function test_it_injects_a_registered_psr_logger(): void { + $handler = new TestHandler(); + + $this->container->singleton(LoggerInterface::class, new Logger('shutdown', [$handler])); + $this->container->register(ShutdownProvider::class); + + $this->container->get(ShutdownRunner::class)->terminate(); + + $this->assertTrue($handler->hasDebugThatMatches('/Running shutdown tasks\./')); + } + + public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void { + $calls = []; + + $this->container->register(ShutdownProvider::class); + $callback = $this->container->callback(ShutdownRunner::class, 'terminate'); + + $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ + new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'terminated'; + })), + ]); + + $this->assertSame(PHP_INT_MAX, has_action('shutdown', $callback)); + + $callback(); + + $this->assertSame(['terminated'], $calls); + } +} From a053cc8a8b87579eb6795b28bf7014c9047ee8d2 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 14:04:44 -0600 Subject: [PATCH 22/81] Add foundation-shutdown to AGENTS.md --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b07274f..7bc74ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,12 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended for libraries and WordPress plugin ecosystems. -Initial packages: +Split packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` - `stellarwp/foundation-pipeline` +- `stellarwp/foundation-shutdown` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` From db530beb9aca7784e9add74dbced8991f4ab2565 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 15:09:56 -0600 Subject: [PATCH 23/81] Add decorators, add fastcgi_finish_request, litespeed_finish_request in ResponseFinishingRunner --- src/Shutdown/Contracts/ShutdownRunner.php | 10 +++ src/Shutdown/README.md | 40 ++++++--- src/Shutdown/ResponseFinishingRunner.php | 43 ++++++++++ src/Shutdown/ShutdownProvider.php | 11 ++- src/Shutdown/ShutdownRunner.php | 7 +- src/Shutdown/composer.json | 2 +- .../Shutdown/finish-request-functions.php | 21 +++++ .../Shutdown/litespeed-finish-request.php | 9 ++ .../Shutdown/ResponseFinishingRunnerTest.php | 84 +++++++++++++++++++ tests/Unit/Shutdown/ShutdownRunnerTest.php | 8 +- .../wpunit/Shutdown/ShutdownProviderTest.php | 20 +++-- 11 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 src/Shutdown/Contracts/ShutdownRunner.php create mode 100644 src/Shutdown/ResponseFinishingRunner.php create mode 100644 tests/Support/Fixtures/Shutdown/finish-request-functions.php create mode 100644 tests/Support/Fixtures/Shutdown/litespeed-finish-request.php create mode 100644 tests/Unit/Shutdown/ResponseFinishingRunnerTest.php diff --git a/src/Shutdown/Contracts/ShutdownRunner.php b/src/Shutdown/Contracts/ShutdownRunner.php new file mode 100644 index 0000000..d222d02 --- /dev/null +++ b/src/Shutdown/Contracts/ShutdownRunner.php @@ -0,0 +1,10 @@ +get(ShutdownRunner::class)->terminate(); ``` +Applications that omit the default provider must bind the `ShutdownRunner` contract +in their own provider or construct the concrete +`StellarWP\Foundation\Shutdown\ShutdownRunner` with their desired tasks. + Each runner instance executes only once, including when termination is invoked recursively. A `Throwable` from one task is isolated so later tasks still run. @@ -110,9 +127,10 @@ recursively. A `Throwable` from one task is isolated so later tasks still run. it automatically. Applications without a logger require no additional setup. The runner logs the task count and each task at `debug` level. Task failures are -logged at `error` level with the task class, priority, exception class, and code. -Logger failures are isolated so diagnostics cannot interrupt termination work. +logged at `error` level with the task class, priority, and actual exception so +compatible loggers retain its message and stack trace. Logger failures are isolated +so diagnostics cannot interrupt termination work. -Framework hooks, response finishing, output-buffer management, hard task timeouts, -and asynchronous execution beyond the default WordPress shutdown action belong to -the consuming application or a dedicated framework integration. +Output-buffer management, hard task timeouts, and asynchronous execution beyond +the default WordPress shutdown action belong to the consuming application or a +dedicated framework integration. diff --git a/src/Shutdown/ResponseFinishingRunner.php b/src/Shutdown/ResponseFinishingRunner.php new file mode 100644 index 0000000..49a6b39 --- /dev/null +++ b/src/Shutdown/ResponseFinishingRunner.php @@ -0,0 +1,43 @@ +terminated) { + return; + } + + $this->terminated = true; + + foreach (['fastcgi_finish_request', 'litespeed_finish_request'] as $finishRequest) { + if (! function_exists($finishRequest)) { + continue; + } + + try { + if ($finishRequest()) { + break; + } + } catch (Throwable) { + // Response finishing is best-effort and must not block termination work. + } + } + + $this->runner->terminate(); + } +} diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php index 28d7b4f..95ecdab 100644 --- a/src/Shutdown/ShutdownProvider.php +++ b/src/Shutdown/ShutdownProvider.php @@ -3,10 +3,14 @@ namespace StellarWP\Foundation\Shutdown; use lucatume\DI52\Container; +use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner as ShutdownRunnerContract; /** * Registers the default shutdown runner and task contribution point. + * + * @property-read ContainerAdapter $container */ final class ShutdownProvider extends Provider { @@ -24,11 +28,14 @@ public function register(): void { ->needs('$tasks') ->give(static fn (Container $container): array => $container->getVar(self::TASKS, [])); - $this->container->singleton(ShutdownRunner::class); + $this->container->singletonDecorators(ShutdownRunnerContract::class, [ + ResponseFinishingRunner::class, + ShutdownRunner::class, + ]); add_action( 'shutdown', - $this->container->callback(ShutdownRunner::class, 'terminate'), + $this->container->callback(ShutdownRunnerContract::class, 'terminate'), PHP_INT_MAX ); } diff --git a/src/Shutdown/ShutdownRunner.php b/src/Shutdown/ShutdownRunner.php index ba9d425..59ef64d 100644 --- a/src/Shutdown/ShutdownRunner.php +++ b/src/Shutdown/ShutdownRunner.php @@ -5,13 +5,13 @@ use InvalidArgumentException; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; -use StellarWP\Foundation\Shutdown\Contracts\Terminable; +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner as ShutdownRunnerContract; use Throwable; /** * Runs contributed termination tasks once in deterministic priority order. */ -final class ShutdownRunner implements Terminable +final class ShutdownRunner implements ShutdownRunnerContract { /** @var list */ private array $tasks; @@ -59,8 +59,7 @@ public function terminate(): void { $task->terminable->terminate(); } catch (Throwable $exception) { $this->log(LogLevel::ERROR, 'Shutdown task failed.', $context + [ - 'exception' => $exception::class, - 'code' => $exception->getCode(), + 'exception' => $exception, ]); } } diff --git a/src/Shutdown/composer.json b/src/Shutdown/composer.json index abb7190..7122744 100644 --- a/src/Shutdown/composer.json +++ b/src/Shutdown/composer.json @@ -1,7 +1,7 @@ { "name": "stellarwp/foundation-shutdown", "type": "library", - "description": "Foundation Shutdown package.", + "description": "Run application shutdown tasks once in deterministic priority order.", "license": "GPL-2.0-or-later", "config": { "vendor-dir": "vendor", diff --git a/tests/Support/Fixtures/Shutdown/finish-request-functions.php b/tests/Support/Fixtures/Shutdown/finish-request-functions.php new file mode 100644 index 0000000..9ef3f06 --- /dev/null +++ b/tests/Support/Fixtures/Shutdown/finish-request-functions.php @@ -0,0 +1,21 @@ +runner(); + + $runner->terminate(); + $runner->terminate(); + + $this->assertSame(['litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset($GLOBALS['foundation_shutdown_calls']); + } + + #[RunInSeparateProcess] + public function test_it_prefers_fastcgi_response_finishing(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset($GLOBALS['foundation_shutdown_calls']); + } + + #[RunInSeparateProcess] + public function test_a_response_finishing_failure_does_not_prevent_shutdown_tasks(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + $GLOBALS['foundation_shutdown_fastcgi_failure'] = true; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset( + $GLOBALS['foundation_shutdown_calls'], + $GLOBALS['foundation_shutdown_fastcgi_failure'] + ); + } + + #[RunInSeparateProcess] + public function test_it_falls_back_when_fastcgi_does_not_finish_the_response(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + $GLOBALS['foundation_shutdown_fastcgi_false'] = true; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset( + $GLOBALS['foundation_shutdown_calls'], + $GLOBALS['foundation_shutdown_fastcgi_false'] + ); + } + + private function runner(): ResponseFinishingRunner { + return new ResponseFinishingRunner(new ShutdownRunner([ + new ShutdownTask(new CallbackTerminable(static function (): void { + $GLOBALS['foundation_shutdown_calls'][] = 'task'; + })), + ])); + } +} diff --git a/tests/Unit/Shutdown/ShutdownRunnerTest.php b/tests/Unit/Shutdown/ShutdownRunnerTest.php index daec649..fd77909 100644 --- a/tests/Unit/Shutdown/ShutdownRunnerTest.php +++ b/tests/Unit/Shutdown/ShutdownRunnerTest.php @@ -80,8 +80,9 @@ public function test_a_failed_task_does_not_prevent_later_tasks(): void { public function test_it_logs_task_execution_and_failures_when_a_logger_is_available(): void { $handler = new TestHandler(); $logger = new Logger('shutdown', [$handler]); - $failing = new CallbackTerminable(static function (): void { - throw new Error('Expected test failure.', 42); + $failure = new Error('Expected test failure.', 42); + $failing = new CallbackTerminable(static function () use ($failure): void { + throw $failure; }); $runner = new ShutdownRunner([ @@ -104,8 +105,7 @@ public function test_it_logs_task_execution_and_failures_when_a_logger_is_availa $this->assertSame([ 'task' => CallbackTerminable::class, 'priority' => 10, - 'exception' => Error::class, - 'code' => 42, + 'exception' => $failure, ], $records[2]['context']); } diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php index 6855044..53690b3 100644 --- a/tests/wpunit/Shutdown/ShutdownProviderTest.php +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -9,8 +9,9 @@ use Psr\Log\LoggerInterface; use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner as ShutdownRunnerContract; +use StellarWP\Foundation\Shutdown\ResponseFinishingRunner; use StellarWP\Foundation\Shutdown\ShutdownProvider; -use StellarWP\Foundation\Shutdown\ShutdownRunner; use StellarWP\Foundation\Shutdown\ShutdownTask; use StellarWP\Foundation\Tests\Support\Fixtures\Shutdown\CallbackTerminable; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; @@ -28,10 +29,10 @@ protected function setUp(): void { } protected function tearDown(): void { - if ($this->container->has(ShutdownRunner::class)) { + if ($this->container->has(ShutdownRunnerContract::class)) { remove_action( 'shutdown', - $this->container->callback(ShutdownRunner::class, 'terminate'), + $this->container->callback(ShutdownRunnerContract::class, 'terminate'), PHP_INT_MAX ); } @@ -49,9 +50,10 @@ public function test_it_registers_a_singleton_runner_with_contributed_tasks(): v })), ]); - $runner = $this->container->get(ShutdownRunner::class); + $runner = $this->container->get(ShutdownRunnerContract::class); - $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + $this->assertInstanceOf(ResponseFinishingRunner::class, $runner); + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class)); $runner->terminate(); @@ -60,11 +62,11 @@ public function test_it_registers_a_singleton_runner_with_contributed_tasks(): v public function test_duplicate_provider_registration_does_not_replace_the_runner(): void { $this->container->register(ShutdownProvider::class); - $runner = $this->container->get(ShutdownRunner::class); + $runner = $this->container->get(ShutdownRunnerContract::class); $this->container->register(ShutdownProvider::class); - $this->assertSame($runner, $this->container->get(ShutdownRunner::class)); + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class)); } public function test_it_injects_a_registered_psr_logger(): void { @@ -73,7 +75,7 @@ public function test_it_injects_a_registered_psr_logger(): void { $this->container->singleton(LoggerInterface::class, new Logger('shutdown', [$handler])); $this->container->register(ShutdownProvider::class); - $this->container->get(ShutdownRunner::class)->terminate(); + $this->container->get(ShutdownRunnerContract::class)->terminate(); $this->assertTrue($handler->hasDebugThatMatches('/Running shutdown tasks\./')); } @@ -82,7 +84,7 @@ public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void { $calls = []; $this->container->register(ShutdownProvider::class); - $callback = $this->container->callback(ShutdownRunner::class, 'terminate'); + $callback = $this->container->callback(ShutdownRunnerContract::class, 'terminate'); $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { From 0bbe799e1f861b00c5b9fdb7378da5ef7c36eaa8 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Tue, 18 Aug 2026 15:36:42 -0600 Subject: [PATCH 24/81] Skip response finisher mocks when native functions exist --- tests/Unit/Shutdown/ResponseFinishingRunnerTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php b/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php index 6cbebcb..e205dc1 100644 --- a/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php +++ b/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php @@ -11,6 +11,14 @@ final class ResponseFinishingRunnerTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + + if (function_exists('fastcgi_finish_request') || function_exists('litespeed_finish_request')) { + $this->markTestSkipped('Native response-finishing functions cannot be replaced by test fixtures.'); + } + } + #[RunInSeparateProcess] public function test_it_finishes_the_response_and_runs_shutdown_tasks_once(): void { require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/litespeed-finish-request.php'; From 0b8e5609563e937851120a100e6f401c284df09b Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 19 Aug 2026 10:47:09 -0600 Subject: [PATCH 25/81] Add additional lock docs, add test_database_lock_replaces_expired_ownership_without_allowing_the_previous_owner_to_release_it --- src/Lock/README.md | 18 ++++++++++++ .../Database/DatabaseIntegrationTest.php | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/Lock/README.md b/src/Lock/README.md index efa98f0..b8a8808 100644 --- a/src/Lock/README.md +++ b/src/Lock/README.md @@ -32,3 +32,21 @@ try { ``` Persistent implementations, such as database-backed locks, should implement `StellarWP\Foundation\Lock\Contracts\Lock` and use `LockToken` ownership checks before releasing or refreshing locks. + +## Expiration And Refreshing + +> [!IMPORTANT] +> Locks are time-bounded leases. Mutual exclusion is guaranteed only until the token expires. Choose a TTL longer than the protected operation or refresh the lock before expiration. + +`refresh()` returns a new token with an expiration of the current time plus the supplied TTL. It returns `null` if the original token no longer owns the lock: + +```php +$token = $lock->refresh($token, 120); + +if ($token === null) { + // The lock expired or another process acquired it. + return; +} +``` + +Refreshing must happen before the current lease expires. For a single blocking operation that cannot be refreshed safely, use a conservative TTL. Locks coordinate application processes but do not replace idempotency when interacting with external systems such as payment gateways. diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 54a2654..03550d7 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\WPUnit\Database; use Adbar\Dot; +use DateTimeImmutable; use lucatume\DI52\Container as DI52Container; use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Container\ContainerAdapter; @@ -22,6 +23,7 @@ use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\Tests\Support\Fixtures\Lock\MutableClock; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; final class DatabaseIntegrationTest extends WPTestCase @@ -285,6 +287,32 @@ public function test_database_lock_coordinates_ownership_in_wordpress(): void { $this->assertFalse($wpSchema->hasTable($lockTable)); } + public function test_database_lock_replaces_expired_ownership_without_allowing_the_previous_owner_to_release_it(): void { + $table = $this->table('expired_locks'); + $wpSchema = new Schema($this->database); + $lockTable = new LockTable($this->database, $table); + $clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $lock = new DatabaseLock($this->database, $table, $clock); + + $wpSchema->createOrUpdate($lockTable); + + $first = $lock->acquire('foundation:database:takeover', 60); + + $this->assertNotNull($first); + + $clock->advance(60); + + $second = $lock->acquire('foundation:database:takeover', 60); + + $this->assertNotNull($second); + $this->assertNotSame($first->owner, $second->owner); + $this->assertFalse($lock->release($first)); + $this->assertTrue($lock->isAcquired('foundation:database:takeover')); + $this->assertTrue($lock->release($second)); + + $wpSchema->drop($lockTable); + } + public function test_provider_registers_wordpress_prefixed_database_services(): void { $container = $this->newContainer(); From 5e1bc40e7cc23ec3a1048a017c71d95605013c81 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 19 Aug 2026 13:51:08 -0600 Subject: [PATCH 26/81] Add foundation-lock-redis --- .env.testing.slic | 4 + .github/workflows/quality.yml | 1 + .github/workflows/tests.yml | 12 ++ AGENTS.md | 7 +- README.md | 5 +- composer.json | 11 +- src/Database/Lock/DatabaseLock.php | 134 +++++++++----- src/Lock/Contracts/Lock.php | 10 + .../Exceptions/LockUnavailableException.php | 12 ++ src/Lock/README.md | 32 ++++ src/LockRedis/.gitattributes | 7 + .../.github/workflows/close-pull-request.yml | 13 ++ src/LockRedis/.gitignore | 2 + .../Connections/PhpRedisConnection.php | 74 ++++++++ .../Connections/PredisConnection.php | 65 +++++++ src/LockRedis/Contracts/Connection.php | 28 +++ src/LockRedis/LockRedisProvider.php | 55 ++++++ src/LockRedis/README.md | 136 ++++++++++++++ src/LockRedis/RedisLock.php | 162 ++++++++++++++++ src/LockRedis/composer.json | 29 +++ tests/CodeceptionSupport/RedisTester.php | 28 +++ .../LockRedis/RecordingConnection.php | 41 ++++ tests/Unit/Database/Lock/DatabaseLockTest.php | 46 +++++ .../Unit/LockRedis/LockRedisProviderTest.php | 44 +++++ .../Unit/LockRedis/PhpRedisConnectionTest.php | 47 +++++ tests/Unit/LockRedis/PredisConnectionTest.php | 65 +++++++ tests/Unit/LockRedis/RedisLockTest.php | 175 ++++++++++++++++++ tests/redis.suite.dist.yml | 6 + .../LockRedis/RedisLockIntegrationTest.php | 162 ++++++++++++++++ 29 files changed, 1359 insertions(+), 54 deletions(-) create mode 100644 src/Lock/Exceptions/LockUnavailableException.php create mode 100644 src/LockRedis/.gitattributes create mode 100644 src/LockRedis/.github/workflows/close-pull-request.yml create mode 100644 src/LockRedis/.gitignore create mode 100644 src/LockRedis/Connections/PhpRedisConnection.php create mode 100644 src/LockRedis/Connections/PredisConnection.php create mode 100644 src/LockRedis/Contracts/Connection.php create mode 100644 src/LockRedis/LockRedisProvider.php create mode 100644 src/LockRedis/README.md create mode 100644 src/LockRedis/RedisLock.php create mode 100644 src/LockRedis/composer.json create mode 100644 tests/CodeceptionSupport/RedisTester.php create mode 100644 tests/Support/Fixtures/LockRedis/RecordingConnection.php create mode 100644 tests/Unit/LockRedis/LockRedisProviderTest.php create mode 100644 tests/Unit/LockRedis/PhpRedisConnectionTest.php create mode 100644 tests/Unit/LockRedis/PredisConnectionTest.php create mode 100644 tests/Unit/LockRedis/RedisLockTest.php create mode 100644 tests/redis.suite.dist.yml create mode 100644 tests/redis/LockRedis/RedisLockIntegrationTest.php diff --git a/.env.testing.slic b/.env.testing.slic index bb361c6..64cc5ce 100644 --- a/.env.testing.slic +++ b/.env.testing.slic @@ -5,6 +5,10 @@ TEST_LOG_CHANNEL=stack TEST_LOG_LEVEL=debug TEST_COMMAND_PREFIX=nxtest +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_TEST_DATABASE=15 + WP_VERSION=latest WP_ROOT_FOLDER=/var/www/html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 540e0ab..8459de8 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -19,6 +19,7 @@ jobs: with: php-version: '8.3' coverage: pcov + extensions: redis - name: Detect File Changes uses: dorny/paths-filter@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 139812b..9e8bacb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,7 @@ jobs: with: php-version: ${{ matrix.php }} coverage: none + extensions: redis - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -111,6 +112,11 @@ jobs: run: | "${SLIC_BIN}" run integration --ext DotReporter + - name: Run Redis tests + if: github.event_name != 'pull_request' + run: | + "${SLIC_BIN}" run redis --ext DotReporter + - name: Run wpunit tests if: github.event_name != 'pull_request' run: | @@ -129,11 +135,17 @@ jobs: "${SLIC_BIN}" pcov on --yes "${SLIC_BIN}" run unit --coverage coverage/unit.cov --ext DotReporter "${SLIC_BIN}" run feature --coverage coverage/feature.cov --ext DotReporter + "${SLIC_BIN}" run redis --coverage coverage/redis.cov --ext DotReporter "${SLIC_BIN}" run integration --coverage coverage/integration.cov --ext DotReporter "${SLIC_BIN}" run wpunit --coverage coverage/wpunit.cov --ext DotReporter "${SLIC_BIN}" run wpcli --coverage coverage/wpcli.cov --ext DotReporter "${SLIC_BIN}" composer run coverage:merge + - name: Test minimum supported Predis version + run: | + "${SLIC_BIN}" composer update predis/predis:3.0.0 --with-all-dependencies + "${SLIC_BIN}" run redis --ext DotReporter + - name: Monitor coverage if: github.event_name == 'pull_request' uses: slavcodev/coverage-monitor-action@v1 diff --git a/AGENTS.md b/AGENTS.md index e5c7fc6..722d7ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Initial packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` - `stellarwp/foundation-lock` +- `stellarwp/foundation-lock-redis` - `stellarwp/foundation-database` - `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` @@ -50,6 +51,8 @@ Avoid `use ... as ...` import aliases unless they resolve a real class-name coll Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. +Add `@throws` PHPDoc annotations to methods and constructors for exceptions they intentionally throw or propagate as part of their contract. Keep the annotation specific enough that callers can understand validation, infrastructure, and failure behavior without reading the implementation. + Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. When a make feature grows beyond a single command class or needs private collaborators, group that feature under its own namespace such as `src/Cli/Commands/Make/Database/`. Command-specific collaborators should live inside that feature namespace, not beside unrelated command classes in `Commands/Make/`. Shared generation infrastructure that is not itself a console command and is reused across command features should live under `src/Cli/Generation/`. @@ -166,9 +169,9 @@ Reusable test fixtures, sample classes, and test doubles should live under `test Tests that need writable temporary files or directories should use a test-specific subdirectory under `tests/_data/temp` instead of `sys_get_temp_dir()`. Use `$this->temp_dir('')` when only the path is needed; it mirrors `codecept_data_dir()` and does not create the directory. Use `$this->prepare_temp_dir('')` in `setUp()` to create a unique clean directory under that name and register it for automatic cleanup by the base test case. Only call `$this->remove_temp_dir('')` manually when a test needs to remove the prepared directories before teardown. -Codeception tests run through SLIC. Use SLIC 2.3.0 or newer so PCOV-backed coverage commands are available. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. +Codeception tests run through SLIC. Use SLIC 2.3.0 or newer so PCOV-backed coverage commands are available. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:redis` or `slic run redis`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. -Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, `wpdb`, `dbDelta()`, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. +Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `redis` is real Redis behavior shared across packages and run against SLIC's Redis service, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, `wpdb`, `dbDelta()`, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. Use `integration` for behavior where multiple providers/packages must be registered together to prove the container graph works. Use `wpunit` for a single package/class where the main concern is direct WordPress API behavior. Use `wpcli` for real WP-CLI command execution shared across packages. Keep unit tests focused on portable package behavior and pure collaborators; do not build large fake WordPress runtimes in unit tests when the behavior can be covered with wp-browser. diff --git a/README.md b/README.md index a701a74..06e4855 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) - [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) +- [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) - [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) - [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) @@ -55,6 +56,7 @@ Run the Codeception suites with SLIC: ```bash slic run unit slic run feature +composer test:redis composer test:integration composer test:wpunit composer test:wpcli @@ -70,6 +72,7 @@ slic use foundation slic composer install slic cc build composer test:integration +composer test:redis composer test:wpunit composer test:wpcli ``` @@ -82,7 +85,7 @@ slic composer update --with-all-dependencies Run `slic cc build` again after changing Codeception suite configuration or modules. Generated Codeception actor files are written to `tests/CodeceptionSupport/` and are intentionally ignored. -The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `integration` suite covers multi-provider/container behavior that needs WordPress runtime APIs. The `wpunit` suite runs lower-level WordPress-loaded tests through wp-browser. The `wpcli` suite is shared across the monorepo for WP-CLI command tests and uses wp-browser's WPCLI module without the full wpunit module stack. +The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `redis` suite runs client interoperability and lease behavior against SLIC's Redis service. The `integration` suite covers multi-provider/container behavior that needs WordPress runtime APIs. The `wpunit` suite runs lower-level WordPress-loaded tests through wp-browser. The `wpcli` suite is shared across the monorepo for WP-CLI command tests and uses wp-browser's WPCLI module without the full wpunit module stack. Generate the test coverage HTML dashboard: diff --git a/composer.json b/composer.json index 2f22745..3e1d28f 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "vlucas/phpdotenv": ">=4.3" }, "require-dev": { + "ext-redis": "*", "lucatume/wp-browser": "^4.5", "monorepo-php/monorepo": "^12.7", "nunomaduro/collision": "^8.9", @@ -31,6 +32,7 @@ "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^11.5", + "predis/predis": ">=3.0 <4.0", "wp-cli/wp-cli": ">=2.12", "zenphp/pinte": "^1.2" }, @@ -40,6 +42,7 @@ "stellarwp/foundation-database": "self.version", "stellarwp/foundation-identifier": "self.version", "stellarwp/foundation-lock": "self.version", + "stellarwp/foundation-lock-redis": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-wpcli": "self.version" @@ -52,6 +55,7 @@ "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Database\\": "src/Database/", "StellarWP\\Foundation\\Identifier\\": "src/Identifier/", + "StellarWP\\Foundation\\LockRedis\\": "src/LockRedis/", "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", @@ -66,6 +70,7 @@ "psr-4": { "StellarWP\\Foundation\\Tests\\Feature\\": "tests/Feature/", "StellarWP\\Foundation\\Tests\\Integration\\": "tests/integration/", + "StellarWP\\Foundation\\Tests\\Redis\\": "tests/redis/", "StellarWP\\Foundation\\Tests\\Support\\": "tests/Support/", "StellarWP\\Foundation\\Tests\\Unit\\": "tests/Unit/", "StellarWP\\Foundation\\Tests\\WPUnitSupport\\": "tests/WPUnitSupport/", @@ -96,6 +101,7 @@ "test:slic:unit": "slic run unit", "test:slic:feature": "slic run feature", "test:integration": "slic run integration", + "test:redis": "slic run redis", "test:wpunit": "slic run wpunit", "test:wpcli": "slic run wpcli", "test:coverage": "@test:coverage:split", @@ -111,13 +117,13 @@ "test:coverage:split": [ "@coverage:phpcov-install", "@coverage:prepare", - "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run redis --coverage coverage/redis.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", "slic composer run coverage:merge" ], "test:coverage-html:split": [ "@coverage:phpcov-install", "@coverage:prepare", - "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run redis --coverage coverage/redis.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", "slic composer run coverage:merge-html" ], "analyze": "@php vendor/bin/phpstan analyse --ansi --memory-limit 2G", @@ -134,6 +140,7 @@ "test:slic:unit": "Run the Codeception unit suite through SLIC.", "test:slic:feature": "Run the Codeception feature suite through SLIC.", "test:integration": "Run the WordPress-loaded integration suite through SLIC.", + "test:redis": "Run real Redis integration tests through SLIC.", "test:wpunit": "Run the WordPress-loaded wpunit suite through SLIC.", "test:wpcli": "Run the shared WP-CLI command suite through SLIC.", "test:coverage": "Generate merged Clover coverage from split SLIC suite artifacts.", diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php index 9216170..44a7b9d 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/Database/Lock/DatabaseLock.php @@ -9,8 +9,10 @@ use InvalidArgumentException; use Random\RandomException; use StellarWP\Foundation\Database\Contracts\Database; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Lock\Contracts\Clock; use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\LockToken; use StellarWP\Foundation\Lock\SystemClock; @@ -27,9 +29,11 @@ public function __construct( } /** - * @throws DateMalformedIntervalStringException - * @throws RandomException - * @throws DateMalformedStringException + * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. + * @throws RandomException When a secure owner token cannot be generated. + * @throws DateMalformedStringException When the stored expiration cannot be parsed. + * @throws InvalidArgumentException When the lock name is empty or the TTL is invalid. + * @throws LockUnavailableException When the database cannot determine the acquisition result. */ public function acquire(string $name, int $ttl): ?LockToken { $this->assertValidName($name); @@ -39,29 +43,33 @@ public function acquire(string $name, int $ttl): ?LockToken { $now = $this->format($this->clock->now()); $expiresAt = $this->expiresAt($ttl); - $this->database->execute( - 'INSERT INTO %i (name, owner, expires_at, created_at, updated_at) - VALUES (%s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - owner = IF(expires_at <= %s, VALUES(owner), owner), - updated_at = IF(expires_at <= %s, VALUES(updated_at), updated_at), - expires_at = IF(expires_at <= %s, VALUES(expires_at), expires_at)', - $this->database->tableName($this->table), - $name, - $owner, - $this->format($expiresAt), - $now, - $now, - $now, - $now, - $now - ); - - $row = $this->database->row( - 'SELECT owner, expires_at FROM %i WHERE name = %s LIMIT 1', - $this->database->tableName($this->table), - $name - ); + try { + $this->database->execute( + 'INSERT INTO %i (name, owner, expires_at, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + owner = IF(expires_at <= %s, VALUES(owner), owner), + updated_at = IF(expires_at <= %s, VALUES(updated_at), updated_at), + expires_at = IF(expires_at <= %s, VALUES(expires_at), expires_at)', + $this->database->tableName($this->table), + $name, + $owner, + $this->format($expiresAt), + $now, + $now, + $now, + $now, + $now + ); + + $row = $this->database->row( + 'SELECT owner, expires_at FROM %i WHERE name = %s LIMIT 1', + $this->database->tableName($this->table), + $name + ); + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine the lock acquisition result.', 0, $exception); + } if ($row === null || ($row['owner'] ?? '') !== $owner) { return null; @@ -74,32 +82,46 @@ public function acquire(string $name, int $ttl): ?LockToken { ); } + /** + * @throws LockUnavailableException When the database cannot determine the release result. + */ public function release(LockToken $token): bool { - return $this->database->execute( - 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > %s', - $this->database->tableName($this->table), - $token->name, - $token->owner, - $this->format($this->clock->now()) - ) > 0; + try { + return $this->database->execute( + 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > %s', + $this->database->tableName($this->table), + $token->name, + $token->owner, + $this->format($this->clock->now()) + ) > 0; + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine the lock release result.', 0, $exception); + } } /** - * @throws DateMalformedIntervalStringException + * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. + * @throws InvalidArgumentException When the TTL is invalid. + * @throws LockUnavailableException When the database cannot determine the refresh result. */ public function refresh(LockToken $token, int $ttl): ?LockToken { $this->assertValidTtl($ttl); $expiresAt = $this->expiresAt($ttl); - $updated = $this->database->execute( - 'UPDATE %i SET expires_at = %s, updated_at = %s WHERE name = %s AND owner = %s AND expires_at > %s', - $this->database->tableName($this->table), - $this->format($expiresAt), - $this->format($this->clock->now()), - $token->name, - $token->owner, - $this->format($this->clock->now()) - ); + + try { + $updated = $this->database->execute( + 'UPDATE %i SET expires_at = %s, updated_at = %s WHERE name = %s AND owner = %s AND expires_at > %s', + $this->database->tableName($this->table), + $this->format($expiresAt), + $this->format($this->clock->now()), + $token->name, + $token->owner, + $this->format($this->clock->now()) + ); + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine the lock refresh result.', 0, $exception); + } if ($updated < 1) { return null; @@ -108,15 +130,23 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { return $token->refresh($expiresAt); } + /** + * @throws InvalidArgumentException When the lock name is empty. + * @throws LockUnavailableException When the database cannot determine whether the lock exists. + */ public function isAcquired(string $name): bool { $this->assertValidName($name); - return $this->database->row( - 'SELECT name FROM %i WHERE name = %s AND expires_at > %s LIMIT 1', - $this->database->tableName($this->table), - $name, - $this->format($this->clock->now()) - ) !== null; + try { + return $this->database->row( + 'SELECT name FROM %i WHERE name = %s AND expires_at > %s LIMIT 1', + $this->database->tableName($this->table), + $name, + $this->format($this->clock->now()) + ) !== null; + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine whether the lock exists.', 0, $exception); + } } /** @@ -128,12 +158,18 @@ private function expiresAt(int $ttl): DateTimeImmutable { return $this->clock->now()->add(new DateInterval(sprintf('PT%dS', $ttl))); } + /** + * @throws InvalidArgumentException When the TTL is less than one second. + */ private function assertValidTtl(int $ttl): void { if ($ttl < 1) { throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); } } + /** + * @throws InvalidArgumentException When the lock name is empty. + */ private function assertValidName(string $name): void { if (trim($name) === '') { throw new InvalidArgumentException('Lock name cannot be empty.'); diff --git a/src/Lock/Contracts/Lock.php b/src/Lock/Contracts/Lock.php index 30073dc..5054325 100644 --- a/src/Lock/Contracts/Lock.php +++ b/src/Lock/Contracts/Lock.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Lock\Contracts; use InvalidArgumentException; +use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\LockToken; /** @@ -19,6 +20,8 @@ interface Lock * * @throws InvalidArgumentException When the lock name is empty or the TTL * is less than one second. + * @throws LockUnavailableException When the backend cannot determine the + * acquisition result. */ public function acquire(string $name, int $ttl): ?LockToken; @@ -27,6 +30,9 @@ public function acquire(string $name, int $ttl): ?LockToken; * * Implementations that coordinate multiple processes should compare and * release atomically by lock name, owner, and non-expired state. + * + * @throws LockUnavailableException When the backend cannot determine the + * release result. */ public function release(LockToken $token): bool; @@ -39,6 +45,8 @@ public function release(LockToken $token): bool; * owner, and non-expired state. * * @throws InvalidArgumentException When the TTL is less than one second. + * @throws LockUnavailableException When the backend cannot determine the + * refresh result. */ public function refresh(LockToken $token, int $ttl): ?LockToken; @@ -49,6 +57,8 @@ public function refresh(LockToken $token, int $ttl): ?LockToken; * coordination primitive. * * @throws InvalidArgumentException When the lock name is empty. + * @throws LockUnavailableException When the backend cannot determine + * whether the lock exists. */ public function isAcquired(string $name): bool; } diff --git a/src/Lock/Exceptions/LockUnavailableException.php b/src/Lock/Exceptions/LockUnavailableException.php new file mode 100644 index 0000000..a4dcc28 --- /dev/null +++ b/src/Lock/Exceptions/LockUnavailableException.php @@ -0,0 +1,12 @@ + [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). +## Choosing An Implementation + +`stellarwp/foundation-lock` provides the shared lock contract and +`InMemoryLock`. Both persistent implementation packages depend on this base +package, so installing either one also makes `InMemoryLock` available for +tests and process-local work. + +Choose one persistent implementation for production based on where application +requests must coordinate: + +| Implementation | Provided by | Use when | +| --- | --- | --- | +| `DatabaseLock` | [`stellarwp/foundation-database`](https://github.com/stellarwp/foundation-database) | WordPress requests should coordinate through the existing database | +| `RedisLock` | [`stellarwp/foundation-lock-redis`](https://github.com/stellarwp/foundation-lock-redis) | Processes or servers can coordinate through a dedicated Redis connection | + +Use the included `InMemoryLock` in tests or when all coordination is confined +to one PHP process. It does not coordinate separate requests, workers, or +servers. + ## Installation +Install this package directly when only the contract and `InMemoryLock` are +needed: + ```shell composer require stellarwp/foundation-lock ``` +For persistent locking, install the selected implementation package from the +table above instead; Composer installs `stellarwp/foundation-lock` with it. + ## Usage `foundation-lock` defines portable lock contracts and a process-local in-memory implementation. The in-memory lock is useful for tests and single-process work, but it is not a cross-request or distributed lock. @@ -50,3 +75,10 @@ if ($token === null) { ``` Refreshing must happen before the current lease expires. For a single blocking operation that cannot be refreshed safely, use a conservative TTL. Locks coordinate application processes but do not replace idempotency when interacting with external systems such as payment gateways. + +## Backend Failures + +Persistent implementations throw `StellarWP\Foundation\Lock\Exceptions\LockUnavailableException` +when their backend cannot provide a trustworthy result. Treat that exception as +a failure to obtain or retain the lock; do not continue the protected work +without coordination. diff --git a/src/LockRedis/.gitattributes b/src/LockRedis/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/LockRedis/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/LockRedis/.github/workflows/close-pull-request.yml b/src/LockRedis/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/LockRedis/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/LockRedis/.gitignore b/src/LockRedis/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/LockRedis/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/LockRedis/Connections/PhpRedisConnection.php b/src/LockRedis/Connections/PhpRedisConnection.php new file mode 100644 index 0000000..d95ab8d --- /dev/null +++ b/src/LockRedis/Connections/PhpRedisConnection.php @@ -0,0 +1,74 @@ +command( + static fn (Redis $redis): mixed => $redis->eval($script, [...$keys, ...$arguments], count($keys)) + ); + + if (! is_int($result)) { + throw new LockUnavailableException('PhpRedis returned an unexpected EVAL response.'); + } + + return $result; + } + + /** + * @throws LockUnavailableException When PhpRedis cannot determine whether the key exists. + */ + public function exists(string $key): bool { + $result = $this->command( + static fn (Redis $redis): Redis|int|bool => $redis->exists($key) + ); + + if (! is_int($result)) { + throw new LockUnavailableException('PhpRedis returned an unexpected EXISTS response.'); + } + + return $result > 0; + } + + /** + * @template T + * + * @param callable(Redis): T $command + * + * @throws LockUnavailableException When PhpRedis reports an exception or command error. + * + * @return T + */ + private function command(callable $command): mixed { + try { + $this->redis->clearLastError(); + $result = $command($this->redis); + $error = $this->redis->getLastError(); + } catch (RedisException $exception) { + throw new LockUnavailableException('PhpRedis could not execute the lock operation.', 0, $exception); + } + + if ($error !== null) { + throw new LockUnavailableException(sprintf('PhpRedis could not execute the lock operation: %s', $error)); + } + + return $result; + } +} diff --git a/src/LockRedis/Connections/PredisConnection.php b/src/LockRedis/Connections/PredisConnection.php new file mode 100644 index 0000000..fb273d0 --- /dev/null +++ b/src/LockRedis/Connections/PredisConnection.php @@ -0,0 +1,65 @@ +command('EVAL', [$script, count($keys), ...$keys, ...$arguments]); + + if (! is_int($result)) { + throw new LockUnavailableException('Predis returned an unexpected EVAL response.'); + } + + return $result; + } + + /** + * @throws LockUnavailableException When Predis cannot determine whether the key exists. + */ + public function exists(string $key): bool { + $result = $this->command('EXISTS', [$key]); + + if (! is_int($result)) { + throw new LockUnavailableException('Predis returned an unexpected EXISTS response.'); + } + + return $result > 0; + } + + /** + * @param list $arguments + * + * @throws LockUnavailableException When Predis reports an exception or command error. + */ + private function command(string $name, array $arguments): mixed { + try { + $result = $this->redis->executeCommand($this->redis->createCommand($name, $arguments)); + } catch (PredisException $exception) { + throw new LockUnavailableException('Predis could not execute the lock operation.', 0, $exception); + } + + if ($result instanceof ErrorInterface) { + throw new LockUnavailableException(sprintf('Predis could not execute the lock operation: %s', $result->getMessage())); + } + + return $result; + } +} diff --git a/src/LockRedis/Contracts/Connection.php b/src/LockRedis/Contracts/Connection.php new file mode 100644 index 0000000..4df9ebf --- /dev/null +++ b/src/LockRedis/Contracts/Connection.php @@ -0,0 +1,28 @@ + $keys + * @param list $arguments + * + * @throws LockUnavailableException When Redis cannot determine the result. + */ + public function evaluate(string $script, array $keys, array $arguments): int; + + /** + * Determine whether the Redis key exists. + * + * @throws LockUnavailableException When Redis cannot determine the result. + */ + public function exists(string $key): bool; +} diff --git a/src/LockRedis/LockRedisProvider.php b/src/LockRedis/LockRedisProvider.php new file mode 100644 index 0000000..eadf0c7 --- /dev/null +++ b/src/LockRedis/LockRedisProvider.php @@ -0,0 +1,55 @@ +registerConfiguration(); + $this->registerClock(); + $this->registerLock(); + } + + /** + * @throws InvalidArgumentException When the required Redis lock prefix is not configured. + */ + private function registerConfiguration(): void { + $prefix = $this->config->get('lock.redis.prefix'); + + if (! is_string($prefix) || trim($prefix) === '') { + throw new InvalidArgumentException('The lock.redis.prefix configuration value must be a non-empty string.'); + } + + $this->container->singleton(self::PREFIX, $prefix); + } + + private function registerClock(): void { + $this->container->singleton(SystemClock::class); + } + + private function registerLock(): void { + $this->container->when(RedisLock::class) + ->needs(Clock::class) + ->give(static fn (C $c): SystemClock => $c->get(SystemClock::class)); + + $this->container->when(RedisLock::class) + ->needs('$prefix') + ->give(static fn (C $c): string => $c->get(self::PREFIX)); + + $this->container->singleton(RedisLock::class); + } +} diff --git a/src/LockRedis/README.md b/src/LockRedis/README.md new file mode 100644 index 0000000..28985ba --- /dev/null +++ b/src/LockRedis/README.md @@ -0,0 +1,136 @@ +# Foundation Lock Redis + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +## Installation + +```shell +composer require stellarwp/foundation-lock-redis +``` + +Install one supported Redis client: + +```shell +composer require "predis/predis:>=3.0 <4.0" +``` + +Alternatively, install and enable the PhpRedis extension. + +## Usage + +`RedisLock` implements Foundation's shared lock contract with atomic Redis +acquisition, release, and refresh operations. Applications must provide a +dedicated Redis connection and an application-specific key prefix. + +Map the Redis connection and lock settings in the application's `config.php`: + +```php + [ + 'redis' => [ + 'host' => $_ENV['FOUNDATION_LOCK_REDIS_HOST'] ?? '127.0.0.1', + 'port' => (int) ($_ENV['FOUNDATION_LOCK_REDIS_PORT'] ?? 6379), + 'database' => (int) ($_ENV['FOUNDATION_LOCK_REDIS_DATABASE'] ?? 1), + 'prefix' => $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'] ?? 'acme:site-123:lock:', + ], + ], +]; +``` + +After [registering `config.php` with the Foundation container](https://github.com/stellarwp/foundation-container#making-a-configphp), +providers receive its configured `Dot` instance through `$this->config`. Use +those values when binding the application's Redis client: + +```php +use lucatume\DI52\Container as C; +use Predis\Client; +use Predis\ClientInterface; +use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\LockRedis\Connections\PredisConnection; +use StellarWP\Foundation\LockRedis\Contracts\Connection; +use StellarWP\Foundation\LockRedis\LockRedisProvider; +use StellarWP\Foundation\LockRedis\RedisLock; + +final class RedisProvider extends Provider +{ + public function register(): void { + $this->container->when(PredisConnection::class) + ->needs(ClientInterface::class) + ->give(fn (): ClientInterface => new Client([ + 'host' => (string) $this->config->get('lock.redis.host'), + 'port' => (int) $this->config->get('lock.redis.port'), + 'database' => (int) $this->config->get('lock.redis.database'), + ])); + + $this->container->singleton(PredisConnection::class); + $this->container->bind(Connection::class, static fn (C $c): PredisConnection => $c->get(PredisConnection::class)); + $this->container->register(LockRedisProvider::class); + + $this->container->bind(Lock::class, static fn (C $c): RedisLock => $c->get(RedisLock::class)); + } +} +``` + +For PhpRedis, bind a separately configured `Redis` instance and select the +PhpRedis adapter instead: + +```php +use lucatume\DI52\Container as C; +use Redis; +use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\LockRedis\Connections\PhpRedisConnection; +use StellarWP\Foundation\LockRedis\Contracts\Connection; +use StellarWP\Foundation\LockRedis\LockRedisProvider; +use StellarWP\Foundation\LockRedis\RedisLock; + +final class RedisProvider extends Provider +{ + public function register(): void { + $this->container->when(PhpRedisConnection::class) + ->needs(Redis::class) + ->give(function (): Redis { + $redis = new Redis(); + $redis->connect( + (string) $this->config->get('lock.redis.host'), + (int) $this->config->get('lock.redis.port') + ); + $redis->select((int) $this->config->get('lock.redis.database')); + + return $redis; + }); + + $this->container->singleton(PhpRedisConnection::class); + $this->container->bind(Connection::class, static fn (C $c): PhpRedisConnection => $c->get(PhpRedisConnection::class)); + $this->container->register(LockRedisProvider::class); + + $this->container->bind(Lock::class, static fn (C $c): RedisLock => $c->get(RedisLock::class)); + } +} +``` + +The package never selects a Redis database or reuses WordPress object-cache +globals. Supply a separate client connection. A separate logical database +protects locks from `FLUSHDB` issued against the object-cache database, but it +does not protect against `FLUSHALL`, eviction, restart, or failover. Use a +separate Redis endpoint when stronger isolation is required. Redis Cluster +supports only database `0`, so endpoint isolation is required there. The +package supports a single writable Redis endpoint; Redis Cluster and Sentinel +are not currently supported or tested. + +Lock contention is not an infrastructure failure: `acquire()` returns `null` +when another owner holds the lock. `release()` returns `false`, and `refresh()` +returns `null`, when the token no longer owns the lease. Uncertain Redis +results throw +`StellarWP\Foundation\Lock\Exceptions\LockUnavailableException`; callers +should fail closed instead of continuing the protected work without a lock. + +Redis locks are expiring leases, not exactly-once guarantees. The TTL must +cover the protected work or be refreshed before it expires. External side +effects such as payment requests should also use provider-supported +idempotency keys. Asynchronous Redis failover, eviction, restart, or +administrative key removal can permit overlapping owners. diff --git a/src/LockRedis/RedisLock.php b/src/LockRedis/RedisLock.php new file mode 100644 index 0000000..78cdf55 --- /dev/null +++ b/src/LockRedis/RedisLock.php @@ -0,0 +1,162 @@ +prefix) === '') { + throw new InvalidArgumentException('Redis lock prefix cannot be empty.'); + } + } + + /** + * @throws InvalidArgumentException When the lock name is empty or the TTL is invalid. + * @throws LockUnavailableException When Redis cannot determine the acquisition result. + * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. + * @throws \Random\RandomException When a secure owner token cannot be generated. + */ + public function acquire(string $name, int $ttl): ?LockToken { + $this->assertValidName($name); + $this->assertValidTtl($ttl); + + $startedAt = $this->clock->now(); + $expiresAt = $this->expiresAt($startedAt, $ttl); + $owner = bin2hex(random_bytes(16)); + $result = $this->connection->evaluate( + self::ACQUIRE_SCRIPT, + [$this->key($name)], + [$owner, $ttl] + ); + + return match ($result) { + 0 => null, + 1 => new LockToken( + name: $name, + owner: $owner, + expiresAt: $expiresAt + ), + default => throw new LockUnavailableException('Redis returned an unexpected acquisition result.'), + }; + } + + /** + * @throws LockUnavailableException When Redis cannot determine the release result. + */ + public function release(LockToken $token): bool { + return match ($this->connection->evaluate( + self::RELEASE_SCRIPT, + [$this->key($token->name)], + [$token->owner] + )) { + 0 => false, + 1 => true, + default => throw new LockUnavailableException('Redis returned an unexpected release result.'), + }; + } + + /** + * @throws InvalidArgumentException When the TTL is invalid. + * @throws LockUnavailableException When Redis cannot determine the refresh result. + * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidTtl($ttl); + + $startedAt = $this->clock->now(); + $expiresAt = $this->expiresAt($startedAt, $ttl); + $result = $this->connection->evaluate( + self::REFRESH_SCRIPT, + [$this->key($token->name)], + [$token->owner, $ttl] + ); + + return match ($result) { + 0 => null, + 1 => $token->refresh($expiresAt), + default => throw new LockUnavailableException('Redis returned an unexpected refresh result.'), + }; + } + + /** + * @throws InvalidArgumentException When the lock name is empty. + * @throws LockUnavailableException When Redis cannot determine whether the lock exists. + */ + public function isAcquired(string $name): bool { + $this->assertValidName($name); + + return $this->connection->exists($this->key($name)); + } + + /** + * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. + */ + private function expiresAt(DateTimeImmutable $startedAt, int $ttl): DateTimeImmutable { + return $startedAt->add(new DateInterval(sprintf('PT%dS', $ttl))); + } + + private function key(string $name): string { + return $this->prefix . $name; + } + + /** + * @throws InvalidArgumentException When the lock name is empty. + */ + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + } + + /** + * @throws InvalidArgumentException When the TTL is less than one second. + */ + private function assertValidTtl(int $ttl): void { + if ($ttl < 1) { + throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); + } + } +} diff --git a/src/LockRedis/composer.json b/src/LockRedis/composer.json new file mode 100644 index 0000000..3426364 --- /dev/null +++ b/src/LockRedis/composer.json @@ -0,0 +1,29 @@ +{ + "name": "stellarwp/foundation-lock-redis", + "type": "library", + "description": "Redis-backed locks for Foundation.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0", + "stellarwp/foundation-lock": "^2.0" + }, + "suggest": { + "ext-redis": "Required to use PhpRedisConnection.", + "predis/predis": "Required to use PredisConnection (>=3.0 <4.0)." + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\LockRedis\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/tests/CodeceptionSupport/RedisTester.php b/tests/CodeceptionSupport/RedisTester.php new file mode 100644 index 0000000..c8c378c --- /dev/null +++ b/tests/CodeceptionSupport/RedisTester.php @@ -0,0 +1,28 @@ +, arguments: list}> + */ + public array $evaluateCalls = []; + + /** + * @var list + */ + public array $existsCalls = []; + + public function evaluate(string $script, array $keys, array $arguments): int { + $this->evaluateCalls[] = [ + 'script' => $script, + 'keys' => $keys, + 'arguments' => $arguments, + ]; + + return $this->evaluateResult; + } + + public function exists(string $key): bool { + $this->existsCalls[] = $key; + + return $this->existsResult; + } +} diff --git a/tests/Unit/Database/Lock/DatabaseLockTest.php b/tests/Unit/Database/Lock/DatabaseLockTest.php index 7be661e..c8a14b6 100644 --- a/tests/Unit/Database/Lock/DatabaseLockTest.php +++ b/tests/Unit/Database/Lock/DatabaseLockTest.php @@ -4,7 +4,11 @@ use DateTimeImmutable; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use StellarWP\Foundation\Database\Contracts\Database; +use StellarWP\Foundation\Database\Exceptions\QueryException; use StellarWP\Foundation\Database\Lock\DatabaseLock; +use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\LockToken; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Lock\MutableClock; @@ -94,6 +98,48 @@ public function test_it_rejects_an_invalid_ttl(): void { $this->lock->acquire('queue:sync', 0); } + public function test_it_rejects_an_empty_name(): void { + $this->expectException(InvalidArgumentException::class); + + $this->lock->isAcquired(''); + } + + /** + * @dataProvider unavailableOperationProvider + */ + #[DataProvider('unavailableOperationProvider')] + public function test_it_normalizes_database_failures(callable $operation, string $databaseMethod): void { + $database = $this->mock(Database::class); + + $database->shouldReceive('tableName')->andReturn('wp_nexcess_foundation_locks'); + $database->shouldReceive($databaseMethod)->andThrow(new QueryException('Query failed.', 'SELECT 1')); + + try { + $operation(new DatabaseLock($database, 'wp_nexcess_foundation_locks', $this->clock)); + $this->fail('Expected the database failure to be normalized.'); + } catch (LockUnavailableException $exception) { + $this->assertInstanceOf(QueryException::class, $exception->getPrevious()); + } + } + + /** + * @return array + */ + public static function unavailableOperationProvider(): array { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + return [ + 'acquire' => [static fn (DatabaseLock $lock): ?LockToken => $lock->acquire('queue:sync', 60), 'execute'], + 'release' => [static fn (DatabaseLock $lock): bool => $lock->release($token), 'execute'], + 'refresh' => [static fn (DatabaseLock $lock): ?LockToken => $lock->refresh($token, 60), 'execute'], + 'is acquired' => [static fn (DatabaseLock $lock): bool => $lock->isAcquired('queue:sync'), 'row'], + ]; + } + private function extractOwnerFromInsert(string $sql): string { preg_match("/VALUES \\('queue:sync', '([a-f0-9]{32})', /", $sql, $matches); diff --git a/tests/Unit/LockRedis/LockRedisProviderTest.php b/tests/Unit/LockRedis/LockRedisProviderTest.php new file mode 100644 index 0000000..2c8b323 --- /dev/null +++ b/tests/Unit/LockRedis/LockRedisProviderTest.php @@ -0,0 +1,44 @@ +container->get(Dot::class)->set('lock.redis.prefix', 'provider:lock:'); + $this->container->bind(Connection::class, $connection); + $this->container->register(LockRedisProvider::class); + + $lock = $this->container->get(RedisLock::class); + $token = $lock->acquire('queue:sync', 60); + + $this->assertSame($lock, $this->container->get(RedisLock::class)); + $this->assertNotNull($token); + $this->assertSame(['provider:lock:queue:sync'], $connection->evaluateCalls[0]['keys']); + } + + public function test_it_does_not_bind_the_generic_lock_contract(): void { + $this->container->get(Dot::class)->set('lock.redis.prefix', 'provider:lock:'); + $this->container->register(LockRedisProvider::class); + + $this->assertFalse($this->container->has(Lock::class)); + } + + public function test_it_requires_an_explicit_prefix(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The lock.redis.prefix configuration value must be a non-empty string.'); + + $this->container->register(LockRedisProvider::class); + } +} diff --git a/tests/Unit/LockRedis/PhpRedisConnectionTest.php b/tests/Unit/LockRedis/PhpRedisConnectionTest.php new file mode 100644 index 0000000..3bece52 --- /dev/null +++ b/tests/Unit/LockRedis/PhpRedisConnectionTest.php @@ -0,0 +1,47 @@ +mock(Redis::class); + $redis->shouldReceive('clearLastError')->once()->andReturnTrue(); + $redis->shouldReceive('exists')->once()->andReturnFalse(); + $redis->shouldReceive('getLastError')->once()->andReturnNull(); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('PhpRedis returned an unexpected EXISTS response.'); + + (new PhpRedisConnection($redis))->exists('lock'); + } + + public function test_it_wraps_phpredis_exceptions(): void { + $redis = $this->mock(Redis::class); + $redis->shouldReceive('clearLastError')->once()->andReturnTrue(); + $redis->shouldReceive('exists')->once()->andThrow(new RedisException('connection lost')); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('PhpRedis could not execute the lock operation.'); + + (new PhpRedisConnection($redis))->exists('lock'); + } + + public function test_it_rejects_phpredis_command_errors(): void { + $redis = $this->mock(Redis::class); + $redis->shouldReceive('clearLastError')->once()->andReturnTrue(); + $redis->shouldReceive('exists')->once()->andReturnFalse(); + $redis->shouldReceive('getLastError')->once()->andReturn('ERR connection lost'); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('PhpRedis could not execute the lock operation: ERR connection lost'); + + (new PhpRedisConnection($redis))->exists('lock'); + } +} diff --git a/tests/Unit/LockRedis/PredisConnectionTest.php b/tests/Unit/LockRedis/PredisConnectionTest.php new file mode 100644 index 0000000..08d2113 --- /dev/null +++ b/tests/Unit/LockRedis/PredisConnectionTest.php @@ -0,0 +1,65 @@ +clientReturning(null)); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis returned an unexpected EVAL response.'); + + $connection->evaluate('return 1', [], []); + } + + public function test_it_rejects_non_integer_exists_responses(): void { + $connection = new PredisConnection($this->clientReturning(null)); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis returned an unexpected EXISTS response.'); + + $connection->exists('lock'); + } + + public function test_it_wraps_predis_exceptions(): void { + $client = $this->mock(ClientInterface::class); + $command = $this->mock(CommandInterface::class); + + $client->shouldReceive('createCommand')->once()->andReturn($command); + $client->shouldReceive('executeCommand')->once()->with($command)->andThrow(new ClientException('connection lost')); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis could not execute the lock operation.'); + + (new PredisConnection($client))->exists('lock'); + } + + public function test_it_rejects_predis_error_responses(): void { + $error = $this->mock(ErrorInterface::class); + $error->shouldReceive('getMessage')->once()->andReturn('ERR connection lost'); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis could not execute the lock operation: ERR connection lost'); + + (new PredisConnection($this->clientReturning($error)))->exists('lock'); + } + + private function clientReturning(mixed $result): ClientInterface { + $client = $this->mock(ClientInterface::class); + $command = $this->mock(CommandInterface::class); + + $client->shouldReceive('createCommand')->once()->andReturn($command); + $client->shouldReceive('executeCommand')->once()->with($command)->andReturn($result); + + return $client; + } +} diff --git a/tests/Unit/LockRedis/RedisLockTest.php b/tests/Unit/LockRedis/RedisLockTest.php new file mode 100644 index 0000000..456103b --- /dev/null +++ b/tests/Unit/LockRedis/RedisLockTest.php @@ -0,0 +1,175 @@ +clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->connection = new RecordingConnection(); + $this->lock = new RedisLock($this->connection, $this->clock, 'tests:lock:'); + } + + public function test_it_acquires_a_prefixed_lock_with_a_conservative_expiration(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $token->owner); + $this->assertSame('2026-01-01 00:01:00', $token->expiresAt->format('Y-m-d H:i:s')); + $this->assertSame([[ + 'script' => $this->connection->evaluateCalls[0]['script'], + 'keys' => ['tests:lock:queue:sync'], + 'arguments' => [$token->owner, 60], + ]], $this->connection->evaluateCalls); + $this->assertStringContainsString("redis.call('SET'", $this->connection->evaluateCalls[0]['script']); + } + + public function test_it_returns_null_when_the_lock_is_contended(): void { + $this->connection->evaluateResult = 0; + + $this->assertNull($this->lock->acquire('queue:sync', 60)); + } + + public function test_it_rejects_an_unexpected_acquisition_result(): void { + $this->connection->evaluateResult = 2; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Redis returned an unexpected acquisition result.'); + + $this->lock->acquire('queue:sync', 60); + } + + public function test_it_releases_only_the_matching_owner(): void { + $token = $this->token(); + + $this->assertTrue($this->lock->release($token)); + $this->assertSame(['tests:lock:queue:sync'], $this->connection->evaluateCalls[0]['keys']); + $this->assertSame(['owner'], $this->connection->evaluateCalls[0]['arguments']); + + $this->connection->evaluateResult = 0; + + $this->assertFalse($this->lock->release($token)); + } + + public function test_it_refreshes_only_the_matching_owner(): void { + $token = $this->token(); + + $this->clock->advance(30); + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame('2026-01-01 00:02:30', $refreshed->expiresAt->format('Y-m-d H:i:s')); + $this->assertSame(['tests:lock:queue:sync'], $this->connection->evaluateCalls[0]['keys']); + $this->assertSame(['owner', 120], $this->connection->evaluateCalls[0]['arguments']); + + $this->connection->evaluateResult = 0; + + $this->assertNull($this->lock->refresh($token, 120)); + } + + public function test_it_reports_prefixed_lock_existence(): void { + $this->connection->existsResult = true; + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + $this->assertSame(['tests:lock:queue:sync'], $this->connection->existsCalls); + } + + public function test_it_rejects_an_empty_prefix(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Redis lock prefix cannot be empty.'); + + new RedisLock($this->connection, $this->clock, ''); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->lock->acquire('', 60); + } + + public function test_it_rejects_an_invalid_acquisition_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_refresh_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->refresh($this->token(), 0); + } + + public function test_it_does_not_acquire_when_the_ttl_cannot_be_represented(): void { + try { + $this->lock->acquire('queue:sync', 1_000_000_000_000); + $this->fail('Expected an invalid interval exception.'); + } catch (DateMalformedIntervalStringException) { + $this->assertSame([], $this->connection->evaluateCalls); + } + } + + public function test_it_does_not_refresh_when_the_ttl_cannot_be_represented(): void { + try { + $this->lock->refresh($this->token(), 1_000_000_000_000); + $this->fail('Expected an invalid interval exception.'); + } catch (DateMalformedIntervalStringException) { + $this->assertSame([], $this->connection->evaluateCalls); + } + } + + public function test_it_rejects_an_empty_name_when_checking_existence(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->lock->isAcquired(''); + } + + public function test_it_rejects_an_unexpected_release_result(): void { + $this->connection->evaluateResult = 2; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Redis returned an unexpected release result.'); + + $this->lock->release($this->token()); + } + + public function test_it_rejects_an_unexpected_refresh_result(): void { + $this->connection->evaluateResult = 2; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Redis returned an unexpected refresh result.'); + + $this->lock->refresh($this->token(), 60); + } + + private function token(): LockToken { + return new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } +} diff --git a/tests/redis.suite.dist.yml b/tests/redis.suite.dist.yml new file mode 100644 index 0000000..fe00b31 --- /dev/null +++ b/tests/redis.suite.dist.yml @@ -0,0 +1,6 @@ +# Codeception Test Suite Configuration + +actor: RedisTester +path: redis +modules: + enabled: [] diff --git a/tests/redis/LockRedis/RedisLockIntegrationTest.php b/tests/redis/LockRedis/RedisLockIntegrationTest.php new file mode 100644 index 0000000..16b55da --- /dev/null +++ b/tests/redis/LockRedis/RedisLockIntegrationTest.php @@ -0,0 +1,162 @@ +prefix = sprintf('tests:lock:%s:', str_replace('.', '', uniqid('', true))); + + $this->phpRedis = new Redis(); + $this->phpRedis->connect($host, $port); + $this->phpRedis->select($database); + + $this->predis = new Client([ + 'scheme' => 'tcp', + 'host' => $host, + 'port' => $port, + 'database' => $database, + ]); + + $this->phpRedisLock = new RedisLock(new PhpRedisConnection($this->phpRedis), new SystemClock(), $this->prefix); + $this->predisLock = new RedisLock(new PredisConnection($this->predis), new SystemClock(), $this->prefix); + } + + protected function tearDown(): void { + $this->predis->disconnect(); + $this->phpRedis->close(); + + parent::tearDown(); + } + + public function test_phpredis_and_predis_coordinate_the_same_lock(): void { + $first = $this->phpRedisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertNull($this->predisLock->acquire('queue:sync', 10)); + $this->assertTrue($this->predisLock->isAcquired('queue:sync')); + $this->assertFalse($this->predisLock->release(new LockToken( + name: 'queue:sync', + owner: 'another-owner', + expiresAt: new DateTimeImmutable('+10 seconds') + ))); + $this->assertTrue($this->phpRedisLock->release($first)); + + $second = $this->predisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $second); + $this->assertFalse($this->phpRedisLock->release($first)); + $this->assertTrue($this->predisLock->release($second)); + } + + public function test_an_expired_owner_cannot_modify_its_replacement(): void { + $expired = $this->predisLock->acquire('queue:sync', 1); + + $this->assertInstanceOf(LockToken::class, $expired); + + sleep(2); + + $replacement = $this->phpRedisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $replacement); + $this->assertNull($this->predisLock->refresh($expired, 10)); + $this->assertFalse($this->predisLock->release($expired)); + $this->assertTrue($this->phpRedisLock->isAcquired('queue:sync')); + $this->assertTrue($this->phpRedisLock->release($replacement)); + } + + public function test_refresh_extends_the_authoritative_redis_lease(): void { + $token = $this->phpRedisLock->acquire('queue:sync', 1); + + $this->assertInstanceOf(LockToken::class, $token); + + $refreshed = $this->predisLock->refresh($token, 3); + + $this->assertInstanceOf(LockToken::class, $refreshed); + + sleep(2); + + $this->assertTrue($this->phpRedisLock->isAcquired('queue:sync')); + $this->assertTrue($this->predisLock->release($refreshed)); + } + + public function test_predis_errors_fail_closed(): void { + $this->expectException(LockUnavailableException::class); + + (new PredisConnection($this->predis))->evaluate('not valid lua', [], []); + } + + public function test_phpredis_errors_fail_closed(): void { + $this->expectException(LockUnavailableException::class); + + (new PhpRedisConnection($this->phpRedis))->evaluate('return false', [], []); + } + + public function test_phpredis_serialization_does_not_change_lock_ownership(): void { + $this->phpRedis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + + $this->assertPhpRedisOwnerCanRefreshAndRelease(); + } + + public function test_phpredis_compression_does_not_change_lock_ownership(): void { + $this->phpRedis->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZF); + + $this->assertPhpRedisOwnerCanRefreshAndRelease(); + } + + public function test_retried_acquisition_recognizes_the_same_owner_without_extending_the_ttl(): void { + $recording = new RecordingConnection(); + $token = (new RedisLock($recording, new SystemClock(), $this->prefix))->acquire('queue:retry', 10); + + $this->assertInstanceOf(LockToken::class, $token); + + $call = $recording->evaluateCalls[0]; + $connection = new PhpRedisConnection($this->phpRedis); + + $this->assertSame(1, $connection->evaluate($call['script'], $call['keys'], $call['arguments'])); + + sleep(2); + + $this->assertSame(1, $connection->evaluate($call['script'], $call['keys'], $call['arguments'])); + $this->assertLessThan(10, $this->phpRedis->ttl($call['keys'][0])); + $this->assertTrue($this->phpRedisLock->release($token)); + } + + private function assertPhpRedisOwnerCanRefreshAndRelease(): void { + $token = $this->phpRedisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $token); + + $refreshed = $this->phpRedisLock->refresh($token, 20); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertTrue($this->phpRedisLock->release($refreshed)); + } +} From 0ae2266f391fc609e0048356e7d766373b833dfa Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 19 Aug 2026 15:13:52 -0600 Subject: [PATCH 27/81] Fix numerous bugs and improve foundation-database locking --- src/Database/DatabaseProvider.php | 18 ++- .../Exceptions/MigrationLockFailed.php | 12 +- src/Database/Lock/DatabaseLock.php | 130 ++++++++++-------- src/Database/Migration/Migrator.php | 32 +++++ src/Database/Migration/Runner.php | 55 +++++++- src/Database/README.md | 64 +++++++++ src/Database/Schema.php | 24 +--- src/Database/Table/Collection.php | 7 +- src/Database/Table/TableDefinition.php | 11 +- src/Database/Table/Tables/LockTable.php | 11 +- src/Lock/README.md | 64 +++++++-- src/LockRedis/README.md | 10 ++ .../register-wpcli-migrate-command.php | 10 +- tests/Unit/Database/Lock/DatabaseLockTest.php | 95 ++++++++++--- tests/Unit/Database/Migration/RunnerTest.php | 94 +++++++++++++ tests/Unit/Database/Table/CollectionTest.php | 4 +- .../Database/Table/TableDefinitionTest.php | 34 +++++ .../Database/Table/Tables/LockTableTest.php | 5 + .../Database/DatabaseProviderTest.php | 2 + .../Database/DatabaseIntegrationTest.php | 94 +++++++++++-- 20 files changed, 640 insertions(+), 136 deletions(-) diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index ce4117d..4180126 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Database; +use Closure; use lucatume\DI52\Container as C; use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Database\Cli\Migrate; @@ -60,7 +61,22 @@ private function registerDatabase(): void { return new Database($wpdb); }); $this->container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); - $this->container->singleton(Schema::class, static fn (C $c): Schema => new Schema($c->get(DatabaseContract::class))); + + $this->container->when(Schema::class) + ->needs(Closure::class) + ->give(static function (): Closure { + if (! function_exists('dbDelta') && defined('ABSPATH')) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + if (! function_exists('dbDelta')) { + throw new DatabaseException('WordPress dbDelta() is not available.'); + } + + return dbDelta(...); + }); + + $this->container->singleton(Schema::class); $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); } diff --git a/src/Database/Exceptions/MigrationLockFailed.php b/src/Database/Exceptions/MigrationLockFailed.php index b295d37..a368140 100644 --- a/src/Database/Exceptions/MigrationLockFailed.php +++ b/src/Database/Exceptions/MigrationLockFailed.php @@ -3,11 +3,21 @@ namespace StellarWP\Foundation\Database\Exceptions; /** - * Raised when another process already owns the migration lock. + * Raised when a migration lock cannot be acquired or its ownership cannot be confirmed. */ final class MigrationLockFailed extends DatabaseException { + /** + * Create an exception for a migration lock that could not be acquired. + */ public static function forLock(string $lock): self { return new self(sprintf('Could not acquire migration lock "%s".', $lock)); } + + /** + * Create an exception when ownership cannot be confirmed during release. + */ + public static function forUnconfirmedOwnership(string $lock): self { + return new self(sprintf('Could not confirm ownership of migration lock "%s" when releasing it.', $lock)); + } } diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php index 44a7b9d..14bedd5 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/Database/Lock/DatabaseLock.php @@ -2,19 +2,16 @@ namespace StellarWP\Foundation\Database\Lock; -use DateInterval; -use DateMalformedIntervalStringException; use DateMalformedStringException; use DateTimeImmutable; +use DateTimeZone; use InvalidArgumentException; use Random\RandomException; use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Lock\Contracts\Clock; use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\LockToken; -use StellarWP\Foundation\Lock\SystemClock; /** * Database-backed lock implementation for WordPress environments. @@ -23,62 +20,64 @@ { public function __construct( private Database $database, - private string $table, - private Clock $clock = new SystemClock() + private string $table ) { } /** - * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. - * @throws RandomException When a secure owner token cannot be generated. - * @throws DateMalformedStringException When the stored expiration cannot be parsed. - * @throws InvalidArgumentException When the lock name is empty or the TTL is invalid. - * @throws LockUnavailableException When the database cannot determine the acquisition result. + * @throws InvalidArgumentException When the lock name is empty or exceeds 191 bytes, or the TTL is invalid. + * @throws LockUnavailableException When ownership cannot be generated or the database cannot determine the result. */ public function acquire(string $name, int $ttl): ?LockToken { $this->assertValidName($name); $this->assertValidTtl($ttl); - $owner = bin2hex(random_bytes(16)); - $now = $this->format($this->clock->now()); - $expiresAt = $this->expiresAt($ttl); + try { + $owner = bin2hex(random_bytes(16)); + } catch (RandomException $exception) { + throw new LockUnavailableException('A secure lock owner token could not be generated.', 0, $exception); + } try { $this->database->execute( 'INSERT INTO %i (name, owner, expires_at, created_at, updated_at) - VALUES (%s, %s, %s, %s, %s) + VALUES (%s, %s, TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE - owner = IF(expires_at <= %s, VALUES(owner), owner), - updated_at = IF(expires_at <= %s, VALUES(updated_at), updated_at), - expires_at = IF(expires_at <= %s, VALUES(expires_at), expires_at)', + owner = IF(expires_at <= UTC_TIMESTAMP(6), %s, owner), + updated_at = IF(expires_at <= UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), updated_at), + expires_at = IF( + expires_at <= UTC_TIMESTAMP(6), + TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), + expires_at + )', $this->database->tableName($this->table), $name, $owner, - $this->format($expiresAt), - $now, - $now, - $now, - $now, - $now + $ttl, + $owner, + $ttl ); $row = $this->database->row( - 'SELECT owner, expires_at FROM %i WHERE name = %s LIMIT 1', + 'SELECT expires_at FROM %i + WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) + LIMIT 1', $this->database->tableName($this->table), - $name + $name, + $owner ); } catch (DatabaseException $exception) { throw new LockUnavailableException('The database could not determine the lock acquisition result.', 0, $exception); } - if ($row === null || ($row['owner'] ?? '') !== $owner) { + if ($row === null) { return null; } return new LockToken( name: $name, owner: $owner, - expiresAt: new DateTimeImmutable((string) $row['expires_at']) + expiresAt: $this->expiration($row) ); } @@ -88,11 +87,10 @@ public function acquire(string $name, int $ttl): ?LockToken { public function release(LockToken $token): bool { try { return $this->database->execute( - 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > %s', + 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', $this->database->tableName($this->table), $token->name, - $token->owner, - $this->format($this->clock->now()) + $token->owner ) > 0; } catch (DatabaseException $exception) { throw new LockUnavailableException('The database could not determine the lock release result.', 0, $exception); @@ -100,38 +98,41 @@ public function release(LockToken $token): bool { } /** - * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. - * @throws InvalidArgumentException When the TTL is invalid. - * @throws LockUnavailableException When the database cannot determine the refresh result. + * @throws InvalidArgumentException When the TTL is invalid. + * @throws LockUnavailableException When the database cannot determine the refresh result. */ public function refresh(LockToken $token, int $ttl): ?LockToken { $this->assertValidTtl($ttl); - $expiresAt = $this->expiresAt($ttl); - try { - $updated = $this->database->execute( - 'UPDATE %i SET expires_at = %s, updated_at = %s WHERE name = %s AND owner = %s AND expires_at > %s', + $this->database->execute( + 'UPDATE %i SET expires_at = TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), updated_at = UTC_TIMESTAMP(6) + WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', $this->database->tableName($this->table), - $this->format($expiresAt), - $this->format($this->clock->now()), + $ttl, $token->name, - $token->owner, - $this->format($this->clock->now()) + $token->owner + ); + + $row = $this->database->row( + 'SELECT expires_at FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', + $this->database->tableName($this->table), + $token->name, + $token->owner ); } catch (DatabaseException $exception) { throw new LockUnavailableException('The database could not determine the lock refresh result.', 0, $exception); } - if ($updated < 1) { + if ($row === null) { return null; } - return $token->refresh($expiresAt); + return $token->refresh($this->expiration($row)); } /** - * @throws InvalidArgumentException When the lock name is empty. + * @throws InvalidArgumentException When the lock name is empty or exceeds 191 bytes. * @throws LockUnavailableException When the database cannot determine whether the lock exists. */ public function isAcquired(string $name): bool { @@ -139,25 +140,15 @@ public function isAcquired(string $name): bool { try { return $this->database->row( - 'SELECT name FROM %i WHERE name = %s AND expires_at > %s LIMIT 1', + 'SELECT name FROM %i WHERE name = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', $this->database->tableName($this->table), - $name, - $this->format($this->clock->now()) + $name ) !== null; } catch (DatabaseException $exception) { throw new LockUnavailableException('The database could not determine whether the lock exists.', 0, $exception); } } - /** - * @throws DateMalformedIntervalStringException - */ - private function expiresAt(int $ttl): DateTimeImmutable { - $this->assertValidTtl($ttl); - - return $this->clock->now()->add(new DateInterval(sprintf('PT%dS', $ttl))); - } - /** * @throws InvalidArgumentException When the TTL is less than one second. */ @@ -168,15 +159,34 @@ private function assertValidTtl(int $ttl): void { } /** - * @throws InvalidArgumentException When the lock name is empty. + * @throws InvalidArgumentException When the lock name is empty or exceeds 191 bytes. */ private function assertValidName(string $name): void { if (trim($name) === '') { throw new InvalidArgumentException('Lock name cannot be empty.'); } + + if (strlen($name) > 191) { + throw new InvalidArgumentException('A database lock name cannot exceed 191 bytes.'); + } } - private function format(DateTimeImmutable $date): string { - return $date->format('Y-m-d H:i:s'); + /** + * @param array{expires_at?: mixed} $row + * + * @throws LockUnavailableException When the database returns an invalid expiration. + */ + private function expiration(array $row): DateTimeImmutable { + $expiration = $row['expires_at'] ?? null; + + if (! is_string($expiration) || $expiration === '') { + throw new LockUnavailableException('The database returned an invalid lock expiration.'); + } + + try { + return new DateTimeImmutable($expiration, new DateTimeZone('UTC')); + } catch (DateMalformedStringException $exception) { + throw new LockUnavailableException('The database returned an invalid lock expiration.', 0, $exception); + } } } diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index ee0eef6..d5179b8 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -3,6 +3,11 @@ namespace StellarWP\Foundation\Database\Migration; use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; +use StellarWP\Foundation\Database\Exceptions\MigrationFailed; +use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; +use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; /** * Configured entry point for preparing and running database migrations. @@ -18,6 +23,8 @@ public function __construct( /** * Ensure the migration subsystem storage is ready. + * + * @throws DatabaseException When migration storage cannot be prepared. */ public function prepare(): void { $this->store->prepare(); @@ -25,6 +32,8 @@ public function prepare(): void { /** * Drop the migration subsystem storage. + * + * @throws DatabaseException When migration storage cannot be dropped. */ public function drop(): void { $this->store->drop(); @@ -32,6 +41,8 @@ public function drop(): void { /** * Determine whether the migration subsystem storage is ready. + * + * @throws DatabaseException When migration storage cannot be inspected. */ public function exists(): bool { return $this->store->exists(); @@ -39,6 +50,12 @@ public function exists(): bool { /** * Run all pending configured migrations. + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws DuplicateMigration When configured migrations share an identifier. + * @throws MigrationFailed When a migration fails while running. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function run(): Result { return $this->withPreparedStore(fn (): Result => $this->runner->run($this->migrations)); @@ -46,6 +63,12 @@ public function run(): Result { /** * Roll back the latest configured migration batch. + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws DuplicateMigration When configured migrations share an identifier. + * @throws MigrationFailed When a migration fails while rolling back. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function rollback(?int $batch = null): Result { return $this->withPreparedStore(fn (): Result => $this->runner->rollback($this->migrations, $batch)); @@ -53,12 +76,21 @@ public function rollback(?int $batch = null): Result { /** * Roll back and rerun all configured migrations. + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws DuplicateMigration When configured migrations share an identifier. + * @throws MigrationFailed When a migration fails while running or rolling back. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function refresh(): Result { return $this->withPreparedStore(fn (): Result => $this->runner->refresh($this->migrations)); } /** + * @throws DatabaseException When migration storage cannot be inspected. + * @throws DuplicateMigration When configured migrations share an identifier. + * * @return list */ public function status(): array { diff --git a/src/Database/Migration/Runner.php b/src/Database/Migration/Runner.php index 6ec2871..d30df49 100644 --- a/src/Database/Migration/Runner.php +++ b/src/Database/Migration/Runner.php @@ -2,13 +2,16 @@ namespace StellarWP\Foundation\Database\Migration; +use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Migration; use StellarWP\Foundation\Database\Contracts\Repository; use StellarWP\Foundation\Database\Contracts\Schema; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use Throwable; /** @@ -16,6 +19,9 @@ */ final readonly class Runner { + /** + * @throws InvalidArgumentException When the migration lock configuration is invalid. + */ public function __construct( private Repository $repository, private Schema $schema, @@ -23,10 +29,23 @@ public function __construct( private string $lockName = 'foundation-database-migrations', private int $lockTtl = 300 ) { + if (trim($this->lockName) === '') { + throw new InvalidArgumentException('The migration lock name cannot be empty.'); + } + + if ($this->lockTtl < 1) { + throw new InvalidArgumentException('The migration lock TTL must be at least one second.'); + } } /** * @param iterable $migrations + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws DuplicateMigration When configured migrations share an identifier. + * @throws MigrationFailed When a migration fails while running. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function run(iterable $migrations): Result { return $this->locked(function () use ($migrations): Result { @@ -57,6 +76,12 @@ public function run(iterable $migrations): Result { /** * @param iterable $migrations + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws DuplicateMigration When configured migrations share an identifier. + * @throws MigrationFailed When a migration fails while rolling back. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function rollback(iterable $migrations, ?int $batch = null): Result { return $this->locked(function () use ($migrations, $batch): Result { @@ -75,6 +100,12 @@ public function rollback(iterable $migrations, ?int $batch = null): Result { /** * @param iterable $migrations + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws DuplicateMigration When configured migrations share an identifier. + * @throws MigrationFailed When a migration fails while running or rolling back. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function refresh(iterable $migrations): Result { return $this->locked(function () use ($migrations): Result { @@ -93,6 +124,9 @@ public function refresh(iterable $migrations): Result { /** * @param iterable $migrations * + * @throws DatabaseException When migration storage access fails. + * @throws DuplicateMigration When configured migrations share an identifier. + * * @return list */ public function status(iterable $migrations): array { @@ -190,6 +224,9 @@ private function normalize(iterable $migrations): array { * * @param callable(): T $callback * + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * * @return T */ private function locked(callable $callback): mixed { @@ -200,9 +237,21 @@ private function locked(callable $callback): mixed { } try { - return $callback(); - } finally { - $this->lock->release($token); + $result = $callback(); + } catch (Throwable $failure) { + try { + $this->lock->release($token); + } catch (Throwable) { + // Preserve the primary migration failure when cleanup also fails. + } + + throw $failure; + } + + if (! $this->lock->release($token)) { + throw MigrationLockFailed::forUnconfirmedOwnership($this->lockName); } + + return $result; } } diff --git a/src/Database/README.md b/src/Database/README.md index 9d9c9cc..74c9fc4 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -15,6 +15,11 @@ Foundation Database is a WordPress-backed database package. It provides a config This package intentionally targets WordPress runtime APIs instead of acting as a generic database abstraction. Migration classes depend on a small schema contract so application packages can define migration behavior without calling `wpdb` directly. +Foundation Database requires WordPress 6.2 or newer because its query layer +uses the `%i` identifier placeholder. Database-backed locks additionally require +fractional-second temporal values: MySQL 5.6.4 or newer, or MariaDB 5.3 or +newer. + ## Registering The Provider Register `DatabaseProvider` in the application container when the project needs Foundation-managed migrations: @@ -76,6 +81,65 @@ return [ ]; ``` +`database.lock_ttl` must cover the complete migration operation. The migration +runner reports unconfirmed ownership if an otherwise successful operation +cannot release its ownership token. Increase the TTL for long-running +migrations; the runner does not refresh the lease while a migration is +executing. + +## Using Database Locks + +`DatabaseProvider` registers `DatabaseLock` for direct use and uses it for +migrations, but intentionally does not select it as the application's global +lock implementation. Register `DatabaseProvider` before an application provider +that chooses the database implementation: + +```php +use lucatume\DI52\Container as C; +use StellarWP\Foundation\Database\Lock\DatabaseLock; +use StellarWP\Foundation\Lock\Contracts\Lock; + +$this->container->bind( + Lock::class, + static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class) +); +``` + +`DatabaseLock` uses the database server's UTC clock for acquisition, expiration, +refresh, and release decisions. This keeps competing PHP processes on one +authoritative timeline even when their host clocks differ. + +Database lock names are byte-exact and may not exceed 191 bytes. + +Lock writes and their verification reads must use the same authoritative +primary connection. Standard `wpdb` satisfies this requirement. Projects with a +database drop-in that routes `SELECT` queries to replicas must pin lock-table +reads to the writer; otherwise replication lag can make a successful acquisition +or refresh fail closed. + +The database lock table must exist before application services acquire locks. +Prepare it during activation or deployment through the configured migrator: + +```php +use StellarWP\Foundation\Database\Migration\Migrator; + +$container->get(Migrator::class)->prepare(); +``` + +Preparing the migration store also reconciles existing internal tables with +their current definitions. + +Projects using the included WP-CLI command can instead run: + +```bash +wp nx migrate --prepare +``` + +Once configured, application services should depend on the shared `Lock` +contract. See the +[Foundation Lock usage examples](https://github.com/stellarwp/foundation-lock#preventing-duplicate-work) +for resource-scoped acquisition, release, and lease handling. + ## Running Queries Application services can inject `StellarWP\Foundation\Database\Contracts\Database` when they need to run queries: diff --git a/src/Database/Schema.php b/src/Database/Schema.php index c41c648..25e2371 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -6,7 +6,6 @@ use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; use StellarWP\Foundation\Database\Contracts\Table; -use StellarWP\Foundation\Database\Exceptions\DatabaseException; /** * WordPress schema operations backed by wpdb and dbDelta. @@ -14,18 +13,16 @@ final readonly class Schema implements SchemaContract { /** - * @param Closure(string): mixed|null $dbDelta + * @param Closure(string): mixed $dbDelta */ public function __construct( private Database $database, - private ?Closure $dbDelta = null + private Closure $dbDelta ) { } public function createOrUpdate(Table|string $table, ?string $sql = null): void { - $dbDelta = $this->dbDelta ?? $this->loadDbDelta(); - - $dbDelta($sql ?? $this->createTableSql($table)); + ($this->dbDelta)($sql ?? $this->createTableSql($table)); } public function execute(string $sql): void { @@ -84,19 +81,4 @@ private function createTableSql(Table|string $table): string { $this->database->charsetCollate() ); } - - /** - * @return Closure(string): mixed - */ - private function loadDbDelta(): Closure { - if (! function_exists('dbDelta') && defined('ABSPATH')) { - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - } - - if (! function_exists('dbDelta')) { - throw new DatabaseException('WordPress dbDelta() is not available.'); - } - - return dbDelta(...); - } } diff --git a/src/Database/Table/Collection.php b/src/Database/Table/Collection.php index 5738dc8..51d8eee 100644 --- a/src/Database/Table/Collection.php +++ b/src/Database/Table/Collection.php @@ -45,11 +45,12 @@ public function all(): array { return $this->tables; } + /** + * Create missing tables and reconcile existing tables with their definitions. + */ public function create(): void { foreach ($this->tables as $table) { - if (! $this->schema->hasTable($table)) { - $this->schema->createOrUpdate($table); - } + $this->schema->createOrUpdate($table); } } diff --git a/src/Database/Table/TableDefinition.php b/src/Database/Table/TableDefinition.php index 69c083f..7e3191b 100644 --- a/src/Database/Table/TableDefinition.php +++ b/src/Database/Table/TableDefinition.php @@ -59,8 +59,15 @@ public function bigInteger(string $name, int $length = 20): self { return $this->column(new Column($name, 'bigint', $length)); } - public function dateTime(string $name): self { - return $this->column(new Column($name, 'datetime')); + /** + * @throws InvalidArgumentException When precision is outside the database-supported range. + */ + public function dateTime(string $name, ?int $precision = null): self { + if ($precision !== null && ($precision < 0 || $precision > 6)) { + throw new InvalidArgumentException('Datetime precision must be between 0 and 6.'); + } + + return $this->column(new Column($name, 'datetime', $precision)); } public function text(string $name): self { diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php index 16e903d..c896839 100644 --- a/src/Database/Table/Tables/LockTable.php +++ b/src/Database/Table/Tables/LockTable.php @@ -4,6 +4,7 @@ use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\Table\Column; use StellarWP\Foundation\Database\Table\TableDefinition; /** @@ -29,11 +30,11 @@ public function name(): string { public function definition(): TableDefinition { return TableDefinition::for($this) - ->string('name', 191) - ->string('owner', 64) - ->dateTime('expires_at') - ->dateTime('created_at') - ->dateTime('updated_at') + ->column(new Column('name', 'varbinary', 191)) + ->column(new Column('owner', 'varbinary', 64)) + ->dateTime('expires_at', 6) + ->dateTime('created_at', 6) + ->dateTime('updated_at', 6) ->primary('name') ->index('expires_at', 'expires_at'); } diff --git a/src/Lock/README.md b/src/Lock/README.md index a3d8282..12281a9 100644 --- a/src/Lock/README.md +++ b/src/Lock/README.md @@ -42,21 +42,67 @@ table above instead; Composer installs `stellarwp/foundation-lock` with it. use StellarWP\Foundation\Lock\InMemoryLock; $lock = new InMemoryLock(); +``` -$token = $lock->acquire('queue:sync', 60); +Use both in-memory and persistent implementations through +`StellarWP\Foundation\Lock\Contracts\Lock`, as shown below. Persistent +implementations use `LockToken` ownership checks before releasing or refreshing +locks. -if ($token === null) { - return; -} +## Preventing Duplicate Work + +Application services should depend on the shared `Lock` contract so production +can use a persistent implementation while tests use `InMemoryLock`. Include the +resource identifier in the lock name so unrelated work can proceed concurrently: -try { - // Run exclusive work here. -} finally { - $lock->release($token); +```php +use RuntimeException; +use StellarWP\Foundation\Lock\Contracts\Lock; +use Throwable; + +final readonly class CatalogSynchronizer +{ + public function __construct( + private Lock $lock + ) { + } + + /** + * @param callable(): void $synchronize + */ + public function synchronize(int $siteId, callable $synchronize): bool + { + $token = $this->lock->acquire(sprintf('catalog:%d:sync', $siteId), 300); + + if ($token === null) { + return false; + } + + try { + $synchronize(); + } catch (Throwable $failure) { + try { + $this->lock->release($token); + } catch (Throwable) { + // Preserve the primary synchronization failure. + } + + throw $failure; + } + + if (! $this->lock->release($token)) { + throw new RuntimeException('Catalog synchronization lock ownership could not be confirmed during release.'); + } + + return true; + } } ``` -Persistent implementations, such as database-backed locks, should implement `StellarWP\Foundation\Lock\Contracts\Lock` and use `LockToken` ownership checks before releasing or refreshing locks. +A `null` acquisition means another process already owns the lease; the caller +can skip, retry, or queue the work. A `false` release means ownership could not +be confirmed during release, so exclusive ownership may not have lasted for the +full operation. ## Expiration And Refreshing diff --git a/src/LockRedis/README.md b/src/LockRedis/README.md index 28985ba..faed452 100644 --- a/src/LockRedis/README.md +++ b/src/LockRedis/README.md @@ -113,6 +113,16 @@ final class RedisProvider extends Provider } ``` +## Application Usage + +After binding `StellarWP\Foundation\Lock\Contracts\Lock` to `RedisLock`, inject +the shared contract into application services rather than depending directly +on the Redis implementation. See +[Preventing Duplicate Work](https://github.com/stellarwp/foundation-lock#preventing-duplicate-work) +for a complete resource-scoped locking example and +[Expiration And Refreshing](https://github.com/stellarwp/foundation-lock#expiration-and-refreshing) +for lease handling guidance. + The package never selects a Redis database or reuses WordPress object-cache globals. Supply a separate client connection. A separate logical database protects locks from `FLUSHDB` issued against the object-cache database, but it diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index f43b005..aa754fd 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -33,13 +33,21 @@ return; } + if (! defined('ABSPATH')) { + return; + } + $container = new ContainerAdapter(new DI52Container()); $container->bind(Container::class, $container); $container->bind(ContainerInterface::class, $container); $container->singleton(Dot::class, new Dot()); + if (! function_exists('dbDelta')) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + $database = new Database($wpdb); - $schema = new Schema($database); + $schema = new Schema($database, dbDelta(...)); $migrationTable = $wpdb->prefix . 'foundation_cli_migrations'; $lockTable = $wpdb->prefix . 'foundation_cli_locks'; $exampleTable = $wpdb->prefix . 'foundation_cli_example'; diff --git a/tests/Unit/Database/Lock/DatabaseLockTest.php b/tests/Unit/Database/Lock/DatabaseLockTest.php index c8a14b6..7573c92 100644 --- a/tests/Unit/Database/Lock/DatabaseLockTest.php +++ b/tests/Unit/Database/Lock/DatabaseLockTest.php @@ -11,36 +11,45 @@ use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\LockToken; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; -use StellarWP\Foundation\Tests\Support\Fixtures\Lock\MutableClock; use StellarWP\Foundation\Tests\TestCase; final class DatabaseLockTest extends TestCase { private FakeDatabase $database; - private MutableClock $clock; - private DatabaseLock $lock; protected function setUp(): void { parent::setUp(); $this->database = new FakeDatabase(); - $this->clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); - $this->lock = new DatabaseLock($this->database, 'wp_nexcess_foundation_locks', $this->clock); + $this->lock = new DatabaseLock($this->database, 'wp_nexcess_foundation_locks'); } public function test_it_acquires_a_database_lock_when_the_written_owner_matches(): void { - $this->database->rowResults[] = fn (string $sql, FakeDatabase $database): array => [ - 'owner' => $this->extractOwnerFromInsert($database->executed[0]), - 'expires_at' => '2026-01-01 00:01:00', + $this->database->rowResults[] = [ + 'expires_at' => '2026-01-01 00:01:00.123456', ]; $token = $this->lock->acquire('queue:sync', 60); $this->assertInstanceOf(LockToken::class, $token); $this->assertSame('queue:sync', $token->name); + $this->assertSame('2026-01-01 00:01:00.123456', $token->expiresAt->format('Y-m-d H:i:s.u')); + $this->assertSame('UTC', $token->expiresAt->getTimezone()->getName()); $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $this->database->executed[0]); + $this->assertStringContainsString('TIMESTAMPADD(SECOND, 60, UTC_TIMESTAMP(6))', $this->database->executed[0]); + $this->assertStringNotContainsString('VALUES(owner)', $this->database->executed[0]); + $this->assertStringContainsString('owner =', $this->database->rowQueries[0]); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); + } + + public function test_it_returns_null_when_the_acquired_lease_is_not_active_during_readback(): void { + $this->database->rowResults[] = null; + + $this->assertNull($this->lock->acquire('queue:sync', 60)); + $this->assertStringContainsString('owner =', $this->database->rowQueries[0]); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); } public function test_it_releases_a_lock_for_the_matching_owner(): void { @@ -55,6 +64,7 @@ public function test_it_releases_a_lock_for_the_matching_owner(): void { $this->assertTrue($this->lock->release($token)); $this->assertStringContainsString('DELETE FROM `wp_nexcess_foundation_locks`', $this->database->executed[0]); $this->assertStringContainsString('owner', $this->database->executed[0]); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->executed[0]); } public function test_it_refreshes_a_lock_for_the_matching_owner(): void { @@ -65,15 +75,17 @@ public function test_it_refreshes_a_lock_for_the_matching_owner(): void { ); $this->database->executeResults[] = 1; + $this->database->rowResults[] = ['expires_at' => '2026-01-01 00:02:00.654321']; $refreshed = $this->lock->refresh($token, 120); $this->assertInstanceOf(LockToken::class, $refreshed); - $this->assertSame('2026-01-01 00:02:00', $refreshed->expiresAt->format('Y-m-d H:i:s')); + $this->assertSame('2026-01-01 00:02:00.654321', $refreshed->expiresAt->format('Y-m-d H:i:s.u')); $this->assertStringContainsString('UPDATE `wp_nexcess_foundation_locks`', $this->database->executed[0]); + $this->assertStringContainsString('TIMESTAMPADD(SECOND, 120, UTC_TIMESTAMP(6))', $this->database->executed[0]); } - public function test_it_returns_null_when_refresh_does_not_update_a_row(): void { + public function test_it_returns_null_when_a_refreshed_lease_is_not_active_during_readback(): void { $token = new LockToken( name: 'queue:sync', owner: 'owner', @@ -85,11 +97,57 @@ public function test_it_returns_null_when_refresh_does_not_update_a_row(): void $this->assertNull($this->lock->refresh($token, 120)); } + public function test_it_returns_a_token_when_refresh_reports_no_change_but_the_lease_is_active(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 0; + $this->database->rowResults[] = ['expires_at' => '2026-01-01 00:02:00.000000']; + + $this->assertInstanceOf(LockToken::class, $this->lock->refresh($token, 120)); + } + + public function test_it_returns_null_when_a_refreshed_lock_can_no_longer_be_read(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 1; + $this->database->rowResults[] = null; + + $this->assertNull($this->lock->refresh($token, 120)); + } + + public function test_it_fails_closed_when_the_database_omits_the_lock_expiration(): void { + $this->database->rowResults[] = []; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('invalid lock expiration'); + + $this->lock->acquire('queue:sync', 60); + } + + public function test_it_fails_closed_when_the_database_returns_an_invalid_lock_expiration(): void { + $this->database->rowResults[] = [ + 'expires_at' => 'invalid', + ]; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('invalid lock expiration'); + + $this->lock->acquire('queue:sync', 60); + } + public function test_it_checks_whether_a_lock_is_acquired(): void { $this->database->rowResults[] = ['name' => 'queue:sync']; $this->assertTrue($this->lock->isAcquired('queue:sync')); - $this->assertStringContainsString("expires_at > '2026-01-01 00:00:00'", $this->database->rowQueries[0]); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); } public function test_it_rejects_an_invalid_ttl(): void { @@ -104,6 +162,13 @@ public function test_it_rejects_an_empty_name(): void { $this->lock->isAcquired(''); } + public function test_it_rejects_names_longer_than_the_database_column(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot exceed 191 bytes'); + + $this->lock->acquire(str_repeat('a', 192), 60); + } + /** * @dataProvider unavailableOperationProvider */ @@ -115,7 +180,7 @@ public function test_it_normalizes_database_failures(callable $operation, string $database->shouldReceive($databaseMethod)->andThrow(new QueryException('Query failed.', 'SELECT 1')); try { - $operation(new DatabaseLock($database, 'wp_nexcess_foundation_locks', $this->clock)); + $operation(new DatabaseLock($database, 'wp_nexcess_foundation_locks')); $this->fail('Expected the database failure to be normalized.'); } catch (LockUnavailableException $exception) { $this->assertInstanceOf(QueryException::class, $exception->getPrevious()); @@ -139,10 +204,4 @@ public static function unavailableOperationProvider(): array { 'is acquired' => [static fn (DatabaseLock $lock): bool => $lock->isAcquired('queue:sync'), 'row'], ]; } - - private function extractOwnerFromInsert(string $sql): string { - preg_match("/VALUES \\('queue:sync', '([a-f0-9]{32})', /", $sql, $matches); - - return $matches[1] ?? ''; - } } diff --git a/tests/Unit/Database/Migration/RunnerTest.php b/tests/Unit/Database/Migration/RunnerTest.php index 30a1655..14871e1 100644 --- a/tests/Unit/Database/Migration/RunnerTest.php +++ b/tests/Unit/Database/Migration/RunnerTest.php @@ -2,12 +2,16 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; +use InvalidArgumentException; use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Result; use StellarWP\Foundation\Database\Migration\Runner; +use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\InMemoryLock; +use StellarWP\Foundation\Lock\LockToken; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FailingMigration; use StellarWP\Foundation\Tests\Support\Fixtures\Database\InMemoryRepository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; @@ -34,6 +38,20 @@ protected function setUp(): void { $this->runner = new Runner($this->repository, $this->schema, $this->lock); } + public function test_it_rejects_a_blank_migration_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lock name cannot be empty'); + + new Runner($this->repository, $this->schema, $this->lock, lockName: ' '); + } + + public function test_it_rejects_an_invalid_migration_lock_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('TTL must be at least one second'); + + new Runner($this->repository, $this->schema, $this->lock, lockTtl: 0); + } + public function test_it_runs_pending_migrations_in_order(): void { $result = $this->runner->run([ new TestMigration('2026_01_01_000001_create_users'), @@ -190,6 +208,74 @@ public function test_it_fails_when_the_migration_lock_is_already_owned(): void { ]); } + public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_during_release(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->with('foundation-database-migrations', 300) + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(false); + + $runner = new Runner($this->repository, $this->schema, $lock); + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not confirm ownership'); + + try { + $runner->run([ + new TestMigration('2026_01_01_000001_create_users'), + ]); + } finally { + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_preserves_the_migration_failure_when_lock_release_is_unavailable(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); + + $runner = new Runner($this->repository, $this->schema, $lock); + + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while running'); + + $runner->run([ + new FailingMigration('2026_01_01_000001_create_users', failUp: true), + ]); + } + + public function test_it_preserves_the_migration_failure_when_release_cannot_confirm_ownership(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(false); + + $runner = new Runner($this->repository, $this->schema, $lock); + + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while running'); + + $runner->run([ + new FailingMigration('2026_01_01_000001_create_users', failUp: true), + ]); + } + public function test_it_does_not_record_a_failed_migration(): void { $this->expectException(MigrationFailed::class); $this->expectExceptionMessage('failed while running'); @@ -219,4 +305,12 @@ public function test_it_does_not_delete_a_record_when_rollback_fails(): void { $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } } + + private function lockToken(): LockToken { + $token = $this->lock->acquire('foundation-database-migrations', 300); + + $this->assertNotNull($token); + + return $token; + } } diff --git a/tests/Unit/Database/Table/CollectionTest.php b/tests/Unit/Database/Table/CollectionTest.php index cc6e58f..2e9501f 100644 --- a/tests/Unit/Database/Table/CollectionTest.php +++ b/tests/Unit/Database/Table/CollectionTest.php @@ -9,7 +9,7 @@ final class CollectionTest extends TestCase { - public function test_it_creates_only_missing_tables(): void { + public function test_it_creates_or_updates_all_tables(): void { $existing = new TestTable('existing_table', 'existing'); $missing = new TestTable('missing_table', 'missing'); $schema = new RecordingSchema(); @@ -19,7 +19,7 @@ public function test_it_creates_only_missing_tables(): void { $collection->create(); - $this->assertSame(['createOrUpdate:missing'], $schema->statements); + $this->assertSame(['createOrUpdate:existing', 'createOrUpdate:missing'], $schema->statements); $this->assertTrue($schema->hasTable($existing)); $this->assertTrue($schema->hasTable($missing)); } diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php index acc5c90..03bf744 100644 --- a/tests/Unit/Database/Table/TableDefinitionTest.php +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Table; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Database\Table\TableDefinition; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\TestCase; @@ -68,6 +69,39 @@ public function test_it_defines_less_common_column_helpers(): void { ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); } + public function test_it_defines_datetime_precision_boundaries(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->dateTime('seconds', 0) + ->dateTime('microseconds', 6); + + $this->assertSame([ + '`seconds` datetime(0) NOT NULL', + '`microseconds` datetime(6) NOT NULL', + ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); + } + + /** + * @dataProvider invalidDateTimePrecisionProvider + */ + #[DataProvider('invalidDateTimePrecisionProvider')] + public function test_it_rejects_invalid_datetime_precision(int $precision): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Datetime precision must be between 0 and 6.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->dateTime('created_at', $precision); + } + + /** + * @return array + */ + public static function invalidDateTimePrecisionProvider(): array { + return [ + 'negative' => [-1], + 'above maximum' => [7], + ]; + } + public function test_it_rejects_indexes_that_reference_missing_columns(): void { $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) ->string('status', 20) diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php index c63e689..08b1773 100644 --- a/tests/Unit/Database/Table/Tables/LockTableTest.php +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -22,6 +22,11 @@ public function test_it_creates_the_lock_table(): void { $this->assertSame(LockTable::ID, $table->id()); $this->assertSame('wp_nexcess_foundation_locks', $table->name()); $this->assertStringContainsString('CREATE TABLE `wp_nexcess_foundation_locks`', $statements[0]); + $this->assertStringContainsString('`name` varbinary(191)', $statements[0]); + $this->assertStringContainsString('`owner` varbinary(64)', $statements[0]); + $this->assertStringContainsString('`expires_at` datetime(6)', $statements[0]); + $this->assertStringContainsString('`created_at` datetime(6)', $statements[0]); + $this->assertStringContainsString('`updated_at` datetime(6)', $statements[0]); $this->assertStringContainsString('PRIMARY KEY (`name`)', $statements[0]); $this->assertStringContainsString('KEY `expires_at`', $statements[0]); } diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index 9dda621..8e1cfa2 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -9,6 +9,7 @@ use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Database\Cli\Migrate; use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; @@ -29,6 +30,7 @@ public function test_it_registers_default_database_configuration(): void { $this->assertSame(300, $this->container->get(DatabaseProvider::LOCK_TTL)); $this->assertContainsOnlyInstancesOf(Command::class, $commands); $this->assertTrue($this->containsMigrateCommand((array) $commands)); + $this->assertInstanceOf(DatabaseLock::class, $this->container->get(DatabaseLock::class)); $this->assertInstanceOf(Migrator::class, $this->container->get(Migrator::class)); $this->assertInstanceOf(Migrate::class, $this->container->get(Migrate::class)); } diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 03550d7..1510c21 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -3,8 +3,8 @@ namespace StellarWP\Foundation\Tests\WPUnit\Database; use Adbar\Dot; -use DateTimeImmutable; use lucatume\DI52\Container as DI52Container; +use RuntimeException; use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; @@ -23,22 +23,33 @@ use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; -use StellarWP\Foundation\Tests\Support\Fixtures\Lock\MutableClock; +use StellarWP\Foundation\Lock\LockToken; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; final class DatabaseIntegrationTest extends WPTestCase { private Database $database; + private Schema $schema; + /** * @var list */ private array $tables = []; + /** + * @throws RuntimeException When WordPress is not loaded. + */ protected function setUp(): void { parent::setUp(); + if (! defined('ABSPATH')) { + throw new RuntimeException('WordPress must be loaded before running database integration tests.'); + } + $this->database = new Database($GLOBALS['wpdb']); + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + $this->schema = new Schema($this->database, dbDelta(...)); } protected function tearDown(): void { @@ -156,7 +167,7 @@ public function test_database_wraps_wordpress_query_failures(): void { public function test_schema_creates_inspects_and_changes_tables_through_wordpress(): void { $table = $this->table('schema'); - $schema = new Schema($this->database); + $schema = $this->schema; $schema->createOrUpdate(sprintf( 'CREATE TABLE %s ( @@ -186,7 +197,7 @@ public function test_schema_creates_inspects_and_changes_tables_through_wordpres public function test_schema_creates_queue_style_table_definitions_through_wordpress(): void { $table = $this->table('queue_schema'); - $schema = new Schema($this->database); + $schema = $this->schema; $queue = new class($this->database, $table) implements Table { public function __construct( private DatabaseContract $database, @@ -232,7 +243,7 @@ public function definition(): TableDefinition { public function test_migration_repository_persists_records_in_wordpress(): void { $table = $this->table('migrations'); - $schema = new Schema($this->database); + $schema = $this->schema; $migrationTable = new MigrationTable($this->database, $table); $repository = new Repository($this->database, $table); @@ -262,7 +273,7 @@ public function test_migration_repository_persists_records_in_wordpress(): void public function test_database_lock_coordinates_ownership_in_wordpress(): void { $table = $this->table('locks'); - $wpSchema = new Schema($this->database); + $wpSchema = $this->schema; $lockTable = new LockTable($this->database, $table); $lock = new DatabaseLock($this->database, $table); @@ -289,10 +300,9 @@ public function test_database_lock_coordinates_ownership_in_wordpress(): void { public function test_database_lock_replaces_expired_ownership_without_allowing_the_previous_owner_to_release_it(): void { $table = $this->table('expired_locks'); - $wpSchema = new Schema($this->database); + $wpSchema = $this->schema; $lockTable = new LockTable($this->database, $table); - $clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); - $lock = new DatabaseLock($this->database, $table, $clock); + $lock = new DatabaseLock($this->database, $table); $wpSchema->createOrUpdate($lockTable); @@ -300,7 +310,11 @@ public function test_database_lock_replaces_expired_ownership_without_allowing_t $this->assertNotNull($first); - $clock->advance(60); + $this->database->execute( + 'UPDATE %i SET expires_at = TIMESTAMPADD(SECOND, -1, UTC_TIMESTAMP(6)) WHERE name = %s', + $table, + 'foundation:database:takeover' + ); $second = $lock->acquire('foundation:database:takeover', 60); @@ -313,6 +327,66 @@ public function test_database_lock_replaces_expired_ownership_without_allowing_t $wpSchema->drop($lockTable); } + public function test_database_lock_compares_names_and_owners_by_exact_bytes(): void { + $table = $this->table('exact_locks'); + $wpSchema = $this->schema; + $lockTable = new LockTable($this->database, $table); + $lock = new DatabaseLock($this->database, $table); + + $wpSchema->createOrUpdate($lockTable); + + $upper = $lock->acquire('Catalog:1', 60); + $lower = $lock->acquire('catalog:1', 60); + + $this->assertNotNull($upper); + $this->assertNotNull($lower); + + $this->database->execute( + 'UPDATE %i SET owner = %s WHERE name = %s', + $table, + 'owner', + $lower->name + ); + + $this->assertFalse($lock->release(new LockToken($lower->name, 'OWNER', $lower->expiresAt))); + $this->assertTrue($lock->release(new LockToken($lower->name, 'owner', $lower->expiresAt))); + $this->assertTrue($lock->release($upper)); + + $wpSchema->drop($lockTable); + } + + public function test_lock_table_reconciles_an_existing_previous_definition(): void { + $table = $this->table('previous_lock_schema'); + $wpSchema = $this->schema; + $lockTable = new LockTable($this->database, $table); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + name varchar(191) NOT NULL, + owner varchar(64) NOT NULL, + expires_at datetime NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (name), + KEY expires_at (expires_at) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + (new TableCollection($wpSchema, [$lockTable]))->create(); + + $name = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'name'); + $owner = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'owner'); + $expiration = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'expires_at'); + + $this->assertSame('varbinary(191)', strtolower((string) ($name['Type'] ?? ''))); + $this->assertSame('varbinary(64)', strtolower((string) ($owner['Type'] ?? ''))); + $this->assertSame('datetime(6)', strtolower((string) ($expiration['Type'] ?? ''))); + + $wpSchema->drop($lockTable); + } + public function test_provider_registers_wordpress_prefixed_database_services(): void { $container = $this->newContainer(); From 371e5747147c33450afee03d8bbe88cefc306c2b Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 19 Aug 2026 16:29:34 -0600 Subject: [PATCH 28/81] Fix database configuration and generator reliability - Preserve exact configured migration and lock table names while prefixing defaults. - Detect existing provider registrations through the PHP AST. - Improve generator failure reporting and idempotent reruns. - Correct irreversible migration stubs and expand setup documentation. - Add coverage for formatting, permissions, and provider configuration. --- README.md | 21 +-- .../Make/Database/MigrationCommand.php | 11 +- .../Database/ProviderRegistrationEditor.php | 20 +-- .../Commands/Make/Database/TableCommand.php | 11 +- src/Cli/Generation/Php/PhpSourceEditor.php | 109 ++++++++++++- src/Database/Lock/DatabaseLock.php | 12 +- src/Database/Migration/Repository.php | 14 +- src/Database/README.md | 12 +- src/Database/Table/Tables/LockTable.php | 4 +- src/Database/Table/Tables/MigrationTable.php | 4 +- src/Database/stubs/migration.stub | 2 +- src/Lock/InMemoryLock.php | 4 +- src/WPCli/README.md | 52 ++++-- .../register-wpcli-migrate-command.php | 4 +- .../Cli/Commands/Make/DatabaseCommandTest.php | 150 +++++++++++++++++- .../Cli/Generation/PhpSourceEditorTest.php | 19 +++ tests/Unit/Database/Cli/MigrateTest.php | 8 +- tests/Unit/Database/Lock/DatabaseLockTest.php | 7 +- .../Unit/Database/Migration/MigratorTest.php | 4 +- .../Database/Migration/RepositoryTest.php | 6 +- .../Database/Table/Tables/LockTableTest.php | 10 +- .../Table/Tables/MigrationTableTest.php | 10 +- ...-provider-without-registration-points.stub | 10 ++ .../formatted-database-provider.stub | 30 ++++ ...-class-reference-without-registration.stub | 17 ++ .../nested-migration-registration.stub | 18 +++ .../Database/DatabaseProviderTest.php | 4 + .../Database/DatabaseIntegrationTest.php | 10 +- 28 files changed, 488 insertions(+), 95 deletions(-) create mode 100644 tests/_data/cli/generation/php-source-editor/database-provider-without-registration-points.stub create mode 100644 tests/_data/cli/generation/php-source-editor/formatted-database-provider.stub create mode 100644 tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub create mode 100644 tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub diff --git a/README.md b/README.md index 06e4855..98f88fe 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,18 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f > This monorepo splits each package out into their own sub-repository, if you only need a specific component you can install only that specific one. ## Repositories -- [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container) -- [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) -- [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) -- [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) -- [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) -- [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) -- [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) -- [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) -- [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) + +| Package | Use when | Installation | +| --- | --- | --- | +| [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container) | The application needs Foundation's DI container and providers | Runtime | +| [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) | Work should pass through an ordered middleware-style pipeline | Runtime | +| [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) | Services need a configured PSR logger | Runtime | +| [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) | Code needs the portable lock contract or process-local test implementation | Runtime | +| [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) | Multiple processes or servers coordinate through dedicated Redis | Runtime | +| [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries, migrations, or database-backed locks | Runtime | +| [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) | Services need injectable ULID generation and validation | Runtime | +| [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) | A shipped WordPress plugin exposes WP-CLI commands | Runtime | +| [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) | Developers need Foundation generators or monorepo maintenance commands | Development | ## Installation diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index 43ef3d0..9f4e384 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -57,7 +57,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->validateExplicitProviderUpdate($input); $file = $this->generatedFile($input); $this->fileWriter->write($file, (bool) $input->getOption('force')); - $providerPath = $this->updateProvider($input); + $providerPath = $this->updateProvider($input, $output); } catch (RuntimeException $exception) { $output->writeln('' . $exception->getMessage() . ''); @@ -154,7 +154,7 @@ private function validateExplicitProviderUpdate(InputInterface $input): void { )); } - private function updateProvider(InputInterface $input): ?string { + private function updateProvider(InputInterface $input, OutputInterface $output): ?string { $project = $this->autoloadResolver->project(); $className = $this->classNameResolver->className((string) $input->getArgument('name')); $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); @@ -187,6 +187,13 @@ private function updateProvider(InputInterface $input): ?string { )); } + $output->writeln(sprintf( + 'Provider not updated: %s (%s). Register %s manually.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status), + $className + )); + return null; } diff --git a/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php index e634f11..e01a1b5 100644 --- a/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php +++ b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php @@ -77,10 +77,6 @@ private function addRegistration(string $providerPath, string $class, string $cl return self::NOT_FOUND; } - if ($write && ! is_writable($providerPath)) { - return self::NOT_WRITABLE; - } - $contents = (string) file_get_contents($providerPath); if (! $this->sourceEditor->canParse($contents)) { @@ -93,7 +89,7 @@ private function addRegistration(string $providerPath, string $class, string $cl $fullyQualifiedClass = $classNamespace . '\\' . $class; - if ($this->sourceEditor->hasImport($contents, $fullyQualifiedClass) && str_contains($contents, $registration)) { + if ($this->sourceEditor->hasContainerSingleton($contents, $fullyQualifiedClass)) { return self::ALREADY_REGISTERED; } @@ -101,6 +97,10 @@ private function addRegistration(string $providerPath, string $class, string $cl return self::IMPORT_COLLISION; } + if (! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + if (! $write) { return self::UPDATED; } @@ -129,10 +129,6 @@ private function addMergeArrayVarRegistration(string $providerPath, string $clas return self::NOT_FOUND; } - if ($write && ! is_writable($providerPath)) { - return self::NOT_WRITABLE; - } - $contents = (string) file_get_contents($providerPath); if (! $this->sourceEditor->canParse($contents)) { @@ -148,7 +144,7 @@ private function addMergeArrayVarRegistration(string $providerPath, string $clas $fullyQualifiedClass = $classNamespace . '\\' . $class; $registration = sprintf('%s->get(%s::class),', $containerExpression, $class); - if ($this->sourceEditor->hasImport($contents, $fullyQualifiedClass) && str_contains($contents, $registration)) { + if ($this->sourceEditor->mergeArrayVarContainsClass($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST, $fullyQualifiedClass)) { return self::ALREADY_REGISTERED; } @@ -156,6 +152,10 @@ private function addMergeArrayVarRegistration(string $providerPath, string $clas return self::IMPORT_COLLISION; } + if (! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + if (! $write) { return self::UPDATED; } diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php index 4120a4d..04c59a8 100644 --- a/src/Cli/Commands/Make/Database/TableCommand.php +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -56,7 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->validateExplicitProviderUpdate($input); $file = $this->generatedFile($input); $this->fileWriter->write($file, (bool) $input->getOption('force')); - $providerPath = $this->updateProvider($input); + $providerPath = $this->updateProvider($input, $output); } catch (RuntimeException $exception) { $output->writeln('' . $exception->getMessage() . ''); @@ -133,7 +133,7 @@ private function validateExplicitProviderUpdate(InputInterface $input): void { )); } - private function updateProvider(InputInterface $input): ?string { + private function updateProvider(InputInterface $input, OutputInterface $output): ?string { $project = $this->autoloadResolver->project(); $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); @@ -166,6 +166,13 @@ private function updateProvider(InputInterface $input): ?string { )); } + $output->writeln(sprintf( + 'Provider not updated: %s (%s). Register %s manually.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status), + $className + )); + return null; } diff --git a/src/Cli/Generation/Php/PhpSourceEditor.php b/src/Cli/Generation/Php/PhpSourceEditor.php index 71b6a9c..c471a2f 100644 --- a/src/Cli/Generation/Php/PhpSourceEditor.php +++ b/src/Cli/Generation/Php/PhpSourceEditor.php @@ -102,6 +102,39 @@ public function mergeArrayVarContainerExpression(string $contents, string $class return $this->mergeArrayVarTarget($contents, $class, $constant)?->containerExpression; } + public function hasContainerSingleton(string $contents, string $fullyQualifiedClass): bool { + $statements = $this->parse($contents); + + if ($statements === null) { + return false; + } + + $aliases = $this->classAliases($contents, $fullyQualifiedClass); + + return $this->findNode( + $statements, + fn (Node $node): bool => $this->isContainerSingleton($node, $fullyQualifiedClass, $aliases) + ) !== null; + } + + public function mergeArrayVarContainsClass(string $contents, string $class, string $constant, string $fullyQualifiedClass): bool { + $target = $this->mergeArrayVarTarget($contents, $class, $constant); + + if ($target === null) { + return false; + } + + $aliases = $this->classAliases($contents, $fullyQualifiedClass); + + foreach ($target->registrationList->items as $item) { + if ($this->isContainerGet($item->value, $target->containerExpression, $fullyQualifiedClass, $aliases)) { + return true; + } + } + + return false; + } + public function insertIntoMergeArrayVar(string $contents, string $class, string $constant, string $statement, ?string $beforeComment = null): ?string { $target = $this->mergeArrayVarTarget($contents, $class, $constant); @@ -293,7 +326,7 @@ private function mergeArrayVarTarget(string $contents, string $class, string $co return null; } - $aliases = $this->classAliases($contents, $class); + $aliases = $this->classAliases($contents, $class, true); $call = $this->findNode($statements, fn (Node $node): bool => $this->isMergeArrayVarCall($node, $class, $constant, $aliases)); if (! $call instanceof Expr\MethodCall) { @@ -306,12 +339,12 @@ private function mergeArrayVarTarget(string $contents, string $class, string $co /** * @return list */ - private function classAliases(string $contents, string $class): array { + private function classAliases(string $contents, string $class, bool $allowPrefixed = false): array { $class = trim($class, '\\'); $aliases = []; foreach ($this->imports($contents) as $import) { - if ($import['class'] === $class || str_ends_with($import['class'], '\\' . $class)) { + if ($import['class'] === $class || ($allowPrefixed && str_ends_with($import['class'], '\\' . $class))) { $aliases[] = $import['alias']; } } @@ -352,6 +385,76 @@ private function isMergeArrayVarCall(Node $node, string $class, string $constant return in_array($referencedClass, $aliases, true); } + /** + * @param list $aliases + */ + private function isContainerSingleton(Node $node, string $class, array $aliases): bool { + if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'singleton') { + return false; + } + + if (! $this->isThisContainer($node->var)) { + return false; + } + + $argument = $node->args[0]->value ?? null; + + return $argument instanceof Expr\ClassConstFetch + && $argument->class instanceof Node\Name + && $argument->name instanceof Node\Identifier + && $argument->name->toString() === 'class' + && $this->isClassReference($argument->class, $class, $aliases); + } + + /** + * @param list $aliases + */ + private function isContainerGet(Node $node, string $containerExpression, string $class, array $aliases): bool { + if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'get') { + return false; + } + + if (! $this->matchesContainerExpression($node->var, $containerExpression)) { + return false; + } + + $argument = $node->args[0]->value ?? null; + + return $argument instanceof Expr\ClassConstFetch + && $argument->class instanceof Node\Name + && $argument->name instanceof Node\Identifier + && $argument->name->toString() === 'class' + && $this->isClassReference($argument->class, $class, $aliases); + } + + private function matchesContainerExpression(Node $node, string $containerExpression): bool { + if ($containerExpression === '$this->container') { + return $this->isThisContainer($node); + } + + return $node instanceof Expr\Variable + && is_string($node->name) + && '$' . $node->name === $containerExpression; + } + + /** + * @param list $aliases + */ + private function isClassReference(Node\Name $name, string $class, array $aliases): bool { + $reference = trim($name->toString(), '\\'); + $class = trim($class, '\\'); + + if ($name instanceof Node\Name\FullyQualified) { + return $reference === $class; + } + + if (str_contains($reference, '\\')) { + return $reference === $class; + } + + return in_array($reference, $aliases, true); + } + private function isThisContainer(Node $node): bool { return $node instanceof Expr\PropertyFetch && $node->var instanceof Expr\Variable diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php index 14bedd5..9600e54 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/Database/Lock/DatabaseLock.php @@ -50,7 +50,7 @@ public function acquire(string $name, int $ttl): ?LockToken { TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), expires_at )', - $this->database->tableName($this->table), + $this->table, $name, $owner, $ttl, @@ -62,7 +62,7 @@ public function acquire(string $name, int $ttl): ?LockToken { 'SELECT expires_at FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', - $this->database->tableName($this->table), + $this->table, $name, $owner ); @@ -88,7 +88,7 @@ public function release(LockToken $token): bool { try { return $this->database->execute( 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', - $this->database->tableName($this->table), + $this->table, $token->name, $token->owner ) > 0; @@ -108,7 +108,7 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { $this->database->execute( 'UPDATE %i SET expires_at = TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), updated_at = UTC_TIMESTAMP(6) WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', - $this->database->tableName($this->table), + $this->table, $ttl, $token->name, $token->owner @@ -116,7 +116,7 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { $row = $this->database->row( 'SELECT expires_at FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', - $this->database->tableName($this->table), + $this->table, $token->name, $token->owner ); @@ -141,7 +141,7 @@ public function isAcquired(string $name): bool { try { return $this->database->row( 'SELECT name FROM %i WHERE name = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', - $this->database->tableName($this->table), + $this->table, $name ) !== null; } catch (DatabaseException $exception) { diff --git a/src/Database/Migration/Repository.php b/src/Database/Migration/Repository.php index 3dffce6..2faec04 100644 --- a/src/Database/Migration/Repository.php +++ b/src/Database/Migration/Repository.php @@ -26,7 +26,7 @@ public function all(): array { foreach ($this->database->rows(sprintf( 'SELECT id, migration, batch, ran_at FROM %s ORDER BY id ASC', - $this->database->quoteIdentifier($this->database->tableName($this->table)) + $this->database->quoteIdentifier($this->table) )) as $row) { $record = $this->recordFromRow($row); @@ -39,7 +39,7 @@ public function all(): array { public function hasRun(string $migration): bool { return $this->database->row( 'SELECT id FROM %i WHERE migration = %s LIMIT 1', - $this->database->tableName($this->table), + $this->table, $migration ) !== null; } @@ -49,7 +49,7 @@ public function recordRun(string $migration, int $batch): Record { $this->database->execute( 'INSERT INTO %i (migration, batch, ran_at) VALUES (%s, %d, %s)', - $this->database->tableName($this->table), + $this->table, $migration, $batch, $ranAt->format('Y-m-d H:i:s') @@ -57,7 +57,7 @@ public function recordRun(string $migration, int $batch): Record { $row = $this->database->row( 'SELECT id, migration, batch, ran_at FROM %i WHERE migration = %s LIMIT 1', - $this->database->tableName($this->table), + $this->table, $migration ); @@ -71,7 +71,7 @@ public function recordRun(string $migration, int $batch): Record { public function deleteRun(string $migration): bool { return $this->database->execute( 'DELETE FROM %i WHERE migration = %s', - $this->database->tableName($this->table), + $this->table, $migration ) > 0; } @@ -85,7 +85,7 @@ public function nextBatch(): int { public function latestBatch(): ?int { $row = $this->database->row(sprintf( 'SELECT MAX(batch) AS batch FROM %s', - $this->database->quoteIdentifier($this->database->tableName($this->table)) + $this->database->quoteIdentifier($this->table) )); if ($row === null || $row['batch'] === null) { @@ -103,7 +103,7 @@ public function recordsForBatch(int $batch): array { fn (array $row): Record => $this->recordFromRow($row), $this->database->rows( 'SELECT id, migration, batch, ran_at FROM %i WHERE batch = %d ORDER BY id ASC', - $this->database->tableName($this->table), + $this->table, $batch ) ); diff --git a/src/Database/README.md b/src/Database/README.md index 74c9fc4..91ef755 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -232,6 +232,8 @@ $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): If migrations are added before registering `DatabaseProvider`, the provider will preserve the existing values. Other providers may also add migrations after `DatabaseProvider` is registered, as long as they do so before the migration collection or migrator is resolved. +Register contributing providers in the order their migrations must run. The migration collection preserves registration order, so a migration that depends on an earlier schema or data change must be contributed after that dependency. + Application feature tables should usually be represented by migrations. If a table only needs normal create/drop behavior, define it with `StellarWP\Foundation\Database\Contracts\Table`, wrap it in `StellarWP\Foundation\Database\Table\CreateTable`, and add that migration instance to `DatabaseProvider::MIGRATIONS`. ```php @@ -298,6 +300,14 @@ final readonly class PluginUpdater `run()`, `rollback()`, and `refresh()` prepare the migration store automatically before executing migrations. +Registering `DatabaseProvider` does not execute migrations. Call `Migrator::run()` from the application's activation or version-update lifecycle, or run `wp nx migrate --run` during deployment. Completed migration IDs are skipped on later runs. Because migration changes and their ledger updates are not one atomic operation, write `up()` and `down()` methods so they can recover from retries after partial work or failed ledger writes. + +## Evolving Tables + +`TableDefinition` and `Schema::createOrUpdate()` use WordPress `dbDelta()` to create tables and reconcile changes that `dbDelta()` supports, such as adding columns and indexes. They should not be relied on to remove or rename columns, replace indexes, manage foreign keys, or backfill data. + +Use an explicit, versioned migration for destructive or data-dependent changes. Such migrations can inspect table and index state with `Schema::hasTable()` and `Schema::hasIndex()`; inject `Database` when column inspection through `Database::columnExists()` is required. Use `Schema::execute()` or focused helpers such as `dropIndex()` for the required SQL. Make rollback behavior explicit; throw `IrreversibleMigration::forMigration(self::ID)` when a migration cannot be safely reversed. + ## Generators If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a database provider, table class, and matching migration in a consuming WordPress project: @@ -324,7 +334,7 @@ The table generator writes a Snake_Case table class under `src/Database/Tables` Migration names matching `Create_*_Table`, or migrations generated with `--table-class`, use the table-backed migration stub and wrap the table in `CreateTable`. Other migration names use the generic migration stub. -If `src/Database/Provider.php` exists and contains the generated provider markers, the table and migration generators automatically add imports and registrations to that provider. Pass `--provider=path/to/Provider.php` to update a non-standard provider file. Re-running a generator does not duplicate existing provider imports or registrations. If you generate a custom provider class name or location, pass `--provider` when generating later tables or migrations. +If `src/Database/Provider.php` exists and contains the generated provider registration points, the table and migration generators automatically add imports and registrations to that provider. Pass `--provider=path/to/Provider.php` to update a non-standard provider file. Re-running a generator does not duplicate existing provider imports or registrations, including after WordPress code formatting. If an existing conventional provider cannot be updated safely, the generator creates the requested class and prints a warning with the manual registration step. An explicitly requested `--provider` that cannot be updated fails before generating the class. Common options: diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php index c896839..4870097 100644 --- a/src/Database/Table/Tables/LockTable.php +++ b/src/Database/Table/Tables/LockTable.php @@ -2,7 +2,6 @@ namespace StellarWP\Foundation\Database\Table\Tables; -use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Table\Column; use StellarWP\Foundation\Database\Table\TableDefinition; @@ -15,7 +14,6 @@ public const string ID = 'foundation_database_locks_table'; public function __construct( - private Database $database, private string $table ) { } @@ -25,7 +23,7 @@ public function id(): string { } public function name(): string { - return $this->database->tableName($this->table); + return $this->table; } public function definition(): TableDefinition { diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php index 788652d..6fdae62 100644 --- a/src/Database/Table/Tables/MigrationTable.php +++ b/src/Database/Table/Tables/MigrationTable.php @@ -2,7 +2,6 @@ namespace StellarWP\Foundation\Database\Table\Tables; -use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Table\TableDefinition; @@ -14,7 +13,6 @@ public const string ID = 'foundation_database_migrations_table'; public function __construct( - private Database $database, private string $table ) { } @@ -24,7 +22,7 @@ public function id(): string { } public function name(): string { - return $this->database->tableName($this->table); + return $this->table; } public function definition(): TableDefinition { diff --git a/src/Database/stubs/migration.stub b/src/Database/stubs/migration.stub index 6784a25..b576a74 100644 --- a/src/Database/stubs/migration.stub +++ b/src/Database/stubs/migration.stub @@ -19,7 +19,7 @@ final readonly class {{ class }} implements Migration { } public function down( Schema $schema ): void { - throw new IrreversibleMigration( self::ID ); + throw IrreversibleMigration::forMigration( self::ID ); } } diff --git a/src/Lock/InMemoryLock.php b/src/Lock/InMemoryLock.php index 765c0c2..6fc86cf 100644 --- a/src/Lock/InMemoryLock.php +++ b/src/Lock/InMemoryLock.php @@ -14,8 +14,8 @@ * Process-local lock implementation useful for tests and single-process work. * * This implementation is not a cross-request or distributed lock. Use a - * persistent implementation, such as a future database-backed lock, when - * multiple PHP processes must coordinate ownership. + * persistent Foundation lock implementation when multiple PHP processes + * must coordinate ownership. */ final class InMemoryLock implements Lock { diff --git a/src/WPCli/README.md b/src/WPCli/README.md index 15ee7ce..826019b 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -113,38 +113,60 @@ final class {{ class }} extends Command ## Provider Setup -Applications should register `StellarWP\Foundation\WPCli\Provider` once in the application provider list. Feature-specific providers can then add command classes to the shared command list with `mergeArrayVar()`. +Applications should register `StellarWP\Foundation\WPCli\WPCliProvider` once, before feature providers that contribute commands. Feature providers can then add resolved command instances to the shared command list with `mergeArrayVar()`. Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin. That provider belongs to the developer-facing `foundation` console binary, not plugin runtime bootstrap. -Generated command classes use Strauss-prefixed Foundation imports automatically when `extra.strauss.namespace_prefix` is configured. Handwritten provider code is still application code, so projects using Strauss with `update_call_sites=false` may need to use their prefixed Foundation namespace in the imports below. +Generated command classes use Strauss-prefixed Foundation imports automatically when `extra.strauss.namespace_prefix` is configured. Handwritten provider code is still application code, so projects using Strauss with `update_call_sites=false` may need to prefix the Foundation and third-party imports shown below, including `lucatume\DI52\Container`. ```php > - */ - private const array COMMANDS = [ - Sync_Command::class, - ]; - public function register(): void { - $this->container->singleton( WPCliProvider::COMMAND_PREFIX, self::COMMAND_PREFIX ); - $this->container->mergeArrayVar( WPCliProvider::COMMANDS, self::COMMANDS ); + $this->container->when( Sync_Command::class ) + ->needs( '$commandPrefix' ) + ->give( static fn ( C $c ): string => $c->get( WPCliProvider::COMMAND_PREFIX ) ); + + $this->container->singleton( Sync_Command::class ); + $this->container->mergeArrayVar( + WPCliProvider::COMMANDS, + static fn ( C $c ): array => [ + $c->get( Sync_Command::class ), + ] + ); } } ``` +Register both providers with the container in this order: + +```php +use Acme\App\Cli\Wp_Cli_Provider; +use StellarWP\Foundation\WPCli\WPCliProvider; + +$container->register( WPCliProvider::class ); +$container->register( Wp_Cli_Provider::class ); +``` + The Foundation WP-CLI provider uses `cli_init` internally so commands are registered only during WP-CLI command bootstrap, after all application providers have had a chance to add command classes. -If your application wants a different default command prefix without a feature-specific CLI provider, bind `WPCliProvider::COMMAND_PREFIX` before WP-CLI's `cli_init` hook runs. +Set `wpcli.command_prefix` in the application's Foundation configuration when it needs a prefix other than `nx`: + +```php +return [ + 'wpcli' => [ + 'command_prefix' => 'acme', + ], +]; +``` + +See [Foundation Container configuration](https://github.com/stellarwp/foundation-container#container-configuration) for loading the `config.php` array into the container's `Dot` binding. diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index aa754fd..261e913 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -86,8 +86,8 @@ public function down(SchemaContract $schema): void { 'foundation', new Migrator( new Store(new TableCollection($schema, [ - new MigrationTable($database, $migrationTable), - new LockTable($database, $lockTable), + new MigrationTable($migrationTable), + new LockTable($lockTable), ])), new Runner( new Repository($database, $migrationTable), diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index bbfb404..fbae537 100644 --- a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -111,7 +111,7 @@ public function test_it_generates_a_generic_database_migration_for_non_table_nam $this->assertStringContainsString('final readonly class Bump_Version implements Migration {', $contents); $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Exceptions\\IrreversibleMigration;', $contents); $this->assertStringContainsString("public const string ID = '2026_06_26_000003_bump_version';", $contents); - $this->assertStringContainsString('throw new IrreversibleMigration( self::ID );', $contents); + $this->assertStringContainsString('throw IrreversibleMigration::forMigration( self::ID );', $contents); $this->assertStringNotContainsString('CreateTable', $contents); $this->assertStringNotContainsString('Bump_Version_Table', $contents); } @@ -357,6 +357,38 @@ public function test_explicit_database_provider_update_fails_when_the_provider_h $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); } + public function test_explicit_database_provider_updates_fail_before_writing_when_the_provider_is_not_writable(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + chmod($providerPath, 0444); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $tableStatus); + $this->assertSame(Command::FAILURE, $migrationStatus); + $this->assertStringContainsString('file is not writable', $tableTester->getDisplay()); + $this->assertStringContainsString('file is not writable', $migrationTester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + public function test_explicit_database_provider_migration_update_fails_when_the_provider_has_no_migration_anchor(): void { $root = $this->temporaryProject([ 'require' => [ @@ -390,6 +422,53 @@ public function register(): void { $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); } + public function test_table_generator_warns_when_the_conventional_provider_cannot_be_updated(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/src/Database', 0777, true); + file_put_contents( + $root . '/src/Database/Provider.php', + file_get_contents($this->data_dir('cli/generation/php-source-editor/database-provider-without-registration-points.stub')) + ); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute(['name' => 'reports']); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($root . '/src/Database/Tables/Reports_Table.php'); + $this->assertStringContainsString('Provider not updated: src/Database/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('Register Reports_Table manually.', $tester->getDisplay()); + } + + public function test_migration_generator_warns_when_the_conventional_provider_cannot_be_updated(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/src/Database', 0777, true); + file_put_contents( + $root . '/src/Database/Provider.php', + file_get_contents($this->data_dir('cli/generation/php-source-editor/database-provider-without-registration-points.stub')) + ); + + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($root . '/src/Database/Migrations/Create_Reports_Table.php'); + $this->assertStringContainsString('Provider not updated: src/Database/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('Register Create_Reports_Table manually.', $tester->getDisplay()); + } + public function test_database_provider_migration_update_preserves_legacy_migration_marker_position(): void { $root = $this->temporaryProject(); @@ -851,6 +930,75 @@ classNamespace: 'Acme\\Plugin\\Database\\Tables' $this->assertSame($contents, (string) file_get_contents($providerPath)); } + public function test_database_provider_updater_is_idempotent_after_wordpress_formatting(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + $contents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/formatted-database-provider.stub')); + file_put_contents($providerPath, $contents); + + $tableStatus = $this->providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + $migrationStatus = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $tableStatus); + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $migrationStatus); + $this->assertSame($contents, (string) file_get_contents($providerPath)); + } + + public function test_database_generators_do_not_duplicate_wordpress_formatted_provider_registrations_when_forced(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + $providerContents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/formatted-database-provider.stub')); + file_put_contents( + $providerPath, + $providerContents + ); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableTester->execute(['name' => 'reports']); + chmod($providerPath, 0444); + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + '--force' => true, + '--provider' => 'src/Database/Provider.php', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--force' => true, + '--provider' => 'src/Database/Provider.php', + ]); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(Command::SUCCESS, $tableStatus); + $this->assertSame(Command::SUCCESS, $migrationStatus); + $this->assertSame($providerContents, $contents); + } + public function test_explicit_database_provider_update_fails_on_import_short_name_collisions(): void { $root = $this->temporaryProject([ 'require' => [ diff --git a/tests/Unit/Cli/Generation/PhpSourceEditorTest.php b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php index 28f56ff..3a07e12 100644 --- a/tests/Unit/Cli/Generation/PhpSourceEditorTest.php +++ b/tests/Unit/Cli/Generation/PhpSourceEditorTest.php @@ -124,6 +124,25 @@ public function test_it_uses_space_indentation_when_inserting_into_space_indente ); } + public function test_it_does_not_treat_an_unresolved_class_name_as_a_merge_array_registration(): void { + $editor = $this->editor(); + $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + $target = 'Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table'; + + $this->assertFalse($editor->mergeArrayVarContainsClass( + $this->fixture('migration-class-reference-without-registration'), + $class, + 'MIGRATIONS', + $target + )); + $this->assertFalse($editor->mergeArrayVarContainsClass( + $this->fixture('nested-migration-registration'), + $class, + 'MIGRATIONS', + $target + )); + } + private function editor(): PhpSourceEditor { return new PhpSourceEditor( parserFactory: new ParserFactory(), diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index 26a72db..e9bb06c 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -39,8 +39,8 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { 'foundation', new Migrator( new Store(new TableCollection($wpSchema, [ - new MigrationTable($database, 'wp_nexcess_foundation_migrations'), - new LockTable($database, 'wp_nexcess_foundation_locks'), + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks'), ])), new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock()), new MigrationCollection() @@ -232,8 +232,8 @@ private function newCommand(): array { 'foundation', new Migrator( new Store(new TableCollection($wpSchema, [ - new MigrationTable($database, 'wp_nexcess_foundation_migrations'), - new LockTable($database, 'wp_nexcess_foundation_locks'), + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks'), ])), $runner, new MigrationCollection([ diff --git a/tests/Unit/Database/Lock/DatabaseLockTest.php b/tests/Unit/Database/Lock/DatabaseLockTest.php index 7573c92..2fb6ddf 100644 --- a/tests/Unit/Database/Lock/DatabaseLockTest.php +++ b/tests/Unit/Database/Lock/DatabaseLockTest.php @@ -23,7 +23,7 @@ protected function setUp(): void { parent::setUp(); $this->database = new FakeDatabase(); - $this->lock = new DatabaseLock($this->database, 'wp_nexcess_foundation_locks'); + $this->lock = new DatabaseLock($this->database, 'network_foundation_locks'); } public function test_it_acquires_a_database_lock_when_the_written_owner_matches(): void { @@ -62,7 +62,7 @@ public function test_it_releases_a_lock_for_the_matching_owner(): void { $this->database->executeResults[] = 1; $this->assertTrue($this->lock->release($token)); - $this->assertStringContainsString('DELETE FROM `wp_nexcess_foundation_locks`', $this->database->executed[0]); + $this->assertStringContainsString('DELETE FROM `network_foundation_locks`', $this->database->executed[0]); $this->assertStringContainsString('owner', $this->database->executed[0]); $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->executed[0]); } @@ -81,7 +81,7 @@ public function test_it_refreshes_a_lock_for_the_matching_owner(): void { $this->assertInstanceOf(LockToken::class, $refreshed); $this->assertSame('2026-01-01 00:02:00.654321', $refreshed->expiresAt->format('Y-m-d H:i:s.u')); - $this->assertStringContainsString('UPDATE `wp_nexcess_foundation_locks`', $this->database->executed[0]); + $this->assertStringContainsString('UPDATE `network_foundation_locks`', $this->database->executed[0]); $this->assertStringContainsString('TIMESTAMPADD(SECOND, 120, UTC_TIMESTAMP(6))', $this->database->executed[0]); } @@ -176,7 +176,6 @@ public function test_it_rejects_names_longer_than_the_database_column(): void { public function test_it_normalizes_database_failures(callable $operation, string $databaseMethod): void { $database = $this->mock(Database::class); - $database->shouldReceive('tableName')->andReturn('wp_nexcess_foundation_locks'); $database->shouldReceive($databaseMethod)->andThrow(new QueryException('Query failed.', 'SELECT 1')); try { diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index a271adf..653ac62 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -108,8 +108,8 @@ private function newMigrator(): array { return [ new Migrator( new Store(new TableCollection($schema, [ - new MigrationTable($database, 'wp_nexcess_foundation_migrations'), - new LockTable($database, 'wp_nexcess_foundation_locks'), + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks'), ])), new Runner($repository, $schema, new InMemoryLock()), new Collection([ diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php index 2f77b79..61f4a49 100644 --- a/tests/Unit/Database/Migration/RepositoryTest.php +++ b/tests/Unit/Database/Migration/RepositoryTest.php @@ -16,7 +16,7 @@ protected function setUp(): void { parent::setUp(); $this->database = new FakeDatabase(); - $this->repository = new Repository($this->database, 'wp_nexcess_foundation_migrations'); + $this->repository = new Repository($this->database, 'network_foundation_migrations'); } public function test_it_returns_all_migration_records_indexed_by_migration_id(): void { @@ -46,14 +46,14 @@ public function test_it_records_a_migration_run(): void { $record = $this->repository->recordRun('2026_01_01_000001_create_users', 2); $this->assertSame(2, $record->batch); - $this->assertStringContainsString('INSERT INTO `wp_nexcess_foundation_migrations`', $this->database->executed[0]); + $this->assertStringContainsString('INSERT INTO `network_foundation_migrations`', $this->database->executed[0]); } public function test_it_deletes_a_migration_run(): void { $this->database->executeResults[] = 1; $this->assertTrue($this->repository->deleteRun('2026_01_01_000001_create_users')); - $this->assertStringContainsString('DELETE FROM `wp_nexcess_foundation_migrations`', $this->database->executed[0]); + $this->assertStringContainsString('DELETE FROM `network_foundation_migrations`', $this->database->executed[0]); } public function test_it_calculates_the_next_batch(): void { diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php index 08b1773..92b2e3b 100644 --- a/tests/Unit/Database/Table/Tables/LockTableTest.php +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -15,13 +15,13 @@ public function test_it_creates_the_lock_table(): void { $schema = new DatabaseSchema($database, static function (string $sql) use (&$statements): void { $statements[] = $sql; }); - $table = new LockTable($database, 'wp_nexcess_foundation_locks'); + $table = new LockTable('network_foundation_locks'); $schema->createOrUpdate($table); $this->assertSame(LockTable::ID, $table->id()); - $this->assertSame('wp_nexcess_foundation_locks', $table->name()); - $this->assertStringContainsString('CREATE TABLE `wp_nexcess_foundation_locks`', $statements[0]); + $this->assertSame('network_foundation_locks', $table->name()); + $this->assertStringContainsString('CREATE TABLE `network_foundation_locks`', $statements[0]); $this->assertStringContainsString('`name` varbinary(191)', $statements[0]); $this->assertStringContainsString('`owner` varbinary(64)', $statements[0]); $this->assertStringContainsString('`expires_at` datetime(6)', $statements[0]); @@ -34,10 +34,10 @@ public function test_it_creates_the_lock_table(): void { public function test_it_drops_the_lock_table(): void { $database = new FakeDatabase(); $schema = new DatabaseSchema($database, static fn (string $sql): array => []); - $table = new LockTable($database, 'wp_nexcess_foundation_locks'); + $table = new LockTable('network_foundation_locks'); $schema->drop($table); - $this->assertSame('DROP TABLE IF EXISTS `wp_nexcess_foundation_locks`', $database->executed[0]); + $this->assertSame('DROP TABLE IF EXISTS `network_foundation_locks`', $database->executed[0]); } } diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php index ca53661..ef3086a 100644 --- a/tests/Unit/Database/Table/Tables/MigrationTableTest.php +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -15,23 +15,23 @@ public function test_it_creates_the_migration_table(): void { $schema = new DatabaseSchema($database, static function (string $sql) use (&$statements): void { $statements[] = $sql; }); - $table = new MigrationTable($database, 'wp_nexcess_foundation_migrations'); + $table = new MigrationTable('network_foundation_migrations'); $schema->createOrUpdate($table); $this->assertSame(MigrationTable::ID, $table->id()); - $this->assertSame('wp_nexcess_foundation_migrations', $table->name()); - $this->assertStringContainsString('CREATE TABLE `wp_nexcess_foundation_migrations`', $statements[0]); + $this->assertSame('network_foundation_migrations', $table->name()); + $this->assertStringContainsString('CREATE TABLE `network_foundation_migrations`', $statements[0]); $this->assertStringContainsString('UNIQUE KEY `migration`', $statements[0]); } public function test_it_drops_the_migration_table(): void { $database = new FakeDatabase(); $schema = new DatabaseSchema($database, static fn (string $sql): array => []); - $table = new MigrationTable($database, 'wp_nexcess_foundation_migrations'); + $table = new MigrationTable('network_foundation_migrations'); $schema->drop($table); - $this->assertSame('DROP TABLE IF EXISTS `wp_nexcess_foundation_migrations`', $database->executed[0]); + $this->assertSame('DROP TABLE IF EXISTS `network_foundation_migrations`', $database->executed[0]); } } diff --git a/tests/_data/cli/generation/php-source-editor/database-provider-without-registration-points.stub b/tests/_data/cli/generation/php-source-editor/database-provider-without-registration-points.stub new file mode 100644 index 0000000..1a082e5 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/database-provider-without-registration-points.stub @@ -0,0 +1,10 @@ +registerTables(); + $this->registerMigrations(); + } + + private function registerTables(): void + { + $this->container->singleton( Reports_Table::class ); + // foundation:database-tables + } + + private function registerMigrations(): void + { + $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + $c->get( Create_Reports_Table::class ), + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub b/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub new file mode 100644 index 0000000..e86ab65 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub @@ -0,0 +1,17 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + Create_Reports_Table::class, + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub b/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub new file mode 100644 index 0000000..a6805ed --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub @@ -0,0 +1,18 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + new Wrapper( $c->get( Create_Reports_Table::class ) ), + ] ); + } +} diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index 8e1cfa2..9da58a1 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -12,6 +12,8 @@ use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Migrator; +use StellarWP\Foundation\Database\Table\Tables\LockTable; +use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; use StellarWP\Foundation\WPCli\Command; @@ -53,6 +55,8 @@ public function test_it_registers_configured_database_configuration(): void { $this->assertSame('custom_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); $this->assertSame('custom_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertSame('custom_migrations', $container->get(MigrationTable::class)->name()); + $this->assertSame('custom_locks', $container->get(LockTable::class)->name()); $this->assertSame('custom-migrations', $container->get(DatabaseProvider::LOCK_NAME)); $this->assertSame(120, $container->get(DatabaseProvider::LOCK_TTL)); $this->assertSame('custom', $container->get(WPCliProvider::COMMAND_PREFIX)); diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 1510c21..c9bb89a 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -244,7 +244,7 @@ public function definition(): TableDefinition { public function test_migration_repository_persists_records_in_wordpress(): void { $table = $this->table('migrations'); $schema = $this->schema; - $migrationTable = new MigrationTable($this->database, $table); + $migrationTable = new MigrationTable($table); $repository = new Repository($this->database, $table); $this->assertFalse($schema->hasTable($migrationTable)); @@ -274,7 +274,7 @@ public function test_migration_repository_persists_records_in_wordpress(): void public function test_database_lock_coordinates_ownership_in_wordpress(): void { $table = $this->table('locks'); $wpSchema = $this->schema; - $lockTable = new LockTable($this->database, $table); + $lockTable = new LockTable($table); $lock = new DatabaseLock($this->database, $table); $this->assertFalse($wpSchema->hasTable($lockTable)); @@ -301,7 +301,7 @@ public function test_database_lock_coordinates_ownership_in_wordpress(): void { public function test_database_lock_replaces_expired_ownership_without_allowing_the_previous_owner_to_release_it(): void { $table = $this->table('expired_locks'); $wpSchema = $this->schema; - $lockTable = new LockTable($this->database, $table); + $lockTable = new LockTable($table); $lock = new DatabaseLock($this->database, $table); $wpSchema->createOrUpdate($lockTable); @@ -330,7 +330,7 @@ public function test_database_lock_replaces_expired_ownership_without_allowing_t public function test_database_lock_compares_names_and_owners_by_exact_bytes(): void { $table = $this->table('exact_locks'); $wpSchema = $this->schema; - $lockTable = new LockTable($this->database, $table); + $lockTable = new LockTable($table); $lock = new DatabaseLock($this->database, $table); $wpSchema->createOrUpdate($lockTable); @@ -358,7 +358,7 @@ public function test_database_lock_compares_names_and_owners_by_exact_bytes(): v public function test_lock_table_reconciles_an_existing_previous_definition(): void { $table = $this->table('previous_lock_schema'); $wpSchema = $this->schema; - $lockTable = new LockTable($this->database, $table); + $lockTable = new LockTable($table); $this->database->execute(sprintf( 'CREATE TABLE %s ( From 3e74faf4b4a8a5faa0d0ddcca0b64693e2d46cdb Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Wed, 19 Aug 2026 21:55:09 -0600 Subject: [PATCH 29/81] WIP: checkpoint new package implementation --- src/Database/Cli/Migrate.php | 32 +-- src/Database/Contracts/Database.php | 41 ++++ src/Database/Contracts/Repository.php | 14 ++ src/Database/Contracts/Schema.php | 15 +- src/Database/Database.php | 9 + src/Database/DatabaseProvider.php | 17 +- src/Database/Migration/Collection.php | 36 ++- .../Exceptions/InvalidMigrationId.php | 12 + .../Exceptions/UnavailableMigration.php | 23 ++ src/Database/Migration/Id.php | 32 +++ src/Database/Migration/Migrator.php | 65 +++--- src/Database/Migration/Repository.php | 25 +- src/Database/Migration/Runner.php | 177 ++++++++------ src/Database/Migration/Status.php | 13 +- src/Database/Migration/Store.php | 47 +++- src/Database/Query/QueryBuilder.php | 5 +- src/Database/README.md | 82 ++++--- src/Database/Schema.php | 60 ++++- src/Database/Table/Column.php | 25 +- src/Database/Table/CreateTable.php | 4 +- src/Database/Table/Tables/MigrationTable.php | 3 +- src/Identifier/Ulid/Contracts/Entropy.php | 5 + .../Ulid/Contracts/UlidGenerator.php | 9 + src/Identifier/Ulid/RandomizerEntropy.php | 4 + src/Identifier/Ulid/UlidGenerator.php | 6 + src/Lock/LockToken.php | 2 + src/WPCli/README.md | 4 + src/WPCli/WPCliProvider.php | 24 +- .../Fixtures/Database/FakeDatabase.php | 8 + .../Fixtures/Database/RecordingSchema.php | 10 +- .../register-wpcli-migrate-command.php | 25 +- tests/Unit/Database/Cli/MigrateTest.php | 61 ++--- .../Database/Migration/CollectionTest.php | 49 +++- .../Unit/Database/Migration/MigratorTest.php | 70 ++++-- .../Database/Migration/RepositoryTest.php | 20 ++ tests/Unit/Database/Migration/RunnerTest.php | 220 +++++++++++++----- .../Unit/Database/Query/QueryBuilderTest.php | 12 + tests/Unit/Database/SchemaTest.php | 47 +++- tests/Unit/Database/Table/ColumnTest.php | 11 + tests/Unit/Database/Table/CreateTableTest.php | 4 +- .../Database/Table/Tables/LockTableTest.php | 10 +- .../Table/Tables/MigrationTableTest.php | 11 +- .../Database/DatabaseProviderTest.php | 6 +- tests/integration/WPCli/WPCliProviderTest.php | 34 +++ .../Database/Cli/DatabaseMigrateCest.php | 36 ++- .../Database/DatabaseIntegrationTest.php | 118 +++++++++- 46 files changed, 1206 insertions(+), 337 deletions(-) create mode 100644 src/Database/Migration/Exceptions/InvalidMigrationId.php create mode 100644 src/Database/Migration/Exceptions/UnavailableMigration.php create mode 100644 src/Database/Migration/Id.php diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index 8f54aa2..abd1fe4 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -18,7 +18,7 @@ final class Migrate extends Command private const string FLAG_RUN = 'run'; private const string FLAG_ROLLBACK = 'rollback'; private const string FLAG_REFRESH = 'refresh'; - private const string FLAG_DROP = 'drop'; + private const string FLAG_DROP_STORE = 'drop-store'; private const string FLAG_PREPARE = 'prepare'; private const string FLAG_CREATE_TABLE = 'create-table'; private const string FLAG_YES = 'yes'; @@ -39,24 +39,24 @@ public function runCommand(array $args = [], array $assocArgs = []): int { $run = (bool) get_flag_value($assocArgs, self::FLAG_RUN, false); $rollback = (bool) get_flag_value($assocArgs, self::FLAG_ROLLBACK, false); $refresh = (bool) get_flag_value($assocArgs, self::FLAG_REFRESH, false); - $drop = (bool) get_flag_value($assocArgs, self::FLAG_DROP, false); + $dropStore = (bool) get_flag_value($assocArgs, self::FLAG_DROP_STORE, false); $prepare = (bool) get_flag_value($assocArgs, self::FLAG_PREPARE, false); $createTable = (bool) get_flag_value($assocArgs, self::FLAG_CREATE_TABLE, false); if (! $this->hasSingleOperation([ - self::FLAG_RUN => $run, - self::FLAG_ROLLBACK => $rollback, - self::FLAG_REFRESH => $refresh, - self::FLAG_DROP => $drop, - self::FLAG_PREPARE => $prepare || $createTable, + self::FLAG_RUN => $run, + self::FLAG_ROLLBACK => $rollback, + self::FLAG_REFRESH => $refresh, + self::FLAG_DROP_STORE => $dropStore, + self::FLAG_PREPARE => $prepare || $createTable, ])) { return self::ERROR; } - if ($drop) { - WP_CLI::confirm('Are you sure you want to drop the Foundation database tables? This cannot be undone.', $assocArgs); - $this->migrator->drop(); - WP_CLI::success('Foundation database tables were dropped.'); + if ($dropStore) { + WP_CLI::confirm('Drop only the migration ledger? Application tables and shared lock storage remain, but all migrations will appear pending afterward.', $assocArgs); + $this->migrator->dropStore(); + WP_CLI::success('The migration ledger was dropped. Application tables were not changed, and shared lock storage remains available.'); return self::SUCCESS; } @@ -128,8 +128,8 @@ protected function arguments(): array { ], [ 'type' => self::FLAG, - 'name' => self::FLAG_DROP, - 'description' => 'Drop Foundation database tables.', + 'name' => self::FLAG_DROP_STORE, + 'description' => 'Drop only the migration ledger.', 'optional' => true, 'default' => false, ], @@ -158,14 +158,14 @@ protected function arguments(): array { } private function showStatus(): void { - if (! $this->migrator->exists()) { - WP_CLI::warning('The Foundation database tables do not exist. Run this command with --prepare or --run.'); + if (! $this->migrator->hasLedger()) { + WP_CLI::warning('The Foundation migration ledger does not exist. Run this command with --prepare or --run.'); } format_items('table', array_map( static fn ($status): array => [ 'migration' => $status->migration, - 'status' => $status->ran ? 'ran' : 'pending', + 'status' => ! $status->available ? 'unavailable' : ($status->ran ? 'ran' : 'pending'), 'batch' => $status->batch ?? '', 'ran_at' => $status->ranAt?->format('Y-m-d H:i:s') ?? '', ], diff --git a/src/Database/Contracts/Database.php b/src/Database/Contracts/Database.php index 8cc0027..b774818 100644 --- a/src/Database/Contracts/Database.php +++ b/src/Database/Contracts/Database.php @@ -2,6 +2,8 @@ namespace StellarWP\Foundation\Database\Contracts; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Exceptions\QueryException; use StellarWP\Foundation\Database\Query\QueryBuilder; /** @@ -13,41 +15,80 @@ public function table(Table|string $table, ?string $alias = null): QueryBuilder; public function tableName(Table|string $table): string; + /** + * @throws DatabaseException When table inspection fails. + */ public function tableExists(Table|string $table): bool; + /** + * @throws DatabaseException When column inspection fails. + */ public function columnExists(Table|string $table, string $column): bool; + /** + * @throws DatabaseException When index inspection fails. + */ public function indexExists(Table|string $table, string $index): bool; + /** + * @throws QueryException When WordPress cannot prepare the statement. + */ public function prepare(string $sql, mixed ...$bindings): string; /** + * @throws QueryException When the query fails. + * * @return array|null */ public function row(string $sql, mixed ...$bindings): ?array; /** + * @throws QueryException When the query fails. + * * @return list> */ public function rows(string $sql, mixed ...$bindings): array; + /** + * @throws QueryException When the query fails. + */ public function value(string $sql, mixed ...$bindings): mixed; + /** + * @throws QueryException When the statement fails. + */ public function execute(string $sql, mixed ...$bindings): int; /** * @param array $data + * + * @throws QueryException When the insert fails. + * + * @return int Number of inserted rows. */ public function insert(Table|string $table, array $data): int; + /** + * Insert a row and return its auto-increment identifier. + * + * @param array $data + * + * @throws QueryException When the insert fails. + */ + public function insertGetId(Table|string $table, array $data): int; + /** * @param array $data * @param array $where + * + * @throws QueryException When the update fails. */ public function update(Table|string $table, array $data, array $where): int; /** * @param array $where + * + * @throws QueryException When the delete fails. */ public function delete(Table|string $table, array $where): int; diff --git a/src/Database/Contracts/Repository.php b/src/Database/Contracts/Repository.php index 9e2a6af..26c85e0 100644 --- a/src/Database/Contracts/Repository.php +++ b/src/Database/Contracts/Repository.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Database\Contracts; +use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; use StellarWP\Foundation\Database\Migration\Record; /** @@ -10,14 +11,25 @@ interface Repository { /** + * @throws InvalidMigrationId When a stored migration identifier is invalid. + * * @return array */ public function all(): array; + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ public function hasRun(string $migration): bool; + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ public function recordRun(string $migration, int $batch): Record; + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ public function deleteRun(string $migration): bool; public function nextBatch(): int; @@ -25,6 +37,8 @@ public function nextBatch(): int; public function latestBatch(): ?int; /** + * @throws InvalidMigrationId When a stored migration identifier is invalid. + * * @return list */ public function recordsForBatch(int $batch): array; diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php index a755ab6..76caca5 100644 --- a/src/Database/Contracts/Schema.php +++ b/src/Database/Contracts/Schema.php @@ -2,6 +2,8 @@ namespace StellarWP\Foundation\Database\Contracts; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; + /** * Applies and inspects WordPress database schema state for migrations. */ @@ -9,11 +11,22 @@ interface Schema { /** * Create or update a table. + * + * @throws DatabaseException When WordPress cannot reconcile the table definition. + */ + public function createOrUpdate(Table $table): void; + + /** + * Create or update a table from explicit dbDelta-compatible SQL. + * + * @throws DatabaseException When WordPress cannot reconcile the SQL definition. */ - public function createOrUpdate(Table|string $table, ?string $sql = null): void; + public function createOrUpdateSql(string $sql): void; /** * Execute explicit schema SQL. + * + * @throws DatabaseException When the statement cannot be executed. */ public function execute(string $sql): void; diff --git a/src/Database/Database.php b/src/Database/Database.php index 8493fdf..1df88d7 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -150,6 +150,15 @@ public function insert(Table|string $table, array $data): int { throw new QueryException($this->message('Unable to insert database row.'), 'INSERT', [], $this->lastError()); } + return (int) $result; + } + + /** + * @param array $data + */ + public function insertGetId(Table|string $table, array $data): int { + $this->insert($table, $data); + return (int) $this->wpdb->insert_id; } diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 4180126..d3d8c14 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -10,6 +10,7 @@ use StellarWP\Foundation\Database\Contracts\Repository; use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Exceptions\QueryException; use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Migrator; @@ -73,7 +74,21 @@ private function registerDatabase(): void { throw new DatabaseException('WordPress dbDelta() is not available.'); } - return dbDelta(...); + return static function (string $sql, bool $execute): array { + $wpdb = $GLOBALS['wpdb'] ?? null; + + if (! $wpdb instanceof \wpdb) { + throw new DatabaseException('The global wpdb instance is not available.'); + } + + $result = dbDelta($sql, $execute); + + if ($execute && $wpdb->last_error !== '') { + throw new QueryException($wpdb->last_error, $sql, [], $wpdb->last_error); + } + + return $result; + }; }); $this->container->singleton(Schema::class); diff --git a/src/Database/Migration/Collection.php b/src/Database/Migration/Collection.php index e7d9e53..2e2f11a 100644 --- a/src/Database/Migration/Collection.php +++ b/src/Database/Migration/Collection.php @@ -6,22 +6,26 @@ use IteratorAggregate; use StellarWP\Foundation\Database\Contracts\Migration; use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; +use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; use Traversable; /** * Ordered collection of migrations registered with the database package. * - * @implements IteratorAggregate + * @implements IteratorAggregate */ final class Collection implements IteratorAggregate { /** - * @var list + * @var array */ private array $migrations = []; /** * @param iterable $migrations + * + * @throws DuplicateMigration When a migration identifier is already registered. + * @throws InvalidMigrationId When a migration identifier cannot be stored safely. */ public function __construct( iterable $migrations = [] @@ -32,29 +36,41 @@ public function __construct( } /** - * @throws DuplicateMigration + * @throws DuplicateMigration When a migration identifier is already registered. + * @throws InvalidMigrationId When a migration identifier cannot be stored safely. */ public function add(Migration ...$migrations): void { foreach ($migrations as $migration) { - foreach ($this->migrations as $registered) { - if ($registered->id() === $migration->id()) { - throw DuplicateMigration::forMigration($migration->id()); - } + $id = (new Id($migration->id()))->value; + + if (isset($this->migrations[$id])) { + throw DuplicateMigration::forMigration($id); } - $this->migrations[] = $migration; + $this->migrations[$id] = $migration; } } /** - * @return list + * Return all migrations keyed by their byte-exact identifier. + * + * @return array */ public function all(): array { return $this->migrations; } /** - * @return Traversable + * Return all migrations as an ordered list. + * + * @return list + */ + public function values(): array { + return array_values($this->migrations); + } + + /** + * @return Traversable */ public function getIterator(): Traversable { return new ArrayIterator($this->migrations); diff --git a/src/Database/Migration/Exceptions/InvalidMigrationId.php b/src/Database/Migration/Exceptions/InvalidMigrationId.php new file mode 100644 index 0000000..a4f9fa2 --- /dev/null +++ b/src/Database/Migration/Exceptions/InvalidMigrationId.php @@ -0,0 +1,12 @@ + $migrations + */ + public function __construct( + public readonly array $migrations + ) { + parent::__construct(sprintf( + 'Cannot roll back unavailable migrations: %s.', + implode(', ', $this->migrations) + )); + } +} diff --git a/src/Database/Migration/Id.php b/src/Database/Migration/Id.php new file mode 100644 index 0000000..ca3f964 --- /dev/null +++ b/src/Database/Migration/Id.php @@ -0,0 +1,32 @@ +value === '' || trim($this->value) !== $this->value) { + throw new InvalidMigrationId('Migration IDs cannot be blank or contain surrounding whitespace.'); + } + + if (strlen($this->value) > self::MAX_BYTES) { + throw new InvalidMigrationId(sprintf('Migration IDs cannot exceed %d bytes.', self::MAX_BYTES)); + } + + if (preg_match('/^(?:0|-?[1-9][0-9]*)$/D', $this->value) === 1) { + throw new InvalidMigrationId('Migration IDs cannot be integer-like strings.'); + } + } +} diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index d5179b8..1d54220 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -2,11 +2,10 @@ namespace StellarWP\Foundation\Database\Migration; -use StellarWP\Foundation\Database\Contracts\Migration; use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; +use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; /** @@ -15,7 +14,6 @@ final readonly class Migrator { public function __construct( - private Store $store, private Runner $runner, private Collection $migrations ) { @@ -24,19 +22,23 @@ public function __construct( /** * Ensure the migration subsystem storage is ready. * - * @throws DatabaseException When migration storage cannot be prepared. + * @throws DatabaseException When migration storage cannot be prepared. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function prepare(): void { - $this->store->prepare(); + $this->runner->prepareStore(); } /** - * Drop the migration subsystem storage. + * Drop the migration ledger while preserving shared lock storage. * - * @throws DatabaseException When migration storage cannot be dropped. + * @throws DatabaseException When migration storage cannot be prepared or dropped. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ - public function drop(): void { - $this->store->drop(); + public function dropStore(): void { + $this->runner->dropStore(); } /** @@ -45,75 +47,62 @@ public function drop(): void { * @throws DatabaseException When migration storage cannot be inspected. */ public function exists(): bool { - return $this->store->exists(); + return $this->runner->storeExists(); + } + + /** + * Determine whether recorded migration state can be read. + * + * @throws DatabaseException When the ledger cannot be inspected. + */ + public function hasLedger(): bool { + return $this->runner->hasLedger(); } /** * Run all pending configured migrations. * * @throws DatabaseException When migration storage or schema access fails. - * @throws DuplicateMigration When configured migrations share an identifier. * @throws MigrationFailed When a migration fails while running. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function run(): Result { - return $this->withPreparedStore(fn (): Result => $this->runner->run($this->migrations)); + return $this->runner->run($this->migrations); } /** * Roll back the latest configured migration batch. * * @throws DatabaseException When migration storage or schema access fails. - * @throws DuplicateMigration When configured migrations share an identifier. * @throws MigrationFailed When a migration fails while rolling back. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ public function rollback(?int $batch = null): Result { - return $this->withPreparedStore(fn (): Result => $this->runner->rollback($this->migrations, $batch)); + return $this->runner->rollback($this->migrations, $batch); } /** * Roll back and rerun all configured migrations. * * @throws DatabaseException When migration storage or schema access fails. - * @throws DuplicateMigration When configured migrations share an identifier. * @throws MigrationFailed When a migration fails while running or rolling back. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ public function refresh(): Result { - return $this->withPreparedStore(fn (): Result => $this->runner->refresh($this->migrations)); + return $this->runner->refresh($this->migrations); } /** - * @throws DatabaseException When migration storage cannot be inspected. - * @throws DuplicateMigration When configured migrations share an identifier. + * @throws DatabaseException When migration storage cannot be inspected. * * @return list */ public function status(): array { - if (! $this->store->exists()) { - return array_map( - static fn (Migration $migration): Status => Status::pending($migration->id()), - $this->migrations->all() - ); - } - return $this->runner->status($this->migrations); } - - /** - * @template T - * - * @param callable(): T $callback - * - * @return T - */ - private function withPreparedStore(callable $callback): mixed { - $this->store->prepare(); - - return $callback(); - } } diff --git a/src/Database/Migration/Repository.php b/src/Database/Migration/Repository.php index 2faec04..0febdbb 100644 --- a/src/Database/Migration/Repository.php +++ b/src/Database/Migration/Repository.php @@ -6,6 +6,7 @@ use DateTimeZone; use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Repository as RepositoryContract; +use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; /** * Stores migration records in a WordPress database table. @@ -19,6 +20,8 @@ public function __construct( } /** + * @throws InvalidMigrationId When a stored migration identifier is invalid. + * * @return array */ public function all(): array { @@ -36,7 +39,12 @@ public function all(): array { return $records; } + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ public function hasRun(string $migration): bool { + $migration = (new Id($migration))->value; + return $this->database->row( 'SELECT id FROM %i WHERE migration = %s LIMIT 1', $this->table, @@ -44,8 +52,12 @@ public function hasRun(string $migration): bool { ) !== null; } + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ public function recordRun(string $migration, int $batch): Record { - $ranAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $migration = (new Id($migration))->value; + $ranAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); $this->database->execute( 'INSERT INTO %i (migration, batch, ran_at) VALUES (%s, %d, %s)', @@ -68,7 +80,12 @@ public function recordRun(string $migration, int $batch): Record { return $this->recordFromRow($row); } + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ public function deleteRun(string $migration): bool { + $migration = (new Id($migration))->value; + return $this->database->execute( 'DELETE FROM %i WHERE migration = %s', $this->table, @@ -96,6 +113,8 @@ public function latestBatch(): ?int { } /** + * @throws InvalidMigrationId When a stored migration identifier is invalid. + * * @return list */ public function recordsForBatch(int $batch): array { @@ -111,11 +130,13 @@ public function recordsForBatch(int $batch): array { /** * @param array $row + * + * @throws InvalidMigrationId When the stored migration identifier is invalid. */ private function recordFromRow(array $row): Record { return new Record( id: (int) $row['id'], - migration: (string) $row['migration'], + migration: (new Id((string) $row['migration']))->value, batch: (int) $row['batch'], ranAt: new DateTimeImmutable((string) $row['ran_at'], new DateTimeZone('UTC')) ); diff --git a/src/Database/Migration/Runner.php b/src/Database/Migration/Runner.php index d30df49..3c94d2f 100644 --- a/src/Database/Migration/Runner.php +++ b/src/Database/Migration/Runner.php @@ -7,9 +7,9 @@ use StellarWP\Foundation\Database\Contracts\Repository; use StellarWP\Foundation\Database\Contracts\Schema; use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; +use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use Throwable; @@ -26,6 +26,7 @@ public function __construct( private Repository $repository, private Schema $schema, private Lock $lock, + private Store $store, private string $lockName = 'foundation-database-migrations', private int $lockTtl = 300 ) { @@ -39,52 +40,30 @@ public function __construct( } /** - * @param iterable $migrations - * * @throws DatabaseException When migration storage or schema access fails. - * @throws DuplicateMigration When configured migrations share an identifier. * @throws MigrationFailed When a migration fails while running. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ - public function run(iterable $migrations): Result { - return $this->locked(function () use ($migrations): Result { - $ran = []; - $skipped = []; - $batch = $this->repository->nextBatch(); - $migrations = $this->normalize($migrations); - - foreach ($migrations as $migration) { - if ($this->repository->hasRun($migration->id())) { - $skipped[] = $migration->id(); - continue; - } - - try { - $migration->up($this->schema); - } catch (Throwable $throwable) { - throw MigrationFailed::whileRunning($migration->id(), $throwable); - } - - $this->repository->recordRun($migration->id(), $batch); - $ran[] = $migration->id(); - } + public function run(Collection $migrations): Result { + $configured = $migrations->all(); - return new Result(ran: $ran, skipped: $skipped); - }); + return $this->withPreparedStore( + fn (): Result => $this->runWithoutLock($configured) + ); } /** - * @param iterable $migrations - * * @throws DatabaseException When migration storage or schema access fails. - * @throws DuplicateMigration When configured migrations share an identifier. * @throws MigrationFailed When a migration fails while rolling back. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ - public function rollback(iterable $migrations, ?int $batch = null): Result { - return $this->locked(function () use ($migrations, $batch): Result { + public function rollback(Collection $migrations, ?int $batch = null): Result { + $configured = $migrations->all(); + + return $this->withPreparedStore(function () use ($configured, $batch): Result { $batch ??= $this->repository->latestBatch(); if ($batch === null) { @@ -92,26 +71,25 @@ public function rollback(iterable $migrations, ?int $batch = null): Result { } return $this->rollbackRecords( - $this->normalize($migrations), + $configured, $this->repository->recordsForBatch($batch) ); }); } /** - * @param iterable $migrations - * * @throws DatabaseException When migration storage or schema access fails. - * @throws DuplicateMigration When configured migrations share an identifier. * @throws MigrationFailed When a migration fails while running or rolling back. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ - public function refresh(iterable $migrations): Result { - return $this->locked(function () use ($migrations): Result { - $normalized = $this->normalize($migrations); - $rollback = $this->rollbackRecords($normalized, array_values($this->repository->all())); - $run = $this->runWithoutLock($normalized); + public function refresh(Collection $migrations): Result { + $configured = $migrations->all(); + + return $this->withPreparedStore(function () use ($configured): Result { + $rollback = $this->rollbackRecords($configured, array_values($this->repository->all())); + $run = $this->runWithoutLock($configured); return new Result( ran: $run->ran, @@ -122,21 +100,76 @@ public function refresh(iterable $migrations): Result { } /** - * @param iterable $migrations + * Prepare the migration ledger while holding the migration lock. + * + * @throws DatabaseException When migration storage cannot be prepared. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + */ + public function prepareStore(): void { + $this->withPreparedStore(static function (): void { + }); + } + + /** + * Drop the migration ledger while holding the migration lock. * - * @throws DatabaseException When migration storage access fails. - * @throws DuplicateMigration When configured migrations share an identifier. + * @throws DatabaseException When migration storage cannot be dropped. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + */ + public function dropStore(): void { + $this->withLock(function (): void { + $this->store->drop(); + }); + } + + /** + * Determine whether the complete migration store is ready. + * + * @throws DatabaseException When migration storage cannot be inspected. + */ + public function storeExists(): bool { + return $this->store->exists(); + } + + /** + * Determine whether recorded migration state can be read. + * + * @throws DatabaseException When migration storage cannot be inspected. + */ + public function hasLedger(): bool { + return $this->store->hasLedger(); + } + + /** + * @throws DatabaseException When migration storage access fails. * * @return list */ - public function status(iterable $migrations): array { + public function status(Collection $migrations): array { + $configured = $migrations->all(); + + if (! $this->store->hasLedger()) { + return array_map( + static fn (Migration $migration): Status => Status::pending($migration->id()), + array_values($configured) + ); + } + $records = $this->repository->all(); $statuses = []; - foreach ($this->normalize($migrations) as $migration) { + foreach ($configured as $migration) { $statuses[] = isset($records[$migration->id()]) ? Status::fromRecord($records[$migration->id()]) : Status::pending($migration->id()); + + unset($records[$migration->id()]); + } + + foreach ($records as $record) { + $statuses[] = Status::unavailable($record); } return $statuses; @@ -145,20 +178,24 @@ public function status(iterable $migrations): array { /** * @param array $migrations * @param list $records + * + * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ private function rollbackRecords(array $migrations, array $records): Result { usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); + $unavailable = array_values(array_map( + static fn (Record $record): string => $record->migration, + array_filter($records, static fn (Record $record): bool => ! isset($migrations[$record->migration])) + )); + + if ($unavailable !== []) { + throw new UnavailableMigration($unavailable); + } $rolledBack = []; - $skipped = []; foreach ($records as $record) { - $migration = $migrations[$record->migration] ?? null; - - if ($migration === null) { - $skipped[] = $record->migration; - continue; - } + $migration = $migrations[$record->migration]; try { $migration->down($this->schema); @@ -170,7 +207,7 @@ private function rollbackRecords(array $migrations, array $records): Result { $rolledBack[] = $migration->id(); } - return new Result(rolledBack: $rolledBack, skipped: $skipped); + return new Result(rolledBack: $rolledBack); } /** @@ -201,22 +238,22 @@ private function runWithoutLock(array $migrations): Result { } /** - * @param iterable $migrations + * @template T + * + * @param callable(): T $callback + * + * @throws DatabaseException When migration storage cannot be prepared. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. * - * @return array + * @return T */ - private function normalize(iterable $migrations): array { - $normalized = []; - - foreach ($migrations as $migration) { - if (isset($normalized[$migration->id()])) { - throw DuplicateMigration::forMigration($migration->id()); - } + private function withPreparedStore(callable $callback): mixed { + return $this->withLock(function () use ($callback): mixed { + $this->store->prepareLedger(); - $normalized[$migration->id()] = $migration; - } - - return $normalized; + return $callback(); + }); } /** @@ -224,12 +261,14 @@ private function normalize(iterable $migrations): array { * * @param callable(): T $callback * + * @throws DatabaseException When lock storage cannot be prepared. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * * @return T */ - private function locked(callable $callback): mixed { + private function withLock(callable $callback): mixed { + $this->store->prepareLock(); $token = $this->lock->acquire($this->lockName, $this->lockTtl); if ($token === null) { diff --git a/src/Database/Migration/Status.php b/src/Database/Migration/Status.php index 7e13399..39a33e2 100644 --- a/src/Database/Migration/Status.php +++ b/src/Database/Migration/Status.php @@ -13,7 +13,8 @@ public function __construct( public string $migration, public bool $ran, public ?int $batch = null, - public ?DateTimeImmutable $ranAt = null + public ?DateTimeImmutable $ranAt = null, + public bool $available = true ) { } @@ -29,4 +30,14 @@ public static function fromRecord(Record $record): self { ranAt: $record->ranAt ); } + + public static function unavailable(Record $record): self { + return new self( + migration: $record->migration, + ran: true, + batch: $record->batch, + ranAt: $record->ranAt, + available: false + ); + } } diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php index 19e2326..ef5367d 100644 --- a/src/Database/Migration/Store.php +++ b/src/Database/Migration/Store.php @@ -2,7 +2,10 @@ namespace StellarWP\Foundation\Database\Migration; -use StellarWP\Foundation\Database\Table\Collection; +use StellarWP\Foundation\Database\Contracts\Schema; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Table\Tables\LockTable; +use StellarWP\Foundation\Database\Table\Tables\MigrationTable; /** * Manages the database tables required by the migration subsystem itself. @@ -10,28 +13,54 @@ final readonly class Store { public function __construct( - private Collection $tables + private Schema $schema, + private MigrationTable $migrationTable, + private LockTable $lockTable ) { } /** - * Ensure the migration subsystem can record state and coordinate locks. + * Ensure the shared lock table is ready before acquiring the migration lock. + * + * @throws DatabaseException When the lock table cannot be reconciled. */ - public function prepare(): void { - $this->tables->create(); + public function prepareLock(): void { + $this->schema->createOrUpdate($this->lockTable); } /** - * Drop the migration subsystem tables. + * Ensure the migration ledger is ready while holding the migration lock. + * + * @throws DatabaseException When the ledger cannot be reconciled. + */ + public function prepareLedger(): void { + $this->schema->createOrUpdate($this->migrationTable); + } + + /** + * Drop the migration ledger while preserving shared lock storage. + * + * @throws DatabaseException When the ledger cannot be dropped. */ public function drop(): void { - $this->tables->drop(); + $this->schema->drop($this->migrationTable); } /** - * Determine whether the migration subsystem tables are ready. + * Determine whether the migration subsystem storage is ready. + * + * @throws DatabaseException When the ledger cannot be inspected. */ public function exists(): bool { - return $this->tables->exists(); + return $this->hasLedger() && $this->schema->hasTable($this->lockTable); + } + + /** + * Determine whether recorded migration state can be read. + * + * @throws DatabaseException When the ledger cannot be inspected. + */ + public function hasLedger(): bool { + return $this->schema->hasTable($this->migrationTable); } } diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php index b8c90fe..8fcf593 100644 --- a/src/Database/Query/QueryBuilder.php +++ b/src/Database/Query/QueryBuilder.php @@ -145,7 +145,10 @@ public function get(): array { * @return array|null */ public function first(): ?array { - return $this->queryWithLimitBindings()->first(); + $query = clone $this; + $query->limit = 1; + + return $query->queryWithLimitBindings()->first(); } private function queryWithLimitBindings(): Query { diff --git a/src/Database/README.md b/src/Database/README.md index 91ef755..c15c7f6 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -180,45 +180,62 @@ $query->bindings(); $query->toPreparedSql(); ``` +`Database::insert()` returns the number of affected rows, which works for both +auto-increment and application-assigned identifiers such as ULIDs. Use +`Database::insertGetId()` only when the table has an auto-increment key and the +generated integer identifier is needed. + ## Defining Migrations Migrations implement `StellarWP\Foundation\Database\Contracts\Migration`: ```php +use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Migration; use StellarWP\Foundation\Database\Contracts\Schema; final readonly class CreateReportsTable implements Migration { - public function id(): string - { - return '2026_06_23_000001_create_reports_table'; - } + public function __construct( + private Database $database + ) { + } - public function up(Schema $schema): void - { - $schema->createOrUpdate( - sprintf( - 'CREATE TABLE %s ( - id bigint unsigned NOT NULL AUTO_INCREMENT, - title varchar(191) NOT NULL, - PRIMARY KEY (id) - );', - $schema->quoteIdentifier('wp_reports') - ) - ); - } + public function id(): string { + return '2026_06_23_000001_create_reports_table'; + } - public function down(Schema $schema): void - { - $schema->execute(sprintf( - 'DROP TABLE IF EXISTS %s', - $schema->quoteIdentifier('wp_reports') - )); - } + public function up(Schema $schema): void { + $table = $this->database->tableName('reports'); + + $schema->createOrUpdateSql( + sprintf( + 'CREATE TABLE %s ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + title varchar(191) NOT NULL, + PRIMARY KEY (id) + );', + $schema->quoteIdentifier($table) + ) + ); + } + + public function down(Schema $schema): void { + $table = $this->database->tableName('reports'); + + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $schema->quoteIdentifier($table) + )); + } } ``` +Migration IDs are byte-exact and case-sensitive. They must be nonblank, contain +no surrounding whitespace, fit within 191 bytes, and not be an integer-like +string such as `123`; these rules keep PHP collection keys and the MySQL ledger +consistent. + Applications should add migrations to `DatabaseProvider::MIGRATIONS` with `mergeArrayVar()` so multiple providers/packages can contribute migrations: ```php @@ -280,7 +297,7 @@ $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): ]); ``` -Application code that needs to run migrations should inject `StellarWP\Foundation\Database\Migration\Migrator`. It is the configured entry point for preparing the migration store, running pending migrations, rolling back, refreshing, dropping migration storage, and reading migration status. +Application code that needs to run migrations should inject `StellarWP\Foundation\Database\Migration\Migrator`. It is the configured entry point for preparing the migration store, running pending migrations, rolling back, refreshing, dropping only the internal migration store, and reading migration status. ```php use StellarWP\Foundation\Database\Migration\Migrator; @@ -300,11 +317,22 @@ final readonly class PluginUpdater `run()`, `rollback()`, and `refresh()` prepare the migration store automatically before executing migrations. +`dropStore()` acquires the migration lock and removes only the migration ledger. +It preserves application tables and shared lock storage. After the ledger is +removed, every configured migration appears pending and may run again after the +store is prepared. It is not a substitute for rollback because it does not call +any migration's `down()` method. + +Recorded migration implementations must remain registered for as long as their +ledger entries may be rolled back. `rollback()` and `refresh()` validate every +selected ledger entry before changing schema and fail without a partial rollback +when an implementation is unavailable. + Registering `DatabaseProvider` does not execute migrations. Call `Migrator::run()` from the application's activation or version-update lifecycle, or run `wp nx migrate --run` during deployment. Completed migration IDs are skipped on later runs. Because migration changes and their ledger updates are not one atomic operation, write `up()` and `down()` methods so they can recover from retries after partial work or failed ledger writes. ## Evolving Tables -`TableDefinition` and `Schema::createOrUpdate()` use WordPress `dbDelta()` to create tables and reconcile changes that `dbDelta()` supports, such as adding columns and indexes. They should not be relied on to remove or rename columns, replace indexes, manage foreign keys, or backfill data. +`TableDefinition` and `Schema::createOrUpdate()` use WordPress `dbDelta()` to create tables and reconcile changes that `dbDelta()` supports, such as adding columns and indexes. Use `Schema::createOrUpdateSql()` when a migration must provide explicit dbDelta-compatible SQL. They should not be relied on to remove or rename columns, replace indexes, manage foreign keys, or backfill data. Use an explicit, versioned migration for destructive or data-dependent changes. Such migrations can inspect table and index state with `Schema::hasTable()` and `Schema::hasIndex()`; inject `Database` when column inspection through `Database::columnExists()` is required. Use `Schema::execute()` or focused helpers such as `dropIndex()` for the required SQL. Make rollback behavior explicit; throw `IrreversibleMigration::forMigration(self::ID)` when a migration cannot be safely reversed. @@ -395,7 +423,7 @@ Available flags: - `--run` runs pending migrations. - `--rollback` rolls back the latest migration batch. - `--refresh` rolls back all known migrations and runs them again. -- `--drop` drops the migrations and lock tables after confirmation. +- `--drop-store` drops only the migration ledger after confirmation. Application tables and shared lock storage remain, and all migrations appear pending afterward. - `--prepare` prepares the migration store without running migrations. - `--create-table` is an alias for `--prepare`. - `--yes` skips confirmation prompts for destructive actions. diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 25e2371..c9565cc 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -6,6 +6,8 @@ use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Table\TableDefinition; /** * WordPress schema operations backed by wpdb and dbDelta. @@ -13,7 +15,7 @@ final readonly class Schema implements SchemaContract { /** - * @param Closure(string): mixed $dbDelta + * @param Closure(string, bool): array $dbDelta */ public function __construct( private Database $database, @@ -21,8 +23,22 @@ public function __construct( ) { } - public function createOrUpdate(Table|string $table, ?string $sql = null): void { - ($this->dbDelta)($sql ?? $this->createTableSql($table)); + /** + * @throws DatabaseException When WordPress cannot reconcile the table definition. + */ + public function createOrUpdate(Table $table): void { + $definition = $table->definition(); + $definition->assertValid(); + + $this->applyDelta($this->createTableSql($table, $definition)); + $this->reconcileComplexDefaults($table, $definition); + } + + /** + * @throws DatabaseException When WordPress cannot reconcile the SQL definition. + */ + public function createOrUpdateSql(string $sql): void { + $this->applyDelta($sql); } public function execute(string $sql): void { @@ -56,14 +72,7 @@ public function quoteIdentifier(string $identifier): string { return $this->database->quoteIdentifier($identifier); } - private function createTableSql(Table|string $table): string { - if (is_string($table)) { - return $table; - } - - $definition = $table->definition(); - $definition->assertValid(); - + private function createTableSql(Table $table, TableDefinition $definition): string { $parts = []; foreach ($definition->columns() as $column) { @@ -81,4 +90,33 @@ private function createTableSql(Table|string $table): string { $this->database->charsetCollate() ); } + + private function reconcileComplexDefaults(Table $table, TableDefinition $definition): void { + foreach ($definition->columns() as $column) { + $default = $column->defaultSql(); + + if ($default === null || ! str_starts_with($default, "X'")) { + continue; + } + + $this->database->execute(sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->database->quoteIdentifier($table->name()), + $this->database->quoteIdentifier($column->name), + $default + )); + } + } + + private function applyDelta(string $sql): void { + ($this->dbDelta)($sql, true); + $pending = ($this->dbDelta)($sql, false); + + if ($pending !== []) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation did not complete: %s', + implode('; ', $pending) + )); + } + } } diff --git a/src/Database/Table/Column.php b/src/Database/Table/Column.php index ce63034..a414667 100644 --- a/src/Database/Table/Column.php +++ b/src/Database/Table/Column.php @@ -29,8 +29,10 @@ public function sql(): string { $this->nullable ? ' NULL' : ' NOT NULL' ); - if ($this->default !== null || $this->hasDefault) { - $sql .= sprintf(' DEFAULT %s', $this->formatDefault($this->default)); + $default = $this->defaultSql(); + + if ($default !== null) { + $sql .= sprintf(' DEFAULT %s', $default); } if ($this->extra !== '') { @@ -40,6 +42,17 @@ public function sql(): string { return $sql; } + /** + * Return the SQL literal for an explicit default value. + */ + public function defaultSql(): ?string { + if ($this->default === null && ! $this->hasDefault) { + return null; + } + + return $this->formatDefault($this->default); + } + public function unsigned(bool $unsigned = true): self { return new self( $this->name, @@ -113,6 +126,12 @@ private function formatDefault(mixed $default): string { return (string) $default; } - return "'" . addslashes((string) $default) . "'"; + $default = (string) $default; + + if (preg_match("/['\\\\\x00-\x1F\x7F]/", $default) === 1) { + return "X'" . bin2hex($default) . "'"; + } + + return "'" . $default . "'"; } } diff --git a/src/Database/Table/CreateTable.php b/src/Database/Table/CreateTable.php index 17910ef..1e8e225 100644 --- a/src/Database/Table/CreateTable.php +++ b/src/Database/Table/CreateTable.php @@ -21,9 +21,7 @@ public function id(): string { } public function up(Schema $schema): void { - if (! $schema->hasTable($this->table)) { - $schema->createOrUpdate($this->table); - } + $schema->createOrUpdate($this->table); } public function down(Schema $schema): void { diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php index 6fdae62..6a5185e 100644 --- a/src/Database/Table/Tables/MigrationTable.php +++ b/src/Database/Table/Tables/MigrationTable.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Database\Table\Tables; use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\Table\Column; use StellarWP\Foundation\Database\Table\TableDefinition; /** @@ -28,7 +29,7 @@ public function name(): string { public function definition(): TableDefinition { return TableDefinition::for($this) ->bigIncrements('id') - ->string('migration', 191) + ->column(new Column('migration', 'varbinary', 191)) ->unsignedInteger('batch') ->dateTime('ran_at') ->unique('migration', 'migration') diff --git a/src/Identifier/Ulid/Contracts/Entropy.php b/src/Identifier/Ulid/Contracts/Entropy.php index 5c83a7b..7cb3aea 100644 --- a/src/Identifier/Ulid/Contracts/Entropy.php +++ b/src/Identifier/Ulid/Contracts/Entropy.php @@ -2,10 +2,15 @@ namespace StellarWP\Foundation\Identifier\Ulid\Contracts; +use Random\RandomException; + /** * Supplies random bytes for ULID randomness. */ interface Entropy { + /** + * @throws RandomException When secure random bytes cannot be generated. + */ public function bytes(int $length): string; } diff --git a/src/Identifier/Ulid/Contracts/UlidGenerator.php b/src/Identifier/Ulid/Contracts/UlidGenerator.php index 58cf377..287ad6e 100644 --- a/src/Identifier/Ulid/Contracts/UlidGenerator.php +++ b/src/Identifier/Ulid/Contracts/UlidGenerator.php @@ -2,6 +2,9 @@ namespace StellarWP\Foundation\Identifier\Ulid\Contracts; +use OutOfRangeException; +use Random\RandomException; +use RuntimeException; use StellarWP\Foundation\Identifier\Contracts\IdentifierGenerator; /** @@ -9,4 +12,10 @@ */ interface UlidGenerator extends IdentifierGenerator { + /** + * @throws OutOfRangeException When the current timestamp is outside the ULID range. + * @throws RandomException When secure random bytes cannot be generated. + * @throws RuntimeException When the entropy source returns an invalid byte count. + */ + public function generate(): string; } diff --git a/src/Identifier/Ulid/RandomizerEntropy.php b/src/Identifier/Ulid/RandomizerEntropy.php index 1017d77..1cd77e3 100644 --- a/src/Identifier/Ulid/RandomizerEntropy.php +++ b/src/Identifier/Ulid/RandomizerEntropy.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Identifier\Ulid; +use Random\RandomException; use Random\Randomizer; use StellarWP\Foundation\Identifier\Ulid\Contracts\Entropy; @@ -15,6 +16,9 @@ public function __construct( ) { } + /** + * @throws RandomException When secure random bytes cannot be generated. + */ public function bytes(int $length): string { return $this->randomizer->getBytes($length); } diff --git a/src/Identifier/Ulid/UlidGenerator.php b/src/Identifier/Ulid/UlidGenerator.php index aebabb7..6d2ea22 100644 --- a/src/Identifier/Ulid/UlidGenerator.php +++ b/src/Identifier/Ulid/UlidGenerator.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Identifier\Ulid; use OutOfRangeException; +use Random\RandomException; use RuntimeException; use StellarWP\Foundation\Identifier\Ulid\Contracts\Entropy; use StellarWP\Foundation\Identifier\Ulid\Contracts\MillisecondClock; @@ -24,6 +25,11 @@ public function __construct( ) { } + /** + * @throws OutOfRangeException When the current timestamp is outside the ULID range. + * @throws RandomException When secure random bytes cannot be generated. + * @throws RuntimeException When the entropy source returns an invalid byte count. + */ public function generate(): string { return $this->encodeTimestamp($this->clock->milliseconds()) . $this->encodeRandomness($this->entropy->bytes(self::RANDOM_BYTES)); diff --git a/src/Lock/LockToken.php b/src/Lock/LockToken.php index 91c2bd7..5a216f9 100644 --- a/src/Lock/LockToken.php +++ b/src/Lock/LockToken.php @@ -14,6 +14,8 @@ * @param string $name The lock name this token owns. * @param string $owner Opaque owner identifier used to prove ownership. * @param DateTimeImmutable $expiresAt The instant this token stops owning the lock. + * + * @throws InvalidArgumentException When the lock name or owner is blank. */ public function __construct( public string $name, diff --git a/src/WPCli/README.md b/src/WPCli/README.md index 826019b..439c24b 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -115,6 +115,10 @@ final class {{ class }} extends Command Applications should register `StellarWP\Foundation\WPCli\WPCliProvider` once, before feature providers that contribute commands. Feature providers can then add resolved command instances to the shared command list with `mergeArrayVar()`. +Every contributed value must extend `StellarWP\Foundation\WPCli\Command`. The +provider validates the complete list before registering anything and throws a +descriptive exception when a contribution is invalid. + Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin. That provider belongs to the developer-facing `foundation` console binary, not plugin runtime bootstrap. Generated command classes use Strauss-prefixed Foundation imports automatically when `extra.strauss.namespace_prefix` is configured. Handwritten provider code is still application code, so projects using Strauss with `update_call_sites=false` may need to prefix the Foundation and third-party imports shown below, including `lucatume\DI52\Container`. diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php index 005e24a..b740e5d 100644 --- a/src/WPCli/WPCliProvider.php +++ b/src/WPCli/WPCliProvider.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\WPCli; use StellarWP\Foundation\Container\Contracts\Provider; +use UnexpectedValueException; /** * Registers Foundation WP-CLI commands contributed by application providers. @@ -25,14 +26,33 @@ public function register(): void { }, 0, 0); } + /** + * @throws UnexpectedValueException When the configured command list contains an invalid value. + */ private function registerCommands(): void { $commands = $this->container->get(self::COMMANDS); - foreach ($commands as $command) { + if (! is_iterable($commands)) { + throw new UnexpectedValueException(sprintf( + 'WP-CLI commands must be iterable; received %s.', + get_debug_type($commands) + )); + } + + $commands = is_array($commands) ? array_values($commands) : iterator_to_array($commands, false); + + foreach ($commands as $index => $command) { if (! $command instanceof Command) { - continue; + throw new UnexpectedValueException(sprintf( + 'WP-CLI command at index %d must extend %s; received %s.', + $index, + Command::class, + get_debug_type($command) + )); } + } + foreach ($commands as $command) { $command->register(); } } diff --git a/tests/Support/Fixtures/Database/FakeDatabase.php b/tests/Support/Fixtures/Database/FakeDatabase.php index 33553e6..007c980 100644 --- a/tests/Support/Fixtures/Database/FakeDatabase.php +++ b/tests/Support/Fixtures/Database/FakeDatabase.php @@ -40,6 +40,8 @@ final class FakeDatabase implements Database public int $insertId = 1; + public int $insertResult = 1; + public function table(Table|string $table, ?string $alias = null): QueryBuilder { return new QueryBuilder($this, $table, $alias); } @@ -112,6 +114,12 @@ public function value(string $sql, mixed ...$bindings): mixed { public function insert(Table|string $table, array $data): int { $this->executed[] = 'INSERT ' . $this->tableName($table); + return $this->insertResult; + } + + public function insertGetId(Table|string $table, array $data): int { + $this->insert($table, $data); + return $this->insertId; } diff --git a/tests/Support/Fixtures/Database/RecordingSchema.php b/tests/Support/Fixtures/Database/RecordingSchema.php index 9363bb9..275b3ce 100644 --- a/tests/Support/Fixtures/Database/RecordingSchema.php +++ b/tests/Support/Fixtures/Database/RecordingSchema.php @@ -22,10 +22,14 @@ final class RecordingSchema implements Schema */ public array $indexes = []; - public function createOrUpdate(Table|string $table, ?string $sql = null): void { - $name = $table instanceof Table ? $table->name() : $table; + public function createOrUpdate(Table $table): void { + $name = $table->name(); $this->tables[$name] = true; - $this->statements[] = 'createOrUpdate:' . ($sql ?? $name); + $this->statements[] = 'createOrUpdate:' . $name; + } + + public function createOrUpdateSql(string $sql): void { + $this->statements[] = 'createOrUpdateSql:' . $sql; } public function execute(string $sql): void { diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 261e913..165a3c1 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -16,7 +16,6 @@ use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema; -use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; @@ -46,11 +45,14 @@ require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } - $database = new Database($wpdb); - $schema = new Schema($database, dbDelta(...)); - $migrationTable = $wpdb->prefix . 'foundation_cli_migrations'; - $lockTable = $wpdb->prefix . 'foundation_cli_locks'; - $exampleTable = $wpdb->prefix . 'foundation_cli_example'; + $database = new Database($wpdb); + $schema = new Schema($database, static fn (string $sql, bool $execute): array => dbDelta($sql, $execute)); + $migrationTableName = $wpdb->prefix . 'foundation_cli_migrations'; + $lockTableName = $wpdb->prefix . 'foundation_cli_locks'; + $exampleTable = $wpdb->prefix . 'foundation_cli_example'; + $migrationTable = new MigrationTable($migrationTableName); + $lockTable = new LockTable($lockTableName); + $store = new Store($schema, $migrationTable, $lockTable); $migration = new class($exampleTable) implements Migration { public function __construct( @@ -63,7 +65,7 @@ public function id(): string { } public function up(SchemaContract $schema): void { - $schema->createOrUpdate(sprintf( + $schema->createOrUpdateSql(sprintf( 'CREATE TABLE %s ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, name varchar(191) NOT NULL, @@ -85,14 +87,11 @@ public function down(SchemaContract $schema): void { $container, 'foundation', new Migrator( - new Store(new TableCollection($schema, [ - new MigrationTable($migrationTable), - new LockTable($lockTable), - ])), new Runner( - new Repository($database, $migrationTable), + new Repository($database, $migrationTableName), $schema, - new DatabaseLock($database, $lockTable) + new DatabaseLock($database, $lockTableName), + $store ), new MigrationCollection([$migration]) ) diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index e9bb06c..757859e 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -10,7 +10,6 @@ use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema as DatabaseSchema; -use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\InMemoryLock; @@ -32,17 +31,15 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $this->loadWpCliUtilities(); - $database = new FakeDatabase(); - $wpSchema = new DatabaseSchema($database, static fn (string $sql): array => []); - $command = new Migrate( + $database = new FakeDatabase(); + $wpSchema = new DatabaseSchema($database, static fn (string $sql, bool $execute): array => []); + $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); + $store = new Store($wpSchema, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); + $command = new Migrate( $this->container, 'foundation', new Migrator( - new Store(new TableCollection($wpSchema, [ - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks'), - ])), - new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock()), + new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock(), $store), new MigrationCollection() ) ); @@ -78,8 +75,8 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { ], [ 'type' => 'flag', - 'name' => 'drop', - 'description' => 'Drop Foundation database tables.', + 'name' => 'drop-store', + 'description' => 'Drop only the migration ledger.', 'optional' => true, 'default' => false, ], @@ -114,8 +111,8 @@ public function test_it_creates_database_tables_without_running_migrations(): vo $this->assertSame([], $repository->all()); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_migrations', 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', ], $schema->statements); } @@ -126,8 +123,8 @@ public function test_it_supports_create_table_as_an_alias_for_prepare(): void { $this->assertSame([], $repository->all()); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_migrations', 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', ], $schema->statements); } @@ -150,8 +147,8 @@ public function test_it_runs_pending_migrations(): void { $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_migrations', 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', 'up:2026_06_23_000001_create_example', ], $schema->statements); } @@ -184,19 +181,19 @@ public function test_it_refreshes_database_migrations(): void { $this->assertContains('up:2026_06_23_000001_create_example', $schema->statements); } - public function test_it_drops_database_tables(): void { + public function test_it_drops_the_migration_store(): void { [$command, , $schema] = $this->newCommand(); $command->runCommand([], ['create-table' => true]); $this->assertSame(0, $command->runCommand([], [ - 'drop' => true, - 'yes' => true, + 'drop-store' => true, + 'yes' => true, ])); - $this->assertSame([], $schema->tables); + $this->assertSame(['wp_nexcess_foundation_locks' => true], $schema->tables); $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); - $this->assertContains('drop:wp_nexcess_foundation_locks', $schema->statements); + $this->assertNotContains('drop:wp_nexcess_foundation_locks', $schema->statements); } public function test_it_shows_a_warning_when_status_tables_do_not_exist(): void { @@ -217,24 +214,32 @@ public function test_it_shows_migration_status_when_tables_exist(): void { $this->assertSame(0, $command->runCommand()); } + public function test_it_shows_unavailable_recorded_migrations(): void { + [$command, $repository] = $this->newCommand(); + + $command->runCommand([], ['prepare' => true]); + $repository->recordRun('2026_06_23_000002_missing_migration', 1); + + $this->expectOutputRegex('/2026_06_23_000002_missing_migration\s+unavailable/'); + + $this->assertSame(0, $command->runCommand()); + } + /** * @return array{Migrate, InMemoryRepository, RecordingSchema} */ private function newCommand(): array { $this->loadWpCliUtilities(); - $database = new FakeDatabase(); - $wpSchema = new RecordingSchema(); - $repository = new InMemoryRepository(); - $runner = new Runner($repository, $wpSchema, new InMemoryLock()); - $command = new Migrate( + $wpSchema = new RecordingSchema(); + $repository = new InMemoryRepository(); + $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); + $store = new Store($wpSchema, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); + $runner = new Runner($repository, $wpSchema, new InMemoryLock(), $store); + $command = new Migrate( $this->container, 'foundation', new Migrator( - new Store(new TableCollection($wpSchema, [ - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks'), - ])), $runner, new MigrationCollection([ new TestMigration('2026_06_23_000001_create_example'), diff --git a/tests/Unit/Database/Migration/CollectionTest.php b/tests/Unit/Database/Migration/CollectionTest.php index d3f8dc3..8ffce85 100644 --- a/tests/Unit/Database/Migration/CollectionTest.php +++ b/tests/Unit/Database/Migration/CollectionTest.php @@ -4,6 +4,7 @@ use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use StellarWP\Foundation\Database\Migration\Collection; +use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\TestCase; @@ -16,8 +17,14 @@ public function test_it_collects_migrations_in_order(): void { $collection = new Collection([$first]); $collection->add($second); - $this->assertSame([$first, $second], $collection->all()); - $this->assertSame([$first, $second], iterator_to_array($collection)); + $indexed = [ + $first->id() => $first, + $second->id() => $second, + ]; + + $this->assertSame($indexed, $collection->all()); + $this->assertSame([$first, $second], $collection->values()); + $this->assertSame($indexed, iterator_to_array($collection)); } public function test_it_rejects_duplicate_migration_ids(): void { @@ -28,4 +35,42 @@ public function test_it_rejects_duplicate_migration_ids(): void { new TestMigration('2026_01_01_000001_create_users'), ]); } + + public function test_it_rejects_blank_migration_ids(): void { + $this->expectException(InvalidMigrationId::class); + + new Collection([new TestMigration(' ')]); + } + + public function test_it_rejects_padded_migration_ids(): void { + $this->expectException(InvalidMigrationId::class); + + new Collection([new TestMigration(' migration')]); + } + + public function test_it_rejects_migration_ids_larger_than_the_ledger_column(): void { + $this->expectException(InvalidMigrationId::class); + $this->expectExceptionMessage('cannot exceed 191 bytes'); + + new Collection([new TestMigration(str_repeat('a', 192))]); + } + + public function test_it_rejects_integer_like_migration_ids_that_php_would_coerce_to_array_keys(): void { + $this->expectException(InvalidMigrationId::class); + $this->expectExceptionMessage('cannot be integer-like strings'); + + new Collection([new TestMigration('123')]); + } + + public function test_it_accepts_case_distinct_ids_at_the_maximum_length(): void { + $upper = new TestMigration(str_repeat('A', 191)); + $lower = new TestMigration(str_repeat('a', 191)); + $collection = new Collection([$upper, $lower]); + + $this->assertSame([ + $upper->id() => $upper, + $lower->id() => $lower, + ], $collection->all()); + $this->assertSame([$upper, $lower], $collection->values()); + } } diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index 653ac62..c673083 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -2,11 +2,11 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; +use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Migration\Store; -use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\InMemoryLock; @@ -26,8 +26,8 @@ public function test_it_prepares_the_store_before_running_configured_migrations( $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_migrations', 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', 'up:2026_06_23_000001_create_example', ], $schema->statements); } @@ -36,7 +36,6 @@ public function test_it_prepares_the_store_before_rolling_back_configured_migrat [$migrator, $repository, $schema] = $this->newMigrator(); $migrator->run(); - $migrator->drop(); $schema->statements = []; $result = $migrator->rollback(); @@ -44,8 +43,8 @@ public function test_it_prepares_the_store_before_rolling_back_configured_migrat $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_migrations', 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_06_23_000001_create_example', ], $schema->statements); } @@ -54,7 +53,6 @@ public function test_it_prepares_the_store_before_refreshing_configured_migratio [$migrator, $repository, $schema] = $this->newMigrator(); $migrator->run(); - $migrator->drop(); $schema->statements = []; $result = $migrator->refresh(); @@ -63,8 +61,8 @@ public function test_it_prepares_the_store_before_refreshing_configured_migratio $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_migrations', 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_06_23_000001_create_example', 'up:2026_06_23_000001_create_example', ], $schema->statements); @@ -90,28 +88,72 @@ public function test_it_prepares_and_drops_the_migration_store(): void { $this->assertTrue($migrator->exists()); - $migrator->drop(); + $migrator->dropStore(); $this->assertFalse($migrator->exists()); $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); - $this->assertContains('drop:wp_nexcess_foundation_locks', $schema->statements); + $this->assertNotContains('drop:wp_nexcess_foundation_locks', $schema->statements); + $this->assertTrue($schema->tables['wp_nexcess_foundation_locks']); + } + + public function test_it_does_not_drop_the_store_while_another_migration_owns_the_lock(): void { + $lock = new InMemoryLock(); + [$migrator, , $schema] = $this->newMigrator($lock); + + $migrator->prepare(); + $token = $lock->acquire('foundation-database-migrations', 300); + + $this->assertNotNull($token); + $this->expectException(MigrationLockFailed::class); + + try { + $migrator->dropStore(); + } finally { + $this->assertTrue($schema->tables['wp_nexcess_foundation_migrations']); + } + } + + public function test_it_does_not_prepare_the_ledger_while_another_migration_owns_the_lock(): void { + $lock = new InMemoryLock(); + [$migrator, , $schema] = $this->newMigrator($lock); + $token = $lock->acquire('foundation-database-migrations', 300); + + $this->assertNotNull($token); + $this->expectException(MigrationLockFailed::class); + + try { + $migrator->prepare(); + } finally { + $this->assertTrue($schema->tables['wp_nexcess_foundation_locks']); + $this->assertArrayNotHasKey('wp_nexcess_foundation_migrations', $schema->tables); + } + } + + public function test_status_uses_the_existing_ledger_when_shared_lock_storage_is_missing(): void { + [$migrator, , $schema] = $this->newMigrator(); + + $migrator->run(); + unset($schema->tables['wp_nexcess_foundation_locks']); + + $this->assertFalse($migrator->exists()); + $this->assertTrue($migrator->status()[0]->ran); } /** * @return array{Migrator, InMemoryRepository, RecordingSchema} */ - private function newMigrator(): array { + private function newMigrator(?InMemoryLock $lock = null): array { $database = new FakeDatabase(); $schema = new RecordingSchema(); $repository = new InMemoryRepository(); + $lock ??= new InMemoryLock(); + $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); + $lockTable = new LockTable('wp_nexcess_foundation_locks'); + $store = new Store($schema, $migrationTable, $lockTable); return [ new Migrator( - new Store(new TableCollection($schema, [ - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks'), - ])), - new Runner($repository, $schema, new InMemoryLock()), + new Runner($repository, $schema, $lock, $store), new Collection([ new TestMigration('2026_06_23_000001_create_example'), ]) diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php index 61f4a49..a816dac 100644 --- a/tests/Unit/Database/Migration/RepositoryTest.php +++ b/tests/Unit/Database/Migration/RepositoryTest.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; +use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\TestCase; @@ -49,6 +50,25 @@ public function test_it_records_a_migration_run(): void { $this->assertStringContainsString('INSERT INTO `network_foundation_migrations`', $this->database->executed[0]); } + public function test_it_rejects_invalid_migration_ids_before_writing_to_the_ledger(): void { + $this->expectException(InvalidMigrationId::class); + + $this->repository->recordRun(' invalid', 2); + } + + public function test_it_rejects_invalid_migration_ids_read_from_the_ledger(): void { + $this->database->rowsResults[] = [[ + 'id' => 1, + 'migration' => '123', + 'batch' => 1, + 'ran_at' => '2026-01-01 00:00:00', + ]]; + + $this->expectException(InvalidMigrationId::class); + + $this->repository->all(); + } + public function test_it_deletes_a_migration_run(): void { $this->database->executeResults[] = 1; diff --git a/tests/Unit/Database/Migration/RunnerTest.php b/tests/Unit/Database/Migration/RunnerTest.php index 14871e1..dddb4b7 100644 --- a/tests/Unit/Database/Migration/RunnerTest.php +++ b/tests/Unit/Database/Migration/RunnerTest.php @@ -3,11 +3,19 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; use InvalidArgumentException; -use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; +use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Contracts\Schema; +use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; +use StellarWP\Foundation\Database\Migration\Collection; +use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; use StellarWP\Foundation\Database\Migration\Result; use StellarWP\Foundation\Database\Migration\Runner; +use StellarWP\Foundation\Database\Migration\Store; +use StellarWP\Foundation\Database\Table\Tables\LockTable; +use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\InMemoryLock; @@ -27,6 +35,8 @@ final class RunnerTest extends TestCase private InMemoryLock $lock; + private Store $store; + private Runner $runner; protected function setUp(): void { @@ -35,28 +45,33 @@ protected function setUp(): void { $this->repository = new InMemoryRepository(); $this->schema = new RecordingSchema(); $this->lock = new InMemoryLock(new MutableClock(new \DateTimeImmutable('2026-01-01 00:00:00'))); - $this->runner = new Runner($this->repository, $this->schema, $this->lock); + $this->store = new Store( + new RecordingSchema(), + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks') + ); + $this->runner = new Runner($this->repository, $this->schema, $this->lock, $this->store); } public function test_it_rejects_a_blank_migration_lock_name(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('lock name cannot be empty'); - new Runner($this->repository, $this->schema, $this->lock, lockName: ' '); + new Runner($this->repository, $this->schema, $this->lock, $this->store, lockName: ' '); } public function test_it_rejects_an_invalid_migration_lock_ttl(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('TTL must be at least one second'); - new Runner($this->repository, $this->schema, $this->lock, lockTtl: 0); + new Runner($this->repository, $this->schema, $this->lock, $this->store, lockTtl: 0); } public function test_it_runs_pending_migrations_in_order(): void { - $result = $this->runner->run([ + $result = $this->runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), - ]); + )); $this->assertSame([ '2026_01_01_000001_create_users', @@ -71,14 +86,14 @@ public function test_it_runs_pending_migrations_in_order(): void { } public function test_it_skips_migrations_that_have_already_run(): void { - $this->runner->run([ + $this->runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), - ]); + )); - $result = $this->runner->run([ + $result = $this->runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), - ]); + )); $this->assertSame(['2026_01_01_000002_create_posts'], $result->ran); $this->assertSame(['2026_01_01_000001_create_users'], $result->skipped); @@ -86,21 +101,21 @@ public function test_it_skips_migrations_that_have_already_run(): void { } public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { - $this->runner->run([ + $this->runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), - ]); - $this->runner->run([ + )); + $this->runner->run($this->collection( new TestMigration('2026_01_01_000002_create_posts'), new TestMigration('2026_01_01_000003_create_comments'), - ]); + )); $this->schema->statements = []; - $result = $this->runner->rollback([ + $result = $this->runner->rollback($this->collection( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), new TestMigration('2026_01_01_000003_create_comments'), - ]); + )); $this->assertSame([ '2026_01_01_000003_create_comments', @@ -115,30 +130,52 @@ public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { } public function test_it_returns_an_empty_result_when_there_is_no_batch_to_roll_back(): void { - $result = $this->runner->rollback([ + $result = $this->runner->rollback($this->collection( new TestMigration('2026_01_01_000001_create_users'), - ]); + )); $this->assertSame([], $result->rolledBack); $this->assertSame(0, $result->count()); } - public function test_it_skips_rollback_records_without_a_matching_migration(): void { + public function test_it_rejects_unavailable_rollback_records_before_changing_schema(): void { + $this->repository->recordRun('2026_01_01_000001_create_users', 1); $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); - $result = $this->runner->rollback([ - new TestMigration('2026_01_01_000002_create_posts'), - ]); + $this->expectException(UnavailableMigration::class); + $this->expectExceptionMessage('2026_01_01_000001_missing_migration'); - $this->assertSame([], $result->rolledBack); - $this->assertSame(['2026_01_01_000001_missing_migration'], $result->skipped); + try { + $this->runner->rollback($this->collection( + new TestMigration('2026_01_01_000001_create_users'), + )); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_rejects_unavailable_refresh_records_before_changing_schema(): void { + $this->repository->recordRun('2026_01_01_000001_create_users', 1); + $this->repository->recordRun('2026_01_01_000002_missing_migration', 1); + + $this->expectException(UnavailableMigration::class); + + try { + $this->runner->refresh($this->collection( + new TestMigration('2026_01_01_000001_create_users'), + )); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } } public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): void { - $migrations = [ + $migrations = $this->collection( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), - ]; + ); $this->runner->run($migrations); $this->schema->statements = []; @@ -161,15 +198,40 @@ public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): voi ], $this->schema->statements); } + public function test_refresh_uses_one_migration_snapshot_for_rollback_and_run(): void { + $collection = new Collection(); + $late = new TestMigration('2026_01_01_000002_create_posts'); + $migration = $this->createMock(Migration::class); + + $migration->method('id')->willReturn('2026_01_01_000001_create_users'); + $migration->method('up') + ->willReturnCallback(static function (Schema $schema): void { + $schema->execute('up:2026_01_01_000001_create_users'); + }); + $migration->method('down') + ->willReturnCallback(static function (Schema $schema) use ($collection, $late): void { + $schema->execute('down:2026_01_01_000001_create_users'); + $collection->add($late); + }); + + $collection->add($migration); + $this->runner->run($collection); + $result = $this->runner->refresh($collection); + + $this->assertSame(['2026_01_01_000001_create_users'], $result->rolledBack); + $this->assertSame(['2026_01_01_000001_create_users'], $result->ran); + $this->assertSame([$migration, $late], $collection->values()); + } + public function test_it_returns_status_for_configured_migrations(): void { - $this->runner->run([ + $this->runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), - ]); + )); - $statuses = $this->runner->status([ + $statuses = $this->runner->status($this->collection( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), - ]); + )); $this->assertTrue($statuses[0]->ran); $this->assertSame(1, $statuses[0]->batch); @@ -177,6 +239,21 @@ public function test_it_returns_status_for_configured_migrations(): void { $this->assertNull($statuses[1]->batch); } + public function test_it_returns_status_for_unavailable_recorded_migrations(): void { + $this->runner->prepareStore(); + $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); + + $statuses = $this->runner->status($this->collection( + new TestMigration('2026_01_01_000002_create_posts'), + )); + + $this->assertTrue($statuses[0]->available); + $this->assertFalse($statuses[0]->ran); + $this->assertFalse($statuses[1]->available); + $this->assertTrue($statuses[1]->ran); + $this->assertSame('2026_01_01_000001_missing_migration', $statuses[1]->migration); + } + public function test_migration_results_count_ran_and_rolled_back_migrations(): void { $result = new Result( ran: ['2026_01_01_000001_create_users'], @@ -187,14 +264,14 @@ public function test_migration_results_count_ran_and_rolled_back_migrations(): v $this->assertSame(2, $result->count()); } - public function test_it_rejects_duplicate_migration_ids(): void { - $this->expectException(DuplicateMigration::class); - $this->expectExceptionMessage('Duplicate migration ID'); + public function test_it_treats_migration_ids_as_case_sensitive(): void { + $result = $this->runner->run($this->collection( + new TestMigration('CreateReports'), + new TestMigration('createreports'), + )); - $this->runner->run([ - new TestMigration('2026_01_01_000001_create_users'), - new TestMigration('2026_01_01_000001_create_users'), - ]); + $this->assertSame(['CreateReports', 'createreports'], $result->ran); + $this->assertCount(2, $this->repository->all()); } public function test_it_fails_when_the_migration_lock_is_already_owned(): void { @@ -203,9 +280,40 @@ public function test_it_fails_when_the_migration_lock_is_already_owned(): void { $this->expectException(MigrationLockFailed::class); $this->expectExceptionMessage('Could not acquire migration lock'); - $this->runner->run([ + $this->runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), - ]); + )); + } + + public function test_it_releases_the_lock_when_ledger_preparation_fails(): void { + $storeSchema = $this->createMock(Schema::class); + $storeSchema->method('createOrUpdate') + ->willReturnCallback(static function (Table $table): void { + if ($table instanceof MigrationTable) { + throw new DatabaseException('Could not prepare the migration ledger.'); + } + }); + + $runner = new Runner( + $this->repository, + $this->schema, + $this->lock, + new Store( + $storeSchema, + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks') + ) + ); + + $this->expectException(DatabaseException::class); + + try { + $runner->run($this->collection( + new TestMigration('2026_01_01_000001_create_users'), + )); + } finally { + $this->assertNotNull($this->lock->acquire('foundation-database-migrations', 300)); + } } public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_during_release(): void { @@ -220,15 +328,15 @@ public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_ ->with($token) ->willReturn(false); - $runner = new Runner($this->repository, $this->schema, $lock); + $runner = new Runner($this->repository, $this->schema, $lock, $this->store); $this->expectException(MigrationLockFailed::class); $this->expectExceptionMessage('Could not confirm ownership'); try { - $runner->run([ + $runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), - ]); + )); } finally { $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } @@ -245,14 +353,14 @@ public function test_it_preserves_the_migration_failure_when_lock_release_is_una ->with($token) ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); - $runner = new Runner($this->repository, $this->schema, $lock); + $runner = new Runner($this->repository, $this->schema, $lock, $this->store); $this->expectException(MigrationFailed::class); $this->expectExceptionMessage('failed while running'); - $runner->run([ + $runner->run($this->collection( new FailingMigration('2026_01_01_000001_create_users', failUp: true), - ]); + )); } public function test_it_preserves_the_migration_failure_when_release_cannot_confirm_ownership(): void { @@ -266,14 +374,14 @@ public function test_it_preserves_the_migration_failure_when_release_cannot_conf ->with($token) ->willReturn(false); - $runner = new Runner($this->repository, $this->schema, $lock); + $runner = new Runner($this->repository, $this->schema, $lock, $this->store); $this->expectException(MigrationFailed::class); $this->expectExceptionMessage('failed while running'); - $runner->run([ + $runner->run($this->collection( new FailingMigration('2026_01_01_000001_create_users', failUp: true), - ]); + )); } public function test_it_does_not_record_a_failed_migration(): void { @@ -281,26 +389,26 @@ public function test_it_does_not_record_a_failed_migration(): void { $this->expectExceptionMessage('failed while running'); try { - $this->runner->run([ + $this->runner->run($this->collection( new FailingMigration('2026_01_01_000001_create_users', failUp: true), - ]); + )); } finally { $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); } } public function test_it_does_not_delete_a_record_when_rollback_fails(): void { - $this->runner->run([ + $this->runner->run($this->collection( new TestMigration('2026_01_01_000001_create_users'), - ]); + )); $this->expectException(MigrationFailed::class); $this->expectExceptionMessage('failed while rolling back'); try { - $this->runner->rollback([ + $this->runner->rollback($this->collection( new FailingMigration('2026_01_01_000001_create_users', failDown: true), - ]); + )); } finally { $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } @@ -313,4 +421,8 @@ private function lockToken(): LockToken { return $token; } + + private function collection(Migration ...$migrations): Collection { + return new Collection($migrations); + } } diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php index d8b63dc..338ed4b 100644 --- a/tests/Unit/Database/Query/QueryBuilderTest.php +++ b/tests/Unit/Database/Query/QueryBuilderTest.php @@ -66,6 +66,18 @@ public function test_it_reads_the_first_row(): void { ['name' => 'first'], $database->table('reports')->where('id', '=', 1)->first() ); + $this->assertStringEndsWith('LIMIT 1', $database->rowQueries[0]); + } + + public function test_reading_the_first_row_does_not_mutate_the_builder_and_preserves_its_offset(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['name' => 'sixth']; + $query = $database->table('reports')->limit(25, 5); + + $this->assertSame(['name' => 'sixth'], $query->first()); + $this->assertStringEndsWith('LIMIT 1 OFFSET 5', $database->rowQueries[0]); + $this->assertSame('SELECT * FROM `wp_reports` LIMIT %d OFFSET %d', $query->toSql()); + $this->assertSame([25, 5], $query->bindings()); } public function test_it_selects_all_columns_by_default(): void { diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php index 23da7af..6ad2d50 100644 --- a/tests/Unit/Database/SchemaTest.php +++ b/tests/Unit/Database/SchemaTest.php @@ -2,28 +2,61 @@ namespace StellarWP\Foundation\Tests\Unit\Database; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\TestCase; final class SchemaTest extends TestCase { public function test_it_runs_create_or_update_sql_through_db_delta(): void { $statements = []; - $schema = new Schema(new FakeDatabase(), static function (string $sql) use (&$statements): void { - $statements[] = $sql; + $schema = new Schema(new FakeDatabase(), static function (string $sql, bool $execute) use (&$statements): array { + $statements[] = [$sql, $execute]; + + return []; + }); + + $schema->createOrUpdateSql('CREATE TABLE example (id bigint)'); + + $this->assertSame([ + ['CREATE TABLE example (id bigint)', true], + ['CREATE TABLE example (id bigint)', false], + ], $statements); + } + + public function test_it_builds_table_definitions_for_db_delta(): void { + $statements = []; + $schema = new Schema(new FakeDatabase(), static function (string $sql, bool $execute) use (&$statements): array { + if ($execute) { + $statements[] = $sql; + } + + return []; }); - $schema->createOrUpdate('CREATE TABLE example (id bigint)'); + $schema->createOrUpdate(new TestTable('example', 'wp_example')); + + $this->assertStringContainsString('CREATE TABLE `wp_example`', $statements[0]); + } + + public function test_it_fails_when_db_delta_still_reports_pending_changes(): void { + $schema = new Schema(new FakeDatabase(), static fn (string $sql, bool $execute): array => $execute ? [] : [ + 'wp_example.name' => 'Added column wp_example.name', + ]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Database schema reconciliation did not complete'); - $this->assertSame(['CREATE TABLE example (id bigint)'], $statements); + $schema->createOrUpdateSql('CREATE TABLE wp_example (name varchar(191))'); } public function test_it_checks_tables_and_indexes(): void { $database = new FakeDatabase(); $database->rowResults[] = ['table' => 'wp_example']; $database->rowResults[] = ['Key_name' => 'example_key']; - $schema = new Schema($database, static fn (string $sql): array => []); + $schema = new Schema($database, static fn (string $sql, bool $execute): array => []); $this->assertTrue($schema->hasTable('wp_example%')); $this->assertTrue($schema->hasIndex('wp_example', 'example_key')); @@ -33,7 +66,7 @@ public function test_it_checks_tables_and_indexes(): void { public function test_it_drops_indexes(): void { $database = new FakeDatabase(); - $schema = new Schema($database, static fn (string $sql): array => []); + $schema = new Schema($database, static fn (string $sql, bool $execute): array => []); $schema->dropIndex('wp_example', 'example_key'); @@ -41,7 +74,7 @@ public function test_it_drops_indexes(): void { } public function test_it_exposes_identifier_helpers(): void { - $schema = new Schema(new FakeDatabase(), static fn (string $sql): array => []); + $schema = new Schema(new FakeDatabase(), static fn (string $sql, bool $execute): array => []); $this->assertSame('`weird``table`', $schema->quoteIdentifier('weird`table')); } diff --git a/tests/Unit/Database/Table/ColumnTest.php b/tests/Unit/Database/Table/ColumnTest.php index 051947c..191e3c4 100644 --- a/tests/Unit/Database/Table/ColumnTest.php +++ b/tests/Unit/Database/Table/ColumnTest.php @@ -56,4 +56,15 @@ public function test_auto_increment_is_idempotent(): void { (new Column('id', 'bigint', 20))->autoIncrement()->autoIncrement()->sql() ); } + + public function test_it_escapes_string_defaults_as_sql_literals(): void { + $column = (new Column('label', 'varchar', 50))->default("customer's \\ path"); + + $this->assertSame( + "`label` varchar(50) NOT NULL DEFAULT X'637573746f6d65722773205c2070617468'", + $column->sql() + ); + $this->assertSame("X'637573746f6d65722773205c2070617468'", $column->defaultSql()); + $this->assertNull((new Column('label', 'varchar', 50))->defaultSql()); + } } diff --git a/tests/Unit/Database/Table/CreateTableTest.php b/tests/Unit/Database/Table/CreateTableTest.php index 0ab960b..2d577ae 100644 --- a/tests/Unit/Database/Table/CreateTableTest.php +++ b/tests/Unit/Database/Table/CreateTableTest.php @@ -25,7 +25,7 @@ public function test_it_creates_missing_tables(): void { $this->assertTrue($schema->hasTable($table)); } - public function test_it_does_not_create_existing_tables(): void { + public function test_it_reconciles_existing_tables(): void { $table = new TestTable('foundation_example_table', 'wp_example'); $migration = new CreateTable($table); $schema = new RecordingSchema(); @@ -34,7 +34,7 @@ public function test_it_does_not_create_existing_tables(): void { $migration->up($schema); - $this->assertSame([], $schema->statements); + $this->assertSame(['createOrUpdate:wp_example'], $schema->statements); } public function test_it_drops_tables_when_rolled_back(): void { diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php index 92b2e3b..df465db 100644 --- a/tests/Unit/Database/Table/Tables/LockTableTest.php +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -12,8 +12,12 @@ final class LockTableTest extends TestCase public function test_it_creates_the_lock_table(): void { $database = new FakeDatabase(); $statements = []; - $schema = new DatabaseSchema($database, static function (string $sql) use (&$statements): void { - $statements[] = $sql; + $schema = new DatabaseSchema($database, static function (string $sql, bool $execute) use (&$statements): array { + if ($execute) { + $statements[] = $sql; + } + + return []; }); $table = new LockTable('network_foundation_locks'); @@ -33,7 +37,7 @@ public function test_it_creates_the_lock_table(): void { public function test_it_drops_the_lock_table(): void { $database = new FakeDatabase(); - $schema = new DatabaseSchema($database, static fn (string $sql): array => []); + $schema = new DatabaseSchema($database, static fn (string $sql, bool $execute): array => []); $table = new LockTable('network_foundation_locks'); $schema->drop($table); diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php index ef3086a..066f6b6 100644 --- a/tests/Unit/Database/Table/Tables/MigrationTableTest.php +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -12,8 +12,12 @@ final class MigrationTableTest extends TestCase public function test_it_creates_the_migration_table(): void { $database = new FakeDatabase(); $statements = []; - $schema = new DatabaseSchema($database, static function (string $sql) use (&$statements): void { - $statements[] = $sql; + $schema = new DatabaseSchema($database, static function (string $sql, bool $execute) use (&$statements): array { + if ($execute) { + $statements[] = $sql; + } + + return []; }); $table = new MigrationTable('network_foundation_migrations'); @@ -22,12 +26,13 @@ public function test_it_creates_the_migration_table(): void { $this->assertSame(MigrationTable::ID, $table->id()); $this->assertSame('network_foundation_migrations', $table->name()); $this->assertStringContainsString('CREATE TABLE `network_foundation_migrations`', $statements[0]); + $this->assertStringContainsString('`migration` varbinary(191)', $statements[0]); $this->assertStringContainsString('UNIQUE KEY `migration`', $statements[0]); } public function test_it_drops_the_migration_table(): void { $database = new FakeDatabase(); - $schema = new DatabaseSchema($database, static fn (string $sql): array => []); + $schema = new DatabaseSchema($database, static fn (string $sql, bool $execute): array => []); $table = new MigrationTable('network_foundation_migrations'); $schema->drop($table); diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index 9da58a1..c6414c0 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -71,7 +71,8 @@ public function test_it_preserves_preconfigured_migrations(): void { $container->register(DatabaseProvider::class); $this->assertSame([$migration], $container->get(DatabaseProvider::MIGRATIONS)); - $this->assertSame([$migration], $container->get(Collection::class)->all()); + $this->assertSame([$migration->id() => $migration], $container->get(Collection::class)->all()); + $this->assertSame([$migration], $container->get(Collection::class)->values()); } public function test_it_collects_migrations_added_after_provider_registration(): void { @@ -82,7 +83,8 @@ public function test_it_collects_migrations_added_after_provider_registration(): $container->register(DatabaseProvider::class); $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); - $this->assertSame([$migration], $container->get(Collection::class)->all()); + $this->assertSame([$migration->id() => $migration], $container->get(Collection::class)->all()); + $this->assertSame([$migration], $container->get(Collection::class)->values()); } /** diff --git a/tests/integration/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php index cdefac8..79dd811 100644 --- a/tests/integration/WPCli/WPCliProviderTest.php +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -3,9 +3,11 @@ namespace StellarWP\Foundation\Tests\Integration\WPCli; use lucatume\DI52\Container as C; +use stdClass; use StellarWP\Foundation\Tests\Support\Fixtures\WPCli\RecordingCommand; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; use StellarWP\Foundation\WPCli\WPCliProvider; +use UnexpectedValueException; final class WPCliProviderTest extends WPTestCase { @@ -25,4 +27,36 @@ public function test_it_registers_configured_commands_on_cli_init(): void { $this->assertTrue($this->container->get(RecordingCommand::class)->registered); } + + public function test_it_rejects_invalid_commands_before_registering_any_command(): void { + $this->container->when(RecordingCommand::class) + ->needs('$commandPrefix') + ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); + + $this->container->singleton(RecordingCommand::class); + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(RecordingCommand::class), + new stdClass(), + ]); + $this->container->register(WPCliProvider::class); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('must extend'); + + try { + do_action('cli_init'); + } finally { + $this->assertFalse($this->container->get(RecordingCommand::class)->registered); + } + } + + public function test_it_rejects_a_non_iterable_command_list(): void { + $this->container->register(WPCliProvider::class); + $this->container->bind(WPCliProvider::COMMANDS, 'invalid'); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('commands must be iterable; received string'); + + do_action('cli_init'); + } } diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index 975c8bd..002899e 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -40,26 +40,39 @@ public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLI $I->seeResultCodeIs(0); $I->seeInShellOutput('Rolled back 1 migrations and ran 1 migrations.'); - $I->cli(['foundation', 'migrate', '--drop', '--yes']); + $I->cli(['foundation', 'migrate', '--drop-store', '--yes']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Foundation database tables were dropped.'); + $I->seeInShellOutput('The migration ledger was dropped. Application tables were not changed, and shared lock storage remains available.'); + + $I->cli([ + 'db', + 'query', + sprintf("SHOW TABLES LIKE '%sfoundation_cli_example'", $this->prefix($I)), + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('foundation_cli_example'); + + $I->cli([ + 'db', + 'query', + sprintf("SHOW TABLES LIKE '%sfoundation_cli_locks'", $this->prefix($I)), + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('foundation_cli_locks'); $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); - Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); + Assert::assertStringContainsString('The Foundation migration ledger does not exist.', $I->grabLastShellErrorOutput()); } public function test_it_warns_when_showing_status_before_tables_exist(WPCLITester $I): void { $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); - Assert::assertStringContainsString('The Foundation database tables do not exist.', $I->grabLastShellErrorOutput()); + Assert::assertStringContainsString('The Foundation migration ledger does not exist.', $I->grabLastShellErrorOutput()); } private function dropTables(WPCLITester $I): void { - $I->cli(['db', 'prefix']); - $I->seeResultCodeIs(0); - - $prefix = trim($I->grabLastShellOutput()); + $prefix = $this->prefix($I); $I->cli([ 'db', @@ -73,4 +86,11 @@ private function dropTables(WPCLITester $I): void { ]); $I->seeResultCodeIs(0); } + + private function prefix(WPCLITester $I): string { + $I->cli(['db', 'prefix']); + $I->seeResultCodeIs(0); + + return trim($I->grabLastShellOutput()); + } } diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index c9bb89a..72bd737 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -49,7 +49,7 @@ protected function setUp(): void { $this->database = new Database($GLOBALS['wpdb']); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - $this->schema = new Schema($this->database, dbDelta(...)); + $this->schema = new Schema($this->database, static fn (string $sql, bool $execute): array => dbDelta($sql, $execute)); } protected function tearDown(): void { @@ -121,7 +121,7 @@ public function test_database_crud_helpers_and_schema_inspection_use_wordpress() $this->assertFalse($this->database->columnExists($table, 'missing')); $this->assertFalse($this->database->indexExists($table, 'missing')); - $id = $this->database->insert($table, [ + $id = $this->database->insertGetId($table, [ 'name' => 'draft report', 'status' => 'draft', ]); @@ -134,6 +134,25 @@ public function test_database_crud_helpers_and_schema_inspection_use_wordpress() $this->assertSame('0', (string) $this->database->value('SELECT COUNT(*) FROM %i', $table)); } + public function test_database_insert_returns_affected_rows_for_string_identifiers(): void { + $table = $this->table('string_ids'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id varchar(26) NOT NULL, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertSame(1, $this->database->insert($table, [ + 'id' => '01J2Z3Y4X5W6V7T8S9R0Q1P2N3', + 'name' => 'report', + ])); + } + public function test_database_returns_null_for_missing_values_without_query_errors(): void { $table = $this->table('missing_value'); @@ -165,11 +184,34 @@ public function test_database_wraps_wordpress_query_failures(): void { } } + public function test_provider_schema_reports_db_delta_query_failures(): void { + $table = $this->table('invalid_schema'); + $container = $this->newContainer(); + $container->register(DatabaseProvider::class); + $schema = $container->get(Schema::class); + $previous = $GLOBALS['wpdb']->suppress_errors(true); + + try { + $this->assertQueryFails(function () use ($schema, $table): void { + $schema->createOrUpdateSql(sprintf( + 'CREATE TABLE %s ( + id definitely_invalid NOT NULL, + PRIMARY KEY (id) + ) %s;', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + }); + } finally { + $GLOBALS['wpdb']->suppress_errors($previous); + } + } + public function test_schema_creates_inspects_and_changes_tables_through_wordpress(): void { $table = $this->table('schema'); $schema = $this->schema; - $schema->createOrUpdate(sprintf( + $schema->createOrUpdateSql(sprintf( 'CREATE TABLE %s ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, name varchar(191) NOT NULL, @@ -241,6 +283,41 @@ public function definition(): TableDefinition { $this->assertTrue($schema->hasIndex($queue, 'taken_failed_done')); } + public function test_schema_preserves_quote_and_backslash_string_defaults(): void { + $tableName = $this->table('string_default'); + $default = "customer's \\ path"; + $table = static function (string $columnDefault) use ($tableName): Table { + return new class($tableName, $columnDefault) implements Table { + public function __construct( + private string $table, + private string $default + ) { + } + + public function id(): string { + return 'string_default_table'; + } + + public function name(): string { + return $this->table; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('label', 100)->default($this->default); + } + }; + }; + + $this->schema->createOrUpdate($table('initial')); + $this->schema->createOrUpdate($table($default)); + $this->schema->createOrUpdate($table($default)); + $this->database->execute('INSERT INTO %i () VALUES ()', $tableName); + + $this->assertSame($default, $this->database->value('SELECT label FROM %i LIMIT 1', $tableName)); + } + public function test_migration_repository_persists_records_in_wordpress(): void { $table = $this->table('migrations'); $schema = $this->schema; @@ -266,6 +343,13 @@ public function test_migration_repository_persists_records_in_wordpress(): void $this->assertTrue($repository->deleteRun('2026_06_23_000001_create_example_table')); $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example_table')); + $repository->recordRun('CreateReports', 2); + $repository->recordRun('createreports', 2); + + $this->assertTrue($repository->hasRun('CreateReports')); + $this->assertTrue($repository->hasRun('createreports')); + $this->assertCount(2, $repository->recordsForBatch(2)); + $schema->drop($migrationTable); $this->assertFalse($schema->hasTable($migrationTable)); @@ -387,6 +471,34 @@ public function test_lock_table_reconciles_an_existing_previous_definition(): vo $wpSchema->drop($lockTable); } + public function test_migration_table_reconciles_case_insensitive_identifiers(): void { + $table = $this->table('previous_migration_schema'); + $wpSchema = $this->schema; + $migrationTable = new MigrationTable($table); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + migration varchar(191) NOT NULL, + batch int(10) unsigned NOT NULL, + ran_at datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY migration (migration), + KEY batch (batch) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + (new TableCollection($wpSchema, [$migrationTable]))->create(); + + $migration = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'migration'); + + $this->assertSame('varbinary(191)', strtolower((string) ($migration['Type'] ?? ''))); + + $wpSchema->drop($migrationTable); + } + public function test_provider_registers_wordpress_prefixed_database_services(): void { $container = $this->newContainer(); From a4bb2a945c986ece75239fd4ff9d332b7171b8b5 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 11:02:45 -0600 Subject: [PATCH 30/81] WIP: simplify migration arch (remove Runner) --- src/Database/DatabaseProvider.php | 19 +- src/Database/Migration/Migrator.php | 236 +++++++++++++- src/Database/Migration/Runner.php | 296 ------------------ src/Database/Migration/Store.php | 23 +- src/Database/README.md | 13 +- .../Fixtures/Database/NoopMigration.php | 24 ++ .../register-wpcli-migrate-command.php | 17 +- tests/Unit/Database/Cli/MigrateTest.php | 28 +- ...nnerTest.php => MigratorExecutionTest.php} | 173 +++++----- .../Unit/Database/Migration/MigratorTest.php | 12 +- .../Database/DatabaseProviderTest.php | 33 ++ .../Database/DatabaseIntegrationTest.php | 5 +- 12 files changed, 433 insertions(+), 446 deletions(-) delete mode 100644 src/Database/Migration/Runner.php create mode 100644 tests/Support/Fixtures/Database/NoopMigration.php rename tests/Unit/Database/Migration/{RunnerTest.php => MigratorExecutionTest.php} (81%) diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index d3d8c14..e2ab4df 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -15,9 +15,6 @@ use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Repository as MigrationRecordRepository; -use StellarWP\Foundation\Database\Migration\Runner; -use StellarWP\Foundation\Database\Migration\Store; -use StellarWP\Foundation\Database\Table\Collection; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; @@ -104,16 +101,8 @@ private function registerTables(): void { ->needs('$table') ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); - $this->container->when(Collection::class) - ->needs('$tables') - ->give(static fn (C $c): array => [ - $c->get(MigrationTable::class), - $c->get(LockTable::class), - ]); - $this->container->singleton(MigrationTable::class); $this->container->singleton(LockTable::class); - $this->container->singleton(Collection::class); } private function registerMigrations(): void { @@ -125,23 +114,21 @@ private function registerMigrations(): void { ->needs('$table') ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); - $this->container->when(Runner::class) + $this->container->when(Migrator::class) ->needs('$lockName') ->give(static fn (C $c): string => $c->get(self::LOCK_NAME)); - $this->container->when(Runner::class) + $this->container->when(Migrator::class) ->needs('$lockTtl') ->give(static fn (C $c): int => $c->get(self::LOCK_TTL)); - $this->container->when(Runner::class) + $this->container->when(Migrator::class) ->needs(Lock::class) ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); $this->container->singleton(MigrationCollection::class); $this->container->singleton(MigrationRecordRepository::class); $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); - $this->container->singleton(Runner::class); - $this->container->singleton(Store::class); $this->container->singleton(Migrator::class); } diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index 1d54220..4408541 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -2,21 +2,44 @@ namespace StellarWP\Foundation\Database\Migration; +use InvalidArgumentException; +use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Contracts\Repository; +use StellarWP\Foundation\Database\Contracts\Schema; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; +use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; +use Throwable; /** - * Configured entry point for preparing and running database migrations. + * Applies and rolls back the configured database migrations while holding a lock. */ final readonly class Migrator { + /** + * Create a migrator for the configured migrations, storage, and lock policy. + * + * @throws InvalidArgumentException When the migration lock configuration is invalid. + */ public function __construct( - private Runner $runner, - private Collection $migrations + private Collection $migrations, + private Repository $repository, + private Schema $schema, + private Lock $lock, + private Store $store, + private string $lockName = 'foundation-database-migrations', + private int $lockTtl = 300 ) { + if (trim($this->lockName) === '') { + throw new InvalidArgumentException('The migration lock name cannot be empty.'); + } + + if ($this->lockTtl < 1) { + throw new InvalidArgumentException('The migration lock TTL must be at least one second.'); + } } /** @@ -27,27 +50,31 @@ public function __construct( * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function prepare(): void { - $this->runner->prepareStore(); + $this->withLock(function (): void { + $this->store->prepareLedger($this->schema); + }); } /** * Drop the migration ledger while preserving shared lock storage. * - * @throws DatabaseException When migration storage cannot be prepared or dropped. + * @throws DatabaseException When migration storage cannot be dropped. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function dropStore(): void { - $this->runner->dropStore(); + $this->withLock(function (): void { + $this->store->drop($this->schema); + }); } /** - * Determine whether the migration subsystem storage is ready. + * Determine whether the complete migration store is ready. * * @throws DatabaseException When migration storage cannot be inspected. */ public function exists(): bool { - return $this->runner->storeExists(); + return $this->store->exists($this->schema); } /** @@ -56,7 +83,7 @@ public function exists(): bool { * @throws DatabaseException When the ledger cannot be inspected. */ public function hasLedger(): bool { - return $this->runner->hasLedger(); + return $this->store->hasLedger($this->schema); } /** @@ -68,12 +95,18 @@ public function hasLedger(): bool { * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ public function run(): Result { - return $this->runner->run($this->migrations); + $configured = $this->migrations->all(); + + return $this->withPreparedStore( + fn (): Result => $this->runPending($configured) + ); } /** * Roll back the latest configured migration batch. * + * @param int|null $batch A migration ledger batch number, available as Status::$batch from status(). Pass null to roll back the latest recorded batch. + * * @throws DatabaseException When migration storage or schema access fails. * @throws MigrationFailed When a migration fails while rolling back. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. @@ -81,7 +114,20 @@ public function run(): Result { * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ public function rollback(?int $batch = null): Result { - return $this->runner->rollback($this->migrations, $batch); + $configured = $this->migrations->all(); + + return $this->withPreparedStore(function () use ($configured, $batch): Result { + $batch ??= $this->repository->latestBatch(); + + if ($batch === null) { + return new Result(); + } + + return $this->rollbackRecords( + $configured, + $this->repository->recordsForBatch($batch) + ); + }); } /** @@ -94,15 +140,179 @@ public function rollback(?int $batch = null): Result { * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ public function refresh(): Result { - return $this->runner->refresh($this->migrations); + $configured = $this->migrations->all(); + + return $this->withPreparedStore(function () use ($configured): Result { + $rollback = $this->rollbackRecords($configured, array_values($this->repository->all())); + $run = $this->runPending($configured); + + return new Result( + ran: $run->ran, + rolledBack: $rollback->rolledBack, + skipped: $run->skipped + ); + }); } /** + * Return the status of every configured and recorded migration. + * * @throws DatabaseException When migration storage cannot be inspected. * * @return list */ public function status(): array { - return $this->runner->status($this->migrations); + $configured = $this->migrations->all(); + + if (! $this->store->hasLedger($this->schema)) { + return array_map( + static fn (Migration $migration): Status => Status::pending($migration->id()), + array_values($configured) + ); + } + + $records = $this->repository->all(); + $statuses = []; + + foreach ($configured as $migration) { + $statuses[] = isset($records[$migration->id()]) + ? Status::fromRecord($records[$migration->id()]) + : Status::pending($migration->id()); + + unset($records[$migration->id()]); + } + + foreach ($records as $record) { + $statuses[] = Status::unavailable($record); + } + + return $statuses; + } + + /** + * Roll back recorded migrations in reverse order after confirming every implementation is available. + * + * @param array $migrations + * @param list $records + * + * @throws UnavailableMigration When a recorded migration implementation is unavailable. + */ + private function rollbackRecords(array $migrations, array $records): Result { + usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); + $unavailable = array_values(array_map( + static fn (Record $record): string => $record->migration, + array_filter($records, static fn (Record $record): bool => ! isset($migrations[$record->migration])) + )); + + if ($unavailable !== []) { + throw new UnavailableMigration($unavailable); + } + + $rolledBack = []; + + foreach ($records as $record) { + $migration = $migrations[$record->migration]; + + try { + $migration->down($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRollingBack($migration->id(), $throwable); + } + + $this->repository->deleteRun($migration->id()); + $rolledBack[] = $migration->id(); + } + + return new Result(rolledBack: $rolledBack); + } + + /** + * Run migrations that are absent from the ledger and record them in the next batch. + * + * @param array $migrations + */ + private function runPending(array $migrations): Result { + $ran = []; + $skipped = []; + $batch = $this->repository->nextBatch(); + + foreach ($migrations as $migration) { + if ($this->repository->hasRun($migration->id())) { + $skipped[] = $migration->id(); + continue; + } + + try { + $migration->up($this->schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRunning($migration->id(), $throwable); + } + + $this->repository->recordRun($migration->id(), $batch); + $ran[] = $migration->id(); + } + + return new Result(ran: $ran, skipped: $skipped); + } + + /** + * Prepare the migration ledger under the migration lock, then run an operation. + * + * @template T + * + * @param callable(): T $operation + * + * @throws DatabaseException When migration storage cannot be prepared. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * + * @return T + */ + private function withPreparedStore(callable $operation): mixed { + return $this->withLock(function () use ($operation): mixed { + $this->store->prepareLedger($this->schema); + + return $operation(); + }); + } + + /** + * Run an operation while owning the configured migration lock and release it afterward. + * + * @template T + * + * @param callable(): T $operation + * + * @throws DatabaseException When lock storage cannot be prepared. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * + * @return T + */ + private function withLock(callable $operation): mixed { + $this->store->prepareLock($this->schema); + $token = $this->lock->acquire($this->lockName, $this->lockTtl); + + if ($token === null) { + throw MigrationLockFailed::forLock($this->lockName); + } + + try { + $result = $operation(); + } catch (Throwable $failure) { + try { + $this->lock->release($token); + } catch (Throwable) { + // Preserve the primary migration failure when cleanup also fails. + } + + throw $failure; + } + + if (! $this->lock->release($token)) { + throw MigrationLockFailed::forUnconfirmedOwnership($this->lockName); + } + + return $result; } } diff --git a/src/Database/Migration/Runner.php b/src/Database/Migration/Runner.php deleted file mode 100644 index 3c94d2f..0000000 --- a/src/Database/Migration/Runner.php +++ /dev/null @@ -1,296 +0,0 @@ -lockName) === '') { - throw new InvalidArgumentException('The migration lock name cannot be empty.'); - } - - if ($this->lockTtl < 1) { - throw new InvalidArgumentException('The migration lock TTL must be at least one second.'); - } - } - - /** - * @throws DatabaseException When migration storage or schema access fails. - * @throws MigrationFailed When a migration fails while running. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - */ - public function run(Collection $migrations): Result { - $configured = $migrations->all(); - - return $this->withPreparedStore( - fn (): Result => $this->runWithoutLock($configured) - ); - } - - /** - * @throws DatabaseException When migration storage or schema access fails. - * @throws MigrationFailed When a migration fails while rolling back. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UnavailableMigration When a recorded migration implementation is unavailable. - */ - public function rollback(Collection $migrations, ?int $batch = null): Result { - $configured = $migrations->all(); - - return $this->withPreparedStore(function () use ($configured, $batch): Result { - $batch ??= $this->repository->latestBatch(); - - if ($batch === null) { - return new Result(); - } - - return $this->rollbackRecords( - $configured, - $this->repository->recordsForBatch($batch) - ); - }); - } - - /** - * @throws DatabaseException When migration storage or schema access fails. - * @throws MigrationFailed When a migration fails while running or rolling back. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * @throws UnavailableMigration When a recorded migration implementation is unavailable. - */ - public function refresh(Collection $migrations): Result { - $configured = $migrations->all(); - - return $this->withPreparedStore(function () use ($configured): Result { - $rollback = $this->rollbackRecords($configured, array_values($this->repository->all())); - $run = $this->runWithoutLock($configured); - - return new Result( - ran: $run->ran, - rolledBack: $rollback->rolledBack, - skipped: $run->skipped - ); - }); - } - - /** - * Prepare the migration ledger while holding the migration lock. - * - * @throws DatabaseException When migration storage cannot be prepared. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - */ - public function prepareStore(): void { - $this->withPreparedStore(static function (): void { - }); - } - - /** - * Drop the migration ledger while holding the migration lock. - * - * @throws DatabaseException When migration storage cannot be dropped. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - */ - public function dropStore(): void { - $this->withLock(function (): void { - $this->store->drop(); - }); - } - - /** - * Determine whether the complete migration store is ready. - * - * @throws DatabaseException When migration storage cannot be inspected. - */ - public function storeExists(): bool { - return $this->store->exists(); - } - - /** - * Determine whether recorded migration state can be read. - * - * @throws DatabaseException When migration storage cannot be inspected. - */ - public function hasLedger(): bool { - return $this->store->hasLedger(); - } - - /** - * @throws DatabaseException When migration storage access fails. - * - * @return list - */ - public function status(Collection $migrations): array { - $configured = $migrations->all(); - - if (! $this->store->hasLedger()) { - return array_map( - static fn (Migration $migration): Status => Status::pending($migration->id()), - array_values($configured) - ); - } - - $records = $this->repository->all(); - $statuses = []; - - foreach ($configured as $migration) { - $statuses[] = isset($records[$migration->id()]) - ? Status::fromRecord($records[$migration->id()]) - : Status::pending($migration->id()); - - unset($records[$migration->id()]); - } - - foreach ($records as $record) { - $statuses[] = Status::unavailable($record); - } - - return $statuses; - } - - /** - * @param array $migrations - * @param list $records - * - * @throws UnavailableMigration When a recorded migration implementation is unavailable. - */ - private function rollbackRecords(array $migrations, array $records): Result { - usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); - $unavailable = array_values(array_map( - static fn (Record $record): string => $record->migration, - array_filter($records, static fn (Record $record): bool => ! isset($migrations[$record->migration])) - )); - - if ($unavailable !== []) { - throw new UnavailableMigration($unavailable); - } - - $rolledBack = []; - - foreach ($records as $record) { - $migration = $migrations[$record->migration]; - - try { - $migration->down($this->schema); - } catch (Throwable $throwable) { - throw MigrationFailed::whileRollingBack($migration->id(), $throwable); - } - - $this->repository->deleteRun($migration->id()); - $rolledBack[] = $migration->id(); - } - - return new Result(rolledBack: $rolledBack); - } - - /** - * @param array $migrations - */ - private function runWithoutLock(array $migrations): Result { - $ran = []; - $skipped = []; - $batch = $this->repository->nextBatch(); - - foreach ($migrations as $migration) { - if ($this->repository->hasRun($migration->id())) { - $skipped[] = $migration->id(); - continue; - } - - try { - $migration->up($this->schema); - } catch (Throwable $throwable) { - throw MigrationFailed::whileRunning($migration->id(), $throwable); - } - - $this->repository->recordRun($migration->id(), $batch); - $ran[] = $migration->id(); - } - - return new Result(ran: $ran, skipped: $skipped); - } - - /** - * @template T - * - * @param callable(): T $callback - * - * @throws DatabaseException When migration storage cannot be prepared. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * - * @return T - */ - private function withPreparedStore(callable $callback): mixed { - return $this->withLock(function () use ($callback): mixed { - $this->store->prepareLedger(); - - return $callback(); - }); - } - - /** - * @template T - * - * @param callable(): T $callback - * - * @throws DatabaseException When lock storage cannot be prepared. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * - * @return T - */ - private function withLock(callable $callback): mixed { - $this->store->prepareLock(); - $token = $this->lock->acquire($this->lockName, $this->lockTtl); - - if ($token === null) { - throw MigrationLockFailed::forLock($this->lockName); - } - - try { - $result = $callback(); - } catch (Throwable $failure) { - try { - $this->lock->release($token); - } catch (Throwable) { - // Preserve the primary migration failure when cleanup also fails. - } - - throw $failure; - } - - if (! $this->lock->release($token)) { - throw MigrationLockFailed::forUnconfirmedOwnership($this->lockName); - } - - return $result; - } -} diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php index ef5367d..cb5a3a1 100644 --- a/src/Database/Migration/Store.php +++ b/src/Database/Migration/Store.php @@ -9,11 +9,12 @@ /** * Manages the database tables required by the migration subsystem itself. + * + * @internal Use Migrator as the supported migration lifecycle entry point. */ final readonly class Store { public function __construct( - private Schema $schema, private MigrationTable $migrationTable, private LockTable $lockTable ) { @@ -24,8 +25,8 @@ public function __construct( * * @throws DatabaseException When the lock table cannot be reconciled. */ - public function prepareLock(): void { - $this->schema->createOrUpdate($this->lockTable); + public function prepareLock(Schema $schema): void { + $schema->createOrUpdate($this->lockTable); } /** @@ -33,8 +34,8 @@ public function prepareLock(): void { * * @throws DatabaseException When the ledger cannot be reconciled. */ - public function prepareLedger(): void { - $this->schema->createOrUpdate($this->migrationTable); + public function prepareLedger(Schema $schema): void { + $schema->createOrUpdate($this->migrationTable); } /** @@ -42,8 +43,8 @@ public function prepareLedger(): void { * * @throws DatabaseException When the ledger cannot be dropped. */ - public function drop(): void { - $this->schema->drop($this->migrationTable); + public function drop(Schema $schema): void { + $schema->drop($this->migrationTable); } /** @@ -51,8 +52,8 @@ public function drop(): void { * * @throws DatabaseException When the ledger cannot be inspected. */ - public function exists(): bool { - return $this->hasLedger() && $this->schema->hasTable($this->lockTable); + public function exists(Schema $schema): bool { + return $this->hasLedger($schema) && $schema->hasTable($this->lockTable); } /** @@ -60,7 +61,7 @@ public function exists(): bool { * * @throws DatabaseException When the ledger cannot be inspected. */ - public function hasLedger(): bool { - return $this->schema->hasTable($this->migrationTable); + public function hasLedger(Schema $schema): bool { + return $schema->hasTable($this->migrationTable); } } diff --git a/src/Database/README.md b/src/Database/README.md index c15c7f6..254e042 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -11,7 +11,7 @@ composer require stellarwp/foundation-database ## Overview -Foundation Database is a WordPress-backed database package. It provides a configured migrator, migration runner, migration and table collections, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. +Foundation Database is a WordPress-backed database package. It provides a configured migrator, migration and table collections, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. This package intentionally targets WordPress runtime APIs instead of acting as a generic database abstraction. Migration classes depend on a small schema contract so application packages can define migration behavior without calling `wpdb` directly. @@ -35,14 +35,11 @@ The provider registers: - `StellarWP\Foundation\Database\Database` - `StellarWP\Foundation\Database\Contracts\Database` - `StellarWP\Foundation\Database\Schema` -- `StellarWP\Foundation\Database\Table\Collection` - `StellarWP\Foundation\Database\Table\Tables\MigrationTable` - `StellarWP\Foundation\Database\Table\Tables\LockTable` - `StellarWP\Foundation\Database\Contracts\Repository` for the migration ledger -- `StellarWP\Foundation\Database\Migration\Store` -- `StellarWP\Foundation\Database\Migration\Runner` - `StellarWP\Foundation\Database\Migration\Migrator` -- `StellarWP\Foundation\Database\Lock\DatabaseLock` for the migration runner +- `StellarWP\Foundation\Database\Lock\DatabaseLock` for the migrator By default, WordPress tables are named: @@ -81,10 +78,10 @@ return [ ]; ``` -`database.lock_ttl` must cover the complete migration operation. The migration -runner reports unconfirmed ownership if an otherwise successful operation +`database.lock_ttl` must cover the complete migration operation. The migrator +reports unconfirmed ownership if an otherwise successful operation cannot release its ownership token. Increase the TTL for long-running -migrations; the runner does not refresh the lease while a migration is +migrations; the migrator does not refresh the lease while a migration is executing. ## Using Database Locks diff --git a/tests/Support/Fixtures/Database/NoopMigration.php b/tests/Support/Fixtures/Database/NoopMigration.php new file mode 100644 index 0000000..e21b26a --- /dev/null +++ b/tests/Support/Fixtures/Database/NoopMigration.php @@ -0,0 +1,24 @@ +id; + } + + public function up(Schema $schema): void { + } + + public function down(Schema $schema): void { + } +} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 165a3c1..4457d02 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -13,7 +13,6 @@ use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Repository; -use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Database\Table\Tables\LockTable; @@ -52,7 +51,9 @@ $exampleTable = $wpdb->prefix . 'foundation_cli_example'; $migrationTable = new MigrationTable($migrationTableName); $lockTable = new LockTable($lockTableName); - $store = new Store($schema, $migrationTable, $lockTable); + $store = new Store($migrationTable, $lockTable); + $repository = new Repository($database, $migrationTableName); + $lock = new DatabaseLock($database, $lockTableName); $migration = new class($exampleTable) implements Migration { public function __construct( @@ -87,13 +88,11 @@ public function down(SchemaContract $schema): void { $container, 'foundation', new Migrator( - new Runner( - new Repository($database, $migrationTableName), - $schema, - new DatabaseLock($database, $lockTableName), - $store - ), - new MigrationCollection([$migration]) + new MigrationCollection([$migration]), + $repository, + $schema, + $lock, + $store ) ); diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index 757859e..e738aea 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -7,13 +7,10 @@ use StellarWP\Foundation\Database\Cli\Migrate; use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Migrator; -use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Migration\Store; -use StellarWP\Foundation\Database\Schema as DatabaseSchema; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\InMemoryLock; -use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\InMemoryRepository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; @@ -31,16 +28,20 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $this->loadWpCliUtilities(); - $database = new FakeDatabase(); - $wpSchema = new DatabaseSchema($database, static fn (string $sql, bool $execute): array => []); $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); - $store = new Store($wpSchema, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); + $repository = new InMemoryRepository(); + $schema = new RecordingSchema(); + $lock = new InMemoryLock(); + $store = new Store($migrationTable, new LockTable('wp_nexcess_foundation_locks')); $command = new Migrate( $this->container, 'foundation', new Migrator( - new Runner(new InMemoryRepository(), new RecordingSchema(), new InMemoryLock(), $store), - new MigrationCollection() + new MigrationCollection(), + $repository, + $schema, + $lock, + $store ) ); @@ -234,16 +235,19 @@ private function newCommand(): array { $wpSchema = new RecordingSchema(); $repository = new InMemoryRepository(); $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); - $store = new Store($wpSchema, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); - $runner = new Runner($repository, $wpSchema, new InMemoryLock(), $store); + $lock = new InMemoryLock(); + $store = new Store($migrationTable, new LockTable('wp_nexcess_foundation_locks')); $command = new Migrate( $this->container, 'foundation', new Migrator( - $runner, new MigrationCollection([ new TestMigration('2026_06_23_000001_create_example'), - ]) + ]), + $repository, + $wpSchema, + $lock, + $store ) ); diff --git a/tests/Unit/Database/Migration/RunnerTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php similarity index 81% rename from tests/Unit/Database/Migration/RunnerTest.php rename to tests/Unit/Database/Migration/MigratorExecutionTest.php index dddb4b7..679b83c 100644 --- a/tests/Unit/Database/Migration/RunnerTest.php +++ b/tests/Unit/Database/Migration/MigratorExecutionTest.php @@ -11,8 +11,8 @@ use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; +use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Result; -use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; @@ -27,7 +27,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Lock\MutableClock; use StellarWP\Foundation\Tests\TestCase; -final class RunnerTest extends TestCase +final class MigratorExecutionTest extends TestCase { private InMemoryRepository $repository; @@ -37,8 +37,6 @@ final class RunnerTest extends TestCase private Store $store; - private Runner $runner; - protected function setUp(): void { parent::setUp(); @@ -46,38 +44,38 @@ protected function setUp(): void { $this->schema = new RecordingSchema(); $this->lock = new InMemoryLock(new MutableClock(new \DateTimeImmutable('2026-01-01 00:00:00'))); $this->store = new Store( - new RecordingSchema(), new MigrationTable('wp_nexcess_foundation_migrations'), new LockTable('wp_nexcess_foundation_locks') ); - $this->runner = new Runner($this->repository, $this->schema, $this->lock, $this->store); } public function test_it_rejects_a_blank_migration_lock_name(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('lock name cannot be empty'); - new Runner($this->repository, $this->schema, $this->lock, $this->store, lockName: ' '); + new Migrator(new Collection(), $this->repository, $this->schema, $this->lock, $this->store, lockName: ' '); } public function test_it_rejects_an_invalid_migration_lock_ttl(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('TTL must be at least one second'); - new Runner($this->repository, $this->schema, $this->lock, $this->store, lockTtl: 0); + new Migrator(new Collection(), $this->repository, $this->schema, $this->lock, $this->store, lockTtl: 0); } public function test_it_runs_pending_migrations_in_order(): void { - $result = $this->runner->run($this->collection( + $result = $this->configured( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), - )); + )->run(); $this->assertSame([ '2026_01_01_000001_create_users', '2026_01_01_000002_create_posts', ], $result->ran); $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', 'up:2026_01_01_000001_create_users', 'up:2026_01_01_000002_create_posts', ], $this->schema->statements); @@ -86,14 +84,14 @@ public function test_it_runs_pending_migrations_in_order(): void { } public function test_it_skips_migrations_that_have_already_run(): void { - $this->runner->run($this->collection( + $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); + )->run(); - $result = $this->runner->run($this->collection( + $result = $this->configured( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), - )); + )->run(); $this->assertSame(['2026_01_01_000002_create_posts'], $result->ran); $this->assertSame(['2026_01_01_000001_create_users'], $result->skipped); @@ -101,27 +99,29 @@ public function test_it_skips_migrations_that_have_already_run(): void { } public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { - $this->runner->run($this->collection( + $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); - $this->runner->run($this->collection( + )->run(); + $this->configured( new TestMigration('2026_01_01_000002_create_posts'), new TestMigration('2026_01_01_000003_create_comments'), - )); + )->run(); $this->schema->statements = []; - $result = $this->runner->rollback($this->collection( + $result = $this->configured( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), new TestMigration('2026_01_01_000003_create_comments'), - )); + )->rollback(); $this->assertSame([ '2026_01_01_000003_create_comments', '2026_01_01_000002_create_posts', ], $result->rolledBack); $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_01_01_000003_create_comments', 'down:2026_01_01_000002_create_posts', ], $this->schema->statements); @@ -130,9 +130,9 @@ public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { } public function test_it_returns_an_empty_result_when_there_is_no_batch_to_roll_back(): void { - $result = $this->runner->rollback($this->collection( + $result = $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); + )->rollback(); $this->assertSame([], $result->rolledBack); $this->assertSame(0, $result->count()); @@ -146,11 +146,14 @@ public function test_it_rejects_unavailable_rollback_records_before_changing_sch $this->expectExceptionMessage('2026_01_01_000001_missing_migration'); try { - $this->runner->rollback($this->collection( + $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); + )->rollback(); } finally { - $this->assertSame([], $this->schema->statements); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', + ], $this->schema->statements); $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } } @@ -162,11 +165,14 @@ public function test_it_rejects_unavailable_refresh_records_before_changing_sche $this->expectException(UnavailableMigration::class); try { - $this->runner->refresh($this->collection( + $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); + )->refresh(); } finally { - $this->assertSame([], $this->schema->statements); + $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', + ], $this->schema->statements); $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } } @@ -177,10 +183,11 @@ public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): voi new TestMigration('2026_01_01_000002_create_posts'), ); - $this->runner->run($migrations); + $migrator = $this->migrator($migrations); + $migrator->run(); $this->schema->statements = []; - $result = $this->runner->refresh($migrations); + $result = $migrator->refresh(); $this->assertSame([ '2026_01_01_000002_create_posts', @@ -191,6 +198,8 @@ public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): voi '2026_01_01_000002_create_posts', ], $result->ran); $this->assertSame([ + 'createOrUpdate:wp_nexcess_foundation_locks', + 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_01_01_000002_create_posts', 'down:2026_01_01_000001_create_users', 'up:2026_01_01_000001_create_users', @@ -215,8 +224,9 @@ public function test_refresh_uses_one_migration_snapshot_for_rollback_and_run(): }); $collection->add($migration); - $this->runner->run($collection); - $result = $this->runner->refresh($collection); + $migrator = $this->migrator($collection); + $migrator->run(); + $result = $migrator->refresh(); $this->assertSame(['2026_01_01_000001_create_users'], $result->rolledBack); $this->assertSame(['2026_01_01_000001_create_users'], $result->ran); @@ -224,14 +234,14 @@ public function test_refresh_uses_one_migration_snapshot_for_rollback_and_run(): } public function test_it_returns_status_for_configured_migrations(): void { - $this->runner->run($this->collection( + $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); + )->run(); - $statuses = $this->runner->status($this->collection( + $statuses = $this->configured( new TestMigration('2026_01_01_000001_create_users'), new TestMigration('2026_01_01_000002_create_posts'), - )); + )->status(); $this->assertTrue($statuses[0]->ran); $this->assertSame(1, $statuses[0]->batch); @@ -240,12 +250,13 @@ public function test_it_returns_status_for_configured_migrations(): void { } public function test_it_returns_status_for_unavailable_recorded_migrations(): void { - $this->runner->prepareStore(); + $migrator = $this->configured( + new TestMigration('2026_01_01_000002_create_posts'), + ); + $migrator->prepare(); $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); - $statuses = $this->runner->status($this->collection( - new TestMigration('2026_01_01_000002_create_posts'), - )); + $statuses = $migrator->status(); $this->assertTrue($statuses[0]->available); $this->assertFalse($statuses[0]->ran); @@ -265,10 +276,10 @@ public function test_migration_results_count_ran_and_rolled_back_migrations(): v } public function test_it_treats_migration_ids_as_case_sensitive(): void { - $result = $this->runner->run($this->collection( + $result = $this->configured( new TestMigration('CreateReports'), new TestMigration('createreports'), - )); + )->run(); $this->assertSame(['CreateReports', 'createreports'], $result->ran); $this->assertCount(2, $this->repository->all()); @@ -280,9 +291,9 @@ public function test_it_fails_when_the_migration_lock_is_already_owned(): void { $this->expectException(MigrationLockFailed::class); $this->expectExceptionMessage('Could not acquire migration lock'); - $this->runner->run($this->collection( + $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); + )->run(); } public function test_it_releases_the_lock_when_ledger_preparation_fails(): void { @@ -294,23 +305,19 @@ public function test_it_releases_the_lock_when_ledger_preparation_fails(): void } }); - $runner = new Runner( - $this->repository, - $this->schema, - $this->lock, - new Store( - $storeSchema, + $migrator = $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + schema: $storeSchema, + store: new Store( new MigrationTable('wp_nexcess_foundation_migrations'), new LockTable('wp_nexcess_foundation_locks') - ) + ), ); $this->expectException(DatabaseException::class); try { - $runner->run($this->collection( - new TestMigration('2026_01_01_000001_create_users'), - )); + $migrator->run(); } finally { $this->assertNotNull($this->lock->acquire('foundation-database-migrations', 300)); } @@ -328,15 +335,16 @@ public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_ ->with($token) ->willReturn(false); - $runner = new Runner($this->repository, $this->schema, $lock, $this->store); + $migrator = $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + ); $this->expectException(MigrationLockFailed::class); $this->expectExceptionMessage('Could not confirm ownership'); try { - $runner->run($this->collection( - new TestMigration('2026_01_01_000001_create_users'), - )); + $migrator->run(); } finally { $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } @@ -353,14 +361,15 @@ public function test_it_preserves_the_migration_failure_when_lock_release_is_una ->with($token) ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); - $runner = new Runner($this->repository, $this->schema, $lock, $this->store); + $migrator = $this->migrator( + $this->collection(new FailingMigration('2026_01_01_000001_create_users', failUp: true)), + lock: $lock, + ); $this->expectException(MigrationFailed::class); $this->expectExceptionMessage('failed while running'); - $runner->run($this->collection( - new FailingMigration('2026_01_01_000001_create_users', failUp: true), - )); + $migrator->run(); } public function test_it_preserves_the_migration_failure_when_release_cannot_confirm_ownership(): void { @@ -374,14 +383,15 @@ public function test_it_preserves_the_migration_failure_when_release_cannot_conf ->with($token) ->willReturn(false); - $runner = new Runner($this->repository, $this->schema, $lock, $this->store); + $migrator = $this->migrator( + $this->collection(new FailingMigration('2026_01_01_000001_create_users', failUp: true)), + lock: $lock, + ); $this->expectException(MigrationFailed::class); $this->expectExceptionMessage('failed while running'); - $runner->run($this->collection( - new FailingMigration('2026_01_01_000001_create_users', failUp: true), - )); + $migrator->run(); } public function test_it_does_not_record_a_failed_migration(): void { @@ -389,26 +399,26 @@ public function test_it_does_not_record_a_failed_migration(): void { $this->expectExceptionMessage('failed while running'); try { - $this->runner->run($this->collection( + $this->configured( new FailingMigration('2026_01_01_000001_create_users', failUp: true), - )); + )->run(); } finally { $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); } } public function test_it_does_not_delete_a_record_when_rollback_fails(): void { - $this->runner->run($this->collection( + $this->configured( new TestMigration('2026_01_01_000001_create_users'), - )); + )->run(); $this->expectException(MigrationFailed::class); $this->expectExceptionMessage('failed while rolling back'); try { - $this->runner->rollback($this->collection( + $this->configured( new FailingMigration('2026_01_01_000001_create_users', failDown: true), - )); + )->rollback(); } finally { $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } @@ -422,6 +432,25 @@ private function lockToken(): LockToken { return $token; } + private function configured(Migration ...$migrations): Migrator { + return $this->migrator($this->collection(...$migrations)); + } + + private function migrator( + Collection $migrations, + ?Lock $lock = null, + ?Schema $schema = null, + ?Store $store = null + ): Migrator { + return new Migrator( + $migrations, + $this->repository, + $schema ?? $this->schema, + $lock ?? $this->lock, + $store ?? $this->store + ); + } + private function collection(Migration ...$migrations): Collection { return new Collection($migrations); } diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index c673083..afc723f 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -5,12 +5,10 @@ use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Migrator; -use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\InMemoryLock; -use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\InMemoryRepository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; @@ -143,20 +141,22 @@ public function test_status_uses_the_existing_ledger_when_shared_lock_storage_is * @return array{Migrator, InMemoryRepository, RecordingSchema} */ private function newMigrator(?InMemoryLock $lock = null): array { - $database = new FakeDatabase(); $schema = new RecordingSchema(); $repository = new InMemoryRepository(); $lock ??= new InMemoryLock(); $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); $lockTable = new LockTable('wp_nexcess_foundation_locks'); - $store = new Store($schema, $migrationTable, $lockTable); + $store = new Store($migrationTable, $lockTable); return [ new Migrator( - new Runner($repository, $schema, $lock, $store), new Collection([ new TestMigration('2026_06_23_000001_create_example'), - ]) + ]), + $repository, + $schema, + $lock, + $store ), $repository, $schema, diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index c6414c0..a531e91 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -8,12 +8,14 @@ use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Database\Cli\Migrate; +use StellarWP\Foundation\Database\Database; use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\NoopMigration; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; use StellarWP\Foundation\WPCli\Command; @@ -87,6 +89,37 @@ public function test_it_collects_migrations_added_after_provider_registration(): $this->assertSame([$migration], $container->get(Collection::class)->values()); } + public function test_provider_built_migrator_executes_against_wordpress(): void { + $suffix = str_replace('.', '_', uniqid('', true)); + $migrationsTable = $GLOBALS['wpdb']->prefix . 'foundation_provider_migrations_' . $suffix; + $locksTable = $GLOBALS['wpdb']->prefix . 'foundation_provider_locks_' . $suffix; + $migration = new NoopMigration('2026_08_20_000001_provider_migration'); + $container = $this->newContainer([ + 'database' => [ + 'migrations_table' => $migrationsTable, + 'locks_table' => $locksTable, + ], + ]); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + $database = $container->get(Database::class); + + try { + $migrator = $container->get(Migrator::class); + $result = $migrator->run(); + + $this->assertSame([$migration->id()], $result->ran); + $this->assertTrue($database->tableExists($migrationsTable)); + $this->assertTrue($database->tableExists($locksTable)); + $this->assertTrue($migrator->status()[0]->ran); + } finally { + $database->execute('DROP TABLE IF EXISTS %i', $migrationsTable); + $database->execute('DROP TABLE IF EXISTS %i', $locksTable); + } + } + /** * @param array $config */ diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 72bd737..e85bc9b 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -15,8 +15,8 @@ use StellarWP\Foundation\Database\DatabaseProvider; use StellarWP\Foundation\Database\Exceptions\QueryException; use StellarWP\Foundation\Database\Lock\DatabaseLock; +use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Repository; -use StellarWP\Foundation\Database\Migration\Runner; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\TableDefinition; @@ -509,11 +509,10 @@ public function test_provider_registers_wordpress_prefixed_database_services(): $this->assertInstanceOf(Database::class, $container->get(Database::class)); $this->assertInstanceOf(Database::class, $container->get(DatabaseContract::class)); $this->assertInstanceOf(Schema::class, $container->get(Schema::class)); - $this->assertInstanceOf(TableCollection::class, $container->get(TableCollection::class)); $this->assertInstanceOf(MigrationTable::class, $container->get(MigrationTable::class)); $this->assertInstanceOf(LockTable::class, $container->get(LockTable::class)); $this->assertInstanceOf(Repository::class, $container->get(MigrationRecordRepositoryContract::class)); - $this->assertInstanceOf(Runner::class, $container->get(Runner::class)); + $this->assertInstanceOf(Migrator::class, $container->get(Migrator::class)); $this->assertFalse($container->has(Lock::class)); } From f5e3fc115bb6663b82bee6124ff19fb7ef32252d Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 12:08:44 -0600 Subject: [PATCH 31/81] Work around WP-CLI bug where it returns `0`, even if the command returns an error response code --- src/WPCli/Command.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/WPCli/Command.php b/src/WPCli/Command.php index bf97272..f96ef36 100644 --- a/src/WPCli/Command.php +++ b/src/WPCli/Command.php @@ -56,7 +56,13 @@ abstract protected function arguments(): array; * Register the command with WP-CLI. */ public function register(): void { - WP_CLI::add_command($this->command(), [$this, 'runCommand'], [ + WP_CLI::add_command($this->command(), function (array $args, array $assocArgs): void { + $status = $this->runCommand(array_values($args), $assocArgs); + + if ($status !== self::SUCCESS) { + WP_CLI::halt($status); + } + }, [ 'shortdesc' => $this->description(), 'synopsis' => $this->arguments(), ]); From 57443d7389ee9ff23f016c2db9b23e4957ec6b39 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 13:16:39 -0600 Subject: [PATCH 32/81] Simplify/clarify Migrations further + improve docs --- src/Database/Cli/Migrate.php | 71 ++++----- src/Database/DatabaseProvider.php | 7 +- .../Exceptions/UninitializedStore.php | 15 ++ src/Database/Migration/Migrator.php | 144 ++++------------- src/Database/Migration/Store.php | 142 ++++++++++++++--- src/Database/README.md | 146 ++++++++++-------- .../register-wpcli-migrate-command.php | 4 +- tests/Unit/Database/Cli/MigrateTest.php | 58 ++----- .../Migration/MigratorExecutionTest.php | 124 +++++++++++---- .../Unit/Database/Migration/MigratorTest.php | 73 +++++---- .../Database/DatabaseProviderTest.php | 26 +++- .../Database/Cli/DatabaseMigrateCest.php | 20 ++- 12 files changed, 473 insertions(+), 357 deletions(-) create mode 100644 src/Database/Migration/Exceptions/UninitializedStore.php diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index abd1fe4..f08b773 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -15,13 +15,12 @@ */ final class Migrate extends Command { - private const string FLAG_RUN = 'run'; - private const string FLAG_ROLLBACK = 'rollback'; - private const string FLAG_REFRESH = 'refresh'; - private const string FLAG_DROP_STORE = 'drop-store'; - private const string FLAG_PREPARE = 'prepare'; - private const string FLAG_CREATE_TABLE = 'create-table'; - private const string FLAG_YES = 'yes'; + private const string FLAG_RUN = 'run'; + private const string FLAG_ROLLBACK = 'rollback'; + private const string FLAG_REFRESH = 'refresh'; + private const string FLAG_DROP_STORE = 'drop-store'; + private const string FLAG_INITIALIZE = 'initialize'; + private const string FLAG_YES = 'yes'; public function __construct( protected Container $container, @@ -36,21 +35,25 @@ public function __construct( * @param array $assocArgs */ public function runCommand(array $args = [], array $assocArgs = []): int { - $run = (bool) get_flag_value($assocArgs, self::FLAG_RUN, false); - $rollback = (bool) get_flag_value($assocArgs, self::FLAG_ROLLBACK, false); - $refresh = (bool) get_flag_value($assocArgs, self::FLAG_REFRESH, false); - $dropStore = (bool) get_flag_value($assocArgs, self::FLAG_DROP_STORE, false); - $prepare = (bool) get_flag_value($assocArgs, self::FLAG_PREPARE, false); - $createTable = (bool) get_flag_value($assocArgs, self::FLAG_CREATE_TABLE, false); - - if (! $this->hasSingleOperation([ + $run = (bool) get_flag_value($assocArgs, self::FLAG_RUN, false); + $rollback = (bool) get_flag_value($assocArgs, self::FLAG_ROLLBACK, false); + $refresh = (bool) get_flag_value($assocArgs, self::FLAG_REFRESH, false); + $dropStore = (bool) get_flag_value($assocArgs, self::FLAG_DROP_STORE, false); + $initialize = (bool) get_flag_value($assocArgs, self::FLAG_INITIALIZE, false); + + $this->assertSingleOperation([ self::FLAG_RUN => $run, self::FLAG_ROLLBACK => $rollback, self::FLAG_REFRESH => $refresh, self::FLAG_DROP_STORE => $dropStore, - self::FLAG_PREPARE => $prepare || $createTable, - ])) { - return self::ERROR; + self::FLAG_INITIALIZE => $initialize, + ]); + + if (($run || $rollback || $refresh || $dropStore) && ! $this->migrator->isInitialized()) { + WP_CLI::error(sprintf( + 'Migration storage is not initialized. Run `wp %s --initialize` first.', + $this->command() + )); } if ($dropStore) { @@ -61,9 +64,9 @@ public function runCommand(array $args = [], array $assocArgs = []): int { return self::SUCCESS; } - if ($prepare || $createTable) { - $this->migrator->prepare(); - WP_CLI::success('Foundation database tables are ready.'); + if ($initialize) { + $this->migrator->initialize(); + WP_CLI::success('Foundation migration storage is initialized.'); return self::SUCCESS; } @@ -135,15 +138,8 @@ protected function arguments(): array { ], [ 'type' => self::FLAG, - 'name' => self::FLAG_PREPARE, - 'description' => 'Prepare Foundation migration storage without running migrations.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => self::FLAG, - 'name' => self::FLAG_CREATE_TABLE, - 'description' => 'Alias for --prepare.', + 'name' => self::FLAG_INITIALIZE, + 'description' => 'Initialize or reconcile Foundation migration storage.', 'optional' => true, 'default' => false, ], @@ -158,8 +154,11 @@ protected function arguments(): array { } private function showStatus(): void { - if (! $this->migrator->hasLedger()) { - WP_CLI::warning('The Foundation migration ledger does not exist. Run this command with --prepare or --run.'); + if (! $this->migrator->isInitialized()) { + WP_CLI::warning(sprintf( + 'Migration storage is not initialized. Run `wp %s --initialize` first.', + $this->command() + )); } format_items('table', array_map( @@ -181,18 +180,16 @@ private function showStatus(): void { /** * @param array $operations */ - private function hasSingleOperation(array $operations): bool { + private function assertSingleOperation(array $operations): void { $selected = array_keys(array_filter($operations)); if (count($selected) <= 1) { - return true; + return; } WP_CLI::error(sprintf( 'Only one migration operation can be used at a time. Received: --%s.', implode(', --', $selected) - ), false); - - return false; + )); } } diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index e2ab4df..0fe6152 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -15,6 +15,7 @@ use StellarWP\Foundation\Database\Migration\Collection as MigrationCollection; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Repository as MigrationRecordRepository; +use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; @@ -114,15 +115,15 @@ private function registerMigrations(): void { ->needs('$table') ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); - $this->container->when(Migrator::class) + $this->container->when(Store::class) ->needs('$lockName') ->give(static fn (C $c): string => $c->get(self::LOCK_NAME)); - $this->container->when(Migrator::class) + $this->container->when(Store::class) ->needs('$lockTtl') ->give(static fn (C $c): int => $c->get(self::LOCK_TTL)); - $this->container->when(Migrator::class) + $this->container->when(Store::class) ->needs(Lock::class) ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); diff --git a/src/Database/Migration/Exceptions/UninitializedStore.php b/src/Database/Migration/Exceptions/UninitializedStore.php new file mode 100644 index 0000000..7a663dd --- /dev/null +++ b/src/Database/Migration/Exceptions/UninitializedStore.php @@ -0,0 +1,15 @@ +lockName) === '') { - throw new InvalidArgumentException('The migration lock name cannot be empty.'); - } - - if ($this->lockTtl < 1) { - throw new InvalidArgumentException('The migration lock TTL must be at least one second.'); - } } /** - * Ensure the migration subsystem storage is ready. + * Initialize or reconcile migration storage before other migration operations run. * - * @throws DatabaseException When migration storage cannot be prepared. + * @throws DatabaseException When migration storage cannot be initialized. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ - public function prepare(): void { - $this->withLock(function (): void { - $this->store->prepareLedger($this->schema); - }); + public function initialize(): void { + $this->store->initialize(); } /** @@ -61,29 +42,19 @@ public function prepare(): void { * @throws DatabaseException When migration storage cannot be dropped. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. */ public function dropStore(): void { - $this->withLock(function (): void { - $this->store->drop($this->schema); - }); + $this->store->drop(); } /** - * Determine whether the complete migration store is ready. + * Determine whether the complete migration store has been initialized. * * @throws DatabaseException When migration storage cannot be inspected. */ - public function exists(): bool { - return $this->store->exists($this->schema); - } - - /** - * Determine whether recorded migration state can be read. - * - * @throws DatabaseException When the ledger cannot be inspected. - */ - public function hasLedger(): bool { - return $this->store->hasLedger($this->schema); + public function isInitialized(): bool { + return $this->store->isInitialized(); } /** @@ -93,12 +64,13 @@ public function hasLedger(): bool { * @throws MigrationFailed When a migration fails while running. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. */ public function run(): Result { $configured = $this->migrations->all(); - return $this->withPreparedStore( - fn (): Result => $this->runPending($configured) + return $this->store->withMigrationLock( + fn (Schema $schema): Result => $this->runPending($configured, $schema) ); } @@ -112,11 +84,12 @@ public function run(): Result { * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * @throws UnavailableMigration When a recorded migration implementation is unavailable. + * @throws UninitializedStore When migration storage has not been initialized. */ public function rollback(?int $batch = null): Result { $configured = $this->migrations->all(); - return $this->withPreparedStore(function () use ($configured, $batch): Result { + return $this->store->withMigrationLock(function (Schema $schema) use ($configured, $batch): Result { $batch ??= $this->repository->latestBatch(); if ($batch === null) { @@ -125,7 +98,8 @@ public function rollback(?int $batch = null): Result { return $this->rollbackRecords( $configured, - $this->repository->recordsForBatch($batch) + $this->repository->recordsForBatch($batch), + $schema ); }); } @@ -138,13 +112,14 @@ public function rollback(?int $batch = null): Result { * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * @throws UnavailableMigration When a recorded migration implementation is unavailable. + * @throws UninitializedStore When migration storage has not been initialized. */ public function refresh(): Result { $configured = $this->migrations->all(); - return $this->withPreparedStore(function () use ($configured): Result { - $rollback = $this->rollbackRecords($configured, array_values($this->repository->all())); - $run = $this->runPending($configured); + return $this->store->withMigrationLock(function (Schema $schema) use ($configured): Result { + $rollback = $this->rollbackRecords($configured, array_values($this->repository->all()), $schema); + $run = $this->runPending($configured, $schema); return new Result( ran: $run->ran, @@ -164,7 +139,7 @@ public function refresh(): Result { public function status(): array { $configured = $this->migrations->all(); - if (! $this->store->hasLedger($this->schema)) { + if (! $this->store->hasLedger()) { return array_map( static fn (Migration $migration): Status => Status::pending($migration->id()), array_values($configured) @@ -194,10 +169,11 @@ public function status(): array { * * @param array $migrations * @param list $records + * @param Schema $schema The initialized schema supplied by the migration store. * * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ - private function rollbackRecords(array $migrations, array $records): Result { + private function rollbackRecords(array $migrations, array $records, Schema $schema): Result { usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); $unavailable = array_values(array_map( static fn (Record $record): string => $record->migration, @@ -214,7 +190,7 @@ private function rollbackRecords(array $migrations, array $records): Result { $migration = $migrations[$record->migration]; try { - $migration->down($this->schema); + $migration->down($schema); } catch (Throwable $throwable) { throw MigrationFailed::whileRollingBack($migration->id(), $throwable); } @@ -230,8 +206,9 @@ private function rollbackRecords(array $migrations, array $records): Result { * Run migrations that are absent from the ledger and record them in the next batch. * * @param array $migrations + * @param Schema $schema The initialized schema supplied by the migration store. */ - private function runPending(array $migrations): Result { + private function runPending(array $migrations, Schema $schema): Result { $ran = []; $skipped = []; $batch = $this->repository->nextBatch(); @@ -243,7 +220,7 @@ private function runPending(array $migrations): Result { } try { - $migration->up($this->schema); + $migration->up($schema); } catch (Throwable $throwable) { throw MigrationFailed::whileRunning($migration->id(), $throwable); } @@ -254,65 +231,4 @@ private function runPending(array $migrations): Result { return new Result(ran: $ran, skipped: $skipped); } - - /** - * Prepare the migration ledger under the migration lock, then run an operation. - * - * @template T - * - * @param callable(): T $operation - * - * @throws DatabaseException When migration storage cannot be prepared. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * - * @return T - */ - private function withPreparedStore(callable $operation): mixed { - return $this->withLock(function () use ($operation): mixed { - $this->store->prepareLedger($this->schema); - - return $operation(); - }); - } - - /** - * Run an operation while owning the configured migration lock and release it afterward. - * - * @template T - * - * @param callable(): T $operation - * - * @throws DatabaseException When lock storage cannot be prepared. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. - * @throws LockUnavailableException When the lock backend cannot determine the lock state. - * - * @return T - */ - private function withLock(callable $operation): mixed { - $this->store->prepareLock($this->schema); - $token = $this->lock->acquire($this->lockName, $this->lockTtl); - - if ($token === null) { - throw MigrationLockFailed::forLock($this->lockName); - } - - try { - $result = $operation(); - } catch (Throwable $failure) { - try { - $this->lock->release($token); - } catch (Throwable) { - // Preserve the primary migration failure when cleanup also fails. - } - - throw $failure; - } - - if (! $this->lock->release($token)) { - throw MigrationLockFailed::forUnconfirmedOwnership($this->lockName); - } - - return $result; - } } diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php index cb5a3a1..25ed8f5 100644 --- a/src/Database/Migration/Store.php +++ b/src/Database/Migration/Store.php @@ -2,66 +2,164 @@ namespace StellarWP\Foundation\Database\Migration; +use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Schema; use StellarWP\Foundation\Database\Exceptions\DatabaseException; +use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; +use StellarWP\Foundation\Database\Migration\Exceptions\UninitializedStore; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; +use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; +use Throwable; /** - * Manages the database tables required by the migration subsystem itself. + * Provides safe access to the migration subsystem's initialized database storage. * * @internal Use Migrator as the supported migration lifecycle entry point. */ final readonly class Store { + /** + * Create the migration store with its schema and lock policy. + * + * @throws InvalidArgumentException When the migration lock configuration is invalid. + */ public function __construct( + private Schema $schema, + private Lock $lock, private MigrationTable $migrationTable, - private LockTable $lockTable + private LockTable $lockTable, + private string $lockName = 'foundation-database-migrations', + private int $lockTtl = 300 ) { + if (trim($this->lockName) === '') { + throw new InvalidArgumentException('The migration lock name cannot be empty.'); + } + + if ($this->lockTtl < 1) { + throw new InvalidArgumentException('The migration lock TTL must be at least one second.'); + } } /** - * Ensure the shared lock table is ready before acquiring the migration lock. + * Initialize or reconcile the complete migration store before migrations run. * - * @throws DatabaseException When the lock table cannot be reconciled. + * @throws DatabaseException When migration storage cannot be initialized. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. */ - public function prepareLock(Schema $schema): void { - $schema->createOrUpdate($this->lockTable); + public function initialize(): void { + $this->schema->createOrUpdate($this->lockTable); + + $this->withLock(function (): void { + $this->schema->createOrUpdate($this->migrationTable); + }); } /** - * Ensure the migration ledger is ready while holding the migration lock. + * Drop the migration ledger while preserving shared lock storage. * - * @throws DatabaseException When the ledger cannot be reconciled. + * @throws DatabaseException When the ledger cannot be dropped. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. */ - public function prepareLedger(Schema $schema): void { - $schema->createOrUpdate($this->migrationTable); + public function drop(): void { + $this->withMigrationLock(function (Schema $schema): void { + $schema->drop($this->migrationTable); + }); } /** - * Drop the migration ledger while preserving shared lock storage. + * Determine whether the migration subsystem storage is ready. * - * @throws DatabaseException When the ledger cannot be dropped. + * @throws DatabaseException When migration storage cannot be inspected. */ - public function drop(Schema $schema): void { - $schema->drop($this->migrationTable); + public function isInitialized(): bool { + return $this->hasLedger() && $this->schema->hasTable($this->lockTable); } /** - * Determine whether the migration subsystem storage is ready. + * Determine whether recorded migration state can be read. * - * @throws DatabaseException When the ledger cannot be inspected. + * @throws DatabaseException When migration storage cannot be inspected. */ - public function exists(Schema $schema): bool { - return $this->hasLedger($schema) && $schema->hasTable($this->lockTable); + public function hasLedger(): bool { + return $this->schema->hasTable($this->migrationTable); } /** - * Determine whether recorded migration state can be read. + * Run an operation against initialized migration storage while holding its lock. + * + * @template T + * + * @param callable(Schema): T $operation The operation that receives the schema while the migration lock is held. + * + * @throws DatabaseException When migration storage cannot be inspected. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. + * + * @return T + */ + public function withMigrationLock(callable $operation): mixed { + $this->assertInitialized(); + + return $this->withLock(fn (): mixed => $operation($this->schema)); + } + + /** + * Reject migration operations until the internal store has been initialized. * - * @throws DatabaseException When the ledger cannot be inspected. + * @throws DatabaseException When migration storage cannot be inspected. + * @throws UninitializedStore When migration storage has not been initialized. */ - public function hasLedger(Schema $schema): bool { - return $schema->hasTable($this->migrationTable); + private function assertInitialized(): void { + if (! $this->isInitialized()) { + throw new UninitializedStore(); + } + } + + /** + * Run an operation while owning the configured migration lock and release it afterward. + * + * The lock table is the one bootstrap exception: it must exist before its own + * database-backed lock can be acquired. + * + * @template T + * + * @param callable(): T $operation + * + * @throws DatabaseException When migration storage access fails. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * + * @return T + */ + private function withLock(callable $operation): mixed { + $token = $this->lock->acquire($this->lockName, $this->lockTtl); + + if ($token === null) { + throw MigrationLockFailed::forLock($this->lockName); + } + + try { + $result = $operation(); + } catch (Throwable $failure) { + try { + $this->lock->release($token); + } catch (Throwable) { + // Preserve the primary migration failure when cleanup also fails. + } + + throw $failure; + } + + if (! $this->lock->release($token)) { + throw MigrationLockFailed::forUnconfirmedOwnership($this->lockName); + } + + return $result; } } diff --git a/src/Database/README.md b/src/Database/README.md index 254e042..e3400fd 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -20,9 +20,63 @@ uses the `%i` identifier placeholder. Database-backed locks additionally require fractional-second temporal values: MySQL 5.6.4 or newer, or MariaDB 5.3 or newer. -## Registering The Provider +## Running Migrations -Register `DatabaseProvider` in the application container when the project needs Foundation-managed migrations: +Use the included WP-CLI command as the standard way to initialize migration +storage and run migrations. Register the WP-CLI provider before the database +provider so the database command is added to the configured command list: + +```php +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\WPCli\WPCliProvider; + +$container->register(WPCliProvider::class); +$container->register(DatabaseProvider::class); +``` + +During deployment, initialize the migration store and then run pending +migrations: + +```bash +wp nx migrate --initialize +wp nx migrate --run +``` + +`--initialize` is idempotent and creates or reconciles Foundation's internal +migration and lock tables. Run it before migration operations, including after +updating Foundation Database. Migration operations fail with an actionable +error when storage has not been initialized. + +Use the remaining commands to inspect or manage migrations: + +```bash +# Show migration status. +wp nx migrate + +# Roll back the latest migration batch. +wp nx migrate --rollback + +# Roll back every known migration and run them again. +wp nx migrate --refresh --yes + +# Drop only the internal migration ledger. +wp nx migrate --drop-store --yes +``` + +`--drop-store` preserves application tables and shared lock storage. It causes +all configured migrations to appear pending after storage is initialized again; +it is not a substitute for rollback because it does not call migration `down()` +methods. Use only one operation flag at a time. `--yes` only skips confirmation +for destructive operations. + +These examples use the default `nx` command prefix. Change +`wpcli.command_prefix` in `config.php` when the application uses another prefix. + +## Database Configuration + +The recommended WP-CLI setup above registers `DatabaseProvider`. Projects that +run migrations programmatically must still register it in the application +container: ```php use StellarWP\Foundation\Database\DatabaseProvider; @@ -115,23 +169,24 @@ reads to the writer; otherwise replication lag can make a successful acquisition or refresh fail closed. The database lock table must exist before application services acquire locks. -Prepare it during activation or deployment through the configured migrator: +The preferred deployment workflow initializes it with the migration store: + +```bash +wp nx migrate --initialize +``` + +If an application cannot run WP-CLI during deployment, it may initialize the +store programmatically during activation or another controlled lifecycle: ```php use StellarWP\Foundation\Database\Migration\Migrator; -$container->get(Migrator::class)->prepare(); +$container->get(Migrator::class)->initialize(); ``` -Preparing the migration store also reconciles existing internal tables with +Initializing the migration store also reconciles existing internal tables with their current definitions. -Projects using the included WP-CLI command can instead run: - -```bash -wp nx migrate --prepare -``` - Once configured, application services should depend on the shared `Lock` contract. See the [Foundation Lock usage examples](https://github.com/stellarwp/foundation-lock#preventing-duplicate-work) @@ -294,38 +349,35 @@ $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): ]); ``` -Application code that needs to run migrations should inject `StellarWP\Foundation\Database\Migration\Migrator`. It is the configured entry point for preparing the migration store, running pending migrations, rolling back, refreshing, dropping only the internal migration store, and reading migration status. +After registering migrations, use the WP-CLI deployment workflow described in +[Running Migrations](#running-migrations). Registering `DatabaseProvider` does +not initialize storage or execute migrations. + +If WP-CLI is unavailable during deployment, application code may use the +configured `Migrator` directly from a controlled activation or version-update +lifecycle: ```php use StellarWP\Foundation\Database\Migration\Migrator; -final readonly class PluginUpdater -{ - public function __construct( - private Migrator $migrator - ) { - } - - public function update(): void { - $this->migrator->run(); - } -} +$migrator = $container->get(Migrator::class); +$migrator->initialize(); +$migrator->run(); ``` -`run()`, `rollback()`, and `refresh()` prepare the migration store automatically before executing migrations. - -`dropStore()` acquires the migration lock and removes only the migration ledger. -It preserves application tables and shared lock storage. After the ledger is -removed, every configured migration appears pending and may run again after the -store is prepared. It is not a substitute for rollback because it does not call -any migration's `down()` method. +Call `initialize()` before `run()`, `rollback()`, `refresh()`, or `dropStore()`. +Migration operations fail with `UninitializedStore` rather than changing +internal table definitions implicitly. Recorded migration implementations must remain registered for as long as their ledger entries may be rolled back. `rollback()` and `refresh()` validate every selected ledger entry before changing schema and fail without a partial rollback when an implementation is unavailable. -Registering `DatabaseProvider` does not execute migrations. Call `Migrator::run()` from the application's activation or version-update lifecycle, or run `wp nx migrate --run` during deployment. Completed migration IDs are skipped on later runs. Because migration changes and their ledger updates are not one atomic operation, write `up()` and `down()` methods so they can recover from retries after partial work or failed ledger writes. +Completed migration IDs are skipped on later runs. Because migration changes and +their ledger updates are not one atomic operation, write `up()` and `down()` +methods so they can recover from retries after partial work or failed ledger +writes. ## Evolving Tables @@ -396,35 +448,3 @@ foundation/stubs/database/provider.stub When present, overrides are used instead of the default stubs from the `foundation-database` package. Override stubs should use the same context-aware placeholders as the default stubs when writing PHP literals. For example, use `{{ id_php }}` and `{{ table_php }}` for values written into PHP constants, and use the `{{ foundation_database_* }}` import placeholders so Strauss-prefixed projects keep working. - -## WP-CLI - -The package includes a `migrate` command class for projects using `stellarwp/foundation-wpcli`. `DatabaseProvider` adds that command to `StellarWP\Foundation\WPCli\WPCliProvider::COMMANDS`; register the WP-CLI provider once in the consuming application so merged commands are registered on `cli_init`. - -```php -use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\WPCli\WPCliProvider; - -$container->register(WPCliProvider::class); -$container->register(DatabaseProvider::class); -``` - -Run the command under the configured WP-CLI prefix: - -```bash -wp nx migrate --run -``` - -Available flags: - -- `--run` runs pending migrations. -- `--rollback` rolls back the latest migration batch. -- `--refresh` rolls back all known migrations and runs them again. -- `--drop-store` drops only the migration ledger after confirmation. Application tables and shared lock storage remain, and all migrations appear pending afterward. -- `--prepare` prepares the migration store without running migrations. -- `--create-table` is an alias for `--prepare`. -- `--yes` skips confirmation prompts for destructive actions. - -Use only one operation flag at a time. `--yes` is a modifier for confirmation prompts and can be combined with destructive operations. - -Running the command without a flag prints migration status. If the migration store does not exist yet, the command warns first and shows all configured migrations as pending. diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 4457d02..f0a6ca6 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -51,9 +51,9 @@ $exampleTable = $wpdb->prefix . 'foundation_cli_example'; $migrationTable = new MigrationTable($migrationTableName); $lockTable = new LockTable($lockTableName); - $store = new Store($migrationTable, $lockTable); $repository = new Repository($database, $migrationTableName); $lock = new DatabaseLock($database, $lockTableName); + $store = new Store($schema, $lock, $migrationTable, $lockTable); $migration = new class($exampleTable) implements Migration { public function __construct( @@ -90,8 +90,6 @@ public function down(SchemaContract $schema): void { new Migrator( new MigrationCollection([$migration]), $repository, - $schema, - $lock, $store ) ); diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index e738aea..cf4aa83 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -32,15 +32,13 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $repository = new InMemoryRepository(); $schema = new RecordingSchema(); $lock = new InMemoryLock(); - $store = new Store($migrationTable, new LockTable('wp_nexcess_foundation_locks')); + $store = new Store($schema, $lock, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); $command = new Migrate( $this->container, 'foundation', new Migrator( new MigrationCollection(), $repository, - $schema, - $lock, $store ) ); @@ -83,15 +81,8 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { ], [ 'type' => 'flag', - 'name' => 'prepare', - 'description' => 'Prepare Foundation migration storage without running migrations.', - 'optional' => true, - 'default' => false, - ], - [ - 'type' => 'flag', - 'name' => 'create-table', - 'description' => 'Alias for --prepare.', + 'name' => 'initialize', + 'description' => 'Initialize or reconcile Foundation migration storage.', 'optional' => true, 'default' => false, ], @@ -105,10 +96,10 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { ], $deferredAdditions['foundation migrate']['args']['synopsis']); } - public function test_it_creates_database_tables_without_running_migrations(): void { + public function test_it_initializes_database_storage_without_running_migrations(): void { [$command, $repository, $schema] = $this->newCommand(); - $this->assertSame(0, $command->runCommand([], ['prepare' => true])); + $this->assertSame(0, $command->runCommand([], ['initialize' => true])); $this->assertSame([], $repository->all()); $this->assertSame([ @@ -117,39 +108,15 @@ public function test_it_creates_database_tables_without_running_migrations(): vo ], $schema->statements); } - public function test_it_supports_create_table_as_an_alias_for_prepare(): void { - [$command, $repository, $schema] = $this->newCommand(); - - $this->assertSame(0, $command->runCommand([], ['create-table' => true])); - - $this->assertSame([], $repository->all()); - $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', - ], $schema->statements); - } - - public function test_it_rejects_conflicting_migration_operations(): void { - [$command, $repository, $schema] = $this->newCommand(); - - $this->assertSame(1, $command->runCommand([], [ - 'run' => true, - 'prepare' => true, - ])); - - $this->assertSame([], $repository->all()); - $this->assertSame([], $schema->statements); - } - public function test_it_runs_pending_migrations(): void { [$command, $repository, $schema] = $this->newCommand(); + $command->runCommand([], ['initialize' => true]); + $schema->statements = []; $this->assertSame(0, $command->runCommand([], ['run' => true])); $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', 'up:2026_06_23_000001_create_example', ], $schema->statements); } @@ -157,6 +124,7 @@ public function test_it_runs_pending_migrations(): void { public function test_it_rolls_back_the_latest_migration_batch(): void { [$command, $repository, $schema] = $this->newCommand(); + $command->runCommand([], ['initialize' => true]); $command->runCommand([], ['run' => true]); $schema->statements = []; @@ -169,6 +137,7 @@ public function test_it_rolls_back_the_latest_migration_batch(): void { public function test_it_refreshes_database_migrations(): void { [$command, $repository, $schema] = $this->newCommand(); + $command->runCommand([], ['initialize' => true]); $command->runCommand([], ['run' => true]); $schema->statements = []; @@ -185,7 +154,7 @@ public function test_it_refreshes_database_migrations(): void { public function test_it_drops_the_migration_store(): void { [$command, , $schema] = $this->newCommand(); - $command->runCommand([], ['create-table' => true]); + $command->runCommand([], ['initialize' => true]); $this->assertSame(0, $command->runCommand([], [ 'drop-store' => true, @@ -208,6 +177,7 @@ public function test_it_shows_a_warning_when_status_tables_do_not_exist(): void public function test_it_shows_migration_status_when_tables_exist(): void { [$command] = $this->newCommand(); + $command->runCommand([], ['initialize' => true]); $command->runCommand([], ['run' => true]); $this->expectOutputRegex('/2026_06_23_000001_create_example\s+ran\s+1\s+2026-01-01 00:00:00/'); @@ -218,7 +188,7 @@ public function test_it_shows_migration_status_when_tables_exist(): void { public function test_it_shows_unavailable_recorded_migrations(): void { [$command, $repository] = $this->newCommand(); - $command->runCommand([], ['prepare' => true]); + $command->runCommand([], ['initialize' => true]); $repository->recordRun('2026_06_23_000002_missing_migration', 1); $this->expectOutputRegex('/2026_06_23_000002_missing_migration\s+unavailable/'); @@ -236,7 +206,7 @@ private function newCommand(): array { $repository = new InMemoryRepository(); $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); $lock = new InMemoryLock(); - $store = new Store($migrationTable, new LockTable('wp_nexcess_foundation_locks')); + $store = new Store($wpSchema, $lock, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); $command = new Migrate( $this->container, 'foundation', @@ -245,8 +215,6 @@ private function newCommand(): array { new TestMigration('2026_06_23_000001_create_example'), ]), $repository, - $wpSchema, - $lock, $store ) ); diff --git a/tests/Unit/Database/Migration/MigratorExecutionTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php index 679b83c..436a9db 100644 --- a/tests/Unit/Database/Migration/MigratorExecutionTest.php +++ b/tests/Unit/Database/Migration/MigratorExecutionTest.php @@ -35,32 +35,47 @@ final class MigratorExecutionTest extends TestCase private InMemoryLock $lock; - private Store $store; - protected function setUp(): void { parent::setUp(); $this->repository = new InMemoryRepository(); $this->schema = new RecordingSchema(); $this->lock = new InMemoryLock(new MutableClock(new \DateTimeImmutable('2026-01-01 00:00:00'))); - $this->store = new Store( + + (new Store( + $this->schema, + $this->lock, new MigrationTable('wp_nexcess_foundation_migrations'), new LockTable('wp_nexcess_foundation_locks') - ); + ))->initialize(); + + $this->schema->statements = []; } public function test_it_rejects_a_blank_migration_lock_name(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('lock name cannot be empty'); - new Migrator(new Collection(), $this->repository, $this->schema, $this->lock, $this->store, lockName: ' '); + new Store( + $this->schema, + $this->lock, + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks'), + lockName: ' ' + ); } public function test_it_rejects_an_invalid_migration_lock_ttl(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('TTL must be at least one second'); - new Migrator(new Collection(), $this->repository, $this->schema, $this->lock, $this->store, lockTtl: 0); + new Store( + $this->schema, + $this->lock, + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks'), + lockTtl: 0 + ); } public function test_it_runs_pending_migrations_in_order(): void { @@ -74,8 +89,6 @@ public function test_it_runs_pending_migrations_in_order(): void { '2026_01_01_000002_create_posts', ], $result->ran); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', 'up:2026_01_01_000001_create_users', 'up:2026_01_01_000002_create_posts', ], $this->schema->statements); @@ -120,8 +133,6 @@ public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { '2026_01_01_000002_create_posts', ], $result->rolledBack); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_01_01_000003_create_comments', 'down:2026_01_01_000002_create_posts', ], $this->schema->statements); @@ -150,10 +161,7 @@ public function test_it_rejects_unavailable_rollback_records_before_changing_sch new TestMigration('2026_01_01_000001_create_users'), )->rollback(); } finally { - $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', - ], $this->schema->statements); + $this->assertSame([], $this->schema->statements); $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } } @@ -169,10 +177,7 @@ public function test_it_rejects_unavailable_refresh_records_before_changing_sche new TestMigration('2026_01_01_000001_create_users'), )->refresh(); } finally { - $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', - ], $this->schema->statements); + $this->assertSame([], $this->schema->statements); $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); } } @@ -198,8 +203,6 @@ public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): voi '2026_01_01_000002_create_posts', ], $result->ran); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_01_01_000002_create_posts', 'down:2026_01_01_000001_create_users', 'up:2026_01_01_000001_create_users', @@ -253,7 +256,6 @@ public function test_it_returns_status_for_unavailable_recorded_migrations(): vo $migrator = $this->configured( new TestMigration('2026_01_01_000002_create_posts'), ); - $migrator->prepare(); $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); $statuses = $migrator->status(); @@ -296,7 +298,28 @@ public function test_it_fails_when_the_migration_lock_is_already_owned(): void { )->run(); } - public function test_it_releases_the_lock_when_ledger_preparation_fails(): void { + public function test_it_propagates_lock_acquisition_failures_with_the_configured_policy(): void { + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->with('custom-migrations', 120) + ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); + $lock->expects($this->never()) + ->method('release'); + + $migrator = $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + lockName: 'custom-migrations', + lockTtl: 120 + ); + + $this->expectException(LockUnavailableException::class); + + $migrator->run(); + } + + public function test_it_releases_the_lock_when_initialization_fails(): void { $storeSchema = $this->createMock(Schema::class); $storeSchema->method('createOrUpdate') ->willReturnCallback(static function (Table $table): void { @@ -305,19 +328,17 @@ public function test_it_releases_the_lock_when_ledger_preparation_fails(): void } }); - $migrator = $this->migrator( - $this->collection(new TestMigration('2026_01_01_000001_create_users')), - schema: $storeSchema, - store: new Store( - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks') - ), + $store = new Store( + $storeSchema, + $this->lock, + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks') ); $this->expectException(DatabaseException::class); try { - $migrator->run(); + $store->initialize(); } finally { $this->assertNotNull($this->lock->acquire('foundation-database-migrations', 300)); } @@ -372,6 +393,31 @@ public function test_it_preserves_the_migration_failure_when_lock_release_is_una $migrator->run(); } + public function test_it_propagates_lock_release_failures_after_a_successful_migration(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); + + $migrator = $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + ); + + $this->expectException(LockUnavailableException::class); + + try { + $migrator->run(); + } finally { + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + public function test_it_preserves_the_migration_failure_when_release_cannot_confirm_ownership(): void { $token = $this->lockToken(); $lock = $this->createMock(Lock::class); @@ -440,14 +486,24 @@ private function migrator( Collection $migrations, ?Lock $lock = null, ?Schema $schema = null, - ?Store $store = null + string $lockName = 'foundation-database-migrations', + int $lockTtl = 300 ): Migrator { + $schema ??= $this->schema; + $lock ??= $this->lock; + $store = new Store( + $schema, + $lock, + new MigrationTable('wp_nexcess_foundation_migrations'), + new LockTable('wp_nexcess_foundation_locks'), + $lockName, + $lockTtl + ); + return new Migrator( $migrations, $this->repository, - $schema ?? $this->schema, - $lock ?? $this->lock, - $store ?? $this->store + $store ); } diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index afc723f..82793af 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -4,6 +4,7 @@ use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Collection; +use StellarWP\Foundation\Database\Migration\Exceptions\UninitializedStore; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Table\Tables\LockTable; @@ -16,7 +17,7 @@ final class MigratorTest extends TestCase { - public function test_it_prepares_the_store_before_running_configured_migrations(): void { + public function test_it_runs_configured_migrations_against_initialized_storage(): void { [$migrator, $repository, $schema] = $this->newMigrator(); $result = $migrator->run(); @@ -24,13 +25,11 @@ public function test_it_prepares_the_store_before_running_configured_migrations( $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', 'up:2026_06_23_000001_create_example', ], $schema->statements); } - public function test_it_prepares_the_store_before_rolling_back_configured_migrations(): void { + public function test_it_rolls_back_configured_migrations_against_initialized_storage(): void { [$migrator, $repository, $schema] = $this->newMigrator(); $migrator->run(); @@ -41,13 +40,11 @@ public function test_it_prepares_the_store_before_rolling_back_configured_migrat $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_06_23_000001_create_example', ], $schema->statements); } - public function test_it_prepares_the_store_before_refreshing_configured_migrations(): void { + public function test_it_refreshes_configured_migrations_against_initialized_storage(): void { [$migrator, $repository, $schema] = $this->newMigrator(); $migrator->run(); @@ -59,8 +56,6 @@ public function test_it_prepares_the_store_before_refreshing_configured_migratio $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', 'down:2026_06_23_000001_create_example', 'up:2026_06_23_000001_create_example', ], $schema->statements); @@ -77,18 +72,18 @@ public function test_it_exposes_migration_status_for_configured_migrations(): vo $this->assertTrue($migrator->status()[0]->ran); } - public function test_it_prepares_and_drops_the_migration_store(): void { - [$migrator, , $schema] = $this->newMigrator(); + public function test_it_initializes_and_drops_the_migration_store(): void { + [$migrator, , $schema] = $this->newMigrator(initialize: false); - $this->assertFalse($migrator->exists()); + $this->assertFalse($migrator->isInitialized()); - $migrator->prepare(); + $migrator->initialize(); - $this->assertTrue($migrator->exists()); + $this->assertTrue($migrator->isInitialized()); $migrator->dropStore(); - $this->assertFalse($migrator->exists()); + $this->assertFalse($migrator->isInitialized()); $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); $this->assertNotContains('drop:wp_nexcess_foundation_locks', $schema->statements); $this->assertTrue($schema->tables['wp_nexcess_foundation_locks']); @@ -98,7 +93,6 @@ public function test_it_does_not_drop_the_store_while_another_migration_owns_the $lock = new InMemoryLock(); [$migrator, , $schema] = $this->newMigrator($lock); - $migrator->prepare(); $token = $lock->acquire('foundation-database-migrations', 300); $this->assertNotNull($token); @@ -111,16 +105,16 @@ public function test_it_does_not_drop_the_store_while_another_migration_owns_the } } - public function test_it_does_not_prepare_the_ledger_while_another_migration_owns_the_lock(): void { + public function test_it_does_not_initialize_the_ledger_while_another_migration_owns_the_lock(): void { $lock = new InMemoryLock(); - [$migrator, , $schema] = $this->newMigrator($lock); + [$migrator, , $schema] = $this->newMigrator($lock, false); $token = $lock->acquire('foundation-database-migrations', 300); $this->assertNotNull($token); $this->expectException(MigrationLockFailed::class); try { - $migrator->prepare(); + $migrator->initialize(); } finally { $this->assertTrue($schema->tables['wp_nexcess_foundation_locks']); $this->assertArrayNotHasKey('wp_nexcess_foundation_migrations', $schema->tables); @@ -133,31 +127,48 @@ public function test_status_uses_the_existing_ledger_when_shared_lock_storage_is $migrator->run(); unset($schema->tables['wp_nexcess_foundation_locks']); - $this->assertFalse($migrator->exists()); + $this->assertFalse($migrator->isInitialized()); $this->assertTrue($migrator->status()[0]->ran); } + public function test_it_rejects_migration_operations_before_storage_is_initialized(): void { + [$migrator, , $schema] = $this->newMigrator(initialize: false); + + $this->expectException(UninitializedStore::class); + + try { + $migrator->run(); + } finally { + $this->assertSame([], $schema->statements); + } + } + /** * @return array{Migrator, InMemoryRepository, RecordingSchema} */ - private function newMigrator(?InMemoryLock $lock = null): array { + private function newMigrator(?InMemoryLock $lock = null, bool $initialize = true): array { $schema = new RecordingSchema(); $repository = new InMemoryRepository(); $lock ??= new InMemoryLock(); $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); $lockTable = new LockTable('wp_nexcess_foundation_locks'); - $store = new Store($migrationTable, $lockTable); + $store = new Store($schema, $lock, $migrationTable, $lockTable); + + $migrator = new Migrator( + new Collection([ + new TestMigration('2026_06_23_000001_create_example'), + ]), + $repository, + $store + ); + + if ($initialize) { + $migrator->initialize(); + $schema->statements = []; + } return [ - new Migrator( - new Collection([ - new TestMigration('2026_06_23_000001_create_example'), - ]), - $repository, - $schema, - $lock, - $store - ), + $migrator, $repository, $schema, ]; diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index a531e91..621907a 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -3,7 +3,9 @@ namespace StellarWP\Foundation\Tests\Integration\Database; use Adbar\Dot; +use InvalidArgumentException; use lucatume\DI52\Container as DI52Container; +use lucatume\DI52\ContainerException; use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Container\ContainerAdapter; use StellarWP\Foundation\Container\Contracts\Container; @@ -64,6 +66,27 @@ public function test_it_registers_configured_database_configuration(): void { $this->assertSame('custom', $container->get(WPCliProvider::COMMAND_PREFIX)); } + public function test_it_applies_configured_lock_policy_to_the_migration_store(): void { + $configurations = [ + [['database' => ['lock_name' => ' ']], 'lock name cannot be empty'], + [['database' => ['lock_ttl' => 0]], 'TTL must be at least one second'], + ]; + + foreach ($configurations as [$config, $message]) { + $container = $this->newContainer($config); + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + try { + $container->get(Migrator::class); + $this->fail('Expected invalid migration lock configuration to be rejected.'); + } catch (ContainerException $exception) { + $this->assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious()); + $this->assertStringContainsString($message, $exception->getMessage()); + } + } + } + public function test_it_preserves_preconfigured_migrations(): void { $migration = new TestMigration('2026_06_23_000001_create_example'); $container = $this->newContainer(); @@ -108,7 +131,8 @@ public function test_provider_built_migrator_executes_against_wordpress(): void try { $migrator = $container->get(Migrator::class); - $result = $migrator->run(); + $migrator->initialize(); + $result = $migrator->run(); $this->assertSame([$migration->id()], $result->ran); $this->assertTrue($database->tableExists($migrationsTable)); diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php index 002899e..773f9cb 100644 --- a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -13,9 +13,9 @@ public function _after(WPCLITester $I): void { } public function test_it_runs_database_migrations_through_wp_cli(WPCLITester $I): void { - $I->cli(['foundation', 'migrate', '--create-table']); + $I->cli(['foundation', 'migrate', '--initialize']); $I->seeResultCodeIs(0); - $I->seeInShellOutput('Foundation database tables are ready.'); + $I->seeInShellOutput('Foundation migration storage is initialized.'); $I->cli(['foundation', 'migrate', '--run']); $I->seeResultCodeIs(0); @@ -32,6 +32,9 @@ public function test_it_runs_database_migrations_through_wp_cli(WPCLITester $I): } public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--initialize']); + $I->seeResultCodeIs(0); + $I->cli(['foundation', 'migrate', '--run']); $I->seeResultCodeIs(0); $I->seeInShellOutput('Ran 1 migrations.'); @@ -62,13 +65,22 @@ public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLI $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); - Assert::assertStringContainsString('The Foundation migration ledger does not exist.', $I->grabLastShellErrorOutput()); + Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); } public function test_it_warns_when_showing_status_before_tables_exist(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--run', '--initialize']); + $I->seeResultCodeIs(1); + Assert::assertStringContainsString('Only one migration operation can be used at a time.', $I->grabLastShellErrorOutput()); + $I->cli(['foundation', 'migrate']); $I->seeResultCodeIs(0); - Assert::assertStringContainsString('The Foundation migration ledger does not exist.', $I->grabLastShellErrorOutput()); + Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); + Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); + + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(1); + Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); } private function dropTables(WPCLITester $I): void { From 19efa61ad841a1bdb1ca156086fd51b19dcc73ea Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 13:28:58 -0600 Subject: [PATCH 33/81] Move valueobjects into their own namespace --- AGENTS.md | 2 ++ src/Database/Contracts/Repository.php | 2 +- src/Database/Migration/Collection.php | 1 + src/Database/Migration/Migrator.php | 3 +++ src/Database/Migration/Repository.php | 2 ++ .../Migration/{ => ValueObjects}/Id.php | 2 +- .../Migration/{ => ValueObjects}/Record.php | 2 +- .../Migration/{ => ValueObjects}/Result.php | 2 +- .../Migration/{ => ValueObjects}/Status.php | 2 +- .../Fixtures/Database/InMemoryRepository.php | 2 +- .../Migration/MigratorExecutionTest.php | 11 ----------- .../Migration/ValueObjects/ResultTest.php | 19 +++++++++++++++++++ 12 files changed, 33 insertions(+), 17 deletions(-) rename src/Database/Migration/{ => ValueObjects}/Id.php (93%) rename src/Database/Migration/{ => ValueObjects}/Record.php (81%) rename src/Database/Migration/{ => ValueObjects}/Result.php (87%) rename src/Database/Migration/{ => ValueObjects}/Status.php (92%) create mode 100644 tests/Unit/Database/Migration/ValueObjects/ResultTest.php diff --git a/AGENTS.md b/AGENTS.md index 722d7ee..15ec674 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,8 @@ Use contextual bindings with `$this->container->when()->needs()->give()` for sca Classes should take the dependencies they need directly. Do not make constructor dependencies nullable just to instantiate fallback concrete classes internally, for example `?Dependency $dependency = null` with `$this->dependency = $dependency ?? new Dependency()`. Register default implementations and aliases in a provider instead so consumers can replace them through container configuration. +Classes should receive service collaborators through constructor injection. Direct `new` expressions inside application classes are reserved for immutable value or result objects, exceptions, PHP standard-library objects, and objects deliberately produced by an owning builder or factory. Keep feature-local value objects under that feature's `ValueObjects/` namespace. Value objects should be `final readonly` where possible and must not resolve or construct service dependencies. + Organize provider registration by feature or capability, not by container mechanism. The main `register()` method should call focused private methods such as `registerConfiguration()`, `registerMigrations()`, `registerLocks()`, or `registerCliCommands()`. Keep each feature's contextual bindings beside the classes they configure. Avoid generic methods such as `configureContextualBindings()` that group unrelated bindings only because they use the same container API. ## Split Packages diff --git a/src/Database/Contracts/Repository.php b/src/Database/Contracts/Repository.php index 26c85e0..c4aea55 100644 --- a/src/Database/Contracts/Repository.php +++ b/src/Database/Contracts/Repository.php @@ -3,7 +3,7 @@ namespace StellarWP\Foundation\Database\Contracts; use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; -use StellarWP\Foundation\Database\Migration\Record; +use StellarWP\Foundation\Database\Migration\ValueObjects\Record; /** * Stores and retrieves the migration ledger. diff --git a/src/Database/Migration/Collection.php b/src/Database/Migration/Collection.php index 2e2f11a..4650170 100644 --- a/src/Database/Migration/Collection.php +++ b/src/Database/Migration/Collection.php @@ -7,6 +7,7 @@ use StellarWP\Foundation\Database\Contracts\Migration; use StellarWP\Foundation\Database\Exceptions\DuplicateMigration; use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; +use StellarWP\Foundation\Database\Migration\ValueObjects\Id; use Traversable; /** diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index 929d7ed..5b25cb8 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -10,6 +10,9 @@ use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; use StellarWP\Foundation\Database\Migration\Exceptions\UninitializedStore; +use StellarWP\Foundation\Database\Migration\ValueObjects\Record; +use StellarWP\Foundation\Database\Migration\ValueObjects\Result; +use StellarWP\Foundation\Database\Migration\ValueObjects\Status; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use Throwable; diff --git a/src/Database/Migration/Repository.php b/src/Database/Migration/Repository.php index 0febdbb..5470553 100644 --- a/src/Database/Migration/Repository.php +++ b/src/Database/Migration/Repository.php @@ -7,6 +7,8 @@ use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Repository as RepositoryContract; use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; +use StellarWP\Foundation\Database\Migration\ValueObjects\Id; +use StellarWP\Foundation\Database\Migration\ValueObjects\Record; /** * Stores migration records in a WordPress database table. diff --git a/src/Database/Migration/Id.php b/src/Database/Migration/ValueObjects/Id.php similarity index 93% rename from src/Database/Migration/Id.php rename to src/Database/Migration/ValueObjects/Id.php index ca3f964..c87c5a8 100644 --- a/src/Database/Migration/Id.php +++ b/src/Database/Migration/ValueObjects/Id.php @@ -1,6 +1,6 @@ assertSame('2026_01_01_000001_missing_migration', $statuses[1]->migration); } - public function test_migration_results_count_ran_and_rolled_back_migrations(): void { - $result = new Result( - ran: ['2026_01_01_000001_create_users'], - rolledBack: ['2026_01_01_000002_create_posts'], - skipped: ['2026_01_01_000003_create_comments'] - ); - - $this->assertSame(2, $result->count()); - } - public function test_it_treats_migration_ids_as_case_sensitive(): void { $result = $this->configured( new TestMigration('CreateReports'), diff --git a/tests/Unit/Database/Migration/ValueObjects/ResultTest.php b/tests/Unit/Database/Migration/ValueObjects/ResultTest.php new file mode 100644 index 0000000..70dce8a --- /dev/null +++ b/tests/Unit/Database/Migration/ValueObjects/ResultTest.php @@ -0,0 +1,19 @@ +assertSame(2, $result->count()); + } +} From e8bc3324eed249d215d550a2b66c0fc7b91e051d Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 13:53:31 -0600 Subject: [PATCH 34/81] Refactor Schema to not take a Closure but an interface --- src/Database/Contracts/SchemaExecutor.php | 16 ++++ src/Database/DatabaseProvider.php | 35 +------- src/Database/Schema.php | 23 +---- src/Database/Schema/DbDelta.php | 48 +++++++++++ .../Database/RecordingSchemaExecutor.php | 17 ++++ .../register-wpcli-migrate-command.php | 7 +- tests/Unit/Database/Schema/DbDeltaTest.php | 21 +++++ tests/Unit/Database/SchemaTest.php | 48 +++-------- .../Database/Table/Tables/LockTableTest.php | 33 ++++---- .../Table/Tables/MigrationTableTest.php | 23 ++--- .../Database/DatabaseIntegrationTest.php | 4 +- tests/wpunit/Database/Schema/DbDeltaTest.php | 83 +++++++++++++++++++ 12 files changed, 232 insertions(+), 126 deletions(-) create mode 100644 src/Database/Contracts/SchemaExecutor.php create mode 100644 src/Database/Schema/DbDelta.php create mode 100644 tests/Support/Fixtures/Database/RecordingSchemaExecutor.php create mode 100644 tests/Unit/Database/Schema/DbDeltaTest.php create mode 100644 tests/wpunit/Database/Schema/DbDeltaTest.php diff --git a/src/Database/Contracts/SchemaExecutor.php b/src/Database/Contracts/SchemaExecutor.php new file mode 100644 index 0000000..385e421 --- /dev/null +++ b/src/Database/Contracts/SchemaExecutor.php @@ -0,0 +1,16 @@ +container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); - - $this->container->when(Schema::class) - ->needs(Closure::class) - ->give(static function (): Closure { - if (! function_exists('dbDelta') && defined('ABSPATH')) { - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - } - - if (! function_exists('dbDelta')) { - throw new DatabaseException('WordPress dbDelta() is not available.'); - } - - return static function (string $sql, bool $execute): array { - $wpdb = $GLOBALS['wpdb'] ?? null; - - if (! $wpdb instanceof \wpdb) { - throw new DatabaseException('The global wpdb instance is not available.'); - } - - $result = dbDelta($sql, $execute); - - if ($execute && $wpdb->last_error !== '') { - throw new QueryException($wpdb->last_error, $sql, [], $wpdb->last_error); - } - - return $result; - }; - }); - + $this->container->singleton(DbDelta::class); + $this->container->singleton(SchemaExecutor::class, static fn (C $c): DbDelta => $c->get(DbDelta::class)); $this->container->singleton(Schema::class); $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); } diff --git a/src/Database/Schema.php b/src/Database/Schema.php index c9565cc..9f94b1f 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -2,9 +2,9 @@ namespace StellarWP\Foundation\Database; -use Closure; use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; +use StellarWP\Foundation\Database\Contracts\SchemaExecutor; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Table\TableDefinition; @@ -14,12 +14,9 @@ */ final readonly class Schema implements SchemaContract { - /** - * @param Closure(string, bool): array $dbDelta - */ public function __construct( private Database $database, - private Closure $dbDelta + private SchemaExecutor $executor ) { } @@ -30,7 +27,7 @@ public function createOrUpdate(Table $table): void { $definition = $table->definition(); $definition->assertValid(); - $this->applyDelta($this->createTableSql($table, $definition)); + $this->executor->execute($this->createTableSql($table, $definition)); $this->reconcileComplexDefaults($table, $definition); } @@ -38,7 +35,7 @@ public function createOrUpdate(Table $table): void { * @throws DatabaseException When WordPress cannot reconcile the SQL definition. */ public function createOrUpdateSql(string $sql): void { - $this->applyDelta($sql); + $this->executor->execute($sql); } public function execute(string $sql): void { @@ -107,16 +104,4 @@ private function reconcileComplexDefaults(Table $table, TableDefinition $definit )); } } - - private function applyDelta(string $sql): void { - ($this->dbDelta)($sql, true); - $pending = ($this->dbDelta)($sql, false); - - if ($pending !== []) { - throw new DatabaseException(sprintf( - 'Database schema reconciliation did not complete: %s', - implode('; ', $pending) - )); - } - } } diff --git a/src/Database/Schema/DbDelta.php b/src/Database/Schema/DbDelta.php new file mode 100644 index 0000000..ac8a96b --- /dev/null +++ b/src/Database/Schema/DbDelta.php @@ -0,0 +1,48 @@ +last_error !== '') { + throw new QueryException($wpdb->last_error, $sql, [], $wpdb->last_error); + } + + $pending = dbDelta($sql, false); + + if ($pending !== []) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation did not complete: %s', + implode('; ', $pending) + )); + } + } +} diff --git a/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php b/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php new file mode 100644 index 0000000..0a2755e --- /dev/null +++ b/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php @@ -0,0 +1,17 @@ + + */ + public array $statements = []; + + public function execute(string $sql): void { + $this->statements[] = $sql; + } +} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index f0a6ca6..92d20d5 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -15,6 +15,7 @@ use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema; +use StellarWP\Foundation\Database\Schema\DbDelta; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; @@ -40,12 +41,8 @@ $container->bind(ContainerInterface::class, $container); $container->singleton(Dot::class, new Dot()); - if (! function_exists('dbDelta')) { - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - } - $database = new Database($wpdb); - $schema = new Schema($database, static fn (string $sql, bool $execute): array => dbDelta($sql, $execute)); + $schema = new Schema($database, new DbDelta()); $migrationTableName = $wpdb->prefix . 'foundation_cli_migrations'; $lockTableName = $wpdb->prefix . 'foundation_cli_locks'; $exampleTable = $wpdb->prefix . 'foundation_cli_example'; diff --git a/tests/Unit/Database/Schema/DbDeltaTest.php b/tests/Unit/Database/Schema/DbDeltaTest.php new file mode 100644 index 0000000..6b63ff9 --- /dev/null +++ b/tests/Unit/Database/Schema/DbDeltaTest.php @@ -0,0 +1,21 @@ +expectException(DatabaseException::class); + $this->expectExceptionMessage('WordPress dbDelta() is not available.'); + + (new DbDelta())->execute('CREATE TABLE example (id bigint)'); + } +} diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php index 6ad2d50..e22ea84 100644 --- a/tests/Unit/Database/SchemaTest.php +++ b/tests/Unit/Database/SchemaTest.php @@ -2,61 +2,37 @@ namespace StellarWP\Foundation\Tests\Unit\Database; -use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchemaExecutor; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\TestCase; final class SchemaTest extends TestCase { - public function test_it_runs_create_or_update_sql_through_db_delta(): void { - $statements = []; - $schema = new Schema(new FakeDatabase(), static function (string $sql, bool $execute) use (&$statements): array { - $statements[] = [$sql, $execute]; - - return []; - }); + public function test_it_runs_create_or_update_sql_through_the_schema_executor(): void { + $executor = new RecordingSchemaExecutor(); + $schema = new Schema(new FakeDatabase(), $executor); $schema->createOrUpdateSql('CREATE TABLE example (id bigint)'); - $this->assertSame([ - ['CREATE TABLE example (id bigint)', true], - ['CREATE TABLE example (id bigint)', false], - ], $statements); + $this->assertSame(['CREATE TABLE example (id bigint)'], $executor->statements); } - public function test_it_builds_table_definitions_for_db_delta(): void { - $statements = []; - $schema = new Schema(new FakeDatabase(), static function (string $sql, bool $execute) use (&$statements): array { - if ($execute) { - $statements[] = $sql; - } - - return []; - }); + public function test_it_builds_table_definitions_for_the_schema_executor(): void { + $executor = new RecordingSchemaExecutor(); + $schema = new Schema(new FakeDatabase(), $executor); $schema->createOrUpdate(new TestTable('example', 'wp_example')); - $this->assertStringContainsString('CREATE TABLE `wp_example`', $statements[0]); - } - - public function test_it_fails_when_db_delta_still_reports_pending_changes(): void { - $schema = new Schema(new FakeDatabase(), static fn (string $sql, bool $execute): array => $execute ? [] : [ - 'wp_example.name' => 'Added column wp_example.name', - ]); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Database schema reconciliation did not complete'); - - $schema->createOrUpdateSql('CREATE TABLE wp_example (name varchar(191))'); + $this->assertStringContainsString('CREATE TABLE `wp_example`', $executor->statements[0]); } public function test_it_checks_tables_and_indexes(): void { $database = new FakeDatabase(); $database->rowResults[] = ['table' => 'wp_example']; $database->rowResults[] = ['Key_name' => 'example_key']; - $schema = new Schema($database, static fn (string $sql, bool $execute): array => []); + $schema = new Schema($database, new RecordingSchemaExecutor()); $this->assertTrue($schema->hasTable('wp_example%')); $this->assertTrue($schema->hasIndex('wp_example', 'example_key')); @@ -66,7 +42,7 @@ public function test_it_checks_tables_and_indexes(): void { public function test_it_drops_indexes(): void { $database = new FakeDatabase(); - $schema = new Schema($database, static fn (string $sql, bool $execute): array => []); + $schema = new Schema($database, new RecordingSchemaExecutor()); $schema->dropIndex('wp_example', 'example_key'); @@ -74,7 +50,7 @@ public function test_it_drops_indexes(): void { } public function test_it_exposes_identifier_helpers(): void { - $schema = new Schema(new FakeDatabase(), static fn (string $sql, bool $execute): array => []); + $schema = new Schema(new FakeDatabase(), new RecordingSchemaExecutor()); $this->assertSame('`weird``table`', $schema->quoteIdentifier('weird`table')); } diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php index df465db..61eead2 100644 --- a/tests/Unit/Database/Table/Tables/LockTableTest.php +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -5,39 +5,34 @@ use StellarWP\Foundation\Database\Schema as DatabaseSchema; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchemaExecutor; use StellarWP\Foundation\Tests\TestCase; final class LockTableTest extends TestCase { public function test_it_creates_the_lock_table(): void { - $database = new FakeDatabase(); - $statements = []; - $schema = new DatabaseSchema($database, static function (string $sql, bool $execute) use (&$statements): array { - if ($execute) { - $statements[] = $sql; - } - - return []; - }); - $table = new LockTable('network_foundation_locks'); + $database = new FakeDatabase(); + $executor = new RecordingSchemaExecutor(); + $schema = new DatabaseSchema($database, $executor); + $table = new LockTable('network_foundation_locks'); $schema->createOrUpdate($table); $this->assertSame(LockTable::ID, $table->id()); $this->assertSame('network_foundation_locks', $table->name()); - $this->assertStringContainsString('CREATE TABLE `network_foundation_locks`', $statements[0]); - $this->assertStringContainsString('`name` varbinary(191)', $statements[0]); - $this->assertStringContainsString('`owner` varbinary(64)', $statements[0]); - $this->assertStringContainsString('`expires_at` datetime(6)', $statements[0]); - $this->assertStringContainsString('`created_at` datetime(6)', $statements[0]); - $this->assertStringContainsString('`updated_at` datetime(6)', $statements[0]); - $this->assertStringContainsString('PRIMARY KEY (`name`)', $statements[0]); - $this->assertStringContainsString('KEY `expires_at`', $statements[0]); + $this->assertStringContainsString('CREATE TABLE `network_foundation_locks`', $executor->statements[0]); + $this->assertStringContainsString('`name` varbinary(191)', $executor->statements[0]); + $this->assertStringContainsString('`owner` varbinary(64)', $executor->statements[0]); + $this->assertStringContainsString('`expires_at` datetime(6)', $executor->statements[0]); + $this->assertStringContainsString('`created_at` datetime(6)', $executor->statements[0]); + $this->assertStringContainsString('`updated_at` datetime(6)', $executor->statements[0]); + $this->assertStringContainsString('PRIMARY KEY (`name`)', $executor->statements[0]); + $this->assertStringContainsString('KEY `expires_at`', $executor->statements[0]); } public function test_it_drops_the_lock_table(): void { $database = new FakeDatabase(); - $schema = new DatabaseSchema($database, static fn (string $sql, bool $execute): array => []); + $schema = new DatabaseSchema($database, new RecordingSchemaExecutor()); $table = new LockTable('network_foundation_locks'); $schema->drop($table); diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php index 066f6b6..19f2063 100644 --- a/tests/Unit/Database/Table/Tables/MigrationTableTest.php +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -5,34 +5,29 @@ use StellarWP\Foundation\Database\Schema as DatabaseSchema; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchemaExecutor; use StellarWP\Foundation\Tests\TestCase; final class MigrationTableTest extends TestCase { public function test_it_creates_the_migration_table(): void { - $database = new FakeDatabase(); - $statements = []; - $schema = new DatabaseSchema($database, static function (string $sql, bool $execute) use (&$statements): array { - if ($execute) { - $statements[] = $sql; - } - - return []; - }); - $table = new MigrationTable('network_foundation_migrations'); + $database = new FakeDatabase(); + $executor = new RecordingSchemaExecutor(); + $schema = new DatabaseSchema($database, $executor); + $table = new MigrationTable('network_foundation_migrations'); $schema->createOrUpdate($table); $this->assertSame(MigrationTable::ID, $table->id()); $this->assertSame('network_foundation_migrations', $table->name()); - $this->assertStringContainsString('CREATE TABLE `network_foundation_migrations`', $statements[0]); - $this->assertStringContainsString('`migration` varbinary(191)', $statements[0]); - $this->assertStringContainsString('UNIQUE KEY `migration`', $statements[0]); + $this->assertStringContainsString('CREATE TABLE `network_foundation_migrations`', $executor->statements[0]); + $this->assertStringContainsString('`migration` varbinary(191)', $executor->statements[0]); + $this->assertStringContainsString('UNIQUE KEY `migration`', $executor->statements[0]); } public function test_it_drops_the_migration_table(): void { $database = new FakeDatabase(); - $schema = new DatabaseSchema($database, static fn (string $sql, bool $execute): array => []); + $schema = new DatabaseSchema($database, new RecordingSchemaExecutor()); $table = new MigrationTable('network_foundation_migrations'); $schema->drop($table); diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index e85bc9b..c0380af 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -18,6 +18,7 @@ use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Database\Schema; +use StellarWP\Foundation\Database\Schema\DbDelta; use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\TableDefinition; use StellarWP\Foundation\Database\Table\Tables\LockTable; @@ -48,8 +49,7 @@ protected function setUp(): void { } $this->database = new Database($GLOBALS['wpdb']); - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - $this->schema = new Schema($this->database, static fn (string $sql, bool $execute): array => dbDelta($sql, $execute)); + $this->schema = new Schema($this->database, new DbDelta()); } protected function tearDown(): void { diff --git a/tests/wpunit/Database/Schema/DbDeltaTest.php b/tests/wpunit/Database/Schema/DbDeltaTest.php new file mode 100644 index 0000000..149efe6 --- /dev/null +++ b/tests/wpunit/Database/Schema/DbDeltaTest.php @@ -0,0 +1,83 @@ +originalLastError = $GLOBALS['wpdb']->last_error; + $GLOBALS['wpdb']->last_error = ''; + } + + protected function tearDown(): void { + $GLOBALS['wpdb']->last_error = $this->originalLastError; + + parent::tearDown(); + } + + public function test_it_executes_and_verifies_the_schema_definition(): void { + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with(self::SQL, true)->once()->andReturn([]); + $dbDelta->with(self::SQL, false)->once()->andReturn([]); + + (new DbDelta())->execute(self::SQL); + + $this->addToAssertionCount(1); + } + + public function test_it_fails_when_schema_changes_remain_pending(): void { + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with(self::SQL, true)->once()->andReturn([]); + $dbDelta->with(self::SQL, false)->once()->andReturn([ + 'wp_example.name' => 'Added column wp_example.name', + ]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Database schema reconciliation did not complete: Added column wp_example.name'); + + (new DbDelta())->execute(self::SQL); + } + + public function test_it_translates_wordpress_database_errors(): void { + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with(self::SQL, true)->once()->andReturnUsing(static function (): array { + $GLOBALS['wpdb']->last_error = 'Could not alter the table.'; + + return []; + }); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Could not alter the table.'); + + (new DbDelta())->execute(self::SQL); + } + + public function test_it_fails_when_the_global_wordpress_database_is_unavailable(): void { + $wpdb = $GLOBALS['wpdb']; + unset($GLOBALS['wpdb']); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('The global wpdb instance is not available.'); + + try { + (new DbDelta())->execute(self::SQL); + } finally { + $GLOBALS['wpdb'] = $wpdb; + } + } +} From ddfc2bc4ff2b7f74a808782b96b519237c494912 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 14:44:20 -0600 Subject: [PATCH 35/81] Fix Migrate command possibly loading when WP-CLI is not there. --- AGENTS.md | 10 +++++++++ src/Database/DatabaseProvider.php | 2 -- .../Feature/Database/DatabaseProviderTest.php | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/Database/DatabaseProviderTest.php diff --git a/AGENTS.md b/AGENTS.md index 15ec674..ecb789b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,16 @@ Do not instruct consuming WordPress plugins to register `StellarWP\Foundation\Cl When generated code depends on runtime APIs, require the runtime package normally. For WP-CLI commands, install `stellarwp/foundation-wpcli` in `require` if the plugin ships those commands, and install `stellarwp/foundation-cli` in `require-dev` only for generation. +Do not register WP-CLI command classes directly with `$this->container->bind(CommandClass::class)` or `$this->container->singleton(CommandClass::class)` from providers loaded during normal WordPress bootstrap. DI52 creates the binding lazily, but its builder factory immediately calls `class_exists()` for string implementations. That autoloads the command class and its `WP_CLI_Command` parent before WP-CLI is available. Keep any contextual bindings for the command, then contribute it lazily through `WPCliProvider::COMMANDS` without separately binding it: + +```php +$this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(CommandClass::class), +]); +``` + +The command class will then be autowired only when `WPCliProvider` resolves the command collection during `cli_init`. + If local scaffolding assets such as `foundation/stubs/` should not be included in a consuming project's release archive, add them to that project's `.gitattributes` production zip exclusions. ## Container Providers diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 83a6c9a..6fc8110 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -119,8 +119,6 @@ private function registerCliCommands(): void { ->needs('$commandPrefix') ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); - $this->container->singleton(Migrate::class); - $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ $c->get(Migrate::class), ]); diff --git a/tests/Feature/Database/DatabaseProviderTest.php b/tests/Feature/Database/DatabaseProviderTest.php new file mode 100644 index 0000000..fa16ff9 --- /dev/null +++ b/tests/Feature/Database/DatabaseProviderTest.php @@ -0,0 +1,21 @@ +assertFalse(class_exists('WP_CLI_Command', false)); + + $this->container->register(DatabaseProvider::class); + + $this->assertFalse(class_exists('WP_CLI_Command', false)); + } +} From 1bde1d03e6aea11fe4e7a5dc5d78a9b73cce89bd Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 14:49:22 -0600 Subject: [PATCH 36/81] Remove singleton command example --- src/WPCli/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/src/WPCli/README.md b/src/WPCli/README.md index 439c24b..05626d7 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -140,7 +140,6 @@ final class Wp_Cli_Provider extends Provider ->needs( '$commandPrefix' ) ->give( static fn ( C $c ): string => $c->get( WPCliProvider::COMMAND_PREFIX ) ); - $this->container->singleton( Sync_Command::class ); $this->container->mergeArrayVar( WPCliProvider::COMMANDS, static fn ( C $c ): array => [ From a649c94efb374afa25b92f091be04932aa9a0403 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 16:19:47 -0600 Subject: [PATCH 37/81] Add `foundation.prefix` configuration option to support multiple plugins on the same instance --- AGENTS.md | 2 + src/Container/README.md | 22 ++++++- .../Traits/ResolvesFoundationPrefix.php | 33 ++++++++++ src/Database/Contracts/Database.php | 15 +++-- src/Database/Contracts/Schema.php | 12 ++++ src/Database/Database.php | 27 ++++++-- src/Database/DatabaseProvider.php | 30 +++++++-- src/Database/Migration/Store.php | 2 +- src/Database/Query/QueryBuilder.php | 17 +++++ src/Database/README.md | 38 ++++++++--- src/Database/Schema.php | 16 ++++- src/WPCli/README.md | 14 ++++- src/WPCli/WPCliProvider.php | 13 +++- .../Traits/FoundationPrefixProvider.php | 18 ++++++ .../Traits/ResolvesFoundationPrefixTest.php | 63 +++++++++++++++++++ tests/Unit/Database/Cli/MigrateTest.php | 18 +++--- tests/Unit/Database/Lock/DatabaseLockTest.php | 2 +- .../Migration/MigratorExecutionTest.php | 30 ++++----- .../Unit/Database/Migration/MigratorTest.php | 22 +++---- .../Database/DatabaseProviderTest.php | 43 ++++++++++++- tests/integration/WPCli/WPCliProviderTest.php | 37 +++++++++++ .../Database/DatabaseIntegrationTest.php | 24 ++++++- 22 files changed, 427 insertions(+), 71 deletions(-) create mode 100644 src/Container/Traits/ResolvesFoundationPrefix.php create mode 100644 tests/Support/Fixtures/Container/Traits/FoundationPrefixProvider.php create mode 100644 tests/Unit/Container/Traits/ResolvesFoundationPrefixTest.php diff --git a/AGENTS.md b/AGENTS.md index ecb789b..bb43e5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,8 @@ Use contextual bindings with `$this->container->when()->needs()->give()` for sca Classes should take the dependencies they need directly. Do not make constructor dependencies nullable just to instantiate fallback concrete classes internally, for example `?Dependency $dependency = null` with `$this->dependency = $dependency ?? new Dependency()`. Register default implementations and aliases in a provider instead so consumers can replace them through container configuration. +Use the optional `foundation.prefix` configuration key when Foundation-managed resources must be scoped to a consuming application. Its effective zero-configuration value is `nx`; providers should derive their default resource names from that shared value instead of repeating their own fallbacks. Distributable plugins must configure a stable, unique prefix so separate Foundation consumers do not share resources. Documentation and examples should use a generic lowercase kebab-case value such as `your-plugin`, never a developer-specific project name. Package-specific settings must take priority over values derived from the shared prefix. + Classes should receive service collaborators through constructor injection. Direct `new` expressions inside application classes are reserved for immutable value or result objects, exceptions, PHP standard-library objects, and objects deliberately produced by an owning builder or factory. Keep feature-local value objects under that feature's `ValueObjects/` namespace. Value objects should be `final readonly` where possible and must not resolve or construct service dependencies. Organize provider registration by feature or capability, not by container mechanism. The main `register()` method should call focused private methods such as `registerConfiguration()`, `registerMigrations()`, `registerLocks()`, or `registerCliCommands()`. Keep each feature's contextual bindings beside the classes they configure. Avoid generic methods such as `configureContextualBindings()` that group unrelated bindings only because they use the same container API. diff --git a/src/Container/README.md b/src/Container/README.md index 7701d47..5f06606 100644 --- a/src/Container/README.md +++ b/src/Container/README.md @@ -95,8 +95,12 @@ A sample config.php for a project. Note: we fall back to sane defaults if the en $_ENV['SOME_KEY'] ?? '', - 'log' => [ + 'foundation' => [ + // For example, "your-plugin" in a distributable plugin. + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? '', + ], + 'some_key' => $_ENV['SOME_KEY'] ?? '', + 'log' => [ 'level' => $_ENV['LOG_LEVEL'] ?? 'debug', 'channel' => $_ENV['LOG_CHANNEL'] ?? 'null', 'channels' => [ @@ -111,6 +115,19 @@ return [ ]; ``` +`foundation.prefix` is optional and defaults to `nx`. Set it to a stable, unique +lowercase kebab-case value when Foundation is bundled into a distributable +plugin. Foundation Database and Foundation WP-CLI use it to scope database +tables, lock names, and WP-CLI commands. Package-specific configuration +continues to override values derived from this prefix. The shared prefix must +still be valid when package-specific overrides are configured. Replace +`your-plugin` with the plugin's own stable prefix. + +> [!IMPORTANT] +> The `nx` default provides a zero-configuration starting point. A distributable +> plugin must set its own stable, unique prefix to avoid sharing tables, locks, +> or WP-CLI command names with another Foundation consumer. + Inside a Provider, we can then access deep variables with dot notation, e.g. ```php @@ -131,4 +148,3 @@ $_ENV['SOME_KEY'] = 'abcd-1234'; $_ENV['LOG_LEVEL'] = 'info'; $_ENV['LOG_CHANNEL'] = 'errorlog'; ``` - diff --git a/src/Container/Traits/ResolvesFoundationPrefix.php b/src/Container/Traits/ResolvesFoundationPrefix.php new file mode 100644 index 0000000..0a1ef97 --- /dev/null +++ b/src/Container/Traits/ResolvesFoundationPrefix.php @@ -0,0 +1,33 @@ +config->get('foundation.prefix'); + + if ($prefix === null || $prefix === '') { + return 'nx'; + } + + if (! is_string($prefix) || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $prefix) !== 1) { + throw new InvalidArgumentException('The Foundation prefix must use lowercase kebab-case, for example "your-plugin".'); + } + + return $prefix; + } +} diff --git a/src/Database/Contracts/Database.php b/src/Database/Contracts/Database.php index b774818..fdac7b3 100644 --- a/src/Database/Contracts/Database.php +++ b/src/Database/Contracts/Database.php @@ -13,6 +13,9 @@ interface Database { public function table(Table|string $table, ?string $alias = null): QueryBuilder; + /** + * @throws DatabaseException When the resulting WordPress table name exceeds MySQL's identifier limit. + */ public function tableName(Table|string $table): string; /** @@ -62,7 +65,8 @@ public function execute(string $sql, mixed ...$bindings): int; /** * @param array $data * - * @throws QueryException When the insert fails. + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. * * @return int Number of inserted rows. */ @@ -73,7 +77,8 @@ public function insert(Table|string $table, array $data): int; * * @param array $data * - * @throws QueryException When the insert fails. + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. */ public function insertGetId(Table|string $table, array $data): int; @@ -81,14 +86,16 @@ public function insertGetId(Table|string $table, array $data): int; * @param array $data * @param array $where * - * @throws QueryException When the update fails. + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the update fails. */ public function update(Table|string $table, array $data, array $where): int; /** * @param array $where * - * @throws QueryException When the delete fails. + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the delete fails. */ public function delete(Table|string $table, array $where): int; diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php index 76caca5..849787c 100644 --- a/src/Database/Contracts/Schema.php +++ b/src/Database/Contracts/Schema.php @@ -30,12 +30,24 @@ public function createOrUpdateSql(string $sql): void; */ public function execute(string $sql): void; + /** + * @throws DatabaseException When table inspection fails. + */ public function hasTable(Table|string $table): bool; + /** + * @throws DatabaseException When index inspection fails. + */ public function hasIndex(Table|string $table, string $index): bool; + /** + * @throws DatabaseException When the table name is invalid or the statement cannot be executed. + */ public function dropIndex(Table|string $table, string $index): void; + /** + * @throws DatabaseException When the table name is invalid or the statement cannot be executed. + */ public function drop(Table|string $table): void; /** diff --git a/src/Database/Database.php b/src/Database/Database.php index 1df88d7..6bd5820 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -24,16 +24,23 @@ public function table(Table|string $table, ?string $alias = null): QueryBuilder return new QueryBuilder($this, $table, $alias); } + /** + * @throws DatabaseException When the resulting WordPress table name exceeds MySQL's identifier limit. + */ public function tableName(Table|string $table): string { if ($table instanceof Table) { - return $table->name(); + $tableName = $table->name(); + } else { + $tableName = str_starts_with($table, $this->wpdb->prefix) ? $table : $this->wpdb->prefix . $table; } - if (str_starts_with($table, $this->wpdb->prefix)) { - return $table; + $length = preg_match_all('/./us', $tableName); + + if (($length === false ? strlen($tableName) : $length) > 64) { + throw new DatabaseException(sprintf('Database table name "%s" exceeds MySQL\'s 64-character identifier limit.', $tableName)); } - return $this->wpdb->prefix . $table; + return $tableName; } public function tableExists(Table|string $table): bool { @@ -142,6 +149,9 @@ public function execute(string $sql, mixed ...$bindings): int { /** * @param array $data + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. */ public function insert(Table|string $table, array $data): int { $result = $this->wpdb->insert($this->tableName($table), $data); @@ -155,6 +165,9 @@ public function insert(Table|string $table, array $data): int { /** * @param array $data + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. */ public function insertGetId(Table|string $table, array $data): int { $this->insert($table, $data); @@ -165,6 +178,9 @@ public function insertGetId(Table|string $table, array $data): int { /** * @param array $data * @param array $where + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the update fails. */ public function update(Table|string $table, array $data, array $where): int { $result = $this->wpdb->update($this->tableName($table), $data, $where); @@ -178,6 +194,9 @@ public function update(Table|string $table, array $data, array $where): int { /** * @param array $where + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the delete fails. */ public function delete(Table|string $table, array $where): int { $result = $this->wpdb->delete($this->tableName($table), $where); diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 6fc8110..50d1187 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -2,8 +2,10 @@ namespace StellarWP\Foundation\Database; +use InvalidArgumentException; use lucatume\DI52\Container as C; use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Container\Traits\ResolvesFoundationPrefix; use StellarWP\Foundation\Database\Cli\Migrate; use StellarWP\Foundation\Database\Contracts\Database as DatabaseContract; use StellarWP\Foundation\Database\Contracts\Repository; @@ -26,12 +28,17 @@ */ final class DatabaseProvider extends Provider { + use ResolvesFoundationPrefix; + public const string MIGRATIONS = 'foundation.database.migrations'; public const string MIGRATIONS_TABLE = 'foundation.database.migrations_table'; public const string LOCKS_TABLE = 'foundation.database.locks_table'; public const string LOCK_NAME = 'foundation.database.lock_name'; public const string LOCK_TTL = 'foundation.database.lock_ttl'; + /** + * @throws InvalidArgumentException When the configured Foundation prefix is invalid. + */ public function register(): void { $this->registerConfiguration(); $this->registerDatabase(); @@ -42,10 +49,23 @@ public function register(): void { } private function registerConfiguration(): void { + $foundationPrefix = $this->foundationPrefix(); + $databasePrefix = str_replace('-', '_', $foundationPrefix); + $migrationsTable = $this->tableName( + $this->config->get('database.migrations_table'), + $databasePrefix . '_foundation_migrations' + ); + $locksTable = $this->tableName( + $this->config->get('database.locks_table'), + $databasePrefix . '_foundation_locks' + ); + $lockName = $this->config->get('database.lock_name') + ?? $foundationPrefix . '-foundation-database-migrations'; + $this->container->mergeArrayVar(self::MIGRATIONS, []); - $this->container->singleton(self::MIGRATIONS_TABLE, $this->tableName('migrations_table', 'nexcess_foundation_migrations')); - $this->container->singleton(self::LOCKS_TABLE, $this->tableName('locks_table', 'nexcess_foundation_locks')); - $this->container->singleton(self::LOCK_NAME, $this->config->get('database.lock_name', 'foundation-database-migrations')); + $this->container->singleton(self::MIGRATIONS_TABLE, $migrationsTable); + $this->container->singleton(self::LOCKS_TABLE, $locksTable); + $this->container->singleton(self::LOCK_NAME, $lockName); $this->container->singleton(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); } @@ -124,9 +144,7 @@ private function registerCliCommands(): void { ]); } - private function tableName(string $key, string $default): mixed { - $configured = $this->config->get('database.' . $key); - + private function tableName(mixed $configured, string $default): mixed { if (is_string($configured) && $configured !== '') { return $configured; } diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php index 25ed8f5..1c9c7f8 100644 --- a/src/Database/Migration/Store.php +++ b/src/Database/Migration/Store.php @@ -30,7 +30,7 @@ public function __construct( private Lock $lock, private MigrationTable $migrationTable, private LockTable $lockTable, - private string $lockName = 'foundation-database-migrations', + private string $lockName = 'nx-foundation-database-migrations', private int $lockTtl = 300 ) { if (trim($this->lockName) === '') { diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php index 8fcf593..138cf50 100644 --- a/src/Database/Query/QueryBuilder.php +++ b/src/Database/Query/QueryBuilder.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Table; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; /** * Builds small, inspectable SELECT queries for WordPress database tables. @@ -82,10 +83,16 @@ public function limit(int $limit, ?int $offset = null): self { return $this; } + /** + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + */ public function query(): Query { return new Query($this->database, $this->toSql(), $this->bindings()); } + /** + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + */ public function toSql(): string { $sql = sprintf( 'SELECT %s FROM %s%s', @@ -130,11 +137,16 @@ public function bindings(): array { return $bindings; } + /** + * @throws DatabaseException When table-name resolution or query preparation fails. + */ public function toPreparedSql(): string { return $this->database->prepare($this->toSql(), ...$this->bindings()); } /** + * @throws DatabaseException When table-name resolution or query execution fails. + * * @return list> */ public function get(): array { @@ -142,6 +154,8 @@ public function get(): array { } /** + * @throws DatabaseException When table-name resolution or query execution fails. + * * @return array|null */ public function first(): ?array { @@ -151,6 +165,9 @@ public function first(): ?array { return $query->queryWithLimitBindings()->first(); } + /** + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + */ private function queryWithLimitBindings(): Query { return new Query($this->database, $this->toSql(), $this->bindings()); } diff --git a/src/Database/README.md b/src/Database/README.md index e3400fd..92337f3 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -69,8 +69,11 @@ it is not a substitute for rollback because it does not call migration `down()` methods. Use only one operation flag at a time. `--yes` only skips confirmation for destructive operations. -These examples use the default `nx` command prefix. Change -`wpcli.command_prefix` in `config.php` when the application uses another prefix. +These examples use the default `nx` command prefix. A distributable plugin must +set `foundation.prefix` to scope all supported Foundation resources with one +stable value. For example, `your-plugin` changes this command to +`wp your-plugin migrate`. Set `wpcli.command_prefix` when only the WP-CLI prefix +needs a different value. ## Database Configuration @@ -97,10 +100,23 @@ The provider registers: By default, WordPress tables are named: -- `nexcess_foundation_migrations` -- `nexcess_foundation_locks` +- `nx_foundation_migrations` +- `nx_foundation_locks` +- migration lock name `nx-foundation-database-migrations` -Configure these through the Foundation config keys `database.migrations_table` and `database.locks_table` when an application needs different table names. Configured table names are treated as exact full table names and are not passed through `Database::tableName()`, so include the WordPress prefix yourself when overriding them. +When `foundation.prefix` is `your-plugin`, the defaults become: + +- `your_plugin_foundation_migrations` +- `your_plugin_foundation_locks` +- migration lock name `your-plugin-foundation-database-migrations` + +Because the prefix participates in database table names, the final table name, +including the WordPress table prefix, must fit MySQL's 64-character identifier limit. + +The configured Foundation prefix must be a stable lowercase kebab-case value. +Changing it later points the application at a different migration ledger. + +Configure these through the Foundation config keys `database.migrations_table` and `database.locks_table` when an application needs different table names. Configured table names are treated as exact full table names: `Database::tableName()` validates them but does not add the WordPress prefix again, so include that prefix yourself when overriding them. Example `config.php` values: @@ -108,19 +124,27 @@ Example `config.php` values: [ + // For example, "your-plugin" in a distributable plugin. + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? '', + ], 'database' => [ // Leave empty or omit these keys to use the default WordPress-prefixed names. 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? '', 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? '', - 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? 'foundation-database-migrations', + 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? null, 'lock_ttl' => (int) ($_ENV['FOUNDATION_DATABASE_LOCK_TTL'] ?? 300), ], 'wpcli' => [ - 'command_prefix' => $_ENV['FOUNDATION_WPCLI_COMMAND_PREFIX'] ?? 'nx', + // Optional package-specific override for foundation.prefix. + 'command_prefix' => $_ENV['FOUNDATION_WPCLI_COMMAND_PREFIX'] ?? null, ], ]; ``` +Replace `your-plugin` with the plugin's own stable prefix. Leaving +`foundation.prefix` unset uses the default `nx` prefix. + If overriding table names, provide the full table name: ```php diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 9f94b1f..95afd4e 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -42,14 +42,23 @@ public function execute(string $sql): void { $this->database->execute($sql); } + /** + * @throws DatabaseException When table inspection fails. + */ public function hasTable(Table|string $table): bool { return $this->database->tableExists($table); } + /** + * @throws DatabaseException When index inspection fails. + */ public function hasIndex(Table|string $table, string $index): bool { return $this->database->indexExists($table, $index); } + /** + * @throws DatabaseException When the table name is invalid or the statement cannot be executed. + */ public function dropIndex(Table|string $table, string $index): void { $this->database->execute(sprintf( 'ALTER TABLE %s DROP INDEX %s', @@ -58,6 +67,9 @@ public function dropIndex(Table|string $table, string $index): void { )); } + /** + * @throws DatabaseException When the table name is invalid or the statement cannot be executed. + */ public function drop(Table|string $table): void { $this->database->execute(sprintf( 'DROP TABLE IF EXISTS %s', @@ -82,7 +94,7 @@ private function createTableSql(Table $table, TableDefinition $definition): stri return sprintf( "CREATE TABLE %s (\n%s\n) %s;", - $this->database->quoteIdentifier($table->name()), + $this->database->quoteIdentifier($this->database->tableName($table)), implode(",\n", $parts), $this->database->charsetCollate() ); @@ -98,7 +110,7 @@ private function reconcileComplexDefaults(Table $table, TableDefinition $definit $this->database->execute(sprintf( 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', - $this->database->quoteIdentifier($table->name()), + $this->database->quoteIdentifier($this->database->tableName($table)), $this->database->quoteIdentifier($column->name), $default )); diff --git a/src/WPCli/README.md b/src/WPCli/README.md index 05626d7..bf94726 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -162,14 +162,24 @@ $container->register( Wp_Cli_Provider::class ); The Foundation WP-CLI provider uses `cli_init` internally so commands are registered only during WP-CLI command bootstrap, after all application providers have had a chance to add command classes. -Set `wpcli.command_prefix` in the application's Foundation configuration when it needs a prefix other than `nx`: +Set a stable, unique application-wide Foundation prefix when packaging +Foundation in a distributable plugin. The shared prefix defaults to `nx`. When +`wpcli.command_prefix` is omitted, WP-CLI uses the shared prefix: ```php return [ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + // Optional package-specific override: 'wpcli' => [ - 'command_prefix' => 'acme', + 'command_prefix' => 'your-command', ], ]; ``` +With only `foundation.prefix` configured, commands are registered under +`wp your-plugin`. Replace `your-plugin` with the plugin's own stable lowercase +kebab-case prefix. + See [Foundation Container configuration](https://github.com/stellarwp/foundation-container#container-configuration) for loading the `config.php` array into the container's `Dot` binding. diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php index b740e5d..436fdc5 100644 --- a/src/WPCli/WPCliProvider.php +++ b/src/WPCli/WPCliProvider.php @@ -2,7 +2,9 @@ namespace StellarWP\Foundation\WPCli; +use InvalidArgumentException; use StellarWP\Foundation\Container\Contracts\Provider; +use StellarWP\Foundation\Container\Traits\ResolvesFoundationPrefix; use UnexpectedValueException; /** @@ -14,12 +16,21 @@ */ final class WPCliProvider extends Provider { + use ResolvesFoundationPrefix; + public const string COMMANDS = 'foundation.wpcli.commands'; public const string COMMAND_PREFIX = 'foundation.wpcli.command_prefix'; + /** + * @throws InvalidArgumentException When the configured Foundation prefix is invalid. + */ public function register(): void { + $foundationPrefix = $this->foundationPrefix(); + $commandPrefix = $this->config->get('wpcli.command_prefix') + ?? $foundationPrefix; + $this->container->mergeArrayVar(self::COMMANDS, []); - $this->container->bind(self::COMMAND_PREFIX, $this->config->get('wpcli.command_prefix', 'nx')); + $this->container->bind(self::COMMAND_PREFIX, $commandPrefix); add_action('cli_init', function (): void { $this->registerCommands(); diff --git a/tests/Support/Fixtures/Container/Traits/FoundationPrefixProvider.php b/tests/Support/Fixtures/Container/Traits/FoundationPrefixProvider.php new file mode 100644 index 0000000..e6267b4 --- /dev/null +++ b/tests/Support/Fixtures/Container/Traits/FoundationPrefixProvider.php @@ -0,0 +1,18 @@ +foundationPrefix(); + } +} diff --git a/tests/Unit/Container/Traits/ResolvesFoundationPrefixTest.php b/tests/Unit/Container/Traits/ResolvesFoundationPrefixTest.php new file mode 100644 index 0000000..b99beee --- /dev/null +++ b/tests/Unit/Container/Traits/ResolvesFoundationPrefixTest.php @@ -0,0 +1,63 @@ +container, new Dot()); + + $this->assertSame('nx', $provider->configuredFoundationPrefix()); + } + + public function test_it_returns_the_default_for_an_empty_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => '', + ], + ])); + + $this->assertSame('nx', $provider->configuredFoundationPrefix()); + } + + public function test_it_provides_the_configured_foundation_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + ])); + + $this->assertSame('your-plugin', $provider->configuredFoundationPrefix()); + } + + public function test_it_rejects_an_invalid_foundation_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => 'Your Plugin', + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $provider->configuredFoundationPrefix(); + } + + public function test_it_rejects_a_non_string_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => ['your-plugin'], + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $provider->configuredFoundationPrefix(); + } +} diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index cf4aa83..fd69326 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -28,11 +28,11 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $this->loadWpCliUtilities(); - $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); $repository = new InMemoryRepository(); $schema = new RecordingSchema(); $lock = new InMemoryLock(); - $store = new Store($schema, $lock, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); + $store = new Store($schema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); $command = new Migrate( $this->container, 'foundation', @@ -103,8 +103,8 @@ public function test_it_initializes_database_storage_without_running_migrations( $this->assertSame([], $repository->all()); $this->assertSame([ - 'createOrUpdate:wp_nexcess_foundation_locks', - 'createOrUpdate:wp_nexcess_foundation_migrations', + 'createOrUpdate:wp_nx_foundation_locks', + 'createOrUpdate:wp_nx_foundation_migrations', ], $schema->statements); } @@ -161,9 +161,9 @@ public function test_it_drops_the_migration_store(): void { 'yes' => true, ])); - $this->assertSame(['wp_nexcess_foundation_locks' => true], $schema->tables); - $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); - $this->assertNotContains('drop:wp_nexcess_foundation_locks', $schema->statements); + $this->assertSame(['wp_nx_foundation_locks' => true], $schema->tables); + $this->assertContains('drop:wp_nx_foundation_migrations', $schema->statements); + $this->assertNotContains('drop:wp_nx_foundation_locks', $schema->statements); } public function test_it_shows_a_warning_when_status_tables_do_not_exist(): void { @@ -204,9 +204,9 @@ private function newCommand(): array { $wpSchema = new RecordingSchema(); $repository = new InMemoryRepository(); - $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); $lock = new InMemoryLock(); - $store = new Store($wpSchema, $lock, $migrationTable, new LockTable('wp_nexcess_foundation_locks')); + $store = new Store($wpSchema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); $command = new Migrate( $this->container, 'foundation', diff --git a/tests/Unit/Database/Lock/DatabaseLockTest.php b/tests/Unit/Database/Lock/DatabaseLockTest.php index 2fb6ddf..c7435e9 100644 --- a/tests/Unit/Database/Lock/DatabaseLockTest.php +++ b/tests/Unit/Database/Lock/DatabaseLockTest.php @@ -179,7 +179,7 @@ public function test_it_normalizes_database_failures(callable $operation, string $database->shouldReceive($databaseMethod)->andThrow(new QueryException('Query failed.', 'SELECT 1')); try { - $operation(new DatabaseLock($database, 'wp_nexcess_foundation_locks')); + $operation(new DatabaseLock($database, 'wp_nx_foundation_locks')); $this->fail('Expected the database failure to be normalized.'); } catch (LockUnavailableException $exception) { $this->assertInstanceOf(QueryException::class, $exception->getPrevious()); diff --git a/tests/Unit/Database/Migration/MigratorExecutionTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php index 702f643..bab9afa 100644 --- a/tests/Unit/Database/Migration/MigratorExecutionTest.php +++ b/tests/Unit/Database/Migration/MigratorExecutionTest.php @@ -44,8 +44,8 @@ protected function setUp(): void { (new Store( $this->schema, $this->lock, - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks') + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks') ))->initialize(); $this->schema->statements = []; @@ -58,8 +58,8 @@ public function test_it_rejects_a_blank_migration_lock_name(): void { new Store( $this->schema, $this->lock, - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks'), + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks'), lockName: ' ' ); } @@ -71,8 +71,8 @@ public function test_it_rejects_an_invalid_migration_lock_ttl(): void { new Store( $this->schema, $this->lock, - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks'), + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks'), lockTtl: 0 ); } @@ -277,7 +277,7 @@ public function test_it_treats_migration_ids_as_case_sensitive(): void { } public function test_it_fails_when_the_migration_lock_is_already_owned(): void { - $this->lock->acquire('foundation-database-migrations', 300); + $this->lock->acquire('nx-foundation-database-migrations', 300); $this->expectException(MigrationLockFailed::class); $this->expectExceptionMessage('Could not acquire migration lock'); @@ -320,8 +320,8 @@ public function test_it_releases_the_lock_when_initialization_fails(): void { $store = new Store( $storeSchema, $this->lock, - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks') + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks') ); $this->expectException(DatabaseException::class); @@ -329,7 +329,7 @@ public function test_it_releases_the_lock_when_initialization_fails(): void { try { $store->initialize(); } finally { - $this->assertNotNull($this->lock->acquire('foundation-database-migrations', 300)); + $this->assertNotNull($this->lock->acquire('nx-foundation-database-migrations', 300)); } } @@ -338,7 +338,7 @@ public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_ $lock = $this->createMock(Lock::class); $lock->expects($this->once()) ->method('acquire') - ->with('foundation-database-migrations', 300) + ->with('nx-foundation-database-migrations', 300) ->willReturn($token); $lock->expects($this->once()) ->method('release') @@ -460,7 +460,7 @@ public function test_it_does_not_delete_a_record_when_rollback_fails(): void { } private function lockToken(): LockToken { - $token = $this->lock->acquire('foundation-database-migrations', 300); + $token = $this->lock->acquire('nx-foundation-database-migrations', 300); $this->assertNotNull($token); @@ -475,7 +475,7 @@ private function migrator( Collection $migrations, ?Lock $lock = null, ?Schema $schema = null, - string $lockName = 'foundation-database-migrations', + string $lockName = 'nx-foundation-database-migrations', int $lockTtl = 300 ): Migrator { $schema ??= $this->schema; @@ -483,8 +483,8 @@ private function migrator( $store = new Store( $schema, $lock, - new MigrationTable('wp_nexcess_foundation_migrations'), - new LockTable('wp_nexcess_foundation_locks'), + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks'), $lockName, $lockTtl ); diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index 82793af..ed6cc9f 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -84,16 +84,16 @@ public function test_it_initializes_and_drops_the_migration_store(): void { $migrator->dropStore(); $this->assertFalse($migrator->isInitialized()); - $this->assertContains('drop:wp_nexcess_foundation_migrations', $schema->statements); - $this->assertNotContains('drop:wp_nexcess_foundation_locks', $schema->statements); - $this->assertTrue($schema->tables['wp_nexcess_foundation_locks']); + $this->assertContains('drop:wp_nx_foundation_migrations', $schema->statements); + $this->assertNotContains('drop:wp_nx_foundation_locks', $schema->statements); + $this->assertTrue($schema->tables['wp_nx_foundation_locks']); } public function test_it_does_not_drop_the_store_while_another_migration_owns_the_lock(): void { $lock = new InMemoryLock(); [$migrator, , $schema] = $this->newMigrator($lock); - $token = $lock->acquire('foundation-database-migrations', 300); + $token = $lock->acquire('nx-foundation-database-migrations', 300); $this->assertNotNull($token); $this->expectException(MigrationLockFailed::class); @@ -101,14 +101,14 @@ public function test_it_does_not_drop_the_store_while_another_migration_owns_the try { $migrator->dropStore(); } finally { - $this->assertTrue($schema->tables['wp_nexcess_foundation_migrations']); + $this->assertTrue($schema->tables['wp_nx_foundation_migrations']); } } public function test_it_does_not_initialize_the_ledger_while_another_migration_owns_the_lock(): void { $lock = new InMemoryLock(); [$migrator, , $schema] = $this->newMigrator($lock, false); - $token = $lock->acquire('foundation-database-migrations', 300); + $token = $lock->acquire('nx-foundation-database-migrations', 300); $this->assertNotNull($token); $this->expectException(MigrationLockFailed::class); @@ -116,8 +116,8 @@ public function test_it_does_not_initialize_the_ledger_while_another_migration_o try { $migrator->initialize(); } finally { - $this->assertTrue($schema->tables['wp_nexcess_foundation_locks']); - $this->assertArrayNotHasKey('wp_nexcess_foundation_migrations', $schema->tables); + $this->assertTrue($schema->tables['wp_nx_foundation_locks']); + $this->assertArrayNotHasKey('wp_nx_foundation_migrations', $schema->tables); } } @@ -125,7 +125,7 @@ public function test_status_uses_the_existing_ledger_when_shared_lock_storage_is [$migrator, , $schema] = $this->newMigrator(); $migrator->run(); - unset($schema->tables['wp_nexcess_foundation_locks']); + unset($schema->tables['wp_nx_foundation_locks']); $this->assertFalse($migrator->isInitialized()); $this->assertTrue($migrator->status()[0]->ran); @@ -150,8 +150,8 @@ private function newMigrator(?InMemoryLock $lock = null, bool $initialize = true $schema = new RecordingSchema(); $repository = new InMemoryRepository(); $lock ??= new InMemoryLock(); - $migrationTable = new MigrationTable('wp_nexcess_foundation_migrations'); - $lockTable = new LockTable('wp_nexcess_foundation_locks'); + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); + $lockTable = new LockTable('wp_nx_foundation_locks'); $store = new Store($schema, $lock, $migrationTable, $lockTable); $migrator = new Migrator( diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index 621907a..f9d2aa8 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -32,7 +32,7 @@ public function test_it_registers_default_database_configuration(): void { $commands = $this->container->get(WPCliProvider::COMMANDS); $this->assertSame([], $this->container->get(DatabaseProvider::MIGRATIONS)); - $this->assertSame('foundation-database-migrations', $this->container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame('nx-foundation-database-migrations', $this->container->get(DatabaseProvider::LOCK_NAME)); $this->assertSame(300, $this->container->get(DatabaseProvider::LOCK_TTL)); $this->assertContainsOnlyInstancesOf(Command::class, $commands); $this->assertTrue($this->containsMigrateCommand((array) $commands)); @@ -43,13 +43,16 @@ public function test_it_registers_default_database_configuration(): void { public function test_it_registers_configured_database_configuration(): void { $container = $this->newContainer([ - 'database' => [ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'database' => [ 'migrations_table' => 'custom_migrations', 'locks_table' => 'custom_locks', 'lock_name' => 'custom-migrations', 'lock_ttl' => '120', ], - 'wpcli' => [ + 'wpcli' => [ 'command_prefix' => 'custom', ], ]); @@ -66,6 +69,40 @@ public function test_it_registers_configured_database_configuration(): void { $this->assertSame('custom', $container->get(WPCliProvider::COMMAND_PREFIX)); } + public function test_it_rejects_an_invalid_foundation_prefix_when_database_resources_are_overridden(): void { + $container = $this->newContainer([ + 'foundation' => [ + 'prefix' => 'Invalid Prefix', + ], + 'database' => [ + 'migrations_table' => 'custom_migrations', + 'locks_table' => 'custom_locks', + 'lock_name' => 'custom-migrations', + ], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $container->register(DatabaseProvider::class); + } + + public function test_it_scopes_default_resources_with_the_foundation_prefix(): void { + $container = $this->newContainer([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + ]); + + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertSame('your-plugin-foundation-database-migrations', $container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame('your-plugin', $container->get(WPCliProvider::COMMAND_PREFIX)); + } + public function test_it_applies_configured_lock_policy_to_the_migration_store(): void { $configurations = [ [['database' => ['lock_name' => ' ']], 'lock name cannot be empty'], diff --git a/tests/integration/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php index 79dd811..3495430 100644 --- a/tests/integration/WPCli/WPCliProviderTest.php +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -2,6 +2,8 @@ namespace StellarWP\Foundation\Tests\Integration\WPCli; +use Adbar\Dot; +use InvalidArgumentException; use lucatume\DI52\Container as C; use stdClass; use StellarWP\Foundation\Tests\Support\Fixtures\WPCli\RecordingCommand; @@ -11,6 +13,41 @@ final class WPCliProviderTest extends WPTestCase { + public function test_it_preserves_the_zero_configuration_command_prefix(): void { + $this->container->singleton(Dot::class, new Dot()); + $this->container->register(WPCliProvider::class); + + $this->assertSame('nx', $this->container->get(WPCliProvider::COMMAND_PREFIX)); + } + + public function test_it_uses_the_foundation_prefix_by_default(): void { + $this->container->singleton(Dot::class, new Dot([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + ])); + + $this->container->register(WPCliProvider::class); + + $this->assertSame('your-plugin', $this->container->get(WPCliProvider::COMMAND_PREFIX)); + } + + public function test_it_rejects_an_invalid_foundation_prefix_when_the_command_prefix_is_overridden(): void { + $this->container->singleton(Dot::class, new Dot([ + 'foundation' => [ + 'prefix' => 'Invalid Prefix', + ], + 'wpcli' => [ + 'command_prefix' => 'custom', + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $this->container->register(WPCliProvider::class); + } + public function test_it_registers_configured_commands_on_cli_init(): void { $this->container->when(RecordingCommand::class) ->needs('$commandPrefix') diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index c0380af..98f8fb9 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -13,6 +13,7 @@ use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Database; use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Exceptions\QueryException; use StellarWP\Foundation\Database\Lock\DatabaseLock; use StellarWP\Foundation\Database\Migration\Migrator; @@ -25,6 +26,7 @@ use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\LockToken; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; final class DatabaseIntegrationTest extends WPTestCase @@ -100,6 +102,24 @@ public function test_database_executes_and_reads_rows_through_wpdb(): void { ], $this->database->table($table)->select('name')->where('id', '=', 1)->get()); } + public function test_database_rejects_table_names_beyond_the_mysql_identifier_limit(): void { + $maximum = str_repeat('a', 64 - strlen($GLOBALS['wpdb']->prefix)); + + $this->assertSame($GLOBALS['wpdb']->prefix . $maximum, $this->database->tableName($maximum)); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('64-character identifier limit'); + + $this->database->tableName(str_repeat('a', 65 - strlen($GLOBALS['wpdb']->prefix))); + } + + public function test_schema_rejects_table_objects_beyond_the_mysql_identifier_limit(): void { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('64-character identifier limit'); + + $this->schema->createOrUpdate(new TestTable('too_long', str_repeat('a', 65))); + } + public function test_database_crud_helpers_and_schema_inspection_use_wordpress(): void { $table = $this->table('crud'); @@ -504,8 +524,8 @@ public function test_provider_registers_wordpress_prefixed_database_services(): $container->register(DatabaseProvider::class); - $this->assertSame($GLOBALS['wpdb']->prefix . 'nexcess_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); - $this->assertSame($GLOBALS['wpdb']->prefix . 'nexcess_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); $this->assertInstanceOf(Database::class, $container->get(Database::class)); $this->assertInstanceOf(Database::class, $container->get(DatabaseContract::class)); $this->assertInstanceOf(Schema::class, $container->get(Schema::class)); From b17057c3b0d23c1aab745254c825f4a375a10dac Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 16:29:02 -0600 Subject: [PATCH 38/81] Fix: failed multi-row queries appear successful. --- src/Database/Database.php | 19 ++++++++--------- .../Database/DatabaseIntegrationTest.php | 21 +++++++++++++++---- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 6bd5820..d919be4 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -69,6 +69,10 @@ public function indexExists(Table|string $table, string $index): bool { } public function prepare(string $sql, mixed ...$bindings): string { + if (trim($sql) === '') { + throw new QueryException('SQL statement cannot be empty.', $sql, array_values($bindings)); + } + if ($bindings === []) { return $sql; } @@ -90,10 +94,9 @@ public function row(string $sql, mixed ...$bindings): ?array { $bindings = array_values($bindings); $query = $this->prepare($sql, ...$bindings); $result = $this->wpdb->get_row($query, self::ARRAY_A); + $this->throwIfLastError($sql, $bindings); if ($result === null) { - $this->throwIfLastError('Unable to retrieve database row.', $sql, $bindings); - return null; } @@ -107,11 +110,10 @@ public function rows(string $sql, mixed ...$bindings): array { $bindings = array_values($bindings); $query = $this->prepare($sql, ...$bindings); $results = $this->wpdb->get_results($query, self::ARRAY_A); + $this->throwIfLastError($sql, $bindings); if ($results === null) { - $this->throwIfLastError('Unable to retrieve database rows.', $sql, $bindings); - - return []; + throw new QueryException('Unable to retrieve database rows.', $sql, $bindings); } $rows = []; @@ -127,10 +129,7 @@ public function value(string $sql, mixed ...$bindings): mixed { $bindings = array_values($bindings); $query = $this->prepare($sql, ...$bindings); $result = $this->wpdb->get_var($query); - - if ($result === null) { - $this->throwIfLastError('Unable to retrieve database value.', $sql, $bindings); - } + $this->throwIfLastError($sql, $bindings); return $result; } @@ -223,7 +222,7 @@ public function charsetCollate(): string { /** * @param list $bindings */ - private function throwIfLastError(string $fallback, string $sql, array $bindings): void { + private function throwIfLastError(string $sql, array $bindings): void { $error = $this->lastError(); if ($error !== null) { diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 98f8fb9..99bfcd0 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -173,7 +173,7 @@ public function test_database_insert_returns_affected_rows_for_string_identifier ])); } - public function test_database_returns_null_for_missing_values_without_query_errors(): void { + public function test_database_returns_empty_results_without_query_errors(): void { $table = $this->table('missing_value'); $this->database->execute(sprintf( @@ -187,14 +187,27 @@ public function test_database_returns_null_for_missing_values_without_query_erro )); $this->assertNull($this->database->value('SELECT name FROM %i WHERE id = %d', $table, 999)); + $this->assertSame([], $this->database->rows('SELECT name FROM %i WHERE id = %d', $table, 999)); + } + + public function test_database_rejects_blank_sql(): void { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('SQL statement cannot be empty.'); + + $this->database->prepare(' '); } public function test_database_wraps_wordpress_query_failures(): void { $previous = $GLOBALS['wpdb']->suppress_errors(true); try { + $exception = $this->assertQueryFails(fn (): mixed => $this->database->rows('SELECT * FROM %i', 'missing_foundation_table')); + + $this->assertSame('SELECT * FROM %i', $exception->sql()); + $this->assertSame(['missing_foundation_table'], $exception->bindings()); + $this->assertNotNull($exception->databaseError()); + $this->assertQueryFails(fn (): mixed => $this->database->row('SELECT * FROM %i', 'missing_foundation_table')); - $this->assertSame([], $this->database->rows('SELECT * FROM %i', 'missing_foundation_table')); $this->assertQueryFails(fn (): mixed => $this->database->execute('SELECT * FROM %i', 'missing_foundation_table')); $this->assertQueryFails(fn (): mixed => $this->database->insert('missing_foundation_table', ['name' => 'test'])); $this->assertQueryFails(fn (): mixed => $this->database->update('missing_foundation_table', ['name' => 'updated'], ['id' => 1])); @@ -547,13 +560,13 @@ private function table(string $suffix): string { /** * @param callable(): mixed $callback */ - private function assertQueryFails(callable $callback): void { + private function assertQueryFails(callable $callback): QueryException { try { $callback(); } catch (QueryException $exception) { $this->assertNotSame('', $exception->getMessage()); - return; + return $exception; } $this->fail('Expected the database operation to throw a query exception.'); From d2e7664228a283f20e4a90f35342a5f2edb01b91 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 16:59:12 -0600 Subject: [PATCH 39/81] Fix: arbitrary older batches can be rolled back while newer batches remain + race condition for lock/migrations table --- .../Exceptions/InvalidRollbackBatch.php | 19 +++++++ src/Database/Migration/Migrator.php | 14 +++-- src/Database/Migration/Store.php | 12 ++++- src/Database/README.md | 4 ++ .../Migration/MigratorExecutionTest.php | 53 ++++++++++++++++++ .../Unit/Database/Migration/MigratorTest.php | 54 ++++++++++++++++++- 6 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 src/Database/Migration/Exceptions/InvalidRollbackBatch.php diff --git a/src/Database/Migration/Exceptions/InvalidRollbackBatch.php b/src/Database/Migration/Exceptions/InvalidRollbackBatch.php new file mode 100644 index 0000000..d67b00d --- /dev/null +++ b/src/Database/Migration/Exceptions/InvalidRollbackBatch.php @@ -0,0 +1,19 @@ +migrations->all(); return $this->store->withMigrationLock(function (Schema $schema) use ($configured, $batch): Result { - $batch ??= $this->repository->latestBatch(); + $latestBatch = $this->repository->latestBatch(); + + if ($batch !== null && $batch !== $latestBatch) { + throw new InvalidRollbackBatch($batch, $latestBatch); + } + + $batch ??= $latestBatch; if ($batch === null) { return new Result(); diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php index 1c9c7f8..7a3905c 100644 --- a/src/Database/Migration/Store.php +++ b/src/Database/Migration/Store.php @@ -104,9 +104,17 @@ public function hasLedger(): bool { * @return T */ public function withMigrationLock(callable $operation): mixed { - $this->assertInitialized(); + // Migration lock storage must exist before lock acquisition. + if (! $this->schema->hasTable($this->lockTable)) { + throw new UninitializedStore(); + } - return $this->withLock(fn (): mixed => $operation($this->schema)); + return $this->withLock(function () use ($operation): mixed { + // The ledger may have changed before this process acquired the lock. + $this->assertInitialized(); + + return $operation($this->schema); + }); } /** diff --git a/src/Database/README.md b/src/Database/README.md index 92337f3..d43f8de 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -393,6 +393,10 @@ Call `initialize()` before `run()`, `rollback()`, `refresh()`, or `dropStore()`. Migration operations fail with `UninitializedStore` rather than changing internal table definitions implicitly. +`rollback()` rolls back only the latest recorded batch. Its optional batch +argument is an expected-latest guard; passing an older batch throws +`InvalidRollbackBatch` instead of leaving newer migrations applied above it. + Recorded migration implementations must remain registered for as long as their ledger entries may be rolled back. `rollback()` and `refresh()` validate every selected ledger entry before changing schema and fail without a partial rollback diff --git a/tests/Unit/Database/Migration/MigratorExecutionTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php index bab9afa..2f2bd6e 100644 --- a/tests/Unit/Database/Migration/MigratorExecutionTest.php +++ b/tests/Unit/Database/Migration/MigratorExecutionTest.php @@ -10,6 +10,7 @@ use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Collection; +use StellarWP\Foundation\Database\Migration\Exceptions\InvalidRollbackBatch; use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Store; @@ -139,6 +140,49 @@ public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); } + public function test_it_rolls_back_an_explicit_batch_when_it_is_still_latest(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + $this->configured( + new TestMigration('2026_01_01_000002_create_posts'), + )->run(); + + $result = $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + )->rollback(2); + + $this->assertSame(['2026_01_01_000002_create_posts'], $result->rolledBack); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); + } + + public function test_it_rejects_rolling_back_an_older_batch(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + $this->configured( + new TestMigration('2026_01_01_000002_create_posts'), + )->run(); + + $this->schema->statements = []; + + $this->expectException(InvalidRollbackBatch::class); + $this->expectExceptionMessage('batch 1 cannot be rolled back because the latest recorded batch is 2'); + + try { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + )->rollback(1); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + $this->assertTrue($this->repository->hasRun('2026_01_01_000002_create_posts')); + } + } + public function test_it_returns_an_empty_result_when_there_is_no_batch_to_roll_back(): void { $result = $this->configured( new TestMigration('2026_01_01_000001_create_users'), @@ -148,6 +192,15 @@ public function test_it_returns_an_empty_result_when_there_is_no_batch_to_roll_b $this->assertSame(0, $result->count()); } + public function test_it_rejects_an_explicit_batch_when_no_batch_remains(): void { + $this->expectException(InvalidRollbackBatch::class); + $this->expectExceptionMessage('batch 1 cannot be rolled back because the latest recorded batch is none'); + + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->rollback(1); + } + public function test_it_rejects_unavailable_rollback_records_before_changing_schema(): void { $this->repository->recordRun('2026_01_01_000001_create_users', 1); $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index ed6cc9f..09b5c54 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; +use DateTimeImmutable; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Exceptions\UninitializedStore; @@ -9,7 +10,9 @@ use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; +use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\InMemoryLock; +use StellarWP\Foundation\Lock\LockToken; use StellarWP\Foundation\Tests\Support\Fixtures\Database\InMemoryRepository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; @@ -143,10 +146,59 @@ public function test_it_rejects_migration_operations_before_storage_is_initializ } } + public function test_it_does_not_acquire_the_migration_lock_without_lock_storage(): void { + $lock = $this->createMock(Lock::class); + $lock->expects($this->never())->method('acquire'); + + [$migrator, , $schema] = $this->newMigrator($lock, initialize: false); + $schema->tables['wp_nx_foundation_migrations'] = true; + + $this->expectException(UninitializedStore::class); + + $migrator->run(); + } + + public function test_it_rechecks_storage_after_acquiring_the_migration_lock(): void { + $schema = new RecordingSchema(); + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); + $lockTable = new LockTable('wp_nx_foundation_locks'); + $token = new LockToken( + 'nx-foundation-database-migrations', + 'owner', + new DateTimeImmutable('+5 minutes') + ); + $lock = $this->createMock(Lock::class); + + $schema->tables[$migrationTable->name()] = true; + $schema->tables[$lockTable->name()] = true; + + $lock->expects($this->once()) + ->method('acquire') + ->willReturnCallback(static function () use ($schema, $migrationTable, $token): LockToken { + unset($schema->tables[$migrationTable->name()]); + + return $token; + }); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(true); + + $migrator = new Migrator( + new Collection([new TestMigration('2026_06_23_000001_create_example')]), + new InMemoryRepository(), + new Store($schema, $lock, $migrationTable, $lockTable) + ); + + $this->expectException(UninitializedStore::class); + + $migrator->run(); + } + /** * @return array{Migrator, InMemoryRepository, RecordingSchema} */ - private function newMigrator(?InMemoryLock $lock = null, bool $initialize = true): array { + private function newMigrator(?Lock $lock = null, bool $initialize = true): array { $schema = new RecordingSchema(); $repository = new InMemoryRepository(); $lock ??= new InMemoryLock(); From 719fb8e7df2eb81aee93c5cb6db98dd51ba2b131 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 17:36:58 -0600 Subject: [PATCH 40/81] Fix: removed --force from src/Cli/Commands/Make/Database/MigrationCommand.php --- AGENTS.md | 2 + .../Make/Database/MigrationCommand.php | 13 +++- src/Cli/Generation/GeneratedFileWriter.php | 29 +++++++-- src/Cli/README.md | 2 + src/Database/README.md | 4 +- .../Cli/Commands/Make/DatabaseCommandTest.php | 51 +++++++++------ .../Generation/GeneratedFileWriterTest.php | 64 ++++++++++++++++++- 7 files changed, 136 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb43e5a..9dfa730 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,8 @@ Generators that write references to Foundation classes should detect `extra.stra Generator stubs should use context-aware placeholders for PHP literals, such as `{{ description_php }}` instead of raw `{{ description }}` inside quoted PHP strings. +Migration generators must not offer a force-overwrite option. Existing migrations are identity-bearing history: edit a migration only before it has been applied anywhere, or create a new migration for a new schema change. + ## CLI Tooling Boundary `stellarwp/foundation-cli` is developer tooling and should normally be installed by split-package consumers with `composer require --dev stellarwp/foundation-cli`. It should not be packaged into production WordPress plugin zips when installed as a split package. diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index 9f4e384..1377e3e 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -48,15 +48,22 @@ protected function configure(): void { ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier.') ->addOption('table-class', null, InputOption::VALUE_REQUIRED, 'Table class or base name used by a table-backed migration.') - ->addOption('table-namespace', null, InputOption::VALUE_REQUIRED, 'Namespace containing the table class.') - ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + ->addOption('table-namespace', null, InputOption::VALUE_REQUIRED, 'Namespace containing the table class.'); } protected function execute(InputInterface $input, OutputInterface $output): int { try { $this->validateExplicitProviderUpdate($input); $file = $this->generatedFile($input); - $this->fileWriter->write($file, (bool) $input->getOption('force')); + + if (file_exists($file->path)) { + throw new RuntimeException(sprintf( + 'Migration already exists: %s. Edit it directly or create a new migration.', + $file->relativePath + )); + } + + $this->fileWriter->write($file); $providerPath = $this->updateProvider($input, $output); } catch (RuntimeException $exception) { $output->writeln('' . $exception->getMessage() . ''); diff --git a/src/Cli/Generation/GeneratedFileWriter.php b/src/Cli/Generation/GeneratedFileWriter.php index 991d020..19280c3 100644 --- a/src/Cli/Generation/GeneratedFileWriter.php +++ b/src/Cli/Generation/GeneratedFileWriter.php @@ -11,17 +11,36 @@ final class GeneratedFileWriter { public function write(GeneratedFile $file, bool $force = false): void { - if (file_exists($file->path) && ! $force) { - throw new RuntimeException(sprintf('File already exists: %s. Use --force to overwrite it.', $file->relativePath)); - } - $directory = dirname($file->path); if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { throw new RuntimeException(sprintf('Could not create directory "%s".', $directory)); } - if (file_put_contents($file->path, $file->contents) === false) { + if ($force) { + if (file_put_contents($file->path, $file->contents) === false) { + throw new RuntimeException(sprintf('Could not write generated file "%s".', $file->relativePath)); + } + + return; + } + + $handle = @fopen($file->path, 'x'); + + if ($handle === false) { + if (file_exists($file->path)) { + throw new RuntimeException(sprintf('File already exists: %s.', $file->relativePath)); + } + + throw new RuntimeException(sprintf('Could not write generated file "%s".', $file->relativePath)); + } + + $written = fwrite($handle, $file->contents); + $closed = fclose($handle); + + if ($written !== strlen($file->contents) || ! $closed) { + @unlink($file->path); + throw new RuntimeException(sprintf('Could not write generated file "%s".', $file->relativePath)); } } diff --git a/src/Cli/README.md b/src/Cli/README.md index 415070c..0bd4aec 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -43,6 +43,8 @@ vendor/bin/foundation make:database-table Reports_Table vendor/bin/foundation make:database-migration Create_Reports_Table ``` +Database migrations are never overwritten by the generator. Edit a migration only before it has been applied anywhere; otherwise generate a new migration for the next schema change. + Generated database providers, tables, and migrations require `stellarwp/foundation-database` as a normal runtime dependency when they ship with the project: ```bash diff --git a/src/Database/README.md b/src/Database/README.md index d43f8de..ff53f54 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -437,9 +437,11 @@ protected array $providers = [ The table generator writes a Snake_Case table class under `src/Database/Tables` by default. The migration generator writes under `src/Database/Migrations` by default and references the matching table class. +The migration generator never overwrites an existing file. Edit a migration only before it has been applied anywhere; otherwise create a new migration for the next schema change. + Migration names matching `Create_*_Table`, or migrations generated with `--table-class`, use the table-backed migration stub and wrap the table in `CreateTable`. Other migration names use the generic migration stub. -If `src/Database/Provider.php` exists and contains the generated provider registration points, the table and migration generators automatically add imports and registrations to that provider. Pass `--provider=path/to/Provider.php` to update a non-standard provider file. Re-running a generator does not duplicate existing provider imports or registrations, including after WordPress code formatting. If an existing conventional provider cannot be updated safely, the generator creates the requested class and prints a warning with the manual registration step. An explicitly requested `--provider` that cannot be updated fails before generating the class. +If `src/Database/Provider.php` exists and contains the generated provider registration points, the table and migration generators automatically add imports and registrations to that provider. Pass `--provider=path/to/Provider.php` to update a non-standard provider file. Provider updates do not duplicate existing imports or registrations, including after WordPress code formatting. If an existing conventional provider cannot be updated safely, the generator creates the requested class and prints a warning with the manual registration step. An explicitly requested `--provider` that cannot be updated fails before generating the class. Common options: diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index fbae537..c827ffe 100644 --- a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -158,6 +158,37 @@ public function test_database_migrations_default_to_timestamped_ids(): void { ); } + public function test_database_migrations_cannot_overwrite_existing_files(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^2.0', + ], + ]); + $command = $this->migrationCommand($root); + $path = $root . '/src/Database/Migrations/Create_Reports_Table.php'; + + (new CommandTester($this->providerCommand($root)))->execute([]); + mkdir(dirname($path), 0777, true); + file_put_contents($path, 'existing migration'); + + $providerPath = $root . '/src/Database/Provider.php'; + $providerContents = (string) file_get_contents($providerPath); + $tester = new CommandTester($command); + $status = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_27_000001_create_reports_table', + ]); + + $this->assertFalse($command->getDefinition()->hasOption('force')); + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString( + 'Migration already exists: src/Database/Migrations/Create_Reports_Table.php. Edit it directly or create a new migration.', + $tester->getDisplay() + ); + $this->assertSame('existing migration', (string) file_get_contents($path)); + $this->assertSame($providerContents, (string) file_get_contents($providerPath)); + } + public function test_database_generators_accept_generation_options(): void { $root = $this->temporaryProject(); @@ -265,11 +296,6 @@ public function test_table_and_migration_generators_update_the_conventional_data 'name' => 'reports', '--force' => true, ]); - (new CommandTester($this->migrationCommand($root)))->execute([ - 'name' => 'create-reports-table', - '--id' => '2026_06_26_000001_create_reports_table', - '--force' => true, - ]); $this->assertSame($contents, (string) file_get_contents($root . '/src/Database/Provider.php')); } @@ -955,7 +981,7 @@ classNamespace: 'Acme\\Plugin\\Database\\Migrations' $this->assertSame($contents, (string) file_get_contents($providerPath)); } - public function test_database_generators_do_not_duplicate_wordpress_formatted_provider_registrations_when_forced(): void { + public function test_database_table_generator_does_not_duplicate_wordpress_formatted_provider_registrations_when_forced(): void { $root = $this->temporaryProject([ 'require' => [ 'stellarwp/foundation-database' => '^1.2', @@ -980,22 +1006,9 @@ public function test_database_generators_do_not_duplicate_wordpress_formatted_pr '--provider' => 'src/Database/Provider.php', ]); - $migrationTester = new CommandTester($this->migrationCommand($root)); - $migrationTester->execute([ - 'name' => 'create-reports-table', - '--id' => '2026_06_26_000001_create_reports_table', - ]); - $migrationStatus = $migrationTester->execute([ - 'name' => 'create-reports-table', - '--id' => '2026_06_26_000001_create_reports_table', - '--force' => true, - '--provider' => 'src/Database/Provider.php', - ]); - $contents = (string) file_get_contents($providerPath); $this->assertSame(Command::SUCCESS, $tableStatus); - $this->assertSame(Command::SUCCESS, $migrationStatus); $this->assertSame($providerContents, $contents); } diff --git a/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php index 2a32cc2..9f6fe04 100644 --- a/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php +++ b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php @@ -2,6 +2,9 @@ namespace StellarWP\Foundation\Tests\Unit\Cli\Generation; +use phpmock\mockery\PHPMockery; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use RuntimeException; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; @@ -36,7 +39,7 @@ public function test_it_refuses_to_overwrite_existing_files_without_force(): voi file_put_contents($path, 'existing'); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('File already exists: Generated.php. Use --force to overwrite it.'); + $this->expectExceptionMessage('File already exists: Generated.php.'); (new GeneratedFileWriter())->write(new GeneratedFile( path: $path, @@ -59,6 +62,65 @@ public function test_it_overwrites_existing_files_when_forced(): void { $this->assertSame('replacement', (string) file_get_contents($path)); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_it_fails_when_a_file_cannot_be_created_exclusively(): void { + $path = $this->tempDir . '/Generated.php'; + + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fopen') + ->with($path, 'x') + ->once() + ->andReturn(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not write generated file "Generated.php".'); + + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'content' + )); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_it_removes_partially_written_generated_files(): void { + $path = $this->tempDir . '/Generated.php'; + $handle = fopen('php://temp', 'w+'); + + $this->assertIsResource($handle); + + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fopen') + ->with($path, 'x') + ->once() + ->andReturn($handle); + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fwrite') + ->with($handle, 'content') + ->once() + ->andReturn(3); + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fclose') + ->with($handle) + ->once() + ->andReturn(true); + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'unlink') + ->with($path) + ->once() + ->andReturn(true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not write generated file "Generated.php".'); + + try { + (new GeneratedFileWriter())->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'content' + )); + } finally { + \fclose($handle); + } + } + public function test_it_fails_when_the_target_directory_cannot_be_created(): void { $path = $this->tempDir . '/blocked'; From 95da4776a37b06eee2974c852bf9ae7af72c7a46 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Thu, 20 Aug 2026 17:42:40 -0600 Subject: [PATCH 41/81] Fix: where(..., null) in src/Database/Query/QueryBuilder.php:57: = uses IS NULL; !=/<> use IS NOT NULL; --- src/Database/Query/QueryBuilder.php | 23 ++++++++++++++++++- src/Database/README.md | 7 ++++++ .../Unit/Database/Query/QueryBuilderTest.php | 22 ++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php index 138cf50..232ba67 100644 --- a/src/Database/Query/QueryBuilder.php +++ b/src/Database/Query/QueryBuilder.php @@ -49,8 +49,29 @@ public function select(string ...$columns): self { return $this; } + /** + * Compare a column to a value. NULL values use IS NULL or IS NOT NULL semantics. + * + * @throws InvalidArgumentException When the operator is unsupported or cannot compare against NULL. + */ public function where(string $column, string $operator, mixed $value): self { - $this->where[] = sprintf('%s %s %%s', $this->database->quoteIdentifier($column), $this->operator($operator)); + $operator = $this->operator($operator); + + if ($value === null) { + if (! in_array($operator, ['=', '!=', '<>'], true)) { + throw new InvalidArgumentException('NULL comparisons only support =, !=, and <> operators.'); + } + + $this->where[] = sprintf( + '%s IS%s NULL', + $this->database->quoteIdentifier($column), + $operator === '=' ? '' : ' NOT' + ); + + return $this; + } + + $this->where[] = sprintf('%s %s %%s', $this->database->quoteIdentifier($column), $operator); $this->bindings[] = $value; return $this; diff --git a/src/Database/README.md b/src/Database/README.md index ff53f54..80ab7e6 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -256,6 +256,13 @@ $query->bindings(); $query->toPreparedSql(); ``` +NULL comparisons use SQL NULL semantics and do not add query bindings: + +```php +$database->table('reports')->where('deleted_at', '=', null); // IS NULL +$database->table('reports')->where('deleted_at', '!=', null); // IS NOT NULL +``` + `Database::insert()` returns the number of affected rows, which works for both auto-increment and application-assigned identifiers such as ULIDs. Use `Database::insertGetId()` only when the table has an auto-increment key and the diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php index 338ed4b..539e506 100644 --- a/tests/Unit/Database/Query/QueryBuilderTest.php +++ b/tests/Unit/Database/Query/QueryBuilderTest.php @@ -37,6 +37,28 @@ public function test_it_rejects_unsupported_operators(): void { (new FakeDatabase())->table('reports')->where('status', 'BETWEEN', ['a', 'z']); } + public function test_it_builds_null_comparisons_without_bindings(): void { + $query = (new FakeDatabase()) + ->table('reports') + ->where('deleted_at', '=', null) + ->where('archived_at', '!=', null) + ->where('expired_at', '<>', null) + ->where('status', '=', 'published'); + + $this->assertSame( + 'SELECT * FROM `wp_reports` WHERE `deleted_at` IS NULL AND `archived_at` IS NOT NULL AND `expired_at` IS NOT NULL AND `status` = %s', + $query->toSql() + ); + $this->assertSame(['published'], $query->bindings()); + } + + public function test_it_rejects_invalid_null_comparisons(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('NULL comparisons only support =, !=, and <> operators.'); + + (new FakeDatabase())->table('reports')->where('updated_at', '>', null); + } + public function test_it_rejects_invalid_order_directions(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Order direction must be ASC or DESC.'); From d1fe269430992d52113080b76581e1a8fa980bf8 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 09:25:28 -0600 Subject: [PATCH 42/81] Normalize zero-precision datetime definitions --- src/Database/Table/TableDefinition.php | 2 +- tests/Unit/Database/Table/TableDefinitionTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Table/TableDefinition.php b/src/Database/Table/TableDefinition.php index 7e3191b..a3cecbf 100644 --- a/src/Database/Table/TableDefinition.php +++ b/src/Database/Table/TableDefinition.php @@ -67,7 +67,7 @@ public function dateTime(string $name, ?int $precision = null): self { throw new InvalidArgumentException('Datetime precision must be between 0 and 6.'); } - return $this->column(new Column($name, 'datetime', $precision)); + return $this->column(new Column($name, 'datetime', $precision === 0 ? null : $precision)); } public function text(string $name): self { diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php index 03bf744..bb6585e 100644 --- a/tests/Unit/Database/Table/TableDefinitionTest.php +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -75,7 +75,7 @@ public function test_it_defines_datetime_precision_boundaries(): void { ->dateTime('microseconds', 6); $this->assertSame([ - '`seconds` datetime(0) NOT NULL', + '`seconds` datetime NOT NULL', '`microseconds` datetime(6) NOT NULL', ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); } From a34afed360c01b192531865f6eecfcfef83f052c Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 09:28:54 -0600 Subject: [PATCH 43/81] Fail when migration ledger writes cannot be confirmed --- src/Database/Contracts/Repository.php | 2 ++ .../Migration/Exceptions/LedgerFailure.php | 15 +++++++++++++++ src/Database/Migration/Repository.php | 4 +++- tests/Unit/Database/Migration/RepositoryTest.php | 12 ++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/Database/Migration/Exceptions/LedgerFailure.php diff --git a/src/Database/Contracts/Repository.php b/src/Database/Contracts/Repository.php index c4aea55..312a7d8 100644 --- a/src/Database/Contracts/Repository.php +++ b/src/Database/Contracts/Repository.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Database\Contracts; use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; +use StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure; use StellarWP\Foundation\Database\Migration\ValueObjects\Record; /** @@ -24,6 +25,7 @@ public function hasRun(string $migration): bool; /** * @throws InvalidMigrationId When the migration identifier is invalid. + * @throws LedgerFailure When the inserted ledger record cannot be read back. */ public function recordRun(string $migration, int $batch): Record; diff --git a/src/Database/Migration/Exceptions/LedgerFailure.php b/src/Database/Migration/Exceptions/LedgerFailure.php new file mode 100644 index 0000000..48a01d6 --- /dev/null +++ b/src/Database/Migration/Exceptions/LedgerFailure.php @@ -0,0 +1,15 @@ +value; @@ -76,7 +78,7 @@ public function recordRun(string $migration, int $batch): Record { ); if ($row === null) { - return new Record(0, $migration, $batch, $ranAt); + throw LedgerFailure::missingAfterInsert($migration); } return $this->recordFromRow($row); diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php index a816dac..bd8fa48 100644 --- a/tests/Unit/Database/Migration/RepositoryTest.php +++ b/tests/Unit/Database/Migration/RepositoryTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; use StellarWP\Foundation\Database\Migration\Exceptions\InvalidMigrationId; +use StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure; use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\TestCase; @@ -50,6 +51,17 @@ public function test_it_records_a_migration_run(): void { $this->assertStringContainsString('INSERT INTO `network_foundation_migrations`', $this->database->executed[0]); } + public function test_it_fails_when_an_inserted_migration_cannot_be_read_back(): void { + $this->expectException(LedgerFailure::class); + $this->expectExceptionMessage('was inserted but could not be read from the ledger'); + + try { + $this->repository->recordRun('2026_01_01_000001_create_users', 2); + } finally { + $this->assertStringContainsString('INSERT INTO `network_foundation_migrations`', $this->database->executed[0]); + } + } + public function test_it_rejects_invalid_migration_ids_before_writing_to_the_ledger(): void { $this->expectException(InvalidMigrationId::class); From ac985341658f4b86b151d03478de92a61493218d Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 09:33:19 -0600 Subject: [PATCH 44/81] Fail when rolled-back ledger records remain --- src/Database/Contracts/Repository.php | 2 ++ .../Migration/Exceptions/LedgerFailure.php | 4 +++ src/Database/Migration/Migrator.php | 8 ++++- .../Migration/MigratorExecutionTest.php | 32 +++++++++++++++++-- .../Database/Migration/RepositoryTest.php | 6 ++++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/Database/Contracts/Repository.php b/src/Database/Contracts/Repository.php index 312a7d8..7f67779 100644 --- a/src/Database/Contracts/Repository.php +++ b/src/Database/Contracts/Repository.php @@ -30,6 +30,8 @@ public function hasRun(string $migration): bool; public function recordRun(string $migration, int $batch): Record; /** + * Return false when no matching ledger row was deleted. + * * @throws InvalidMigrationId When the migration identifier is invalid. */ public function deleteRun(string $migration): bool; diff --git a/src/Database/Migration/Exceptions/LedgerFailure.php b/src/Database/Migration/Exceptions/LedgerFailure.php index 48a01d6..d97ed15 100644 --- a/src/Database/Migration/Exceptions/LedgerFailure.php +++ b/src/Database/Migration/Exceptions/LedgerFailure.php @@ -12,4 +12,8 @@ final class LedgerFailure extends DatabaseException public static function missingAfterInsert(string $migration): self { return new self(sprintf('Migration "%s" was inserted but could not be read from the ledger.', $migration)); } + + public static function notDeletedAfterRollback(string $migration): self { + return new self(sprintf('Migration "%s" was rolled back but its ledger record was not deleted.', $migration)); + } } diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index 7e51ebc..e05ebbc 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -9,6 +9,7 @@ use StellarWP\Foundation\Database\Exceptions\MigrationFailed; use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Exceptions\InvalidRollbackBatch; +use StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure; use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; use StellarWP\Foundation\Database\Migration\Exceptions\UninitializedStore; use StellarWP\Foundation\Database\Migration\ValueObjects\Record; @@ -85,6 +86,7 @@ public function run(): Result { * * @throws DatabaseException When migration storage or schema access fails. * @throws InvalidRollbackBatch When the requested batch does not match the latest recorded batch. + * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. * @throws MigrationFailed When a migration fails while rolling back. * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. * @throws LockUnavailableException When the lock backend cannot determine the lock state. @@ -182,6 +184,7 @@ public function status(): array { * @param list $records * @param Schema $schema The initialized schema supplied by the migration store. * + * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ private function rollbackRecords(array $migrations, array $records, Schema $schema): Result { @@ -206,7 +209,10 @@ private function rollbackRecords(array $migrations, array $records, Schema $sche throw MigrationFailed::whileRollingBack($migration->id(), $throwable); } - $this->repository->deleteRun($migration->id()); + if (! $this->repository->deleteRun($migration->id())) { + throw LedgerFailure::notDeletedAfterRollback($migration->id()); + } + $rolledBack[] = $migration->id(); } diff --git a/tests/Unit/Database/Migration/MigratorExecutionTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php index 2f2bd6e..620fff1 100644 --- a/tests/Unit/Database/Migration/MigratorExecutionTest.php +++ b/tests/Unit/Database/Migration/MigratorExecutionTest.php @@ -2,8 +2,10 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Migration; +use DateTimeImmutable; use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Migration; +use StellarWP\Foundation\Database\Contracts\Repository; use StellarWP\Foundation\Database\Contracts\Schema; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Exceptions\DatabaseException; @@ -11,9 +13,11 @@ use StellarWP\Foundation\Database\Exceptions\MigrationLockFailed; use StellarWP\Foundation\Database\Migration\Collection; use StellarWP\Foundation\Database\Migration\Exceptions\InvalidRollbackBatch; +use StellarWP\Foundation\Database\Migration\Exceptions\LedgerFailure; use StellarWP\Foundation\Database\Migration\Exceptions\UnavailableMigration; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\Database\Migration\Store; +use StellarWP\Foundation\Database\Migration\ValueObjects\Record; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; @@ -40,7 +44,7 @@ protected function setUp(): void { $this->repository = new InMemoryRepository(); $this->schema = new RecordingSchema(); - $this->lock = new InMemoryLock(new MutableClock(new \DateTimeImmutable('2026-01-01 00:00:00'))); + $this->lock = new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00'))); (new Store( $this->schema, @@ -512,6 +516,28 @@ public function test_it_does_not_delete_a_record_when_rollback_fails(): void { } } + public function test_it_fails_when_a_rolled_back_ledger_record_is_not_deleted(): void { + $migration = new TestMigration('2026_01_01_000001_create_users'); + $repository = $this->createMock(Repository::class); + $repository->method('latestBatch')->willReturn(1); + $repository->method('recordsForBatch')->willReturn([ + new Record(1, $migration->id(), 1, new DateTimeImmutable('2026-01-01 00:00:00')), + ]); + $repository->expects($this->once()) + ->method('deleteRun') + ->with($migration->id()) + ->willReturn(false); + + $this->expectException(LedgerFailure::class); + $this->expectExceptionMessage('was rolled back but its ledger record was not deleted'); + + try { + $this->migrator($this->collection($migration), repository: $repository)->rollback(); + } finally { + $this->assertSame(['down:' . $migration->id()], $this->schema->statements); + } + } + private function lockToken(): LockToken { $token = $this->lock->acquire('nx-foundation-database-migrations', 300); @@ -528,11 +554,13 @@ private function migrator( Collection $migrations, ?Lock $lock = null, ?Schema $schema = null, + ?Repository $repository = null, string $lockName = 'nx-foundation-database-migrations', int $lockTtl = 300 ): Migrator { $schema ??= $this->schema; $lock ??= $this->lock; + $repository ??= $this->repository; $store = new Store( $schema, $lock, @@ -544,7 +572,7 @@ private function migrator( return new Migrator( $migrations, - $this->repository, + $repository, $store ); } diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php index bd8fa48..6e0dd83 100644 --- a/tests/Unit/Database/Migration/RepositoryTest.php +++ b/tests/Unit/Database/Migration/RepositoryTest.php @@ -88,6 +88,12 @@ public function test_it_deletes_a_migration_run(): void { $this->assertStringContainsString('DELETE FROM `network_foundation_migrations`', $this->database->executed[0]); } + public function test_it_reports_when_no_migration_run_was_deleted(): void { + $this->database->executeResults[] = 0; + + $this->assertFalse($this->repository->deleteRun('2026_01_01_000001_create_users')); + } + public function test_it_calculates_the_next_batch(): void { $this->database->rowResults[] = ['batch' => 4]; From 5cd505e7c87e93868ad995f196b1b10108eb7553 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 09:36:39 -0600 Subject: [PATCH 45/81] Clarify lock token expiration updates --- src/Database/Lock/DatabaseLock.php | 2 +- src/Lock/InMemoryLock.php | 2 +- src/Lock/LockToken.php | 4 ++-- src/LockRedis/RedisLock.php | 2 +- tests/Unit/Lock/LockTokenTest.php | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php index 9600e54..6ce641f 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/Database/Lock/DatabaseLock.php @@ -128,7 +128,7 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { return null; } - return $token->refresh($this->expiration($row)); + return $token->withExpiration($this->expiration($row)); } /** diff --git a/src/Lock/InMemoryLock.php b/src/Lock/InMemoryLock.php index 6fc86cf..019ff84 100644 --- a/src/Lock/InMemoryLock.php +++ b/src/Lock/InMemoryLock.php @@ -78,7 +78,7 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { return null; } - $refreshed = $token->refresh($this->expiresAt($ttl)); + $refreshed = $token->withExpiration($this->expiresAt($ttl)); $this->locks[$token->name] = $refreshed; diff --git a/src/Lock/LockToken.php b/src/Lock/LockToken.php index 5a216f9..c4c9341 100644 --- a/src/Lock/LockToken.php +++ b/src/Lock/LockToken.php @@ -49,9 +49,9 @@ public function matches(self $token): bool { } /** - * Return a new token for the same owner with a later expiration time. + * Return a new token for the same owner with the provided expiration time. */ - public function refresh(DateTimeImmutable $expiresAt): self { + public function withExpiration(DateTimeImmutable $expiresAt): self { return new self( name: $this->name, owner: $this->owner, diff --git a/src/LockRedis/RedisLock.php b/src/LockRedis/RedisLock.php index 78cdf55..21a6fc7 100644 --- a/src/LockRedis/RedisLock.php +++ b/src/LockRedis/RedisLock.php @@ -116,7 +116,7 @@ public function refresh(LockToken $token, int $ttl): ?LockToken { return match ($result) { 0 => null, - 1 => $token->refresh($expiresAt), + 1 => $token->withExpiration($expiresAt), default => throw new LockUnavailableException('Redis returned an unexpected refresh result.'), }; } diff --git a/tests/Unit/Lock/LockTokenTest.php b/tests/Unit/Lock/LockTokenTest.php index 2f0425f..bd96449 100644 --- a/tests/Unit/Lock/LockTokenTest.php +++ b/tests/Unit/Lock/LockTokenTest.php @@ -44,14 +44,14 @@ public function test_it_matches_tokens_for_the_same_lock_owner(): void { ))); } - public function test_it_refreshes_with_the_same_lock_name_and_owner(): void { + public function test_it_changes_expiration_with_the_same_lock_name_and_owner(): void { $token = new LockToken( name: 'queue:sync', owner: 'owner', expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') ); - $refreshed = $token->refresh(new DateTimeImmutable('2026-01-01 00:02:00')); + $refreshed = $token->withExpiration(new DateTimeImmutable('2026-01-01 00:02:00')); $this->assertSame($token->name, $refreshed->name); $this->assertSame($token->owner, $refreshed->owner); From aa255ea6b5a05948c54f79635d973a3569fc43f4 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 09:43:22 -0600 Subject: [PATCH 46/81] Remove unversioned table collection API --- src/Database/README.md | 2 +- src/Database/Table/Collection.php | 79 ------------------- tests/Unit/Database/Table/CollectionTest.php | 57 ------------- .../Database/DatabaseIntegrationTest.php | 5 +- 4 files changed, 3 insertions(+), 140 deletions(-) delete mode 100644 src/Database/Table/Collection.php delete mode 100644 tests/Unit/Database/Table/CollectionTest.php diff --git a/src/Database/README.md b/src/Database/README.md index 80ab7e6..bf8e549 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -11,7 +11,7 @@ composer require stellarwp/foundation-database ## Overview -Foundation Database is a WordPress-backed database package. It provides a configured migrator, migration and table collections, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. +Foundation Database is a WordPress-backed database package. It provides a configured migrator, migration collection, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. This package intentionally targets WordPress runtime APIs instead of acting as a generic database abstraction. Migration classes depend on a small schema contract so application packages can define migration behavior without calling `wpdb` directly. diff --git a/src/Database/Table/Collection.php b/src/Database/Table/Collection.php deleted file mode 100644 index 51d8eee..0000000 --- a/src/Database/Table/Collection.php +++ /dev/null @@ -1,79 +0,0 @@ - - */ -final class Collection implements IteratorAggregate -{ - /** - * @var list
- */ - private array $tables = []; - - /** - * @param iterable
$tables - */ - public function __construct( - private readonly Schema $schema, - iterable $tables = [] - ) { - foreach ($tables as $table) { - $this->add($table); - } - } - - public function add(Table ...$tables): void { - foreach ($tables as $table) { - $this->tables[] = $table; - } - } - - /** - * @return list
- */ - public function all(): array { - return $this->tables; - } - - /** - * Create missing tables and reconcile existing tables with their definitions. - */ - public function create(): void { - foreach ($this->tables as $table) { - $this->schema->createOrUpdate($table); - } - } - - public function drop(): void { - foreach ($this->tables as $table) { - $this->schema->drop($table); - } - } - - public function exists(): bool { - foreach ($this->tables as $table) { - if (! $this->schema->hasTable($table)) { - return false; - } - } - - return true; - } - - /** - * @return Traversable - */ - public function getIterator(): Traversable { - return new ArrayIterator($this->tables); - } -} diff --git a/tests/Unit/Database/Table/CollectionTest.php b/tests/Unit/Database/Table/CollectionTest.php deleted file mode 100644 index 2e9501f..0000000 --- a/tests/Unit/Database/Table/CollectionTest.php +++ /dev/null @@ -1,57 +0,0 @@ -tables['existing'] = true; - $collection = new Collection($schema, [$existing, $missing]); - - $collection->create(); - - $this->assertSame(['createOrUpdate:existing', 'createOrUpdate:missing'], $schema->statements); - $this->assertTrue($schema->hasTable($existing)); - $this->assertTrue($schema->hasTable($missing)); - } - - public function test_it_drops_all_tables(): void { - $first = new TestTable('first_table', 'first'); - $second = new TestTable('second_table', 'second'); - $schema = new RecordingSchema(); - - $collection = new Collection($schema, [$first]); - $collection->add($second); - $collection->drop(); - - $this->assertSame(['drop:first', 'drop:second'], $schema->statements); - $this->assertSame([$first, $second], $collection->all()); - $this->assertSame([$first, $second], iterator_to_array($collection)); - } - - public function test_it_checks_whether_all_tables_exist(): void { - $schema = new RecordingSchema(); - $first = new TestTable('first_table', 'first'); - $second = new TestTable('second_table', 'second'); - - $schema->tables = [ - 'first' => true, - 'second' => true, - ]; - - $this->assertTrue((new Collection($schema, [$first, $second]))->exists()); - - unset($schema->tables['second']); - - $this->assertFalse((new Collection($schema, [$first, $second]))->exists()); - } -} diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 99bfcd0..3deb353 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -20,7 +20,6 @@ use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Database\Schema\DbDelta; -use StellarWP\Foundation\Database\Table\Collection as TableCollection; use StellarWP\Foundation\Database\Table\TableDefinition; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; @@ -491,7 +490,7 @@ public function test_lock_table_reconciles_an_existing_previous_definition(): vo $this->database->charsetCollate() )); - (new TableCollection($wpSchema, [$lockTable]))->create(); + $wpSchema->createOrUpdate($lockTable); $name = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'name'); $owner = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'owner'); @@ -523,7 +522,7 @@ public function test_migration_table_reconciles_case_insensitive_identifiers(): $this->database->charsetCollate() )); - (new TableCollection($wpSchema, [$migrationTable]))->create(); + $wpSchema->createOrUpdate($migrationTable); $migration = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'migration'); From 745a22b59170ab48d8546670f8fbb2e19f3f5096 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 09:59:10 -0600 Subject: [PATCH 47/81] Harden generated PHP source updates --- .../Make/Database/MigrationCommand.php | 22 +- .../Commands/Make/Database/TableCommand.php | 13 +- src/Cli/Generation/GeneratedFileWriter.php | 22 +- src/Cli/Generation/Php/PhpSourceEditor.php | 370 +++++++++++++++--- src/Cli/README.md | 2 +- .../Cli/Commands/Make/DatabaseCommandTest.php | 200 +++++++--- .../Cli/Commands/Make/WPCliCommandTest.php | 12 +- .../Generation/GeneratedFileWriterTest.php | 105 ++++- .../Cli/Generation/PhpSourceEditorTest.php | 80 ++++ .../class-import-collision.stub | 9 + .../duplicate-class.stub | 11 + .../duplicate-import.stub | 10 + .../import-shadowed-registrations.stub | 18 + .../imported-prefix-registrations.stub | 17 + .../multiple-migration-contributions.stub | 17 + .../namespace-relative-registrations.stub | 18 + 16 files changed, 775 insertions(+), 151 deletions(-) create mode 100644 tests/_data/cli/generation/generated-file-writer/class-import-collision.stub create mode 100644 tests/_data/cli/generation/generated-file-writer/duplicate-class.stub create mode 100644 tests/_data/cli/generation/generated-file-writer/duplicate-import.stub create mode 100644 tests/_data/cli/generation/php-source-editor/import-shadowed-registrations.stub create mode 100644 tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub create mode 100644 tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub create mode 100644 tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index 1377e3e..afb1eca 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -12,6 +12,7 @@ use StellarWP\Foundation\Cli\Generation\ValueObjects\Psr4Namespace; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; use StellarWP\Foundation\Database\DatabaseStubPath; +use StellarWP\Foundation\Database\Migration\ValueObjects\Id; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -21,8 +22,8 @@ /** * Generates a WordPress-style migration class for Foundation Database. * - * Use this from a consuming WordPress project when a feature needs a versioned, - * reversible schema change that can be registered with `DatabaseProvider`. + * Use this from a consuming WordPress project when a feature needs a versioned + * database change that can be registered with `DatabaseProvider`. */ final class MigrationCommand extends Command { @@ -46,7 +47,7 @@ protected function configure(): void { ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated migration class.') ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the migration class should be written.') ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') - ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier: nonblank, unpadded, non-integer-like, and at most 191 bytes.') ->addOption('table-class', null, InputOption::VALUE_REQUIRED, 'Table class or base name used by a table-backed migration.') ->addOption('table-namespace', null, InputOption::VALUE_REQUIRED, 'Namespace containing the table class.'); } @@ -95,7 +96,8 @@ private function generatedFile(InputInterface $input): GeneratedFile { $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); $path = $this->path($input, $namespace, $project); $relative = $this->relativePath($path . '/' . $className . '.php'); - $id = $this->optionOrDefault($input, 'id', $this->classNameResolver->migrationId($className)); + $idOption = $input->getOption('id'); + $id = (new Id(is_string($idOption) ? $idOption : $this->classNameResolver->migrationId($className)))->value; if ($this->isTableMigration($input, $className)) { $stub = $this->stubResolver->resolve('database', 'table-migration', DatabaseStubPath::tableMigration()); @@ -226,16 +228,6 @@ private function tableClass(InputInterface $input, string $migrationClass): stri return $this->classNameResolver->tableClass($name); } - private function optionOrDefault(InputInterface $input, string $option, string $default): string { - $value = $input->getOption($option); - - if (is_string($value) && trim($value) !== '') { - return trim($value); - } - - return $default; - } - private function phpString(string $value): string { return var_export($value, true); } @@ -308,7 +300,7 @@ private function providerUpdateFailure(string $status): string { ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', - ProviderRegistrationEditor::IMPORT_COLLISION => 'a different imported class uses the same short class name', + ProviderRegistrationEditor::IMPORT_COLLISION => 'another class declaration or import uses the same short class name', ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', default => 'provider could not be updated', diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php index 04c59a8..5b11960 100644 --- a/src/Cli/Commands/Make/Database/TableCommand.php +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -12,6 +12,7 @@ use StellarWP\Foundation\Cli\Generation\ValueObjects\Psr4Namespace; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; use StellarWP\Foundation\Database\DatabaseStubPath; +use StellarWP\Foundation\Database\Migration\ValueObjects\Id; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -46,16 +47,15 @@ protected function configure(): void { ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated table class.') ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the table class should be written.') ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') - ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable table identifier used by migrations.') - ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name.') - ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier: nonblank, unpadded, non-integer-like, and at most 191 bytes.') + ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name.'); } protected function execute(InputInterface $input, OutputInterface $output): int { try { $this->validateExplicitProviderUpdate($input); $file = $this->generatedFile($input); - $this->fileWriter->write($file, (bool) $input->getOption('force')); + $this->fileWriter->write($file); $providerPath = $this->updateProvider($input, $output); } catch (RuntimeException $exception) { $output->writeln('' . $exception->getMessage() . ''); @@ -89,7 +89,8 @@ private function generatedFile(InputInterface $input): GeneratedFile { $stub = $this->stubResolver->resolve('database', 'table', DatabaseStubPath::table()); $relative = $this->relativePath($path . '/' . $className . '.php'); $table = $this->optionOrDefault($input, 'table', $this->classNameResolver->tableName($className)); - $id = $this->optionOrDefault($input, 'id', $table . '_table'); + $idOption = $input->getOption('id'); + $id = (new Id(is_string($idOption) ? $idOption : $table . '_table'))->value; return new GeneratedFile( path: $path . '/' . $className . '.php', @@ -248,7 +249,7 @@ private function providerUpdateFailure(string $status): string { ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', - ProviderRegistrationEditor::IMPORT_COLLISION => 'a different imported class uses the same short class name', + ProviderRegistrationEditor::IMPORT_COLLISION => 'another class declaration or import uses the same short class name', ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', default => 'provider could not be updated', diff --git a/src/Cli/Generation/GeneratedFileWriter.php b/src/Cli/Generation/GeneratedFileWriter.php index 19280c3..2899433 100644 --- a/src/Cli/Generation/GeneratedFileWriter.php +++ b/src/Cli/Generation/GeneratedFileWriter.php @@ -3,14 +3,34 @@ namespace StellarWP\Foundation\Cli\Generation; use RuntimeException; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; /** * Writes generated files to disk with overwrite protection. */ -final class GeneratedFileWriter +final readonly class GeneratedFileWriter { + public function __construct( + private PhpSourceEditor $sourceEditor + ) { + } + public function write(GeneratedFile $file, bool $force = false): void { + if (! $this->sourceEditor->canParse($file->contents)) { + throw new RuntimeException(sprintf('Generated file "%s" is not valid PHP.', $file->relativePath)); + } + + $collision = $this->sourceEditor->classImportCollision($file->contents); + + if ($collision !== null) { + throw new RuntimeException(sprintf( + 'Generated file "%s" declares or imports "%s" more than once.', + $file->relativePath, + $collision + )); + } + $directory = dirname($file->path); if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { diff --git a/src/Cli/Generation/Php/PhpSourceEditor.php b/src/Cli/Generation/Php/PhpSourceEditor.php index c471a2f..2afbae3 100644 --- a/src/Cli/Generation/Php/PhpSourceEditor.php +++ b/src/Cli/Generation/Php/PhpSourceEditor.php @@ -8,6 +8,9 @@ use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PhpParser\Node\UseItem; +use PhpParser\NodeFinder; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\NameResolver; use PhpParser\ParserFactory; use StellarWP\Foundation\Cli\Generation\Php\ValueObjects\LineComment; use StellarWP\Foundation\Cli\Generation\Php\ValueObjects\LineInsertion; @@ -18,22 +21,34 @@ */ final readonly class PhpSourceEditor { + /** + * Create an editor with PHP-Parser services for syntax trees and source-position tokens. + */ public function __construct( private ParserFactory $parserFactory, private Lexer $lexer ) { } + /** + * Determine whether the supplied source is syntactically valid PHP. + */ public function canParse(string $contents): bool { return $this->parse($contents) !== null; } + /** + * Determine whether a class is already imported under its default short name. + * + * For example, `Acme\Reports\Table` matches `use Acme\Reports\Table;` but + * does not match an import of the same class aliased as another name. + */ public function hasImport(string $contents, string $fullyQualifiedClass): bool { $target = trim($fullyQualifiedClass, '\\'); $alias = basename(str_replace('\\', '/', $target)); foreach ($this->imports($contents) as $import) { - if ($import['class'] === $target && $import['alias'] === $alias) { + if (strcasecmp($import['class'], $target) === 0 && strcasecmp($import['alias'], $alias) === 0) { return true; } } @@ -41,11 +56,29 @@ public function hasImport(string $contents, string $fullyQualifiedClass): bool { return false; } + /** + * Determine whether a proposed short name conflicts with another import or class declaration. + * + * For example, generating `ReportsTable` conflicts with + * `use Other\Package\ReportsTable;` and `final class ReportsTable`. + */ public function hasImportShortNameCollision(string $contents, string $class, string $fullyQualifiedClass): bool { $target = trim($fullyQualifiedClass, '\\'); foreach ($this->imports($contents) as $import) { - if ($import['alias'] === $class && $import['class'] !== $target) { + if (strcasecmp($import['alias'], $class) === 0 && strcasecmp($import['class'], $target) !== 0) { + return true; + } + } + + $statements = $this->parse($contents); + + if ($statements === null) { + return false; + } + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\ClassLike && $statement->name !== null && strcasecmp($statement->name->toString(), $class) === 0) { return true; } } @@ -53,10 +86,64 @@ public function hasImportShortNameCollision(string $contents, string $class, str return false; } + /** + * Return the first class-like declaration or import alias that conflicts in PHP's case-insensitive symbol table. + * + * This detects invalid generated files such as importing `Migration` and + * declaring a class named `migration` in the same namespace. + */ + public function classImportCollision(string $contents): ?string { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $aliases = []; + + foreach ($this->imports($contents) as $import) { + $alias = strtolower($import['alias']); + + if (isset($aliases[$alias])) { + return $import['alias']; + } + + $aliases[$alias] = $import['class']; + } + + foreach ($this->topLevelStatements($statements) as $statement) { + if (! $statement instanceof Stmt\ClassLike || $statement->name === null) { + continue; + } + + $name = $statement->name->toString(); + $key = strtolower($name); + + if (isset($aliases[$key])) { + return $name; + } + + $aliases[$key] = $name; + } + + return null; + } + + /** + * Determine whether the source contains an exact standalone line comment. + * + * Marker text embedded in code or a longer comment is intentionally ignored. + */ public function hasLineComment(string $contents, string $comment): bool { return $this->lineComment($contents, $comment) !== null; } + /** + * Add a class import after existing imports or the namespace declaration. + * + * Existing imports are left unchanged. Null is returned when the source + * cannot be parsed or a safe insertion offset cannot be determined. + */ public function addImport(string $contents, string $fullyQualifiedClass): ?string { if ($this->hasImport($contents, $fullyQualifiedClass)) { return $contents; @@ -73,6 +160,12 @@ public function addImport(string $contents, string $fullyQualifiedClass): ?strin return substr($contents, 0, $offset) . $this->importSeparator($contents, $offset) . $import . substr($contents, $offset); } + /** + * Insert a statement immediately before an exact standalone marker comment. + * + * The marker's indentation is reused so generated provider registrations + * follow the surrounding source formatting. + */ public function insertBeforeLineComment(string $contents, string $comment, string $statement): ?string { $lineComment = $this->lineComment($contents, $comment); @@ -85,6 +178,13 @@ public function insertBeforeLineComment(string $contents, string $comment, strin . substr($contents, $lineComment->lineStartOffset); } + /** + * Determine whether a matching `mergeArrayVar()` registration list can be edited safely. + * + * For example, this locates the array returned from + * `mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c) => [...])`. + * Inline arrays are rejected because inserting a formatted line would be unsafe. + */ public function canInsertIntoMergeArrayVar(string $contents, string $class, string $constant, ?string $beforeComment = null): bool { $target = $this->mergeArrayVarTarget($contents, $class, $constant); @@ -98,43 +198,59 @@ public function canInsertIntoMergeArrayVar(string $contents, string $class, stri return $insertion !== null; } + /** + * Return the container expression used by the first editable `mergeArrayVar()` callback. + * + * This returns expressions such as `$c` for callback registrations or + * `$this->container` when a direct array is supplied. + */ public function mergeArrayVarContainerExpression(string $contents, string $class, string $constant): ?string { return $this->mergeArrayVarTarget($contents, $class, $constant)?->containerExpression; } + /** + * Determine whether the provider registers a class with `$this->container->singleton()`. + * + * PHP names are resolved before comparison, so imported aliases and + * namespace-relative class references are recognized correctly. + */ public function hasContainerSingleton(string $contents, string $fullyQualifiedClass): bool { - $statements = $this->parse($contents); + $statements = $this->resolvedStatements($contents); if ($statements === null) { return false; } - $aliases = $this->classAliases($contents, $fullyQualifiedClass); - return $this->findNode( $statements, - fn (Node $node): bool => $this->isContainerSingleton($node, $fullyQualifiedClass, $aliases) + fn (Node $node): bool => $this->isContainerSingleton($node, $fullyQualifiedClass) ) !== null; } + /** + * Determine whether any matching `mergeArrayVar()` contribution resolves a class from its container. + * + * For example, this recognizes `$c->get(CreateReportsTable::class)` inside + * every contribution to `DatabaseProvider::MIGRATIONS`. + */ public function mergeArrayVarContainsClass(string $contents, string $class, string $constant, string $fullyQualifiedClass): bool { - $target = $this->mergeArrayVarTarget($contents, $class, $constant); - - if ($target === null) { - return false; - } - - $aliases = $this->classAliases($contents, $fullyQualifiedClass); - - foreach ($target->registrationList->items as $item) { - if ($this->isContainerGet($item->value, $target->containerExpression, $fullyQualifiedClass, $aliases)) { - return true; + foreach ($this->mergeArrayVarTargets($contents, $class, $constant) as $target) { + foreach ($target->registrationList->items as $item) { + if ($this->isContainerGet($item->value, $target->containerExpression, $fullyQualifiedClass)) { + return true; + } } } return false; } + /** + * Insert a statement into the first safely editable matching `mergeArrayVar()` array. + * + * A marker inside the registration array is preferred when supplied; + * otherwise the statement is inserted immediately before the closing bracket. + */ public function insertIntoMergeArrayVar(string $contents, string $class, string $constant, string $statement, ?string $beforeComment = null): ?string { $target = $this->mergeArrayVarTarget($contents, $class, $constant); @@ -155,6 +271,11 @@ public function insertIntoMergeArrayVar(string $contents, string $class, string } /** + * Collect top-level class imports and their effective aliases. + * + * Function and constant imports are excluded because they do not occupy the + * class symbol table used by generated declarations. + * * @return list */ private function imports(string $contents): array { @@ -180,6 +301,8 @@ private function imports(string $contents): array { } /** + * Normalize a standard `use` statement into class and alias pairs. + * * @param array $uses * * @return list @@ -202,6 +325,10 @@ private function useImports(array $uses, int $type): array { } /** + * Normalize a grouped `use` statement into complete class and alias pairs. + * + * For example, `use Acme\{One, Two as Alias};` becomes two complete imports. + * * @return list */ private function groupUseImports(Stmt\GroupUse $groupUse): array { @@ -224,6 +351,12 @@ private function groupUseImports(Stmt\GroupUse $groupUse): array { return $imports; } + /** + * Find where a new import belongs without disturbing namespace or declaration syntax. + * + * Imports are placed after the last existing import, then after the namespace, + * then after a strict-types declaration, or finally after the PHP opening tag. + */ private function importInsertionOffset(string $contents): ?int { $statements = $this->parse($contents); @@ -258,6 +391,9 @@ private function importInsertionOffset(string $contents): ?int { return $this->openingTagEndOffset($contents); } + /** + * Find the byte offset immediately after a semicolon-style or braced namespace declaration. + */ private function namespaceDeclarationEndOffset(string $contents, Stmt\Namespace_ $namespace): ?int { foreach ($this->lexer->tokenize($contents) as $token) { if ($token->pos < $namespace->getStartFilePos()) { @@ -272,6 +408,9 @@ private function namespaceDeclarationEndOffset(string $contents, Stmt\Namespace_ return null; } + /** + * Find the byte offset immediately after the PHP opening tag, or zero when none exists. + */ private function openingTagEndOffset(string $contents): int { foreach ($this->lexer->tokenize($contents) as $token) { if ($token->id === T_OPEN_TAG) { @@ -282,6 +421,9 @@ private function openingTagEndOffset(string $contents): int { return 0; } + /** + * Choose spacing for a new import based on whether an import block already exists. + */ private function importSeparator(string $contents, int $offset): string { $before = substr($contents, 0, $offset); @@ -292,6 +434,12 @@ private function importSeparator(string $contents, int $offset): string { return "\n\n"; } + /** + * Find an exact standalone line comment and preserve its source indentation. + * + * When `$within` is provided, only comments inside that syntax node are + * considered, preventing an unrelated marker elsewhere in the provider from matching. + */ private function lineComment(string $contents, string $comment, ?Node $within = null): ?LineComment { foreach ($this->lexer->tokenize($contents) as $token) { if (! $token->is(T_COMMENT) || trim($token->text) !== $comment) { @@ -319,43 +467,62 @@ private function lineComment(string $contents, string $comment, ?Node $within = return null; } + /** + * Return the first matching `mergeArrayVar()` target whose array has a safe line insertion point. + */ private function mergeArrayVarTarget(string $contents, string $class, string $constant): ?MergeArrayVarTarget { - $statements = $this->parse($contents); - - if ($statements === null) { - return null; - } - - $aliases = $this->classAliases($contents, $class, true); - $call = $this->findNode($statements, fn (Node $node): bool => $this->isMergeArrayVarCall($node, $class, $constant, $aliases)); - - if (! $call instanceof Expr\MethodCall) { - return null; + foreach ($this->mergeArrayVarTargets($contents, $class, $constant) as $target) { + if ($this->arrayInsertion($contents, $target->registrationList) !== null) { + return $target; + } } - return $this->mergeArrayVarTargetFromCall($call); + return null; } /** - * @return list + * Collect every recognized `mergeArrayVar()` contribution for a class constant. + * + * For example, separate feature providers may each contribute to + * `DatabaseProvider::MIGRATIONS`; all contributions must be searched for duplicates. + * + * @return list */ - private function classAliases(string $contents, string $class, bool $allowPrefixed = false): array { - $class = trim($class, '\\'); - $aliases = []; + private function mergeArrayVarTargets(string $contents, string $class, string $constant): array { + $statements = $this->resolvedStatements($contents); - foreach ($this->imports($contents) as $import) { - if ($import['class'] === $class || ($allowPrefixed && str_ends_with($import['class'], '\\' . $class))) { - $aliases[] = $import['alias']; + if ($statements === null) { + return []; + } + + $targets = []; + $calls = (new NodeFinder())->find( + $statements, + fn (Node $node): bool => $this->isMergeArrayVarCall($node, $class, $constant) + ); + + foreach ($calls as $call) { + if (! $call instanceof Expr\MethodCall) { + continue; + } + + $target = $this->mergeArrayVarTargetFromCall($call); + + if ($target !== null) { + $targets[] = $target; } } - return $aliases; + return $targets; } /** - * @param list $aliases + * Determine whether a syntax node calls `$this->container->mergeArrayVar()` for the requested constant. + * + * Strauss-prefixed references are accepted when their resolved class ends + * with the requested Foundation class namespace. */ - private function isMergeArrayVarCall(Node $node, string $class, string $constant, array $aliases): bool { + private function isMergeArrayVarCall(Node $node, string $class, string $constant): bool { if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'mergeArrayVar') { return false; } @@ -374,21 +541,13 @@ private function isMergeArrayVarCall(Node $node, string $class, string $constant return false; } - $referencedClass = $firstArgument->class->toString(); - - if (str_contains($referencedClass, '\\')) { - $referencedClass = trim($referencedClass, '\\'); - - return $referencedClass === $class || str_ends_with($referencedClass, '\\' . $class); - } - - return in_array($referencedClass, $aliases, true); + return $this->isClassReference($firstArgument->class, $class, true); } /** - * @param list $aliases + * Determine whether a syntax node is a singleton registration for the requested class. */ - private function isContainerSingleton(Node $node, string $class, array $aliases): bool { + private function isContainerSingleton(Node $node, string $class): bool { if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'singleton') { return false; } @@ -403,13 +562,16 @@ private function isContainerSingleton(Node $node, string $class, array $aliases) && $argument->class instanceof Node\Name && $argument->name instanceof Node\Identifier && $argument->name->toString() === 'class' - && $this->isClassReference($argument->class, $class, $aliases); + && $this->isClassReference($argument->class, $class); } /** - * @param list $aliases + * Determine whether an array item resolves the requested class from the callback container. + * + * The receiver must match the callback parameter, such as `$c` in + * `static fn (C $c): array => [$c->get(Service::class)]`. */ - private function isContainerGet(Node $node, string $containerExpression, string $class, array $aliases): bool { + private function isContainerGet(Node $node, string $containerExpression, string $class): bool { if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'get') { return false; } @@ -424,9 +586,12 @@ private function isContainerGet(Node $node, string $containerExpression, string && $argument->class instanceof Node\Name && $argument->name instanceof Node\Identifier && $argument->name->toString() === 'class' - && $this->isClassReference($argument->class, $class, $aliases); + && $this->isClassReference($argument->class, $class); } + /** + * Determine whether a node represents the expected callback variable or provider container property. + */ private function matchesContainerExpression(Node $node, string $containerExpression): bool { if ($containerExpression === '$this->container') { return $this->isThisContainer($node); @@ -438,23 +603,37 @@ private function matchesContainerExpression(Node $node, string $containerExpress } /** - * @param list $aliases + * Compare a PHP name using the fully resolved name attached by PHP-Parser. */ - private function isClassReference(Node\Name $name, string $class, array $aliases): bool { - $reference = trim($name->toString(), '\\'); - $class = trim($class, '\\'); + private function isClassReference(Node\Name $name, string $class, bool $allowPrefixed = false): bool { + $resolved = $name->getAttribute('resolvedName') ?? $name->getAttribute('namespacedName'); - if ($name instanceof Node\Name\FullyQualified) { - return $reference === $class; - } + return $resolved instanceof Node\Name + && $this->classMatches($resolved->toString(), $class, $allowPrefixed); + } + + /** + * Compare class names case-insensitively, optionally allowing a namespace prefix. + * + * Prefix matching supports rewritten references such as + * `Acme\Prefixed\StellarWP\Foundation\Database\DatabaseProvider`. + */ + private function classMatches(string $reference, string $class, bool $allowPrefixed = false): bool { + $reference = trim($reference, '\\'); + $class = trim($class, '\\'); - if (str_contains($reference, '\\')) { - return $reference === $class; + if (strcasecmp($reference, $class) === 0) { + return true; } - return in_array($reference, $aliases, true); + return $allowPrefixed + && strlen($reference) > strlen($class) + && strcasecmp(substr($reference, -strlen($class) - 1), '\\' . $class) === 0; } + /** + * Determine whether a node is exactly the provider's `$this->container` property. + */ private function isThisContainer(Node $node): bool { return $node instanceof Expr\PropertyFetch && $node->var instanceof Expr\Variable @@ -463,6 +642,12 @@ private function isThisContainer(Node $node): bool { && $node->name->toString() === 'container'; } + /** + * Extract the editable registration array and its container expression from a merge call. + * + * Supported values are a direct array, an arrow function returning an array, + * or a closure with an explicit array return statement. + */ private function mergeArrayVarTargetFromCall(Expr\MethodCall $call): ?MergeArrayVarTarget { $callback = $call->args[1]->value ?? null; @@ -508,6 +693,9 @@ private function mergeArrayVarTargetFromCall(Expr\MethodCall $call): ?MergeArray return null; } + /** + * Return the first callback parameter as a source expression such as `$c`. + */ private function callbackContainerExpression(Expr\Closure|Expr\ArrowFunction $callback): ?string { $parameter = $callback->params[0] ?? null; @@ -518,6 +706,12 @@ private function callbackContainerExpression(Expr\Closure|Expr\ArrowFunction $ca return '$' . $parameter->var->name; } + /** + * Calculate a formatting-preserving insertion before a multiline array's closing bracket. + * + * Single-line arrays return null because inserting a line without reprinting the + * complete syntax tree could corrupt formatting or comments. + */ private function arrayInsertion(string $contents, Expr\Array_ $array): ?LineInsertion { $indent = null; @@ -545,6 +739,9 @@ private function arrayInsertion(string $contents, Expr\Array_ $array): ?LineInse ); } + /** + * Add one indentation level while preserving the surrounding tab or space style. + */ private function childIndent(string $indent): string { if ($indent === '' || str_contains($indent, "\t")) { return $indent . "\t"; @@ -554,6 +751,8 @@ private function childIndent(string $indent): string { } /** + * Search statements depth-first and return the first node accepted by a predicate. + * * @param array $statements * @param callable(Node): bool $predicate */ @@ -570,6 +769,8 @@ private function findNode(array $statements, callable $predicate): ?Node { } /** + * Search one syntax node and its descendants depth-first. + * * @param callable(Node): bool $predicate */ private function findMatchingNode(Node $node, callable $predicate): ?Node { @@ -606,6 +807,9 @@ private function findMatchingNode(Node $node, callable $predicate): ?Node { return null; } + /** + * Return the byte offset of the first character on the line containing an offset. + */ private function lineStartOffset(string $contents, int $offset): int { $previousNewline = strrpos(substr($contents, 0, $offset), "\n"); @@ -616,6 +820,9 @@ private function lineStartOffset(string $contents, int $offset): int { return $previousNewline + 1; } + /** + * Return the byte offset of the newline ending a line, or the end of the source. + */ private function lineEndOffset(string $contents, int $offset): int { $nextNewline = strpos($contents, "\n", $offset); @@ -627,6 +834,11 @@ private function lineEndOffset(string $contents, int $offset): int { } /** + * Return statements inside the first namespace, or the original root statements. + * + * Generator targets use one namespace per file, so imports and declarations are + * inspected only within that file-level namespace. + * * @param array $statements * * @return array @@ -642,6 +854,8 @@ private function topLevelStatements(array $statements): array { } /** + * Parse source while converting PHP-Parser syntax failures to null. + * * @return array|null */ private function parse(string $contents): ?array { @@ -651,4 +865,32 @@ private function parse(string $contents): ?array { return null; } } + + /** + * Parse source and attach PHP-resolved names without replacing original nodes. + * + * Keeping original nodes preserves source offsets for edits while resolvedName and + * namespacedName attributes provide PHP-accurate comparisons for aliases and prefixes. + * + * @return array|null + */ + private function resolvedStatements(string $contents): ?array { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); + + try { + return array_values(array_filter( + $traverser->traverse($statements), + static fn (Node $node): bool => $node instanceof Stmt + )); + } catch (Error) { + return null; + } + } } diff --git a/src/Cli/README.md b/src/Cli/README.md index 0bd4aec..f37d5c3 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -43,7 +43,7 @@ vendor/bin/foundation make:database-table Reports_Table vendor/bin/foundation make:database-migration Create_Reports_Table ``` -Database migrations are never overwritten by the generator. Edit a migration only before it has been applied anywhere; otherwise generate a new migration for the next schema change. +Database tables and migrations are never overwritten by the generators. Edit a migration only before it has been applied anywhere; otherwise generate a new migration for the next schema change. Generated database providers, tables, and migrations require `stellarwp/foundation-database` as a normal runtime dependency when they ship with the project: diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index c827ffe..04adf26 100644 --- a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -4,6 +4,7 @@ use PhpParser\Lexer; use PhpParser\ParserFactory; +use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderRegistrationEditor; @@ -189,6 +190,23 @@ public function test_database_migrations_cannot_overwrite_existing_files(): void $this->assertSame($providerContents, (string) file_get_contents($providerPath)); } + public function test_database_tables_cannot_overwrite_existing_files(): void { + $root = $this->temporaryProject(); + $command = $this->tableCommand($root); + $path = $root . '/src/Database/Tables/Reports_Table.php'; + + mkdir(dirname($path), 0777, true); + file_put_contents($path, 'existing table'); + + $tester = new CommandTester($command); + $status = $tester->execute(['name' => 'reports']); + + $this->assertFalse($command->getDefinition()->hasOption('force')); + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('File already exists: src/Database/Tables/Reports_Table.php.', $tester->getDisplay()); + $this->assertSame('existing table', (string) file_get_contents($path)); + } + public function test_database_generators_accept_generation_options(): void { $root = $this->temporaryProject(); @@ -291,13 +309,6 @@ public function test_table_and_migration_generators_update_the_conventional_data $this->assertStringContainsString("\t\t\t\$c->get(Create_Reports_Table::class),\n\t\t] );", $contents); $this->assertStringNotContainsString('Array$this', $contents); $this->assertStringNotContainsString('Array$c', $contents); - - (new CommandTester($this->tableCommand($root)))->execute([ - 'name' => 'reports', - '--force' => true, - ]); - - $this->assertSame($contents, (string) file_get_contents($root . '/src/Database/Provider.php')); } public function test_database_migration_generator_appends_to_existing_provider_migrations_in_order(): void { @@ -981,35 +992,29 @@ classNamespace: 'Acme\\Plugin\\Database\\Migrations' $this->assertSame($contents, (string) file_get_contents($providerPath)); } - public function test_database_table_generator_does_not_duplicate_wordpress_formatted_provider_registrations_when_forced(): void { - $root = $this->temporaryProject([ - 'require' => [ - 'stellarwp/foundation-database' => '^1.2', - ], - ]); + public function test_database_provider_updater_is_idempotent_for_namespace_relative_registrations(): void { + $root = $this->temporaryProject(); mkdir($root . '/src/Database', 0777, true); - $providerPath = $root . '/src/Database/Provider.php'; - $providerContents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/formatted-database-provider.stub')); - file_put_contents( - $providerPath, - $providerContents - ); - - $tableTester = new CommandTester($this->tableCommand($root)); - $tableTester->execute(['name' => 'reports']); - chmod($providerPath, 0444); - $tableStatus = $tableTester->execute([ - 'name' => 'reports', - '--force' => true, - '--provider' => 'src/Database/Provider.php', - ]); + $providerPath = $root . '/src/Database/Provider.php'; + $contents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/namespace-relative-registrations.stub')); + file_put_contents($providerPath, $contents); - $contents = (string) file_get_contents($providerPath); + $tableStatus = $this->providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + $migrationStatus = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); - $this->assertSame(Command::SUCCESS, $tableStatus); - $this->assertSame($providerContents, $contents); + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $tableStatus); + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $migrationStatus); + $this->assertSame($contents, (string) file_get_contents($providerPath)); } public function test_explicit_database_provider_update_fails_on_import_short_name_collisions(): void { @@ -1024,7 +1029,7 @@ public function test_explicit_database_provider_update_fails_on_import_short_nam $providerPath = $root . '/src/Database/Provider.php'; file_put_contents($providerPath, str_replace( 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', - "use Acme\\Other\\Reports_Table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + "use Acme\\Other\\reports_table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", (string) file_get_contents($providerPath) )); @@ -1035,7 +1040,7 @@ public function test_explicit_database_provider_update_fails_on_import_short_nam ]); $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); } @@ -1062,7 +1067,7 @@ public function test_explicit_database_provider_update_fails_on_grouped_import_s ]); $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); } @@ -1089,10 +1094,33 @@ public function test_explicit_database_provider_update_fails_on_aliased_import_s ]); $this->assertSame(Command::FAILURE, $statusCode); - $this->assertStringContainsString('a different imported class uses the same short class name', $tester->getDisplay()); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); } + public function test_explicit_database_provider_update_fails_when_the_generated_class_matches_the_provider_class(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + $providerContents = (string) file_get_contents($providerPath); + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'Provider', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); + $this->assertSame($providerContents, (string) file_get_contents($providerPath)); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Provider.php'); + } + public function test_database_table_generator_accepts_an_absolute_output_path(): void { $root = $this->temporaryProject(); $outputRoot = $this->temporaryRoot('foundation-make-database-output-'); @@ -1160,10 +1188,10 @@ public function test_database_generators_use_project_stub_overrides(): void { $root = $this->temporaryProject(); mkdir($root . '/foundation/stubs/database', 0777, true); - file_put_contents($root . '/foundation/stubs/database/table.stub', 'Generated table {{ class }} in {{ namespace }}'); - file_put_contents($root . '/foundation/stubs/database/table-migration.stub', 'Generated migration {{ class }} with {{ table_class }}'); - file_put_contents($root . '/foundation/stubs/database/migration.stub', 'Generated migration {{ class }}'); - file_put_contents($root . '/foundation/stubs/database/provider.stub', 'Generated provider {{ class }} in {{ namespace }}'); + file_put_contents($root . '/foundation/stubs/database/table.stub', 'tableCommand($root)))->execute([ 'name' => 'reports', @@ -1178,19 +1206,19 @@ public function test_database_generators_use_project_stub_overrides(): void { ]); (new CommandTester($this->providerCommand($root)))->execute([]); - $this->assertSame( + $this->assertStringContainsString( 'Generated table Reports_Table in Acme\\Plugin\\Database\\Tables', (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php') ); - $this->assertSame( + $this->assertStringContainsString( 'Generated migration Create_Reports_Table with Reports_Table', (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php') ); - $this->assertSame( + $this->assertStringContainsString( 'Generated migration Bump_Version', (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php') ); - $this->assertSame( + $this->assertStringContainsString( 'Generated provider Provider in Acme\\Plugin\\Database', (string) file_get_contents($root . '/src/Database/Provider.php') ); @@ -1363,6 +1391,82 @@ public function test_database_provider_generator_rejects_namespaces_outside_the_ $this->assertFileDoesNotExist($root . '/src/Tools/Database/Provider.php'); } + /** + * @dataProvider invalidMigrationIdProvider + */ + #[DataProvider('invalidMigrationIdProvider')] + public function test_database_migration_generator_rejects_runtime_invalid_ids(string $id, string $message): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'bump-version', + '--id' => $id, + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString($message, $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Bump_Version.php'); + } + + /** + * @dataProvider invalidMigrationIdProvider + */ + #[DataProvider('invalidMigrationIdProvider')] + public function test_database_table_generator_rejects_runtime_invalid_ids(string $id, string $message): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--id' => $id, + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString($message, $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + /** + * @return iterable + */ + public static function invalidMigrationIdProvider(): iterable { + yield 'blank' => ['', 'cannot be blank']; + + yield 'padded' => [' padded ', 'surrounding whitespace']; + + yield 'integer-like' => ['123', 'integer-like']; + + yield 'over ledger limit' => [str_repeat('a', 192), 'cannot exceed 191 bytes']; + } + + public function test_database_migration_generator_rejects_an_overlong_generated_id(): void { + $root = $this->temporaryProject(); + $name = str_repeat('a', 180); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute(['name' => $name]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('cannot exceed 191 bytes', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/' . ucfirst($name) . '.php'); + } + + public function test_database_generators_reject_class_names_that_conflict_with_stub_imports(): void { + $root = $this->temporaryProject(); + $cases = [ + [new CommandTester($this->tableCommand($root)), ['name' => 'Table'], 'src/Database/Tables/Table.php'], + [new CommandTester($this->migrationCommand($root)), ['name' => 'Migration'], 'src/Database/Migrations/Migration.php'], + [new CommandTester($this->providerCommand($root)), ['name' => 'C'], 'src/Database/C.php'], + ]; + + foreach ($cases as [$tester, $input, $relativePath]) { + $this->assertSame(Command::FAILURE, $tester->execute($input)); + $this->assertStringContainsString('declares or imports', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/' . $relativePath); + } + } + private function tableCommand(string $root): TableCommand { return new TableCommand( rootPath: $root, @@ -1370,7 +1474,7 @@ private function tableCommand(string $root): TableCommand { classNameResolver: new WordPressClassNameResolver(), stubResolver: new StubResolver($root), stubRenderer: new StubRenderer(), - fileWriter: new GeneratedFileWriter(), + fileWriter: $this->fileWriter(), providerUpdater: $this->providerUpdater() ); } @@ -1382,7 +1486,7 @@ private function migrationCommand(string $root): MigrationCommand { classNameResolver: new WordPressClassNameResolver(), stubResolver: new StubResolver($root), stubRenderer: new StubRenderer(), - fileWriter: new GeneratedFileWriter(), + fileWriter: $this->fileWriter(), providerUpdater: $this->providerUpdater() ); } @@ -1394,10 +1498,14 @@ private function providerCommand(string $root): ProviderCommand { classNameResolver: new WordPressClassNameResolver(), stubResolver: new StubResolver($root), stubRenderer: new StubRenderer(), - fileWriter: new GeneratedFileWriter() + fileWriter: $this->fileWriter() ); } + private function fileWriter(): GeneratedFileWriter { + return new GeneratedFileWriter(new PhpSourceEditor(new ParserFactory(), new Lexer())); + } + private function providerUpdater(): ProviderRegistrationEditor { return new ProviderRegistrationEditor( sourceEditor: new PhpSourceEditor( diff --git a/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php index 4d506f5..bd51834 100644 --- a/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php @@ -2,9 +2,12 @@ namespace StellarWP\Foundation\Tests\Unit\Cli\Commands\Make; +use PhpParser\Lexer; +use PhpParser\ParserFactory; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; @@ -299,14 +302,17 @@ public function test_it_uses_project_stub_overrides(): void { $root = $this->temporaryProject(); mkdir($root . '/foundation/stubs/wpcli', 0777, true); - file_put_contents($root . '/foundation/stubs/wpcli/command.stub', 'Generated {{ class }} in {{ namespace }}'); + file_put_contents( + $root . '/foundation/stubs/wpcli/command.stub', + 'command($root)); $tester->execute([ 'name' => 'Sync_Products', ]); - $this->assertSame( + $this->assertStringContainsString( 'Generated Sync_Products_Command in Acme\\Plugin\\Cli\\Commands', (string) file_get_contents($root . '/src/Cli/Commands/Sync_Products_Command.php') ); @@ -397,7 +403,7 @@ private function command(string $root): WPCliCommand { classNameResolver: new WordPressClassNameResolver(), stubResolver: new StubResolver($root), stubRenderer: new StubRenderer(), - fileWriter: new GeneratedFileWriter() + fileWriter: new GeneratedFileWriter(new PhpSourceEditor(new ParserFactory(), new Lexer())) ); } diff --git a/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php index 9f6fe04..e48e94a 100644 --- a/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php +++ b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php @@ -3,10 +3,13 @@ namespace StellarWP\Foundation\Tests\Unit\Cli\Generation; use phpmock\mockery\PHPMockery; +use PhpParser\Lexer; +use PhpParser\ParserFactory; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use RuntimeException; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; use StellarWP\Foundation\Tests\TestCase; @@ -27,12 +30,80 @@ public function test_it_writes_generated_files_to_nested_directories(): void { contents: 'write($file); + $this->writer()->write($file); $this->assertFileExists($file->path); $this->assertSame($file->contents, (string) file_get_contents($file->path)); } + public function test_it_rejects_invalid_php_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Invalid.php', + relativePath: 'Invalid.php', + contents: 'expectException(RuntimeException::class); + $this->expectExceptionMessage('Generated file "Invalid.php" is not valid PHP.'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + + public function test_it_rejects_case_insensitive_class_import_collisions_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Migration.php', + relativePath: 'Migration.php', + contents: (string) file_get_contents($this->data_dir('cli/generation/generated-file-writer/class-import-collision.stub')) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares or imports "migration" more than once'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + + public function test_it_rejects_duplicate_identical_imports_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Example.php', + relativePath: 'Example.php', + contents: (string) file_get_contents($this->data_dir('cli/generation/generated-file-writer/duplicate-import.stub')) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares or imports "Migration" more than once'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + + public function test_it_rejects_duplicate_class_declarations_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Duplicate.php', + relativePath: 'Duplicate.php', + contents: (string) file_get_contents($this->data_dir('cli/generation/generated-file-writer/duplicate-class.stub')) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares or imports "duplicate" more than once'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + public function test_it_refuses_to_overwrite_existing_files_without_force(): void { $path = $this->tempDir . '/Generated.php'; @@ -41,10 +112,10 @@ public function test_it_refuses_to_overwrite_existing_files_without_force(): voi $this->expectException(RuntimeException::class); $this->expectExceptionMessage('File already exists: Generated.php.'); - (new GeneratedFileWriter())->write(new GeneratedFile( + $this->writer()->write(new GeneratedFile( path: $path, relativePath: 'Generated.php', - contents: 'replacement' + contents: 'write(new GeneratedFile( + $this->writer()->write(new GeneratedFile( path: $path, relativePath: 'Generated.php', - contents: 'replacement' + contents: 'assertSame('replacement', (string) file_get_contents($path)); + $this->assertSame('expectException(RuntimeException::class); $this->expectExceptionMessage('Could not write generated file "Generated.php".'); - (new GeneratedFileWriter())->write(new GeneratedFile( + $this->writer()->write(new GeneratedFile( path: $path, relativePath: 'Generated.php', - contents: 'content' + contents: 'once() ->andReturn($handle); PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fwrite') - ->with($handle, 'content') + ->with($handle, 'once() ->andReturn(3); PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fclose') @@ -111,10 +182,10 @@ public function test_it_removes_partially_written_generated_files(): void { $this->expectExceptionMessage('Could not write generated file "Generated.php".'); try { - (new GeneratedFileWriter())->write(new GeneratedFile( + $this->writer()->write(new GeneratedFile( path: $path, relativePath: 'Generated.php', - contents: 'content' + contents: ' true); try { - (new GeneratedFileWriter())->write(new GeneratedFile( + $this->writer()->write(new GeneratedFile( path: $path . '/Generated.php/File.php', relativePath: 'blocked/Generated.php/File.php', - contents: 'content' + contents: ' true); try { - (new GeneratedFileWriter())->write(new GeneratedFile( + $this->writer()->write(new GeneratedFile( path: $path, relativePath: 'Generated.php', - contents: 'content' + contents: 'fixture('existing-import'); $this->assertSame($contents, $this->editor()->addImport($contents, 'Acme\\Generated')); + $this->assertTrue($this->editor()->hasImport($contents, 'acme\\generated')); + } + + public function test_it_detects_case_insensitive_import_collisions(): void { + $this->assertTrue($this->editor()->hasImportShortNameCollision( + $this->fixture('existing-import'), + 'generated', + 'Acme\\Other\\Generated' + )); } public function test_it_returns_null_when_a_line_comment_cannot_be_found(): void { @@ -143,6 +152,77 @@ public function test_it_does_not_treat_an_unresolved_class_name_as_a_merge_array )); } + public function test_it_resolves_namespace_relative_provider_registrations(): void { + $contents = $this->fixture('namespace-relative-registrations'); + + $this->assertTrue($this->editor()->hasContainerSingleton( + $contents, + 'Acme\\Plugin\\Database\\Tables\\Reports_Table' + )); + $this->assertTrue($this->editor()->mergeArrayVarContainsClass( + $contents, + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS', + 'Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table' + )); + } + + public function test_it_resolves_imported_namespace_prefixes(): void { + $contents = $this->fixture('imported-prefix-registrations'); + + $this->assertTrue($this->editor()->hasContainerSingleton( + $contents, + 'Acme\Plugin\Database\Tables\Reports_Table' + )); + $this->assertTrue($this->editor()->mergeArrayVarContainsClass( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + 'Acme\Plugin\Database\Migrations\Create_Reports_Table' + )); + } + + public function test_it_does_not_treat_imported_namespaces_as_local_references(): void { + $contents = $this->fixture('import-shadowed-registrations'); + + $this->assertFalse($this->editor()->hasContainerSingleton( + $contents, + 'Acme\Plugin\Database\Tables\Reports_Table' + )); + $this->assertFalse($this->editor()->mergeArrayVarContainsClass( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + 'Acme\Plugin\Database\Migrations\Create_Reports_Table' + )); + } + + public function test_it_uses_valid_later_merge_array_contributions(): void { + $contents = $this->fixture('multiple-migration-contributions'); + $editor = $this->editor(); + + $this->assertTrue($editor->canInsertIntoMergeArrayVar( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS' + )); + $this->assertTrue($editor->mergeArrayVarContainsClass( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + 'Acme\Plugin\Database\Migrations\Create_Reports_Table' + )); + $this->assertStringContainsString( + '$c->get(Generated::class),', + (string) $editor->insertIntoMergeArrayVar( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + '$c->get(Generated::class),' + ) + ); + } + private function editor(): PhpSourceEditor { return new PhpSourceEditor( parserFactory: new ParserFactory(), diff --git a/tests/_data/cli/generation/generated-file-writer/class-import-collision.stub b/tests/_data/cli/generation/generated-file-writer/class-import-collision.stub new file mode 100644 index 0000000..8fa3caf --- /dev/null +++ b/tests/_data/cli/generation/generated-file-writer/class-import-collision.stub @@ -0,0 +1,9 @@ +container->singleton(Tables\Reports_Table::class); + + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(Migrations\Create_Reports_Table::class), + ]); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub b/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub new file mode 100644 index 0000000..df89920 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub @@ -0,0 +1,17 @@ +container->singleton(DB\Tables\Reports_Table::class); + + $this->container->mergeArrayVar(FoundationDatabase\DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(DB\Migrations\Create_Reports_Table::class), + ]); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub b/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub new file mode 100644 index 0000000..64e7a85 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub @@ -0,0 +1,17 @@ +container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [$c->get(Existing::class)]); + + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(Create_Reports_Table::class), + ]); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub b/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub new file mode 100644 index 0000000..347bf7b --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub @@ -0,0 +1,18 @@ +container->singleton(Tables\Reports_Table::class); + // foundation:database-tables + + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(Migrations\Create_Reports_Table::class), + // foundation:database-migrations + ]); + } +} From f232ba8952eb91cf2ec3aeedebde856ff667c106 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 10:12:20 -0600 Subject: [PATCH 48/81] Normalize lock driver failure handling --- src/Database/Lock/DatabaseLock.php | 25 ++++------- src/Lock/Contracts/Lock.php | 12 ++--- .../Exceptions/LockUnavailableException.php | 2 +- src/Lock/InMemoryLock.php | 44 +++++++----------- src/Lock/README.md | 12 ++--- src/Lock/Traits/CalculatesLockExpiration.php | 25 +++++++++++ src/Lock/Traits/GeneratesLockOwner.php | 23 ++++++++++ src/Lock/Traits/ValidatesLockTtl.php | 20 +++++++++ src/LockRedis/README.md | 2 +- src/LockRedis/RedisLock.php | 45 ++++++------------- tests/Unit/Lock/InMemoryLockFailureTest.php | 30 +++++++++++++ tests/Unit/Lock/InMemoryLockTest.php | 7 +++ tests/Unit/LockRedis/RedisLockFailureTest.php | 40 +++++++++++++++++ tests/Unit/LockRedis/RedisLockTest.php | 11 ++--- 14 files changed, 204 insertions(+), 94 deletions(-) create mode 100644 src/Lock/Traits/CalculatesLockExpiration.php create mode 100644 src/Lock/Traits/GeneratesLockOwner.php create mode 100644 src/Lock/Traits/ValidatesLockTtl.php create mode 100644 tests/Unit/Lock/InMemoryLockFailureTest.php create mode 100644 tests/Unit/LockRedis/RedisLockFailureTest.php diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php index 6ce641f..612bd90 100644 --- a/src/Database/Lock/DatabaseLock.php +++ b/src/Database/Lock/DatabaseLock.php @@ -6,18 +6,22 @@ use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; -use Random\RandomException; use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; use StellarWP\Foundation\Lock\LockToken; +use StellarWP\Foundation\Lock\Traits\GeneratesLockOwner; +use StellarWP\Foundation\Lock\Traits\ValidatesLockTtl; /** * Database-backed lock implementation for WordPress environments. */ final readonly class DatabaseLock implements Lock { + use GeneratesLockOwner; + use ValidatesLockTtl; + public function __construct( private Database $database, private string $table @@ -30,13 +34,9 @@ public function __construct( */ public function acquire(string $name, int $ttl): ?LockToken { $this->assertValidName($name); - $this->assertValidTtl($ttl); + $this->assertValidLockTtl($ttl); - try { - $owner = bin2hex(random_bytes(16)); - } catch (RandomException $exception) { - throw new LockUnavailableException('A secure lock owner token could not be generated.', 0, $exception); - } + $owner = $this->generateLockOwner(); try { $this->database->execute( @@ -102,7 +102,7 @@ public function release(LockToken $token): bool { * @throws LockUnavailableException When the database cannot determine the refresh result. */ public function refresh(LockToken $token, int $ttl): ?LockToken { - $this->assertValidTtl($ttl); + $this->assertValidLockTtl($ttl); try { $this->database->execute( @@ -149,15 +149,6 @@ public function isAcquired(string $name): bool { } } - /** - * @throws InvalidArgumentException When the TTL is less than one second. - */ - private function assertValidTtl(int $ttl): void { - if ($ttl < 1) { - throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); - } - } - /** * @throws InvalidArgumentException When the lock name is empty or exceeds 191 bytes. */ diff --git a/src/Lock/Contracts/Lock.php b/src/Lock/Contracts/Lock.php index 5054325..7fd60b4 100644 --- a/src/Lock/Contracts/Lock.php +++ b/src/Lock/Contracts/Lock.php @@ -18,10 +18,11 @@ interface Lock * already holds the lock. Implementations that coordinate multiple * processes should perform acquisition atomically. * - * @throws InvalidArgumentException When the lock name is empty or the TTL - * is less than one second. - * @throws LockUnavailableException When the backend cannot determine the - * acquisition result. + * @throws InvalidArgumentException When the lock name is empty, the TTL + * is less than one second, or its + * expiration cannot be represented. + * @throws LockUnavailableException When ownership cannot be generated or + * the backend cannot determine the result. */ public function acquire(string $name, int $ttl): ?LockToken; @@ -44,7 +45,8 @@ public function release(LockToken $token): bool; * multiple processes should compare and renew atomically by lock name, * owner, and non-expired state. * - * @throws InvalidArgumentException When the TTL is less than one second. + * @throws InvalidArgumentException When the TTL is less than one second or + * its expiration cannot be represented. * @throws LockUnavailableException When the backend cannot determine the * refresh result. */ diff --git a/src/Lock/Exceptions/LockUnavailableException.php b/src/Lock/Exceptions/LockUnavailableException.php index a4dcc28..daff9a1 100644 --- a/src/Lock/Exceptions/LockUnavailableException.php +++ b/src/Lock/Exceptions/LockUnavailableException.php @@ -5,7 +5,7 @@ use RuntimeException; /** - * Indicates that a lock backend could not provide a trustworthy result. + * Indicates that a lock operation could not provide a trustworthy result. */ final class LockUnavailableException extends RuntimeException { diff --git a/src/Lock/InMemoryLock.php b/src/Lock/InMemoryLock.php index 019ff84..bc56f3e 100644 --- a/src/Lock/InMemoryLock.php +++ b/src/Lock/InMemoryLock.php @@ -2,13 +2,12 @@ namespace StellarWP\Foundation\Lock; -use DateInterval; -use DateMalformedIntervalStringException; -use DateTimeImmutable; use InvalidArgumentException; -use Random\RandomException; use StellarWP\Foundation\Lock\Contracts\Clock; use StellarWP\Foundation\Lock\Contracts\Lock; +use StellarWP\Foundation\Lock\Traits\CalculatesLockExpiration; +use StellarWP\Foundation\Lock\Traits\GeneratesLockOwner; +use StellarWP\Foundation\Lock\Traits\ValidatesLockTtl; /** * Process-local lock implementation useful for tests and single-process work. @@ -19,6 +18,10 @@ */ final class InMemoryLock implements Lock { + use CalculatesLockExpiration; + use GeneratesLockOwner; + use ValidatesLockTtl; + /** * @var array */ @@ -31,13 +34,10 @@ public function __construct( /** * {@inheritDoc} - * - * @throws RandomException - * @throws DateMalformedIntervalStringException */ public function acquire(string $name, int $ttl): ?LockToken { $this->assertValidName($name); - $this->assertValidTtl($ttl); + $this->assertValidLockTtl($ttl); $this->releaseIfExpired($name); if (isset($this->locks[$name])) { @@ -46,8 +46,8 @@ public function acquire(string $name, int $ttl): ?LockToken { $token = new LockToken( name: $name, - owner: bin2hex(random_bytes(16)), - expiresAt: $this->expiresAt($ttl) + owner: $this->generateLockOwner(), + expiresAt: $this->calculateLockExpiration($this->clock->now(), $ttl) ); $this->locks[$name] = $token; @@ -68,17 +68,19 @@ public function release(LockToken $token): bool { } /** - * @throws DateMalformedIntervalStringException + * {@inheritDoc} */ public function refresh(LockToken $token, int $ttl): ?LockToken { - $this->assertValidTtl($ttl); + $this->assertValidLockTtl($ttl); $this->releaseIfExpired($token->name); if (! isset($this->locks[$token->name]) || ! $this->locks[$token->name]->matches($token)) { return null; } - $refreshed = $token->withExpiration($this->expiresAt($ttl)); + $refreshed = $token->withExpiration( + $this->calculateLockExpiration($this->clock->now(), $ttl) + ); $this->locks[$token->name] = $refreshed; @@ -92,22 +94,6 @@ public function isAcquired(string $name): bool { return isset($this->locks[$name]); } - /** - * @throws DateMalformedIntervalStringException - * @throws InvalidArgumentException - */ - private function expiresAt(int $ttl): DateTimeImmutable { - $this->assertValidTtl($ttl); - - return $this->clock->now()->add(new DateInterval(sprintf('PT%dS', $ttl))); - } - - private function assertValidTtl(int $ttl): void { - if ($ttl < 1) { - throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); - } - } - private function releaseIfExpired(string $name): void { if (! isset($this->locks[$name])) { return; diff --git a/src/Lock/README.md b/src/Lock/README.md index 12281a9..69ec736 100644 --- a/src/Lock/README.md +++ b/src/Lock/README.md @@ -109,7 +109,9 @@ full operation. > [!IMPORTANT] > Locks are time-bounded leases. Mutual exclusion is guaranteed only until the token expires. Choose a TTL longer than the protected operation or refresh the lock before expiration. -`refresh()` returns a new token with an expiration of the current time plus the supplied TTL. It returns `null` if the original token no longer owns the lock: +Only `Lock::refresh()` renews the backend lease. It returns a new token with an +expiration of the current time plus the supplied TTL, or `null` if the original +token no longer owns the lock: ```php $token = $lock->refresh($token, 120); @@ -124,7 +126,7 @@ Refreshing must happen before the current lease expires. For a single blocking o ## Backend Failures -Persistent implementations throw `StellarWP\Foundation\Lock\Exceptions\LockUnavailableException` -when their backend cannot provide a trustworthy result. Treat that exception as -a failure to obtain or retain the lock; do not continue the protected work -without coordination. +Lock implementations throw `StellarWP\Foundation\Lock\Exceptions\LockUnavailableException` +when their backend or secure owner generation cannot provide a trustworthy +result. Treat that exception as a failure to obtain or retain the lock; do not +continue the protected work without coordination. diff --git a/src/Lock/Traits/CalculatesLockExpiration.php b/src/Lock/Traits/CalculatesLockExpiration.php new file mode 100644 index 0000000..c2ae545 --- /dev/null +++ b/src/Lock/Traits/CalculatesLockExpiration.php @@ -0,0 +1,25 @@ +add(new DateInterval(sprintf('PT%dS', $ttl))); + } catch (DateMalformedIntervalStringException $exception) { + throw new InvalidArgumentException('Lock TTL cannot be represented.', 0, $exception); + } + } +} diff --git a/src/Lock/Traits/GeneratesLockOwner.php b/src/Lock/Traits/GeneratesLockOwner.php new file mode 100644 index 0000000..9bfd123 --- /dev/null +++ b/src/Lock/Traits/GeneratesLockOwner.php @@ -0,0 +1,23 @@ +assertValidName($name); - $this->assertValidTtl($ttl); + $this->assertValidLockTtl($ttl); $startedAt = $this->clock->now(); - $expiresAt = $this->expiresAt($startedAt, $ttl); - $owner = bin2hex(random_bytes(16)); + $expiresAt = $this->calculateLockExpiration($startedAt, $ttl); + $owner = $this->generateLockOwner(); $result = $this->connection->evaluate( self::ACQUIRE_SCRIPT, [$this->key($name)], @@ -99,15 +100,13 @@ public function release(LockToken $token): bool { } /** - * @throws InvalidArgumentException When the TTL is invalid. - * @throws LockUnavailableException When Redis cannot determine the refresh result. - * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. + * {@inheritDoc} */ public function refresh(LockToken $token, int $ttl): ?LockToken { - $this->assertValidTtl($ttl); + $this->assertValidLockTtl($ttl); $startedAt = $this->clock->now(); - $expiresAt = $this->expiresAt($startedAt, $ttl); + $expiresAt = $this->calculateLockExpiration($startedAt, $ttl); $result = $this->connection->evaluate( self::REFRESH_SCRIPT, [$this->key($token->name)], @@ -131,13 +130,6 @@ public function isAcquired(string $name): bool { return $this->connection->exists($this->key($name)); } - /** - * @throws DateMalformedIntervalStringException When PHP cannot represent the requested TTL. - */ - private function expiresAt(DateTimeImmutable $startedAt, int $ttl): DateTimeImmutable { - return $startedAt->add(new DateInterval(sprintf('PT%dS', $ttl))); - } - private function key(string $name): string { return $this->prefix . $name; } @@ -150,13 +142,4 @@ private function assertValidName(string $name): void { throw new InvalidArgumentException('Lock name cannot be empty.'); } } - - /** - * @throws InvalidArgumentException When the TTL is less than one second. - */ - private function assertValidTtl(int $ttl): void { - if ($ttl < 1) { - throw new InvalidArgumentException('Lock TTL must be greater than zero seconds.'); - } - } } diff --git a/tests/Unit/Lock/InMemoryLockFailureTest.php b/tests/Unit/Lock/InMemoryLockFailureTest.php new file mode 100644 index 0000000..5883f64 --- /dev/null +++ b/tests/Unit/Lock/InMemoryLockFailureTest.php @@ -0,0 +1,30 @@ +once() + ->andThrow(new RandomException('Entropy unavailable.')); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Unable to generate a secure lock owner.'); + + (new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')))) + ->acquire('queue:sync', 60); + } +} diff --git a/tests/Unit/Lock/InMemoryLockTest.php b/tests/Unit/Lock/InMemoryLockTest.php index 21325b9..5b76c9f 100644 --- a/tests/Unit/Lock/InMemoryLockTest.php +++ b/tests/Unit/Lock/InMemoryLockTest.php @@ -193,4 +193,11 @@ public function test_it_rejects_an_invalid_ttl_when_refreshing_a_lock(): void { $this->lock->refresh($token, 0); } + + public function test_it_rejects_a_ttl_that_cannot_be_represented(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL cannot be represented.'); + + $this->lock->acquire('queue:sync', 1_000_000_000_000); + } } diff --git a/tests/Unit/LockRedis/RedisLockFailureTest.php b/tests/Unit/LockRedis/RedisLockFailureTest.php new file mode 100644 index 0000000..e8a9190 --- /dev/null +++ b/tests/Unit/LockRedis/RedisLockFailureTest.php @@ -0,0 +1,40 @@ +once() + ->andThrow(new RandomException('Entropy unavailable.')); + $connection = new RecordingConnection(); + $lock = new RedisLock( + $connection, + new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')), + 'tests:lock:' + ); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Unable to generate a secure lock owner.'); + + try { + $lock->acquire('queue:sync', 60); + } finally { + $this->assertSame([], $connection->evaluateCalls); + } + } +} diff --git a/tests/Unit/LockRedis/RedisLockTest.php b/tests/Unit/LockRedis/RedisLockTest.php index 456103b..0ccf478 100644 --- a/tests/Unit/LockRedis/RedisLockTest.php +++ b/tests/Unit/LockRedis/RedisLockTest.php @@ -2,7 +2,6 @@ namespace StellarWP\Foundation\Tests\Unit\LockRedis; -use DateMalformedIntervalStringException; use DateTimeImmutable; use InvalidArgumentException; use StellarWP\Foundation\Lock\Exceptions\LockUnavailableException; @@ -125,8 +124,9 @@ public function test_it_rejects_an_invalid_refresh_ttl(): void { public function test_it_does_not_acquire_when_the_ttl_cannot_be_represented(): void { try { $this->lock->acquire('queue:sync', 1_000_000_000_000); - $this->fail('Expected an invalid interval exception.'); - } catch (DateMalformedIntervalStringException) { + $this->fail('Expected an invalid TTL exception.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Lock TTL cannot be represented.', $exception->getMessage()); $this->assertSame([], $this->connection->evaluateCalls); } } @@ -134,8 +134,9 @@ public function test_it_does_not_acquire_when_the_ttl_cannot_be_represented(): v public function test_it_does_not_refresh_when_the_ttl_cannot_be_represented(): void { try { $this->lock->refresh($this->token(), 1_000_000_000_000); - $this->fail('Expected an invalid interval exception.'); - } catch (DateMalformedIntervalStringException) { + $this->fail('Expected an invalid TTL exception.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Lock TTL cannot be represented.', $exception->getMessage()); $this->assertSame([], $this->connection->evaluateCalls); } } From 140768261ea8fd5e06a906a4d79e3a27747fbb03 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 10:15:43 -0600 Subject: [PATCH 49/81] Quote qualified query columns by segment --- src/Database/Query/QueryBuilder.php | 68 +++++++++++++++---- .../Unit/Database/Query/QueryBuilderTest.php | 48 +++++++++++++ 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php index 232ba67..1f13db9 100644 --- a/src/Database/Query/QueryBuilder.php +++ b/src/Database/Query/QueryBuilder.php @@ -52,7 +52,7 @@ public function select(string ...$columns): self { /** * Compare a column to a value. NULL values use IS NULL or IS NOT NULL semantics. * - * @throws InvalidArgumentException When the operator is unsupported or cannot compare against NULL. + * @throws InvalidArgumentException When the column or operator is invalid, or the operator cannot compare against NULL. */ public function where(string $column, string $operator, mixed $value): self { $operator = $this->operator($operator); @@ -64,19 +64,22 @@ public function where(string $column, string $operator, mixed $value): self { $this->where[] = sprintf( '%s IS%s NULL', - $this->database->quoteIdentifier($column), + $this->quoteColumn($column), $operator === '=' ? '' : ' NOT' ); return $this; } - $this->where[] = sprintf('%s %s %%s', $this->database->quoteIdentifier($column), $operator); + $this->where[] = sprintf('%s %s %%s', $this->quoteColumn($column), $operator); $this->bindings[] = $value; return $this; } + /** + * @throws InvalidArgumentException When the column or direction is invalid. + */ public function orderBy(string $column, string $direction = 'ASC'): self { $direction = strtoupper($direction); @@ -84,11 +87,14 @@ public function orderBy(string $column, string $direction = 'ASC'): self { throw new InvalidArgumentException('Order direction must be ASC or DESC.'); } - $this->orderBy[] = sprintf('%s %s', $this->database->quoteIdentifier($column), $direction); + $this->orderBy[] = sprintf('%s %s', $this->quoteColumn($column), $direction); return $this; } + /** + * @throws InvalidArgumentException When the limit or offset is invalid. + */ public function limit(int $limit, ?int $offset = null): self { if ($limit < 1) { throw new InvalidArgumentException('Query limit must be greater than zero.'); @@ -105,14 +111,16 @@ public function limit(int $limit, ?int $offset = null): self { } /** - * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws InvalidArgumentException When a selected column is invalid. */ public function query(): Query { return new Query($this->database, $this->toSql(), $this->bindings()); } /** - * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws InvalidArgumentException When a selected column is invalid. */ public function toSql(): string { $sql = sprintf( @@ -159,14 +167,16 @@ public function bindings(): array { } /** - * @throws DatabaseException When table-name resolution or query preparation fails. + * @throws DatabaseException When table-name resolution or query preparation fails. + * @throws InvalidArgumentException When a selected column is invalid. */ public function toPreparedSql(): string { return $this->database->prepare($this->toSql(), ...$this->bindings()); } /** - * @throws DatabaseException When table-name resolution or query execution fails. + * @throws DatabaseException When table-name resolution or query execution fails. + * @throws InvalidArgumentException When a selected column is invalid. * * @return list> */ @@ -175,7 +185,8 @@ public function get(): array { } /** - * @throws DatabaseException When table-name resolution or query execution fails. + * @throws DatabaseException When table-name resolution or query execution fails. + * @throws InvalidArgumentException When a selected column is invalid. * * @return array|null */ @@ -194,11 +205,7 @@ private function queryWithLimitBindings(): Query { } private function selectSql(): string { - if ($this->columns === ['*']) { - return '*'; - } - - return implode(', ', array_map($this->database->quoteIdentifier(...), $this->columns)); + return implode(', ', array_map(fn (string $column): string => $this->quoteColumn($column, true), $this->columns)); } private function aliasSql(): string { @@ -218,4 +225,37 @@ private function operator(string $operator): string { return $operator; } + + /** + * Quote each segment of a qualified column reference. + * + * For example, p.ID becomes `p`.`ID`. When wildcards are allowed, + * p.* becomes `p`.*. + * + * @throws InvalidArgumentException When the column contains an empty segment or a disallowed wildcard. + */ + private function quoteColumn(string $column, bool $allowWildcard = false): string { + $segments = explode('.', $column); + $last = array_key_last($segments); + $quoted = []; + + foreach ($segments as $index => $segment) { + if ($segment === '') { + throw new InvalidArgumentException(sprintf('Invalid query column: %s.', $column)); + } + + if ($segment === '*') { + if (! $allowWildcard || $index !== $last) { + throw new InvalidArgumentException(sprintf('Invalid query column wildcard: %s.', $column)); + } + + $quoted[] = '*'; + continue; + } + + $quoted[] = $this->database->quoteIdentifier($segment); + } + + return implode('.', $quoted); + } } diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php index 539e506..8e3c5bd 100644 --- a/tests/Unit/Database/Query/QueryBuilderTest.php +++ b/tests/Unit/Database/Query/QueryBuilderTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Query; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Database\Query\Query; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; @@ -31,6 +32,53 @@ public function test_it_builds_inspectable_select_queries(): void { ); } + public function test_it_quotes_qualified_columns_and_select_wildcards_by_segment(): void { + $query = (new FakeDatabase()) + ->table('posts', 'p') + ->select('*', 'p.ID', 'p.*') + ->where('p.status', '=', 'publish') + ->where('p.deleted_at', '=', null) + ->orderBy('p.ID'); + + $this->assertSame( + 'SELECT *, `p`.`ID`, `p`.* FROM `wp_posts` AS `p` WHERE `p`.`status` = %s AND `p`.`deleted_at` IS NULL ORDER BY `p`.`ID` ASC', + $query->toSql() + ); + $this->assertSame(['publish'], $query->bindings()); + } + + public function test_it_escapes_each_qualified_column_segment(): void { + $query = (new FakeDatabase())->table('posts')->where('p`ost.I`D', '=', 1); + + $this->assertSame('SELECT * FROM `wp_posts` WHERE `p``ost`.`I``D` = %s', $query->toSql()); + } + + /** + * @dataProvider invalidColumnProvider + */ + #[DataProvider('invalidColumnProvider')] + public function test_it_rejects_invalid_qualified_columns(string $column): void { + $this->expectException(InvalidArgumentException::class); + + (new FakeDatabase())->table('posts')->select($column)->toSql(); + } + + /** + * @return iterable + */ + public static function invalidColumnProvider(): iterable { + yield 'empty segment' => ['p..ID']; + + yield 'non-terminal wildcard' => ['p.*.ID']; + } + + public function test_it_rejects_wildcards_outside_selects(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid query column wildcard'); + + (new FakeDatabase())->table('posts')->where('p.*', '=', 1); + } + public function test_it_rejects_unsupported_operators(): void { $this->expectException(InvalidArgumentException::class); From 9a8f7a5dc57b211264639e3b2bc55beccbf68a14 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 10:18:30 -0600 Subject: [PATCH 50/81] Clarify physical database table names --- src/Database/Contracts/Table.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Database/Contracts/Table.php b/src/Database/Contracts/Table.php index 8938782..da87b94 100644 --- a/src/Database/Contracts/Table.php +++ b/src/Database/Contracts/Table.php @@ -11,6 +11,12 @@ interface Table { public function id(): string; + /** + * Return the complete physical table name, including the WordPress table prefix. + * + * For example, a configured table name of `reports` with the WordPress prefix + * `wp_` must return `wp_reports`. + */ public function name(): string; public function definition(): TableDefinition; From 9de18236f19cffa5a414d1b851b5fea46e0466fc Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 10:21:00 -0600 Subject: [PATCH 51/81] Clarify the migration contract --- src/Database/Contracts/Migration.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Database/Contracts/Migration.php b/src/Database/Contracts/Migration.php index 488938a..3478742 100644 --- a/src/Database/Contracts/Migration.php +++ b/src/Database/Contracts/Migration.php @@ -3,12 +3,13 @@ namespace StellarWP\Foundation\Database\Contracts; /** - * Defines a reversible database schema change. + * Defines a versioned database change. */ interface Migration { /** - * Unique, stable migration identifier. + * Return a unique, stable identifier that is nonblank, unpadded, + * non-integer-like, and no longer than 191 bytes. */ public function id(): string; @@ -18,7 +19,7 @@ public function id(): string; public function up(Schema $schema): void; /** - * Reverse the migration. + * Reverse the migration when supported. */ public function down(Schema $schema): void; } From 05ad5fa4a531a09e412c843d4391c46467ba15c8 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 10:29:32 -0600 Subject: [PATCH 52/81] Remove raw SQL schema reconciliation API --- src/Database/Contracts/Schema.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php index 849787c..30f0456 100644 --- a/src/Database/Contracts/Schema.php +++ b/src/Database/Contracts/Schema.php @@ -16,13 +16,6 @@ interface Schema */ public function createOrUpdate(Table $table): void; - /** - * Create or update a table from explicit dbDelta-compatible SQL. - * - * @throws DatabaseException When WordPress cannot reconcile the SQL definition. - */ - public function createOrUpdateSql(string $sql): void; - /** * Execute explicit schema SQL. * From 0e2745a935406379c68a81e6442145952923e220 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 11:03:55 -0600 Subject: [PATCH 53/81] Extract database schema reconciliation --- src/Database/Contracts/Schema.php | 4 +- src/Database/DatabaseProvider.php | 2 + src/Database/Schema.php | 58 +---- src/Database/Schema/DbDelta.php | 27 ++ src/Database/Schema/Reconciler.php | 237 ++++++++++++++++++ .../Database/DateTimePrecisionTable.php | 28 +++ .../Fixtures/Database/RecordingSchema.php | 4 - .../Database/SchemaReconciliationTable.php | 41 +++ .../register-wpcli-migrate-command.php | 19 +- tests/Unit/Database/Schema/ReconcilerTest.php | 109 ++++++++ tests/Unit/Database/SchemaTest.php | 27 +- .../Database/Table/Tables/LockTableTest.php | 12 +- .../Table/Tables/MigrationTableTest.php | 11 +- .../Database/DatabaseIntegrationTest.php | 87 +++++-- tests/wpunit/Database/Schema/DbDeltaTest.php | 20 ++ 15 files changed, 570 insertions(+), 116 deletions(-) create mode 100644 src/Database/Schema/Reconciler.php create mode 100644 tests/Support/Fixtures/Database/DateTimePrecisionTable.php create mode 100644 tests/Support/Fixtures/Database/SchemaReconciliationTable.php create mode 100644 tests/Unit/Database/Schema/ReconcilerTest.php diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php index 30f0456..6fa7abc 100644 --- a/src/Database/Contracts/Schema.php +++ b/src/Database/Contracts/Schema.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Database\Contracts; +use InvalidArgumentException; use StellarWP\Foundation\Database\Exceptions\DatabaseException; /** @@ -12,7 +13,8 @@ interface Schema /** * Create or update a table. * - * @throws DatabaseException When WordPress cannot reconcile the table definition. + * @throws DatabaseException When WordPress cannot reconcile the table definition. + * @throws InvalidArgumentException When the table definition is invalid. */ public function createOrUpdate(Table $table): void; diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 50d1187..917fbd6 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -18,6 +18,7 @@ use StellarWP\Foundation\Database\Migration\Repository as MigrationRecordRepository; use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema\DbDelta; +use StellarWP\Foundation\Database\Schema\Reconciler; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; @@ -82,6 +83,7 @@ private function registerDatabase(): void { $this->container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); $this->container->singleton(DbDelta::class); $this->container->singleton(SchemaExecutor::class, static fn (C $c): DbDelta => $c->get(DbDelta::class)); + $this->container->singleton(Reconciler::class); $this->container->singleton(Schema::class); $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); } diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 95afd4e..8655981 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -2,12 +2,12 @@ namespace StellarWP\Foundation\Database; +use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Schema as SchemaContract; -use StellarWP\Foundation\Database\Contracts\SchemaExecutor; use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Exceptions\DatabaseException; -use StellarWP\Foundation\Database\Table\TableDefinition; +use StellarWP\Foundation\Database\Schema\Reconciler; /** * WordPress schema operations backed by wpdb and dbDelta. @@ -16,26 +16,16 @@ { public function __construct( private Database $database, - private SchemaExecutor $executor + private Reconciler $reconciler ) { } /** - * @throws DatabaseException When WordPress cannot reconcile the table definition. + * @throws DatabaseException When WordPress cannot reconcile the table definition. + * @throws InvalidArgumentException When the table definition is invalid. */ public function createOrUpdate(Table $table): void { - $definition = $table->definition(); - $definition->assertValid(); - - $this->executor->execute($this->createTableSql($table, $definition)); - $this->reconcileComplexDefaults($table, $definition); - } - - /** - * @throws DatabaseException When WordPress cannot reconcile the SQL definition. - */ - public function createOrUpdateSql(string $sql): void { - $this->executor->execute($sql); + $this->reconciler->reconcile($table); } public function execute(string $sql): void { @@ -80,40 +70,4 @@ public function drop(Table|string $table): void { public function quoteIdentifier(string $identifier): string { return $this->database->quoteIdentifier($identifier); } - - private function createTableSql(Table $table, TableDefinition $definition): string { - $parts = []; - - foreach ($definition->columns() as $column) { - $parts[] = ' ' . $column->sql(); - } - - foreach ($definition->indexes() as $index) { - $parts[] = ' ' . $index->sql(); - } - - return sprintf( - "CREATE TABLE %s (\n%s\n) %s;", - $this->database->quoteIdentifier($this->database->tableName($table)), - implode(",\n", $parts), - $this->database->charsetCollate() - ); - } - - private function reconcileComplexDefaults(Table $table, TableDefinition $definition): void { - foreach ($definition->columns() as $column) { - $default = $column->defaultSql(); - - if ($default === null || ! str_starts_with($default, "X'")) { - continue; - } - - $this->database->execute(sprintf( - 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', - $this->database->quoteIdentifier($this->database->tableName($table)), - $this->database->quoteIdentifier($column->name), - $default - )); - } - } } diff --git a/src/Database/Schema/DbDelta.php b/src/Database/Schema/DbDelta.php index ac8a96b..7c67f9b 100644 --- a/src/Database/Schema/DbDelta.php +++ b/src/Database/Schema/DbDelta.php @@ -37,6 +37,10 @@ public function execute(string $sql): void { } $pending = dbDelta($sql, false); + $pending = array_filter( + $pending, + fn (string $change): bool => ! $this->createdTableExists($change, $wpdb) + ); if ($pending !== []) { throw new DatabaseException(sprintf( @@ -45,4 +49,27 @@ public function execute(string $sql): void { )); } } + + /** + * Ignore WordPress 6.2's stale dry-run result for a table that was created successfully. + * + * @throws QueryException When WordPress cannot verify the table. + */ + private function createdTableExists(string $change, \wpdb $wpdb): bool { + $prefix = 'Created table '; + + if (! str_starts_with($change, $prefix)) { + return false; + } + + $table = trim(substr($change, strlen($prefix)), '`'); + $query = $wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)); + $found = $wpdb->get_var($query); + + if ($wpdb->last_error !== '') { + throw new QueryException($wpdb->last_error, 'SHOW TABLES LIKE %s', [$table], $wpdb->last_error); + } + + return $found === $table; + } } diff --git a/src/Database/Schema/Reconciler.php b/src/Database/Schema/Reconciler.php new file mode 100644 index 0000000..638cfff --- /dev/null +++ b/src/Database/Schema/Reconciler.php @@ -0,0 +1,237 @@ +definition(); + $definition->assertValid(); + + $this->executor->execute($this->createTableSql($table, $definition)); + $this->reconcileComplexDefaults($table, $definition); + $this->assertColumnPropertiesMatch($table, $definition); + } + + /** + * Build the CREATE TABLE statement passed to the WordPress schema executor. + * + * @throws DatabaseException When the physical table name is invalid. + */ + private function createTableSql(Table $table, TableDefinition $definition): string { + $parts = []; + + foreach ($definition->columns() as $column) { + $parts[] = ' ' . $column->sql(); + } + + foreach ($definition->indexes() as $index) { + $parts[] = ' ' . $index->sql(); + } + + return sprintf( + "CREATE TABLE %s (\n%s\n) %s;", + $this->database->quoteIdentifier($this->database->tableName($table)), + implode(",\n", $parts), + $this->database->charsetCollate() + ); + } + + /** + * Reconcile binary string defaults that dbDelta cannot represent safely. + * + * @throws DatabaseException When a default cannot be reconciled. + */ + private function reconcileComplexDefaults(Table $table, TableDefinition $definition): void { + foreach ($definition->columns() as $column) { + $default = $column->defaultSql(); + + if ($default === null || ! str_starts_with($default, "X'")) { + continue; + } + + $this->database->execute(sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->database->quoteIdentifier($this->database->tableName($table)), + $this->database->quoteIdentifier($column->name), + $default + )); + } + } + + /** + * Verify properties that dbDelta does not reliably reconcile. + * + * @throws DatabaseException When column metadata is missing, invalid, or differs from the definition. + */ + private function assertColumnPropertiesMatch(Table $table, TableDefinition $definition): void { + $differences = []; + + foreach ($definition->columns() as $column) { + $properties = $this->columnProperties($table, $column); + + if ($properties['nullable'] !== $column->nullable) { + $differences[] = sprintf( + 'column %s expected %s, found %s', + $column->name, + $column->nullable ? 'NULL' : 'NOT NULL', + $properties['nullable'] ? 'NULL' : 'NOT NULL' + ); + } + + if (! $this->defaultMatches($column, $properties['default'])) { + $differences[] = sprintf( + 'column %s expected %s, found %s', + $column->name, + $column->defaultSql() === null ? 'no default' : 'DEFAULT ' . $column->defaultSql(), + $properties['default'] === null ? 'DEFAULT NULL' : 'DEFAULT ' . (string) $properties['default'] + ); + } + + $expectedExtra = $this->normalizeExtra($column->extra); + $actualExtra = $this->normalizeExtra($properties['extra']); + + if ($expectedExtra !== $actualExtra) { + $differences[] = sprintf( + 'column %s expected extra %s, found %s', + $column->name, + $expectedExtra === '' ? 'none' : $expectedExtra, + $actualExtra === '' ? 'none' : $actualExtra + ); + } + } + + if ($differences !== []) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation did not apply the definition for %s: %s.', + $this->database->tableName($table), + implode('; ', $differences) + )); + } + } + + /** + * Read and validate the database metadata used to verify a column definition. + * + * @throws DatabaseException When column metadata is missing or invalid. + * + * @return array{nullable: bool, default: mixed, extra: string} + */ + private function columnProperties(Table $table, Column $column): array { + $row = $this->database->row( + 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', + $this->database->tableName($table), + $column->name + ); + + if ($row === null) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation could not inspect %s.%s.', + $this->database->tableName($table), + $column->name + )); + } + + $nullable = $row['Null'] ?? null; + $extra = $row['Extra'] ?? null; + + if ( + ! is_string($nullable) + || ! in_array(strtoupper($nullable), ['YES', 'NO'], true) + || ! array_key_exists('Default', $row) + || ! is_string($extra) + ) { + throw new DatabaseException(sprintf( + 'Database returned invalid column metadata for %s.%s.', + $this->database->tableName($table), + $column->name + )); + } + + return [ + 'nullable' => strtoupper($nullable) === 'YES', + 'default' => $row['Default'], + 'extra' => $extra, + ]; + } + + /** + * Determine whether a database-reported default matches the declared column default. + */ + private function defaultMatches(Column $column, mixed $actual): bool { + if ($column->default === null) { + return $actual === null; + } + + if ($actual === null) { + return false; + } + + if (is_bool($column->default)) { + return $this->integerDefaultMatches($column->default ? 1 : 0, $actual); + } + + if (is_int($column->default)) { + return $this->integerDefaultMatches($column->default, $actual); + } + + if (is_float($column->default)) { + return is_numeric($actual) && (float) $actual === $column->default; + } + + return (string) $actual === (string) $column->default; + } + + /** + * Compare an integer default with MySQL integer, decimal, or bit-literal metadata. + */ + private function integerDefaultMatches(int $expected, mixed $actual): bool { + $actual = (string) $actual; + + if (preg_match("/^b'([01]+)'$/i", $actual, $bits) === 1) { + return bindec($bits[1]) === $expected; + } + + if (preg_match('/^([+-]?\d+)(?:\.0+)?$/', $actual, $integer) !== 1) { + return false; + } + + return filter_var($integer[1], FILTER_VALIDATE_INT) === $expected; + } + + /** + * Normalize equivalent MySQL Extra metadata before comparing column definitions. + */ + private function normalizeExtra(string $extra): string { + $extra = str_ireplace('DEFAULT_GENERATED', '', $extra); + $extra = preg_replace('/CURRENT_TIMESTAMP\(\)/i', 'CURRENT_TIMESTAMP', $extra) ?? $extra; + + return strtolower(trim(preg_replace('/\s+/', ' ', $extra) ?? $extra)); + } +} diff --git a/tests/Support/Fixtures/Database/DateTimePrecisionTable.php b/tests/Support/Fixtures/Database/DateTimePrecisionTable.php new file mode 100644 index 0000000..b70423f --- /dev/null +++ b/tests/Support/Fixtures/Database/DateTimePrecisionTable.php @@ -0,0 +1,28 @@ +table; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->dateTime('occurred_at', 0); + } +} diff --git a/tests/Support/Fixtures/Database/RecordingSchema.php b/tests/Support/Fixtures/Database/RecordingSchema.php index 275b3ce..2981ce5 100644 --- a/tests/Support/Fixtures/Database/RecordingSchema.php +++ b/tests/Support/Fixtures/Database/RecordingSchema.php @@ -28,10 +28,6 @@ public function createOrUpdate(Table $table): void { $this->statements[] = 'createOrUpdate:' . $name; } - public function createOrUpdateSql(string $sql): void { - $this->statements[] = 'createOrUpdateSql:' . $sql; - } - public function execute(string $sql): void { $this->statements[] = $sql; } diff --git a/tests/Support/Fixtures/Database/SchemaReconciliationTable.php b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php new file mode 100644 index 0000000..a347b6b --- /dev/null +++ b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php @@ -0,0 +1,41 @@ +table; + } + + public function definition(): TableDefinition { + $definition = TableDefinition::for($this) + ->bigIncrements('id') + ->integer('attempts')->default($this->attemptsDefault) + ->dateTime('completed_at') + ->string('label')->default('') + ->column(new Column('ratio', 'decimal(10,2)', default: 1.25)) + ->column(new Column('enabled', 'bit', 1, default: true)); + + if ($this->completedAtNullable) { + $definition->column(new Column('completed_at', 'datetime', nullable: true)); + } + + return $definition; + } +} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 92d20d5..581da1e 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -16,8 +16,10 @@ use StellarWP\Foundation\Database\Migration\Store; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Database\Schema\DbDelta; +use StellarWP\Foundation\Database\Schema\Reconciler; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; if (! class_exists(WP_CLI::class)) { return; @@ -42,7 +44,7 @@ $container->singleton(Dot::class, new Dot()); $database = new Database($wpdb); - $schema = new Schema($database, new DbDelta()); + $schema = new Schema($database, new Reconciler($database, new DbDelta())); $migrationTableName = $wpdb->prefix . 'foundation_cli_migrations'; $lockTableName = $wpdb->prefix . 'foundation_cli_locks'; $exampleTable = $wpdb->prefix . 'foundation_cli_example'; @@ -52,9 +54,9 @@ $lock = new DatabaseLock($database, $lockTableName); $store = new Store($schema, $lock, $migrationTable, $lockTable); - $migration = new class($exampleTable) implements Migration { + $migration = new class(new TestTable('foundation_cli_example', $exampleTable)) implements Migration { public function __construct( - private readonly string $exampleTable + private readonly TestTable $table ) { } @@ -63,20 +65,13 @@ public function id(): string { } public function up(SchemaContract $schema): void { - $schema->createOrUpdateSql(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - name varchar(191) NOT NULL, - PRIMARY KEY (id) - );', - $schema->quoteIdentifier($this->exampleTable) - )); + $schema->createOrUpdate($this->table); } public function down(SchemaContract $schema): void { $schema->execute(sprintf( 'DROP TABLE IF EXISTS %s', - $schema->quoteIdentifier($this->exampleTable) + $schema->quoteIdentifier($this->table->name()) )); } }; diff --git a/tests/Unit/Database/Schema/ReconcilerTest.php b/tests/Unit/Database/Schema/ReconcilerTest.php new file mode 100644 index 0000000..dd5a7f4 --- /dev/null +++ b/tests/Unit/Database/Schema/ReconcilerTest.php @@ -0,0 +1,109 @@ +rowResults[] = ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment']; + $executor = new RecordingSchemaExecutor(); + $reconciler = new Reconciler($database, $executor); + + $reconciler->reconcile(new TestTable('example', 'wp_example')); + + $this->assertStringContainsString('CREATE TABLE `wp_example`', $executor->statements[0]); + $this->assertSame("SHOW FULL COLUMNS FROM `wp_example` WHERE Field = 'id'", $database->rowQueries[0]); + } + + public function test_it_accepts_matching_column_defaults_and_nullability(): void { + $database = new FakeDatabase(); + $database->rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ['Null' => 'NO', 'Default' => '5', 'Extra' => ''], + ['Null' => 'YES', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => '', 'Extra' => ''], + ['Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], + ['Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], + ]; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $reconciler->reconcile(new SchemaReconciliationTable('wp_example', 5, true)); + + $this->assertSame([], $database->executed); + } + + public function test_it_fails_when_column_defaults_and_nullability_remain_unapplied(): void { + $database = new FakeDatabase(); + $database->rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ['Null' => 'NO', 'Default' => 'not-an-integer', 'Extra' => ''], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => '', 'Extra' => ''], + ['Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], + ['Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], + ]; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column attempts expected DEFAULT 5, found DEFAULT not-an-integer; column completed_at expected NULL, found NOT NULL'); + + $reconciler->reconcile(new SchemaReconciliationTable('wp_example', 5, true)); + } + + public function test_it_does_not_treat_a_missing_default_as_an_empty_string(): void { + $database = new FakeDatabase(); + $database->rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ['Null' => 'NO', 'Default' => '5', 'Extra' => ''], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], + ['Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], + ]; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage("column label expected DEFAULT '', found DEFAULT NULL"); + + $reconciler->reconcile(new SchemaReconciliationTable('wp_example', 5, false)); + } + + public function test_it_rejects_unapplied_column_extra_attributes(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['Null' => 'NO', 'Default' => null, 'Extra' => '']; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column id expected extra auto_increment, found none'); + + $reconciler->reconcile(new TestTable('example', 'wp_example')); + } + + public function test_it_rejects_missing_column_metadata(): void { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('could not inspect wp_example.id'); + + (new Reconciler(new FakeDatabase(), new RecordingSchemaExecutor())) + ->reconcile(new TestTable('example', 'wp_example')); + } + + public function test_it_rejects_invalid_column_metadata(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['Null' => 'MAYBE', 'Default' => null, 'Extra' => '']; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('returned invalid column metadata for wp_example.id'); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new TestTable('example', 'wp_example')); + } +} diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php index e22ea84..f27b44b 100644 --- a/tests/Unit/Database/SchemaTest.php +++ b/tests/Unit/Database/SchemaTest.php @@ -3,36 +3,18 @@ namespace StellarWP\Foundation\Tests\Unit\Database; use StellarWP\Foundation\Database\Schema; +use StellarWP\Foundation\Database\Schema\Reconciler; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchemaExecutor; -use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\TestCase; final class SchemaTest extends TestCase { - public function test_it_runs_create_or_update_sql_through_the_schema_executor(): void { - $executor = new RecordingSchemaExecutor(); - $schema = new Schema(new FakeDatabase(), $executor); - - $schema->createOrUpdateSql('CREATE TABLE example (id bigint)'); - - $this->assertSame(['CREATE TABLE example (id bigint)'], $executor->statements); - } - - public function test_it_builds_table_definitions_for_the_schema_executor(): void { - $executor = new RecordingSchemaExecutor(); - $schema = new Schema(new FakeDatabase(), $executor); - - $schema->createOrUpdate(new TestTable('example', 'wp_example')); - - $this->assertStringContainsString('CREATE TABLE `wp_example`', $executor->statements[0]); - } - public function test_it_checks_tables_and_indexes(): void { $database = new FakeDatabase(); $database->rowResults[] = ['table' => 'wp_example']; $database->rowResults[] = ['Key_name' => 'example_key']; - $schema = new Schema($database, new RecordingSchemaExecutor()); + $schema = new Schema($database, new Reconciler($database, new RecordingSchemaExecutor())); $this->assertTrue($schema->hasTable('wp_example%')); $this->assertTrue($schema->hasIndex('wp_example', 'example_key')); @@ -42,7 +24,7 @@ public function test_it_checks_tables_and_indexes(): void { public function test_it_drops_indexes(): void { $database = new FakeDatabase(); - $schema = new Schema($database, new RecordingSchemaExecutor()); + $schema = new Schema($database, new Reconciler($database, new RecordingSchemaExecutor())); $schema->dropIndex('wp_example', 'example_key'); @@ -50,7 +32,8 @@ public function test_it_drops_indexes(): void { } public function test_it_exposes_identifier_helpers(): void { - $schema = new Schema(new FakeDatabase(), new RecordingSchemaExecutor()); + $database = new FakeDatabase(); + $schema = new Schema($database, new Reconciler($database, new RecordingSchemaExecutor())); $this->assertSame('`weird``table`', $schema->quoteIdentifier('weird`table')); } diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php index 61eead2..313df6b 100644 --- a/tests/Unit/Database/Table/Tables/LockTableTest.php +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Table\Tables; use StellarWP\Foundation\Database\Schema as DatabaseSchema; +use StellarWP\Foundation\Database\Schema\Reconciler; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchemaExecutor; @@ -11,10 +12,11 @@ final class LockTableTest extends TestCase { public function test_it_creates_the_lock_table(): void { - $database = new FakeDatabase(); - $executor = new RecordingSchemaExecutor(); - $schema = new DatabaseSchema($database, $executor); - $table = new LockTable('network_foundation_locks'); + $database = new FakeDatabase(); + $database->rowResults = array_fill(0, 5, ['Null' => 'NO', 'Default' => null, 'Extra' => '']); + $executor = new RecordingSchemaExecutor(); + $schema = new DatabaseSchema($database, new Reconciler($database, $executor)); + $table = new LockTable('network_foundation_locks'); $schema->createOrUpdate($table); @@ -32,7 +34,7 @@ public function test_it_creates_the_lock_table(): void { public function test_it_drops_the_lock_table(): void { $database = new FakeDatabase(); - $schema = new DatabaseSchema($database, new RecordingSchemaExecutor()); + $schema = new DatabaseSchema($database, new Reconciler($database, new RecordingSchemaExecutor())); $table = new LockTable('network_foundation_locks'); $schema->drop($table); diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php index 19f2063..393db11 100644 --- a/tests/Unit/Database/Table/Tables/MigrationTableTest.php +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Table\Tables; use StellarWP\Foundation\Database\Schema as DatabaseSchema; +use StellarWP\Foundation\Database\Schema\Reconciler; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchemaExecutor; @@ -11,9 +12,13 @@ final class MigrationTableTest extends TestCase { public function test_it_creates_the_migration_table(): void { - $database = new FakeDatabase(); + $database = new FakeDatabase(); + $database->rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ...array_fill(0, 3, ['Null' => 'NO', 'Default' => null, 'Extra' => '']), + ]; $executor = new RecordingSchemaExecutor(); - $schema = new DatabaseSchema($database, $executor); + $schema = new DatabaseSchema($database, new Reconciler($database, $executor)); $table = new MigrationTable('network_foundation_migrations'); $schema->createOrUpdate($table); @@ -27,7 +32,7 @@ public function test_it_creates_the_migration_table(): void { public function test_it_drops_the_migration_table(): void { $database = new FakeDatabase(); - $schema = new DatabaseSchema($database, new RecordingSchemaExecutor()); + $schema = new DatabaseSchema($database, new Reconciler($database, new RecordingSchemaExecutor())); $table = new MigrationTable('network_foundation_migrations'); $schema->drop($table); diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 3deb353..4fb1277 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -20,11 +20,14 @@ use StellarWP\Foundation\Database\Migration\Repository; use StellarWP\Foundation\Database\Schema; use StellarWP\Foundation\Database\Schema\DbDelta; +use StellarWP\Foundation\Database\Schema\Reconciler; use StellarWP\Foundation\Database\Table\TableDefinition; use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\LockToken; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\DateTimePrecisionTable; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\SchemaReconciliationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; @@ -50,7 +53,7 @@ protected function setUp(): void { } $this->database = new Database($GLOBALS['wpdb']); - $this->schema = new Schema($this->database, new DbDelta()); + $this->schema = new Schema($this->database, new Reconciler($this->database, new DbDelta())); } protected function tearDown(): void { @@ -216,16 +219,13 @@ public function test_database_wraps_wordpress_query_failures(): void { } } - public function test_provider_schema_reports_db_delta_query_failures(): void { - $table = $this->table('invalid_schema'); - $container = $this->newContainer(); - $container->register(DatabaseProvider::class); - $schema = $container->get(Schema::class); + public function test_db_delta_reports_query_failures(): void { + $table = $this->table('invalid_schema'); $previous = $GLOBALS['wpdb']->suppress_errors(true); try { - $this->assertQueryFails(function () use ($schema, $table): void { - $schema->createOrUpdateSql(sprintf( + $this->assertQueryFails(function () use ($table): void { + (new DbDelta())->execute(sprintf( 'CREATE TABLE %s ( id definitely_invalid NOT NULL, PRIMARY KEY (id) @@ -243,17 +243,13 @@ public function test_schema_creates_inspects_and_changes_tables_through_wordpres $table = $this->table('schema'); $schema = $this->schema; - $schema->createOrUpdateSql(sprintf( - 'CREATE TABLE %s ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - name varchar(191) NOT NULL, - PRIMARY KEY (id), - KEY name (name) - ) %s;', + $schema->createOrUpdate(new TestTable('schema_table', $table)); + $schema->execute(sprintf( + 'ALTER TABLE %s ADD KEY %s (%s)', $this->database->quoteIdentifier($table), - $this->database->charsetCollate() + $this->database->quoteIdentifier('name'), + $this->database->quoteIdentifier('id') )); - $this->assertTrue($schema->hasTable($table)); $this->assertTrue($schema->hasIndex($table, 'name')); @@ -315,6 +311,17 @@ public function definition(): TableDefinition { $this->assertTrue($schema->hasIndex($queue, 'taken_failed_done')); } + public function test_datetime_zero_precision_is_canonical_and_idempotent(): void { + $table = new DateTimePrecisionTable($this->table('datetime_zero')); + + $this->schema->createOrUpdate($table); + $this->schema->createOrUpdate($table); + + $column = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table->name(), 'occurred_at'); + + $this->assertSame('datetime', strtolower((string) ($column['Type'] ?? ''))); + } + public function test_schema_preserves_quote_and_backslash_string_defaults(): void { $tableName = $this->table('string_default'); $default = "customer's \\ path"; @@ -350,6 +357,52 @@ public function definition(): TableDefinition { $this->assertSame($default, $this->database->value('SELECT label FROM %i LIMIT 1', $tableName)); } + public function test_schema_rejects_unapplied_numeric_defaults_and_nullability(): void { + $table = $this->table('column_properties'); + + $this->schema->createOrUpdate(new SchemaReconciliationTable($table, 1, false)); + + try { + $this->schema->createOrUpdate(new SchemaReconciliationTable($table, 5, true)); + $this->fail('Expected unapplied column properties to fail schema reconciliation.'); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('column attempts expected DEFAULT 5, found DEFAULT 1', $exception->getMessage()); + $this->assertStringContainsString('column completed_at expected NULL, found NOT NULL', $exception->getMessage()); + } + + $this->database->execute( + 'ALTER TABLE %i MODIFY COLUMN attempts int(10) NOT NULL DEFAULT 5, MODIFY COLUMN completed_at datetime NULL', + $table + ); + $this->schema->createOrUpdate(new SchemaReconciliationTable($table, 5, true)); + $this->database->execute('INSERT INTO %i (completed_at) VALUES (NULL)', $table); + + $row = $this->database->row('SELECT attempts, completed_at FROM %i LIMIT 1', $table); + + $this->assertSame('5', $row['attempts'] ?? null); + $this->assertNull($row['completed_at'] ?? null); + } + + public function test_schema_rejects_an_unapplied_auto_increment_attribute(): void { + $table = $this->table('column_extra'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s (id bigint(20) unsigned NOT NULL, PRIMARY KEY (id)) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + try { + $this->schema->createOrUpdate(new TestTable('column_extra', $table)); + $this->fail('Expected an unapplied AUTO_INCREMENT attribute to fail schema reconciliation.'); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('column id expected extra auto_increment, found none', $exception->getMessage()); + } + + $this->database->execute('ALTER TABLE %i MODIFY COLUMN id bigint(20) unsigned NOT NULL AUTO_INCREMENT', $table); + $this->schema->createOrUpdate(new TestTable('column_extra', $table)); + } + public function test_migration_repository_persists_records_in_wordpress(): void { $table = $this->table('migrations'); $schema = $this->schema; diff --git a/tests/wpunit/Database/Schema/DbDeltaTest.php b/tests/wpunit/Database/Schema/DbDeltaTest.php index 149efe6..50f1bc7 100644 --- a/tests/wpunit/Database/Schema/DbDeltaTest.php +++ b/tests/wpunit/Database/Schema/DbDeltaTest.php @@ -53,6 +53,26 @@ public function test_it_fails_when_schema_changes_remain_pending(): void { (new DbDelta())->execute(self::SQL); } + public function test_it_ignores_wordpress_62_created_table_dry_run_false_positives(): void { + $table = $GLOBALS['wpdb']->prefix . 'foundation_dbdelta_existing'; + $sql = sprintf('CREATE TABLE `%s` (id bigint)', $table); + + $GLOBALS['wpdb']->query(sprintf('CREATE TABLE `%s` (id bigint)', $table)); + + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with($sql, true)->once()->andReturn([]); + $dbDelta->with($sql, false)->once()->andReturn([ + '`' . $table . '`' => 'Created table `' . $table . '`', + ]); + + try { + (new DbDelta())->execute($sql); + $this->addToAssertionCount(1); + } finally { + $GLOBALS['wpdb']->query(sprintf('DROP TABLE IF EXISTS `%s`', $table)); + } + } + public function test_it_translates_wordpress_database_errors(): void { $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); $dbDelta->with(self::SQL, true)->once()->andReturnUsing(static function (): array { From c37223aa11cb74d3e61d80bbeddedcfab0af29c0 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 11:05:04 -0600 Subject: [PATCH 54/81] Update database schema documentation --- src/Database/README.md | 44 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Database/README.md b/src/Database/README.md index bf8e549..a5d74dc 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -263,6 +263,12 @@ $database->table('reports')->where('deleted_at', '=', null); // IS NULL $database->table('reports')->where('deleted_at', '!=', null); // IS NOT NULL ``` +Qualified columns are quoted by segment, including aliases and select wildcards: + +```php +$database->table('posts', 'p')->select('p.ID', 'p.*')->where('p.post_status', '=', 'publish'); +``` + `Database::insert()` returns the number of affected rows, which works for both auto-increment and application-assigned identifiers such as ULIDs. Use `Database::insertGetId()` only when the table has an auto-increment key and the @@ -273,14 +279,13 @@ generated integer identifier is needed. Migrations implement `StellarWP\Foundation\Database\Contracts\Migration`: ```php -use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Migration; use StellarWP\Foundation\Database\Contracts\Schema; final readonly class CreateReportsTable implements Migration { public function __construct( - private Database $database + private ReportsTable $table ) { } @@ -289,27 +294,11 @@ final readonly class CreateReportsTable implements Migration } public function up(Schema $schema): void { - $table = $this->database->tableName('reports'); - - $schema->createOrUpdateSql( - sprintf( - 'CREATE TABLE %s ( - id bigint unsigned NOT NULL AUTO_INCREMENT, - title varchar(191) NOT NULL, - PRIMARY KEY (id) - );', - $schema->quoteIdentifier($table) - ) - ); + $schema->createOrUpdate($this->table); } public function down(Schema $schema): void { - $table = $this->database->tableName('reports'); - - $schema->execute(sprintf( - 'DROP TABLE IF EXISTS %s', - $schema->quoteIdentifier($table) - )); + $schema->drop($this->table); } } ``` @@ -336,6 +325,13 @@ Register contributing providers in the order their migrations must run. The migr Application feature tables should usually be represented by migrations. If a table only needs normal create/drop behavior, define it with `StellarWP\Foundation\Database\Contracts\Table`, wrap it in `StellarWP\Foundation\Database\Table\CreateTable`, and add that migration instance to `DatabaseProvider::MIGRATIONS`. +`Schema::createOrUpdate()` independently verifies column defaults, nullability, +and extra attributes after WordPress runs `dbDelta()`. If one of those +properties still differs, reconciliation fails before the migration is +recorded. Make data-dependent changes such as backfills or `NULL` to `NOT NULL` +conversions explicitly in a versioned migration, then call `createOrUpdate()` +to verify the final table definition. + ```php use StellarWP\Foundation\Database\Contracts\Database; use StellarWP\Foundation\Database\Contracts\Table; @@ -416,7 +412,7 @@ writes. ## Evolving Tables -`TableDefinition` and `Schema::createOrUpdate()` use WordPress `dbDelta()` to create tables and reconcile changes that `dbDelta()` supports, such as adding columns and indexes. Use `Schema::createOrUpdateSql()` when a migration must provide explicit dbDelta-compatible SQL. They should not be relied on to remove or rename columns, replace indexes, manage foreign keys, or backfill data. +`TableDefinition` and `Schema::createOrUpdate()` use WordPress `dbDelta()` to create tables and reconcile changes that `dbDelta()` supports, such as adding columns and indexes. They should not be relied on to remove or rename columns, replace indexes, manage foreign keys, or backfill data. Use an explicit, versioned migration for destructive or data-dependent changes. Such migrations can inspect table and index state with `Schema::hasTable()` and `Schema::hasIndex()`; inject `Database` when column inspection through `Database::columnExists()` is required. Use `Schema::execute()` or focused helpers such as `dropIndex()` for the required SQL. Make rollback behavior explicit; throw `IrreversibleMigration::forMigration(self::ID)` when a migration cannot be safely reversed. @@ -444,7 +440,11 @@ protected array $providers = [ The table generator writes a Snake_Case table class under `src/Database/Tables` by default. The migration generator writes under `src/Database/Migrations` by default and references the matching table class. -The migration generator never overwrites an existing file. Edit a migration only before it has been applied anywhere; otherwise create a new migration for the next schema change. +The table and migration generators never overwrite existing files. Edit a migration only before it has been applied anywhere; otherwise create a new migration for the next schema change. + +Generated and explicit table and migration IDs follow the runtime ledger rules: they must +be nonblank, contain no surrounding whitespace, fit within 191 bytes, and not +be integer-like strings. Migration names matching `Create_*_Table`, or migrations generated with `--table-class`, use the table-backed migration stub and wrap the table in `CreateTable`. Other migration names use the generic migration stub. From b3ad94efbcc25e2dc8ecd63eae0896860111c2c3 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 11:05:29 -0600 Subject: [PATCH 55/81] Document SLIC workflow version policy --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9dfa730..c1e28ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,8 @@ Tests that need writable temporary files or directories should use a test-specif Codeception tests run through SLIC. Use SLIC 2.3.0 or newer so PCOV-backed coverage commands are available. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:redis` or `slic run redis`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. +GitHub workflows should check out SLIC from `main`; do not pin SLIC to a release tag or commit. + Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `redis` is real Redis behavior shared across packages and run against SLIC's Redis service, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, `wpdb`, `dbDelta()`, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. Use `integration` for behavior where multiple providers/packages must be registered together to prove the container graph works. Use `wpunit` for a single package/class where the main concern is direct WordPress API behavior. Use `wpcli` for real WP-CLI command execution shared across packages. Keep unit tests focused on portable package behavior and pure collaborators; do not build large fake WordPress runtimes in unit tests when the behavior can be covered with wp-browser. From b05826252a10b87eefc5ad115fb10cf706c7d772 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 16:28:54 -0600 Subject: [PATCH 56/81] Refactor WP-CLI command prefix injection --- src/Cli/Commands/Make/WPCliCommand.php | 2 +- src/Database/Cli/Migrate.php | 3 +- src/Database/DatabaseProvider.php | 4 -- src/WPCli/Command.php | 5 +- src/WPCli/ValueObjects/CommandPrefix.php | 22 +++++++++ src/WPCli/WPCliProvider.php | 9 ++-- .../register-wpcli-migrate-command.php | 3 +- .../Fixtures/WPCli/RecordingCommand.php | 6 ++- .../Cli/Commands/Make/WPCliCommandTest.php | 1 + tests/Unit/Database/Cli/MigrateTest.php | 5 +- tests/Unit/WPCli/CommandTest.php | 9 ++-- .../WPCli/ValueObjects/CommandPrefixTest.php | 39 +++++++++++++++ .../Database/DatabaseProviderTest.php | 5 +- tests/integration/WPCli/WPCliProviderTest.php | 47 ++++++++++++++----- 14 files changed, 125 insertions(+), 35 deletions(-) create mode 100644 src/WPCli/ValueObjects/CommandPrefix.php create mode 100644 tests/Unit/WPCli/ValueObjects/CommandPrefixTest.php diff --git a/src/Cli/Commands/Make/WPCliCommand.php b/src/Cli/Commands/Make/WPCliCommand.php index 1a0693b..5703881 100644 --- a/src/Cli/Commands/Make/WPCliCommand.php +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -61,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Created: %s', $file->relativePath)); $output->writeln(''); - $output->writeln('Register this command from your WP-CLI provider and configure its $commandPrefix container argument.'); + $output->writeln('Contribute this command to WPCliProvider::COMMANDS from its feature provider.'); $runtimeDependencyWarning = $this->runtimeDependencyWarning(); diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php index f08b773..f782760 100644 --- a/src/Database/Cli/Migrate.php +++ b/src/Database/Cli/Migrate.php @@ -5,6 +5,7 @@ use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Database\Migration\Migrator; use StellarWP\Foundation\WPCli\Command; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use WP_CLI; use function WP_CLI\Utils\format_items; @@ -24,7 +25,7 @@ final class Migrate extends Command public function __construct( protected Container $container, - string $commandPrefix, + CommandPrefix $commandPrefix, private readonly Migrator $migrator ) { parent::__construct($this->container, $commandPrefix); diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 917fbd6..47b2d0b 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -137,10 +137,6 @@ private function registerLocks(): void { } private function registerCliCommands(): void { - $this->container->when(Migrate::class) - ->needs('$commandPrefix') - ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); - $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ $c->get(Migrate::class), ]); diff --git a/src/WPCli/Command.php b/src/WPCli/Command.php index f96ef36..0f82930 100644 --- a/src/WPCli/Command.php +++ b/src/WPCli/Command.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\WPCli; use StellarWP\Foundation\Container\Contracts\Container; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use WP_CLI; use WP_CLI_Command; @@ -22,7 +23,7 @@ abstract class Command extends WP_CLI_Command public function __construct( protected Container $container, - private readonly string $commandPrefix + private readonly CommandPrefix $commandPrefix ) { parent::__construct(); } @@ -69,7 +70,7 @@ public function register(): void { } protected function command(): string { - return trim($this->commandPrefix . ' ' . $this->subcommand()); + return trim($this->commandPrefix->value . ' ' . $this->subcommand()); } /** diff --git a/src/WPCli/ValueObjects/CommandPrefix.php b/src/WPCli/ValueObjects/CommandPrefix.php new file mode 100644 index 0000000..988f413 --- /dev/null +++ b/src/WPCli/ValueObjects/CommandPrefix.php @@ -0,0 +1,22 @@ +value === '' || trim($this->value) !== $this->value) { + throw new InvalidArgumentException('The WP-CLI command prefix cannot be empty or contain surrounding whitespace.'); + } + } +} diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php index 436fdc5..cedfc52 100644 --- a/src/WPCli/WPCliProvider.php +++ b/src/WPCli/WPCliProvider.php @@ -5,6 +5,7 @@ use InvalidArgumentException; use StellarWP\Foundation\Container\Contracts\Provider; use StellarWP\Foundation\Container\Traits\ResolvesFoundationPrefix; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use UnexpectedValueException; /** @@ -18,8 +19,7 @@ final class WPCliProvider extends Provider { use ResolvesFoundationPrefix; - public const string COMMANDS = 'foundation.wpcli.commands'; - public const string COMMAND_PREFIX = 'foundation.wpcli.command_prefix'; + public const string COMMANDS = 'foundation.wpcli.commands'; /** * @throws InvalidArgumentException When the configured Foundation prefix is invalid. @@ -30,7 +30,10 @@ public function register(): void { ?? $foundationPrefix; $this->container->mergeArrayVar(self::COMMANDS, []); - $this->container->bind(self::COMMAND_PREFIX, $commandPrefix); + $this->container->when(CommandPrefix::class) + ->needs('$value') + ->give($commandPrefix); + $this->container->singleton(CommandPrefix::class); add_action('cli_init', function (): void { $this->registerCommands(); diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php index 581da1e..fbaaa1b 100644 --- a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -20,6 +20,7 @@ use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; if (! class_exists(WP_CLI::class)) { return; @@ -78,7 +79,7 @@ public function down(SchemaContract $schema): void { $command = new Migrate( $container, - 'foundation', + new CommandPrefix('foundation'), new Migrator( new MigrationCollection([$migration]), $repository, diff --git a/tests/Support/Fixtures/WPCli/RecordingCommand.php b/tests/Support/Fixtures/WPCli/RecordingCommand.php index 7f805e2..55d30a3 100644 --- a/tests/Support/Fixtures/WPCli/RecordingCommand.php +++ b/tests/Support/Fixtures/WPCli/RecordingCommand.php @@ -6,14 +6,16 @@ final class RecordingCommand extends Command { - public bool $registered = false; + public static bool $registered = false; + public static ?string $registeredName = null; public function runCommand(array $args = [], array $assocArgs = []): int { return self::SUCCESS; } public function register(): void { - $this->registered = true; + self::$registered = true; + self::$registeredName = $this->command(); } protected function subcommand(): string { diff --git a/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php index bd51834..32f8c09 100644 --- a/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php @@ -52,6 +52,7 @@ public function test_it_generates_a_wpcli_command_from_project_autoload_defaults $this->assertSame(Command::SUCCESS, $statusCode); $this->assertFileExists($path); $this->assertStringContainsString('Created: src/Cli/Commands/Sync_Products_Command.php', $tester->getDisplay()); + $this->assertStringContainsString('Contribute this command to WPCliProvider::COMMANDS from its feature provider.', $tester->getDisplay()); $contents = (string) file_get_contents($path); diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index fd69326..55b65ce 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -15,6 +15,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\TestCase; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use WP_CLI; final class MigrateTest extends TestCase @@ -35,7 +36,7 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $store = new Store($schema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); $command = new Migrate( $this->container, - 'foundation', + new CommandPrefix('foundation'), new Migrator( new MigrationCollection(), $repository, @@ -209,7 +210,7 @@ private function newCommand(): array { $store = new Store($wpSchema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); $command = new Migrate( $this->container, - 'foundation', + new CommandPrefix('foundation'), new Migrator( new MigrationCollection([ new TestMigration('2026_06_23_000001_create_example'), diff --git a/tests/Unit/WPCli/CommandTest.php b/tests/Unit/WPCli/CommandTest.php index a04c395..dbf9bba 100644 --- a/tests/Unit/WPCli/CommandTest.php +++ b/tests/Unit/WPCli/CommandTest.php @@ -6,12 +6,13 @@ use PHPUnit\Framework\Attributes\RunInSeparateProcess; use StellarWP\Foundation\Tests\Support\Fixtures\WPCli\TestCommand; use StellarWP\Foundation\Tests\TestCase; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use WP_CLI; final class CommandTest extends TestCase { public function test_it_runs_a_foundation_wp_cli_command(): void { - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $this->assertSame(0, $command->runCommand(['value'], ['flag' => true])); $this->assertSame(['value'], $command->args); @@ -28,7 +29,7 @@ public function test_it_runs_a_foundation_wp_cli_command(): void { } public function test_it_asks_for_normalized_input(): void { - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $result = $command->promptWithInput('Continue?', 'YES' . PHP_EOL); @@ -37,7 +38,7 @@ public function test_it_asks_for_normalized_input(): void { } public function test_it_exposes_default_input_and_output_streams(): void { - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $this->assertIsResource($command->defaultInput()); $this->assertIsResource($command->defaultOutput()); @@ -58,7 +59,7 @@ public function test_it_registers_with_wp_cli_using_the_prefixed_command_name(): require_once $wpCliRoot . '/php/utils.php'; - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $command->register(); $deferredAdditions = WP_CLI::get_deferred_additions(); diff --git a/tests/Unit/WPCli/ValueObjects/CommandPrefixTest.php b/tests/Unit/WPCli/ValueObjects/CommandPrefixTest.php new file mode 100644 index 0000000..56fa360 --- /dev/null +++ b/tests/Unit/WPCli/ValueObjects/CommandPrefixTest.php @@ -0,0 +1,39 @@ +assertSame('your-plugin', (new CommandPrefix('your-plugin'))->value); + } + + /** + * @dataProvider invalidPrefixes + */ + #[DataProvider('invalidPrefixes')] + public function test_it_rejects_an_invalid_prefix(string $prefix): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be empty or contain surrounding whitespace'); + + new CommandPrefix($prefix); + } + + /** + * @return iterable + */ + public static function invalidPrefixes(): iterable { + yield 'empty' => ['']; + + yield 'spaces' => [' ']; + + yield 'leading whitespace' => [' your-plugin']; + + yield 'trailing whitespace' => ['your-plugin ']; + } +} diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php index f9d2aa8..8a9a133 100644 --- a/tests/integration/Database/DatabaseProviderTest.php +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -21,6 +21,7 @@ use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; use StellarWP\Foundation\WPCli\Command; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use StellarWP\Foundation\WPCli\WPCliProvider; final class DatabaseProviderTest extends WPTestCase @@ -66,7 +67,7 @@ public function test_it_registers_configured_database_configuration(): void { $this->assertSame('custom_locks', $container->get(LockTable::class)->name()); $this->assertSame('custom-migrations', $container->get(DatabaseProvider::LOCK_NAME)); $this->assertSame(120, $container->get(DatabaseProvider::LOCK_TTL)); - $this->assertSame('custom', $container->get(WPCliProvider::COMMAND_PREFIX)); + $this->assertSame('custom', $container->get(CommandPrefix::class)->value); } public function test_it_rejects_an_invalid_foundation_prefix_when_database_resources_are_overridden(): void { @@ -100,7 +101,7 @@ public function test_it_scopes_default_resources_with_the_foundation_prefix(): v $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); $this->assertSame('your-plugin-foundation-database-migrations', $container->get(DatabaseProvider::LOCK_NAME)); - $this->assertSame('your-plugin', $container->get(WPCliProvider::COMMAND_PREFIX)); + $this->assertSame('your-plugin', $container->get(CommandPrefix::class)->value); } public function test_it_applies_configured_lock_policy_to_the_migration_store(): void { diff --git a/tests/integration/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php index 3495430..8a5b6a2 100644 --- a/tests/integration/WPCli/WPCliProviderTest.php +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -8,6 +8,7 @@ use stdClass; use StellarWP\Foundation\Tests\Support\Fixtures\WPCli\RecordingCommand; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use StellarWP\Foundation\WPCli\WPCliProvider; use UnexpectedValueException; @@ -17,7 +18,10 @@ public function test_it_preserves_the_zero_configuration_command_prefix(): void $this->container->singleton(Dot::class, new Dot()); $this->container->register(WPCliProvider::class); - $this->assertSame('nx', $this->container->get(WPCliProvider::COMMAND_PREFIX)); + $commandPrefix = $this->container->get(CommandPrefix::class); + + $this->assertSame('nx', $commandPrefix->value); + $this->assertSame($commandPrefix, $this->container->get(CommandPrefix::class)); } public function test_it_uses_the_foundation_prefix_by_default(): void { @@ -29,7 +33,22 @@ public function test_it_uses_the_foundation_prefix_by_default(): void { $this->container->register(WPCliProvider::class); - $this->assertSame('your-plugin', $this->container->get(WPCliProvider::COMMAND_PREFIX)); + $this->assertSame('your-plugin', $this->container->get(CommandPrefix::class)->value); + } + + public function test_it_uses_the_package_specific_command_prefix(): void { + $this->container->singleton(Dot::class, new Dot([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'wpcli' => [ + 'command_prefix' => 'your-plugin-tools', + ], + ])); + + $this->container->register(WPCliProvider::class); + + $this->assertSame('your-plugin-tools', $this->container->get(CommandPrefix::class)->value); } public function test_it_rejects_an_invalid_foundation_prefix_when_the_command_prefix_is_overridden(): void { @@ -49,11 +68,14 @@ public function test_it_rejects_an_invalid_foundation_prefix_when_the_command_pr } public function test_it_registers_configured_commands_on_cli_init(): void { - $this->container->when(RecordingCommand::class) - ->needs('$commandPrefix') - ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); + $this->container->singleton(Dot::class, new Dot([ + 'wpcli' => [ + 'command_prefix' => 'your-plugin-tools', + ], + ])); - $this->container->singleton(RecordingCommand::class); + RecordingCommand::$registered = false; + RecordingCommand::$registeredName = null; $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ $c->get(RecordingCommand::class), ]); @@ -62,15 +84,13 @@ public function test_it_registers_configured_commands_on_cli_init(): void { do_action('cli_init'); - $this->assertTrue($this->container->get(RecordingCommand::class)->registered); + $this->assertTrue(RecordingCommand::$registered); + $this->assertSame('your-plugin-tools recording', RecordingCommand::$registeredName); } public function test_it_rejects_invalid_commands_before_registering_any_command(): void { - $this->container->when(RecordingCommand::class) - ->needs('$commandPrefix') - ->give(static fn (C $c): string => $c->get(WPCliProvider::COMMAND_PREFIX)); - - $this->container->singleton(RecordingCommand::class); + RecordingCommand::$registered = false; + RecordingCommand::$registeredName = null; $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ $c->get(RecordingCommand::class), new stdClass(), @@ -83,7 +103,8 @@ public function test_it_rejects_invalid_commands_before_registering_any_command( try { do_action('cli_init'); } finally { - $this->assertFalse($this->container->get(RecordingCommand::class)->registered); + $this->assertFalse(RecordingCommand::$registered); + $this->assertNull(RecordingCommand::$registeredName); } } From 53f5916969d9d3daed10c71e41f0d8cd8e958db3 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 16:49:41 -0600 Subject: [PATCH 57/81] Simplify generated table migrations --- .../Commands/Make/Database/MigrationCommand.php | 15 +++++++-------- src/Cli/Commands/Make/Database/TableCommand.php | 4 ++-- src/Database/stubs/table-migration.stub | 5 ++--- .../Cli/Commands/Make/DatabaseCommandTest.php | 6 ++++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php index afb1eca..a9872a3 100644 --- a/src/Cli/Commands/Make/Database/MigrationCommand.php +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -108,14 +108,13 @@ private function generatedFile(InputInterface $input): GeneratedFile { path: $path . '/' . $className . '.php', relativePath: $relative, contents: $this->stubRenderer->render($stub, [ - 'namespace' => $namespace, - 'class' => $className, - 'id_php' => $this->phpString($id), - 'table_class' => $tableClass, - 'table_namespace' => $tableNamespace, - 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), - 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), - 'foundation_database_create_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\CreateTable'), + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_class' => $tableClass, + 'table_namespace' => $tableNamespace, + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), ]) ); } diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php index 5b11960..9a96f2e 100644 --- a/src/Cli/Commands/Make/Database/TableCommand.php +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -23,7 +23,7 @@ * Generates a WordPress-style table class for Foundation Database migrations. * * Use this from a consuming WordPress project when a feature needs a table - * definition that can be wrapped in a Foundation `CreateTable` migration. + * definition that can be applied by a Foundation migration. */ final class TableCommand extends Command { @@ -65,7 +65,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Created: %s', $file->relativePath)); $output->writeln(''); - $output->writeln('Add this table to a migration, usually with StellarWP\Foundation\Database\Table\CreateTable.'); + $output->writeln('Add this table to a migration with Schema::createOrUpdate() and Schema::drop().'); if ($providerPath !== null) { $output->writeln(sprintf('Updated: %s', $this->relativePath($providerPath))); diff --git a/src/Database/stubs/table-migration.stub b/src/Database/stubs/table-migration.stub index 08afb1d..9cb6e2a 100644 --- a/src/Database/stubs/table-migration.stub +++ b/src/Database/stubs/table-migration.stub @@ -4,7 +4,6 @@ namespace {{ namespace }}; use {{ foundation_database_migration }}; use {{ foundation_database_schema }}; -use {{ foundation_database_create_table }}; use {{ table_namespace }}\{{ table_class }}; final readonly class {{ class }} implements Migration { @@ -21,11 +20,11 @@ final readonly class {{ class }} implements Migration { } public function up( Schema $schema ): void { - ( new CreateTable( $this->table ) )->up( $schema ); + $schema->createOrUpdate( $this->table ); } public function down( Schema $schema ): void { - ( new CreateTable( $this->table ) )->down( $schema ); + $schema->drop( $this->table ); } } diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php index 04adf26..a2dfb15 100644 --- a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -55,6 +55,7 @@ public function test_it_generates_a_database_table_from_project_autoload_default $this->assertSame(Command::SUCCESS, $statusCode); $this->assertFileExists($path); $this->assertStringContainsString('Created: src/Database/Tables/Reports_Table.php', $tester->getDisplay()); + $this->assertStringContainsString('Add this table to a migration with Schema::createOrUpdate() and Schema::drop().', $tester->getDisplay()); $contents = (string) file_get_contents($path); @@ -89,12 +90,13 @@ public function test_it_generates_a_database_migration_from_project_autoload_def $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Migrations;', $contents); $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $contents); $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Schema;', $contents); - $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Table\\CreateTable;', $contents); $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); $this->assertStringContainsString('final readonly class Create_Reports_Table implements Migration {', $contents); $this->assertStringContainsString("public const string ID = '2026_06_26_000001_create_reports_table';", $contents); $this->assertStringContainsString('private Reports_Table $table', $contents); - $this->assertStringContainsString('( new CreateTable( $this->table ) )->up( $schema );', $contents); + $this->assertStringContainsString('$schema->createOrUpdate( $this->table );', $contents); + $this->assertStringContainsString('$schema->drop( $this->table );', $contents); + $this->assertStringNotContainsString('CreateTable', $contents); } public function test_it_generates_a_generic_database_migration_for_non_table_names(): void { From 6d52d803c2daf7c298e03eeabd4c8af816cfd7a1 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Fri, 21 Aug 2026 17:24:45 -0600 Subject: [PATCH 58/81] WIP: foundation-docs --- .gitattributes | 1 + .github/bin/repo-map.sh | 4 +- AGENTS.md | 37 + README.md | 5 +- src/Cli/README.md | 130 +- src/Container/README.md | 144 +- src/Database/README.md | 482 +- src/Docs/.gitattributes | 3 + .../.github/workflows/close-pull-request.yml | 13 + src/Docs/.gitignore | 13 + src/Docs/.nvmrc | 1 + src/Docs/README.md | 24 + src/Docs/astro.config.mjs | 65 + src/Docs/package-lock.json | 8995 +++++++++++++++++ src/Docs/package.json | 21 + src/Docs/public/favicon.svg | 4 + src/Docs/src/content.config.ts | 8 + .../src/content/docs/components/container.mdx | 208 + .../src/content/docs/components/database.mdx | 142 + .../content/docs/components/database/lock.mdx | 104 + .../docs/components/database/migrations.mdx | 214 + .../components/database/query-builder.mdx | 166 + .../content/docs/components/identifier.mdx | 251 + src/Docs/src/content/docs/components/lock.mdx | 342 + src/Docs/src/content/docs/components/log.mdx | 252 + .../src/content/docs/components/pipeline.mdx | 325 + .../src/content/docs/components/wp-cli.mdx | 367 + src/Docs/src/content/docs/index.mdx | 40 + .../docs/start/bootstrap-wordpress-plugin.md | 182 + .../docs/start/configure-the-container.md | 73 + .../content/docs/start/install-foundation.md | 69 + .../docs/start/register-service-providers.md | 148 + .../content/docs/start/scope-foundation.md | 73 + .../content/docs/start/what-is-foundation.md | 52 + .../content/docs/tooling/foundation-cli.mdx | 234 + src/Docs/src/content/i18n/en.json | 3 + src/Docs/src/styles/custom.css | 62 + src/Docs/tsconfig.json | 3 + src/Identifier/README.md | 46 +- src/Lock/README.md | 126 +- src/LockRedis/README.md | 134 +- src/Log/README.md | 43 +- src/Pipeline/README.md | 9 +- src/WPCli/README.md | 179 +- 44 files changed, 12563 insertions(+), 1234 deletions(-) create mode 100644 src/Docs/.gitattributes create mode 100644 src/Docs/.github/workflows/close-pull-request.yml create mode 100644 src/Docs/.gitignore create mode 100644 src/Docs/.nvmrc create mode 100644 src/Docs/README.md create mode 100644 src/Docs/astro.config.mjs create mode 100644 src/Docs/package-lock.json create mode 100644 src/Docs/package.json create mode 100644 src/Docs/public/favicon.svg create mode 100644 src/Docs/src/content.config.ts create mode 100644 src/Docs/src/content/docs/components/container.mdx create mode 100644 src/Docs/src/content/docs/components/database.mdx create mode 100644 src/Docs/src/content/docs/components/database/lock.mdx create mode 100644 src/Docs/src/content/docs/components/database/migrations.mdx create mode 100644 src/Docs/src/content/docs/components/database/query-builder.mdx create mode 100644 src/Docs/src/content/docs/components/identifier.mdx create mode 100644 src/Docs/src/content/docs/components/lock.mdx create mode 100644 src/Docs/src/content/docs/components/log.mdx create mode 100644 src/Docs/src/content/docs/components/pipeline.mdx create mode 100644 src/Docs/src/content/docs/components/wp-cli.mdx create mode 100644 src/Docs/src/content/docs/index.mdx create mode 100644 src/Docs/src/content/docs/start/bootstrap-wordpress-plugin.md create mode 100644 src/Docs/src/content/docs/start/configure-the-container.md create mode 100644 src/Docs/src/content/docs/start/install-foundation.md create mode 100644 src/Docs/src/content/docs/start/register-service-providers.md create mode 100644 src/Docs/src/content/docs/start/scope-foundation.md create mode 100644 src/Docs/src/content/docs/start/what-is-foundation.md create mode 100644 src/Docs/src/content/docs/tooling/foundation-cli.mdx create mode 100644 src/Docs/src/content/i18n/en.json create mode 100644 src/Docs/src/styles/custom.css create mode 100644 src/Docs/tsconfig.json diff --git a/.gitattributes b/.gitattributes index 8fcef21..ba56558 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,7 @@ /.github export-ignore /docs export-ignore /dev export-ignore +/src/Docs export-ignore /.env.testing.slic export-ignore /.env.slic.local export-ignore /.env.slic.run export-ignore diff --git a/.github/bin/repo-map.sh b/.github/bin/repo-map.sh index a4e10ec..b8c9af8 100755 --- a/.github/bin/repo-map.sh +++ b/.github/bin/repo-map.sh @@ -28,10 +28,10 @@ else fi # Use jq to generate the JSON array directly without line breaks -packages_json=$(find "$root/src" -name composer.json -print0 | +packages_json=$(find "$root/src" -mindepth 2 -maxdepth 2 \( -name composer.json -o -name package.json \) -print0 | while IFS= read -r -d $'\0' file; do # Extract the package name and directory - package_name=$(jq -r '.name' < "$file" | sed 's/stellarwp\///') + package_name=$(jq -r '.name' < "$file" | sed -E 's/^@?stellarwp\///') relative_directory=$(realpath --relative-to="$root/src" "$(dirname "$file")") # Build the JSON object for each package diff --git a/AGENTS.md b/AGENTS.md index c1e28ed..73b2815 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Initial packages: - `stellarwp/foundation-pipeline` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` +- `stellarwp/foundation-docs` ## Namespaces @@ -89,6 +90,8 @@ $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array The command class will then be autowired only when `WPCliProvider` resolves the command collection during `cli_init`. +`WPCliProvider` owns the configured `StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix` singleton. Commands with explicit constructors should accept that object and pass it to the base `Command` constructor. Feature providers should not repeat contextual `$commandPrefix` bindings or define their own command-prefix container entries. + If local scaffolding assets such as `foundation/stubs/` should not be included in a consuming project's release archive, add them to that project's `.gitattributes` production zip exclusions. ## Container Providers @@ -105,6 +108,10 @@ Classes should receive service collaborators through constructor injection. Dire Organize provider registration by feature or capability, not by container mechanism. The main `register()` method should call focused private methods such as `registerConfiguration()`, `registerMigrations()`, `registerLocks()`, or `registerCliCommands()`. Keep each feature's contextual bindings beside the classes they configure. Avoid generic methods such as `configureContextualBindings()` that group unrelated bindings only because they use the same container API. +Treat configured pipelines as provider-owned services. Bind each distinct pipe sequence under a feature-local container identifier, then use a contextual binding to give each consumer the pipeline it needs. Consumers should send values and choose the destination without assembling their own pipe lists. Use `bind()` rather than `singleton()` because `Pipeline` carries mutable execution state. + +Register infrastructure providers and top-level feature providers from the application's composition root, such as the ordered provider list in `App.php`. A provider that registers definitions, configuration, or hooks must not also register other providers. The exception is a feature composition provider whose sole responsibility is registering that feature's internal providers; it should contain no service bindings, configuration, hooks, or other behavior. Keep cross-feature and application-level dependencies visible in the `App.php` provider list. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. @@ -127,6 +134,8 @@ Each split package should include: - `.gitignore` - `.github/workflows/close-pull-request.yml` +Non-Composer split projects may use their ecosystem manifest instead of `composer.json`. For example, `src/Docs/` uses `package.json` and must remain discoverable by `.github/bin/repo-map.sh` so it splits to `stellarwp/foundation-docs`. All other required split-repository files and warning text still apply. + When adding a new split package, add its `stellarwp/foundation-` repository link to the root `README.md` repositories list. Each split package `README.md` must include this warning immediately after the package heading: @@ -177,6 +186,34 @@ After adding or changing split package dependencies, run `composer monorepo merg Use `composer monorepo list` to inspect available Monorepo Builder commands. +## Documentation + +The documentation site lives in `src/Docs/` and uses Astro with Starlight. Use the Node version in `src/Docs/.nvmrc`, install dependencies with `npm ci`, and run `npm run build` from `src/Docs/` after documentation changes. + +Write public documentation as current product documentation. Do not mention implementation phases, review checkpoints, future documentation work, or temporary plans. If code behavior, public APIs, package requirements, configuration, or supported integrations change, update the relevant documentation in the same change. Add a component guide and sidebar entry when adding a public split package. + +Once a component has a central documentation guide, keep its split-package `README.md` focused on a short overview, installation, and links to the canonical guide. Do not duplicate full configuration and usage documentation across the README and documentation site. + +Structure component guides for scanning. Prefer a small set of root sections such as `Installation`, `Configuration`, `Usage`, and `Testing`, with task-oriented subsections beneath them. Avoid a long flat list of root headings. Lead with the decision a developer must make, then show installation, configuration, the simplest complete use case, important failure behavior, and testing. + +When one component exposes several independently used capabilities, use a concise overview page with nested task guides instead of forcing every capability into one long page. Keep shared installation and configuration on the overview, then give each task guide one clear ownership boundary. Link to canonical shared behavior rather than duplicating it across component pages. + +Place operational warnings beside the decision or API behavior they qualify. State the concrete failure mode, distinguish expected outcomes from infrastructure failures, tell the developer whether to skip, retry, or abort, and include compact pseudocode when the response would otherwise remain ambiguous. + +Order sequential setup guides so files are created before later examples reference or call them. When a component example assumes the application composition root or provider architecture, link back to the relevant Start Here guides. Prefer Starlight link cards for these prerequisite guides and Starlight asides for decisions or warnings developers must not miss. + +Keep runtime and developer dependencies distinct in installation documentation. Standalone WordPress plugins should require only the split runtime packages they ship and install `stellarwp/foundation-cli` with `--dev`. Before showing `composer require stellarwp/foundation`, warn that the aggregate package includes the developer CLI in its normal installation and that `--no-dev` will not remove it. + +Explain `foundation.prefix` according to the deployment boundary. A complete WordPress application that centrally owns its themes, plugins, and Foundation composition root can use the shared `nx` default. A distributable standalone plugin must configure a stable, unique prefix because PHP namespace prefixing does not isolate shared WP-CLI command names, database tables, or locks. + +Documentation examples for consuming WordPress projects should use Snake_Case class names and WordPress formatting, including a blank line immediately after each class declaration's opening brace. Keep translatable user-facing text in the class that renders it; configuration examples should represent runtime or deployment behavior rather than untranslated display copy. Validate required scalar configuration at construction boundaries when an empty value would make the feature invalid. + +WP-CLI command class examples should include one or more `@example` annotations showing the complete `wp ` invocation, including a representative invocation with options or flags when applicable. + +Use the canonical application architecture in WordPress examples: `App` owns the request singleton, providers are registered in explicit dependency order, and feature providers group definitions and hooks by capability. Use `$this->container->callback(ClassName::class, 'method')` for WordPress callbacks when the container should resolve the service lazily. + +Keep full source paths in the prose immediately before code examples and use only the filename in a code-block `title`. Nova's Shiki metadata parser interprets path segments such as `/Lock/` as word-highlighting instructions and otherwise adds unintended borders around matching code tokens. + ## Verification When `composer lint` reports style-only issues, run `composer format` to let the project formatter fix them before making manual formatting edits. diff --git a/README.md b/README.md index 98f88fe..368a3d8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Foundation -Foundation is a StellarWP Composer monorepo for reusable PHP packages intended for libraries and WordPress plugin ecosystems. +Foundation is a StellarWP Composer monorepo of shared PHP infrastructure for Nexcess libraries and WordPress plugins. Its packages are publicly available, while Nexcess application needs primarily drive changes and the roadmap. > [!NOTE] > This monorepo splits each package out into their own sub-repository, if you only need a specific component you can install only that specific one. +See the [Foundation documentation](https://foundation.stellarwp.com/) for installation, application architecture, component configuration, and developer tooling. + ## Repositories | Package | Use when | Installation | @@ -18,6 +20,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f | [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) | Services need injectable ULID generation and validation | Runtime | | [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) | A shipped WordPress plugin exposes WP-CLI commands | Runtime | | [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) | Developers need Foundation generators or monorepo maintenance commands | Development | +| [stellarwp/foundation-docs](https://github.com/stellarwp/foundation-docs) | Contributors maintain or deploy the Foundation documentation site | Documentation | ## Installation diff --git a/src/Cli/README.md b/src/Cli/README.md index f37d5c3..ac17539 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -3,137 +3,17 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -Foundation CLI tooling for generating application code and maintaining the Foundation monorepo. +Foundation CLI provides developer tooling for generating Foundation-aware project code and maintaining the Foundation monorepo. ## Installation Install this package as a development dependency in consuming projects: -```bash +```shell composer require --dev stellarwp/foundation-cli ``` -`foundation-cli` is build-time tooling. It should not be registered in a WordPress plugin application and should not be packaged in production plugin zips. Use Composer's `--no-dev` install mode for production builds so the CLI, Symfony Console, generators, and local tooling stay out of the runtime artifact. +## Documentation -If a generated WP-CLI command ships with the plugin, install `stellarwp/foundation-wpcli` as a normal runtime dependency: - -```bash -composer require stellarwp/foundation-wpcli -``` - -## Generators - -List all available commands: - -```bash -vendor/bin/foundation list -``` - -Foundation CLI includes generators for packages that own generated class shapes. For example, the WPCli package provides a generator for command classes: - -```bash -vendor/bin/foundation make:wpcli-command Sync_Products_Command -``` - -The Database package provides generators for a feature provider, table definitions, and migrations: - -```bash -vendor/bin/foundation make:database-provider -vendor/bin/foundation make:database-table Reports_Table -vendor/bin/foundation make:database-migration Create_Reports_Table -``` - -Database tables and migrations are never overwritten by the generators. Edit a migration only before it has been applied anywhere; otherwise generate a new migration for the next schema change. - -Generated database providers, tables, and migrations require `stellarwp/foundation-database` as a normal runtime dependency when they ship with the project: - -```bash -composer require stellarwp/foundation-database -``` - -Do not add `StellarWP\Foundation\Cli\CliProvider` to the consuming WordPress plugin's provider list. That provider only boots the Foundation Symfony Console application for the `foundation` binary. Register generated WP-CLI commands from the plugin's own WP-CLI provider using `stellarwp/foundation-wpcli`. - -See the WPCli and Database package READMEs for generator behavior, options, and stub overrides. - -## Foundation Monorepo Maintenance - -The `package:create` command is for maintainers working inside the Foundation monorepo. It creates local split-package scaffolding and can create GitHub repositories for Foundation split packages. - -Create a split repository for a new Foundation package: - -```bash -vendor/bin/foundation package:create Log -``` - -If the package does not exist yet, the command asks whether to create the local scaffold in `src/` and asks for the Composer package name. For example, `WPCli` defaults to `stellarwp/foundation-wpcli`. After scaffolding, it runs `composer monorepo merge` so the root package metadata includes the new split package. - -By default, commands that change external systems run as a dry run. Pass `--apply` to execute the generated repository actions. - -In the Foundation monorepo, the root Composer script can also be used: - -```bash -composer run foundation -- package:create Log -``` - -## Custom Commands - -Applications can build their own Foundation CLI by creating Symfony Console commands and registering them with `StellarWP\Foundation\Cli\Application`. - -```php -writeln('Cache cleared.'); - - return Command::SUCCESS; - } -} -``` - -For one or more related commands, group them behind a command provider. - -```php -run()); -``` - -When commands need shared services, register them in your container and pass constructed commands or command providers into the `Application`. +See the [Foundation CLI documentation](https://foundation.stellarwp.com/tooling/foundation-cli/) +for project generators, stub overrides, Strauss support, custom commands, and monorepo maintenance. diff --git a/src/Container/README.md b/src/Container/README.md index 5f06606..344a930 100644 --- a/src/Container/README.md +++ b/src/Container/README.md @@ -3,8 +3,10 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -The DI Container configuration and Service Provider implementation, utilizing -[di52](https://github.com/lucatume/di52). +Foundation Container adapts [DI52](https://github.com/lucatume/di52) behind a +shared container contract and service provider base class. It supports +autowiring, contextual bindings, lazy WordPress callbacks, cross-provider +collections, and decorator chains. ## Installation @@ -12,139 +14,7 @@ The DI Container configuration and Service Provider implementation, utilizing composer require stellarwp/foundation-container ``` -## Container Configuration +## Documentation -Create a new ContainerAdapter, by passing in an instance of di52: - -```php -load(); -} - -// This implements the Contracts/Container.php interface. -$container = new ContainerAdapter(new Container()); - -// Bind the concrete to the interface, so anytime we ask for a container we get this one. -$container->bind(Container::class, $container); - -// Register our project's configuration. See "Making a config.php" below for more detail. -$container->bind(Dot::class, new Dot(require_once dirname(__FILE__) . '/config.php')); - -// Register any service providers in the container. -$providers = [ - StellarWP\YourProject\ServiceProvider::class, - // as many as you have made... -]; - -foreach ( $providers as $provider ) { - $container->register($provider); -} -``` - -Here is an example Service Provider: - -```php -container->bind(Storage::class, LocalStorage::class); - $this->container->when(LocalStorage::class) - ->needs('$storagePath') - ->give($this->config->get('storage_path')); - } -} -``` - -## Environment Variable Configuration - -This library uses the [Dot](https://github.com/adbario/php-dot-notation) package to set and fetch configuration -values, which are initially provided via Environment Variables, either manually set or via an `.env` file -utilizing [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) to read them. - -Each Service Provider will have access to Dot via the `$this->config` property, it is best practice to only -access configuration variables from a Service Provider and never in your concrete classes. - -### Making a config.php - -A sample config.php for a project. Note: we fall back to sane defaults if the environment variable isn't available. - -```php - [ - // For example, "your-plugin" in a distributable plugin. - 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? '', - ], - 'some_key' => $_ENV['SOME_KEY'] ?? '', - 'log' => [ - 'level' => $_ENV['LOG_LEVEL'] ?? 'debug', - 'channel' => $_ENV['LOG_CHANNEL'] ?? 'null', - 'channels' => [ - 'errorlog' => [], - 'console' => [ - 'with' => [ - 'stream' => 'php://stdout', - ], - ], - ], - ], -]; -``` - -`foundation.prefix` is optional and defaults to `nx`. Set it to a stable, unique -lowercase kebab-case value when Foundation is bundled into a distributable -plugin. Foundation Database and Foundation WP-CLI use it to scope database -tables, lock names, and WP-CLI commands. Package-specific configuration -continues to override values derived from this prefix. The shared prefix must -still be valid when package-specific overrides are configured. Replace -`your-plugin` with the plugin's own stable prefix. - -> [!IMPORTANT] -> The `nx` default provides a zero-configuration starting point. A distributable -> plugin must set its own stable, unique prefix to avoid sharing tables, locks, -> or WP-CLI command names with another Foundation consumer. - -Inside a Provider, we can then access deep variables with dot notation, e.g. - -```php -// Get the console log stream type from config.php. -$stream = $this->config->get('log.channels.console.with.stream'); - -// Get the log level. -$level = $this->config->get('log.level'); -``` - -> 💡 If using a `config.php` in a WordPress plugin, simply add your configured $_ENV vars in your wp-config.php. - -```php -// Inside wp-config.php - -// App configuration. -$_ENV['SOME_KEY'] = 'abcd-1234'; -$_ENV['LOG_LEVEL'] = 'info'; -$_ENV['LOG_CHANNEL'] = 'errorlog'; -``` +See the [Foundation Container documentation](https://foundation.stellarwp.com/components/container/) +for application setup, bindings, provider collections, lazy callbacks, and testing. diff --git a/src/Database/README.md b/src/Database/README.md index a5d74dc..2a11e63 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -3,485 +3,17 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). +Foundation Database provides WordPress-backed table definitions, versioned +migrations, a small query API, database-backed locks, and a WP-CLI deployment +workflow built on `wpdb` and `dbDelta()`. + ## Installation ```shell composer require stellarwp/foundation-database ``` -## Overview - -Foundation Database is a WordPress-backed database package. It provides a configured migrator, migration collection, `wpdb`/`dbDelta` schema services, a database-backed lock, and a WP-CLI migration command. - -This package intentionally targets WordPress runtime APIs instead of acting as a generic database abstraction. Migration classes depend on a small schema contract so application packages can define migration behavior without calling `wpdb` directly. - -Foundation Database requires WordPress 6.2 or newer because its query layer -uses the `%i` identifier placeholder. Database-backed locks additionally require -fractional-second temporal values: MySQL 5.6.4 or newer, or MariaDB 5.3 or -newer. - -## Running Migrations - -Use the included WP-CLI command as the standard way to initialize migration -storage and run migrations. Register the WP-CLI provider before the database -provider so the database command is added to the configured command list: - -```php -use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\WPCli\WPCliProvider; - -$container->register(WPCliProvider::class); -$container->register(DatabaseProvider::class); -``` - -During deployment, initialize the migration store and then run pending -migrations: - -```bash -wp nx migrate --initialize -wp nx migrate --run -``` - -`--initialize` is idempotent and creates or reconciles Foundation's internal -migration and lock tables. Run it before migration operations, including after -updating Foundation Database. Migration operations fail with an actionable -error when storage has not been initialized. - -Use the remaining commands to inspect or manage migrations: - -```bash -# Show migration status. -wp nx migrate - -# Roll back the latest migration batch. -wp nx migrate --rollback - -# Roll back every known migration and run them again. -wp nx migrate --refresh --yes - -# Drop only the internal migration ledger. -wp nx migrate --drop-store --yes -``` - -`--drop-store` preserves application tables and shared lock storage. It causes -all configured migrations to appear pending after storage is initialized again; -it is not a substitute for rollback because it does not call migration `down()` -methods. Use only one operation flag at a time. `--yes` only skips confirmation -for destructive operations. - -These examples use the default `nx` command prefix. A distributable plugin must -set `foundation.prefix` to scope all supported Foundation resources with one -stable value. For example, `your-plugin` changes this command to -`wp your-plugin migrate`. Set `wpcli.command_prefix` when only the WP-CLI prefix -needs a different value. - -## Database Configuration - -The recommended WP-CLI setup above registers `DatabaseProvider`. Projects that -run migrations programmatically must still register it in the application -container: - -```php -use StellarWP\Foundation\Database\DatabaseProvider; - -$container->register(DatabaseProvider::class); -``` - -The provider registers: - -- `StellarWP\Foundation\Database\Database` -- `StellarWP\Foundation\Database\Contracts\Database` -- `StellarWP\Foundation\Database\Schema` -- `StellarWP\Foundation\Database\Table\Tables\MigrationTable` -- `StellarWP\Foundation\Database\Table\Tables\LockTable` -- `StellarWP\Foundation\Database\Contracts\Repository` for the migration ledger -- `StellarWP\Foundation\Database\Migration\Migrator` -- `StellarWP\Foundation\Database\Lock\DatabaseLock` for the migrator - -By default, WordPress tables are named: - -- `nx_foundation_migrations` -- `nx_foundation_locks` -- migration lock name `nx-foundation-database-migrations` - -When `foundation.prefix` is `your-plugin`, the defaults become: - -- `your_plugin_foundation_migrations` -- `your_plugin_foundation_locks` -- migration lock name `your-plugin-foundation-database-migrations` - -Because the prefix participates in database table names, the final table name, -including the WordPress table prefix, must fit MySQL's 64-character identifier limit. - -The configured Foundation prefix must be a stable lowercase kebab-case value. -Changing it later points the application at a different migration ledger. - -Configure these through the Foundation config keys `database.migrations_table` and `database.locks_table` when an application needs different table names. Configured table names are treated as exact full table names: `Database::tableName()` validates them but does not add the WordPress prefix again, so include that prefix yourself when overriding them. - -Example `config.php` values: - -```php - [ - // For example, "your-plugin" in a distributable plugin. - 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? '', - ], - 'database' => [ - // Leave empty or omit these keys to use the default WordPress-prefixed names. - 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? '', - 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? '', - 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? null, - 'lock_ttl' => (int) ($_ENV['FOUNDATION_DATABASE_LOCK_TTL'] ?? 300), - ], - 'wpcli' => [ - // Optional package-specific override for foundation.prefix. - 'command_prefix' => $_ENV['FOUNDATION_WPCLI_COMMAND_PREFIX'] ?? null, - ], -]; -``` - -Replace `your-plugin` with the plugin's own stable prefix. Leaving -`foundation.prefix` unset uses the default `nx` prefix. - -If overriding table names, provide the full table name: - -```php -return [ - 'database' => [ - 'migrations_table' => 'wp_custom_foundation_migrations', - 'locks_table' => 'wp_custom_foundation_locks', - ], -]; -``` - -`database.lock_ttl` must cover the complete migration operation. The migrator -reports unconfirmed ownership if an otherwise successful operation -cannot release its ownership token. Increase the TTL for long-running -migrations; the migrator does not refresh the lease while a migration is -executing. - -## Using Database Locks - -`DatabaseProvider` registers `DatabaseLock` for direct use and uses it for -migrations, but intentionally does not select it as the application's global -lock implementation. Register `DatabaseProvider` before an application provider -that chooses the database implementation: - -```php -use lucatume\DI52\Container as C; -use StellarWP\Foundation\Database\Lock\DatabaseLock; -use StellarWP\Foundation\Lock\Contracts\Lock; - -$this->container->bind( - Lock::class, - static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class) -); -``` - -`DatabaseLock` uses the database server's UTC clock for acquisition, expiration, -refresh, and release decisions. This keeps competing PHP processes on one -authoritative timeline even when their host clocks differ. - -Database lock names are byte-exact and may not exceed 191 bytes. - -Lock writes and their verification reads must use the same authoritative -primary connection. Standard `wpdb` satisfies this requirement. Projects with a -database drop-in that routes `SELECT` queries to replicas must pin lock-table -reads to the writer; otherwise replication lag can make a successful acquisition -or refresh fail closed. - -The database lock table must exist before application services acquire locks. -The preferred deployment workflow initializes it with the migration store: - -```bash -wp nx migrate --initialize -``` - -If an application cannot run WP-CLI during deployment, it may initialize the -store programmatically during activation or another controlled lifecycle: - -```php -use StellarWP\Foundation\Database\Migration\Migrator; - -$container->get(Migrator::class)->initialize(); -``` - -Initializing the migration store also reconciles existing internal tables with -their current definitions. - -Once configured, application services should depend on the shared `Lock` -contract. See the -[Foundation Lock usage examples](https://github.com/stellarwp/foundation-lock#preventing-duplicate-work) -for resource-scoped acquisition, release, and lease handling. - -## Running Queries - -Application services can inject `StellarWP\Foundation\Database\Contracts\Database` when they need to run queries: - -```php -use StellarWP\Foundation\Database\Contracts\Database; - -final readonly class ReportRepository -{ - public function __construct( - private Database $database - ) { - } - - public function published(): array - { - return $this->database - ->table('reports') - ->select('id', 'title') - ->where('status', '=', 'published') - ->orderBy('id', 'DESC') - ->limit(25) - ->get(); - } -} -``` - -Queries can be inspected before they are executed: - -```php -$query = $database - ->table('reports') - ->where('status', '=', 'published') - ->limit(25); - -$query->toSql(); -$query->bindings(); -$query->toPreparedSql(); -``` - -NULL comparisons use SQL NULL semantics and do not add query bindings: - -```php -$database->table('reports')->where('deleted_at', '=', null); // IS NULL -$database->table('reports')->where('deleted_at', '!=', null); // IS NOT NULL -``` - -Qualified columns are quoted by segment, including aliases and select wildcards: - -```php -$database->table('posts', 'p')->select('p.ID', 'p.*')->where('p.post_status', '=', 'publish'); -``` - -`Database::insert()` returns the number of affected rows, which works for both -auto-increment and application-assigned identifiers such as ULIDs. Use -`Database::insertGetId()` only when the table has an auto-increment key and the -generated integer identifier is needed. - -## Defining Migrations - -Migrations implement `StellarWP\Foundation\Database\Contracts\Migration`: - -```php -use StellarWP\Foundation\Database\Contracts\Migration; -use StellarWP\Foundation\Database\Contracts\Schema; - -final readonly class CreateReportsTable implements Migration -{ - public function __construct( - private ReportsTable $table - ) { - } - - public function id(): string { - return '2026_06_23_000001_create_reports_table'; - } - - public function up(Schema $schema): void { - $schema->createOrUpdate($this->table); - } - - public function down(Schema $schema): void { - $schema->drop($this->table); - } -} -``` - -Migration IDs are byte-exact and case-sensitive. They must be nonblank, contain -no surrounding whitespace, fit within 191 bytes, and not be an integer-like -string such as `123`; these rules keep PHP collection keys and the MySQL ledger -consistent. - -Applications should add migrations to `DatabaseProvider::MIGRATIONS` with `mergeArrayVar()` so multiple providers/packages can contribute migrations: - -```php -use lucatume\DI52\Container as C; -use StellarWP\Foundation\Database\DatabaseProvider; - -$this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ - $c->get(CreateReportsTable::class), -]); -``` - -If migrations are added before registering `DatabaseProvider`, the provider will preserve the existing values. Other providers may also add migrations after `DatabaseProvider` is registered, as long as they do so before the migration collection or migrator is resolved. - -Register contributing providers in the order their migrations must run. The migration collection preserves registration order, so a migration that depends on an earlier schema or data change must be contributed after that dependency. - -Application feature tables should usually be represented by migrations. If a table only needs normal create/drop behavior, define it with `StellarWP\Foundation\Database\Contracts\Table`, wrap it in `StellarWP\Foundation\Database\Table\CreateTable`, and add that migration instance to `DatabaseProvider::MIGRATIONS`. - -`Schema::createOrUpdate()` independently verifies column defaults, nullability, -and extra attributes after WordPress runs `dbDelta()`. If one of those -properties still differs, reconciliation fails before the migration is -recorded. Make data-dependent changes such as backfills or `NULL` to `NOT NULL` -conversions explicitly in a versioned migration, then call `createOrUpdate()` -to verify the final table definition. - -```php -use StellarWP\Foundation\Database\Contracts\Database; -use StellarWP\Foundation\Database\Contracts\Table; -use StellarWP\Foundation\Database\Table\TableDefinition; - -final readonly class ReportsTable implements Table -{ - public const string ID = 'reports_table'; - - public function __construct( - private Database $database - ) { - } - - public function id(): string { - return self::ID; - } - - public function name(): string { - return $this->database->tableName('reports'); - } - - public function definition(): TableDefinition { - return TableDefinition::for($this) - ->bigIncrements('id') - ->string('status', 20)->default('draft') - ->longText('payload') - ->dateTime('published_at')->nullable() - ->tinyInteger('failed', 1)->unsigned()->default(false) - ->index('status', 'status'); - } -} -``` - -```php -use lucatume\DI52\Container as C; -use StellarWP\Foundation\Database\DatabaseProvider; -use StellarWP\Foundation\Database\Table\CreateTable; - -$this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c): array => [ - new CreateTable($c->get(ReportsTable::class)), // ReportsTable implements Contracts\Table. -]); -``` - -After registering migrations, use the WP-CLI deployment workflow described in -[Running Migrations](#running-migrations). Registering `DatabaseProvider` does -not initialize storage or execute migrations. - -If WP-CLI is unavailable during deployment, application code may use the -configured `Migrator` directly from a controlled activation or version-update -lifecycle: - -```php -use StellarWP\Foundation\Database\Migration\Migrator; - -$migrator = $container->get(Migrator::class); -$migrator->initialize(); -$migrator->run(); -``` - -Call `initialize()` before `run()`, `rollback()`, `refresh()`, or `dropStore()`. -Migration operations fail with `UninitializedStore` rather than changing -internal table definitions implicitly. - -`rollback()` rolls back only the latest recorded batch. Its optional batch -argument is an expected-latest guard; passing an older batch throws -`InvalidRollbackBatch` instead of leaving newer migrations applied above it. - -Recorded migration implementations must remain registered for as long as their -ledger entries may be rolled back. `rollback()` and `refresh()` validate every -selected ledger entry before changing schema and fail without a partial rollback -when an implementation is unavailable. - -Completed migration IDs are skipped on later runs. Because migration changes and -their ledger updates are not one atomic operation, write `up()` and `down()` -methods so they can recover from retries after partial work or failed ledger -writes. - -## Evolving Tables - -`TableDefinition` and `Schema::createOrUpdate()` use WordPress `dbDelta()` to create tables and reconcile changes that `dbDelta()` supports, such as adding columns and indexes. They should not be relied on to remove or rename columns, replace indexes, manage foreign keys, or backfill data. - -Use an explicit, versioned migration for destructive or data-dependent changes. Such migrations can inspect table and index state with `Schema::hasTable()` and `Schema::hasIndex()`; inject `Database` when column inspection through `Database::columnExists()` is required. Use `Schema::execute()` or focused helpers such as `dropIndex()` for the required SQL. Make rollback behavior explicit; throw `IrreversibleMigration::forMigration(self::ID)` when a migration cannot be safely reversed. - -## Generators - -If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a database provider, table class, and matching migration in a consuming WordPress project: - -```bash -vendor/bin/foundation make:database-provider -vendor/bin/foundation make:database-table Reports_Table -vendor/bin/foundation make:database-migration Create_Reports_Table -``` - -The provider generator reads the project's first `autoload.psr-4` namespace from `composer.json` and writes `src/Database/Provider.php` by default. Register the Foundation `DatabaseProvider` first, then the generated application provider: - -```php -use Acme\Plugin\Database\Provider; -use StellarWP\Foundation\Database\DatabaseProvider; - -protected array $providers = [ - DatabaseProvider::class, - Provider::class, -]; -``` - -The table generator writes a Snake_Case table class under `src/Database/Tables` by default. The migration generator writes under `src/Database/Migrations` by default and references the matching table class. - -The table and migration generators never overwrite existing files. Edit a migration only before it has been applied anywhere; otherwise create a new migration for the next schema change. - -Generated and explicit table and migration IDs follow the runtime ledger rules: they must -be nonblank, contain no surrounding whitespace, fit within 191 bytes, and not -be integer-like strings. - -Migration names matching `Create_*_Table`, or migrations generated with `--table-class`, use the table-backed migration stub and wrap the table in `CreateTable`. Other migration names use the generic migration stub. - -If `src/Database/Provider.php` exists and contains the generated provider registration points, the table and migration generators automatically add imports and registrations to that provider. Pass `--provider=path/to/Provider.php` to update a non-standard provider file. Provider updates do not duplicate existing imports or registrations, including after WordPress code formatting. If an existing conventional provider cannot be updated safely, the generator creates the requested class and prints a warning with the manual registration step. An explicitly requested `--provider` that cannot be updated fails before generating the class. - -Common options: - -```bash -vendor/bin/foundation make:database-provider Provider \ - --namespace="Acme\\Plugin\\Database" \ - --path=src/Database - -vendor/bin/foundation make:database-table Reports_Table \ - --namespace="Acme\\Plugin\\Database\\Tables" \ - --path=src/Database/Tables \ - --provider=src/Database/Provider.php \ - --id=reports_table \ - --table=reports - -vendor/bin/foundation make:database-migration Create_Reports_Table \ - --namespace="Acme\\Plugin\\Database\\Migrations" \ - --path=src/Database/Migrations \ - --provider=src/Database/Provider.php \ - --id=2026_06_26_000001_create_reports_table \ - --table-class=Reports_Table \ - --table-namespace="Acme\\Plugin\\Database\\Tables" -``` - -Project-specific stub overrides live in: - -```text -foundation/stubs/database/table.stub -foundation/stubs/database/migration.stub -foundation/stubs/database/table-migration.stub -foundation/stubs/database/provider.stub -``` - -When present, overrides are used instead of the default stubs from the `foundation-database` package. +## Documentation -Override stubs should use the same context-aware placeholders as the default stubs when writing PHP literals. For example, use `{{ id_php }}` and `{{ table_php }}` for values written into PHP constants, and use the `{{ foundation_database_* }}` import placeholders so Strauss-prefixed projects keep working. +See the [Foundation Database documentation](https://foundation.stellarwp.com/components/database/) +for configuration, migrations, query building, database locks, and testing. diff --git a/src/Docs/.gitattributes b/src/Docs/.gitattributes new file mode 100644 index 0000000..df7ba69 --- /dev/null +++ b/src/Docs/.gitattributes @@ -0,0 +1,3 @@ +# Ignore repository metadata when GitHub creates a source archive. +/.gitattributes export-ignore +/.github export-ignore diff --git a/src/Docs/.github/workflows/close-pull-request.yml b/src/Docs/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/Docs/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Docs/.gitignore b/src/Docs/.gitignore new file mode 100644 index 0000000..8acbc68 --- /dev/null +++ b/src/Docs/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.astro/ +.wrangler/ +.dev.vars +.dev.vars.* +.env +.env.* +!.env.example +npm-debug.log* +.DS_Store +.idea/ +.vscode/ diff --git a/src/Docs/.nvmrc b/src/Docs/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/src/Docs/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/src/Docs/README.md b/src/Docs/README.md new file mode 100644 index 0000000..3e15201 --- /dev/null +++ b/src/Docs/README.md @@ -0,0 +1,24 @@ +# Foundation Documentation + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +The source for the Foundation documentation site. + +## Local Development + +Use Node.js 24 and install the locked npm dependencies: + +```shell +nvm use +npm ci +npm run dev +``` + +Astro prints the local documentation URL after the development server starts. + +Create a production build with: + +```shell +npm run build +``` diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs new file mode 100644 index 0000000..5733934 --- /dev/null +++ b/src/Docs/astro.config.mjs @@ -0,0 +1,65 @@ +import starlight from '@astrojs/starlight'; +import { defineConfig } from 'astro/config'; +import starlightThemeNova from 'starlight-theme-nova'; + +export default defineConfig({ + site: 'https://foundation.stellarwp.com', + integrations: [ + starlight({ + title: 'Foundation', + description: 'Shared PHP infrastructure for Nexcess libraries and WordPress plugins.', + favicon: '/favicon.svg', + customCss: ['./src/styles/custom.css'], + editLink: { + baseUrl: 'https://github.com/stellarwp/foundation/edit/main/src/Docs/', + }, + lastUpdated: true, + plugins: [starlightThemeNova()], + sidebar: [ + { + label: 'Start Here', + items: [ + { slug: 'start/what-is-foundation' }, + { slug: 'start/install-foundation' }, + { slug: 'start/configure-the-container' }, + { slug: 'start/bootstrap-wordpress-plugin' }, + { slug: 'start/register-service-providers' }, + { slug: 'start/scope-foundation' }, + ], + }, + { + label: 'Components', + items: [ + { slug: 'components/container' }, + { + label: 'Database', + collapsed: false, + items: [ + { slug: 'components/database', label: 'Overview' }, + { slug: 'components/database/migrations' }, + { slug: 'components/database/query-builder' }, + { slug: 'components/database/lock' }, + ], + }, + { slug: 'components/lock' }, + { slug: 'components/log' }, + { slug: 'components/identifier' }, + { slug: 'components/pipeline' }, + { slug: 'components/wp-cli' }, + ], + }, + { + label: 'Developer Tooling', + items: [{ slug: 'tooling/foundation-cli' }], + }, + ], + social: [ + { + icon: 'github', + label: 'Foundation on GitHub', + href: 'https://github.com/stellarwp/foundation', + }, + ], + }), + ], +}); diff --git a/src/Docs/package-lock.json b/src/Docs/package-lock.json new file mode 100644 index 0000000..f8df4df --- /dev/null +++ b/src/Docs/package-lock.json @@ -0,0 +1,8995 @@ +{ + "name": "@stellarwp/foundation-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@stellarwp/foundation-docs", + "dependencies": { + "@astrojs/starlight": "^0.41.7", + "astro": "^7.2.4", + "starlight-theme-nova": "^0.12.2" + }, + "devDependencies": { + "wrangler": "^4.125.0" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@aria-ui/core": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@aria-ui/core/-/core-0.2.1.tgz", + "integrity": "sha512-EEh2mIHL4HnfjIgZocxhCCqPRU6EE5eLFQcGw4FOfomil11jIBKtmqCQkz8qJ9Ni3IBoh/5DvUFaY206X0oY6A==", + "license": "MIT", + "dependencies": { + "@ocavue/utils": "^1.6.0", + "@zag-js/dom-query": "^1.40.0", + "alien-signals": "^3.2.1", + "server-dom-shim": "^1.1.0" + } + }, + "node_modules/@aria-ui/elements": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@aria-ui/elements/-/elements-0.1.13.tgz", + "integrity": "sha512-+uZaWhNobVCIncKn6wBsnVstWet9IM/W7zmDVk71Ktui4ZNRGSj9tudwnsQdnIPtIqDLHiUIr03NPCEnf99lBg==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "0.2.1", + "@aria-ui/utils": "0.1.7", + "@floating-ui/dom": "^1.8.0", + "@zag-js/dismissable": "^1.43.0", + "@zag-js/dom-query": "^1.43.0" + } + }, + "node_modules/@aria-ui/utils": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@aria-ui/utils/-/utils-0.1.7.tgz", + "integrity": "sha512-ZKc/JOugSEYqsPUHYomxSbLyK9TcypsnhGL/yK2X14q6qyXVax5vHjLqgZmX5pwm28vYP4FOsUiUUtuSIK/eKw==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "0.2.1", + "@zag-js/dom-query": "^1.42.0" + } + }, + "node_modules/@astrojs/compiler-binding": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.3.2.tgz", + "integrity": "sha512-8w/9CWmYrAJJ8N0SY3O43ws2BgxoW6u3QsD8u2mE140lMYAlwh+tlNoUeSBq22wVheFuiBbR212l6ixZ2IIgCQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-darwin-arm64": "0.3.2", + "@astrojs/compiler-binding-darwin-x64": "0.3.2", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.2", + "@astrojs/compiler-binding-linux-arm64-musl": "0.3.2", + "@astrojs/compiler-binding-linux-x64-gnu": "0.3.2", + "@astrojs/compiler-binding-linux-x64-musl": "0.3.2", + "@astrojs/compiler-binding-wasm32-wasi": "0.3.2", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.2", + "@astrojs/compiler-binding-win32-x64-msvc": "0.3.2" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.3.2.tgz", + "integrity": "sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.3.2.tgz", + "integrity": "sha512-2lXOlzf8xb7jLomRsf/aswh61/NnGusynB2OwFkK6k4pmOtpfXMYnG0PLfXrEvxXYj69NdCnmUYXtHDd+JOOag==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.3.2.tgz", + "integrity": "sha512-BmU3kWj7qnLrd4vzm49zFEPJ5oFnn1tCT4Vt9hZbqdU5Cmb8GZl7fn6VFsnNfe7B18a2gIFtVzbLINtYl5kBjQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.3.2.tgz", + "integrity": "sha512-f0heT9ZZEseSu5bHCeb80eL2DH07ArE6U9xi1WT/PEusNjzPmEr3GJsjG1tRLo5VYUUYX7h3ScaqGmGrMOVGmw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.3.2.tgz", + "integrity": "sha512-M8fOUt0itRpqiGyoEA/ij184s8O+hqbCz3+YozRusOOM3osgGljpDThhbKAJjqh82wOo6FioQ4w8PBvU1XMD5Q==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.3.2.tgz", + "integrity": "sha512-/Kebk8sO6HnLeSd691JkaAPfN7CqR9/KEXmWvyNPkaKNGmj8rTZ/lf2uXtnPu92Lan84UrKdIKVPy1fSo2encQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.3.2.tgz", + "integrity": "sha512-pUA6xbcOSB7DhfzIArB8BCAkFfAIqriiR7zl5zOStd6oU2G0kIKj+GUdGnyXyhfiv881Hffyk5tC0mR18sDjDw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.2.tgz", + "integrity": "sha512-ESruf+6Qkl1trHUFxI6GSf6t52j8yN2kCNSzMWdzt7V/T09tFHrYzrVaJQohb2C9bJUH76pNvX6Zb51+xCQc9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.3.2.tgz", + "integrity": "sha512-wzzVrEbOwbsLWOdEbocskjMRx2aZPxJ7ZbmL+jnpBamFwmigm+2M/wzuM6JWncocgYwLic1csSpalBh96kQKXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.3.2.tgz", + "integrity": "sha512-xlx/T7JovIKduu4ucbTQUxQ5+Q8wxkHxhLjnZk3VlJbhbQ9RLvvuDk1p2YYFYFQ5y14dVm3FGGO4isQXa4F+Tg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.3.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.4.tgz", + "integrity": "sha512-MvspGMynWKAjTe4/lTUdmBPHIFKNVLTCF6UlyWGogTGzNrTvjD+D4n48k7h8swxsEPKHK2TwxkZO7uoaCv1Pow==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.7.tgz", + "integrity": "sha512-NHcHbrKW/opbZnTZQ5nH293BdcK2VV0tjuzI88CLnvy2njiEJVwUQ5KFnYd4NoWgHJCY/I1CyjGWCR4Vv3khXQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.10.3" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/@astrojs/mdx": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.7.tgz", + "integrity": "sha512-hv+NJh2s+/KDrjXaYNOaS344e3M2ekMXHBR7x9EwBwE3VvE1y/9Ifh1rhR1H2zK2sMgXoUnakI9e9ZeCKPX9/Q==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-remark": "7.2.4", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.16.0", + "es-module-lexer": "^2.0.0", + "estree-util-visit": "^2.0.0", + "hast-util-to-html": "^9.0.5", + "piccolore": "^0.1.3", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@astrojs/markdown-satteri": "^0.3.1", + "astro": "^7.0.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-satteri": { + "optional": true + } + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz", + "integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "stream-replace-string": "^2.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.41.7.tgz", + "integrity": "sha512-579VJuZgo20UpNQPm9EIez5W3DFSrD16uiV2YX6rUlpLtjgKSdnc69TxVTZXn4AtI2B731TI2qhW1O3K+vwtrQ==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-satteri": "^0.3.5", + "@astrojs/mdx": "^7.0.5", + "@astrojs/sitemap": "^3.7.3", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.4", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.44.0", + "bcp-47": "^2.1.0", + "hast-util-from-html": "^2.0.3", + "hast-util-select": "^6.0.4", + "hast-util-to-string": "^3.0.1", + "hastscript": "^9.0.1", + "i18next": "^26.0.7", + "js-yaml": "^4.1.1", + "klona": "^2.0.6", + "magic-string": "^0.30.21", + "mdast-util-directive": "^3.1.0", + "mdast-util-to-markdown": "^2.1.2", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.5.2", + "rehype": "^13.0.2", + "rehype-format": "^5.0.1", + "remark-directive": "^4.0.0", + "satteri": "^0.9.1", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.2.0", + "astro": "^7.0.2" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "package-manager-detector": "^1.6.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.5.tgz", + "integrity": "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.9.5.tgz", + "integrity": "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.9.5.tgz", + "integrity": "sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.9.5.tgz", + "integrity": "sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.9.5.tgz", + "integrity": "sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.9.5.tgz", + "integrity": "sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.9.5.tgz", + "integrity": "sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.9.5.tgz", + "integrity": "sha512-SrfE7NEsgZjBvU3c+RR6oQRu0ToXY5uVJEbieXEF0YTctIV2zAVlbaMjWLts074QCgh3a+XHWkR/lWh2VH2LUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.9.5.tgz", + "integrity": "sha512-5Kw9ZAtTGS8WHizyn+CJhjjfIQrw+7jcZodpmpXJjefnO15M8UexIi6JR2E5thyvsmHyhL6ZDDMUNR4bKJPd4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260820.1.tgz", + "integrity": "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260820.1.tgz", + "integrity": "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260820.1.tgz", + "integrity": "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260820.1.tgz", + "integrity": "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260820.1.tgz", + "integrity": "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.1.tgz", + "integrity": "sha512-3dDo9N8D7hYrLNNMMWFovg3+aDUtnQm7c7z0GZc1c0LEFVBc0Q6lKG+tVT28gDadOvsgOANfCn35fgpe97Pmgg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.1.tgz", + "integrity": "sha512-HC/bdRao9225ApcgO/e3jn8ZOhldKO7ob1O/Tcipvtv7Vb5nMphZhMtD9uuywpvxkPYBHJi3504WhrKg05Dwqg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.1.tgz", + "integrity": "sha512-YApiZt3buUzBwL5tqj8G+sYC5NjMjRCHgQwr9bmGl69rtcHy6fE9dooWUeKYB978fJT2BuxT5FeHcF47rA3SEg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1", + "shiki": "^4.0.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.1.tgz", + "integrity": "sha512-B3BsJoJ8CFMlcIX9f+X9tcI3C4zPDO601+YuLi9GheSTNro7ZfqSjLptMQKBHOWZvxnAtY5zvIX7iO/qtBhNBg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@ocavue/utils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ocavue/utils/-/utils-1.7.0.tgz", + "integrity": "sha512-yEk9ATNBjTZTtuVFMB/MAIF6zJBvJ2+lVNQvK2+O+ggEBGTgx2tp27d4FPgmD5bRsNHHP3D0SleQia/bvIeV8w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.5.2.tgz", + "integrity": "sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==", + "license": "MIT" + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/transformers": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.4.3.tgz", + "integrity": "sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/twoslash": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/twoslash/-/twoslash-4.4.3.tgz", + "integrity": "sha512-m7HNzunEIHRk1jCya3ngGsO3+8pYxrPIIxtdJewg/W8ceW/+m/mSsm4jM3L9DvYYNa8Rvbu7Dabt3BOpCclz8Q==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/types": "4.4.3", + "twoslash": "^0.3.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "typescript": ">=5.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@zag-js/dismissable": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/dismissable/-/dismissable-1.43.3.tgz", + "integrity": "sha512-3znmDAC6qv7I9h/ZInVE+sFxE2wcBeQhT0bEiUjXAlgFGgaVmgA6A0ElGI7CDrot8qhgC88BZQisMWfYRRWgeg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.43.3", + "@zag-js/interact-outside": "1.43.3", + "@zag-js/utils": "1.43.3" + } + }, + "node_modules/@zag-js/dom-query": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.43.3.tgz", + "integrity": "sha512-kpTTYMemMmHxhp4gyOllK7/Lf86AySTO+AlGds9iQM21eUwi1jvc9q3VTxP+qD/KwHePGftfPwkwxifC3iSGbw==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.43.3" + } + }, + "node_modules/@zag-js/interact-outside": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/interact-outside/-/interact-outside-1.43.3.tgz", + "integrity": "sha512-qgyAyWELSzFrHUtB6D7IzemI48NjX7E5oIDCXsz/DBPh+ybsK+pKMGsZkduTCK+uEqJcA/w+DAWaDUOxhjqvWA==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.43.3", + "@zag-js/utils": "1.43.3" + } + }, + "node_modules/@zag-js/types": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.43.3.tgz", + "integrity": "sha512-QokzUgkJ7a/TRE8SAXqlm1XfiNDbyM8y+mx0PpmzHa8bvmWXLEe/Zx2TRz8aYoGim75NluYzDy3x+WWileF7uA==", + "license": "MIT", + "dependencies": { + "csstype": "3.2.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.43.3.tgz", + "integrity": "sha512-9P9fvFFxuiDcLqX0BYgGNkf0/a/id32nHvQpwgv/Xv3b9n5yeyw2U8nKefpWr+1VfFsrpf2XMXiA2CDSWZgt2g==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "license": "MIT" + }, + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "license": "MIT", + "dependencies": { + "process-ancestry": "^0.1.0" + }, + "bin": { + "am-i-vibing": "dist/cli.mjs" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/astro": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.4.tgz", + "integrity": "sha512-+cuLsBns2wwUHI9a10xZMbjrF91m7+QNwqTVeljTx0B8Lf+8h0LgVGjdVIL2FALDKD2I565lczeeS3BFC+KdZg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-rs": "^0.3.2", + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-satteri": "0.3.7", + "@astrojs/telemetry": "3.3.3", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "am-i-vibing": "^0.4.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^5.8.1", + "diff": "^8.0.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.28.0", + "find-process": "^2.1.1", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.3.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.0.0", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^1.0.1", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.5", + "unstorage": "^1.17.5", + "vite": "^8.0.13", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0 || ^0.35.0" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "7.2.4" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/astro-expressive-code": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.1.tgz", + "integrity": "sha512-DT1LnCqbHasBKlvzJ3m6LR4VI94wwx3W9EV/YbP1te4rqjOHsvsezHYuqb5MeLWLftXms/1FA9QBbwCo43DnJQ==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.44.1", + "url-extras": "^0.1.0" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" + } + }, + "node_modules/astro-theme-toggle": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/astro-theme-toggle/-/astro-theme-toggle-0.8.2.tgz", + "integrity": "sha512-x6DKMc4V5q47s5Wc3tvC11Yhd9VFVA8XQflD23GX+BpdEIv2wLN0592wIllrCtrMgiv6TgdiVSruhx18qg2Qtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/astro/node_modules/magic-string": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", + "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz", + "integrity": "sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz", + "integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.1.tgz", + "integrity": "sha512-GakidxhapWDzpKLqEaFQ8wGk6gAqEtPQibu8+yPBfnDLgev5Vdsh1pasTxnrXL/mzIknyqeTwhMHTghdaiUrTg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1", + "@expressive-code/plugin-frames": "^0.44.1", + "@expressive-code/plugin-shiki": "^0.44.1", + "@expressive-code/plugin-text-markers": "^0.44.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-process": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/find-process/-/find-process-2.1.1.tgz", + "integrity": "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==", + "license": "MIT", + "dependencies": { + "chalk": "~4.1.2", + "commander": "^14.0.3", + "loglevel": "^1.9.2" + }, + "bin": { + "find-process": "dist/cjs/bin/find-process.js" + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/i18next": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz", + "integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/miniflare": { + "version": "5.20260820.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260820.0-alpha.tgz", + "integrity": "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260820.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", + "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-expressive-code": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.1.tgz", + "integrity": "sha512-+VZgs7Evw4LXRN3owpoBNSTpYuW6GeOdjqcUT1TuY8o/4MGPtbd0EU7Bgrju7X8KrQ6SslOBAuGWJ5fV5TriJQ==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.44.1" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.3.tgz", + "integrity": "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/satteri": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz", + "integrity": "sha512-ZuWVl+vnM64y+/TtX8Kosv2c00W+hLQiiwnEL6H0UKVVrxFqMw4D2CJHHQaouVd89OAhtBBfjWLqhKi3TVUV4w==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.9.5", + "@bruits/satteri-darwin-x64": "0.9.5", + "@bruits/satteri-linux-arm64-gnu": "0.9.5", + "@bruits/satteri-linux-arm64-musl": "0.9.5", + "@bruits/satteri-linux-x64-gnu": "0.9.5", + "@bruits/satteri-linux-x64-musl": "0.9.5", + "@bruits/satteri-wasm32-wasi": "0.9.5", + "@bruits/satteri-win32-arm64-msvc": "0.9.5", + "@bruits/satteri-win32-x64-msvc": "0.9.5" + } + }, + "node_modules/satteri-custom-header-id": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/satteri-custom-header-id/-/satteri-custom-header-id-0.1.0.tgz", + "integrity": "sha512-pqMwdh7B39suLmznT/8wMYWO//zTKwG7JU6TCwHIuAeOSnjxgEsMwj2h/gP7nEK/eIe40SXgFiC6QHcEBsPm5A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ocavue" + }, + "peerDependencies": { + "satteri": "^0.9.0" + }, + "peerDependenciesMeta": { + "satteri": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/server-dom-shim": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/server-dom-shim/-/server-dom-shim-1.1.0.tgz", + "integrity": "sha512-oyKhBZtkr/SGB9YE2r0VtQxQCxaVx/Ix1fMz0XMd6K4T1/TMfDs9K2GR9QjpUtD+siyeLXr+3CzzGSvhTI1sEw==", + "license": "MIT", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/shiki-twoslash-renderer": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/shiki-twoslash-renderer/-/shiki-twoslash-renderer-0.3.5.tgz", + "integrity": "sha512-V1d8JzkhIIJ1n7ZboZmGcPCBREZTkfdF9xIMgVOR71MyWVzvqyFre7sVRTmt7T/VjN0phtOgfMX7JChcaBp5Rg==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "^0.2.1", + "@aria-ui/elements": "^0.1.10", + "@shikijs/twoslash": "^4.1.0", + "@shikijs/types": "^4.1.0", + "@types/hast": "^3.0.4", + "twoslash": "^0.3.8" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/starlight-theme-nova": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/starlight-theme-nova/-/starlight-theme-nova-0.12.2.tgz", + "integrity": "sha512-ElILXQ4d+wtQV6eMrWVcJpSParodVc6zJge+OCk6ArAC64u767gznV1QKFe+qfkZltIecIsAlwhQezrFNKsAzA==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "^0.2.1", + "@aria-ui/utils": "^0.1.7", + "@astrojs/markdown-satteri": "^0.3.4", + "@pagefind/default-ui": "^1.5.2", + "@shikijs/transformers": "^4.3.1", + "@shikijs/twoslash": "^4.3.1", + "@shikijs/types": "^4.3.1", + "@types/hast": "^3.0.5", + "astro-theme-toggle": "^0.8.1", + "hast-util-is-element": "^3.0.0", + "rehype": "^13.0.2", + "satteri-custom-header-id": "^0.1.0", + "shiki-twoslash-renderer": "^0.3.5" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + }, + "peerDependencies": { + "@astrojs/starlight": "*" + }, + "peerDependenciesMeta": { + "@astrojs/starlight": { + "optional": true + } + } + }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svgo": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", + "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", + "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/twoslash": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/twoslash/-/twoslash-0.3.9.tgz", + "integrity": "sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q==", + "license": "MIT", + "dependencies": { + "@typescript/vfs": "^1.6.4", + "twoslash-protocol": "0.3.9" + }, + "peerDependencies": { + "typescript": "^5.5.0 || ^6.0.0" + } + }, + "node_modules/twoslash-protocol": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/twoslash-protocol/-/twoslash-protocol-0.3.9.tgz", + "integrity": "sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ohash": "^2.0.11", + "undici": "^8.0.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/url-extras": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", + "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/workerd": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260820.1.tgz", + "integrity": "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260820.1", + "@cloudflare/workerd-darwin-arm64": "1.20260820.1", + "@cloudflare/workerd-linux-64": "1.20260820.1", + "@cloudflare/workerd-linux-arm64": "1.20260820.1", + "@cloudflare/workerd-windows-64": "1.20260820.1" + } + }, + "node_modules/wrangler": { + "version": "4.125.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.125.0.tgz", + "integrity": "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260820.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260820.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260820.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/youch/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/src/Docs/package.json b/src/Docs/package.json new file mode 100644 index 0000000..4840a5c --- /dev/null +++ b/src/Docs/package.json @@ -0,0 +1,21 @@ +{ + "name": "@stellarwp/foundation-docs", + "private": true, + "type": "module", + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/starlight": "^0.41.7", + "astro": "^7.2.4", + "starlight-theme-nova": "^0.12.2" + }, + "devDependencies": { + "wrangler": "^4.125.0" + } +} diff --git a/src/Docs/public/favicon.svg b/src/Docs/public/favicon.svg new file mode 100644 index 0000000..fbd51b5 --- /dev/null +++ b/src/Docs/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Docs/src/content.config.ts b/src/Docs/src/content.config.ts new file mode 100644 index 0000000..e54e72f --- /dev/null +++ b/src/Docs/src/content.config.ts @@ -0,0 +1,8 @@ +import { defineCollection } from 'astro:content'; +import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders'; +import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), + i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }), +}; diff --git a/src/Docs/src/content/docs/components/container.mdx b/src/Docs/src/content/docs/components/container.mdx new file mode 100644 index 0000000..b0e1d6f --- /dev/null +++ b/src/Docs/src/content/docs/components/container.mdx @@ -0,0 +1,208 @@ +--- +title: Container +description: Autowire application services, select implementations, and compose features through service providers. +sidebar: + order: 1 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Container adapts [DI52](https://github.com/lucatume/di52) behind a shared container contract and service provider base class. Use it to describe how application services are constructed while keeping dependency resolution out of the services themselves. + +## Installation + +Install the split package in applications that define their own container or service providers: + +```shell +composer require stellarwp/foundation-container +``` + +Other Foundation packages install Container automatically when they depend on it. Composer does not require a second explicit installation in that case. + +### Prepare the application + +Create one container in the application composition root and register providers in dependency order. These guides establish that structure: + + + + + + + +## Usage + +### Let the container autowire concrete classes + +The container can construct an unbound concrete class when its constructor dependencies are also concrete classes: + +```php +final readonly class Catalog_Synchronizer { + + public function __construct( + private Product_Repository $products, + private Remote_Catalog $catalog + ) { + } +} +``` + +Resolve the application entrypoint where it is needed: + +```php +$synchronizer = $container->get( Catalog_Synchronizer::class ); +``` + +Prefer constructor injection throughout application code. Calling `get()` inside a service hides its dependencies and turns the container into a service locator. + +### Select an interface implementation + +In `src/Catalog/Provider.php`, bind an interface when the container cannot infer which implementation the application wants. Use `bind()` for a new instance on each resolution and `singleton()` when every resolution should return the same instance: + +```php title="Provider.php" +register_catalog(); + } + + private function register_catalog(): void { + $this->container->singleton( + Catalog::class, + Remote_Catalog::class + ); + } +} +``` + +Bindings are lazy. Registering `Remote_Catalog` does not construct it; the container builds it when another service first requests `Catalog`. + +### Supply configuration and scalar values + +In the same `src/Catalog/Provider.php`, use a contextual binding when one class needs a scalar or a feature-specific implementation. Target scalar constructor arguments by their `$name`. Import `lucatume\DI52\Container as C` when a factory callback must resolve another service: + +```php title="Provider.php" +private function register_catalog(): void { + $this->container->when( Remote_Catalog::class ) + ->needs( '$endpoint' ) + ->give( (string) $this->config->get( 'catalog.endpoint' ) ); + + $this->container->singleton( Remote_Catalog::class ); + $this->container->singleton( + Catalog::class, + static fn ( C $c ): Remote_Catalog => $c->get( Remote_Catalog::class ) + ); +} +``` + +The callback aliases `Catalog` to the configured `Remote_Catalog` singleton. This preserves the contextual bindings registered for the concrete class and ensures both identifiers resolve the same object. + +Use a factory callback only when the value must be computed or fetched from the container. Let the container construct the complete service whenever it can. + +### Build a collection across providers + +In `src/Report/Provider.php`, use `mergeArrayVar()` when independent providers contribute to one ordered collection. The provider that owns the collection registers its default and supplies it to the consuming class: + +```php title="Provider.php" +public const string EXPORTERS = 'your_plugin.report.exporters'; + +private function register_exporter_collection(): void { + $this->container->mergeArrayVar( self::EXPORTERS, [] ); + + $this->container->when( Exporter_Collection::class ) + ->needs( '$exporters' ) + ->give( static fn ( C $c ): array => $c->get( self::EXPORTERS ) ); +} +``` + +Other feature providers append their implementations without replacing earlier contributions. For example, `src/Report/Csv/Provider.php` can contribute the CSV implementation: + +```php title="Provider.php" +private function register_csv_exporter(): void { + $this->container->mergeArrayVar( + Report\Provider::EXPORTERS, + static fn ( C $c ): array => [ + $c->get( Csv_Exporter::class ), + ] + ); +} +``` + +:::caution[Complete registration before resolving the collection] +Register every contributing provider before resolving services that consume the collection. An existing service will not be rebuilt when a later provider adds another contribution. +::: + +### Register lazy WordPress callbacks + +In `src/Catalog/Provider.php`, use `callback()` to let WordPress resolve a service only when its hook runs: + +```php title="Provider.php" +private function register_catalog_sync(): void { + $this->container->singleton( Catalog_Synchronizer::class ); + + add_action( + 'your_plugin/sync_catalog', + $this->container->callback( Catalog_Synchronizer::class, 'synchronize' ) + ); +} +``` + +This avoids constructing the synchronizer during every request merely to register its callback. + +### Decorate a service + +In `src/Catalog/Provider.php`, use a decorator chain when cross-cutting behavior should wrap a service without changing its implementation. List the outermost decorator first and the base implementation last: + +```php title="Provider.php" +private function register_catalog(): void { + $this->container->singletonDecorators( + Catalog::class, + [ + Logging_Catalog::class, + Caching_Catalog::class, + Remote_Catalog::class, + ] + ); +} +``` + +Resolving `Catalog` returns one `Logging_Catalog` that wraps `Caching_Catalog`, which wraps `Remote_Catalog`. Use `bindDecorators()` instead when the application needs a new chain on every resolution. + +## Testing + +### Replace an implementation in a focused test + +Bind a test double to the same contract before resolving the class under test: + +```php +$catalog = new Fake_Catalog(); + +$this->container->bind( Catalog::class, $catalog ); + +$synchronizer = $this->container->get( Catalog_Synchronizer::class ); +$synchronizer->synchronize(); + +$this->assertTrue( $catalog->was_synchronized() ); +``` + +Test application services through their public behavior. Reserve container integration tests for provider graphs where the binding itself is the behavior under test. diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx new file mode 100644 index 0000000..9a0d503 --- /dev/null +++ b/src/Docs/src/content/docs/components/database.mdx @@ -0,0 +1,142 @@ +--- +title: Database +description: WordPress-backed migrations, queries, and distributed locks built on wpdb and dbDelta. +sidebar: + order: 2 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Database provides three focused capabilities for WordPress applications: versioned schema migrations, a small inspectable query API, and a database-backed implementation of the Foundation Lock contract. It intentionally builds on `wpdb`, `dbDelta()`, and WordPress table prefixes instead of acting as a generic database abstraction. + +## Installation + +Install the runtime package in the application: + +```shell +composer require stellarwp/foundation-database +``` + +Foundation Database installs its Container, Lock, and WP-CLI runtime dependencies automatically. Install `stellarwp/foundation-cli` separately with `--dev` only when the project uses its migration and table generators. + +:::note[Runtime requirements] +Foundation Database requires WordPress 6.2 or newer because prepared identifier placeholders use `%i`. Database locks require MySQL 5.6.4 or newer, or MariaDB 5.3 or newer, for fractional-second timestamps. +::: + +### Prepare the application + +Database services use the application's existing container, provider graph, Foundation prefix, and WP-CLI command prefix: + + + + + + + + +## Configuration + +### Scope database resources + +Set a stable application prefix in the root `config.php` for a standalone plugin: + +```php title="config.php" + [ + 'prefix' => 'your-plugin', + ], +]; +``` + +With this prefix, Foundation uses these resources by default: + +| Resource | Default | +| --- | --- | +| Migration table | `your_plugin_foundation_migrations` | +| Lock table | `your_plugin_foundation_locks` | +| Migration lock | `your-plugin-foundation-database-migrations` | +| WP-CLI command | `wp your-plugin migrate` | + +Complete WordPress applications that own the entire installation can keep the zero-configuration `nx` prefix. Standalone plugins should not share the default because another Foundation consumer could otherwise read the same migration ledger or contend for the same lock. + +Keep the prefix stable after migrations have run. Changing it points the application at a different ledger and lock table, making every configured migration appear pending. + +### Override individual resources + +Package-specific settings in the root `config.php` override values derived from `foundation.prefix`: + +```php title="config.php" +return [ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'database' => [ + 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? '', + 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? '', + 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? null, + 'lock_ttl' => (int) ( $_ENV['FOUNDATION_DATABASE_LOCK_TTL'] ?? 300 ), + ], +]; +``` + +Leave table names empty to use the scoped defaults. An overridden table name is the complete physical name and must include the WordPress table prefix itself. All physical table names must fit MySQL's 64-character identifier limit. + +The migration lock settings coordinate migration execution only. Applications selecting `DatabaseLock` for their own work choose each lock name and TTL when calling `acquire()`. + +### Register the providers + +In `src/App.php`, register `WPCliProvider` before `DatabaseProvider`, then register application providers that contribute migrations or select the database lock: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Database; +use StellarWP\Foundation\WPCli; +use YourPlugin\Database as ApplicationDatabase; + +/** @var list> */ +private const array PROVIDERS = [ + WPCli\WPCliProvider::class, + Database\DatabaseProvider::class, + ApplicationDatabase\Provider::class, +]; +``` + +`DatabaseProvider` configures `wpdb`, schema services, migration storage, the migration lock, and the `migrate` command. It does not create tables, run migrations, or select `DatabaseLock` as the application's general `Lock` implementation during WordPress bootstrap. + +## Choose a feature + + + + + + diff --git a/src/Docs/src/content/docs/components/database/lock.mdx b/src/Docs/src/content/docs/components/database/lock.mdx new file mode 100644 index 0000000..0f339e0 --- /dev/null +++ b/src/Docs/src/content/docs/components/database/lock.mdx @@ -0,0 +1,104 @@ +--- +title: Database Lock +description: Use the WordPress database as the shared backend for Foundation lock ownership. +sidebar: + order: 3 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +`DatabaseLock` implements the shared Foundation `Lock` contract with a WordPress table. Choose it when every process that must coordinate can reach the same primary database and a dedicated Redis service is unnecessary. + + + + + + +## Configuration + +### Select the database implementation + +`DatabaseProvider` registers `DatabaseLock`, but does not choose it as the application's global `Lock` implementation. Make that application-level decision in `src/Lock/Provider.php`: + +```php title="Provider.php" +register_lock(); + } + + private function register_lock(): void { + $this->container->singleton( + Lock::class, + static fn ( C $c ): DatabaseLock => $c->get( DatabaseLock::class ) + ); + } +} +``` + +The factory aliases the interface to the `DatabaseLock` singleton already owned by `DatabaseProvider`. A direct concrete binding would ask the container to construct a separate instance. + +Register providers in dependency order in `src/App.php`: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\WPCli\WPCliProvider; +use YourPlugin\Lock; + +/** @var list> */ +private const array PROVIDERS = [ + WPCliProvider::class, + DatabaseProvider::class, + Lock\Provider::class, +]; +``` + +### Initialize lock storage + +Create or reconcile the database lock table during deployment: + +```shell +wp your-plugin migrate --initialize +``` + +Replace `your-plugin` with the configured command prefix. The command is idempotent, so deployment automation can run it before migrations without first checking whether the table exists. + +## Usage + +Inject the shared `Lock` contract into application services. Acquisition, release, refresh, contention, long-running leases, and remote idempotency are covered in the [Lock guide](/components/lock/). + +Database-backed locks have these operational constraints: + +- Lock names must fit within 191 bytes. +- Expiration uses the database's UTC clock, keeping ownership consistent between PHP processes. +- Every contender must read and write through the same primary database. Replica reads can report stale ownership. +- The TTL must exceed the protected operation, or the owner must refresh the token before it expires. + +:::caution[Keep database and remote guarantees separate] +A database lock prevents cooperating application processes from overlapping while its lease is valid. It cannot guarantee exactly-once behavior in a remote payment or messaging API. Use the remote system's idempotency key as well. +::: + +## Testing + +Use `InMemoryLock` in unit and feature tests that only verify application behavior against the shared contract. Use `wpunit` when testing `DatabaseLock` itself or behavior that depends on real lock-table queries and database time. diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx new file mode 100644 index 0000000..72483ba --- /dev/null +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -0,0 +1,214 @@ +--- +title: Migrations +description: Define WordPress tables, register versioned schema changes, and run them safely during deployment. +sidebar: + order: 1 +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Foundation migrations apply ordered database changes and record each successful run in a WordPress-backed ledger. Prefer the bundled WP-CLI command during deployment so initialization, locking, execution, and status reporting follow one path. + + + +## Create a migration + +### Generate the database feature + +Install the generator as a development dependency: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +Generate the application provider before its tables and migrations: + +```shell +vendor/bin/foundation make:database-provider +vendor/bin/foundation make:database-table Reports_Table +vendor/bin/foundation make:database-migration Create_Reports_Table +``` + +The generators use the project's Composer namespace and create this feature structure by default: + +```text +src/Database/ + Provider.php + Migrations/ + Create_Reports_Table.php + Tables/ + Reports_Table.php +``` + +When `src/Database/Provider.php` exists, the table and migration generators add their container registrations automatically. Register that provider in the application's ordered provider list as shown in [Database configuration](/components/database/#register-the-providers). + +Project-specific stubs can override the defaults at: + +```text +foundation/stubs/database/provider.stub +foundation/stubs/database/table.stub +foundation/stubs/database/table-migration.stub +foundation/stubs/database/migration.stub +``` + +### Define the table + +The generated `src/Database/Tables/Reports_Table.php` owns its physical name and desired schema. `Database::tableName()` applies the current WordPress table prefix. + +```php title="Reports_Table.php" +database->tableName( self::TABLE ); + } + + public function definition(): TableDefinition { + return TableDefinition::for( $this ) + ->bigIncrements( 'id' ) + ->string( 'status', 20 )->default( 'draft' ) + ->longText( 'payload' ) + ->dateTime( 'created_at' ) + ->dateTime( 'updated_at' )->nullable() + ->index( 'status', 'status' ); + } +} +``` + +### Apply the table definition + +The generated `src/Database/Migrations/Create_Reports_Table.php` passes the table object to `Schema`. The schema service uses `dbDelta()` and verifies the resulting definition before the migration is recorded as successful. + +```php title="Create_Reports_Table.php" +createOrUpdate( $this->table ); + } + + public function down( Schema $schema ): void { + $schema->drop( $this->table ); + } +} +``` + +Migration IDs are permanent, byte-exact identifiers. The generator prefixes them with a sortable timestamp so migrations contributed by separate providers run in a predictable order. Do not change an ID after the migration has been deployed. + +For later schema changes, update the table's desired definition and create a new migration that applies it. Use `Schema::execute()` for data changes or schema operations that `dbDelta()` cannot express reliably. + +:::caution[Make rollback behavior deliberate] +The generic migration stub throws `IrreversibleMigration` from `down()`. Implement a safe inverse before relying on rollback, or keep the migration explicitly irreversible. Foundation does not pretend a destructive data change can be undone. +::: + +## Run migrations + +### Initialize migration storage + +Create or reconcile Foundation's migration ledger and lock table before running migrations: + +```shell +wp your-plugin migrate --initialize +``` + +Run this idempotent command during every deployment. Replace `your-plugin` with the configured command prefix; applications using the default prefix run `wp nx migrate --initialize`. + +### Apply pending migrations + +```shell +wp your-plugin migrate --run +``` + +Running the command without an operation displays migration status: + +```shell +wp your-plugin migrate +``` + +The runner acquires the configured migration lock, executes pending migrations in ID order, and records each successful migration in one batch. + +### Roll back or rebuild + +Roll back the latest applied batch: + +```shell +wp your-plugin migrate --rollback +``` + +Roll back every configured migration and run them again: + +```shell +wp your-plugin migrate --refresh --yes +``` + +Drop only Foundation's migration ledger when intentionally resetting migration history: + +```shell +wp your-plugin migrate --drop-store --yes +``` + +:::danger[`--drop-store` does not drop application tables] +This removes migration history, not tables created by migrations and not the Foundation lock table. The next `--run` treats every configured migration as pending, so use it only when the remaining schema is compatible with reapplying that history. +::: + +## Run migrations from PHP + +WP-CLI is the preferred deployment interface. For controlled environments that cannot invoke WP-CLI, resolve the same `Migrator` service from the application container: + +```php +use StellarWP\Foundation\Database\Migration\Migrator; + +$migrator = $container->get( Migrator::class ); + +$migrator->initialize(); +$result = $migrator->run(); +``` + +The programmatic API follows the same ledger and lock rules as the command. Do not run migrations during every normal WordPress request. + +## Testing + +Use `wpunit` tests for table definitions, schema reconciliation, and migrations that execute against WordPress. Use `integration` when the test proves contributions from multiple providers, and use `wpcli` for the real migration command lifecycle. + +Create and remove application tables within the test lifecycle so tests exercise the real `wpdb` and `dbDelta()` behavior rather than a PHP fake. diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx new file mode 100644 index 0000000..0bb49c8 --- /dev/null +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -0,0 +1,166 @@ +--- +title: Query Builder +description: Read and write WordPress tables through a small query API with inspectable SQL and bindings. +sidebar: + order: 2 +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Foundation Database wraps common `wpdb` operations with prepared bindings, quoted identifiers, consistent exceptions, and a small fluent query builder. It remains intentionally close to SQL so developers can inspect exactly what WordPress will execute. + + + +## Read rows + +Inject the `Database` contract and the table object into the class that owns the query. For example, create `src/Report/Report_Repository.php`: + +```php title="Report_Repository.php" +> + */ + public function published( int $limit = 100 ): array { + return $this->database + ->table( $this->table, 'r' ) + ->select( 'r.id', 'r.status', 'r.payload', 'r.created_at' ) + ->where( 'r.status', '=', 'published' ) + ->orderBy( 'r.created_at', 'DESC' ) + ->limit( $limit ) + ->get(); + } +} +``` + +`first()` returns one row or `null`. `get()` returns a list of associative rows. Qualified identifiers such as `r.created_at` are quoted as `` `r`.`created_at` `` rather than as one identifier. + +Use `null` with equality operators for SQL null checks: + +```php +$unpublished = $this->database + ->table( $this->table ) + ->where( 'published_at', '=', null ) + ->get(); + +$published = $this->database + ->table( $this->table ) + ->where( 'published_at', '!=', null ) + ->get(); +``` + +These comparisons compile to `IS NULL` and `IS NOT NULL`. Other operators with `null` are rejected because they do not have useful SQL semantics. + +## Write rows + +Use the table object for inserts, updates, and deletes so physical table naming stays in one place: + +```php +$reportId = $this->database->insertGetId( $this->table, [ + 'status' => 'draft', + 'payload' => wp_json_encode( $payload ), + 'created_at' => current_time( 'mysql', true ), +] ); + +$updated = $this->database->update( + $this->table, + [ + 'status' => 'published', + 'updated_at' => current_time( 'mysql', true ), + ], + [ 'id' => $reportId ] +); + +$deleted = $this->database->delete( $this->table, [ 'id' => $reportId ] ); +``` + +`insert()` returns the affected row count, while `insertGetId()` returns the generated integer ID. `update()`, `delete()`, and `execute()` return affected row counts. + +## Inspect and execute SQL + +Build a query before executing it when logging or diagnostics need the SQL shape and separate bindings: + +```php +$query = $this->database + ->table( $this->table, 'r' ) + ->select( 'r.id', 'r.status' ) + ->where( 'r.status', '=', 'failed' ) + ->limit( 25 ) + ->query(); + +$sql = $query->toSql(); +$bindings = $query->bindings(); +$preparedSql = $query->toPreparedSql(); +$rows = $query->get(); +``` + +Prefer `toSql()` plus `bindings()` for structured diagnostics. A fully prepared SQL string may contain customer or application data and should not be logged without considering its sensitivity. + +The `Database` contract also exposes prepared low-level operations for queries that do not fit the builder: + +```php +$row = $this->database->row( + 'SELECT * FROM %i WHERE id = %d', + $this->table->name(), + $reportId +); + +$count = $this->database->value( + 'SELECT COUNT(*) FROM %i WHERE status = %s', + $this->table->name(), + 'published' +); + +$affected = $this->database->execute( + 'UPDATE %i SET status = %s WHERE status = %s', + $this->table->name(), + 'archived', + 'published' +); +``` + +Use `prepare()` when another WordPress API requires the prepared SQL string. Keep values in placeholders instead of concatenating untrusted input. + +## Handle query failures + +Database operations throw `QueryException` when `wpdb` reports an error. The exception retains the SQL template, bindings, and database error separately: + +```php +use Psr\Log\LoggerInterface; +use StellarWP\Foundation\Database\Exceptions\QueryException; + +try { + $rows = $query->get(); +} catch ( QueryException $exception ) { + $this->logger->error( 'Report query failed.', [ + 'sql' => $exception->sql(), + 'bindings' => $exception->bindings(), + 'database_error' => $exception->databaseError(), + ] ); + + throw $exception; +} +``` + +Avoid exposing database errors or bindings to end users. They may contain schema details or sensitive values. + +## Testing + +Use `wpunit` tests for repositories and query behavior. Create the real table, exercise the real WordPress database, and remove the table during cleanup. This catches placeholder, collation, identifier, and MariaDB behavior that a mocked `wpdb` cannot reproduce. diff --git a/src/Docs/src/content/docs/components/identifier.mdx b/src/Docs/src/content/docs/components/identifier.mdx new file mode 100644 index 0000000..33f3c01 --- /dev/null +++ b/src/Docs/src/content/docs/components/identifier.mdx @@ -0,0 +1,251 @@ +--- +title: Identifier +description: Generate and validate injectable ULIDs without coupling application services to static helpers. +sidebar: + order: 5 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Identifier provides injectable contracts for string identifiers and a default ULID implementation. Generated ULIDs are canonical 26-character uppercase strings that combine a millisecond timestamp with secure randomness. + +ULIDs work well for identifiers that must be portable across databases or systems while remaining roughly sortable by creation time. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-identifier +``` + +### Prepare the application + +Identifier services are registered through the shared application provider list: + + + + + + + +## Configuration + +### Register the identifier provider + +In `src/App.php`, add the Foundation provider before features that generate or validate ULIDs: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Identifier\IdentifierProvider; +use YourPlugin\Job; + +/** @var list> */ +private const array PROVIDERS = [ + IdentifierProvider::class, + Job\Provider::class, +]; +``` + +The provider registers secure entropy, a system millisecond clock, `UlidGenerator`, and `UlidValidator` as shared services. + +### Choose the contract your feature needs + +Use the narrowest contract that describes the feature: + +| Contract | Use when | +| --- | --- | +| `Ulid\Contracts\UlidGenerator` | The stored or exchanged identifier must be a ULID | +| `Contracts\IdentifierGenerator` | The feature needs a unique string but should not choose its format | + +`IdentifierProvider` binds the ULID-specific contract. It deliberately does not bind the broad `IdentifierGenerator` contract because the application must decide whether ULID is its default identifier strategy. + +If the application chooses ULIDs as its default, create `src/Identifier/Provider.php`: + +```php title="Provider.php" +register_default_generator(); + } + + private function register_default_generator(): void { + $this->container->bind( + IdentifierGenerator::class, + static fn ( C $c ): UlidGenerator => $c->get( UlidGenerator::class ) + ); + } +} +``` + +Register both providers in `src/App.php`, in that order: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Identifier\IdentifierProvider; +use YourPlugin\Identifier; + +/** @var list> */ +private const array PROVIDERS = [ + IdentifierProvider::class, + Identifier\Provider::class, +]; +``` + +The callback aliases the broad contract to the configured ULID singleton, so both contracts resolve the same generator. + +## Usage + +### Generate the application's default identifier + +In `src/Job/Job_Creator.php`, inject the broad contract when the feature needs a unique string but does not own its format. With the application binding above, it resolves to the ULID generator: + +```php title="Job_Creator.php" +generator->generate(); + } +} +``` + +A generated value looks like `01ARYZ6S410000000000000000`. + +If a database column, message contract, or remote API specifically requires a ULID, inject `Ulid\Contracts\UlidGenerator` instead. That type makes the format requirement explicit and does not require the broad application binding. + +:::caution[Do not replace failed secure generation with a weaker identifier] +Generation fails when secure entropy is unavailable, the clock is outside the ULID timestamp range, or an injected entropy source returns the wrong number of bytes. Abort or retry the operation. Do not fall back to `uniqid()`, a timestamp, or another predictable value that weakens identifier guarantees. +::: + +### Validate external ULIDs + +In `src/Job/Job_Request.php`, use `UlidValidator` at input boundaries before passing an external identifier into application behavior: + +```php title="Job_Request.php" +validator->isValid( $value ) ) { + throw new InvalidArgumentException( 'The job identifier is invalid.' ); + } + + return $value; + } +} +``` + +Validation accepts canonical uppercase ULIDs only. Lowercase values, invalid lengths, ambiguous characters such as `I`, `L`, `O`, and `U`, and timestamps outside the ULID range are rejected. + +### Understand ordering and exposure + +The first ten ULID characters encode creation time in milliseconds, so sorting canonical ULID strings groups identifiers by generation time. + +:::caution[ULIDs are not a total sequence] +The default generator uses random entropy and is not monotonic. Two identifiers generated within the same millisecond are valid but are not guaranteed to sort in generation order. Use a database sequence or another explicit ordering field when exact order matters. +::: + +:::caution[ULIDs are identifiers, not secrets] +A ULID exposes its approximate creation time. Never use possession of an identifier as authorization, and do not place secrets in or derive secrets from it. Continue to enforce normal capability and ownership checks when loading the identified resource. +::: + +## Testing + +### Replace format-agnostic generation + +When application code depends on `IdentifierGenerator`, use a small fixture that always returns a known value. Create `tests/Support/Fixtures/Identifier/Fixed_Identifier_Generator.php`: + +```php title="Fixed_Identifier_Generator.php" +identifier; + } +} +``` + +Bind the fixture before resolving the service under test: + +```php +$identifier = '01ARYZ6S410000000000000000'; + +$this->container->bind( + IdentifierGenerator::class, + new Fixed_Identifier_Generator( $identifier ) +); + +$service = $this->container->get( Job_Creator::class ); + +$this->assertSame( $identifier, $service->create_id() ); +``` + +Use `UlidValidator` when a test only needs to confirm that production generation returns a valid ULID. Avoid asserting an exact value from the system clock and secure entropy. diff --git a/src/Docs/src/content/docs/components/lock.mdx b/src/Docs/src/content/docs/components/lock.mdx new file mode 100644 index 0000000..cb80317 --- /dev/null +++ b/src/Docs/src/content/docs/components/lock.mdx @@ -0,0 +1,342 @@ +--- +title: Lock +description: Coordinate work safely across WordPress requests, workers, and servers with expiring ownership tokens. +sidebar: + order: 3 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation locks prevent two processes from performing the same protected work at the same time. Every implementation uses the shared `Lock` contract and returns an expiring `LockToken` that proves ownership. + +Locks are useful when processing renewals, synchronizing a remote catalog, rebuilding a shared resource, or running any operation that must not overlap for the same record. + +## Installation + +### Choose where requests coordinate + +Choose an implementation based on which processes must see the same lock: + +| Implementation | Package | Use when | +| --- | --- | --- | +| `InMemoryLock` | `stellarwp/foundation-lock` | Tests or work confined to one PHP process | +| `DatabaseLock` | `stellarwp/foundation-database` | WordPress requests coordinate through the site's database | +| `RedisLock` | `stellarwp/foundation-lock-redis` | Multiple processes or servers coordinate through a dedicated Redis connection | + +:::caution[Do not use InMemoryLock across requests] +`InMemoryLock` cannot coordinate separate PHP requests, workers, or servers. Use the database or Redis implementation for production work that can run concurrently. +::: + +### Install a lock implementation + +Install only the shared contract and in-memory implementation when no persistent coordination is needed: + +```shell +composer require stellarwp/foundation-lock +``` + +For database-backed locks in WordPress: + +```shell +composer require stellarwp/foundation-database +``` + +For Redis-backed locks: + +```shell +composer require stellarwp/foundation-lock-redis +``` + +:::note[The shared lock package is included] +The database and Redis packages install `stellarwp/foundation-lock` automatically. Do not require it separately when using either persistent implementation. +::: + +### Prepare the application container + +The backend examples below assume the application already has one composition root and registers feature providers through `App`. Review these guides before adding a lock provider: + + + + + + +## Configuration + +### Use the WordPress database + +The database implementation is the simplest persistent option when every process can reach the same primary WordPress database. Its guide covers provider wiring, lock-table initialization, and database-specific operating constraints. + + + +### Use a dedicated Redis connection + +Redis is appropriate when requests and workers coordinate across several application servers. Install one supported client in addition to the Redis lock package: + +```shell +composer require "predis/predis:>=3.0 <4.0" +``` + +Alternatively, install and enable the PhpRedis extension. + +Map the connection and key prefix in the root `config.php`: + +```php title="config.php" + [ + 'redis' => [ + 'host' => $_ENV['FOUNDATION_LOCK_REDIS_HOST'] ?? '127.0.0.1', + 'port' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_PORT'] ?? 6379 ), + 'database' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_DATABASE'] ?? 1 ), + 'prefix' => $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'] ?? 'your-plugin:lock:', + ], + ], +]; +``` + +Configure Predis and select `RedisLock` in the application's `src/Lock/Provider.php`: + +```php title="Provider.php" +register_connection(); + $this->register_lock(); + } + + private function register_connection(): void { + $this->container->when( Client::class ) + ->needs( '$parameters' ) + ->give( fn (): array => [ + 'host' => (string) $this->config->get( 'lock.redis.host' ), + 'port' => (int) $this->config->get( 'lock.redis.port' ), + 'database' => (int) $this->config->get( 'lock.redis.database' ), + ] ); + + $this->container->singleton( Client::class ); + $this->container->when( PredisConnection::class ) + ->needs( ClientInterface::class ) + ->give( static fn ( C $c ): Client => $c->get( Client::class ) ); + + $this->container->singleton( PredisConnection::class ); + $this->container->singleton( + Connection::class, + static fn ( C $c ): PredisConnection => $c->get( PredisConnection::class ) + ); + } + + private function register_lock(): void { + $this->container->singleton( + Lock::class, + static fn ( C $c ): RedisLock => $c->get( RedisLock::class ) + ); + } +} +``` + +Register the Foundation Redis provider and the application lock provider directly in `src/App.php`, in that order: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\LockRedis\LockRedisProvider; +use YourPlugin\Lock; + +/** @var list> */ +private const array PROVIDERS = [ + LockRedisProvider::class, + Lock\Provider::class, +]; +``` + +:::caution[Keep locks separate from the WordPress object cache] +Use a dedicated Redis connection and key prefix. A separate logical database protects locks from `FLUSHDB` against the object-cache database, but not from `FLUSHALL`, eviction, restart, or failover. Use a separate Redis endpoint when stronger isolation is required. +::: + +Redis Cluster supports only database `0`, so it requires endpoint and key-prefix isolation. Foundation currently supports one writable Redis endpoint; Redis Cluster and Sentinel are not supported. + +## Usage + +### Stop two requests from processing the same resource + +Application services should depend on `Lock`, not a backend class. For example, create `src/Catalog/Catalog_Synchronizer.php` and include the resource identifier in its lock name so unrelated work can proceed concurrently: + +```php title="Catalog_Synchronizer.php" +lock->acquire( + sprintf( 'catalog:%d:sync', $site_id ), + 300 + ); + + if ( $token === null ) { + return false; + } + + try { + $synchronize(); + } catch ( Throwable $failure ) { + try { + $this->lock->release( $token ); + } catch ( Throwable ) { + // Preserve the synchronization failure when cleanup also fails. + } + + throw $failure; + } + + if ( ! $this->lock->release( $token ) ) { + throw new RuntimeException( 'Catalog synchronization lock ownership was lost.' ); + } + + return true; + } +} +``` + +A `false` result means another process owns that site's lock. The caller can skip the duplicate request, retry later, or enqueue it without blocking synchronization for other sites. + +### Understand lock results + +Each operation distinguishes contention or lost ownership from an infrastructure failure: + +| Operation | Success | Contention or lost ownership | +| --- | --- | --- | +| `acquire($name, $ttl)` | Returns a `LockToken` | Returns `null` | +| `release($token)` | Returns `true` | Returns `false` | +| `refresh($token, $ttl)` | Returns a renewed `LockToken` | Returns `null` | +| `isAcquired($name)` | Returns the current observed state | Advisory only; do not use it before acting | + +`isAcquired()` cannot safely replace `acquire()`. Another process can acquire or release the lock immediately after the check. + +:::caution[Do not treat an unavailable backend as lock contention] +`acquire()` returning `null` is an expected result: another process owns the lock. Skip the duplicate work or retry it later. + +`LockUnavailableException` means Foundation could not generate secure ownership or could not determine the backend operation's result. Abort or retry the protected operation. Do not continue without a lock because ownership may be unknown. + +```php +try { + $token = $lock->acquire( 'catalog:42:sync', 300 ); +} catch ( LockUnavailableException $exception ) { + // Coordination failed. Report the failure and retry the whole operation later. + return; +} + +if ( $token === null ) { + // Another process owns the lock. Skip this duplicate attempt. + return; +} + +// Perform the protected work and release the token as shown above. +``` +::: + +### Keep a lease during long work + +Locks are time-bounded leases. Choose a TTL longer than the protected operation, or refresh ownership before the current token expires: + +```php +$refreshed = $lock->refresh( $token, 120 ); + +if ( $refreshed === null ) { + // Ownership expired or was lost. Do not continue protected work. + return; +} + +$token = $refreshed; +``` + +Only `Lock::refresh()` renews the backend lease. The returned token contains the new expiration and must replace the previous token. + +:::caution[Blocking operations cannot heartbeat themselves] +If one remote request may block longer than the remaining lease, choose a conservative TTL before starting it. A lock that expires while work is still running can be acquired by another process. +::: + +:::caution[A lock does not make remote side effects exactly-once] +A lock coordinates cooperating application processes only while its lease remains valid. It cannot determine whether a payment gateway or another remote API completed a request after a timeout, and another process can acquire an expired lock. + +For payments, order creation, email delivery, and similar side effects, also use the remote system's idempotency support. Derive the idempotency key from the stable business operation, such as a renewal order ID. The lock reduces local concurrency; the idempotency key prevents the remote operation from being applied twice. + +```php +$renewal_id = 1042; +$operation_name = sprintf( 'renewal:%d', $renewal_id ); +$token = $lock->acquire( $operation_name, 300 ); + +if ( $token === null ) { + return; +} + +// Every attempt for this renewal sends the same key, including retries after a timeout. +$gateway->charge( + $renewal_id, + [ 'idempotency_key' => $operation_name ] +); + +// Release the token as shown above. +``` +::: + +## Testing + +### Use in-memory locks in tests + +Inject `InMemoryLock` when a test needs real ownership and expiration behavior without a database or Redis service: + +```php +use StellarWP\Foundation\Lock\InMemoryLock; + +$service = new Catalog_Synchronizer( new InMemoryLock() ); +``` + +Because application code depends on the shared `Lock` contract, the production backend can change without changing the service under test. diff --git a/src/Docs/src/content/docs/components/log.mdx b/src/Docs/src/content/docs/components/log.mdx new file mode 100644 index 0000000..2d948f8 --- /dev/null +++ b/src/Docs/src/content/docs/components/log.mdx @@ -0,0 +1,252 @@ +--- +title: Log +description: Add configurable PSR-3 logging to application services with console, PHP error log, stacked, or null channels. +sidebar: + order: 4 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Log configures [Monolog](https://github.com/Seldaek/monolog) behind the standard `Psr\Log\LoggerInterface`. Application services depend on the PSR-3 contract, while configuration selects where records are written and which levels are kept. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-log +``` + +### Prepare the application + +Foundation Log uses the shared application configuration and provider architecture established in these guides: + + + + + + + +## Configuration + +### Choose where records are written + +Set one channel for the application: + +| Channel | Writes to | Use when | +| --- | --- | --- | +| `console` | A configured stream, with colored levels | Local development, CLI processes, or container logs | +| `errorlog` | PHP's `error_log()` | The hosting platform collects the PHP error log | +| `stack` | Both `console` and `errorlog` | The same records must reach the process stream and PHP error log | +| `null` | Nothing | Logging must be intentionally disabled, including in focused tests | + +:::caution[Choose a production channel explicitly] +The example below defaults to `null` so an unconfigured application does not write somewhere unexpected. Set `APP_LOG_CHANNEL` in deployed environments where records must be retained; otherwise failures will not be logged. +::: + +Map the channel, minimum level, and stream in the application's root `config.php`: + +```php title="config.php" + [ + 'channel' => $_ENV['APP_LOG_CHANNEL'] ?? 'null', + 'level' => $_ENV['APP_LOG_LEVEL'] ?? 'info', + 'channels' => [ + 'console' => [ + 'with' => [ + 'stream' => 'php://stdout', + ], + ], + 'stack' => [ + 'with' => [ + 'stream' => 'php://stdout', + ], + ], + ], + ], +]; +``` + +The stream setting is used by `console` and by the console side of `stack`. Common values are `php://stdout` and `php://stderr`. + +### Choose the minimum level + +The configured level keeps records at that severity and above: + +| Level | Typical use | +| --- | --- | +| `debug` | Detailed diagnostics useful during development | +| `info` | Normal application milestones | +| `notice` | Significant but expected events | +| `warning` | Unexpected conditions from which the operation can recover | +| `error` | An operation failed but the application can continue | +| `critical` | A major application capability is unavailable | +| `alert` | Immediate operator action is required | +| `emergency` | The application or site is unusable | + +Use lowercase names in configuration. Foundation also accepts title case and uppercase variants. + +### Register the logging provider + +In `src/App.php`, add `LogProvider` before feature providers that consume `LoggerInterface`: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Log\LogProvider; +use YourPlugin\Catalog; + +/** @var list> */ +private const array PROVIDERS = [ + LogProvider::class, + Catalog\Provider::class, +]; +``` + +`LogProvider` is an optional default. Applications that need rotating files, a remote log service, custom processors, or different failure behavior can omit it and bind `LoggerInterface` in their own provider. + +### Understand configuration failures + +An unavailable PHP `error_log()` function does not stop the application: + +- The `errorlog` channel falls back to the null handler. +- The `stack` channel keeps the console handler and skips the unavailable error-log handler. + +Invalid configuration is different. An unsupported level fails while `LogProvider` is registered, and an unsupported channel fails when `LoggerInterface` is first resolved. Use one of the documented values rather than silently losing records because of a typo. + +```php +// error_log() is disabled: the application continues without that handler. +$_ENV['APP_LOG_CHANNEL'] = 'errorlog'; + +// Unsupported configuration: fix the value instead of continuing silently. +$_ENV['APP_LOG_CHANNEL'] = 'file'; +``` + +## Usage + +### Inject the PSR-3 logger + +In `src/Catalog/Catalog_Importer.php`, depend on `Psr\Log\LoggerInterface`, not Monolog or a Foundation handler. Include structured context with identifiers and values needed to investigate the event: + +```php title="Catalog_Importer.php" +logger->info( + 'Starting catalog import.', + [ + 'site_id' => $site_id, + 'product_count' => count( $products ), + ] + ); + + foreach ( $products as $product ) { + if ( empty( $product['sku'] ) ) { + $this->logger->warning( + 'Skipping a product without a SKU.', + [ + 'site_id' => $site_id, + 'product_id' => $product['id'] ?? null, + ] + ); + + continue; + } + + // Import the product. + } + } +} +``` + +Context remains machine-readable and keeps operational data out of the message text. Do not include passwords, access tokens, payment details, or other secrets. + +### Record exceptions with their context + +Pass the exception under the conventional `exception` key so handlers and processors can inspect it: + +```php +try { + $this->catalog->synchronize( $site_id ); +} catch ( Throwable $exception ) { + $this->logger->error( + 'Catalog synchronization failed.', + [ + 'site_id' => $site_id, + 'exception' => $exception, + ] + ); + + throw $exception; +} +``` + +Log the failure at the boundary responsible for handling or reporting it. Avoid recording the same exception again at every layer through which it passes. + +## Testing + +### Disable records when logging is irrelevant + +Replace the application logger with the PSR-3 `NullLogger` when a focused test does not assert logging behavior: + +```php +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +$this->container->bind( LoggerInterface::class, NullLogger::class ); +``` + +### Assert important records + +Monolog's `TestHandler` captures records without writing them to an external destination: + +```php +use Monolog\Handler\TestHandler; +use Monolog\Logger; +use Psr\Log\LoggerInterface; + +$handler = new TestHandler(); +$logger = new Logger( 'test', [ $handler ] ); + +$this->container->bind( LoggerInterface::class, $logger ); + +$service = $this->container->get( Catalog_Importer::class ); +$service->import( 42, [ [ 'id' => 10 ] ] ); + +$this->assertTrue( $handler->hasWarning( [ + 'message' => 'Skipping a product without a SKU.', + 'context' => [ + 'site_id' => 42, + 'product_id' => 10, + ], +] ) ); +``` + +Assert logs only when they are part of the feature's observable operational contract. Otherwise, test the feature's result and use `NullLogger`. diff --git a/src/Docs/src/content/docs/components/pipeline.mdx b/src/Docs/src/content/docs/components/pipeline.mdx new file mode 100644 index 0000000..73ed681 --- /dev/null +++ b/src/Docs/src/content/docs/components/pipeline.mdx @@ -0,0 +1,325 @@ +--- +title: Pipeline +description: Pass a value through an ordered sequence of container-resolved transformations and checks. +sidebar: + order: 6 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Pipeline passes a value through an ordered chain of pipes. Each pipe can transform the value, perform a check, stop execution, or call the next pipe. The implementation is based on Laravel's Pipeline pattern and uses the Foundation container to resolve class-based pipes. + +Pipelines are useful when one operation has several independent steps whose order should remain visible, such as normalizing input, validating business rules, enriching data, and persisting the final result. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-pipeline +``` + +### Prepare the application + +Pipeline has no service provider or configuration file. It uses the shared container when class names are supplied as pipes: + + + + + + + +## Configuration + +### Configure a pipeline in a provider + +Define the ordered pipe list in the feature provider, then use a contextual binding to select that configured pipeline for its consumer. This keeps workflow composition out of the class that runs it. + +In `src/Catalog/Provider.php`: + +```php title="Provider.php" +register_product_import_pipeline(); + } + + private function register_product_import_pipeline(): void { + $this->container->bind( + self::PRODUCT_IMPORT_PIPELINE, + static fn ( C $c ): Pipeline => $c->get( Pipeline::class )->through( [ + Normalize_Product::class, + Require_Product_Sku::class, + ] ) + ); + + $this->container->when( Product_Importer::class ) + ->needs( Pipeline::class ) + ->give( + static fn ( C $c ): Pipeline => $c->get( self::PRODUCT_IMPORT_PIPELINE ) + ); + } +} +``` + +In `src/Catalog/Product_Importer.php`, the consumer uses the pipeline it receives without knowing which pipes compose it: + +```php title="Product_Importer.php" + $product + * + * @throws InvalidArgumentException When the product has no SKU. + */ + public function import( array $product ): Product { + /** @var Product $imported */ + $imported = $this->pipeline + ->send( $product ) + ->then( + fn ( array $normalized ): Product => $this->products->save( $normalized ) + ); + + return $imported; + } +} +``` + +:::caution[Use a prototype binding] +Use `bind()` for configured pipelines. `send()`, `through()`, `pipe()`, and `via()` change the pipeline instance, so a singleton can leak mutable state between operations in a long-running process. +::: + +Register each distinct workflow under its own container identifier and contextually give consumers the one they need. Class-name pipes are resolved through the container, so their own dependencies remain injectable. + +## Usage + +### Create a transforming pipe + +Create `src/Catalog/Normalize_Product.php` for the first pipe. A pipe receives the current value and a `$next` closure; pass the transformed value to `$next` to continue: + +```php title="Normalize_Product.php" + $product + * + * @return mixed + */ + public function handle( array $product, Closure $next ): mixed { + $product['sku'] = strtoupper( trim( (string) ( $product['sku'] ?? '' ) ) ); + $product['name'] = trim( (string) ( $product['name'] ?? '' ) ); + + return $next( $product ); + } +} +``` + +The first configured pipe runs first. The destination passed to `then()` runs only after every pipe calls `$next`. + +### Reject invalid input + +Create `src/Catalog/Require_Product_Sku.php` for the validation pipe. Throw when the operation cannot continue; Pipeline rethrows exceptions from pipes and the destination unchanged: + +```php title="Require_Product_Sku.php" + $product + * + * @throws InvalidArgumentException When the product has no SKU. + * + * @return mixed + */ + public function handle( array $product, Closure $next ): mixed { + if ( $product['sku'] === '' ) { + throw new InvalidArgumentException( 'A product SKU is required.' ); + } + + return $next( $product ); + } +} +``` + +Catch the exception at the application boundary that can report, retry, or convert the failure. Avoid swallowing it inside the pipeline unless stopping is an expected result. + +### Stop without running later pipes + +A pipe short-circuits the pipeline by returning a result without calling `$next`: + +```php +public function handle( array $product, Closure $next ): mixed { + if ( ( $product['status'] ?? '' ) === 'ignored' ) { + return $product; + } + + return $next( $product ); +} +``` + +In this example, later pipes and the final destination do not run. Use short-circuiting only when the returned type is valid for the entire pipeline; otherwise callers receive an unexpected result type. + +### Choose a pipe form + +The provider's `through()` call accepts several pipe forms: + +| Pipe | Behavior | +| --- | --- | +| Class name | Resolved through the container; `handle()` is called when present, otherwise the object must be invokable | +| Object | Used directly; `handle()` is called when present, otherwise the object must be invokable | +| Callable | Called directly with the current value and `$next` | +| `ClassName:param1,param2` | Resolved through the container and given the extra string parameters after `$next` | + +Prefer class-name pipes for reusable application behavior because their constructor dependencies remain injectable. Closures are useful for a small operation local to one configured pipeline: + +```php +static fn ( C $c ): Pipeline => $c->get( Pipeline::class ) + ->through( + Normalize_Product::class, + static fn ( array $value, Closure $next ): mixed => $next( [ + ...$value, + 'source' => 'remote', + ] ) + ); +``` + +The consumer uses `thenReturn()` when the fully processed value is the result. It uses `then()` when the destination performs the final operation, such as saving the normalized product. + +### Pass literal parameters to a pipe + +Append comma-separated string parameters after the class name in the provider's pipe list: + +```php +$pipeline->through( + Replace_Product_Status::class . ':draft,pending' +); +``` + +The matching pipe receives them after the value and `$next`: + +```php +public function handle( + array $product, + Closure $next, + string $from, + string $to +): mixed { + if ( ( $product['status'] ?? '' ) === $from ) { + $product['status'] = $to; + } + + return $next( $product ); +} +``` + +Parameters from the pipe string are always strings. Prefer constructor injection and provider configuration for service dependencies or structured configuration. + +### Use a different pipe method + +Pipes use `handle()` by default. Configure `via()` when every object pipe in that pipeline exposes another method: + +```php +static fn ( C $c ): Pipeline => $c->get( Pipeline::class ) + ->via( 'process' ) + ->through( [ + Product_Normalizer::class, + Product_Validator::class, + ] ); +``` + +Both classes in this example must expose `process( $value, Closure $next )`. Use `through()` to replace the configured pipe list and `pipe()` to append additional pipes. + +## Testing + +### Test each pipe in isolation + +Call the pipe with an identity closure so the test observes the value passed to the next stage: + +```php +$pipe = new Normalize_Product(); + +$result = $pipe->handle( + [ + 'sku' => ' abc-123 ', + 'name' => ' Example product ', + ], + static fn ( array $product ): array => $product +); + +$this->assertSame( 'ABC-123', $result['sku'] ); +$this->assertSame( 'Example product', $result['name'] ); +``` + +### Test the configured order once + +Use the real container for one focused test that resolves the consumer and proves the provider-configured pipeline runs in the intended order: + +```php +$product = $this->container->get( Product_Importer::class )->import( [ + 'sku' => ' abc-123 ', + 'name' => ' Example product ', + ] ); + +$this->assertSame( 'ABC-123', $product->sku() ); +``` + +Keep most tests on individual pipes. The pipeline package already owns the generic chaining behavior; application tests need to prove only their transformations, short circuits, and configured order. diff --git a/src/Docs/src/content/docs/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx new file mode 100644 index 0000000..d276a20 --- /dev/null +++ b/src/Docs/src/content/docs/components/wp-cli.mdx @@ -0,0 +1,367 @@ +--- +title: WP-CLI +description: Build container-aware WP-CLI commands and register them from feature providers. +sidebar: + order: 7 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation WP-CLI provides a container-aware command base class and a shared provider for registering commands during WP-CLI bootstrap. Application services remain injectable, command prefixes remain configurable, and feature providers can contribute commands without loading WP-CLI classes during normal WordPress requests. + +## Installation + +### Install the runtime package + +Install WP-CLI support as a production dependency when the plugin ships commands: + +```shell +composer require stellarwp/foundation-wpcli +``` + +WP-CLI supplies the `WP_CLI` and `WP_CLI_Command` runtime classes. Applications running commands through WP-CLI do not normally need to install `wp-cli/wp-cli` separately. + +:::caution[Install WP-CLI support without `--dev`] +If the plugin ships WP-CLI commands, `stellarwp/foundation-wpcli` must be in Composer's `require` section. A production installation using `--no-dev` omits packages from `require-dev`, leaving the plugin's command classes without their Foundation `Command` base class. +::: + +### Install the generator separately + +Install the developer CLI only when the team wants to generate command classes: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +The generator is development tooling and does not need to ship in a standalone plugin archive. + +### Prepare the application + +WP-CLI commands use the same container, configuration, and provider graph as the rest of the application: + + + + + + + +## Configuration + +### Choose the command prefix + +In the root `config.php`, configure a stable application prefix for a distributable plugin: + +```php title="config.php" + [ + 'prefix' => 'your-plugin', + ], +]; +``` + +Commands will be registered beneath `wp your-plugin`. Complete WordPress applications that own the full installation can keep the zero-configuration `nx` default. + +Set `wpcli.command_prefix` in the same root `config.php` only when WP-CLI should intentionally use a different prefix: + +```php title="config.php" +return [ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'wpcli' => [ + 'command_prefix' => 'your-plugin-tools', + ], +]; +``` + +### Register the WP-CLI provider + +In `src/App.php`, register `WPCliProvider` before feature providers that contribute commands: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\WPCli; +use YourPlugin\Catalog; + +/** @var list> */ +private const array PROVIDERS = [ + WPCli\WPCliProvider::class, + Catalog\Provider::class, +]; +``` + +`WPCliProvider` listens to `cli_init` and resolves the command collection only when WP-CLI is active. Feature providers should contribute commands to that collection instead of registering their own `cli_init` hooks. + +### Contribute a command from its feature provider + +`WPCliProvider` creates the shared `CommandPrefix` from configuration. Feature providers only add their commands to the shared collection. + +In `src/Catalog/Provider.php`: + +```php title="Provider.php" +register_cli_commands(); + } + + private function register_cli_commands(): void { + $this->container->mergeArrayVar( + WPCliProvider::COMMANDS, + static fn ( C $c ): array => [ + $c->get( Sync_Catalog_Command::class ), + ] + ); + } +} +``` + +:::danger[Do not bind command classes separately] +Do not call `bind()` or `singleton()` for `Sync_Catalog_Command`. DI52 may autoload its `WP_CLI_Command` parent during normal WordPress bootstrap, before WP-CLI exists. The lazy `mergeArrayVar()` contribution resolves the command during `cli_init` instead. +::: + +Add more commands to the same returned array or contribute them from other feature providers. `WPCliProvider` validates the complete collection before registering any command. + +## Usage + +### Generate a command + +Generate the initial class from the project root: + +```shell +vendor/bin/foundation make:wpcli-command Sync_Catalog \ + --namespace="YourPlugin\\Catalog\\Cli" \ + --subcommand="catalog:sync" \ + --description="Synchronize the product catalog." +``` + +The generator uses Composer's PSR-4 mapping to write `src/Catalog/Cli/Sync_Catalog_Command.php`. It creates a Snake_Case class with examples of a positional argument, associative option, and flag. + +Projects using Strauss receive the configured namespace prefix on generated Foundation imports. With `update_call_sites=false`, handwritten provider imports may also need the project's Strauss prefix. + +Project-specific command stubs can override the default at `foundation/stubs/wpcli/command.stub`. + +### Implement the command + +Keep the command focused on input, output, and selecting the application operation. Inject the service that owns the business behavior rather than resolving it from the container. + +In `src/Catalog/Cli/Sync_Catalog_Command.php`: + +```php title="Sync_Catalog_Command.php" + $args + * @param array $assocArgs + * + * @throws \WP_CLI\ExitException When command input is invalid. + */ + public function runCommand( array $args = [], array $assocArgs = [] ): int { + $source = (string) ( $args[0] ?? '' ); + $batchSize = absint( get_flag_value( + $assocArgs, + self::OPTION_BATCH_SIZE, + self::DEFAULT_BATCH_SIZE + ) ); + $dryRun = (bool) get_flag_value( $assocArgs, self::FLAG_DRY_RUN, false ); + + if ( $batchSize < 1 ) { + WP_CLI::error( __( 'The batch size must be greater than zero.', 'your-plugin' ) ); + } + + $count = $this->synchronizer->sync( $source, $batchSize, $dryRun ); + + if ( $dryRun ) { + WP_CLI::success( sprintf( + /* translators: 1: Product count, 2: Catalog source. */ + __( 'Dry run found %1$d products to synchronize from %2$s.', 'your-plugin' ), + $count, + $source + ) ); + + return self::SUCCESS; + } + + WP_CLI::success( sprintf( + /* translators: 1: Product count, 2: Catalog source. */ + __( 'Synchronized %1$d products from %2$s.', 'your-plugin' ), + $count, + $source + ) ); + + return self::SUCCESS; + } + + protected function subcommand(): string { + return 'catalog:sync'; + } + + protected function description(): string { + return __( 'Synchronize the product catalog.', 'your-plugin' ); + } + + protected function arguments(): array { + return [ + [ + 'type' => self::POSITIONAL, + 'name' => self::ARG_SOURCE, + 'description' => __( 'The catalog source to synchronize.', 'your-plugin' ), + 'optional' => false, + ], + [ + 'type' => self::ASSOCIATIVE, + 'name' => self::OPTION_BATCH_SIZE, + 'description' => __( 'The number of products processed per batch.', 'your-plugin' ), + 'optional' => true, + 'default' => self::DEFAULT_BATCH_SIZE, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_DRY_RUN, + 'description' => __( 'Preview the synchronization without writing changes.', 'your-plugin' ), + 'optional' => true, + ], + ]; + } +} +``` + +The three synopsis types map to WP-CLI input as follows: + +| Type | Declaration | Invocation | +| --- | --- | --- | +| Positional | `source` | `staging` | +| Associative | `batch-size` | `--batch-size=50` | +| Flag | `dry-run` | `--dry-run` | + +### Run the command + +With the `your-plugin` application prefix, run: + +```shell +wp your-plugin catalog:sync staging --batch-size=50 --dry-run +``` + +Inspect the generated synopsis and description with: + +```shell +wp help your-plugin catalog:sync +``` + +:::note[Choose the correct failure signal] +Use `WP_CLI::error()` for invalid input or an operation that cannot continue; it stops the command with a failing exit code. Use `WP_CLI::warning()` when execution can continue. Returning any nonzero status from `runCommand()` also causes the Foundation command wrapper to halt with that status. +::: + +## Testing + +### Test business behavior outside the command + +Keep most tests on `Catalog_Synchronizer` and its collaborators. The command should contain only input normalization, application service invocation, and WP-CLI output behavior. + +### Execute the registered command + +Use the Codeception `wpcli` suite for one end-to-end test that proves the provider contribution, command prefix, arguments, output, and exit code work together. + +In `tests/wpcli/Catalog/SyncCatalogCest.php`: + +```php title="SyncCatalogCest.php" +cli( [ + 'your-plugin', + 'catalog:sync', + 'staging', + '--batch-size=50', + '--dry-run', + ] ); + + $I->seeResultCodeIs( 0 ); + $I->seeInShellOutput( 'Dry run found' ); + } + + public function test_it_rejects_an_invalid_batch_size( WPCLITester $I ): void { + $I->cli( [ + 'your-plugin', + 'catalog:sync', + 'staging', + '--batch-size=0', + ] ); + + $I->seeResultCodeIs( 1 ); + Assert::assertStringContainsString( + 'The batch size must be greater than zero.', + $I->grabLastShellErrorOutput() + ); + } +} +``` + +Run the suite through SLIC: + +```shell +slic run wpcli +``` + +One real command test is more valuable than duplicating the Foundation command wrapper's generic registration tests throughout the application. diff --git a/src/Docs/src/content/docs/index.mdx b/src/Docs/src/content/docs/index.mdx new file mode 100644 index 0000000..b12448d --- /dev/null +++ b/src/Docs/src/content/docs/index.mdx @@ -0,0 +1,40 @@ +--- +title: Foundation +description: Shared PHP infrastructure for Nexcess libraries and WordPress plugins. +template: splash +hero: + title: Foundation + tagline: Shared PHP infrastructure for Nexcess libraries and WordPress plugins. + actions: + - text: Start with Foundation + link: /start/what-is-foundation/ + icon: right-arrow + variant: primary + - text: View on GitHub + link: https://github.com/stellarwp/foundation + icon: external + variant: minimal +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + +## Start with the application foundation + + + + Use the aggregate package for convenience or select individual components for a lean production archive. + [Choose an installation approach](/start/install-foundation/) + + + Build the shared container and configuration that Foundation providers use throughout an application. + [Configure the container](/start/configure-the-container/) + + + Keep bindings, hooks, and feature configuration inside focused service providers. + [Register service providers](/start/register-service-providers/) + + + Set a stable application prefix so multiple Foundation consumers do not share commands or database resources. + [Scope Foundation resources](/start/scope-foundation/) + + diff --git a/src/Docs/src/content/docs/start/bootstrap-wordpress-plugin.md b/src/Docs/src/content/docs/start/bootstrap-wordpress-plugin.md new file mode 100644 index 0000000..de05bd4 --- /dev/null +++ b/src/Docs/src/content/docs/start/bootstrap-wordpress-plugin.md @@ -0,0 +1,182 @@ +--- +title: Bootstrap a WordPress Plugin +description: Use an application composition root to configure Foundation and register plugin features in one place. +sidebar: + order: 4 +--- + +A WordPress plugin needs one predictable place to construct its container, load configuration, and register service providers. Keep that work in a small application composition root so feature classes receive fully configured dependencies. + +## Create the application configuration + +The root `config.php` maps environment values into the structure providers consume: + +```php title="config.php" + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; +``` + +## Compose the application in App + +Create `src/App.php`. The application object binds shared values before registering providers, and its provider list makes startup order visible from one file: + +```php title="App.php" +> */ + private const array PROVIDERS = []; + + private static self $instance; + + private function __construct( + private readonly string $plugin_file, + private readonly Container $container, + private readonly Dot $config + ) { + $this->configure_container(); + $this->register_providers(); + } + + public static function instance( + string $plugin_file, + Container $container, + Dot $config + ): self { + if ( ! isset( self::$instance ) ) { + self::$instance = new self( $plugin_file, $container, $config ); + } + + return self::$instance; + } + + public function container(): Container { + return $this->container; + } + + private function configure_container(): void { + $this->container->bind( Container::class, $this->container ); + $this->container->singleton( Dot::class, $this->config ); + $this->container->singleton( self::PLUGIN_FILE, $this->plugin_file ); + $this->container->singleton( self::PLUGIN_DIR, plugin_dir_path( $this->plugin_file ) ); + } + + private function register_providers(): void { + foreach ( self::PROVIDERS as $provider ) { + $this->container->register( $provider ); + } + } +} +``` + +Add infrastructure providers and top-level feature providers directly to `PROVIDERS` as application features are introduced. Keep cross-feature dependencies and their registration order visible in this composition root. + +A large feature may expose one composition provider that registers its own internal providers. That provider should do only that: it must not also register service definitions, configuration, or hooks. + +## Create the application helper + +Create `src/functions.php`. The helper supplies the application dependencies on first use, and `App::instance()` returns the same application for the remainder of the request: + +```php title="functions.php" +container()` when WordPress invokes a callback that cannot receive constructor dependencies, such as activation and deactivation hooks. Application services should continue to use constructor injection. + +## Autoload the application with Composer + +In the root `composer.json`, map the plugin namespace to `src/` and autoload the application helper: + +```json title="composer.json" +{ + "autoload": { + "psr-4": { + "YourPlugin\\\\": "src/" + }, + "files": [ + "src/functions.php" + ] + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0" + } +} +``` + +Regenerate Composer's autoloader after changing the mapping: + +```shell +composer dump-autoload +``` + +## Start the application from the plugin entrypoint + +The root `your-plugin.php` can now load Composer and defer application startup to an appropriate WordPress hook: + +```php title="your-plugin.php" +bind( Container::class, $container ); +$container->singleton( Dot::class, $config ); +``` + +Binding the Foundation `Container` contract allows application services to request the shared adapter. The `Dot` binding makes the application's configuration available to every Foundation provider. + +## Map environment values in config.php + +Keep environment access at the configuration boundary rather than reading `$_ENV` throughout application services. Map values in the root `config.php`: + +```php title="config.php" + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], + 'log' => [ + 'channel' => $_ENV['APP_LOG_CHANNEL'] ?? 'null', + 'level' => $_ENV['APP_LOG_LEVEL'] ?? 'debug', + ], +]; +``` + +Providers read nested values through their inherited `$this->config` property: + +```php +$channel = $this->config->get( 'log.channel' ); +``` + +Pass resolved configuration into service constructors through container bindings. Application services should not read the environment or the `Dot` configuration object directly. + +## Load an optional .env file + +The container package includes `vlucas/phpdotenv`. A non-WordPress application can load a local environment file before requiring `config.php`: + +```php +use Dotenv\Dotenv; + +if ( is_file( __DIR__ . '/.env' ) ) { + Dotenv::createImmutable( __DIR__ )->load(); +} +``` + +WordPress applications can map deployment values into `$_ENV` before the plugin constructs its container. Keep local environment files out of production archives. + +## Continue + +[Bootstrap a WordPress plugin](/start/bootstrap-wordpress-plugin/) with one application object that owns the container, configuration, and provider order. diff --git a/src/Docs/src/content/docs/start/install-foundation.md b/src/Docs/src/content/docs/start/install-foundation.md new file mode 100644 index 0000000..8596203 --- /dev/null +++ b/src/Docs/src/content/docs/start/install-foundation.md @@ -0,0 +1,69 @@ +--- +title: Install Foundation +description: Install the aggregate Foundation package or select individual runtime components. +sidebar: + order: 2 +--- + +Foundation requires PHP 8.3 or newer. Composer resolves the dependencies required by the aggregate package or selected components. + +:::caution[Building a production WordPress plugin?] +Do not install `stellarwp/foundation` only to get the Foundation CLI. The aggregate package includes the developer CLI as a normal dependency, so `composer install --no-dev` will not remove it. + +Require only the split runtime packages the plugin ships, then install `stellarwp/foundation-cli` separately with `--dev`. +::: + +## Install runtime components + +Install the packages the application uses in production: + +```shell +composer require \ + stellarwp/foundation-container \ + stellarwp/foundation-log \ + stellarwp/foundation-lock +``` + +Add other components as the application needs them rather than installing integrations speculatively. + +## Install developer tooling separately + +The CLI package generates project code and normally does not belong in a production WordPress plugin archive: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +Production builds can then exclude developer dependencies: + +```shell +composer install --no-dev --classmap-authoritative +``` + +If generated code extends a runtime Foundation class, install that runtime package normally. For example, generated WP-CLI commands require `stellarwp/foundation-wpcli` in `require`. + +## Install every component + +Install the aggregate package when one application intentionally owns the complete Foundation installation and expects to use most components: + +```shell +composer require stellarwp/foundation +``` + +This provides every runtime component and the developer CLI at `vendor/bin/foundation`. It is convenient for a complete application or development environment, but it is not the lean installation path for a distributable WordPress plugin. + +## Load Composer before Foundation + +Load Composer's generated autoloader from the application entrypoint before constructing the container or registering providers: + +```php + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], + 'admin_notice' => [ + 'capability' => $_ENV['ADMIN_NOTICE_CAPABILITY'] ?? 'manage_options', + ], +]; +``` + +Application classes should receive resolved values through constructor injection rather than reading `$_ENV` or the configuration object directly. + +## Create the feature class + +Create `src/Admin_Notice/Notice.php`. The notice receives the capability supplied by its provider, while user-facing text remains in the feature so WordPress can translate it: + +```php title="Notice.php" +capability ) === '' ) { + throw new InvalidArgumentException( 'The admin notice capability cannot be empty.' ); + } + } + + /** + * Display the configured notice. + * + * @action admin_notices + */ + public function display(): void { + if ( ! current_user_can( $this->capability ) ) { + return; + } + + printf( + '

%s

', + esc_html__( 'Your plugin is ready.', 'your-plugin' ) + ); + } +} +``` + +The `@action admin_notices` annotation records why WordPress calls `display()`. The class itself does not register global hooks or resolve dependencies from the container. + +## Register the complete feature + +In `src/Admin_Notice/Provider.php`, alias the Foundation base provider because it shares the `Provider` short name. Keep the feature's definitions and hooks together in one focused registration method: + +```php title="Provider.php" +register_admin_notice(); + } + + private function register_admin_notice(): void { + $this->container->when( Notice::class ) + ->needs( '$capability' ) + ->give( (string) $this->config->get( 'admin_notice.capability' ) ); + + $this->container->singleton( Notice::class ); + + add_action( + 'admin_notices', + $this->container->callback( Notice::class, 'display' ) + ); + } +} +``` + +The provider registers the hook before resolving `Notice`. WordPress creates the notice through the container only when `admin_notices` runs. + +As a provider grows, add methods named for the feature or capability they configure, such as `register_admin_notice()` or `register_report_export()`. Avoid grouping unrelated work under methods such as `register_bindings()` or `register_hooks()` merely because it uses the same API. + +## Add the provider to the application + +Register the feature in the ordered provider list in `src/App.php`: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use YourPlugin\Admin_Notice; + +/** @var list> */ +private const array PROVIDERS = [ + Admin_Notice\Provider::class, +]; +``` + +Register infrastructure providers before feature providers that consume them. Avoid resolving application services while providers are still registering; complete the container graph before WordPress invokes its feature entrypoints. + +For a larger feature, its top-level `Provider` may register internal providers so `App` only needs to know the feature entrypoint. Keep that provider as a pure composition boundary: if it registers other providers, it should not also contain bindings, configuration, hooks, or feature behavior. Providers shared across features still belong in the application's ordered provider list. + +## Continue + +[Scope Foundation to the application](/start/scope-foundation/) before using shared WordPress resources. diff --git a/src/Docs/src/content/docs/start/scope-foundation.md b/src/Docs/src/content/docs/start/scope-foundation.md new file mode 100644 index 0000000..32da47b --- /dev/null +++ b/src/Docs/src/content/docs/start/scope-foundation.md @@ -0,0 +1,73 @@ +--- +title: Scope Foundation to Your Application +description: Decide whether Foundation-managed commands and database resources need an application prefix. +sidebar: + order: 6 +--- + +The Foundation prefix gives shared resources an application identity. It affects resources that exist outside PHP's class namespace, including WP-CLI command names, migration tables, and migration locks. + +## Decide whether you need a prefix + +:::tip[Building the complete WordPress application?] +If one project owns the WordPress installation, its themes, its plugins, and the Foundation composition root, you generally do not need to configure a prefix. Foundation's default `nx` prefix gives that application one shared resource identity. + +This is appropriate when the deployed code is intentionally one application and Foundation is configured centrally rather than bundled independently by its features. +::: + +:::caution[Building a standalone plugin?] +A plugin distributed for installation on WordPress sites must configure its own stable prefix. It can run alongside unrelated plugins that also bundle Foundation, and those plugins must not share its commands, migration history, or locks. +::: + +PHP namespace prefixing tools such as Strauss do not solve this problem. They isolate PHP classes, but WordPress database tables and WP-CLI command names still share the same installation-wide namespace. + +## Configure a standalone plugin + +Set `foundation.prefix` in the plugin's `config.php`: + +```php title="config.php" + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; +``` + +Use a stable lowercase kebab-case value based on the plugin's permanent identity: + +```text +your-plugin +``` + +Do not derive the prefix from a display name, installation directory, release version, or other value that may change. Changing it later makes Foundation look for a different set of resources. + +## Understand what it changes + +Foundation adapts the prefix to the format required by each package. Given `your-plugin`: + +- WP-CLI commands are registered under `wp your-plugin`. +- The migration ledger defaults to `your_plugin_foundation_migrations`. +- Database-backed migration locks default to `your_plugin_foundation_locks`. +- The migration lock name defaults to `your-plugin-foundation-database-migrations`. + +Without configuration, those resources use `nx`. That is convenient for an application that owns the installation, but unsafe for a standalone plugin because another Foundation consumer may use the same defaults. + +## Keep the prefix stable + +Treat the prefix as persisted application identity. Once a release has created migration tables or registered operational commands, changing the prefix can make existing migrations appear missing and can leave the old resources behind. + +:::note +Different environments may use different databases, but a deployed environment should retain the same prefix for the lifetime of its Foundation-managed resources. +::: + +## Override one package when necessary + +Package-specific configuration takes precedence over names derived from `foundation.prefix`. Use an override when an existing installation must retain a previously published table, lock, or command name. + +New applications and plugins should normally configure only `foundation.prefix` and allow Foundation packages to derive consistent defaults. + +## Continue + +[Configure application locks](/components/lock/) when work must not overlap across requests, workers, or servers. diff --git a/src/Docs/src/content/docs/start/what-is-foundation.md b/src/Docs/src/content/docs/start/what-is-foundation.md new file mode 100644 index 0000000..5f242f0 --- /dev/null +++ b/src/Docs/src/content/docs/start/what-is-foundation.md @@ -0,0 +1,52 @@ +--- +title: What Foundation Is +description: Understand when to use the Foundation aggregate package or one of its focused components. +sidebar: + order: 1 +--- + +Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, or command conventions. + +Foundation is primarily developed for internal Nexcess projects. Its packages are publicly available and designed to remain reusable, but the needs of Nexcess applications will primarily drive changes, priorities, and the project roadmap. + +Each component is published to its own read-only repository. A project can install the aggregate `stellarwp/foundation` package or require only the components it uses. + +:::caution[Choose dependencies before installing] +For a distributable WordPress plugin, install split runtime packages and require `stellarwp/foundation-cli` with `--dev`. The aggregate package includes the developer CLI in its normal installation; `composer install --no-dev` does not remove it. +::: + +## Choose individual components + +Use split packages when a library or production plugin should ship with the smallest practical dependency set. + +```shell +composer require stellarwp/foundation-container stellarwp/foundation-log +composer require --dev stellarwp/foundation-cli +``` + +Runtime features belong in Composer's `require` section. Install `stellarwp/foundation-cli` in `require-dev` when it is used only to generate project code. + +## Choose the aggregate package + +Use `stellarwp/foundation` when convenience is more important than minimizing installed code. It provides every component and the `vendor/bin/foundation` developer CLI. + +```shell +composer require stellarwp/foundation +``` + +The aggregate package is appropriate for complete applications or development environments that intentionally own the whole Foundation installation. Prefer split packages for production plugin archives. + +## Treat Foundation as application infrastructure + +Foundation components provide infrastructure and extension points. Application-specific decisions still belong to the consuming project: + +- Select and configure the components the application needs. +- Register application services through focused providers. +- Set a unique application prefix for shared WordPress resources. +- Keep authorization, business rules, and domain behavior in application code. + +Foundation does not require an application to adopt every component at once. + +## Continue + +[Install Foundation](/start/install-foundation/) using the approach that matches the application. diff --git a/src/Docs/src/content/docs/tooling/foundation-cli.mdx b/src/Docs/src/content/docs/tooling/foundation-cli.mdx new file mode 100644 index 0000000..997246b --- /dev/null +++ b/src/Docs/src/content/docs/tooling/foundation-cli.mdx @@ -0,0 +1,234 @@ +--- +title: Foundation CLI +description: Generate Foundation-aware WordPress classes and build project-specific developer commands. +sidebar: + order: 1 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation CLI is development tooling for generating project code. It reads the consuming project's Composer configuration, follows WordPress naming and formatting conventions, and uses stubs owned by the runtime package that defines each generated API. + +## Installation + +Install the CLI as a development dependency in a consuming project: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +List its available commands: + +```shell +vendor/bin/foundation list +``` + +:::caution[Keep the CLI out of production plugin archives] +`stellarwp/foundation-cli` includes Symfony Console, generators, and scaffolding assets. Install it with `--dev` and build standalone plugin archives with `composer install --no-dev`. Generated classes depend on their runtime Foundation package, not on the generator. + +The aggregate `stellarwp/foundation` package includes the CLI in its normal installation. Require individual split packages when a production archive must remain lean. +::: + +Do not register `StellarWP\Foundation\Cli\CliProvider` in the WordPress application's provider list. It boots the Symfony Console application for the `foundation` executable and is unrelated to WordPress request bootstrap. + +### Add a Composer script + +The binary can be exposed through a project script in `composer.json`: + +```json title="composer.json" +{ + "scripts": { + "foundation": "@php vendor/bin/foundation" + } +} +``` + +Pass command arguments after `--`: + +```shell +composer run foundation -- list +``` + +## Generate project code + +### Generate a WP-CLI command + +```shell +vendor/bin/foundation make:wpcli-command Sync_Products_Command +``` + +The generated class extends Foundation's WP-CLI command base and demonstrates positional arguments, associative options, and flags. A command shipped by the plugin requires the runtime package: + +```shell +composer require stellarwp/foundation-wpcli +``` + + + +### Generate a database feature + +Generate the application provider first so later generators can register the table and migration automatically: + +```shell +vendor/bin/foundation make:database-provider +vendor/bin/foundation make:database-table Reports_Table +vendor/bin/foundation make:database-migration Create_Reports_Table +``` + +Generated database classes require the runtime package: + +```shell +composer require stellarwp/foundation-database +``` + +Database table and migration generators refuse to overwrite existing files. Edit an unapplied migration directly, or create a new migration after the existing one has been deployed. + + + +### Inspect command options + +Use Symfony Console's built-in help for supported names, paths, namespaces, and feature-specific options: + +```shell +vendor/bin/foundation help make:wpcli-command +vendor/bin/foundation help make:database-provider +vendor/bin/foundation help make:database-table +vendor/bin/foundation help make:database-migration +``` + +The generators use the first `autoload.psr-4` entry in the project's `composer.json` to determine the default namespace and source path. Explicit `--namespace` and `--path` options override those defaults. + +## Customize generation + +### Override package stubs + +Place project-specific stubs under `foundation/stubs/` using the same feature path as the package default: + +```text +foundation/stubs/ + wpcli/ + command.stub + database/ + provider.stub + table.stub + table-migration.stub + migration.stub +``` + +Copy the package's default stub before customizing it so required placeholders remain available. Local scaffolding assets that should not ship in a production zip should be excluded in the consuming project's `.gitattributes`. + +### Generate Strauss-compatible imports + +When the consuming project's `composer.json` defines `extra.strauss.namespace_prefix`, generators apply that prefix to Foundation imports. For example, a configured `YourPlugin\\` prefix changes: + +```php +use StellarWP\Foundation\WPCli\Command; +``` + +to: + +```php +use YourPlugin\StellarWP\Foundation\WPCli\Command; +``` + +This keeps generated classes compatible when Strauss prefixes dependencies without updating project call sites. Handwritten imports remain the application's responsibility. + +## Build a project-specific CLI + +The installed `vendor/bin/foundation` executable contains Foundation's commands. A project that needs its own Symfony Console commands can create a separate executable using `StellarWP\Foundation\Cli\Application`. + +In `src/Cli/Cache_Clear_Command.php`: + +```php title="Cache_Clear_Command.php" +writeln( 'Cache cleared.' ); + + return Command::SUCCESS; + } +} +``` + +In `src/Cli/Command_Provider.php`: + +```php title="Command_Provider.php" +command; + } +} +``` + +Then create the project's executable, for example `bin/your-plugin`: + +```php title="your-plugin" +#!/usr/bin/env php +run() ); +``` + +This small example has no application dependencies. When commands need services, construct the command provider through the project's container instead of creating dependencies inside command classes. + +## Foundation monorepo maintenance + +:::danger[Not a consuming-project command] +`package:create` exists only for maintainers working inside the Foundation monorepo. It creates split-package scaffolding and configures read-only GitHub repositories; it is not a general package generator. +::: + +Preview the repository actions without changing GitHub: + +```shell +composer run foundation -- package:create Log +``` + +Pass `--apply` only after reviewing the generated actions: + +```shell +composer run foundation -- package:create Log --apply +``` + +The command can create `src/` scaffolding, asks for the Composer package name, and runs `composer monorepo merge` after local package creation. diff --git a/src/Docs/src/content/i18n/en.json b/src/Docs/src/content/i18n/en.json new file mode 100644 index 0000000..7936149 --- /dev/null +++ b/src/Docs/src/content/i18n/en.json @@ -0,0 +1,3 @@ +{ + "page.editLink": "Help improve this page" +} diff --git a/src/Docs/src/styles/custom.css b/src/Docs/src/styles/custom.css new file mode 100644 index 0000000..c7a9590 --- /dev/null +++ b/src/Docs/src/styles/custom.css @@ -0,0 +1,62 @@ +:root { + --sl-color-accent-low: #dce8ff; + --sl-color-accent: #0a55cc; + --sl-color-accent-high: #062f7a; + --sl-content-width: 52rem; + --foundation-code-bg: #f3f5f7; + --foundation-code-title-bg: #e9edf2; + --foundation-code-border: #cfd5dd; +} + +:root[data-theme='dark'] { + --sl-color-accent-low: #112c5c; + --sl-color-accent: #3d7bf5; + --sl-color-accent-high: #dce8ff; + --foundation-code-bg: #171c26; + --foundation-code-title-bg: #111620; + --foundation-code-border: #343b49; +} + +:root[data-theme='light'] { + --sl-color-bg: #fefbf2; + --sl-color-bg-nav: #fefbf2; + --sl-color-bg-sidebar: #f8f4e9; +} + +.hero .actions a[href='/start/what-is-foundation/'] { + background: #0a55cc; + color: #fefbf2; +} + +.hero .actions a[href='/start/what-is-foundation/']:hover { + background: #3d7bf5; +} + +a[aria-current='page'] { + background: #0a55cc; + color: #fefbf2; +} + +.sl-markdown-content h2 { + border-top: 1px solid var(--sl-color-gray-5); + padding-top: 1.5rem; +} + +.sl-markdown-content h2:first-of-type { + border-top: 0; + padding-top: 0; +} + +.sl-markdown-content [data-nova-code-container] { + background: var(--foundation-code-bg); + border-color: var(--foundation-code-border); +} + +.sl-markdown-content [data-nova-code-container] pre.astro-code { + background: var(--foundation-code-bg) !important; +} + +.sl-markdown-content [data-nova-code-container][data-nova-code-title] > div:first-child { + background: var(--foundation-code-title-bg); + border-color: var(--foundation-code-border); +} diff --git a/src/Docs/tsconfig.json b/src/Docs/tsconfig.json new file mode 100644 index 0000000..bcbf8b5 --- /dev/null +++ b/src/Docs/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} diff --git a/src/Identifier/README.md b/src/Identifier/README.md index 630438d..09825a7 100644 --- a/src/Identifier/README.md +++ b/src/Identifier/README.md @@ -3,50 +3,16 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). +Foundation Identifier provides injectable contracts for string identifiers, +with secure ULID generation and canonical ULID validation. + ## Installation ```shell composer require stellarwp/foundation-identifier ``` -## Usage - -`foundation-identifier` provides injectable identifier generation contracts and a ULID implementation. - -```php -use StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator; - -final class CreateJob -{ - public function __construct( - private readonly UlidGenerator $identifiers - ) { - } - - public function __invoke(): string { - return $this->identifiers->generate(); - } -} -``` - -Consumers using Foundation's container can register `StellarWP\Foundation\Identifier\IdentifierProvider` to make the ULID services available. The provider does not bind `IdentifierGenerator` globally; applications should decide which identifier strategy satisfies that contract. - -The provider binds `StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator` to the default ULID implementation. If ULIDs should be the application's default identifier strategy, bind the broader `IdentifierGenerator` contract in an application provider: - -```php -use lucatume\DI52\Container as C; -use StellarWP\Foundation\Container\Contracts\Provider as ServiceProvider; -use StellarWP\Foundation\Identifier\Contracts\IdentifierGenerator; -use StellarWP\Foundation\Identifier\IdentifierProvider as FoundationIdentifierProvider; -use StellarWP\Foundation\Identifier\Ulid\Contracts\UlidGenerator; - -final class IdentifierProvider extends ServiceProvider -{ - public function register(): void { - $this->container->register(FoundationIdentifierProvider::class); - $this->container->bind(IdentifierGenerator::class, static fn (C $c): UlidGenerator => $c->get(UlidGenerator::class)); - } -} -``` +## Documentation -The default generator returns canonical uppercase ULIDs, such as `01ARYZ6S410000000000000000`. Use `StellarWP\Foundation\Identifier\Ulid\UlidValidator` when accepting ULIDs from external input. +See the [Foundation Identifier documentation](https://foundation.stellarwp.com/components/identifier/) +for provider configuration, generation, validation, ordering, and testing. diff --git a/src/Lock/README.md b/src/Lock/README.md index 69ec736..c232d61 100644 --- a/src/Lock/README.md +++ b/src/Lock/README.md @@ -3,130 +3,18 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -## Choosing An Implementation - -`stellarwp/foundation-lock` provides the shared lock contract and -`InMemoryLock`. Both persistent implementation packages depend on this base -package, so installing either one also makes `InMemoryLock` available for -tests and process-local work. - -Choose one persistent implementation for production based on where application -requests must coordinate: - -| Implementation | Provided by | Use when | -| --- | --- | --- | -| `DatabaseLock` | [`stellarwp/foundation-database`](https://github.com/stellarwp/foundation-database) | WordPress requests should coordinate through the existing database | -| `RedisLock` | [`stellarwp/foundation-lock-redis`](https://github.com/stellarwp/foundation-lock-redis) | Processes or servers can coordinate through a dedicated Redis connection | - -Use the included `InMemoryLock` in tests or when all coordination is confined -to one PHP process. It does not coordinate separate requests, workers, or -servers. +Foundation Lock provides the shared lock contract, ownership tokens, and an +in-memory implementation for tests and single-process work. Persistent database +and Redis implementations are available through separate Foundation packages. ## Installation -Install this package directly when only the contract and `InMemoryLock` are -needed: - ```shell composer require stellarwp/foundation-lock ``` -For persistent locking, install the selected implementation package from the -table above instead; Composer installs `stellarwp/foundation-lock` with it. - -## Usage - -`foundation-lock` defines portable lock contracts and a process-local in-memory implementation. The in-memory lock is useful for tests and single-process work, but it is not a cross-request or distributed lock. - -```php -use StellarWP\Foundation\Lock\InMemoryLock; - -$lock = new InMemoryLock(); -``` - -Use both in-memory and persistent implementations through -`StellarWP\Foundation\Lock\Contracts\Lock`, as shown below. Persistent -implementations use `LockToken` ownership checks before releasing or refreshing -locks. - -## Preventing Duplicate Work - -Application services should depend on the shared `Lock` contract so production -can use a persistent implementation while tests use `InMemoryLock`. Include the -resource identifier in the lock name so unrelated work can proceed concurrently: - -```php -use RuntimeException; -use StellarWP\Foundation\Lock\Contracts\Lock; -use Throwable; - -final readonly class CatalogSynchronizer -{ - public function __construct( - private Lock $lock - ) { - } - - /** - * @param callable(): void $synchronize - */ - public function synchronize(int $siteId, callable $synchronize): bool - { - $token = $this->lock->acquire(sprintf('catalog:%d:sync', $siteId), 300); - - if ($token === null) { - return false; - } - - try { - $synchronize(); - } catch (Throwable $failure) { - try { - $this->lock->release($token); - } catch (Throwable) { - // Preserve the primary synchronization failure. - } - - throw $failure; - } - - if (! $this->lock->release($token)) { - throw new RuntimeException('Catalog synchronization lock ownership could not be confirmed during release.'); - } - - return true; - } -} -``` - -A `null` acquisition means another process already owns the lease; the caller -can skip, retry, or queue the work. A `false` release means ownership could not -be confirmed during release, so exclusive ownership may not have lasted for the -full operation. - -## Expiration And Refreshing - -> [!IMPORTANT] -> Locks are time-bounded leases. Mutual exclusion is guaranteed only until the token expires. Choose a TTL longer than the protected operation or refresh the lock before expiration. - -Only `Lock::refresh()` renews the backend lease. It returns a new token with an -expiration of the current time plus the supplied TTL, or `null` if the original -token no longer owns the lock: - -```php -$token = $lock->refresh($token, 120); - -if ($token === null) { - // The lock expired or another process acquired it. - return; -} -``` - -Refreshing must happen before the current lease expires. For a single blocking operation that cannot be refreshed safely, use a conservative TTL. Locks coordinate application processes but do not replace idempotency when interacting with external systems such as payment gateways. - -## Backend Failures +## Documentation -Lock implementations throw `StellarWP\Foundation\Lock\Exceptions\LockUnavailableException` -when their backend or secure owner generation cannot provide a trustworthy -result. Treat that exception as a failure to obtain or retain the lock; do not -continue the protected work without coordination. +See the [Foundation Lock documentation](https://foundation.stellarwp.com/components/lock/) +for backend selection, configuration, lease handling, failure behavior, usage +examples, and testing. diff --git a/src/LockRedis/README.md b/src/LockRedis/README.md index c7d355c..9dbb327 100644 --- a/src/LockRedis/README.md +++ b/src/LockRedis/README.md @@ -3,6 +3,10 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). +Redis-backed expiring locks for Foundation. This package implements the shared +`stellarwp/foundation-lock` contract using atomic Redis acquisition, release, +and refresh operations. + ## Installation ```shell @@ -17,130 +21,8 @@ composer require "predis/predis:>=3.0 <4.0" Alternatively, install and enable the PhpRedis extension. -## Usage - -`RedisLock` implements Foundation's shared lock contract with atomic Redis -acquisition, release, and refresh operations. Applications must provide a -dedicated Redis connection and an application-specific key prefix. - -Map the Redis connection and lock settings in the application's `config.php`: - -```php - [ - 'redis' => [ - 'host' => $_ENV['FOUNDATION_LOCK_REDIS_HOST'] ?? '127.0.0.1', - 'port' => (int) ($_ENV['FOUNDATION_LOCK_REDIS_PORT'] ?? 6379), - 'database' => (int) ($_ENV['FOUNDATION_LOCK_REDIS_DATABASE'] ?? 1), - 'prefix' => $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'] ?? 'acme:site-123:lock:', - ], - ], -]; -``` - -After [registering `config.php` with the Foundation container](https://github.com/stellarwp/foundation-container#making-a-configphp), -providers receive its configured `Dot` instance through `$this->config`. Use -those values when binding the application's Redis client: - -```php -use lucatume\DI52\Container as C; -use Predis\Client; -use Predis\ClientInterface; -use StellarWP\Foundation\Container\Contracts\Provider; -use StellarWP\Foundation\Lock\Contracts\Lock; -use StellarWP\Foundation\LockRedis\Connections\PredisConnection; -use StellarWP\Foundation\LockRedis\Contracts\Connection; -use StellarWP\Foundation\LockRedis\LockRedisProvider; -use StellarWP\Foundation\LockRedis\RedisLock; - -final class RedisProvider extends Provider -{ - public function register(): void { - $this->container->when(PredisConnection::class) - ->needs(ClientInterface::class) - ->give(fn (): ClientInterface => new Client([ - 'host' => (string) $this->config->get('lock.redis.host'), - 'port' => (int) $this->config->get('lock.redis.port'), - 'database' => (int) $this->config->get('lock.redis.database'), - ])); - - $this->container->singleton(PredisConnection::class); - $this->container->bind(Connection::class, static fn (C $c): PredisConnection => $c->get(PredisConnection::class)); - $this->container->register(LockRedisProvider::class); - - $this->container->bind(Lock::class, static fn (C $c): RedisLock => $c->get(RedisLock::class)); - } -} -``` - -For PhpRedis, bind a separately configured `Redis` instance and select the -PhpRedis adapter instead: - -```php -use lucatume\DI52\Container as C; -use Redis; -use StellarWP\Foundation\Container\Contracts\Provider; -use StellarWP\Foundation\Lock\Contracts\Lock; -use StellarWP\Foundation\LockRedis\Connections\PhpRedisConnection; -use StellarWP\Foundation\LockRedis\Contracts\Connection; -use StellarWP\Foundation\LockRedis\LockRedisProvider; -use StellarWP\Foundation\LockRedis\RedisLock; - -final class RedisProvider extends Provider -{ - public function register(): void { - $this->container->when(PhpRedisConnection::class) - ->needs(Redis::class) - ->give(function (): Redis { - $redis = new Redis(); - $redis->connect( - (string) $this->config->get('lock.redis.host'), - (int) $this->config->get('lock.redis.port') - ); - $redis->select((int) $this->config->get('lock.redis.database')); - - return $redis; - }); - - $this->container->singleton(PhpRedisConnection::class); - $this->container->bind(Connection::class, static fn (C $c): PhpRedisConnection => $c->get(PhpRedisConnection::class)); - $this->container->register(LockRedisProvider::class); - - $this->container->bind(Lock::class, static fn (C $c): RedisLock => $c->get(RedisLock::class)); - } -} -``` - -## Application Usage - -After binding `StellarWP\Foundation\Lock\Contracts\Lock` to `RedisLock`, inject -the shared contract into application services rather than depending directly -on the Redis implementation. See -[Preventing Duplicate Work](https://github.com/stellarwp/foundation-lock#preventing-duplicate-work) -for a complete resource-scoped locking example and -[Expiration And Refreshing](https://github.com/stellarwp/foundation-lock#expiration-and-refreshing) -for lease handling guidance. - -The package never selects a Redis database or reuses WordPress object-cache -globals. Supply a separate client connection. A separate logical database -protects locks from `FLUSHDB` issued against the object-cache database, but it -does not protect against `FLUSHALL`, eviction, restart, or failover. Use a -separate Redis endpoint when stronger isolation is required. Redis Cluster -supports only database `0`, so endpoint isolation is required there. The -package supports a single writable Redis endpoint; Redis Cluster and Sentinel -are not currently supported or tested. - -Lock contention is not an infrastructure failure: `acquire()` returns `null` -when another owner holds the lock. `release()` returns `false`, and `refresh()` -returns `null`, when the token no longer owns the lease. Uncertain Redis -results or owner generation failures throw -`StellarWP\Foundation\Lock\Exceptions\LockUnavailableException`; callers -should fail closed instead of continuing the protected work without a lock. +## Documentation -Redis locks are expiring leases, not exactly-once guarantees. The TTL must -cover the protected work or be refreshed before it expires. External side -effects such as payment requests should also use provider-supported -idempotency keys. Asynchronous Redis failover, eviction, restart, or -administrative key removal can permit overlapping owners. +See the [Foundation Lock guide](https://foundation.stellarwp.com/components/lock/) +for backend selection, Redis configuration, container registration, lease +handling, failure behavior, and usage examples. diff --git a/src/Log/README.md b/src/Log/README.md index dd4baa0..02c3fec 100644 --- a/src/Log/README.md +++ b/src/Log/README.md @@ -3,48 +3,17 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -A logging library using [Monolog](https://github.com/Seldaek/monolog) that implements the `Psr\Log\LoggerInterface` interface. +Foundation Log configures [Monolog](https://github.com/Seldaek/monolog) behind +the standard `Psr\Log\LoggerInterface`. It includes console, PHP error log, +stacked, and null channels. ## Installation - ```shell composer require stellarwp/foundation-log ``` -If using [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container), create a `config.php` and register -it in the container with: - -```php -$this->container->bind(Dot::class, new Dot(require_once dirname(__FILE__) . '/config.php')); -``` - -The config.php file maps environment variables, either from an `.env` file if you configured [phpdotenv](https://github.com/vlucas/phpdotenv), or manually set, e.g. - -```php - [ - 'level' => $_ENV['APP_LOG_LEVEL'] ?? 'debug', - 'channel' => $_ENV['APP_LOG_CHANNEL'] ?? 'null', // console, errorlog, stack (both console and errorlog) or null - 'channels' => [ - 'errorlog' => [], - 'console' => [ - 'with' => [ - 'stream' => 'php://stdout', - ], - ], - 'stack' => [ - 'with' => [ - 'stream' => 'php://stdout', - ], - ], - ], - ], -]; -``` +## Documentation -Then, include the [LogProvider.php](./LogProvider.php) in your -application and call the `register` method. Anytime you inject a `Psr\Log\LoggerInterface` instance into another class, it will use -your provided configuration. +See the [Foundation Log documentation](https://foundation.stellarwp.com/components/log/) +for channel configuration, structured logging, failure behavior, and testing. diff --git a/src/Pipeline/README.md b/src/Pipeline/README.md index 1f20a67..da6a809 100644 --- a/src/Pipeline/README.md +++ b/src/Pipeline/README.md @@ -3,10 +3,17 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -A Pipeline / Command Design Pattern implementation based on [Laravel's Pipeline implementation](https://github.com/illuminate/pipeline/blob/master/Pipeline.php). +Foundation Pipeline passes values through ordered, container-resolved pipes. It +supports class, object, callable, and parameterized pipes and is based on +[Laravel's Pipeline implementation](https://github.com/illuminate/pipeline/blob/master/Pipeline.php). ## Installation ```shell composer require stellarwp/foundation-pipeline ``` + +## Documentation + +See the [Foundation Pipeline documentation](https://foundation.stellarwp.com/components/pipeline/) +for pipeline construction, transformations, short circuits, parameters, error handling, and testing. diff --git a/src/WPCli/README.md b/src/WPCli/README.md index bf94726..7f9cc06 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -3,7 +3,8 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -Foundation helpers for building WP-CLI commands with the Foundation container. +Foundation WP-CLI provides a container-aware command base class and a shared +provider for registering application commands during WP-CLI bootstrap. ## Installation @@ -11,175 +12,11 @@ Foundation helpers for building WP-CLI commands with the Foundation container. composer require stellarwp/foundation-wpcli ``` -WP-CLI is expected to provide the `WP_CLI` and `WP_CLI_Command` runtime classes. This package includes `wp-cli/wp-cli` as a development dependency for tests and static analysis, but applications normally do not need to install it separately when running inside WP-CLI. +WP-CLI supplies its runtime classes when commands execute, so applications do +not normally need to install `wp-cli/wp-cli` separately. -Install `stellarwp/foundation-wpcli` as a normal dependency when the plugin ships WP-CLI commands. Install `stellarwp/foundation-cli` separately with `composer require --dev stellarwp/foundation-cli` only when developers need generators such as `make:wpcli-command`. +## Documentation -## Commands - -Extend `StellarWP\Foundation\WPCli\Command` for commands that should receive the Foundation container. - -## Generating Commands - -If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a WP-CLI command class in a consuming WordPress project: - -```bash -vendor/bin/foundation make:wpcli-command Sync_Products_Command -``` - -If the consuming project has a Composer script named `foundation` that points to the installed Foundation binary, it can also run `composer run foundation -- make:wpcli-command Sync_Products_Command`. - -The generator reads the project's `autoload.psr-4` namespaces from `composer.json` and writes a Snake_Case command class under `Cli/Commands` inside the default PSR-4 root. When `--namespace` is passed, the output path is resolved from the matching PSR-4 root unless `--path` is also passed. - -For example, a project with this Composer autoload entry: - -```json -{ - "autoload": { - "psr-4": { - "Acme\\Plugin\\": "src" - } - } -} -``` - -will generate: - -```text -src/Cli/Commands/Sync_Products_Command.php -``` - -with namespace: - -```php -Acme\Plugin\Cli\Commands -``` - -The generated class extends `StellarWP\Foundation\WPCli\Command` and includes example positional, associative, and flag arguments using constants. - -When generated through `foundation-cli`, projects using Strauss with `extra.strauss.namespace_prefix` receive prefixed Foundation imports automatically. - -Available options: - -```bash -vendor/bin/foundation make:wpcli-command Sync_Products_Command --namespace="Acme\\Plugin\\Cli" --path=src/Cli --subcommand=sync-products --description="Sync products." --force -``` - -Project stub overrides live under: - -```text -foundation/stubs/wpcli/command.stub -``` - -When present, the override is used instead of the default stub from the `foundation-wpcli` package. - -Override stubs should use the same context-aware placeholders as the default stub when writing PHP literals. For example, use `{{ description_php }}` and `{{ subcommand_php }}` for values returned from PHP methods, and `{{ foundation_wpcli_command }}` for the Foundation command import so Strauss-prefixed projects keep working. - -```php -container. - - return self::SUCCESS; - } - - protected function subcommand(): string { - return {{ subcommand_php }}; - } - - protected function description(): string { - return {{ description_php }}; - } - - protected function arguments(): array { - return [ - [ - 'type' => self::FLAG, - 'name' => 'dry-run', - 'description' => 'Preview the sync without writing changes.', - 'optional' => true, - ], - ]; - } -} -``` - -## Provider Setup - -Applications should register `StellarWP\Foundation\WPCli\WPCliProvider` once, before feature providers that contribute commands. Feature providers can then add resolved command instances to the shared command list with `mergeArrayVar()`. - -Every contributed value must extend `StellarWP\Foundation\WPCli\Command`. The -provider validates the complete list before registering anything and throws a -descriptive exception when a contribution is invalid. - -Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin. That provider belongs to the developer-facing `foundation` console binary, not plugin runtime bootstrap. - -Generated command classes use Strauss-prefixed Foundation imports automatically when `extra.strauss.namespace_prefix` is configured. Handwritten provider code is still application code, so projects using Strauss with `update_call_sites=false` may need to prefix the Foundation and third-party imports shown below, including `lucatume\DI52\Container`. - -```php -container->when( Sync_Command::class ) - ->needs( '$commandPrefix' ) - ->give( static fn ( C $c ): string => $c->get( WPCliProvider::COMMAND_PREFIX ) ); - - $this->container->mergeArrayVar( - WPCliProvider::COMMANDS, - static fn ( C $c ): array => [ - $c->get( Sync_Command::class ), - ] - ); - } -} -``` - -Register both providers with the container in this order: - -```php -use Acme\App\Cli\Wp_Cli_Provider; -use StellarWP\Foundation\WPCli\WPCliProvider; - -$container->register( WPCliProvider::class ); -$container->register( Wp_Cli_Provider::class ); -``` - -The Foundation WP-CLI provider uses `cli_init` internally so commands are registered only during WP-CLI command bootstrap, after all application providers have had a chance to add command classes. - -Set a stable, unique application-wide Foundation prefix when packaging -Foundation in a distributable plugin. The shared prefix defaults to `nx`. When -`wpcli.command_prefix` is omitted, WP-CLI uses the shared prefix: - -```php -return [ - 'foundation' => [ - 'prefix' => 'your-plugin', - ], - // Optional package-specific override: - 'wpcli' => [ - 'command_prefix' => 'your-command', - ], -]; -``` - -With only `foundation.prefix` configured, commands are registered under -`wp your-plugin`. Replace `your-plugin` with the plugin's own stable lowercase -kebab-case prefix. - -See [Foundation Container configuration](https://github.com/stellarwp/foundation-container#container-configuration) for loading the `config.php` array into the container's `Dot` binding. +See the [Foundation WP-CLI documentation](https://foundation.stellarwp.com/components/wp-cli/) +for command generation, provider registration, prefixes, arguments, failure +behavior, and testing. From dfbd958510e6a6ddf1ec056bf122e5985a1ba2af Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 09:13:29 -0600 Subject: [PATCH 59/81] Fix: correct docs to migration order --- src/Docs/src/content/docs/components/database/migrations.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index 72483ba..b116af2 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -134,7 +134,7 @@ final readonly class Create_Reports_Table implements Migration { } ``` -Migration IDs are permanent, byte-exact identifiers. The generator prefixes them with a sortable timestamp so migrations contributed by separate providers run in a predictable order. Do not change an ID after the migration has been deployed. +Migration IDs are permanent, byte-exact identifiers. The generator prefixes them with a sortable timestamp so migration history is easy to inspect, but execution follows provider contribution order rather than sorting by ID. Register providers and migrations in dependency order, and do not change an ID after the migration has been deployed. For later schema changes, update the table's desired definition and create a new migration that applies it. Use `Schema::execute()` for data changes or schema operations that `dbDelta()` cannot express reliably. @@ -166,7 +166,7 @@ Running the command without an operation displays migration status: wp your-plugin migrate ``` -The runner acquires the configured migration lock, executes pending migrations in ID order, and records each successful migration in one batch. +The runner acquires the configured migration lock, executes pending migrations in provider contribution order, and records each successful migration in one batch. ### Roll back or rebuild From f5304356fbd644476294dbadcd478ad2fadb7248 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 09:57:55 -0600 Subject: [PATCH 60/81] Add foundation-shutdown docs --- src/Docs/astro.config.mjs | 1 + .../src/content/docs/components/shutdown.mdx | 238 ++++++++++++++++++ .../src/content/docs/components/wp-cli.mdx | 2 +- .../content/docs/start/what-is-foundation.md | 2 +- src/Shutdown/README.md | 136 +--------- .../wpunit/Shutdown/ShutdownProviderTest.php | 14 -- 6 files changed, 254 insertions(+), 139 deletions(-) create mode 100644 src/Docs/src/content/docs/components/shutdown.mdx diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index 5733934..85c9be3 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -45,6 +45,7 @@ export default defineConfig({ { slug: 'components/log' }, { slug: 'components/identifier' }, { slug: 'components/pipeline' }, + { slug: 'components/shutdown' }, { slug: 'components/wp-cli' }, ], }, diff --git a/src/Docs/src/content/docs/components/shutdown.mdx b/src/Docs/src/content/docs/components/shutdown.mdx new file mode 100644 index 0000000..a30322b --- /dev/null +++ b/src/Docs/src/content/docs/components/shutdown.mdx @@ -0,0 +1,238 @@ +--- +title: Shutdown +description: Run bounded application termination work once in deterministic priority order. +sidebar: + order: 7 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Some work must happen before a PHP request ends, but does not need to delay the response sent to the client. For example, an application may need to flush buffered logs or telemetry to a third-party service, close request-scoped resources, or publish diagnostics collected during the request. Running that work before returning the response makes the website or API feel slower even though the result does not affect what the client receives. + +Foundation Shutdown lets features contribute these end-of-request tasks from their providers. In WordPress, it attempts to finish the HTTP response before running the tasks from the `shutdown` action. Each task runs once in priority order, and one failed task does not prevent the remaining tasks from running. + +Use shutdown tasks for bounded, best-effort work that can finish within the current PHP process. They are not asynchronous jobs: the PHP worker remains occupied until every task finishes. + +:::note[Use a queue for durable work] +Use a durable queue when work is long-running, must be retried, or cannot safely be lost if the process exits. +::: + +## Installation + +Install the runtime package: + +```shell +composer require stellarwp/foundation-shutdown +``` + +### Prepare the application + +Shutdown tasks use the application's existing container and ordered provider graph: + + + + + + + +## Configuration + +### Configure the cache write + +In the root `config.php`, choose the WordPress transient key and lifetime used for the cached snapshot: + +```php title="config.php" + [ + 'key' => $_ENV['PRODUCT_CACHE_KEY'] ?? 'your_plugin_products', + 'ttl' => (int) ( $_ENV['PRODUCT_CACHE_TTL'] ?? 300 ), + ], +]; +``` + +### Create a cache-write task + +Create `src/Product_Cache/Product_Cache_Writer.php`. This example assumes `Product_Cache_Buffer` collects an in-memory snapshot while the application handles the request. The task writes that prepared snapshot to a WordPress transient only when it changed: + +```php title="Product_Cache_Writer.php" +cache_key === '' ) { + throw new InvalidArgumentException( 'The product cache key cannot be empty.' ); + } + + if ( $this->ttl < 1 ) { + throw new InvalidArgumentException( 'The product cache TTL must be greater than zero.' ); + } + } + + /** + * @action shutdown + */ + public function terminate(): void { + if ( ! $this->buffer->has_changes() ) { + return; + } + + set_transient( + $this->cache_key, + $this->buffer->snapshot(), + $this->ttl + ); + } +} +``` + +The buffer is responsible for collecting cacheable state during the request. The shutdown task only performs the final bounded write. The PHP worker remains occupied until `terminate()` returns, even when the response has already been sent to the client. + +### Contribute the task from its feature provider + +In `src/Product_Cache/Provider.php`, supply the task's scalar configuration before adding it lazily to `ShutdownProvider::TASKS`: + +```php title="Provider.php" +register_cache_writer(); + $this->register_shutdown_task(); + } + + private function register_cache_writer(): void { + $this->container->when( Product_Cache_Writer::class ) + ->needs( '$cache_key' ) + ->give( (string) $this->config->get( 'product_cache.key', 'your_plugin_products' ) ); + + $this->container->when( Product_Cache_Writer::class ) + ->needs( '$ttl' ) + ->give( (int) $this->config->get( 'product_cache.ttl', 300 ) ); + } + + private function register_shutdown_task(): void { + $this->container->mergeArrayVar( + ShutdownProvider::TASKS, + static fn ( C $c ): array => [ + new ShutdownTask( + $c->get( Product_Cache_Writer::class ), + self::WRITE_PRIORITY + ), + ] + ); + } +} +``` + +The contextual bindings target the task's `$cache_key` and `$ttl` constructor arguments. The container autowires `Product_Cache_Buffer`, applies those configured scalar values when the task collection is resolved, and then constructs the task. + +`ShutdownTask` is an immutable value object pairing one `Terminable` service with its priority. Lower values run first. Tasks with the same priority retain provider contribution order. + +### Register the providers + +In `src/App.php`, register `ShutdownProvider` before the feature provider that contributes the cache task: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Shutdown\ShutdownProvider; +use YourPlugin\Product_Cache; + +/** @var list> */ +private const array PROVIDERS = [ + ShutdownProvider::class, + Product_Cache\Provider::class, +]; +``` + +The shutdown provider registers the shared task collection, the decorated runner, and one callback on WordPress's `shutdown` action at `PHP_INT_MAX`. Registration alone does not construct or run contributed tasks. + +If the application uses `foundation-log`, register `LogProvider` with the other infrastructure providers before the feature providers. The shutdown runner receives the configured `LoggerInterface` automatically. + +Register every contributing provider before resolving the shutdown runner. The normal application bootstrap does this automatically because the runner is resolved only when the WordPress action fires. + +## Usage + +### Run tasks at WordPress shutdown + +No application hook is required after `ShutdownProvider` is registered. At WordPress shutdown, Foundation: + +1. Resolves the complete contributed task collection. +2. Attempts `fastcgi_finish_request()` and then `litespeed_finish_request()` when available, stopping after one succeeds. +3. Runs tasks from the lowest priority to the highest. +4. Runs equal-priority tasks in contribution order. +5. Ignores repeated or recursive calls to the same runner instance. + +Response finishing is best effort. A missing function, a `false` result, or a thrown exception does not prevent termination tasks from running. + +### Invoke the runner from another lifecycle + +An application with another explicit termination boundary can resolve the configured contract directly: + +```php +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner; + +$container->get( ShutdownRunner::class )->terminate(); +``` + +Each runner instance executes only once. Calling it before WordPress shutdown means the later `shutdown` callback is a no-op for that instance. + +### Handle task failures + +The runner catches every `Throwable` from a task and continues with the remaining tasks. When a PSR-3 logger is available, it records task execution at `debug` and task failures at `error`, including the task class, priority, and exception. Logger failures are also isolated. + +:::caution[Shutdown work is best effort] +Task exceptions are not rethrown, and PHP shutdown provides no retry or durability guarantee. Do not use this component for payments, required data persistence, customer notifications, or work that must eventually complete. Persist that work before the request ends and process it through a durable queue. +::: + +:::caution[Finishing the response is not asynchronous execution] +`fastcgi_finish_request()` or `litespeed_finish_request()` may let the client receive its response sooner, but the PHP worker remains busy. Keep shutdown tasks short and move slow or unbounded operations to a queue. +::: + +## Testing + +Test each `Terminable` service directly through its observable behavior. Add one provider integration test when the contribution itself matters: register `ShutdownProvider` and the feature provider, resolve the `ShutdownRunner` contract, call `terminate()`, and assert the task's effect. + +The package already tests priority ordering, equal-priority stability, once-only execution, recursive invocation, response-finishing fallbacks, failure isolation, and optional logging. Application tests do not need to duplicate those generic guarantees. diff --git a/src/Docs/src/content/docs/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx index d276a20..ea98dd3 100644 --- a/src/Docs/src/content/docs/components/wp-cli.mdx +++ b/src/Docs/src/content/docs/components/wp-cli.mdx @@ -2,7 +2,7 @@ title: WP-CLI description: Build container-aware WP-CLI commands and register them from feature providers. sidebar: - order: 7 + order: 8 --- import { CardGrid, LinkCard } from '@astrojs/starlight/components'; diff --git a/src/Docs/src/content/docs/start/what-is-foundation.md b/src/Docs/src/content/docs/start/what-is-foundation.md index 5f242f0..a93c050 100644 --- a/src/Docs/src/content/docs/start/what-is-foundation.md +++ b/src/Docs/src/content/docs/start/what-is-foundation.md @@ -5,7 +5,7 @@ sidebar: order: 1 --- -Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, or command conventions. +Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, or command conventions. Foundation is primarily developed for internal Nexcess projects. Its packages are publicly available and designed to remain reusable, but the needs of Nexcess applications will primarily drive changes, priorities, and the project roadmap. diff --git a/src/Shutdown/README.md b/src/Shutdown/README.md index 716a5f9..7d9ec7c 100644 --- a/src/Shutdown/README.md +++ b/src/Shutdown/README.md @@ -3,8 +3,15 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -Run application termination work once, in a predictable order, without allowing -one failed task to prevent the remaining tasks from running. +Some work must happen before a PHP request ends but does not need to delay the +response sent to the client, such as flushing buffered logs or telemetry to a +third-party service. Foundation Shutdown lets features contribute that bounded +end-of-request work and attempts to finish supported HTTP responses before it +begins. + +Tasks run once in priority order, and one failed task does not prevent later tasks +from running. Shutdown work is best effort within the current PHP process; use a +durable queue for long-running work, retries, or work that cannot safely be lost. ## Installation @@ -12,125 +19,8 @@ one failed task to prevent the remaining tasks from running. composer require stellarwp/foundation-shutdown ``` -## Register the provider - -Register `ShutdownProvider` through your application's normal Foundation provider -list: - -```php -use StellarWP\Foundation\Shutdown\ShutdownProvider; - -private array $providers = [ - ShutdownProvider::class, -]; -``` - -The provider has no custom constructor and uses the application's existing -Foundation container and configuration. Package installation alone has no side -effects; consumers may omit this provider and construct the public runner directly -or supply their own provider. - -## Create and contribute tasks - -Termination work implements the small `Terminable` contract: - -```php -use StellarWP\Foundation\Shutdown\Contracts\Terminable; - -final class FlushTelemetry implements Terminable -{ - public function terminate(): void { - // Flush bounded application telemetry. - } -} - -final class CloseRequestLog implements Terminable -{ - public function terminate(): void { - // Close the request log after the response is sent. - } -} -``` - -Contribute an application's termination work from one provider. Resolve the -concrete tasks lazily so all providers can finish registering before termination -services are constructed. Contributions must be registered before the runner is -resolved: - -```php -use lucatume\DI52\Container; -use StellarWP\Foundation\Container\Contracts\Provider; -use StellarWP\Foundation\Shutdown\ShutdownProvider as FoundationShutdownProvider; -use StellarWP\Foundation\Shutdown\ShutdownTask; - -final class ApplicationShutdownProvider extends Provider -{ - public function register(): void { - $this->container->singleton(CloseRequestLog::class); - $this->container->singleton(FlushTelemetry::class); - - $this->container->mergeArrayVar( - FoundationShutdownProvider::TASKS, - static fn (Container $container): array => [ - new ShutdownTask($container->get(CloseRequestLog::class), 10), - new ShutdownTask($container->get(FlushTelemetry::class), 100), - ] - ); - } -} -``` - -Register both providers through the application's provider list: - -```php -private array $providers = [ - FoundationShutdownProvider::class, - ApplicationShutdownProvider::class, -]; -``` - -Lower priority values run first. Tasks with the same priority retain their -registration order. - -## WordPress shutdown - -`ShutdownProvider` binds the `ShutdownRunner` contract to the ordered task runner, -decorated by `ResponseFinishingRunner`, and attaches it to WordPress's `shutdown` -action at the latest priority. When supported, it finishes the response with -`fastcgi_finish_request()` or `litespeed_finish_request()` before running the -contributed tasks. The PHP worker remains occupied until those tasks finish, so -long-running work still belongs in a proper background queue. - -The runner is resolved lazily when the action fires, so features may contribute -tasks after the provider is registered. - -With the default provider registered, applications may also invoke the configured -runner chain directly: - -```php -use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner; - -$container->get(ShutdownRunner::class)->terminate(); -``` - -Applications that omit the default provider must bind the `ShutdownRunner` contract -in their own provider or construct the concrete -`StellarWP\Foundation\Shutdown\ShutdownRunner` with their desired tasks. - -Each runner instance executes only once, including when termination is invoked -recursively. A `Throwable` from one task is isolated so later tasks still run. - -## Logging - -`ShutdownRunner` accepts an optional PSR-3 logger. When the application binds a -`Psr\Log\LoggerInterface`—including through `foundation-log`—the container injects -it automatically. Applications without a logger require no additional setup. - -The runner logs the task count and each task at `debug` level. Task failures are -logged at `error` level with the task class, priority, and actual exception so -compatible loggers retain its message and stack trace. Logger failures are isolated -so diagnostics cannot interrupt termination work. +## Documentation -Output-buffer management, hard task timeouts, and asynchronous execution beyond -the default WordPress shutdown action belong to the consuming application or a -dedicated framework integration. +See the [Foundation Shutdown documentation](https://foundation.stellarwp.com/components/shutdown/) +for provider registration, task contributions, priority ordering, response +finishing, failure behavior, and testing. diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php index 53690b3..e5d89da 100644 --- a/tests/wpunit/Shutdown/ShutdownProviderTest.php +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -2,13 +2,9 @@ namespace StellarWP\Foundation\Tests\WPUnit\Shutdown; -use Adbar\Dot; -use lucatume\DI52\Container as DI52Container; use Monolog\Handler\TestHandler; use Monolog\Logger; use Psr\Log\LoggerInterface; -use StellarWP\Foundation\Container\ContainerAdapter; -use StellarWP\Foundation\Container\Contracts\Container; use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner as ShutdownRunnerContract; use StellarWP\Foundation\Shutdown\ResponseFinishingRunner; use StellarWP\Foundation\Shutdown\ShutdownProvider; @@ -18,16 +14,6 @@ final class ShutdownProviderTest extends WPTestCase { - private ContainerAdapter $container; - - protected function setUp(): void { - parent::setUp(); - - $this->container = new ContainerAdapter(new DI52Container()); - $this->container->bind(Container::class, $this->container); - $this->container->singleton(Dot::class, new Dot()); - } - protected function tearDown(): void { if ($this->container->has(ShutdownRunnerContract::class)) { remove_action( From 44265b8ec02debc4dba037e521fef9843b3ed58d Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 10:03:43 -0600 Subject: [PATCH 61/81] Ensure plugins are not uninstalling or WordPress isn't installing before running shutdown tasks. --- .../src/content/docs/components/shutdown.mdx | 2 +- src/Shutdown/ShutdownProvider.php | 5 +++++ tests/wpunit/Shutdown/ShutdownProviderTest.php | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Docs/src/content/docs/components/shutdown.mdx b/src/Docs/src/content/docs/components/shutdown.mdx index a30322b..ac223c6 100644 --- a/src/Docs/src/content/docs/components/shutdown.mdx +++ b/src/Docs/src/content/docs/components/shutdown.mdx @@ -187,7 +187,7 @@ private const array PROVIDERS = [ ]; ``` -The shutdown provider registers the shared task collection, the decorated runner, and one callback on WordPress's `shutdown` action at `PHP_INT_MAX`. Registration alone does not construct or run contributed tasks. +The shutdown provider registers the shared task collection, the decorated runner, and one callback on WordPress's `shutdown` action at `PHP_INT_MAX`. It does not register that automatic callback while WordPress is installing or the plugin is being uninstalled. Registration alone does not construct or run contributed tasks. If the application uses `foundation-log`, register `LogProvider` with the other infrastructure providers before the feature providers. The shutdown runner receives the configured `LoggerInterface` automatically. diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php index 95ecdab..e4e86e5 100644 --- a/src/Shutdown/ShutdownProvider.php +++ b/src/Shutdown/ShutdownProvider.php @@ -33,6 +33,11 @@ public function register(): void { ShutdownRunner::class, ]); + // Installation and uninstall requests may not have the complete application state expected by shutdown tasks. + if (defined('WP_UNINSTALL_PLUGIN') || wp_installing()) { + return; + } + add_action( 'shutdown', $this->container->callback(ShutdownRunnerContract::class, 'terminate'), diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php index e5d89da..e15bb74 100644 --- a/tests/wpunit/Shutdown/ShutdownProviderTest.php +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -84,4 +84,21 @@ public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void { $this->assertSame(['terminated'], $calls); } + + public function test_it_does_not_register_the_shutdown_hook_while_wordpress_is_installing(): void { + $wasInstalling = wp_installing(true); + + try { + $this->container->register(ShutdownProvider::class); + $callback = $this->container->callback(ShutdownRunnerContract::class, 'terminate'); + + $this->assertFalse(has_action('shutdown', $callback)); + $this->assertInstanceOf( + ResponseFinishingRunner::class, + $this->container->get(ShutdownRunnerContract::class) + ); + } finally { + wp_installing($wasInstalling); + } + } } From 41d1c8a19977408a13896ed13b900a64a3568547 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 10:06:45 -0600 Subject: [PATCH 62/81] Refactor shutdown idempotency marker --- src/Shutdown/ShutdownProvider.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php index e4e86e5..b6e5743 100644 --- a/src/Shutdown/ShutdownProvider.php +++ b/src/Shutdown/ShutdownProvider.php @@ -14,15 +14,17 @@ */ final class ShutdownProvider extends Provider { - public const string TASKS = self::class . '.tasks'; - private const string REGISTERED = self::class . '.registered'; + public const string TASKS = self::class . '.tasks'; + + private bool $registered = false; public function register(): void { - if ($this->container->has(self::REGISTERED)) { + // DI52 may register the provider repeatedly, but its definitions and WordPress hook must be added only once. + if ($this->registered) { return; } - $this->container->singleton(self::REGISTERED, true); + $this->registered = true; $this->container->when(ShutdownRunner::class) ->needs('$tasks') From 2b6bdc51b132045e6ad1e2ad23b467caa7184762 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 10:14:47 -0600 Subject: [PATCH 63/81] Prefix all custom provider keys with their class so they won't collide across plugins that properly use Strauss. --- AGENTS.md | 2 ++ src/Cli/CliProvider.php | 2 +- src/Database/DatabaseProvider.php | 10 +++++----- src/LockRedis/LockRedisProvider.php | 2 +- src/Log/LogProvider.php | 2 +- src/WPCli/WPCliProvider.php | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c0420a3..cc90c5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ When writing providers or container registration code, prefer container-driven c Use contextual bindings with `$this->container->when()->needs()->give()` for scalar constructor arguments, command lists, or feature-specific substitutions. Use a factory closure only when the value must be computed or resolved from the container, and keep that closure focused on supplying the constructor dependency rather than constructing the full object. +Provider-owned internal container identifiers, including scalar bindings and additive collection contribution points, must use `self::class . '.descriptive_suffix'` instead of global literal strings. This allows namespace prefixing tools such as Strauss to scope the identifier to the prefixed provider class. Do not apply this pattern to configuration keys, persistent database or cache identifiers, lock names, channel names, WP-CLI command names, or other externally visible values that must remain stable across builds. + Classes should take the dependencies they need directly. Do not make constructor dependencies nullable just to instantiate fallback concrete classes internally, for example `?Dependency $dependency = null` with `$this->dependency = $dependency ?? new Dependency()`. Register default implementations and aliases in a provider instead so consumers can replace them through container configuration. Use the optional `foundation.prefix` configuration key when Foundation-managed resources must be scoped to a consuming application. Its effective zero-configuration value is `nx`; providers should derive their default resource names from that shared value instead of repeating their own fallbacks. Distributable plugins must configure a stable, unique prefix so separate Foundation consumers do not share resources. Documentation and examples should use a generic lowercase kebab-case value such as `your-plugin`, never a developer-specific project name. Package-specific settings must take priority over values derived from the shared prefix. diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index 0c25edf..61533ca 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -35,7 +35,7 @@ */ final class CliProvider extends Provider { - public const string ROOT_PATH = 'foundation.cli.root_path'; + public const string ROOT_PATH = self::class . '.root_path'; public function register(): void { $this->container->singleton(self::ROOT_PATH, getcwd() ?: dirname(__DIR__, 2)); diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php index 47b2d0b..359f4c1 100644 --- a/src/Database/DatabaseProvider.php +++ b/src/Database/DatabaseProvider.php @@ -31,11 +31,11 @@ final class DatabaseProvider extends Provider { use ResolvesFoundationPrefix; - public const string MIGRATIONS = 'foundation.database.migrations'; - public const string MIGRATIONS_TABLE = 'foundation.database.migrations_table'; - public const string LOCKS_TABLE = 'foundation.database.locks_table'; - public const string LOCK_NAME = 'foundation.database.lock_name'; - public const string LOCK_TTL = 'foundation.database.lock_ttl'; + public const string MIGRATIONS = self::class . '.migrations'; + public const string MIGRATIONS_TABLE = self::class . '.migrations_table'; + public const string LOCKS_TABLE = self::class . '.locks_table'; + public const string LOCK_NAME = self::class . '.lock_name'; + public const string LOCK_TTL = self::class . '.lock_ttl'; /** * @throws InvalidArgumentException When the configured Foundation prefix is invalid. diff --git a/src/LockRedis/LockRedisProvider.php b/src/LockRedis/LockRedisProvider.php index eadf0c7..fdc1c95 100644 --- a/src/LockRedis/LockRedisProvider.php +++ b/src/LockRedis/LockRedisProvider.php @@ -13,7 +13,7 @@ */ final class LockRedisProvider extends Provider { - public const string PREFIX = 'foundation.lock_redis.prefix'; + public const string PREFIX = self::class . '.prefix'; /** * @throws InvalidArgumentException When the required Redis lock prefix is not configured. diff --git a/src/Log/LogProvider.php b/src/Log/LogProvider.php index c587bf6..1bd2e35 100644 --- a/src/Log/LogProvider.php +++ b/src/Log/LogProvider.php @@ -24,7 +24,7 @@ */ final class LogProvider extends Provider { - public const string LOG_LEVEL = 'foundation.log.log_level'; + public const string LOG_LEVEL = self::class . '.log_level'; public const string CHANNEL_ERRORLOG = 'errorlog'; private const string CHANNEL_CONSOLE = 'console'; private const string CHANNEL_NULL = 'null'; diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php index cedfc52..85d47e2 100644 --- a/src/WPCli/WPCliProvider.php +++ b/src/WPCli/WPCliProvider.php @@ -19,7 +19,7 @@ final class WPCliProvider extends Provider { use ResolvesFoundationPrefix; - public const string COMMANDS = 'foundation.wpcli.commands'; + public const string COMMANDS = self::class . '.commands'; /** * @throws InvalidArgumentException When the configured Foundation prefix is invalid. From bfdd14cefde0eea8c5d3cd6ef324d48e0fbc3378 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 10:30:04 -0600 Subject: [PATCH 64/81] Fix shutdown provider example --- src/Docs/src/content/docs/components/shutdown.mdx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Docs/src/content/docs/components/shutdown.mdx b/src/Docs/src/content/docs/components/shutdown.mdx index ac223c6..16253a8 100644 --- a/src/Docs/src/content/docs/components/shutdown.mdx +++ b/src/Docs/src/content/docs/components/shutdown.mdx @@ -140,7 +140,6 @@ final class Provider extends Service_Provider { public function register(): void { $this->register_cache_writer(); - $this->register_shutdown_task(); } private function register_cache_writer(): void { @@ -151,9 +150,7 @@ final class Provider extends Service_Provider { $this->container->when( Product_Cache_Writer::class ) ->needs( '$ttl' ) ->give( (int) $this->config->get( 'product_cache.ttl', 300 ) ); - } - private function register_shutdown_task(): void { $this->container->mergeArrayVar( ShutdownProvider::TASKS, static fn ( C $c ): array => [ From 77825caafb210d1ce9bb05af5beb5a7488b17614 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 10:30:47 -0600 Subject: [PATCH 65/81] Add example for shared container identifiers and agent instructions for future packages --- AGENTS.md | 2 +- src/Docs/src/content/docs/components/container.mdx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc90c5f..5013f8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ When writing providers or container registration code, prefer container-driven c Use contextual bindings with `$this->container->when()->needs()->give()` for scalar constructor arguments, command lists, or feature-specific substitutions. Use a factory closure only when the value must be computed or resolved from the container, and keep that closure focused on supplying the constructor dependency rather than constructing the full object. -Provider-owned internal container identifiers, including scalar bindings and additive collection contribution points, must use `self::class . '.descriptive_suffix'` instead of global literal strings. This allows namespace prefixing tools such as Strauss to scope the identifier to the prefixed provider class. Do not apply this pattern to configuration keys, persistent database or cache identifiers, lock names, channel names, WP-CLI command names, or other externally visible values that must remain stable across builds. +Foundation package providers must define their internal container identifiers, including scalar bindings and additive collection contribution points, with `self::class . '.descriptive_suffix'` instead of global literal strings. This allows namespace prefixing tools such as Strauss to scope the identifier to the prefixed provider class. Consuming applications should instead use stable text identifiers prefixed with their application or plugin name, for example `your-plugin.report.exporters`. Do not apply class-derived identifiers to configuration keys, persistent database or cache identifiers, lock names, channel names, WP-CLI command names, or other externally visible values that must remain stable across builds. Classes should take the dependencies they need directly. Do not make constructor dependencies nullable just to instantiate fallback concrete classes internally, for example `?Dependency $dependency = null` with `$this->dependency = $dependency ?? new Dependency()`. Register default implementations and aliases in a provider instead so consumers can replace them through container configuration. diff --git a/src/Docs/src/content/docs/components/container.mdx b/src/Docs/src/content/docs/components/container.mdx index b0e1d6f..2091dd6 100644 --- a/src/Docs/src/content/docs/components/container.mdx +++ b/src/Docs/src/content/docs/components/container.mdx @@ -124,7 +124,7 @@ Use a factory callback only when the value must be computed or fetched from the In `src/Report/Provider.php`, use `mergeArrayVar()` when independent providers contribute to one ordered collection. The provider that owns the collection registers its default and supplies it to the consuming class: ```php title="Provider.php" -public const string EXPORTERS = 'your_plugin.report.exporters'; +public const string EXPORTERS = 'your-plugin.report.exporters'; private function register_exporter_collection(): void { $this->container->mergeArrayVar( self::EXPORTERS, [] ); @@ -135,6 +135,14 @@ private function register_exporter_collection(): void { } ``` +:::note[Name shared container identifiers] +Use class and interface names as container identifiers for services. Introduce a text identifier only when a provider owns a shared scalar, configured object, or contribution point that has no natural service contract. + +Application-owned text identifiers should start with a stable application or plugin prefix, followed by the feature and value name, as shown by `your-plugin.report.exporters` above. This keeps ownership visible and prevents unrelated features from choosing the same key. Keep the identifier stable if the provider class is renamed. + +Text container identifiers are internal application wiring. Do not reuse them as configuration paths, database tables, cache keys, lock names, commands, or other externally visible values. +::: + Other feature providers append their implementations without replacing earlier contributions. For example, `src/Report/Csv/Provider.php` can contribute the CSV implementation: ```php title="Provider.php" From 001cb5b01e8d70f7ffe8b2c5517826adae675a5b Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 10:36:55 -0600 Subject: [PATCH 66/81] Move coverage report into its own job with write permissions --- .github/workflows/tests.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9e8bacb..e5fc22d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,8 +146,33 @@ jobs: "${SLIC_BIN}" composer update predis/predis:3.0.0 --with-all-dependencies "${SLIC_BIN}" run redis --ext DotReporter - - name: Monitor coverage + - name: Upload coverage report if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: clover.xml + if-no-files-found: error + retention-days: 1 + + coverage: + if: github.event_name == 'pull_request' + needs: slic + runs-on: ubuntu-latest + name: Monitor coverage + + permissions: + contents: read + pull-requests: write + statuses: write + + steps: + - name: Download coverage report + uses: actions/download-artifact@v8 + with: + name: coverage-report + + - name: Monitor coverage uses: slavcodev/coverage-monitor-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} From 00d05f6b83c53ea2b156e12540f98527f215dcfc Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 12:00:53 -0600 Subject: [PATCH 67/81] Add `foundation-view` --- AGENTS.md | 5 + README.md | 1 + composer.json | 2 + src/Docs/astro.config.mjs | 1 + src/Docs/src/content/docs/components/view.mdx | 279 ++++++++++++++++++ .../content/docs/start/what-is-foundation.md | 2 +- src/View/.gitattributes | 7 + .../.github/workflows/close-pull-request.yml | 13 + src/View/.gitignore | 2 + src/View/Contracts/DirectoryAwareView.php | 18 ++ src/View/Contracts/View.php | 20 ++ src/View/Exceptions/ViewNotFoundException.php | 12 + src/View/PhpView.php | 154 ++++++++++ src/View/README.md | 18 ++ src/View/ViewProvider.php | 41 +++ src/View/composer.json | 24 ++ .../Fixtures/View/DelegatingDirectoryView.php | 24 ++ tests/Support/Fixtures/View/JsonView.php | 22 ++ tests/Unit/View/PhpViewTest.php | 216 ++++++++++++++ tests/Unit/View/ViewContractTest.php | 30 ++ tests/Unit/View/ViewProviderTest.php | 37 +++ .../View/default/admin/product-summary.php | 1 + tests/_data/View/default/balanced-buffer.php | 7 + tests/_data/View/default/closes-buffer.php | 3 + tests/_data/View/default/flushes-buffer.php | 4 + tests/_data/View/default/greeting.php | 4 + .../_data/View/default/internal-variable.php | 3 + tests/_data/View/default/replaces-buffer.php | 5 + tests/_data/View/default/throws.php | 6 + tests/_data/View/default/unclosed-buffer.php | 4 + tests/_data/View/outside.php | 1 + tests/_data/View/runtime/greeting.php | 1 + 32 files changed, 966 insertions(+), 1 deletion(-) create mode 100644 src/Docs/src/content/docs/components/view.mdx create mode 100644 src/View/.gitattributes create mode 100644 src/View/.github/workflows/close-pull-request.yml create mode 100644 src/View/.gitignore create mode 100644 src/View/Contracts/DirectoryAwareView.php create mode 100644 src/View/Contracts/View.php create mode 100644 src/View/Exceptions/ViewNotFoundException.php create mode 100644 src/View/PhpView.php create mode 100644 src/View/README.md create mode 100644 src/View/ViewProvider.php create mode 100644 src/View/composer.json create mode 100644 tests/Support/Fixtures/View/DelegatingDirectoryView.php create mode 100644 tests/Support/Fixtures/View/JsonView.php create mode 100644 tests/Unit/View/PhpViewTest.php create mode 100644 tests/Unit/View/ViewContractTest.php create mode 100644 tests/Unit/View/ViewProviderTest.php create mode 100644 tests/_data/View/default/admin/product-summary.php create mode 100644 tests/_data/View/default/balanced-buffer.php create mode 100644 tests/_data/View/default/closes-buffer.php create mode 100644 tests/_data/View/default/flushes-buffer.php create mode 100644 tests/_data/View/default/greeting.php create mode 100644 tests/_data/View/default/internal-variable.php create mode 100644 tests/_data/View/default/replaces-buffer.php create mode 100644 tests/_data/View/default/throws.php create mode 100644 tests/_data/View/default/unclosed-buffer.php create mode 100644 tests/_data/View/outside.php create mode 100644 tests/_data/View/runtime/greeting.php diff --git a/AGENTS.md b/AGENTS.md index 5013f8d..c17738b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ Split packages: - `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` - `stellarwp/foundation-shutdown` +- `stellarwp/foundation-view` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` - `stellarwp/foundation-docs` @@ -49,6 +50,10 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. +Design public contracts as the smallest stable capabilities consumers need so applications can replace the supplied implementation without inheriting unrelated backend assumptions. Do not put filesystem, database, transport, framework, or other implementation-specific behavior on a general contract merely because the default concrete class supports it. Use a separate capability contract when only some implementations provide optional behavior, and bind each supported contract to the default implementation in the package provider. Follow interface segregation and dependency inversion: application code should be able to supply a substantially different implementation without implementing meaningless methods or extending Foundation internals. + +Keep convenience methods and implementation machinery on the concrete class unless they form a genuine reusable capability. Do not expose private helpers as public API speculatively. Prefer composition, and extract a focused collaborator when another real implementation needs to share the same policy or behavior. + Avoid `use ... as ...` import aliases unless they resolve a real class-name collision or ambiguity. Prefer importing the class by its actual short name. The standing exception is `use lucatume\DI52\Container as C;`, which may be used for concise container factory callbacks. Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. diff --git a/README.md b/README.md index 509ed2c..1b2451f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ See the [Foundation documentation](https://foundation.stellarwp.com/) for instal | [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) | Multiple processes or servers coordinate through dedicated Redis | Runtime | | [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries, migrations, or database-backed locks | Runtime | | [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) | Services need injectable ULID generation and validation | Runtime | +| [stellarwp/foundation-view](https://github.com/stellarwp/foundation-view) | Services need scoped PHP template rendering without global state | Runtime | | [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) | A shipped WordPress plugin exposes WP-CLI commands | Runtime | | [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) | Developers need Foundation generators or monorepo maintenance commands | Development | | [stellarwp/foundation-docs](https://github.com/stellarwp/foundation-docs) | Contributors maintain or deploy the Foundation documentation site | Documentation | diff --git a/composer.json b/composer.json index 3b245f7..4cdb81d 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-shutdown": "self.version", + "stellarwp/foundation-view": "self.version", "stellarwp/foundation-wpcli": "self.version" }, "minimum-stability": "dev", @@ -61,6 +62,7 @@ "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/", + "StellarWP\\Foundation\\View\\": "src/View/", "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" }, "exclude-from-classmap": [ diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index 85c9be3..2025cd0 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -46,6 +46,7 @@ export default defineConfig({ { slug: 'components/identifier' }, { slug: 'components/pipeline' }, { slug: 'components/shutdown' }, + { slug: 'components/view' }, { slug: 'components/wp-cli' }, ], }, diff --git a/src/Docs/src/content/docs/components/view.mdx b/src/Docs/src/content/docs/components/view.mdx new file mode 100644 index 0000000..ba51f9b --- /dev/null +++ b/src/Docs/src/content/docs/components/view.mdx @@ -0,0 +1,279 @@ +--- +title: View +description: Render trusted PHP templates to strings from configured or runtime-selected directories. +sidebar: + order: 9 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation View provides a small contract for rendering named views and a default `PhpView` implementation that renders trusted PHP templates from an explicit directory. It keeps markup in view files while application services remain responsible for selecting the view and preparing its data. + +`PhpView` uses ordinary PHP templates without introducing custom template syntax. It captures their output and returns it as a string instead of echoing it automatically. A configured renderer can also create an immutable renderer for another trusted directory at runtime. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-view +``` + +### Prepare the application + +Foundation View uses the shared application configuration and provider architecture established in these guides: + + + + + + + +## Configuration + +### Choose the default view directory + +Create a `views/` directory at the application root. In the root `config.php`, provide its absolute path: + +```php title="config.php" + [ + 'directory' => __DIR__ . '/views', + ], +]; +``` + +`view.directory` is required and must identify an existing, readable directory. Foundation resolves it to its canonical path before rendering. + +### Register the view provider + +In `src/App.php`, add `ViewProvider` before feature providers that consume the `View` contract: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\View\ViewProvider; +use YourPlugin\Admin; + +/** @var list> */ +private const array PROVIDERS = [ + ViewProvider::class, + Admin\Provider::class, +]; +``` + +`ViewProvider` binds one shared `PhpView` instance to `StellarWP\Foundation\View\Contracts\View` and `StellarWP\Foundation\View\Contracts\DirectoryAwareView`. + +## Usage + +### Create a view before rendering it + +View names are relative to the configured directory and omit the `.php` extension. For the name `admin/product-summary`, first create `views/admin/product-summary.php`: + +```php title="product-summary.php" + +
+

+

+
+``` + +:::note[Document every view variable] +Add a typed `@var` annotation, including a short description, for every value the view expects. These annotations define the template's input contract, allow PHPStan to analyze the file without undefined-variable errors, and provide type-aware completion in supported IDEs. +::: + +:::caution[Escape output in the view] +Foundation passes data into trusted PHP files but does not escape it automatically. Escape each value for its HTML, attribute, URL, or JavaScript context when the view outputs it. Do not render user-uploaded PHP templates. +::: + +### Render the view from a service + +In `src/Admin/Product_Summary_Notice.php`, inject the `View` contract and return or echo the rendered string at the application boundary: + +```php title="Product_Summary_Notice.php" +products->count(); + + echo $this->view->render( + 'admin/product-summary', + [ + 'title' => __( 'Product catalog', 'your-plugin' ), + 'summary' => sprintf( + /* translators: %d: number of products. */ + _n( '%d product is available.', '%d products are available.', $count, 'your-plugin' ), + $count + ), + ] + ); + } +} +``` + +Register the WordPress callback from `src/Admin/Provider.php`: + +```php title="Provider.php" +container->callback( Product_Summary_Notice::class, 'display' ) + ); + } +} +``` + +### Select another directory at runtime + +Inject `DirectoryAwareView` instead of the base `View` contract when a service must select another trusted template root, such as a theme override directory: + +```php title="Receipt_Renderer.php" +view->withDirectory( $trusted_template_directory ); + + return $renderer->render( + 'email/receipt', + [ 'receipt' => $receipt ] + ); + } +} +``` + +`withDirectory()` returns a new renderer. It does not mutate the shared renderer or affect other services using the configured directory. + +:::caution[Do not accept a directory from request input] +Runtime directory selection is for trusted application paths. Never pass a URL parameter, form value, REST field, or other untrusted input to `withDirectory()`. +::: + +### Supply another renderer + +The base `View` contract requires only named rendering. A renderer that does not use PHP files or directories can implement it without supporting `withDirectory()`: + +```php title="Json_View.php" + $name, + 'data' => $data, + ], + JSON_THROW_ON_ERROR + ); + } +} +``` + +Bind the replacement from the application's feature provider instead of registering `ViewProvider`: + +```php title="Provider.php" +use StellarWP\Foundation\View\Contracts\View; + +public function register(): void { + $this->container->singleton( View::class, Json_View::class ); +} +``` + +Use a separate capability contract when a custom renderer supports optional behavior such as runtime directory selection. Application services that only call `render()` should continue depending on `View`. + +### Handle missing views + +The renderer throws `ViewNotFoundException` when a view is missing, unreadable, or resolves outside the selected directory. Empty names, absolute paths, null bytes, and parent traversal such as `../private` are rejected with `InvalidArgumentException`. + +Exceptions thrown by the view itself are propagated after Foundation restores the output-buffer level. A view may use balanced buffers of its own, but it must not clean, flush, close, or replace Foundation's rendering buffer. Invalid buffer state is rejected instead of returning incomplete output. Let application-level error handling record or present those failures rather than returning a partial template. + +## Testing + +Place small PHP view fixtures under the test data directory. For example, create `tests/_data/views/message.php`: + +```php title="message.php" +

+``` + +Render the fixture with the concrete class: + +```php +$view = new PhpView( codecept_data_dir( 'views' ) ); + +$this->assertSame( + '

Hello, Foundation

', + $view->render( + 'message', + [ 'message' => 'Hello, Foundation' ] + ) +); +``` + +Test feature services through the `View` contract when the rendered markup is part of their observable behavior. Use a temporary directory under `tests/_data/temp` for path-containment or runtime-directory tests that must create files. diff --git a/src/Docs/src/content/docs/start/what-is-foundation.md b/src/Docs/src/content/docs/start/what-is-foundation.md index a93c050..3179821 100644 --- a/src/Docs/src/content/docs/start/what-is-foundation.md +++ b/src/Docs/src/content/docs/start/what-is-foundation.md @@ -5,7 +5,7 @@ sidebar: order: 1 --- -Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, or command conventions. +Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, view, or command conventions. Foundation is primarily developed for internal Nexcess projects. Its packages are publicly available and designed to remain reusable, but the needs of Nexcess applications will primarily drive changes, priorities, and the project roadmap. diff --git a/src/View/.gitattributes b/src/View/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/View/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/View/.github/workflows/close-pull-request.yml b/src/View/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/View/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/View/.gitignore b/src/View/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/View/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/View/Contracts/DirectoryAwareView.php b/src/View/Contracts/DirectoryAwareView.php new file mode 100644 index 0000000..cdd2ddb --- /dev/null +++ b/src/View/Contracts/DirectoryAwareView.php @@ -0,0 +1,18 @@ + $data Values made available to the renderer. + * + * @throws Throwable When rendering fails. + */ + public function render(string $name, array $data = []): string; +} diff --git a/src/View/Exceptions/ViewNotFoundException.php b/src/View/Exceptions/ViewNotFoundException.php new file mode 100644 index 0000000..2d03e86 --- /dev/null +++ b/src/View/Exceptions/ViewNotFoundException.php @@ -0,0 +1,12 @@ +directory = $resolved; + } + + /** + * {@inheritDoc} + */ + public function withDirectory(string $directory): static { + return new self($directory); + } + + /** + * {@inheritDoc} + * + * @throws InvalidArgumentException When the view name is empty, absolute, or traverses parent directories. + * @throws RuntimeException When the view leaves output buffering in an invalid state. + * @throws ViewNotFoundException When the view does not exist, is unreadable, or resolves outside the configured directory. + * @throws \Throwable When the view itself throws. + */ + public function render(string $name, array $data = []): string { + $path = $this->resolve($name); + $bufferLevel = ob_get_level(); + $renderBufferLevel = $bufferLevel + 1; + $renderBufferTouched = false; + + ob_start(static function () use (&$renderBufferTouched): string { + $renderBufferTouched = true; + + return ''; + }); + + try { + self::renderFile($path, $data); + + if ($renderBufferTouched || ob_get_level() !== $renderBufferLevel) { + throw new RuntimeException(sprintf('The view "%s" must leave output buffering unchanged.', $name)); + } + + $output = ob_get_clean(); + + if ($output === false) { + throw new RuntimeException(sprintf('The output buffer for view "%s" could not be read.', $name)); + } + + return $output; + } finally { + self::discardBuffersAbove($bufferLevel); + } + } + + /** + * Render a PHP file in an isolated static scope with the supplied view data. + * + * @param array $foundationViewData + */ + private static function renderFile(string $foundationViewPath, array $foundationViewData): void { + extract($foundationViewData, EXTR_SKIP); + + require $foundationViewPath; + } + + /** + * Remove buffers opened while rendering without closing a caller-owned buffer. + */ + private static function discardBuffersAbove(int $bufferLevel): void { + while (ob_get_level() > $bufferLevel) { + $status = ob_get_status(); + + if (($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) === 0 || ! ob_end_clean()) { + return; + } + } + } + + /** + * Resolve a relative view name to a readable PHP file inside the configured directory. + * + * @throws InvalidArgumentException When the view name is empty, absolute, or traverses parent directories. + * @throws ViewNotFoundException When the view cannot be safely resolved and read. + */ + private function resolve(string $name): string { + $this->validateName($name); + + $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $name) . '.php'; + $path = realpath($this->directory . DIRECTORY_SEPARATOR . $relative); + + if ($path === false || ! is_file($path) || ! is_readable($path) || ! $this->contains($path)) { + throw new ViewNotFoundException(sprintf('The view "%s" could not be found in "%s".', $name, $this->directory)); + } + + return $path; + } + + /** + * Reject names that could select files outside the configured directory. + * + * @throws InvalidArgumentException When the name is empty, absolute, or contains a parent-directory segment. + */ + private function validateName(string $name): void { + if ( + trim($name) === '' + || str_contains($name, "\0") + || str_starts_with($name, '/') + || str_starts_with($name, '\\') + || preg_match('/^[A-Za-z]:[\\\\\/]/', $name) === 1 + ) { + throw new InvalidArgumentException('View names must be non-empty relative paths.'); + } + + $segments = preg_split('#[\\\\/]#', $name); + + if ($segments === false || in_array('..', $segments, true)) { + throw new InvalidArgumentException('View names cannot traverse parent directories.'); + } + } + + /** + * Determine whether a canonical file path remains inside the canonical view directory. + */ + private function contains(string $path): bool { + $directory = rtrim($this->directory, '/\\') . DIRECTORY_SEPARATOR; + + if (DIRECTORY_SEPARATOR === '\\') { + return str_starts_with(strtolower($path), strtolower($directory)); + } + + return str_starts_with($path, $directory); + } +} diff --git a/src/View/README.md b/src/View/README.md new file mode 100644 index 0000000..cdf7595 --- /dev/null +++ b/src/View/README.md @@ -0,0 +1,18 @@ +# Foundation View + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation View renders trusted PHP templates to strings from a configured +directory and supports immutable runtime directory selection. + +## Installation + +```shell +composer require stellarwp/foundation-view +``` + +## Documentation + +See the [Foundation View documentation](https://foundation.stellarwp.com/components/view/) +for configuration, rendering, runtime directory selection, path safety, and testing. diff --git a/src/View/ViewProvider.php b/src/View/ViewProvider.php new file mode 100644 index 0000000..1ac238a --- /dev/null +++ b/src/View/ViewProvider.php @@ -0,0 +1,41 @@ +registerView(); + } + + /** + * @throws InvalidArgumentException When view.directory is not a non-empty string. + */ + private function registerView(): void { + $directory = $this->config->get('view.directory'); + + if (! is_string($directory) || trim($directory) === '') { + throw new InvalidArgumentException('The view.directory configuration value must be a non-empty string.'); + } + + $this->container->when(PhpView::class) + ->needs('$directory') + ->give($directory); + + $this->container->singleton(PhpView::class); + $this->container->singleton(View::class, static fn (C $c): PhpView => $c->get(PhpView::class)); + $this->container->singleton(DirectoryAwareView::class, static fn (C $c): PhpView => $c->get(PhpView::class)); + } +} diff --git a/src/View/composer.json b/src/View/composer.json new file mode 100644 index 0000000..14cc05c --- /dev/null +++ b/src/View/composer.json @@ -0,0 +1,24 @@ +{ + "name": "stellarwp/foundation-view", + "type": "library", + "description": "Render scoped PHP views to strings with immutable runtime directory selection.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\View\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/View/DelegatingDirectoryView.php b/tests/Support/Fixtures/View/DelegatingDirectoryView.php new file mode 100644 index 0000000..dc1cbe0 --- /dev/null +++ b/tests/Support/Fixtures/View/DelegatingDirectoryView.php @@ -0,0 +1,24 @@ +view->withDirectory($directory); + } + + public function render(string $name, array $data = []): string { + return $this->view->render($name, $data); + } +} diff --git a/tests/Support/Fixtures/View/JsonView.php b/tests/Support/Fixtures/View/JsonView.php new file mode 100644 index 0000000..83edfc4 --- /dev/null +++ b/tests/Support/Fixtures/View/JsonView.php @@ -0,0 +1,22 @@ + $name, + 'data' => $data, + ], JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Unit/View/PhpViewTest.php b/tests/Unit/View/PhpViewTest.php new file mode 100644 index 0000000..7b26f7c --- /dev/null +++ b/tests/Unit/View/PhpViewTest.php @@ -0,0 +1,216 @@ +data_dir('View/default')); + + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_escapes_template_data_in_the_template(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + '

<strong>Hello</strong>, Foundation

' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_renders_a_nested_view_name(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('

Nested view

' . PHP_EOL, $view->render('admin/product-summary')); + } + + public function test_it_returns_a_new_renderer_for_a_runtime_directory_without_mutating_the_original(): void { + $view = new PhpView($this->data_dir('View/default')); + $runtimeView = $view->withDirectory($this->data_dir('View/runtime')); + + $this->assertNotSame($view, $runtimeView); + $this->assertSame('

Runtime directory

' . PHP_EOL, $runtimeView->render('greeting')); + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_view_data_cannot_replace_the_resolved_view_path(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + 'internal-variable.php', + $view->render('internal-variable', [ + 'foundationViewPath' => $this->data_dir('View/outside.php'), + ]) + ); + } + + public function test_it_restores_the_output_buffer_when_a_view_throws(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('throws'); + $this->fail('Expected the view exception to be propagated.'); + } catch (RuntimeException $exception) { + $this->assertSame('View rendering failed.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_and_cleans_up_an_unclosed_view_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('unclosed-buffer'); + $this->fail('Expected unbalanced output buffering to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "unclosed-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_allows_balanced_buffers_owned_by_the_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('Balanced view output.', $view->render('balanced-buffer')); + } + + public function test_it_does_not_close_a_caller_buffer_when_the_view_closes_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('closes-buffer'); + $this->fail('Expected an unexpectedly closed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "closes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame($bufferLevel + 1, ob_get_level()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_a_same_depth_replacement_for_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('replaces-buffer'); + $this->fail('Expected a replaced rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "replaces-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_flushing_its_rendering_buffer_without_leaking_output(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('flushes-buffer'); + $this->fail('Expected a flushed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "flushes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame('', ob_get_contents()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_an_invalid_view_directory(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must exist and be readable'); + + new PhpView($this->data_dir('View/missing')); + } + + public function test_it_reports_a_missing_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(ViewNotFoundException::class); + $this->expectExceptionMessage('The view "missing" could not be found'); + + $view->render('missing'); + } + + /** + * @dataProvider invalid_view_names + */ + #[\PHPUnit\Framework\Attributes\DataProvider('invalid_view_names')] + public function test_it_rejects_unsafe_view_names(string $name): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(InvalidArgumentException::class); + + $view->render($name); + } + + /** + * @return array + */ + public static function invalid_view_names(): array { + return [ + 'empty' => ['name' => ''], + 'null byte' => ['name' => "greeting\0ignored"], + 'absolute Unix' => ['name' => '/tmp/view'], + 'absolute Windows' => ['name' => 'C:\\tmp\\view'], + 'parent traversal' => ['name' => '../outside'], + 'nested traversal' => ['name' => 'nested/../../outside'], + ]; + } + + public function test_it_rejects_a_symlink_that_resolves_outside_the_view_directory(): void { + $directory = $this->prepare_temp_dir('view'); + $root = $directory . '/root'; + $outside = $directory . '/outside.php'; + + mkdir($root); + file_put_contents($outside, 'Outside'); + + if (! symlink($outside, $root . '/linked.php')) { + $this->markTestSkipped('The test environment cannot create symbolic links.'); + } + + $view = new PhpView($root); + + $this->expectException(ViewNotFoundException::class); + + $view->render('linked'); + } +} diff --git a/tests/Unit/View/ViewContractTest.php b/tests/Unit/View/ViewContractTest.php new file mode 100644 index 0000000..317fd38 --- /dev/null +++ b/tests/Unit/View/ViewContractTest.php @@ -0,0 +1,30 @@ +assertSame( + '{"view":"product-summary","data":{"count":3}}', + $view->render('product-summary', ['count' => 3]) + ); + } + + public function test_a_directory_aware_adapter_may_return_another_implementation(): void { + $view = new PhpView($this->data_dir('View/default')); + $adapter = new DelegatingDirectoryView($view); + $runtimeView = $adapter->withDirectory($this->data_dir('View/runtime')); + + $this->assertInstanceOf(PhpView::class, $runtimeView); + $this->assertNotSame($view, $runtimeView); + $this->assertSame('

Runtime directory

' . PHP_EOL, $runtimeView->render('greeting')); + } +} diff --git a/tests/Unit/View/ViewProviderTest.php b/tests/Unit/View/ViewProviderTest.php new file mode 100644 index 0000000..1018927 --- /dev/null +++ b/tests/Unit/View/ViewProviderTest.php @@ -0,0 +1,37 @@ +container->get(Dot::class)->set('view.directory', $this->data_dir('View/default')); + $this->container->register(ViewProvider::class); + + $view = $this->container->get(View::class); + + $this->assertInstanceOf(PhpView::class, $view); + $this->assertSame($view, $this->container->get(View::class)); + $this->assertSame($view, $this->container->get(DirectoryAwareView::class)); + $this->assertSame($view, $this->container->get(PhpView::class)); + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_it_rejects_missing_view_configuration(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('view.directory configuration value must be a non-empty string'); + + $this->container->register(ViewProvider::class); + } +} diff --git a/tests/_data/View/default/admin/product-summary.php b/tests/_data/View/default/admin/product-summary.php new file mode 100644 index 0000000..2d45471 --- /dev/null +++ b/tests/_data/View/default/admin/product-summary.php @@ -0,0 +1 @@ +

Nested view

diff --git a/tests/_data/View/default/balanced-buffer.php b/tests/_data/View/default/balanced-buffer.php new file mode 100644 index 0000000..1f2e065 --- /dev/null +++ b/tests/_data/View/default/balanced-buffer.php @@ -0,0 +1,7 @@ +

,

diff --git a/tests/_data/View/default/internal-variable.php b/tests/_data/View/default/internal-variable.php new file mode 100644 index 0000000..43e0588 --- /dev/null +++ b/tests/_data/View/default/internal-variable.php @@ -0,0 +1,3 @@ +Runtime directory

From 49f4d8bfcc39623ab0f827e5502db75fa251130b Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 12:19:19 -0600 Subject: [PATCH 68/81] Fix product-summary.php confusing test --- tests/Unit/View/PhpViewTest.php | 4 ++-- tests/_data/View/default/admin/product-summary.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Unit/View/PhpViewTest.php b/tests/Unit/View/PhpViewTest.php index 7b26f7c..df33fa1 100644 --- a/tests/Unit/View/PhpViewTest.php +++ b/tests/Unit/View/PhpViewTest.php @@ -34,10 +34,10 @@ public function test_it_escapes_template_data_in_the_template(): void { ); } - public function test_it_renders_a_nested_view_name(): void { + public function test_it_renders_a_view_from_a_nested_directory(): void { $view = new PhpView($this->data_dir('View/default')); - $this->assertSame('

Nested view

' . PHP_EOL, $view->render('admin/product-summary')); + $this->assertSame('

Product summary

' . PHP_EOL, $view->render('admin/product-summary')); } public function test_it_returns_a_new_renderer_for_a_runtime_directory_without_mutating_the_original(): void { diff --git a/tests/_data/View/default/admin/product-summary.php b/tests/_data/View/default/admin/product-summary.php index 2d45471..ce78839 100644 --- a/tests/_data/View/default/admin/product-summary.php +++ b/tests/_data/View/default/admin/product-summary.php @@ -1 +1 @@ -

Nested view

+

Product summary

From fe46578b73e45cd61ec85a23345d1dd93201aa8d Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 12:32:04 -0600 Subject: [PATCH 69/81] Update docs and comments --- src/Docs/src/content/docs/components/view.mdx | 8 +++++++- src/View/PhpView.php | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Docs/src/content/docs/components/view.mdx b/src/Docs/src/content/docs/components/view.mdx index ba51f9b..941b66c 100644 --- a/src/Docs/src/content/docs/components/view.mdx +++ b/src/Docs/src/content/docs/components/view.mdx @@ -250,7 +250,11 @@ Use a separate capability contract when a custom renderer supports optional beha The renderer throws `ViewNotFoundException` when a view is missing, unreadable, or resolves outside the selected directory. Empty names, absolute paths, null bytes, and parent traversal such as `../private` are rejected with `InvalidArgumentException`. -Exceptions thrown by the view itself are propagated after Foundation restores the output-buffer level. A view may use balanced buffers of its own, but it must not clean, flush, close, or replace Foundation's rendering buffer. Invalid buffer state is rejected instead of returning incomplete output. Let application-level error handling record or present those failures rather than returning a partial template. +Exceptions thrown by the view itself are propagated after Foundation removes any removable buffers opened while rendering. A view may use balanced buffers of its own, but it must not clean, flush, close, or replace Foundation's rendering buffer. Invalid buffer state is rejected instead of returning incomplete output. Let application-level error handling record or present those failures rather than returning a partial template. + +:::danger[Do not create non-removable output buffers] +A PHP template must not start a buffer without `PHP_OUTPUT_HANDLER_REMOVABLE`. PHP cannot close such a buffer before the process ends, so no in-process PHP renderer can restore the request's output-buffer stack afterward. Treat view files as trusted application code and keep any buffers they open balanced and removable. +::: ## Testing @@ -265,6 +269,8 @@ Place small PHP view fixtures under the test data directory. For example, create Render the fixture with the concrete class: ```php +use StellarWP\Foundation\View\PhpView; + $view = new PhpView( codecept_data_dir( 'views' ) ); $this->assertSame( diff --git a/src/View/PhpView.php b/src/View/PhpView.php index fbb2fc7..d544bcc 100644 --- a/src/View/PhpView.php +++ b/src/View/PhpView.php @@ -86,6 +86,8 @@ private static function renderFile(string $foundationViewPath, array $foundation /** * Remove buffers opened while rendering without closing a caller-owned buffer. + * + * PHP cannot remove a buffer created without PHP_OUTPUT_HANDLER_REMOVABLE. */ private static function discardBuffersAbove(int $bufferLevel): void { while (ob_get_level() > $bufferLevel) { From ce56f2fb86bbafec2a0fb39bc95b12287341654a Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 13:18:16 -0600 Subject: [PATCH 70/81] Add reopened and permissions to close-pull-request.yml + update package scaffolder --- src/Cli/.github/workflows/close-pull-request.yml | 5 ++++- src/Cli/Commands/Package/PackageScaffolder.php | 5 ++++- src/Container/.github/workflows/close-pull-request.yml | 5 ++++- src/Database/.github/workflows/close-pull-request.yml | 5 ++++- src/Docs/.github/workflows/close-pull-request.yml | 5 ++++- src/Identifier/.github/workflows/close-pull-request.yml | 5 ++++- src/Lock/.github/workflows/close-pull-request.yml | 5 ++++- src/LockRedis/.github/workflows/close-pull-request.yml | 5 ++++- src/Log/.github/workflows/close-pull-request.yml | 5 ++++- src/Pipeline/.github/workflows/close-pull-request.yml | 5 ++++- src/Shutdown/.github/workflows/close-pull-request.yml | 5 ++++- src/View/.github/workflows/close-pull-request.yml | 5 ++++- src/WPCli/.github/workflows/close-pull-request.yml | 5 ++++- tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php | 5 +++++ 14 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/Cli/.github/workflows/close-pull-request.yml b/src/Cli/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/Cli/.github/workflows/close-pull-request.yml +++ b/src/Cli/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Cli/Commands/Package/PackageScaffolder.php b/src/Cli/Commands/Package/PackageScaffolder.php index 4867081..658b1cc 100644 --- a/src/Cli/Commands/Package/PackageScaffolder.php +++ b/src/Cli/Commands/Package/PackageScaffolder.php @@ -216,7 +216,10 @@ private function closePullRequestWorkflow(): string { on: pull_request_target: - types: [opened] + types: [opened, reopened] + + permissions: + pull-requests: write jobs: run: diff --git a/src/Container/.github/workflows/close-pull-request.yml b/src/Container/.github/workflows/close-pull-request.yml index 7c34d2e..a2353e6 100644 --- a/src/Container/.github/workflows/close-pull-request.yml +++ b/src/Container/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Database/.github/workflows/close-pull-request.yml b/src/Database/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/Database/.github/workflows/close-pull-request.yml +++ b/src/Database/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Docs/.github/workflows/close-pull-request.yml b/src/Docs/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/Docs/.github/workflows/close-pull-request.yml +++ b/src/Docs/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Identifier/.github/workflows/close-pull-request.yml b/src/Identifier/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/Identifier/.github/workflows/close-pull-request.yml +++ b/src/Identifier/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Lock/.github/workflows/close-pull-request.yml b/src/Lock/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/Lock/.github/workflows/close-pull-request.yml +++ b/src/Lock/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/LockRedis/.github/workflows/close-pull-request.yml b/src/LockRedis/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/LockRedis/.github/workflows/close-pull-request.yml +++ b/src/LockRedis/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Log/.github/workflows/close-pull-request.yml b/src/Log/.github/workflows/close-pull-request.yml index 7c34d2e..a2353e6 100644 --- a/src/Log/.github/workflows/close-pull-request.yml +++ b/src/Log/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Pipeline/.github/workflows/close-pull-request.yml b/src/Pipeline/.github/workflows/close-pull-request.yml index 7c34d2e..a2353e6 100644 --- a/src/Pipeline/.github/workflows/close-pull-request.yml +++ b/src/Pipeline/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Shutdown/.github/workflows/close-pull-request.yml b/src/Shutdown/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/Shutdown/.github/workflows/close-pull-request.yml +++ b/src/Shutdown/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/View/.github/workflows/close-pull-request.yml b/src/View/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/View/.github/workflows/close-pull-request.yml +++ b/src/View/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/WPCli/.github/workflows/close-pull-request.yml b/src/WPCli/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/WPCli/.github/workflows/close-pull-request.yml +++ b/src/WPCli/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php b/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php index c15bf99..3a88b57 100644 --- a/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php +++ b/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php @@ -46,6 +46,11 @@ public function test_it_creates_a_package_scaffold_with_the_default_package_name '.github/workflows/close-pull-request.yml', ], $scaffold->createdFiles); $this->assertFileExists($rootPath . '/src/WPCli/composer.json'); + $workflow = file_get_contents($rootPath . '/src/WPCli/.github/workflows/close-pull-request.yml'); + + $this->assertIsString($workflow); + $this->assertStringContainsString('types: [opened, reopened]', $workflow); + $this->assertStringContainsString("permissions:\n pull-requests: write", $workflow); $composer = $this->packageComposer($rootPath, 'WPCli'); From 0504acdc491306e4047a3f18e3265238b8674c68 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 14:18:36 -0600 Subject: [PATCH 71/81] Add Cloudflare pages preparation/workflows --- .github/workflows/docs-preview.yml | 94 ++++++++++++++++++++++ AGENTS.md | 3 + src/Docs/.github/workflows/deploy-docs.yml | 53 ++++++++++++ src/Docs/README.md | 24 +++++- src/Docs/package.json | 3 +- 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docs-preview.yml create mode 100644 src/Docs/.github/workflows/deploy-docs.yml diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 0000000..0795106 --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,94 @@ +name: Documentation Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/Docs/**' + - '.github/workflows/docs-preview.yml' + +concurrency: + group: docs-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + + permissions: + contents: read + deployments: write + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: 'src/Docs/.nvmrc' + cache: npm + cache-dependency-path: 'src/Docs/package-lock.json' + + - name: Install dependencies + working-directory: src/Docs + run: npm ci + + - name: Build documentation + working-directory: src/Docs + run: npm run build + + - name: Deploy preview + id: deploy + if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + packageManager: npm + workingDirectory: src/Docs + command: pages deploy dist --project-name=foundation-docs --branch=pr-${{ github.event.pull_request.number }} + + - name: Comment preview URL + if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + uses: actions/github-script@v9 + env: + PREVIEW_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url || steps.deploy.outputs.deployment-url }} + PREVIEW_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const marker = ''; + const body = [ + marker, + '## Documentation preview', + '', + `[Review this documentation update](${process.env.PREVIEW_URL})`, + '', + `Updated for commit \`${process.env.PREVIEW_SHA.slice(0, 7)}\`.`, + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/AGENTS.md b/AGENTS.md index c17738b..5221adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,8 @@ Use `composer monorepo list` to inspect available Monorepo Builder commands. The documentation site lives in `src/Docs/` and uses Astro with Starlight. Use the Node version in `src/Docs/.nvmrc`, install dependencies with `npm ci`, and run `npm run build` from `src/Docs/` after documentation changes. +Same-repository documentation pull requests are previewed from the monorepo workflow. Production documentation is deployed only for published stable releases by the tag workflow that is split into `stellarwp/foundation-docs`; never deploy documentation production from a push to the monorepo's `main` branch. Both workflows use the `foundation-docs` Cloudflare Pages project through Direct Upload, configured with `production` as its production branch. + Write public documentation as current product documentation. Do not mention implementation phases, review checkpoints, future documentation work, or temporary plans. If code behavior, public APIs, package requirements, configuration, or supported integrations change, update the relevant documentation in the same change. Add a component guide and sidebar entry when adding a public split package. Once a component has a central documentation guide, keep its split-package `README.md` focused on a short overview, installation, and links to the canonical guide. Do not duplicate full configuration and usage documentation across the README and documentation site. @@ -248,6 +250,7 @@ After completing a feature, run `composer test:coverage`, review `clover.xml` fo - Run `composer monorepo bump-interdependency ` when planning a major version release so Foundation packages that depend on each other require the new major line, for example `^3.0`. It may also be useful for a minor release when one package must require APIs added in that new minor, for example `^1.1`. - Before publishing a release, verify the intended release-line package constraints are already committed. For a minor release such as `1.2.0`, internal Foundation package dependencies should already require the released line, for example `^1.2`. - Publishing a GitHub release creates the release tag and triggers the tagged monorepo split. Wait for the tagged `Split Monorepo Packages and Release` workflow to succeed before considering the release complete. +- When a release includes `foundation-docs`, also verify that the tag-triggered `Deploy Documentation` workflow succeeds in the `stellarwp/foundation-docs` split repository before considering the documentation release complete. - After a successful tagged split for a minor or major `.0` release, the split workflow automatically bumps internal package constraints and branch aliases to the next development line on `main`, for example from `^1.2` and `1.2.x-dev` to `^1.3` and `1.3.x-dev`. - The post-release automation intentionally skips patch tags such as `1.2.1`, because patch releases should not move `dev-main` to a new minor development line. - If the post-release automation fails, fetch the release tag and manually run `composer monorepo bump-interdependency `, `composer monorepo package-alias`, and `composer monorepo merge`, then commit and push the updated package `composer.json` files. diff --git a/src/Docs/.github/workflows/deploy-docs.yml b/src/Docs/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..1fa72ef --- /dev/null +++ b/src/Docs/.github/workflows/deploy-docs.yml @@ -0,0 +1,53 @@ +name: Deploy Documentation + +on: + push: + tags: + - '*' + +concurrency: + group: docs-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + deployments: write + + steps: + - name: Checkout tagged documentation + uses: actions/checkout@v7 + + - name: Verify published Foundation release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [[ "$(gh api "repos/stellarwp/foundation/releases/tags/${GITHUB_REF_NAME}" --jq '.draft == false and .prerelease == false')" != "true" ]]; then + echo "${GITHUB_REF_NAME} is not a published stable Foundation release." + exit 1 + fi + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: 'package-lock.json' + + - name: Install dependencies + run: npm ci + + - name: Build documentation + run: npm run build + + - name: Deploy production + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + packageManager: npm + command: pages deploy dist --project-name=foundation-docs --branch=production diff --git a/src/Docs/README.md b/src/Docs/README.md index 3e15201..ec90f96 100644 --- a/src/Docs/README.md +++ b/src/Docs/README.md @@ -10,7 +10,7 @@ The source for the Foundation documentation site. Use Node.js 24 and install the locked npm dependencies: ```shell -nvm use +nvm install npm ci npm run dev ``` @@ -22,3 +22,25 @@ Create a production build with: ```shell npm run build ``` + +Preview the production build with Cloudflare Pages locally: + +```shell +npm run preview:cloudflare +``` + +Wrangler serves the site at `http://localhost:8788`. Press `t` in that terminal +to create a temporary public Cloudflare Tunnel URL for sharing the preview. +The tunnel is publicly accessible and must not be used for production. + +## Deployment + +Documentation previews deploy from same-repository pull requests in +`stellarwp/foundation`. Production deploys only when a stable release tag reaches +the read-only `stellarwp/foundation-docs` split repository. + +Both workflows use a Direct Upload Cloudflare Pages project named +`foundation-docs` whose production branch is `production`. The Cloudflare API +token requires `Account > Cloudflare Pages > Edit`. The +`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` organization Actions secrets +must be available to both repositories. diff --git a/src/Docs/package.json b/src/Docs/package.json index 4840a5c..d6284ca 100644 --- a/src/Docs/package.json +++ b/src/Docs/package.json @@ -8,7 +8,8 @@ "scripts": { "dev": "astro dev", "build": "astro build", - "preview": "astro preview" + "preview": "astro preview", + "preview:cloudflare": "npm run build && wrangler pages dev dist" }, "dependencies": { "@astrojs/starlight": "^0.41.7", From 5b239ec57402bec3ad1250b1aa98733c75ea66bd Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 14:23:43 -0600 Subject: [PATCH 72/81] Update prod docs URL to `foundation.nexcess.dev` --- README.md | 2 +- src/Cli/README.md | 2 +- src/Container/README.md | 2 +- src/Database/README.md | 2 +- src/Docs/astro.config.mjs | 2 +- src/Identifier/README.md | 2 +- src/Lock/README.md | 2 +- src/LockRedis/README.md | 2 +- src/Log/README.md | 2 +- src/Pipeline/README.md | 2 +- src/Shutdown/README.md | 2 +- src/View/README.md | 2 +- src/WPCli/README.md | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1b2451f..9a776b1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Foundation is a StellarWP Composer monorepo of shared PHP infrastructure for Nex > [!NOTE] > This monorepo splits each package out into their own sub-repository, if you only need a specific component you can install only that specific one. -See the [Foundation documentation](https://foundation.stellarwp.com/) for installation, application architecture, component configuration, and developer tooling. +See the [Foundation documentation](https://foundation.nexcess.dev/) for installation, application architecture, component configuration, and developer tooling. ## Repositories diff --git a/src/Cli/README.md b/src/Cli/README.md index ac17539..e6ac6b5 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -15,5 +15,5 @@ composer require --dev stellarwp/foundation-cli ## Documentation -See the [Foundation CLI documentation](https://foundation.stellarwp.com/tooling/foundation-cli/) +See the [Foundation CLI documentation](https://foundation.nexcess.dev/tooling/foundation-cli/) for project generators, stub overrides, Strauss support, custom commands, and monorepo maintenance. diff --git a/src/Container/README.md b/src/Container/README.md index 344a930..ac6d43c 100644 --- a/src/Container/README.md +++ b/src/Container/README.md @@ -16,5 +16,5 @@ composer require stellarwp/foundation-container ## Documentation -See the [Foundation Container documentation](https://foundation.stellarwp.com/components/container/) +See the [Foundation Container documentation](https://foundation.nexcess.dev/components/container/) for application setup, bindings, provider collections, lazy callbacks, and testing. diff --git a/src/Database/README.md b/src/Database/README.md index 2a11e63..de83ed1 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -15,5 +15,5 @@ composer require stellarwp/foundation-database ## Documentation -See the [Foundation Database documentation](https://foundation.stellarwp.com/components/database/) +See the [Foundation Database documentation](https://foundation.nexcess.dev/components/database/) for configuration, migrations, query building, database locks, and testing. diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs index 2025cd0..a434e17 100644 --- a/src/Docs/astro.config.mjs +++ b/src/Docs/astro.config.mjs @@ -3,7 +3,7 @@ import { defineConfig } from 'astro/config'; import starlightThemeNova from 'starlight-theme-nova'; export default defineConfig({ - site: 'https://foundation.stellarwp.com', + site: 'https://foundation.nexcess.dev', integrations: [ starlight({ title: 'Foundation', diff --git a/src/Identifier/README.md b/src/Identifier/README.md index 09825a7..646f699 100644 --- a/src/Identifier/README.md +++ b/src/Identifier/README.md @@ -14,5 +14,5 @@ composer require stellarwp/foundation-identifier ## Documentation -See the [Foundation Identifier documentation](https://foundation.stellarwp.com/components/identifier/) +See the [Foundation Identifier documentation](https://foundation.nexcess.dev/components/identifier/) for provider configuration, generation, validation, ordering, and testing. diff --git a/src/Lock/README.md b/src/Lock/README.md index c232d61..4ea09db 100644 --- a/src/Lock/README.md +++ b/src/Lock/README.md @@ -15,6 +15,6 @@ composer require stellarwp/foundation-lock ## Documentation -See the [Foundation Lock documentation](https://foundation.stellarwp.com/components/lock/) +See the [Foundation Lock documentation](https://foundation.nexcess.dev/components/lock/) for backend selection, configuration, lease handling, failure behavior, usage examples, and testing. diff --git a/src/LockRedis/README.md b/src/LockRedis/README.md index 9dbb327..53f3569 100644 --- a/src/LockRedis/README.md +++ b/src/LockRedis/README.md @@ -23,6 +23,6 @@ Alternatively, install and enable the PhpRedis extension. ## Documentation -See the [Foundation Lock guide](https://foundation.stellarwp.com/components/lock/) +See the [Foundation Lock guide](https://foundation.nexcess.dev/components/lock/) for backend selection, Redis configuration, container registration, lease handling, failure behavior, and usage examples. diff --git a/src/Log/README.md b/src/Log/README.md index 02c3fec..a03fdd1 100644 --- a/src/Log/README.md +++ b/src/Log/README.md @@ -15,5 +15,5 @@ composer require stellarwp/foundation-log ## Documentation -See the [Foundation Log documentation](https://foundation.stellarwp.com/components/log/) +See the [Foundation Log documentation](https://foundation.nexcess.dev/components/log/) for channel configuration, structured logging, failure behavior, and testing. diff --git a/src/Pipeline/README.md b/src/Pipeline/README.md index da6a809..1414161 100644 --- a/src/Pipeline/README.md +++ b/src/Pipeline/README.md @@ -15,5 +15,5 @@ composer require stellarwp/foundation-pipeline ## Documentation -See the [Foundation Pipeline documentation](https://foundation.stellarwp.com/components/pipeline/) +See the [Foundation Pipeline documentation](https://foundation.nexcess.dev/components/pipeline/) for pipeline construction, transformations, short circuits, parameters, error handling, and testing. diff --git a/src/Shutdown/README.md b/src/Shutdown/README.md index 7d9ec7c..1ba401c 100644 --- a/src/Shutdown/README.md +++ b/src/Shutdown/README.md @@ -21,6 +21,6 @@ composer require stellarwp/foundation-shutdown ## Documentation -See the [Foundation Shutdown documentation](https://foundation.stellarwp.com/components/shutdown/) +See the [Foundation Shutdown documentation](https://foundation.nexcess.dev/components/shutdown/) for provider registration, task contributions, priority ordering, response finishing, failure behavior, and testing. diff --git a/src/View/README.md b/src/View/README.md index cdf7595..85322a5 100644 --- a/src/View/README.md +++ b/src/View/README.md @@ -14,5 +14,5 @@ composer require stellarwp/foundation-view ## Documentation -See the [Foundation View documentation](https://foundation.stellarwp.com/components/view/) +See the [Foundation View documentation](https://foundation.nexcess.dev/components/view/) for configuration, rendering, runtime directory selection, path safety, and testing. diff --git a/src/WPCli/README.md b/src/WPCli/README.md index 7f9cc06..862586f 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -17,6 +17,6 @@ not normally need to install `wp-cli/wp-cli` separately. ## Documentation -See the [Foundation WP-CLI documentation](https://foundation.stellarwp.com/components/wp-cli/) +See the [Foundation WP-CLI documentation](https://foundation.nexcess.dev/components/wp-cli/) for command generation, provider registration, prefixes, arguments, failure behavior, and testing. From ef041280fb0513da5306f1f2a53f697caa3acef6 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 15:11:03 -0600 Subject: [PATCH 73/81] Update to use CF org secrets --- .github/workflows/docs-preview.yml | 4 ++-- src/Docs/.github/workflows/deploy-docs.yml | 4 ++-- src/Docs/README.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 0795106..d45e583 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -44,8 +44,8 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' uses: cloudflare/wrangler-action@v4 with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.CLOUDFLARE_DEPLOY_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_DEPLOY_ACCOUNT_ID }} gitHubToken: ${{ secrets.GITHUB_TOKEN }} packageManager: npm workingDirectory: src/Docs diff --git a/src/Docs/.github/workflows/deploy-docs.yml b/src/Docs/.github/workflows/deploy-docs.yml index 1fa72ef..899c675 100644 --- a/src/Docs/.github/workflows/deploy-docs.yml +++ b/src/Docs/.github/workflows/deploy-docs.yml @@ -46,8 +46,8 @@ jobs: - name: Deploy production uses: cloudflare/wrangler-action@v4 with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.CLOUDFLARE_DEPLOY_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_DEPLOY_ACCOUNT_ID }} gitHubToken: ${{ secrets.GITHUB_TOKEN }} packageManager: npm command: pages deploy dist --project-name=foundation-docs --branch=production diff --git a/src/Docs/README.md b/src/Docs/README.md index ec90f96..00c2c88 100644 --- a/src/Docs/README.md +++ b/src/Docs/README.md @@ -42,5 +42,5 @@ the read-only `stellarwp/foundation-docs` split repository. Both workflows use a Direct Upload Cloudflare Pages project named `foundation-docs` whose production branch is `production`. The Cloudflare API token requires `Account > Cloudflare Pages > Edit`. The -`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` organization Actions secrets +`CLOUDFLARE_DEPLOY_ACCOUNT_ID` and `CLOUDFLARE_DEPLOY_TOKEN` organization Actions secrets must be available to both repositories. From 0092ec929f083e9fa18416603668952d65d454d2 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 16:15:30 -0600 Subject: [PATCH 74/81] Prevent multiple registrations of `cli_init` --- src/WPCli/WPCliProvider.php | 8 ++++++ .../Fixtures/WPCli/RecordingCommand.php | 2 ++ tests/integration/WPCli/WPCliProviderTest.php | 28 ++++++++++++++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php index 85d47e2..db4b4c9 100644 --- a/src/WPCli/WPCliProvider.php +++ b/src/WPCli/WPCliProvider.php @@ -21,10 +21,16 @@ final class WPCliProvider extends Provider public const string COMMANDS = self::class . '.commands'; + private bool $registered = false; + /** * @throws InvalidArgumentException When the configured Foundation prefix is invalid. */ public function register(): void { + if ($this->registered) { + return; + } + $foundationPrefix = $this->foundationPrefix(); $commandPrefix = $this->config->get('wpcli.command_prefix') ?? $foundationPrefix; @@ -38,6 +44,8 @@ public function register(): void { add_action('cli_init', function (): void { $this->registerCommands(); }, 0, 0); + + $this->registered = true; } /** diff --git a/tests/Support/Fixtures/WPCli/RecordingCommand.php b/tests/Support/Fixtures/WPCli/RecordingCommand.php index 55d30a3..7708a6b 100644 --- a/tests/Support/Fixtures/WPCli/RecordingCommand.php +++ b/tests/Support/Fixtures/WPCli/RecordingCommand.php @@ -8,6 +8,7 @@ final class RecordingCommand extends Command { public static bool $registered = false; public static ?string $registeredName = null; + public static int $registrationCount = 0; public function runCommand(array $args = [], array $assocArgs = []): int { return self::SUCCESS; @@ -16,6 +17,7 @@ public function runCommand(array $args = [], array $assocArgs = []): int { public function register(): void { self::$registered = true; self::$registeredName = $this->command(); + self::$registrationCount++; } protected function subcommand(): string { diff --git a/tests/integration/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php index 8a5b6a2..559b64a 100644 --- a/tests/integration/WPCli/WPCliProviderTest.php +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -74,8 +74,9 @@ public function test_it_registers_configured_commands_on_cli_init(): void { ], ])); - RecordingCommand::$registered = false; - RecordingCommand::$registeredName = null; + RecordingCommand::$registered = false; + RecordingCommand::$registeredName = null; + RecordingCommand::$registrationCount = 0; $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ $c->get(RecordingCommand::class), ]); @@ -86,11 +87,29 @@ public function test_it_registers_configured_commands_on_cli_init(): void { $this->assertTrue(RecordingCommand::$registered); $this->assertSame('your-plugin-tools recording', RecordingCommand::$registeredName); + $this->assertSame(1, RecordingCommand::$registrationCount); + } + + public function test_it_registers_commands_only_once_when_the_provider_is_registered_repeatedly(): void { + RecordingCommand::$registered = false; + RecordingCommand::$registeredName = null; + RecordingCommand::$registrationCount = 0; + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(RecordingCommand::class), + ]); + + $this->container->register(WPCliProvider::class); + $this->container->register(WPCliProvider::class); + + do_action('cli_init'); + + $this->assertSame(1, RecordingCommand::$registrationCount); } public function test_it_rejects_invalid_commands_before_registering_any_command(): void { - RecordingCommand::$registered = false; - RecordingCommand::$registeredName = null; + RecordingCommand::$registered = false; + RecordingCommand::$registeredName = null; + RecordingCommand::$registrationCount = 0; $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ $c->get(RecordingCommand::class), new stdClass(), @@ -105,6 +124,7 @@ public function test_it_rejects_invalid_commands_before_registering_any_command( } finally { $this->assertFalse(RecordingCommand::$registered); $this->assertNull(RecordingCommand::$registeredName); + $this->assertSame(0, RecordingCommand::$registrationCount); } } From 79a4537a409d387fd11fd4867d4b0d39fc1b4e2f Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 16:15:37 -0600 Subject: [PATCH 75/81] Fix migrations docs --- .../docs/components/database/migrations.mdx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index b116af2..b70001a 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -136,7 +136,51 @@ final readonly class Create_Reports_Table implements Migration { Migration IDs are permanent, byte-exact identifiers. The generator prefixes them with a sortable timestamp so migration history is easy to inspect, but execution follows provider contribution order rather than sorting by ID. Register providers and migrations in dependency order, and do not change an ID after the migration has been deployed. -For later schema changes, update the table's desired definition and create a new migration that applies it. Use `Schema::execute()` for data changes or schema operations that `dbDelta()` cannot express reliably. +For later schema changes, update the table's desired definition and create a new migration that applies it. Use `Schema::execute()` only for trusted schema SQL that `dbDelta()` cannot express reliably. + +For parameterized data changes, inject the `Database` contract into the migration and pass values through WordPress placeholders. For example, `src/Database/Migrations/Backfill_Report_Status.php` can update existing rows without interpolating values into SQL: + +```php title="Backfill_Report_Status.php" +database->execute( + 'UPDATE %i SET status = %s WHERE status = %s', + $this->table->name(), + 'active', + 'legacy' + ); + } + + public function down( Schema $schema ): void { + throw IrreversibleMigration::forMigration( self::ID ); + } +} +``` + +Do not interpolate request, configuration, or stored data directly into SQL. :::caution[Make rollback behavior deliberate] The generic migration stub throws `IrreversibleMigration` from `down()`. Implement a safe inverse before relying on rollback, or keep the migration explicitly irreversible. Foundation does not pretend a destructive data change can be undone. From ea6562f3689bdcf947e37782f09f83e0049cc613 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 16:23:54 -0600 Subject: [PATCH 76/81] Organize CliProvider, don't default Clock instance for InMemoryLock.php --- src/Cli/CliProvider.php | 83 ++++++++++++------- src/Docs/src/content/docs/components/lock.mdx | 3 +- src/Lock/InMemoryLock.php | 2 +- tests/Unit/Database/Cli/MigrateTest.php | 5 +- .../Unit/Database/Migration/MigratorTest.php | 12 +-- 5 files changed, 68 insertions(+), 37 deletions(-) diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index 61533ca..1ebc7e7 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -38,28 +38,61 @@ final class CliProvider extends Provider public const string ROOT_PATH = self::class . '.root_path'; public function register(): void { + $this->registerRootPath(); + $this->registerProcess(); + $this->registerGeneration(); + $this->registerPackageCommand(); + $this->registerDatabaseCommands(); + $this->registerWpCliCommand(); + $this->registerApplication(); + } + + private function registerRootPath(): void { $this->container->singleton(self::ROOT_PATH, getcwd() ?: dirname(__DIR__, 2)); + } - $this->container->when(PackageResolver::class) - ->needs('$rootPath') - ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + private function registerProcess(): void { + $this->container->singleton(ShellProcessRunner::class); + $this->container->bind(ProcessRunner::class, ShellProcessRunner::class); + } - $this->container->when(PackageScaffolder::class) + private function registerGeneration(): void { + $this->container->when(ComposerAutoloadResolver::class) ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); - $this->container->when(ComposerAutoloadResolver::class) + $this->container->when(StubResolver::class) ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); - $this->container->when(StubResolver::class) + $this->container->singleton(WordPressClassNameResolver::class); + $this->container->singleton(ComposerAutoloadResolver::class); + $this->container->singleton(GeneratedFileWriter::class); + $this->container->singleton(Lexer::class); + $this->container->singleton(ParserFactory::class); + $this->container->singleton(PhpSourceEditor::class); + $this->container->singleton(StubRenderer::class); + $this->container->singleton(StubResolver::class); + } + + private function registerPackageCommand(): void { + $this->container->when(PackageResolver::class) ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); - $this->container->when(WPCliCommand::class) + $this->container->when(PackageScaffolder::class) ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->singleton(PackageResolver::class); + $this->container->singleton(PackageScaffolder::class); + $this->container->singleton(PackageFilesValidator::class); + $this->container->singleton(PackageRepositoryPlanFactory::class); + $this->container->bind(PackageRepositoryCreator::class, GitHubPackageRepositoryCreator::class); + $this->container->singleton(CreateCommand::class); + } + + private function registerDatabaseCommands(): void { $this->container->when(MigrationCommand::class) ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); @@ -72,6 +105,21 @@ public function register(): void { ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->singleton(MigrationCommand::class); + $this->container->singleton(ProviderCommand::class); + $this->container->singleton(ProviderRegistrationEditor::class); + $this->container->singleton(TableCommand::class); + } + + private function registerWpCliCommand(): void { + $this->container->when(WPCliCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->singleton(WPCliCommand::class); + } + + private function registerApplication(): void { $this->container->when(Application::class) ->needs('$commands') ->give(static fn (Container $c): array => [ @@ -82,27 +130,6 @@ public function register(): void { $c->get(WPCliCommand::class), ]); - $this->container->singleton(PackageResolver::class); - $this->container->singleton(PackageScaffolder::class); - $this->container->singleton(PackageFilesValidator::class); - $this->container->singleton(PackageRepositoryPlanFactory::class); - $this->container->singleton(ShellProcessRunner::class); - $this->container->bind(ProcessRunner::class, ShellProcessRunner::class); - $this->container->bind(PackageRepositoryCreator::class, GitHubPackageRepositoryCreator::class); - $this->container->singleton(CreateCommand::class); - $this->container->singleton(WordPressClassNameResolver::class); - $this->container->singleton(ComposerAutoloadResolver::class); - $this->container->singleton(GeneratedFileWriter::class); - $this->container->singleton(Lexer::class); - $this->container->singleton(ParserFactory::class); - $this->container->singleton(PhpSourceEditor::class); - $this->container->singleton(StubRenderer::class); - $this->container->singleton(StubResolver::class); - $this->container->singleton(MigrationCommand::class); - $this->container->singleton(ProviderCommand::class); - $this->container->singleton(ProviderRegistrationEditor::class); - $this->container->singleton(TableCommand::class); - $this->container->singleton(WPCliCommand::class); $this->container->singleton(Application::class); } } diff --git a/src/Docs/src/content/docs/components/lock.mdx b/src/Docs/src/content/docs/components/lock.mdx index cb80317..fdc20b8 100644 --- a/src/Docs/src/content/docs/components/lock.mdx +++ b/src/Docs/src/content/docs/components/lock.mdx @@ -335,8 +335,9 @@ Inject `InMemoryLock` when a test needs real ownership and expiration behavior w ```php use StellarWP\Foundation\Lock\InMemoryLock; +use StellarWP\Foundation\Lock\SystemClock; -$service = new Catalog_Synchronizer( new InMemoryLock() ); +$service = new Catalog_Synchronizer( new InMemoryLock( new SystemClock() ) ); ``` Because application code depends on the shared `Lock` contract, the production backend can change without changing the service under test. diff --git a/src/Lock/InMemoryLock.php b/src/Lock/InMemoryLock.php index bc56f3e..fcfb6fe 100644 --- a/src/Lock/InMemoryLock.php +++ b/src/Lock/InMemoryLock.php @@ -28,7 +28,7 @@ final class InMemoryLock implements Lock private array $locks = []; public function __construct( - private readonly Clock $clock = new SystemClock() + private readonly Clock $clock ) { } diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php index 55b65ce..70778c4 100644 --- a/tests/Unit/Database/Cli/MigrateTest.php +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -11,6 +11,7 @@ use StellarWP\Foundation\Database\Table\Tables\LockTable; use StellarWP\Foundation\Database\Table\Tables\MigrationTable; use StellarWP\Foundation\Lock\InMemoryLock; +use StellarWP\Foundation\Lock\SystemClock; use StellarWP\Foundation\Tests\Support\Fixtures\Database\InMemoryRepository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; @@ -32,7 +33,7 @@ public function test_it_registers_the_migration_command_with_wp_cli(): void { $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); $repository = new InMemoryRepository(); $schema = new RecordingSchema(); - $lock = new InMemoryLock(); + $lock = new InMemoryLock(new SystemClock()); $store = new Store($schema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); $command = new Migrate( $this->container, @@ -206,7 +207,7 @@ private function newCommand(): array { $wpSchema = new RecordingSchema(); $repository = new InMemoryRepository(); $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); - $lock = new InMemoryLock(); + $lock = new InMemoryLock(new SystemClock()); $store = new Store($wpSchema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); $command = new Migrate( $this->container, diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index 09b5c54..826a241 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -13,9 +13,11 @@ use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\InMemoryLock; use StellarWP\Foundation\Lock\LockToken; +use StellarWP\Foundation\Lock\SystemClock; use StellarWP\Foundation\Tests\Support\Fixtures\Database\InMemoryRepository; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchema; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestMigration; +use StellarWP\Foundation\Tests\Support\Fixtures\Lock\MutableClock; use StellarWP\Foundation\Tests\TestCase; final class MigratorTest extends TestCase @@ -93,7 +95,7 @@ public function test_it_initializes_and_drops_the_migration_store(): void { } public function test_it_does_not_drop_the_store_while_another_migration_owns_the_lock(): void { - $lock = new InMemoryLock(); + $lock = new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00'))); [$migrator, , $schema] = $this->newMigrator($lock); $token = $lock->acquire('nx-foundation-database-migrations', 300); @@ -109,7 +111,7 @@ public function test_it_does_not_drop_the_store_while_another_migration_owns_the } public function test_it_does_not_initialize_the_ledger_while_another_migration_owns_the_lock(): void { - $lock = new InMemoryLock(); + $lock = new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00'))); [$migrator, , $schema] = $this->newMigrator($lock, false); $token = $lock->acquire('nx-foundation-database-migrations', 300); @@ -199,9 +201,9 @@ public function test_it_rechecks_storage_after_acquiring_the_migration_lock(): v * @return array{Migrator, InMemoryRepository, RecordingSchema} */ private function newMigrator(?Lock $lock = null, bool $initialize = true): array { - $schema = new RecordingSchema(); - $repository = new InMemoryRepository(); - $lock ??= new InMemoryLock(); + $schema = new RecordingSchema(); + $repository = new InMemoryRepository(); + $lock ??= new InMemoryLock(new SystemClock()); $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); $lockTable = new LockTable('wp_nx_foundation_locks'); $store = new Store($schema, $lock, $migrationTable, $lockTable); From 4ad658d89f15e59121002d909f2f6b9f6de4dfa4 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 16:39:36 -0600 Subject: [PATCH 77/81] Add duplicate column protection --- src/Database/Table/TableDefinition.php | 43 ++++++++++++++++--- .../Database/SchemaReconciliationTable.php | 20 +++------ .../Database/Table/TableDefinitionTest.php | 17 ++++++++ 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/Database/Table/TableDefinition.php b/src/Database/Table/TableDefinition.php index a3cecbf..96e1bde 100644 --- a/src/Database/Table/TableDefinition.php +++ b/src/Database/Table/TableDefinition.php @@ -31,6 +31,9 @@ public static function for(Table $table): self { return new self($table); } + /** + * @throws InvalidArgumentException When the generated column name is already defined. + */ public function bigIncrements(string $name): self { return $this ->column(new Column($name, 'bigint', 20)) @@ -39,28 +42,43 @@ public function bigIncrements(string $name): self { ->primary($name); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function string(string $name, int $length = 191, ?string $default = null): self { return $this->column(new Column($name, 'varchar', $length, default: $default)); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function unsignedInteger(string $name, int $length = 10, ?int $default = null): self { return $this->column(new Column($name, 'int', $length, unsigned: true, default: $default)); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function integer(string $name, int $length = 10): self { return $this->column(new Column($name, 'int', $length)); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function tinyInteger(string $name, int $length = 3): self { return $this->column(new Column($name, 'tinyint', $length)); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function bigInteger(string $name, int $length = 20): self { return $this->column(new Column($name, 'bigint', $length)); } /** - * @throws InvalidArgumentException When precision is outside the database-supported range. + * @throws InvalidArgumentException When the column name is already defined or precision is outside the database-supported range. */ public function dateTime(string $name, ?int $precision = null): self { if ($precision !== null && ($precision < 0 || $precision > 6)) { @@ -70,17 +88,32 @@ public function dateTime(string $name, ?int $precision = null): self { return $this->column(new Column($name, 'datetime', $precision === 0 ? null : $precision)); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function text(string $name): self { return $this->column(new Column($name, 'text')); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function longText(string $name): self { return $this->column(new Column($name, 'longtext')); } + /** + * @throws InvalidArgumentException When the column name is already defined. + */ public function column(Column $column): self { - $this->columns[$column->name] = $column; - $this->currentColumn = $column->name; + $key = strtolower($column->name); + + if (isset($this->columns[$key])) { + throw new InvalidArgumentException(sprintf('Column %s is already defined.', $column->name)); + } + + $this->columns[$key] = $column; + $this->currentColumn = $key; return $this; } @@ -110,7 +143,7 @@ public function extra(string $extra): self { } private function replaceCurrentColumn(Column $column): self { - $this->columns[$column->name] = $column; + $this->columns[strtolower($column->name)] = $column; return $this; } @@ -179,7 +212,7 @@ public function validationErrors(): array { foreach ($this->indexes as $index) { foreach ($index->columns as $column) { - if (! isset($this->columns[$column])) { + if (! isset($this->columns[strtolower($column)])) { $errors[] = sprintf('Index %s references missing column %s.', $index->name, $column); } } diff --git a/tests/Support/Fixtures/Database/SchemaReconciliationTable.php b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php index a347b6b..3133965 100644 --- a/tests/Support/Fixtures/Database/SchemaReconciliationTable.php +++ b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php @@ -24,18 +24,12 @@ public function name(): string { } public function definition(): TableDefinition { - $definition = TableDefinition::for($this) - ->bigIncrements('id') - ->integer('attempts')->default($this->attemptsDefault) - ->dateTime('completed_at') - ->string('label')->default('') - ->column(new Column('ratio', 'decimal(10,2)', default: 1.25)) - ->column(new Column('enabled', 'bit', 1, default: true)); - - if ($this->completedAtNullable) { - $definition->column(new Column('completed_at', 'datetime', nullable: true)); - } - - return $definition; + return TableDefinition::for($this) + ->bigIncrements('id') + ->integer('attempts')->default($this->attemptsDefault) + ->dateTime('completed_at')->nullable($this->completedAtNullable) + ->string('label')->default('') + ->column(new Column('ratio', 'decimal(10,2)', default: 1.25)) + ->column(new Column('enabled', 'bit', 1, default: true)); } } diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php index bb6585e..11773e0 100644 --- a/tests/Unit/Database/Table/TableDefinitionTest.php +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -144,6 +144,23 @@ public function test_it_rejects_column_modifiers_after_index_definitions(): void ->default('draft'); } + public function test_it_rejects_duplicate_column_names_case_insensitively(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Column status is already defined.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('Status')->nullable() + ->text('status'); + } + + public function test_it_matches_index_column_references_case_insensitively(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('Status') + ->index('status_lookup', 'status'); + + $this->assertSame([], $definition->validationErrors()); + } + public function test_it_reports_duplicate_primary_keys(): void { $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) ->bigIncrements('id') From 2a5b3f9714d26aedf934010866c8f2ad0a8fdc72 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 16:42:24 -0600 Subject: [PATCH 78/81] Move `??=` to the top since pinte can't align this properly --- tests/Unit/Database/Migration/MigratorTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php index 826a241..1df662b 100644 --- a/tests/Unit/Database/Migration/MigratorTest.php +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -201,9 +201,10 @@ public function test_it_rechecks_storage_after_acquiring_the_migration_lock(): v * @return array{Migrator, InMemoryRepository, RecordingSchema} */ private function newMigrator(?Lock $lock = null, bool $initialize = true): array { + $lock ??= new InMemoryLock(new SystemClock()); + $schema = new RecordingSchema(); $repository = new InMemoryRepository(); - $lock ??= new InMemoryLock(new SystemClock()); $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); $lockTable = new LockTable('wp_nx_foundation_locks'); $store = new Store($schema, $lock, $migrationTable, $lockTable); From f3fb30d039431fff36fbf751323b70a10c0fd2a8 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 16:48:52 -0600 Subject: [PATCH 79/81] Throw and document reserved view variables. --- src/Docs/src/content/docs/components/view.mdx | 4 +++ src/View/PhpView.php | 13 ++++++++- .../Database/SchemaReconciliationTable.php | 12 ++++----- tests/Unit/View/PhpViewTest.php | 27 ++++++++++++++----- .../_data/View/default/internal-variable.php | 3 --- 5 files changed, 42 insertions(+), 17 deletions(-) delete mode 100644 tests/_data/View/default/internal-variable.php diff --git a/src/Docs/src/content/docs/components/view.mdx b/src/Docs/src/content/docs/components/view.mdx index 941b66c..794d047 100644 --- a/src/Docs/src/content/docs/components/view.mdx +++ b/src/Docs/src/content/docs/components/view.mdx @@ -100,6 +100,10 @@ View names are relative to the configured directory and omit the `.php` extensio Add a typed `@var` annotation, including a short description, for every value the view expects. These annotations define the template's input contract, allow PHPStan to analyze the file without undefined-variable errors, and provide type-aware completion in supported IDEs. ::: +:::caution[Reserved view variable names] +`PhpView` uses `foundationViewPath` and `foundationViewData` internally. Passing either name as a view data key throws `InvalidArgumentException`; choose application-specific variable names instead. +::: + :::caution[Escape output in the view] Foundation passes data into trusted PHP files but does not escape it automatically. Escape each value for its HTML, attribute, URL, or JavaScript context when the view outputs it. Do not render user-uploaded PHP templates. ::: diff --git a/src/View/PhpView.php b/src/View/PhpView.php index d544bcc..fb438af 100644 --- a/src/View/PhpView.php +++ b/src/View/PhpView.php @@ -12,6 +12,11 @@ */ final readonly class PhpView implements DirectoryAwareView { + private const array RESERVED_DATA_KEYS = [ + 'foundationViewPath', + 'foundationViewData', + ]; + private string $directory; /** @@ -37,12 +42,18 @@ public function withDirectory(string $directory): static { /** * {@inheritDoc} * - * @throws InvalidArgumentException When the view name is empty, absolute, or traverses parent directories. + * @throws InvalidArgumentException When the view name is invalid or the data contains a reserved key. * @throws RuntimeException When the view leaves output buffering in an invalid state. * @throws ViewNotFoundException When the view does not exist, is unreadable, or resolves outside the configured directory. * @throws \Throwable When the view itself throws. */ public function render(string $name, array $data = []): string { + foreach (self::RESERVED_DATA_KEYS as $reservedKey) { + if (array_key_exists($reservedKey, $data)) { + throw new InvalidArgumentException(sprintf('View data key "%s" is reserved by PhpView.', $reservedKey)); + } + } + $path = $this->resolve($name); $bufferLevel = ob_get_level(); $renderBufferLevel = $bufferLevel + 1; diff --git a/tests/Support/Fixtures/Database/SchemaReconciliationTable.php b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php index 3133965..9474496 100644 --- a/tests/Support/Fixtures/Database/SchemaReconciliationTable.php +++ b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php @@ -25,11 +25,11 @@ public function name(): string { public function definition(): TableDefinition { return TableDefinition::for($this) - ->bigIncrements('id') - ->integer('attempts')->default($this->attemptsDefault) - ->dateTime('completed_at')->nullable($this->completedAtNullable) - ->string('label')->default('') - ->column(new Column('ratio', 'decimal(10,2)', default: 1.25)) - ->column(new Column('enabled', 'bit', 1, default: true)); + ->bigIncrements('id') + ->integer('attempts')->default($this->attemptsDefault) + ->dateTime('completed_at')->nullable($this->completedAtNullable) + ->string('label')->default('') + ->column(new Column('ratio', 'decimal(10,2)', default: 1.25)) + ->column(new Column('enabled', 'bit', 1, default: true)); } } diff --git a/tests/Unit/View/PhpViewTest.php b/tests/Unit/View/PhpViewTest.php index df33fa1..bf5aefa 100644 --- a/tests/Unit/View/PhpViewTest.php +++ b/tests/Unit/View/PhpViewTest.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\Tests\Unit\View; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use StellarWP\Foundation\Tests\TestCase; use StellarWP\Foundation\View\Exceptions\ViewNotFoundException; @@ -52,15 +53,27 @@ public function test_it_returns_a_new_renderer_for_a_runtime_directory_without_m ); } - public function test_view_data_cannot_replace_the_resolved_view_path(): void { + /** + * @dataProvider reserved_view_data_keys + */ + #[DataProvider('reserved_view_data_keys')] + public function test_it_rejects_reserved_view_data_keys(string $key): void { $view = new PhpView($this->data_dir('View/default')); - $this->assertSame( - 'internal-variable.php', - $view->render('internal-variable', [ - 'foundationViewPath' => $this->data_dir('View/outside.php'), - ]) - ); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('View data key "%s" is reserved by PhpView.', $key)); + + $view->render('greeting', [$key => null]); + } + + /** + * @return array + */ + public static function reserved_view_data_keys(): array { + return [ + 'view path' => ['key' => 'foundationViewPath'], + 'view data' => ['key' => 'foundationViewData'], + ]; } public function test_it_restores_the_output_buffer_when_a_view_throws(): void { diff --git a/tests/_data/View/default/internal-variable.php b/tests/_data/View/default/internal-variable.php deleted file mode 100644 index 43e0588..0000000 --- a/tests/_data/View/default/internal-variable.php +++ /dev/null @@ -1,3 +0,0 @@ - Date: Mon, 24 Aug 2026 17:12:24 -0600 Subject: [PATCH 80/81] Add migration lock renewal --- .../Exceptions/MigrationLockFailed.php | 9 +- src/Database/Migration/Migrator.php | 45 ++- src/Database/Migration/Store.php | 25 +- .../src/content/docs/components/database.mdx | 2 + .../docs/components/database/migrations.mdx | 6 + .../Migration/MigratorExecutionTest.php | 260 +++++++++++++++++- 6 files changed, 325 insertions(+), 22 deletions(-) diff --git a/src/Database/Exceptions/MigrationLockFailed.php b/src/Database/Exceptions/MigrationLockFailed.php index a368140..9a94b4a 100644 --- a/src/Database/Exceptions/MigrationLockFailed.php +++ b/src/Database/Exceptions/MigrationLockFailed.php @@ -3,7 +3,7 @@ namespace StellarWP\Foundation\Database\Exceptions; /** - * Raised when a migration lock cannot be acquired or its ownership cannot be confirmed. + * Raised when a migration lock cannot be acquired, renewed, or released safely. */ final class MigrationLockFailed extends DatabaseException { @@ -14,6 +14,13 @@ public static function forLock(string $lock): self { return new self(sprintf('Could not acquire migration lock "%s".', $lock)); } + /** + * Create an exception when ownership is lost before the lock can be renewed. + */ + public static function forLostOwnership(string $lock): self { + return new self(sprintf('Could not refresh migration lock "%s" because ownership was lost.', $lock)); + } + /** * Create an exception when ownership cannot be confirmed during release. */ diff --git a/src/Database/Migration/Migrator.php b/src/Database/Migration/Migrator.php index e05ebbc..f9e8b36 100644 --- a/src/Database/Migration/Migrator.php +++ b/src/Database/Migration/Migrator.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Database\Migration; +use Closure; use StellarWP\Foundation\Database\Contracts\Migration; use StellarWP\Foundation\Database\Contracts\Repository; use StellarWP\Foundation\Database\Contracts\Schema; @@ -67,7 +68,7 @@ public function isInitialized(): bool { * * @throws DatabaseException When migration storage or schema access fails. * @throws MigrationFailed When a migration fails while running. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * @throws UninitializedStore When migration storage has not been initialized. */ @@ -75,7 +76,7 @@ public function run(): Result { $configured = $this->migrations->all(); return $this->store->withMigrationLock( - fn (Schema $schema): Result => $this->runPending($configured, $schema) + fn (Schema $schema, Closure $renewLock): Result => $this->runPending($configured, $schema, $renewLock) ); } @@ -88,7 +89,7 @@ public function run(): Result { * @throws InvalidRollbackBatch When the requested batch does not match the latest recorded batch. * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. * @throws MigrationFailed When a migration fails while rolling back. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * @throws UnavailableMigration When a recorded migration implementation is unavailable. * @throws UninitializedStore When migration storage has not been initialized. @@ -96,7 +97,7 @@ public function run(): Result { public function rollback(?int $batch = null): Result { $configured = $this->migrations->all(); - return $this->store->withMigrationLock(function (Schema $schema) use ($configured, $batch): Result { + return $this->store->withMigrationLock(function (Schema $schema, Closure $renewLock) use ($configured, $batch): Result { $latestBatch = $this->repository->latestBatch(); if ($batch !== null && $batch !== $latestBatch) { @@ -112,7 +113,8 @@ public function rollback(?int $batch = null): Result { return $this->rollbackRecords( $configured, $this->repository->recordsForBatch($batch), - $schema + $schema, + $renewLock ); }); } @@ -122,7 +124,7 @@ public function rollback(?int $batch = null): Result { * * @throws DatabaseException When migration storage or schema access fails. * @throws MigrationFailed When a migration fails while running or rolling back. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * @throws UnavailableMigration When a recorded migration implementation is unavailable. * @throws UninitializedStore When migration storage has not been initialized. @@ -130,9 +132,9 @@ public function rollback(?int $batch = null): Result { public function refresh(): Result { $configured = $this->migrations->all(); - return $this->store->withMigrationLock(function (Schema $schema) use ($configured): Result { - $rollback = $this->rollbackRecords($configured, array_values($this->repository->all()), $schema); - $run = $this->runPending($configured, $schema); + return $this->store->withMigrationLock(function (Schema $schema, Closure $renewLock) use ($configured): Result { + $rollback = $this->rollbackRecords($configured, array_values($this->repository->all()), $schema, $renewLock); + $run = $this->runPending($configured, $schema, $renewLock); return new Result( ran: $run->ran, @@ -183,11 +185,15 @@ public function status(): array { * @param array $migrations * @param list $records * @param Schema $schema The initialized schema supplied by the migration store. + * @param Closure(): void $renewLock The callback that renews migration lock ownership. * - * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. - * @throws UnavailableMigration When a recorded migration implementation is unavailable. + * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. + * @throws MigrationFailed When a migration fails while rolling back. + * @throws MigrationLockFailed When the migration lock cannot be renewed. + * @throws LockUnavailableException When the lock backend cannot determine the refresh result. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. */ - private function rollbackRecords(array $migrations, array $records, Schema $schema): Result { + private function rollbackRecords(array $migrations, array $records, Schema $schema, Closure $renewLock): Result { usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); $unavailable = array_values(array_map( static fn (Record $record): string => $record->migration, @@ -202,6 +208,7 @@ private function rollbackRecords(array $migrations, array $records, Schema $sche foreach ($records as $record) { $migration = $migrations[$record->migration]; + $renewLock(); try { $migration->down($schema); @@ -209,6 +216,8 @@ private function rollbackRecords(array $migrations, array $records, Schema $sche throw MigrationFailed::whileRollingBack($migration->id(), $throwable); } + $renewLock(); + if (! $this->repository->deleteRun($migration->id())) { throw LedgerFailure::notDeletedAfterRollback($migration->id()); } @@ -224,8 +233,14 @@ private function rollbackRecords(array $migrations, array $records, Schema $sche * * @param array $migrations * @param Schema $schema The initialized schema supplied by the migration store. + * @param Closure(): void $renewLock The callback that renews migration lock ownership. + * + * @throws DatabaseException When migration ledger access fails. + * @throws MigrationFailed When a migration fails while running. + * @throws MigrationLockFailed When the migration lock cannot be renewed. + * @throws LockUnavailableException When the lock backend cannot determine the refresh result. */ - private function runPending(array $migrations, Schema $schema): Result { + private function runPending(array $migrations, Schema $schema, Closure $renewLock): Result { $ran = []; $skipped = []; $batch = $this->repository->nextBatch(); @@ -236,12 +251,16 @@ private function runPending(array $migrations, Schema $schema): Result { continue; } + $renewLock(); + try { $migration->up($schema); } catch (Throwable $throwable) { throw MigrationFailed::whileRunning($migration->id(), $throwable); } + $renewLock(); + $this->repository->recordRun($migration->id(), $batch); $ran[] = $migration->id(); } diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php index 7a3905c..8bb1e05 100644 --- a/src/Database/Migration/Store.php +++ b/src/Database/Migration/Store.php @@ -2,6 +2,7 @@ namespace StellarWP\Foundation\Database\Migration; +use Closure; use InvalidArgumentException; use StellarWP\Foundation\Database\Contracts\Schema; use StellarWP\Foundation\Database\Exceptions\DatabaseException; @@ -94,10 +95,10 @@ public function hasLedger(): bool { * * @template T * - * @param callable(Schema): T $operation The operation that receives the schema while the migration lock is held. + * @param callable(Schema, Closure(): void): T $operation The operation receives the schema and a lock-renewal callback. * * @throws DatabaseException When migration storage cannot be inspected. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * @throws UninitializedStore When migration storage has not been initialized. * @@ -109,11 +110,11 @@ public function withMigrationLock(callable $operation): mixed { throw new UninitializedStore(); } - return $this->withLock(function () use ($operation): mixed { + return $this->withLock(function (Closure $renewLock) use ($operation): mixed { // The ledger may have changed before this process acquired the lock. $this->assertInitialized(); - return $operation($this->schema); + return $operation($this->schema, $renewLock); }); } @@ -137,10 +138,10 @@ private function assertInitialized(): void { * * @template T * - * @param callable(): T $operation + * @param callable(Closure(): void): T $operation * * @throws DatabaseException When migration storage access fails. - * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws MigrationLockFailed When the lock cannot be acquired, renewed, or released. * @throws LockUnavailableException When the lock backend cannot determine the lock state. * * @return T @@ -152,8 +153,18 @@ private function withLock(callable $operation): mixed { throw MigrationLockFailed::forLock($this->lockName); } + $renewLock = function () use (&$token): void { + $refreshed = $this->lock->refresh($token, $this->lockTtl); + + if ($refreshed === null) { + throw MigrationLockFailed::forLostOwnership($this->lockName); + } + + $token = $refreshed; + }; + try { - $result = $operation(); + $result = $operation($renewLock); } catch (Throwable $failure) { try { $this->lock->release($token); diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx index 9a0d503..d3b8ee6 100644 --- a/src/Docs/src/content/docs/components/database.mdx +++ b/src/Docs/src/content/docs/components/database.mdx @@ -101,6 +101,8 @@ Leave table names empty to use the scoped defaults. An overridden table name is The migration lock settings coordinate migration execution only. Applications selecting `DatabaseLock` for their own work choose each lock name and TTL when calling `acquire()`. +Set `database.lock_ttl` longer than the longest uninterrupted operation between renewals. Foundation renews the migration lock immediately before and after every `up()` and `down()` call, but it cannot renew the lease while a blocking migration method or ledger update is still running. Split unusually long work into separate migrations or increase `FOUNDATION_DATABASE_LOCK_TTL` before deployment. + ### Register the providers In `src/App.php`, register `WPCliProvider` before `DatabaseProvider`, then register application providers that contribute migrations or select the database lock: diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index b70001a..e191b68 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -212,6 +212,12 @@ wp your-plugin migrate The runner acquires the configured migration lock, executes pending migrations in provider contribution order, and records each successful migration in one batch. +:::caution[Allow enough time for each migration] +Foundation renews its lock immediately before and after each migration. The configured `database.lock_ttl` must exceed the longest uninterrupted `up()`, `down()`, or ledger operation because blocking work cannot renew its lease while it is running. Increase the TTL or split long work into separate migrations before deploying it. + +If a migration finishes but the following renewal fails, Foundation leaves the ledger unchanged and stops. A later command may therefore call that migration again. Write `up()` and `down()` so repeated execution can safely reconcile the intended state. +::: + ### Roll back or rebuild Roll back the latest applied batch: diff --git a/tests/Unit/Database/Migration/MigratorExecutionTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php index 620fff1..a712935 100644 --- a/tests/Unit/Database/Migration/MigratorExecutionTest.php +++ b/tests/Unit/Database/Migration/MigratorExecutionTest.php @@ -37,6 +37,8 @@ final class MigratorExecutionTest extends TestCase private RecordingSchema $schema; + private MutableClock $clock; + private InMemoryLock $lock; protected function setUp(): void { @@ -44,7 +46,8 @@ protected function setUp(): void { $this->repository = new InMemoryRepository(); $this->schema = new RecordingSchema(); - $this->lock = new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00'))); + $this->clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->lock = new InMemoryLock($this->clock); (new Store( $this->schema, @@ -100,6 +103,54 @@ public function test_it_runs_pending_migrations_in_order(): void { $this->assertSame(1, $this->repository->all()['2026_01_01_000002_create_posts']->batch); } + public function test_it_renews_the_lock_around_each_migration(): void { + $first = $this->createMock(Migration::class); + $first->method('id')->willReturn('2026_01_01_000001_create_users'); + $first->method('up')->willReturnCallback(function (Schema $schema): void { + $schema->execute('up:2026_01_01_000001_create_users'); + $this->clock->advance(9); + }); + + $second = $this->createMock(Migration::class); + $second->method('id')->willReturn('2026_01_01_000002_create_posts'); + $second->method('up')->willReturnCallback(function (Schema $schema): void { + $schema->execute('up:2026_01_01_000002_create_posts'); + $this->clock->advance(9); + }); + + $result = $this->migrator( + $this->collection($first, $second), + lockTtl: 10 + )->run(); + + $this->assertSame([ + '2026_01_01_000001_create_users', + '2026_01_01_000002_create_posts', + ], $result->ran); + } + + public function test_it_does_not_record_a_migration_after_its_lock_expires(): void { + $migration = $this->createMock(Migration::class); + $migration->method('id')->willReturn('2026_01_01_000001_create_users'); + $migration->method('up')->willReturnCallback(function (Schema $schema): void { + $schema->execute('up:2026_01_01_000001_create_users'); + $this->clock->advance(10); + }); + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not refresh migration lock'); + + try { + $this->migrator( + $this->collection($migration), + lockTtl: 10 + )->run(); + } finally { + $this->assertSame(['up:2026_01_01_000001_create_users'], $this->schema->statements); + $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + public function test_it_skips_migrations_that_have_already_run(): void { $this->configured( new TestMigration('2026_01_01_000001_create_users'), @@ -144,6 +195,60 @@ public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); } + public function test_it_renews_the_lock_around_each_rollback(): void { + $first = $this->createMock(Migration::class); + $first->method('id')->willReturn('2026_01_01_000001_create_users'); + $first->method('down')->willReturnCallback(function (Schema $schema): void { + $schema->execute('down:2026_01_01_000001_create_users'); + $this->clock->advance(9); + }); + + $second = $this->createMock(Migration::class); + $second->method('id')->willReturn('2026_01_01_000002_create_posts'); + $second->method('down')->willReturnCallback(function (Schema $schema): void { + $schema->execute('down:2026_01_01_000002_create_posts'); + $this->clock->advance(9); + }); + + $migrator = $this->migrator( + $this->collection($first, $second), + lockTtl: 10 + ); + $migrator->run(); + + $result = $migrator->rollback(); + + $this->assertSame([ + '2026_01_01_000002_create_posts', + '2026_01_01_000001_create_users', + ], $result->rolledBack); + } + + public function test_it_preserves_a_migration_record_when_its_rollback_lock_expires(): void { + $migration = $this->createMock(Migration::class); + $migration->method('id')->willReturn('2026_01_01_000001_create_users'); + $migration->method('down')->willReturnCallback(function (Schema $schema): void { + $schema->execute('down:2026_01_01_000001_create_users'); + $this->clock->advance(10); + }); + + $this->configured(new TestMigration('2026_01_01_000001_create_users'))->run(); + $this->schema->statements = []; + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not refresh migration lock'); + + try { + $this->migrator( + $this->collection($migration), + lockTtl: 10 + )->rollback(); + } finally { + $this->assertSame(['down:2026_01_01_000001_create_users'], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + public function test_it_rolls_back_an_explicit_batch_when_it_is_still_latest(): void { $this->configured( new TestMigration('2026_01_01_000001_create_users'), @@ -266,6 +371,48 @@ public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): voi ], $this->schema->statements); } + public function test_it_renews_one_lock_through_both_phases_of_refresh(): void { + $migration = new TestMigration('2026_01_01_000001_create_users'); + $acquired = new LockToken('custom-migrations', 'owner', new DateTimeImmutable('2026-01-01 00:01:00')); + $beforeRollback = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:02:00')); + $afterRollback = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:03:00')); + $beforeRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:04:00')); + $afterRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:05:00')); + $expectedTokens = [$acquired, $beforeRollback, $afterRollback, $beforeRun]; + $refreshedTokens = [$beforeRollback, $afterRollback, $beforeRun, $afterRun]; + $lock = $this->createMock(Lock::class); + + $this->repository->recordRun($migration->id(), 1); + $lock->expects($this->once()) + ->method('acquire') + ->with('custom-migrations', 10) + ->willReturn($acquired); + $lock->expects($this->exactly(4)) + ->method('refresh') + ->willReturnCallback(function (LockToken $token, int $ttl) use (&$expectedTokens, &$refreshedTokens): ?LockToken { + $this->assertSame(array_shift($expectedTokens), $token); + $this->assertSame(10, $ttl); + + return array_shift($refreshedTokens); + }); + $lock->expects($this->once()) + ->method('release') + ->with($afterRun) + ->willReturn(true); + + $migrator = $this->migrator( + $this->collection($migration), + lock: $lock, + lockName: 'custom-migrations', + lockTtl: 10 + ); + + $result = $migrator->refresh(); + + $this->assertSame(['2026_01_01_000001_create_users'], $result->rolledBack); + $this->assertSame(['2026_01_01_000001_create_users'], $result->ran); + } + public function test_refresh_uses_one_migration_snapshot_for_rollback_and_run(): void { $collection = new Collection(); $late = new TestMigration('2026_01_01_000002_create_posts'); @@ -365,6 +512,101 @@ public function test_it_propagates_lock_acquisition_failures_with_the_configured $migrator->run(); } + public function test_it_releases_the_lock_when_renewal_fails_before_a_migration_runs(): void { + $migration = new TestMigration('2026_01_01_000001_create_users'); + $token = new LockToken('custom-migrations', 'owner', new DateTimeImmutable('2026-01-01 00:02:00')); + $lock = $this->createMock(Lock::class); + + $lock->expects($this->once()) + ->method('acquire') + ->with('custom-migrations', 120) + ->willReturn($token); + $lock->expects($this->once()) + ->method('refresh') + ->with($token, 120) + ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(true); + + $this->expectException(LockUnavailableException::class); + + try { + $this->migrator( + $this->collection($migration), + lock: $lock, + lockName: 'custom-migrations', + lockTtl: 120 + )->run(); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertFalse($this->repository->hasRun($migration->id())); + } + } + + public function test_it_uses_the_configured_ttl_and_releases_the_latest_refreshed_token(): void { + $acquired = new LockToken('custom-migrations', 'owner', new DateTimeImmutable('2026-01-01 00:02:00')); + $beforeRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:04:00')); + $afterRun = $acquired->withExpiration(new DateTimeImmutable('2026-01-01 00:06:00')); + $expected = [$acquired, $beforeRun]; + $refreshed = [$beforeRun, $afterRun]; + $lock = $this->createMock(Lock::class); + + $lock->expects($this->once()) + ->method('acquire') + ->with('custom-migrations', 120) + ->willReturn($acquired); + $lock->expects($this->exactly(2)) + ->method('refresh') + ->willReturnCallback(function (LockToken $token, int $ttl) use (&$expected, &$refreshed): ?LockToken { + $this->assertSame(array_shift($expected), $token); + $this->assertSame(120, $ttl); + + return array_shift($refreshed); + }); + $lock->expects($this->once()) + ->method('release') + ->with($afterRun) + ->willReturn(true); + + $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + lockName: 'custom-migrations', + lockTtl: 120 + )->run(); + } + + public function test_it_stops_before_running_a_migration_when_lock_renewal_loses_ownership(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->willReturn($token); + $lock->expects($this->once()) + ->method('refresh') + ->with($token, 300) + ->willReturn(null); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(false); + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not refresh migration lock'); + + try { + $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + )->run(); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + public function test_it_releases_the_lock_when_initialization_fails(): void { $storeSchema = $this->createMock(Schema::class); $storeSchema->method('createOrUpdate') @@ -397,6 +639,10 @@ public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_ ->method('acquire') ->with('nx-foundation-database-migrations', 300) ->willReturn($token); + $lock->expects($this->exactly(2)) + ->method('refresh') + ->with($token, 300) + ->willReturn($token); $lock->expects($this->once()) ->method('release') ->with($token) @@ -423,6 +669,10 @@ public function test_it_preserves_the_migration_failure_when_lock_release_is_una $lock->expects($this->once()) ->method('acquire') ->willReturn($token); + $lock->expects($this->once()) + ->method('refresh') + ->with($token, 300) + ->willReturn($token); $lock->expects($this->once()) ->method('release') ->with($token) @@ -445,6 +695,10 @@ public function test_it_propagates_lock_release_failures_after_a_successful_migr $lock->expects($this->once()) ->method('acquire') ->willReturn($token); + $lock->expects($this->exactly(2)) + ->method('refresh') + ->with($token, 300) + ->willReturn($token); $lock->expects($this->once()) ->method('release') ->with($token) @@ -470,6 +724,10 @@ public function test_it_preserves_the_migration_failure_when_release_cannot_conf $lock->expects($this->once()) ->method('acquire') ->willReturn($token); + $lock->expects($this->once()) + ->method('refresh') + ->with($token, 300) + ->willReturn($token); $lock->expects($this->once()) ->method('release') ->with($token) From ca8971bf0aa737ddf3bd55e091e5dff1994d43d4 Mon Sep 17 00:00:00 2001 From: Justin Frydman Date: Mon, 24 Aug 2026 17:41:29 -0600 Subject: [PATCH 81/81] Ensure migrations don't silently succeed if dbDelta leaves behind indexes --- src/Database/Schema/Reconciler.php | 183 ++++++++++++++ src/Database/Table/TableDefinition.php | 7 +- .../docs/components/database/migrations.mdx | 6 + .../Database/IndexReconciliationTable.php | 46 ++++ tests/Unit/Database/Schema/ReconcilerTest.php | 235 +++++++++++++++++- .../Database/Table/TableDefinitionTest.php | 19 ++ .../Database/Table/Tables/LockTableTest.php | 14 +- .../Table/Tables/MigrationTableTest.php | 5 + .../Database/DatabaseIntegrationTest.php | 13 + 9 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 tests/Support/Fixtures/Database/IndexReconciliationTable.php diff --git a/src/Database/Schema/Reconciler.php b/src/Database/Schema/Reconciler.php index 638cfff..776dd4e 100644 --- a/src/Database/Schema/Reconciler.php +++ b/src/Database/Schema/Reconciler.php @@ -8,6 +8,7 @@ use StellarWP\Foundation\Database\Contracts\Table; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Table\Column; +use StellarWP\Foundation\Database\Table\IndexType; use StellarWP\Foundation\Database\Table\TableDefinition; /** @@ -37,6 +38,7 @@ public function reconcile(Table $table): void { $this->executor->execute($this->createTableSql($table, $definition)); $this->reconcileComplexDefaults($table, $definition); $this->assertColumnPropertiesMatch($table, $definition); + $this->assertIndexesMatch($table, $definition); } /** @@ -181,6 +183,187 @@ private function columnProperties(Table $table, Column $column): array { ]; } + /** + * Verify that every declared index, and no undeclared index, exists with the expected type and ordered columns. + * + * @throws DatabaseException When index metadata is invalid or differs from the definition. + */ + private function assertIndexesMatch(Table $table, TableDefinition $definition): void { + $expected = $this->expectedIndexes($definition); + $actual = $this->physicalIndexes($table); + $differences = []; + + foreach ($expected as $name => $index) { + if (! isset($actual[$name])) { + $differences[] = sprintf('index %s expected %s, found missing', $index['name'], $this->describeIndex($index)); + continue; + } + + if ($index['type'] !== $actual[$name]['type'] || $index['columns'] !== $actual[$name]['columns']) { + $differences[] = sprintf( + 'index %s expected %s, found %s', + $index['name'], + $this->describeIndex($index), + $this->describeIndex($actual[$name]) + ); + } + + unset($actual[$name]); + } + + foreach ($actual as $index) { + $differences[] = sprintf('unexpected index %s found %s', $index['name'], $this->describeIndex($index)); + } + + if ($differences !== []) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation did not apply the definition for %s: %s.', + $this->database->tableName($table), + implode('; ', $differences) + )); + } + } + + /** + * Normalize the indexes declared by a table definition for comparison with database metadata. + * + * @return array}> + */ + private function expectedIndexes(TableDefinition $definition): array { + $indexes = []; + + foreach ($definition->indexes() as $index) { + $name = $index->type === IndexType::PRIMARY ? 'PRIMARY' : $index->name; + + $indexes[strtolower($name)] = [ + 'name' => $name, + 'type' => $index->type, + 'columns' => array_map(strtolower(...), $index->columns), + ]; + } + + return $indexes; + } + + /** + * Read and normalize the physical indexes reported by MariaDB or MySQL. + * + * @throws DatabaseException When the database returns invalid index metadata. + * + * @return array}> + */ + private function physicalIndexes(Table $table): array { + /** @var array}> $indexes */ + $indexes = []; + + foreach ($this->database->rows('SHOW INDEX FROM %i', $this->database->tableName($table)) as $row) { + $name = $row['Key_name'] ?? null; + $column = $row['Column_name'] ?? null; + $indexType = $row['Index_type'] ?? null; + $collation = $row['Collation'] ?? null; + $nonUnique = filter_var($row['Non_unique'] ?? null, FILTER_VALIDATE_INT); + $sequence = filter_var($row['Seq_in_index'] ?? null, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1], + ]); + + if ( + ! is_string($name) + || $name === '' + || ! is_string($column) + || $column === '' + || ! is_string($indexType) + || $indexType === '' + || ($collation !== null && (! is_string($collation) || ! in_array(strtoupper($collation), ['A', 'D'], true))) + || ! in_array($nonUnique, [0, 1], true) + || ! is_int($sequence) + ) { + throw new DatabaseException(sprintf( + 'Database returned invalid index metadata for %s.', + $this->database->tableName($table) + )); + } + + $key = strtolower($name); + $semanticIndexType = strtoupper($indexType); + $type = strcasecmp($name, 'PRIMARY') === 0 + ? IndexType::PRIMARY + : (in_array($semanticIndexType, ['FULLTEXT', 'SPATIAL', 'RTREE'], true) + ? strtolower($semanticIndexType) + : ($nonUnique === 0 ? IndexType::UNIQUE : IndexType::KEY)); + + if (isset($indexes[$key]) && $indexes[$key]['type'] !== $type) { + throw new DatabaseException(sprintf( + 'Database returned invalid index metadata for %s.%s.', + $this->database->tableName($table), + $name + )); + } + + $indexes[$key] ??= [ + 'name' => $name, + 'type' => $type, + 'columns' => [], + ]; + + if (isset($indexes[$key]['columns'][$sequence])) { + throw new DatabaseException(sprintf( + 'Database returned invalid index metadata for %s.%s.', + $this->database->tableName($table), + $name + )); + } + + $subPart = $row['Sub_part'] ?? null; + + if ($subPart !== null) { + $subPart = filter_var($subPart, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + + if (! is_int($subPart)) { + throw new DatabaseException(sprintf( + 'Database returned invalid index metadata for %s.%s.', + $this->database->tableName($table), + $name + )); + } + + $column .= '(' . $subPart . ')'; + } + + if (strtoupper((string) $collation) === 'D') { + $column .= ' DESC'; + } + + $indexes[$key]['columns'][$sequence] = strtolower($column); + } + + foreach ($indexes as &$index) { + ksort($index['columns']); + + if (array_keys($index['columns']) !== range(1, count($index['columns']))) { + throw new DatabaseException(sprintf( + 'Database returned invalid index metadata for %s.%s.', + $this->database->tableName($table), + $index['name'] + )); + } + + $index['columns'] = array_values($index['columns']); + } + + unset($index); + + return $indexes; + } + + /** + * Format one normalized index for a reconciliation error. + * + * @param array{name: string, type: string, columns: list} $index + */ + private function describeIndex(array $index): string { + return sprintf('%s (%s)', strtoupper($index['type']), implode(', ', $index['columns'])); + } + /** * Determine whether a database-reported default matches the declared column default. */ diff --git a/src/Database/Table/TableDefinition.php b/src/Database/Table/TableDefinition.php index 96e1bde..d16b60b 100644 --- a/src/Database/Table/TableDefinition.php +++ b/src/Database/Table/TableDefinition.php @@ -198,6 +198,11 @@ public function validationErrors(): array { continue; } + if (strcasecmp($index->name, 'PRIMARY') === 0) { + $errors[] = 'The PRIMARY index name is reserved for the primary key.'; + continue; + } + foreach ($this->indexesByName($index->name) as $duplicate) { if ($duplicate !== $index && $duplicate->type !== IndexType::PRIMARY) { $errors[] = sprintf('Index %s is defined more than once.', $index->name); @@ -256,7 +261,7 @@ private function currentColumn(): Column { private function indexesByName(string $name): array { return array_values(array_filter( $this->indexes, - static fn (Index $index): bool => $index->name === $name + static fn (Index $index): bool => strcasecmp($index->name, $name) === 0 )); } } diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx index e191b68..d55b819 100644 --- a/src/Docs/src/content/docs/components/database/migrations.mdx +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -138,6 +138,12 @@ Migration IDs are permanent, byte-exact identifiers. The generator prefixes them For later schema changes, update the table's desired definition and create a new migration that applies it. Use `Schema::execute()` only for trusted schema SQL that `dbDelta()` cannot express reliably. +:::caution[Remove or change indexes explicitly] +WordPress `dbDelta()` can leave removed or changed indexes in place. Foundation verifies index names, uniqueness, ordered columns, and column prefix lengths after reconciliation, and it will not record the migration when the physical indexes differ from the table definition. + +Use `Schema::dropIndex()` for removed or changed named secondary indexes, then call `Schema::createOrUpdate()` to apply and verify the final table definition. Primary-key changes and other operations without a focused helper require trusted SQL through `Schema::execute()`. +::: + For parameterized data changes, inject the `Database` contract into the migration and pass values through WordPress placeholders. For example, `src/Database/Migrations/Backfill_Report_Status.php` can update existing rows without interpolating values into SQL: ```php title="Backfill_Report_Status.php" diff --git a/tests/Support/Fixtures/Database/IndexReconciliationTable.php b/tests/Support/Fixtures/Database/IndexReconciliationTable.php new file mode 100644 index 0000000..b3dd71b --- /dev/null +++ b/tests/Support/Fixtures/Database/IndexReconciliationTable.php @@ -0,0 +1,46 @@ + $indexColumns + */ + public function __construct( + private string $table, + private bool $includeIndex, + private array $indexColumns = ['email'], + private string $indexType = IndexType::UNIQUE + ) { + } + + public function id(): string { + return 'index_reconciliation_table'; + } + + public function name(): string { + return $this->table; + } + + public function definition(): TableDefinition { + $definition = TableDefinition::for($this) + ->bigIncrements('id') + ->string('email') + ->string('tenant'); + + if (! $this->includeIndex) { + return $definition; + } + + if ($this->indexType === IndexType::UNIQUE) { + return $definition->unique('email_unique', ...$this->indexColumns); + } + + return $definition->index('email_unique', ...$this->indexColumns); + } +} diff --git a/tests/Unit/Database/Schema/ReconcilerTest.php b/tests/Unit/Database/Schema/ReconcilerTest.php index dd5a7f4..5e2a4c1 100644 --- a/tests/Unit/Database/Schema/ReconcilerTest.php +++ b/tests/Unit/Database/Schema/ReconcilerTest.php @@ -2,9 +2,12 @@ namespace StellarWP\Foundation\Tests\Unit\Database\Schema; +use PHPUnit\Framework\Attributes\DataProvider; use StellarWP\Foundation\Database\Exceptions\DatabaseException; use StellarWP\Foundation\Database\Schema\Reconciler; +use StellarWP\Foundation\Database\Table\IndexType; use StellarWP\Foundation\Tests\Support\Fixtures\Database\FakeDatabase; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\IndexReconciliationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\RecordingSchemaExecutor; use StellarWP\Foundation\Tests\Support\Fixtures\Database\SchemaReconciliationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; @@ -13,15 +16,17 @@ final class ReconcilerTest extends TestCase { public function test_it_builds_table_definitions_for_the_schema_executor(): void { - $database = new FakeDatabase(); - $database->rowResults[] = ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment']; - $executor = new RecordingSchemaExecutor(); - $reconciler = new Reconciler($database, $executor); + $database = new FakeDatabase(); + $database->rowResults[] = ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment']; + $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; + $executor = new RecordingSchemaExecutor(); + $reconciler = new Reconciler($database, $executor); $reconciler->reconcile(new TestTable('example', 'wp_example')); $this->assertStringContainsString('CREATE TABLE `wp_example`', $executor->statements[0]); $this->assertSame("SHOW FULL COLUMNS FROM `wp_example` WHERE Field = 'id'", $database->rowQueries[0]); + $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); } public function test_it_accepts_matching_column_defaults_and_nullability(): void { @@ -34,13 +39,199 @@ public function test_it_accepts_matching_column_defaults_and_nullability(): void ['Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], ['Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], ]; - $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + $database->rowsResults[] = [self::indexRow('PRIMARY', 0, 1, 'id')]; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); $reconciler->reconcile(new SchemaReconciliationTable('wp_example', 5, true)); $this->assertSame([], $database->executed); } + public function test_it_accepts_matching_index_metadata(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 1, 'email'), + ]; + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true)); + + $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); + } + + public function test_it_orders_composite_index_metadata_by_sequence(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 2, 'tenant'), + self::indexRow('email_unique', 0, 1, 'email'), + ]; + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true, ['email', 'tenant'])); + + $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); + } + + public function test_it_rejects_reordered_composite_index_columns(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 1, 'tenant'), + self::indexRow('email_unique', 0, 2, 'email'), + ]; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('index email_unique expected UNIQUE (email, tenant), found UNIQUE (tenant, email)'); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true, ['email', 'tenant'])); + } + + public function test_it_rejects_descending_index_columns(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 1, 'email', collation: 'D'), + ]; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('index email_unique expected UNIQUE (email), found UNIQUE (email desc)'); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true)); + } + + public function test_it_rejects_a_semantically_different_index_type(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 1, 1, 'email', indexType: 'FULLTEXT'), + ]; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('expected KEY (email), found FULLTEXT (email)'); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true, indexType: IndexType::KEY)); + } + + public function test_it_accepts_hash_as_an_index_storage_method(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 1, 1, 'email', indexType: 'HASH'), + ]; + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true, indexType: IndexType::KEY)); + + $this->assertSame('SHOW INDEX FROM `wp_example`', $database->rowsQueries[0]); + } + + /** + * @dataProvider indexDifferences + * + * @param list> $indexes + */ + #[DataProvider('indexDifferences')] + public function test_it_rejects_indexes_that_do_not_match_the_definition( + bool $includeUniqueIndex, + array $indexes, + string $message + ): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = $indexes; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage($message); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', $includeUniqueIndex)); + } + + /** + * @return iterable>, string}> + */ + public static function indexDifferences(): iterable { + yield 'missing' => [ + true, + [self::indexRow('PRIMARY', 0, 1, 'id')], + 'index email_unique expected UNIQUE (email), found missing', + ]; + + yield 'unexpected' => [ + false, + [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 1, 'email'), + ], + 'unexpected index email_unique found UNIQUE (email)', + ]; + + yield 'changed uniqueness' => [ + true, + [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 1, 1, 'email'), + ], + 'index email_unique expected UNIQUE (email), found KEY (email)', + ]; + + yield 'changed columns' => [ + true, + [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 1, 'id'), + ], + 'index email_unique expected UNIQUE (email), found UNIQUE (id)', + ]; + + yield 'column prefix' => [ + true, + [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 1, 'email', 32), + ], + 'index email_unique expected UNIQUE (email), found UNIQUE (email(32))', + ]; + } + + public function test_it_rejects_invalid_index_metadata(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [['Key_name' => 'PRIMARY']]; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('returned invalid index metadata for wp_example'); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true)); + } + + public function test_it_rejects_non_contiguous_index_column_sequences(): void { + $database = new FakeDatabase(); + $database->rowResults = self::indexTableColumns(); + $database->rowsResults[] = [ + self::indexRow('PRIMARY', 0, 1, 'id'), + self::indexRow('email_unique', 0, 2, 'email'), + ]; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('returned invalid index metadata for wp_example.email_unique'); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new IndexReconciliationTable('wp_example', true)); + } + public function test_it_fails_when_column_defaults_and_nullability_remain_unapplied(): void { $database = new FakeDatabase(); $database->rowResults = [ @@ -106,4 +297,38 @@ public function test_it_rejects_invalid_column_metadata(): void { (new Reconciler($database, new RecordingSchemaExecutor())) ->reconcile(new TestTable('example', 'wp_example')); } + + /** + * @return list> + */ + private static function indexTableColumns(): array { + return [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ]; + } + + /** + * @return array + */ + private static function indexRow( + string $name, + int $nonUnique, + int $sequence, + string $column, + ?int $subPart = null, + string $indexType = 'BTREE', + ?string $collation = 'A' + ): array { + return [ + 'Key_name' => $name, + 'Non_unique' => $nonUnique, + 'Seq_in_index' => $sequence, + 'Column_name' => $column, + 'Sub_part' => $subPart, + 'Index_type' => $indexType, + 'Collation' => $collation, + ]; + } } diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php index 11773e0..7eafb07 100644 --- a/tests/Unit/Database/Table/TableDefinitionTest.php +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -180,4 +180,23 @@ public function test_it_reports_duplicate_index_names(): void { $this->assertContains('Index status_lookup is defined more than once.', $definition->validationErrors()); } + + public function test_it_reports_duplicate_index_names_case_insensitively(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('status') + ->string('type') + ->index('Status_Lookup', 'status') + ->index('status_lookup', 'type'); + + $this->assertContains('Index Status_Lookup is defined more than once.', $definition->validationErrors()); + } + + public function test_it_rejects_primary_as_a_secondary_index_name(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigIncrements('id') + ->string('status') + ->index('PRIMARY', 'status'); + + $this->assertContains('The PRIMARY index name is reserved for the primary key.', $definition->validationErrors()); + } } diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php index 313df6b..ea1defe 100644 --- a/tests/Unit/Database/Table/Tables/LockTableTest.php +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -12,11 +12,15 @@ final class LockTableTest extends TestCase { public function test_it_creates_the_lock_table(): void { - $database = new FakeDatabase(); - $database->rowResults = array_fill(0, 5, ['Null' => 'NO', 'Default' => null, 'Extra' => '']); - $executor = new RecordingSchemaExecutor(); - $schema = new DatabaseSchema($database, new Reconciler($database, $executor)); - $table = new LockTable('network_foundation_locks'); + $database = new FakeDatabase(); + $database->rowResults = array_fill(0, 5, ['Null' => 'NO', 'Default' => null, 'Extra' => '']); + $database->rowsResults[] = [ + ['Key_name' => 'PRIMARY', 'Non_unique' => 0, 'Seq_in_index' => 1, 'Column_name' => 'name', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], + ['Key_name' => 'expires_at', 'Non_unique' => 1, 'Seq_in_index' => 1, 'Column_name' => 'expires_at', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], + ]; + $executor = new RecordingSchemaExecutor(); + $schema = new DatabaseSchema($database, new Reconciler($database, $executor)); + $table = new LockTable('network_foundation_locks'); $schema->createOrUpdate($table); diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php index 393db11..4cc4c9e 100644 --- a/tests/Unit/Database/Table/Tables/MigrationTableTest.php +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -17,6 +17,11 @@ public function test_it_creates_the_migration_table(): void { ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], ...array_fill(0, 3, ['Null' => 'NO', 'Default' => null, 'Extra' => '']), ]; + $database->rowsResults[] = [ + ['Key_name' => 'PRIMARY', 'Non_unique' => 0, 'Seq_in_index' => 1, 'Column_name' => 'id', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], + ['Key_name' => 'migration', 'Non_unique' => 0, 'Seq_in_index' => 1, 'Column_name' => 'migration', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], + ['Key_name' => 'batch', 'Non_unique' => 1, 'Seq_in_index' => 1, 'Column_name' => 'batch', 'Sub_part' => null, 'Index_type' => 'BTREE', 'Collation' => 'A'], + ]; $executor = new RecordingSchemaExecutor(); $schema = new DatabaseSchema($database, new Reconciler($database, $executor)); $table = new MigrationTable('network_foundation_migrations'); diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php index 4fb1277..ba1454f 100644 --- a/tests/wpunit/Database/DatabaseIntegrationTest.php +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -27,6 +27,7 @@ use StellarWP\Foundation\Lock\Contracts\Lock; use StellarWP\Foundation\Lock\LockToken; use StellarWP\Foundation\Tests\Support\Fixtures\Database\DateTimePrecisionTable; +use StellarWP\Foundation\Tests\Support\Fixtures\Database\IndexReconciliationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\SchemaReconciliationTable; use StellarWP\Foundation\Tests\Support\Fixtures\Database\TestTable; use StellarWP\Foundation\Tests\WPUnitSupport\WPTestCase; @@ -383,6 +384,18 @@ public function test_schema_rejects_unapplied_numeric_defaults_and_nullability() $this->assertNull($row['completed_at'] ?? null); } + public function test_schema_rejects_an_index_that_db_delta_does_not_remove(): void { + $table = $this->table('removed_index'); + + $this->schema->createOrUpdate(new IndexReconciliationTable($table, true)); + $this->assertTrue($this->schema->hasIndex($table, 'email_unique')); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('unexpected index email_unique'); + + $this->schema->createOrUpdate(new IndexReconciliationTable($table, false)); + } + public function test_schema_rejects_an_unapplied_auto_increment_attribute(): void { $table = $this->table('column_extra');