From 8618c3af8a475461fb9cebf6671d1f08beaaec72 Mon Sep 17 00:00:00 2001 From: Nicolo Singer Date: Wed, 15 Jul 2026 12:52:36 +0200 Subject: [PATCH 01/11] Add `Throwable` to `CommandBus::dispatch` @throws Handlers can throw any exception, and without declaring it, catching one forces `@phpstan-ignore catch.neverThrown` everywhere, which is a bad pattern. Signed-off-by: Nicolo Singer --- src/CommandBus/CommandBus.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/CommandBus/CommandBus.php b/src/CommandBus/CommandBus.php index f47ee9c28..c4c3323d5 100644 --- a/src/CommandBus/CommandBus.php +++ b/src/CommandBus/CommandBus.php @@ -4,8 +4,13 @@ namespace Patchlevel\EventSourcing\CommandBus; +use Throwable; + interface CommandBus { - /** @throws HandlerNotFound */ + /** + * @throws HandlerNotFound + * @throws Throwable + */ public function dispatch(object $command): void; } From dacc76d4ccc2d7d6b090a94fcb9ae246f3fadce6 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Fri, 17 Jul 2026 08:47:03 +0200 Subject: [PATCH 02/11] Add fronmatter support for docs formatting, format docs --- bin/docs-extract-php-code | 11 +++++++++++ bin/docs-inject-php-code | 21 ++++++++++++++++++++- docs/message.md | 4 ---- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/bin/docs-extract-php-code b/bin/docs-extract-php-code index d50b642df..a27094df7 100755 --- a/bin/docs-extract-php-code +++ b/bin/docs-extract-php-code @@ -3,6 +3,8 @@ use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Extension\FrontMatter\Data\FrontMatterDataParserInterface; +use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Node\Query; use League\CommonMark\Parser\MarkdownParser; use Wnx\CommonmarkMarkdownRenderer\MarkdownRendererExtension; @@ -11,6 +13,15 @@ require __DIR__ . '/../vendor/autoload.php'; $environment = new Environment([]); $environment->addExtension(new MarkdownRendererExtension()); +$environment->addExtension(new FrontMatterExtension( + // keep the raw YAML string so it round-trips byte-identically + new class implements FrontMatterDataParserInterface { + public function parse(string $frontMatter): string + { + return $frontMatter; + } + }, +)); $parser = new MarkdownParser($environment); diff --git a/bin/docs-inject-php-code b/bin/docs-inject-php-code index e4e81ead1..13694a10b 100755 --- a/bin/docs-inject-php-code +++ b/bin/docs-inject-php-code @@ -3,6 +3,9 @@ use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Extension\FrontMatter\Data\FrontMatterDataParserInterface; +use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; +use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; use League\CommonMark\Node\Query; use League\CommonMark\Parser\MarkdownParser; use Wnx\CommonmarkMarkdownRenderer\MarkdownRendererExtension; @@ -14,6 +17,15 @@ require __DIR__ . '/../vendor/autoload.php'; $environment = new Environment([]); $environment->addExtension(new MarkdownRendererExtension()); +$environment->addExtension(new FrontMatterExtension( + // keep the raw YAML string so it round-trips byte-identically + new class implements FrontMatterDataParserInterface { + public function parse(string $frontMatter): string + { + return $frontMatter; + } + }, +)); $parser = new MarkdownParser($environment); $markdownRenderer = new MarkdownRenderer($environment); @@ -62,7 +74,14 @@ foreach ($finder as $file) { $node->setLiteral(trim($code)); } - file_put_contents($file->getPathname(), $markdownRenderer->renderDocument($document)); + $rendered = $markdownRenderer->renderDocument($document); + $output = $rendered->getContent(); + + if ($rendered instanceof RenderedContentWithFrontMatter) { + $output = $rendered->getFrontMatter() . "---\n" . $output; + } + + file_put_contents($file->getPathname(), $output); } if (file_exists($targetDir)) { diff --git a/docs/message.md b/docs/message.md index 25e47e908..71338672c 100644 --- a/docs/message.md +++ b/docs/message.md @@ -97,7 +97,6 @@ use Patchlevel\EventSourcing\Message\Message; /** @var Message $message */ $message->header(ApplicationHeader::class); ``` - ## Missing headers When a message is deserialized, every header name is resolved to its registered header class. @@ -117,7 +116,6 @@ $serializer = DefaultHeadersSerializer::createFromPaths( ['legacyApplication', 'legacyTenant'], ); ``` - You can access the collected headers via the `MissingHeaders` object: ```php @@ -127,7 +125,6 @@ use Patchlevel\EventSourcing\Message\MissingHeaders; $missingHeaders = $message->header(MissingHeaders::class); $missingHeaders->headers; // ['legacyApplication' => [...], 'legacyTenant' => [...]] ``` - :::warning Only the header names you list are handled gracefully. If a message contains an unregistered header whose name is **not** in the list, deserialization still throws `HeaderNameNotRegistered`. @@ -143,7 +140,6 @@ $serializer = DefaultHeadersSerializer::createFromPaths( ['*'], ); ``` - ## Pipe The `Pipe` is a construct that allows you to chain multiple translators. From d3c1a7f1abdef9ace161318045bf62bfeebbfbf0 Mon Sep 17 00:00:00 2001 From: Thomas Tourlourat Date: Wed, 22 Jul 2026 11:28:23 +0200 Subject: [PATCH 03/11] Update SubscriptionRemoveCommand.php Add missing configure from parent. --- src/Console/Command/SubscriptionRemoveCommand.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Console/Command/SubscriptionRemoveCommand.php b/src/Console/Command/SubscriptionRemoveCommand.php index 93b9ba78d..09876dedb 100644 --- a/src/Console/Command/SubscriptionRemoveCommand.php +++ b/src/Console/Command/SubscriptionRemoveCommand.php @@ -19,6 +19,8 @@ final class SubscriptionRemoveCommand extends SubscriptionCommand { protected function configure(): void { + parent::configure(); + $this ->addOption('force', 'f', InputOption::VALUE_NONE, 'Set this parameter to execute this action without prompting'); } From 4c0bd419a78fd3acb5887f3438d802402a6829e4 Mon Sep 17 00:00:00 2001 From: Thomas Tourlourat Date: Wed, 22 Jul 2026 12:13:26 +0200 Subject: [PATCH 04/11] make csfixer --- src/Console/Command/SubscriptionRemoveCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/Command/SubscriptionRemoveCommand.php b/src/Console/Command/SubscriptionRemoveCommand.php index 09876dedb..5a4bd74ee 100644 --- a/src/Console/Command/SubscriptionRemoveCommand.php +++ b/src/Console/Command/SubscriptionRemoveCommand.php @@ -20,7 +20,7 @@ final class SubscriptionRemoveCommand extends SubscriptionCommand protected function configure(): void { parent::configure(); - + $this ->addOption('force', 'f', InputOption::VALUE_NONE, 'Set this parameter to execute this action without prompting'); } From 36ed66bb82c42fc5c6d7ba70997f70f126fe9b3f Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Wed, 22 Jul 2026 13:20:24 +0200 Subject: [PATCH 05/11] Use xdebug instead of pcov as coverage provider --- .github/workflows/backward-compatibility-check.yml | 1 - .github/workflows/integration.yml | 4 ---- .github/workflows/mutation-tests-diff.yml | 2 +- .github/workflows/mutation-tests.yml | 2 +- .github/workflows/unit.yml | 2 +- 5 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/backward-compatibility-check.yml b/.github/workflows/backward-compatibility-check.yml index 0a98d7837..ebfc8ab25 100644 --- a/.github/workflows/backward-compatibility-check.yml +++ b/.github/workflows/backward-compatibility-check.yml @@ -29,7 +29,6 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_sqlite, bcmath, intl, sodium diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index eb84cdcae..6759acac9 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -53,7 +53,6 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_pgsql @@ -108,7 +107,6 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_mysql @@ -162,7 +160,6 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_mysql @@ -196,7 +193,6 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_sqlite diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index c38555ac7..98555ab71 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -29,7 +29,7 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" + coverage: "xdebug" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_sqlite, pdo_pgsql diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index e4ee6a896..9619c5ff7 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -31,7 +31,7 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" + coverage: "xdebug" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_sqlite, pdo_pgsql diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 749c003b7..269c23682 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -43,7 +43,7 @@ jobs: - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" with: - coverage: "pcov" + coverage: "xdebug" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 extensions: pdo_sqlite, pdo_pgsql From 9174c67022659473e561c70ca824baf341553775 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Sun, 26 Jul 2026 10:54:06 +0200 Subject: [PATCH 06/11] Add some missing docs pieces: * debug & store migration command * encoder * some message headers introduced by stream store * metadata caching --- docs/aggregate.md | 6 ++ docs/cli.md | 82 ++++++++++++++++++ docs/events.md | 68 +++++++++++++++ docs/message.md | 44 +++++++++- docs/metadata-cache.md | 185 +++++++++++++++++++++++++++++++++++++++++ docs/project.json | 4 + docs/repository.md | 2 +- docs/store.md | 104 ++++++++++++++++++++++- docs/subscription.md | 52 ++++++++++++ 9 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 docs/metadata-cache.md diff --git a/docs/aggregate.md b/docs/aggregate.md index e93e2697c..9e9371bc7 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -944,6 +944,11 @@ use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegist $aggregateRegistry = (new AttributeAggregateRootRegistryFactory())->create([/* paths... */]); ``` +:::tip +Scanning the paths on every request costs time. In production you can wrap the factory +in a [metadata cache](metadata-cache.md). +::: + ## Learn more * [How to create own aggregate id](aggregate-id.md) @@ -951,3 +956,4 @@ $aggregateRegistry = (new AttributeAggregateRootRegistryFactory())->create([/* p * [How to snapshot aggregates](snapshots.md) * [How to create Projections](subscription.md) * [How to split streams](split-stream.md) +* [How to cache metadata](metadata-cache.md) diff --git a/docs/cli.md b/docs/cli.md index 832f4c671..43ba954c7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -7,6 +7,8 @@ You can: * Create and delete `databases` * Create, update and delete `schemas` * Manage `subscriptions` +* Inspect your `aggregates`, `events` and `subscribers` +* Migrate events from one `store` to another ## Database commands @@ -53,6 +55,86 @@ The inspector is a tool to inspect the event streams. * ShowAggregateCommand: `event-sourcing:show-aggregate` * WatchCommand: `event-sourcing:watch` +## Debug command + +The debug command prints everything the library knows about your application: +all registered aggregates, all registered events and all subscribers with their subscribe methods. +It is the fastest way to check if a class was picked up by the attribute scanning. + +* DebugCommand: `event-sourcing:debug` (alias `debug:event-sourcing`) + +```php +use Patchlevel\EventSourcing\Console\Command\DebugCommand; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; +use Symfony\Component\Console\Application; + +/** + * @var Application $cli + * @var AggregateRootRegistry $aggregateRootRegistry + * @var EventRegistry $eventRegistry + * @var SubscriberAccessorRepository $subscriberAccessorRepository + */ +$cli->add( + new DebugCommand( + $aggregateRootRegistry, + $eventRegistry, + $subscriberAccessorRepository, + ), +); +``` +:::note +The subscriber repository is optional. If you don't pass it, the subscriber section is skipped. +::: + +## Store migration command + +The store migration command copies all events from one store into another one. +You need it when you switch the store implementation, +for example from the `DoctrineDbalStore` to the [StreamDoctrineDbalStore](store.md#streamdoctrinedbalstore). + +* StoreMigrateCommand: `event-sourcing:store:migrate` + +```php +use Patchlevel\EventSourcing\Console\Command\StoreMigrateCommand; +use Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator; +use Patchlevel\EventSourcing\Store\Store; +use Symfony\Component\Console\Application; + +/** + * @var Application $cli + * @var Store $oldStore + * @var Store $newStore + */ +$cli->add( + new StoreMigrateCommand( + $oldStore, + $newStore, + [new AggregateToStreamHeaderTranslator()], + ), +); +``` +The third constructor argument is a list of [translators](message.md#translator) +that are applied to every message before it is written into the new store. +The `AggregateToStreamHeaderTranslator` converts the `AggregateHeader` into the stream based headers +and is what you need for a migration to the `StreamDoctrineDbalStore`. + +Events are written in batches. You can control the batch size with the `buffer` option: + +```bash +bin/console event-sourcing:store:migrate --buffer=5000 +``` +:::danger +The command writes into the target store, it does not clean it up first. +Make sure the target store is empty and create a backup before you run the migration. +::: + +:::note +The schema of the new store has to exist before you run the command. +You can create it with the [schema commands](#schema-commands). +::: + ## CLI example A cli php file can look like this: diff --git a/docs/events.md b/docs/events.md index 6979adbeb..49784c9fa 100644 --- a/docs/events.md +++ b/docs/events.md @@ -82,6 +82,68 @@ The serializer needs the path information where the event classes are located so that it can instantiate the correct classes. Internally, an EventRegistry is used, which will be described later. +## Encoder + +The serializer turns an event into an array first and then encodes that array into a string. +The encoding is done by an `Encoder`. By default the `JsonEncoder` is used, which encodes the payload as JSON. + +If you want to change how the payload is encoded, you can pass your own encoder to the serializer. + +```php +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; + +$serializer = new DefaultEventSerializer( + (new AttributeEventRegistryFactory())->create(['src/Domain']), + encoder: new JsonEncoder(), +); +``` +The `Encoder` interface has two methods: + +```php +use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; + +final class MyEncoder implements Encoder +{ + /** + * @param array $data + * @param array $options + */ + public function encode(array $data, array $options = []): string + { + // your encoding + } + + /** + * @param array $options + * + * @return array + */ + public function decode(string $data, array $options = []): array + { + // your decoding + } +} +``` +An `encode` call that fails must throw an `EncodeNotPossible` exception, +a failing `decode` call a `DecodeNotPossible` exception. + +The options are passed through from the serializer. The `JsonEncoder` understands +`Encoder::OPTION_PRETTY_PRINT`, which is used by the [cli](cli.md) to print readable payloads. + +```php +use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; +use Patchlevel\EventSourcing\Serializer\EventSerializer; + +/** @var EventSerializer $serializer */ +$data = $serializer->serialize($event, [Encoder::OPTION_PRETTY_PRINT => true]); +``` +:::warning +The encoder decides the format of everything that is already in your store. +If you change it, old events can no longer be decoded. +::: + ## Normalizer Sometimes you also want to add more complex data as a payload. For example DateTime or value objects. @@ -140,6 +202,11 @@ use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; $eventRegistry = (new AttributeEventRegistryFactory())->create([/* paths... */]); ``` +:::tip +Scanning the paths on every request costs time. In production you can wrap the factory +in a [metadata cache](metadata-cache.md). +::: + ## Learn more * [How to normalize events](normalizer.md) @@ -147,3 +214,4 @@ $eventRegistry = (new AttributeEventRegistryFactory())->create([/* paths... */]) * [How to store events](store.md) * [How to upcast events](upcasting.md) * [How to use messages](message.md) +* [How to cache metadata](metadata-cache.md) diff --git a/docs/message.md b/docs/message.md index 71338672c..fbd0f5324 100644 --- a/docs/message.md +++ b/docs/message.md @@ -50,11 +50,53 @@ $message->headers(); // [AggregateHeader object] ## Built-in headers The message object has some built-in headers which are used internally. +Which of them you get depends on the [store](store.md) you use. -* `AggregateHeader` - Contains the aggregate name, aggregate id, playhead and recorded on. +These headers are set by every store: + +* `IndexHeader` - The global position of the message in the store. * `ArchivedHeader` - Flag if the message is archived. + +The `DoctrineDbalStore` is aggregate based and adds: + +* `AggregateHeader` - Contains the aggregate name, aggregate id, playhead and recorded on. * `StreamStartHeader` - Flag if the message is the first message in a new stream. +The `StreamDoctrineDbalStore` is stream based and splits the same information into single headers: + +* `StreamNameHeader` - The name of the stream, for example `profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e`. +* `PlayheadHeader` - The position of the message inside its stream. +* `RecordedOnHeader` - The point in time when the message was saved. +* `EventIdHeader` - The unique id of the event. + +```php +use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\Header\EventIdHeader; +use Patchlevel\EventSourcing\Store\Header\IndexHeader; +use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; +use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; + +/** @var Message $message */ +$message->header(IndexHeader::class)->index; // 42 +$message->header(StreamNameHeader::class)->streamName; // 'profile-e3e3e3e3-...' +$message->header(PlayheadHeader::class)->playhead; // 2 +$message->header(RecordedOnHeader::class)->recordedOn; // DateTimeImmutable +$message->header(EventIdHeader::class)->eventId; // 'a4a4a4a4-4a4a-...' +``` +:::warning +The `PlayheadHeader` is only added if the stream is playhead based. +Streams that are written without a playhead, for example custom streams, +do not have this header. Use `hasHeader` before you access it. +::: + +:::note +The `AggregateHeader` and the stream based headers never appear on the same message. +If you migrate from the `DoctrineDbalStore` to the `StreamDoctrineDbalStore`, you can convert +them with the `AggregateToStreamHeaderTranslator`, see the +[store migration command](cli.md#store-migration-command). +::: + ## Custom headers You can also add custom headers to the message object. For example, you can add an application id. diff --git a/docs/metadata-cache.md b/docs/metadata-cache.md new file mode 100644 index 000000000..cd77bcbff --- /dev/null +++ b/docs/metadata-cache.md @@ -0,0 +1,185 @@ +# Metadata Cache + +The library reads a lot of information from attributes: which classes are aggregates and events, +which apply method belongs to which event, which subscriber listens to what. +This information is called metadata, and collecting it means scanning directories +and reflecting over classes. + +In development that is exactly what you want, because every change is picked up immediately. +In production the classes never change while the process runs, so the same work is repeated on every request. +For this the library ships decorators that cache the metadata in a +[PSR-6](https://www.php-fig.org/psr/psr-6/) or [PSR-16](https://www.php-fig.org/psr/psr-16/) cache. + +:::note +The library only provides the decorators, not the cache implementation itself. +You can use [symfony cache](https://symfony.com/doc/current/components/cache.html) or any other +PSR-6 or PSR-16 compatible cache. +::: + +## Registry cache + +Registries are the hashmaps between names and classes. +Building them means scanning all configured paths, which is the most expensive part of the metadata handling. + +The `AggregateRootRegistryFactory` and the `EventRegistryFactory` both have a caching decorator. +You wrap the attribute based factory and pass a cache to it. + +```php +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegistryFactory; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\Psr6AggregateRootRegistryFactory; +use Psr\Cache\CacheItemPoolInterface; + +/** @var CacheItemPoolInterface $cache */ +$aggregateRegistryFactory = new Psr6AggregateRootRegistryFactory( + new AttributeAggregateRootRegistryFactory(), + $cache, +); + +$aggregateRegistry = $aggregateRegistryFactory->create(['src/Domain']); +``` +```php +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; +use Patchlevel\EventSourcing\Metadata\Event\Psr6EventRegistryFactory; +use Psr\Cache\CacheItemPoolInterface; + +/** @var CacheItemPoolInterface $cache */ +$eventRegistryFactory = new Psr6EventRegistryFactory( + new AttributeEventRegistryFactory(), + $cache, +); + +$eventRegistry = $eventRegistryFactory->create(['src/Domain']); +``` +If you use a PSR-16 cache, take the `Psr16` variants instead. They work the same way. + +```php +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; +use Patchlevel\EventSourcing\Metadata\Event\Psr16EventRegistryFactory; +use Psr\SimpleCache\CacheInterface; + +/** @var CacheInterface $cache */ +$eventRegistryFactory = new Psr16EventRegistryFactory( + new AttributeEventRegistryFactory(), + $cache, +); +``` +:::warning +The registry factories cache under a fixed key, `aggregate_root_registry` and `event_registry`. +The paths you pass to `create` are not part of that key. +If you call the same factory with different paths, you get the result of the first call back. +Use a separate factory instance with its own cache for each set of paths. +::: + +## Metadata cache + +Next to the registries, there is the metadata of a single class. +These factories are called with a class name and return the metadata for it, +so they cache per class name. + +```php +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootMetadataFactory; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\Psr6AggregateRootMetadataFactory; +use Psr\Cache\CacheItemPoolInterface; + +/** @var CacheItemPoolInterface $cache */ +$aggregateMetadataFactory = new Psr6AggregateRootMetadataFactory( + new AttributeAggregateRootMetadataFactory(), + $cache, +); +``` +```php +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; +use Patchlevel\EventSourcing\Metadata\Event\Psr6EventMetadataFactory; +use Psr\Cache\CacheItemPoolInterface; + +/** @var CacheItemPoolInterface $cache */ +$eventMetadataFactory = new Psr6EventMetadataFactory( + new AttributeEventMetadataFactory(), + $cache, +); +``` +```php +use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; +use Patchlevel\EventSourcing\Metadata\Subscriber\Psr6SubscriberMetadataFactory; +use Psr\Cache\CacheItemPoolInterface; + +/** @var CacheItemPoolInterface $cache */ +$subscriberMetadataFactory = new Psr6SubscriberMetadataFactory( + new AttributeSubscriberMetadataFactory(), + $cache, +); +``` +For all three there is a `Psr16` variant that takes a PSR-16 cache instead. + +:::note +The attribute based metadata factories already keep an in memory cache for the current process. +The PSR decorators add a cache that survives the process. +::: + +## Usage + +The cached factories are drop-in replacements, so you pass them wherever the library asks +for a factory or a registry. + +The registries go into the [repository manager](repository.md) and the serializer, +the aggregate metadata factory is the seventh argument of the `DefaultRepositoryManager`. + +```php +use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; +use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Store\Store; + +/** @var Store $store */ +$repositoryManager = new DefaultRepositoryManager( + $aggregateRegistryFactory->create(['src/Domain']), + $store, + null, + null, + new SplitStreamDecorator($eventMetadataFactory), + null, + $aggregateMetadataFactory, +); + +$serializer = new DefaultEventSerializer( + $eventRegistryFactory->create(['src/Domain']), +); +``` +The subscriber metadata factory goes into the `MetadataSubscriberAccessorRepository`. + +```php +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; + +$subscriberRepository = new MetadataSubscriberAccessorRepository( + [/* subscribers... */], + $subscriberMetadataFactory, +); +``` +:::note +Without an explicit factory, the `DefaultRepositoryManager` asks the aggregate class itself +for its metadata. Pass the cached factory as shown above if you want the repository manager to use it. +::: + +## Deployment + +The cache is keyed by class name and by a fixed registry key, not by the content of your classes. +Nothing invalidates it when you change an attribute. + +:::danger +You have to clear the cache on every deployment. +A stale registry makes the library load the wrong class for an event name, +a stale metadata entry makes it call the wrong apply method. +::: + +:::tip +Use a separate cache pool for the metadata and clear it as part of your deployment, +in the same step where you warm up your other caches. +Do not enable the cache in development, otherwise new events and subscribers are not picked up. +::: + +## Learn more + +* [How to register aggregates](aggregate.md) +* [How to register events](events.md) +* [How to create subscribers](subscription.md) +* [How to store events](store.md) diff --git a/docs/project.json b/docs/project.json index f6cc27897..bb4dd46c6 100644 --- a/docs/project.json +++ b/docs/project.json @@ -84,6 +84,10 @@ "title": "Time / Clock", "file": "clock.md" }, + { + "title": "Metadata Cache", + "file": "metadata-cache.md" + }, { "title": "Testing", "file": "testing.md" diff --git a/docs/repository.md b/docs/repository.md index 79deb558b..734abce63 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -176,7 +176,7 @@ An `AggregateOutdated` exception is thrown if a conflict occurs. ::: :::tip -If you use the Command Bus, you can use the [RetryOutdatedAggregateCommandBus](command-bus.md#retry-outdated-aggregate-command-bus) +If you use the Command Bus, you can use the [InstantRetryCommandBus](command-bus.md#instant-retry) to retry the command when an `AggregateOutdated` exception occurs automatically. ::: diff --git a/docs/store.md b/docs/store.md index da6f37a58..e59a6eced 100644 --- a/docs/store.md +++ b/docs/store.md @@ -340,12 +340,14 @@ use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; $criteria = new Criteria( new AggregateNameCriterion('profile'), new AggregateIdCriterion('e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), new FromPlayheadCriterion(2), new FromIndexCriterion(100), + new ToIndexCriterion(200), new ArchivedCriterion(true), new EventsCriterion(['profile.created', 'profile.name_changed']), ); @@ -364,6 +366,56 @@ $criteria = (new CriteriaBuilder()) ->events(['profile.created', 'profile.name_changed']) ->build(); ``` +#### Criteria for Stream Stores + +The `StreamDoctrineDbalStore` does not know about aggregates, it works with stream names. +Instead of the aggregate criteria you use the `StreamCriterion`, which also supports a `*` wildcard. +On top of that it offers the `ToPlayheadCriterion` and the `EventIdCriterion`. + +```php +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\EventIdCriterion; +use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Criteria\ToPlayheadCriterion; + +$criteria = new Criteria( + new StreamCriterion('profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), + new FromPlayheadCriterion(2), + new ToPlayheadCriterion(10), +); + +$criteria = new Criteria( + new EventIdCriterion('a4a4a4a4-4a4a-4a4a-4a4a-4a4a4a4a4a4a'), +); +``` +The `StreamCriterion` is variadic, so you can pass multiple stream names. +There is also a `startWith` named constructor that appends the wildcard for you. + +```php +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; + +$criterion = new StreamCriterion('profile-*', 'hotel-*'); +$criterion = StreamCriterion::startWith('profile-'); +``` +The criteria builder covers this as well: + +```php +use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; + +$criteria = (new CriteriaBuilder()) + ->streamName('profile-*') + ->fromPlayhead(2) + ->toPlayhead(10) + ->build(); +``` +:::warning +Not every store supports every criterion. If a store gets a criterion it cannot handle, +it throws an `UnsupportedCriterion` exception. `StreamCriterion`, `ToPlayheadCriterion` and +`EventIdCriterion` only work with the `StreamDoctrineDbalStore`, +`AggregateNameCriterion` and `AggregateIdCriterion` only with the `DoctrineDbalStore`. +::: + #### Stream The load method returns a `Stream` object and is a generator. @@ -449,18 +501,66 @@ In event sourcing, the events are immutable. ### Remove -You can remove a stream with the `remove` method. +You can remove events with the `remove` method. It takes the same criteria as the `load` method. ```php +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\StreamStore; /** @var StreamStore $store */ -$store->remove('profile-*'); +$store->remove(new Criteria(StreamCriterion::startWith('profile-'))); ``` +:::danger +Without criteria the method removes every event in the store. +Deleted events cannot be restored, all subscriptions built from them become inconsistent. +::: + :::note The method is only available in the `StreamStore` like `StreamDoctrineDbalStore`. ::: +### Archive + +You can archive events with the `archive` method. +Archived events are still in the store, but they are skipped when an aggregate is loaded, +which keeps the loading of long living aggregates fast. + +```php +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Criteria\ToPlayheadCriterion; +use Patchlevel\EventSourcing\Store\StreamStore; + +/** @var StreamStore $store */ +$store->archive( + new Criteria( + new StreamCriterion('profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), + new ToPlayheadCriterion(100), + ), +); +``` +Archived events get the `ArchivedHeader` when they are loaded again. +You can include or exclude them explicitly with the `ArchivedCriterion`. + +```php +use Patchlevel\EventSourcing\Store\Criteria\ArchivedCriterion; +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Store; + +/** @var Store $store */ +$stream = $store->load(new Criteria(new ArchivedCriterion(false))); +``` +:::note +The method is only available in the `StreamStore` like `StreamDoctrineDbalStore`. +The `DoctrineDbalStore` archives events automatically when a [split stream](split-stream.md) event is saved. +::: + +:::tip +Archiving is the non destructive alternative to `remove`. The events stay readable, +so you can still replay them by passing an `ArchivedCriterion(true)`. +::: + ### List Streams You can list all streams with the `streams` method. diff --git a/docs/subscription.md b/docs/subscription.md index 4fea2afbe..e52651e5c 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -988,6 +988,58 @@ use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; $retryStrategy = new NoRetryStrategy(); ``` +#### Custom Retry Strategy + +You can write your own strategy by implementing the `RetryStrategy` interface. +The `shouldRetry` method is asked on every run whether the errored subscription should be picked up again. + +```php +use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; +use Patchlevel\EventSourcing\Subscription\Subscription; + +final class AlwaysRetryStrategy implements RetryStrategy +{ + public function shouldRetry(Subscription $subscription): bool + { + return true; + } +} +``` +A plain `RetryStrategy` can never mark a subscription as failed. +The subscription stays in the error status and is offered to `shouldRetry` again on every run. +To give up at some point, implement `ConditionalRetryStrategy` instead. +It adds a `canRetry` method that answers whether a retry is possible at all. +As soon as `canRetry` returns `false`, the subscription is set to failed and is skipped in all future runs. + +```php +use Patchlevel\EventSourcing\Subscription\RetryStrategy\ConditionalRetryStrategy; +use Patchlevel\EventSourcing\Subscription\Subscription; + +final class TenAttemptsRetryStrategy implements ConditionalRetryStrategy +{ + public function canRetry(Subscription $subscription): bool + { + return $subscription->retryAttempt() < 10; + } + + public function shouldRetry(Subscription $subscription): bool + { + return $this->canRetry($subscription); + } +} +``` +:::note +Both built-in strategies implement `ConditionalRetryStrategy`. +The `ClockBasedRetryStrategy` returns `false` in `canRetry` once `maxAttempts` is reached, +the `NoRetryStrategy` always returns `false`. +::: + +:::warning +`shouldRetry` is called on every run of the subscription engine, so keep it cheap. +Use `canRetry` for the "is there any point in trying again" decision and `shouldRetry` +for the "is it time to try again" decision. +::: + #### Retry Strategy Repository You can define multiple retry strategies and select them by name in the subscriber. From 5186a9e61e20722e7dfc797e482d83cb699283f5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:36:48 +0000 Subject: [PATCH 07/11] Update dependency league/commonmark to v2.9.0 [SECURITY] | datasource | package | from | to | | ---------- | ----------------- | ----- | ----- | | packagist | league/commonmark | 2.8.2 | 2.9.0 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.lock b/composer.lock index bed5fa748..23429ca17 100644 --- a/composer.lock +++ b/composer.lock @@ -3622,16 +3622,16 @@ }, { "name": "league/commonmark", - "version": "2.8.2", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", "shasum": "" }, "require": { @@ -3653,8 +3653,8 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", @@ -3668,7 +3668,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.10-dev" } }, "autoload": { @@ -3725,7 +3725,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-08-03T13:42:31+00:00" }, { "name": "league/config", From 4af9745dd7afb308fc33c88a2060a1223e1e6b00 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:26:31 +0000 Subject: [PATCH 08/11] Update postgres Docker tag to v18.6 | datasource | package | from | to | | ---------- | -------- | ---- | ---- | | docker | postgres | 18.4 | 18.6 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 23f877cf5..afb84d9ef 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -14,7 +14,7 @@ jobs: services: postgres: # Docker Hub image - image: "postgres:18.4" + image: "postgres:18.6" # Provide the password for postgres env: POSTGRES_PASSWORD: postgres From 928752ea41cfd2d106397f62a641a1e58200b36e Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 26 Aug 2026 09:16:25 +0200 Subject: [PATCH 09/11] export docs directory Remove docs directory from export-ignore list in .gitattributes. --- .gitattributes | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index d2f9d2cb0..7bdfb803e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,7 +5,6 @@ /.gitignore export-ignore /composer.lock export-ignore /demo/ export-ignore -/docs/ export-ignore /phpbench.json export-ignore /phpcs.xml.dist export-ignore /phpstan.neon export-ignore From 1ce973605731ba8ff686e9862971f17fba47baed Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 7 Sep 2026 17:35:11 +0200 Subject: [PATCH 10/11] docs cs --- docs/dynamic-consistency-boundary.md | 2 -- docs/events.md | 2 +- docs/message.md | 1 - docs/store.md | 6 +----- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index 7b750f3bd..0605d0695 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -46,7 +46,6 @@ final class HotelCreated } } ``` - :::note A tagged property that is `null` is skipped, so no tag is generated for it. This lets you tag optional properties without having to guard against missing values. ::: @@ -423,7 +422,6 @@ $provider = new ServiceHandlerProvider([ $commandBus = new SyncCommandBus($provider); ``` - :::tip For tests you can replace the `TaggableDoctrineDbalStore` with an `InMemoryStore`. It implements the same tag query and conditional append, so the decision model and the append condition behave the same diff --git a/docs/events.md b/docs/events.md index 1e8447b37..ef1fae60e 100644 --- a/docs/events.md +++ b/docs/events.md @@ -40,7 +40,7 @@ Here are some examples: * `profile.created` * `profile.name_changed` * `hotel.guest_checked_out` - + ::: ## Alias diff --git a/docs/message.md b/docs/message.md index f025ab6fe..a01e05af2 100644 --- a/docs/message.md +++ b/docs/message.md @@ -186,7 +186,6 @@ $serializer = DefaultHeadersSerializer::createFromPaths( ['*'], ); ``` - ## Stream A `Stream` wraps an iterable of messages and allows you to chain multiple translators with `transform`. diff --git a/docs/store.md b/docs/store.md index 54700f848..af0d25f31 100644 --- a/docs/store.md +++ b/docs/store.md @@ -334,8 +334,8 @@ use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; -use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; $criteria = new Criteria( new StreamCriterion('profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), @@ -346,7 +346,6 @@ $criteria = new Criteria( new EventsCriterion(['profile.created', 'profile.name_changed']), ); ``` - The `StreamCriterion` is variadic, so you can pass multiple stream names. There is also a `startWith` named constructor that appends the wildcard for you. @@ -356,7 +355,6 @@ use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; $criterion = new StreamCriterion('profile-*', 'hotel-*'); $criterion = StreamCriterion::startWith('profile-'); ``` - Or you can the criteria builder to create the criteria. ```php @@ -370,7 +368,6 @@ $criteria = (new CriteriaBuilder()) ->events(['profile.created', 'profile.name_changed']) ->build(); ``` - :::tip A stream name has the format `[aggregateName]-[aggregateId]`. To match every stream of an aggregate, use a wildcard with `StreamCriterion::startWith('profile-')`. @@ -463,7 +460,6 @@ In event sourcing, the events are immutable. You can remove events with the `remove` method. It takes the same criteria as the `load` method. - ```php use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; From 839b125c3a9b4dad5a8de4c09837e550bcaf58b8 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 7 Sep 2026 17:44:51 +0200 Subject: [PATCH 11/11] Remove outdated claims from docs --- docs/cli.md | 12 +++++++----- docs/message.md | 21 --------------------- docs/store.md | 8 ++++---- docs/subscription.md | 2 +- 4 files changed, 12 insertions(+), 31 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 5dd8efb74..277a73802 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -92,13 +92,14 @@ The subscriber repository is optional. If you don't pass it, the subscriber sect The store migration command copies all events from one store into another one. You need it when you switch the store implementation, -for example from the `DoctrineDbalStore` to the [StreamDoctrineDbalStore](store.md#streamdoctrinedbalstore). +for example from the [StreamDoctrineDbalStore](store.md#streamdoctrinedbalstore) +to the [TaggableDoctrineDbalStore](store.md#taggabledoctrinedbalstore). * StoreMigrateCommand: `event-sourcing:store:migrate` ```php use Patchlevel\EventSourcing\Console\Command\StoreMigrateCommand; -use Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator; +use Patchlevel\EventSourcing\Message\Translator\ExtractEventTagTranslator; use Patchlevel\EventSourcing\Store\Store; use Symfony\Component\Console\Application; @@ -111,14 +112,15 @@ $cli->add( new StoreMigrateCommand( $oldStore, $newStore, - [new AggregateToStreamHeaderTranslator()], + [new ExtractEventTagTranslator()], ), ); ``` The third constructor argument is a list of [translators](message.md#translator) that are applied to every message before it is written into the new store. -The `AggregateToStreamHeaderTranslator` converts the `AggregateHeader` into the stream based headers -and is what you need for a migration to the `StreamDoctrineDbalStore`. +The `ExtractEventTagTranslator` reads the tags from the events and adds them as `TagsHeader`, +which is what you need for a migration to the `TaggableDoctrineDbalStore`. +If both stores work with the same headers, you can leave the list empty. Events are written in batches. You can control the batch size with the `buffer` option: diff --git a/docs/message.md b/docs/message.md index a01e05af2..54b631cd2 100644 --- a/docs/message.md +++ b/docs/message.md @@ -49,9 +49,6 @@ $message->headers(); // [StreamNameHeader object, PlayheadHeader object, ...] ## Built-in headers The message object has some built-in headers which are used internally. -Which of them you get depends on the [store](store.md) you use. - -These headers are set by every store: * `StreamNameHeader` - The name of the stream the message belongs to, in the format `[aggregateName]-[aggregateId]`. * `PlayheadHeader` - The position of the message within its stream. @@ -60,19 +57,8 @@ These headers are set by every store: * `IndexHeader` - The global position of the message in the store. * `TagsHeader` - The tags attached to the message (experimental). * `ArchivedHeader` - Flag if the message is archived. - -The `DoctrineDbalStore` is aggregate based and adds: - -* `AggregateHeader` - Contains the aggregate name, aggregate id, playhead and recorded on. * `StreamStartHeader` - Flag if the message is the first message in a new stream. -The `StreamDoctrineDbalStore` is stream based and splits the same information into single headers: - -* `StreamNameHeader` - The name of the stream, for example `profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e`. -* `PlayheadHeader` - The position of the message inside its stream. -* `RecordedOnHeader` - The point in time when the message was saved. -* `EventIdHeader` - The unique id of the event. - ```php use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\Header\EventIdHeader; @@ -94,13 +80,6 @@ Streams that are written without a playhead, for example custom streams, do not have this header. Use `hasHeader` before you access it. ::: -:::note -The `AggregateHeader` and the stream based headers never appear on the same message. -If you migrate from the `DoctrineDbalStore` to the `StreamDoctrineDbalStore`, you can convert -them with the `AggregateToStreamHeaderTranslator`, see the -[store migration command](cli.md#store-migration-command). -::: - ## Custom headers You can also add custom headers to the message object. For example, you can add an application id. diff --git a/docs/store.md b/docs/store.md index af0d25f31..859113410 100644 --- a/docs/store.md +++ b/docs/store.md @@ -463,9 +463,9 @@ You can remove events with the `remove` method. It takes the same criteria as th ```php use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; -use Patchlevel\EventSourcing\Store\StreamStore; +use Patchlevel\EventSourcing\Store\Store; -/** @var StreamStore $store */ +/** @var Store $store */ $store->remove(new Criteria(StreamCriterion::startWith('profile-'))); ``` :::danger @@ -483,9 +483,9 @@ which keeps the loading of long living aggregates fast. use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToPlayheadCriterion; -use Patchlevel\EventSourcing\Store\StreamStore; +use Patchlevel\EventSourcing\Store\Store; -/** @var StreamStore $store */ +/** @var Store $store */ $store->archive( new Criteria( new StreamCriterion('profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e'), diff --git a/docs/subscription.md b/docs/subscription.md index 2ca3c4a86..d2388bf9e 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -241,7 +241,7 @@ final class PublicProfileProjection public function onPublished(Lookup $lookup): void { $messages = $lookup - ->currentAggregate() // or ->currentStream() for StreamStore + ->currentStream() ->events( ProfileCreated::class, ProfileNameChanged::class,