diff --git a/composer.json b/composer.json index 5dd05a0..aa92bd8 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.15.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 d7b8dbf..69e312b 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": "bc09e58c40314d0baf71205b9d2bd56d", + "content-hash": "6f654ea9800b1ae0a89ca06ed9e6db8f", "packages": [ { "name": "brick/math", @@ -1402,16 +1402,16 @@ }, { "name": "laravel/framework", - "version": "v12.45.1", + "version": "v12.46.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "1ca7e2a2ee17ae5bc435af7cb52d2f130148e2fa" + "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/1ca7e2a2ee17ae5bc435af7cb52d2f130148e2fa", - "reference": "1ca7e2a2ee17ae5bc435af7cb52d2f130148e2fa", + "url": "https://api.github.com/repos/laravel/framework/zipball/9dcff48d25a632c1fadb713024c952fec489c4ae", + "reference": "9dcff48d25a632c1fadb713024c952fec489c4ae", "shasum": "" }, "require": { @@ -1620,7 +1620,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-07T00:50:24+00:00" + "time": "2026-01-07T23:26:53+00:00" }, { "name": "laravel/prompts", @@ -2752,16 +2752,16 @@ }, { "name": "patchlevel/event-sourcing", - "version": "3.14.1", + "version": "3.15.0", "source": { "type": "git", "url": "https://github.com/patchlevel/event-sourcing.git", - "reference": "8ec50ac6ff9b0d40ffd2d2c0171908a3c1131518" + "reference": "71cb083baf3d9b5e80a12e646dc788b02b2d170a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/8ec50ac6ff9b0d40ffd2d2c0171908a3c1131518", - "reference": "8ec50ac6ff9b0d40ffd2d2c0171908a3c1131518", + "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/71cb083baf3d9b5e80a12e646dc788b02b2d170a", + "reference": "71cb083baf3d9b5e80a12e646dc788b02b2d170a", "shasum": "" }, "require": { @@ -2784,7 +2784,7 @@ "require-dev": { "doctrine/orm": "^2.18.0 || ^3.0.0", "ext-pdo_sqlite": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "infection/infection": "^0.31.9", + "infection/infection": "^0.32.0", "league/commonmark": "^2.6.1", "patchlevel/coding-standard": "^1.3.0", "phpat/phpat": "^0.12.0", @@ -2827,9 +2827,9 @@ ], "support": { "issues": "https://github.com/patchlevel/event-sourcing/issues", - "source": "https://github.com/patchlevel/event-sourcing/tree/3.14.1" + "source": "https://github.com/patchlevel/event-sourcing/tree/3.15.0" }, - "time": "2025-12-02T14:20:09+00:00" + "time": "2026-01-07T00:39:09+00:00" }, { "name": "patchlevel/hydrator", @@ -7496,16 +7496,16 @@ }, { "name": "infection/infection", - "version": "0.32.1", + "version": "0.32.2", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "b5c2dc7dd7615c90edf56924df09d39170c2817d" + "reference": "df90353784ab0505f07502770f59f8fabef24d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/b5c2dc7dd7615c90edf56924df09d39170c2817d", - "reference": "b5c2dc7dd7615c90edf56924df09d39170c2817d", + "url": "https://api.github.com/repos/infection/infection/zipball/df90353784ab0505f07502770f59f8fabef24d8c", + "reference": "df90353784ab0505f07502770f59f8fabef24d8c", "shasum": "" }, "require": { @@ -7616,7 +7616,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.32.1" + "source": "https://github.com/infection/infection/tree/0.32.2" }, "funding": [ { @@ -7628,7 +7628,7 @@ "type": "open_collective" } ], - "time": "2026-01-05T18:10:44+00:00" + "time": "2026-01-07T13:28:58+00:00" }, { "name": "infection/mutator", @@ -8703,16 +8703,16 @@ }, { "name": "orchestra/testbench-core", - "version": "v10.8.1", + "version": "v10.8.2", "source": { "type": "git", "url": "https://github.com/orchestral/testbench-core.git", - "reference": "f1da36cedc677d015d2a46d36abee54ffd5ba711" + "reference": "08c178232df58b1f083faa06aebd753b57db1d90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/f1da36cedc677d015d2a46d36abee54ffd5ba711", - "reference": "f1da36cedc677d015d2a46d36abee54ffd5ba711", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/08c178232df58b1f083faa06aebd753b57db1d90", + "reference": "08c178232df58b1f083faa06aebd753b57db1d90", "shasum": "" }, "require": { @@ -8792,7 +8792,7 @@ "issues": "https://github.com/orchestral/testbench/issues", "source": "https://github.com/orchestral/testbench-core" }, - "time": "2025-12-08T08:07:27+00:00" + "time": "2025-12-26T02:15:14+00:00" }, { "name": "orchestra/workbench", @@ -9664,12 +9664,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9a341b84b3ebb8ad254193ce440b44c7d4375a4f" + "reference": "ebc5572f219ad85f60f20fcff71b98b5055c4f8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9a341b84b3ebb8ad254193ce440b44c7d4375a4f", - "reference": "9a341b84b3ebb8ad254193ce440b44c7d4375a4f", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ebc5572f219ad85f60f20fcff71b98b5055c4f8e", + "reference": "ebc5572f219ad85f60f20fcff71b98b5055c4f8e", "shasum": "" }, "conflict": { @@ -9802,6 +9802,7 @@ "contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", + "coreshop/core-shop": "<=4.1.7", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", "couleurcitron/tarteaucitron-wp": "<0.3", @@ -10662,7 +10663,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T20:06:42+00:00" + "time": "2026-01-07T20:06:51+00:00" }, { "name": "sanmai/di-container", @@ -12453,16 +12454,16 @@ }, { "name": "webmozart/assert", - "version": "2.0.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54" + "reference": "bdbabc199a7ba9965484e4725d66170e5711323b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/1b34b004e35a164bc5bb6ebd33c844b2d8069a54", - "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b", + "reference": "bdbabc199a7ba9965484e4725d66170e5711323b", "shasum": "" }, "require": { @@ -12509,9 +12510,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.0.0" + "source": "https://github.com/webmozarts/assert/tree/2.1.1" }, - "time": "2025-12-16T21:36:00+00:00" + "time": "2026-01-08T11:28:40+00:00" }, { "name": "wnx/commonmark-markdown-renderer", 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/EventSourcingServiceProvider.php b/src/EventSourcingServiceProvider.php index 5bcdeab..0d55235 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; @@ -19,6 +24,7 @@ use Patchlevel\EventSourcing\Console\Command\SchemaUpdateCommand; use Patchlevel\EventSourcing\Console\Command\ShowAggregateCommand; use Patchlevel\EventSourcing\Console\Command\ShowCommand; +use Patchlevel\EventSourcing\Console\Command\StoreMigrateCommand; use Patchlevel\EventSourcing\Console\Command\SubscriptionBootCommand; use Patchlevel\EventSourcing\Console\Command\SubscriptionPauseCommand; use Patchlevel\EventSourcing\Console\Command\SubscriptionReactivateCommand; @@ -51,6 +57,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 +79,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; @@ -97,6 +117,7 @@ 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(); + } }