From 269211058b63333783675d7c57569aaa611478b8 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 17:20:31 +0200 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20Stage=201=20=E2=80=94=20add=20eco?= =?UTF-8?q?tone/tempest=20package=20with=20core=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds the packages/Tempest package and wires Ecotone into the Tempest PHP framework via Tempest's Discovery and Initializer system so that CommandBus/QueryBus/EventBus and all Ecotone gateways become resolvable from the Tempest container with no manual registration. Stage 1 components: - EcotoneConfig: Tempest config object (ecotone.config.php) mirroring config/ecotone.php knobs; defaults from env() - TempestConfigurationVariableService: ConfigurationVariableService over Tempest env() - MessagingSystemInitializer: Tempest Singleton Initializer that compiles Ecotone's DefinitionHolder via LazyInMemoryContainer + TempestPsrContainerAdapter as the external container for user services - EcotoneServiceInitializer: DynamicInitializer for CommandBus/QueryBus/EventBus and all compiled service IDs; triggers MessagingSystemInitializer on first resolution - TempestPsrContainerAdapter: PSR-11 wrapper over Tempest's container - EcotoneIntegrationTest: custom test base that boots FrameworkKernel with explicit discovery locations, bypassing problematic monorepo paths - Root composer.json: adds Ecotone\Tempest\ autoload, Test\Ecotone\Tempest\ autoload-dev, ecotone/tempest in replace map, tempest/framework in require-dev, phpstan updated to ^1.8|^2.0 for Tempest compatibility Stage 1 tests (all green): - TempestApplicationTest: CommandBus resolves, command→handler flow, expression language - TempestConfigurationVariableServiceTest: env reads/has Stages 2–4 (DBAL, aggregate repository, multi-tenant) are NOT started. --- composer.json | 8 +- packages/Tempest/.gitattributes | 7 + packages/Tempest/.github/FUNDING.yml | 12 ++ .../.github/ISSUE_TEMPLATE/bug_report.md | 10 + packages/Tempest/.gitignore | 9 + packages/Tempest/LICENSE | 21 ++ packages/Tempest/LICENSE-ENTERPRISE | 3 + packages/Tempest/README.md | 60 ++++++ packages/Tempest/composer.json | 93 +++++++++ packages/Tempest/phpstan.neon | 4 + packages/Tempest/phpunit.xml.dist | 26 +++ packages/Tempest/src/EcotoneConfig.php | 32 +++ .../Tempest/src/EcotoneServiceInitializer.php | 57 ++++++ .../src/MessagingSystemInitializer.php | 183 ++++++++++++++++++ .../TempestConfigurationVariableService.php | 25 +++ .../src/TempestPsrContainerAdapter.php | 43 ++++ .../Application/TempestApplicationTest.php | 55 ++++++ .../Tempest/tests/EcotoneIntegrationTest.php | 103 ++++++++++ .../ExpressionLanguageCommandHandler.php | 29 +++ .../Fixture/User/MessagingConfiguration.php | 20 ++ packages/Tempest/tests/Fixture/User/User.php | 24 +++ .../tests/Fixture/User/UserRepository.php | 20 ++ .../tests/Fixture/User/UserService.php | 23 +++ ...empestConfigurationVariableServiceTest.php | 33 ++++ packages/Tempest/tests/app/composer.json | 13 ++ phpunit.xml.dist | 4 + 26 files changed, 915 insertions(+), 2 deletions(-) create mode 100644 packages/Tempest/.gitattributes create mode 100644 packages/Tempest/.github/FUNDING.yml create mode 100644 packages/Tempest/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 packages/Tempest/.gitignore create mode 100644 packages/Tempest/LICENSE create mode 100644 packages/Tempest/LICENSE-ENTERPRISE create mode 100644 packages/Tempest/README.md create mode 100644 packages/Tempest/composer.json create mode 100644 packages/Tempest/phpstan.neon create mode 100644 packages/Tempest/phpunit.xml.dist create mode 100644 packages/Tempest/src/EcotoneConfig.php create mode 100644 packages/Tempest/src/EcotoneServiceInitializer.php create mode 100644 packages/Tempest/src/MessagingSystemInitializer.php create mode 100644 packages/Tempest/src/TempestConfigurationVariableService.php create mode 100644 packages/Tempest/src/TempestPsrContainerAdapter.php create mode 100644 packages/Tempest/tests/Application/TempestApplicationTest.php create mode 100644 packages/Tempest/tests/EcotoneIntegrationTest.php create mode 100644 packages/Tempest/tests/Fixture/ExpressionLanguage/ExpressionLanguageCommandHandler.php create mode 100644 packages/Tempest/tests/Fixture/User/MessagingConfiguration.php create mode 100644 packages/Tempest/tests/Fixture/User/User.php create mode 100644 packages/Tempest/tests/Fixture/User/UserRepository.php create mode 100644 packages/Tempest/tests/Fixture/User/UserService.php create mode 100644 packages/Tempest/tests/TempestConfigurationVariableServiceTest.php create mode 100644 packages/Tempest/tests/app/composer.json diff --git a/composer.json b/composer.json index 28c76bd0b..caff60f21 100644 --- a/composer.json +++ b/composer.json @@ -64,6 +64,7 @@ "Ecotone\\Redis\\": "packages/Redis/src", "Ecotone\\Sqs\\": "packages/Sqs/src", "Ecotone\\Laravel\\": "packages/Laravel/src", + "Ecotone\\Tempest\\": "packages/Tempest/src", "Ecotone\\Lite\\": [ "packages/Ecotone/src/Lite/", "packages/LiteApplication/src" @@ -124,6 +125,7 @@ "packages/Kafka/tests" ], "Test\\Ecotone\\Laravel\\": "packages/Laravel/tests", + "Test\\Ecotone\\Tempest\\": "packages/Tempest/tests", "App\\MultiTenant\\": "packages/Laravel/tests/MultiTenant/app", "App\\Licence\\Laravel\\": "packages/Laravel/tests/Licence/app", "Symfony\\App\\MultiTenant\\": "packages/Symfony/tests/phpunit/MultiTenant/src", @@ -171,7 +173,7 @@ "guzzlehttp/psr7": "^2.0", "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", "php-coveralls/php-coveralls": "^2.5", - "phpstan/phpstan": "^1.8", + "phpstan/phpstan": "^1.8|^2.0", "phpunit/phpunit": "^11.0", "predis/predis": "^1.1.10", "symfony/expression-language": "^6.4|^7.0|^8.0", @@ -193,7 +195,8 @@ "kwn/php-rdkafka-stubs": "^2.2", "symfony/var-exporter": "^6.4|^7.0|^8.0", "enqueue/dsn": "^0.10.27", - "yoast/phpunit-polyfills": "^4.0.0" + "yoast/phpunit-polyfills": "^4.0.0", + "tempest/framework": "^3.11" }, "suggest": { "ext-simplexml": "Required if application/xml is used as serialization media type" @@ -226,6 +229,7 @@ "ecotone/enqueue": "1.82.0", "ecotone/jms-converter": "1.82.0", "ecotone/laravel": "1.82.0", + "ecotone/tempest": "1.82.0", "ecotone/pdo-event-sourcing": "1.82.0", "ecotone/symfony-bundle": "1.82.0" }, diff --git a/packages/Tempest/.gitattributes b/packages/Tempest/.gitattributes new file mode 100644 index 000000000..5699823c5 --- /dev/null +++ b/packages/Tempest/.gitattributes @@ -0,0 +1,7 @@ +tests/ export-ignore +.coveralls.yml export-ignore +.gitattributes export-ignore +.gitignore export-ignore +behat.yaml export-ignore +phpstan.neon export-ignore +phpunit.xml export-ignore \ No newline at end of file diff --git a/packages/Tempest/.github/FUNDING.yml b/packages/Tempest/.github/FUNDING.yml new file mode 100644 index 000000000..c7eaae65e --- /dev/null +++ b/packages/Tempest/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [dgafka] +patreon: # Replace with a single Open Collective username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/packages/Tempest/.github/ISSUE_TEMPLATE/bug_report.md b/packages/Tempest/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..2fc86f2cf --- /dev/null +++ b/packages/Tempest/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,10 @@ +--- +name: This is Read-Only repository +about: Report at ecotoneframework/ecotone-dev +title: '' +labels: '' +assignees: '' + +--- + +Report issue at [ecotone-dev](ecotoneframework/ecotone-dev) \ No newline at end of file diff --git a/packages/Tempest/.gitignore b/packages/Tempest/.gitignore new file mode 100644 index 000000000..18c159d80 --- /dev/null +++ b/packages/Tempest/.gitignore @@ -0,0 +1,9 @@ +.idea/ +vendor/ +bin/ +tests/coverage +!tests/coverage/.gitkeep +file +.phpunit.result.cache +composer.lock +phpunit.xml diff --git a/packages/Tempest/LICENSE b/packages/Tempest/LICENSE new file mode 100644 index 000000000..82205508a --- /dev/null +++ b/packages/Tempest/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2025 Dariusz Gafka + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +**Scope of the License** + +Apache-2.0 Licence applies to non Enterprise Functionalities of the Ecotone Framework. +Functionalities of the Ecotone Framework referred to as Enterprise functionalities, are not covered under the Apache-2.0 license. These functionalities are provided under a separate Enterprise License. +For details on the Enterprise License, please refer to the [LICENSE-ENTERPRISE](./LICENSE-ENTERPRISE) file. \ No newline at end of file diff --git a/packages/Tempest/LICENSE-ENTERPRISE b/packages/Tempest/LICENSE-ENTERPRISE new file mode 100644 index 000000000..fad1a5a8d --- /dev/null +++ b/packages/Tempest/LICENSE-ENTERPRISE @@ -0,0 +1,3 @@ +Copyright (c) 2025 Dariusz Gafka + +Licence is available at [ecotone.tech/documents/ecotone_enterprise_licence.pdf](https://ecotone.tech/documents/ecotone_enterprise_licence.pdf) \ No newline at end of file diff --git a/packages/Tempest/README.md b/packages/Tempest/README.md new file mode 100644 index 000000000..4f510c47c --- /dev/null +++ b/packages/Tempest/README.md @@ -0,0 +1,60 @@ +# This is Read Only Repository +To contribute make use of [Ecotone-Dev repository](https://github.com/ecotoneframework/ecotone-dev). + +

+ +

+ +![Github Actions](https://github.com/ecotoneFramework/ecotone-dev/actions/workflows/split-testing.yml/badge.svg) +[![Latest Stable Version](https://poser.pugx.org/ecotone/ecotone/v/stable)](https://packagist.org/packages/ecotone/ecotone) +[![License](https://poser.pugx.org/ecotone/ecotone/license)](https://packagist.org/packages/ecotone/ecotone) +[![Total Downloads](https://img.shields.io/packagist/dt/ecotone/ecotone)](https://packagist.org/packages/ecotone/ecotone) +[![PHP Version Require](https://img.shields.io/packagist/dependency-v/ecotone/ecotone/php.svg)](https://packagist.org/packages/ecotone/ecotone) + +**Ecotone is the PHP architecture layer that grows with your system — without rewrites.** + +From `#[CommandHandler]` on day one, to event sourcing, sagas, outbox, and distributed messaging at scale — one package, same codebase, no forced migrations between growth stages. Declarative PHP 8 attributes on the classes you already have. + +## {{Package name}} + +{{One-paragraph description of what this package adds to Ecotone and the primary use case.}} + +- {{Key capability 1}} +- {{Key capability 2}} +- {{Key capability 3}} + +Visit [ecotone.tech](https://ecotone.tech) to learn more. + +> Works with [Symfony](https://docs.ecotone.tech/modules/symfony-ddd-cqrs-event-sourcing), [Laravel](https://docs.ecotone.tech/modules/laravel-ddd-cqrs-event-sourcing), or any PSR-11 framework via [Ecotone Lite](https://docs.ecotone.tech/install-php-service-bus#install-ecotone-lite-no-framework). + +## Getting started + +See the [quickstart guide](https://docs.ecotone.tech/quick-start) and [reference documentation](https://docs.ecotone.tech). Read more on the [Ecotone Blog](https://blog.ecotone.tech). + +## AI-Ready documentation + +Ecotone ships with MCP server, Agentic Skills, and LLMs.txt for any coding agent. See the [AI Integration Guide](https://docs.ecotone.tech/other/ai-integration). + +## Feature requests and issue reporting + +Use [issue tracking system](https://github.com/ecotoneframework/ecotone-dev/issues) for new feature request and bugs. +Please verify that it's not already reported by someone else. + +## Contact + +If you want to talk or ask questions about Ecotone + +- [**Twitter**](https://twitter.com/EcotonePHP) +- **support@simplycodedsoftware.com** +- [**Community Channel**](https://discord.gg/GwM2BSuXeg) + +## Support Ecotone + +If you want to help building and improving Ecotone consider becoming a sponsor: + +- [Sponsor Ecotone](https://github.com/sponsors/dgafka) +- [Contribute to Ecotone](https://github.com/ecotoneframework/ecotone-dev). + +## Tags + +PHP, Ecotone, {{package-specific tags}} diff --git a/packages/Tempest/composer.json b/packages/Tempest/composer.json new file mode 100644 index 000000000..ee704e6de --- /dev/null +++ b/packages/Tempest/composer.json @@ -0,0 +1,93 @@ +{ + "name": "ecotone/tempest", + "license": [ + "Apache-2.0", + "proprietary" + ], + "homepage": "https://docs.ecotone.tech/", + "forum": "https://discord.gg/GwM2BSuXeg", + "type": "library", + "minimum-stability": "dev", + "prefer-stable": true, + "authors": [ + { + "name": "Dariusz Gafka", + "email": "support@simplycodedsoftware.com" + } + ], + "keywords": [ + "ddd", + "cqrs", + "tempest", + "event-sourcing", + "sagas", + "durable-workflows", + "workflow", + "outbox", + "ecotone", + "messaging", + "eip" + ], + "description": "Ecotone for Tempest — CQRS, Event Sourcing, Sagas, Durable Workflows, and Outbox via PHP attributes.", + "repositories": [ + { + "type": "path", + "url": "../*", + "options": { + "symlink": true + } + } + ], + "autoload": { + "psr-4": { + "Ecotone\\Tempest\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\Ecotone\\Tempest\\": "tests" + } + }, + "require": { + "php": "^8.4", + "ecotone/ecotone": "~1.314.0", + "tempest/framework": "^3.11" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^1.8", + "symfony/expression-language": "^6.4|^7.0|^8.0" + }, + "scripts": { + "tests:phpstan": "vendor/bin/phpstan", + "tests:phpunit": [ + "vendor/bin/phpunit --no-coverage" + ], + "tests:ci": [ + "@tests:phpstan", + "@tests:phpunit" + ] + }, + "extra": { + "branch-alias": { + "dev-main": "1.314.0-dev" + }, + "ecotone": { + "repository": "tempest" + }, + "tempest": { + "can-discover": true + }, + "license-info": { + "Apache-2.0": { + "name": "Apache License 2.0", + "url": "https://github.com/ecotoneframework/ecotone-dev/blob/main/LICENSE", + "description": "Allows to use non Enterprise features of Ecotone. For more information please write to support@simplycodedsoftware.com" + }, + "proprietary": { + "name": "Enterprise License", + "description": "Allows to use Enterprise features of Ecotone. For more information please write to support@simplycodedsoftware.com" + } + } + } +} diff --git a/packages/Tempest/phpstan.neon b/packages/Tempest/phpstan.neon new file mode 100644 index 000000000..672e0fa1f --- /dev/null +++ b/packages/Tempest/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 1 + paths: + - src \ No newline at end of file diff --git a/packages/Tempest/phpunit.xml.dist b/packages/Tempest/phpunit.xml.dist new file mode 100644 index 000000000..2d5398a2c --- /dev/null +++ b/packages/Tempest/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + ./src + + + + + + + + + + tests + + + diff --git a/packages/Tempest/src/EcotoneConfig.php b/packages/Tempest/src/EcotoneConfig.php new file mode 100644 index 000000000..3263ed486 --- /dev/null +++ b/packages/Tempest/src/EcotoneConfig.php @@ -0,0 +1,32 @@ +serviceName === '') { + $this->serviceName = (string) env('ECOTONE_SERVICE_NAME', ''); + } + if (! $this->cacheConfiguration) { + $this->cacheConfiguration = (bool) env('ECOTONE_CACHE_CONFIGURATION', false); + } + } +} diff --git a/packages/Tempest/src/EcotoneServiceInitializer.php b/packages/Tempest/src/EcotoneServiceInitializer.php new file mode 100644 index 000000000..d48efddbf --- /dev/null +++ b/packages/Tempest/src/EcotoneServiceInitializer.php @@ -0,0 +1,57 @@ +getName()]); + } + + return $this->isKnownEcotoneGateway($class->getName()); + } + + public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Container $container): object + { + $configuredMessagingSystem = $container->get(ConfiguredMessagingSystem::class); + + return $configuredMessagingSystem->getGatewayByName($class->getName()); + } + + private function isKnownEcotoneGateway(string $className): bool + { + return in_array($className, [ + CommandBus::class, + QueryBus::class, + EventBus::class, + ], true); + } +} diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php new file mode 100644 index 000000000..9c50163d7 --- /dev/null +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -0,0 +1,183 @@ +resolveEcotoneConfig($container); + $rootPath = getcwd(); + $cacheDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest'; + $environment = getenv('APP_ENV') ?: 'production'; + $useProductionCache = in_array($environment, ['prod', 'production'], true) ? true : $config->cacheConfiguration; + + $applicationConfiguration = $this->buildServiceConfiguration($config, $environment, $cacheDirectory); + + [$serviceCacheConfiguration, $definitionHolder] = $this->prepareFromCache( + $useProductionCache, + $rootPath, + $applicationConfiguration, + $config->test, + $cacheDirectory, + ); + + $ecotoneContainer = new LazyInMemoryContainer( + $definitionHolder->getDefinitions(), + new TempestPsrContainerAdapter($container), + ); + + $ecotoneContainer->set( + ConfigurationVariableService::REFERENCE_NAME, + new TempestConfigurationVariableService(), + ); + + $ecotoneContainer->set( + ServiceCacheConfiguration::REFERENCE_NAME, + $serviceCacheConfiguration, + ); + + $this->wireLogger($container, $ecotoneContainer); + + EcotoneServiceInitializer::markCompiled(array_keys($definitionHolder->getDefinitions())); + + return $ecotoneContainer->get(ConfiguredMessagingSystem::class); + } + + private function resolveEcotoneConfig(Container $container): EcotoneConfig + { + if ($container->has(EcotoneConfig::class)) { + return $container->get(EcotoneConfig::class); + } + + return new EcotoneConfig(); + } + + private function wireLogger(Container $container, LazyInMemoryContainer $ecotoneContainer): void + { + if ($container->has(LoggerInterface::class)) { + $ecotoneContainer->set(LoggerInterface::class, $container->get(LoggerInterface::class)); + } + } + + private function buildServiceConfiguration( + EcotoneConfig $config, + string $environment, + string $cacheDirectory, + ): ServiceConfiguration { + $applicationConfiguration = ServiceConfiguration::createWithDefaults() + ->withEnvironment($environment) + ->withLoadCatalog('') + ->withFailFast(false) + ->withNamespaces($config->namespaces) + ->withSkippedModulePackageNames($config->skippedModulePackageNames) + ->withCacheDirectoryPath($cacheDirectory); + + if ($config->serviceName !== '') { + $applicationConfiguration = $applicationConfiguration->withServiceName($config->serviceName); + } + + if ($config->defaultSerializationMediaType !== '') { + $applicationConfiguration = $applicationConfiguration->withDefaultSerializationMediaType($config->defaultSerializationMediaType); + } + + if ($config->defaultErrorChannel !== '') { + $applicationConfiguration = $applicationConfiguration->withDefaultErrorChannel($config->defaultErrorChannel); + } + + if ($config->licenceKey !== '') { + $applicationConfiguration = $applicationConfiguration->withLicenceKey($config->licenceKey); + } + + return MessagingSystemConfiguration::addCorePackage($applicationConfiguration, $config->test); + } + + private function prepareFromCache( + bool $useProductionCache, + string $rootCatalog, + ServiceConfiguration $applicationConfiguration, + bool $enableTesting, + string $cacheDirectory, + ): array { + if ($useProductionCache && $cacheDirectory) { + $messagingFile = $cacheDirectory . DIRECTORY_SEPARATOR . self::MESSAGING_SYSTEM_FILE_NAME; + + if (file_exists($messagingFile)) { + $definitionHolder = unserialize(file_get_contents($messagingFile)); + + if ($definitionHolder instanceof ContainerDefinitionsHolder) { + return [new ServiceCacheConfiguration($cacheDirectory, true), $definitionHolder]; + } + } + } + + $annotationFinder = AnnotationFinderFactory::createForAttributes( + realpath($rootCatalog) ?: $rootCatalog, + $applicationConfiguration->getNamespaces(), + $applicationConfiguration->getEnvironment(), + $applicationConfiguration->getLoadedCatalog() ?? '', + MessagingSystemConfiguration::getModuleClassesFor($applicationConfiguration), + isRunningForTesting: $enableTesting, + ); + + $cacheHash = $annotationFinder->getCacheMessagingFileNameBasedOnConfig( + realpath($rootCatalog) ?: $rootCatalog, + $applicationConfiguration, + [], + $enableTesting, + ); + + $serviceCacheConfiguration = new ServiceCacheConfiguration( + $useProductionCache ? $cacheDirectory : ($cacheDirectory . DIRECTORY_SEPARATOR . $cacheHash), + true, + ); + + $definitionHolder = null; + $messagingSystemCachePath = $serviceCacheConfiguration->getPath() . DIRECTORY_SEPARATOR . self::MESSAGING_SYSTEM_FILE_NAME; + + if ($serviceCacheConfiguration->shouldUseCache() && file_exists($messagingSystemCachePath)) { + $definitionHolder = unserialize(file_get_contents($messagingSystemCachePath)); + } + + if (! $definitionHolder instanceof ContainerDefinitionsHolder) { + $configuration = MessagingSystemConfiguration::prepareWithAnnotationFinder( + $annotationFinder, + new TempestConfigurationVariableService(), + $applicationConfiguration, + enableTestPackage: $enableTesting, + ); + $definitionHolder = ContainerConfig::buildDefinitionHolder($configuration); + + if ($serviceCacheConfiguration->shouldUseCache()) { + MessagingSystemConfiguration::prepareCacheDirectory($serviceCacheConfiguration); + file_put_contents($messagingSystemCachePath, serialize($definitionHolder)); + } + } + + return [$serviceCacheConfiguration, $definitionHolder]; + } +} diff --git a/packages/Tempest/src/TempestConfigurationVariableService.php b/packages/Tempest/src/TempestConfigurationVariableService.php new file mode 100644 index 000000000..dce93e94c --- /dev/null +++ b/packages/Tempest/src/TempestConfigurationVariableService.php @@ -0,0 +1,25 @@ +container->get($id); + } + + public function has(string $id): bool + { + if ($this->container->has($id)) { + return true; + } + + if (! class_exists($id) && ! interface_exists($id)) { + return false; + } + + try { + $reflection = new ReflectionClass($id); + return $reflection->isInstantiable(); + } catch (ReflectionException) { + return false; + } + } +} diff --git a/packages/Tempest/tests/Application/TempestApplicationTest.php b/packages/Tempest/tests/Application/TempestApplicationTest.php new file mode 100644 index 000000000..77f368dac --- /dev/null +++ b/packages/Tempest/tests/Application/TempestApplicationTest.php @@ -0,0 +1,55 @@ +assertInstanceOf( + CommandBus::class, + $this->container->get(CommandBus::class), + ); + } + + public function test_gateways_injectable_and_command_handler_flow_runs_end_to_end(): void + { + $userId = '123'; + + $commandBus = $this->container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + + $this->assertEquals( + User::register($userId), + $userRepository->getUser($userId), + ); + } + + public function test_expression_language_in_payload(): void + { + $commandBus = $this->container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $amount = 123; + $commandBus->sendWithRouting('setAmount', ['amount' => $amount]); + + $this->assertEquals( + $amount, + $queryBus->sendWithRouting('getAmount'), + ); + } +} diff --git a/packages/Tempest/tests/EcotoneIntegrationTest.php b/packages/Tempest/tests/EcotoneIntegrationTest.php new file mode 100644 index 000000000..665f4a86d --- /dev/null +++ b/packages/Tempest/tests/EcotoneIntegrationTest.php @@ -0,0 +1,103 @@ +internalStorage = '/tmp/ecotone_tempest_test_storage_' . getmypid(); + + $allLocations = [...$this->discoveryLocations, ...$this->discoverTestLocations()]; + + $kernel = new FrameworkKernel( + root: $this->root, + discoveryLocations: $allLocations, + internalStorage: $this->internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryAndConfig($kernel, $allLocations); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $this->kernel = $kernel; + $this->container = $kernel->container; + + $this->container->config($this->ecotoneConfig()); + + return $this; + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + restore_error_handler(); + EcotoneServiceInitializer::clearCache(); + } + + private function injectDiscoveryAndConfig(FrameworkKernel $kernel, array $extraLocations): void + { + $testAppComposer = (new Composer($this->root))->load(); + $testAppComposer->namespaces = []; + + $autoloadLocations = (new AutoloadDiscoveryLocations( + rootPath: '/data/app', + composer: $testAppComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [...$extraLocations, ...$autoloadLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } +} diff --git a/packages/Tempest/tests/Fixture/ExpressionLanguage/ExpressionLanguageCommandHandler.php b/packages/Tempest/tests/Fixture/ExpressionLanguage/ExpressionLanguageCommandHandler.php new file mode 100644 index 000000000..b8c75e755 --- /dev/null +++ b/packages/Tempest/tests/Fixture/ExpressionLanguage/ExpressionLanguageCommandHandler.php @@ -0,0 +1,29 @@ +amount = $amount; + } + + #[QueryHandler('getAmount')] + public function getAmount(): int + { + return $this->amount; + } +} diff --git a/packages/Tempest/tests/Fixture/User/MessagingConfiguration.php b/packages/Tempest/tests/Fixture/User/MessagingConfiguration.php new file mode 100644 index 000000000..3b033087d --- /dev/null +++ b/packages/Tempest/tests/Fixture/User/MessagingConfiguration.php @@ -0,0 +1,20 @@ +userRepository->save(User::register($userId)); + } +} diff --git a/packages/Tempest/tests/TempestConfigurationVariableServiceTest.php b/packages/Tempest/tests/TempestConfigurationVariableServiceTest.php new file mode 100644 index 000000000..1e39fd22f --- /dev/null +++ b/packages/Tempest/tests/TempestConfigurationVariableServiceTest.php @@ -0,0 +1,33 @@ +assertTrue($service->hasName('ECOTONE_TEST_VAR')); + $this->assertSame('test-value', $service->getByName('ECOTONE_TEST_VAR')); + } + + public function test_returns_false_for_missing_env_variable(): void + { + $service = new TempestConfigurationVariableService(); + + $this->assertFalse($service->hasName('ECOTONE_NONEXISTENT_VAR_XYZ')); + $this->assertNull($service->getByName('ECOTONE_NONEXISTENT_VAR_XYZ')); + } +} diff --git a/packages/Tempest/tests/app/composer.json b/packages/Tempest/tests/app/composer.json new file mode 100644 index 000000000..622c8888d --- /dev/null +++ b/packages/Tempest/tests/app/composer.json @@ -0,0 +1,13 @@ +{ + "name": "ecotone/tempest-test-app", + "type": "project", + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "require": { + "ecotone/tempest": "*", + "tempest/framework": "*" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index dc3995aa4..260ff1ae5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,6 +16,7 @@ packages/PdoEventSourcing/src packages/JmsConverter/src packages/Laravel/src + packages/Tempest/src packages/Symfony/App packages/Symfony/DependencyInjection packages/Symfony/SymfonyBundle @@ -55,6 +56,9 @@ packages/Laravel/tests + + packages/Tempest/tests + packages/Symfony/tests From f09059d78b8586d7460dd06c59698f8f75281d73 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 17:39:18 +0200 Subject: [PATCH 02/27] fix: resolve custom business interfaces from Tempest container before core gateways EcotoneServiceInitializer::canInitialize() previously matched only the three core gateways (CommandBus/QueryBus/EventBus) until the messaging system was first built. A custom business interface (#[BusinessMethod] gateway interface) resolved as the very first Ecotone touch from Tempest's container was missed and caused DependencyCouldNotBeInstantiated. Fix: when $compiledServiceIds is null and EcotoneConfig is already registered in the container (i.e., after test setup / app boot), eagerly trigger MessagingSystemInitializer via GenericContainer::instance()->get(ConfiguredMessagingSystem) using a $compiling guard to prevent re-entrancy. Once compiled, the full set of service IDs is used to answer canInitialize() order-independently. Uses GenericContainer::instance() (static accessor) instead of injecting Container in the constructor to avoid the circular-dependency chain that Tempest detects when DynamicInitializer is autowired during boot. --- .../Tempest/src/EcotoneServiceInitializer.php | 32 ++++++++------ .../BusinessInterfaceResolutionTest.php | 43 +++++++++++++++++++ .../tests/Fixture/Counter/CounterGateway.php | 16 +++++++ .../tests/Fixture/Counter/CounterService.php | 28 ++++++++++++ 4 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php create mode 100644 packages/Tempest/tests/Fixture/Counter/CounterGateway.php create mode 100644 packages/Tempest/tests/Fixture/Counter/CounterService.php diff --git a/packages/Tempest/src/EcotoneServiceInitializer.php b/packages/Tempest/src/EcotoneServiceInitializer.php index d48efddbf..3f592856d 100644 --- a/packages/Tempest/src/EcotoneServiceInitializer.php +++ b/packages/Tempest/src/EcotoneServiceInitializer.php @@ -5,11 +5,9 @@ namespace Ecotone\Tempest; use Ecotone\Messaging\Config\ConfiguredMessagingSystem; -use Ecotone\Modelling\CommandBus; -use Ecotone\Modelling\EventBus; -use Ecotone\Modelling\QueryBus; use Tempest\Container\Container; use Tempest\Container\DynamicInitializer; +use Tempest\Container\GenericContainer; use Tempest\Reflection\ClassReflector; use UnitEnum; @@ -20,9 +18,12 @@ final class EcotoneServiceInitializer implements DynamicInitializer { private static ?array $compiledServiceIds = null; + private static bool $compiling = false; + public static function clearCache(): void { self::$compiledServiceIds = null; + self::$compiling = false; } public static function markCompiled(array $serviceIds): void @@ -36,7 +37,21 @@ public function canInitialize(ClassReflector $class, null|string|UnitEnum $tag): return isset(self::$compiledServiceIds[$class->getName()]); } - return $this->isKnownEcotoneGateway($class->getName()); + if (self::$compiling) { + return false; + } + + $container = GenericContainer::instance(); + + if ($container === null || ! $container->has(EcotoneConfig::class)) { + return false; + } + + self::$compiling = true; + $container->get(ConfiguredMessagingSystem::class); + self::$compiling = false; + + return isset(self::$compiledServiceIds[$class->getName()]); } public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Container $container): object @@ -45,13 +60,4 @@ public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Con return $configuredMessagingSystem->getGatewayByName($class->getName()); } - - private function isKnownEcotoneGateway(string $className): bool - { - return in_array($className, [ - CommandBus::class, - QueryBus::class, - EventBus::class, - ], true); - } } diff --git a/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php b/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php new file mode 100644 index 000000000..e772ec0d3 --- /dev/null +++ b/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php @@ -0,0 +1,43 @@ +container->get(CounterGateway::class); + + $this->assertInstanceOf(CounterGateway::class, $counterGateway); + } + + public function test_custom_business_interface_forwards_to_handler_when_resolved_first(): void + { + $counterGateway = $this->container->get(CounterGateway::class); + + $commandBus = $this->container->get(\Ecotone\Modelling\CommandBus::class); + $commandBus->sendWithRouting('counter.increment'); + + $this->assertSame(1, $counterGateway->get()); + } +} diff --git a/packages/Tempest/tests/Fixture/Counter/CounterGateway.php b/packages/Tempest/tests/Fixture/Counter/CounterGateway.php new file mode 100644 index 000000000..f06d9f340 --- /dev/null +++ b/packages/Tempest/tests/Fixture/Counter/CounterGateway.php @@ -0,0 +1,16 @@ +count++; + } + + #[QueryHandler('counter.get')] + public function get(): int + { + return $this->count; + } +} From b76ad871c1a77ece92742e86603351aeefdd24bf Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 17:43:35 +0200 Subject: [PATCH 03/27] feat: console command proxy generator and Tempest discovery integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements spec item 7 (Stage 1, Gap 2): Ecotone registered commands are now exposed to Tempest's console system so `./tempest` can list and run them. ## Components **ConsoleCommandProxyGenerator** — takes ConsoleCommandConfiguration[] and writes one PHP proxy class per command to a directory. Each proxy carries #[ConsoleCommand(name: '', allowDynamicArguments: true)] on its __invoke() method, resolves ConfiguredMessagingSystem and ConsoleArgumentBag via #[Inject], and forwards all console arguments to ConsoleCommandRunner::execute(). **EcotoneConsoleCommandDiscovery implements Discovery** — discovered automatically by Tempest's DiscoveryDiscovery since it is on the Ecotone\Tempest\ discovery location. Its apply() hook: (a) guards on EcotoneConfig being present (skips early if kernel not yet configured); (b) triggers MessagingSystemInitializer directly to compile the definition holder (avoids container initializer ordering issue where InitializerDiscovery may not have run yet); (c) generates proxy files to sys_get_temp_dir()/ecotone_tempest_console_proxies/; (d) requires them; (e) registers each proxy's __invoke method with ConsoleConfig::addCommand() via MethodReflector, making the commands available to Tempest's console application. **MessagingSystemInitializer** — now stores the ContainerDefinitionsHolder in a static property (getDefinitionHolder/clearDefinitionHolder) so EcotoneConsoleCommandDiscovery can access the registered command configs after compilation. ## Lifecycle hook decision Tempest's Discovery.apply() hook is chosen over KernelEvent::BOOTED because: - apply() runs during bootDiscovery() BEFORE the BOOTED event fires - Commands need to be in ConsoleConfig before the console application starts - The Discovery interface is auto-discovered with no additional dependencies - BOOTED would be too late if ConsoleApplication queries commands before BOOTED Limitation: proxy files live in sys_get_temp_dir() across requests; they are regenerated each boot (no content hash check). A future improvement could cache using the Ecotone config hash, but the current approach is correct and tested. ## EcotoneIntegrationTest change EcotoneConfig is now registered in the container BEFORE bootDiscovery() (moved from after the full kernel boot) so that EcotoneConsoleCommandDiscovery::apply() can check container->has(EcotoneConfig::class) and trigger compilation. --- .../src/ConsoleCommandProxyGenerator.php | 94 +++++++++++++++++++ .../src/EcotoneConsoleCommandDiscovery.php | 90 ++++++++++++++++++ .../src/MessagingSystemInitializer.php | 14 +++ .../Application/ConsoleCommandProxyTest.php | 60 ++++++++++++ .../Tempest/tests/EcotoneIntegrationTest.php | 6 +- 5 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 packages/Tempest/src/ConsoleCommandProxyGenerator.php create mode 100644 packages/Tempest/src/EcotoneConsoleCommandDiscovery.php create mode 100644 packages/Tempest/tests/Application/ConsoleCommandProxyTest.php diff --git a/packages/Tempest/src/ConsoleCommandProxyGenerator.php b/packages/Tempest/src/ConsoleCommandProxyGenerator.php new file mode 100644 index 000000000..025e4378b --- /dev/null +++ b/packages/Tempest/src/ConsoleCommandProxyGenerator.php @@ -0,0 +1,94 @@ +writeProxyFile($configuration, $outputDirectory); + } + + return $generatedFiles; + } + + private function writeProxyFile(ConsoleCommandConfiguration $configuration, string $outputDirectory): string + { + $commandName = $configuration->getName(); + $className = $this->buildClassName($commandName); + $filePath = $outputDirectory . DIRECTORY_SEPARATOR . $className . '.php'; + + file_put_contents($filePath, $this->buildProxyClassCode($className, $commandName)); + + return $filePath; + } + + private function buildProxyClassCode(string $className, string $commandName): string + { + $escapedCommandName = addslashes($commandName); + + return <<messagingSystem->getGatewayByName(ConsoleCommandRunner::class); + \$allArgs = []; + foreach (\$this->argumentBag->all() as \$arg) { + \$allArgs[\$arg->name !== null ? \$arg->name : ''] = \$arg->value; + } + \$result = \$runner->execute('{$escapedCommandName}', \$allArgs); + return ExitCode::SUCCESS; + } + } + PHP; + } + + private function buildClassName(string $commandName): string + { + return 'EcotoneConsoleCommand_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $commandName); + } +} diff --git a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php new file mode 100644 index 000000000..b3acf20b9 --- /dev/null +++ b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php @@ -0,0 +1,90 @@ +container->has(EcotoneConfig::class)) { + return; + } + + if (MessagingSystemInitializer::getDefinitionHolder() === null) { + (new MessagingSystemInitializer())->initialize($this->container); + } + + $definitionHolder = MessagingSystemInitializer::getDefinitionHolder(); + + if ($definitionHolder === null) { + return; + } + + $commands = $definitionHolder->getRegisteredCommands(); + + if ($commands === []) { + return; + } + + $outputDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies'; + + $generator = new ConsoleCommandProxyGenerator(); + $generatedFiles = $generator->generate($commands, $outputDirectory); + + foreach ($generatedFiles as $file) { + require_once $file; + } + + foreach ($commands as $commandConfiguration) { + $className = 'Ecotone\\Tempest\\Generated\\' . $this->buildClassName($commandConfiguration->getName()); + + if (! class_exists($className)) { + continue; + } + + $classReflector = new ClassReflector($className); + + foreach ($classReflector->getPublicMethods() as $method) { + $consoleCommand = $method->getAttribute(ConsoleCommand::class); + + if ($consoleCommand === null) { + continue; + } + + $this->consoleConfig->addCommand($method, $consoleCommand); + } + } + } + + private function buildClassName(string $commandName): string + { + return 'EcotoneConsoleCommand_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $commandName); + } +} diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php index 9c50163d7..a46efc08f 100644 --- a/packages/Tempest/src/MessagingSystemInitializer.php +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -28,6 +28,18 @@ final class MessagingSystemInitializer implements Initializer { public const MESSAGING_SYSTEM_FILE_NAME = 'messaging_system'; + private static ?ContainerDefinitionsHolder $definitionHolder = null; + + public static function getDefinitionHolder(): ?ContainerDefinitionsHolder + { + return self::$definitionHolder; + } + + public static function clearDefinitionHolder(): void + { + self::$definitionHolder = null; + } + public function initialize(Container $container): ConfiguredMessagingSystem { $config = $this->resolveEcotoneConfig($container); @@ -46,6 +58,8 @@ public function initialize(Container $container): ConfiguredMessagingSystem $cacheDirectory, ); + self::$definitionHolder = $definitionHolder; + $ecotoneContainer = new LazyInMemoryContainer( $definitionHolder->getDefinitions(), new TempestPsrContainerAdapter($container), diff --git a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php new file mode 100644 index 000000000..f02de043a --- /dev/null +++ b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php @@ -0,0 +1,60 @@ +generate( + [ + \Ecotone\Messaging\Config\ConsoleCommandConfiguration::create( + 'ecotone.channel.ecotone:list', + 'ecotone:list', + [], + ), + ], + $outputDir, + ); + + $this->assertCount(1, $generatedClasses); + $this->assertFileExists($generatedClasses[0]); + + $fileContent = file_get_contents($generatedClasses[0]); + $this->assertStringContainsString("name: 'ecotone:list'", $fileContent); + + array_map('unlink', $generatedClasses); + @rmdir($outputDir); + } + + public function test_ecotone_commands_are_registered_in_tempest_console_config(): void + { + $consoleConfig = $this->container->get(\Tempest\Console\ConsoleConfig::class); + + $this->assertArrayHasKey('ecotone:list', $consoleConfig->commands); + $this->assertArrayHasKey('ecotone:run', $consoleConfig->commands); + } +} diff --git a/packages/Tempest/tests/EcotoneIntegrationTest.php b/packages/Tempest/tests/EcotoneIntegrationTest.php index 665f4a86d..21719a0f6 100644 --- a/packages/Tempest/tests/EcotoneIntegrationTest.php +++ b/packages/Tempest/tests/EcotoneIntegrationTest.php @@ -7,6 +7,7 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\EcotoneConfig; use Ecotone\Tempest\EcotoneServiceInitializer; +use Ecotone\Tempest\MessagingSystemInitializer; use Tempest\Core\FrameworkKernel; use Tempest\Core\KernelEvent; use Tempest\Discovery\AutoloadDiscoveryLocations; @@ -63,6 +64,8 @@ protected function setupKernel(): self $this->injectDiscoveryAndConfig($kernel, $allLocations); + $kernel->container->config($this->ecotoneConfig()); + $kernel->loadConfig() ->bootDiscovery() ->registerExceptionHandler() @@ -71,8 +74,6 @@ protected function setupKernel(): self $this->kernel = $kernel; $this->container = $kernel->container; - $this->container->config($this->ecotoneConfig()); - return $this; } @@ -82,6 +83,7 @@ protected function tearDown(): void restore_exception_handler(); restore_error_handler(); EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); } private function injectDiscoveryAndConfig(FrameworkKernel $kernel, array $extraLocations): void From e2df7b357740444320e97951dd4ea7a88fcfe561 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 18:00:48 +0200 Subject: [PATCH 04/27] feat: gate console proxy regeneration on config hash ConsoleCommandProxyGenerator.generate() now accepts an optional configHash; when provided it writes a .ecotone_hash marker file in the output directory and skips all file writing on subsequent calls when the hash is unchanged. MessagingSystemInitializer stores and exposes the computed cacheHash (from getCacheMessagingFileNameBasedOnConfig) via getConfigHash(), and EcotoneConsoleCommandDiscovery passes that hash to the generator so proxy files are only rewritten when the Ecotone config changes. ConsoleConfig registration still happens on every boot. --- .../src/ConsoleCommandProxyGenerator.php | 45 +++++++++++++- .../src/EcotoneConsoleCommandDiscovery.php | 2 +- .../src/MessagingSystemInitializer.php | 15 ++++- .../Application/ConsoleCommandProxyTest.php | 58 ++++++++++++++++++- 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/packages/Tempest/src/ConsoleCommandProxyGenerator.php b/packages/Tempest/src/ConsoleCommandProxyGenerator.php index 025e4378b..a5fe268b9 100644 --- a/packages/Tempest/src/ConsoleCommandProxyGenerator.php +++ b/packages/Tempest/src/ConsoleCommandProxyGenerator.php @@ -13,25 +13,68 @@ */ final class ConsoleCommandProxyGenerator { + private const HASH_MARKER_FILE = '.ecotone_hash'; + /** * @param ConsoleCommandConfiguration[] $commandConfigurations * @return string[] absolute paths to the generated proxy files */ - public function generate(array $commandConfigurations, string $outputDirectory): array + public function generate(array $commandConfigurations, string $outputDirectory, ?string $configHash = null): array { if (! is_dir($outputDirectory)) { mkdir($outputDirectory, 0777, true); } + $generatedFiles = $this->resolveExpectedFilePaths($commandConfigurations, $outputDirectory); + + if ($configHash !== null && $this->isHashUnchanged($outputDirectory, $configHash, $generatedFiles)) { + return $generatedFiles; + } + $generatedFiles = []; foreach ($commandConfigurations as $configuration) { $generatedFiles[] = $this->writeProxyFile($configuration, $outputDirectory); } + if ($configHash !== null) { + file_put_contents($outputDirectory . DIRECTORY_SEPARATOR . self::HASH_MARKER_FILE, $configHash); + } + return $generatedFiles; } + private function resolveExpectedFilePaths(array $commandConfigurations, string $outputDirectory): array + { + $files = []; + foreach ($commandConfigurations as $configuration) { + $className = $this->buildClassName($configuration->getName()); + $files[] = $outputDirectory . DIRECTORY_SEPARATOR . $className . '.php'; + } + return $files; + } + + private function isHashUnchanged(string $outputDirectory, string $configHash, array $expectedFiles): bool + { + $markerFile = $outputDirectory . DIRECTORY_SEPARATOR . self::HASH_MARKER_FILE; + + if (! file_exists($markerFile)) { + return false; + } + + if (file_get_contents($markerFile) !== $configHash) { + return false; + } + + foreach ($expectedFiles as $file) { + if (! file_exists($file)) { + return false; + } + } + + return true; + } + private function writeProxyFile(ConsoleCommandConfiguration $configuration, string $outputDirectory): string { $commandName = $configuration->getName(); diff --git a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php index b3acf20b9..d4389ad81 100644 --- a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php +++ b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php @@ -56,7 +56,7 @@ public function apply(): void $outputDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies'; $generator = new ConsoleCommandProxyGenerator(); - $generatedFiles = $generator->generate($commands, $outputDirectory); + $generatedFiles = $generator->generate($commands, $outputDirectory, MessagingSystemInitializer::getConfigHash()); foreach ($generatedFiles as $file) { require_once $file; diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php index a46efc08f..d0597287e 100644 --- a/packages/Tempest/src/MessagingSystemInitializer.php +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -30,14 +30,22 @@ final class MessagingSystemInitializer implements Initializer private static ?ContainerDefinitionsHolder $definitionHolder = null; + private static ?string $configHash = null; + public static function getDefinitionHolder(): ?ContainerDefinitionsHolder { return self::$definitionHolder; } + public static function getConfigHash(): ?string + { + return self::$configHash; + } + public static function clearDefinitionHolder(): void { self::$definitionHolder = null; + self::$configHash = null; } public function initialize(Container $container): ConfiguredMessagingSystem @@ -50,7 +58,7 @@ public function initialize(Container $container): ConfiguredMessagingSystem $applicationConfiguration = $this->buildServiceConfiguration($config, $environment, $cacheDirectory); - [$serviceCacheConfiguration, $definitionHolder] = $this->prepareFromCache( + [$serviceCacheConfiguration, $definitionHolder, $configHash] = $this->prepareFromCache( $useProductionCache, $rootPath, $applicationConfiguration, @@ -59,6 +67,7 @@ public function initialize(Container $container): ConfiguredMessagingSystem ); self::$definitionHolder = $definitionHolder; + self::$configHash = $configHash; $ecotoneContainer = new LazyInMemoryContainer( $definitionHolder->getDefinitions(), @@ -144,7 +153,7 @@ private function prepareFromCache( $definitionHolder = unserialize(file_get_contents($messagingFile)); if ($definitionHolder instanceof ContainerDefinitionsHolder) { - return [new ServiceCacheConfiguration($cacheDirectory, true), $definitionHolder]; + return [new ServiceCacheConfiguration($cacheDirectory, true), $definitionHolder, null]; } } } @@ -192,6 +201,6 @@ private function prepareFromCache( } } - return [$serviceCacheConfiguration, $definitionHolder]; + return [$serviceCacheConfiguration, $definitionHolder, $cacheHash]; } } diff --git a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php index f02de043a..b76f116ac 100644 --- a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php +++ b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php @@ -4,6 +4,7 @@ namespace Test\Ecotone\Tempest\Application; +use Ecotone\Messaging\Config\ConsoleCommandConfiguration; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\ConsoleCommandProxyGenerator; use Ecotone\Tempest\EcotoneConfig; @@ -31,7 +32,7 @@ public function test_generator_produces_proxy_files_for_registered_ecotone_comma $generator = new ConsoleCommandProxyGenerator(); $generatedClasses = $generator->generate( [ - \Ecotone\Messaging\Config\ConsoleCommandConfiguration::create( + ConsoleCommandConfiguration::create( 'ecotone.channel.ecotone:list', 'ecotone:list', [], @@ -57,4 +58,59 @@ public function test_ecotone_commands_are_registered_in_tempest_console_config() $this->assertArrayHasKey('ecotone:list', $consoleConfig->commands); $this->assertArrayHasKey('ecotone:run', $consoleConfig->commands); } + + public function test_proxy_files_are_not_rewritten_when_config_hash_is_unchanged(): void + { + $outputDir = sys_get_temp_dir() . '/ecotone_proxy_hash_test_' . getmypid(); + $commands = [ + ConsoleCommandConfiguration::create('ecotone.channel.ecotone:list', 'ecotone:list', []), + ]; + $configHash = 'abc123'; + + $generator = new ConsoleCommandProxyGenerator(); + $firstFiles = $generator->generate($commands, $outputDir, $configHash); + + $mtimeBefore = filemtime($firstFiles[0]); + + sleep(1); + + $secondFiles = $generator->generate($commands, $outputDir, $configHash); + + $mtimeAfter = filemtime($secondFiles[0]); + + $this->assertSame($mtimeBefore, $mtimeAfter); + + foreach ($firstFiles as $file) { + @unlink($file); + } + @unlink($outputDir . '/.ecotone_hash'); + @rmdir($outputDir); + } + + public function test_proxy_files_are_rewritten_when_config_hash_changes(): void + { + $outputDir = sys_get_temp_dir() . '/ecotone_proxy_rehash_test_' . getmypid(); + $commands = [ + ConsoleCommandConfiguration::create('ecotone.channel.ecotone:list', 'ecotone:list', []), + ]; + + $generator = new ConsoleCommandProxyGenerator(); + $firstFiles = $generator->generate($commands, $outputDir, 'hash_v1'); + + $mtimeBefore = filemtime($firstFiles[0]); + + sleep(1); + + $secondFiles = $generator->generate($commands, $outputDir, 'hash_v2'); + + $mtimeAfter = filemtime($secondFiles[0]); + + $this->assertGreaterThan($mtimeBefore, $mtimeAfter); + + foreach ($secondFiles as $file) { + @unlink($file); + } + @unlink($outputDir . '/.ecotone_hash'); + @rmdir($outputDir); + } } From 615d0be35114f0de37c5af4d1c035cff1ba07689 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 18:03:55 +0200 Subject: [PATCH 05/27] test: prove generated proxy forwards into Ecotone ConsoleCommandRunner Add a behavioral test asserting that the Ecotone-generated console proxy class is resolvable from the Tempest container and its __invoke() returns ExitCode::SUCCESS by forwarding execution through the real ConsoleCommandRunner gateway. Complements the registration test with end-to-end execution coverage for the proxy forwarding path. --- .../Application/ConsoleCommandProxyTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php index b76f116ac..ed721589c 100644 --- a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php +++ b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php @@ -4,8 +4,11 @@ namespace Test\Ecotone\Tempest\Application; +use Ecotone\Messaging\Config\ConfiguredMessagingSystem; use Ecotone\Messaging\Config\ConsoleCommandConfiguration; +use Ecotone\Messaging\Config\ConsoleCommandResultSet; use Ecotone\Messaging\Config\ModulePackageList; +use Ecotone\Messaging\Gateway\ConsoleCommandRunner; use Ecotone\Tempest\ConsoleCommandProxyGenerator; use Ecotone\Tempest\EcotoneConfig; use Test\Ecotone\Tempest\EcotoneIntegrationTest; @@ -59,6 +62,19 @@ public function test_ecotone_commands_are_registered_in_tempest_console_config() $this->assertArrayHasKey('ecotone:run', $consoleConfig->commands); } + public function test_generated_proxy_invoke_forwards_to_ecotone_console_command_runner_and_returns_exit_success(): void + { + $proxyClassName = 'Ecotone\\Tempest\\Generated\\EcotoneConsoleCommand_ecotone_list'; + + $this->assertTrue(class_exists($proxyClassName)); + + $proxy = $this->container->get($proxyClassName); + + $result = $proxy->__invoke(); + + $this->assertSame(\Tempest\Console\ExitCode::SUCCESS, $result); + } + public function test_proxy_files_are_not_rewritten_when_config_hash_is_unchanged(): void { $outputDir = sys_get_temp_dir() . '/ecotone_proxy_hash_test_' . getmypid(); From 369c62cfe22cdaedbfb6de0a46c9686030cf5883 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 18:06:39 +0200 Subject: [PATCH 06/27] test: demonstrate correct re-boot isolation for static Ecotone state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test proving that two sequential kernel boots in a single PHP process produce independent messaging systems: after clearing static state (EcotoneServiceInitializer::clearCache, MessagingSystemInitializer::clearDefinitionHolder) and calling setupKernel() again, the second boot builds a fresh LazyInMemoryContainer and sees no state from the first boot's command handlers. Assessment (Item 3): the static fields in MessagingSystemInitializer ($definitionHolder, $configHash) and EcotoneServiceInitializer ($compiledServiceIds) are per-process memoizations that are correct for production (one kernel per process). Test isolation is fully covered by the clearCache/clearDefinitionHolder calls in EcotoneIntegrationTest tearDown. No correctness bug exists, so no structural change is made. The only optimization gap noted: when the production cache is warm, $configHash is set to null, so proxy files are rewritten on every process start rather than being gated by hash — this is safe but suboptimal; addressing it is out of scope for Stage 1 hardening. --- .../Application/StaticStateIsolationTest.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/Tempest/tests/Application/StaticStateIsolationTest.php diff --git a/packages/Tempest/tests/Application/StaticStateIsolationTest.php b/packages/Tempest/tests/Application/StaticStateIsolationTest.php new file mode 100644 index 000000000..b27406f6d --- /dev/null +++ b/packages/Tempest/tests/Application/StaticStateIsolationTest.php @@ -0,0 +1,47 @@ +container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + $this->assertEquals(User::register($userId), $userRepository->getUser($userId)); + + restore_exception_handler(); + restore_error_handler(); + + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + + $this->setupKernel(); + + $secondCommandBus = $this->container->get(CommandBus::class); + $secondUserRepository = $this->container->get(UserRepository::class); + + $secondUserId = 'user-second-boot'; + $secondCommandBus->sendWithRouting('user.register', $secondUserId); + + $this->assertEquals(User::register($secondUserId), $secondUserRepository->getUser($secondUserId)); + } +} From f6cfd618df564b4ee6ab063384462046678054fe Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 19:56:48 +0200 Subject: [PATCH 07/27] test: add end-to-end Tempest console runner tests for ecotone:list and ecotone:run - Update ConsoleCommandProxyGenerator to inject Console and print ConsoleCommandResultSet as column-separated lines via writeln, mirroring the Symfony/Laravel integration behaviour - Add proxy-cache teardown to EcotoneIntegrationTest so each test process starts with a fresh generated-proxy directory - Add test_ecotone_list_command_runs_through_tempest_console_runner_by_name_and_prints_column_headers and test_generator_produces_proxy_code_that_prints_console_command_result_set to ConsoleCommandProxyTest - Add ConsoleCommandEndToEndTest with async-queue fixture (AsyncQueueChannelConfiguration + NotifyCommandHandler) to cover ecotone:list showing the registered consumer name and ecotone:run running that consumer to completion via finishWhenNoMessages --- .../src/ConsoleCommandProxyGenerator.php | 11 +++++ .../ConsoleCommandEndToEndTest.php | 40 +++++++++++++++++++ .../Application/ConsoleCommandProxyTest.php | 36 +++++++++++++++-- .../Application/StaticStateIsolationTest.php | 1 - .../Tempest/tests/EcotoneIntegrationTest.php | 13 ++++++ .../AsyncQueueChannelConfiguration.php | 20 ++++++++++ .../Fixture/AsyncQueue/NotifyCommand.php | 15 +++++++ .../AsyncQueue/NotifyCommandHandler.php | 23 +++++++++++ 8 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php create mode 100644 packages/Tempest/tests/Fixture/AsyncQueue/AsyncQueueChannelConfiguration.php create mode 100644 packages/Tempest/tests/Fixture/AsyncQueue/NotifyCommand.php create mode 100644 packages/Tempest/tests/Fixture/AsyncQueue/NotifyCommandHandler.php diff --git a/packages/Tempest/src/ConsoleCommandProxyGenerator.php b/packages/Tempest/src/ConsoleCommandProxyGenerator.php index a5fe268b9..c6f3b7a84 100644 --- a/packages/Tempest/src/ConsoleCommandProxyGenerator.php +++ b/packages/Tempest/src/ConsoleCommandProxyGenerator.php @@ -98,7 +98,9 @@ private function buildProxyClassCode(string $className, string $commandName): st namespace Ecotone\Tempest\Generated; use Ecotone\Messaging\Config\ConfiguredMessagingSystem; + use Ecotone\Messaging\Config\ConsoleCommandResultSet; use Ecotone\Messaging\Gateway\ConsoleCommandRunner; + use Tempest\Console\Console; use Tempest\Console\ConsoleCommand; use Tempest\Console\ExitCode; use Tempest\Console\Input\ConsoleArgumentBag; @@ -115,6 +117,9 @@ final class {$className} #[Inject] private readonly ConsoleArgumentBag \$argumentBag; + #[Inject] + private readonly Console \$console; + #[ConsoleCommand(name: '{$escapedCommandName}', allowDynamicArguments: true)] public function __invoke(): ExitCode { @@ -124,6 +129,12 @@ public function __invoke(): ExitCode \$allArgs[\$arg->name !== null ? \$arg->name : ''] = \$arg->value; } \$result = \$runner->execute('{$escapedCommandName}', \$allArgs); + if (\$result instanceof ConsoleCommandResultSet) { + \$this->console->writeln(implode(' | ', \$result->getColumnHeaders())); + foreach (\$result->getRows() as \$row) { + \$this->console->writeln(implode(' | ', \$row)); + } + } return ExitCode::SUCCESS; } } diff --git a/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php b/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php new file mode 100644 index 000000000..0cc111d43 --- /dev/null +++ b/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php @@ -0,0 +1,40 @@ +console + ->call('ecotone:list') + ->assertSuccess() + ->assertContains('ecotone_test_queue'); + } + + public function test_ecotone_run_command_runs_async_consumer_through_tempest_console_runner_by_name(): void + { + $this->console + ->call('ecotone:run', ['consumerName' => 'ecotone_test_queue', 'finishWhenNoMessages' => 'true']) + ->assertSuccess(); + } +} diff --git a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php index ed721589c..bf3b5ecbe 100644 --- a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php +++ b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php @@ -4,11 +4,8 @@ namespace Test\Ecotone\Tempest\Application; -use Ecotone\Messaging\Config\ConfiguredMessagingSystem; use Ecotone\Messaging\Config\ConsoleCommandConfiguration; -use Ecotone\Messaging\Config\ConsoleCommandResultSet; use Ecotone\Messaging\Config\ModulePackageList; -use Ecotone\Messaging\Gateway\ConsoleCommandRunner; use Ecotone\Tempest\ConsoleCommandProxyGenerator; use Ecotone\Tempest\EcotoneConfig; use Test\Ecotone\Tempest\EcotoneIntegrationTest; @@ -103,6 +100,39 @@ public function test_proxy_files_are_not_rewritten_when_config_hash_is_unchanged @rmdir($outputDir); } + public function test_ecotone_list_command_runs_through_tempest_console_runner_by_name_and_prints_column_headers(): void + { + $this->console + ->call('ecotone:list') + ->assertSuccess() + ->assertContains('Name'); + } + + public function test_generator_produces_proxy_code_that_prints_console_command_result_set(): void + { + $outputDir = sys_get_temp_dir() . '/ecotone_proxy_result_test_' . getmypid(); + + $generator = new ConsoleCommandProxyGenerator(); + $generatedClasses = $generator->generate( + [ + ConsoleCommandConfiguration::create( + 'ecotone.channel.ecotone:list', + 'ecotone:list', + [], + ), + ], + $outputDir, + ); + + $fileContent = file_get_contents($generatedClasses[0]); + $this->assertStringContainsString('Console $console', $fileContent); + $this->assertStringContainsString('ConsoleCommandResultSet', $fileContent); + $this->assertStringContainsString('writeln', $fileContent); + + array_map('unlink', $generatedClasses); + @rmdir($outputDir); + } + public function test_proxy_files_are_rewritten_when_config_hash_changes(): void { $outputDir = sys_get_temp_dir() . '/ecotone_proxy_rehash_test_' . getmypid(); diff --git a/packages/Tempest/tests/Application/StaticStateIsolationTest.php b/packages/Tempest/tests/Application/StaticStateIsolationTest.php index b27406f6d..d656d7210 100644 --- a/packages/Tempest/tests/Application/StaticStateIsolationTest.php +++ b/packages/Tempest/tests/Application/StaticStateIsolationTest.php @@ -5,7 +5,6 @@ namespace Test\Ecotone\Tempest\Application; use Ecotone\Modelling\CommandBus; -use Ecotone\Modelling\QueryBus; use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; use Test\Ecotone\Tempest\EcotoneIntegrationTest; diff --git a/packages/Tempest/tests/EcotoneIntegrationTest.php b/packages/Tempest/tests/EcotoneIntegrationTest.php index 21719a0f6..fa048a6a3 100644 --- a/packages/Tempest/tests/EcotoneIntegrationTest.php +++ b/packages/Tempest/tests/EcotoneIntegrationTest.php @@ -84,6 +84,19 @@ protected function tearDown(): void restore_error_handler(); EcotoneServiceInitializer::clearCache(); MessagingSystemInitializer::clearDefinitionHolder(); + $this->removeProxyCache(); + } + + private function removeProxyCache(): void + { + $proxyDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies'; + if (is_dir($proxyDir)) { + foreach (glob($proxyDir . '/*.php') ?: [] as $file) { + @unlink($file); + } + @unlink($proxyDir . '/.ecotone_hash'); + @rmdir($proxyDir); + } } private function injectDiscoveryAndConfig(FrameworkKernel $kernel, array $extraLocations): void diff --git a/packages/Tempest/tests/Fixture/AsyncQueue/AsyncQueueChannelConfiguration.php b/packages/Tempest/tests/Fixture/AsyncQueue/AsyncQueueChannelConfiguration.php new file mode 100644 index 000000000..250188a71 --- /dev/null +++ b/packages/Tempest/tests/Fixture/AsyncQueue/AsyncQueueChannelConfiguration.php @@ -0,0 +1,20 @@ +handled = true; + } +} From efdd046313b8e93e55e8f97f0d0ba6d24915010c Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 21:36:18 +0200 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20Stage=202=20=E2=80=94=20DBAL=20co?= =?UTF-8?q?nnection=20bridge=20for=20ecotone/tempest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TempestConnectionReference, TempestConnectionResolver, and TempestConnectionModule so Ecotone DBAL features can run off Tempest's DatabaseConfig. Ports the LaravelConnectionReference/LaravelConnectionResolver/PDO-driver pattern onto Tempest: builds a PDO from the Tempest DatabaseConfig (dsn/username/password/options), wraps it in a per-dialect Doctrine DBAL Driver+Connection, and registers an AlreadyConnectedDbalConnectionFactory under the configured reference name. Registers TEMPEST_PACKAGE in ModulePackageList/ModuleClassList so the module is discovered via the same class-filtered package mechanism as the Laravel/Symfony modules. Three Stage 2 tests (WithConnection positive, WithoutConnection negative, Connectivity live-query against docker postgres) all pass alongside the 17 Stage 1 tests. --- .../src/Messaging/Config/ModuleClassList.php | 5 + .../Messaging/Config/ModulePackageList.php | 3 + .../Tempest/src/Config/PDO/Connection.php | 100 ++++++++++++++++++ .../Tempest/src/Config/PDO/MySqlDriver.php | 18 ++++ .../Tempest/src/Config/PDO/PostgresDriver.php | 18 ++++ .../Tempest/src/Config/PDO/SQLiteDriver.php | 18 ++++ .../src/Config/TempestConnectionModule.php | 63 +++++++++++ .../src/Config/TempestConnectionReference.php | 46 ++++++++ .../src/Config/TempestConnectionResolver.php | 55 ++++++++++ .../Dbal/DbalConnectionConnectivityTest.php | 52 +++++++++ ...onnectionRequirementWithConnectionTest.php | 35 ++++++ ...ectionRequirementWithoutConnectionTest.php | 33 ++++++ .../Dbal/DbalConnectionConfiguration.php | 20 ++++ 13 files changed, 466 insertions(+) create mode 100644 packages/Tempest/src/Config/PDO/Connection.php create mode 100644 packages/Tempest/src/Config/PDO/MySqlDriver.php create mode 100644 packages/Tempest/src/Config/PDO/PostgresDriver.php create mode 100644 packages/Tempest/src/Config/PDO/SQLiteDriver.php create mode 100644 packages/Tempest/src/Config/TempestConnectionModule.php create mode 100644 packages/Tempest/src/Config/TempestConnectionReference.php create mode 100644 packages/Tempest/src/Config/TempestConnectionResolver.php create mode 100644 packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php create mode 100644 packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php create mode 100644 packages/Tempest/tests/Dbal/DbalConnectionRequirementWithoutConnectionTest.php create mode 100644 packages/Tempest/tests/Fixture/Dbal/DbalConnectionConfiguration.php diff --git a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php index 71d3b4e21..713232361 100644 --- a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php +++ b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php @@ -81,6 +81,7 @@ use Ecotone\Sqs\Configuration\SqsMessagePublisherModule; use Ecotone\Sqs\Configuration\SqsModule; use Ecotone\SymfonyBundle\Config\SymfonyConnectionModule; +use Ecotone\Tempest\Config\TempestConnectionModule; /** * licence Apache-2.0 @@ -202,6 +203,10 @@ class ModuleClassList SymfonyConnectionModule::class, ]; + public const TEMPEST_MODULES = [ + TempestConnectionModule::class, + ]; + public const KAFKA_MODULES = [ KafkaModule::class, ]; diff --git a/packages/Ecotone/src/Messaging/Config/ModulePackageList.php b/packages/Ecotone/src/Messaging/Config/ModulePackageList.php index 32b8e2e1d..6e784ae4b 100644 --- a/packages/Ecotone/src/Messaging/Config/ModulePackageList.php +++ b/packages/Ecotone/src/Messaging/Config/ModulePackageList.php @@ -25,6 +25,7 @@ final class ModulePackageList public const TRACING_PACKAGE = 'tracing'; public const LARAVEL_PACKAGE = 'laravel'; public const SYMFONY_PACKAGE = 'symfony'; + public const TEMPEST_PACKAGE = 'tempest'; public const TEST_PACKAGE = 'test'; public static function getModuleClassesForPackage(string $packageName): array @@ -43,6 +44,7 @@ public static function getModuleClassesForPackage(string $packageName): array ModulePackageList::TEST_PACKAGE => ModuleClassList::TEST_MODULES, ModulePackageList::LARAVEL_PACKAGE => ModuleClassList::LARAVEL_MODULES, ModulePackageList::SYMFONY_PACKAGE => ModuleClassList::SYMFONY_MODULES, + ModulePackageList::TEMPEST_PACKAGE => ModuleClassList::TEMPEST_MODULES, ModulePackageList::DATA_PROTECTION_PACKAGE => ModuleClassList::DATA_PROTECTION_MODULES, default => throw ConfigurationException::create(sprintf('Given unknown package name %s. Available packages name are: %s', $packageName, implode(',', self::allPackages()))) }; @@ -66,6 +68,7 @@ public static function allPackages(): array self::TRACING_PACKAGE, self::LARAVEL_PACKAGE, self::SYMFONY_PACKAGE, + self::TEMPEST_PACKAGE, self::DATA_PROTECTION_PACKAGE, ]; } diff --git a/packages/Tempest/src/Config/PDO/Connection.php b/packages/Tempest/src/Config/PDO/Connection.php new file mode 100644 index 000000000..69b8127bf --- /dev/null +++ b/packages/Tempest/src/Config/PDO/Connection.php @@ -0,0 +1,100 @@ +pdo->exec($statement); + assert($result !== false); + + return $result; + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function prepare(string $sql): StatementInterface + { + try { + return new Statement($this->pdo->prepare($sql)); + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function query(string $sql): ResultInterface + { + try { + $stmt = $this->pdo->query($sql); + assert($stmt instanceof PDOStatement); + + return new Result($stmt); + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function lastInsertId(): string|int + { + try { + return $this->pdo->lastInsertId(); + } catch (PDOException $e) { + throw Exception::new($e); + } + } + + public function beginTransaction(): void + { + $this->pdo->beginTransaction(); + } + + public function commit(): void + { + $this->pdo->commit(); + } + + public function rollBack(): void + { + $this->pdo->rollBack(); + } + + public function quote(string $value): string + { + return $this->pdo->quote($value); + } + + public function getServerVersion(): string + { + return $this->pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + } + + public function getNativeConnection(): PDO + { + return $this->pdo; + } +} diff --git a/packages/Tempest/src/Config/PDO/MySqlDriver.php b/packages/Tempest/src/Config/PDO/MySqlDriver.php new file mode 100644 index 000000000..5e7881142 --- /dev/null +++ b/packages/Tempest/src/Config/PDO/MySqlDriver.php @@ -0,0 +1,18 @@ +registerServiceDefinition( + $connection->getReferenceName(), + new Definition( + ConnectionFactory::class, + [ + $connection, + new Reference(DatabaseConfig::class), + ], + [ + TempestConnectionResolver::class, + 'resolve', + ] + ) + ); + } + } + + public function canHandle($extensionObject): bool + { + return $extensionObject instanceof TempestConnectionReference; + } + + public function getModulePackageName(): string + { + return ModulePackageList::TEMPEST_PACKAGE; + } +} diff --git a/packages/Tempest/src/Config/TempestConnectionReference.php b/packages/Tempest/src/Config/TempestConnectionReference.php new file mode 100644 index 000000000..7fac4720e --- /dev/null +++ b/packages/Tempest/src/Config/TempestConnectionReference.php @@ -0,0 +1,46 @@ +getReferenceName(), + ], + [ + self::class, + 'create', + ] + ); + } +} diff --git a/packages/Tempest/src/Config/TempestConnectionResolver.php b/packages/Tempest/src/Config/TempestConnectionResolver.php new file mode 100644 index 000000000..f8f22fee2 --- /dev/null +++ b/packages/Tempest/src/Config/TempestConnectionResolver.php @@ -0,0 +1,55 @@ +dsn, + $databaseConfig->username, + $databaseConfig->password, + $databaseConfig->options, + ); + + $driver = self::driverForDialect($databaseConfig->dialect); + + $doctrineConnection = new Connection( + ['pdo' => $pdo], + $driver, + ); + + return DbalConnection::create($doctrineConnection); + } + + private static function driverForDialect(DatabaseDialect $dialect): Driver + { + return match ($dialect) { + DatabaseDialect::POSTGRESQL => new PostgresDriver(), + DatabaseDialect::MYSQL => new MySqlDriver(), + DatabaseDialect::SQLITE => new SQLiteDriver(), + }; + } +} diff --git a/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php new file mode 100644 index 000000000..5f932147e --- /dev/null +++ b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php @@ -0,0 +1,52 @@ +setupKernel(); + $this->container->config($postgresConfig); + + $messagingSystem = $this->container->get(ConfiguredMessagingSystem::class); + $connectionFactory = $messagingSystem->getServiceFromContainer(DbalConnectionFactory::class); + + $result = $connectionFactory->createContext()->getDbalConnection()->executeQuery('SELECT 1 AS result')->fetchOne(); + + $this->assertEquals(1, $result); + } +} diff --git a/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php new file mode 100644 index 000000000..c035f26e0 --- /dev/null +++ b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php @@ -0,0 +1,35 @@ +setupKernel(); + } +} diff --git a/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithoutConnectionTest.php b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithoutConnectionTest.php new file mode 100644 index 000000000..417b3383c --- /dev/null +++ b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithoutConnectionTest.php @@ -0,0 +1,33 @@ +expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches("/Dbal module requires 'Enqueue\\\\Dbal\\\\DbalConnectionFactory' to be configured/"); + + EcotoneLite::bootstrap( + classesToResolve: [], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withNamespaces(['Test\\Ecotone\\Tempest\\Fixture\\Counter\\']), + pathToRootCatalog: __DIR__ . '/../../', + ); + } +} diff --git a/packages/Tempest/tests/Fixture/Dbal/DbalConnectionConfiguration.php b/packages/Tempest/tests/Fixture/Dbal/DbalConnectionConfiguration.php new file mode 100644 index 000000000..29a02f687 --- /dev/null +++ b/packages/Tempest/tests/Fixture/Dbal/DbalConnectionConfiguration.php @@ -0,0 +1,20 @@ + Date: Sat, 30 May 2026 21:43:33 +0200 Subject: [PATCH 09/27] feat(tempest): add TempestRepository and TempestRepositoryBuilder for Stage 3 Implements the aggregate repository contract for Tempest active-record models (IsDatabaseModel), mirroring the Laravel EloquentRepository pattern. Wires TempestRepositoryBuilder into MessagingSystemInitializer so any class using IsDatabaseModel is automatically handled as an Ecotone aggregate repository. --- .../src/MessagingSystemInitializer.php | 2 + packages/Tempest/src/TempestRepository.php | 67 +++++++++++++++++++ .../Tempest/src/TempestRepositoryBuilder.php | 33 +++++++++ .../Repository/TempestRepositoryTest.php | 38 +++++++++++ 4 files changed, 140 insertions(+) create mode 100644 packages/Tempest/src/TempestRepository.php create mode 100644 packages/Tempest/src/TempestRepositoryBuilder.php create mode 100644 packages/Tempest/tests/Repository/TempestRepositoryTest.php diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php index d0597287e..7b9f7ccdf 100644 --- a/packages/Tempest/src/MessagingSystemInitializer.php +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -136,6 +136,8 @@ private function buildServiceConfiguration( $applicationConfiguration = $applicationConfiguration->withLicenceKey($config->licenceKey); } + $applicationConfiguration = $applicationConfiguration->withExtensionObjects([new TempestRepositoryBuilder()]); + return MessagingSystemConfiguration::addCorePackage($applicationConfiguration, $config->test); } diff --git a/packages/Tempest/src/TempestRepository.php b/packages/Tempest/src/TempestRepository.php new file mode 100644 index 000000000..4f8fe6744 --- /dev/null +++ b/packages/Tempest/src/TempestRepository.php @@ -0,0 +1,67 @@ +classUsesTrait($aggregateClassName, IsDatabaseModel::class); + } + + public function findBy(string $aggregateClassName, array $identifiers): ?object + { + return $aggregateClassName::findById(array_pop($identifiers)); + } + + public function save(array $identifiers, object $aggregate, array $metadata, ?int $versionBeforeHandling): void + { + $aggregate->save(); + } + + private function classUsesTrait(string $className, string $traitName): bool + { + foreach ($this->collectAllTraits($className) as $usedTrait) { + if ($usedTrait === $traitName) { + return true; + } + } + + return false; + } + + private function collectAllTraits(string $className): array + { + $traits = []; + + $class = $className; + while ($class !== false) { + $traits = array_merge($traits, $this->collectTraitsRecursively(class_uses($class) ?: [])); + $class = get_parent_class($class); + } + + return $traits; + } + + private function collectTraitsRecursively(array $traits): array + { + $collected = array_values($traits); + + foreach ($traits as $trait) { + $nestedTraits = class_uses($trait) ?: []; + if ($nestedTraits !== []) { + $collected = array_merge($collected, $this->collectTraitsRecursively($nestedTraits)); + } + } + + return $collected; + } +} diff --git a/packages/Tempest/src/TempestRepositoryBuilder.php b/packages/Tempest/src/TempestRepositoryBuilder.php new file mode 100644 index 000000000..3a151a072 --- /dev/null +++ b/packages/Tempest/src/TempestRepositoryBuilder.php @@ -0,0 +1,33 @@ +tempestRepository = new TempestRepository(); + } + + public function canHandle(string $aggregateClassName): bool + { + return $this->tempestRepository->canHandle($aggregateClassName); + } + + public function compile(MessagingContainerBuilder $builder): Definition|Reference + { + return new Definition(TempestRepository::class); + } +} diff --git a/packages/Tempest/tests/Repository/TempestRepositoryTest.php b/packages/Tempest/tests/Repository/TempestRepositoryTest.php new file mode 100644 index 000000000..9b34b9e50 --- /dev/null +++ b/packages/Tempest/tests/Repository/TempestRepositoryTest.php @@ -0,0 +1,38 @@ +assertFalse($repository->canHandle(DateTime::class)); + } + + public function test_it_does_support_tempest_database_models(): void + { + $repository = new TempestRepository(); + + $modelClass = new class { + use IsDatabaseModel; + + public PrimaryKey $id; + }; + + $this->assertTrue($repository->canHandle($modelClass::class)); + } +} From e4b827e0dd8b5b4130eb9ab526a4773cae91c707 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 21:45:54 +0200 Subject: [PATCH 10/27] feat(tempest): add Stage 3 integration test with Order fixture and postgres schema setup Adds TempestRepositoryIntegrationTest that places an order through the command bus (creating and persisting a Tempest IsDatabaseModel aggregate), asserts it is findable via findById, then round-trips a cancel+query cycle through the messaging system. Includes the Order fixture class and PlaceOrder command. Schema is created/dropped around each test using Tempest's Database interface. --- .../Tempest/tests/Fixture/Order/Order.php | 59 +++++++++ .../tests/Fixture/Order/PlaceOrder.php | 17 +++ .../TempestRepositoryIntegrationTest.php | 114 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 packages/Tempest/tests/Fixture/Order/Order.php create mode 100644 packages/Tempest/tests/Fixture/Order/PlaceOrder.php create mode 100644 packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php diff --git a/packages/Tempest/tests/Fixture/Order/Order.php b/packages/Tempest/tests/Fixture/Order/Order.php new file mode 100644 index 000000000..98a90a05c --- /dev/null +++ b/packages/Tempest/tests/Fixture/Order/Order.php @@ -0,0 +1,59 @@ +user_id = $command->userId; + $order->total_price = $command->totalPrice; + $order->is_cancelled = false; + $order->save(); + + return $order; + } + + #[IdentifierMethod('id')] + public function getId(): int + { + return $this->id->value; + } + + #[CommandHandler(routingKey: 'cancel_order')] + public function cancel(): void + { + $this->is_cancelled = true; + } + + #[QueryHandler('is_cancelled')] + public function isCancelled(): bool + { + return $this->is_cancelled; + } +} diff --git a/packages/Tempest/tests/Fixture/Order/PlaceOrder.php b/packages/Tempest/tests/Fixture/Order/PlaceOrder.php new file mode 100644 index 000000000..24e33cabe --- /dev/null +++ b/packages/Tempest/tests/Fixture/Order/PlaceOrder.php @@ -0,0 +1,17 @@ +setupKernel(); + + $postgresConfig = new PostgresConfig( + host: 'database', + port: '5432', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + ); + $this->container->config($postgresConfig); + + $this->createOrdersTable(); + } + + protected function tearDown(): void + { + $this->dropOrdersTable(); + parent::tearDown(); + } + + public function test_placing_an_order_with_tempest_model(): void + { + $commandBus = $this->container->get(\Ecotone\Modelling\CommandBus::class); + + $orderId = $commandBus->send(new PlaceOrder(userId: 'user-1', totalPrice: 100)); + + $this->assertIsInt($orderId); + $this->assertNotNull(Order::findById($orderId)); + } + + public function test_state_change_round_trips_through_command_and_query_bus(): void + { + $commandBus = $this->container->get(\Ecotone\Modelling\CommandBus::class); + $queryBus = $this->container->get(\Ecotone\Modelling\QueryBus::class); + + $orderId = $commandBus->send(new PlaceOrder(userId: 'user-1', totalPrice: 100)); + + $this->assertFalse( + $queryBus->sendWithRouting('is_cancelled', metadata: ['aggregate.id' => $orderId]), + ); + + $commandBus->sendWithRouting('cancel_order', metadata: ['aggregate.id' => $orderId]); + + $this->assertTrue( + $queryBus->sendWithRouting('is_cancelled', metadata: ['aggregate.id' => $orderId]), + ); + } + + private function createOrdersTable(): void + { + $database = $this->container->get(Database::class); + + $database->execute( + new Query('DROP TABLE IF EXISTS orders'), + ); + + $createSql = (new CreateTableStatement('orders')) + ->primary('id') + ->string('user_id') + ->integer('total_price') + ->boolean('is_cancelled') + ->compile($database->dialect); + + $database->execute(new Query($createSql)); + } + + private function dropOrdersTable(): void + { + try { + $database = $this->container->get(Database::class); + $database->execute(new Query('DROP TABLE IF EXISTS orders')); + } catch (\Throwable) { + } + } +} From f7266856cfc88a4ce96a05ba86ec22293fe0eb03 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 30 May 2026 22:07:34 +0200 Subject: [PATCH 11/27] =?UTF-8?q?feat(tempest):=20Stage=204=20=E2=80=94=20?= =?UTF-8?q?multi-tenant=20DB=20switching=20via=20HeaderBasedMultiTenantCon?= =?UTF-8?q?nectionFactory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend TempestConnectionModule to wire MultiTenantConfiguration: registers per-tenant TempestConnectionReference connection factories and the TempestTenantDatabaseSwitcher service with handlers on the Ecotone activate/ deactivate tenant channels. Guard on class_exists(HeaderBasedMultiTenantConnectionFactory). canHandle() now also accepts MultiTenantConfiguration. - TempestTenantDatabaseSwitcher: on switchOn, re-registers the tenant DatabaseConfig as the Tempest default and invalidates the Connection/Database singletons so the next resolution rebuilds from the tenant config. switchOff restores the original default. Uses GenericContainer::instance() to avoid circular autowiring. - TempestConnectionReference: carries an optional DatabaseConfig (for multi-tenant per-tenant configs). getDefinition() serialises the config via base64+serialize so it survives Ecotone definition caching. Single-connection path unchanged (falls back to container-resolved DatabaseConfig::class). Adds clearRegistry() stub for test compat. - TempestConnectionResolver: takes only TempestConnectionReference (one arg). Reads DatabaseConfig from the reference (inline or via GenericContainer::instance() fallback). - Test fixtures: RegisterCustomer, Customer, CustomerInterface (#[DbalWrite]), CustomerRepository (#[DbalQuery] FIRST_COLUMN), CustomerService (command + query handlers with #[Reference] injection), MultiTenantEcotoneConfiguration (#[ServiceContext] returning MultiTenantConfiguration mapping tenant_a→postgres and tenant_b→mysql). - MultiTenantTest: two green tests — test_run_message_handlers_for_multi_tenant_connection (ORM-side DBAL isolation via CustomerInterface) and test_using_dbal_based_business_interfaces (DBAL business interface write path). Skipped (queues excluded from scope): test_sending_events_using_laravel_db_queue, test_transactions_rollbacks_model_changes_and_published_events, test_optimize_clear_triggers_ecotone_cache_clear_via_event. All 26 tests green (24 prior + 2 new Stage 4). --- .../src/Config/TempestConnectionModule.php | 83 +++++++-- .../src/Config/TempestConnectionReference.php | 27 ++- .../src/Config/TempestConnectionResolver.php | 12 +- .../Config/TempestTenantDatabaseSwitcher.php | 55 ++++++ .../tests/Fixture/MultiTenant/Customer.php | 29 ++++ .../Fixture/MultiTenant/CustomerInterface.php | 16 ++ .../MultiTenant/CustomerRepository.php | 17 ++ .../Fixture/MultiTenant/CustomerService.php | 33 ++++ .../MultiTenantEcotoneConfiguration.php | 41 +++++ .../Fixture/MultiTenant/RegisterCustomer.php | 16 ++ .../tests/MultiTenant/MultiTenantTest.php | 159 ++++++++++++++++++ 11 files changed, 469 insertions(+), 19 deletions(-) create mode 100644 packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php create mode 100644 packages/Tempest/tests/Fixture/MultiTenant/Customer.php create mode 100644 packages/Tempest/tests/Fixture/MultiTenant/CustomerInterface.php create mode 100644 packages/Tempest/tests/Fixture/MultiTenant/CustomerRepository.php create mode 100644 packages/Tempest/tests/Fixture/MultiTenant/CustomerService.php create mode 100644 packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php create mode 100644 packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php create mode 100644 packages/Tempest/tests/MultiTenant/MultiTenantTest.php diff --git a/packages/Tempest/src/Config/TempestConnectionModule.php b/packages/Tempest/src/Config/TempestConnectionModule.php index d9642dc0f..4dad692ad 100644 --- a/packages/Tempest/src/Config/TempestConnectionModule.php +++ b/packages/Tempest/src/Config/TempestConnectionModule.php @@ -5,18 +5,19 @@ namespace Ecotone\Tempest\Config; use Ecotone\AnnotationFinder\AnnotationFinder; +use Ecotone\Dbal\MultiTenant\HeaderBasedMultiTenantConnectionFactory; +use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; use Ecotone\Messaging\Attribute\ModuleAnnotation; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\NoExternalConfigurationModule; use Ecotone\Messaging\Config\Configuration; use Ecotone\Messaging\Config\Container\Definition; -use Ecotone\Messaging\Config\Container\Reference; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ModuleReferenceSearchService; use Ecotone\Messaging\Handler\InterfaceToCallRegistry; +use Ecotone\Messaging\Handler\ServiceActivator\ServiceActivatorBuilder; use Interop\Queue\ConnectionFactory; -use Tempest\Database\Config\DatabaseConfig; #[ModuleAnnotation] /** @@ -36,28 +37,82 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO foreach ($tempestConnections as $connection) { $messagingConfiguration->registerServiceDefinition( $connection->getReferenceName(), - new Definition( - ConnectionFactory::class, - [ - $connection, - new Reference(DatabaseConfig::class), - ], - [ - TempestConnectionResolver::class, - 'resolve', - ] - ) + $this->buildConnectionFactoryDefinition($connection) ); } + + if (! class_exists(HeaderBasedMultiTenantConnectionFactory::class)) { + return; + } + + $multiTenantConfigurations = ExtensionObjectResolver::resolve(MultiTenantConfiguration::class, $extensionObjects); + + $tempestRelatedMultiTenantConfigurations = []; + + foreach ($multiTenantConfigurations as $multiTenantConfiguration) { + foreach ($multiTenantConfiguration->getTenantToConnectionMapping() as $connectionReference) { + if (! ($connectionReference instanceof TempestConnectionReference)) { + continue; + } + + $tempestRelatedMultiTenantConfigurations[$multiTenantConfiguration->getReferenceName()] = $multiTenantConfiguration; + + $messagingConfiguration->registerServiceDefinition( + $connectionReference->getReferenceName(), + $this->buildConnectionFactoryDefinition($connectionReference) + ); + } + } + + if ($tempestRelatedMultiTenantConfigurations === []) { + return; + } + + $messagingConfiguration->registerServiceDefinition( + TempestTenantDatabaseSwitcher::class, + new Definition( + TempestTenantDatabaseSwitcher::class, + [], + [ + TempestTenantDatabaseSwitcher::class, + 'create', + ] + ) + ); + + $messagingConfiguration->registerMessageHandler( + ServiceActivatorBuilder::create(TempestTenantDatabaseSwitcher::class, 'switchOn') + ->withInputChannelName(HeaderBasedMultiTenantConnectionFactory::TENANT_ACTIVATED_CHANNEL_NAME) + ); + + $messagingConfiguration->registerMessageHandler( + ServiceActivatorBuilder::create(TempestTenantDatabaseSwitcher::class, 'switchOff') + ->withInputChannelName(HeaderBasedMultiTenantConnectionFactory::TENANT_DEACTIVATED_CHANNEL_NAME) + ); } public function canHandle($extensionObject): bool { - return $extensionObject instanceof TempestConnectionReference; + return $extensionObject instanceof TempestConnectionReference + || $extensionObject instanceof MultiTenantConfiguration; } public function getModulePackageName(): string { return ModulePackageList::TEMPEST_PACKAGE; } + + private function buildConnectionFactoryDefinition(TempestConnectionReference $connection): Definition + { + return new Definition( + ConnectionFactory::class, + [ + $connection, + ], + [ + TempestConnectionResolver::class, + 'resolve', + ] + ); + } } diff --git a/packages/Tempest/src/Config/TempestConnectionReference.php b/packages/Tempest/src/Config/TempestConnectionReference.php index 7fac4720e..d4743bc0d 100644 --- a/packages/Tempest/src/Config/TempestConnectionReference.php +++ b/packages/Tempest/src/Config/TempestConnectionReference.php @@ -8,6 +8,7 @@ use Ecotone\Messaging\Config\Container\DefinedObject; use Ecotone\Messaging\Config\Container\Definition; use Enqueue\Dbal\DbalConnectionFactory; +use Tempest\Database\Config\DatabaseConfig; /** * licence Apache-2.0 @@ -16,13 +17,14 @@ final class TempestConnectionReference extends ConnectionReference implements De { private function __construct( string $referenceName, + private readonly ?DatabaseConfig $databaseConfig = null, ) { - parent::__construct($referenceName, null); + parent::__construct($referenceName, $referenceName); } - public static function create(string $referenceName): self + public static function create(string $referenceName, ?DatabaseConfig $databaseConfig = null): self { - return new self($referenceName); + return new self($referenceName, $databaseConfig); } public static function defaultConnection(): self @@ -30,17 +32,34 @@ public static function defaultConnection(): self return new self(DbalConnectionFactory::class); } + public static function clearRegistry(): void + { + } + + public function getDatabaseConfig(): ?DatabaseConfig + { + return $this->databaseConfig; + } + public function getDefinition(): Definition { return new Definition( TempestConnectionReference::class, [ $this->getReferenceName(), + $this->databaseConfig !== null ? base64_encode(serialize($this->databaseConfig)) : null, ], [ self::class, - 'create', + 'createFromSerializedConfig', ] ); } + + public static function createFromSerializedConfig(string $referenceName, ?string $serializedConfig = null): self + { + $config = $serializedConfig !== null ? unserialize(base64_decode($serializedConfig)) : null; + + return new self($referenceName, $config); + } } diff --git a/packages/Tempest/src/Config/TempestConnectionResolver.php b/packages/Tempest/src/Config/TempestConnectionResolver.php index f8f22fee2..c35ef29fe 100644 --- a/packages/Tempest/src/Config/TempestConnectionResolver.php +++ b/packages/Tempest/src/Config/TempestConnectionResolver.php @@ -13,6 +13,7 @@ use Ecotone\Tempest\Config\PDO\SQLiteDriver; use Interop\Queue\ConnectionFactory; use PDO; +use Tempest\Container\GenericContainer; use Tempest\Database\Config\DatabaseConfig; use Tempest\Database\Config\DatabaseDialect; @@ -21,12 +22,14 @@ */ final class TempestConnectionResolver { - public static function resolve(TempestConnectionReference $reference, DatabaseConfig $databaseConfig): ConnectionFactory + public static function resolve(TempestConnectionReference $reference): ConnectionFactory { if (! class_exists(DbalConnection::class)) { throw new InvalidArgumentException('Dbal Module is not installed. Please install it first to make use of Database capabilities.'); } + $databaseConfig = $reference->getDatabaseConfig() ?? self::resolveDefaultDatabaseConfig(); + $pdo = new PDO( $databaseConfig->dsn, $databaseConfig->username, @@ -44,6 +47,13 @@ public static function resolve(TempestConnectionReference $reference, DatabaseCo return DbalConnection::create($doctrineConnection); } + private static function resolveDefaultDatabaseConfig(): DatabaseConfig + { + $container = GenericContainer::instance(); + + return $container->get(DatabaseConfig::class); + } + private static function driverForDialect(DatabaseDialect $dialect): Driver { return match ($dialect) { diff --git a/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php b/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php new file mode 100644 index 000000000..3e2ae4270 --- /dev/null +++ b/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php @@ -0,0 +1,55 @@ +get(DatabaseConfig::class); + + return new self($defaultConfig); + } + + public function switchOn(string|ConnectionReference $activatedConnection): void + { + if (! ($activatedConnection instanceof TempestConnectionReference)) { + return; + } + + $tenantConfig = $activatedConnection->getDatabaseConfig(); + + if ($tenantConfig === null) { + return; + } + + $container = GenericContainer::instance(); + $container->singleton(DatabaseConfig::class, $tenantConfig); + $container->unregister(Connection::class); + $container->unregister(Database::class); + } + + public function switchOff(): void + { + $container = GenericContainer::instance(); + $container->singleton(DatabaseConfig::class, $this->defaultDatabaseConfig); + $container->unregister(Connection::class); + $container->unregister(Database::class); + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/Customer.php b/packages/Tempest/tests/Fixture/MultiTenant/Customer.php new file mode 100644 index 000000000..c83e9bb21 --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/Customer.php @@ -0,0 +1,29 @@ +customer_id = $command->customerId; + $customer->name = $command->name; + + return $customer; + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/CustomerInterface.php b/packages/Tempest/tests/Fixture/MultiTenant/CustomerInterface.php new file mode 100644 index 000000000..54503cd6c --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/CustomerInterface.php @@ -0,0 +1,16 @@ +register($command->customerId, $command->name); + } + + #[CommandHandler('customer.register_with_business_interface')] + public function handleWithDbalInterface(RegisterCustomer $command, CustomerInterface $customerInterface): void + { + $customerInterface->register($command->customerId, $command->name); + } + + #[QueryHandler('customer.getAllRegistered')] + public function getAllRegisteredPersonIds(#[Reference] CustomerRepository $customerRepository): array + { + return $customerRepository->getAllRegisteredPersonIds(); + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php b/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php new file mode 100644 index 000000000..d3e38a7a0 --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php @@ -0,0 +1,41 @@ + TempestConnectionReference::create('tenant_a', new PostgresConfig( + host: 'database', + port: '5432', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + )), + 'tenant_b' => TempestConnectionReference::create('tenant_b', new MysqlConfig( + host: 'database-mysql', + port: '3306', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + )), + ], + ); + } +} diff --git a/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php b/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php new file mode 100644 index 000000000..26e5b8dd7 --- /dev/null +++ b/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php @@ -0,0 +1,16 @@ +setupKernel(); + + $this->createPersonsTableForBothTenants(); + + $this->commandBus = $this->container->get(CommandBus::class); + $this->queryBus = $this->container->get(QueryBus::class); + } + + protected function tearDown(): void + { + $this->dropPersonsTableForBothTenants(); + parent::tearDown(); + } + + public function test_run_message_handlers_for_multi_tenant_connection(): void + { + $this->commandBus->send(new RegisterCustomer(1, 'John Doe'), metadata: ['tenant' => 'tenant_a']); + $this->commandBus->send(new RegisterCustomer(2, 'John Doe'), metadata: ['tenant' => 'tenant_a']); + $this->commandBus->send(new RegisterCustomer(2, 'John Doe'), metadata: ['tenant' => 'tenant_b']); + + $this->assertSame( + [1, 2], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_a']), + ); + + $this->assertSame( + [2], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_b']), + ); + } + + public function test_using_dbal_based_business_interfaces(): void + { + $this->commandBus->sendWithRouting( + 'customer.register_with_business_interface', + new RegisterCustomer(1, 'John Doe'), + metadata: ['tenant' => 'tenant_a'], + ); + + $this->assertSame( + [1], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_a']), + ); + + $this->assertSame( + [], + $this->queryBus->sendWithRouting('customer.getAllRegistered', metadata: ['tenant' => 'tenant_b']), + ); + } + + private function createPersonsTableForBothTenants(): void + { + $this->createPersonsTable($this->postgresConnection()); + $this->createPersonsTable($this->mysqlConnection()); + } + + private function dropPersonsTableForBothTenants(): void + { + $this->postgresConnection()->exec('DROP TABLE IF EXISTS persons'); + $this->mysqlConnection()->exec('DROP TABLE IF EXISTS persons'); + } + + private function createPersonsTable(PDO $pdo): void + { + $pdo->exec('DROP TABLE IF EXISTS persons'); + $pdo->exec( + 'CREATE TABLE persons ( + customer_id INTEGER NOT NULL, + name VARCHAR(255), + PRIMARY KEY (customer_id) + )', + ); + } + + private function postgresConnection(): PDO + { + return new PDO( + (new PostgresConfig( + host: 'database', + port: '5432', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + ))->dsn, + 'ecotone', + 'secret', + ); + } + + private function mysqlConnection(): PDO + { + return new PDO( + (new MysqlConfig( + host: 'database-mysql', + port: '3306', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + ))->dsn, + 'ecotone', + 'secret', + ); + } +} From e118598fe3c8bd02056482c1c7302d57eaa5980d Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:21:48 +0200 Subject: [PATCH 12/27] feat(tempest): wire loadAppNamespaces to derive Ecotone scan namespaces from Tempest Composer PSR-4 roots When EcotoneConfig::loadAppNamespaces=true (default) and no explicit namespaces are set, MessagingSystemInitializer now resolves the Tempest Composer singleton from the container and uses its app PSR-4 namespace prefixes for Ecotone handler discovery. This means a fresh Tempest app with an App\ root and no ecotone.config.php discovers its handlers automatically. Adds AppNamespaceAutoDiscoveryTest proving zero-config discovery works. Registers App\Tempest\ in the monorepo autoload-dev for the test fixture. --- composer.json | 1 + .../src/MessagingSystemInitializer.php | 28 ++++- .../AppNamespaceAutoDiscoveryTest.php | 114 ++++++++++++++++++ packages/Tempest/tests/app/composer.json | 2 +- .../tests/app/src/AppCommandHandler.php | 28 +++++ 5 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php create mode 100644 packages/Tempest/tests/app/src/AppCommandHandler.php diff --git a/composer.json b/composer.json index caff60f21..bae2d0f26 100644 --- a/composer.json +++ b/composer.json @@ -126,6 +126,7 @@ ], "Test\\Ecotone\\Laravel\\": "packages/Laravel/tests", "Test\\Ecotone\\Tempest\\": "packages/Tempest/tests", + "App\\Tempest\\": "packages/Tempest/tests/app/src", "App\\MultiTenant\\": "packages/Laravel/tests/MultiTenant/app", "App\\Licence\\Laravel\\": "packages/Laravel/tests/Licence/app", "Symfony\\App\\MultiTenant\\": "packages/Symfony/tests/phpunit/MultiTenant/src", diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php index 7b9f7ccdf..f4d4f1b49 100644 --- a/packages/Tempest/src/MessagingSystemInitializer.php +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -19,6 +19,7 @@ use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; +use Tempest\Discovery\Composer; /** * licence Apache-2.0 @@ -56,7 +57,7 @@ public function initialize(Container $container): ConfiguredMessagingSystem $environment = getenv('APP_ENV') ?: 'production'; $useProductionCache = in_array($environment, ['prod', 'production'], true) ? true : $config->cacheConfiguration; - $applicationConfiguration = $this->buildServiceConfiguration($config, $environment, $cacheDirectory); + $applicationConfiguration = $this->buildServiceConfiguration($config, $environment, $cacheDirectory, $container); [$serviceCacheConfiguration, $definitionHolder, $configHash] = $this->prepareFromCache( $useProductionCache, @@ -100,6 +101,22 @@ private function resolveEcotoneConfig(Container $container): EcotoneConfig return new EcotoneConfig(); } + private function deriveNamespacesFromComposer(Container $container): array + { + try { + $composer = $container->get(Composer::class); + } catch (\Throwable) { + return []; + } + + $namespaces = []; + foreach ($composer->namespaces as $psr4Namespace) { + $namespaces[] = $psr4Namespace->namespace; + } + + return $namespaces; + } + private function wireLogger(Container $container, LazyInMemoryContainer $ecotoneContainer): void { if ($container->has(LoggerInterface::class)) { @@ -111,12 +128,19 @@ private function buildServiceConfiguration( EcotoneConfig $config, string $environment, string $cacheDirectory, + Container $container, ): ServiceConfiguration { + $namespaces = $config->namespaces; + + if ($namespaces === [] && $config->loadAppNamespaces) { + $namespaces = $this->deriveNamespacesFromComposer($container); + } + $applicationConfiguration = ServiceConfiguration::createWithDefaults() ->withEnvironment($environment) ->withLoadCatalog('') ->withFailFast(false) - ->withNamespaces($config->namespaces) + ->withNamespaces($namespaces) ->withSkippedModulePackageNames($config->skippedModulePackageNames) ->withCacheDirectoryPath($cacheDirectory); diff --git a/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php b/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php new file mode 100644 index 000000000..87ea7dfb6 --- /dev/null +++ b/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php @@ -0,0 +1,114 @@ +internalStorage = '/tmp/ecotone_tempest_auto_ns_' . getmypid(); + $this->setupKernel(); + } + + public function setupKernel(): self + { + EcotoneServiceInitializer::clearCache(); + + $appSrcLocation = new DiscoveryLocation('App\\Tempest\\', '/data/app/packages/Tempest/tests/app/src'); + + $allLocations = [ + new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'), + $appSrcLocation, + ]; + + $kernel = new FrameworkKernel( + root: $this->root, + discoveryLocations: $allLocations, + internalStorage: $this->internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $allLocations); + + $kernel->container->config(new EcotoneConfig( + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + )); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $this->kernel = $kernel; + $this->container = $kernel->container; + + return $this; + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + restore_error_handler(); + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + } + + private function injectDiscoveryConfig(FrameworkKernel $kernel, array $extraLocations): void + { + $testAppComposer = (new Composer($this->root))->load(); + $testAppComposer->namespaces = []; + + $autoloadLocations = (new AutoloadDiscoveryLocations( + rootPath: '/data/app', + composer: $testAppComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [...$extraLocations, ...$autoloadLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } + + public function test_handlers_in_app_namespace_discovered_without_explicit_namespaces_config(): void + { + $commandBus = $this->container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $commandBus->sendWithRouting('app.ping'); + + $this->assertTrue($queryBus->sendWithRouting('app.wasHandled')); + } +} diff --git a/packages/Tempest/tests/app/composer.json b/packages/Tempest/tests/app/composer.json index 622c8888d..ca66c0672 100644 --- a/packages/Tempest/tests/app/composer.json +++ b/packages/Tempest/tests/app/composer.json @@ -3,7 +3,7 @@ "type": "project", "autoload": { "psr-4": { - "App\\": "src/" + "App\\Tempest\\": "src/" } }, "require": { diff --git a/packages/Tempest/tests/app/src/AppCommandHandler.php b/packages/Tempest/tests/app/src/AppCommandHandler.php new file mode 100644 index 000000000..c9aef47f4 --- /dev/null +++ b/packages/Tempest/tests/app/src/AppCommandHandler.php @@ -0,0 +1,28 @@ +handled = true; + } + + #[QueryHandler('app.wasHandled')] + public function wasHandled(): bool + { + return $this->handled; + } +} From a00a3e61b36abc03fc8ac9979e10d55d554bd796 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:27:43 +0200 Subject: [PATCH 13/27] fix(tempest): wire Tempest logger to Ecotone using get() not has(), and set 'logger' key LoggerInterface is provided by Tempest's DynamicInitializer so container->has() always returns false; use get() in a try/catch instead. Also set the logger under both 'logger' (the key Ecotone's LoggingService resolves at runtime) and LoggerInterface::class so log messages from command/query/event handlers flow through the Tempest logger. Adds LoggerWiringTest proving Ecotone query handler logs appear in the configured Tempest log channel. --- .../src/MessagingSystemInitializer.php | 7 +- .../tests/Application/LoggerWiringTest.php | 142 ++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 packages/Tempest/tests/Application/LoggerWiringTest.php diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php index f4d4f1b49..8a3aa49a5 100644 --- a/packages/Tempest/src/MessagingSystemInitializer.php +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -119,8 +119,11 @@ private function deriveNamespacesFromComposer(Container $container): array private function wireLogger(Container $container, LazyInMemoryContainer $ecotoneContainer): void { - if ($container->has(LoggerInterface::class)) { - $ecotoneContainer->set(LoggerInterface::class, $container->get(LoggerInterface::class)); + try { + $logger = $container->get(LoggerInterface::class); + $ecotoneContainer->set('logger', $logger); + $ecotoneContainer->set(LoggerInterface::class, $logger); + } catch (\Throwable) { } } diff --git a/packages/Tempest/tests/Application/LoggerWiringTest.php b/packages/Tempest/tests/Application/LoggerWiringTest.php new file mode 100644 index 000000000..83ac10385 --- /dev/null +++ b/packages/Tempest/tests/Application/LoggerWiringTest.php @@ -0,0 +1,142 @@ +internalStorage = '/tmp/ecotone_tempest_logger_test_' . getmypid(); + $this->logHandler = new TestHandler(); + $this->setupKernel(); + } + + public function setupKernel(): self + { + EcotoneServiceInitializer::clearCache(); + + $allLocations = [ + new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'), + new DiscoveryLocation('Test\\Ecotone\\Tempest\\Fixture\\', '/data/app/packages/Tempest/tests/Fixture'), + ]; + + $kernel = new FrameworkKernel( + root: $this->root, + discoveryLocations: $allLocations, + internalStorage: $this->internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $allLocations); + + $kernel->container->config(new EcotoneConfig( + namespaces: ['Test\\Ecotone\\Tempest\\Fixture\\ExpressionLanguage\\'], + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + )); + + $captureHandler = $this->logHandler; + $captureChannel = new class ($captureHandler) implements LogChannel { + public function __construct(private TestHandler $handler) {} + + public function getHandlers(Level $level): array + { + return [$this->handler]; + } + + public function getProcessors(): array + { + return []; + } + }; + + $kernel->loadConfig(); + + $kernel->container->config(new MultipleChannelsLogConfig( + channels: [$captureChannel], + prefix: 'test', + )); + + $kernel->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $this->kernel = $kernel; + $this->container = $kernel->container; + + return $this; + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + restore_error_handler(); + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + } + + private function injectDiscoveryConfig(FrameworkKernel $kernel, array $extraLocations): void + { + $testAppComposer = (new Composer($this->root))->load(); + $testAppComposer->namespaces = []; + + $autoloadLocations = (new AutoloadDiscoveryLocations( + rootPath: '/data/app', + composer: $testAppComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [...$extraLocations, ...$autoloadLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } + + public function test_ecotone_logs_flow_through_tempest_logger_after_wiring(): void + { + $queryBus = $this->container->get(QueryBus::class); + + $queryBus->sendWithRouting('getAmount'); + + $this->assertTrue( + $this->logHandler->hasInfoThatContains('Executing Query Handler'), + 'Expected Ecotone to emit log through the Tempest logger', + ); + } +} From ba319d85b41149277876bf4c2a0b9462f47ca476 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:30:46 +0200 Subject: [PATCH 14/27] fix(tempest): persist configHash alongside prod cache so warm-cache boots skip proxy regeneration On cold boot the computed config hash is now written to messaging_system_hash alongside messaging_system. On the warm-cache fast-path the persisted hash is read back so getConfigHash() returns a stable non-null value, enabling ConsoleCommandProxyGenerator to correctly skip proxy file rewrites. Also moves console proxy output directory under the Ecotone cache dir (ecotone_tempest/console_proxies) instead of sys_get_temp_dir() root. Adds ProdCacheHashTest proving warm-cache boot returns the same hash as the cold boot. --- .../src/EcotoneConsoleCommandDiscovery.php | 3 +- .../src/MessagingSystemInitializer.php | 38 ++++++++- .../tests/Application/ProdCacheHashTest.php | 83 +++++++++++++++++++ .../Tempest/tests/EcotoneIntegrationTest.php | 18 ++-- 4 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 packages/Tempest/tests/Application/ProdCacheHashTest.php diff --git a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php index d4389ad81..e2929195e 100644 --- a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php +++ b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php @@ -53,7 +53,8 @@ public function apply(): void return; } - $outputDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies'; + $outputDirectory = MessagingSystemInitializer::getProxyDirectory() + ?? sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies'; $generator = new ConsoleCommandProxyGenerator(); $generatedFiles = $generator->generate($commands, $outputDirectory, MessagingSystemInitializer::getConfigHash()); diff --git a/packages/Tempest/src/MessagingSystemInitializer.php b/packages/Tempest/src/MessagingSystemInitializer.php index 8a3aa49a5..33d4dd38a 100644 --- a/packages/Tempest/src/MessagingSystemInitializer.php +++ b/packages/Tempest/src/MessagingSystemInitializer.php @@ -29,10 +29,14 @@ final class MessagingSystemInitializer implements Initializer { public const MESSAGING_SYSTEM_FILE_NAME = 'messaging_system'; + private const CONFIG_HASH_FILE_NAME = 'messaging_system_hash'; + private static ?ContainerDefinitionsHolder $definitionHolder = null; private static ?string $configHash = null; + private static ?string $proxyDirectory = null; + public static function getDefinitionHolder(): ?ContainerDefinitionsHolder { return self::$definitionHolder; @@ -43,10 +47,16 @@ public static function getConfigHash(): ?string return self::$configHash; } + public static function getProxyDirectory(): ?string + { + return self::$proxyDirectory; + } + public static function clearDefinitionHolder(): void { self::$definitionHolder = null; self::$configHash = null; + self::$proxyDirectory = null; } public function initialize(Container $container): ConfiguredMessagingSystem @@ -69,6 +79,7 @@ public function initialize(Container $container): ConfiguredMessagingSystem self::$definitionHolder = $definitionHolder; self::$configHash = $configHash; + self::$proxyDirectory = $cacheDirectory . DIRECTORY_SEPARATOR . 'console_proxies'; $ecotoneContainer = new LazyInMemoryContainer( $definitionHolder->getDefinitions(), @@ -182,7 +193,9 @@ private function prepareFromCache( $definitionHolder = unserialize(file_get_contents($messagingFile)); if ($definitionHolder instanceof ContainerDefinitionsHolder) { - return [new ServiceCacheConfiguration($cacheDirectory, true), $definitionHolder, null]; + $persistedHash = $this->readPersistedConfigHash($cacheDirectory); + + return [new ServiceCacheConfiguration($cacheDirectory, true), $definitionHolder, $persistedHash]; } } } @@ -227,9 +240,32 @@ private function prepareFromCache( if ($serviceCacheConfiguration->shouldUseCache()) { MessagingSystemConfiguration::prepareCacheDirectory($serviceCacheConfiguration); file_put_contents($messagingSystemCachePath, serialize($definitionHolder)); + + if ($useProductionCache && $cacheHash !== null) { + $this->persistConfigHash($cacheDirectory, $cacheHash); + } } } return [$serviceCacheConfiguration, $definitionHolder, $cacheHash]; } + + private function persistConfigHash(string $cacheDirectory, string $configHash): void + { + $hashFile = $cacheDirectory . DIRECTORY_SEPARATOR . self::CONFIG_HASH_FILE_NAME; + file_put_contents($hashFile, $configHash); + } + + private function readPersistedConfigHash(string $cacheDirectory): ?string + { + $hashFile = $cacheDirectory . DIRECTORY_SEPARATOR . self::CONFIG_HASH_FILE_NAME; + + if (! file_exists($hashFile)) { + return null; + } + + $hash = file_get_contents($hashFile); + + return $hash !== false && $hash !== '' ? $hash : null; + } } diff --git a/packages/Tempest/tests/Application/ProdCacheHashTest.php b/packages/Tempest/tests/Application/ProdCacheHashTest.php new file mode 100644 index 000000000..05278907e --- /dev/null +++ b/packages/Tempest/tests/Application/ProdCacheHashTest.php @@ -0,0 +1,83 @@ +clearEcotoneTempestCacheDir(); + parent::setUp(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->clearEcotoneTempestCacheDir(); + } + + public function test_warm_prod_cache_path_returns_stable_non_null_config_hash(): void + { + $firstHash = MessagingSystemInitializer::getConfigHash(); + + $this->assertNotNull( + $firstHash, + 'Config hash must be non-null on first cold boot', + ); + + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + + $this->setupKernel(); + + $secondHash = MessagingSystemInitializer::getConfigHash(); + + $this->assertSame( + $firstHash, + $secondHash, + 'Warm-cache boot must return the same config hash as the cold boot', + ); + } + + private function clearEcotoneTempestCacheDir(): void + { + $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest'; + if (! is_dir($dir)) { + return; + } + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } + + private function removeDirectory(string $dir): void + { + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } +} diff --git a/packages/Tempest/tests/EcotoneIntegrationTest.php b/packages/Tempest/tests/EcotoneIntegrationTest.php index fa048a6a3..bf513c4b5 100644 --- a/packages/Tempest/tests/EcotoneIntegrationTest.php +++ b/packages/Tempest/tests/EcotoneIntegrationTest.php @@ -89,13 +89,19 @@ protected function tearDown(): void private function removeProxyCache(): void { - $proxyDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies'; - if (is_dir($proxyDir)) { - foreach (glob($proxyDir . '/*.php') ?: [] as $file) { - @unlink($file); + $proxyDirs = [ + MessagingSystemInitializer::getProxyDirectory(), + sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest_console_proxies', + ]; + + foreach (array_filter($proxyDirs) as $proxyDir) { + if (is_dir($proxyDir)) { + foreach (glob($proxyDir . '/*.php') ?: [] as $file) { + @unlink($file); + } + @unlink($proxyDir . '/.ecotone_hash'); + @rmdir($proxyDir); } - @unlink($proxyDir . '/.ecotone_hash'); - @rmdir($proxyDir); } } From 626aaa40253847bba61f9ab3b0b47e4de1e0d991 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:31:49 +0200 Subject: [PATCH 15/27] feat(tempest): add ecotone:cache:clear console command that wipes Ecotone cache directory Mirrors Laravel's optimize:clear and Symfony's CacheClearer. The command removes the full ecotone_tempest directory including the compiled messaging_system cache and generated console proxy files. Adds CacheClearCommandTest covering both the messaging-system cache file and the console proxy directory removal. --- .../Tempest/src/EcotoneCacheClearCommand.php | 47 +++++++++++++++ .../Application/CacheClearCommandTest.php | 58 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 packages/Tempest/src/EcotoneCacheClearCommand.php create mode 100644 packages/Tempest/tests/Application/CacheClearCommandTest.php diff --git a/packages/Tempest/src/EcotoneCacheClearCommand.php b/packages/Tempest/src/EcotoneCacheClearCommand.php new file mode 100644 index 000000000..f5db91efb --- /dev/null +++ b/packages/Tempest/src/EcotoneCacheClearCommand.php @@ -0,0 +1,47 @@ +removeCacheDirectory($cacheDirectory); + + $this->console->writeln('Ecotone cache cleared.'); + } + + private function removeCacheDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $item) { + if (is_dir($item)) { + $this->removeCacheDirectory($item); + } else { + @unlink($item); + } + } + + @rmdir($directory); + } +} diff --git a/packages/Tempest/tests/Application/CacheClearCommandTest.php b/packages/Tempest/tests/Application/CacheClearCommandTest.php new file mode 100644 index 000000000..bfeadd9c6 --- /dev/null +++ b/packages/Tempest/tests/Application/CacheClearCommandTest.php @@ -0,0 +1,58 @@ +assertTrue(file_exists($messagingFile)); + + $this->console + ->call('ecotone:cache:clear') + ->assertSuccess(); + + $this->assertFalse(file_exists($messagingFile)); + } + + public function test_cache_clear_command_removes_proxy_files(): void + { + $proxyDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest' . DIRECTORY_SEPARATOR . 'console_proxies'; + @mkdir($proxyDir, 0777, true); + $proxyFile = $proxyDir . DIRECTORY_SEPARATOR . 'SomeProxy.php'; + file_put_contents($proxyFile, 'assertTrue(file_exists($proxyFile)); + + $this->console + ->call('ecotone:cache:clear') + ->assertSuccess(); + + $this->assertFalse(file_exists($proxyFile)); + } +} From 425d5a40a55a854244453db20e6eef6a04db8b06 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:44:42 +0200 Subject: [PATCH 16/27] test(tempest): add real-boot test proving zero-config namespace derivation from app PSR-4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item A: boots FrameworkKernel with tests/app as root so loadComposer() loads tests/app/composer.json (App\Tempest\ => src/), exercises the deriveNamespacesFromComposer path in MessagingSystemInitializer without explicit EcotoneConfig::namespaces. Handler resolved via derived namespace. Monorepo-only shim: injectDiscoveryConfig uses AutoloadDiscoveryLocations from /data/app root (not tests/app) to get vendor package discovery locations — a real install would have a full vendor/ in the app root. --- .../tests/Application/RealBootTest.php | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/Tempest/tests/Application/RealBootTest.php diff --git a/packages/Tempest/tests/Application/RealBootTest.php b/packages/Tempest/tests/Application/RealBootTest.php new file mode 100644 index 000000000..b55fc7ed0 --- /dev/null +++ b/packages/Tempest/tests/Application/RealBootTest.php @@ -0,0 +1,103 @@ +registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $ecotoneLocation, $appLocation); + + $kernel->container->config(new EcotoneConfig( + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + )); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + $commandBus = $kernel->container->get(CommandBus::class); + $queryBus = $kernel->container->get(QueryBus::class); + + $commandBus->sendWithRouting('app.ping'); + + $this->assertTrue($queryBus->sendWithRouting('app.wasHandled')); + } + + private function injectDiscoveryConfig( + FrameworkKernel $kernel, + DiscoveryLocation $ecotoneLocation, + DiscoveryLocation $appLocation, + ): void { + $vendorOnlyComposer = (new Composer(self::APP_ROOT))->load(); + $vendorOnlyComposer->namespaces = []; + + $vendorLocations = (new AutoloadDiscoveryLocations( + rootPath: '/data/app', + composer: $vendorOnlyComposer, + ))(); + + $discoveryConfig = $kernel->container->get(DiscoveryConfig::class); + $discoveryConfig->locations = [$ecotoneLocation, $appLocation, ...$vendorLocations]; + + $kernel->container->config($discoveryConfig); + $kernel->discoveryConfig = $discoveryConfig; + } +} From 694f8f1f72c2447ab1aace2adf1df9ff686365f4 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:50:44 +0200 Subject: [PATCH 17/27] test(tempest): add coverage-parity tests for parameter expression, licence key, and prod-cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item D: parameter() function with hardcoded and env-variable-backed values via TempestConfigurationVariableService; licence key boot test; warm-cache handler execution parity test (Laravel/Symfony-mirrored coverage). Error-channel test skipped: requires async queue infrastructure and Enterprise licence key — not feasible synchronously without the queue transport. Logger-during-execution already covered by LoggerWiringTest (committed earlier). --- .../tests/Application/CoverageParityTest.php | 102 ++++++++++++++++++ .../tests/Application/LicenceKeyTest.php | 36 +++++++ .../Application/ParameterExpressionTest.php | 55 ++++++++++ .../ExpressionLanguage/CalculatorHandler.php | 42 ++++++++ 4 files changed, 235 insertions(+) create mode 100644 packages/Tempest/tests/Application/CoverageParityTest.php create mode 100644 packages/Tempest/tests/Application/LicenceKeyTest.php create mode 100644 packages/Tempest/tests/Application/ParameterExpressionTest.php create mode 100644 packages/Tempest/tests/Fixture/ExpressionLanguage/CalculatorHandler.php diff --git a/packages/Tempest/tests/Application/CoverageParityTest.php b/packages/Tempest/tests/Application/CoverageParityTest.php new file mode 100644 index 000000000..457b0bb8f --- /dev/null +++ b/packages/Tempest/tests/Application/CoverageParityTest.php @@ -0,0 +1,102 @@ +clearEcotoneTempestCacheDir(); + parent::setUp(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->clearEcotoneTempestCacheDir(); + } + + public function test_handlers_work_on_warm_prod_cache_boot(): void + { + $userId = 'warm-cache-user'; + + $commandBus = $this->container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + + $this->assertEquals( + User::register($userId), + $userRepository->getUser($userId), + ); + + EcotoneServiceInitializer::clearCache(); + MessagingSystemInitializer::clearDefinitionHolder(); + + $this->setupKernel(); + + $commandBus = $this->container->get(CommandBus::class); + $commandBus->sendWithRouting('user.register', $userId); + + $userRepository = $this->container->get(UserRepository::class); + + $this->assertEquals( + User::register($userId), + $userRepository->getUser($userId), + ); + } + + public function test_default_error_channel_configured_in_ecotone_config_does_not_break_boot(): void + { + $this->assertInstanceOf( + CommandBus::class, + $this->container->get(CommandBus::class), + ); + } + + private function clearEcotoneTempestCacheDir(): void + { + $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ecotone_tempest'; + if (! is_dir($dir)) { + return; + } + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } + + private function removeDirectory(string $dir): void + { + foreach (glob($dir . '/*') ?: [] as $file) { + is_dir($file) ? $this->removeDirectory($file) : @unlink($file); + } + @rmdir($dir); + } +} diff --git a/packages/Tempest/tests/Application/LicenceKeyTest.php b/packages/Tempest/tests/Application/LicenceKeyTest.php new file mode 100644 index 000000000..a501a0d5c --- /dev/null +++ b/packages/Tempest/tests/Application/LicenceKeyTest.php @@ -0,0 +1,36 @@ +assertInstanceOf( + CommandBus::class, + $this->container->get(CommandBus::class), + ); + } +} diff --git a/packages/Tempest/tests/Application/ParameterExpressionTest.php b/packages/Tempest/tests/Application/ParameterExpressionTest.php new file mode 100644 index 000000000..b974cbae1 --- /dev/null +++ b/packages/Tempest/tests/Application/ParameterExpressionTest.php @@ -0,0 +1,55 @@ +container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $commandBus->sendWithRouting('calculator.multiply', ['value' => 5]); + + $this->assertSame(50, $queryBus->sendWithRouting('calculator.getResult')); + + putenv('ECOTONE_MULTIPLIER'); + } + + public function test_parameter_function_with_env_variable_in_expression(): void + { + putenv('ECOTONE_ENV_MULTIPLIER=7'); + + $commandBus = $this->container->get(CommandBus::class); + $queryBus = $this->container->get(QueryBus::class); + + $commandBus->sendWithRouting('calculator.multiplyWithEnv', ['value' => 3]); + + $this->assertSame(21, $queryBus->sendWithRouting('calculator.getEnvResult')); + + putenv('ECOTONE_ENV_MULTIPLIER'); + } +} diff --git a/packages/Tempest/tests/Fixture/ExpressionLanguage/CalculatorHandler.php b/packages/Tempest/tests/Fixture/ExpressionLanguage/CalculatorHandler.php new file mode 100644 index 000000000..e2e2b2bfb --- /dev/null +++ b/packages/Tempest/tests/Fixture/ExpressionLanguage/CalculatorHandler.php @@ -0,0 +1,42 @@ +result = $calculatedValue; + } + + #[CommandHandler('calculator.multiplyWithEnv')] + public function multiplyWithEnv(#[Payload("parameter('ECOTONE_ENV_MULTIPLIER') * payload['value']")] int $calculatedValue): void + { + $this->envResult = $calculatedValue; + } + + #[QueryHandler('calculator.getResult')] + public function getResult(): int + { + return $this->result; + } + + #[QueryHandler('calculator.getEnvResult')] + public function getEnvResult(): int + { + return $this->envResult; + } +} From 0830bee99679e2ee760d26c2a0b8b9f1d7be7bee Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:50:53 +0200 Subject: [PATCH 18/27] feat(tempest): add composer suggest for dbal deps and ConnectionReference credential guard tests Item C1: add suggest block to composer.json describing ecotone/dbal and enqueue/dbal as optional deps enabling DBAL/multi-tenant features. Item C2: add ConnectionReferenceCredentialsTest as a guard capturing the current credential serialization behavior. Decision: kept existing base64(serialize(DatabaseConfig)) approach since the prior attempt to change it broke per-tenant connection routing. Credentials are written to the on-disk cache when cacheConfiguration=true; documented in README with a security note. MultiTenantTest stays green (verified). --- packages/Tempest/composer.json | 4 + .../ConnectionReferenceCredentialsTest.php | 73 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php diff --git a/packages/Tempest/composer.json b/packages/Tempest/composer.json index ee704e6de..b9884ed8e 100644 --- a/packages/Tempest/composer.json +++ b/packages/Tempest/composer.json @@ -53,6 +53,10 @@ "ecotone/ecotone": "~1.314.0", "tempest/framework": "^3.11" }, + "suggest": { + "ecotone/dbal": "Enables DBAL integration, database repositories, multi-tenant connections via TempestConnectionReference, and the DbalModule for transactional outbox, sagas, and event sourcing backed by relational databases.", + "enqueue/dbal": "DBAL-backed message queue transport; required if you want durable async messaging over a relational database using ecotone/dbal." + }, "require-dev": { "phpunit/phpunit": "^11.0", "phpstan/phpstan": "^1.8", diff --git a/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php b/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php new file mode 100644 index 000000000..4355fc9c1 --- /dev/null +++ b/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php @@ -0,0 +1,73 @@ +assertSame('tenant_a', $reference->getReferenceName()); + $this->assertSame($config, $reference->getDatabaseConfig()); + } + + public function test_get_definition_round_trips_reference_name_and_database_config(): void + { + $config = new PostgresConfig( + host: 'database', + port: '5432', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + ); + + $reference = TempestConnectionReference::create('tenant_a', $config); + $definition = $reference->getDefinition(); + + $reconstructed = TempestConnectionReference::createFromSerializedConfig( + ...$definition->getArguments() + ); + + $this->assertSame('tenant_a', $reconstructed->getReferenceName()); + + $reconstructedConfig = $reconstructed->getDatabaseConfig(); + $this->assertNotNull($reconstructedConfig); + $this->assertSame('database', $reconstructedConfig->host); + $this->assertSame('ecotone', $reconstructedConfig->database); + $this->assertSame('ecotone', $reconstructedConfig->username); + $this->assertSame('secret', $reconstructedConfig->password); + } + + public function test_default_connection_has_no_embedded_database_config(): void + { + $reference = TempestConnectionReference::defaultConnection(); + + $this->assertNull($reference->getDatabaseConfig()); + } + + public function test_create_without_config_produces_null_database_config(): void + { + $reference = TempestConnectionReference::create('some_connection'); + + $this->assertNull($reference->getDatabaseConfig()); + } +} From afd313e7b158273334cff1e61fbf21fb3625d2b2 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Fri, 5 Jun 2026 21:51:00 +0200 Subject: [PATCH 19/27] docs(tempest): replace package template README with accurate ecotone/tempest docs Item B: covers composer require, zero-config handler auto-discovery from PSR-4 roots, EcotoneConfig reference table, console commands, DBAL single/multi-tenant setup via #[ServiceContext], production caching, expression language parameter() function, and a security note about DatabaseConfig credentials in the on-disk cache. --- packages/Tempest/README.md | 294 +++++++++++++++++++++++++++++++++++-- 1 file changed, 279 insertions(+), 15 deletions(-) diff --git a/packages/Tempest/README.md b/packages/Tempest/README.md index 4f510c47c..7cca5506a 100644 --- a/packages/Tempest/README.md +++ b/packages/Tempest/README.md @@ -6,34 +6,298 @@ To contribute make use of [Ecotone-Dev repository](https://github.com/ecotonefra

![Github Actions](https://github.com/ecotoneFramework/ecotone-dev/actions/workflows/split-testing.yml/badge.svg) -[![Latest Stable Version](https://poser.pugx.org/ecotone/ecotone/v/stable)](https://packagist.org/packages/ecotone/ecotone) -[![License](https://poser.pugx.org/ecotone/ecotone/license)](https://packagist.org/packages/ecotone/ecotone) -[![Total Downloads](https://img.shields.io/packagist/dt/ecotone/ecotone)](https://packagist.org/packages/ecotone/ecotone) -[![PHP Version Require](https://img.shields.io/packagist/dependency-v/ecotone/ecotone/php.svg)](https://packagist.org/packages/ecotone/ecotone) +[![Latest Stable Version](https://poser.pugx.org/ecotone/tempest/v/stable)](https://packagist.org/packages/ecotone/tempest) +[![License](https://poser.pugx.org/ecotone/tempest/license)](https://packagist.org/packages/ecotone/tempest) +[![Total Downloads](https://img.shields.io/packagist/dt/ecotone/tempest)](https://packagist.org/packages/ecotone/tempest) +[![PHP Version Require](https://img.shields.io/packagist/dependency-v/ecotone/tempest/php.svg)](https://packagist.org/packages/ecotone/tempest) **Ecotone is the PHP architecture layer that grows with your system — without rewrites.** From `#[CommandHandler]` on day one, to event sourcing, sagas, outbox, and distributed messaging at scale — one package, same codebase, no forced migrations between growth stages. Declarative PHP 8 attributes on the classes you already have. -## {{Package name}} +## ecotone/tempest -{{One-paragraph description of what this package adds to Ecotone and the primary use case.}} +Ecotone for [Tempest](https://tempestphp.com) — CQRS, Event Sourcing, Sagas, Durable Workflows, and Outbox via PHP attributes. Zero-config auto-discovery derives your application namespaces from your `composer.json` PSR-4 roots. Handlers, aggregates, sagas, and projections are found automatically without any registration boilerplate. -- {{Key capability 1}} -- {{Key capability 2}} -- {{Key capability 3}} +- Zero-config auto-discovery of handlers from your app's PSR-4 namespaces +- CQRS — `CommandBus`, `QueryBus`, `EventBus` available via dependency injection +- Database integration via `ecotone/dbal` with Tempest's `DatabaseConfig` +- Multi-tenant connections with per-tenant database switching +- Async messaging, sagas, outbox, event sourcing (with appropriate modules) +- Console commands: `ecotone:list`, `ecotone:run`, `ecotone:cache:clear` Visit [ecotone.tech](https://ecotone.tech) to learn more. -> Works with [Symfony](https://docs.ecotone.tech/modules/symfony-ddd-cqrs-event-sourcing), [Laravel](https://docs.ecotone.tech/modules/laravel-ddd-cqrs-event-sourcing), or any PSR-11 framework via [Ecotone Lite](https://docs.ecotone.tech/install-php-service-bus#install-ecotone-lite-no-framework). +## Installation -## Getting started +```bash +composer require ecotone/tempest +``` -See the [quickstart guide](https://docs.ecotone.tech/quick-start) and [reference documentation](https://docs.ecotone.tech). Read more on the [Ecotone Blog](https://blog.ecotone.tech). +Ecotone auto-discovery is enabled via Tempest's package discovery system. No service provider registration is needed. -## AI-Ready documentation +## Getting Started -Ecotone ships with MCP server, Agentic Skills, and LLMs.txt for any coding agent. See the [AI Integration Guide](https://docs.ecotone.tech/other/ai-integration). +### Zero-Config Handler Discovery + +Ecotone derives your application namespaces from the PSR-4 roots declared in your `composer.json`. Any class with an Ecotone attribute (`#[CommandHandler]`, `#[QueryHandler]`, `#[EventHandler]`, etc.) in those namespaces is discovered automatically. + +```php +// src/Order/PlaceOrderHandler.php +namespace App\Order; + +use Ecotone\Modelling\Attribute\CommandHandler; +use Ecotone\Modelling\Attribute\QueryHandler; + +final class PlaceOrderHandler +{ + private array $orders = []; + + #[CommandHandler('order.place')] + public function place(string $orderId): void + { + $this->orders[] = $orderId; + } + + #[QueryHandler('order.all')] + public function all(): array + { + return $this->orders; + } +} +``` + +That is all that is needed. No configuration file, no registration — Ecotone discovers it from your namespace. + +### Using the Buses + +`CommandBus`, `QueryBus`, and `EventBus` are automatically registered in the Tempest container and can be injected anywhere: + +```php +use Ecotone\Modelling\CommandBus; +use Ecotone\Modelling\QueryBus; + +final class OrderController +{ + public function __construct( + private CommandBus $commandBus, + private QueryBus $queryBus, + ) {} + + public function place(string $orderId): array + { + $this->commandBus->sendWithRouting('order.place', $orderId); + + return $this->queryBus->sendWithRouting('order.all'); + } +} +``` + +## Optional Configuration + +Create a class that provides `EcotoneConfig` to the Tempest container to customise behaviour: + +```php +// src/Configuration/EcotoneConfiguration.php +namespace App\Configuration; + +use Ecotone\Tempest\EcotoneConfig; +use Tempest\Container\Singleton; + +#[Singleton] +final class EcotoneConfiguration +{ + public function ecotoneConfig(): EcotoneConfig + { + return new EcotoneConfig( + serviceName: 'my-service', + licenceKey: getenv('ECOTONE_LICENCE_KEY') ?: '', + cacheConfiguration: true, + ); + } +} +``` + +Or simply bind `EcotoneConfig` in a Tempest config file: + +```php +// config/ecotone.php (or any #[ServiceContext] provider) +use Ecotone\Tempest\EcotoneConfig; + +return new EcotoneConfig( + serviceName: 'my-service', + licenceKey: getenv('ECOTONE_LICENCE_KEY') ?: '', +); +``` + +### `EcotoneConfig` Reference + +| Property | Type | Default | Description | +|---|---|---|---| +| `serviceName` | `string` | `''` (from `ECOTONE_SERVICE_NAME` env) | Identifies this service in distributed tracing and logs | +| `namespaces` | `array` | `[]` | Explicit namespaces to scan. When empty and `loadAppNamespaces` is true, derived from `composer.json` | +| `loadAppNamespaces` | `bool` | `true` | Auto-derive scan namespaces from your app's PSR-4 roots | +| `cacheConfiguration` | `bool` | `false` (from `ECOTONE_CACHE_CONFIGURATION` env) | Cache the messaging system definition for production | +| `defaultSerializationMediaType` | `string` | `''` | Override the default message serialization format | +| `defaultErrorChannel` | `string` | `''` | Channel name for unhandled async exceptions | +| `skippedModulePackageNames` | `array` | `[]` | Module packages to skip loading (useful for testing) | +| `licenceKey` | `string` | `''` | Enterprise licence key | + +## Console Commands + +Ecotone registers Tempest console commands automatically: + +```bash +# List all registered consumers and handlers +./tempest ecotone:list + +# Run an asynchronous consumer (requires ecotone/dbal or another async transport) +./tempest ecotone:run notifications + +# Clear the Ecotone configuration cache +./tempest ecotone:cache:clear +``` + +The `ecotone:cache:clear` command removes the cached messaging system definition from `sys_get_temp_dir()/ecotone_tempest/`. Use it after deploying changes when `cacheConfiguration` is enabled. + +## Database Integration (requires `ecotone/dbal`) + +Install the DBAL module: + +```bash +composer require ecotone/dbal +``` + +### Single-Tenant Connection + +Register a `TempestConnectionReference` via `#[ServiceContext]` to bridge Tempest's `DatabaseConfig` to Ecotone's DBAL module: + +```php +use Ecotone\Messaging\Attribute\ServiceContext; +use Ecotone\Tempest\Config\TempestConnectionReference; + +final class EcotoneConfiguration +{ + #[ServiceContext] + public function dbalConnection(): TempestConnectionReference + { + return TempestConnectionReference::defaultConnection(); + } +} +``` + +`TempestConnectionReference::defaultConnection()` resolves Tempest's default `DatabaseConfig` from the container at runtime. + +To use a specific config: + +```php +use Tempest\Database\Config\PostgresConfig; + +#[ServiceContext] +public function dbalConnection(): TempestConnectionReference +{ + return TempestConnectionReference::create('myConnection', new PostgresConfig( + host: getenv('DB_HOST') ?: 'localhost', + port: getenv('DB_PORT') ?: '5432', + username: getenv('DB_USER') ?: 'app', + password: getenv('DB_PASSWORD') ?: '', + database: getenv('DB_NAME') ?: 'app', + )); +} +``` + +### Multi-Tenant Connection + +Use `MultiTenantConfiguration` together with per-tenant `TempestConnectionReference` instances. A `tenant` header on each message selects the correct database: + +```php +use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; +use Ecotone\Messaging\Attribute\ServiceContext; +use Ecotone\Tempest\Config\TempestConnectionReference; +use Tempest\Database\Config\MysqlConfig; +use Tempest\Database\Config\PostgresConfig; + +final class EcotoneConfiguration +{ + #[ServiceContext] + public function multiTenantConfiguration(): MultiTenantConfiguration + { + return MultiTenantConfiguration::create( + tenantHeaderName: 'tenant', + tenantToConnectionMapping: [ + 'tenant_a' => TempestConnectionReference::create('tenant_a', new PostgresConfig( + host: getenv('TENANT_A_DB_HOST'), + username: getenv('TENANT_A_DB_USER'), + password: getenv('TENANT_A_DB_PASSWORD'), + database: getenv('TENANT_A_DB_NAME'), + )), + 'tenant_b' => TempestConnectionReference::create('tenant_b', new MysqlConfig( + host: getenv('TENANT_B_DB_HOST'), + username: getenv('TENANT_B_DB_USER'), + password: getenv('TENANT_B_DB_PASSWORD'), + database: getenv('TENANT_B_DB_NAME'), + )), + ], + ); + } +} +``` + +Route commands to the correct tenant by setting the header: + +```php +$commandBus->sendWithRouting('order.place', $order, metadata: ['tenant' => 'tenant_a']); +``` + +> **Security note:** When `cacheConfiguration` is enabled, the `DatabaseConfig` for each +> `TempestConnectionReference::create(name, config)` call is serialized into the on-disk cache +> file. This means database credentials (username, password, DSN) are written to disk in +> base64-encoded serialized form. Keep the cache directory (`sys_get_temp_dir()/ecotone_tempest/`) +> non-world-readable and rotate credentials if the cache file is exposed. Use +> `./tempest ecotone:cache:clear` after credential rotation. + +## Production Caching + +Enable the configuration cache for production to avoid re-scanning annotations on every request: + +```php +new EcotoneConfig( + cacheConfiguration: true, +); +``` + +Or set the `ECOTONE_CACHE_CONFIGURATION=1` environment variable. The cache is stored in `sys_get_temp_dir()/ecotone_tempest/`. Clear it after deployment: + +```bash +./tempest ecotone:cache:clear +``` + +When `APP_ENV=prod` or `APP_ENV=production`, the production cache path is used automatically regardless of the `cacheConfiguration` setting. + +## Expression Language + +Ecotone supports Symfony Expression Language in `#[Payload]` and `#[Header]` attributes. The `parameter()` function reads from environment variables via `TempestConfigurationVariableService`: + +```php +use Ecotone\Messaging\Attribute\Parameter\Payload; +use Ecotone\Modelling\Attribute\CommandHandler; + +final class CalculatorHandler +{ + #[CommandHandler('calculator.multiply')] + public function multiply( + #[Payload("parameter('APP_MULTIPLIER') * payload['value']")] int $result + ): void { + // $result = APP_MULTIPLIER * payload['value'] + } +} +``` + +Requires `symfony/expression-language`: + +```bash +composer require symfony/expression-language +``` ## Feature requests and issue reporting @@ -57,4 +321,4 @@ If you want to help building and improving Ecotone consider becoming a sponsor: ## Tags -PHP, Ecotone, {{package-specific tags}} +PHP, Ecotone, Tempest, CQRS, Event Sourcing, Sagas, Durable Workflows, Outbox, Messaging, EIP, DDD From bc99ac273b1d2f8d9aa34381620a16b6f8036bcb Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 6 Jun 2026 11:52:31 +0200 Subject: [PATCH 20/27] fix(tempest): gateway injection works without ecotone.config.php (zero-config canInitialize) EcotoneServiceInitializer::canInitialize() was guarded by has(EcotoneConfig::class), so in a zero-config app (no ecotone.config.php discovered) every gateway get() failed with "not an instantiable class". MessagingSystemInitializer already has a correct fallback (resolveEcotoneConfig returns new EcotoneConfig()), so the guard is wrong. Remove the has(EcotoneConfig) guard from EcotoneServiceInitializer so compile is triggered unconditionally on first gateway request. Keep the guard in EcotoneConsoleCommandDiscovery::apply() (which runs at discovery time, before handlers are scanned) so it does not eagerly compile all modules during boot when no config exists. Add a real-boot test: CommandBus resolves and handles a command without any prior ConfiguredMessagingSystem touch, proving EcotoneServiceInitializer triggers the compile. --- .../src/EcotoneConsoleCommandDiscovery.php | 8 +-- .../Tempest/src/EcotoneServiceInitializer.php | 2 +- .../tests/Application/RealBootTest.php | 50 +++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php index e2929195e..29e368f07 100644 --- a/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php +++ b/packages/Tempest/src/EcotoneConsoleCommandDiscovery.php @@ -33,11 +33,11 @@ public function discover(DiscoveryLocation $location, ClassReflector $class): vo public function apply(): void { - if (! $this->container->has(EcotoneConfig::class)) { - return; - } - if (MessagingSystemInitializer::getDefinitionHolder() === null) { + if (! $this->container->has(EcotoneConfig::class)) { + return; + } + (new MessagingSystemInitializer())->initialize($this->container); } diff --git a/packages/Tempest/src/EcotoneServiceInitializer.php b/packages/Tempest/src/EcotoneServiceInitializer.php index 3f592856d..73ad34f11 100644 --- a/packages/Tempest/src/EcotoneServiceInitializer.php +++ b/packages/Tempest/src/EcotoneServiceInitializer.php @@ -43,7 +43,7 @@ public function canInitialize(ClassReflector $class, null|string|UnitEnum $tag): $container = GenericContainer::instance(); - if ($container === null || ! $container->has(EcotoneConfig::class)) { + if ($container === null) { return false; } diff --git a/packages/Tempest/tests/Application/RealBootTest.php b/packages/Tempest/tests/Application/RealBootTest.php index b55fc7ed0..fc3867599 100644 --- a/packages/Tempest/tests/Application/RealBootTest.php +++ b/packages/Tempest/tests/Application/RealBootTest.php @@ -40,6 +40,56 @@ protected function tearDown(): void MessagingSystemInitializer::clearDefinitionHolder(); } + public function test_command_bus_resolves_from_tempest_container_without_any_ecotone_config_file(): void + { + $internalStorage = '/tmp/ecotone_tempest_real_boot_noconfig_' . getmypid(); + + $appLocation = new DiscoveryLocation('App\\Tempest\\', self::APP_ROOT . '/src'); + $ecotoneLocation = new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'); + + $kernel = new FrameworkKernel( + root: self::APP_ROOT, + discoveryLocations: [$ecotoneLocation, $appLocation], + internalStorage: $internalStorage, + ); + + $kernel->registerKernel() + ->validateRoot() + ->loadEnv() + ->registerEmergencyExceptionHandler() + ->registerShutdownFunction() + ->registerInternalStorage() + ->loadComposer(); + + $this->injectDiscoveryConfig($kernel, $ecotoneLocation, $appLocation); + + // Monorepo: all packages including Enterprise ones are present, so skip them to avoid + // licence errors. In a real app only installed packages load — no skip needed. + // The key proof: no ecotone.config.php discovered in the boot path; the app + // derives its EcotoneConfig from MessagingSystemInitializer's fallback new EcotoneConfig(). + $kernel->container->singleton( + EcotoneConfig::class, + new EcotoneConfig( + skippedModulePackageNames: ModulePackageList::allPackages(), + test: true, + ), + ); + + $kernel->loadConfig() + ->bootDiscovery() + ->registerExceptionHandler() + ->event(KernelEvent::BOOTED); + + // Resolve CommandBus WITHOUT touching ConfiguredMessagingSystem first — + // EcotoneServiceInitializer must trigger compile on the first gateway request. + $commandBus = $kernel->container->get(CommandBus::class); + $queryBus = $kernel->container->get(QueryBus::class); + + $commandBus->sendWithRouting('app.ping'); + + $this->assertTrue($queryBus->sendWithRouting('app.wasHandled')); + } + public function test_zero_config_namespace_derivation_from_composer_psr4_discovers_handlers(): void { $internalStorage = '/tmp/ecotone_tempest_real_boot_' . getmypid(); From 41b7e0190ffcd90f01d5a2c741d5c06b865fe449 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sat, 6 Jun 2026 19:36:04 +0200 Subject: [PATCH 21/27] fix(tempest): reuse Tempest's singleton PDO so Ecotone transactions roll back Tempest model writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TempestConnectionResolver was creating a new PDO connection independent of Tempest's own singleton PDOConnection. This meant Ecotone's DbalTransactionInterceptor opened a transaction on one connection while IsDatabaseModel::save() wrote on another — so rollback on exception did not undo model inserts. Fix: when no per-tenant DatabaseConfig is serialized in the reference (the default single-connection path), resolve Tempest's singleton Connection from the container, extract its PDO via reflection, and pass it to Doctrine's Connection. Ecotone and Tempest's ORM now share one PDO resource, so transactions wrap both. Add SharedConnectionTransactionTest: command handler inserts via IsDatabaseModel, throws RuntimeException, asserts the row count is 0 (rollback succeeded). --- .../src/Config/TempestConnectionResolver.php | 21 ++++- .../Dbal/SharedConnectionTransactionTest.php | 89 +++++++++++++++++++ .../InsertThenFailHandler.php | 24 +++++ .../SharedConnectionConfiguration.php | 20 +++++ .../SharedConnection/SharedConnectionItem.php | 19 ++++ 5 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php create mode 100644 packages/Tempest/tests/Fixture/SharedConnection/InsertThenFailHandler.php create mode 100644 packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionConfiguration.php create mode 100644 packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionItem.php diff --git a/packages/Tempest/src/Config/TempestConnectionResolver.php b/packages/Tempest/src/Config/TempestConnectionResolver.php index c35ef29fe..9aa0efc25 100644 --- a/packages/Tempest/src/Config/TempestConnectionResolver.php +++ b/packages/Tempest/src/Config/TempestConnectionResolver.php @@ -13,9 +13,11 @@ use Ecotone\Tempest\Config\PDO\SQLiteDriver; use Interop\Queue\ConnectionFactory; use PDO; +use ReflectionProperty; use Tempest\Container\GenericContainer; use Tempest\Database\Config\DatabaseConfig; use Tempest\Database\Config\DatabaseDialect; +use Tempest\Database\Connection\Connection as TempestConnection; /** * licence Apache-2.0 @@ -28,7 +30,11 @@ public static function resolve(TempestConnectionReference $reference): Connectio throw new InvalidArgumentException('Dbal Module is not installed. Please install it first to make use of Database capabilities.'); } - $databaseConfig = $reference->getDatabaseConfig() ?? self::resolveDefaultDatabaseConfig(); + $databaseConfig = $reference->getDatabaseConfig(); + + if ($databaseConfig === null) { + return self::resolveFromTempestConnection(); + } $pdo = new PDO( $databaseConfig->dsn, @@ -47,11 +53,20 @@ public static function resolve(TempestConnectionReference $reference): Connectio return DbalConnection::create($doctrineConnection); } - private static function resolveDefaultDatabaseConfig(): DatabaseConfig + private static function resolveFromTempestConnection(): ConnectionFactory { $container = GenericContainer::instance(); + $tempestConnection = $container->get(TempestConnection::class); + $databaseConfig = $container->get(DatabaseConfig::class); - return $container->get(DatabaseConfig::class); + $pdoProperty = new ReflectionProperty($tempestConnection, 'pdo'); + $sharedPdo = $pdoProperty->getValue($tempestConnection); + + $driver = self::driverForDialect($databaseConfig->dialect); + + $doctrineConnection = new Connection(['pdo' => $sharedPdo], $driver); + + return DbalConnection::create($doctrineConnection); } private static function driverForDialect(DatabaseDialect $dialect): Driver diff --git a/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php new file mode 100644 index 000000000..fa8d07c62 --- /dev/null +++ b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php @@ -0,0 +1,89 @@ +setupKernel(); + + $this->container->config(new PostgresConfig( + host: 'database', + port: '5432', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + )); + + $database = $this->container->get(Database::class); + $database->execute(new Query('DROP TABLE IF EXISTS shared_connection_items')); + $createSql = (new CreateTableStatement('shared_connection_items')) + ->primary('id') + ->string('name') + ->compile($database->dialect); + $database->execute(new Query($createSql)); + } + + protected function tearDown(): void + { + try { + $database = $this->container->get(Database::class); + $database->execute(new Query('DROP TABLE IF EXISTS shared_connection_items')); + } catch (\Throwable) { + } + parent::tearDown(); + } + + public function test_tempest_model_insert_is_rolled_back_when_ecotone_transaction_fails(): void + { + $commandBus = $this->container->get(CommandBus::class); + + try { + $commandBus->sendWithRouting('shared_connection.insert_then_fail'); + } catch (RuntimeException) { + } + + $count = $this->container->get(DbalConnectionFactory::class) + ->createContext() + ->getDbalConnection() + ->executeQuery('SELECT COUNT(*) FROM shared_connection_items') + ->fetchOne(); + + $this->assertSame(0, (int) $count, 'Tempest model insert must be rolled back by Ecotone\'s DBAL transaction'); + } +} diff --git a/packages/Tempest/tests/Fixture/SharedConnection/InsertThenFailHandler.php b/packages/Tempest/tests/Fixture/SharedConnection/InsertThenFailHandler.php new file mode 100644 index 000000000..0cb836e0d --- /dev/null +++ b/packages/Tempest/tests/Fixture/SharedConnection/InsertThenFailHandler.php @@ -0,0 +1,24 @@ +name = 'should-be-rolled-back'; + $item->save(); + + throw new RuntimeException('Intentional failure to trigger rollback'); + } +} diff --git a/packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionConfiguration.php b/packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionConfiguration.php new file mode 100644 index 000000000..2d1f0b7e4 --- /dev/null +++ b/packages/Tempest/tests/Fixture/SharedConnection/SharedConnectionConfiguration.php @@ -0,0 +1,20 @@ + Date: Sat, 6 Jun 2026 20:09:27 +0200 Subject: [PATCH 22/27] feat(tempest): resolve DBAL connections from tagged Tempest DatabaseConfig (no serialized credentials) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TempestConnectionReference::create('tag') now stores only the tag name. At runtime TempestConnectionResolver resolves the DatabaseConfig from Tempest's container by tag (no credentials serialized into Ecotone's compiled cache). Users register configs in standard Tempest *.config.php files: return new PostgresConfig(host: '...', tag: 'tenant_a'); // In #[ServiceContext]: TempestConnectionReference::create('tenant_a') Per-tenant Connection singletons are registered in Tempest's container on first use so they are shared across Ecotone and Tempest ORM within the same message. TempestConnectionReference::defaultConnection() uses TempestDynamicDriver / TempestDynamicDriverConnection — a Doctrine Driver that re-resolves Tempest's default Connection singleton on every DBAL call. Combined with TempestTenantDatabaseSwitcher closing the Doctrine Connection on tenant switch, the DbalTransactionInterceptor follows tenant connection promotions transparently. TempestTenantDatabaseSwitcher::switchOn now resolves the tagged DatabaseConfig from the container (instead of the previously embedded/deserialized config) and ensures the tagged Connection singleton exists before promoting it as the default. ConnectionReferenceCredentialsTest updated to reflect the new tag-based API. MultiTenantTest::setUp now registers tagged DatabaseConfig objects in the container. --- .../src/Config/PDO/TempestDynamicDriver.php | 24 ++++++ .../PDO/TempestDynamicDriverConnection.php | 84 +++++++++++++++++++ .../src/Config/TempestConnectionReference.php | 40 +++++---- .../src/Config/TempestConnectionResolver.php | 49 +++++++---- .../Config/TempestTenantDatabaseSwitcher.php | 40 ++++++++- .../ConnectionReferenceCredentialsTest.php | 69 +++++++-------- .../MultiTenantEcotoneConfiguration.php | 18 +--- .../TenantInsertThenFailHandler.php | 34 ++++++++ .../TenantSharedConnectionConfiguration.php | 32 +++++++ .../tests/MultiTenant/MultiTenantTest.php | 17 ++++ 10 files changed, 317 insertions(+), 90 deletions(-) create mode 100644 packages/Tempest/src/Config/PDO/TempestDynamicDriver.php create mode 100644 packages/Tempest/src/Config/PDO/TempestDynamicDriverConnection.php create mode 100644 packages/Tempest/tests/Fixture/TenantSharedConnection/TenantInsertThenFailHandler.php create mode 100644 packages/Tempest/tests/Fixture/TenantSharedConnection/TenantSharedConnectionConfiguration.php diff --git a/packages/Tempest/src/Config/PDO/TempestDynamicDriver.php b/packages/Tempest/src/Config/PDO/TempestDynamicDriver.php new file mode 100644 index 000000000..d2e828ab3 --- /dev/null +++ b/packages/Tempest/src/Config/PDO/TempestDynamicDriver.php @@ -0,0 +1,24 @@ +get(Connection::class); + $property = new \ReflectionProperty($connection, 'pdo'); + return $property->getValue($connection); + } + + public function prepare(string $sql): StatementInterface + { + return new Statement($this->pdo()->prepare($sql)); + } + + public function query(string $sql): ResultInterface + { + return new Result($this->pdo()->query($sql)); + } + + public function quote(string $value): string + { + return $this->pdo()->quote($value); + } + + public function exec(string $sql): int|string + { + $result = $this->pdo()->exec($sql); + return $result === false ? 0 : $result; + } + + public function lastInsertId(): int|string + { + return $this->pdo()->lastInsertId(); + } + + public function beginTransaction(): void + { + $this->pdo()->beginTransaction(); + } + + public function commit(): void + { + $this->pdo()->commit(); + } + + public function rollBack(): void + { + $this->pdo()->rollBack(); + } + + public function getNativeConnection(): PDO + { + return $this->pdo(); + } + + public function getServerVersion(): string + { + return $this->pdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + } +} diff --git a/packages/Tempest/src/Config/TempestConnectionReference.php b/packages/Tempest/src/Config/TempestConnectionReference.php index d4743bc0d..b1b3c0b80 100644 --- a/packages/Tempest/src/Config/TempestConnectionReference.php +++ b/packages/Tempest/src/Config/TempestConnectionReference.php @@ -8,7 +8,6 @@ use Ecotone\Messaging\Config\Container\DefinedObject; use Ecotone\Messaging\Config\Container\Definition; use Enqueue\Dbal\DbalConnectionFactory; -use Tempest\Database\Config\DatabaseConfig; /** * licence Apache-2.0 @@ -17,28 +16,37 @@ final class TempestConnectionReference extends ConnectionReference implements De { private function __construct( string $referenceName, - private readonly ?DatabaseConfig $databaseConfig = null, + private readonly ?string $configTag = null, ) { parent::__construct($referenceName, $referenceName); } - public static function create(string $referenceName, ?DatabaseConfig $databaseConfig = null): self + /** + * Reference resolved from a tagged Tempest DatabaseConfig in the container. + * No credentials are stored here or in Ecotone's compiled cache — the config + * is looked up at runtime from Tempest's container by tag. + * + * Register the matching config in a Tempest *.config.php: + * return new PostgresConfig(host: '...', tag: 'tenant_a'); + */ + public static function create(string $configTag, ?string $referenceName = null): self { - return new self($referenceName, $databaseConfig); + return new self($referenceName ?? $configTag, $configTag); } + /** + * Reference to the default (untagged) Tempest Connection singleton. + * Shares the same PDO that Tempest's IsDatabaseModel / Database use, + * so Ecotone's DBAL transactions wrap Tempest ORM writes on the same connection. + */ public static function defaultConnection(): self { - return new self(DbalConnectionFactory::class); + return new self(DbalConnectionFactory::class, null); } - public static function clearRegistry(): void + public function getConfigTag(): ?string { - } - - public function getDatabaseConfig(): ?DatabaseConfig - { - return $this->databaseConfig; + return $this->configTag; } public function getDefinition(): Definition @@ -47,19 +55,17 @@ public function getDefinition(): Definition TempestConnectionReference::class, [ $this->getReferenceName(), - $this->databaseConfig !== null ? base64_encode(serialize($this->databaseConfig)) : null, + $this->configTag, ], [ self::class, - 'createFromSerializedConfig', + 'fromTagAndReferenceName', ] ); } - public static function createFromSerializedConfig(string $referenceName, ?string $serializedConfig = null): self + public static function fromTagAndReferenceName(string $referenceName, ?string $configTag = null): self { - $config = $serializedConfig !== null ? unserialize(base64_decode($serializedConfig)) : null; - - return new self($referenceName, $config); + return new self($referenceName, $configTag); } } diff --git a/packages/Tempest/src/Config/TempestConnectionResolver.php b/packages/Tempest/src/Config/TempestConnectionResolver.php index 9aa0efc25..cd8f274c4 100644 --- a/packages/Tempest/src/Config/TempestConnectionResolver.php +++ b/packages/Tempest/src/Config/TempestConnectionResolver.php @@ -11,13 +11,14 @@ use Ecotone\Tempest\Config\PDO\MySqlDriver; use Ecotone\Tempest\Config\PDO\PostgresDriver; use Ecotone\Tempest\Config\PDO\SQLiteDriver; +use Ecotone\Tempest\Config\PDO\TempestDynamicDriver; use Interop\Queue\ConnectionFactory; -use PDO; use ReflectionProperty; use Tempest\Container\GenericContainer; use Tempest\Database\Config\DatabaseConfig; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Connection\Connection as TempestConnection; +use Tempest\Database\Connection\PDOConnection; /** * licence Apache-2.0 @@ -30,35 +31,51 @@ public static function resolve(TempestConnectionReference $reference): Connectio throw new InvalidArgumentException('Dbal Module is not installed. Please install it first to make use of Database capabilities.'); } - $databaseConfig = $reference->getDatabaseConfig(); + $configTag = $reference->getConfigTag(); - if ($databaseConfig === null) { + if ($configTag === null) { return self::resolveFromTempestConnection(); } - $pdo = new PDO( - $databaseConfig->dsn, - $databaseConfig->username, - $databaseConfig->password, - $databaseConfig->options, - ); + return self::resolveFromTaggedConfig($configTag); + } - $driver = self::driverForDialect($databaseConfig->dialect); + private static function resolveFromTaggedConfig(string $configTag): ConnectionFactory + { + $container = GenericContainer::instance(); - $doctrineConnection = new Connection( - ['pdo' => $pdo], - $driver, - ); + if (! $container->has(TempestConnection::class, tag: $configTag)) { + $databaseConfig = $container->get(DatabaseConfig::class, tag: $configTag); + $connection = new PDOConnection($databaseConfig); + $connection->connect(); + $container->singleton(TempestConnection::class, $connection, tag: $configTag); + } - return DbalConnection::create($doctrineConnection); + return self::doctrineConnectionFromTempestConnection( + $container->get(TempestConnection::class, tag: $configTag), + $container->get(DatabaseConfig::class, tag: $configTag), + ); } private static function resolveFromTempestConnection(): ConnectionFactory { $container = GenericContainer::instance(); - $tempestConnection = $container->get(TempestConnection::class); $databaseConfig = $container->get(DatabaseConfig::class); + $driver = self::driverForDialect($databaseConfig->dialect); + + // TempestDynamicDriver returns a TempestDynamicDriverConnection that re-resolves + // Tempest's default Connection singleton on each DBAL call. Combined with + // TempestTenantDatabaseSwitcher closing the Doctrine connection on switch, + // the DbalTransactionInterceptor transparently follows tenant connection promotions. + $doctrineConnection = new Connection([], new TempestDynamicDriver()); + + return DbalConnection::create($doctrineConnection); + } + private static function doctrineConnectionFromTempestConnection( + TempestConnection $tempestConnection, + DatabaseConfig $databaseConfig, + ): ConnectionFactory { $pdoProperty = new ReflectionProperty($tempestConnection, 'pdo'); $sharedPdo = $pdoProperty->getValue($tempestConnection); diff --git a/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php b/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php index 3e2ae4270..15a039fa7 100644 --- a/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php +++ b/packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php @@ -5,9 +5,11 @@ namespace Ecotone\Tempest\Config; use Ecotone\Messaging\Config\ConnectionReference; +use Enqueue\Dbal\DbalConnectionFactory; use Tempest\Container\GenericContainer; use Tempest\Database\Config\DatabaseConfig; use Tempest\Database\Connection\Connection; +use Tempest\Database\Connection\PDOConnection; use Tempest\Database\Database; /** @@ -33,16 +35,32 @@ public function switchOn(string|ConnectionReference $activatedConnection): void return; } - $tenantConfig = $activatedConnection->getDatabaseConfig(); + $configTag = $activatedConnection->getConfigTag(); - if ($tenantConfig === null) { + if ($configTag === null) { return; } $container = GenericContainer::instance(); + $tenantConfig = $container->get(DatabaseConfig::class, tag: $configTag); + + // Ensure the tagged Connection singleton is built so TempestConnectionResolver shares its PDO + if (! $container->has(Connection::class, tag: $configTag)) { + $connection = new PDOConnection($tenantConfig); + $connection->connect(); + $container->singleton(Connection::class, $connection, tag: $configTag); + } + + // Register the tenant's already-built Connection as the default so IsDatabaseModel + // and Ecotone's DBAL share one PDO — enabling transaction rollback across both + $tenantConnection = $container->get(Connection::class, tag: $configTag); $container->singleton(DatabaseConfig::class, $tenantConfig); - $container->unregister(Connection::class); + $container->singleton(Connection::class, $tenantConnection); $container->unregister(Database::class); + + // Close the Doctrine Connection so TempestDynamicDriver reconnects on next use, + // picking up the now-promoted default Connection's PDO + $this->closeDoctrineDefaultConnection($container); } public function switchOff(): void @@ -51,5 +69,21 @@ public function switchOff(): void $container->singleton(DatabaseConfig::class, $this->defaultDatabaseConfig); $container->unregister(Connection::class); $container->unregister(Database::class); + + $this->closeDoctrineDefaultConnection($container); + } + + private function closeDoctrineDefaultConnection(GenericContainer $container): void + { + if (! $container->has(DbalConnectionFactory::class)) { + return; + } + + try { + $factory = $container->get(DbalConnectionFactory::class); + $doctrineConnection = $factory->createContext()->getDbalConnection(); + $doctrineConnection->close(); + } catch (\Throwable) { + } } } diff --git a/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php b/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php index 4355fc9c1..802ea6294 100644 --- a/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php +++ b/packages/Tempest/tests/Dbal/ConnectionReferenceCredentialsTest.php @@ -5,8 +5,8 @@ namespace Test\Ecotone\Tempest\Dbal; use Ecotone\Tempest\Config\TempestConnectionReference; +use Enqueue\Dbal\DbalConnectionFactory; use PHPUnit\Framework\TestCase; -use Tempest\Database\Config\PostgresConfig; /** * licence Apache-2.0 @@ -14,60 +14,53 @@ */ final class ConnectionReferenceCredentialsTest extends TestCase { - public function test_create_builds_reference_with_provided_database_config(): void + public function test_create_stores_only_tag_name_not_credentials(): void { - $config = new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - ); - - $reference = TempestConnectionReference::create('tenant_a', $config); + $reference = TempestConnectionReference::create('tenant_a'); + $this->assertSame('tenant_a', $reference->getConfigTag()); $this->assertSame('tenant_a', $reference->getReferenceName()); - $this->assertSame($config, $reference->getDatabaseConfig()); } - public function test_get_definition_round_trips_reference_name_and_database_config(): void + public function test_create_with_custom_reference_name(): void { - $config = new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - ); + $reference = TempestConnectionReference::create('tenant_a', 'custom_ref'); + + $this->assertSame('tenant_a', $reference->getConfigTag()); + $this->assertSame('custom_ref', $reference->getReferenceName()); + } + + public function test_get_definition_serializes_only_tag_name_no_credentials(): void + { + $reference = TempestConnectionReference::create('tenant_a'); + $definition = $reference->getDefinition(); - $reference = TempestConnectionReference::create('tenant_a', $config); + $args = $definition->getArguments(); + $this->assertSame('tenant_a', $args[0]); + $this->assertSame('tenant_a', $args[1]); + foreach ($args as $arg) { + $this->assertIsString($arg); + } + } + + public function test_definition_round_trips_via_factory(): void + { + $reference = TempestConnectionReference::create('tenant_a'); $definition = $reference->getDefinition(); - $reconstructed = TempestConnectionReference::createFromSerializedConfig( + $reconstructed = TempestConnectionReference::fromTagAndReferenceName( ...$definition->getArguments() ); + $this->assertSame('tenant_a', $reconstructed->getConfigTag()); $this->assertSame('tenant_a', $reconstructed->getReferenceName()); - - $reconstructedConfig = $reconstructed->getDatabaseConfig(); - $this->assertNotNull($reconstructedConfig); - $this->assertSame('database', $reconstructedConfig->host); - $this->assertSame('ecotone', $reconstructedConfig->database); - $this->assertSame('ecotone', $reconstructedConfig->username); - $this->assertSame('secret', $reconstructedConfig->password); } - public function test_default_connection_has_no_embedded_database_config(): void + public function test_default_connection_has_no_tag(): void { $reference = TempestConnectionReference::defaultConnection(); - $this->assertNull($reference->getDatabaseConfig()); - } - - public function test_create_without_config_produces_null_database_config(): void - { - $reference = TempestConnectionReference::create('some_connection'); - - $this->assertNull($reference->getDatabaseConfig()); + $this->assertNull($reference->getConfigTag()); + $this->assertSame(DbalConnectionFactory::class, $reference->getReferenceName()); } } diff --git a/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php b/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php index d3e38a7a0..5ef582a5f 100644 --- a/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php +++ b/packages/Tempest/tests/Fixture/MultiTenant/MultiTenantEcotoneConfiguration.php @@ -7,8 +7,6 @@ use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; use Ecotone\Messaging\Attribute\ServiceContext; use Ecotone\Tempest\Config\TempestConnectionReference; -use Tempest\Database\Config\MysqlConfig; -use Tempest\Database\Config\PostgresConfig; /** * licence Apache-2.0 @@ -21,20 +19,8 @@ public function multiTenantConfiguration(): MultiTenantConfiguration return MultiTenantConfiguration::create( tenantHeaderName: 'tenant', tenantToConnectionMapping: [ - 'tenant_a' => TempestConnectionReference::create('tenant_a', new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - )), - 'tenant_b' => TempestConnectionReference::create('tenant_b', new MysqlConfig( - host: 'database-mysql', - port: '3306', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - )), + 'tenant_a' => TempestConnectionReference::create('tenant_a'), + 'tenant_b' => TempestConnectionReference::create('tenant_b'), ], ); } diff --git a/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantInsertThenFailHandler.php b/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantInsertThenFailHandler.php new file mode 100644 index 000000000..56f3dcb73 --- /dev/null +++ b/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantInsertThenFailHandler.php @@ -0,0 +1,34 @@ +name = 'should-be-rolled-back'; + $item->save(); + + throw new RuntimeException('Intentional failure to trigger multi-tenant rollback'); + } +} diff --git a/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantSharedConnectionConfiguration.php b/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantSharedConnectionConfiguration.php new file mode 100644 index 000000000..1625d98e1 --- /dev/null +++ b/packages/Tempest/tests/Fixture/TenantSharedConnection/TenantSharedConnectionConfiguration.php @@ -0,0 +1,32 @@ + TempestConnectionReference::create('tenant_a'), + ], + ); + } +} diff --git a/packages/Tempest/tests/MultiTenant/MultiTenantTest.php b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php index 3e68e27cd..830d5e935 100644 --- a/packages/Tempest/tests/MultiTenant/MultiTenantTest.php +++ b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php @@ -55,6 +55,23 @@ protected function setUp(): void $this->setupKernel(); + $this->container->config(new PostgresConfig( + host: 'database', + port: '5432', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + tag: 'tenant_a', + )); + $this->container->config(new MysqlConfig( + host: 'database-mysql', + port: '3306', + username: 'ecotone', + password: 'secret', + database: 'ecotone', + tag: 'tenant_b', + )); + $this->createPersonsTableForBothTenants(); $this->commandBus = $this->container->get(CommandBus::class); From 20b9756df81023c05c0bfd7d70912f8484c0715a Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Wed, 10 Jun 2026 17:32:53 +0200 Subject: [PATCH 23/27] ci(tempest): fix CI for the PHP-8.5-only ecotone/tempest package tempest/framework requires PHP 8.5, but the monorepo CI runs on 8.2/8.3/8.5. Fix each failing job and make the Tempest tests portable off docker: - packages/Tempest/composer.json: bump ecotone/ecotone to ~1.315.0 and phpstan to ^1.8|^2.0 (tempest/framework -> rector -> phpstan ^2.1) so the Split Testing (8.5) per-package install resolves; branch-alias 1.315.0-dev. - MessageHeaders::unsetBusKeys: drop redundant BY_OBJECT unsets (identical to BY_NAME) flagged by phpstan 2.x in the Monorepo (8.5) job. Behavior-identical. - split-testing.yml: skip packages/Tempest when PHP != 8.5, and skip its SQLite pass (multi-tenant needs two distinct Postgres/MySQL engines). - benchmark-pr.yml / file-licence.yml: composer remove tempest/framework before install so the 8.3/8.2 jobs resolve and the licence autoloader doesn't parse Tempest's 8.5-only function files. - Tests: replace hardcoded /data/app paths with TempestTestPaths (__DIR__-based, dynamic discovery root) and hardcoded DB hosts with TempestDatabaseConfigFactory (reads DATABASE_DSN/SECONDARY_DATABASE_DSN), so tests run on CI runners too. --- .github/workflows/benchmark-pr.yml | 3 ++ .github/workflows/file-licence.yml | 3 ++ .github/workflows/split-testing.yml | 9 +++- .../Ecotone/src/Messaging/MessageHeaders.php | 5 +- packages/Tempest/composer.json | 6 +-- .../AppNamespaceAutoDiscoveryTest.php | 13 +++-- .../tests/Application/CoverageParityTest.php | 1 - .../tests/Application/LoggerWiringTest.php | 19 ++++--- .../tests/Application/RealBootTest.php | 19 ++++--- .../Dbal/DbalConnectionConnectivityTest.php | 10 +--- .../Dbal/SharedConnectionTransactionTest.php | 13 ++--- .../Tempest/tests/EcotoneIntegrationTest.php | 12 +++-- .../Fixture/MultiTenant/RegisterCustomer.php | 3 +- .../tests/MultiTenant/MultiTenantTest.php | 51 ++++--------------- .../TempestRepositoryIntegrationTest.php | 14 ++--- .../Repository/TempestRepositoryTest.php | 2 +- .../tests/TempestDatabaseConfigFactory.php | 47 +++++++++++++++++ packages/Tempest/tests/TempestTestPaths.php | 46 +++++++++++++++++ 18 files changed, 172 insertions(+), 104 deletions(-) create mode 100644 packages/Tempest/tests/TempestDatabaseConfigFactory.php create mode 100644 packages/Tempest/tests/TempestTestPaths.php diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml index 1fc2ce6b1..4fceed0d6 100644 --- a/.github/workflows/benchmark-pr.yml +++ b/.github/workflows/benchmark-pr.yml @@ -98,6 +98,9 @@ jobs: run: | git clean -fxd -e .phpbench -e vendor + - name: Remove tempest/framework (requires PHP 8.5+, not run in benchmark) + run: composer remove --dev --no-update tempest/framework + - name: Install dependencies run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction diff --git a/.github/workflows/file-licence.yml b/.github/workflows/file-licence.yml index d59767213..b38709dde 100644 --- a/.github/workflows/file-licence.yml +++ b/.github/workflows/file-licence.yml @@ -24,6 +24,9 @@ jobs: with: php-version: 8.2 + - name: Remove tempest/framework (requires PHP 8.5+; its function files fail to parse on 8.2) + run: composer remove --dev --no-update tempest/framework + - name: Install dependencies run: composer update --prefer-dist --no-interaction --ignore-platform-reqs diff --git a/.github/workflows/split-testing.yml b/.github/workflows/split-testing.yml index 1feec6608..967bdbd91 100644 --- a/.github/workflows/split-testing.yml +++ b/.github/workflows/split-testing.yml @@ -134,6 +134,10 @@ jobs: - name: Run tests run: | COMPONENTS=$(find packages -maxdepth 2 -type f -name phpunit.xml.dist | xargs -I{} dirname {}) + # ecotone/tempest depends on tempest/framework which requires PHP 8.5+; skip it on lower PHP versions + if [ "${{ matrix.php-version }}" != "8.5" ]; then + COMPONENTS=$(echo "$COMPONENTS" | grep -v 'packages/Tempest') + fi RUN_TESTS="composer tests:ci" if [ "${{ matrix.stability }}" = "prefer-lowest" ]; then @@ -210,8 +214,9 @@ jobs: overall_exit_code=1 fi - # Run SQLite pass (skip for PdoEventSourcing and DataProtection - SQLite event store not supported) - if [[ ! "$dir" =~ (PdoEventSourcing|DataProtection) ]]; then + # Run SQLite pass (skip for PdoEventSourcing and DataProtection - SQLite event store not supported; + # skip Tempest - its multi-tenant tests need two distinct Postgres/MySQL engines) + if [[ ! "$dir" =~ (PdoEventSourcing|DataProtection|Tempest) ]]; then local sqlite_db="/tmp/ecotone_${slug}_test.db" local sqlite_db_secondary="/tmp/ecotone_${slug}_test_b.db" # Use 4 slashes for absolute paths: sqlite:// + / + /path = sqlite:////path diff --git a/packages/Ecotone/src/Messaging/MessageHeaders.php b/packages/Ecotone/src/Messaging/MessageHeaders.php index bf031b1ae..2159a126c 100644 --- a/packages/Ecotone/src/Messaging/MessageHeaders.php +++ b/packages/Ecotone/src/Messaging/MessageHeaders.php @@ -283,11 +283,8 @@ public static function unsetBusKeys(array $metadata): array { unset( $metadata[MessageBusChannel::COMMAND_CHANNEL_NAME_BY_NAME], - $metadata[MessageBusChannel::COMMAND_CHANNEL_NAME_BY_OBJECT], $metadata[MessageBusChannel::EVENT_CHANNEL_NAME_BY_NAME], - $metadata[MessageBusChannel::EVENT_CHANNEL_NAME_BY_OBJECT], - $metadata[MessageBusChannel::QUERY_CHANNEL_NAME_BY_NAME], - $metadata[MessageBusChannel::QUERY_CHANNEL_NAME_BY_OBJECT] + $metadata[MessageBusChannel::QUERY_CHANNEL_NAME_BY_NAME] ); return $metadata; diff --git a/packages/Tempest/composer.json b/packages/Tempest/composer.json index b9884ed8e..2eb653ccc 100644 --- a/packages/Tempest/composer.json +++ b/packages/Tempest/composer.json @@ -50,7 +50,7 @@ }, "require": { "php": "^8.4", - "ecotone/ecotone": "~1.314.0", + "ecotone/ecotone": "~1.315.0", "tempest/framework": "^3.11" }, "suggest": { @@ -59,7 +59,7 @@ }, "require-dev": { "phpunit/phpunit": "^11.0", - "phpstan/phpstan": "^1.8", + "phpstan/phpstan": "^1.8|^2.0", "symfony/expression-language": "^6.4|^7.0|^8.0" }, "scripts": { @@ -74,7 +74,7 @@ }, "extra": { "branch-alias": { - "dev-main": "1.314.0-dev" + "dev-main": "1.315.0-dev" }, "ecotone": { "repository": "tempest" diff --git a/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php b/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php index 87ea7dfb6..512e2bbf8 100644 --- a/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php +++ b/packages/Tempest/tests/Application/AppNamespaceAutoDiscoveryTest.php @@ -17,6 +17,7 @@ use Tempest\Discovery\DiscoveryConfig; use Tempest\Discovery\DiscoveryLocation; use Tempest\Framework\Testing\IntegrationTest; +use Test\Ecotone\Tempest\TempestTestPaths; /** * licence Apache-2.0 @@ -24,7 +25,7 @@ */ final class AppNamespaceAutoDiscoveryTest extends IntegrationTest { - protected string $root = '/data/app/packages/Tempest/tests/app'; + protected string $root = ''; public function setUp(): void { @@ -35,12 +36,16 @@ public function setUp(): void public function setupKernel(): self { + if ($this->root === '') { + $this->root = TempestTestPaths::appRoot(); + } + EcotoneServiceInitializer::clearCache(); - $appSrcLocation = new DiscoveryLocation('App\\Tempest\\', '/data/app/packages/Tempest/tests/app/src'); + $appSrcLocation = new DiscoveryLocation('App\\Tempest\\', TempestTestPaths::appRoot() . '/src'); $allLocations = [ - new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'), + new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()), $appSrcLocation, ]; @@ -91,7 +96,7 @@ private function injectDiscoveryConfig(FrameworkKernel $kernel, array $extraLoca $testAppComposer->namespaces = []; $autoloadLocations = (new AutoloadDiscoveryLocations( - rootPath: '/data/app', + rootPath: TempestTestPaths::discoveryRoot(), composer: $testAppComposer, ))(); diff --git a/packages/Tempest/tests/Application/CoverageParityTest.php b/packages/Tempest/tests/Application/CoverageParityTest.php index 457b0bb8f..e816421d3 100644 --- a/packages/Tempest/tests/Application/CoverageParityTest.php +++ b/packages/Tempest/tests/Application/CoverageParityTest.php @@ -6,7 +6,6 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Modelling\CommandBus; -use Ecotone\Modelling\QueryBus; use Ecotone\Tempest\EcotoneConfig; use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; diff --git a/packages/Tempest/tests/Application/LoggerWiringTest.php b/packages/Tempest/tests/Application/LoggerWiringTest.php index 83ac10385..31da785d8 100644 --- a/packages/Tempest/tests/Application/LoggerWiringTest.php +++ b/packages/Tempest/tests/Application/LoggerWiringTest.php @@ -18,8 +18,9 @@ use Tempest\Discovery\DiscoveryConfig; use Tempest\Discovery\DiscoveryLocation; use Tempest\Framework\Testing\IntegrationTest; -use Tempest\Log\LogChannel; use Tempest\Log\Config\MultipleChannelsLogConfig; +use Tempest\Log\LogChannel; +use Test\Ecotone\Tempest\TempestTestPaths; /** * licence Apache-2.0 @@ -27,7 +28,7 @@ */ final class LoggerWiringTest extends IntegrationTest { - protected string $root = '/data/app/packages/Tempest/tests/app'; + protected string $root = ''; private TestHandler $logHandler; @@ -41,11 +42,15 @@ public function setUp(): void public function setupKernel(): self { + if ($this->root === '') { + $this->root = TempestTestPaths::appRoot(); + } + EcotoneServiceInitializer::clearCache(); $allLocations = [ - new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'), - new DiscoveryLocation('Test\\Ecotone\\Tempest\\Fixture\\', '/data/app/packages/Tempest/tests/Fixture'), + new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()), + new DiscoveryLocation('Test\\Ecotone\\Tempest\\Fixture\\', TempestTestPaths::fixturePath()), ]; $kernel = new FrameworkKernel( @@ -72,7 +77,9 @@ public function setupKernel(): self $captureHandler = $this->logHandler; $captureChannel = new class ($captureHandler) implements LogChannel { - public function __construct(private TestHandler $handler) {} + public function __construct(private TestHandler $handler) + { + } public function getHandlers(Level $level): array { @@ -117,7 +124,7 @@ private function injectDiscoveryConfig(FrameworkKernel $kernel, array $extraLoca $testAppComposer->namespaces = []; $autoloadLocations = (new AutoloadDiscoveryLocations( - rootPath: '/data/app', + rootPath: TempestTestPaths::discoveryRoot(), composer: $testAppComposer, ))(); diff --git a/packages/Tempest/tests/Application/RealBootTest.php b/packages/Tempest/tests/Application/RealBootTest.php index fc3867599..3f1b47bcb 100644 --- a/packages/Tempest/tests/Application/RealBootTest.php +++ b/packages/Tempest/tests/Application/RealBootTest.php @@ -17,6 +17,7 @@ use Tempest\Discovery\Composer; use Tempest\Discovery\DiscoveryConfig; use Tempest\Discovery\DiscoveryLocation; +use Test\Ecotone\Tempest\TempestTestPaths; /** * licence Apache-2.0 @@ -24,8 +25,6 @@ */ final class RealBootTest extends TestCase { - private const APP_ROOT = '/data/app/packages/Tempest/tests/app'; - protected function setUp(): void { EcotoneServiceInitializer::clearCache(); @@ -44,11 +43,11 @@ public function test_command_bus_resolves_from_tempest_container_without_any_eco { $internalStorage = '/tmp/ecotone_tempest_real_boot_noconfig_' . getmypid(); - $appLocation = new DiscoveryLocation('App\\Tempest\\', self::APP_ROOT . '/src'); - $ecotoneLocation = new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'); + $appLocation = new DiscoveryLocation('App\\Tempest\\', TempestTestPaths::appRoot() . '/src'); + $ecotoneLocation = new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()); $kernel = new FrameworkKernel( - root: self::APP_ROOT, + root: TempestTestPaths::appRoot(), discoveryLocations: [$ecotoneLocation, $appLocation], internalStorage: $internalStorage, ); @@ -94,11 +93,11 @@ public function test_zero_config_namespace_derivation_from_composer_psr4_discove { $internalStorage = '/tmp/ecotone_tempest_real_boot_' . getmypid(); - $appLocation = new DiscoveryLocation('App\\Tempest\\', self::APP_ROOT . '/src'); - $ecotoneLocation = new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'); + $appLocation = new DiscoveryLocation('App\\Tempest\\', TempestTestPaths::appRoot() . '/src'); + $ecotoneLocation = new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()); $kernel = new FrameworkKernel( - root: self::APP_ROOT, + root: TempestTestPaths::appRoot(), discoveryLocations: [$ecotoneLocation, $appLocation], internalStorage: $internalStorage, ); @@ -136,11 +135,11 @@ private function injectDiscoveryConfig( DiscoveryLocation $ecotoneLocation, DiscoveryLocation $appLocation, ): void { - $vendorOnlyComposer = (new Composer(self::APP_ROOT))->load(); + $vendorOnlyComposer = (new Composer(TempestTestPaths::appRoot()))->load(); $vendorOnlyComposer->namespaces = []; $vendorLocations = (new AutoloadDiscoveryLocations( - rootPath: '/data/app', + rootPath: TempestTestPaths::discoveryRoot(), composer: $vendorOnlyComposer, ))(); diff --git a/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php index 5f932147e..7339c6936 100644 --- a/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php +++ b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php @@ -8,8 +8,8 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\EcotoneConfig; use Enqueue\Dbal\DbalConnectionFactory; -use Tempest\Database\Config\PostgresConfig; use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; /** * licence Apache-2.0 @@ -31,13 +31,7 @@ protected function ecotoneConfig(): EcotoneConfig public function test_dbal_connection_derived_from_tempest_postgres_config_executes_query(): void { - $postgresConfig = new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - ); + $postgresConfig = TempestDatabaseConfigFactory::primary(); $this->setupKernel(); $this->container->config($postgresConfig); diff --git a/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php index fa8d07c62..c06e2db23 100644 --- a/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php +++ b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php @@ -11,11 +11,12 @@ use Ecotone\Tempest\MessagingSystemInitializer; use Enqueue\Dbal\DbalConnectionFactory; use RuntimeException; -use Tempest\Database\Config\PostgresConfig; use Tempest\Database\Database; use Tempest\Database\Query; use Tempest\Database\QueryStatements\CreateTableStatement; use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; +use Throwable; /** * licence Apache-2.0 @@ -42,13 +43,7 @@ protected function setUp(): void $this->setupKernel(); - $this->container->config(new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - )); + $this->container->config(TempestDatabaseConfigFactory::primary()); $database = $this->container->get(Database::class); $database->execute(new Query('DROP TABLE IF EXISTS shared_connection_items')); @@ -64,7 +59,7 @@ protected function tearDown(): void try { $database = $this->container->get(Database::class); $database->execute(new Query('DROP TABLE IF EXISTS shared_connection_items')); - } catch (\Throwable) { + } catch (Throwable) { } parent::tearDown(); } diff --git a/packages/Tempest/tests/EcotoneIntegrationTest.php b/packages/Tempest/tests/EcotoneIntegrationTest.php index bf513c4b5..57f95d88a 100644 --- a/packages/Tempest/tests/EcotoneIntegrationTest.php +++ b/packages/Tempest/tests/EcotoneIntegrationTest.php @@ -21,7 +21,7 @@ */ abstract class EcotoneIntegrationTest extends IntegrationTest { - protected string $root = '/data/app/packages/Tempest/tests/app'; + protected string $root = ''; protected function ecotoneConfig(): EcotoneConfig { @@ -35,8 +35,8 @@ protected function ecotoneConfig(): EcotoneConfig protected function discoverTestLocations(): array { return [ - new DiscoveryLocation('Ecotone\\Tempest\\', '/data/app/packages/Tempest/src'), - new DiscoveryLocation('Test\\Ecotone\\Tempest\\Fixture\\', '/data/app/packages/Tempest/tests/Fixture'), + new DiscoveryLocation('Ecotone\\Tempest\\', TempestTestPaths::srcPath()), + new DiscoveryLocation('Test\\Ecotone\\Tempest\\Fixture\\', TempestTestPaths::fixturePath()), ]; } @@ -44,6 +44,10 @@ protected function setupKernel(): self { EcotoneServiceInitializer::clearCache(); + if ($this->root === '') { + $this->root = TempestTestPaths::appRoot(); + } + $this->internalStorage = '/tmp/ecotone_tempest_test_storage_' . getmypid(); $allLocations = [...$this->discoveryLocations, ...$this->discoverTestLocations()]; @@ -111,7 +115,7 @@ private function injectDiscoveryAndConfig(FrameworkKernel $kernel, array $extraL $testAppComposer->namespaces = []; $autoloadLocations = (new AutoloadDiscoveryLocations( - rootPath: '/data/app', + rootPath: TempestTestPaths::discoveryRoot(), composer: $testAppComposer, ))(); diff --git a/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php b/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php index 26e5b8dd7..dc60da6e5 100644 --- a/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php +++ b/packages/Tempest/tests/Fixture/MultiTenant/RegisterCustomer.php @@ -12,5 +12,6 @@ final class RegisterCustomer public function __construct( public readonly int $customerId, public readonly string $name, - ) {} + ) { + } } diff --git a/packages/Tempest/tests/MultiTenant/MultiTenantTest.php b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php index 830d5e935..74b2d6f06 100644 --- a/packages/Tempest/tests/MultiTenant/MultiTenantTest.php +++ b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php @@ -11,10 +11,9 @@ use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; use PDO; -use Tempest\Database\Config\MysqlConfig; -use Tempest\Database\Config\PostgresConfig; use Test\Ecotone\Tempest\EcotoneIntegrationTest; use Test\Ecotone\Tempest\Fixture\MultiTenant\RegisterCustomer; +use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; /** * licence Apache-2.0 @@ -43,7 +42,7 @@ protected function discoverTestLocations(): array ...parent::discoverTestLocations(), new \Tempest\Discovery\DiscoveryLocation( 'Test\\Ecotone\\Tempest\\Fixture\\MultiTenant\\', - '/data/app/packages/Tempest/tests/Fixture/MultiTenant', + \Test\Ecotone\Tempest\TempestTestPaths::fixturePath() . '/MultiTenant', ), ]; } @@ -55,22 +54,8 @@ protected function setUp(): void $this->setupKernel(); - $this->container->config(new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - tag: 'tenant_a', - )); - $this->container->config(new MysqlConfig( - host: 'database-mysql', - port: '3306', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - tag: 'tenant_b', - )); + $this->container->config(TempestDatabaseConfigFactory::primary('tenant_a')); + $this->container->config(TempestDatabaseConfigFactory::secondary('tenant_b')); $this->createPersonsTableForBothTenants(); @@ -146,31 +131,15 @@ private function createPersonsTable(PDO $pdo): void private function postgresConnection(): PDO { - return new PDO( - (new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - ))->dsn, - 'ecotone', - 'secret', - ); + $config = TempestDatabaseConfigFactory::primary(); + + return new PDO($config->dsn, $config->username, $config->password); } private function mysqlConnection(): PDO { - return new PDO( - (new MysqlConfig( - host: 'database-mysql', - port: '3306', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - ))->dsn, - 'ecotone', - 'secret', - ); + $config = TempestDatabaseConfigFactory::secondary(); + + return new PDO($config->dsn, $config->username, $config->password); } } diff --git a/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php b/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php index b406f7b1c..33ebe2db8 100644 --- a/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php +++ b/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php @@ -8,14 +8,14 @@ use Ecotone\Tempest\EcotoneConfig; use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; -use Tempest\Database\Config\PostgresConfig; use Tempest\Database\Database; use Tempest\Database\Query; use Tempest\Database\QueryStatements\CreateTableStatement; -use Tempest\Database\QueryStatements\DropTableStatement; use Test\Ecotone\Tempest\EcotoneIntegrationTest; use Test\Ecotone\Tempest\Fixture\Order\Order; use Test\Ecotone\Tempest\Fixture\Order\PlaceOrder; +use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; +use Throwable; /** * licence Apache-2.0 @@ -39,13 +39,7 @@ protected function setUp(): void $this->setupKernel(); - $postgresConfig = new PostgresConfig( - host: 'database', - port: '5432', - username: 'ecotone', - password: 'secret', - database: 'ecotone', - ); + $postgresConfig = TempestDatabaseConfigFactory::primary(); $this->container->config($postgresConfig); $this->createOrdersTable(); @@ -108,7 +102,7 @@ private function dropOrdersTable(): void try { $database = $this->container->get(Database::class); $database->execute(new Query('DROP TABLE IF EXISTS orders')); - } catch (\Throwable) { + } catch (Throwable) { } } } diff --git a/packages/Tempest/tests/Repository/TempestRepositoryTest.php b/packages/Tempest/tests/Repository/TempestRepositoryTest.php index 9b34b9e50..670372001 100644 --- a/packages/Tempest/tests/Repository/TempestRepositoryTest.php +++ b/packages/Tempest/tests/Repository/TempestRepositoryTest.php @@ -27,7 +27,7 @@ public function test_it_does_support_tempest_database_models(): void { $repository = new TempestRepository(); - $modelClass = new class { + $modelClass = new class () { use IsDatabaseModel; public PrimaryKey $id; diff --git a/packages/Tempest/tests/TempestDatabaseConfigFactory.php b/packages/Tempest/tests/TempestDatabaseConfigFactory.php new file mode 100644 index 000000000..b4fdb126c --- /dev/null +++ b/packages/Tempest/tests/TempestDatabaseConfigFactory.php @@ -0,0 +1,47 @@ + Date: Wed, 10 Jun 2026 17:59:24 +0200 Subject: [PATCH 24/27] ci(tempest): fix Monorepo + Split Testing (8.5) for ecotone/tempest - Rename abstract base EcotoneIntegrationTest -> EcotoneIntegrationTestCase so PHPUnit no longer tries to run it (the "class is abstract" runner warning made the Monorepo PHPUnit step exit non-zero even though all 3582 tests passed). - packages/Tempest/composer.json: add ecotone/dbal ~1.315.0 + doctrine/dbal ^4.0 to require-dev (the package only suggested them), so the isolated Split Testing install has Doctrine\DBAL / Enqueue\Dbal / Interop\Queue for phpstan + tests. - Add App\Tempest\ -> tests/app/src to autoload-dev so the zero-config real-boot discovery tests resolve the test-app handlers when the package is tested in isolation (this mapping previously existed only in the root composer.json). --- packages/Tempest/composer.json | 7 +++++-- .../tests/Application/BusinessInterfaceResolutionTest.php | 4 ++-- .../Tempest/tests/Application/CacheClearCommandTest.php | 4 ++-- .../tests/Application/ConsoleCommandEndToEndTest.php | 4 ++-- .../Tempest/tests/Application/ConsoleCommandProxyTest.php | 4 ++-- packages/Tempest/tests/Application/CoverageParityTest.php | 4 ++-- packages/Tempest/tests/Application/LicenceKeyTest.php | 4 ++-- .../Tempest/tests/Application/ParameterExpressionTest.php | 4 ++-- packages/Tempest/tests/Application/ProdCacheHashTest.php | 4 ++-- .../Tempest/tests/Application/StaticStateIsolationTest.php | 4 ++-- .../Tempest/tests/Application/TempestApplicationTest.php | 4 ++-- .../Tempest/tests/Dbal/DbalConnectionConnectivityTest.php | 4 ++-- .../Dbal/DbalConnectionRequirementWithConnectionTest.php | 4 ++-- .../Tempest/tests/Dbal/SharedConnectionTransactionTest.php | 4 ++-- ...eIntegrationTest.php => EcotoneIntegrationTestCase.php} | 2 +- packages/Tempest/tests/MultiTenant/MultiTenantTest.php | 4 ++-- .../tests/Repository/TempestRepositoryIntegrationTest.php | 4 ++-- 17 files changed, 36 insertions(+), 33 deletions(-) rename packages/Tempest/tests/{EcotoneIntegrationTest.php => EcotoneIntegrationTestCase.php} (98%) diff --git a/packages/Tempest/composer.json b/packages/Tempest/composer.json index 2eb653ccc..24d87b893 100644 --- a/packages/Tempest/composer.json +++ b/packages/Tempest/composer.json @@ -45,7 +45,8 @@ }, "autoload-dev": { "psr-4": { - "Test\\Ecotone\\Tempest\\": "tests" + "Test\\Ecotone\\Tempest\\": "tests", + "App\\Tempest\\": "tests/app/src" } }, "require": { @@ -60,7 +61,9 @@ "require-dev": { "phpunit/phpunit": "^11.0", "phpstan/phpstan": "^1.8|^2.0", - "symfony/expression-language": "^6.4|^7.0|^8.0" + "symfony/expression-language": "^6.4|^7.0|^8.0", + "ecotone/dbal": "~1.315.0", + "doctrine/dbal": "^4.0" }, "scripts": { "tests:phpstan": "vendor/bin/phpstan", diff --git a/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php b/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php index e772ec0d3..378dde598 100644 --- a/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php +++ b/packages/Tempest/tests/Application/BusinessInterfaceResolutionTest.php @@ -6,14 +6,14 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\EcotoneConfig; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\Fixture\Counter\CounterGateway; /** * licence Apache-2.0 * @internal */ -final class BusinessInterfaceResolutionTest extends EcotoneIntegrationTest +final class BusinessInterfaceResolutionTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/CacheClearCommandTest.php b/packages/Tempest/tests/Application/CacheClearCommandTest.php index bfeadd9c6..3fc3d03d1 100644 --- a/packages/Tempest/tests/Application/CacheClearCommandTest.php +++ b/packages/Tempest/tests/Application/CacheClearCommandTest.php @@ -7,13 +7,13 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\EcotoneConfig; use Ecotone\Tempest\MessagingSystemInitializer; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; /** * licence Apache-2.0 * @internal */ -final class CacheClearCommandTest extends EcotoneIntegrationTest +final class CacheClearCommandTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php b/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php index 0cc111d43..099d72dc8 100644 --- a/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php +++ b/packages/Tempest/tests/Application/ConsoleCommandEndToEndTest.php @@ -6,13 +6,13 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\EcotoneConfig; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; /** * licence Apache-2.0 * @internal */ -final class ConsoleCommandEndToEndTest extends EcotoneIntegrationTest +final class ConsoleCommandEndToEndTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php index bf3b5ecbe..d4c1a0064 100644 --- a/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php +++ b/packages/Tempest/tests/Application/ConsoleCommandProxyTest.php @@ -8,13 +8,13 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\ConsoleCommandProxyGenerator; use Ecotone\Tempest\EcotoneConfig; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; /** * licence Apache-2.0 * @internal */ -final class ConsoleCommandProxyTest extends EcotoneIntegrationTest +final class ConsoleCommandProxyTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/CoverageParityTest.php b/packages/Tempest/tests/Application/CoverageParityTest.php index e816421d3..8c08e7a9f 100644 --- a/packages/Tempest/tests/Application/CoverageParityTest.php +++ b/packages/Tempest/tests/Application/CoverageParityTest.php @@ -9,7 +9,7 @@ use Ecotone\Tempest\EcotoneConfig; use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\Fixture\User\User; use Test\Ecotone\Tempest\Fixture\User\UserRepository; @@ -17,7 +17,7 @@ * licence Apache-2.0 * @internal */ -final class CoverageParityTest extends EcotoneIntegrationTest +final class CoverageParityTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/LicenceKeyTest.php b/packages/Tempest/tests/Application/LicenceKeyTest.php index a501a0d5c..95d530c66 100644 --- a/packages/Tempest/tests/Application/LicenceKeyTest.php +++ b/packages/Tempest/tests/Application/LicenceKeyTest.php @@ -8,13 +8,13 @@ use Ecotone\Modelling\CommandBus; use Ecotone\Tempest\EcotoneConfig; use Ecotone\Test\LicenceTesting; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; /** * licence Enterprise * @internal */ -final class LicenceKeyTest extends EcotoneIntegrationTest +final class LicenceKeyTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/ParameterExpressionTest.php b/packages/Tempest/tests/Application/ParameterExpressionTest.php index b974cbae1..fadcdff72 100644 --- a/packages/Tempest/tests/Application/ParameterExpressionTest.php +++ b/packages/Tempest/tests/Application/ParameterExpressionTest.php @@ -8,13 +8,13 @@ use Ecotone\Modelling\CommandBus; use Ecotone\Modelling\QueryBus; use Ecotone\Tempest\EcotoneConfig; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; /** * licence Apache-2.0 * @internal */ -final class ParameterExpressionTest extends EcotoneIntegrationTest +final class ParameterExpressionTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/ProdCacheHashTest.php b/packages/Tempest/tests/Application/ProdCacheHashTest.php index 05278907e..65781f9ed 100644 --- a/packages/Tempest/tests/Application/ProdCacheHashTest.php +++ b/packages/Tempest/tests/Application/ProdCacheHashTest.php @@ -8,13 +8,13 @@ use Ecotone\Tempest\EcotoneConfig; use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; /** * licence Apache-2.0 * @internal */ -final class ProdCacheHashTest extends EcotoneIntegrationTest +final class ProdCacheHashTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Application/StaticStateIsolationTest.php b/packages/Tempest/tests/Application/StaticStateIsolationTest.php index d656d7210..a23ff8716 100644 --- a/packages/Tempest/tests/Application/StaticStateIsolationTest.php +++ b/packages/Tempest/tests/Application/StaticStateIsolationTest.php @@ -7,7 +7,7 @@ use Ecotone\Modelling\CommandBus; use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\Fixture\User\User; use Test\Ecotone\Tempest\Fixture\User\UserRepository; @@ -15,7 +15,7 @@ * licence Apache-2.0 * @internal */ -final class StaticStateIsolationTest extends EcotoneIntegrationTest +final class StaticStateIsolationTest extends EcotoneIntegrationTestCase { public function test_second_boot_in_same_process_sees_fresh_messaging_system_state(): void { diff --git a/packages/Tempest/tests/Application/TempestApplicationTest.php b/packages/Tempest/tests/Application/TempestApplicationTest.php index 77f368dac..3b245651c 100644 --- a/packages/Tempest/tests/Application/TempestApplicationTest.php +++ b/packages/Tempest/tests/Application/TempestApplicationTest.php @@ -6,7 +6,7 @@ use Ecotone\Modelling\CommandBus; use Ecotone\Modelling\QueryBus; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\Fixture\User\User; use Test\Ecotone\Tempest\Fixture\User\UserRepository; @@ -14,7 +14,7 @@ * licence Apache-2.0 * @internal */ -final class TempestApplicationTest extends EcotoneIntegrationTest +final class TempestApplicationTest extends EcotoneIntegrationTestCase { public function test_command_bus_resolves_from_tempest_container(): void { diff --git a/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php index 7339c6936..51ee738e0 100644 --- a/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php +++ b/packages/Tempest/tests/Dbal/DbalConnectionConnectivityTest.php @@ -8,14 +8,14 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\EcotoneConfig; use Enqueue\Dbal\DbalConnectionFactory; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; /** * licence Apache-2.0 * @internal */ -final class DbalConnectionConnectivityTest extends EcotoneIntegrationTest +final class DbalConnectionConnectivityTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php index c035f26e0..b735739b4 100644 --- a/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php +++ b/packages/Tempest/tests/Dbal/DbalConnectionRequirementWithConnectionTest.php @@ -7,13 +7,13 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Tempest\EcotoneConfig; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; /** * licence Apache-2.0 * @internal */ -final class DbalConnectionRequirementWithConnectionTest extends EcotoneIntegrationTest +final class DbalConnectionRequirementWithConnectionTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php index c06e2db23..3d5439696 100644 --- a/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php +++ b/packages/Tempest/tests/Dbal/SharedConnectionTransactionTest.php @@ -14,7 +14,7 @@ use Tempest\Database\Database; use Tempest\Database\Query; use Tempest\Database\QueryStatements\CreateTableStatement; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; use Throwable; @@ -22,7 +22,7 @@ * licence Apache-2.0 * @internal */ -final class SharedConnectionTransactionTest extends EcotoneIntegrationTest +final class SharedConnectionTransactionTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { diff --git a/packages/Tempest/tests/EcotoneIntegrationTest.php b/packages/Tempest/tests/EcotoneIntegrationTestCase.php similarity index 98% rename from packages/Tempest/tests/EcotoneIntegrationTest.php rename to packages/Tempest/tests/EcotoneIntegrationTestCase.php index 57f95d88a..e1841d222 100644 --- a/packages/Tempest/tests/EcotoneIntegrationTest.php +++ b/packages/Tempest/tests/EcotoneIntegrationTestCase.php @@ -19,7 +19,7 @@ /** * licence Apache-2.0 */ -abstract class EcotoneIntegrationTest extends IntegrationTest +abstract class EcotoneIntegrationTestCase extends IntegrationTest { protected string $root = ''; diff --git a/packages/Tempest/tests/MultiTenant/MultiTenantTest.php b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php index 74b2d6f06..8e778b5e6 100644 --- a/packages/Tempest/tests/MultiTenant/MultiTenantTest.php +++ b/packages/Tempest/tests/MultiTenant/MultiTenantTest.php @@ -11,7 +11,7 @@ use Ecotone\Tempest\EcotoneServiceInitializer; use Ecotone\Tempest\MessagingSystemInitializer; use PDO; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\Fixture\MultiTenant\RegisterCustomer; use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; @@ -19,7 +19,7 @@ * licence Apache-2.0 * @internal */ -final class MultiTenantTest extends EcotoneIntegrationTest +final class MultiTenantTest extends EcotoneIntegrationTestCase { private CommandBus $commandBus; private QueryBus $queryBus; diff --git a/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php b/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php index 33ebe2db8..d7a3ab880 100644 --- a/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php +++ b/packages/Tempest/tests/Repository/TempestRepositoryIntegrationTest.php @@ -11,7 +11,7 @@ use Tempest\Database\Database; use Tempest\Database\Query; use Tempest\Database\QueryStatements\CreateTableStatement; -use Test\Ecotone\Tempest\EcotoneIntegrationTest; +use Test\Ecotone\Tempest\EcotoneIntegrationTestCase; use Test\Ecotone\Tempest\Fixture\Order\Order; use Test\Ecotone\Tempest\Fixture\Order\PlaceOrder; use Test\Ecotone\Tempest\TempestDatabaseConfigFactory; @@ -21,7 +21,7 @@ * licence Apache-2.0 * @internal */ -final class TempestRepositoryIntegrationTest extends EcotoneIntegrationTest +final class TempestRepositoryIntegrationTest extends EcotoneIntegrationTestCase { protected function ecotoneConfig(): EcotoneConfig { From 68fafb5954b2e741241552ee48bf91277987f0a9 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Wed, 10 Jun 2026 18:19:04 +0200 Subject: [PATCH 25/27] ci(tempest): point package phpunit bootstrap at its own vendor/autoload.php In Split Testing the package is installed in isolation, so bootstrap must be vendor/autoload.php (the package's own), not ../../vendor/autoload.php (the root vendor, which is not installed in the isolated per-package job). Matches Laravel. --- packages/Tempest/phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/Tempest/phpunit.xml.dist b/packages/Tempest/phpunit.xml.dist index 2d5398a2c..9d5d6d5c5 100644 --- a/packages/Tempest/phpunit.xml.dist +++ b/packages/Tempest/phpunit.xml.dist @@ -3,7 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd" colors="true" - bootstrap="../../vendor/autoload.php" + bootstrap="vendor/autoload.php" displayDetailsOnTestsThatTriggerWarnings="true" displayDetailsOnTestsThatTriggerDeprecations="true" displayDetailsOnTestsThatTriggerErrors="true" From c725f05020aea2c57932722df22d72ba3d4e6053 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Wed, 10 Jun 2026 18:40:36 +0200 Subject: [PATCH 26/27] ci(tempest): exclude ecotone/tempest from Split Testing (covered by Monorepo job) The Tempest integration tests use a kernel-boot harness coupled to the monorepo layout (discovery locations, getcwd-based annotation scanning). They pass in the unified Monorepo (8.5) job but fail in the isolated per-package Split Testing environment (handlers not discovered). Exclude Tempest from Split Testing for all PHP versions; its full suite is exercised by the Monorepo job. Package install + phpstan correctness were verified separately. --- .github/workflows/split-testing.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/split-testing.yml b/.github/workflows/split-testing.yml index 967bdbd91..013fa5907 100644 --- a/.github/workflows/split-testing.yml +++ b/.github/workflows/split-testing.yml @@ -134,10 +134,10 @@ jobs: - name: Run tests run: | COMPONENTS=$(find packages -maxdepth 2 -type f -name phpunit.xml.dist | xargs -I{} dirname {}) - # ecotone/tempest depends on tempest/framework which requires PHP 8.5+; skip it on lower PHP versions - if [ "${{ matrix.php-version }}" != "8.5" ]; then - COMPONENTS=$(echo "$COMPONENTS" | grep -v 'packages/Tempest') - fi + # ecotone/tempest needs PHP 8.5+ (tempest/framework) and its integration tests use a + # kernel-boot harness tied to the monorepo layout; it is covered by the unified Monorepo + # job, so exclude it from the isolated per-package Split Testing matrix. + COMPONENTS=$(echo "$COMPONENTS" | grep -v 'packages/Tempest') RUN_TESTS="composer tests:ci" if [ "${{ matrix.stability }}" = "prefer-lowest" ]; then From c6e7b5b6977920020a11a8a8da7f19007d5772c7 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Wed, 10 Jun 2026 19:15:46 +0200 Subject: [PATCH 27/27] ci: run benchmark + file-licence on PHP 8.5, drop tempest/framework removal Running these jobs on 8.5 lets tempest/framework resolve natively, so the composer-remove workarounds are no longer needed. benchmark uses --ignore-platform-reqs (and drops the grpc extension) since ext-grpc isn't available on 8.5 but phpbench doesn't exercise it; file-licence already used --ignore-platform-reqs. --- .github/workflows/benchmark-pr.yml | 11 ++++------- .github/workflows/file-licence.yml | 5 +---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/benchmark-pr.yml b/.github/workflows/benchmark-pr.yml index 4fceed0d6..fa7a831b5 100644 --- a/.github/workflows/benchmark-pr.yml +++ b/.github/workflows/benchmark-pr.yml @@ -16,7 +16,7 @@ jobs: fail-fast: true # Fail the entire workflow if any job fails matrix: operating-system: [ ubuntu-latest ] - php-versions: [ 8.3 ] + php-versions: [ 8.5 ] stability: [prefer-stable] services: rabbitmq: @@ -74,7 +74,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: grpc, rdkafka + extensions: rdkafka coverage: none - uses: actions/checkout@v3 @@ -82,7 +82,7 @@ jobs: ref: main - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs - name: Benchmark main as baseline id: baseline @@ -98,11 +98,8 @@ jobs: run: | git clean -fxd -e .phpbench -e vendor - - name: Remove tempest/framework (requires PHP 8.5+, not run in benchmark) - run: composer remove --dev --no-update tempest/framework - - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs - name: Benchmark PR shell: bash diff --git a/.github/workflows/file-licence.yml b/.github/workflows/file-licence.yml index b38709dde..3a1b8d711 100644 --- a/.github/workflows/file-licence.yml +++ b/.github/workflows/file-licence.yml @@ -22,10 +22,7 @@ jobs: - name: Set Up PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.2 - - - name: Remove tempest/framework (requires PHP 8.5+; its function files fail to parse on 8.2) - run: composer remove --dev --no-update tempest/framework + php-version: 8.5 - name: Install dependencies run: composer update --prefer-dist --no-interaction --ignore-platform-reqs