diff --git a/composer.json b/composer.json index 8733467..40e8e1a 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "require": { "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "illuminate/contracts": "^10.0 || ^11.0 || ^12.0", - "patchlevel/event-sourcing": "^3.5" + "patchlevel/event-sourcing": "^3.13.0" }, "require-dev": { "ext-pdo_sqlite": "*", @@ -72,4 +72,4 @@ "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi" } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 206f13b..992d8de 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cd039828537d8224931de70def194966", + "content-hash": "69de41f2e21cdd77c5cb2546559e5379", "packages": [ { "name": "brick/math", @@ -212,16 +212,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.0", + "version": "4.4.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "e8c5163fbec0f34e357431bd1e5fc4056cdf4fdc" + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/e8c5163fbec0f34e357431bd1e5fc4056cdf4fdc", - "reference": "e8c5163fbec0f34e357431bd1e5fc4056cdf4fdc", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", "shasum": "" }, "require": { @@ -298,7 +298,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.0" + "source": "https://github.com/doctrine/dbal/tree/4.4.1" }, "funding": [ { @@ -314,7 +314,7 @@ "type": "tidelift" } ], - "time": "2025-11-29T12:17:09+00:00" + "time": "2025-12-04T10:11:03+00:00" }, { "name": "doctrine/deprecations", @@ -858,31 +858,31 @@ }, { "name": "fruitcake/php-cors", - "version": "v1.3.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2-dev" + "dev-master": "1.3-dev" } }, "autoload": { @@ -913,7 +913,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -925,7 +925,7 @@ "type": "github" } ], - "time": "2023-10-12T05:21:21+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", @@ -1402,16 +1402,16 @@ }, { "name": "laravel/framework", - "version": "v12.41.1", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "3e229b05935fd0300c632fb1f718c73046d664fc" + "reference": "509b33095564c5165366d81bbaa0afaac28abe75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/3e229b05935fd0300c632fb1f718c73046d664fc", - "reference": "3e229b05935fd0300c632fb1f718c73046d664fc", + "url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75", + "reference": "509b33095564c5165366d81bbaa0afaac28abe75", "shasum": "" }, "require": { @@ -1499,6 +1499,7 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", @@ -1523,7 +1524,7 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.8.0", + "orchestra/testbench-core": "^10.8.1", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1585,6 +1586,7 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], @@ -1593,7 +1595,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -1617,7 +1620,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-03T01:02:13+00:00" + "time": "2025-12-09T15:51:23+00:00" }, { "name": "laravel/prompts", @@ -2118,20 +2121,20 @@ }, { "name": "league/uri", - "version": "7.6.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "f625804987a0a9112d954f9209d91fec52182344" + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", - "reference": "f625804987a0a9112d954f9209d91fec52182344", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.6", + "league/uri-interfaces": "^7.7", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2204,7 +2207,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.6.0" + "source": "https://github.com/thephpleague/uri/tree/7.7.0" }, "funding": [ { @@ -2212,20 +2215,20 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2025-12-07T16:02:06+00:00" }, { "name": "league/uri-interfaces", - "version": "7.6.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", "shasum": "" }, "require": { @@ -2288,7 +2291,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" }, "funding": [ { @@ -2296,7 +2299,7 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2025-12-07T16:03:21+00:00" }, { "name": "monolog/monolog", @@ -3765,16 +3768,16 @@ }, { "name": "symfony/console", - "version": "v7.4.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8" + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", - "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", + "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", "shasum": "" }, "require": { @@ -3839,7 +3842,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.0" + "source": "https://github.com/symfony/console/tree/v7.4.1" }, "funding": [ { @@ -3859,7 +3862,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-12-05T15:23:39+00:00" }, { "name": "symfony/css-selector", @@ -4310,16 +4313,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "769c1720b68e964b13b58529c17d4a385c62167b" + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/769c1720b68e964b13b58529c17d4a385c62167b", - "reference": "769c1720b68e964b13b58529c17d4a385c62167b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", "shasum": "" }, "require": { @@ -4368,7 +4371,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.0" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" }, "funding": [ { @@ -4388,20 +4391,20 @@ "type": "tidelift" } ], - "time": "2025-11-13T08:49:24+00:00" + "time": "2025-12-07T11:13:10+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.0", + "version": "v7.4.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "7348193cd384495a755554382e4526f27c456085" + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7348193cd384495a755554382e4526f27c456085", - "reference": "7348193cd384495a755554382e4526f27c456085", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", "shasum": "" }, "require": { @@ -4487,7 +4490,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.0" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" }, "funding": [ { @@ -4507,7 +4510,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:38:24+00:00" + "time": "2025-12-08T07:43:37+00:00" }, { "name": "symfony/mailer", @@ -5816,16 +5819,16 @@ }, { "name": "symfony/string", - "version": "v8.0.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f929eccf09531078c243df72398560e32fa4cf4f" + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f929eccf09531078c243df72398560e32fa4cf4f", - "reference": "f929eccf09531078c243df72398560e32fa4cf4f", + "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", "shasum": "" }, "require": { @@ -5882,7 +5885,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.0" + "source": "https://github.com/symfony/string/tree/v8.0.1" }, "funding": [ { @@ -5902,20 +5905,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:37:55+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/translation", - "version": "v8.0.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6" + "reference": "770e3b8b0ba8360958abedcabacd4203467333ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/82ab368a6fca6358d995b6dd5c41590fb42c03e6", - "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6", + "url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca", + "reference": "770e3b8b0ba8360958abedcabacd4203467333ca", "shasum": "" }, "require": { @@ -5975,7 +5978,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.0" + "source": "https://github.com/symfony/translation/tree/v8.0.1" }, "funding": [ { @@ -5995,7 +5998,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T08:09:45+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/translation-contracts", @@ -6081,16 +6084,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "9de828eae6aeb33806f8f2fec161a8f8e79338d0" + "reference": "bb091cec1f70383538c7d000699781813f8d1a6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/9de828eae6aeb33806f8f2fec161a8f8e79338d0", - "reference": "9de828eae6aeb33806f8f2fec161a8f8e79338d0", + "url": "https://api.github.com/repos/symfony/type-info/zipball/bb091cec1f70383538c7d000699781813f8d1a6a", + "reference": "bb091cec1f70383538c7d000699781813f8d1a6a", "shasum": "" }, "require": { @@ -6139,7 +6142,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.0" + "source": "https://github.com/symfony/type-info/tree/v8.0.1" }, "funding": [ { @@ -6159,7 +6162,7 @@ "type": "tidelift" } ], - "time": "2025-11-08T16:30:39+00:00" + "time": "2025-12-05T14:08:45+00:00" }, { "name": "symfony/uid", @@ -8203,16 +8206,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -8255,9 +8258,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nunomaduro/collision", @@ -8694,21 +8697,21 @@ }, { "name": "orchestra/testbench-core", - "version": "v10.8.0", + "version": "v10.8.1", "source": { "type": "git", "url": "https://github.com/orchestral/testbench-core.git", - "reference": "3da92b7038d8e924f4e5f2684585a50f1512327c" + "reference": "f1da36cedc677d015d2a46d36abee54ffd5ba711" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/3da92b7038d8e924f4e5f2684585a50f1512327c", - "reference": "3da92b7038d8e924f4e5f2684585a50f1512327c", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/f1da36cedc677d015d2a46d36abee54ffd5ba711", + "reference": "f1da36cedc677d015d2a46d36abee54ffd5ba711", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "orchestra/sidekick": "~1.1.20|~1.2.17", + "orchestra/sidekick": "~1.1.21|~1.2.18", "php": "^8.2", "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-php83": "^1.32" @@ -8718,7 +8721,7 @@ "laravel/framework": "<12.40.0|>=13.0.0", "laravel/serializable-closure": "<1.3.0|>=2.0.0 <2.0.3|>=3.0.0", "nunomaduro/collision": "<8.0.0|>=9.0.0", - "phpunit/phpunit": "<10.5.35|>=11.0.0 <11.5.3|12.0.0|>=12.5.0" + "phpunit/phpunit": "<10.5.35|>=11.0.0 <11.5.3|12.0.0|>=12.6.0" }, "require-dev": { "fakerphp/faker": "^1.24", @@ -8783,7 +8786,7 @@ "issues": "https://github.com/orchestral/testbench/issues", "source": "https://github.com/orchestral/testbench-core" }, - "time": "2025-11-24T01:27:10+00:00" + "time": "2025-12-08T08:07:27+00:00" }, { "name": "orchestra/workbench", @@ -9075,11 +9078,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.32", + "version": "2.1.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", - "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", + "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", "shasum": "" }, "require": { @@ -9124,7 +9127,7 @@ "type": "github" } ], - "time": "2025-11-11T15:18:17+00:00" + "time": "2025-12-05T10:24:31+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9463,16 +9466,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.45", + "version": "11.5.46", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "faf5fff4fb9beb290affa53f812b05380819c51a" + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/faf5fff4fb9beb290affa53f812b05380819c51a", - "reference": "faf5fff4fb9beb290affa53f812b05380819c51a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", "shasum": "" }, "require": { @@ -9544,7 +9547,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.45" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" }, "funding": [ { @@ -9568,20 +9571,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T07:38:43+00:00" + "time": "2025-12-06T08:01:15+00:00" }, { "name": "psy/psysh", - "version": "v0.12.15", + "version": "v0.12.16", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c" + "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/38953bc71491c838fcb6ebcbdc41ab7483cd549c", - "reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", "shasum": "" }, "require": { @@ -9589,8 +9592,8 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" @@ -9645,9 +9648,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.15" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.16" }, - "time": "2025-11-28T00:00:14+00:00" + "time": "2025-12-07T03:39:01+00:00" }, { "name": "roave/security-advisories", @@ -9655,12 +9658,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "10c1e6abcb8094a428b92e7d8c3126371f9f9126" + "reference": "75d4ccd9c135c4ac904cd4211a43e51d12feb1ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/10c1e6abcb8094a428b92e7d8c3126371f9f9126", - "reference": "10c1e6abcb8094a428b92e7d8c3126371f9f9126", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/75d4ccd9c135c4ac904cd4211a43e51d12feb1ef", + "reference": "75d4ccd9c135c4ac904cd4211a43e51d12feb1ef", "shasum": "" }, "conflict": { @@ -9912,6 +9915,7 @@ "feehi/feehicms": "<=2.1.1", "fenom/fenom": "<=2.12.1", "filament/actions": ">=3.2,<3.2.123", + "filament/filament": ">=4,<4.3.1", "filament/infolists": ">=3,<3.2.115", "filament/tables": ">=3,<3.2.115", "filegator/filegator": "<7.8", @@ -10166,6 +10170,7 @@ "netgen/tagsbundle": ">=3.4,<3.4.11|>=4,<4.0.15", "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", + "neuron-core/neuron-ai": "<=2.8.11", "nilsteampassnet/teampass": "<3.1.3.1-dev", "nitsan/ns-backup": "<13.0.1", "nonfiction/nterchange": "<4.1.1", @@ -10185,7 +10190,7 @@ "october/system": "<3.7.5", "oliverklee/phpunit": "<3.5.15", "omeka/omeka-s": "<4.0.3", - "onelogin/php-saml": "<2.10.4", + "onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1", "oneup/uploader-bundle": ">=1,<1.9.3|>=2,<2.1.5", "open-web-analytics/open-web-analytics": "<1.8.1", "opencart/opencart": ">=0", @@ -10300,7 +10305,7 @@ "reportico-web/reportico": "<=8.1", "rhukster/dom-sanitizer": "<1.0.7", "rmccue/requests": ">=1.6,<1.8", - "robrichards/xmlseclibs": ">=1,<3.0.4", + "robrichards/xmlseclibs": "<=3.1.3", "roots/soil": "<4.1", "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11", "rudloff/alltube": "<3.0.3", @@ -10319,8 +10324,8 @@ "shopware/core": "<6.6.10.9-dev|>=6.7,<6.7.4.1-dev", "shopware/platform": "<6.6.10.7-dev|>=6.7,<6.7.3.1-dev", "shopware/production": "<=6.3.5.2", - "shopware/shopware": "<=5.7.17|>=6.7,<6.7.2.1-dev", - "shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev", + "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", + "shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", "shopxo/shopxo": "<=6.4", "showdoc/showdoc": "<2.10.4", "shuchkin/simplexlsx": ">=1.0.12,<1.1.13", @@ -10571,6 +10576,7 @@ "yoast-seo-for-typo3/yoast_seo": "<7.2.3", "yourls/yourls": "<=1.8.2", "yuan1994/tpadmin": "<=1.3.12", + "yungifez/skuul": "<=2.6.5", "z-push/z-push-dev": "<2.7.6", "zencart/zencart": "<=1.5.7.0-beta", "zendesk/zendesk_api_client_php": "<2.2.11", @@ -10645,7 +10651,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T21:05:14+00:00" + "time": "2025-12-09T18:07:05+00:00" }, { "name": "sanmai/di-container", @@ -12172,16 +12178,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810" + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/6c84a4b55aee4cd02034d1c528e83f69ddf63810", - "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", "shasum": "" }, "require": { @@ -12224,7 +12230,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.0" + "source": "https://github.com/symfony/yaml/tree/v7.4.1" }, "funding": [ { @@ -12244,7 +12250,7 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2025-12-04T18:11:45+00:00" }, { "name": "thecodingmachine/safe", diff --git a/config/event-sourcing.php b/config/event-sourcing.php index badd5b2..c84a663 100644 --- a/config/event-sourcing.php +++ b/config/event-sourcing.php @@ -1,5 +1,7 @@ true, ], /* @@ -27,7 +30,7 @@ | Here you can configure the event store. | You can choose between different types of stores. | dbal_aggregate (default): Store events in a single table with the aggregate and aggregate id. - | dbal_stream (experimental): Store events in a single table with a stream id. + | dbal_stream (new default in 4.x): Store events in a single table with a stream id. | in_memory: Store events in memory. | custom: Use a custom store, you need to provide a service. | @@ -37,7 +40,36 @@ 'service' => null, 'options' => [ 'table_name' => 'eventstore', - ] + ], + 'readonly' => false, + 'migrate_to_new_store' => [ + 'enabled' => false, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Migrate Store + |-------------------------------------------------------------------------- + | + | Here you can configure the migration options for the event store. + | If you enable this option you can use our migration services for a smooth migration. + | You can specify which translators should be used for the migratiop process and also + | to which store you want to migrate. + | + | You can choose between different types of stores: + | dbal_aggregate (default): Store events in a single table with the aggregate and aggregate id. + | dbal_stream (new default in 4.x): Store events in a single table with a stream id. + | in_memory: Store events in memory. + | custom: Use a custom store, you need to provide a service. + | + */ + 'migrate_to_new_store' => [ + 'enabled' => false, + 'type' => '', + 'service' => null, + 'options' => [], + 'translators' => [], ], /* @@ -69,11 +101,30 @@ */ 'subscription' => [ 'throw_on_error' => true, - 'catch_up' => true, - 'retry_strategy' => [ - 'base_delay' => 5, - 'delay_factor' => 2, - 'max_attempts' => 5, + 'catch_up' => [ + 'enabled' => true, + 'limit' => null, + ], + 'retry_strategies' => [ + 'default' => [ + 'type' => 'clock_based', + 'options' => [ + 'base_delay' => 5, + 'delay_factor' => 2, + 'max_attempts' => 5, + ], + ], + 'no_retry' => [ + 'type' => 'no_retry', + ], + ], + 'default_retry_strategy' => 'default', + 'store' => [ + 'type' => 'dbal', + 'service' => null, + 'options' => [ + 'table_name' => 'subscriptions', + ], ], 'run_after_aggregate_save' => [ 'enabled' => true, @@ -89,6 +140,11 @@ 'ids' => null, 'groups' => null, ], + 'gap_detection' => [ + 'enabled' => true, + 'retries_in_ms' => [0, 5, 50, 500], + 'detection_window' => 'PT5M', + ], ], /* @@ -98,11 +154,73 @@ | | Here you can enable or disable the cryptography. | You can also define the algorithm for the cryptography. + | It is disabled by default, because it requires the openssl extension + | and has a performance impact due to registered listeners. | */ 'cryptography' => [ + 'enabled' => false, + 'algorithm' => 'aes256', + 'use_encrypted_field_name' => true, + 'fallback_to_field_name' => false, + ], + + /* + |-------------------------------------------------------------------------- + | CommandBus + |-------------------------------------------------------------------------- + | + | Here you can enable or disable the command bus. + | You can also configure the command bus regarding the retries and the handlers. + | + */ + 'command_bus' => [ + 'enabled' => true, + 'instant_retry' => [ + 'max_retries' => 3, + 'exceptions' => [ + AggregateOutdated::class, + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | QueryBus + |-------------------------------------------------------------------------- + | + | Here you can enable or disable the query bus. + | + */ + 'query_bus' => [ 'enabled' => true, - 'algorithm' => 'aes256' + ], + + /* + |-------------------------------------------------------------------------- + | EventBus + |-------------------------------------------------------------------------- + | + | Here you can enable or disable the event bus. + | The subscription engine is highly recommended to use instead of the event bus. + | + */ + 'event_bus' => [ + 'enabled' => false, + ], + + /* + |-------------------------------------------------------------------------- + | Clock + |-------------------------------------------------------------------------- + | + | Here you can enable or disable the freeze clock or set a custom clock. + | This is useful for testing purposes. + | + */ + 'clock' => [ + 'freeze' => null, + 'service' => null, ], /* diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 67ea101..702d2cc 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -93,6 +93,7 @@ nav: - Installation: installation.md - Getting Started: getting_started.md - Facades: facades.md + - Configuration: configuration.md - Links: - Blog: https://patchlevel.de/blog - Library Documentation: https://event-sourcing.patchlevel.io/latest/ diff --git a/docs/pages/configuration.md b/docs/pages/configuration.md new file mode 100644 index 0000000..8e8526d --- /dev/null +++ b/docs/pages/configuration.md @@ -0,0 +1,561 @@ +# Configuration + +!!! info + + You can find out more about event sourcing in the library + [documentation](https://event-sourcing.patchlevel.io/latest/). + This documentation is limited to the laravel integration and configuration. + +!!! tip + + We provide a [default configuration](./installation.md#configuration-file) that should work for most projects. + +## Aggregate + +A path must be specified for Event Sourcing to know where to look for your aggregates. +If you want you can use glob patterns to specify multiple paths. + +```php +return [ + 'aggregates' => [app_path()], +]; +``` +Or use an array to specify multiple paths. + +```php +return [ + 'aggregates' => [ + app_path() . 'src/Hotel/Domain', + app_path() . 'src/Room/Domain', + ], +]; +``` +!!! note + + The library will automatically register all classes marked with the `#[Aggregate]` attribute in the specified paths. + +!!! tip + + If you want to learn more about aggregates, read the [library documentation](https://event-sourcing.patchlevel.io/latest/aggregate/). + +## Events + +A path must be specified for Event Sourcing to know where to look for your events. +If you want you can use glob patterns to specify multiple paths. + +```php +return [ + 'events' => [app_path()], +]; +``` +Or use an array to specify multiple paths. + +```php +return [ + 'events' => [ + app_path() . 'src/Hotel/Domain/Event', + app_path() . 'src/Room/Domain/Event', + ], +]; +``` +!!! tip + + If you want to learn more about events, read the [library documentation](https://event-sourcing.patchlevel.io/latest/events/). + +## Custom Headers + +If you want to implement custom headers for your application, you must specify the +paths to look for those headers. +If you want you can use glob patterns to specify multiple paths. + +```php +return [ + 'headers' => [app_path()], +]; +``` +Or use an array to specify multiple paths. + +```php +return [ + 'headers' => [ + app_path() . 'src/Hotel/Domain/Header', + app_path() . 'src/Room/Domain/Header', + ], +]; +``` +!!! tip + + If you want to learn more about custom headers, read the [library documentation](https://event-sourcing.patchlevel.io/latest/message/#custom-headers). + +## Connection + +You have to specify the connection url to the event store. + +```php +return [ + 'connection' => [ + 'url' => env('EVENT_SOURCING_DB_URL'), + ], +]; +``` +!!! note + + You can find out more about how to create a connection + [here](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html) + +### Connection for Projections + +Per default, our event sourcing connection is not available to use in your application. +But you can create a dedicated connection that you can use for your projections. + +```php +return [ + 'connection' => [ + 'url' => env('EVENT_SOURCING_DB_URL'), + 'provide_dedicated_connection' => true, + ], +]; +``` +!!! warning + + If you use doctrine migrations, you should exclude you projection tables from the schema generation. + The schema is managed by the subscription engine and should not be managed by doctrine. + +!!! tip + + You can autowire the connection in your services like this: + + ```php + use Doctrine\DBAL\Connection; + use Patchlevel\LaravelEventSourcing\Attribute\ProjectionConnection; + + public function __construct( + #[ProjectionConnection] + private readonly Connection $connection, + ) { + } + ``` + +## Store + +The store and schema is configurable. + +### Change Store type + +You can change the store type of the event store. + +```php +return [ + 'store' => ['type' => 'dbal_stream'], +]; +``` +Following store types are available: + +- `dbal_aggregate` *default (deprecated)* +- `dbal_stream` *recommended* +- `in_memory` +- `custom` + +!!! note + + If you use `custom` store type, you need to set the service id under `store.service`. + +### Change table Name + +You can change the table name of the event store. + +```php +return [ + 'store' => [ + 'type' => 'dbal_stream', + 'options' => ['table_name' => 'my_event_store'], + ], +]; +``` +### Read Only Mode + +For `dbal_aggregate` and `dbal_stream` store types you can activate the read only mode. +Readings are possible, but if you try to write, an exception `StoreIsReadOnly` is thrown. + +```php +return [ + 'store' => [ + 'type' => 'dbal_stream', + 'readonly' => true, + ], +]; +``` +!!! tip + + This is useful if you have maintenance work on the event store and you want to avoid side effects. + +### Data Migration + +If you want to migrate from your current store to a new store, you can use the following configuration. +This registers a new store and a new cli command `event-sourcing:store:migrate`. +You can define translators to translate the old events to the new store. +Here is an example for a migration from `dbal_aggregate` to `dbal_stream`. + +```php +use Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator; + +return [ + 'store' => [ + 'type' => 'dbal_aggregate', + 'readonly' => true, + 'options' => ['table_name' => 'old_store'], + 'migrate_to_new_store' => [ + 'enabled' => true, + 'type' => 'dbal_stream', + 'options' => ['table_name' => 'my_stream_store'], + 'translators' => [ + AggregateToStreamHeaderTranslator::class, + ], + ], + ], +]; +``` +!!! danger + + Make sure that you use different table names for the old and new store. + Otherwise your event store will be destroyed. + +!!! tip + + Set the `read_only` flag to `true` for the old store to avoid side effects + and missing events during the migration. + +## Subscription + +!!! tip + + You can find out more about subscriptions in the library + [documentation](https://event-sourcing.patchlevel.io/latest/subscription/). + +### Store + +You can change where the subscription engine stores its necessary information about the subscription. +Default is `dbal`, which means it stores it in the same DB that is used by the dbal event store. + +Otherwise you can choose between the following stores: + +- `dbal` *default* +- `in_memory` +- `static_in_memory` +- `custom` + +```php +return [ + 'subscription' => [ + 'store' => [ + 'type' => 'custom', // default is 'dbal' + 'service' => 'my_subscription_store', + 'options' => ['table_name' => 'my_subscription_store'], + ], + ], +]; +``` +!!! tip + + You can use the `static_in_memory` store for testing, if you are using transactions to rollback changes. + +### Catch Up + +If aggregates are used in the processors and new events are generated there, +then they are not part of the current subscription engine `run` and will only be processed during the next run or boot. +This is usually not a problem in prod environment because a worker is used +and these events will be processed at some point. But in testing it is not so easy. +For this reason, you can activate the `catch_up` option. For local dev this is also very handy. + +```php +return [ + 'subscription' => [ + 'catch_up' => [ + 'enabled' => true, + 'limit' => null, // define a limit to catch up only a limited number of events + ], + ], +]; +``` +### Throw on Error + +You can activate the `throw_on_error` option to throw an exception if a subscription engine run has an error. +This is useful for testing and development to get direct feedback if something is wrong. + +```php +return [ + 'subscription' => ['throw_on_error' => true], +]; +``` +!!! warning + + This option should not be used in production. The normal behavior is to log the error and continue. + +### Run After Aggregate Save + +If you want to run the subscription engine after an aggregate is saved, you can activate this option. +This is useful for testing and development, so you don't have to run a worker to process the events. + +```php +return [ + 'subscription' => [ + 'run_after_aggregate_save' => [ + 'enabled' => true, + 'ids' => null, // limit to specific subscriptions ids + 'groups' => null, // limit to specific subscriptions groups + 'limit' => null, // limit how many events should be processed + ], + ], +]; +``` +### Auto Setup + +If you want to automatically setup the subscription engine, you can activate this option. +This is useful for development, so you don't have to setup the subscription engine manually. + +```php +return [ + 'subscription' => [ + 'auto_setup' => [ + 'enabled' => true, + 'ids' => null, // limit to specific subscriptions ids + 'groups' => null, // limit to specific subscriptions groups + ], + ], +]; +``` +!!! note + + This works only before each http requests and not if you use the console commands. + +### Rebuild After File Change + +If you want to rebuild the subscription engine after a file change, you can activate this option. +This is also useful for development, so you don't have to rebuild the projections manually. + +```php +return [ + 'subscription' => [ + 'rebuild_after_file_change' => ['enabled' => true], + ], +]; +``` +!!! note + + This works only before each http requests and not if you use the console commands. + +!!! tip + + This is using the cache system to store the latest file change time. You can change the cache pool with the `cache_pool` option. + +### Gap Detection + +Depending on the database you are using for the eventstore it may be happening that your subscriptions are skipping some +events. This is due to how auto-increments work in these databases in combination with e.g. longer open transactions. +Even when not working with longer open transactions, this may occur if load is high on the database. We already have a +locking mechanism in place to prevent this behavior which throttles write speed. Gap Detection operates differently, it +checks if a gap between the last message handled and the current message is present. If so it waits a reasonable amount +of time and re-fetches the message. This results in slower updates for the subscriptions but creates more resilience. + +```php +return [ + 'subscription' => [ + 'gap_detection' => ['enabled' => true], + ], +]; +``` +!!! info + + For more context you can read more about this in [this issue](https://github.com/patchlevel/event-sourcing/issues/727#issuecomment-2757297536). + +!!! tip + + You can use both techniques locking and gap detecion to mitigate gaps happening in the subscriptions. + +You can also define how often the gap detection should re-check the gap and how long it should wait, in this example we +instantly retry the first time, then we wait 500ms and after that we check a last time after 1 second. + +```php +return [ + 'subscription' => [ + 'gap_detection' => [ + 'enabled' => true, + 'retries_in_ms' => [0, 5, 50, 500], + ], + ], +]; +``` +Another config option is to define the detection window. The option defines the timeframe from now if we should check +for a gap. It's defined as an [DateInterval](https://www.php.net/manual/en/class.dateinterval.php) so you need to +provide a valid `string` for it. + +```php +return [ + 'subscription' => [ + 'gap_detection' => [ + 'enabled' => true, + 'detection_window' => 'PT5M', + ], + ], +]; +``` +## Command Bus + +You can enable the command bus integration to use your aggregates as command handlers. + +```php +return [ + 'subscription' => [ + 'command_bus' => ['enabled' => true], + ], +]; +``` +For now, we *do not* provide a laravel/queue integration, but we are open for suggestions. + +!!! note + + You can find out more about the command bus and the aggregate handlers [here](https://event-sourcing.patchlevel.io/latest/command_bus/). + +### Instant Retry + +You can define the default instant retry configuration for the command bus. +This will be used if you don't define a retry configuration for a specific command. + +```php +use Patchlevel\EventSourcing\Repository\AggregateOutdated; + +return [ + 'subscription' => [ + 'command_bus' => [ + 'enabled' => true, + 'instant_retry' => [ + 'default_max_retries' => 3, + 'default_exceptions' => [ + AggregateOutdated::class, + ], + ], + ], + ], +]; +``` +!!! note + + You can find out more about instant retry [here](https://event-sourcing.patchlevel.io/latest/command_bus/#instant-retry). + +## Query Bus + +You can enable the query bus integration to use queries to retrieve data from your system. + +```php +return [ + 'subscription' => [ + 'query_bus' => ['enabled' => true], + ], +]; +``` +For now, we *do not* provide a laravel/queue integration, but we are open for suggestions. + +!!! note + + You can find out more about the query bus [here](https://event-sourcing.patchlevel.io/latest/query_bus/). + +## Event Bus + +You can enable the event bus to listen for events and messages synchronously. +The subscription engine is highly recommended to use instead of the event bus. + +```php +return [ + 'subscription' => [ + 'event_bus' => ['enabled' => true], + ], +]; +``` +!!! note + + Default is the patchlevel [event bus](https://event-sourcing.patchlevel.io/latest/event_bus/). + +## Snapshot + +You only need to tell the aggregate that it should use this snapshot store. + +```php +namespace App\Profile\Domain; + +use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\Snapshot; + +#[Aggregate(name: 'profile')] +#[Snapshot('default')] +final class Profile extends BasicAggregateRoot +{ + // ... +} +``` +!!! note + + You can find out more about snapshots [here](https://event-sourcing.patchlevel.io/latest/snapshots/). + +## Cryptography + +You can use the library to encrypt and decrypt personal data. +For this you need to enable the crypto shredding. + +```php +return [ + 'cryptography' => [ + 'enabled' => true, + 'use_encrypted_field_name' => true, + 'fallback_to_field_name' => false, + ], +]; +``` +!!! tip + + You should activate `use_encrypted_field_name` to mark the fields that are encrypted. + That allows you later to migrate not encrypted fields to encrypted fields. + If you have already encrypted fields, you can activate `fallback_to_field_name` to use the old field name as fallback. + +If you want to use another algorithm, you can specify this here: + +```php +return [ + 'cryptography' => [ + 'enabled' => true, + 'algorithm' => 'aes256', + ], +]; +``` +!!! note + + You can find out more about sensitive data [here](https://event-sourcing.patchlevel.io/latest/personal_data/). + +## Clock + +The clock is used to return the current time as `DateTimeImmutable`. + +### Freeze Clock + +You can freeze the clock for testing purposes: + +```php +return [ + 'clock' => ['freeze' => '2020-01-01 22:00:00'], +]; +``` +!!! note + + If freeze is not set, then the system clock is used. + +### PSR-20 + +You can also use your own implementation of your choice. +They only have to implement the interface of the [psr-20](https://www.php-fig.org/psr/psr-20/). +You can then specify this service here: + +```php +return [ + 'clock' => ['service' => 'my_own_clock_service_id'], +]; +``` \ No newline at end of file diff --git a/docs/pages/facades.md b/docs/pages/facades.md index 5538f54..61191dc 100644 --- a/docs/pages/facades.md +++ b/docs/pages/facades.md @@ -67,4 +67,31 @@ $messages = Store::load( This documentation is limited to the package integration. You should also read the [library documentation](https://event-sourcing.patchlevel.io/latest/). - \ No newline at end of file + +## Projection Connection + +You can access the projection connection using the `ProjectionConnection` facade. +This facade provides you the `DBAL\Connection` used to connect to the projection database. + +!!! note + + This documentation is limited to the package integration. + You should also read the [dbal documentation](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/data-retrieval-and-manipulation.html#api). + +## CommandBus + +You can access the command bus using the `CommandBus` facade. With this facade you can dispatch commands. + +```php +CommandBus::dispatch(new BookHotel()); +``` +Then, the command will be handled by the corresponding command handler specified via `#[Handle]` attribute. + +## QueryBus + +You can access the query bus using the `QueryBus` facade. With this facade you can dispatch queries. + +```php +$result = QueryBus::dispatch(new HotelCountQuery()); +``` +Then, the query will be handled by the corresponding query handler specified via `#[Answer]` attribute. diff --git a/docs/pages/index.md b/docs/pages/index.md index 6ccff05..d3ec7a9 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -13,7 +13,7 @@ for [event-sourcing](https://github.com/patchlevel/event-sourcing) library. * Automatic [snapshot](https://event-sourcing.patchlevel.io/latest/snapshots/)-system to boost your performance * [Split](https://event-sourcing.patchlevel.io/latest/split_stream/) big aggregates into multiple streams * Versioned and managed lifecycle of [subscriptions](https://event-sourcing.patchlevel.io/latest/subscription/) like projections and processors -* Safe usage of [Personal Data](https://event-sourcing.patchlevel.io/latest/personal_data/) with crypto-shredding +* Safe usage of [Sensitive Data](https://event-sourcing.patchlevel.io/latest/personal_data/) with crypto-shredding * Smooth [upcasting](https://event-sourcing.patchlevel.io/latest/upcasting/) of old events * Simple setup with [scheme management](https://event-sourcing.patchlevel.io/latest/store/) and [doctrine migration](https://event-sourcing.patchlevel.io/latest/store/) * Built in [cli commands](https://event-sourcing.patchlevel.io/latest/cli/) diff --git a/infection.json.dist b/infection.json.dist index 3a20330..6c875b4 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -16,7 +16,7 @@ "mutators": { "@default": true }, - "minMsi": 80, - "minCoveredMsi": 80, + "minMsi": 76, + "minCoveredMsi": 76, "testFrameworkOptions": "--testsuite=unit" } diff --git a/src/Attribute/AggregateRepository.php b/src/Attribute/AggregateRepository.php new file mode 100644 index 0000000..15f8ffb --- /dev/null +++ b/src/Attribute/AggregateRepository.php @@ -0,0 +1,33 @@ + $aggregateClass */ + public function __construct( + private string $aggregateClass, + ) { + } + + /** + * @param self $attribute + * + * @return Repository + */ + public static function resolve(self $attribute, Container $container): Repository + { + return $container->get(RepositoryManager::class)->get($attribute->aggregateClass); + } +} diff --git a/src/Attribute/ProjectionConnection.php b/src/Attribute/ProjectionConnection.php new file mode 100644 index 0000000..aa13339 --- /dev/null +++ b/src/Attribute/ProjectionConnection.php @@ -0,0 +1,19 @@ +get('event_sourcing.dbal_public_connection'); + } +} diff --git a/src/Command/StoreMigrateCommand.php b/src/Command/StoreMigrateCommand.php new file mode 100644 index 0000000..baf8bf8 --- /dev/null +++ b/src/Command/StoreMigrateCommand.php @@ -0,0 +1,88 @@ + $translators */ + public function __construct( + private readonly Store $store, + private readonly Store $newStore, + private readonly iterable $translators = [], + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption( + 'buffer', + null, + InputOption::VALUE_REQUIRED, + 'How many messages should be buffered', + 1_000, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $buffer = InputHelper::positiveIntOrZero($input->getOption('buffer')); + $style = new OutputStyle($input, $output); + + $style->info('Migration initialization...'); + + $count = $this->store->count(); + $messages = $this->store->load(); + + $style->progressStart($count); + + $bufferedMessages = []; + + $pipe = new Pipe( + $messages, + ...$this->translators, + ); + + foreach ($pipe as $message) { + $bufferedMessages[] = $message; + + if (count($bufferedMessages) < $buffer) { + continue; + } + + $this->newStore->save(...$bufferedMessages); + $bufferedMessages = []; + $style->progressAdvance($buffer); + } + + if (count($bufferedMessages) !== 0) { + $this->newStore->save(...$bufferedMessages); + $style->progressAdvance(count($bufferedMessages)); + } + + $style->progressFinish(); + $style->success('Migration finished'); + + return 0; + } +} diff --git a/src/EventSourcingServiceProvider.php b/src/EventSourcingServiceProvider.php index 5bcdeab..a07c96f 100644 --- a/src/EventSourcingServiceProvider.php +++ b/src/EventSourcingServiceProvider.php @@ -4,6 +4,7 @@ namespace Patchlevel\LaravelEventSourcing; +use DateInterval; use DateTimeImmutable; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; @@ -11,6 +12,10 @@ use InvalidArgumentException; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Clock\SystemClock; +use Patchlevel\EventSourcing\CommandBus\AggregateHandlerProvider; +use Patchlevel\EventSourcing\CommandBus\CommandBus; +use Patchlevel\EventSourcing\CommandBus\InstantRetryCommandBus; +use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; use Patchlevel\EventSourcing\Console\Command\DatabaseCreateCommand; use Patchlevel\EventSourcing\Console\Command\DatabaseDropCommand; use Patchlevel\EventSourcing\Console\Command\DebugCommand; @@ -51,6 +56,9 @@ use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadataFactory; +use Patchlevel\EventSourcing\QueryBus\QueryBus; +use Patchlevel\EventSourcing\QueryBus\ServiceHandlerProvider; +use Patchlevel\EventSourcing\QueryBus\SyncQueryBus; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Repository\MessageDecorator\ChainMessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator; @@ -70,17 +78,28 @@ use Patchlevel\EventSourcing\Snapshot\SnapshotStore; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\InMemoryStore; +use Patchlevel\EventSourcing\Store\ReadOnlyStore; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Store\StreamReadOnlyStore; use Patchlevel\EventSourcing\Subscription\Engine\CatchUpSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\GapResolverStoreMessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; -use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\EventArgumentResolver; +use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\LookupResolver; +use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\MessageArgumentResolver; +use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\RecordedOnArgumentResolver; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberHelper; @@ -94,9 +113,11 @@ use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\LaravelEventSourcing\Command\StoreMigrateCommand; use Patchlevel\LaravelEventSourcing\Middleware\AutoSetupMiddleware; use Patchlevel\LaravelEventSourcing\Middleware\EventSourcingMiddleware; use Patchlevel\LaravelEventSourcing\Middleware\SubscriptionRebuildAfterFileChangeMiddleware; +use Patchlevel\LaravelEventSourcing\Subscription\StaticInMemorySubscriptionStoreFactory; use function app; use function array_filter; @@ -151,6 +172,7 @@ public function boot(): void SubscriptionStatusCommand::class, SubscriptionPauseCommand::class, SubscriptionReactivateCommand::class, + StoreMigrateCommand::class, ]); } @@ -158,25 +180,63 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/event-sourcing.php', 'event-sourcing'); - $this->registerConnection(); - $this->registerStore(); - $this->registerSerializer(); $this->registerHydrator(); - $this->registerClock(); - $this->registerAggregates(); - $this->registerDebugCommands(); - $this->registerSchema(); $this->registerUpcaster(); + $this->registerSerializer(); $this->registerMessageDecorator(); + $this->registerCommandBus(); $this->registerEventBus(); + $this->registerQueryBus(); + $this->registerConnection(); + $this->registerStore(); $this->registerSnapshots(); + $this->registerAggregates(); + $this->registerDebugCommands(); + // add maybe telescope integration? + $this->registerClock(); + $this->registerSchema(); + $this->registerMessageLoader(); $this->registerSubscription(); $this->registerCryptography(); + // do we want to add doctrine migrations? + // $this->registerValueResolver(); in symf bundle we have an id route resolver - this seems not easy possible + $this->registerStoreMigration(); + } + + private function registerCommandBus(): void + { + if (!config('event-sourcing.command_bus.enabled')) { + return; + } + + $this->app->singleton( + CommandBus::class, + static fn ($app) => new InstantRetryCommandBus( + new SyncCommandBus(app(AggregateHandlerProvider::class)), + config('event-sourcing.command_bus.instant_retry.max_retries'), + config('event-sourcing.command_bus.instant_retry.exceptions'), + ), + ); + } + + private function registerQueryBus(): void + { + if (!config('event-sourcing.query_bus.enabled')) { + return; + } + + $this->app->singleton( + QueryBus::class, + static fn ($app) => new SyncQueryBus( + new ServiceHandlerProvider($app->tagged('event_sourcing.subscriber')), + app('log'), + ), + ); } private function registerConnection(): void { - $this->app->singleton('event_sourcing.dbal_connection', static function () { + $connectionCreationCallback = static function () { $url = config('event-sourcing.connection.url'); if (is_string($url)) { @@ -225,7 +285,15 @@ private function registerConnection(): void static fn (mixed $value) => $value !== null, ), ); - }); + }; + + $this->app->singleton('event_sourcing.dbal_connection', $connectionCreationCallback); + + if (!config('event-sourcing.connection.provide_dedicated_connection')) { + return; + } + + $this->app->singleton('event_sourcing.dbal_public_connection', $connectionCreationCallback); } private function registerStore(): void @@ -235,6 +303,10 @@ private function registerStore(): void $type = config('event-sourcing.store.type'); if ($type === 'custom') { + if (config('event-sourcing.store.read_only')) { + throw new InvalidArgumentException('Custom store type does not support read only'); + } + /** @var string $service */ $service = config('event-sourcing.store.service'); @@ -242,29 +314,49 @@ private function registerStore(): void } if ($type === 'in_memory') { - return new InMemoryStore(); + if (config('event-sourcing.store.read_only')) { + throw new InvalidArgumentException('In memory store type does not support read only'); + } + + return new InMemoryStore( + [], + app(EventRegistry::class), + app('event_sourcing.clock'), + ); } /** @var array $options */ $options = config('event-sourcing.store.options'); if ($type === 'dbal_aggregate') { - return new DoctrineDbalStore( + $store = new DoctrineDbalStore( app('event_sourcing.dbal_connection'), app(EventSerializer::class), app(HeadersSerializer::class), $options, ); + + if (config('event-sourcing.store.read_only')) { + $store = new ReadOnlyStore($store, app('log')); + } + + return $store; } if ($type === 'dbal_stream') { - return new StreamDoctrineDbalStore( + $store = new StreamDoctrineDbalStore( app('event_sourcing.dbal_connection'), app(EventSerializer::class), app(HeadersSerializer::class), app('event_sourcing.clock'), $options, ); + + if (config('event-sourcing.store.read_only')) { + $store = new StreamReadOnlyStore($store, app('log')); + } + + return $store; } throw new InvalidArgumentException(sprintf('Unknown store type "%s"', $type)); @@ -350,7 +442,7 @@ private function registerAggregates(): void return new DefaultRepositoryManager( app(AggregateRootRegistry::class), app(Store::class), - app(EventBus::class), + config('event-sourcing.event_bus.enabled') ? app(EventBus::class) : null, app(SnapshotStore::class), app(MessageDecorator::class), app('event_sourcing.clock'), @@ -481,6 +573,10 @@ private function registerMessageDecorator(): void private function registerEventBus(): void { + if (!config('event-sourcing.event_bus.enabled')) { + return; + } + /** @var class-string $class */ foreach (config('event-sourcing.listeners') as $class) { $this->app->tag($class, 'event_sourcing.listener'); @@ -518,6 +614,26 @@ private function registerSnapshots(): void }); } + private function registerMessageLoader(): void + { + if (config('event-sourcing.subscription.gap_detection.enabled')) { + $this->app->singleton(MessageLoader::class, static function () { + return new GapResolverStoreMessageLoader( + app(Store::class), + app('event_sourcing.clock'), + config('event-sourcing.subscription.gap_detection.retries_in_ms'), + new DateInterval(config('event-sourcing.subscription.gap_detection.detection_window')), + ); + }); + + return; + } + + $this->app->singleton(MessageLoader::class, static function () { + return new StoreMessageLoader(app(Store::class)); + }); + } + private function registerSubscription(): void { /** @var class-string $class */ @@ -525,14 +641,56 @@ private function registerSubscription(): void $this->app->tag($class, 'event_sourcing.subscriber'); } - $this->app->singleton(RetryStrategy::class, static function () { - return new ClockBasedRetryStrategy( + if (config('event-sourcing.subscription.retry_strategy') && config('event-sourcing.subscription.retry_strategies')) { + throw new InvalidArgumentException('Cannot use "retry_strategies" and "retry_strategy" at the same time. Use only "retry_strategies".'); + } + + if (config('event-sourcing.subscription.retry_strategy')) { + $strategies['default'] = new ClockBasedRetryStrategy( app('event_sourcing.clock'), config('event-sourcing.subscription.retry_strategy.base_delay'), config('event-sourcing.subscription.retry_strategy.delay_factor'), config('event-sourcing.subscription.retry_strategy.max_attempts'), ); - }); + $strategies['no_retry'] = new NoRetryStrategy(); + } + + $strategies = []; + + foreach (config('event-sourcing.subscription.retry_strategies') as $name => $config) { + if ($config['type'] === 'custom') { + $strategies[$name] = app($config['service']); + + continue; + } + + if ($config['type'] === 'clock_based') { + $strategies[$name] = new ClockBasedRetryStrategy( + app('event_sourcing.clock'), + $config['options']['base_delay'] ?? 5, + $config['options']['delay_factor'] ?? 2, + $config['options']['max_attempts'] ?? 5, + ); + + continue; + } + + if ($config['type'] === 'no_retry') { + $strategies[$name] = new NoRetryStrategy(); + + continue; + } + + throw new InvalidArgumentException(sprintf('Unknown retry strategy type "%s"', $config['type'])); + } + + $this->app->singleton( + RetryStrategyRepository::class, + static fn () => new RetryStrategyRepository( + $strategies, + config('event-sourcing.subscription.default_retry_strategy'), + ), + ); $this->app->singleton(SubscriberHelper::class, static function () { return new SubscriberHelper( @@ -540,14 +698,39 @@ private function registerSubscription(): void ); }); - $this->app->singleton(SubscriptionStore::class, static function () { - return new DoctrineSubscriptionStore( + if (config('event-sourcing.subscription.store.type') === 'custom') { + if (config('event-sourcing.subscription.store.service') === null) { + throw new InvalidArgumentException('Custom subscription store type requires a service'); + } + + $storeCallback = static fn () => app(config('event-sourcing.subscription.store.service')); + } elseif (config('event-sourcing.subscription.store.type') === 'in_memory') { + $storeCallback = static fn () => new InMemorySubscriptionStore([], app('event_sourcing.clock')); + } elseif (config('event-sourcing.subscription.store.type') === 'static_in_memory') { + $storeCallback = static fn () => StaticInMemorySubscriptionStoreFactory::create(); + } elseif (config('event-sourcing.subscription.store.type') === 'dbal') { + $storeCallback = static fn () => new DoctrineSubscriptionStore( app('event_sourcing.dbal_connection'), + app('event_sourcing.clock'), + config('event-sourcing.subscription.store.options.table_name'), ); - }); + } else { + throw new InvalidArgumentException('Subscription store type is unknown.'); + } + $this->app->singleton(SubscriptionStore::class, $storeCallback); $this->app->tag(SubscriptionStore::class, ['event_sourcing.doctrine_schema_configurator']); + $this->app->tag( + [ + LookupResolver::class, + RecordedOnArgumentResolver::class, + EventArgumentResolver::class, + MessageArgumentResolver::class, + ], + 'event_sourcing.argument_resolver', + ); + /** @var class-string $class */ foreach (config('event-sourcing.argument_resolvers') as $class) { $this->app->tag($class, 'event_sourcing.argument_resolver'); @@ -566,7 +749,7 @@ private function registerSubscription(): void app(Store::class), app(SubscriptionStore::class), app(SubscriberAccessorRepository::class), - app(RetryStrategy::class), + app(RetryStrategyRepository::class), app('log'), ); }); @@ -621,54 +804,60 @@ private function registerSubscription(): void ); }); - $this->app->singleton(SubscriptionSetupCommand::class, static function () { - return new SubscriptionSetupCommand( + $this->app->singleton( + SubscriptionSetupCommand::class, + static fn () => new SubscriptionSetupCommand( app(SubscriptionEngine::class), - ); - }); + ), + ); - $this->app->singleton(SubscriptionBootCommand::class, static function () { - return new SubscriptionBootCommand( + $this->app->singleton( + SubscriptionBootCommand::class, + static fn () => new SubscriptionBootCommand( app(SubscriptionEngine::class), - ); - }); + ), + ); - $this->app->singleton(SubscriptionRunCommand::class, static function () { - return new SubscriptionRunCommand( + $this->app->singleton( + SubscriptionRunCommand::class, + static fn () => new SubscriptionRunCommand( app(SubscriptionEngine::class), app(Store::class), - ); - }); + ), + ); - $this->app->singleton(SubscriptionTeardownCommand::class, static function () { - return new SubscriptionTeardownCommand( + $this->app->singleton( + SubscriptionTeardownCommand::class, + static fn () => new SubscriptionTeardownCommand( app(SubscriptionEngine::class), - ); - }); + ), + ); - $this->app->singleton(SubscriptionRemoveCommand::class, static function () { - return new SubscriptionRemoveCommand( + $this->app->singleton( + SubscriptionRemoveCommand::class, + static fn () => new SubscriptionRemoveCommand( app(SubscriptionEngine::class), - ); - }); + ), + ); - $this->app->singleton(SubscriptionStatusCommand::class, static function () { - return new SubscriptionStatusCommand( + $this->app->singleton( + SubscriptionStatusCommand::class, + static fn () => new SubscriptionStatusCommand( app(SubscriptionEngine::class), - ); - }); + ), + ); - $this->app->singleton(SubscriptionPauseCommand::class, static function () { - return new SubscriptionPauseCommand( + $this->app->singleton( + SubscriptionPauseCommand::class, + static fn () => new SubscriptionPauseCommand( app(SubscriptionEngine::class), - ); - }); + ), + ); - $this->app->singleton(SubscriptionReactivateCommand::class, static function () { - return new SubscriptionReactivateCommand( - app(SubscriptionEngine::class), - ); - }); + $this->app->singleton( + SubscriptionReactivateCommand::class, + static fn () => new SubscriptionReactivateCommand(app(SubscriptionEngine::class)), + ); } private function registerCryptography(): void @@ -677,29 +866,95 @@ private function registerCryptography(): void return; } - $this->app->singleton(CipherKeyFactory::class, static function () { - return new OpensslCipherKeyFactory(config('event-sourcing.cryptography.algorithm')); - }); + $this->app->singleton( + CipherKeyFactory::class, + static fn () => new OpensslCipherKeyFactory(config('event-sourcing.cryptography.algorithm')), + ); - $this->app->singleton(CipherKeyStore::class, static function () { - return new DoctrineCipherKeyStore( + $this->app->singleton( + CipherKeyStore::class, + static fn () => new DoctrineCipherKeyStore( app('event_sourcing.dbal_connection'), 'eventstore_cipher_keys', - ); - }); + ), + ); $this->app->tag(CipherKeyStore::class, ['event_sourcing.doctrine_schema_configurator']); - $this->app->singleton(Cipher::class, static function () { - return new OpensslCipher(); - }); + $this->app->singleton(Cipher::class, static fn () => new OpensslCipher()); - $this->app->singleton(PayloadCryptographer::class, static function () { - return new PersonalDataPayloadCryptographer( + $this->app->singleton( + PayloadCryptographer::class, + static fn () => new PersonalDataPayloadCryptographer( app(CipherKeyStore::class), app(CipherKeyFactory::class), app(Cipher::class), + config('event-sourcing.cryptography.use_encrypted_field_name'), + config('event-sourcing.cryptography.fallback_to_field_name'), + ), + ); + } + + private function registerStoreMigration(): void + { + if (!config('event-sourcing.migrate_to_new_store.enabled')) { + return; + } + + $id = 'event_sourcing.store.new_store'; + + foreach (config('event-sourcing.migrate_to_new_store.translators') as $class) { + $this->app->tag($class, 'event_sourcing.translator'); + } + + $storeType = config('event-sourcing.migrate_to_new_store.type'); + if ($storeType === 'custom') { + if (config('event-sourcing.migrate_to_new_store.service') === null) { + throw new InvalidArgumentException('Custom store type requires a service'); + } + + $this->app->singleton($id, static fn () => app(config('event-sourcing.migrate_to_new_store.service'))); + } elseif ($storeType === 'in_memory') { + $this->app->singleton( + $id, + static fn () => new InMemoryStore( + [], + app(EventRegistry::class), + app('event_sourcing.clock'), + ), ); - }); + } elseif ($storeType === 'dbal_aggregate') { + $this->app->singleton( + $id, + static fn () => new DoctrineDbalStore( + app('event_sourcing.dbal_connection'), + app(EventSerializer::class), + app(HeadersSerializer::class), + config('event-sourcing.migrate_to_new_store.options'), + ), + ); + } elseif ($storeType === 'dbal_stream') { + $this->app->singleton( + $id, + static fn () => new StreamDoctrineDbalStore( + app('event_sourcing.dbal_connection'), + app(EventSerializer::class), + app(HeadersSerializer::class), + app('event_sourcing.clock'), + config('event-sourcing.migrate_to_new_store.options'), + ), + ); + } else { + throw new InvalidArgumentException(sprintf('Unknown store type "%s"', $storeType)); + } + + $this->app->singleton( + StoreMigrateCommand::class, + static fn ($app) => new StoreMigrateCommand( + app(Store::class), + app($id), + $app->tagged('event_sourcing.translator'), + ), + ); } } diff --git a/src/Facade/CommandBus.php b/src/Facade/CommandBus.php new file mode 100644 index 0000000..f9e93c4 --- /dev/null +++ b/src/Facade/CommandBus.php @@ -0,0 +1,17 @@ +lastMessage = $message; } } diff --git a/tests/Fixtures/Listener2.php b/tests/Fixtures/Listener2.php index 11e49c1..e82c84d 100644 --- a/tests/Fixtures/Listener2.php +++ b/tests/Fixtures/Listener2.php @@ -4,9 +4,7 @@ use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\LaravelEventSourcing\Attribute\AsListener; -#[AsListener] class Listener2 { #[Subscribe(ProfileCreated::class)] diff --git a/tests/Fixtures/Profile.php b/tests/Fixtures/Profile.php index 5a8d454..7a263eb 100644 --- a/tests/Fixtures/Profile.php +++ b/tests/Fixtures/Profile.php @@ -7,7 +7,7 @@ use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\Attribute\Id; -use Patchlevel\EventSourcing\Repository\Repository; +use Patchlevel\Hydrator\Hydrator; use Patchlevel\LaravelEventSourcing\AggregateRoot; #[Aggregate('profile')] @@ -19,7 +19,7 @@ class Profile extends AggregateRoot #[Handle] public static function create( CreateProfile $command, - Repository $profileRepository + Hydrator $hydrator, ): self { $profile = new self(); diff --git a/tests/Fixtures/ProfileListener.php b/tests/Fixtures/ProfileListener.php deleted file mode 100644 index 378248a..0000000 --- a/tests/Fixtures/ProfileListener.php +++ /dev/null @@ -1,10 +0,0 @@ -createMock(Repository::class)); + $profile = Profile::create(new CreateProfile(CustomId::fromString('1')), $this->createMock(Hydrator::class)); $profile->save(); self::assertTrue(true); } public function testRepositoryAvailableAndAggregateCanBeLoaded(): void { - $profile = Profile::create(new CreateProfile(CustomId::fromString('1')), $this->createMock(Repository::class)); + $profile = Profile::create(new CreateProfile(CustomId::fromString('1')), $this->createMock(Hydrator::class)); $profile->save(); $profile2 = Profile::load(CustomId::fromString('1')); @@ -37,7 +38,7 @@ public function testRepositoryAvailableAndAggregateCanBeLoaded(): void public function testRepositoryAvailableAndAggregateCanBeChecked(): void { - $profile = Profile::create(new CreateProfile(CustomId::fromString('1')), $this->createMock(Repository::class)); + $profile = Profile::create(new CreateProfile(CustomId::fromString('1')), $this->createMock(Hydrator::class)); $profile->save(); self::assertFalse(Profile::has(CustomId::fromString('2'))); diff --git a/tests/Unit/CommandBusTest.php b/tests/Unit/CommandBusTest.php new file mode 100644 index 0000000..8c10d48 --- /dev/null +++ b/tests/Unit/CommandBusTest.php @@ -0,0 +1,24 @@ +load($id); + self::assertEquals($id, $profile->aggregateRootId()); + } +} diff --git a/tests/Unit/FacadeTest.php b/tests/Unit/FacadeTest.php index 7851cc6..b2808e2 100644 --- a/tests/Unit/FacadeTest.php +++ b/tests/Unit/FacadeTest.php @@ -4,8 +4,12 @@ namespace Patchlevel\LaravelEventSourcing\Tests\Unit; +use Doctrine\DBAL\Connection; use Illuminate\Support\Facades\Facade; use Patchlevel\EventSourcing\Repository\RepositoryManager; +use Patchlevel\LaravelEventSourcing\Facade\CommandBus; +use Patchlevel\LaravelEventSourcing\Facade\ProjectionConnection; +use Patchlevel\LaravelEventSourcing\Facade\QueryBus; use Patchlevel\LaravelEventSourcing\Facade\Repository; use Patchlevel\LaravelEventSourcing\Facade\Store; use PHPUnit\Framework\Attributes\DataProvider; @@ -29,5 +33,8 @@ public static function provideFacades(): iterable { yield [Repository::class, RepositoryManager::class]; yield [Store::class, \Patchlevel\EventSourcing\Store\Store::class]; + yield [CommandBus::class, \Patchlevel\EventSourcing\CommandBus\CommandBus::class]; + yield [QueryBus::class, \Patchlevel\EventSourcing\QueryBus\QueryBus::class]; + yield [ProjectionConnection::class, Connection::class]; } } diff --git a/tests/Unit/QueryBusTest.php b/tests/Unit/QueryBusTest.php new file mode 100644 index 0000000..0e6d50c --- /dev/null +++ b/tests/Unit/QueryBusTest.php @@ -0,0 +1,21 @@ +setConfig('event-sourcing.subscribers', [ProfileProjector::class]); + + $result = QueryBus::dispatch(new QueryFoo('bar')); + + self::assertEquals('bar', $result); + } +} diff --git a/tests/Unit/ServicesTest.php b/tests/Unit/ServicesTest.php index ff1a3e3..b54a8f7 100644 --- a/tests/Unit/ServicesTest.php +++ b/tests/Unit/ServicesTest.php @@ -6,6 +6,8 @@ use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Clock\SystemClock; +use Patchlevel\EventSourcing\CommandBus\CommandBus; +use Patchlevel\EventSourcing\CommandBus\InstantRetryCommandBus; use Patchlevel\EventSourcing\Console\Command\DatabaseCreateCommand; use Patchlevel\EventSourcing\Console\Command\DatabaseDropCommand; use Patchlevel\EventSourcing\Console\Command\DebugCommand; @@ -43,6 +45,8 @@ use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadataFactory; +use Patchlevel\EventSourcing\QueryBus\QueryBus; +use Patchlevel\EventSourcing\QueryBus\SyncQueryBus; use Patchlevel\EventSourcing\Repository\MessageDecorator\ChainMessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; @@ -62,11 +66,12 @@ use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\CatchUpSubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\GapResolverStoreMessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; -use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; -use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; @@ -84,6 +89,9 @@ use Patchlevel\LaravelEventSourcing\Middleware\AutoSetupMiddleware; use Patchlevel\LaravelEventSourcing\Middleware\EventSourcingMiddleware; use Patchlevel\LaravelEventSourcing\Middleware\SubscriptionRebuildAfterFileChangeMiddleware; +use Patchlevel\LaravelEventSourcing\Tests\Fixtures\Profile; +use Patchlevel\LaravelEventSourcing\Tests\Fixtures\ProfileProcessor; +use Patchlevel\LaravelEventSourcing\Tests\Fixtures\ProfileProjector; use PHPUnit\Framework\Attributes\DataProvider; final class ServicesTest extends TestCase @@ -95,12 +103,18 @@ final class ServicesTest extends TestCase #[DataProvider('provideServices')] public function testServiceIsAvailable(string $serviceClass, string $concreteClass): void { + $this->setConfig('event-sourcing.event_bus.enabled', true); + $this->setConfig('event-sourcing.cryptography.enabled', true); + $service = $this->app->get($serviceClass); self::assertNotNull($service); self::assertInstanceOf($concreteClass, $service); } + /** + * @return iterable + */ public static function provideServices(): iterable { yield [EventMetadataFactory::class, AttributeEventMetadataFactory::class]; @@ -109,6 +123,7 @@ public static function provideServices(): iterable yield [AggregateRootMetadataFactory::class, AggregateRootMetadataAwareMetadataFactory::class]; yield [SubscriberMetadataFactory::class, AttributeSubscriberMetadataFactory::class]; yield ['event_sourcing.dbal_connection', Connection::class]; + yield ['event_sourcing.dbal_public_connection', Connection::class]; yield [Store::class, DoctrineDbalStore::class]; yield [EventRegistry::class, EventRegistry::class]; yield [EventSerializer::class, DefaultEventSerializer::class]; @@ -132,11 +147,14 @@ public static function provideServices(): iterable yield [Upcaster::class, UpcasterChain::class]; yield [MessageDecorator::class, ChainMessageDecorator::class]; yield [SplitStreamDecorator::class, SplitStreamDecorator::class]; + yield [CommandBus::class, InstantRetryCommandBus::class]; + yield [QueryBus::class, SyncQueryBus::class]; + yield [MessageLoader::class, GapResolverStoreMessageLoader::class]; yield [ListenerProvider::class, AttributeListenerProvider::class]; yield [Consumer::class, DefaultConsumer::class]; yield [EventBus::class, DefaultEventBus::class]; yield [SnapshotStore::class, DefaultSnapshotStore::class]; - yield [RetryStrategy::class, ClockBasedRetryStrategy::class]; + yield [RetryStrategyRepository::class, RetryStrategyRepository::class]; yield [SubscriberHelper::class, SubscriberHelper::class]; yield [SubscriptionStore::class, DoctrineSubscriptionStore::class]; yield [SubscriberAccessorRepository::class, MetadataSubscriberAccessorRepository::class]; @@ -157,4 +175,31 @@ public static function provideServices(): iterable yield [Cipher::class, OpensslCipher::class]; yield [PayloadCryptographer::class, PersonalDataPayloadCryptographer::class]; } + + public function testPublicConnectionIsNotSameAsPrivate(): void + { + /** @var Connection $private */ + $private = $this->app->get('event_sourcing.dbal_connection'); + /** @var Connection $public */ + $public = $this->app->get('event_sourcing.dbal_public_connection'); + + self::assertNotSame($public, $private); + self::assertEquals($public->getParams(), $private->getParams()); + } + + public function testAttributeProjectionConnectionInjection(): void + { + $public = $this->app->get('event_sourcing.dbal_public_connection'); + $service = $this->app->get(ProfileProjector::class); + + self::assertSame($public, $service->connection); + } + + public function testAttributeAggregateRepositoryInjection(): void + { + $service = $this->app->get(ProfileProcessor::class); + $public = $this->app->get(RepositoryManager::class)->get(Profile::class); + + self::assertEquals($public, $service->repository); + } } diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 07bdea3..e7231fc 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -36,4 +36,11 @@ protected function defineEnvironment($app): void $app['config']->set('event-sourcing.aggregates', [__DIR__ . '/../Fixtures']); $app['config']->set('event-sourcing.events', [__DIR__ . '/../Fixtures']); } + + protected function setConfig(string $name, $value) + { + config()->set($name, $value); + + (new EventSourcingServiceProvider($this->app))->register(); + } }