From 944e5048c9d524e35ed7858426a5e6c3344a45dd Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:42:06 -0700 Subject: [PATCH 01/33] feat(eventsourcing): add the firefly/eventsourcing package and register it in every package list --- README.md | 3 +- composer.json | 5 + deptrac.yaml | 29 +++ docs/README.md | 5 +- docs/book.md | 2 +- docs/index.md | 6 +- docs/modules.md | 10 +- docs/modules/eventsourcing.md | 12 ++ docs/publishing.md | 2 +- mkdocs.yml | 1 + packages/eventsourcing/.gitattributes | 2 + packages/eventsourcing/LICENSE | 204 ++++++++++++++++++ packages/eventsourcing/README.md | 18 ++ .../firefly-eventsourcing-components.php | 8 + .../cache/firefly-eventsourcing-context.php | 8 + packages/eventsourcing/composer.json | 72 +++++++ .../src/EventSourcingServiceProvider.php | 26 +++ .../src/EventSourcingWiringProvider.php | 17 ++ .../tests/CompiledManifestFreshnessTest.php | 30 +++ .../eventsourcing/tests/PackageBootsTest.php | 17 ++ .../tests/ReflectionFreeEventSourcingTest.php | 32 +++ packages/firefly/composer.json | 1 + packages/installer/src/CapabilityCatalog.php | 1 + 23 files changed, 498 insertions(+), 13 deletions(-) create mode 100644 docs/modules/eventsourcing.md create mode 100644 packages/eventsourcing/.gitattributes create mode 100644 packages/eventsourcing/LICENSE create mode 100644 packages/eventsourcing/README.md create mode 100644 packages/eventsourcing/cache/firefly-eventsourcing-components.php create mode 100644 packages/eventsourcing/cache/firefly-eventsourcing-context.php create mode 100644 packages/eventsourcing/composer.json create mode 100644 packages/eventsourcing/src/EventSourcingServiceProvider.php create mode 100644 packages/eventsourcing/src/EventSourcingWiringProvider.php create mode 100644 packages/eventsourcing/tests/CompiledManifestFreshnessTest.php create mode 100644 packages/eventsourcing/tests/PackageBootsTest.php create mode 100644 packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php diff --git a/README.md b/README.md index 29675516..40094bec 100644 --- a/README.md +++ b/README.md @@ -1072,7 +1072,7 @@ Full flag reference and generated-file contents: [CLI](docs/cli.md). One published library, `fireflyframework/larafly`, contains the components under `packages/*` and the bundled application skeleton. Its `replace` entries satisfy component requirements at the framework's version; -component names remain useful module boundaries, with their own test suites. The 38 +component names remain useful module boundaries, with their own test suites. The 39 [module guides](docs/modules/) below group them by concern: | Group | Module | Package(s) | @@ -1100,6 +1100,7 @@ component names remain useful module boundaries, with their own test suites. The | Eventing & Messaging | [Webhooks](docs/modules/webhooks.md) — inbound webhooks: an endpoint per provider, HMAC/Stripe/GitHub/Twilio signatures, a replay window, idempotency, published to EDA | `firefly/webhooks` | | Eventing & Messaging | [Callbacks](docs/modules/callbacks.md) — outbound callbacks: subscriptions, signed deliveries driven by events, retries, a circuit breaker per endpoint, a replayable execution log | `firefly/callbacks` | | Eventing & Messaging | [Orchestration](docs/modules/orchestration.md) — sagas, TCC, durable workflows, recovery, DLQ | `firefly/orchestration` | +| Eventing & Messaging | [Event Sourcing](docs/modules/eventsourcing.md) — event store, aggregates, snapshots, upcasters, projections, outbox publishing | `firefly/eventsourcing` | | CQRS | [CQRS](docs/modules/cqrs.md) — `CommandBus`/`QueryBus`, the domain→integration-event bridge | `firefly/cqrs` | | Security | [Security](docs/modules/security.md) — principal model, `HttpSecurity`, `#[PreAuthorize]`, JWT/OAuth2 | `firefly/security` | | Security | [OAuth2 Client](docs/modules/security-oauth2-client.md) — OpenID Connect login with presets and discovery, PKCE, id-token validation, `OidcUser`, RP-initiated logout, client credentials, `Http::oauth2Client()` | `firefly/security-oauth2-client` | diff --git a/composer.json b/composer.json index ae5fc957..c408b6da 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ "Firefly\\Eda\\Postgres\\Tests\\": "packages/eda-postgres/tests/", "Firefly\\Eda\\Rabbitmq\\Tests\\": "packages/eda-rabbitmq/tests/", "Firefly\\Eda\\Tests\\": "packages/eda/tests/", + "Firefly\\EventSourcing\\Tests\\": "packages/eventsourcing/tests/", "Firefly\\FeatureFlags\\Tests\\": "packages/feature-flags/tests/", "Firefly\\Firefly\\Tests\\": "packages/firefly/tests/", "Firefly\\Installer\\Tests\\": "packages/installer/tests/", @@ -142,6 +143,7 @@ "firefly/eda-kafka": "self.version", "firefly/eda-postgres": "self.version", "firefly/eda-rabbitmq": "self.version", + "firefly/eventsourcing": "self.version", "firefly/feature-flags": "self.version", "firefly/firefly": "self.version", "firefly/installer": "self.version", @@ -180,6 +182,7 @@ "Firefly\\Eda\\Kafka\\": "packages/eda-kafka/src/", "Firefly\\Eda\\Postgres\\": "packages/eda-postgres/src/", "Firefly\\Eda\\Rabbitmq\\": "packages/eda-rabbitmq/src/", + "Firefly\\EventSourcing\\": "packages/eventsourcing/src/", "Firefly\\FeatureFlags\\": "packages/feature-flags/src/", "Firefly\\Installer\\": "packages/installer/src/", "Firefly\\Kernel\\": "packages/kernel/src/", @@ -287,6 +290,8 @@ "Firefly\\Callbacks\\CallbacksWiringProvider", "Firefly\\Orchestration\\OrchestrationServiceProvider", "Firefly\\Orchestration\\OrchestrationWiringProvider", + "Firefly\\EventSourcing\\EventSourcingServiceProvider", + "Firefly\\EventSourcing\\EventSourcingWiringProvider", "Firefly\\Cli\\Boot\\FireflyCacheServiceProvider" ] } diff --git a/deptrac.yaml b/deptrac.yaml index 17fbd3c8..eb9b8a2c 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -122,6 +122,10 @@ deptrac: collectors: - type: directory value: packages/orchestration/src/.* + - name: EventSourcing + collectors: + - type: directory + value: packages/eventsourcing/src/.* - name: OpenApi collectors: - type: directory @@ -609,6 +613,31 @@ deptrac: - Eda - Actuator + # EventSourcing is the event-store capability. It reads Config, carries Container/Context attributes and boot + # wiring, extends AutoConfigure's base and reuses Kernel exceptions. It builds its aggregates on Domain's + # AggregateRoot/DomainEvent (=> Domain) and translates a duplicate-key race through Data's + # PersistenceExceptionTranslator, choosing READPAST through Data's DatabaseCapabilities (=> Data). It + # serializes envelopes with Eda's Serializer and publishes through Eda's EventPublisher (=> Eda), recognising + # the outbox writer and its connection from EdaDatabase (=> EdaDatabase, OutboxWriter + DatabaseOutboxSettings + # only). It leases projections on Scheduling's DistributedLock (=> Scheduling), parses durations with + # Resilience's Duration (=> Resilience), supplies an Actuator HealthIndicator (=> Actuator) and supplies + # Orchestration's ExecutionPersistenceProvider port as `event-sourced` (=> Orchestration; Orchestration never + # depends on it). Cli depends on it (firefly:cache compiles its manifest); nothing else does. + EventSourcing: + - Kernel + - Container + - Config + - Context + - AutoConfigure + - Domain + - Data + - Eda + - EdaDatabase + - Scheduling + - Resilience + - Actuator + - Orchestration + # OpenApi is a top-of-stack capability (like Web/Cqrs/Security/Actuator): it generates an OpenAPI 3.1 # document from manifests that already exist, so it is nearly all READS. It reads Web's RouteManifest/ # RouteDescriptor (paths, verbs, statuses, binding plans) and Validation's ConstraintManifest + Rule diff --git a/docs/README.md b/docs/README.md index 56862136..319fbb5b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ ## Module Guides Every module guide lives under [`modules/`](modules/), in the ten groups `mkdocs.yml`'s navigation and the -site's own [Modules](modules.md) landing page use. All 38 are listed here, and neither the list nor the +site's own [Modules](modules.md) landing page use. All 39 are listed here, and neither the list nor the number is maintained on trust: `tests/ModuleDocumentationTest.php` fails the build if a guide is missing from the navigation, from the table below, from the root `README.md`'s table, from `docs/index.md` or from the landing page, and `tests/SiteNavigationTest.php` reads that number off `docs/modules/*.md` itself, so a @@ -81,6 +81,7 @@ thirty-ninth guide turns this page red rather than quietly making it wrong. | [Webhooks](modules/webhooks.md) | `firefly/webhooks` — an endpoint per provider, HMAC/Stripe/GitHub/Twilio signatures, replay window, idempotency, published to the event bus | | [Callbacks](modules/callbacks.md) | `firefly/callbacks` — subscriptions, signed deliveries driven by events, retries, a circuit breaker per endpoint, a replayable log | | [Orchestration](modules/orchestration.md) | `firefly/orchestration` — sagas, TCC and durable workflows with checkpoints, recovery and a dead-letter queue | +| [Event Sourcing](modules/eventsourcing.md) | `firefly/eventsourcing` — an event store with a commit-ordered global stream, snapshots, upcasters and leased projections | ### CQRS @@ -128,7 +129,7 @@ thirty-ninth guide turns this page red rather than quietly making it wrong. | Document | Description | |----------|-------------| -| [Modules](modules.md) | The grouped index of all 38 module guides, and how a package wires itself | +| [Modules](modules.md) | The grouped index of all 39 module guides, and how a package wires itself | | [CLI Reference](cli.md) | Every `firefly:*` command and `make:firefly-*` generator, including the seventeen contributed by capability packages | | [Database Support Matrix](database-support.md) | What each database-backed capability does on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite, and which CI lane proves it | | [Laravel Comparison](laravel-comparison.md) | Side-by-side concept mapping for developers coming from plain Laravel | diff --git a/docs/book.md b/docs/book.md index 7af127ed..eec4047e 100644 --- a/docs/book.md +++ b/docs/book.md @@ -27,7 +27,7 @@ composer global require fireflyframework/larafly firefly new my-app ``` -All 35 components are included in that package. An existing application starts with +All 36 components are included in that package. An existing application starts with `composer require fireflyframework/larafly`; compatible legacy `firefly/*` requirements are then satisfied by the framework's `replace` declarations. See [Installation](installation.md) for details. diff --git a/docs/index.md b/docs/index.md index 3d555893..55a6c7c8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ CQRS, event-driven architecture, first-party security including both halves of O directly onto Laravel's own runtime. Nothing is forked or wrapped: a LaraFly app is, in every respect a Laravel developer would recognize, still a Laravel app — it just boots like a Spring Boot one. -The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 35 components, +The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 36 components, wired together by a single zero-reflection boot pipeline: a component scan compiles to a cached manifest once, and every request after that runs against plain PHP arrays — no runtime reflection on the hot path. @@ -103,7 +103,7 @@ See [Installation](installation.md) for requirements and manual setup, and ## The modules -Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 38 +Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 39 guides out by concern, with a line on each saying what it is for, and the same grouping is the site's **Modules** tab. @@ -113,7 +113,7 @@ guides out by concern, with a line on each saying what it is for, and the same g | **Web & API** | [Web Layer](modules/web.md) · [Web Filters](modules/web-filters.md) · [OpenAPI](modules/openapi.md) | | **Resilience & Scheduling** | [Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) · [Scheduling Database Locks](modules/scheduling-database.md) | | **Data & Domain** | [Domain (DDD)](modules/domain.md) · [Data & Repositories](modules/data.md) · [Relational Data](modules/data-relational.md) · [Transactions](modules/transactional.md) | -| **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) · [Webhooks](modules/webhooks.md) · [Callbacks](modules/callbacks.md) · [Orchestration](modules/orchestration.md) | +| **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) · [Webhooks](modules/webhooks.md) · [Callbacks](modules/callbacks.md) · [Orchestration](modules/orchestration.md) · [Event Sourcing](modules/eventsourcing.md) | | **CQRS** | [Command/Query](modules/cqrs.md) | | **Security** | [Security](modules/security.md) · [OAuth2 Client](modules/security-oauth2-client.md) · [OAuth2 Authorization Server](modules/security-oauth2-server.md) | | **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Tracing](modules/tracing.md) · [Logging](modules/logging.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) · [Feature Flags](modules/feature-flags.md) | diff --git a/docs/modules.md b/docs/modules.md index d02712d6..908e088f 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -1,15 +1,15 @@ # Modules -LaraFly publishes one `fireflyframework/larafly` library containing the **35 component directories** under -`packages/*`, each with its own tests or metadata. The **38 guides** below explain their public surfaces. +LaraFly publishes one `fireflyframework/larafly` library containing the **36 component directories** under +`packages/*`, each with its own tests or metadata. The **39 guides** below explain their public surfaces. Several components carry more than one guide: `firefly/data` answers for *Data & Repositories*, *Relational Data* and *Transactions*. Laravel discovers the root library's providers, and configuration selects optional features. A bean you declare wins over the framework default. Two idioms deliver that outcome, and -which one you are looking at matters the moment you go reading the source. **23 of the 35 packages** carry a +which one you are looking at matters the moment you go reading the source. **23 of the 36 packages** carry a `#[Configuration]` class whose `#[Bean]` methods sit behind `#[ConditionalOnMissingBean]`, so a bean of yours makes the framework's back off silently — `packages/data/src/DataAutoConfiguration.php` is the model. The -**other 12** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards +**other 13** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards each binding in `WebServiceProvider::register()` with a `bound()` check instead, first-one-wins by hand.
@@ -43,7 +43,7 @@ each binding in `WebServiceProvider::register()` with a `bound()` check instead,
Eventing & Messaging -[EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) · [Webhooks](modules/webhooks.md) · [Callbacks](modules/callbacks.md) · [Orchestration](modules/orchestration.md) +[EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) · [Webhooks](modules/webhooks.md) · [Callbacks](modules/callbacks.md) · [Orchestration](modules/orchestration.md) · [Event Sourcing](modules/eventsourcing.md) One `EventPublisher` port with in-memory, queue, RabbitMQ, database (any SQL server), Postgres and Kafka adapters, a genuine same-transaction outbox safe with concurrent consumers on every database, and the lower-level raw-bytes broker layer beneath it. Signed inbound webhooks and outbound callbacks ride on the same bus.
diff --git a/docs/modules/eventsourcing.md b/docs/modules/eventsourcing.md new file mode 100644 index 00000000..20cf5959 --- /dev/null +++ b/docs/modules/eventsourcing.md @@ -0,0 +1,12 @@ +# Event Sourcing + +`firefly/eventsourcing` stores what happened instead of what is. An aggregate's state is the replay of its +events. The events live in an `EventStore` (`memory` or `database`, `firefly.eventsourcing.store.provider`), +appended under optimistic concurrency and read back two ways: + +- as the aggregate's own stream; +- as one global stream in commit order, which projections follow with a checkpoint. + +Snapshots shorten replays, upcasters move old event versions forward on read, and appended events can be +published to `firefly/eda` in the same transaction through the database outbox. The package is shaped after +the Java `fireflyframework-eventsourcing` library and PyFly's `pyfly.eventsourcing`. diff --git a/docs/publishing.md b/docs/publishing.md index 3ef294ce..1f6faab7 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -7,7 +7,7 @@ cross-repository `ACCESS_TOKEN` are needed. ## Package identity and compatibility -The root package replaces all 35 component names, including `firefly/firefly`, with +The root package replaces all 36 component names, including `firefly/firefly`, with `self.version`. Packagist's `firefly` vendor namespace belongs to another publisher, so the public package uses the Firefly Framework organization's namespace. `firefly/lumen` is a sample, not a replacement; `firefly/skeleton` is a bundled project template, not a second published package. diff --git a/mkdocs.yml b/mkdocs.yml index 344fab1f..bdf8b037 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -146,6 +146,7 @@ nav: - Webhooks: modules/webhooks.md - Callbacks: modules/callbacks.md - Orchestration: modules/orchestration.md + - Event Sourcing: modules/eventsourcing.md - CQRS: - CQRS (Command/Query): modules/cqrs.md - Security: diff --git a/packages/eventsourcing/.gitattributes b/packages/eventsourcing/.gitattributes new file mode 100644 index 00000000..538b69a4 --- /dev/null +++ b/packages/eventsourcing/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.gitattributes export-ignore diff --git a/packages/eventsourcing/LICENSE b/packages/eventsourcing/LICENSE new file mode 100644 index 00000000..2240005d --- /dev/null +++ b/packages/eventsourcing/LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Copyright 2026 Firefly Software Solutions Inc. diff --git a/packages/eventsourcing/README.md b/packages/eventsourcing/README.md new file mode 100644 index 00000000..e443e89e --- /dev/null +++ b/packages/eventsourcing/README.md @@ -0,0 +1,18 @@ +# firefly/eventsourcing + +LaraFly's event sourcing, shaped after the Java `fireflyframework-eventsourcing` library and PyFly's +`pyfly.eventsourcing`. An `EventStore` port (`memory`, `database`) appends events under optimistic +concurrency and reads them back as per-aggregate streams and as one global stream in commit order, safe under +concurrent appenders on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite. On top of it: + +- event-sourced aggregates and a snapshot-aware repository; +- snapshot stores and upcasters; +- projections with checkpoints, a `DistributedLock` lease, a health component and + `php artisan firefly:eventsourcing:projections*`; +- same-transaction publishing through the eda-database outbox; +- an `event-sourced` orchestration execution store (`firefly.orchestration.persistence.provider=event-sourced`). + +Installing the package is inert until an application uses an event store. The guide is +[docs/modules/eventsourcing.md](../../docs/modules/eventsourcing.md). + +Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/eventsourcing/cache/firefly-eventsourcing-components.php b/packages/eventsourcing/cache/firefly-eventsourcing-components.php new file mode 100644 index 00000000..b2ca1444 --- /dev/null +++ b/packages/eventsourcing/cache/firefly-eventsourcing-components.php @@ -0,0 +1,8 @@ + dirname(__DIR__).'/src']; + $cache = dirname(__DIR__).'/cache'; + + $freshComponents = sys_get_temp_dir().'/firefly-eventsourcing-fresh-c-'.bin2hex(random_bytes(6)).'.php'; + $freshContext = sys_get_temp_dir().'/firefly-eventsourcing-fresh-x-'.bin2hex(random_bytes(6)).'.php'; + + try { + (new AutoConfigManifestCompiler)->write($src, $freshComponents, $freshContext); + + expect(ComponentManifest::load($freshComponents))->toEqual(ComponentManifest::load($cache.'/firefly-eventsourcing-components.php')) + ->and(ContextManifest::load($freshContext))->toEqual(ContextManifest::load($cache.'/firefly-eventsourcing-context.php')); + } finally { + @unlink($freshComponents); + @unlink($freshContext); + } +}); diff --git a/packages/eventsourcing/tests/PackageBootsTest.php b/packages/eventsourcing/tests/PackageBootsTest.php new file mode 100644 index 00000000..1546c622 --- /dev/null +++ b/packages/eventsourcing/tests/PackageBootsTest.php @@ -0,0 +1,17 @@ +make(ApplicationContext::class))->toBeInstanceOf(ApplicationContext::class); +}); diff --git a/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php b/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php new file mode 100644 index 00000000..75f868e5 --- /dev/null +++ b/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php @@ -0,0 +1,32 @@ + basenames under $dir whose contents reference boot-time reflection */ +function eventSourcingReflectionHits(string $dir): array +{ + $hits = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $contents = (string) file_get_contents((string) $file->getRealPath()); + if (preg_match('/ReflectionClass|ReflectionMethod|getAttributes/', $contents) === 1) { + $hits[] = $file->getBasename(); + } + } + sort($hits); + + return $hits; +} + +/** + * Production loads the compiled EventSourcingManifest by require; reflection is allowed in exactly one file, the + * scanner that compiles it (at firefly:cache time, or as the uncached development fallback). Until Task 8 adds that + * scanner there is none at all. + */ +it('confines reflection in firefly/eventsourcing to the scanner', function (): void { + expect(eventSourcingReflectionHits(__DIR__.'/../src'))->toBe([]); +}); diff --git a/packages/firefly/composer.json b/packages/firefly/composer.json index 20c2de84..95cee722 100644 --- a/packages/firefly/composer.json +++ b/packages/firefly/composer.json @@ -35,6 +35,7 @@ "firefly/domain": "*@dev", "firefly/eda": "*@dev", "firefly/eda-database": "*@dev", + "firefly/eventsourcing": "*@dev", "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", diff --git a/packages/installer/src/CapabilityCatalog.php b/packages/installer/src/CapabilityCatalog.php index c63655f0..12e79667 100644 --- a/packages/installer/src/CapabilityCatalog.php +++ b/packages/installer/src/CapabilityCatalog.php @@ -47,6 +47,7 @@ public static function all(): array new Capability('webhooks', 'firefly/webhooks', 'Inbound webhooks: an endpoint per provider, HMAC/Stripe/GitHub/Twilio signatures, a replay window and idempotency, published to the event bus', requires: ['eda']), new Capability('callbacks', 'firefly/callbacks', 'Outbound callbacks: subscriptions, HMAC-signed deliveries driven by events, retries with back-off, a circuit breaker per endpoint and a replayable execution log', requires: ['eda', 'resilience']), new Capability('orchestration', 'firefly/orchestration', 'Sagas, TCC and durable workflows: compensation policies, signals and timers, recovery, a dead-letter queue, EDA triggers', requires: ['eda', 'scheduling', 'resilience', 'actuator']), + new Capability('eventsourcing', 'firefly/eventsourcing', 'Event sourcing: an event store with optimistic concurrency and a commit-ordered global stream, aggregates, snapshots, upcasters, projections with leases, outbox publishing, event-sourced orchestration persistence', requires: ['domain', 'data', 'eda', 'orchestration']), new Capability('eda-kafka', 'firefly/eda-kafka', 'Kafka publisher/consumer over ext-rdkafka', requires: ['eda'], adapter: true), new Capability('eda-rabbitmq', 'firefly/eda-rabbitmq', 'RabbitMQ publisher/consumer over php-amqplib', requires: ['eda'], adapter: true), From d6727f2089f79f5994390f9eb3bc4e2965f87b24 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:47:35 -0700 Subject: [PATCH 02/33] feat(eventsourcing): read firefly.eventsourcing.* once and refuse what the package cannot honour --- .../src/EventSourcingSettings.php | 172 ++++++++++++++++++ .../tests/EventSourcingSettingsTest.php | 90 +++++++++ skeleton/config/firefly.php | 79 ++++++++ 3 files changed, 341 insertions(+) create mode 100644 packages/eventsourcing/src/EventSourcingSettings.php create mode 100644 packages/eventsourcing/tests/EventSourcingSettingsTest.php diff --git a/packages/eventsourcing/src/EventSourcingSettings.php b/packages/eventsourcing/src/EventSourcingSettings.php new file mode 100644 index 00000000..51ba71f3 --- /dev/null +++ b/packages/eventsourcing/src/EventSourcingSettings.php @@ -0,0 +1,172 @@ + */ + public const array PROVIDERS = ['memory', 'database']; + + /** + * @param list $publishExcludedAggregateTypes + */ + public function __construct( + public string $storeProvider = 'memory', + public ?string $connection = null, + public int $batchSize = 500, + public bool $snapshotEnabled = true, + public string $snapshotStore = 'memory', + public int $snapshotThreshold = 50, + public int $snapshotKeepCount = 3, + public string $checkpointStore = 'memory', + public int $projectionBatchSize = 100, + public float $leaseTtl = 30.0, + public float $pollInterval = 1.0, + public int $maxLag = 0, + public bool $publishEnabled = false, + public string $publishDestination = 'eventsourcing.events', + public array $publishExcludedAggregateTypes = ['orchestration.execution'], + public bool $tenancyEnabled = false, + public bool $captureUser = true, + public bool $healthEnabled = true, + ) {} + + public static function fromConfig(Config $config): self + { + $store = self::provider($config->string('firefly.eventsourcing.store.provider', 'memory'), 'firefly.eventsourcing.store.provider'); + $snapshotStore = $config->get('firefly.eventsourcing.snapshot.store'); + $checkpointStore = $config->get('firefly.eventsourcing.projection.checkpoint-store'); + $connection = $config->get('firefly.eventsourcing.store.connection'); + if ($connection !== null && (! is_string($connection) || $connection === '')) { + throw new ConfigurationException('Configuration key [firefly.eventsourcing.store.connection] must be a connection name or null.'); + } + + $excluded = []; + foreach ($config->array('firefly.eventsourcing.publish.exclude-aggregate-types', ['orchestration.execution']) as $type) { + if (! is_string($type) || $type === '') { + throw new ConfigurationException('Configuration key [firefly.eventsourcing.publish.exclude-aggregate-types] must be a list of aggregate types.'); + } + $excluded[] = $type; + } + + $destination = $config->string('firefly.eventsourcing.publish.destination', 'eventsourcing.events'); + if ($destination === '') { + throw new ConfigurationException('Configuration key [firefly.eventsourcing.publish.destination] must not be empty.'); + } + + $settings = new self( + storeProvider: $store, + connection: $connection, + batchSize: self::atLeast($config->int('firefly.eventsourcing.store.batch-size', 500), 1, 'firefly.eventsourcing.store.batch-size'), + snapshotEnabled: $config->bool('firefly.eventsourcing.snapshot.enabled', true), + snapshotStore: $snapshotStore === null || $snapshotStore === '' ? $store : self::provider(is_string($snapshotStore) ? $snapshotStore : get_debug_type($snapshotStore), 'firefly.eventsourcing.snapshot.store'), + snapshotThreshold: self::atLeast($config->int('firefly.eventsourcing.snapshot.threshold', 50), 0, 'firefly.eventsourcing.snapshot.threshold'), + snapshotKeepCount: self::atLeast($config->int('firefly.eventsourcing.snapshot.keep-count', 3), 1, 'firefly.eventsourcing.snapshot.keep-count'), + checkpointStore: $checkpointStore === null || $checkpointStore === '' ? $store : self::provider(is_string($checkpointStore) ? $checkpointStore : get_debug_type($checkpointStore), 'firefly.eventsourcing.projection.checkpoint-store'), + projectionBatchSize: self::atLeast($config->int('firefly.eventsourcing.projection.batch-size', 100), 1, 'firefly.eventsourcing.projection.batch-size'), + leaseTtl: self::seconds($config->get('firefly.eventsourcing.projection.lease-ttl'), 30.0, 'firefly.eventsourcing.projection.lease-ttl', 1.0), + pollInterval: self::seconds($config->get('firefly.eventsourcing.projection.poll-interval'), 1.0, 'firefly.eventsourcing.projection.poll-interval', 0.0), + maxLag: self::atLeast($config->int('firefly.eventsourcing.projection.max-lag', 0), 0, 'firefly.eventsourcing.projection.max-lag'), + publishEnabled: $config->bool('firefly.eventsourcing.publish.enabled', false), + publishDestination: $destination, + publishExcludedAggregateTypes: $excluded, + tenancyEnabled: $config->bool('firefly.eventsourcing.tenancy.enabled', false), + captureUser: $config->bool('firefly.eventsourcing.metadata.capture-user', true), + healthEnabled: $config->bool('firefly.eventsourcing.health.enabled', true), + ); + + self::refuseSplitOutbox($config, $settings); + + return $settings; + } + + public function usesDatabase(): bool + { + return in_array('database', [$this->storeProvider, $this->snapshotStore, $this->checkpointStore], true); + } + + private static function refuseSplitOutbox(Config $config, self $settings): void + { + $eda = $config->string('firefly.eda.provider', 'memory'); + if (! $settings->publishEnabled || $settings->storeProvider !== 'database' || ! in_array($eda, ['database', 'postgres'], true)) { + return; + } + + $default = $config->get('database.default'); + $default = is_string($default) ? $default : null; + $store = $settings->connection ?? $default; + $outbox = DatabaseOutboxSettings::from($config)->connection ?? $default; + + if ($store !== $outbox) { + throw new ConfigurationException(sprintf( + 'Configuration key [firefly.eventsourcing.store.connection] is [%s] but the %s outbox writes on [%s] ' + .'(firefly.eda.database.connection): events and their outbox rows must share one transaction, so put them on one connection.', + $store ?? 'default', + $eda, + $outbox ?? 'default', + )); + } + } + + private static function provider(string $value, string $key): string + { + if (! in_array($value, self::PROVIDERS, true)) { + throw new ConfigurationException("Configuration key [{$key}] must be memory or database, got [{$value}]."); + } + + return $value; + } + + private static function atLeast(int $value, int $minimum, string $key): int + { + if ($value < $minimum) { + throw new ConfigurationException("Configuration key [{$key}] must be at least {$minimum}, got [{$value}]."); + } + + return $value; + } + + /** A duration in the framework grammar ('250ms', '30s', '5m', 'PT1H') or a number of seconds; null is the default. */ + private static function seconds(mixed $value, float $default, string $key, float $minimum): float + { + if ($value === null || $value === '') { + return $default; + } + + if (is_int($value) || is_float($value)) { + $seconds = (float) $value; + } elseif (is_string($value)) { + try { + $seconds = Duration::parse($value); + } catch (ConfigurationException $exception) { + throw new ConfigurationException("Configuration key [{$key}] is not a duration this framework can parse. {$exception->getMessage()}", previous: $exception); + } + } else { + throw new ConfigurationException("Configuration key [{$key}] must be a duration such as '30s', got ".get_debug_type($value).'.'); + } + + if (! is_finite($seconds) || $seconds < $minimum) { + throw new ConfigurationException("Configuration key [{$key}] must be at least {$minimum} seconds, got [".var_export($value, true).'].'); + } + + return $seconds; + } +} diff --git a/packages/eventsourcing/tests/EventSourcingSettingsTest.php b/packages/eventsourcing/tests/EventSourcingSettingsTest.php new file mode 100644 index 00000000..719f7860 --- /dev/null +++ b/packages/eventsourcing/tests/EventSourcingSettingsTest.php @@ -0,0 +1,90 @@ + $eventsourcing + * @param array $extra + */ +function eventSourcingSettings(array $eventsourcing = [], array $extra = []): EventSourcingSettings +{ + return EventSourcingSettings::fromConfig(new Config(new Repository(array_replace_recursive([ + 'database' => ['default' => 'main'], + 'firefly' => ['eventsourcing' => $eventsourcing], + ], $extra)))); +} + +it('reads the defaults the reference documents', function (): void { + $settings = eventSourcingSettings(); + + expect($settings->storeProvider)->toBe('memory') + ->and($settings->connection)->toBeNull() + ->and($settings->batchSize)->toBe(500) + ->and($settings->snapshotEnabled)->toBeTrue() + ->and($settings->snapshotStore)->toBe('memory') + ->and($settings->snapshotThreshold)->toBe(50) + ->and($settings->snapshotKeepCount)->toBe(3) + ->and($settings->checkpointStore)->toBe('memory') + ->and($settings->projectionBatchSize)->toBe(100) + ->and($settings->leaseTtl)->toBe(30.0) + ->and($settings->pollInterval)->toBe(1.0) + ->and($settings->maxLag)->toBe(0) + ->and($settings->publishEnabled)->toBeFalse() + ->and($settings->publishDestination)->toBe('eventsourcing.events') + ->and($settings->publishExcludedAggregateTypes)->toBe(['orchestration.execution']) + ->and($settings->tenancyEnabled)->toBeFalse() + ->and($settings->captureUser)->toBeTrue() + ->and($settings->healthEnabled)->toBeTrue() + ->and($settings->usesDatabase())->toBeFalse(); +}); + +it('moves the snapshot and checkpoint stores with the event store unless they are named', function (): void { + $follows = eventSourcingSettings(['store' => ['provider' => 'database']]); + $split = eventSourcingSettings(['store' => ['provider' => 'database'], 'snapshot' => ['store' => 'memory'], 'projection' => ['checkpoint-store' => 'memory']]); + + expect([$follows->snapshotStore, $follows->checkpointStore, $follows->usesDatabase()])->toBe(['database', 'database', true]) + ->and([$split->snapshotStore, $split->checkpointStore])->toBe(['memory', 'memory']); +}); + +it('refuses a configuration it cannot honour, naming the key', function (array $eventsourcing, string $key): void { + /** @var array $eventsourcing */ + expect(fn () => eventSourcingSettings($eventsourcing))->toThrow(ConfigurationException::class, $key); +})->with([ + 'store provider' => [['store' => ['provider' => 'redis']], 'firefly.eventsourcing.store.provider'], + 'snapshot store' => [['snapshot' => ['store' => 'file']], 'firefly.eventsourcing.snapshot.store'], + 'checkpoint store' => [['projection' => ['checkpoint-store' => 'cache']], 'firefly.eventsourcing.projection.checkpoint-store'], + 'batch size' => [['store' => ['batch-size' => 0]], 'firefly.eventsourcing.store.batch-size'], + 'threshold' => [['snapshot' => ['threshold' => -1]], 'firefly.eventsourcing.snapshot.threshold'], + 'keep count' => [['snapshot' => ['keep-count' => 0]], 'firefly.eventsourcing.snapshot.keep-count'], + 'projection batch' => [['projection' => ['batch-size' => 0]], 'firefly.eventsourcing.projection.batch-size'], + 'lease' => [['projection' => ['lease-ttl' => '500ms']], 'firefly.eventsourcing.projection.lease-ttl'], + 'poll' => [['projection' => ['poll-interval' => 'soon']], 'firefly.eventsourcing.projection.poll-interval'], + 'max lag' => [['projection' => ['max-lag' => -5]], 'firefly.eventsourcing.projection.max-lag'], + 'destination' => [['publish' => ['destination' => '']], 'firefly.eventsourcing.publish.destination'], + 'connection' => [['store' => ['connection' => '']], 'firefly.eventsourcing.store.connection'], + 'excluded types' => [['publish' => ['exclude-aggregate-types' => ['orders', 7]]], 'firefly.eventsourcing.publish.exclude-aggregate-types'], + 'excluded types shape' => [['publish' => ['exclude-aggregate-types' => 'orders']], 'firefly.eventsourcing.publish.exclude-aggregate-types'], + 'snapshot store type' => [['snapshot' => ['store' => true]], 'firefly.eventsourcing.snapshot.store'], +]); + +it('refuses same-transaction publishing when the outbox and the event store use different connections', function (): void { + expect(fn () => eventSourcingSettings( + ['store' => ['provider' => 'database', 'connection' => 'events'], 'publish' => ['enabled' => true]], + ['firefly' => ['eda' => ['provider' => 'database', 'database' => ['connection' => 'outbox']]]], + ))->toThrow(ConfigurationException::class, 'firefly.eventsourcing.store.connection'); + + // The same connection by two spellings (null = the default) is accepted, and so is any after-commit provider. + expect(eventSourcingSettings( + ['store' => ['provider' => 'database', 'connection' => 'main'], 'publish' => ['enabled' => true]], + ['firefly' => ['eda' => ['provider' => 'postgres']]], + )->publishEnabled)->toBeTrue() + ->and(eventSourcingSettings( + ['store' => ['provider' => 'database', 'connection' => 'events'], 'publish' => ['enabled' => true]], + ['firefly' => ['eda' => ['provider' => 'memory']]], + )->publishEnabled)->toBeTrue(); +}); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 61fade6e..81003ba9 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -3058,4 +3058,83 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Event sourcing — firefly/eventsourcing + |-------------------------------------------------------------------------- + | + | Events are the source of truth: an aggregate's state is the replay of its stream. See + | docs/modules/eventsourcing.md. + | + */ + + 'eventsourcing' => [ + + /* + | Where events live: `memory` (this process only, the default) or `database` (portable tables on + | `connection`, null = the default connection; run the migration, --tag=firefly-eventsourcing-migrations + | to publish it). Concurrent appends to one aggregate are refused with a 409 ConcurrencyException except + | the first; the global stream every projection follows is numbered in COMMIT order, so a reader never + | skips an event that committed late. `batch-size` caps one numbering round and the default page. + | Defaults: memory, null, 500. + */ + 'store' => [ + 'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'), + 'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'), + 'batch-size' => 500, + ], + + /* + | Snapshots of aggregates that implement SnapshotAware: one each time the version crosses a multiple of + | `threshold` (0 = never), the newest `keep-count` kept. `store` is memory or database; null follows + | store.provider. A snapshot that cannot be written is logged and the append stands. + | Defaults: true, null, 50, 3. + */ + 'snapshot' => [ + 'enabled' => env('FIREFLY_EVENTSOURCING_SNAPSHOT_ENABLED', true), + 'store' => env('FIREFLY_EVENTSOURCING_SNAPSHOT_STORE'), + 'threshold' => (int) env('FIREFLY_EVENTSOURCING_SNAPSHOT_THRESHOLD', 50), + 'keep-count' => 3, + ], + + /* + | Projections follow the global stream from a checkpoint (`checkpoint-store`: memory or database, null + | follows store.provider), `batch-size` events per transaction, under a `lease-ttl` lease on the + | firefly.scheduling.lock.provider DistributedLock so one node advances each projection (keep a batch + | well under the lease). Live mode (php artisan firefly:eventsourcing:projections:run) sleeps + | `poll-interval` when idle. The `eventsourcing` health component turns DOWN when a projection lags more + | than `max-lag` events (0 = never). Defaults: null, 100, 30s, 1s, 0. + */ + 'projection' => [ + 'checkpoint-store' => env('FIREFLY_EVENTSOURCING_CHECKPOINT_STORE'), + 'batch-size' => 100, + 'lease-ttl' => '30s', + 'poll-interval' => '1s', + 'max-lag' => 0, + ], + + /* + | Publish every appended event to firefly/eda on `destination`. Under firefly.eda.provider database or + | postgres the outbox row is written IN the append's transaction (the event store and the outbox must + | share a connection, or the boot is refused); under any other provider the events are published after + | commit, and a rolled-back append publishes nothing. Aggregate types in `exclude-aggregate-types` are + | never published. Defaults: false, eventsourcing.events, ['orchestration.execution']. + */ + 'publish' => [ + 'enabled' => env('FIREFLY_EVENTSOURCING_PUBLISH_ENABLED', false), + 'destination' => env('FIREFLY_EVENTSOURCING_PUBLISH_DESTINATION', 'eventsourcing.events'), + 'exclude-aggregate-types' => ['orchestration.execution'], + ], + + // Stamp every event with the TenantResolver bean's tenant and refuse to load another tenant's stream + // (projections still read every tenant). Default: false. + 'tenancy' => ['enabled' => env('FIREFLY_EVENTSOURCING_TENANCY_ENABLED', false)], + + // Stamp the authenticated Laravel user's id into every event's metadata (x-user-id). Default: true. + 'metadata' => ['capture-user' => true], + + // The `eventsourcing` health component: the store, the global position and every projection's lag. + 'health' => ['enabled' => true], + ], + ]; From e9bf70e19940b502de4f0e89f6bb2f88c266958a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:50:06 -0700 Subject: [PATCH 03/33] feat(eventsourcing): define the EventStore port, the event envelope with its metadata and the eventsourcing exceptions --- .../src/Event/EventEnvelopeCodec.php | 34 +++++++ .../eventsourcing/src/Event/EventMetadata.php | 94 +++++++++++++++++++ packages/eventsourcing/src/Event/NewEvent.php | 52 ++++++++++ .../src/Event/PayloadNormalizer.php | 55 +++++++++++ .../eventsourcing/src/Event/StoredEvent.php | 45 +++++++++ .../Exception/AggregateNotFoundException.php | 16 ++++ .../Exception/CheckpointConflictException.php | 14 +++ .../src/Exception/ConcurrencyException.php | 35 +++++++ .../Exception/EventSerializationException.php | 14 +++ .../src/Exception/EventSourcingException.php | 18 ++++ .../Exception/StreamTypeMismatchException.php | 14 +++ .../src/Exception/TenantRequiredException.php | 19 ++++ .../Exception/UnknownEventTypeException.php | 27 ++++++ .../src/Exception/UpcastingException.php | 19 ++++ .../eventsourcing/src/Store/EventFilter.php | 52 ++++++++++ .../eventsourcing/src/Store/EventStore.php | 46 +++++++++ .../eventsourcing/src/Store/EventStream.php | 34 +++++++ .../src/Store/ExpectedVersion.php | 15 +++ .../tests/Event/EventEnvelopeCodecTest.php | 23 +++++ .../tests/Event/EventMetadataTest.php | 27 ++++++ .../tests/Event/PayloadNormalizerTest.php | 42 +++++++++ .../tests/Store/EventFilterTest.php | 35 +++++++ 22 files changed, 730 insertions(+) create mode 100644 packages/eventsourcing/src/Event/EventEnvelopeCodec.php create mode 100644 packages/eventsourcing/src/Event/EventMetadata.php create mode 100644 packages/eventsourcing/src/Event/NewEvent.php create mode 100644 packages/eventsourcing/src/Event/PayloadNormalizer.php create mode 100644 packages/eventsourcing/src/Event/StoredEvent.php create mode 100644 packages/eventsourcing/src/Exception/AggregateNotFoundException.php create mode 100644 packages/eventsourcing/src/Exception/CheckpointConflictException.php create mode 100644 packages/eventsourcing/src/Exception/ConcurrencyException.php create mode 100644 packages/eventsourcing/src/Exception/EventSerializationException.php create mode 100644 packages/eventsourcing/src/Exception/EventSourcingException.php create mode 100644 packages/eventsourcing/src/Exception/StreamTypeMismatchException.php create mode 100644 packages/eventsourcing/src/Exception/TenantRequiredException.php create mode 100644 packages/eventsourcing/src/Exception/UnknownEventTypeException.php create mode 100644 packages/eventsourcing/src/Exception/UpcastingException.php create mode 100644 packages/eventsourcing/src/Store/EventFilter.php create mode 100644 packages/eventsourcing/src/Store/EventStore.php create mode 100644 packages/eventsourcing/src/Store/EventStream.php create mode 100644 packages/eventsourcing/src/Store/ExpectedVersion.php create mode 100644 packages/eventsourcing/tests/Event/EventEnvelopeCodecTest.php create mode 100644 packages/eventsourcing/tests/Event/EventMetadataTest.php create mode 100644 packages/eventsourcing/tests/Event/PayloadNormalizerTest.php create mode 100644 packages/eventsourcing/tests/Store/EventFilterTest.php diff --git a/packages/eventsourcing/src/Event/EventEnvelopeCodec.php b/packages/eventsourcing/src/Event/EventEnvelopeCodec.php new file mode 100644 index 00000000..2b05a85b --- /dev/null +++ b/packages/eventsourcing/src/Event/EventEnvelopeCodec.php @@ -0,0 +1,34 @@ +serializer->serialize(new EventEnvelope($event->eventType, $aggregateType, $event->payload, $event->metadata->toHeaders(), $event->eventId, $event->occurredAt)); + } + + /** @return array{payload: array, metadata: EventMetadata} */ + public function decode(string $raw): array + { + $envelope = $this->serializer->deserialize($raw); + + return ['payload' => $envelope->payload, 'metadata' => EventMetadata::fromHeaders($envelope->headers)]; + } +} diff --git a/packages/eventsourcing/src/Event/EventMetadata.php b/packages/eventsourcing/src/Event/EventMetadata.php new file mode 100644 index 00000000..5a924d57 --- /dev/null +++ b/packages/eventsourcing/src/Event/EventMetadata.php @@ -0,0 +1,94 @@ + $extra + */ + public function __construct( + public ?string $correlationId = null, + public ?string $causationId = null, + public ?string $tenantId = null, + public ?string $userId = null, + public array $extra = [], + ) {} + + /** The members $over sets win; extras are merged, $over's on top. */ + public function merge(self $over): self + { + return new self( + $over->correlationId ?? $this->correlationId, + $over->causationId ?? $this->causationId, + $over->tenantId ?? $this->tenantId, + $over->userId ?? $this->userId, + array_merge($this->extra, $over->extra), + ); + } + + public function withTenantId(?string $tenantId): self + { + return new self($this->correlationId, $this->causationId, $tenantId, $this->userId, $this->extra); + } + + public function withUserId(?string $userId): self + { + return new self($this->correlationId, $this->causationId, $this->tenantId, $userId, $this->extra); + } + + public function withCorrelationId(?string $correlationId): self + { + return new self($correlationId, $this->causationId, $this->tenantId, $this->userId, $this->extra); + } + + /** Metadata for an event appended while handling $cause: same correlation (or the cause itself), caused by it. */ + public static function causedBy(StoredEvent $cause): self + { + return new self($cause->metadata->correlationId ?? $cause->eventId, $cause->eventId, $cause->metadata->tenantId); + } + + /** @return array */ + public function toHeaders(): array + { + $headers = $this->extra; + foreach ([self::CORRELATION_ID => $this->correlationId, self::CAUSATION_ID => $this->causationId, self::TENANT_ID => $this->tenantId, self::USER_ID => $this->userId] as $name => $value) { + if ($value !== null) { + $headers[$name] = $value; + } + } + + return $headers; + } + + /** @param array $headers */ + public static function fromHeaders(array $headers): self + { + $extra = $headers; + unset($extra[self::CORRELATION_ID], $extra[self::CAUSATION_ID], $extra[self::TENANT_ID], $extra[self::USER_ID]); + + return new self( + $headers[self::CORRELATION_ID] ?? null, + $headers[self::CAUSATION_ID] ?? null, + $headers[self::TENANT_ID] ?? null, + $headers[self::USER_ID] ?? null, + $extra, + ); + } +} diff --git a/packages/eventsourcing/src/Event/NewEvent.php b/packages/eventsourcing/src/Event/NewEvent.php new file mode 100644 index 00000000..8e017200 --- /dev/null +++ b/packages/eventsourcing/src/Event/NewEvent.php @@ -0,0 +1,52 @@ + $payload + */ + public function __construct( + public string $eventType, + public array $payload, + public int $eventVersion = 1, + public EventMetadata $metadata = new EventMetadata, + ?string $eventId = null, + ?DateTimeImmutable $occurredAt = null, + ) { + $this->eventId = $eventId ?? self::uuid4(); + $this->occurredAt = $occurredAt ?? new DateTimeImmutable; + } + + public function withMetadata(EventMetadata $metadata): self + { + return new self($this->eventType, $this->payload, $this->eventVersion, $metadata, $this->eventId, $this->occurredAt); + } + + /** random_bytes + formatting, no ramsey/uuid: the DomainEvent and EventEnvelope precedent. */ + public static function uuid4(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80); + + /** @var array $chunks */ + $chunks = str_split(bin2hex($bytes), 4); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', $chunks); + } +} diff --git a/packages/eventsourcing/src/Event/PayloadNormalizer.php b/packages/eventsourcing/src/Event/PayloadNormalizer.php new file mode 100644 index 00000000..5149c775 --- /dev/null +++ b/packages/eventsourcing/src/Event/PayloadNormalizer.php @@ -0,0 +1,55 @@ + $value, + is_float($value) => is_finite($value) ? $value : throw EventSerializationException::at($path, 'holds a non-finite float, which JSON cannot represent.'), + is_string($value) => mb_check_encoding($value, 'UTF-8') ? $value : throw EventSerializationException::at($path, 'holds a string that is not valid UTF-8; encode binary data (base64) first.'), + is_array($value) => self::normalizeArray($value, $path), + $value instanceof DateTimeInterface => $value->format(self::DATE_FORMAT), + $value instanceof BackedEnum => $value->value, + $value instanceof UnitEnum => $value->name, + $value instanceof Closure => throw EventSerializationException::at($path, 'holds a closure, which an event store cannot persist.'), + $value instanceof JsonSerializable => self::normalize($value->jsonSerialize(), $path), + is_object($value) => throw EventSerializationException::at($path, 'holds an object of class '.$value::class.'; make it JsonSerializable or implement SerializableEvent on the event.'), + default => throw EventSerializationException::at($path, 'holds a '.get_debug_type($value).', which an event store cannot persist.'), + }; + } + + /** + * @param array $values + * @return array + */ + private static function normalizeArray(array $values, string $path): array + { + $normalized = []; + foreach ($values as $key => $item) { + $normalized[$key] = self::normalize($item, $path.'.'.$key); + } + + return $normalized; + } +} diff --git a/packages/eventsourcing/src/Event/StoredEvent.php b/packages/eventsourcing/src/Event/StoredEvent.php new file mode 100644 index 00000000..323f3239 --- /dev/null +++ b/packages/eventsourcing/src/Event/StoredEvent.php @@ -0,0 +1,45 @@ + $payload + */ + public function __construct( + public string $eventId, + public string $aggregateId, + public string $aggregateType, + public int $aggregateVersion, + public ?int $globalPosition, + public string $eventType, + public int $eventVersion, + public array $payload, + public EventMetadata $metadata, + public DateTimeImmutable $occurredAt, + public DateTimeImmutable $recordedAt, + ) {} + + /** @param array $payload */ + public function upcasted(string $eventType, int $eventVersion, array $payload): self + { + return new self($this->eventId, $this->aggregateId, $this->aggregateType, $this->aggregateVersion, $this->globalPosition, $eventType, $eventVersion, $payload, $this->metadata, $this->occurredAt, $this->recordedAt); + } + + public function withGlobalPosition(int $position): self + { + return new self($this->eventId, $this->aggregateId, $this->aggregateType, $this->aggregateVersion, $position, $this->eventType, $this->eventVersion, $this->payload, $this->metadata, $this->occurredAt, $this->recordedAt); + } +} diff --git a/packages/eventsourcing/src/Exception/AggregateNotFoundException.php b/packages/eventsourcing/src/Exception/AggregateNotFoundException.php new file mode 100644 index 00000000..3a52a2cf --- /dev/null +++ b/packages/eventsourcing/src/Exception/AggregateNotFoundException.php @@ -0,0 +1,16 @@ + $eventTypes + * @param list $aggregateTypes + */ + public function __construct( + public array $eventTypes = [], + public array $aggregateTypes = [], + public ?DateTimeImmutable $occurredFrom = null, + public ?DateTimeImmutable $occurredBefore = null, + public ?string $tenantId = null, + ) {} + + public static function ofEventTypes(string ...$types): self + { + return new self(eventTypes: array_values($types)); + } + + public static function ofAggregateTypes(string ...$types): self + { + return new self(aggregateTypes: array_values($types)); + } + + public static function between(DateTimeImmutable $from, DateTimeImmutable $before): self + { + return new self(occurredFrom: $from, occurredBefore: $before); + } + + public function matches(StoredEvent $event): bool + { + return ($this->eventTypes === [] || in_array($event->eventType, $this->eventTypes, true)) + && ($this->aggregateTypes === [] || in_array($event->aggregateType, $this->aggregateTypes, true)) + && ($this->occurredFrom === null || $event->occurredAt >= $this->occurredFrom) + && ($this->occurredBefore === null || $event->occurredAt < $this->occurredBefore) + && ($this->tenantId === null || $event->metadata->tenantId === $this->tenantId); + } +} diff --git a/packages/eventsourcing/src/Store/EventStore.php b/packages/eventsourcing/src/Store/EventStore.php new file mode 100644 index 00000000..7e9b01ed --- /dev/null +++ b/packages/eventsourcing/src/Store/EventStore.php @@ -0,0 +1,46 @@ + $events + * @return list the appended events with their versions; their global positions are given when read + */ + public function append(string $aggregateId, string $aggregateType, array $events, int $expectedVersion = ExpectedVersion::ANY): array; + + public function load(string $aggregateId, int $fromVersion = 1, ?int $toVersion = null): EventStream; + + /** The stream's version: its last event's aggregateVersion, 0 when it has none. */ + public function version(string $aggregateId): int; + + public function exists(string $aggregateId): bool; + + /** @return list */ + public function readAll(int $afterPosition = 0, int $limit = 500, ?EventFilter $filter = null): array; + + /** The highest global position given so far (0 for an empty store). */ + public function currentPosition(): int; + + public function isHealthy(): bool; +} diff --git a/packages/eventsourcing/src/Store/EventStream.php b/packages/eventsourcing/src/Store/EventStream.php new file mode 100644 index 00000000..9381d750 --- /dev/null +++ b/packages/eventsourcing/src/Store/EventStream.php @@ -0,0 +1,34 @@ + $events + */ + public function __construct( + public string $aggregateId, + public array $events, + ) {} + + public function version(): int + { + return $this->events === [] ? 0 : $this->events[count($this->events) - 1]->aggregateVersion; + } + + public function isEmpty(): bool + { + return $this->events === []; + } + + public function aggregateType(): ?string + { + return $this->events === [] ? null : $this->events[0]->aggregateType; + } +} diff --git a/packages/eventsourcing/src/Store/ExpectedVersion.php b/packages/eventsourcing/src/Store/ExpectedVersion.php new file mode 100644 index 00000000..51655cc4 --- /dev/null +++ b/packages/eventsourcing/src/Store/ExpectedVersion.php @@ -0,0 +1,15 @@ + 'o-1', 'total' => 12.5], 2, new EventMetadata('corr-1', tenantId: 'acme'), 'evt-1'); + + $raw = $codec->encode($event, 'order'); + /** @var array{eventType: string, destination: string, eventId: string, headers: array} $wire */ + $wire = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + + expect($wire['eventType'])->toBe('order.placed') + ->and($wire['destination'])->toBe('order') + ->and($wire['eventId'])->toBe('evt-1') + ->and($wire['headers'])->toBe(['x-correlation-id' => 'corr-1', 'x-tenant-id' => 'acme']) + ->and($codec->decode($raw))->toEqual(['payload' => ['orderId' => 'o-1', 'total' => 12.5], 'metadata' => new EventMetadata('corr-1', tenantId: 'acme')]); +}); diff --git a/packages/eventsourcing/tests/Event/EventMetadataTest.php b/packages/eventsourcing/tests/Event/EventMetadataTest.php new file mode 100644 index 00000000..978846bf --- /dev/null +++ b/packages/eventsourcing/tests/Event/EventMetadataTest.php @@ -0,0 +1,27 @@ + 'import']); + + expect($metadata->toHeaders())->toBe([ + 'x-source' => 'import', + 'x-correlation-id' => 'corr-1', + 'x-causation-id' => 'cause-1', + 'x-tenant-id' => 'acme', + 'x-user-id' => 'user-7', + ])->and(EventMetadata::fromHeaders($metadata->toHeaders()))->toEqual($metadata) + ->and((new EventMetadata)->toHeaders())->toBe([]); +}); + +it('lets the more specific metadata win a merge, and chains causation from the event that caused it', function (): void { + $merged = (new EventMetadata('corr-1', null, 'acme', null, ['a' => '1']))->merge(new EventMetadata(null, 'cause-2', null, 'u', ['b' => '2'])); + $cause = new StoredEvent('evt-9', 'order-1', 'order', 3, 12, 'order.placed', 1, [], new EventMetadata(null, null, 'acme'), new DateTimeImmutable, new DateTimeImmutable); + + expect($merged)->toEqual(new EventMetadata('corr-1', 'cause-2', 'acme', 'u', ['a' => '1', 'b' => '2'])) + ->and(EventMetadata::causedBy($cause))->toEqual(new EventMetadata('evt-9', 'evt-9', 'acme')); +}); diff --git a/packages/eventsourcing/tests/Event/PayloadNormalizerTest.php b/packages/eventsourcing/tests/Event/PayloadNormalizerTest.php new file mode 100644 index 00000000..f69594cf --- /dev/null +++ b/packages/eventsourcing/tests/Event/PayloadNormalizerTest.php @@ -0,0 +1,42 @@ + 1]; + } + }; + + expect(PayloadNormalizer::normalize([ + 'at' => new DateTimeImmutable('2026-10-13 10:00:00.123456', new DateTimeZone('UTC')), + 'suit' => PayloadNormalizerSuit::Hearts, + 'json' => $json, + 'list' => [1, 2.5, 'x', null, true], + ]))->toBe([ + 'at' => '2026-10-13T10:00:00.123456+00:00', + 'suit' => 'H', + 'json' => ['n' => 1], + 'list' => [1, 2.5, 'x', null, true], + ]); +}); + +it('refuses a value an event store cannot hold, naming where it sat', function (mixed $value, string $path): void { + expect(fn () => PayloadNormalizer::normalize(['lines' => [$value]]))->toThrow(EventSerializationException::class, $path); +})->with([ + 'closure' => [static fn (): int => 1, 'payload.lines.0'], + 'object' => [new stdClass, 'payload.lines.0'], + 'non-finite float' => [INF, 'payload.lines.0'], +]); diff --git a/packages/eventsourcing/tests/Store/EventFilterTest.php b/packages/eventsourcing/tests/Store/EventFilterTest.php new file mode 100644 index 00000000..364cb7bf --- /dev/null +++ b/packages/eventsourcing/tests/Store/EventFilterTest.php @@ -0,0 +1,35 @@ +matches($placed))->toBeTrue() + ->and(EventFilter::ofEventTypes('order.paid')->matches($placed))->toBeFalse() + ->and(EventFilter::ofAggregateTypes('order')->matches($placed))->toBeTrue() + ->and(EventFilter::between(new DateTimeImmutable('2026-10-13 10:00:00'), new DateTimeImmutable('2026-10-13 11:00:00'))->matches($placed))->toBeTrue() + ->and(EventFilter::between(new DateTimeImmutable('2026-10-13 09:00:00'), new DateTimeImmutable('2026-10-13 10:00:00'))->matches($placed))->toBeFalse() + ->and((new EventFilter(tenantId: 'acme'))->matches($placed))->toBeTrue() + ->and((new EventFilter(tenantId: 'globex'))->matches($placed))->toBeFalse(); +}); + +it('reports a stream version as its last event, or 0 when empty', function (): void { + $stream = new EventStream('a-1', [filterEvent('order.placed', 'order', 'now', version: 1), filterEvent('order.paid', 'order', 'now', version: 2)]); + + expect($stream->version())->toBe(2) + ->and($stream->aggregateType())->toBe('order') + ->and($stream->isEmpty())->toBeFalse() + ->and((new EventStream('a-2', []))->version())->toBe(0) + ->and((new EventStream('a-2', []))->aggregateType())->toBeNull(); +}); From b87a7e07af078687504317b85b644055b04e45d3 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:52:14 -0700 Subject: [PATCH 04/33] feat(eventsourcing): upcast stored events through a type-and-version chain with priorities --- .../src/Upcasting/CallbackEventUpcaster.php | 44 ++++++++++++++ .../src/Upcasting/EventUpcaster.php | 23 +++++++ .../src/Upcasting/UpcasterChain.php | 60 +++++++++++++++++++ .../tests/Upcasting/UpcasterChainTest.php | 49 +++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 packages/eventsourcing/src/Upcasting/CallbackEventUpcaster.php create mode 100644 packages/eventsourcing/src/Upcasting/EventUpcaster.php create mode 100644 packages/eventsourcing/src/Upcasting/UpcasterChain.php create mode 100644 packages/eventsourcing/tests/Upcasting/UpcasterChainTest.php diff --git a/packages/eventsourcing/src/Upcasting/CallbackEventUpcaster.php b/packages/eventsourcing/src/Upcasting/CallbackEventUpcaster.php new file mode 100644 index 00000000..3e555054 --- /dev/null +++ b/packages/eventsourcing/src/Upcasting/CallbackEventUpcaster.php @@ -0,0 +1,44 @@ +, StoredEvent): array $transform + */ + public function __construct( + private readonly string $eventType, + private readonly int $fromVersion, + private readonly Closure $transform, + private readonly ?int $toVersion = null, + private readonly ?string $toType = null, + private readonly int $priority = 0, + ) {} + + public function eventType(): string + { + return $this->eventType; + } + + public function fromVersion(): int + { + return $this->fromVersion; + } + + public function priority(): int + { + return $this->priority; + } + + public function upcast(StoredEvent $event): StoredEvent + { + return $event->upcasted($this->toType ?? $event->eventType, $this->toVersion ?? $this->fromVersion + 1, ($this->transform)($event->payload, $event)); + } +} diff --git a/packages/eventsourcing/src/Upcasting/EventUpcaster.php b/packages/eventsourcing/src/Upcasting/EventUpcaster.php new file mode 100644 index 00000000..c48c9a56 --- /dev/null +++ b/packages/eventsourcing/src/Upcasting/EventUpcaster.php @@ -0,0 +1,23 @@ + "type#version" => the winning upcaster */ + private array $byKey = []; + + /** + * @param iterable $upcasters + */ + public function __construct(iterable $upcasters = []) + { + $sorted = []; + foreach ($upcasters as $upcaster) { + $sorted[] = $upcaster; + } + usort($sorted, static fn (EventUpcaster $a, EventUpcaster $b): int => $b->priority() <=> $a->priority()); + + foreach ($sorted as $upcaster) { + $this->byKey[$upcaster->eventType().'#'.$upcaster->fromVersion()] ??= $upcaster; + } + } + + public function isEmpty(): bool + { + return $this->byKey === []; + } + + public function apply(StoredEvent $event): StoredEvent + { + for ($step = 0; $step < self::MAX_STEPS; $step++) { + $upcaster = $this->byKey[$event->eventType.'#'.$event->eventVersion] ?? null; + if ($upcaster === null) { + return $event; + } + + $next = $upcaster->upcast($event); + if ($next->eventType === $event->eventType && $next->eventVersion === $event->eventVersion) { + throw UpcastingException::noProgress($event->eventType, $event->eventVersion); + } + $event = $next; + } + + throw UpcastingException::loop($event->eventType); + } +} diff --git a/packages/eventsourcing/tests/Upcasting/UpcasterChainTest.php b/packages/eventsourcing/tests/Upcasting/UpcasterChainTest.php new file mode 100644 index 00000000..ec15116d --- /dev/null +++ b/packages/eventsourcing/tests/Upcasting/UpcasterChainTest.php @@ -0,0 +1,49 @@ + $payload */ +function upcastable(string $type = 'order.placed', int $version = 1, array $payload = ['total' => 10]): StoredEvent +{ + return new StoredEvent('e-1', 'o-1', 'order', 1, 1, $type, $version, $payload, new EventMetadata, new DateTimeImmutable, new DateTimeImmutable); +} + +it('walks an event through every version step, renames included, and leaves other events alone', function (): void { + $chain = new UpcasterChain([ + new CallbackEventUpcaster('order.placed', 1, static fn (array $p): array => [...$p, 'currency' => 'EUR']), + new CallbackEventUpcaster('order.placed', 2, static fn (array $p): array => ['amount' => $p['total'], 'currency' => $p['currency']], toType: 'order.submitted'), + ]); + + $upcast = $chain->apply(upcastable()); + + expect([$upcast->eventType, $upcast->eventVersion, $upcast->payload])->toBe(['order.submitted', 3, ['amount' => 10, 'currency' => 'EUR']]) + ->and($chain->apply(upcastable('order.paid'))->payload)->toBe(['total' => 10]) + ->and((new UpcasterChain)->isEmpty())->toBeTrue(); +}); + +it('lets the highest priority win among upcasters of the same type and version', function (): void { + $chain = new UpcasterChain([ + new CallbackEventUpcaster('order.placed', 1, static fn (array $p): array => ['by' => 'low'], priority: 1), + new CallbackEventUpcaster('order.placed', 1, static fn (array $p): array => ['by' => 'high'], priority: 10), + new CallbackEventUpcaster('order.placed', 1, static fn (array $p): array => ['by' => 'tie-loses'], priority: 10), + ]); + + expect($chain->apply(upcastable())->payload)->toBe(['by' => 'high']); +}); + +it('refuses an upcaster that does not move the event and a chain that never settles', function (): void { + $still = new UpcasterChain([new CallbackEventUpcaster('order.placed', 1, static fn (array $p): array => $p, toVersion: 1)]); + $loop = new UpcasterChain([ + new CallbackEventUpcaster('a', 1, static fn (array $p): array => $p, toType: 'b'), + new CallbackEventUpcaster('b', 2, static fn (array $p): array => $p, toVersion: 1, toType: 'a'), + ]); + + expect(fn () => $still->apply(upcastable()))->toThrow(UpcastingException::class, 'returned the same type and version') + ->and(fn () => $loop->apply(upcastable('a')))->toThrow(UpcastingException::class, 'did not settle'); +}); From 42e477b4188c74a4f30f6683bcc56fa462cfdc84 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:54:50 -0700 Subject: [PATCH 05/33] feat(eventsourcing): add the memory event store and the contract every event store passes --- .../src/Publishing/EventStorePublisher.php | 28 +++ .../Publishing/NoOpEventStorePublisher.php | 15 ++ .../src/Store/MemoryEventStore.php | 153 +++++++++++++++ .../tests/Store/MemoryEventStoreTest.php | 46 +++++ .../tests/Support/EventStoreContract.php | 182 ++++++++++++++++++ 5 files changed, 424 insertions(+) create mode 100644 packages/eventsourcing/src/Publishing/EventStorePublisher.php create mode 100644 packages/eventsourcing/src/Publishing/NoOpEventStorePublisher.php create mode 100644 packages/eventsourcing/src/Store/MemoryEventStore.php create mode 100644 packages/eventsourcing/tests/Store/MemoryEventStoreTest.php create mode 100644 packages/eventsourcing/tests/Support/EventStoreContract.php diff --git a/packages/eventsourcing/src/Publishing/EventStorePublisher.php b/packages/eventsourcing/src/Publishing/EventStorePublisher.php new file mode 100644 index 00000000..05cdc112 --- /dev/null +++ b/packages/eventsourcing/src/Publishing/EventStorePublisher.php @@ -0,0 +1,28 @@ + $events */ + public function inTransaction(?ConnectionInterface $connection, array $events): void; + + /** @param list $events */ + public function afterTransaction(?ConnectionInterface $connection, array $events): void; +} diff --git a/packages/eventsourcing/src/Publishing/NoOpEventStorePublisher.php b/packages/eventsourcing/src/Publishing/NoOpEventStorePublisher.php new file mode 100644 index 00000000..3df46694 --- /dev/null +++ b/packages/eventsourcing/src/Publishing/NoOpEventStorePublisher.php @@ -0,0 +1,15 @@ +> */ + private array $streams = []; + + /** @var list global position - 1 => [aggregate id, index in its stream] */ + private array $global = []; + + /** + * @param (Closure(): DateTimeImmutable)|null $clock + */ + public function __construct( + private readonly EventEnvelopeCodec $codec = new EventEnvelopeCodec, + private readonly UpcasterChain $upcasters = new UpcasterChain, + private readonly EventStorePublisher $publisher = new NoOpEventStorePublisher, + private readonly ?Closure $clock = null, + ) {} + + public function append(string $aggregateId, string $aggregateType, array $events, int $expectedVersion = ExpectedVersion::ANY): array + { + if ($events === []) { + return []; + } + + $stream = $this->streams[$aggregateId] ?? []; + $version = count($stream); + if ($stream !== [] && $stream[0]['event']->aggregateType !== $aggregateType) { + throw StreamTypeMismatchException::of($aggregateId, $stream[0]['event']->aggregateType, $aggregateType); + } + if ($expectedVersion !== ExpectedVersion::ANY && $expectedVersion !== $version) { + throw ConcurrencyException::conflict($aggregateId, $expectedVersion, $version); + } + + $recordedAt = $this->now(); + $records = []; + $stored = []; + foreach ($events as $event) { + $normalized = new NewEvent($event->eventType, self::payload($event), $event->eventVersion, $event->metadata, $event->eventId, $event->occurredAt); + $raw = $this->codec->encode($normalized, $aggregateType); + $position = count($this->global) + count($stored) + 1; + $storedEvent = new StoredEvent($event->eventId, $aggregateId, $aggregateType, ++$version, $position, $event->eventType, $event->eventVersion, $normalized->payload, $event->metadata, $event->occurredAt, $recordedAt); + $stored[] = $storedEvent; + $records[] = ['event' => $storedEvent, 'raw' => $raw]; + } + + $this->publisher->inTransaction(null, $stored); + + foreach ($records as $record) { + $this->global[] = [$aggregateId, count($stream)]; + $stream[] = $record; + } + $this->streams[$aggregateId] = $stream; + + $this->publisher->afterTransaction(null, $stored); + + return $stored; + } + + public function load(string $aggregateId, int $fromVersion = 1, ?int $toVersion = null): EventStream + { + $events = []; + foreach ($this->streams[$aggregateId] ?? [] as $record) { + $version = $record['event']->aggregateVersion; + if ($version >= $fromVersion && ($toVersion === null || $version <= $toVersion)) { + $events[] = $this->read($record); + } + } + + return new EventStream($aggregateId, $events); + } + + public function version(string $aggregateId): int + { + return count($this->streams[$aggregateId] ?? []); + } + + public function exists(string $aggregateId): bool + { + return $this->version($aggregateId) > 0; + } + + public function readAll(int $afterPosition = 0, int $limit = 500, ?EventFilter $filter = null): array + { + $events = []; + for ($position = max(0, $afterPosition); $position < count($this->global) && count($events) < max(1, $limit); $position++) { + [$aggregateId, $index] = $this->global[$position]; + $event = $this->read($this->streams[$aggregateId][$index]); + if ($filter === null || $filter->matches($event)) { + $events[] = $event; + } + } + + return $events; + } + + public function currentPosition(): int + { + return count($this->global); + } + + public function isHealthy(): bool + { + return true; + } + + /** @param array{event: StoredEvent, raw: string} $record */ + private function read(array $record): StoredEvent + { + $decoded = $this->codec->decode($record['raw']); + $event = $record['event']; + + return $this->upcasters->apply(new StoredEvent($event->eventId, $event->aggregateId, $event->aggregateType, $event->aggregateVersion, $event->globalPosition, $event->eventType, $event->eventVersion, $decoded['payload'], $decoded['metadata'], $event->occurredAt, $event->recordedAt)); + } + + /** @return array */ + private static function payload(NewEvent $event): array + { + /** @var array $payload */ + $payload = PayloadNormalizer::normalize($event->payload); + + return $payload; + } + + private function now(): DateTimeImmutable + { + return ($this->clock === null ? new DateTimeImmutable : ($this->clock)())->setTimezone(new DateTimeZone('UTC')); + } +} diff --git a/packages/eventsourcing/tests/Store/MemoryEventStoreTest.php b/packages/eventsourcing/tests/Store/MemoryEventStoreTest.php new file mode 100644 index 00000000..24857d53 --- /dev/null +++ b/packages/eventsourcing/tests/Store/MemoryEventStoreTest.php @@ -0,0 +1,46 @@ + $assert) { + it("memory event store {$case}", function () use ($assert): void { + $assert(static fn (?UpcasterChain $upcasters): EventStore => new MemoryEventStore(upcasters: $upcasters ?? new UpcasterChain)); + }); +} + +it('appends nothing when the payload cannot be stored or the in-transaction publisher refuses', function (): void { + $refusing = new class implements EventStorePublisher + { + public function inTransaction(?ConnectionInterface $connection, array $events): void + { + throw new RuntimeException('outbox down'); + } + + public function afterTransaction(?ConnectionInterface $connection, array $events): void {} + }; + $store = new MemoryEventStore; + $refused = new MemoryEventStore(publisher: $refusing); + + expect(fn () => $store->append('o-1', 'order', [EventStoreContract::event(), EventStoreContract::event(payload: ['bad' => INF])], 0))->toThrow(EventSerializationException::class) + ->and(fn () => $refused->append('o-1', 'order', [EventStoreContract::event()], 0))->toThrow(RuntimeException::class, 'outbox down') + ->and($store->exists('o-1'))->toBeFalse() + ->and($store->currentPosition())->toBe(0) + ->and($refused->exists('o-1'))->toBeFalse() + ->and($refused->currentPosition())->toBe(0); +}); + +it('numbers the global stream as it appends, one process being its own commit order', function (): void { + $store = new MemoryEventStore; + $stored = $store->append('o-1', 'order', [EventStoreContract::event(), EventStoreContract::event('order.paid')], 0); + + expect(array_map(static fn (StoredEvent $e): ?int => $e->globalPosition, $stored))->toBe([1, 2]); +}); diff --git a/packages/eventsourcing/tests/Support/EventStoreContract.php b/packages/eventsourcing/tests/Support/EventStoreContract.php new file mode 100644 index 00000000..40439c3a --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventStoreContract.php @@ -0,0 +1,182 @@ + $payload + */ + public static function event(string $type = 'order.placed', array $payload = ['orderId' => 'o-1'], ?EventMetadata $metadata = null, ?string $occurredAt = null): NewEvent + { + return new NewEvent($type, $payload, 1, $metadata ?? new EventMetadata, occurredAt: $occurredAt === null ? null : new DateTimeImmutable($occurredAt, new DateTimeZone('UTC'))); + } + + /** + * @param list $events + * @return list + */ + public static function ids(array $events): array + { + return array_map(static fn (StoredEvent $event): string => $event->aggregateId, $events); + } + + /** Builds the store under test through the case's factory, and refuses anything that is not an EventStore. */ + private static function store(Closure $make, ?UpcasterChain $upcasters = null): EventStore + { + $store = $make($upcasters); + if (! $store instanceof EventStore) { + throw new \LogicException('The contract factory must build an EventStore, got '.get_debug_type($store).'.'); + } + + return $store; + } + + private static function conflict(Closure $append): ConcurrencyException + { + try { + $append(); + } catch (ConcurrencyException $conflict) { + return $conflict; + } + + throw new \LogicException('The append was expected to conflict.'); + } + + /** + * @return array + */ + public static function cases(): array + { + return [ + 'appends and loads a stream in version order with its payload and metadata intact' => static function (Closure $make): void { + $store = self::store($make); + $stored = $store->append('order-1', 'order', [ + self::event('order.placed', ['orderId' => 'order-1', 'lines' => [['sku' => 'A', 'qty' => 2]], 'note' => 'café'], new EventMetadata('corr-1', tenantId: 'acme'), '2026-10-13 10:00:00.123456'), + self::event('order.paid', ['amount' => 12.5]), + ], ExpectedVersion::NO_STREAM); + $stream = $store->load('order-1'); + + expect(array_map(static fn (StoredEvent $e): int => $e->aggregateVersion, $stored))->toBe([1, 2]) + ->and(array_map(static fn (StoredEvent $e): array => [$e->eventType, $e->aggregateVersion, $e->payload], $stream->events))->toBe([ + ['order.placed', 1, ['orderId' => 'order-1', 'lines' => [['sku' => 'A', 'qty' => 2]], 'note' => 'café']], + ['order.paid', 2, ['amount' => 12.5]], + ]) + ->and($stream->events[0]->metadata)->toEqual(new EventMetadata('corr-1', tenantId: 'acme')) + ->and($stream->events[0]->eventId)->toBe($stored[0]->eventId) + ->and($stream->events[0]->occurredAt->format('Y-m-d H:i:s.u'))->toBe('2026-10-13 10:00:00.123456') + ->and($stream->aggregateType())->toBe('order') + ->and($store->version('order-1'))->toBe(2) + ->and($store->exists('order-1'))->toBeTrue() + ->and($store->exists('order-2'))->toBeFalse() + ->and($store->version('order-2'))->toBe(0); + }, + 'refuses a stale expected version and leaves the stream as it was' => static function (Closure $make): void { + $store = self::store($make); + $store->append('order-1', 'order', [self::event(), self::event('order.paid')], 0); + + $conflict = self::conflict(static fn () => $store->append('order-1', 'order', [self::event('order.shipped')], 1)); + + expect([$conflict->aggregateId, $conflict->expectedVersion, $conflict->actualVersion])->toBe(['order-1', 1, 2]) + ->and($conflict->httpStatus())->toBe(409) + ->and($store->version('order-1'))->toBe(2) + ->and($store->load('order-1')->events)->toHaveCount(2); + }, + 'refuses an expected version ahead of the stream before writing anything' => static function (Closure $make): void { + $store = self::store($make); + + $conflict = self::conflict(static fn () => $store->append('order-9', 'order', [self::event()], 5)); + + expect([$conflict->expectedVersion, $conflict->actualVersion])->toBe([5, 0]) + ->and($store->exists('order-9'))->toBeFalse(); + }, + 'appends at the head with ANY and refuses NO_STREAM on a stream that exists' => static function (Closure $make): void { + $store = self::store($make); + $store->append('order-1', 'order', [self::event()], ExpectedVersion::ANY); + $store->append('order-1', 'order', [self::event('order.paid')], ExpectedVersion::ANY); + + expect($store->version('order-1'))->toBe(2) + ->and(self::conflict(static fn () => $store->append('order-1', 'order', [self::event()], ExpectedVersion::NO_STREAM))->actualVersion)->toBe(2); + }, + 'refuses another aggregate type on an existing id' => static function (Closure $make): void { + $store = self::store($make); + $store->append('shared-1', 'order', [self::event()], 0); + + expect(fn () => $store->append('shared-1', 'invoice', [self::event('invoice.raised')], ExpectedVersion::ANY)) + ->toThrow(StreamTypeMismatchException::class, 'shared-1'); + }, + 'loads a slice of a stream between two versions' => static function (Closure $make): void { + $store = self::store($make); + $store->append('order-1', 'order', array_map(static fn (int $n): NewEvent => self::event('order.touched', ['n' => $n]), range(1, 5)), 0); + + expect(array_map(static fn (StoredEvent $e): int => $e->aggregateVersion, $store->load('order-1', 2, 4)->events))->toBe([2, 3, 4]) + ->and($store->load('order-1', 6)->isEmpty())->toBeTrue(); + }, + 'reads the global stream in commit order, paged after a position' => static function (Closure $make): void { + $store = self::store($make); + $store->append('a', 'order', [self::event(), self::event('order.paid')], 0); + $store->append('b', 'order', [self::event()], 0); + $store->append('a', 'order', [self::event('order.shipped')], 2); + + $all = $store->readAll(0, 100); + $positions = array_map(static fn (StoredEvent $e): ?int => $e->globalPosition, $all); + + expect(self::ids($all))->toBe(['a', 'a', 'b', 'a']) + ->and($positions)->toBe([1, 2, 3, 4]) + ->and(array_map(static fn (StoredEvent $e): ?int => $e->globalPosition, $store->readAll(2, 1)))->toBe([3]) + ->and($store->readAll(4))->toBe([]) + ->and($store->currentPosition())->toBe(4); + }, + 'filters the global stream by event type, aggregate type, time range and tenant' => static function (Closure $make): void { + $store = self::store($make); + $store->append('o-1', 'order', [self::event('order.placed', metadata: new EventMetadata(tenantId: 'acme'), occurredAt: '2026-10-13 09:00:00')], 0); + $store->append('i-1', 'invoice', [self::event('invoice.raised', metadata: new EventMetadata(tenantId: 'globex'), occurredAt: '2026-10-13 10:00:00')], 0); + $store->append('o-2', 'order', [self::event('order.paid', metadata: new EventMetadata(tenantId: 'acme'), occurredAt: '2026-10-13 11:00:00')], 0); + + expect(self::ids($store->readAll(0, 100, EventFilter::ofEventTypes('order.paid', 'invoice.raised'))))->toBe(['i-1', 'o-2']) + ->and(self::ids($store->readAll(0, 100, EventFilter::ofAggregateTypes('order'))))->toBe(['o-1', 'o-2']) + ->and(self::ids($store->readAll(0, 100, EventFilter::between(new DateTimeImmutable('2026-10-13 09:30:00', new DateTimeZone('UTC')), new DateTimeImmutable('2026-10-13 11:00:00', new DateTimeZone('UTC'))))))->toBe(['i-1']) + ->and(self::ids($store->readAll(0, 100, new EventFilter(tenantId: 'acme'))))->toBe(['o-1', 'o-2']); + }, + 'hands every read through the upcasters' => static function (Closure $make): void { + $store = self::store($make, new UpcasterChain([new CallbackEventUpcaster('order.placed', 1, static fn (array $p): array => [...$p, 'currency' => 'EUR'])])); + $store->append('order-1', 'order', [self::event('order.placed', ['total' => 5])], 0); + + $loaded = $store->load('order-1')->events[0]; + $read = $store->readAll()[0]; + + expect([$loaded->eventVersion, $loaded->payload])->toBe([2, ['total' => 5, 'currency' => 'EUR']]) + ->and([$read->eventVersion, $read->payload])->toBe([2, ['total' => 5, 'currency' => 'EUR']]); + }, + 'treats an empty append as a no-op and reports itself healthy' => static function (Closure $make): void { + $store = self::store($make); + + expect($store->append('order-1', 'order', [], 0))->toBe([]) + ->and($store->exists('order-1'))->toBeFalse() + ->and($store->currentPosition())->toBe(0) + ->and($store->isHealthy())->toBeTrue(); + }, + ]; + } +} From 8804e0adcaf28f2992ade069716449b499e01917 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:57:14 -0700 Subject: [PATCH 06/33] fix(eventsourcing): match the readAll filter against the stored event before upcasting --- .../eventsourcing/src/Store/EventFilter.php | 4 +++- .../eventsourcing/src/Store/EventStore.php | 6 +++++- .../src/Store/MemoryEventStore.php | 21 ++++++++++++------- .../tests/Support/EventStoreContract.php | 13 ++++++++++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/eventsourcing/src/Store/EventFilter.php b/packages/eventsourcing/src/Store/EventFilter.php index b109a2c6..fa8e8855 100644 --- a/packages/eventsourcing/src/Store/EventFilter.php +++ b/packages/eventsourcing/src/Store/EventFilter.php @@ -10,7 +10,9 @@ /** * Narrows a global read: any of the event types, any of the aggregate types, occurredAt in [from, before), and one * tenant. Empty lists and nulls do not narrow. The database store turns each member into a WHERE; matches() is the - * same rule for the memory store. + * same rule for the memory store. The filter matches the STORED type and metadata, before upcasting: a WHERE can + * only see the stored columns, so an event whose upcaster renames it is found by its stored name, never by the + * name it is read as. */ final readonly class EventFilter { diff --git a/packages/eventsourcing/src/Store/EventStore.php b/packages/eventsourcing/src/Store/EventStore.php index 7e9b01ed..2018c014 100644 --- a/packages/eventsourcing/src/Store/EventStore.php +++ b/packages/eventsourcing/src/Store/EventStore.php @@ -36,7 +36,11 @@ public function version(string $aggregateId): int; public function exists(string $aggregateId): bool; - /** @return list */ + /** + * The filter matches the stored type and metadata, before upcasting; only the matching events are upcast. + * + * @return list + */ public function readAll(int $afterPosition = 0, int $limit = 500, ?EventFilter $filter = null): array; /** The highest global position given so far (0 for an empty store). */ diff --git a/packages/eventsourcing/src/Store/MemoryEventStore.php b/packages/eventsourcing/src/Store/MemoryEventStore.php index 0b333189..f9016a2a 100644 --- a/packages/eventsourcing/src/Store/MemoryEventStore.php +++ b/packages/eventsourcing/src/Store/MemoryEventStore.php @@ -21,7 +21,9 @@ * firefly.eventsourcing.store.provider=memory: one process, lost on exit. It is the reference behavior of the * contract, and it stores what the database stores: every event goes through the PayloadNormalizer and the * EventEnvelopeCodec on the way in and is decoded and upcast on the way out. A payload that works here therefore - * works on a database too. One process is its own commit order, so global positions are given at append. + * works on a database too. readAll() matches its EventFilter against the stored, pre-upcast event and upcasts only + * the matches, as the database's WHERE does. One process is its own commit order, so global positions are given at + * append. */ final class MemoryEventStore implements EventStore { @@ -87,7 +89,7 @@ public function load(string $aggregateId, int $fromVersion = 1, ?int $toVersion foreach ($this->streams[$aggregateId] ?? [] as $record) { $version = $record['event']->aggregateVersion; if ($version >= $fromVersion && ($toVersion === null || $version <= $toVersion)) { - $events[] = $this->read($record); + $events[] = $this->upcasters->apply($this->decode($record)); } } @@ -109,9 +111,9 @@ public function readAll(int $afterPosition = 0, int $limit = 500, ?EventFilter $ $events = []; for ($position = max(0, $afterPosition); $position < count($this->global) && count($events) < max(1, $limit); $position++) { [$aggregateId, $index] = $this->global[$position]; - $event = $this->read($this->streams[$aggregateId][$index]); + $event = $this->decode($this->streams[$aggregateId][$index]); if ($filter === null || $filter->matches($event)) { - $events[] = $event; + $events[] = $this->upcasters->apply($event); } } @@ -128,13 +130,18 @@ public function isHealthy(): bool return true; } - /** @param array{event: StoredEvent, raw: string} $record */ - private function read(array $record): StoredEvent + /** + * The event as the database would hand it back, before upcasting: the stored type, version and metadata the + * EventFilter matches, exactly as the database store's WHERE does. + * + * @param array{event: StoredEvent, raw: string} $record + */ + private function decode(array $record): StoredEvent { $decoded = $this->codec->decode($record['raw']); $event = $record['event']; - return $this->upcasters->apply(new StoredEvent($event->eventId, $event->aggregateId, $event->aggregateType, $event->aggregateVersion, $event->globalPosition, $event->eventType, $event->eventVersion, $decoded['payload'], $decoded['metadata'], $event->occurredAt, $event->recordedAt)); + return new StoredEvent($event->eventId, $event->aggregateId, $event->aggregateType, $event->aggregateVersion, $event->globalPosition, $event->eventType, $event->eventVersion, $decoded['payload'], $decoded['metadata'], $event->occurredAt, $event->recordedAt); } /** @return array */ diff --git a/packages/eventsourcing/tests/Support/EventStoreContract.php b/packages/eventsourcing/tests/Support/EventStoreContract.php index 40439c3a..3f85c15d 100644 --- a/packages/eventsourcing/tests/Support/EventStoreContract.php +++ b/packages/eventsourcing/tests/Support/EventStoreContract.php @@ -169,6 +169,19 @@ public static function cases(): array expect([$loaded->eventVersion, $loaded->payload])->toBe([2, ['total' => 5, 'currency' => 'EUR']]) ->and([$read->eventVersion, $read->payload])->toBe([2, ['total' => 5, 'currency' => 'EUR']]); }, + 'filters the global stream on the stored type, before a renaming upcaster runs' => static function (Closure $make): void { + $store = self::store($make, new UpcasterChain([new CallbackEventUpcaster('order.created', 1, static fn (array $p): array => $p, toType: 'order.placed')])); + $store->append('o-1', 'order', [self::event('order.created')], 0); + $store->append('o-2', 'order', [self::event('order.placed')], 0); + + $byStoredName = $store->readAll(0, 100, EventFilter::ofEventTypes('order.created')); + $byReadName = $store->readAll(0, 100, EventFilter::ofEventTypes('order.placed')); + + expect(self::ids($byStoredName))->toBe(['o-1']) + ->and(array_map(static fn (StoredEvent $e): array => [$e->eventType, $e->eventVersion], $byStoredName))->toBe([['order.placed', 2]]) + ->and(self::ids($byReadName))->toBe(['o-2']) + ->and(array_map(static fn (StoredEvent $e): string => $e->eventType, $store->readAll(0, 100)))->toBe(['order.placed', 'order.placed']); + }, 'treats an empty append as a no-op and reports itself healthy' => static function (Closure $make): void { $store = self::store($make); From 011f742313c60c468fe74d6e2bd0fcec3524bba3 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:01:52 -0700 Subject: [PATCH 07/33] feat(eventsourcing): add the database event store, numbered in commit order and translating racing appends into ConcurrencyException --- ...00_create_firefly_eventsourcing_tables.php | 31 ++ .../src/Store/CommitOrderNumbering.php | 104 +++++++ .../src/Store/DatabaseEventStore.php | 283 ++++++++++++++++++ .../src/Store/EventSourcingSchema.php | 181 +++++++++++ .../tests/Store/CommitOrderNumberingTest.php | 46 +++ .../tests/Store/DatabaseEventStoreTest.php | 60 ++++ .../tests/Support/EventSourcingDatabase.php | 78 +++++ .../tests/Support/EventStoreContract.php | 8 + 8 files changed, 791 insertions(+) create mode 100644 packages/eventsourcing/database/migrations/2026_10_13_000000_create_firefly_eventsourcing_tables.php create mode 100644 packages/eventsourcing/src/Store/CommitOrderNumbering.php create mode 100644 packages/eventsourcing/src/Store/DatabaseEventStore.php create mode 100644 packages/eventsourcing/src/Store/EventSourcingSchema.php create mode 100644 packages/eventsourcing/tests/Store/CommitOrderNumberingTest.php create mode 100644 packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php create mode 100644 packages/eventsourcing/tests/Support/EventSourcingDatabase.php diff --git a/packages/eventsourcing/database/migrations/2026_10_13_000000_create_firefly_eventsourcing_tables.php b/packages/eventsourcing/database/migrations/2026_10_13_000000_create_firefly_eventsourcing_tables.php new file mode 100644 index 00000000..51fb2910 --- /dev/null +++ b/packages/eventsourcing/database/migrations/2026_10_13_000000_create_firefly_eventsourcing_tables.php @@ -0,0 +1,31 @@ +getConnection())); + } + + public function down(): void + { + EventSourcingSchema::drop(Schema::connection($this->getConnection())); + } +}; diff --git a/packages/eventsourcing/src/Store/CommitOrderNumbering.php b/packages/eventsourcing/src/Store/CommitOrderNumbering.php new file mode 100644 index 00000000..a03f27f3 --- /dev/null +++ b/packages/eventsourcing/src/Store/CommitOrderNumbering.php @@ -0,0 +1,104 @@ +readCommitted = DatabaseCapabilities::fromVersion((string) $connection->getDriverName(), '')->isMySqlFamily(); + } + + /** Numbers every committed, unnumbered event; returns how many it numbered. */ + public function number(): int + { + if ($this->connection->transactionLevel() > 0 || ! $this->pending()) { + return 0; + } + + $total = 0; + do { + $numbered = $this->round(); + $total += $numbered; + } while ($numbered === $this->batchSize); + + return $total; + } + + public function pending(): bool + { + return EventSourcingSchema::readPast($this->connection->table(EventSourcingSchema::EVENTS)->whereNull('global_position'), $this->connection)->exists(); + } + + private function round(): int + { + if ($this->readCommitted) { + $this->connection->unprepared('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); + } + + return $this->connection->transaction(function (): int { + $now = EventSourcingSchema::instant(new DateTimeImmutable); + $this->connection->table(EventSourcingSchema::HEAD)->where('name', EventSourcingSchema::GLOBAL_STREAM)->update(['updated_at' => $now]); + + $head = $this->connection->table(EventSourcingSchema::HEAD)->where('name', EventSourcingSchema::GLOBAL_STREAM)->value('position'); + if ($head === null) { + $this->connection->table(EventSourcingSchema::HEAD)->insert(['name' => EventSourcingSchema::GLOBAL_STREAM, 'position' => 0, 'updated_at' => $now]); + $head = 0; + } + $position = is_numeric($head) ? (int) $head : 0; + + $ids = EventSourcingSchema::readPast( + $this->connection->table(EventSourcingSchema::EVENTS)->whereNull('global_position')->orderBy('id')->limit($this->batchSize), + $this->connection, + )->pluck('id')->all(); + + foreach ($ids as $id) { + $this->connection->table(EventSourcingSchema::EVENTS)->where('id', $id)->update(['global_position' => ++$position]); + } + if ($ids !== []) { + $this->connection->table(EventSourcingSchema::HEAD)->where('name', EventSourcingSchema::GLOBAL_STREAM)->update(['position' => $position]); + } + + return count($ids); + }); + } +} diff --git a/packages/eventsourcing/src/Store/DatabaseEventStore.php b/packages/eventsourcing/src/Store/DatabaseEventStore.php new file mode 100644 index 00000000..bd2e9bc1 --- /dev/null +++ b/packages/eventsourcing/src/Store/DatabaseEventStore.php @@ -0,0 +1,283 @@ + */ + private const array COLUMNS = ['event_id', 'aggregate_id', 'aggregate_type', 'aggregate_version', 'global_position', 'event_type', 'event_version', 'payload', 'occurred_at', 'recorded_at']; + + private readonly CommitOrderNumbering $numbering; + + private readonly PersistenceExceptionTranslator $translator; + + /** + * @param (Closure(): DateTimeImmutable)|null $clock + */ + public function __construct( + private readonly Connection $connection, + private readonly EventEnvelopeCodec $codec = new EventEnvelopeCodec, + private readonly UpcasterChain $upcasters = new UpcasterChain, + private readonly EventStorePublisher $publisher = new NoOpEventStorePublisher, + int $batchSize = 500, + private readonly ?Closure $clock = null, + ) { + $this->numbering = new CommitOrderNumbering($connection, max(1, $batchSize)); + $this->translator = new PersistenceExceptionTranslator(true); + } + + public function connection(): Connection + { + return $this->connection; + } + + public function append(string $aggregateId, string $aggregateType, array $events, int $expectedVersion = ExpectedVersion::ANY): array + { + if ($events === []) { + return []; + } + + EventSourcingSchema::fit('aggregateId', $aggregateId, EventSourcingSchema::ID_LENGTH); + EventSourcingSchema::fit('aggregateType', $aggregateType, EventSourcingSchema::TYPE_LENGTH); + $prepared = []; + foreach ($events as $event) { + EventSourcingSchema::fit('eventType', $event->eventType, EventSourcingSchema::TYPE_LENGTH); + EventSourcingSchema::fit('eventId', $event->eventId, EventSourcingSchema::EVENT_ID_LENGTH); + EventSourcingSchema::fit('tenantId', $event->metadata->tenantId, EventSourcingSchema::TENANT_LENGTH); + EventSourcingSchema::fit('correlationId', $event->metadata->correlationId, EventSourcingSchema::CORRELATION_LENGTH); + /** @var array $payload */ + $payload = PayloadNormalizer::normalize($event->payload); + $normalized = new NewEvent($event->eventType, $payload, $event->eventVersion, $event->metadata, $event->eventId, $event->occurredAt); + $prepared[] = [$normalized, $this->codec->encode($normalized, $aggregateType)]; + } + + try { + /** @var list $stored */ + $stored = $this->connection->transaction(function () use ($aggregateId, $aggregateType, $prepared, $expectedVersion): array { + $base = $this->checkHead($aggregateId, $aggregateType, $expectedVersion); + $recordedAt = $this->now(); + $rows = []; + $stored = []; + foreach ($prepared as $offset => [$event, $raw]) { + $version = $base + $offset + 1; + $rows[] = [ + 'event_id' => $event->eventId, + 'aggregate_id' => $aggregateId, + 'aggregate_type' => $aggregateType, + 'aggregate_version' => $version, + 'global_position' => null, + 'event_type' => $event->eventType, + 'event_version' => $event->eventVersion, + 'tenant_id' => $event->metadata->tenantId, + 'correlation_id' => $event->metadata->correlationId, + 'payload' => $raw, + 'occurred_at' => EventSourcingSchema::instant($event->occurredAt), + 'recorded_at' => EventSourcingSchema::instant($recordedAt), + ]; + $stored[] = new StoredEvent($event->eventId, $aggregateId, $aggregateType, $version, null, $event->eventType, $event->eventVersion, $event->payload, $event->metadata, $event->occurredAt, $recordedAt); + } + + foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) { + $this->connection->table(EventSourcingSchema::EVENTS)->insert($chunk); + } + + $this->publisher->inTransaction($this->connection, $stored); + + return $stored; + }); + } catch (Throwable $failure) { + $translated = $this->translator->translate($failure, (string) $this->connection->getDriverName()); + + throw $translated instanceof DuplicateKeyException + ? ConcurrencyException::conflict($aggregateId, $expectedVersion, $this->version($aggregateId), $translated) + : $translated; + } + + $this->publisher->afterTransaction($this->connection, $stored); + + return $stored; + } + + public function load(string $aggregateId, int $fromVersion = 1, ?int $toVersion = null): EventStream + { + $query = $this->connection->table(EventSourcingSchema::EVENTS) + ->where('aggregate_id', $aggregateId) + ->where('aggregate_version', '>=', max(1, $fromVersion)); + if ($toVersion !== null) { + $query->where('aggregate_version', '<=', $toVersion); + } + + return new EventStream($aggregateId, $this->hydrate($query->orderBy('aggregate_version')->get(self::COLUMNS))); + } + + public function version(string $aggregateId): int + { + return self::int($this->connection->table(EventSourcingSchema::EVENTS)->where('aggregate_id', $aggregateId)->max('aggregate_version')); + } + + public function exists(string $aggregateId): bool + { + return $this->version($aggregateId) > 0; + } + + public function readAll(int $afterPosition = 0, int $limit = 500, ?EventFilter $filter = null): array + { + $this->numbering->number(); + + $query = $this->connection->table(EventSourcingSchema::EVENTS) + ->whereNotNull('global_position') + ->where('global_position', '>', max(0, $afterPosition)); + if ($filter !== null) { + if ($filter->eventTypes !== []) { + $query->whereIn('event_type', $filter->eventTypes); + } + if ($filter->aggregateTypes !== []) { + $query->whereIn('aggregate_type', $filter->aggregateTypes); + } + if ($filter->occurredFrom !== null) { + $query->where('occurred_at', '>=', EventSourcingSchema::instant($filter->occurredFrom)); + } + if ($filter->occurredBefore !== null) { + $query->where('occurred_at', '<', EventSourcingSchema::instant($filter->occurredBefore)); + } + if ($filter->tenantId !== null) { + $query->where('tenant_id', $filter->tenantId); + } + } + + return $this->hydrate(EventSourcingSchema::readPast($query, $this->connection)->orderBy('global_position')->limit(max(1, $limit))->get(self::COLUMNS)); + } + + public function currentPosition(): int + { + $this->numbering->number(); + + return self::int(EventSourcingSchema::readPast($this->connection->table(EventSourcingSchema::EVENTS), $this->connection)->max('global_position')); + } + + public function isHealthy(): bool + { + try { + EventSourcingSchema::readPast($this->connection->table(EventSourcingSchema::EVENTS), $this->connection)->limit(1)->get(['id']); + + return true; + } catch (Throwable) { + return false; + } + } + + /** The version a new event goes after, or a refusal; see the class docblock. */ + private function checkHead(string $aggregateId, string $aggregateType, int $expected): int + { + $head = $this->connection->table(EventSourcingSchema::EVENTS) + ->where('aggregate_id', $aggregateId) + ->orderByDesc('aggregate_version') + ->limit(1) + ->first(['aggregate_version', 'aggregate_type']); + $row = $head === null ? null : (array) $head; + $version = $row === null ? 0 : self::int($row['aggregate_version'] ?? 0); + + if ($row !== null && self::string($row['aggregate_type'] ?? '') !== $aggregateType) { + throw StreamTypeMismatchException::of($aggregateId, self::string($row['aggregate_type'] ?? ''), $aggregateType); + } + if ($expected === ExpectedVersion::ANY) { + return $version; + } + if ($expected < 0) { + throw new EventSourcingException("An expected version is ExpectedVersion::ANY or at least 0, got {$expected}.", 'EVENT_STORE_EXPECTED_VERSION', 422); + } + if ($expected > $version) { + throw ConcurrencyException::conflict($aggregateId, $expected, $version); + } + + return $expected; + } + + /** + * @param Collection $rows + * @return list + */ + private function hydrate(Collection $rows): array + { + $events = []; + foreach ($rows as $object) { + $row = (array) $object; + $decoded = $this->codec->decode(self::string($row['payload'] ?? '')); + $position = $row['global_position'] ?? null; + $events[] = $this->upcasters->apply(new StoredEvent( + self::string($row['event_id'] ?? ''), + self::string($row['aggregate_id'] ?? ''), + self::string($row['aggregate_type'] ?? ''), + self::int($row['aggregate_version'] ?? 0), + $position === null ? null : self::int($position), + self::string($row['event_type'] ?? ''), + self::int($row['event_version'] ?? 1), + $decoded['payload'], + $decoded['metadata'], + EventSourcingSchema::parseInstant(self::string($row['occurred_at'] ?? '')), + EventSourcingSchema::parseInstant(self::string($row['recorded_at'] ?? '')), + )); + } + + return $events; + } + + private function now(): DateTimeImmutable + { + return ($this->clock === null ? new DateTimeImmutable : ($this->clock)())->setTimezone(new DateTimeZone('UTC')); + } + + private static function int(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } + + private static function string(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } +} diff --git a/packages/eventsourcing/src/Store/EventSourcingSchema.php b/packages/eventsourcing/src/Store/EventSourcingSchema.php new file mode 100644 index 00000000..c224caee --- /dev/null +++ b/packages/eventsourcing/src/Store/EventSourcingSchema.php @@ -0,0 +1,181 @@ + $length) { + throw new EventSourcingException("{$field} is ".mb_strlen($value, 'UTF-8')." characters long; the eventsourcing tables hold at most {$length}.", 'EVENT_STORE_VALUE_TOO_LONG', 422); + } + } + + public static function instant(DateTimeImmutable $at): string + { + return $at->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); + } + + public static function parseInstant(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } + + /** + * `WITH (READPAST)` on SQL Server, where a plain read waits on another session's uncommitted row; nothing elsewhere. + * Only a Laravel Connection names its driver, so anything else reads plainly. + */ + public static function readPast(QueryBuilder $query, ConnectionInterface $connection): QueryBuilder + { + $readPast = $connection instanceof Connection && DatabaseCapabilities::fromVersion($connection->getDriverName(), '')->supportsReadPast(); + + return $readPast ? $query->lock('with(readpast)') : $query; + } + + public static function create(Builder $schema): void + { + $collation = self::binaryCollation($schema); + $binary = static function (ColumnDefinition $column) use ($collation): ColumnDefinition { + if ($collation === self::SQL_SERVER_BINARY) { + $column->collation($collation); + } elseif ($collation !== null) { + $column->charset('utf8mb4')->collation($collation); + } + + return $column; + }; + + if (! $schema->hasTable(self::EVENTS)) { + $schema->create(self::EVENTS, static function (Blueprint $table) use ($binary): void { + $table->bigIncrements('id'); + $binary($table->string('event_id', self::EVENT_ID_LENGTH))->unique('firefly_eventsourcing_events_event_id'); + $binary($table->string('aggregate_id', self::ID_LENGTH)); + $binary($table->string('aggregate_type', self::TYPE_LENGTH)); + $table->bigInteger('aggregate_version'); + $table->bigInteger('global_position')->nullable(); + $binary($table->string('event_type', self::TYPE_LENGTH)); + $table->unsignedInteger('event_version')->default(1); + $binary($table->string('tenant_id', self::TENANT_LENGTH))->nullable(); + $table->string('correlation_id', self::CORRELATION_LENGTH)->nullable(); + $table->longText('payload'); + $table->dateTime('occurred_at', 6); + $table->dateTime('recorded_at', 6); + $table->unique(['aggregate_id', 'aggregate_version'], 'firefly_eventsourcing_events_stream'); + $table->index(['global_position'], 'firefly_eventsourcing_events_position'); + $table->index(['event_type', 'global_position'], 'firefly_eventsourcing_events_type'); + $table->index(['aggregate_type', 'global_position'], 'firefly_eventsourcing_events_aggregate_type'); + $table->index(['tenant_id', 'global_position'], 'firefly_eventsourcing_events_tenant'); + $table->index(['occurred_at'], 'firefly_eventsourcing_events_occurred'); + }); + } + + if (! $schema->hasTable(self::HEAD)) { + $schema->create(self::HEAD, static function (Blueprint $table) use ($binary): void { + $binary($table->string('name', 64))->primary(); + $table->bigInteger('position')->default(0); + $table->dateTime('updated_at', 6); + }); + } + $connection = $schema->getConnection(); + if (! $connection->table(self::HEAD)->where('name', self::GLOBAL_STREAM)->exists()) { + $connection->table(self::HEAD)->insert(['name' => self::GLOBAL_STREAM, 'position' => 0, 'updated_at' => self::instant(new DateTimeImmutable)]); + } + + if (! $schema->hasTable(self::SNAPSHOTS)) { + $schema->create(self::SNAPSHOTS, static function (Blueprint $table) use ($binary): void { + $binary($table->string('aggregate_id', self::ID_LENGTH)); + $table->bigInteger('aggregate_version'); + $binary($table->string('aggregate_type', self::TYPE_LENGTH)); + $table->unsignedInteger('state_version')->default(1); + $table->longText('state'); + $table->dateTime('created_at', 6); + $table->primary(['aggregate_id', 'aggregate_version'], 'firefly_eventsourcing_snapshots_pk'); + }); + } + + if (! $schema->hasTable(self::CHECKPOINTS)) { + $schema->create(self::CHECKPOINTS, static function (Blueprint $table) use ($binary): void { + $binary($table->string('projection', self::NAME_LENGTH))->primary(); + $table->bigInteger('position')->default(0); + $table->dateTime('updated_at', 6); + $table->text('last_error')->nullable(); + $table->dateTime('failed_at', 6)->nullable(); + }); + } + } + + public static function drop(Builder $schema): void + { + $schema->dropIfExists(self::CHECKPOINTS); + $schema->dropIfExists(self::SNAPSHOTS); + $schema->dropIfExists(self::HEAD); + $schema->dropIfExists(self::EVENTS); + } + + private static function binaryCollation(Builder $schema): ?string + { + $connection = $schema->getConnection(); + if ($connection instanceof SqlServerConnection) { + return self::SQL_SERVER_BINARY; + } + if (! $connection instanceof MySqlConnection) { + return null; + } + + $modern = version_compare($connection->getServerVersion(), $connection->isMaria() ? '10.2.2' : '8.0.1', '>='); + + return $modern ? ($connection->isMaria() ? 'utf8mb4_nopad_bin' : 'utf8mb4_0900_bin') : 'utf8mb4_bin'; + } +} diff --git a/packages/eventsourcing/tests/Store/CommitOrderNumberingTest.php b/packages/eventsourcing/tests/Store/CommitOrderNumberingTest.php new file mode 100644 index 00000000..43fb046c --- /dev/null +++ b/packages/eventsourcing/tests/Store/CommitOrderNumberingTest.php @@ -0,0 +1,46 @@ +append('a', 'order', [EventStoreContract::event(), EventStoreContract::event('order.paid')], 0); + + expect(array_map(static fn (StoredEvent $e): ?int => $e->globalPosition, $stored))->toBe([null, null]) + ->and($connection->table(EventSourcingSchema::EVENTS)->whereNull('global_position')->count())->toBe(2) + ->and(array_map(static fn (StoredEvent $e): ?int => $e->globalPosition, $store->readAll()))->toBe([1, 2]) + ->and($connection->table(EventSourcingSchema::HEAD)->value('position'))->toBe(2); +}); + +it('never numbers inside the caller\'s open transaction, so the head lock is never held for a unit of work', function (): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + $store = new DatabaseEventStore($connection); + + $connection->beginTransaction(); + $store->append('a', 'order', [EventStoreContract::event()], 0); + $inside = $store->readAll(); + $connection->commit(); + + expect($inside)->toBe([]) + ->and(EventStoreContract::ids($store->readAll()))->toBe(['a']); +}); + +it('numbers in rounds of the batch size, keeping each aggregate in version order', function (): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + $store = new DatabaseEventStore($connection, batchSize: 2); + $store->append('a', 'order', [EventStoreContract::event(), EventStoreContract::event('order.paid')], 0); + $store->append('b', 'order', [EventStoreContract::event()], 0); + $store->append('a', 'order', [EventStoreContract::event('order.shipped'), EventStoreContract::event('order.closed')], 2); + + expect((new CommitOrderNumbering($connection, 2))->number())->toBe(5) + ->and((new CommitOrderNumbering($connection, 2))->pending())->toBeFalse() + ->and(array_map(static fn (StoredEvent $e): string => $e->aggregateId.$e->aggregateVersion, $store->readAll(0, 10)))->toBe(['a1', 'a2', 'b1', 'a3', 'a4']); +}); diff --git a/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php b/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php new file mode 100644 index 00000000..93685dec --- /dev/null +++ b/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php @@ -0,0 +1,60 @@ + $assert) { + it("SQLite database event store {$case}", function () use ($assert): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + $assert(static fn (?UpcasterChain $upcasters): EventStore => new DatabaseEventStore($connection, upcasters: $upcasters ?? new UpcasterChain)); + }); +} + +it('turns the duplicate key of a racing append into a ConcurrencyException through the data layer translator', function (): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + $store = new DatabaseEventStore($connection); + $store->append('order-1', 'order', [EventStoreContract::event()], 0); + + // A writer that loaded version 0 appends after the stream moved to 1: the head check lets a LOWER expected + // version through, so UNIQUE(aggregate_id, aggregate_version) is what refuses it. + try { + $store->append('order-1', 'order', [EventStoreContract::event('order.paid')], 0); + $conflict = null; + } catch (ConcurrencyException $caught) { + $conflict = $caught; + } + + expect($conflict)->not->toBeNull() + ->and($conflict?->getPrevious())->toBeInstanceOf(DuplicateKeyException::class) + ->and($conflict?->actualVersion)->toBe(1) + ->and($connection->table(EventSourcingSchema::EVENTS)->count())->toBe(1); +}); + +it('refuses, before any write, a value longer than its column, as every driver would have to', function (string $field, Closure $append): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + + expect(fn () => $append(new DatabaseEventStore($connection)))->toThrow(EventSourcingException::class, $field) + ->and($connection->table(EventSourcingSchema::EVENTS)->count())->toBe(0); +})->with([ + 'aggregate id' => ['aggregateId', static fn (DatabaseEventStore $s) => $s->append(str_repeat('a', 129), 'order', [EventStoreContract::event()], 0)], + 'aggregate type' => ['aggregateType', static fn (DatabaseEventStore $s) => $s->append('a', str_repeat('t', 192), [EventStoreContract::event()], 0)], + 'event type' => ['eventType', static fn (DatabaseEventStore $s) => $s->append('a', 'order', [EventStoreContract::event(str_repeat('e', 192))], 0)], +]); + +it('keeps every eventsourcing table across a second create and keeps the global head row', function (): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + EventSourcingSchema::create($connection->getSchemaBuilder()); + + expect($connection->table(EventSourcingSchema::HEAD)->pluck('position', 'name')->all())->toBe(['global' => 0]) + ->and($connection->getSchemaBuilder()->hasTable(EventSourcingSchema::SNAPSHOTS))->toBeTrue() + ->and($connection->getSchemaBuilder()->hasTable(EventSourcingSchema::CHECKPOINTS))->toBeTrue(); +}); diff --git a/packages/eventsourcing/tests/Support/EventSourcingDatabase.php b/packages/eventsourcing/tests/Support/EventSourcingDatabase.php new file mode 100644 index 00000000..51f3b0c4 --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventSourcingDatabase.php @@ -0,0 +1,78 @@ + */ + public static function lanes(): array + { + return IntegrationDsn::lanes(); + } + + /** + * The lanes that are a separate server process with row-level locking: what an interleaving of two sessions + * inside one open transaction needs (sqlite has one writer per file, so there it is only a wait). + * + * @return list + */ + public static function serverLanes(): array + { + return array_values(array_filter(IntegrationDsn::lanes(), static fn (string $lane): bool => $lane !== 'sqlite')); + } + + public static function skipReason(string $lane): ?string + { + return IntegrationDsn::skipReason($lane); + } + + public static function connection(string $lane): Connection + { + return IntegrationDsn::connect($lane, 'eventsourcing-'.$lane.'-'.bin2hex(random_bytes(3)), self::overrides($lane)); + } + + /** @return array */ + public static function config(string $lane): array + { + return IntegrationDsn::connectionConfig($lane, self::overrides($lane)); + } + + public static function sqlitePath(): string + { + return IntegrationDsn::sqlitePath('eventsourcing'); + } + + public static function sqlite(string $database = ':memory:'): Connection + { + $capsule = new Manager; + $capsule->addConnection(['driver' => 'sqlite', 'database' => $database, 'prefix' => '', 'foreign_key_constraints' => true], 'eventsourcing-sqlite'); + + return $capsule->getConnection('eventsourcing-sqlite'); + } + + public static function fresh(Connection $connection): Connection + { + EventSourcingSchema::drop($connection->getSchemaBuilder()); + EventSourcingSchema::create($connection->getSchemaBuilder()); + + return $connection; + } + + /** @return array */ + private static function overrides(string $lane): array + { + return $lane === 'sqlite' ? ['database' => self::sqlitePath()] : []; + } +} diff --git a/packages/eventsourcing/tests/Support/EventStoreContract.php b/packages/eventsourcing/tests/Support/EventStoreContract.php index 3f85c15d..fd94365d 100644 --- a/packages/eventsourcing/tests/Support/EventStoreContract.php +++ b/packages/eventsourcing/tests/Support/EventStoreContract.php @@ -159,6 +159,14 @@ public static function cases(): array ->and(self::ids($store->readAll(0, 100, EventFilter::between(new DateTimeImmutable('2026-10-13 09:30:00', new DateTimeZone('UTC')), new DateTimeImmutable('2026-10-13 11:00:00', new DateTimeZone('UTC'))))))->toBe(['i-1']) ->and(self::ids($store->readAll(0, 100, new EventFilter(tenantId: 'acme'))))->toBe(['o-1', 'o-2']); }, + 'keeps tenants that differ only in case apart, as a tenant filter must be exact' => static function (Closure $make): void { + $store = self::store($make); + $store->append('o-1', 'order', [self::event(metadata: new EventMetadata(tenantId: 'Acme'))], 0); + $store->append('o-2', 'order', [self::event(metadata: new EventMetadata(tenantId: 'acme'))], 0); + + expect(self::ids($store->readAll(0, 100, new EventFilter(tenantId: 'acme'))))->toBe(['o-2']) + ->and(self::ids($store->readAll(0, 100, new EventFilter(tenantId: 'Acme'))))->toBe(['o-1']); + }, 'hands every read through the upcasters' => static function (Closure $make): void { $store = self::store($make, new UpcasterChain([new CallbackEventUpcaster('order.placed', 1, static fn (array $p): array => [...$p, 'currency' => 'EUR'])])); $store->append('order-1', 'order', [self::event('order.placed', ['total' => 5])], 0); From c749a9b9bbae73aae46d6498f8173307c6b367ab Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:08:46 -0700 Subject: [PATCH 08/33] fix(eventsourcing): take the SQLite write lock before the head check so concurrent appenders wait instead of failing with SQLITE_BUSY --- .../src/Store/DatabaseEventStore.php | 23 +++++++++ .../tests/Store/DatabaseEventStoreTest.php | 49 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/eventsourcing/src/Store/DatabaseEventStore.php b/packages/eventsourcing/src/Store/DatabaseEventStore.php index bd2e9bc1..9f338304 100644 --- a/packages/eventsourcing/src/Store/DatabaseEventStore.php +++ b/packages/eventsourcing/src/Store/DatabaseEventStore.php @@ -40,6 +40,9 @@ * data's PersistenceExceptionTranslator, built here with translation always ON, because conflict detection must * not depend on firefly.data.exception-translation.enabled. * + * On SQLite the append first touches the head row, so it holds the database write lock before the head check (see + * lockSqliteForWrite()): two processes appending to one file wait for each other instead of failing. + * * READS: a stream load and the head check are plain reads. They must never skip a committed row, and on SQL Server * they wait for an uncommitted append of the same aggregate, which is the right answer. Global reads go through * READPAST on SQL Server. @@ -98,6 +101,7 @@ public function append(string $aggregateId, string $aggregateType, array $events try { /** @var list $stored */ $stored = $this->connection->transaction(function () use ($aggregateId, $aggregateType, $prepared, $expectedVersion): array { + $this->lockSqliteForWrite(); $base = $this->checkHead($aggregateId, $aggregateType, $expectedVersion); $recordedAt = $this->now(); $rows = []; @@ -210,6 +214,25 @@ public function isHealthy(): bool } } + /** + * On SQLite, makes the append's first statement a write, so it holds the database write lock before it reads + * the head. PDO begins a DEFERRED transaction (PHP 8.3 cannot ask for IMMEDIATE), and in WAL mode the head read + * would pin a snapshot: when another process commits before the INSERT, SQLite refuses the upgrade at once with + * SQLITE_BUSY, which no busy timeout waits out. Taking the lock first turns that into a wait. SQLite has one + * writer per database, so touching the head row adds no contention there; servers skip it, because on them it + * would serialize every append. + */ + private function lockSqliteForWrite(): void + { + if ($this->connection->getDriverName() !== 'sqlite') { + return; + } + + $this->connection->table(EventSourcingSchema::HEAD) + ->where('name', EventSourcingSchema::GLOBAL_STREAM) + ->update(['updated_at' => EventSourcingSchema::instant(new DateTimeImmutable)]); + } + /** The version a new event goes after, or a refusal; see the class docblock. */ private function checkHead(string $aggregateId, string $aggregateType, int $expected): int { diff --git a/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php b/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php index 93685dec..dbf6947a 100644 --- a/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php +++ b/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php @@ -10,7 +10,11 @@ use Firefly\EventSourcing\Tests\Support\EventSourcingDatabase; use Firefly\EventSourcing\Tests\Support\EventStoreContract; use Firefly\EventSourcing\Upcasting\UpcasterChain; +use Firefly\Kernel\Exception\Infrastructure\CannotAcquireLockException; use Firefly\Kernel\Exception\Infrastructure\DuplicateKeyException; +use Firefly\Testing\Integration\IntegrationDsn; +use Illuminate\Database\Capsule\Manager; +use Illuminate\Database\Connection; foreach (EventStoreContract::cases() as $case => $assert) { it("SQLite database event store {$case}", function () use ($assert): void { @@ -58,3 +62,48 @@ ->and($connection->getSchemaBuilder()->hasTable(EventSourcingSchema::SNAPSHOTS))->toBeTrue() ->and($connection->getSchemaBuilder()->hasTable(EventSourcingSchema::CHECKPOINTS))->toBeTrue(); }); + +it('takes the SQLite write lock before it reads the head, so a writer committing in between cannot fail the append', function (): void { + $path = IntegrationDsn::sqlitePath('eventsourcing-write-lock'); + if (is_file($path)) { + unlink($path); + } + $connect = static function (string $name, int $busyTimeoutMs) use ($path): Connection { + $capsule = new Manager; + $capsule->addConnection(['busy_timeout' => $busyTimeoutMs] + IntegrationDsn::sqlite($path), $name); + + return $capsule->getConnection($name); + }; + $connection = EventSourcingDatabase::fresh($connect('es-lock-a', 10000)); + $other = $connect('es-lock-b', 1); + $intruder = null; + + // The clock runs inside the append's transaction, after the head check: the moment a second process would + // commit its own append. A deferred BEGIN followed by a read would have pinned a WAL snapshot there, and the + // INSERT would then fail at once with SQLITE_BUSY (the busy timeout cannot help a stale snapshot). Holding + // the write lock from the first statement makes the other writer wait instead. + $store = new DatabaseEventStore($connection, clock: static function () use ($other, &$intruder): DateTimeImmutable { + if ($intruder === null) { + try { + (new DatabaseEventStore($other))->append('other-1', 'order', [EventStoreContract::event()], 0); + $intruder = 'committed'; + } catch (Throwable $refused) { + $intruder = $refused::class; + } + } + + return new DateTimeImmutable; + }); + + try { + $store->append('order-1', 'order', [EventStoreContract::event()], 0); + + expect($store->version('order-1'))->toBe(1) + ->and($intruder)->toBe(CannotAcquireLockException::class) + ->and($store->version('other-1'))->toBe(0); + } finally { + $connection->disconnect(); + $other->disconnect(); + unlink($path); + } +}); From 4ddb0088bb112e86c92565b717b6a92ffb34eedb Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:08:46 -0700 Subject: [PATCH 09/33] test(eventsourcing): prove the event store, racing appenders and late committers on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite --- .../Integration/AppendRaceIntegrationTest.php | 106 ++++++++++++++++++ .../DatabaseEventStoreIntegrationTest.php | 76 +++++++++++++ .../tests/Support/append-racer.php | 44 ++++++++ 3 files changed, 226 insertions(+) create mode 100644 packages/eventsourcing/tests/Integration/AppendRaceIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Integration/DatabaseEventStoreIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Support/append-racer.php diff --git a/packages/eventsourcing/tests/Integration/AppendRaceIntegrationTest.php b/packages/eventsourcing/tests/Integration/AppendRaceIntegrationTest.php new file mode 100644 index 00000000..f60d619d --- /dev/null +++ b/packages/eventsourcing/tests/Integration/AppendRaceIntegrationTest.php @@ -0,0 +1,106 @@ +isRunning(); + if (str_contains($process->getOutput(), 'ready')) { + return true; + } + if (! $running) { + return false; + } + usleep(1000); + } + + return false; +} + +/** + * Starts one racer per aggregate id, releases them together and returns each racer's last output line. + * + * @param list $aggregateIds + * @return list + */ +function appendRace(string $lane, string $mode, array $aggregateIds): array +{ + $go = sys_get_temp_dir().'/firefly-es-race-'.bin2hex(random_bytes(6)); + $script = dirname(__DIR__).'/Support/append-racer.php'; + $processes = []; + foreach ($aggregateIds as $id) { + $arguments = [PHP_BINARY, $script, $lane, $mode, $go, $id]; + if ($lane === 'sqlite') { + $arguments[] = EventSourcingDatabase::sqlitePath(); + } + $process = new Process($arguments, timeout: 120); + $process->start(); + $processes[] = $process; + } + + try { + foreach ($processes as $process) { + expect(appendRaceAwaitReady($process))->toBeTrue('a racer never became ready: '.$process->getErrorOutput()); + } + touch($go); + foreach ($processes as $process) { + $process->wait(); + } + } finally { + if (is_file($go)) { + unlink($go); + } + } + + return array_map(static function (Process $process): string { + $lines = array_values(array_filter(explode("\n", trim($process->getOutput())))); + + return $lines[count($lines) - 1] ?? ''; + }, $processes); +} + +foreach (EventSourcingDatabase::lanes() as $lane) { + it("lets exactly one of four racing appenders on one aggregate win on {$lane}", function () use ($lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + try { + $outcomes = appendRace($lane, 'one', ['contested', 'contested', 'contested', 'contested']); + sort($outcomes); + + expect($outcomes)->toBe(['conflict', 'conflict', 'conflict', 'won']) + ->and((new DatabaseEventStore($connection))->version('contested'))->toBe(3) + ->and(count(array_unique(array_map(static fn (StoredEvent $e): int => is_int($e->payload['by'] ?? null) ? $e->payload['by'] : -1, (new DatabaseEventStore($connection))->load('contested')->events))))->toBe(1); + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); + + it("lets racing appenders on different aggregates all win, numbered without a hole and in version order on {$lane}", function () use ($lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + try { + expect(appendRace($lane, 'many', ['r-1', 'r-2', 'r-3', 'r-4']))->toBe(['appended 25', 'appended 25', 'appended 25', 'appended 25']); + + $all = (new DatabaseEventStore($connection))->readAll(0, 1000); + $positions = array_map(static fn (StoredEvent $e): ?int => $e->globalPosition, $all); + $versionsByAggregate = []; + foreach ($all as $event) { + $versionsByAggregate[$event->aggregateId][] = $event->aggregateVersion; + } + + expect($positions)->toBe(range(1, 100)) + ->and(array_keys($versionsByAggregate))->toHaveCount(4); + foreach ($versionsByAggregate as $versions) { + expect($versions)->toBe(range(1, 25)); + } + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); +} diff --git a/packages/eventsourcing/tests/Integration/DatabaseEventStoreIntegrationTest.php b/packages/eventsourcing/tests/Integration/DatabaseEventStoreIntegrationTest.php new file mode 100644 index 00000000..0e680b96 --- /dev/null +++ b/packages/eventsourcing/tests/Integration/DatabaseEventStoreIntegrationTest.php @@ -0,0 +1,76 @@ + $assert) { + it("{$lane} database event store {$case}", function () use ($assert, $lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + try { + $assert(static fn (?UpcasterChain $upcasters): EventStore => new DatabaseEventStore($connection, upcasters: $upcasters ?? new UpcasterChain)); + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); + } + + it("keeps case-distinct ids as two streams and full-width ids on {$lane}", function () use ($lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + try { + $store = new DatabaseEventStore($connection); + $store->append('Order-1', 'order', [EventStoreContract::event()], 0); + $store->append('order-1', 'order', [EventStoreContract::event()], 0); + $wide = str_repeat('i', 128); + $store->append($wide, str_repeat('t', 191), [EventStoreContract::event(str_repeat('e', 191))], 0); + + expect($store->version('Order-1'))->toBe(1) + ->and($store->version('order-1'))->toBe(1) + ->and($store->load($wide)->events[0]->eventType)->toBe(str_repeat('e', 191)); + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); +} + +/* + * THE VISIBILITY CAVEAT, on a real server: a writer holds an append open while a reader appends and reads the + * global stream. The reader must not see the open event, must not wait for it (READPAST on SQL Server), and must + * find it ABOVE everything it already read once it commits — never below. + */ +foreach (EventSourcingDatabase::serverLanes() as $lane) { + it("never lets a reader skip an append that commits late on {$lane}", function () use ($lane): void { + $writerConnection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + $readerConnection = EventSourcingDatabase::connection($lane); + try { + $writer = new DatabaseEventStore($writerConnection); + $reader = new DatabaseEventStore($readerConnection); + + $writerConnection->beginTransaction(); + $writer->append('late', 'race', [new NewEvent('race.happened', ['n' => 1])], 0); + + $reader->append('early', 'race', [new NewEvent('race.happened', ['n' => 2])], 0); + $before = $reader->readAll(0); + + $writerConnection->commit(); + $after = $reader->readAll(1); + + expect(array_map(static fn (StoredEvent $e): array => [$e->aggregateId, $e->globalPosition], $before))->toBe([['early', 1]]) + ->and(array_map(static fn (StoredEvent $e): array => [$e->aggregateId, $e->globalPosition], $after))->toBe([['late', 2]]) + ->and($reader->currentPosition())->toBe(2); + } finally { + if ($writerConnection->transactionLevel() > 0) { + $writerConnection->rollBack(); + } + $writerConnection->disconnect(); + $readerConnection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); +} diff --git a/packages/eventsourcing/tests/Support/append-racer.php b/packages/eventsourcing/tests/Support/append-racer.php new file mode 100644 index 00000000..443e68b5 --- /dev/null +++ b/packages/eventsourcing/tests/Support/append-racer.php @@ -0,0 +1,44 @@ + $argv[5]] : [])); +$events = static fn (int $count): array => array_map(static fn (int $n): NewEvent => new NewEvent('race.happened', ['n' => $n, 'by' => getmypid()]), range(1, $count)); + +fwrite(STDOUT, "ready\n"); +$deadline = microtime(true) + 60.0; +while (! is_file($go) && microtime(true) < $deadline) { + usleep(500); +} + +try { + if ($mode === 'one') { + $store->append($aggregateId, 'race', $events(3), 0); + fwrite(STDOUT, "won\n"); + } else { + for ($batch = 0; $batch < 5; $batch++) { + $store->append($aggregateId, 'race', $events(5), $batch * 5); + } + fwrite(STDOUT, "appended 25\n"); + } +} catch (ConcurrencyException) { + fwrite(STDOUT, "conflict\n"); +} catch (Throwable $failure) { + fwrite(STDOUT, 'error '.$failure::class.': '.$failure->getMessage()."\n"); +} From fa997169bb29ff3cd4c547190b90622bb4e6ca66 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:13:50 -0700 Subject: [PATCH 10/33] feat(eventsourcing): compile #[EventType] events into a registry and map them to stored events without reflection --- packages/context/src/Scan/AppScan.php | 2 + .../src/Event/Attributes/EventType.php | 21 +++ .../eventsourcing/src/Event/EventMapper.php | 128 ++++++++++++++++++ .../src/Event/EventParameter.php | 34 +++++ .../src/Event/EventTypeDescriptor.php | 48 +++++++ .../src/Event/EventTypeRegistry.php | 71 ++++++++++ .../src/Event/SerializableEvent.php | 21 +++ .../src/EventSourcingWiringProvider.php | 27 +++- .../src/Manifest/EventSourcingManifest.php | 67 +++++++++ .../EventSourcingManifestCompiler.php | 30 ++++ .../src/Scanner/EventSourcingScanner.php | 101 ++++++++++++++ .../tests/Event/EventMapperTest.php | 51 +++++++ .../tests/Event/EventTypeRegistryTest.php | 31 +++++ .../tests/Fixtures/Events/OrderChannel.php | 11 ++ .../tests/Fixtures/Events/OrderPaid.php | 35 +++++ .../tests/Fixtures/Events/OrderPlaced.php | 29 ++++ .../tests/Fixtures/Events/Untyped.php | 10 ++ .../InvalidFixtures/DuplicateType/First.php | 11 ++ .../InvalidFixtures/DuplicateType/Second.php | 11 ++ .../InvalidFixtures/NotADomainEvent/Stray.php | 10 ++ .../Manifest/EventSourcingManifestTest.php | 57 ++++++++ .../eventsourcing/tests/PackageBootsTest.php | 7 + .../tests/ReflectionFreeEventSourcingTest.php | 5 +- 23 files changed, 813 insertions(+), 5 deletions(-) create mode 100644 packages/eventsourcing/src/Event/Attributes/EventType.php create mode 100644 packages/eventsourcing/src/Event/EventMapper.php create mode 100644 packages/eventsourcing/src/Event/EventParameter.php create mode 100644 packages/eventsourcing/src/Event/EventTypeDescriptor.php create mode 100644 packages/eventsourcing/src/Event/EventTypeRegistry.php create mode 100644 packages/eventsourcing/src/Event/SerializableEvent.php create mode 100644 packages/eventsourcing/src/Manifest/EventSourcingManifest.php create mode 100644 packages/eventsourcing/src/Manifest/EventSourcingManifestCompiler.php create mode 100644 packages/eventsourcing/src/Scanner/EventSourcingScanner.php create mode 100644 packages/eventsourcing/tests/Event/EventMapperTest.php create mode 100644 packages/eventsourcing/tests/Event/EventTypeRegistryTest.php create mode 100644 packages/eventsourcing/tests/Fixtures/Events/OrderChannel.php create mode 100644 packages/eventsourcing/tests/Fixtures/Events/OrderPaid.php create mode 100644 packages/eventsourcing/tests/Fixtures/Events/OrderPlaced.php create mode 100644 packages/eventsourcing/tests/Fixtures/Events/Untyped.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/DuplicateType/First.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/DuplicateType/Second.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/NotADomainEvent/Stray.php create mode 100644 packages/eventsourcing/tests/Manifest/EventSourcingManifestTest.php diff --git a/packages/context/src/Scan/AppScan.php b/packages/context/src/Scan/AppScan.php index d433b38a..c6bbc1e9 100644 --- a/packages/context/src/Scan/AppScan.php +++ b/packages/context/src/Scan/AppScan.php @@ -64,6 +64,8 @@ final class AppScan public const string ORCHESTRATION = 'orchestration.php'; + public const string EVENTSOURCING = 'eventsourcing.php'; + public const string SECURITY_METHODS = 'security-methods.php'; public const string TRANSACTIONAL = 'transactional.php'; diff --git a/packages/eventsourcing/src/Event/Attributes/EventType.php b/packages/eventsourcing/src/Event/Attributes/EventType.php new file mode 100644 index 00000000..a4f89b12 --- /dev/null +++ b/packages/eventsourcing/src/Event/Attributes/EventType.php @@ -0,0 +1,21 @@ + stored event, without reflection. + * + * OUT: the registered type and version, and the payload. That is the event's own toPayload() for a + * SerializableEvent. For any other event it is its PUBLIC properties minus the base's identity (eventId, + * occurredAt travel in the envelope), normalized by PayloadNormalizer. + * + * IN: the stored version (after upcasting) must be the registered one, or an upcaster is missing. A + * SerializableEvent builds itself. Any other event is constructed with named arguments from the payload, each + * coerced by the parameter type the scanner compiled: + * + * - JSON returns 12.0 as 12, so a float parameter takes an int; + * - a backed enum takes its value, a date its ISO string; + * - `eventId` and `occurredAt` come from the envelope, so a reloaded event keeps its identity when its + * constructor forwards them (the documented pattern). + */ +final class EventMapper +{ + public function __construct(private readonly EventTypeRegistry $types) {} + + public function types(): EventTypeRegistry + { + return $this->types; + } + + public function toNewEvent(DomainEvent $event, EventMetadata $metadata = new EventMetadata): NewEvent + { + $descriptor = $this->types->forClass($event::class); + + if ($event instanceof SerializableEvent) { + $payload = $event->toPayload(); + } else { + $payload = get_object_vars($event); + unset($payload['eventId'], $payload['occurredAt']); + } + + /** @var array $normalized */ + $normalized = PayloadNormalizer::normalize($payload, $descriptor->type); + + return new NewEvent($descriptor->type, $normalized, $descriptor->version, $metadata, $event->eventId, $event->occurredAt); + } + + public function toDomainEvent(StoredEvent $event): DomainEvent + { + $descriptor = $this->types->forType($event->eventType); + if ($event->eventVersion !== $descriptor->version) { + throw UnknownEventTypeException::version($event->eventType, $event->eventVersion, $descriptor->version); + } + + $class = $descriptor->class; + if ($descriptor->serializable && is_subclass_of($class, SerializableEvent::class)) { + return $class::fromPayload($event->payload, $event); + } + + $arguments = []; + foreach ($descriptor->parameters as $parameter) { + $path = $event->eventType.'.'.$parameter->name; + if (array_key_exists($parameter->name, $event->payload)) { + $arguments[$parameter->name] = self::coerce($parameter, $event->payload[$parameter->name], $path); + } elseif ($parameter->name === 'eventId') { + $arguments['eventId'] = $event->eventId; + } elseif ($parameter->name === 'occurredAt') { + $arguments['occurredAt'] = $event->occurredAt; + } elseif (! $parameter->optional) { + throw EventSerializationException::at($path, 'is missing from the stored payload and has no default; register an EventUpcaster that sets it.'); + } + } + + try { + return new $class(...$arguments); + } catch (EventSerializationException $failure) { + throw $failure; + } catch (Throwable $failure) { + throw new EventSerializationException("The stored event [{$event->eventType}] could not be built as {$class}: {$failure->getMessage()}", 'EVENT_SERIALIZATION', 500, $failure); + } + } + + private static function coerce(EventParameter $parameter, mixed $value, string $path): mixed + { + $type = $parameter->type; + if ($value === null) { + return $parameter->nullable || $type === null || $type === 'mixed' ? null : throw EventSerializationException::at($path, 'is null but its parameter does not accept null.'); + } + + return match (true) { + $type === null, $type === 'mixed' => $value, + $type === 'int' => match (true) { + is_int($value) => $value, + is_float($value) && floor($value) === $value => (int) $value, + is_string($value) && preg_match('/^-?\d+$/', $value) === 1 => (int) $value, + default => throw EventSerializationException::at($path, 'is not an int.'), + }, + $type === 'float' => is_int($value) || is_float($value) ? (float) $value : (is_string($value) && is_numeric($value) ? (float) $value : throw EventSerializationException::at($path, 'is not a number.')), + $type === 'string' => is_string($value) ? $value : (is_int($value) || is_float($value) ? (string) $value : throw EventSerializationException::at($path, 'is not a string.')), + $type === 'bool' => is_bool($value) ? $value : throw EventSerializationException::at($path, 'is not a bool.'), + $type === 'array' => is_array($value) ? $value : throw EventSerializationException::at($path, 'is not an array.'), + $type === DateTime::class => is_string($value) ? new DateTime($value) : throw EventSerializationException::at($path, 'is not a date string.'), + $type === DateTimeInterface::class || is_a($type, DateTimeImmutable::class, true) => is_string($value) ? new DateTimeImmutable($value) : throw EventSerializationException::at($path, 'is not a date string.'), + is_a($type, BackedEnum::class, true) => self::enum($type, $value, $path), + default => throw EventSerializationException::at($path, "has the type {$type}, which the convention mapper cannot build; implement SerializableEvent on the event."), + }; + } + + /** @param class-string $type */ + private static function enum(string $type, mixed $value, string $path): BackedEnum + { + if (! is_int($value) && ! is_string($value)) { + throw EventSerializationException::at($path, "is not a {$type} value."); + } + + return $type::tryFrom($value) ?? throw EventSerializationException::at($path, "is not a {$type} case."); + } +} diff --git a/packages/eventsourcing/src/Event/EventParameter.php b/packages/eventsourcing/src/Event/EventParameter.php new file mode 100644 index 00000000..6c7f0f4d --- /dev/null +++ b/packages/eventsourcing/src/Event/EventParameter.php @@ -0,0 +1,34 @@ + $this->name, 'type' => $this->type, 'nullable' => $this->nullable, 'optional' => $this->optional]; + } + + /** @param EventParameterRow $row */ + public static function fromArray(array $row): self + { + return new self($row['name'], $row['type'], $row['nullable'], $row['optional']); + } +} diff --git a/packages/eventsourcing/src/Event/EventTypeDescriptor.php b/packages/eventsourcing/src/Event/EventTypeDescriptor.php new file mode 100644 index 00000000..6ee0708b --- /dev/null +++ b/packages/eventsourcing/src/Event/EventTypeDescriptor.php @@ -0,0 +1,48 @@ +, type: string, version: int, serializable: bool, parameters: list} + */ +final readonly class EventTypeDescriptor +{ + /** + * @param class-string $class + * @param list $parameters + */ + public function __construct( + public string $class, + public string $type, + public int $version = 1, + public bool $serializable = false, + public array $parameters = [], + ) {} + + /** @return EventTypeRow */ + public function toArray(): array + { + return [ + 'class' => $this->class, + 'type' => $this->type, + 'version' => $this->version, + 'serializable' => $this->serializable, + 'parameters' => array_map(static fn (EventParameter $parameter): array => $parameter->toArray(), $this->parameters), + ]; + } + + /** @param EventTypeRow $row */ + public static function fromArray(array $row): self + { + return new self($row['class'], $row['type'], $row['version'], $row['serializable'], array_map(static fn (array $parameter): EventParameter => EventParameter::fromArray($parameter), $row['parameters'])); + } +} diff --git a/packages/eventsourcing/src/Event/EventTypeRegistry.php b/packages/eventsourcing/src/Event/EventTypeRegistry.php new file mode 100644 index 00000000..307c11fd --- /dev/null +++ b/packages/eventsourcing/src/Event/EventTypeRegistry.php @@ -0,0 +1,71 @@ + class for every event the store may hold: the compiled #[EventType] manifest plus framework events + * registered by hand (the orchestration provider's). A type is one class and a class one type, refused at boot + * otherwise. Re-registering the same descriptor is harmless (a manifest and a hand registration may meet). + */ +final class EventTypeRegistry +{ + /** @var array */ + private array $byType = []; + + /** @var array */ + private array $byClass = []; + + /** + * @param iterable $descriptors + */ + public function __construct(iterable $descriptors = []) + { + foreach ($descriptors as $descriptor) { + $this->register($descriptor); + } + } + + public function register(EventTypeDescriptor $descriptor): void + { + $byType = $this->byType[$descriptor->type] ?? null; + $byClass = $this->byClass[$descriptor->class] ?? null; + if ($byType !== null && $byType->toArray() === $descriptor->toArray()) { + return; + } + if ($byType !== null) { + throw new ConfigurationException("The event type [{$descriptor->type}] is claimed by both {$byType->class} and {$descriptor->class}."); + } + if ($byClass !== null) { + throw new ConfigurationException("The event class {$descriptor->class} is registered as both [{$byClass->type}] and [{$descriptor->type}]."); + } + + $this->byType[$descriptor->type] = $descriptor; + $this->byClass[$descriptor->class] = $descriptor; + } + + public function forClass(string $class): EventTypeDescriptor + { + return $this->byClass[$class] ?? throw UnknownEventTypeException::forClass($class); + } + + public function forType(string $type): EventTypeDescriptor + { + return $this->byType[$type] ?? throw UnknownEventTypeException::forType($type); + } + + public function has(string $type): bool + { + return isset($this->byType[$type]); + } + + /** @return list */ + public function all(): array + { + return array_values($this->byType); + } +} diff --git a/packages/eventsourcing/src/Event/SerializableEvent.php b/packages/eventsourcing/src/Event/SerializableEvent.php new file mode 100644 index 00000000..0ec56401 --- /dev/null +++ b/packages/eventsourcing/src/Event/SerializableEvent.php @@ -0,0 +1,21 @@ + */ + public function toPayload(): array; + + /** @param array $payload */ + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent; +} diff --git a/packages/eventsourcing/src/EventSourcingWiringProvider.php b/packages/eventsourcing/src/EventSourcingWiringProvider.php index fb1a2b8e..5a5278b9 100644 --- a/packages/eventsourcing/src/EventSourcingWiringProvider.php +++ b/packages/eventsourcing/src/EventSourcingWiringProvider.php @@ -5,13 +5,36 @@ namespace Firefly\EventSourcing; use Firefly\Context\Boot\FireflyServiceProvider; +use Firefly\Context\Scan\AppScan; +use Firefly\EventSourcing\Manifest\EventSourcingManifest; +use Firefly\EventSourcing\Scanner\EventSourcingScanner; +use Illuminate\Contracts\Container\Container; /** * The boot half of firefly/eventsourcing, a plain FireflyServiceProvider because an AutoConfiguration records - * candidacy only (the OrchestrationWiringProvider shape). Later tasks give it the EventSourcingManifest binding, - * the firefly:eventsourcing:* commands and the migrations. + * candidacy only (the OrchestrationWiringProvider shape). It binds the EventSourcingManifest behind a bound() guard: + * the compiled `eventsourcing.php` first, then an in-process scan of `firefly.scan.paths`, then an empty manifest + * (the AppScan convention), lazily, so a cached application pays nothing. Later tasks add the commands and the + * migrations. */ final class EventSourcingWiringProvider extends FireflyServiceProvider { public const string MIGRATIONS = __DIR__.'/../database/migrations'; + + public function register(): void + { + if (! $this->app->bound(EventSourcingManifest::class)) { + $this->app->singleton(EventSourcingManifest::class, static function (Container $app): EventSourcingManifest { + if (($file = AppScan::cachedFile($app, AppScan::EVENTSOURCING)) !== null) { + return EventSourcingManifest::load($file); + } + + $paths = AppScan::paths($app); + + return $paths === [] ? EventSourcingManifest::empty() : (new EventSourcingScanner)->scan($paths); + }); + } + + parent::register(); + } } diff --git a/packages/eventsourcing/src/Manifest/EventSourcingManifest.php b/packages/eventsourcing/src/Manifest/EventSourcingManifest.php new file mode 100644 index 00000000..18536d2e --- /dev/null +++ b/packages/eventsourcing/src/Manifest/EventSourcingManifest.php @@ -0,0 +1,67 @@ + $eventTypes + */ + public function __construct(private readonly array $eventTypes = []) {} + + public static function empty(): self + { + return new self; + } + + /** @param array{eventTypes?: list} $data */ + public static function fromArray(array $data): self + { + return new self(array_map(static fn (array $row): EventTypeDescriptor => EventTypeDescriptor::fromArray($row), $data['eventTypes'] ?? [])); + } + + public static function load(string $path): self + { + if (! is_file($path)) { + throw new ConfigurationException("Event-sourcing manifest not found at {$path}. Run php artisan firefly:cache."); + } + + /** @var mixed $data */ + $data = require $path; + if (! is_array($data)) { + throw new ConfigurationException("Event-sourcing manifest at {$path} did not return an array."); + } + + /** @var array{eventTypes?: list} $data */ + return self::fromArray($data); + } + + /** @return list */ + public function eventTypes(): array + { + return $this->eventTypes; + } + + public function isEmpty(): bool + { + return $this->eventTypes === []; + } + + /** @return array{eventTypes: list} */ + public function toArray(): array + { + return ['eventTypes' => array_map(static fn (EventTypeDescriptor $descriptor): array => $descriptor->toArray(), $this->eventTypes)]; + } +} diff --git a/packages/eventsourcing/src/Manifest/EventSourcingManifestCompiler.php b/packages/eventsourcing/src/Manifest/EventSourcingManifestCompiler.php new file mode 100644 index 00000000..cdb20123 --- /dev/null +++ b/packages/eventsourcing/src/Manifest/EventSourcingManifestCompiler.php @@ -0,0 +1,30 @@ +toArray(), true) + .";\n"; + } + + public function write(EventSourcingManifest $manifest, string $path): void + { + $dir = dirname($path); + if (! is_dir($dir) && ! mkdir($dir, 0o775, true) && ! is_dir($dir)) { + throw new ConfigurationException("Could not create manifest directory {$dir}."); + } + + if (file_put_contents($path, $this->compile($manifest)) === false) { + throw new ConfigurationException("Could not write the event-sourcing manifest to {$path}."); + } + } +} diff --git a/packages/eventsourcing/src/Scanner/EventSourcingScanner.php b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php new file mode 100644 index 00000000..9beb2141 --- /dev/null +++ b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php @@ -0,0 +1,101 @@ + $psr4 namespace-prefix => absolute directory + */ + public function scan(array $psr4): EventSourcingManifest + { + $descriptors = []; + + foreach ($this->classes($psr4) as $class) { + $reflection = new ReflectionClass($class); + $attributes = $reflection->getAttributes(EventType::class); + if ($attributes === []) { + continue; + } + if (! $reflection->isSubclassOf(DomainEvent::class)) { + throw new ConfigurationException("#[EventType] on {$class}: an event type must extend Firefly\\Domain\\DomainEvent."); + } + + $type = $attributes[0]->newInstance(); + $serializable = $reflection->implementsInterface(SerializableEvent::class); + $parameters = []; + if (! $serializable) { + foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) { + $declared = $parameter->getType(); + $name = $declared instanceof ReflectionNamedType ? $declared->getName() : null; + $parameters[] = new EventParameter($parameter->getName(), $name === 'self' || $name === 'static' ? $class : $name, $declared === null || $declared->allowsNull(), $parameter->isOptional()); + } + } + + /** @var class-string $class */ + $descriptors[] = new EventTypeDescriptor($class, $type->name, $type->version, $serializable, $parameters); + } + + usort($descriptors, static fn (EventTypeDescriptor $a, EventTypeDescriptor $b): int => $a->type <=> $b->type); + new EventTypeRegistry($descriptors); + + return new EventSourcingManifest($descriptors); + } + + /** + * @param array $psr4 + * @return list + */ + private function classes(array $psr4): array + { + $classes = []; + foreach ($psr4 as $prefix => $dir) { + $prefix = rtrim($prefix, '\\').'\\'; + if (! is_dir($dir)) { + continue; + } + $realDir = rtrim((string) realpath($dir), DIRECTORY_SEPARATOR); + /** @var iterable $files */ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($realDir, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $relative = substr((string) $file->getRealPath(), strlen($realDir) + 1, -4); + $class = $prefix.str_replace(DIRECTORY_SEPARATOR, '\\', $relative); + if (! class_exists($class)) { + continue; + } + if ((new ReflectionClass($class))->isAbstract()) { + continue; + } + $classes[] = $class; + } + } + sort($classes); + + return $classes; + } +} diff --git a/packages/eventsourcing/tests/Event/EventMapperTest.php b/packages/eventsourcing/tests/Event/EventMapperTest.php new file mode 100644 index 00000000..8c811ac8 --- /dev/null +++ b/packages/eventsourcing/tests/Event/EventMapperTest.php @@ -0,0 +1,51 @@ +scan(['Firefly\\EventSourcing\\Tests\\Fixtures\\Events\\' => dirname(__DIR__).'/Fixtures/Events'])->eventTypes())); +} + +/** @param array $payload */ +function storedAs(string $type, int $version, array $payload): StoredEvent +{ + return new StoredEvent('evt-1', 'o-1', 'order', 1, 1, $type, $version, $payload, new EventMetadata, new DateTimeImmutable('2026-10-13 10:00:00', new DateTimeZone('UTC')), new DateTimeImmutable); +} + +it('maps an event to its registered type, version and normalized public properties', function (): void { + $event = new OrderPlaced('o-1', 12.0, OrderChannel::Phone, new DateTimeImmutable('2026-10-20 00:00:00', new DateTimeZone('UTC')), ['A']); + $new = eventMapper()->toNewEvent($event, new EventMetadata('corr-1')); + + expect([$new->eventType, $new->eventVersion, $new->eventId, $new->metadata->correlationId])->toBe(['order.placed', 2, $event->eventId, 'corr-1']) + ->and($new->payload)->toBe(['orderId' => 'o-1', 'total' => 12.0, 'channel' => 'phone', 'promisedBy' => '2026-10-20T00:00:00.000000+00:00', 'skus' => ['A']]) + ->and(eventMapper()->toNewEvent(new OrderPaid('o-1', 1250))->payload)->toBe(['order' => 'o-1', 'amount' => 1250]); +}); + +it('builds the event back from a JSON-decoded payload, coercing by the compiled parameter types', function (): void { + // JSON gave 12.0 back as 12; the compiled type says float. + $event = eventMapper()->toDomainEvent(storedAs('order.placed', 2, ['orderId' => 'o-1', 'total' => 12, 'channel' => 'phone', 'promisedBy' => '2026-10-20T00:00:00.000000+00:00'])); + + expect($event)->toBeInstanceOf(OrderPlaced::class) + ->and($event instanceof OrderPlaced ? [$event->total, $event->channel, $event->promisedBy?->format('Y-m-d'), $event->skus, $event->eventId] : null)->toBe([12.0, OrderChannel::Phone, '2026-10-20', [], 'evt-1']) + ->and($event->occurredAt->format('Y-m-d H:i'))->toBe('2026-10-13 10:00') + ->and(eventMapper()->toDomainEvent(storedAs('order.paid', 1, ['order' => 'o-2', 'amount' => 5])))->toEqual(new OrderPaid('o-2', 5, 'evt-1', new DateTimeImmutable('2026-10-13 10:00:00', new DateTimeZone('UTC')))); +}); + +it('refuses an unregistered class, a stored version without its upcaster, and a payload missing a required value', function (): void { + expect(fn () => eventMapper()->toNewEvent(new Untyped))->toThrow(UnknownEventTypeException::class, Untyped::class) + ->and(fn () => eventMapper()->toDomainEvent(storedAs('order.placed', 1, ['orderId' => 'o-1', 'total' => 1])))->toThrow(UnknownEventTypeException::class, 'version 1 after upcasting') + ->and(fn () => eventMapper()->toDomainEvent(storedAs('order.placed', 2, ['orderId' => 'o-1'])))->toThrow(EventSerializationException::class, 'order.placed.total'); +}); diff --git a/packages/eventsourcing/tests/Event/EventTypeRegistryTest.php b/packages/eventsourcing/tests/Event/EventTypeRegistryTest.php new file mode 100644 index 00000000..09be1943 --- /dev/null +++ b/packages/eventsourcing/tests/Event/EventTypeRegistryTest.php @@ -0,0 +1,31 @@ +register($placed); + + expect($registry->forClass(OrderPlaced::class))->toBe($placed) + ->and($registry->forType('order.placed'))->toBe($placed) + ->and($registry->has('order.placed'))->toBeTrue() + ->and($registry->all())->toBe([$placed]) + ->and(fn () => $registry->forClass(Untyped::class))->toThrow(UnknownEventTypeException::class, 'firefly:cache') + ->and(fn () => $registry->forType('order.lost'))->toThrow(UnknownEventTypeException::class, 'order.lost'); +}); + +it('refuses a type claimed by two classes and a class registered under two types', function (): void { + $registry = new EventTypeRegistry([new EventTypeDescriptor(OrderPlaced::class, 'order.placed', 2)]); + + expect(fn () => $registry->register(new EventTypeDescriptor(OrderPaid::class, 'order.placed')))->toThrow(ConfigurationException::class, 'order.placed') + ->and(fn () => $registry->register(new EventTypeDescriptor(OrderPlaced::class, 'order.created', 2)))->toThrow(ConfigurationException::class, OrderPlaced::class); +}); diff --git a/packages/eventsourcing/tests/Fixtures/Events/OrderChannel.php b/packages/eventsourcing/tests/Fixtures/Events/OrderChannel.php new file mode 100644 index 00000000..f166cf1d --- /dev/null +++ b/packages/eventsourcing/tests/Fixtures/Events/OrderChannel.php @@ -0,0 +1,11 @@ + $this->orderId, 'amount' => $this->cents]; + } + + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent + { + $order = $payload['order'] ?? ''; + $amount = $payload['amount'] ?? 0; + + return new self(is_string($order) ? $order : '', is_int($amount) ? $amount : 0, $event->eventId, $event->occurredAt); + } +} diff --git a/packages/eventsourcing/tests/Fixtures/Events/OrderPlaced.php b/packages/eventsourcing/tests/Fixtures/Events/OrderPlaced.php new file mode 100644 index 00000000..a7fded53 --- /dev/null +++ b/packages/eventsourcing/tests/Fixtures/Events/OrderPlaced.php @@ -0,0 +1,29 @@ + $skus + */ + public function __construct( + public string $orderId, + public float $total, + public OrderChannel $channel = OrderChannel::Web, + public ?DateTimeImmutable $promisedBy = null, + public array $skus = [], + ?string $eventId = null, + ?DateTimeImmutable $occurredAt = null, + ) { + parent::__construct($eventId, $occurredAt); + } +} diff --git a/packages/eventsourcing/tests/Fixtures/Events/Untyped.php b/packages/eventsourcing/tests/Fixtures/Events/Untyped.php new file mode 100644 index 00000000..851bea5b --- /dev/null +++ b/packages/eventsourcing/tests/Fixtures/Events/Untyped.php @@ -0,0 +1,10 @@ + */ +function eventFixtures(string $dir = 'Fixtures/Events', string $namespace = 'Fixtures\\Events'): array +{ + return ['Firefly\\EventSourcing\\Tests\\'.$namespace.'\\' => dirname(__DIR__).'/'.$dir]; +} + +it('scans #[EventType] events with their constructor parameters, and compiles them to a file that loads back', function (): void { + $manifest = (new EventSourcingScanner)->scan(eventFixtures()); + $path = sys_get_temp_dir().'/firefly-eventsourcing-manifest-'.bin2hex(random_bytes(6)).'.php'; + + try { + (new EventSourcingManifestCompiler)->write($manifest, $path); + $loaded = EventSourcingManifest::load($path); + $byType = []; + foreach ($loaded->eventTypes() as $descriptor) { + $byType[$descriptor->type] = $descriptor; + } + + expect((string) file_get_contents($path))->toStartWith("and($loaded->toArray())->toBe($manifest->toArray()) + ->and(array_keys($byType))->toBe(['order.paid', 'order.placed']) + ->and([$byType['order.placed']->class, $byType['order.placed']->version, $byType['order.placed']->serializable])->toBe([OrderPlaced::class, 2, false]) + ->and($byType['order.placed']->parameters)->toEqual([ + new EventParameter('orderId', 'string', false, false), + new EventParameter('total', 'float', false, false), + new EventParameter('channel', OrderChannel::class, false, true), + new EventParameter('promisedBy', DateTimeImmutable::class, true, true), + new EventParameter('skus', 'array', false, true), + new EventParameter('eventId', 'string', true, true), + new EventParameter('occurredAt', DateTimeImmutable::class, true, true), + ]) + ->and([$byType['order.paid']->class, $byType['order.paid']->serializable, $byType['order.paid']->parameters])->toBe([OrderPaid::class, true, []]) + ->and(EventSourcingManifest::empty()->isEmpty())->toBeTrue(); + } finally { + @unlink($path); + } +}); + +it('refuses #[EventType] on a class that is not a DomainEvent, and one type named twice', function (string $dir, string $namespace, string $message): void { + expect(fn () => (new EventSourcingScanner)->scan(eventFixtures($dir, $namespace)))->toThrow(ConfigurationException::class, $message); +})->with([ + 'not a domain event' => ['InvalidFixtures/NotADomainEvent', 'InvalidFixtures\\NotADomainEvent', 'must extend Firefly\\Domain\\DomainEvent'], + 'duplicate type' => ['InvalidFixtures/DuplicateType', 'InvalidFixtures\\DuplicateType', 'twice.named'], +]); diff --git a/packages/eventsourcing/tests/PackageBootsTest.php b/packages/eventsourcing/tests/PackageBootsTest.php index 1546c622..059cd340 100644 --- a/packages/eventsourcing/tests/PackageBootsTest.php +++ b/packages/eventsourcing/tests/PackageBootsTest.php @@ -5,6 +5,7 @@ use Firefly\Context\Boot\ApplicationContext; use Firefly\EventSourcing\EventSourcingServiceProvider; use Firefly\EventSourcing\EventSourcingWiringProvider; +use Firefly\EventSourcing\Manifest\EventSourcingManifest; /** * The two discovered providers boot over the real pipeline with nothing configured: installing the package is @@ -15,3 +16,9 @@ expect($app->make(ApplicationContext::class))->toBeInstanceOf(ApplicationContext::class); }); + +it('binds an empty event-sourcing manifest when the application names no scan paths', function (): void { + $app = fireflyApplication([], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]); + + expect($app->make(EventSourcingManifest::class)->isEmpty())->toBeTrue(); +}); diff --git a/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php b/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php index 75f868e5..54e65fee 100644 --- a/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php +++ b/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php @@ -24,9 +24,8 @@ function eventSourcingReflectionHits(string $dir): array /** * Production loads the compiled EventSourcingManifest by require; reflection is allowed in exactly one file, the - * scanner that compiles it (at firefly:cache time, or as the uncached development fallback). Until Task 8 adds that - * scanner there is none at all. + * scanner that compiles it (at firefly:cache time, or as the uncached development fallback). The scanner is that file. */ it('confines reflection in firefly/eventsourcing to the scanner', function (): void { - expect(eventSourcingReflectionHits(__DIR__.'/../src'))->toBe([]); + expect(eventSourcingReflectionHits(__DIR__.'/../src'))->toBe(['EventSourcingScanner.php']); }); From e130046c4e7d62fc99870efd2bd1f5bf7d9f2e82 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:17:25 -0700 Subject: [PATCH 11/33] fix(eventsourcing): refuse a convention-mapped event whose constructor parameters and public properties differ, so nothing is stored that cannot be read back --- .../src/Scanner/EventSourcingScanner.php | 44 +++++++++++++++++++ .../UnreadProperty/Defaulted.php | 21 +++++++++ .../UnstoredParameter/Renamed.php | 21 +++++++++ .../Manifest/EventSourcingManifestTest.php | 10 +++++ 4 files changed, 96 insertions(+) create mode 100644 packages/eventsourcing/tests/InvalidFixtures/UnreadProperty/Defaulted.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/UnstoredParameter/Renamed.php diff --git a/packages/eventsourcing/src/Scanner/EventSourcingScanner.php b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php index 9beb2141..7b9c3f01 100644 --- a/packages/eventsourcing/src/Scanner/EventSourcingScanner.php +++ b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php @@ -16,6 +16,8 @@ use RecursiveIteratorIterator; use ReflectionClass; use ReflectionNamedType; +use ReflectionParameter; +use ReflectionProperty; use SplFileInfo; /** @@ -23,6 +25,12 @@ * #[EventType] class: its type, version and whether it maps itself. For a convention-mapped class it also compiles * each constructor parameter's name, declared type, nullability and optionality, which EventMapper needs to build it * back without reflection. A class that is not a DomainEvent, and a type named twice, refuse the scan. + * + * The convention mapper WRITES an event's public properties and READS it back through its constructor, so a + * convention-mapped class must have the two match by name (eventId and occurredAt aside, which travel in the + * envelope). A constructor parameter with no public property of its name would be missing from every stored payload, + * and a public property no constructor parameter takes would be stored and then silently dropped on reload. Either + * refuses the scan here, rather than surfacing as an unreadable stream at replay time. */ final class EventSourcingScanner { @@ -47,6 +55,7 @@ public function scan(array $psr4): EventSourcingManifest $serializable = $reflection->implementsInterface(SerializableEvent::class); $parameters = []; if (! $serializable) { + $this->assertRoundTrips($reflection); foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) { $declared = $parameter->getType(); $name = $declared instanceof ReflectionNamedType ? $declared->getName() : null; @@ -64,6 +73,41 @@ public function scan(array $psr4): EventSourcingManifest return new EventSourcingManifest($descriptors); } + /** + * A convention-mapped event stores its public instance properties and is rebuilt from its constructor parameters, + * so both sets must name the same fields, or what is appended cannot be read back as it was written. + * + * @param ReflectionClass $reflection + */ + private function assertRoundTrips(ReflectionClass $reflection): void + { + $envelope = ['eventId', 'occurredAt']; + $parameters = array_values(array_diff(array_map( + static fn (ReflectionParameter $parameter): string => $parameter->getName(), + $reflection->getConstructor()?->getParameters() ?? [], + ), $envelope)); + $properties = array_values(array_diff(array_map( + static fn (ReflectionProperty $property): string => $property->getName(), + array_filter($reflection->getProperties(ReflectionProperty::IS_PUBLIC), static fn (ReflectionProperty $property): bool => ! $property->isStatic()), + ), $envelope)); + + $unstored = array_values(array_diff($parameters, $properties)); + $unread = array_values(array_diff($properties, $parameters)); + if ($unstored === [] && $unread === []) { + return; + } + + $problems = []; + if ($unstored !== []) { + $problems[] = 'the constructor parameter(s) $'.implode(', $', $unstored).' have no public property of the same name, so they would never be stored'; + } + if ($unread !== []) { + $problems[] = 'the public property(ies) $'.implode(', $', $unread).' are not constructor parameters, so they would be stored and lost on reload'; + } + + throw new ConfigurationException("#[EventType] on {$reflection->getName()}: ".implode('; ', $problems).'. The convention mapper stores public properties and rebuilds the event through its constructor, so the two must match by name; rename them, or implement Firefly\\EventSourcing\\Event\\SerializableEvent to map the payload yourself.'); + } + /** * @param array $psr4 * @return list diff --git a/packages/eventsourcing/tests/InvalidFixtures/UnreadProperty/Defaulted.php b/packages/eventsourcing/tests/InvalidFixtures/UnreadProperty/Defaulted.php new file mode 100644 index 00000000..ea3650a4 --- /dev/null +++ b/packages/eventsourcing/tests/InvalidFixtures/UnreadProperty/Defaulted.php @@ -0,0 +1,21 @@ +qty = $count; + } +} diff --git a/packages/eventsourcing/tests/InvalidFixtures/UnstoredParameter/Renamed.php b/packages/eventsourcing/tests/InvalidFixtures/UnstoredParameter/Renamed.php new file mode 100644 index 00000000..4afee4a8 --- /dev/null +++ b/packages/eventsourcing/tests/InvalidFixtures/UnstoredParameter/Renamed.php @@ -0,0 +1,21 @@ +ident = $id; + } +} diff --git a/packages/eventsourcing/tests/Manifest/EventSourcingManifestTest.php b/packages/eventsourcing/tests/Manifest/EventSourcingManifestTest.php index b8e15a64..12be14af 100644 --- a/packages/eventsourcing/tests/Manifest/EventSourcingManifestTest.php +++ b/packages/eventsourcing/tests/Manifest/EventSourcingManifestTest.php @@ -55,3 +55,13 @@ function eventFixtures(string $dir = 'Fixtures/Events', string $namespace = 'Fix 'not a domain event' => ['InvalidFixtures/NotADomainEvent', 'InvalidFixtures\\NotADomainEvent', 'must extend Firefly\\Domain\\DomainEvent'], 'duplicate type' => ['InvalidFixtures/DuplicateType', 'InvalidFixtures\\DuplicateType', 'twice.named'], ]); + +it('refuses a convention-mapped event whose constructor parameters and public properties do not match, pointing to SerializableEvent', function (string $dir, string $namespace, string $message): void { + expect(fn () => (new EventSourcingScanner)->scan(eventFixtures($dir, $namespace))) + ->toThrow(ConfigurationException::class, $message) + ->and(fn () => (new EventSourcingScanner)->scan(eventFixtures($dir, $namespace))) + ->toThrow(ConfigurationException::class, 'implement Firefly\\EventSourcing\\Event\\SerializableEvent'); +})->with([ + 'a parameter with no public property' => ['InvalidFixtures/UnstoredParameter', 'InvalidFixtures\\UnstoredParameter', 'the constructor parameter(s) $id have no public property of the same name'], + 'a public property no parameter takes' => ['InvalidFixtures/UnreadProperty', 'InvalidFixtures\\UnreadProperty', 'the public property(ies) $qty are not constructor parameters'], +]); From dc4784ae5e95cde3317f7c2385ad8158d0f30849 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:22:01 -0700 Subject: [PATCH 12/33] fix(eventsourcing): read a pure enum back by its case name and refuse at scan time a convention-mapped parameter type the mapper cannot rebuild --- .../eventsourcing/src/Event/EventMapper.php | 42 ++++++++++++++++++- .../src/Scanner/EventSourcingScanner.php | 28 ++++++++++++- .../tests/Event/EventMapperTest.php | 14 +++++++ .../tests/Fixtures/Moods/Mood.php | 12 ++++++ .../tests/Fixtures/Moods/MoodChanged.php | 19 +++++++++ .../InvalidFixtures/UnbuildableType/Money.php | 19 +++++++++ .../UnbuildableType/Priced.php | 18 ++++++++ .../Manifest/EventSourcingManifestTest.php | 7 ++++ 8 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 packages/eventsourcing/tests/Fixtures/Moods/Mood.php create mode 100644 packages/eventsourcing/tests/Fixtures/Moods/MoodChanged.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/UnbuildableType/Money.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/UnbuildableType/Priced.php diff --git a/packages/eventsourcing/src/Event/EventMapper.php b/packages/eventsourcing/src/Event/EventMapper.php index f7369293..432200e9 100644 --- a/packages/eventsourcing/src/Event/EventMapper.php +++ b/packages/eventsourcing/src/Event/EventMapper.php @@ -12,6 +12,7 @@ use Firefly\EventSourcing\Exception\EventSerializationException; use Firefly\EventSourcing\Exception\UnknownEventTypeException; use Throwable; +use UnitEnum; /** * DomainEvent <-> stored event, without reflection. @@ -25,9 +26,13 @@ * coerced by the parameter type the scanner compiled: * * - JSON returns 12.0 as 12, so a float parameter takes an int; - * - a backed enum takes its value, a date its ISO string; + * - a backed enum takes its value, a pure enum its case name, a date its ISO string; * - `eventId` and `occurredAt` come from the envelope, so a reloaded event keeps its identity when its * constructor forwards them (the documented pattern). + * + * canBuild() names the parameter types coerce() can rebuild. The scanner refuses a convention-mapped event with a + * parameter outside that set, so a type PayloadNormalizer writes but this mapper cannot read (a JsonSerializable + * value object, say) fails the scan instead of leaving a stream that cannot be replayed. */ final class EventMapper { @@ -90,6 +95,19 @@ public function toDomainEvent(StoredEvent $event): DomainEvent } } + /** + * Whether coerce() can rebuild a constructor parameter of this compiled type from its stored JSON value: no + * declared type, a union (compiled as null), mixed, the JSON scalars, array, a date, or an enum. + */ + public static function canBuild(?string $type): bool + { + return match (true) { + $type === null, in_array($type, ['mixed', 'int', 'float', 'string', 'bool', 'array'], true) => true, + $type === DateTime::class, $type === DateTimeInterface::class => true, + default => is_a($type, DateTimeImmutable::class, true) || is_a($type, UnitEnum::class, true), + }; + } + private static function coerce(EventParameter $parameter, mixed $value, string $path): mixed { $type = $parameter->type; @@ -110,8 +128,10 @@ private static function coerce(EventParameter $parameter, mixed $value, string $ $type === 'bool' => is_bool($value) ? $value : throw EventSerializationException::at($path, 'is not a bool.'), $type === 'array' => is_array($value) ? $value : throw EventSerializationException::at($path, 'is not an array.'), $type === DateTime::class => is_string($value) ? new DateTime($value) : throw EventSerializationException::at($path, 'is not a date string.'), - $type === DateTimeInterface::class || is_a($type, DateTimeImmutable::class, true) => is_string($value) ? new DateTimeImmutable($value) : throw EventSerializationException::at($path, 'is not a date string.'), + $type === DateTimeInterface::class => is_string($value) ? new DateTimeImmutable($value) : throw EventSerializationException::at($path, 'is not a date string.'), + is_a($type, DateTimeImmutable::class, true) => is_string($value) ? new $type($value) : throw EventSerializationException::at($path, 'is not a date string.'), is_a($type, BackedEnum::class, true) => self::enum($type, $value, $path), + is_a($type, UnitEnum::class, true) => self::pureEnum($type, $value, $path), default => throw EventSerializationException::at($path, "has the type {$type}, which the convention mapper cannot build; implement SerializableEvent on the event."), }; } @@ -125,4 +145,22 @@ private static function enum(string $type, mixed $value, string $path): BackedEn return $type::tryFrom($value) ?? throw EventSerializationException::at($path, "is not a {$type} case."); } + + /** + * A pure enum is stored as its case name (PayloadNormalizer), so it is read back by that name. + * + * @param class-string $type + */ + private static function pureEnum(string $type, mixed $value, string $path): UnitEnum + { + if (is_string($value)) { + foreach ($type::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + } + + throw EventSerializationException::at($path, "is not a {$type} case name."); + } } diff --git a/packages/eventsourcing/src/Scanner/EventSourcingScanner.php b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php index 7b9c3f01..e8a4c48d 100644 --- a/packages/eventsourcing/src/Scanner/EventSourcingScanner.php +++ b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php @@ -6,6 +6,7 @@ use Firefly\Domain\DomainEvent; use Firefly\EventSourcing\Event\Attributes\EventType; +use Firefly\EventSourcing\Event\EventMapper; use Firefly\EventSourcing\Event\EventParameter; use Firefly\EventSourcing\Event\EventTypeDescriptor; use Firefly\EventSourcing\Event\EventTypeRegistry; @@ -29,8 +30,10 @@ * The convention mapper WRITES an event's public properties and READS it back through its constructor, so a * convention-mapped class must have the two match by name (eventId and occurredAt aside, which travel in the * envelope). A constructor parameter with no public property of its name would be missing from every stored payload, - * and a public property no constructor parameter takes would be stored and then silently dropped on reload. Either - * refuses the scan here, rather than surfacing as an unreadable stream at replay time. + * and a public property no constructor parameter takes would be stored and then silently dropped on reload. A + * constructor parameter whose type EventMapper cannot rebuild from JSON (EventMapper::canBuild(), for example a + * JsonSerializable value object, which is written but never read back) is refused too. Each refuses the scan here, + * rather than surfacing as an unreadable stream at replay time. */ final class EventSourcingScanner { @@ -61,6 +64,7 @@ public function scan(array $psr4): EventSourcingManifest $name = $declared instanceof ReflectionNamedType ? $declared->getName() : null; $parameters[] = new EventParameter($parameter->getName(), $name === 'self' || $name === 'static' ? $class : $name, $declared === null || $declared->allowsNull(), $parameter->isOptional()); } + $this->assertBuildable($class, $parameters); } /** @var class-string $class */ @@ -108,6 +112,26 @@ private function assertRoundTrips(ReflectionClass $reflection): void throw new ConfigurationException("#[EventType] on {$reflection->getName()}: ".implode('; ', $problems).'. The convention mapper stores public properties and rebuilds the event through its constructor, so the two must match by name; rename them, or implement Firefly\\EventSourcing\\Event\\SerializableEvent to map the payload yourself.'); } + /** + * Every convention-mapped constructor parameter must have a type EventMapper can rebuild from its stored value. + * + * @param list $parameters + */ + private function assertBuildable(string $class, array $parameters): void + { + $unbuildable = []; + foreach ($parameters as $parameter) { + if (! in_array($parameter->name, ['eventId', 'occurredAt'], true) && ! EventMapper::canBuild($parameter->type)) { + $unbuildable[] = '$'.$parameter->name.' ('.$parameter->type.')'; + } + } + if ($unbuildable === []) { + return; + } + + throw new ConfigurationException("#[EventType] on {$class}: the constructor parameter(s) ".implode(', ', $unbuildable).' have a type the convention mapper cannot rebuild from the stored payload, so the event could be appended but never read back. It builds int, float, string, bool, array, mixed, untyped and union parameters, dates and enums; implement Firefly\\EventSourcing\\Event\\SerializableEvent to map the other types yourself.'); + } + /** * @param array $psr4 * @return list diff --git a/packages/eventsourcing/tests/Event/EventMapperTest.php b/packages/eventsourcing/tests/Event/EventMapperTest.php index 8c811ac8..805ab4b0 100644 --- a/packages/eventsourcing/tests/Event/EventMapperTest.php +++ b/packages/eventsourcing/tests/Event/EventMapperTest.php @@ -13,6 +13,8 @@ use Firefly\EventSourcing\Tests\Fixtures\Events\OrderPaid; use Firefly\EventSourcing\Tests\Fixtures\Events\OrderPlaced; use Firefly\EventSourcing\Tests\Fixtures\Events\Untyped; +use Firefly\EventSourcing\Tests\Fixtures\Moods\Mood; +use Firefly\EventSourcing\Tests\Fixtures\Moods\MoodChanged; function eventMapper(): EventMapper { @@ -49,3 +51,15 @@ function storedAs(string $type, int $version, array $payload): StoredEvent ->and(fn () => eventMapper()->toDomainEvent(storedAs('order.placed', 1, ['orderId' => 'o-1', 'total' => 1])))->toThrow(UnknownEventTypeException::class, 'version 1 after upcasting') ->and(fn () => eventMapper()->toDomainEvent(storedAs('order.placed', 2, ['orderId' => 'o-1'])))->toThrow(EventSerializationException::class, 'order.placed.total'); }); + +it('round-trips a pure enum through its case name, and refuses a stored name that is not a case', function (): void { + $mapper = new EventMapper(new EventTypeRegistry((new EventSourcingScanner)->scan(['Firefly\\EventSourcing\\Tests\\Fixtures\\Moods\\' => dirname(__DIR__).'/Fixtures/Moods'])->eventTypes())); + $new = $mapper->toNewEvent(new MoodChanged(Mood::Happy, Mood::Grumpy)); + $stored = storedAs('mood.changed', 1, $new->payload); + $event = $mapper->toDomainEvent($stored); + + expect($new->payload)->toBe(['mood' => 'Happy', 'previous' => 'Grumpy']) + ->and($event)->toEqual(new MoodChanged(Mood::Happy, Mood::Grumpy, 'evt-1', $stored->occurredAt)) + ->and($mapper->toDomainEvent(storedAs('mood.changed', 1, ['mood' => 'Grumpy', 'previous' => null])))->toEqual(new MoodChanged(Mood::Grumpy, null, 'evt-1', $stored->occurredAt)) + ->and(fn () => $mapper->toDomainEvent(storedAs('mood.changed', 1, ['mood' => 'Sleepy'])))->toThrow(EventSerializationException::class, 'mood.changed.mood'); +}); diff --git a/packages/eventsourcing/tests/Fixtures/Moods/Mood.php b/packages/eventsourcing/tests/Fixtures/Moods/Mood.php new file mode 100644 index 00000000..7f00d0b0 --- /dev/null +++ b/packages/eventsourcing/tests/Fixtures/Moods/Mood.php @@ -0,0 +1,12 @@ + $this->cents]; + } +} diff --git a/packages/eventsourcing/tests/InvalidFixtures/UnbuildableType/Priced.php b/packages/eventsourcing/tests/InvalidFixtures/UnbuildableType/Priced.php new file mode 100644 index 00000000..76ed88ab --- /dev/null +++ b/packages/eventsourcing/tests/InvalidFixtures/UnbuildableType/Priced.php @@ -0,0 +1,18 @@ + ['InvalidFixtures/UnstoredParameter', 'InvalidFixtures\\UnstoredParameter', 'the constructor parameter(s) $id have no public property of the same name'], 'a public property no parameter takes' => ['InvalidFixtures/UnreadProperty', 'InvalidFixtures\\UnreadProperty', 'the public property(ies) $qty are not constructor parameters'], ]); + +it('refuses a convention-mapped constructor parameter whose type the mapper cannot rebuild, naming it and pointing to SerializableEvent', function (): void { + $scan = fn () => (new EventSourcingScanner)->scan(eventFixtures('InvalidFixtures/UnbuildableType', 'InvalidFixtures\\UnbuildableType')); + + expect($scan)->toThrow(ConfigurationException::class, 'the constructor parameter(s) $price (Firefly\\EventSourcing\\Tests\\InvalidFixtures\\UnbuildableType\\Money) have a type the convention mapper cannot rebuild') + ->and($scan)->toThrow(ConfigurationException::class, 'implement Firefly\\EventSourcing\\Event\\SerializableEvent'); +}); From c5b1270b4c7205fd91be5c718bb1f158d115d094 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:27:13 -0700 Subject: [PATCH 13/33] fix(eventsourcing): refuse at scan time a convention-mapped union with a class member or an intersection, which the mapper would hand the raw stored value --- .../eventsourcing/src/Event/EventMapper.php | 4 +- .../src/Event/EventParameter.php | 4 +- .../src/Scanner/EventSourcingScanner.php | 40 ++++++++++++++++--- .../tests/Fixtures/Unions/Counted.php | 19 +++++++++ .../IntersectionType/Tagged.php | 21 ++++++++++ .../InvalidFixtures/UnionType/Repriced.php | 23 +++++++++++ .../Manifest/EventSourcingManifestTest.php | 29 ++++++++++++++ 7 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 packages/eventsourcing/tests/Fixtures/Unions/Counted.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/IntersectionType/Tagged.php create mode 100644 packages/eventsourcing/tests/InvalidFixtures/UnionType/Repriced.php diff --git a/packages/eventsourcing/src/Event/EventMapper.php b/packages/eventsourcing/src/Event/EventMapper.php index 432200e9..f700231f 100644 --- a/packages/eventsourcing/src/Event/EventMapper.php +++ b/packages/eventsourcing/src/Event/EventMapper.php @@ -97,7 +97,9 @@ public function toDomainEvent(StoredEvent $event): DomainEvent /** * Whether coerce() can rebuild a constructor parameter of this compiled type from its stored JSON value: no - * declared type, a union (compiled as null), mixed, the JSON scalars, array, a date, or an enum. + * declared type, mixed, the JSON scalars, array, a date, or an enum. Null also stands for a union, which the scanner + * compiles as null only when every member is a JSON scalar or array (passed through as stored); it refuses any + * other union, and every intersection, before this is asked. */ public static function canBuild(?string $type): bool { diff --git a/packages/eventsourcing/src/Event/EventParameter.php b/packages/eventsourcing/src/Event/EventParameter.php index 6c7f0f4d..9a777435 100644 --- a/packages/eventsourcing/src/Event/EventParameter.php +++ b/packages/eventsourcing/src/Event/EventParameter.php @@ -6,8 +6,8 @@ /** * One constructor parameter of a convention-mapped event, compiled at scan time so that building the event back - * needs no reflection. `type` is the declared type's name (`string`, `float`, a class), or null for a union or no - * type. + * needs no reflection. `type` is the declared type's name (`string`, `float`, a class), or null for no type or a + * union of JSON scalars and array, whose stored value is passed through as is (the scanner refuses any other union). * * @phpstan-type EventParameterRow array{name: string, type: string|null, nullable: bool, optional: bool} */ diff --git a/packages/eventsourcing/src/Scanner/EventSourcingScanner.php b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php index e8a4c48d..adb24ad3 100644 --- a/packages/eventsourcing/src/Scanner/EventSourcingScanner.php +++ b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php @@ -19,6 +19,8 @@ use ReflectionNamedType; use ReflectionParameter; use ReflectionProperty; +use ReflectionType; +use ReflectionUnionType; use SplFileInfo; /** @@ -32,7 +34,8 @@ * envelope). A constructor parameter with no public property of its name would be missing from every stored payload, * and a public property no constructor parameter takes would be stored and then silently dropped on reload. A * constructor parameter whose type EventMapper cannot rebuild from JSON (EventMapper::canBuild(), for example a - * JsonSerializable value object, which is written but never read back) is refused too. Each refuses the scan here, + * JsonSerializable value object, which is written but never read back) is refused too, and so is a union with a + * member other than a JSON scalar or array, or an intersection (passesThrough()). Each refuses the scan here, * rather than surfacing as an unreadable stream at replay time. */ final class EventSourcingScanner @@ -59,12 +62,16 @@ public function scan(array $psr4): EventSourcingManifest $parameters = []; if (! $serializable) { $this->assertRoundTrips($reflection); + $composite = []; foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) { $declared = $parameter->getType(); + if (! $declared instanceof ReflectionNamedType && $declared !== null && ! self::passesThrough($declared)) { + $composite[] = '$'.$parameter->getName().' ('.$declared.')'; + } $name = $declared instanceof ReflectionNamedType ? $declared->getName() : null; $parameters[] = new EventParameter($parameter->getName(), $name === 'self' || $name === 'static' ? $class : $name, $declared === null || $declared->allowsNull(), $parameter->isOptional()); } - $this->assertBuildable($class, $parameters); + $this->assertBuildable($class, $parameters, $composite); } /** @var class-string $class */ @@ -114,12 +121,15 @@ private function assertRoundTrips(ReflectionClass $reflection): void /** * Every convention-mapped constructor parameter must have a type EventMapper can rebuild from its stored value. + * $composite carries the union and intersection parameters already refused by passesThrough(): they compile as + * type null, which canBuild() accepts, so they are named here rather than found again in $parameters. * * @param list $parameters + * @param list $composite */ - private function assertBuildable(string $class, array $parameters): void + private function assertBuildable(string $class, array $parameters, array $composite): void { - $unbuildable = []; + $unbuildable = $composite; foreach ($parameters as $parameter) { if (! in_array($parameter->name, ['eventId', 'occurredAt'], true) && ! EventMapper::canBuild($parameter->type)) { $unbuildable[] = '$'.$parameter->name.' ('.$parameter->type.')'; @@ -129,7 +139,27 @@ private function assertBuildable(string $class, array $parameters): void return; } - throw new ConfigurationException("#[EventType] on {$class}: the constructor parameter(s) ".implode(', ', $unbuildable).' have a type the convention mapper cannot rebuild from the stored payload, so the event could be appended but never read back. It builds int, float, string, bool, array, mixed, untyped and union parameters, dates and enums; implement Firefly\\EventSourcing\\Event\\SerializableEvent to map the other types yourself.'); + throw new ConfigurationException("#[EventType] on {$class}: the constructor parameter(s) ".implode(', ', $unbuildable).' have a type the convention mapper cannot rebuild from the stored payload, so the event could be appended but never read back. It builds int, float, string, bool, array, mixed and untyped parameters, unions of those, dates and enums; implement Firefly\\EventSourcing\\Event\\SerializableEvent to map the other types yourself.'); + } + + /** + * A union or intersection parameter compiles as type null, so EventMapper hands it the stored JSON value as is. + * That is right only when every non-null member of a union is a JSON scalar, array or mixed: a class member (a + * date, an enum, a value object) would be given a string or an array it cannot accept, or would silently come + * back as that string. An intersection only ever names classes, so it never passes through. + */ + private static function passesThrough(ReflectionType $declared): bool + { + if (! $declared instanceof ReflectionUnionType) { + return false; + } + foreach ($declared->getTypes() as $member) { + if (! $member instanceof ReflectionNamedType || ! in_array($member->getName(), ['null', 'int', 'float', 'string', 'bool', 'false', 'true', 'array', 'mixed'], true)) { + return false; + } + } + + return true; } /** diff --git a/packages/eventsourcing/tests/Fixtures/Unions/Counted.php b/packages/eventsourcing/tests/Fixtures/Unions/Counted.php new file mode 100644 index 00000000..f3223a92 --- /dev/null +++ b/packages/eventsourcing/tests/Fixtures/Unions/Counted.php @@ -0,0 +1,19 @@ +|string $bins */ + public function __construct(public int|string $quantity, public float|bool|null $flag, public array|string $bins) + { + parent::__construct(); + } +} diff --git a/packages/eventsourcing/tests/InvalidFixtures/IntersectionType/Tagged.php b/packages/eventsourcing/tests/InvalidFixtures/IntersectionType/Tagged.php new file mode 100644 index 00000000..ca6d5cad --- /dev/null +++ b/packages/eventsourcing/tests/InvalidFixtures/IntersectionType/Tagged.php @@ -0,0 +1,21 @@ + $tags */ + public function __construct(public Countable&Traversable $tags) + { + parent::__construct(); + } +} diff --git a/packages/eventsourcing/tests/InvalidFixtures/UnionType/Repriced.php b/packages/eventsourcing/tests/InvalidFixtures/UnionType/Repriced.php new file mode 100644 index 00000000..2cc14d91 --- /dev/null +++ b/packages/eventsourcing/tests/InvalidFixtures/UnionType/Repriced.php @@ -0,0 +1,23 @@ + */ @@ -72,3 +77,27 @@ function eventFixtures(string $dir = 'Fixtures/Events', string $namespace = 'Fix expect($scan)->toThrow(ConfigurationException::class, 'the constructor parameter(s) $price (Firefly\\EventSourcing\\Tests\\InvalidFixtures\\UnbuildableType\\Money) have a type the convention mapper cannot rebuild') ->and($scan)->toThrow(ConfigurationException::class, 'implement Firefly\\EventSourcing\\Event\\SerializableEvent'); }); + +it('refuses a convention-mapped union parameter with a class member, since its stored value would be handed back raw', function (): void { + $scan = fn () => (new EventSourcingScanner)->scan(eventFixtures('InvalidFixtures/UnionType', 'InvalidFixtures\\UnionType')); + + expect($scan)->toThrow(ConfigurationException::class, 'the constructor parameter(s) $price (Firefly\\EventSourcing\\Tests\\InvalidFixtures\\UnbuildableType\\Money|int), $effective (DateTimeImmutable|string|null) have a type the convention mapper cannot rebuild') + ->and($scan)->toThrow(ConfigurationException::class, 'implement Firefly\\EventSourcing\\Event\\SerializableEvent'); +}); + +it('refuses a convention-mapped intersection parameter', function (): void { + expect(fn () => (new EventSourcingScanner)->scan(eventFixtures('InvalidFixtures/IntersectionType', 'InvalidFixtures\\IntersectionType'))) + ->toThrow(ConfigurationException::class, 'the constructor parameter(s) $tags (Countable&Traversable) have a type the convention mapper cannot rebuild'); +}); + +it('accepts a union of JSON scalars and array and reads it back exactly as it was written', function (): void { + $mapper = new EventMapper(new EventTypeRegistry((new EventSourcingScanner)->scan(eventFixtures('Fixtures/Unions', 'Fixtures\\Unions'))->eventTypes())); + $event = new Counted('12', null, ['A1', 'B2']); + $new = $mapper->toNewEvent($event); + /** @var array $payload */ + $payload = json_decode((string) json_encode($new->payload), true, flags: JSON_THROW_ON_ERROR); + $back = $mapper->toDomainEvent(new StoredEvent($new->eventId, 's-1', 'stock', 1, 1, $new->eventType, $new->eventVersion, $payload, new EventMetadata, $new->occurredAt, new DateTimeImmutable)); + + expect($back)->toBeInstanceOf(Counted::class) + ->and($back instanceof Counted ? [$back->quantity, $back->flag, $back->bins] : null)->toBe(['12', null, ['A1', 'B2']]); +}); From d06b954137b42b478c272087fd9b96136612e62a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:34:50 -0700 Subject: [PATCH 14/33] feat(cli): compile the event-sourcing manifest in firefly:cache and recount the sixteen artifacts it writes --- book/src-es/02-dependency-injection.md | 2 +- book/src-es/03-configuration.md | 2 +- book/src-es/07-cqrs.md | 6 ++--- book/src-es/08-eda-outbox.md | 2 +- book/src-es/09-transactions.md | 2 +- book/src-es/13-cli-cache.md | 17 +++++++++---- book/src/02-dependency-injection.md | 2 +- book/src/03-configuration.md | 2 +- book/src/07-cqrs.md | 6 ++--- book/src/08-eda-outbox.md | 2 +- book/src/09-transactions.md | 2 +- book/src/13-cli-cache.md | 17 +++++++++---- deptrac.yaml | 4 ++- docs/cli.md | 7 +++--- docs/tutorial.es.md | 6 ++--- docs/tutorial.md | 6 ++--- packages/cli/composer.json | 1 + .../src/Boot/FireflyCacheServiceProvider.php | 4 +++ packages/cli/src/Cache/FireflyCachePaths.php | 2 ++ .../cli/src/Cache/ManifestCacheWriter.php | 8 ++++++ .../tests/Cache/ManifestCacheWriterTest.php | 25 +++++++++++++++++++ packages/context/tests/Scan/AppScanTest.php | 3 ++- tests/DocsProseIsRealTest.php | 8 +++--- 23 files changed, 97 insertions(+), 39 deletions(-) diff --git a/book/src-es/02-dependency-injection.md b/book/src-es/02-dependency-injection.md index 173b98c3..046c919c 100644 --- a/book/src-es/02-dependency-injection.md +++ b/book/src-es/02-dependency-injection.md @@ -367,7 +367,7 @@ php artisan firefly:cache ``` ``` -firefly:cache — wrote 15 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly ``` Ya ejecutaste esto una vez, de forma indirecta — `firefly new` lo llama por ti en su script `post-create-project-cmd`, que es la razón por la que la aplicación del Inicio rápido arrancó correctamente sin que ejecutaras el comando a mano ni una sola vez. De aquí en adelante, cada vez que añadas o cambies un `#[Service]`, `#[Repository]`, `#[Configuration]` o cualquier otro atributo de Firefly, vuelve a ejecutar `firefly:cache` para recompilar el manifiesto; `php artisan firefly:clear` elimina la caché compilada y vuelve a la ruta de escaneo de desarrollo (más lenta, basada en reflexión). diff --git a/book/src-es/03-configuration.md b/book/src-es/03-configuration.md index a9c8b3e1..abec6998 100644 --- a/book/src-es/03-configuration.md +++ b/book/src-es/03-configuration.md @@ -231,7 +231,7 @@ foreach ($this->classesIn($prefix, $dir) as $class) { } ``` -`ConfigManifestCompiler` escribe esa lista en `bootstrap/cache/firefly/config-properties.php` con la misma técnica basada en `var_export()`, sin reflexión, que usó el manifiesto de componentes. `php artisan firefly:cache` ejecuta este escaneo como uno de los trece pares escáner/compilador que orquesta — nunca invocas `ConfigPropertiesScanner` tú mismo. +`ConfigManifestCompiler` escribe esa lista en `bootstrap/cache/firefly/config-properties.php` con la misma técnica basada en `var_export()`, sin reflexión, que usó el manifiesto de componentes. `php artisan firefly:cache` ejecuta este escaneo como uno de los catorce pares escáner/compilador que orquesta — nunca invocas `ConfigPropertiesScanner` tú mismo. En el arranque, `Firefly\Cli\Boot\FireflyCacheServiceProvider` carga ese manifiesto compilado y se lo entrega directamente a `Firefly\Config\Registrar\ConfigRegistrar::register()`, que vincula el DTO de cada descriptor como singleton del contenedor en un solo paso: diff --git a/book/src-es/07-cqrs.md b/book/src-es/07-cqrs.md index 9a5d30af..aecb7606 100644 --- a/book/src-es/07-cqrs.md +++ b/book/src-es/07-cqrs.md @@ -469,11 +469,11 @@ El tipo exterior te dice *qué operación del bus* falló; el tipo interior — `HandlerScanner` es el **único** punto de reflexión en `packages/cqrs/src` (la misma invariante forzada por grep que el Capítulo 6 describió para `firefly/domain`). Recorre una vez las raíces PSR-4 de la aplicación, resuelve cada `#[CommandHandler]`/`#[QueryHandler]` (ejecutando exactamente la inferencia de `messageType()` que acabas de leer) en un `HandlerDescriptor`, y entrega la lista completa a `HandlerManifestCompiler`, que la `var_export()`a en un archivo PHP de array plano — la misma técnica sin reflexión que el Capítulo 2 introdujo para el manifiesto de componentes y el Capítulo 3 para `#[ConfigProperties]`. -Nunca ejecutas `HandlerScanner` tú mismo. Es uno de los **trece** pares escáner/compilador que `php artisan firefly:cache` orquesta en un solo paso, junto al de componentes/contexto, rutas, propiedades de configuración y (del Capítulo 9) el transaccional: +Nunca ejecutas `HandlerScanner` tú mismo. Es uno de los **catorce** pares escáner/compilador que `php artisan firefly:cache` orquesta en un solo paso, junto al de componentes/contexto, rutas, propiedades de configuración y (del Capítulo 9) el transaccional: ```bash $ php artisan firefly:cache -firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly ``` El archivo compilado aterriza en `bootstrap/cache/firefly/handlers.php`; `FireflyCacheServiceProvider` lo vincula directamente sobre la entrada del contenedor `HandlerManifest` — sobrescribiendo incondicionalmente el `HandlerManifest` **vacío** por defecto que `CqrsWiringProvider` vincula cuando todavía no existe ninguna caché (el mismo patrón de retirada al estilo `#[ConditionalOnMissingBean]` que el Capítulo 2 te mostró para los beans, aplicado aquí a todo un manifiesto compilado). `CqrsHandlerWiringPass` recorre entonces ese manifiesto en el arranque y llena el `HandlerRegistry` que conociste arriba con un invocador por descriptor — cada invocador resuelve su bean manejador **de nuevo desde el contenedor en cada despacho**, nunca en caché en tiempo de cableado, así que un manejador `#[Transactional]` como `OpenWalletHandler` siempre se observa a través de su proxy generado (Capítulo 9), nunca a través de la clase desnuda, sin proxy. @@ -492,7 +492,7 @@ Equivócate en esto en una aplicación — añade un manejador, olvida volver a | La tubería acotada | correlacionar → validar → autorizar → (obtener de caché, solo consultas) → resolver e invocar → (guardar en caché, solo consultas) → métricas | | `CommandProcessingException` / `QueryProcessingException` | Envoltorios que preservan la categoría; el fallo original siempre es alcanzable vía `getPrevious()` | | `HandlerRegistry` | Una clase de comando/consulta se mapea a exactamente un manejador — un registro duplicado falla ruidosamente en tiempo de cableado | -| `HandlerScanner` → `HandlerManifest` | El único punto de reflexión; compilado por `firefly:cache` (uno de trece pares escáner/compilador) en un manifiesto sin reflexión | +| `HandlerScanner` → `HandlerManifest` | El único punto de reflexión; compilado por `firefly:cache` (uno de catorce pares escáner/compilador) en un manifiesto sin reflexión | --- diff --git a/book/src-es/08-eda-outbox.md b/book/src-es/08-eda-outbox.md index a641e2bc..8e889af7 100644 --- a/book/src-es/08-eda-outbox.md +++ b/book/src-es/08-eda-outbox.md @@ -183,7 +183,7 @@ final class OrderService Aquí el destino (`'orders'`) y el tipo de evento (`'order.placed'`) son cadenas deliberadamente distintas, y el patrón `'order.*'` coincide con la **segunda** — precisamente para que este ejemplo no pueda confundirse con evidencia de que los patrones alguna vez miran el destino. -`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) es el único sitio de reflexión en `firefly/eda`, recorre una vez las raíces PSR-4 de la aplicación y compila cada método `#[EventListener]` en un `EventListenerDescriptor`; `EventListenerManifest` carga el resultado sin reflexión alguna, exactamente el mismo idioma de `HandlerManifest` que te mostró el Capítulo 7. `firefly:cache` ejecuta este escáner como uno de sus trece pares, escribiendo `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` lo vincula, sustituyendo el valor por defecto vacío con el que arrancaría una aplicación sin caché. `EventListenerWiringPass` recorre después ese manifiesto al arrancar — en *todo* proceso, tanto en la petición web como en el worker de cola — envuelve cada invocación de destino en el decorador de reintento/DLQ de más abajo, y llama a `$bus->subscribe($pattern, $wrapped)` por cada patrón, resolviendo el bean de destino **fresco desde el contenedor en cada despacho**. +`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) es el único sitio de reflexión en `firefly/eda`, recorre una vez las raíces PSR-4 de la aplicación y compila cada método `#[EventListener]` en un `EventListenerDescriptor`; `EventListenerManifest` carga el resultado sin reflexión alguna, exactamente el mismo idioma de `HandlerManifest` que te mostró el Capítulo 7. `firefly:cache` ejecuta este escáner como uno de sus catorce pares, escribiendo `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` lo vincula, sustituyendo el valor por defecto vacío con el que arrancaría una aplicación sin caché. `EventListenerWiringPass` recorre después ese manifiesto al arrancar — en *todo* proceso, tanto en la petición web como en el worker de cola — envuelve cada invocación de destino en el decorador de reintento/DLQ de más abajo, y llama a `$bus->subscribe($pattern, $wrapped)` por cada patrón, resolviendo el bean de destino **fresco desde el contenedor en cada despacho**. --- diff --git a/book/src-es/09-transactions.md b/book/src-es/09-transactions.md index bc965632..0a9b0805 100644 --- a/book/src-es/09-transactions.md +++ b/book/src-es/09-transactions.md @@ -404,7 +404,7 @@ Cuatro detalles, cada uno de los cuales acabarás necesitando: El Capítulo 2 te enseñó la forma de una clase `#[Configuration]` y sus métodos fábrica `#[Bean]`. Durante una versión de este framework, toda aplicación LaraFly tuvo que escribir una de ellas a mano — un `#[Configuration]` cuyo único `#[Bean]` cargaba el `TransactionalManifest` cacheado — o `#[Transactional]` no hacía absolutamente nada. **Ya no necesitas esa clase**, y merece una página: el fallo que aquella clase sorteaba fue de los más afilados que ha tenido este framework, y su arreglo es una pequeña lección sobre cómo se resuelve cada artefacto compilado. -`php artisan firefly:cache` emite **tres** artefactos separados que lee la maquinaria de proxies. Ejecuta `TransactionalScanner`/`TransactionalManifestCompiler` — uno de los trece pares scanner/compiler — para escribir los datos del manifiesto en `bootstrap/cache/firefly/transactional.php`. Ejecuta `ProxyPlanCompiler` sobre el `ProxyPlan` fusionado para escribir `bootstrap/cache/firefly/proxy-plan.php` — el artefacto que decide qué beans reciben siquiera un proxy y qué advice ejecuta cada uno de sus métodos. Y ejecuta `ProxyClassGenerator` para emitir un archivo fuente `{Target}__FireflyTransactionalProxy` **por cada clase que el plan nombra** — cada clase que reclama algún `AdviceSource`, así que un bean que solo lleva reglas de seguridad de método también recibe uno — en un directorio `proxies/`, más un classmap `proxies.php`. +`php artisan firefly:cache` emite **tres** artefactos separados que lee la maquinaria de proxies. Ejecuta `TransactionalScanner`/`TransactionalManifestCompiler` — uno de los catorce pares scanner/compiler — para escribir los datos del manifiesto en `bootstrap/cache/firefly/transactional.php`. Ejecuta `ProxyPlanCompiler` sobre el `ProxyPlan` fusionado para escribir `bootstrap/cache/firefly/proxy-plan.php` — el artefacto que decide qué beans reciben siquiera un proxy y qué advice ejecuta cada uno de sus métodos. Y ejecuta `ProxyClassGenerator` para emitir un archivo fuente `{Target}__FireflyTransactionalProxy` **por cada clase que el plan nombra** — cada clase que reclama algún `AdviceSource`, así que un bean que solo lleva reglas de seguridad de método también recibe uno — en un directorio `proxies/`, más un classmap `proxies.php`. Durante mucho tiempo, nada cargaba el primero de esos tres. `DataAutoConfiguration` vinculaba un `TransactionalManifest` *vacío* incondicionalmente, el `transactional.php` compilado solo lo referenciaba su propia declaración, y por eso `hasProxyFor()` era siempre falso y `TransactionalBeanPostProcessor` devolvía cada bean sin envolver. `#[Transactional]` era un **no-op silencioso** en cualquier aplicación que no escribiera a mano su propia configuración de manifiesto — que es exactamente lo que aquel `#[Configuration]` manuscrito existía para hacer, y exactamente el modo de fallo del que avisaba la sección anterior, llegando desde el propio lado del framework. diff --git a/book/src-es/13-cli-cache.md b/book/src-es/13-cli-cache.md index 40ebdbc7..cf31f787 100644 --- a/book/src-es/13-cli-cache.md +++ b/book/src-es/13-cli-cache.md @@ -2,7 +2,7 @@ # La CLI y la Caché Sin Reflexión {.chtitle} -Al terminar este capítulo sabrás exactamente qué hace `php artisan firefly:cache` — los catorce pasos de escáner-y-compilador que ejecuta, en orden, sobre las propias raíces PSR-4 de tu aplicación, y los manifiestos de `bootstrap/cache/firefly/` y las clases proxy generadas (una por cada clase que nombra el plan de proxies) que escribe — cómo `FireflyCacheServiceProvider` vuelve a cargar esa caché en el arranque con cero reflexión, el inverso exacto de `firefly:clear`, los comandos de actuator-sobre-CLI (`firefly:about`/`:routes`/`:health`/`:metrics`) que reutilizan los endpoints del Capítulo 11 en-proceso sin ida y vuelta HTTP, los ocho generadores `make:firefly-*`, y cómo el `firefly new` de `firefly/installer` te lleva de la nada a una aplicación cacheada y en funcionamiento en un solo comando. +Al terminar este capítulo sabrás exactamente qué hace `php artisan firefly:cache` — los quince pasos de escáner-y-compilador que ejecuta, en orden, sobre las propias raíces PSR-4 de tu aplicación, y los manifiestos de `bootstrap/cache/firefly/` y las clases proxy generadas (una por cada clase que nombra el plan de proxies) que escribe — cómo `FireflyCacheServiceProvider` vuelve a cargar esa caché en el arranque con cero reflexión, el inverso exacto de `firefly:clear`, los comandos de actuator-sobre-CLI (`firefly:about`/`:routes`/`:health`/`:metrics`) que reutilizan los endpoints del Capítulo 11 en-proceso sin ida y vuelta HTTP, los ocho generadores `make:firefly-*`, y cómo el `firefly new` de `firefly/installer` te lleva de la nada a una aplicación cacheada y en funcionamiento en un solo comando. !!! note "Término nuevo: arranque sin reflexión" Cada capítulo anterior a este ha mencionado, de pasada, que un manifiesto compilado reemplaza a "escanea el sistema de archivos con reflexión de PHP en cada arranque". Un **arranque sin reflexión** es lo que obtienes una vez que `firefly:cache` ha ejecutado realmente: `FireflyCacheServiceProvider` carga manifiestos precomputados directamente desde archivos PHP bajo `bootstrap/cache/firefly/`, de modo que ningún atributo, ninguna clase y ningún método se somete jamás a reflexión en tiempo de petición — el escaneo ocurrió una vez, en tiempo de despliegue, no en cada petición. @@ -133,6 +133,12 @@ final class ManifestCacheWriter $files[] = $dir.'/'.FireflyCachePaths::ORCHESTRATION, ); + // eventsourcing — #[EventType] events and the constructor parameters their mapper needs. + (new EventSourcingManifestCompiler)->write( + (new EventSourcingScanner)->scan($psr4), + $files[] = $dir.'/'.FireflyCachePaths::EVENTSOURCING, + ); + // security (new SecurityMethodManifestCompiler)->write( (new MethodSecurityScanner)->scan($psr4), @@ -154,7 +160,7 @@ final class ManifestCacheWriter } ``` -Eso son trece llamadas `scan()`-luego-`write()` — cada capacidad desde el escaneo de componentes del Capítulo 2 hasta el manifiesto de seguridad de método del Capítulo 10, más el manifiesto de orquestación con las definiciones `#[Saga]`, `#[Tcc]` y `#[Workflow]`, cada una ya presentada en un capítulo anterior como un escaneo en-proceso — más un paso más, `writeProxies()`, que genera las clases proxy que te enseñó el Capítulo 9, usando el mismísimo `ProxyClassGenerator` sin reflexión que usa el propio runtime cuando aún no existe caché: +Eso son catorce llamadas `scan()`-luego-`write()` — cada capacidad desde el escaneo de componentes del Capítulo 2 hasta el manifiesto de seguridad de método del Capítulo 10, más el manifiesto de orquestación con las definiciones `#[Saga]`, `#[Tcc]` y `#[Workflow]` y el manifiesto de event sourcing con los eventos `#[EventType]`, cada una ya presentada en un capítulo anterior como un escaneo en-proceso — más un paso más, `writeProxies()`, que genera las clases proxy que te enseñó el Capítulo 9, usando el mismísimo `ProxyClassGenerator` sin reflexión que usa el propio runtime cuando aún no existe caché: ```php @@ -203,7 +209,7 @@ final class ManifestCacheWriter } ``` -Catorce pasos en total dejan quince artefactos bajo `bootstrap/cache/firefly/`: +Quince pasos en total dejan dieciséis artefactos bajo `bootstrap/cache/firefly/`: ``` bootstrap/cache/firefly/ @@ -218,6 +224,7 @@ bootstrap/cache/firefly/ ├── message-listeners.php # #[MessageListener] manifest ├── scheduled.php # #[Scheduled] manifest ├── orchestration.php # #[Saga]/#[Tcc]/#[Workflow] definitions and their schedules +├── eventsourcing.php # #[EventType] events and their constructor parameters ├── security-methods.php # #[PreAuthorize]/#[Secured]/#[RolesAllowed] manifest ├── transactional.php # #[Transactional] method manifest ├── proxy-plan.php # qué beans reciben un proxy, y qué advice ejecuta cada método @@ -322,7 +329,7 @@ php artisan firefly:cache ``` ``` -firefly:cache — wrote 15 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider ``` @@ -530,7 +537,7 @@ El Capítulo 13A presenta `firefly:flags` para inspeccionar y cambiar banderas e | Concepto | Qué hace | |---|---| -| `firefly:cache` | Ejecuta el par escáner→compilador propio de cada capacidad (14 pasos, 15 artefactos) sobre `firefly.scan.paths`; escribe manifiestos + un archivo de clase proxy generado por cada clase que nombra el plan de proxies a `bootstrap/cache/firefly/` | +| `firefly:cache` | Ejecuta el par escáner→compilador propio de cada capacidad (15 pasos, 16 artefactos) sobre `firefly.scan.paths`; escribe manifiestos + un archivo de clase proxy generado por cada clase que nombra el plan de proxies a `bootstrap/cache/firefly/` | | `FireflyCacheServiceProvider` | Enlaza cada manifiesto cacheado incondicionalmente (gana sobre todo valor por defecto vacío) + instala el autoloader de classmap de proxies — antes de que se ejecute cualquier resolución de bean | | `firefly:clear` | Borra recursivamente solo el directorio de caché; el siguiente arranque recurre al escaneo en-proceso | | `firefly:about` / `:routes` / `:health` / `:metrics` | Renderiza los endpoints de actuator del Capítulo 11 en el terminal, en-proceso, sin ida y vuelta HTTP | diff --git a/book/src/02-dependency-injection.md b/book/src/02-dependency-injection.md index 8dfc6d89..d18c03a6 100644 --- a/book/src/02-dependency-injection.md +++ b/book/src/02-dependency-injection.md @@ -367,7 +367,7 @@ php artisan firefly:cache ``` ``` -firefly:cache — wrote 15 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly ``` You already ran this once, indirectly — `firefly new` calls it for you in its `post-create-project-cmd` script, which is why the Quick Start's application booted correctly without you ever running the command by hand. From here on, any time you add or change a `#[Service]`, `#[Repository]`, `#[Configuration]`, or any other Firefly attribute, re-run `firefly:cache` to recompile the manifest; `php artisan firefly:clear` deletes the compiled cache and falls back to the slower, reflection-based development scan. diff --git a/book/src/03-configuration.md b/book/src/03-configuration.md index 47502f11..bfad66c1 100644 --- a/book/src/03-configuration.md +++ b/book/src/03-configuration.md @@ -231,7 +231,7 @@ foreach ($this->classesIn($prefix, $dir) as $class) { } ``` -`ConfigManifestCompiler` writes that list to `bootstrap/cache/firefly/config-properties.php` with the same `var_export()`-based, zero-reflection technique the component manifest used. `php artisan firefly:cache` runs this scan as one step among the thirteen scanner/compiler pairs it orchestrates — you never invoke `ConfigPropertiesScanner` yourself. +`ConfigManifestCompiler` writes that list to `bootstrap/cache/firefly/config-properties.php` with the same `var_export()`-based, zero-reflection technique the component manifest used. `php artisan firefly:cache` runs this scan as one step among the fourteen scanner/compiler pairs it orchestrates — you never invoke `ConfigPropertiesScanner` yourself. At boot, `Firefly\Cli\Boot\FireflyCacheServiceProvider` loads that compiled manifest and hands it straight to `Firefly\Config\Registrar\ConfigRegistrar::register()`, which binds every descriptor's DTO as a container singleton in one pass: diff --git a/book/src/07-cqrs.md b/book/src/07-cqrs.md index 9d166f3c..0ac0c558 100644 --- a/book/src/07-cqrs.md +++ b/book/src/07-cqrs.md @@ -469,11 +469,11 @@ The outer type tells you *which bus operation* failed; the inner type — reache `HandlerScanner` is the **sole** reflection site in `packages/cqrs/src` (the same grep-enforced invariant Chapter 6 described for `firefly/domain`). It walks the app's PSR-4 roots once, resolves every `#[CommandHandler]`/`#[QueryHandler]` (running exactly the `messageType()` inference you just read) into a `HandlerDescriptor`, and hands the whole list to `HandlerManifestCompiler`, which `var_export()`s it to a plain PHP array file — the same zero-reflection technique Chapter 2 introduced for the component manifest and Chapter 3 for `#[ConfigProperties]`. -You never run `HandlerScanner` yourself. It is one of the **thirteen** scanner/compiler pairs `php artisan firefly:cache` orchestrates in one pass, alongside the component/context, route, config-properties, and (Chapter 9's) transactional scanners: +You never run `HandlerScanner` yourself. It is one of the **fourteen** scanner/compiler pairs `php artisan firefly:cache` orchestrates in one pass, alongside the component/context, route, config-properties, and (Chapter 9's) transactional scanners: ```bash $ php artisan firefly:cache -firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly ``` The compiled file lands at `bootstrap/cache/firefly/handlers.php`; `FireflyCacheServiceProvider` binds it straight onto the `HandlerManifest` container entry — unconditionally overriding the **empty** default `CqrsWiringProvider` binds when no cache exists yet (the same `#[ConditionalOnMissingBean]`-style backing-off pattern Chapter 2 showed you for beans, applied here to a whole compiled manifest). `CqrsHandlerWiringPass` then walks that manifest at boot and populates the `HandlerRegistry` you met above with one invoker per descriptor — each invoker resolving its handler bean **fresh from the container on every dispatch**, never cached at wiring time, so a `#[Transactional]` handler like `OpenWalletHandler` is always observed through its generated proxy (Chapter 9), never through the bare, unproxied class. @@ -492,7 +492,7 @@ Get this wrong in an application — add a handler, forget to re-run `firefly:ca | The bounded pipeline | correlate → validate → authorize → (cache-get, query only) → resolve + invoke → (cache-put, query only) → metrics | | `CommandProcessingException` / `QueryProcessingException` | Category-preserving wrappers; the original fault is always reachable via `getPrevious()` | | `HandlerRegistry` | One command/query class maps to exactly one handler — a duplicate registration fails loud at wiring time | -| `HandlerScanner` → `HandlerManifest` | The sole reflection site; compiled by `firefly:cache` (one of thirteen scanner/compiler pairs) into a zero-reflection manifest | +| `HandlerScanner` → `HandlerManifest` | The sole reflection site; compiled by `firefly:cache` (one of fourteen scanner/compiler pairs) into a zero-reflection manifest | --- diff --git a/book/src/08-eda-outbox.md b/book/src/08-eda-outbox.md index f36649b1..27d7df83 100644 --- a/book/src/08-eda-outbox.md +++ b/book/src/08-eda-outbox.md @@ -183,7 +183,7 @@ final class OrderService Here the destination (`'orders'`) and the event type (`'order.placed'`) are deliberately different strings, and the `'order.*'` pattern matches the **second** one — precisely so this example cannot be mistaken for evidence that patterns ever look at the destination. -`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) is the sole reflection site in `firefly/eda`, walking the app's PSR-4 roots once and compiling every `#[EventListener]` method into an `EventListenerDescriptor`; `EventListenerManifest` loads the result with zero reflection, exactly the `HandlerManifest` idiom Chapter 7 just showed you. `firefly:cache` runs this scanner as one of its thirteen pairs, writing `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` binds it, overriding the empty default an uncached app would otherwise boot with. `EventListenerWiringPass` then walks that manifest at boot — in *every* process, web request and queue worker alike — wraps each target invocation in the retry/DLQ decorator below, and calls `$bus->subscribe($pattern, $wrapped)` per pattern, resolving the target bean **fresh from the container on every dispatch**. +`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) is the sole reflection site in `firefly/eda`, walking the app's PSR-4 roots once and compiling every `#[EventListener]` method into an `EventListenerDescriptor`; `EventListenerManifest` loads the result with zero reflection, exactly the `HandlerManifest` idiom Chapter 7 just showed you. `firefly:cache` runs this scanner as one of its fourteen pairs, writing `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` binds it, overriding the empty default an uncached app would otherwise boot with. `EventListenerWiringPass` then walks that manifest at boot — in *every* process, web request and queue worker alike — wraps each target invocation in the retry/DLQ decorator below, and calls `$bus->subscribe($pattern, $wrapped)` per pattern, resolving the target bean **fresh from the container on every dispatch**. --- diff --git a/book/src/09-transactions.md b/book/src/09-transactions.md index 1abce006..bec13452 100644 --- a/book/src/09-transactions.md +++ b/book/src/09-transactions.md @@ -404,7 +404,7 @@ Four details, each of which you will eventually need: Chapter 2 taught you the shape of a `#[Configuration]` class and its `#[Bean]` factory methods. For one release of this framework, every LaraFly application had to write one of them by hand — a `#[Configuration]` whose single `#[Bean]` loaded the cached `TransactionalManifest` — or `#[Transactional]` did nothing at all. **You no longer need that class**, and it is worth a page: the bug it worked around was one of the sharpest this framework has had, and the fix is a small lesson in how every compiled artifact is resolved. -`php artisan firefly:cache` emits **three** separate artifacts the proxy machinery reads. It runs `TransactionalScanner`/`TransactionalManifestCompiler` — one of the thirteen scanner/compiler pairs — to write the manifest data to `bootstrap/cache/firefly/transactional.php`. It runs `ProxyPlanCompiler` over the merged `ProxyPlan` to write `bootstrap/cache/firefly/proxy-plan.php` — the artifact that decides which beans get a proxy at all and which advice each of their methods runs. And it runs `ProxyClassGenerator` to emit one `{Target}__FireflyTransactionalProxy` source file **per class the plan names** — every class any `AdviceSource` claims, so a bean carrying only method-security rules gets one too — into a `proxies/` directory, plus a `proxies.php` classmap. +`php artisan firefly:cache` emits **three** separate artifacts the proxy machinery reads. It runs `TransactionalScanner`/`TransactionalManifestCompiler` — one of the fourteen scanner/compiler pairs — to write the manifest data to `bootstrap/cache/firefly/transactional.php`. It runs `ProxyPlanCompiler` over the merged `ProxyPlan` to write `bootstrap/cache/firefly/proxy-plan.php` — the artifact that decides which beans get a proxy at all and which advice each of their methods runs. And it runs `ProxyClassGenerator` to emit one `{Target}__FireflyTransactionalProxy` source file **per class the plan names** — every class any `AdviceSource` claims, so a bean carrying only method-security rules gets one too — into a `proxies/` directory, plus a `proxies.php` classmap. For a long time, nothing loaded the first of those three. `DataAutoConfiguration` bound an unconditional *empty* `TransactionalManifest`, the compiled `transactional.php` was referenced only by its own declaration, and so `hasProxyFor()` was always false and `TransactionalBeanPostProcessor` handed back every bean unwrapped. `#[Transactional]` was a **silent no-op** in any application that did not hand-write its own manifest configuration — which is exactly what that hand-written `#[Configuration]` existed to do, and exactly the failure mode the previous section warned about, arriving from the framework's own side. diff --git a/book/src/13-cli-cache.md b/book/src/13-cli-cache.md index a7fe69de..12b30f49 100644 --- a/book/src/13-cli-cache.md +++ b/book/src/13-cli-cache.md @@ -2,7 +2,7 @@ # The CLI and the Zero-Reflection Cache {.chtitle} -By the end of this chapter you will know exactly what `php artisan firefly:cache` does — the fourteen scanner-and-compiler steps it runs, in order, over your application's own PSR-4 roots, and the `bootstrap/cache/firefly/` manifests and generated proxy classes (one per class the proxy plan names) it writes — how `FireflyCacheServiceProvider` loads that cache back in at boot with zero reflection, `firefly:clear`'s exact inverse, the actuator-over-CLI commands (`firefly:about`/`:routes`/`:health`/`:metrics`) that reuse Chapter 11's endpoints in-process with no HTTP round trip, the eight `make:firefly-*` generators, and how `firefly/installer`'s `firefly new` gets you from nothing to a cached, running application in one command. +By the end of this chapter you will know exactly what `php artisan firefly:cache` does — the fifteen scanner-and-compiler steps it runs, in order, over your application's own PSR-4 roots, and the `bootstrap/cache/firefly/` manifests and generated proxy classes (one per class the proxy plan names) it writes — how `FireflyCacheServiceProvider` loads that cache back in at boot with zero reflection, `firefly:clear`'s exact inverse, the actuator-over-CLI commands (`firefly:about`/`:routes`/`:health`/`:metrics`) that reuse Chapter 11's endpoints in-process with no HTTP round trip, the eight `make:firefly-*` generators, and how `firefly/installer`'s `firefly new` gets you from nothing to a cached, running application in one command. !!! note "New term: zero-reflection boot" Every chapter before this one has mentioned, in passing, that a compiled manifest replaces "scan the filesystem with PHP reflection at every boot." A **zero-reflection boot** is what you get once `firefly:cache` has actually run: `FireflyCacheServiceProvider` loads pre-computed manifests straight from PHP files under `bootstrap/cache/firefly/`, so no attribute, no class, and no method is ever reflected on at request time — the scan happened once, at deploy time, not on every request. @@ -133,6 +133,12 @@ final class ManifestCacheWriter $files[] = $dir.'/'.FireflyCachePaths::ORCHESTRATION, ); + // eventsourcing — #[EventType] events and the constructor parameters their mapper needs. + (new EventSourcingManifestCompiler)->write( + (new EventSourcingScanner)->scan($psr4), + $files[] = $dir.'/'.FireflyCachePaths::EVENTSOURCING, + ); + // security (new SecurityMethodManifestCompiler)->write( (new MethodSecurityScanner)->scan($psr4), @@ -154,7 +160,7 @@ final class ManifestCacheWriter } ``` -That is thirteen `scan()`-then-`write()` calls — every capability from Chapter 2's component scan through Chapter 10's method-security manifest, plus the orchestration manifest of `#[Saga]`, `#[Tcc]` and `#[Workflow]` definitions, each already introduced in an earlier chapter as an in-process scan — plus one more step, `writeProxies()`, that generates the proxy classes Chapter 9 taught you about, using the exact same reflection-free `ProxyClassGenerator` the runtime itself uses when no cache exists yet: +That is fourteen `scan()`-then-`write()` calls — every capability from Chapter 2's component scan through Chapter 10's method-security manifest, plus the orchestration manifest of `#[Saga]`, `#[Tcc]` and `#[Workflow]` definitions and the event-sourcing manifest of `#[EventType]` events, each already introduced in an earlier chapter as an in-process scan — plus one more step, `writeProxies()`, that generates the proxy classes Chapter 9 taught you about, using the exact same reflection-free `ProxyClassGenerator` the runtime itself uses when no cache exists yet: ```php @@ -203,7 +209,7 @@ final class ManifestCacheWriter } ``` -Fourteen steps in total land fifteen artifacts under `bootstrap/cache/firefly/`: +Fifteen steps in total land sixteen artifacts under `bootstrap/cache/firefly/`: ``` bootstrap/cache/firefly/ @@ -218,6 +224,7 @@ bootstrap/cache/firefly/ ├── message-listeners.php # #[MessageListener] manifest ├── scheduled.php # #[Scheduled] manifest ├── orchestration.php # #[Saga]/#[Tcc]/#[Workflow] definitions and their schedules +├── eventsourcing.php # #[EventType] events and their constructor parameters ├── security-methods.php # #[PreAuthorize]/#[Secured]/#[RolesAllowed] manifest ├── transactional.php # #[Transactional] method manifest ├── proxy-plan.php # which beans get a proxy, and which advice each method runs @@ -322,7 +329,7 @@ php artisan firefly:cache ``` ``` -firefly:cache — wrote 15 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider ``` @@ -530,7 +537,7 @@ Chapter 13A introduces `firefly:flags` for inspecting and changing runtime flags | Concept | What it does | |---|---| -| `firefly:cache` | Runs every capability's own scanner→compiler pair (14 steps, 15 artifacts) over `firefly.scan.paths`; writes manifests + one generated proxy class file per class the proxy plan names to `bootstrap/cache/firefly/` | +| `firefly:cache` | Runs every capability's own scanner→compiler pair (15 steps, 16 artifacts) over `firefly.scan.paths`; writes manifests + one generated proxy class file per class the proxy plan names to `bootstrap/cache/firefly/` | | `FireflyCacheServiceProvider` | Binds each cached manifest unconditionally (wins over every empty default) + installs the proxy classmap autoloader — before any bean resolution runs | | `firefly:clear` | Recursively deletes only the cache dir; the next boot falls back to in-process scanning | | `firefly:about` / `:routes` / `:health` / `:metrics` | Renders Chapter 11's actuator endpoints at the terminal, in-process, no HTTP round trip | diff --git a/deptrac.yaml b/deptrac.yaml index eb9b8a2c..c3b83647 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -688,7 +688,7 @@ deptrac: - Observability - FeatureFlags - # Cli is the top-of-stack developer-experience package: firefly:cache drives each of the 13 + # Cli is the top-of-stack developer-experience package: firefly:cache drives each of the 14 # compile-producing packages' scanner/compiler pair directly (D4 DIRECT DEPENDENCY). UNLIKE Testing # (whose composer.json genuinely requires the full runtime family, so it legitimately lists all 18 # layers), firefly/cli's composer.json requires exactly the packages ManifestCacheWriter/CacheCommand/ @@ -703,6 +703,7 @@ deptrac: # accordingly. # Orchestration is listed for the same reason: ManifestCacheWriter compiles #[Saga]/#[Tcc]/#[Workflow] through # OrchestrationScanner into orchestration.php, and packages/cli/composer.json requires it accordingly. + # EventSourcing is listed for the same reason: ManifestCacheWriter compiles #[EventType] events through EventSourcingScanner into eventsourcing.php. # It is depended on by NONE. It does NOT depend on Testing (dev-only). Cli: - Kernel @@ -724,6 +725,7 @@ deptrac: - Resilience - FeatureFlags - Orchestration + - EventSourcing # The bundled installer reads the framework version for its local skeleton repository. Installer: diff --git a/docs/cli.md b/docs/cli.md index 182fb8f3..37a80890 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -21,10 +21,10 @@ php artisan firefly:cache ``` The zero-reflection payoff: it runs every settled package's existing scanner → compiler pair over -`config('firefly.scan.paths')` (a PSR-4 map, e.g. `'App\\' => app_path()`) and writes fifteen compiled +`config('firefly.scan.paths')` (a PSR-4 map, e.g. `'App\\' => app_path()`) and writes sixteen compiled artifacts — every manifest below, unconditionally, whether or not the application has anything to put in it — plus one generated proxy class per planned bean, into `bootstrap/cache/firefly/`. It prints what it wrote: -`firefly:cache — wrote 15 manifest(s) + N proxy(ies) to `. +`firefly:cache — wrote 16 manifest(s) + N proxy(ies) to `. ``` bootstrap/cache/firefly/ @@ -39,6 +39,7 @@ bootstrap/cache/firefly/ ├── message-listeners.php # #[MessageListener] manifest ├── scheduled.php # #[Scheduled] manifest ├── orchestration.php # #[Saga]/#[Tcc]/#[Workflow] definitions and their schedules +├── eventsourcing.php # #[EventType] events and their constructor parameters ├── security-methods.php # #[PreAuthorize]/#[Secured]/#[RolesAllowed] manifest ├── transactional.php # #[Transactional] method manifest ├── proxy-plan.php # the compiled ProxyPlan: which beans get a proxy, and the advice each method runs @@ -112,7 +113,7 @@ php artisan firefly:cache ``` ``` -firefly:cache — wrote 15 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider ``` diff --git a/docs/tutorial.es.md b/docs/tutorial.es.md index 7bce932f..433774ea 100644 --- a/docs/tutorial.es.md +++ b/docs/tutorial.es.md @@ -1093,10 +1093,10 @@ php artisan firefly:cache ``` ``` -firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly ``` -El recuento de manifiestos es fijo, quince, porque `ManifestCacheWriter` escribe cada artefacto sin +El recuento de manifiestos es fijo, dieciséis, porque `ManifestCacheWriter` escribe cada artefacto sin condiciones — una aplicación sin ningún método `#[Scheduled]` obtiene igualmente un `scheduled.php` vacío, que es lo que permite a la ruta de arranque leer "el fichero no está" como "nadie ha compilado todavía" y no como "no hay nada que cargar". Este único comando ejecuta *cada* par escáner → compilador ya asentado de @@ -1104,7 +1104,7 @@ cada paquete sobre `config('firefly.scan.paths')` y escribe, en `bootstrap/cache de DI/context/config-properties, la tabla de rutas compilada, el manifiesto de handlers `#[ControllerAdvice]`, el manifiesto de restricciones de validación, el manifiesto de handlers de CQRS, los manifiestos de listeners de evento/mensaje, el manifiesto de tareas programadas, el manifiesto de orquestación -(`#[Saga]`/`#[Tcc]`/`#[Workflow]`), el manifiesto de métodos de +(`#[Saga]`/`#[Tcc]`/`#[Workflow]`), el manifiesto de event sourcing (`#[EventType]`), el manifiesto de métodos de seguridad, el manifiesto de `#[Transactional]`, el **plan de proxies** (`proxy-plan.php` — qué beans reciben un proxy y qué advice ejecuta cada uno de sus métodos) y el classmap de proxies, **y** un fichero de clase `RenameGreetingHandler__FireflyTransactionalProxy` generado (el "1 proxy" de arriba). Se emite un proxy por diff --git a/docs/tutorial.md b/docs/tutorial.md index e7184f57..8ddc2550 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1074,17 +1074,17 @@ php artisan firefly:cache ``` ``` -firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly +firefly:cache — wrote 16 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly ``` -The manifest count is fixed at fifteen, because `ManifestCacheWriter` writes every artifact unconditionally +The manifest count is fixed at sixteen, because `ManifestCacheWriter` writes every artifact unconditionally — an application with no `#[Scheduled]` method still gets an empty `scheduled.php`, which is what lets the boot path treat "the file is missing" as "nobody has compiled yet" rather than as "there is nothing to load". This single command drives *every* settled package's existing scanner → compiler pair over `config('firefly.scan.paths')` and writes, into `bootstrap/cache/firefly/`: the DI/context/config-properties manifests, the compiled route table, the `#[ControllerAdvice]` handler manifest, the validation constraint manifest, the CQRS handler manifest, the event/message-listener manifests, the scheduled-task manifest, the -orchestration manifest (`#[Saga]`/`#[Tcc]`/`#[Workflow]`), the security-method manifest, the `#[Transactional]` manifest, the **proxy plan** (`proxy-plan.php` — which +orchestration manifest (`#[Saga]`/`#[Tcc]`/`#[Workflow]`), the event-sourcing manifest (`#[EventType]`), the security-method manifest, the `#[Transactional]` manifest, the **proxy plan** (`proxy-plan.php` — which beans get a proxy and which advice each of their methods runs) and the proxy classmap, **and** a generated `RenameGreetingHandler__FireflyTransactionalProxy` class file (the "1 proxy" above). A proxy is emitted per class the *plan* claims, which is every class any advice source names — so a `#[Service]` carrying only diff --git a/packages/cli/composer.json b/packages/cli/composer.json index 3455988b..395d4170 100644 --- a/packages/cli/composer.json +++ b/packages/cli/composer.json @@ -29,6 +29,7 @@ "firefly/cqrs": "*@dev", "firefly/data": "*@dev", "firefly/eda": "*@dev", + "firefly/eventsourcing": "*@dev", "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", diff --git a/packages/cli/src/Boot/FireflyCacheServiceProvider.php b/packages/cli/src/Boot/FireflyCacheServiceProvider.php index 28f84d83..48fdce0b 100644 --- a/packages/cli/src/Boot/FireflyCacheServiceProvider.php +++ b/packages/cli/src/Boot/FireflyCacheServiceProvider.php @@ -10,6 +10,7 @@ use Firefly\Config\Scanner\ConfigPropertiesManifest; use Firefly\Cqrs\Handler\HandlerManifest; use Firefly\Eda\Listener\EventListenerManifest; +use Firefly\EventSourcing\Manifest\EventSourcingManifest; use Firefly\Messaging\Listener\MessageListenerManifest; use Firefly\Orchestration\Manifest\OrchestrationManifest; use Firefly\Scheduling\Schedule\ScheduledManifest; @@ -59,6 +60,9 @@ public function register(): void if (is_file($path = $dir.'/'.FireflyCachePaths::ORCHESTRATION)) { $this->app->instance(OrchestrationManifest::class, OrchestrationManifest::load($path)); } + if (is_file($path = $dir.'/'.FireflyCachePaths::EVENTSOURCING)) { + $this->app->instance(EventSourcingManifest::class, EventSourcingManifest::load($path)); + } if (is_file($path = $dir.'/'.FireflyCachePaths::SECURITY_METHODS)) { $this->app->instance(SecurityMethodManifest::class, SecurityMethodManifest::load($path)); } diff --git a/packages/cli/src/Cache/FireflyCachePaths.php b/packages/cli/src/Cache/FireflyCachePaths.php index afee3ff5..d09c9a3e 100644 --- a/packages/cli/src/Cache/FireflyCachePaths.php +++ b/packages/cli/src/Cache/FireflyCachePaths.php @@ -31,6 +31,8 @@ final class FireflyCachePaths public const string ORCHESTRATION = 'orchestration.php'; + public const string EVENTSOURCING = 'eventsourcing.php'; + public const string SECURITY_METHODS = 'security-methods.php'; public const string TRANSACTIONAL = 'transactional.php'; diff --git a/packages/cli/src/Cache/ManifestCacheWriter.php b/packages/cli/src/Cache/ManifestCacheWriter.php index 82011230..18dff876 100644 --- a/packages/cli/src/Cache/ManifestCacheWriter.php +++ b/packages/cli/src/Cache/ManifestCacheWriter.php @@ -17,6 +17,8 @@ use Firefly\Data\Transaction\TransactionalManifestCompiler; use Firefly\Eda\Listener\EventListenerManifestCompiler; use Firefly\Eda\Scanner\EventListenerScanner; +use Firefly\EventSourcing\Manifest\EventSourcingManifestCompiler; +use Firefly\EventSourcing\Scanner\EventSourcingScanner; use Firefly\FeatureFlags\Gating\FeatureFlagAdviceSource; use Firefly\Messaging\Listener\MessageListenerManifestCompiler; use Firefly\Messaging\Scanner\MessageListenerScanner; @@ -197,6 +199,12 @@ public function writeManifests(array $psr4, string $dir): CacheReport $files[] = $dir.'/'.FireflyCachePaths::ORCHESTRATION, ); + // eventsourcing — #[EventType] events and the constructor parameters their mapper needs. + (new EventSourcingManifestCompiler)->write( + (new EventSourcingScanner)->scan($psr4), + $files[] = $dir.'/'.FireflyCachePaths::EVENTSOURCING, + ); + // security (new SecurityMethodManifestCompiler)->write( (new MethodSecurityScanner)->scan($psr4), diff --git a/packages/cli/tests/Cache/ManifestCacheWriterTest.php b/packages/cli/tests/Cache/ManifestCacheWriterTest.php index 2eb28424..09bb885d 100644 --- a/packages/cli/tests/Cache/ManifestCacheWriterTest.php +++ b/packages/cli/tests/Cache/ManifestCacheWriterTest.php @@ -2,10 +2,12 @@ declare(strict_types=1); +use Firefly\Cli\Boot\FireflyCacheServiceProvider; use Firefly\Cli\Cache\FireflyCachePaths; use Firefly\Cli\Cache\ManifestCacheWriter; use Firefly\Cqrs\Handler\HandlerManifest; use Firefly\Eda\Listener\EventListenerManifest; +use Firefly\EventSourcing\Manifest\EventSourcingManifest; use Firefly\Web\Route\RouteManifest; /** @return array */ @@ -23,6 +25,7 @@ function fixturesPsr4(): array FireflyCachePaths::COMPONENT, FireflyCachePaths::CONTEXT, FireflyCachePaths::CONFIG_PROPERTIES, FireflyCachePaths::ROUTES, FireflyCachePaths::CONSTRAINTS, FireflyCachePaths::HANDLERS, FireflyCachePaths::EVENT_LISTENERS, FireflyCachePaths::MESSAGE_LISTENERS, FireflyCachePaths::SCHEDULED, FireflyCachePaths::ORCHESTRATION, + FireflyCachePaths::EVENTSOURCING, FireflyCachePaths::SECURITY_METHODS, FireflyCachePaths::TRANSACTIONAL, FireflyCachePaths::PROXY_PLAN, ] as $basename) { $path = $dir.'/'.$basename; @@ -45,3 +48,25 @@ function fixturesPsr4(): array ->and($handlers)->toBeInstanceOf(HandlerManifest::class) ->and($listeners)->toBeInstanceOf(EventListenerManifest::class); }); + +it('compiles the event-sourcing manifest beside the orchestration one', function () { + $dir = sys_get_temp_dir().'/firefly-cache-'.bin2hex(random_bytes(6)); + (new ManifestCacheWriter)->writeManifests(['Firefly\\EventSourcing\\Tests\\Fixtures\\Events\\' => dirname(__DIR__, 3).'/eventsourcing/tests/Fixtures/Events'], $dir); + + $manifest = EventSourcingManifest::load($dir.'/'.FireflyCachePaths::EVENTSOURCING); + + expect(array_map(static fn ($descriptor): string => $descriptor->type, $manifest->eventTypes()))->toBe(['order.paid', 'order.placed']); +}); + +it('binds the compiled event-sourcing manifest on the cached boot path', function () { + $dir = sys_get_temp_dir().'/firefly-cache-'.bin2hex(random_bytes(6)); + (new ManifestCacheWriter)->writeManifests(['Firefly\\EventSourcing\\Tests\\Fixtures\\Events\\' => dirname(__DIR__, 3).'/eventsourcing/tests/Fixtures/Events'], $dir); + + // No firefly.scan.paths and no EventSourcingWiringProvider: the only thing that can bind the manifest here is + // FireflyCacheServiceProvider loading eventsourcing.php. + $app = fireflyApplication(['firefly' => ['cache' => ['path' => $dir]]], [FireflyCacheServiceProvider::class]); + + expect($app->bound(EventSourcingManifest::class))->toBeTrue() + ->and(array_map(static fn ($descriptor): string => $descriptor->type, $app->make(EventSourcingManifest::class)->eventTypes())) + ->toBe(['order.paid', 'order.placed']); +}); diff --git a/packages/context/tests/Scan/AppScanTest.php b/packages/context/tests/Scan/AppScanTest.php index 10ff4c87..23c615ea 100644 --- a/packages/context/tests/Scan/AppScanTest.php +++ b/packages/context/tests/Scan/AppScanTest.php @@ -74,7 +74,8 @@ function appScanContainer(array $firefly = []): Container foreach ([ AppScan::COMPONENT, AppScan::CONTEXT, AppScan::CONFIG_PROPERTIES, AppScan::ROUTES, AppScan::EXCEPTION_HANDLERS, AppScan::CONSTRAINTS, AppScan::HANDLERS, AppScan::EVENT_LISTENERS, - AppScan::MESSAGE_LISTENERS, AppScan::SCHEDULED, AppScan::ORCHESTRATION, AppScan::SECURITY_METHODS, AppScan::TRANSACTIONAL, + AppScan::MESSAGE_LISTENERS, AppScan::SCHEDULED, AppScan::ORCHESTRATION, AppScan::EVENTSOURCING, AppScan::SECURITY_METHODS, + AppScan::TRANSACTIONAL, AppScan::PROXY_MAP, AppScan::PROXY_PLAN, ] as $basename) { expect($source)->toContain("'{$basename}'"); diff --git a/tests/DocsProseIsRealTest.php b/tests/DocsProseIsRealTest.php index 24011a20..d9985fb7 100644 --- a/tests/DocsProseIsRealTest.php +++ b/tests/DocsProseIsRealTest.php @@ -1569,13 +1569,13 @@ function fireflyOAuth2InstallProse(array $client, array $server): array // one phrasing of a claim is a guard on that phrasing, not on the claim, so all three are matched here. $figures = fireflyCacheFigures(); - // Fifteen artifacts out of thirteen scanner/compiler calls: the component+context compiler emits two, and + // Sixteen artifacts out of fourteen scanner/compiler calls: the component+context compiler emits two, and // writeProxies() appends the proxies.php classmap. If this fails, the documents below are right and this // expectation is the thing to re-derive — but read ManifestCacheWriter first. - expect($figures)->toBe(['artifacts' => 15, 'pairs' => 13]); + expect($figures)->toBe(['artifacts' => 16, 'pairs' => 14]); - // A STEP is not a pair: writeManifests() makes thirteen scanner-then-compiler calls and writeProxies() is - // the fourteenth step, which is why the chapter's own body says "thirteen `scan()`-then-`write()` calls ... + // A STEP is not a pair: writeManifests() makes fourteen scanner-then-compiler calls and writeProxies() is + // the fifteenth step, which is why the chapter's own body says "fourteen `scan()`-then-`write()` calls ... // plus one more step". Derived as pairs + 1 rather than typed, so adding a capability moves the pair // count, the step count and the sentences that quote either of them together. $steps = $figures['pairs'] + 1; From 5bfa852c82191af090dfee2df1f424c75007a39e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:39:23 -0700 Subject: [PATCH 15/33] feat(eventsourcing): add the event-sourced aggregate base with convention handlers, versions and history replay --- .../Aggregate/EventSourcedAggregateRoot.php | 140 ++++++++++++++++++ .../src/Aggregate/SnapshotAware.php | 22 +++ .../EventSourcedAggregateRootTest.php | 111 ++++++++++++++ .../tests/Fixtures/Aggregate/Event.php | 16 ++ .../eventsourcing/tests/Fixtures/Order.php | 84 +++++++++++ 5 files changed, 373 insertions(+) create mode 100644 packages/eventsourcing/src/Aggregate/EventSourcedAggregateRoot.php create mode 100644 packages/eventsourcing/src/Aggregate/SnapshotAware.php create mode 100644 packages/eventsourcing/tests/Aggregate/EventSourcedAggregateRootTest.php create mode 100644 packages/eventsourcing/tests/Fixtures/Aggregate/Event.php create mode 100644 packages/eventsourcing/tests/Fixtures/Order.php diff --git a/packages/eventsourcing/src/Aggregate/EventSourcedAggregateRoot.php b/packages/eventsourcing/src/Aggregate/EventSourcedAggregateRoot.php new file mode 100644 index 00000000..17f24b4d --- /dev/null +++ b/packages/eventsourcing/src/Aggregate/EventSourcedAggregateRoot.php @@ -0,0 +1,140 @@ +(Event $e)` by convention (`applyOrderPlaced`), or the method + * eventHandlers() names for the class. An event with no handler changes nothing, which is fine for events that + * carry no state. + * - committedVersion() is the stream version this object was loaded at, and the expected version of the next save. + * version() adds the buffered events. + * + * The constructor is final and takes the id only, so the repository can create an empty aggregate before replay + * (`new static($id)`). Create a new aggregate through a static named constructor that records its first event. + */ +abstract class EventSourcedAggregateRoot extends AggregateRoot +{ + private int $committedVersion = 0; + + final public function __construct(string $id) + { + parent::__construct($id); + } + + public function id(): string + { + return (string) $this->id; + } + + /** The stream's aggregate type. Override with a stable name: it is stored with every event. */ + public static function aggregateType(): string + { + $class = static::class; + $position = strrpos($class, '\\'); + + return strtolower($position === false ? $class : substr($class, $position + 1)); + } + + public function version(): int + { + return $this->committedVersion + count($this->pendingEvents()); + } + + public function committedVersion(): int + { + return $this->committedVersion; + } + + /** @return list */ + public function uncommittedEvents(): array + { + return $this->pendingEvents(); + } + + public function markEventsCommitted(): void + { + $this->committedVersion += count($this->pendingEvents()); + $this->clearEvents(); + } + + /** @param iterable $history */ + public function loadFromHistory(iterable $history): void + { + foreach ($history as $event) { + $this->route($event); + $this->committedVersion++; + } + } + + /** + * @param iterable $history the events after $fromVersion + * @param array|null $snapshotState the state at $fromVersion, for a SnapshotAware aggregate + */ + public static function reconstitute(string $id, iterable $history, int $fromVersion = 0, ?array $snapshotState = null): static + { + $aggregate = new static($id); + if ($snapshotState !== null) { + if (! $aggregate instanceof SnapshotAware) { + throw new LogicException(static::class.' was given a snapshot but does not implement SnapshotAware.'); + } + $aggregate->restoreSnapshot($snapshotState); + } + $aggregate->committedVersion = $fromVersion; + $aggregate->loadFromHistory($history); + + return $aggregate; + } + + protected function recordThat(DomainEvent $event): void + { + $this->route($event); + $this->raiseEvent($event); + } + + /** @return array, string> event class => handler method, for names the convention cannot give */ + protected function eventHandlers(): array + { + return []; + } + + /** + * Calls the event's handler. The name deliberately does not start with "apply": an event short-named `Event` + * must reach the subclass's applyEvent(), never this method. A handler eventHandlers() names must exist, since + * a misspelled name would otherwise drop the event's state without a word. + */ + private function route(DomainEvent $event): void + { + $named = $this->eventHandlers()[$event::class] ?? null; + $method = $named ?? 'apply'.self::shortName($event::class); + if (! method_exists($this, $method)) { + if ($named !== null) { + throw new LogicException(static::class.'::eventHandlers() maps '.$event::class." to {$named}(), which the aggregate does not declare."); + } + + return; + } + if (! is_callable([$this, $method])) { + throw new LogicException(static::class."::{$method}() must be protected or public: the aggregate base calls it."); + } + + $this->{$method}($event); + } + + private static function shortName(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? $class : substr($class, $position + 1); + } +} diff --git a/packages/eventsourcing/src/Aggregate/SnapshotAware.php b/packages/eventsourcing/src/Aggregate/SnapshotAware.php new file mode 100644 index 00000000..8ca0472b --- /dev/null +++ b/packages/eventsourcing/src/Aggregate/SnapshotAware.php @@ -0,0 +1,22 @@ + */ + public function snapshotState(): array; + + /** @param array $state */ + public function restoreSnapshot(array $state): void; + + public static function snapshotVersion(): int; +} diff --git a/packages/eventsourcing/tests/Aggregate/EventSourcedAggregateRootTest.php b/packages/eventsourcing/tests/Aggregate/EventSourcedAggregateRootTest.php new file mode 100644 index 00000000..c9f544fa --- /dev/null +++ b/packages/eventsourcing/tests/Aggregate/EventSourcedAggregateRootTest.php @@ -0,0 +1,111 @@ + convention and counts uncommitted events', function (): void { + $order = Order::place('o-1', 12.5); + $order->pay(1000); + + expect([$order->total(), $order->paidCents(), $order->version(), $order->committedVersion()])->toBe([12.5, 1000, 2, 0]) + ->and(array_map(static fn (DomainEvent $e): string => $e::class, $order->uncommittedEvents()))->toBe([OrderPlaced::class, OrderPaid::class]); + + $order->markEventsCommitted(); + + expect([$order->version(), $order->committedVersion(), $order->uncommittedEvents()])->toBe([2, 2, []]) + ->and(Order::aggregateType())->toBe('order'); +}); + +it('replays history and a snapshot without recording anything', function (): void { + $replayed = Order::reconstitute('o-1', [new OrderPlaced('o-1', 20.0), new OrderPaid('o-1', 500)]); + $fromSnapshot = Order::reconstitute('o-1', [new OrderPaid('o-1', 1500)], 7, ['total' => 20.0, 'paidCents' => 500]); + + expect([$replayed->paidCents(), $replayed->committedVersion(), $replayed->uncommittedEvents()])->toBe([500, 2, []]) + ->and([$fromSnapshot->total(), $fromSnapshot->paidCents(), $fromSnapshot->committedVersion(), $fromSnapshot->isPaid()])->toBe([20.0, 2000, 8, true]); +}); + +it('routes an event to a handler named in eventHandlers(), ignores an event with no handler, and refuses a private handler', function (): void { + $aggregate = new class('x-1') extends EventSourcedAggregateRoot + { + public float $seen = 0.0; + + public function place(): void + { + $this->recordThat(new OrderPlaced('x-1', 3.0)); + $this->recordThat(new OrderPaid('x-1', 1)); + } + + protected function eventHandlers(): array + { + return [OrderPlaced::class => 'whenPlaced']; + } + + protected function whenPlaced(OrderPlaced $event): void + { + $this->seen = $event->total; + } + }; + $aggregate->place(); + + $private = new class('y-1') extends EventSourcedAggregateRoot + { + public function place(): void + { + $this->recordThat(new OrderPlaced('y-1', 1.0)); + } + + /** The aggregate may call its own private handler; the base may not. */ + public function applyDirectly(OrderPlaced $event): void + { + $this->applyOrderPlaced($event); + } + + private function applyOrderPlaced(OrderPlaced $event): void {} + }; + + expect([$aggregate->seen, $aggregate->version()])->toBe([3.0, 2]) + ->and(fn () => $private->place())->toThrow(LogicException::class, 'protected'); +}); + +it('routes an event short-named Event to the aggregate own applyEvent handler', function (): void { + $aggregate = new class('z-1') extends EventSourcedAggregateRoot + { + public string $note = ''; + + public function note(string $note): void + { + $this->recordThat(new Event($note)); + } + + protected function applyEvent(Event $event): void + { + $this->note = $event->note; + } + }; + $aggregate->note('hello'); + + expect([$aggregate->note, $aggregate->version()])->toBe(['hello', 1]); +}); + +it('refuses a handler that eventHandlers() names but the aggregate does not declare', function (): void { + $aggregate = new class('w-1') extends EventSourcedAggregateRoot + { + public function place(): void + { + $this->recordThat(new OrderPlaced('w-1', 1.0)); + } + + protected function eventHandlers(): array + { + return [OrderPlaced::class => 'whenPlacd']; + } + }; + + expect(fn () => $aggregate->place())->toThrow(LogicException::class, 'whenPlacd'); +}); diff --git a/packages/eventsourcing/tests/Fixtures/Aggregate/Event.php b/packages/eventsourcing/tests/Fixtures/Aggregate/Event.php new file mode 100644 index 00000000..eafaf154 --- /dev/null +++ b/packages/eventsourcing/tests/Fixtures/Aggregate/Event.php @@ -0,0 +1,16 @@ +recordThat(new OrderPlaced($id, $total)); + + return $order; + } + + public function pay(int $cents): void + { + if ($this->isPaid()) { + throw new LogicException('The order is already paid.'); + } + $this->recordThat(new OrderPaid($this->id(), $cents)); + } + + public function total(): float + { + return $this->total; + } + + public function paidCents(): int + { + return $this->paidCents; + } + + public function isPaid(): bool + { + return $this->paidCents >= (int) round($this->total * 100); + } + + protected function applyOrderPlaced(OrderPlaced $event): void + { + $this->total = $event->total; + } + + protected function applyOrderPaid(OrderPaid $event): void + { + $this->paidCents += $event->cents; + } + + /** @return array{total: float, paidCents: int} */ + public function snapshotState(): array + { + return ['total' => $this->total, 'paidCents' => $this->paidCents]; + } + + public function restoreSnapshot(array $state): void + { + $total = $state['total'] ?? 0.0; + $paid = $state['paidCents'] ?? 0; + $this->total = is_int($total) || is_float($total) ? (float) $total : 0.0; + $this->paidCents = is_int($paid) ? $paid : 0; + } + + public static function snapshotVersion(): int + { + return 1; + } +} From 19bdc45a62356f1bd8f72c71d521dac553161d92 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:42:03 -0700 Subject: [PATCH 16/33] feat(eventsourcing): add memory and database snapshot stores with keep-count retention and an every-N policy --- .../src/Snapshot/DatabaseSnapshotStore.php | 121 ++++++++++++++++++ .../src/Snapshot/MemorySnapshotStore.php | 51 ++++++++ .../eventsourcing/src/Snapshot/Snapshot.php | 23 ++++ .../src/Snapshot/SnapshotPolicy.php | 27 ++++ .../src/Snapshot/SnapshotStore.php | 25 ++++ .../DatabaseSnapshotStoreIntegrationTest.php | 20 +++ .../tests/Snapshot/SnapshotPolicyTest.php | 18 +++ .../tests/Snapshot/SnapshotStoreTest.php | 18 +++ .../tests/Support/SnapshotStoreContract.php | 66 ++++++++++ 9 files changed, 369 insertions(+) create mode 100644 packages/eventsourcing/src/Snapshot/DatabaseSnapshotStore.php create mode 100644 packages/eventsourcing/src/Snapshot/MemorySnapshotStore.php create mode 100644 packages/eventsourcing/src/Snapshot/Snapshot.php create mode 100644 packages/eventsourcing/src/Snapshot/SnapshotPolicy.php create mode 100644 packages/eventsourcing/src/Snapshot/SnapshotStore.php create mode 100644 packages/eventsourcing/tests/Integration/DatabaseSnapshotStoreIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Snapshot/SnapshotPolicyTest.php create mode 100644 packages/eventsourcing/tests/Snapshot/SnapshotStoreTest.php create mode 100644 packages/eventsourcing/tests/Support/SnapshotStoreContract.php diff --git a/packages/eventsourcing/src/Snapshot/DatabaseSnapshotStore.php b/packages/eventsourcing/src/Snapshot/DatabaseSnapshotStore.php new file mode 100644 index 00000000..de2ce0cd --- /dev/null +++ b/packages/eventsourcing/src/Snapshot/DatabaseSnapshotStore.php @@ -0,0 +1,121 @@ +translator = new PersistenceExceptionTranslator(true); + } + + public function save(Snapshot $snapshot): void + { + EventSourcingSchema::fit('aggregateId', $snapshot->aggregateId, EventSourcingSchema::ID_LENGTH); + EventSourcingSchema::fit('aggregateType', $snapshot->aggregateType, EventSourcingSchema::TYPE_LENGTH); + try { + $state = json_encode($snapshot->state, self::FLAGS); + } catch (JsonException $failure) { + throw EventSerializationException::at('snapshot.'.$snapshot->aggregateId, 'holds a value JSON cannot represent: '.$failure->getMessage()); + } + $row = [ + 'aggregate_type' => $snapshot->aggregateType, + 'state_version' => $snapshot->stateVersion, + 'state' => $state, + 'created_at' => EventSourcingSchema::instant($snapshot->createdAt), + ]; + + try { + $this->connection->transaction(fn () => $this->connection->table(EventSourcingSchema::SNAPSHOTS)->insert(['aggregate_id' => $snapshot->aggregateId, 'aggregate_version' => $snapshot->version] + $row)); + } catch (Throwable $failure) { + $translated = $this->translator->translate($failure, (string) $this->connection->getDriverName()); + if (! $translated instanceof DuplicateKeyException) { + throw $translated; + } + $this->connection->table(EventSourcingSchema::SNAPSHOTS) + ->where('aggregate_id', $snapshot->aggregateId) + ->where('aggregate_version', $snapshot->version) + ->update($row); + } + } + + public function latest(string $aggregateId, ?int $atOrBeforeVersion = null): ?Snapshot + { + $query = $this->connection->table(EventSourcingSchema::SNAPSHOTS)->where('aggregate_id', $aggregateId); + if ($atOrBeforeVersion !== null) { + $query->where('aggregate_version', '<=', $atOrBeforeVersion); + } + $found = $query->orderByDesc('aggregate_version')->limit(1)->first(); + if ($found === null) { + return null; + } + + $row = (array) $found; + /** @var array $state */ + $state = json_decode(is_string($row['state'] ?? null) ? $row['state'] : '{}', true, 512, JSON_THROW_ON_ERROR); + + return new Snapshot( + $aggregateId, + is_string($row['aggregate_type'] ?? null) ? $row['aggregate_type'] : '', + is_numeric($row['aggregate_version'] ?? null) ? (int) $row['aggregate_version'] : 0, + $state, + is_numeric($row['state_version'] ?? null) ? (int) $row['state_version'] : 1, + EventSourcingSchema::parseInstant(is_string($row['created_at'] ?? null) ? $row['created_at'] : 'now'), + ); + } + + public function prune(string $aggregateId, int $keep): int + { + $versions = $this->connection->table(EventSourcingSchema::SNAPSHOTS) + ->where('aggregate_id', $aggregateId) + ->orderByDesc('aggregate_version') + ->pluck('aggregate_version') + ->all(); + $doomed = array_slice($versions, max(1, $keep)); + if ($doomed === []) { + return 0; + } + + return $this->connection->table(EventSourcingSchema::SNAPSHOTS) + ->where('aggregate_id', $aggregateId) + ->whereIn('aggregate_version', $doomed) + ->delete(); + } + + public function delete(string $aggregateId): int + { + return $this->connection->table(EventSourcingSchema::SNAPSHOTS)->where('aggregate_id', $aggregateId)->delete(); + } + + public function isHealthy(): bool + { + try { + $this->connection->table(EventSourcingSchema::SNAPSHOTS)->limit(1)->get(['aggregate_id']); + + return true; + } catch (Throwable) { + return false; + } + } +} diff --git a/packages/eventsourcing/src/Snapshot/MemorySnapshotStore.php b/packages/eventsourcing/src/Snapshot/MemorySnapshotStore.php new file mode 100644 index 00000000..2721ccf9 --- /dev/null +++ b/packages/eventsourcing/src/Snapshot/MemorySnapshotStore.php @@ -0,0 +1,51 @@ +> aggregate id => version => snapshot */ + private array $snapshots = []; + + public function save(Snapshot $snapshot): void + { + $this->snapshots[$snapshot->aggregateId][$snapshot->version] = $snapshot; + krsort($this->snapshots[$snapshot->aggregateId]); + } + + public function latest(string $aggregateId, ?int $atOrBeforeVersion = null): ?Snapshot + { + foreach ($this->snapshots[$aggregateId] ?? [] as $version => $snapshot) { + if ($atOrBeforeVersion === null || $version <= $atOrBeforeVersion) { + return $snapshot; + } + } + + return null; + } + + public function prune(string $aggregateId, int $keep): int + { + $all = $this->snapshots[$aggregateId] ?? []; + $kept = array_slice($all, 0, max(1, $keep), true); + $this->snapshots[$aggregateId] = $kept; + + return count($all) - count($kept); + } + + public function delete(string $aggregateId): int + { + $count = count($this->snapshots[$aggregateId] ?? []); + unset($this->snapshots[$aggregateId]); + + return $count; + } + + public function isHealthy(): bool + { + return true; + } +} diff --git a/packages/eventsourcing/src/Snapshot/Snapshot.php b/packages/eventsourcing/src/Snapshot/Snapshot.php new file mode 100644 index 00000000..c5d4d861 --- /dev/null +++ b/packages/eventsourcing/src/Snapshot/Snapshot.php @@ -0,0 +1,23 @@ + $state + */ + public function __construct( + public string $aggregateId, + public string $aggregateType, + public int $version, + public array $state, + public int $stateVersion = 1, + public DateTimeImmutable $createdAt = new DateTimeImmutable, + ) {} +} diff --git a/packages/eventsourcing/src/Snapshot/SnapshotPolicy.php b/packages/eventsourcing/src/Snapshot/SnapshotPolicy.php new file mode 100644 index 00000000..06168ea9 --- /dev/null +++ b/packages/eventsourcing/src/Snapshot/SnapshotPolicy.php @@ -0,0 +1,27 @@ +snapshotEnabled, $settings->snapshotThreshold, $settings->snapshotKeepCount); + } + + public function due(int $before, int $after): bool + { + return $this->enabled && $this->threshold > 0 && intdiv($after, $this->threshold) > intdiv($before, $this->threshold); + } +} diff --git a/packages/eventsourcing/src/Snapshot/SnapshotStore.php b/packages/eventsourcing/src/Snapshot/SnapshotStore.php new file mode 100644 index 00000000..c5cf6021 --- /dev/null +++ b/packages/eventsourcing/src/Snapshot/SnapshotStore.php @@ -0,0 +1,25 @@ + $assert) { + it("{$lane} snapshot store {$case}", function () use ($assert, $lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + try { + $assert(new DatabaseSnapshotStore($connection)); + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); + } +} diff --git a/packages/eventsourcing/tests/Snapshot/SnapshotPolicyTest.php b/packages/eventsourcing/tests/Snapshot/SnapshotPolicyTest.php new file mode 100644 index 00000000..1713da80 --- /dev/null +++ b/packages/eventsourcing/tests/Snapshot/SnapshotPolicyTest.php @@ -0,0 +1,18 @@ +due(0, 49))->toBeFalse() + ->and($policy->due(48, 50))->toBeTrue() + ->and($policy->due(49, 120))->toBeTrue() + ->and($policy->due(50, 99))->toBeFalse() + ->and((new SnapshotPolicy(false, 50, 3))->due(0, 100))->toBeFalse() + ->and((new SnapshotPolicy(true, 0, 3))->due(0, 100))->toBeFalse() + ->and(SnapshotPolicy::fromSettings(new EventSourcingSettings(snapshotThreshold: 10, snapshotKeepCount: 5)))->toEqual(new SnapshotPolicy(true, 10, 5)); +}); diff --git a/packages/eventsourcing/tests/Snapshot/SnapshotStoreTest.php b/packages/eventsourcing/tests/Snapshot/SnapshotStoreTest.php new file mode 100644 index 00000000..f25d6c1f --- /dev/null +++ b/packages/eventsourcing/tests/Snapshot/SnapshotStoreTest.php @@ -0,0 +1,18 @@ + $assert) { + it("memory snapshot store {$case}", function () use ($assert): void { + $assert(new MemorySnapshotStore); + }); + + it("SQLite snapshot store {$case}", function () use ($assert): void { + $assert(new DatabaseSnapshotStore(EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()))); + }); +} diff --git a/packages/eventsourcing/tests/Support/SnapshotStoreContract.php b/packages/eventsourcing/tests/Support/SnapshotStoreContract.php new file mode 100644 index 00000000..c7fc32b7 --- /dev/null +++ b/packages/eventsourcing/tests/Support/SnapshotStoreContract.php @@ -0,0 +1,66 @@ + $state */ + public static function snapshot(string $id, int $version, array $state = ['total' => 10.0, 'note' => 'café'], int $stateVersion = 1): Snapshot + { + return new Snapshot($id, 'order', $version, $state, $stateVersion, new DateTimeImmutable('2026-10-13 10:00:00')); + } + + /** @return array */ + public static function cases(): array + { + return [ + 'saves a snapshot and loads the latest, its whole floats intact' => static function (SnapshotStore $store): void { + $store->save(self::snapshot('o-1', 50)); + $store->save(self::snapshot('o-1', 100, ['total' => 20.0, 'lines' => [['sku' => 'A']]])); + + $latest = $store->latest('o-1'); + + expect([$latest?->version, $latest?->state, $latest?->aggregateType, $latest?->stateVersion])->toBe([100, ['total' => 20.0, 'lines' => [['sku' => 'A']]], 'order', 1]) + ->and($store->latest('o-2'))->toBeNull(); + }, + 'loads the latest snapshot at or before a version' => static function (SnapshotStore $store): void { + foreach ([50, 100, 150] as $version) { + $store->save(self::snapshot('o-1', $version)); + } + + expect($store->latest('o-1', 149)?->version)->toBe(100) + ->and($store->latest('o-1', 150)?->version)->toBe(150) + ->and($store->latest('o-1', 49))->toBeNull(); + }, + 'replaces a snapshot of the same version' => static function (SnapshotStore $store): void { + $store->save(self::snapshot('o-1', 50, ['v' => 1])); + $store->save(self::snapshot('o-1', 50, ['v' => 2], 2)); + + expect([$store->latest('o-1')?->state, $store->latest('o-1')?->stateVersion])->toBe([['v' => 2], 2]); + }, + 'prunes to the newest keep-count and deletes an aggregate\'s snapshots' => static function (SnapshotStore $store): void { + foreach ([50, 100, 150, 200] as $version) { + $store->save(self::snapshot('o-1', $version)); + } + $store->save(self::snapshot('o-2', 50)); + + expect($store->prune('o-1', 2))->toBe(2) + ->and($store->latest('o-1', 149))->toBeNull() + ->and($store->latest('o-1')?->version)->toBe(200) + ->and($store->prune('o-1', 2))->toBe(0) + ->and($store->delete('o-1'))->toBe(2) + ->and($store->latest('o-1'))->toBeNull() + ->and($store->latest('o-2')?->version)->toBe(50) + ->and($store->isHealthy())->toBeTrue(); + }, + ]; + } +} From 559f20a19d2cef9feda8efaf3ae20d8b36afed72 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:47:19 -0700 Subject: [PATCH 17/33] feat(eventsourcing): add the snapshot-aware repository, metadata contributors and tenant-scoped streams --- packages/eventsourcing/composer.json | 1 + .../src/Aggregate/EventSourcedRepository.php | 140 ++++++++++ .../src/Aggregate/EventSourcingSupport.php | 40 +++ .../AuthenticatedUserMetadataContributor.php | 34 +++ .../CorrelationIdMetadataContributor.php | 39 +++ .../src/Metadata/EventMetadataContributor.php | 18 ++ .../src/Metadata/MetadataResolver.php | 36 +++ .../src/Store/NullTenantResolver.php | 14 + .../src/Store/TenantResolver.php | 11 + .../src/Store/TenantScopedEventStore.php | 96 +++++++ .../Aggregate/EventSourcedRepositoryTest.php | 252 ++++++++++++++++++ .../tests/Metadata/MetadataResolverTest.php | 101 +++++++ .../Store/TenantScopedEventStoreTest.php | 42 +++ 13 files changed, 824 insertions(+) create mode 100644 packages/eventsourcing/src/Aggregate/EventSourcedRepository.php create mode 100644 packages/eventsourcing/src/Aggregate/EventSourcingSupport.php create mode 100644 packages/eventsourcing/src/Metadata/AuthenticatedUserMetadataContributor.php create mode 100644 packages/eventsourcing/src/Metadata/CorrelationIdMetadataContributor.php create mode 100644 packages/eventsourcing/src/Metadata/EventMetadataContributor.php create mode 100644 packages/eventsourcing/src/Metadata/MetadataResolver.php create mode 100644 packages/eventsourcing/src/Store/NullTenantResolver.php create mode 100644 packages/eventsourcing/src/Store/TenantResolver.php create mode 100644 packages/eventsourcing/src/Store/TenantScopedEventStore.php create mode 100644 packages/eventsourcing/tests/Aggregate/EventSourcedRepositoryTest.php create mode 100644 packages/eventsourcing/tests/Metadata/MetadataResolverTest.php create mode 100644 packages/eventsourcing/tests/Store/TenantScopedEventStoreTest.php diff --git a/packages/eventsourcing/composer.json b/packages/eventsourcing/composer.json index 8e639041..949cd6de 100644 --- a/packages/eventsourcing/composer.json +++ b/packages/eventsourcing/composer.json @@ -41,6 +41,7 @@ "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", "illuminate/database": "^13.0", + "illuminate/log": "^13.0", "illuminate/support": "^13.0", "psr/log": "^3.0" }, diff --git a/packages/eventsourcing/src/Aggregate/EventSourcedRepository.php b/packages/eventsourcing/src/Aggregate/EventSourcedRepository.php new file mode 100644 index 00000000..68088490 --- /dev/null +++ b/packages/eventsourcing/src/Aggregate/EventSourcedRepository.php @@ -0,0 +1,140 @@ + $aggregateClass + */ + public function __construct( + private readonly EventSourcingSupport $support, + private readonly string $aggregateClass, + ) {} + + /** @return class-string */ + public function aggregateClass(): string + { + return $this->aggregateClass; + } + + /** @return T|null */ + public function find(string $id): ?EventSourcedAggregateRoot + { + $class = $this->aggregateClass; + $snapshot = $this->snapshotFor($id); + if ($snapshot !== null && ! $this->support->store->exists($id)) { + return null; + } + + $from = $snapshot === null ? 0 : $snapshot->version; + $stream = $this->support->store->load($id, $from + 1); + if ($snapshot === null && $stream->isEmpty()) { + return null; + } + + $history = array_map(fn (StoredEvent $event) => $this->support->mapper->toDomainEvent($event), $stream->events); + + return $class::reconstitute($id, $history, $from, $snapshot?->state); + } + + /** @return T */ + public function get(string $id): EventSourcedAggregateRoot + { + return $this->find($id) ?? throw AggregateNotFoundException::of($this->aggregateClass::aggregateType(), $id); + } + + public function exists(string $id): bool + { + return $this->support->store->exists($id); + } + + /** + * @param T $aggregate + * @return list + */ + public function save(EventSourcedAggregateRoot $aggregate, ?EventMetadata $metadata = null): array + { + $pending = $aggregate->uncommittedEvents(); + if ($pending === []) { + return []; + } + + $resolved = $this->support->metadata->resolve($metadata); + $events = array_map(fn ($event) => $this->support->mapper->toNewEvent($event, $resolved), $pending); + $before = $aggregate->committedVersion(); + + $stored = $this->support->store->append($aggregate->id(), $this->aggregateClass::aggregateType(), $events, $before); + $aggregate->markEventsCommitted(); + $this->snapshotIfDue($aggregate, $before); + + return $stored; + } + + private function snapshotFor(string $id): ?Snapshot + { + $class = $this->aggregateClass; + if (! $this->support->snapshotPolicy->enabled || ! is_subclass_of($class, SnapshotAware::class)) { + return null; + } + + try { + $snapshot = $this->support->snapshots->latest($id); + } catch (Throwable $failure) { + $this->support->logger->warning( + sprintf('Could not read a snapshot of aggregate [%s], replaying it from its first event: %s', $id, $failure->getMessage()), + ['id' => $id, 'exception' => $failure], + ); + + return null; + } + + return $snapshot !== null && $snapshot->stateVersion === $class::snapshotVersion() && $snapshot->aggregateType === $class::aggregateType() ? $snapshot : null; + } + + private function snapshotIfDue(EventSourcedAggregateRoot $aggregate, int $before): void + { + $policy = $this->support->snapshotPolicy; + if (! $aggregate instanceof SnapshotAware || ! $policy->due($before, $aggregate->committedVersion())) { + return; + } + + try { + $this->support->snapshots->save(new Snapshot($aggregate->id(), $aggregate::aggregateType(), $aggregate->committedVersion(), $aggregate->snapshotState(), $aggregate::snapshotVersion())); + $this->support->snapshots->prune($aggregate->id(), $policy->keepCount); + } catch (Throwable $failure) { + $this->support->logger->warning( + sprintf('Could not snapshot aggregate [%s] at version %d: %s', $aggregate->id(), $aggregate->committedVersion(), $failure->getMessage()), + ['id' => $aggregate->id(), 'version' => $aggregate->committedVersion(), 'exception' => $failure], + ); + } + } +} diff --git a/packages/eventsourcing/src/Aggregate/EventSourcingSupport.php b/packages/eventsourcing/src/Aggregate/EventSourcingSupport.php new file mode 100644 index 00000000..8a0ae9e6 --- /dev/null +++ b/packages/eventsourcing/src/Aggregate/EventSourcingSupport.php @@ -0,0 +1,40 @@ + $aggregateClass + * @return EventSourcedRepository + */ + public function repository(string $aggregateClass): EventSourcedRepository + { + return new EventSourcedRepository($this, $aggregateClass); + } +} diff --git a/packages/eventsourcing/src/Metadata/AuthenticatedUserMetadataContributor.php b/packages/eventsourcing/src/Metadata/AuthenticatedUserMetadataContributor.php new file mode 100644 index 00000000..335ac396 --- /dev/null +++ b/packages/eventsourcing/src/Metadata/AuthenticatedUserMetadataContributor.php @@ -0,0 +1,34 @@ +userId !== null) { + return $metadata; + } + + try { + $guard = $this->auth->guard(); + $id = $guard->hasUser() ? $guard->id() : null; + } catch (Throwable) { + return $metadata; + } + + return is_int($id) || (is_string($id) && $id !== '') ? $metadata->withUserId((string) $id) : $metadata; + } +} diff --git a/packages/eventsourcing/src/Metadata/CorrelationIdMetadataContributor.php b/packages/eventsourcing/src/Metadata/CorrelationIdMetadataContributor.php new file mode 100644 index 00000000..607c9d12 --- /dev/null +++ b/packages/eventsourcing/src/Metadata/CorrelationIdMetadataContributor.php @@ -0,0 +1,39 @@ +context instanceof Closure ? ($this->context)() : $this->context; + if ($metadata->correlationId !== null || $context === null) { + return $metadata; + } + $id = $context->get(self::KEY); + + return is_string($id) && $id !== '' ? $metadata->withCorrelationId($id) : $metadata; + } +} diff --git a/packages/eventsourcing/src/Metadata/EventMetadataContributor.php b/packages/eventsourcing/src/Metadata/EventMetadataContributor.php new file mode 100644 index 00000000..a85fbf44 --- /dev/null +++ b/packages/eventsourcing/src/Metadata/EventMetadataContributor.php @@ -0,0 +1,18 @@ + */ + private readonly array $contributors; + + /** + * @param iterable $contributors + */ + public function __construct(iterable $contributors = []) + { + $list = []; + foreach ($contributors as $contributor) { + $list[] = $contributor; + } + $this->contributors = $list; + } + + public function resolve(?EventMetadata $explicit = null): EventMetadata + { + $metadata = new EventMetadata; + foreach ($this->contributors as $contributor) { + $metadata = $contributor->contribute($metadata); + } + + return $explicit === null ? $metadata : $metadata->merge($explicit); + } +} diff --git a/packages/eventsourcing/src/Store/NullTenantResolver.php b/packages/eventsourcing/src/Store/NullTenantResolver.php new file mode 100644 index 00000000..c4188839 --- /dev/null +++ b/packages/eventsourcing/src/Store/NullTenantResolver.php @@ -0,0 +1,14 @@ +inner; + } + + public function append(string $aggregateId, string $aggregateType, array $events, int $expectedVersion = ExpectedVersion::ANY): array + { + $tenant = $this->tenant(); + $scoped = []; + foreach ($events as $event) { + if ($event->metadata->tenantId !== null && $event->metadata->tenantId !== $tenant) { + throw TenantRequiredException::foreign($aggregateId); + } + $scoped[] = $event->withMetadata($event->metadata->withTenantId($tenant)); + } + if (! $this->owned($aggregateId, $tenant)) { + throw TenantRequiredException::foreign($aggregateId); + } + + return $this->inner->append($aggregateId, $aggregateType, $scoped, $expectedVersion); + } + + public function load(string $aggregateId, int $fromVersion = 1, ?int $toVersion = null): EventStream + { + $tenant = $this->tenant(); + if (! $this->owned($aggregateId, $tenant)) { + return new EventStream($aggregateId, []); + } + + return $this->inner->load($aggregateId, $fromVersion, $toVersion); + } + + public function version(string $aggregateId): int + { + return $this->owned($aggregateId, $this->tenant()) ? $this->inner->version($aggregateId) : 0; + } + + public function exists(string $aggregateId): bool + { + return $this->version($aggregateId) > 0; + } + + public function readAll(int $afterPosition = 0, int $limit = 500, ?EventFilter $filter = null): array + { + return $this->inner->readAll($afterPosition, $limit, $filter); + } + + public function currentPosition(): int + { + return $this->inner->currentPosition(); + } + + public function isHealthy(): bool + { + return $this->inner->isHealthy(); + } + + private function tenant(): string + { + $tenant = $this->tenants->currentTenant(); + + return $tenant === null || $tenant === '' ? throw TenantRequiredException::missing() : $tenant; + } + + /** A stream with no events is anybody's; otherwise its first event names its tenant. */ + private function owned(string $aggregateId, string $tenant): bool + { + $first = $this->inner->load($aggregateId, 1, 1)->events[0] ?? null; + + return $first === null || $first->metadata->tenantId === $tenant; + } +} diff --git a/packages/eventsourcing/tests/Aggregate/EventSourcedRepositoryTest.php b/packages/eventsourcing/tests/Aggregate/EventSourcedRepositoryTest.php new file mode 100644 index 00000000..faf2016d --- /dev/null +++ b/packages/eventsourcing/tests/Aggregate/EventSourcedRepositoryTest.php @@ -0,0 +1,252 @@ + */ + public array $loadsFrom = []; + + public function __construct(private readonly EventStore $inner = new MemoryEventStore) {} + + public function append(string $aggregateId, string $aggregateType, array $events, int $expectedVersion = -1): array + { + return $this->inner->append($aggregateId, $aggregateType, $events, $expectedVersion); + } + + public function load(string $aggregateId, int $fromVersion = 1, ?int $toVersion = null): EventStream + { + $this->loadsFrom[] = $fromVersion; + + return $this->inner->load($aggregateId, $fromVersion, $toVersion); + } + + public function version(string $aggregateId): int + { + return $this->inner->version($aggregateId); + } + + public function exists(string $aggregateId): bool + { + return $this->inner->exists($aggregateId); + } + + public function readAll(int $afterPosition = 0, int $limit = 500, ?EventFilter $filter = null): array + { + return $this->inner->readAll($afterPosition, $limit, $filter); + } + + public function currentPosition(): int + { + return $this->inner->currentPosition(); + } + + public function isHealthy(): bool + { + return true; + } +} + +/** Keeps every log line as "level: message", so a test can see a warning was written. */ +final class RecordingLogger extends AbstractLogger +{ + /** @var list */ + public array $lines = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->lines[] = (is_string($level) ? $level : get_debug_type($level)).': '.$message; + } +} + +/** @return EventSourcedRepository */ +function orders(EventStore $store, SnapshotStore $snapshots = new MemorySnapshotStore, int $threshold = 0, ?MetadataResolver $metadata = null, ?LoggerInterface $logger = null): EventSourcedRepository +{ + $mapper = new EventMapper(new EventTypeRegistry((new EventSourcingScanner)->scan(['Firefly\\EventSourcing\\Tests\\Fixtures\\Events\\' => dirname(__DIR__).'/Fixtures/Events'])->eventTypes())); + + return (new EventSourcingSupport($store, $mapper, $snapshots, new SnapshotPolicy(true, $threshold, 2), $metadata ?? new MetadataResolver, $logger ?? new NullLogger))->repository(Order::class); +} + +it('saves an aggregate and finds it again at the version it was saved', function (): void { + $repository = orders(new MemoryEventStore); + $order = Order::place('o-1', 12.5); + $order->pay(500); + + $stored = $repository->save($order); + $found = $repository->get('o-1'); + + expect(count($stored))->toBe(2) + ->and([$found->total(), $found->paidCents(), $found->committedVersion()])->toBe([12.5, 500, 2]) + ->and($order->uncommittedEvents())->toBe([]) + ->and($repository->save($order))->toBe([]) + ->and($repository->exists('o-1'))->toBeTrue() + ->and($repository->find('o-9'))->toBeNull() + ->and(fn () => $repository->get('o-9'))->toThrow(AggregateNotFoundException::class, 'o-9'); +}); + +it('refuses the second of two writers that loaded the same version', function (): void { + $repository = orders(new MemoryEventStore); + $repository->save(Order::place('o-1', 30.0)); + $first = $repository->get('o-1'); + $second = $repository->get('o-1'); + $first->pay(1000); + $second->pay(2000); + + $repository->save($first); + + expect(fn () => $repository->save($second))->toThrow(ConcurrencyException::class) + ->and($repository->get('o-1')->paidCents())->toBe(1000); +}); + +it('snapshots each time the version crosses the threshold, keeps two, and loads from the newest', function (): void { + $store = new LoadRecordingStore; + $snapshots = new MemorySnapshotStore; + $repository = orders($store, $snapshots, 2); + $order = Order::place('o-1', 100.0); + $repository->save($order); + foreach ([100, 200, 300, 400, 500] as $cents) { + $order->pay($cents); + $repository->save($order); + } + $store->loadsFrom = []; + + $found = $repository->get('o-1'); + + expect($snapshots->latest('o-1')?->version)->toBe(6) + ->and($snapshots->latest('o-1', 3)?->version)->toBeNull() + ->and($store->loadsFrom)->toBe([7]) + ->and([$found->paidCents(), $found->committedVersion()])->toBe([1500, 6]); +}); + +it('replays from the first event when the snapshot was taken in another state shape', function (): void { + $store = new LoadRecordingStore; + $snapshots = new MemorySnapshotStore; + $repository = orders($store, $snapshots, 0); + $repository->save(Order::place('o-1', 10.0)); + $snapshots->save(new Snapshot('o-1', 'order', 1, ['total' => 999.0, 'paidCents' => 0], stateVersion: 99)); + + expect($repository->get('o-1')->total())->toBe(10.0) + ->and($store->loadsFrom)->toBe([1]); +}); + +it('stamps the resolved metadata and keeps the append when a snapshot cannot be written', function (): void { + $broken = new class implements SnapshotStore + { + public function save(Snapshot $snapshot): void + { + throw new RuntimeException('snapshot disk full'); + } + + public function latest(string $aggregateId, ?int $atOrBeforeVersion = null): ?Snapshot + { + return null; + } + + public function prune(string $aggregateId, int $keep): int + { + return 0; + } + + public function delete(string $aggregateId): int + { + return 0; + } + + public function isHealthy(): bool + { + return false; + } + }; + $logger = new RecordingLogger; + $store = new MemoryEventStore; + $repository = orders($store, $broken, 1, new MetadataResolver, $logger); + + $repository->save(Order::place('o-1', 5.0), new EventMetadata('corr-7')); + + expect($store->load('o-1')->events[0]->metadata->correlationId)->toBe('corr-7') + ->and($logger->lines)->toHaveCount(1) + ->and($logger->lines[0])->toContain('warning: ')->toContain('o-1'); +}); + +it('hides another tenant\'s aggregate even when a snapshot of it exists', function (): void { + $tenants = new class implements TenantResolver + { + public ?string $tenant = 'acme'; + + public function currentTenant(): ?string + { + return $this->tenant; + } + }; + $snapshots = new MemorySnapshotStore; + $repository = orders(new TenantScopedEventStore(new MemoryEventStore, $tenants), $snapshots, 1); + $repository->save(Order::place('o-1', 10.0)); + + expect($snapshots->latest('o-1')?->version)->toBe(1); + + $tenants->tenant = 'globex'; + + expect($repository->find('o-1'))->toBeNull() + ->and($repository->exists('o-1'))->toBeFalse(); +}); + +it('replays from the first event and logs a warning when the snapshot store cannot be read', function (): void { + $unreadable = new class implements SnapshotStore + { + public function save(Snapshot $snapshot): void {} + + public function latest(string $aggregateId, ?int $atOrBeforeVersion = null): ?Snapshot + { + throw new RuntimeException('snapshot table missing'); + } + + public function prune(string $aggregateId, int $keep): int + { + return 0; + } + + public function delete(string $aggregateId): int + { + return 0; + } + + public function isHealthy(): bool + { + return false; + } + }; + $logger = new RecordingLogger; + $store = new LoadRecordingStore; + $repository = orders($store, $unreadable, 0, null, $logger); + $repository->save(Order::place('o-1', 10.0)); + + expect($repository->get('o-1')->total())->toBe(10.0) + ->and($store->loadsFrom)->toBe([1]) + ->and($logger->lines)->toHaveCount(1) + ->and($logger->lines[0])->toContain('warning: ')->toContain('o-1')->toContain('snapshot table missing'); +}); diff --git a/packages/eventsourcing/tests/Metadata/MetadataResolverTest.php b/packages/eventsourcing/tests/Metadata/MetadataResolverTest.php new file mode 100644 index 00000000..c987f12d --- /dev/null +++ b/packages/eventsourcing/tests/Metadata/MetadataResolverTest.php @@ -0,0 +1,101 @@ +add('firefly.correlation_id', 'corr-request'); + $tenant = new class implements EventMetadataContributor + { + public function contribute(EventMetadata $metadata): EventMetadata + { + return $metadata->withTenantId('acme'); + } + }; + $resolver = new MetadataResolver([new CorrelationIdMetadataContributor($context), $tenant]); + + expect($resolver->resolve())->toEqual(new EventMetadata('corr-request', tenantId: 'acme')) + ->and($resolver->resolve(new EventMetadata(causationId: 'evt-1', extra: ['x-source' => 'import'])))->toEqual(new EventMetadata('corr-request', 'evt-1', 'acme', null, ['x-source' => 'import'])) + ->and((new MetadataResolver([new CorrelationIdMetadataContributor(null)]))->resolve())->toEqual(new EventMetadata); +}); + +it('stamps the authenticated user of the default guard and adds nothing without one', function (): void { + $guard = new class implements Guard + { + public int|string|null $id = null; + + public bool $broken = false; + + public function check(): bool + { + return $this->id !== null; + } + + public function guest(): bool + { + return $this->id === null; + } + + public function user(): ?Authenticatable + { + return null; + } + + public function id(): int|string|null + { + return $this->id; + } + + /** @param array $credentials */ + public function validate(array $credentials = []): bool + { + return false; + } + + public function hasUser(): bool + { + if ($this->broken) { + throw new RuntimeException('no session store'); + } + + return $this->id !== null; + } + + public function setUser(Authenticatable $user): static + { + return $this; + } + }; + $auth = new class($guard) implements Factory + { + public function __construct(private readonly Guard $guard) {} + + public function guard($name = null): Guard + { + return $this->guard; + } + + public function shouldUse($name): void {} + }; + $contributor = new AuthenticatedUserMetadataContributor($auth); + + expect($contributor->contribute(new EventMetadata))->toEqual(new EventMetadata); + + $guard->id = 42; + expect($contributor->contribute(new EventMetadata))->toEqual(new EventMetadata(userId: '42')) + ->and($contributor->contribute(new EventMetadata(userId: 'import-bot')))->toEqual(new EventMetadata(userId: 'import-bot')); + + $guard->broken = true; + expect($contributor->contribute(new EventMetadata('corr-1')))->toEqual(new EventMetadata('corr-1')); +}); diff --git a/packages/eventsourcing/tests/Store/TenantScopedEventStoreTest.php b/packages/eventsourcing/tests/Store/TenantScopedEventStoreTest.php new file mode 100644 index 00000000..a1c4f73e --- /dev/null +++ b/packages/eventsourcing/tests/Store/TenantScopedEventStoreTest.php @@ -0,0 +1,42 @@ +tenant; + } +} + +it('stamps the current tenant, hides another tenant\'s stream and refuses to write into it', function (): void { + $tenants = new MutableTenant('acme'); + $store = new TenantScopedEventStore(new MemoryEventStore, $tenants); + $store->append('o-1', 'order', [EventStoreContract::event()], 0); + + expect($store->load('o-1')->events[0]->metadata->tenantId)->toBe('acme'); + + $tenants->tenant = 'globex'; + + expect($store->load('o-1')->isEmpty())->toBeTrue() + ->and($store->exists('o-1'))->toBeFalse() + ->and($store->version('o-1'))->toBe(0) + ->and(fn () => $store->append('o-1', 'order', [EventStoreContract::event('order.paid')], 1))->toThrow(TenantRequiredException::class, 'o-1') + ->and(fn () => $store->append('o-2', 'order', [EventStoreContract::event(metadata: new EventMetadata(tenantId: 'acme'))], 0))->toThrow(TenantRequiredException::class) + // Projections read every tenant: the global stream is not scoped. + ->and(EventStoreContract::ids($store->readAll()))->toBe(['o-1']); + + $tenants->tenant = null; + + expect(fn () => $store->load('o-1'))->toThrow(TenantRequiredException::class, 'no tenant'); +}); From 15b535d82c09d58bdc5ee1b5c5ce9e92356edc3f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:51:49 -0700 Subject: [PATCH 18/33] feat(eventsourcing): publish appended events through the outbox in the same transaction, or after commit --- .../AfterCommitEventStorePublisher.php | 65 +++++++++++ .../Publishing/OutboxEventStorePublisher.php | 39 +++++++ .../src/Publishing/PublishedHeaders.php | 31 ++++++ .../Publishing/EventStorePublishersTest.php | 104 ++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 packages/eventsourcing/src/Publishing/AfterCommitEventStorePublisher.php create mode 100644 packages/eventsourcing/src/Publishing/OutboxEventStorePublisher.php create mode 100644 packages/eventsourcing/src/Publishing/PublishedHeaders.php create mode 100644 packages/eventsourcing/tests/Publishing/EventStorePublishersTest.php diff --git a/packages/eventsourcing/src/Publishing/AfterCommitEventStorePublisher.php b/packages/eventsourcing/src/Publishing/AfterCommitEventStorePublisher.php new file mode 100644 index 00000000..69382e9f --- /dev/null +++ b/packages/eventsourcing/src/Publishing/AfterCommitEventStorePublisher.php @@ -0,0 +1,65 @@ + $excludedAggregateTypes + */ + public function __construct( + private readonly EventPublisher $publisher, + private readonly string $destination, + private readonly array $excludedAggregateTypes = [], + ) {} + + public function inTransaction(?ConnectionInterface $connection, array $events): void {} + + public function afterTransaction(?ConnectionInterface $connection, array $events): void + { + $publishable = array_values(array_filter($events, fn ($event): bool => ! in_array($event->aggregateType, $this->excludedAggregateTypes, true))); + if ($publishable === []) { + return; + } + + $publish = function () use ($publishable): void { + foreach ($publishable as $event) { + $this->publisher->publish($this->destination, $event->eventType, $event->payload, PublishedHeaders::for($event)); + } + }; + + if ($connection === null || $connection->transactionLevel() === 0) { + $publish(); + + return; + } + + if (! $connection instanceof Connection) { + throw new EventSourcingException('Cannot defer publishing to the end of the open transaction: the store connection is not an Illuminate\Database\Connection.', 'EVENT_PUBLISH_DEFERRAL'); + } + + try { + $connection->afterCommit($publish); + } catch (RuntimeException $failure) { + throw new EventSourcingException('Cannot defer publishing to the end of the open transaction: the connection has no transactions manager.', 'EVENT_PUBLISH_DEFERRAL', 500, $failure); + } + } +} diff --git a/packages/eventsourcing/src/Publishing/OutboxEventStorePublisher.php b/packages/eventsourcing/src/Publishing/OutboxEventStorePublisher.php new file mode 100644 index 00000000..fdfd6519 --- /dev/null +++ b/packages/eventsourcing/src/Publishing/OutboxEventStorePublisher.php @@ -0,0 +1,39 @@ + $excludedAggregateTypes + */ + public function __construct( + private readonly EventPublisher $outbox, + private readonly string $destination, + private readonly array $excludedAggregateTypes = [], + ) {} + + public function inTransaction(?ConnectionInterface $connection, array $events): void + { + foreach ($events as $event) { + if (! in_array($event->aggregateType, $this->excludedAggregateTypes, true)) { + $this->outbox->publish($this->destination, $event->eventType, $event->payload, PublishedHeaders::for($event)); + } + } + } + + public function afterTransaction(?ConnectionInterface $connection, array $events): void {} +} diff --git a/packages/eventsourcing/src/Publishing/PublishedHeaders.php b/packages/eventsourcing/src/Publishing/PublishedHeaders.php new file mode 100644 index 00000000..a50d1235 --- /dev/null +++ b/packages/eventsourcing/src/Publishing/PublishedHeaders.php @@ -0,0 +1,31 @@ + */ + public static function for(StoredEvent $event): array + { + return [ + 'x-event-id' => $event->eventId, + 'x-event-version' => (string) $event->eventVersion, + 'x-aggregate-id' => $event->aggregateId, + 'x-aggregate-type' => $event->aggregateType, + 'x-aggregate-version' => (string) $event->aggregateVersion, + 'x-occurred-at' => $event->occurredAt->format(PayloadNormalizer::DATE_FORMAT), + ] + $event->metadata->toHeaders(); + } +} diff --git a/packages/eventsourcing/tests/Publishing/EventStorePublishersTest.php b/packages/eventsourcing/tests/Publishing/EventStorePublishersTest.php new file mode 100644 index 00000000..81972b22 --- /dev/null +++ b/packages/eventsourcing/tests/Publishing/EventStorePublishersTest.php @@ -0,0 +1,104 @@ +getSchemaBuilder()->create(OutboxSchema::TABLE, static fn (Blueprint $table) => OutboxSchema::blueprint($table)); + $connection->setTransactionManager(new DatabaseTransactionsManager); + + return $connection; +} + +it('writes one outbox row per appended event inside the append, and none when the caller rolls back', function (): void { + $connection = outboxConnection(); + $outbox = new DatabaseEventPublisher($connection, new SubscriberRegistry); + $store = new DatabaseEventStore($connection, publisher: new OutboxEventStorePublisher($outbox, 'orders', ['orchestration.execution'])); + + $store->append('o-1', 'order', [EventStoreContract::event(metadata: new EventMetadata('corr-1')), EventStoreContract::event('order.paid')], 0); + try { + $connection->transaction(static function () use ($store): void { + $store->append('o-2', 'order', [EventStoreContract::event()], 0); + throw new RuntimeException('the command failed after appending'); + }); + } catch (RuntimeException) { + } + $store->append('x-1', 'orchestration.execution', [EventStoreContract::event('orchestration.execution.started')], 0); + + $rows = $connection->table(OutboxSchema::TABLE)->orderBy('id')->get(['destination', 'event_type', 'transaction_id', 'headers'])->all(); + $first = (array) $rows[0]; + /** @var array $headers */ + $headers = json_decode(is_string($first['headers'] ?? null) ? $first['headers'] : '{}', true, flags: JSON_THROW_ON_ERROR); + + expect(array_map(static fn (object $row): array => [((array) $row)['destination'], ((array) $row)['event_type']], $rows))->toBe([['orders', 'order.placed'], ['orders', 'order.paid']]) + ->and($first['transaction_id'] ?? null)->toBe('corr-1') + ->and([$headers['x-aggregate-id'], $headers['x-aggregate-type'], $headers['x-aggregate-version'], $headers['x-event-version']])->toBe(['o-1', 'order', '1', '1']) + ->and($connection->table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'o-2')->count())->toBe(0); +}); + +it('publishes after commit, at once outside a transaction, and never for a rolled-back append', function (): void { + $connection = outboxConnection(); + $recorder = new RecordingEventPublisher; + $store = new DatabaseEventStore($connection, publisher: new AfterCommitEventStorePublisher($recorder, 'orders')); + + $store->append('o-1', 'order', [EventStoreContract::event()], 0); + $atOnce = count($recorder->published); + + $insideCount = null; + $connection->transaction(static function () use ($store, $recorder, &$insideCount): void { + $store->append('o-2', 'order', [EventStoreContract::event()], 0); + $insideCount = count($recorder->published); + }); + $afterCommit = count($recorder->published); + + try { + $connection->transaction(static function () use ($store): void { + $store->append('o-3', 'order', [EventStoreContract::event()], 0); + throw new RuntimeException('rolled back'); + }); + } catch (RuntimeException) { + } + + expect([$atOnce, $insideCount, $afterCommit, count($recorder->published)])->toBe([1, 1, 2, 2]) + ->and($recorder->published[1]['headers']['x-aggregate-id'])->toBe('o-2') + ->and($recorder->published[1]['destination'])->toBe('orders'); +}); + +it('publishes once from the memory store as well, whichever adapter is bound', function (): void { + $inTransaction = new RecordingEventPublisher; + $afterCommit = new RecordingEventPublisher; + (new MemoryEventStore(publisher: new OutboxEventStorePublisher($inTransaction, 'd')))->append('o-1', 'order', [EventStoreContract::event()], 0); + (new MemoryEventStore(publisher: new AfterCommitEventStorePublisher($afterCommit, 'd')))->append('o-1', 'order', [EventStoreContract::event()], 0); + + expect([count($inTransaction->published), count($afterCommit->published)])->toBe([1, 1]); +}); + +it('publishes the event\'s own stream position even when its metadata carries headers copied from the event that caused it', function (): void { + $recorder = new RecordingEventPublisher; + $store = new MemoryEventStore(publisher: new AfterCommitEventStorePublisher($recorder, 'orders')); + // Metadata rebuilt from an inbound eda envelope keeps that envelope's positional headers as extras. + $inbound = EventMetadata::fromHeaders(['x-correlation-id' => 'corr-9', 'x-event-id' => 'cause-event', 'x-aggregate-id' => 'cause-1', 'x-aggregate-version' => '7', 'x-tenant' => 'kept']); + + [$stored] = $store->append('o-1', 'order', [EventStoreContract::event(metadata: $inbound)], 0); + $headers = $recorder->published[0]['headers']; + + expect([$headers['x-event-id'], $headers['x-aggregate-id'], $headers['x-aggregate-version']])->toBe([$stored->eventId, 'o-1', '1']) + ->and([$headers['x-correlation-id'], $headers['x-tenant']])->toBe(['corr-9', 'kept']); +}); From e72663dc5d746c6bd646fd4a23d97f7a1abc4b78 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:00:40 -0700 Subject: [PATCH 19/33] feat(eventsourcing): auto-configure the stores, the repository support and the migrations, proven through the real pipeline --- docs/modules.md | 4 +- .../firefly-eventsourcing-components.php | 161 ++++++++++++++ .../cache/firefly-eventsourcing-context.php | 134 ++++++++++++ .../src/EventSourcingAutoConfiguration.php | 206 ++++++++++++++++++ .../src/EventSourcingWiringProvider.php | 15 +- .../tests/Capstone/CapstoneAggregateTest.php | 63 ++++++ .../LegacyOrderPlacedUpcaster.php | 38 ++++ .../CapstoneFixtures/OrderRepository.php | 20 ++ .../eventsourcing/tests/PackageBootsTest.php | 49 +++++ .../Support/EventSourcingCapstoneTestCase.php | 89 ++++++++ .../Support/EventSourcingFixturesProvider.php | 38 ++++ 11 files changed, 813 insertions(+), 4 deletions(-) create mode 100644 packages/eventsourcing/src/EventSourcingAutoConfiguration.php create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneAggregateTest.php create mode 100644 packages/eventsourcing/tests/CapstoneFixtures/LegacyOrderPlacedUpcaster.php create mode 100644 packages/eventsourcing/tests/CapstoneFixtures/OrderRepository.php create mode 100644 packages/eventsourcing/tests/Support/EventSourcingCapstoneTestCase.php create mode 100644 packages/eventsourcing/tests/Support/EventSourcingFixturesProvider.php diff --git a/docs/modules.md b/docs/modules.md index 908e088f..1926ffe3 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -6,10 +6,10 @@ Several components carry more than one guide: `firefly/data` answers for *Data & *Relational Data* and *Transactions*. Laravel discovers the root library's providers, and configuration selects optional features. A bean you declare wins over the framework default. Two idioms deliver that outcome, and -which one you are looking at matters the moment you go reading the source. **23 of the 36 packages** carry a +which one you are looking at matters the moment you go reading the source. **24 of the 36 packages** carry a `#[Configuration]` class whose `#[Bean]` methods sit behind `#[ConditionalOnMissingBean]`, so a bean of yours makes the framework's back off silently — `packages/data/src/DataAutoConfiguration.php` is the model. The -**other 13** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards +**other 12** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards each binding in `WebServiceProvider::register()` with a `bound()` check instead, first-one-wins by hand.
diff --git a/packages/eventsourcing/cache/firefly-eventsourcing-components.php b/packages/eventsourcing/cache/firefly-eventsourcing-components.php index b2ca1444..99a62c99 100644 --- a/packages/eventsourcing/cache/firefly-eventsourcing-components.php +++ b/packages/eventsourcing/cache/firefly-eventsourcing-components.php @@ -5,4 +5,165 @@ // Generated by firefly/container. Do not edit. return [ + 0 => [ + 'class' => 'Firefly\\EventSourcing\\EventSourcingAutoConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 1000, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'eventSourcingSettings', + 'returns' => 'Firefly\\EventSourcing\\EventSourcingSettings', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], + ], + 1 => [ + 'method' => 'eventTypeRegistry', + 'returns' => 'Firefly\\EventSourcing\\Event\\EventTypeRegistry', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', + ], + ], + 2 => [ + 'method' => 'eventMapper', + 'returns' => 'Firefly\\EventSourcing\\Event\\EventMapper', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\Event\\EventTypeRegistry', + ], + ], + 3 => [ + 'method' => 'upcasterChain', + 'returns' => 'Firefly\\EventSourcing\\Upcasting\\UpcasterChain', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + ], + ], + 4 => [ + 'method' => 'eventEnvelopeCodec', + 'returns' => 'Firefly\\EventSourcing\\Event\\EventEnvelopeCodec', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', + ], + ], + 5 => [ + 'method' => 'eventStorePublisher', + 'returns' => 'Firefly\\EventSourcing\\Publishing\\EventStorePublisher', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 1 => 'Illuminate\\Container\\Container', + ], + ], + 6 => [ + 'method' => 'tenantResolver', + 'returns' => 'Firefly\\EventSourcing\\Store\\TenantResolver', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + ], + ], + 7 => [ + 'method' => 'eventStore', + 'returns' => 'Firefly\\EventSourcing\\Store\\EventStore', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 1 => 'Firefly\\EventSourcing\\Event\\EventEnvelopeCodec', + 2 => 'Firefly\\EventSourcing\\Upcasting\\UpcasterChain', + 3 => 'Firefly\\EventSourcing\\Publishing\\EventStorePublisher', + 4 => 'Firefly\\EventSourcing\\Store\\TenantResolver', + 5 => 'Illuminate\\Container\\Container', + ], + ], + 8 => [ + 'method' => 'snapshotStore', + 'returns' => 'Firefly\\EventSourcing\\Snapshot\\SnapshotStore', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 1 => 'Illuminate\\Container\\Container', + ], + ], + 9 => [ + 'method' => 'metadataResolver', + 'returns' => 'Firefly\\EventSourcing\\Metadata\\MetadataResolver', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 1 => 'Firefly\\Container\\Container', + 2 => 'Illuminate\\Container\\Container', + ], + ], + 10 => [ + 'method' => 'eventSourcingSupport', + 'returns' => 'Firefly\\EventSourcing\\Aggregate\\EventSourcingSupport', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\Store\\EventStore', + 1 => 'Firefly\\EventSourcing\\Event\\EventMapper', + 2 => 'Firefly\\EventSourcing\\Snapshot\\SnapshotStore', + 3 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 4 => 'Firefly\\EventSourcing\\Metadata\\MetadataResolver', + 5 => 'Illuminate\\Container\\Container', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], ]; diff --git a/packages/eventsourcing/cache/firefly-eventsourcing-context.php b/packages/eventsourcing/cache/firefly-eventsourcing-context.php index 4a1771ff..8c0394fd 100644 --- a/packages/eventsourcing/cache/firefly-eventsourcing-context.php +++ b/packages/eventsourcing/cache/firefly-eventsourcing-context.php @@ -5,4 +5,138 @@ // Generated by firefly/context. Do not edit. return [ + 0 => [ + 'class' => 'Firefly\\EventSourcing\\EventSourcingAutoConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'eventSourcingSettings', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\EventSourcingSettings', + ], + ], + ], + ], + 1 => [ + 'method' => 'eventTypeRegistry', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Event\\EventTypeRegistry', + ], + ], + ], + ], + 2 => [ + 'method' => 'eventMapper', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Event\\EventMapper', + ], + ], + ], + ], + 3 => [ + 'method' => 'upcasterChain', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Upcasting\\UpcasterChain', + ], + ], + ], + ], + 4 => [ + 'method' => 'eventEnvelopeCodec', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Event\\EventEnvelopeCodec', + ], + ], + ], + ], + 5 => [ + 'method' => 'eventStorePublisher', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Publishing\\EventStorePublisher', + ], + ], + ], + ], + 6 => [ + 'method' => 'tenantResolver', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Store\\TenantResolver', + ], + ], + ], + ], + 7 => [ + 'method' => 'eventStore', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Store\\EventStore', + ], + ], + ], + ], + 8 => [ + 'method' => 'snapshotStore', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Snapshot\\SnapshotStore', + ], + ], + ], + ], + 9 => [ + 'method' => 'metadataResolver', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Metadata\\MetadataResolver', + ], + ], + ], + ], + 10 => [ + 'method' => 'eventSourcingSupport', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Aggregate\\EventSourcingSupport', + ], + ], + ], + ], + ], + ], ]; diff --git a/packages/eventsourcing/src/EventSourcingAutoConfiguration.php b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php new file mode 100644 index 00000000..fbdc7c67 --- /dev/null +++ b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php @@ -0,0 +1,206 @@ +bound(EventSourcingManifest::class) ? $app->make(EventSourcingManifest::class) : null; + + return new EventTypeRegistry($manifest instanceof EventSourcingManifest ? $manifest->eventTypes() : []); + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(EventMapper::class)] + public function eventMapper(EventTypeRegistry $types): EventMapper + { + return new EventMapper($types); + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(UpcasterChain::class)] + public function upcasterChain(FireflyContainer $beans): UpcasterChain + { + return new UpcasterChain(array_values(array_filter($beans->getAll(EventUpcaster::class), static fn (object $upcaster): bool => $upcaster instanceof EventUpcaster))); + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(EventEnvelopeCodec::class)] + public function eventEnvelopeCodec(Container $app): EventEnvelopeCodec + { + $serializer = $app->bound(Serializer::class) ? $app->make(Serializer::class) : null; + + return new EventEnvelopeCodec($serializer instanceof Serializer ? $serializer : new JsonSerializer); + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(EventStorePublisher::class)] + public function eventStorePublisher(EventSourcingSettings $settings, Container $app): EventStorePublisher + { + if (! $settings->publishEnabled) { + return new NoOpEventStorePublisher; + } + + if (! $app->bound(EventPublisher::class)) { + throw new ConfigurationException('Configuration key [firefly.eventsourcing.publish.enabled] is true but no firefly/eda EventPublisher is bound.'); + } + /** @var EventPublisher $publisher whichever eda provider is selected: the outbox writer, a broker, or the in-memory bus. */ + $publisher = $app->make(EventPublisher::class); + + return $publisher instanceof OutboxWriter + ? new OutboxEventStorePublisher($publisher, $settings->publishDestination, $settings->publishExcludedAggregateTypes) + : new AfterCommitEventStorePublisher($publisher, $settings->publishDestination, $settings->publishExcludedAggregateTypes); + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(TenantResolver::class)] + public function tenantResolver(): TenantResolver + { + return new NullTenantResolver; + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(EventStore::class)] + public function eventStore(EventSourcingSettings $settings, EventEnvelopeCodec $codec, UpcasterChain $upcasters, EventStorePublisher $publisher, TenantResolver $tenants, Container $app): EventStore + { + $store = $settings->storeProvider === 'database' + ? new DatabaseEventStore(self::connection($app, $settings->connection, 'firefly.eventsourcing.store.provider'), $codec, $upcasters, $publisher, $settings->batchSize) + : new MemoryEventStore($codec, $upcasters, $publisher); + + return $settings->tenancyEnabled ? new TenantScopedEventStore($store, $tenants) : $store; + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(SnapshotStore::class)] + public function snapshotStore(EventSourcingSettings $settings, Container $app): SnapshotStore + { + return $settings->snapshotStore === 'database' + ? new DatabaseSnapshotStore(self::connection($app, $settings->connection, 'firefly.eventsourcing.snapshot.store')) + : new MemorySnapshotStore; + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(MetadataResolver::class)] + public function metadataResolver(EventSourcingSettings $settings, FireflyContainer $beans, Container $app): MetadataResolver + { + $contributors = [new CorrelationIdMetadataContributor(static function () use ($app): ContextRepository { + /** @var ContextRepository $context the scoped Context of the current request. */ + $context = $app->make(ContextRepository::class); + + return $context; + })]; + if ($settings->captureUser && $app->bound(AuthFactory::class)) { + /** @var AuthFactory $auth */ + $auth = $app->make(AuthFactory::class); + $contributors[] = new AuthenticatedUserMetadataContributor($auth); + } + foreach ($beans->getAll(EventMetadataContributor::class) as $contributor) { + if ($contributor instanceof EventMetadataContributor) { + $contributors[] = $contributor; + } + } + + return new MetadataResolver($contributors); + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(EventSourcingSupport::class)] + public function eventSourcingSupport(EventStore $store, EventMapper $mapper, SnapshotStore $snapshots, EventSourcingSettings $settings, MetadataResolver $metadata, Container $app): EventSourcingSupport + { + $logger = $app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null; + + return new EventSourcingSupport($store, $mapper, $snapshots, SnapshotPolicy::fromSettings($settings), $metadata, $logger instanceof LoggerInterface ? $logger : new NullLogger); + } + + /** The concrete connection $name names (null = the default), refused with the key that asked for it. */ + public static function connection(Container $app, ?string $name, string $key): Connection + { + $databases = $app->bound('db') ? $app->make('db') : null; + if (! $databases instanceof ConnectionResolverInterface) { + throw new ConfigurationException("Configuration key [{$key}] is database but no database connection resolver (db) is bound."); + } + /** @var ConnectionInterface $connection the resolver's declared type: a custom resolver may hand back any implementation. */ + $connection = $databases->connection($name); + + return $connection instanceof Connection ? $connection : throw new ConfigurationException("Configuration key [{$key}] is database but the connection [".($name ?? 'default').'] is not an Illuminate\Database\Connection.'); + } +} diff --git a/packages/eventsourcing/src/EventSourcingWiringProvider.php b/packages/eventsourcing/src/EventSourcingWiringProvider.php index 5a5278b9..71431f16 100644 --- a/packages/eventsourcing/src/EventSourcingWiringProvider.php +++ b/packages/eventsourcing/src/EventSourcingWiringProvider.php @@ -4,6 +4,7 @@ namespace Firefly\EventSourcing; +use Firefly\Config\Config; use Firefly\Context\Boot\FireflyServiceProvider; use Firefly\Context\Scan\AppScan; use Firefly\EventSourcing\Manifest\EventSourcingManifest; @@ -14,8 +15,9 @@ * The boot half of firefly/eventsourcing, a plain FireflyServiceProvider because an AutoConfiguration records * candidacy only (the OrchestrationWiringProvider shape). It binds the EventSourcingManifest behind a bound() guard: * the compiled `eventsourcing.php` first, then an in-process scan of `firefly.scan.paths`, then an empty manifest - * (the AppScan convention), lazily, so a cached application pays nothing. Later tasks add the commands and the - * migrations. + * (the AppScan convention), lazily, so a cached application pays nothing. It publishes the migrations + * (`--tag=firefly-eventsourcing-migrations`) and loads them while any eventsourcing store is `database`. Later tasks + * add the commands. */ final class EventSourcingWiringProvider extends FireflyServiceProvider { @@ -37,4 +39,13 @@ public function register(): void parent::register(); } + + public function boot(): void + { + $this->publishes([self::MIGRATIONS => $this->app->databasePath('migrations')], 'firefly-eventsourcing-migrations'); + + if (EventSourcingSettings::fromConfig(new Config($this->app->make('config')))->usesDatabase()) { + $this->loadMigrationsFrom(self::MIGRATIONS); + } + } } diff --git a/packages/eventsourcing/tests/Capstone/CapstoneAggregateTest.php b/packages/eventsourcing/tests/Capstone/CapstoneAggregateTest.php new file mode 100644 index 00000000..bd823050 --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneAggregateTest.php @@ -0,0 +1,63 @@ +app()->make(OrderRepository::class); + $order = Order::place('cap-1', 40.0); + $orders->save($order); + $order->pay(1000); + $order->pay(500); + $orders->save($order); + + $found = $orders->get('cap-1'); + + expect($this->app()->make(EventStore::class))->toBeInstanceOf(DatabaseEventStore::class) + ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'cap-1')->count())->toBe(3) + ->and($this->app()->make(SnapshotStore::class)->latest('cap-1')?->version)->toBe(3) + ->and([$found->total(), $found->paidCents(), $found->committedVersion()])->toBe([40.0, 1500, 3]); +}); + +it('upcasts an old event version through an upcaster bean when the aggregate loads', function (): void { + /** @var EventSourcingCapstoneTestCase $this */ + $this->app()->make(EventStore::class)->append('cap-legacy', 'order', [new NewEvent('order.placed', ['orderId' => 'cap-legacy', 'amount' => 7], 1)], 0); + + expect($this->app()->make(OrderRepository::class)->get('cap-legacy')->total())->toBe(7.0); +}); + +it('refuses a stale writer with a 409 ConcurrencyException', function (): void { + /** @var EventSourcingCapstoneTestCase $this */ + $orders = $this->app()->make(OrderRepository::class); + $orders->save(Order::place('cap-2', 10.0)); + $stale = $orders->get('cap-2'); + $fresh = $orders->get('cap-2'); + $fresh->pay(100); + $orders->save($fresh); + $stale->pay(200); + + expect(fn () => $orders->save($stale))->toThrow(ConcurrencyException::class); +}); + +it('stamps the correlation id of the request context into every event', function (): void { + /** @var EventSourcingCapstoneTestCase $this */ + Context::add('firefly.correlation_id', 'corr-capstone'); + $this->app()->make(OrderRepository::class)->save(Order::place('cap-3', 1.0)); + + expect($this->app()->make(EventStore::class)->load('cap-3')->events[0]->metadata->correlationId)->toBe('corr-capstone') + ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'cap-3')->value('correlation_id'))->toBe('corr-capstone'); +}); diff --git a/packages/eventsourcing/tests/CapstoneFixtures/LegacyOrderPlacedUpcaster.php b/packages/eventsourcing/tests/CapstoneFixtures/LegacyOrderPlacedUpcaster.php new file mode 100644 index 00000000..43425343 --- /dev/null +++ b/packages/eventsourcing/tests/CapstoneFixtures/LegacyOrderPlacedUpcaster.php @@ -0,0 +1,38 @@ +payload; + $payload['total'] = $payload['amount'] ?? 0; + unset($payload['amount']); + + return $event->upcasted('order.placed', 2, $payload); + } +} diff --git a/packages/eventsourcing/tests/CapstoneFixtures/OrderRepository.php b/packages/eventsourcing/tests/CapstoneFixtures/OrderRepository.php new file mode 100644 index 00000000..31038f02 --- /dev/null +++ b/packages/eventsourcing/tests/CapstoneFixtures/OrderRepository.php @@ -0,0 +1,20 @@ + */ +#[Component] +final class OrderRepository extends EventSourcedRepository +{ + public function __construct(EventSourcingSupport $support) + { + parent::__construct($support, Order::class); + } +} diff --git a/packages/eventsourcing/tests/PackageBootsTest.php b/packages/eventsourcing/tests/PackageBootsTest.php index 059cd340..1b1379f4 100644 --- a/packages/eventsourcing/tests/PackageBootsTest.php +++ b/packages/eventsourcing/tests/PackageBootsTest.php @@ -6,6 +6,13 @@ use Firefly\EventSourcing\EventSourcingServiceProvider; use Firefly\EventSourcing\EventSourcingWiringProvider; use Firefly\EventSourcing\Manifest\EventSourcingManifest; +use Firefly\EventSourcing\Snapshot\MemorySnapshotStore; +use Firefly\EventSourcing\Snapshot\SnapshotStore; +use Firefly\EventSourcing\Store\EventStore; +use Firefly\EventSourcing\Store\MemoryEventStore; +use Firefly\EventSourcing\Store\TenantScopedEventStore; +use Firefly\Kernel\Exception\Framework\ConfigurationException; +use Illuminate\Support\ServiceProvider; /** * The two discovered providers boot over the real pipeline with nothing configured: installing the package is @@ -22,3 +29,45 @@ expect($app->make(EventSourcingManifest::class)->isEmpty())->toBeTrue(); }); + +it('binds the memory store by default and refuses at boot a provider it does not know', function (): void { + $app = fireflyApplication([], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]); + + expect($app->make(EventStore::class))->toBeInstanceOf(MemoryEventStore::class) + ->and($app->make(SnapshotStore::class))->toBeInstanceOf(MemorySnapshotStore::class) + ->and(fn () => fireflyApplication(['firefly' => ['eventsourcing' => ['store' => ['provider' => 'mongo']]]], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class])) + ->toThrow(ConfigurationException::class, 'firefly.eventsourcing.store.provider'); +}); + +it('wraps the store in the tenant scope when tenancy is enabled', function (): void { + $app = fireflyApplication(['firefly' => ['eventsourcing' => ['tenancy' => ['enabled' => true]]]], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]); + + expect($app->make(EventStore::class))->toBeInstanceOf(TenantScopedEventStore::class); +}); + +it('loads the migrations only while an eventsourcing store is database, and publishes them under their tag', function (): void { + // A recording migrator: loadMigrationsFrom() hands its paths to whatever 'migrator' resolves to. + $migrator = static fn (): object => new class + { + /** @var list */ + public array $paths = []; + + public function path(string $path): void + { + $this->paths[] = $path; + } + }; + $memory = $migrator(); + $database = $migrator(); + $memoryApp = fireflyApplication([], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]); + $databaseApp = fireflyApplication(['firefly' => ['eventsourcing' => ['snapshot' => ['store' => 'database']]]], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]); + // Bound after boot and resolved through make(), so the provider's after-resolving callback runs on it. + $memoryApp->bind('migrator', static fn (): object => $memory); + $databaseApp->bind('migrator', static fn (): object => $database); + $memoryApp->make('migrator'); + $databaseApp->make('migrator'); + + expect($memory->paths)->toBe([]) + ->and($database->paths)->toBe([EventSourcingWiringProvider::MIGRATIONS]) + ->and(ServiceProvider::pathsToPublish(EventSourcingWiringProvider::class, 'firefly-eventsourcing-migrations'))->toHaveKey(EventSourcingWiringProvider::MIGRATIONS); +}); diff --git a/packages/eventsourcing/tests/Support/EventSourcingCapstoneTestCase.php b/packages/eventsourcing/tests/Support/EventSourcingCapstoneTestCase.php new file mode 100644 index 00000000..bd8b6f18 --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventSourcingCapstoneTestCase.php @@ -0,0 +1,89 @@ + */ + public static function fixtures(): array + { + return [ + 'Firefly\\EventSourcing\\Tests\\Fixtures\\Events\\' => dirname(__DIR__).'/Fixtures/Events', + 'Firefly\\EventSourcing\\Tests\\CapstoneFixtures\\' => dirname(__DIR__).'/CapstoneFixtures', + ]; + } + + /** @return list */ + protected function fireflyProviders(): array + { + return [ + ResilienceServiceProvider::class, + SchedulingServiceProvider::class, + SchedulingWiringProvider::class, + EdaServiceProvider::class, + EdaWiringProvider::class, + EventSourcingServiceProvider::class, + EventSourcingWiringProvider::class, + EventSourcingFixturesProvider::class, + ]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [ + 'cache.default' => 'array', + 'queue.default' => 'sync', + 'firefly.eventsourcing.store.provider' => 'database', + 'firefly.eventsourcing.snapshot.threshold' => 2, + ]; + } + + protected function defineFireflyEnvironment(Application $app): void + { + $app->instance(EventSourcingManifest::class, (new EventSourcingScanner)->scan(self::fixtures())); + } + + protected function setUp(): void + { + parent::setUp(); + + // Dropped first: on a real server the tables of the last run remain. + EventSourcingSchema::drop(DB::connection()->getSchemaBuilder()); + EventSourcingSchema::create(DB::connection()->getSchemaBuilder()); + } + + /** @param array $parameters */ + public function command(string $name, array $parameters = []): int + { + return $this->app()->make(Kernel::class)->call($name, $parameters); + } + + public function commandOutput(): string + { + return $this->app()->make(Kernel::class)->output(); + } +} diff --git a/packages/eventsourcing/tests/Support/EventSourcingFixturesProvider.php b/packages/eventsourcing/tests/Support/EventSourcingFixturesProvider.php new file mode 100644 index 00000000..0b77d6cc --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventSourcingFixturesProvider.php @@ -0,0 +1,38 @@ +write(['Firefly\\EventSourcing\\Tests\\CapstoneFixtures\\' => dirname(__DIR__).'/CapstoneFixtures'], $components, $context); + $paths = [$components, $context]; + } + + return $paths; + } +} From 4677728525b6a9c6fb4576ace5ee64c353e52983 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:15:15 -0700 Subject: [PATCH 20/33] test(eventsourcing): prove same-transaction outbox publishing and after-commit publishing through the real pipeline and on every server --- .../CapstoneAfterCommitPublishingTest.php | 40 ++++++++++++++ .../Capstone/CapstoneOutboxPublishingTest.php | 47 ++++++++++++++++ .../OutboxPublishingIntegrationTest.php | 39 +++++++++++++ .../AfterCommitPublishingCapstoneTestCase.php | 15 +++++ .../OutboxPublishingCapstoneTestCase.php | 55 +++++++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneAfterCommitPublishingTest.php create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneOutboxPublishingTest.php create mode 100644 packages/eventsourcing/tests/Integration/OutboxPublishingIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Support/AfterCommitPublishingCapstoneTestCase.php create mode 100644 packages/eventsourcing/tests/Support/OutboxPublishingCapstoneTestCase.php diff --git a/packages/eventsourcing/tests/Capstone/CapstoneAfterCommitPublishingTest.php b/packages/eventsourcing/tests/Capstone/CapstoneAfterCommitPublishingTest.php new file mode 100644 index 00000000..025cc9a6 --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneAfterCommitPublishingTest.php @@ -0,0 +1,40 @@ +app()->make(EventPublisher::class)->subscribe('order.*', static function (EventEnvelope $envelope) use (&$seen): void { + $seen[] = ($envelope->headers['x-aggregate-id'] ?? '?').':'.$envelope->eventType; + }); + $orders = $this->app()->make(OrderRepository::class); + + $insideCommit = null; + DB::transaction(static function () use ($orders, &$seen, &$insideCommit): void { + $orders->save(Order::place('pub-3', 1.0)); + $insideCommit = count($seen); + }); + try { + DB::transaction(static function () use ($orders): void { + $orders->save(Order::place('pub-4', 1.0)); + throw new RuntimeException('rolled back'); + }); + } catch (RuntimeException) { + } + + expect($this->app()->make(EventStorePublisher::class))->toBeInstanceOf(AfterCommitEventStorePublisher::class) + ->and($insideCommit)->toBe(0) + ->and($seen)->toBe(['pub-3:order.placed']); +}); diff --git a/packages/eventsourcing/tests/Capstone/CapstoneOutboxPublishingTest.php b/packages/eventsourcing/tests/Capstone/CapstoneOutboxPublishingTest.php new file mode 100644 index 00000000..c347d670 --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneOutboxPublishingTest.php @@ -0,0 +1,47 @@ +app()->make(OrderRepository::class); + $order = Order::place('pub-1', 20.0); + $orders->save($order); + $order->pay(500); + $orders->save($order); + + expect($this->app()->make(EventStorePublisher::class))->toBeInstanceOf(OutboxEventStorePublisher::class) + ->and(DB::table(OutboxSchema::TABLE)->orderBy('id')->pluck('event_type')->all())->toBe(['order.placed', 'order.paid']) + ->and(DB::table(OutboxSchema::TABLE)->distinct()->pluck('destination')->all())->toBe(['orders']) + ->and(DB::table(EventSourcingSchema::EVENTS)->count())->toBe(2); +}); + +it('rolls the outbox rows back with the events, and never publishes an excluded aggregate type', function (): void { + /** @var OutboxPublishingCapstoneTestCase $this */ + $orders = $this->app()->make(OrderRepository::class); + try { + DB::transaction(static function () use ($orders): void { + $orders->save(Order::place('pub-2', 5.0)); + throw new RuntimeException('the command failed after saving'); + }); + } catch (RuntimeException) { + } + $this->app()->make(EventStore::class)->append('exec-1', 'orchestration.execution', [new NewEvent('orchestration.execution.started', ['state' => '{}'])], 0); + + expect(DB::table(OutboxSchema::TABLE)->count())->toBe(0) + ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'pub-2')->count())->toBe(0) + ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'exec-1')->count())->toBe(1); +}); diff --git a/packages/eventsourcing/tests/Integration/OutboxPublishingIntegrationTest.php b/packages/eventsourcing/tests/Integration/OutboxPublishingIntegrationTest.php new file mode 100644 index 00000000..d4db4407 --- /dev/null +++ b/packages/eventsourcing/tests/Integration/OutboxPublishingIntegrationTest.php @@ -0,0 +1,39 @@ +getSchemaBuilder()->dropIfExists(OutboxSchema::TABLE); + $connection->getSchemaBuilder()->create(OutboxSchema::TABLE, static fn (Blueprint $table) => OutboxSchema::blueprint($table)); + $store = new DatabaseEventStore($connection, publisher: new OutboxEventStorePublisher(new DatabaseEventPublisher($connection, new SubscriberRegistry), 'orders')); + + $store->append('o-1', 'order', [EventStoreContract::event(), EventStoreContract::event('order.paid')], 0); + try { + $connection->transaction(static function () use ($store): void { + $store->append('o-2', 'order', [EventStoreContract::event()], 0); + throw new RuntimeException('rolled back'); + }); + } catch (RuntimeException) { + } + + expect($connection->table(OutboxSchema::TABLE)->count())->toBe(2) + ->and($connection->table(EventSourcingSchema::EVENTS)->count())->toBe(2); + } finally { + $connection->getSchemaBuilder()->dropIfExists(OutboxSchema::TABLE); + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); +} diff --git a/packages/eventsourcing/tests/Support/AfterCommitPublishingCapstoneTestCase.php b/packages/eventsourcing/tests/Support/AfterCommitPublishingCapstoneTestCase.php new file mode 100644 index 00000000..d5e25f14 --- /dev/null +++ b/packages/eventsourcing/tests/Support/AfterCommitPublishingCapstoneTestCase.php @@ -0,0 +1,15 @@ + */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.eventsourcing.publish.enabled' => true, 'firefly.eventsourcing.publish.destination' => 'orders']; + } +} diff --git a/packages/eventsourcing/tests/Support/OutboxPublishingCapstoneTestCase.php b/packages/eventsourcing/tests/Support/OutboxPublishingCapstoneTestCase.php new file mode 100644 index 00000000..b3dd493f --- /dev/null +++ b/packages/eventsourcing/tests/Support/OutboxPublishingCapstoneTestCase.php @@ -0,0 +1,55 @@ + */ + protected function fireflyProviders(): array + { + return [...parent::fireflyProviders(), EdaDatabaseServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.eda.provider' => 'database', 'firefly.eventsourcing.publish.enabled' => true, 'firefly.eventsourcing.publish.destination' => 'orders']; + } + + protected function defineFireflyEnvironment(Application $app): void + { + parent::defineFireflyEnvironment($app); + // The bare app registers neither firefly/cqrs nor firefly/data; the outbox beans take these as parameters. + $app->instance(HandlerManifest::class, new HandlerManifest([], [])); + $app->instance(CorrelationContext::class, new CorrelationContext); + $app->instance(AggregateTracker::class, new AggregateTracker); + $app->instance(ApplicationEventPublisher::class, new class implements ApplicationEventPublisher + { + public function publish(object $event): void {} + }); + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::dropIfExists(OutboxSchema::TABLE); + Schema::create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + } +} From c94512b5111981d45cc183311802057d2d13b12f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:19:01 -0700 Subject: [PATCH 21/33] feat(eventsourcing): add the projection port, function projections and fenced memory and database checkpoint stores --- .../src/Projection/Checkpoint.php | 36 +++++ .../src/Projection/CheckpointStore.php | 44 ++++++ .../Projection/DatabaseCheckpointStore.php | 148 ++++++++++++++++++ .../src/Projection/FunctionProjection.php | 49 ++++++ .../src/Projection/InlineProjection.php | 13 ++ .../src/Projection/MemoryCheckpointStore.php | 68 ++++++++ .../src/Projection/Projection.php | 35 +++++ .../src/Projection/ProjectionRegistry.php | 69 ++++++++ ...DatabaseCheckpointStoreIntegrationTest.php | 35 +++++ .../tests/Projection/CheckpointStoreTest.php | 50 ++++++ .../Projection/ProjectionRegistryTest.php | 51 ++++++ .../tests/Support/CheckpointStoreContract.php | 79 ++++++++++ 12 files changed, 677 insertions(+) create mode 100644 packages/eventsourcing/src/Projection/Checkpoint.php create mode 100644 packages/eventsourcing/src/Projection/CheckpointStore.php create mode 100644 packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php create mode 100644 packages/eventsourcing/src/Projection/FunctionProjection.php create mode 100644 packages/eventsourcing/src/Projection/InlineProjection.php create mode 100644 packages/eventsourcing/src/Projection/MemoryCheckpointStore.php create mode 100644 packages/eventsourcing/src/Projection/Projection.php create mode 100644 packages/eventsourcing/src/Projection/ProjectionRegistry.php create mode 100644 packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Projection/CheckpointStoreTest.php create mode 100644 packages/eventsourcing/tests/Projection/ProjectionRegistryTest.php create mode 100644 packages/eventsourcing/tests/Support/CheckpointStoreContract.php diff --git a/packages/eventsourcing/src/Projection/Checkpoint.php b/packages/eventsourcing/src/Projection/Checkpoint.php new file mode 100644 index 00000000..d22ce669 --- /dev/null +++ b/packages/eventsourcing/src/Projection/Checkpoint.php @@ -0,0 +1,36 @@ +lastError !== null; + } + + /** A checkpoint only ever moves forward; the runner advances it to the last position of a non-empty batch. */ + public static function forward(string $projection, int $from, int $to): void + { + if ($to <= $from) { + throw new EventSourcingException("The checkpoint of [{$projection}] only moves forward, but was asked to go from {$from} to {$to}.", 'EVENT_SOURCING_CHECKPOINT', 422); + } + } +} diff --git a/packages/eventsourcing/src/Projection/CheckpointStore.php b/packages/eventsourcing/src/Projection/CheckpointStore.php new file mode 100644 index 00000000..f9b5e23f --- /dev/null +++ b/packages/eventsourcing/src/Projection/CheckpointStore.php @@ -0,0 +1,44 @@ + by projection name */ + public function all(): array; + + /** Moves `$projection` from `$from` to `$to` and clears its failure; false when its position is no longer `$from`. */ + public function advance(string $projection, int $from, int $to): bool; + + public function reset(string $projection, int $position = 0): void; + + /** Records why the newest batch rolled back, keeping the position. */ + public function recordFailure(string $projection, string $error, DateTimeImmutable $at): void; + + /** + * Runs `$work` in one unit of work with the checkpoint: a throw undoes every advance inside it (and, for the + * database store, every row the work wrote on the same connection). + * + * @template T + * + * @param Closure(): T $work + * @return T + */ + public function transactional(Closure $work): mixed; + + public function isHealthy(): bool; +} diff --git a/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php b/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php new file mode 100644 index 00000000..c99503bd --- /dev/null +++ b/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php @@ -0,0 +1,148 @@ +translator = new PersistenceExceptionTranslator(true); + } + + public function get(string $projection): Checkpoint + { + $row = $this->connection->table(EventSourcingSchema::CHECKPOINTS)->where('projection', $projection)->first(); + + return $row === null ? new Checkpoint($projection) : self::hydrate((array) $row); + } + + public function all(): array + { + $all = []; + foreach ($this->connection->table(EventSourcingSchema::CHECKPOINTS)->orderBy('projection')->get() as $row) { + $checkpoint = self::hydrate((array) $row); + $all[$checkpoint->projection] = $checkpoint; + } + ksort($all, SORT_STRING); + + return $all; + } + + public function advance(string $projection, int $from, int $to): bool + { + EventSourcingSchema::fit('projection', $projection, EventSourcingSchema::NAME_LENGTH); + Checkpoint::forward($projection, $from, $to); + $moved = ['position' => $to, 'updated_at' => EventSourcingSchema::instant(new DateTimeImmutable), 'last_error' => null, 'failed_at' => null]; + + if ($from === 0 && ! $this->exists($projection)) { + return $this->insert(['projection' => $projection] + $moved); + } + + return $this->connection->table(EventSourcingSchema::CHECKPOINTS) + ->where('projection', $projection) + ->where('position', $from) + ->update($moved) === 1; + } + + public function reset(string $projection, int $position = 0): void + { + EventSourcingSchema::fit('projection', $projection, EventSourcingSchema::NAME_LENGTH); + $row = ['position' => max(0, $position), 'updated_at' => EventSourcingSchema::instant(new DateTimeImmutable), 'last_error' => null, 'failed_at' => null]; + + if ($this->exists($projection) || ! $this->insert(['projection' => $projection] + $row)) { + $this->connection->table(EventSourcingSchema::CHECKPOINTS)->where('projection', $projection)->update($row); + } + } + + public function recordFailure(string $projection, string $error, DateTimeImmutable $at): void + { + EventSourcingSchema::fit('projection', $projection, EventSourcingSchema::NAME_LENGTH); + $instant = EventSourcingSchema::instant($at); + $failure = ['last_error' => mb_substr($error, 0, 4000, 'UTF-8'), 'failed_at' => $instant, 'updated_at' => $instant]; + + if ($this->exists($projection) || ! $this->insert(['projection' => $projection, 'position' => 0] + $failure)) { + $this->connection->table(EventSourcingSchema::CHECKPOINTS)->where('projection', $projection)->update($failure); + } + } + + public function transactional(Closure $work): mixed + { + return $this->connection->transaction(static fn () => $work()); + } + + public function isHealthy(): bool + { + try { + $this->connection->table(EventSourcingSchema::CHECKPOINTS)->limit(1)->get(['projection']); + + return true; + } catch (Throwable) { + return false; + } + } + + private function exists(string $projection): bool + { + return $this->connection->table(EventSourcingSchema::CHECKPOINTS)->where('projection', $projection)->exists(); + } + + /** @param array $row */ + private function insert(array $row): bool + { + try { + $this->connection->transaction(fn () => $this->connection->table(EventSourcingSchema::CHECKPOINTS)->insert($row)); + + return true; + } catch (Throwable $failure) { + $translated = $this->translator->translate($failure, (string) $this->connection->getDriverName()); + if ($translated instanceof DuplicateKeyException) { + return false; + } + + throw $translated; + } + } + + /** @param array $row */ + private static function hydrate(array $row): Checkpoint + { + $projection = $row['projection'] ?? ''; + $position = $row['position'] ?? 0; + $updatedAt = $row['updated_at'] ?? null; + $lastError = $row['last_error'] ?? null; + $failedAt = $row['failed_at'] ?? null; + + return new Checkpoint( + is_scalar($projection) ? (string) $projection : '', + is_numeric($position) ? (int) $position : 0, + is_string($updatedAt) ? EventSourcingSchema::parseInstant($updatedAt) : null, + is_string($lastError) ? $lastError : null, + is_string($failedAt) ? EventSourcingSchema::parseInstant($failedAt) : null, + ); + } +} diff --git a/packages/eventsourcing/src/Projection/FunctionProjection.php b/packages/eventsourcing/src/Projection/FunctionProjection.php new file mode 100644 index 00000000..a13446d4 --- /dev/null +++ b/packages/eventsourcing/src/Projection/FunctionProjection.php @@ -0,0 +1,49 @@ + ..., ['order.placed'], fn () => ...)`. Register it on the ProjectionRegistry bean from a service provider. + */ +final class FunctionProjection implements Projection +{ + /** + * @param Closure(StoredEvent): void $handler + * @param list $handles + * @param (Closure(): void)|null $reset + */ + public function __construct( + private readonly string $name, + private readonly Closure $handler, + private readonly array $handles = [], + private readonly ?Closure $reset = null, + ) {} + + public function name(): string + { + return $this->name; + } + + public function handles(): array + { + return $this->handles; + } + + public function handle(StoredEvent $event): void + { + ($this->handler)($event); + } + + public function reset(): void + { + if ($this->reset !== null) { + ($this->reset)(); + } + } +} diff --git a/packages/eventsourcing/src/Projection/InlineProjection.php b/packages/eventsourcing/src/Projection/InlineProjection.php new file mode 100644 index 00000000..c1e19821 --- /dev/null +++ b/packages/eventsourcing/src/Projection/InlineProjection.php @@ -0,0 +1,13 @@ +` is the one path that replays it, to repair a + * read model or to build it the first time. + */ +interface InlineProjection extends Projection {} diff --git a/packages/eventsourcing/src/Projection/MemoryCheckpointStore.php b/packages/eventsourcing/src/Projection/MemoryCheckpointStore.php new file mode 100644 index 00000000..9c0d02da --- /dev/null +++ b/packages/eventsourcing/src/Projection/MemoryCheckpointStore.php @@ -0,0 +1,68 @@ + */ + private array $checkpoints = []; + + public function get(string $projection): Checkpoint + { + return $this->checkpoints[$projection] ?? new Checkpoint($projection); + } + + public function all(): array + { + $all = $this->checkpoints; + ksort($all, SORT_STRING); + + return $all; + } + + public function advance(string $projection, int $from, int $to): bool + { + Checkpoint::forward($projection, $from, $to); + if ($this->get($projection)->position !== $from) { + return false; + } + + $this->checkpoints[$projection] = new Checkpoint($projection, $to, new DateTimeImmutable); + + return true; + } + + public function reset(string $projection, int $position = 0): void + { + $this->checkpoints[$projection] = new Checkpoint($projection, max(0, $position), new DateTimeImmutable); + } + + public function recordFailure(string $projection, string $error, DateTimeImmutable $at): void + { + $this->checkpoints[$projection] = new Checkpoint($projection, $this->get($projection)->position, $at, $error, $at); + } + + public function transactional(Closure $work): mixed + { + $before = $this->checkpoints; + try { + return $work(); + } catch (Throwable $failure) { + $this->checkpoints = $before; + + throw $failure; + } + } + + public function isHealthy(): bool + { + return true; + } +} diff --git a/packages/eventsourcing/src/Projection/Projection.php b/packages/eventsourcing/src/Projection/Projection.php new file mode 100644 index 00000000..d1392558 --- /dev/null +++ b/packages/eventsourcing/src/Projection/Projection.php @@ -0,0 +1,35 @@ + + */ + public function handles(): array; + + public function handle(StoredEvent $event): void; + + /** Forgets everything the projection built, before a rebuild replays the stream from position 0. */ + public function reset(): void; +} diff --git a/packages/eventsourcing/src/Projection/ProjectionRegistry.php b/packages/eventsourcing/src/Projection/ProjectionRegistry.php new file mode 100644 index 00000000..8a09ad0d --- /dev/null +++ b/packages/eventsourcing/src/Projection/ProjectionRegistry.php @@ -0,0 +1,69 @@ + */ + private array $projections = []; + + /** @param iterable $projections */ + public function __construct(iterable $projections = []) + { + foreach ($projections as $projection) { + $this->register($projection); + } + } + + public function register(Projection $projection): void + { + $name = $projection->name(); + if ($name === '') { + throw new ConfigurationException('A projection ('.$projection::class.') must have a non-empty name(): it names its checkpoint and its lease.'); + } + + $existing = $this->projections[$name] ?? null; + if ($existing !== null && $existing !== $projection) { + throw new ConfigurationException("Two projections are named [{$name}]: ".$existing::class.' and '.$projection::class.'. A name is a checkpoint, so it must be unique.'); + } + + $this->projections[$name] = $projection; + ksort($this->projections, SORT_STRING); + } + + public function has(string $name): bool + { + return isset($this->projections[$name]); + } + + public function get(string $name): Projection + { + return $this->projections[$name] ?? throw new EventSourcingException( + "No projection is named [{$name}]; registered: ".($this->projections === [] ? 'none' : implode(', ', $this->names())).'.', + 'EVENT_SOURCING_UNKNOWN_PROJECTION', + 404, + ); + } + + /** @return list by name */ + public function all(): array + { + return array_values($this->projections); + } + + /** @return list */ + public function names(): array + { + return array_map(static fn (Projection $projection): string => $projection->name(), $this->all()); + } +} diff --git a/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php b/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php new file mode 100644 index 00000000..b03d007a --- /dev/null +++ b/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php @@ -0,0 +1,35 @@ + $assert) { + it("{$lane} checkpoint store {$case}", function () use ($assert, $lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + try { + $assert(new DatabaseCheckpointStore($connection)); + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); + } + + it("keeps a full-width projection name and case-distinct names apart on {$lane}", function () use ($lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + try { + $store = new DatabaseCheckpointStore($connection); + $wide = str_repeat('p', 191); + + expect($store->advance($wide, 0, 2))->toBeTrue() + ->and($store->advance('Orders', 0, 1))->toBeTrue() + ->and($store->advance('orders', 0, 5))->toBeTrue() + ->and([$store->get($wide)->position, $store->get('Orders')->position, $store->get('orders')->position])->toBe([2, 1, 5]); + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); +} diff --git a/packages/eventsourcing/tests/Projection/CheckpointStoreTest.php b/packages/eventsourcing/tests/Projection/CheckpointStoreTest.php new file mode 100644 index 00000000..c3a4c1b3 --- /dev/null +++ b/packages/eventsourcing/tests/Projection/CheckpointStoreTest.php @@ -0,0 +1,50 @@ + $assert) { + it("memory checkpoint store {$case}", function () use ($assert): void { + $assert(new MemoryCheckpointStore); + }); + + it("SQLite checkpoint store {$case}", function () use ($assert): void { + $assert(new DatabaseCheckpointStore(EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()))); + }); +} + +it('rolls the rows a projection wrote back with the advance of its batch, on the checkpoint connection', function (): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + $connection->getSchemaBuilder()->create('es_test_rows', static function (Blueprint $table): void { + $table->string('value'); + }); + $store = new DatabaseCheckpointStore($connection); + + try { + $store->transactional(static function () use ($connection, $store): void { + $connection->table('es_test_rows')->insert(['value' => 'projected']); + $store->advance('p', 0, 1); + + throw new RuntimeException('the batch failed'); + }); + } catch (RuntimeException) { + } + + expect($connection->table('es_test_rows')->count())->toBe(0) + ->and($connection->table(EventSourcingSchema::CHECKPOINTS)->count())->toBe(0); +}); + +it('refuses, before any write, a projection name longer than its column', function (): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + + expect(fn () => (new DatabaseCheckpointStore($connection))->advance(str_repeat('p', 192), 0, 1)) + ->toThrow(EventSourcingException::class, 'projection') + ->and($connection->table(EventSourcingSchema::CHECKPOINTS)->count())->toBe(0); +}); diff --git a/packages/eventsourcing/tests/Projection/ProjectionRegistryTest.php b/packages/eventsourcing/tests/Projection/ProjectionRegistryTest.php new file mode 100644 index 00000000..5ba31b61 --- /dev/null +++ b/packages/eventsourcing/tests/Projection/ProjectionRegistryTest.php @@ -0,0 +1,51 @@ + 'o-1'], new EventMetadata, $at, $at); +} + +it('keeps projections by name and hands a function projection its events and its reset', function (): void { + $seen = []; + $resets = 0; + $totals = new FunctionProjection('orders.totals', static function (StoredEvent $event) use (&$seen): void { + $seen[] = $event->eventType; + }, ['order.placed'], static function () use (&$resets): void { + $resets++; + }); + $audit = new FunctionProjection('audit', static function (StoredEvent $event): void {}); + $registry = new ProjectionRegistry([$totals, $audit]); + $registry->register($totals); + + $registry->get('orders.totals')->handle(projectionRegistryEvent()); + $registry->get('orders.totals')->reset(); + $audit->reset(); + + expect($registry->names())->toBe(['audit', 'orders.totals']) + ->and(array_map(static fn (Projection $p): string => $p->name(), $registry->all()))->toBe(['audit', 'orders.totals']) + ->and($registry->has('audit'))->toBeTrue() + ->and($registry->has('nope'))->toBeFalse() + ->and($totals->handles())->toBe(['order.placed']) + ->and($audit->handles())->toBe([]) + ->and([$seen, $resets])->toBe([['order.placed'], 1]); +}); + +it('refuses two projections of one name, an empty name, and a name it does not know', function (): void { + $registry = new ProjectionRegistry([new FunctionProjection('audit', static function (StoredEvent $event): void {})]); + + expect(fn () => $registry->register(new FunctionProjection('audit', static function (StoredEvent $event): void {})))->toThrow(ConfigurationException::class, 'Two projections are named [audit]') + ->and(fn () => $registry->register(new FunctionProjection('', static function (StoredEvent $event): void {})))->toThrow(ConfigurationException::class, 'non-empty name') + ->and(fn () => $registry->get('orders'))->toThrow(EventSourcingException::class, 'No projection is named [orders]; registered: audit.'); +}); diff --git a/packages/eventsourcing/tests/Support/CheckpointStoreContract.php b/packages/eventsourcing/tests/Support/CheckpointStoreContract.php new file mode 100644 index 00000000..729a1486 --- /dev/null +++ b/packages/eventsourcing/tests/Support/CheckpointStoreContract.php @@ -0,0 +1,79 @@ + */ + public static function cases(): array + { + return [ + 'starts a projection it has never seen at position 0' => static function (CheckpointStore $store): void { + $checkpoint = $store->get('orders.totals'); + + expect([$checkpoint->projection, $checkpoint->position, $checkpoint->failed()])->toBe(['orders.totals', 0, false]) + ->and($store->all())->toBe([]); + }, + 'advances only from the position it was read at' => static function (CheckpointStore $store): void { + expect($store->advance('p', 0, 5))->toBeTrue() + ->and($store->advance('p', 0, 7))->toBeFalse() + ->and($store->get('p')->position)->toBe(5) + ->and($store->advance('p', 5, 9))->toBeTrue() + ->and($store->advance('q', 0, 3))->toBeTrue() + ->and($store->get('p')->position)->toBe(9) + ->and(array_keys($store->all()))->toBe(['p', 'q']) + ->and($store->all()['q']->position)->toBe(3); + }, + 'records a failure without moving, and clears it on the next advance' => static function (CheckpointStore $store): void { + $store->advance('p', 0, 4); + $store->recordFailure('p', 'RuntimeException: the read model is down', new DateTimeImmutable('2026-10-13 10:00:00', new DateTimeZone('UTC'))); + $failed = $store->get('p'); + $store->recordFailure('never-ran', 'boom', new DateTimeImmutable('2026-10-13 10:00:00', new DateTimeZone('UTC'))); + + expect([$failed->position, $failed->lastError, $failed->failed()])->toBe([4, 'RuntimeException: the read model is down', true]) + ->and($failed->failedAt?->format('Y-m-d H:i:s'))->toBe('2026-10-13 10:00:00') + ->and([$store->get('never-ran')->position, $store->get('never-ran')->failed()])->toBe([0, true]) + ->and($store->advance('p', 4, 6))->toBeTrue() + ->and([$store->get('p')->position, $store->get('p')->lastError, $store->get('p')->failedAt])->toBe([6, null, null]); + }, + 'resets to a position, creating the row when it has to' => static function (CheckpointStore $store): void { + $store->advance('p', 0, 9); + $store->reset('p'); + $store->reset('fresh', 3); + + expect($store->get('p')->position)->toBe(0) + ->and($store->get('fresh')->position)->toBe(3) + ->and($store->advance('p', 0, 2))->toBeTrue() + ->and($store->advance('fresh', 3, 4))->toBeTrue(); + }, + 'undoes every advance of a transaction that throws, and hands back what the work returned' => static function (CheckpointStore $store): void { + $store->advance('p', 0, 3); + try { + $store->transactional(static function () use ($store): void { + $store->advance('p', 3, 8); + + throw new RuntimeException('the batch failed'); + }); + } catch (RuntimeException) { + } + + expect($store->get('p')->position)->toBe(3) + ->and($store->transactional(static fn (): int => 42))->toBe(42); + }, + 'refuses a checkpoint that would move backwards or stand still' => static function (CheckpointStore $store): void { + expect(fn () => $store->advance('p', 4, 4))->toThrow(EventSourcingException::class, 'only moves forward') + ->and($store->isHealthy())->toBeTrue(); + }, + ]; + } +} From 269932c2c4cc349d6adef81fbf8d98f68e7e8b5a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:25:14 -0700 Subject: [PATCH 22/33] fix(eventsourcing): fence a late checkpoint advance on MariaDB snapshot isolation as a checkpoint conflict --- .../Exception/CheckpointConflictException.php | 6 ++- .../src/Projection/CheckpointStore.php | 12 ++++- .../Projection/DatabaseCheckpointStore.php | 50 ++++++++++++++++--- ...DatabaseCheckpointStoreIntegrationTest.php | 14 ++++++ .../tests/Support/CheckpointStoreContract.php | 46 +++++++++++++++++ 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/packages/eventsourcing/src/Exception/CheckpointConflictException.php b/packages/eventsourcing/src/Exception/CheckpointConflictException.php index 6d87bc58..bba5cc8a 100644 --- a/packages/eventsourcing/src/Exception/CheckpointConflictException.php +++ b/packages/eventsourcing/src/Exception/CheckpointConflictException.php @@ -4,11 +4,13 @@ namespace Firefly\EventSourcing\Exception; +use Throwable; + /** Another runner advanced the projection's checkpoint first (its lease had expired): this batch is rolled back. */ final class CheckpointConflictException extends EventSourcingException { - public static function of(string $projection, int $expected): self + public static function of(string $projection, int $expected, ?Throwable $previous = null): self { - return new self("The checkpoint of projection [{$projection}] moved past {$expected} while this batch ran; another runner advanced it.", 'PROJECTION_CHECKPOINT_CONFLICT', 409); + return new self("The checkpoint of projection [{$projection}] moved past {$expected} while this batch ran; another runner advanced it.", 'PROJECTION_CHECKPOINT_CONFLICT', 409, $previous); } } diff --git a/packages/eventsourcing/src/Projection/CheckpointStore.php b/packages/eventsourcing/src/Projection/CheckpointStore.php index f9b5e23f..63f77de7 100644 --- a/packages/eventsourcing/src/Projection/CheckpointStore.php +++ b/packages/eventsourcing/src/Projection/CheckpointStore.php @@ -6,13 +6,17 @@ use Closure; use DateTimeImmutable; +use Firefly\EventSourcing\Exception\CheckpointConflictException; /** * Where projections keep their position. Selected by firefly.eventsourcing.projection.checkpoint-store (`memory` | * `database`, null follows store.provider). Every adapter passes CheckpointStoreContract. * * advance() is the FENCE: it moves a checkpoint only from the position the caller read, so when a lease expired - * mid-batch and a second runner moved first, the late runner's advance() is false and its batch rolls back. + * mid-batch and a second runner moved first, the late runner's advance() is false and its batch rolls back. Where + * the database itself aborts the caller's transaction on that race (MariaDB's snapshot isolation reports the + * changed row as an error), advance() throws CheckpointConflictException instead: the same fenced answer, and the + * caller must let transactional() roll back rather than write anything more. */ interface CheckpointStore { @@ -21,7 +25,11 @@ public function get(string $projection): Checkpoint; /** @return array by projection name */ public function all(): array; - /** Moves `$projection` from `$from` to `$to` and clears its failure; false when its position is no longer `$from`. */ + /** + * Moves `$projection` from `$from` to `$to` and clears its failure; false when its position is no longer `$from`. + * + * @throws CheckpointConflictException when another runner moved first and the database aborted the transaction + */ public function advance(string $projection, int $from, int $to): bool; public function reset(string $projection, int $position = 0): void; diff --git a/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php b/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php index c99503bd..442cb5e2 100644 --- a/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php +++ b/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php @@ -7,9 +7,11 @@ use Closure; use DateTimeImmutable; use Firefly\Data\Exception\PersistenceExceptionTranslator; +use Firefly\EventSourcing\Exception\CheckpointConflictException; use Firefly\EventSourcing\Store\EventSourcingSchema; use Firefly\Kernel\Exception\Infrastructure\DuplicateKeyException; use Illuminate\Database\Connection; +use PDOException; use Throwable; /** @@ -24,6 +26,11 @@ * would then deadlock on each other's INSERT. The insert runs in a SAVEPOINT, so a duplicate key (a rival created * the row first) leaves the caller's transaction usable on PostgreSQL and becomes `false`, the same answer as a * lost conditional UPDATE. There is no MERGE and no range scan, so SQL Server never waits on another runner's rows. + * + * MariaDB's snapshot isolation (innodb_snapshot_isolation, ON by default since 11.8) answers the same race with + * ER_CHECKREAD (1020, "Record has changed since last read") on the UPDATE or the INSERT, and that error ABORTS the + * caller's transaction: there is nothing left to return `false` into, so advance() throws + * CheckpointConflictException, the fenced answer the ProjectionRunner already maps to FENCED. */ final class DatabaseCheckpointStore implements CheckpointStore { @@ -59,14 +66,22 @@ public function advance(string $projection, int $from, int $to): bool Checkpoint::forward($projection, $from, $to); $moved = ['position' => $to, 'updated_at' => EventSourcingSchema::instant(new DateTimeImmutable), 'last_error' => null, 'failed_at' => null]; - if ($from === 0 && ! $this->exists($projection)) { - return $this->insert(['projection' => $projection] + $moved); - } + try { + if ($from === 0 && ! $this->exists($projection)) { + return $this->insert(['projection' => $projection] + $moved); + } - return $this->connection->table(EventSourcingSchema::CHECKPOINTS) - ->where('projection', $projection) - ->where('position', $from) - ->update($moved) === 1; + return $this->connection->table(EventSourcingSchema::CHECKPOINTS) + ->where('projection', $projection) + ->where('position', $from) + ->update($moved) === 1; + } catch (Throwable $failure) { + if ($this->changedSinceRead($failure)) { + throw CheckpointConflictException::of($projection, $from, $failure); + } + + throw $failure; + } } public function reset(string $projection, int $position = 0): void @@ -119,6 +134,9 @@ private function insert(array $row): bool return true; } catch (Throwable $failure) { + if ($this->changedSinceRead($failure)) { + throw $failure; + } $translated = $this->translator->translate($failure, (string) $this->connection->getDriverName()); if ($translated instanceof DuplicateKeyException) { return false; @@ -128,6 +146,24 @@ private function insert(array $row): bool } } + /** + * MariaDB's ER_CHECKREAD, raised as a QueryException or wrapped in the DeadlockException Laravel throws from a + * nested transaction: a rival committed the row after this transaction's snapshot, and the transaction is gone. + */ + private function changedSinceRead(Throwable $failure): bool + { + if (! in_array($this->connection->getDriverName(), ['mysql', 'mariadb'], true)) { + return false; + } + for ($cause = $failure; $cause !== null; $cause = $cause->getPrevious()) { + if ($cause instanceof PDOException && ($cause->errorInfo[1] ?? null) === 1020) { + return true; + } + } + + return false; + } + /** @param array $row */ private static function hydrate(array $row): Checkpoint { diff --git a/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php b/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php index b03d007a..e2e2add6 100644 --- a/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php +++ b/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php @@ -18,6 +18,20 @@ })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); } + foreach ($lane === 'sqlite' ? [] : CheckpointStoreContract::races() as $case => $race) { + it("{$lane} checkpoint store {$case}", function () use ($race, $lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + $rival = EventSourcingDatabase::connection($lane); + try { + $race(new DatabaseCheckpointStore($connection), new DatabaseCheckpointStore($rival)); + expect($connection->transactionLevel())->toBe(0); + } finally { + $rival->disconnect(); + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); + } + it("keeps a full-width projection name and case-distinct names apart on {$lane}", function () use ($lane): void { $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); try { diff --git a/packages/eventsourcing/tests/Support/CheckpointStoreContract.php b/packages/eventsourcing/tests/Support/CheckpointStoreContract.php index 729a1486..749fb8be 100644 --- a/packages/eventsourcing/tests/Support/CheckpointStoreContract.php +++ b/packages/eventsourcing/tests/Support/CheckpointStoreContract.php @@ -7,6 +7,7 @@ use Closure; use DateTimeImmutable; use DateTimeZone; +use Firefly\EventSourcing\Exception\CheckpointConflictException; use Firefly\EventSourcing\Exception\EventSourcingException; use Firefly\EventSourcing\Projection\CheckpointStore; use RuntimeException; @@ -76,4 +77,49 @@ public static function cases(): array }, ]; } + + /** + * The fence under a real race: `$store` reads inside transactional(), `$rival` (a second connection on a server, + * the same store where there is one writer) advances and commits, then `$store` advances late, the way the + * ProjectionRunner does. The late advance is fenced, as `false` or as a CheckpointConflictException where the + * database itself aborted the transaction, and either way nothing of the late batch survives. + * + * @return array + */ + public static function races(): array + { + return [ + 'fences a late first advance after a rival created the row' => static function (CheckpointStore $store, CheckpointStore $rival): void { + expect(self::raceLate($store, $rival, 'p', 3, 4))->toBe('fenced') + ->and($rival->get('p')->position)->toBe(3) + ->and($store->advance('p', 3, 5))->toBeTrue() + ->and($rival->get('p')->position)->toBe(5); + }, + 'fences a late advance after a rival moved an existing row' => static function (CheckpointStore $store, CheckpointStore $rival): void { + expect($store->advance('p', 0, 5))->toBeTrue() + ->and(self::raceLate($store, $rival, 'p', 7, 6))->toBe('fenced') + ->and($rival->get('p')->position)->toBe(7) + ->and($store->advance('p', 7, 9))->toBeTrue() + ->and($rival->get('p')->position)->toBe(9); + }, + ]; + } + + /** Reads `$projection` in a transaction, lets `$rival` move it to `$rivalTo` and commit, then advances it to `$lateTo`. */ + private static function raceLate(CheckpointStore $store, CheckpointStore $rival, string $projection, int $rivalTo, int $lateTo): string + { + try { + $store->transactional(static function () use ($store, $rival, $projection, $rivalTo, $lateTo): void { + $read = $store->get($projection)->position; + expect($rival->advance($projection, $read, $rivalTo))->toBeTrue(); + if (! $store->advance($projection, $read, $lateTo)) { + throw CheckpointConflictException::of($projection, $read); + } + }); + + return 'committed'; + } catch (CheckpointConflictException) { + return 'fenced'; + } + } } From f3d00fc19b7f016a5377b72bc7c27a6d122d0e54 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:30:22 -0700 Subject: [PATCH 23/33] feat(eventsourcing): run projections under a DistributedLock lease with fenced checkpoints, catch-up, live mode and rebuild --- .../src/Projection/ProjectionRunResult.php | 39 +++ .../src/Projection/ProjectionRunner.php | 264 ++++++++++++++++++ .../src/Projection/ProjectionStatus.php | 34 +++ .../Fixtures/RecordingInlineProjection.php | 38 +++ .../ProjectionRaceIntegrationTest.php | 100 +++++++ .../tests/Projection/ProjectionRunnerTest.php | 223 +++++++++++++++ .../tests/Support/projection-racer.php | 55 ++++ 7 files changed, 753 insertions(+) create mode 100644 packages/eventsourcing/src/Projection/ProjectionRunResult.php create mode 100644 packages/eventsourcing/src/Projection/ProjectionRunner.php create mode 100644 packages/eventsourcing/src/Projection/ProjectionStatus.php create mode 100644 packages/eventsourcing/tests/Fixtures/RecordingInlineProjection.php create mode 100644 packages/eventsourcing/tests/Integration/ProjectionRaceIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Projection/ProjectionRunnerTest.php create mode 100644 packages/eventsourcing/tests/Support/projection-racer.php diff --git a/packages/eventsourcing/src/Projection/ProjectionRunResult.php b/packages/eventsourcing/src/Projection/ProjectionRunResult.php new file mode 100644 index 00000000..ae135f3e --- /dev/null +++ b/packages/eventsourcing/src/Projection/ProjectionRunResult.php @@ -0,0 +1,39 @@ +outcome === self::FAILED; + } +} diff --git a/packages/eventsourcing/src/Projection/ProjectionRunner.php b/packages/eventsourcing/src/Projection/ProjectionRunner.php new file mode 100644 index 00000000..010f5a7c --- /dev/null +++ b/packages/eventsourcing/src/Projection/ProjectionRunner.php @@ -0,0 +1,264 @@ +`, for + * firefly.eventsourcing.projection.lease-ttl). Not acquired: another node is advancing it, `leased-elsewhere`. + * 2. Read the checkpoint, then up to `batch-size` events after it from EventStore::readAll(). That read happens + * OUTSIDE any transaction, so the store numbers the events that committed (the commit-order guarantee: nothing + * can later appear below a position already read). + * 3. In CheckpointStore::transactional(): hand the projection every event whose type it handles(), then advance the + * checkpoint from the position read in step 2 to the batch's last position. The advance is FENCED: when a + * runner whose lease outlived ours moved first, it is false, the batch rolls back, `fenced`. A projection that + * throws rolls the batch back too; its failure is recorded on the checkpoint, `failed`, and the next cycle + * retries it. + * 4. Release the lease. The port has no extend, so the lease is taken per batch and lease-ttl must exceed one. + * + * catchUpAll() and live mode skip InlineProjections: their writer keeps them current, and rebuild() is the one path + * that replays them. rebuild() resets the projection and its checkpoint in one transaction under the lease, then + * catches up from position 0. + */ +final class ProjectionRunner +{ + public const string LOCK_PREFIX = 'firefly.eventsourcing.projection.'; + + private bool $stopping = false; + + /** + * @param (Closure(float): void)|null $sleep how live mode waits (seconds); usleep() when null + * @param (Closure(): DateTimeImmutable)|null $clock + */ + public function __construct( + private readonly EventStore $store, + private readonly CheckpointStore $checkpoints, + private readonly ProjectionRegistry $projections, + private readonly DistributedLock $lock = new NoneLock, + private readonly int $batchSize = 100, + private readonly float $leaseTtl = 30.0, + private readonly float $pollInterval = 1.0, + private readonly LoggerInterface $logger = new NullLogger, + private readonly ?Closure $sleep = null, + private readonly ?Closure $clock = null, + ) {} + + public function projections(): ProjectionRegistry + { + return $this->projections; + } + + public function catchUp(string $name): ProjectionRunResult + { + $projection = $this->projections->get($name); + if ($projection instanceof InlineProjection) { + throw new EventSourcingException( + "The projection [{$name}] is inline: its writer keeps it current. Rebuild it with php artisan firefly:eventsourcing:projections:rebuild {$name}.", + 'EVENT_SOURCING_INLINE_PROJECTION', + 409, + ); + } + + return $this->drive($projection); + } + + /** @return list one per catch-up projection, by name */ + public function catchUpAll(): array + { + $results = []; + foreach ($this->projections->all() as $projection) { + if (! $projection instanceof InlineProjection) { + $results[] = $this->drive($projection); + } + } + + return $results; + } + + /** + * Catches every projection up, again and again, sleeping poll-interval after a cycle that found nothing, until + * $maxRuntime seconds passed, stop() was called (the command traps SIGINT/SIGTERM into it) or $shouldStop says so. + * + * @param (Closure(): bool)|null $shouldStop asked before every cycle + * @return int the cycles run + */ + public function runLive(?float $maxRuntime = null, ?Closure $shouldStop = null): int + { + $this->stopping = false; + $deadline = $maxRuntime === null ? null : microtime(true) + max(0.0, $maxRuntime); + $cycles = 0; + + while (! $this->stopping && ($deadline === null || microtime(true) < $deadline)) { + if ($shouldStop !== null && $shouldStop() === true) { + break; + } + + $handled = 0; + foreach ($this->catchUpAll() as $result) { + $handled += $result->handled; + } + $cycles++; + + if ($handled === 0 && ! $this->stopping) { + $this->pause($deadline); + } + } + + return $cycles; + } + + public function stop(): void + { + $this->stopping = true; + } + + public function rebuild(string $name): ProjectionRunResult + { + $projection = $this->projections->get($name); + $lease = self::LOCK_PREFIX.$name; + if (! $this->lock->tryAcquire($lease, $this->leaseTtl)) { + return new ProjectionRunResult($name, ProjectionRunResult::LEASED_ELSEWHERE, 0, 0); + } + + try { + $this->checkpoints->transactional(function () use ($projection, $name): void { + $projection->reset(); + $this->checkpoints->reset($name, 0); + }); + } finally { + $this->lock->release($lease); + } + + return $this->drive($projection); + } + + public function lag(string $name): int + { + $projection = $this->projections->get($name); + + return $projection instanceof InlineProjection ? 0 : max(0, $this->store->currentPosition() - $this->checkpoints->get($name)->position); + } + + /** @return list by name */ + public function statuses(): array + { + $head = $this->store->currentPosition(); + $checkpoints = $this->checkpoints->all(); + $statuses = []; + foreach ($this->projections->all() as $projection) { + $name = $projection->name(); + $checkpoint = $checkpoints[$name] ?? new Checkpoint($name); + $inline = $projection instanceof InlineProjection; + $statuses[] = new ProjectionStatus( + $name, + $inline, + $checkpoint->position, + $head, + $inline ? 0 : max(0, $head - $checkpoint->position), + $checkpoint->lastError, + $checkpoint->failedAt, + $checkpoint->updatedAt, + ); + } + + return $statuses; + } + + private function drive(Projection $projection): ProjectionRunResult + { + $name = $projection->name(); + $lease = self::LOCK_PREFIX.$name; + $start = $this->checkpoints->get($name)->position; + $position = $start; + $handled = 0; + + while (true) { + if (! $this->lock->tryAcquire($lease, $this->leaseTtl)) { + return new ProjectionRunResult($name, ProjectionRunResult::LEASED_ELSEWHERE, $start, $position, $handled); + } + + try { + $from = $this->checkpoints->get($name)->position; + $events = $this->store->readAll($from, $this->batchSize); + if ($events === []) { + return new ProjectionRunResult($name, ProjectionRunResult::CAUGHT_UP, $start, $from, $handled); + } + $to = $events[count($events) - 1]->globalPosition ?? $from; + + try { + $handled += $this->checkpoints->transactional(function () use ($projection, $events, $name, $from, $to): int { + $types = $projection->handles(); + $count = 0; + foreach ($events as $event) { + if ($types === [] || in_array($event->eventType, $types, true)) { + $projection->handle($event); + $count++; + } + } + if (! $this->checkpoints->advance($name, $from, $to)) { + throw CheckpointConflictException::of($name, $from); + } + + return $count; + }); + } catch (CheckpointConflictException) { + return new ProjectionRunResult($name, ProjectionRunResult::FENCED, $start, $position, $handled); + } catch (Throwable $failure) { + $error = $failure::class.': '.$failure->getMessage(); + $this->checkpoints->recordFailure($name, $error, $this->now()); + $this->logger->error( + sprintf('Projection [%s] failed after position %d: %s', $name, $from, $failure->getMessage()), + ['projection' => $name, 'position' => $from, 'exception' => $failure], + ); + + return new ProjectionRunResult($name, ProjectionRunResult::FAILED, $start, $position, $handled, $error); + } + + $position = $to; + if (count($events) < $this->batchSize) { + return new ProjectionRunResult($name, ProjectionRunResult::CAUGHT_UP, $start, $position, $handled); + } + } finally { + $this->lock->release($lease); + } + } + } + + private function pause(?float $deadline): void + { + $seconds = $deadline === null ? $this->pollInterval : min($this->pollInterval, max(0.0, $deadline - microtime(true))); + if ($seconds <= 0.0) { + return; + } + + if ($this->sleep !== null) { + ($this->sleep)($seconds); + + return; + } + + usleep((int) round($seconds * 1_000_000)); + } + + private function now(): DateTimeImmutable + { + return ($this->clock === null ? new DateTimeImmutable : ($this->clock)())->setTimezone(new DateTimeZone('UTC')); + } +} diff --git a/packages/eventsourcing/src/Projection/ProjectionStatus.php b/packages/eventsourcing/src/Projection/ProjectionStatus.php new file mode 100644 index 00000000..45606625 --- /dev/null +++ b/packages/eventsourcing/src/Projection/ProjectionStatus.php @@ -0,0 +1,34 @@ +lastError !== null => 'failed', + $this->inline => 'inline', + default => 'ok', + }; + } +} diff --git a/packages/eventsourcing/tests/Fixtures/RecordingInlineProjection.php b/packages/eventsourcing/tests/Fixtures/RecordingInlineProjection.php new file mode 100644 index 00000000..0de908c1 --- /dev/null +++ b/packages/eventsourcing/tests/Fixtures/RecordingInlineProjection.php @@ -0,0 +1,38 @@ + */ + public array $positions = []; + + public int $resets = 0; + + public function name(): string + { + return 'recorded.inline'; + } + + public function handles(): array + { + return []; + } + + public function handle(StoredEvent $event): void + { + $this->positions[] = (int) $event->globalPosition; + } + + public function reset(): void + { + $this->resets++; + $this->positions = []; + } +} diff --git a/packages/eventsourcing/tests/Integration/ProjectionRaceIntegrationTest.php b/packages/eventsourcing/tests/Integration/ProjectionRaceIntegrationTest.php new file mode 100644 index 00000000..8fcd114e --- /dev/null +++ b/packages/eventsourcing/tests/Integration/ProjectionRaceIntegrationTest.php @@ -0,0 +1,100 @@ +isRunning(); + if (str_contains($process->getOutput(), 'ready')) { + return true; + } + if (! $running) { + return false; + } + usleep(1000); + } + + return false; +} + +/** + * Starts $runners projection racers, releases them together and returns each racer's last output line. + * + * @return list + */ +function projectionRace(string $lane, string $table, int $runners): array +{ + $go = sys_get_temp_dir().'/firefly-es-projection-race-'.bin2hex(random_bytes(6)); + $script = dirname(__DIR__).'/Support/projection-racer.php'; + $processes = []; + for ($n = 0; $n < $runners; $n++) { + $arguments = [PHP_BINARY, $script, $lane, $go, $table]; + if ($lane === 'sqlite') { + $arguments[] = EventSourcingDatabase::sqlitePath(); + } + $process = new Process($arguments, timeout: 180); + $process->start(); + $processes[] = $process; + } + + try { + foreach ($processes as $process) { + expect(projectionRaceAwaitReady($process))->toBeTrue('a racer never became ready: '.$process->getErrorOutput()); + } + touch($go); + foreach ($processes as $process) { + $process->wait(); + } + } finally { + if (is_file($go)) { + unlink($go); + } + } + + return array_map(static function (Process $process): string { + $lines = array_values(array_filter(explode("\n", trim($process->getOutput())))); + + return $lines[count($lines) - 1] ?? ''; + }, $processes); +} + +foreach (EventSourcingDatabase::lanes() as $lane) { + it("projects every event exactly once while two unleased runners race on {$lane}", function () use ($lane): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::connection($lane)); + $table = 'firefly_es_race_tally'; + try { + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists($table); + $schema->create($table, static function (Blueprint $t): void { + $t->bigIncrements('id'); + $t->bigInteger('global_position'); + $t->string('runner', 32); + }); + $store = new DatabaseEventStore($connection); + for ($n = 1; $n <= 120; $n++) { + $store->append('t-'.($n % 12), 'tally', [new NewEvent('race.happened', ['n' => $n])]); + } + + $outcomes = projectionRace($lane, $table, 2); + $positions = array_map(static fn (mixed $p): int => is_numeric($p) ? (int) $p : 0, $connection->table($table)->pluck('global_position')->all()); + sort($positions); + + expect(array_map(static fn (string $line): string => explode(' ', $line)[0], $outcomes))->toBe(['done', 'done'], implode(' | ', $outcomes)) + ->and($positions)->toBe(range(1, 120)) + ->and((new DatabaseCheckpointStore($connection))->get('race.tally')->position)->toBe(120); + } finally { + $connection->getSchemaBuilder()->dropIfExists($table); + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); +} diff --git a/packages/eventsourcing/tests/Projection/ProjectionRunnerTest.php b/packages/eventsourcing/tests/Projection/ProjectionRunnerTest.php new file mode 100644 index 00000000..33e73fbd --- /dev/null +++ b/packages/eventsourcing/tests/Projection/ProjectionRunnerTest.php @@ -0,0 +1,223 @@ +append('o-'.$n, 'order', [new NewEvent($n % 2 === 0 ? 'order.paid' : 'order.placed', ['n' => $n])], 0); + } + + return $store; +} + +/** @return list */ +function projectionRunnerTally(Connection $connection): array +{ + return array_values(array_map(static fn (mixed $position): int => is_numeric($position) ? (int) $position : 0, $connection->table('es_test_tally')->orderBy('position')->pluck('position')->all())); +} + +it('catches up in batches, one checkpoint per batch, and moves past the event types it does not handle', function (): void { + $seen = []; + $projection = new FunctionProjection('placed', static function (StoredEvent $event) use (&$seen): void { + $seen[] = $event->globalPosition; + }, ['order.placed']); + $checkpoints = new MemoryCheckpointStore; + $runner = new ProjectionRunner(projectionRunnerStore(5), $checkpoints, new ProjectionRegistry([$projection]), batchSize: 2); + + $result = $runner->catchUp('placed'); + + expect([$result->outcome, $result->fromPosition, $result->toPosition, $result->handled])->toBe([ProjectionRunResult::CAUGHT_UP, 0, 5, 3]) + ->and($seen)->toBe([1, 3, 5]) + ->and($checkpoints->get('placed')->position)->toBe(5) + ->and($runner->lag('placed'))->toBe(0) + ->and($runner->catchUp('placed')->handled)->toBe(0); +}); + +it('leaves a projection alone while another node holds its lease', function (): void { + $cache = new Repository(new ArrayStore); + $elsewhere = new CacheLock($cache, 'node-b'); + $elsewhere->tryAcquire(ProjectionRunner::LOCK_PREFIX.'placed', 30.0); + $handled = 0; + $projection = new FunctionProjection('placed', static function (StoredEvent $event) use (&$handled): void { + $handled++; + }); + $runner = new ProjectionRunner(projectionRunnerStore(3), new MemoryCheckpointStore, new ProjectionRegistry([$projection]), new CacheLock($cache, 'node-a')); + + $held = $runner->catchUp('placed'); + $elsewhere->release(ProjectionRunner::LOCK_PREFIX.'placed'); + $free = $runner->catchUp('placed'); + + expect([$held->outcome, $held->handled])->toBe([ProjectionRunResult::LEASED_ELSEWHERE, 0]) + ->and([$free->outcome, $free->handled, $handled])->toBe([ProjectionRunResult::CAUGHT_UP, 3, 3]); +}); + +it('rolls a failing batch back with its rows, keeps the checkpoint, records the failure and retries next time', function (): void { + $connection = EventSourcingDatabase::fresh(EventSourcingDatabase::sqlite()); + $connection->getSchemaBuilder()->create('es_test_tally', static function (Blueprint $table): void { + $table->bigInteger('position'); + }); + $store = new DatabaseEventStore($connection); + foreach (range(1, 4) as $n) { + $store->append('o-'.$n, 'order', [new NewEvent('order.placed', ['n' => $n])], 0); + } + // A switch the test flips between the two catch-ups (an object, so the closure sees the change). + $outage = new class + { + public bool $down = true; + }; + $projection = new FunctionProjection('tally', static function (StoredEvent $event) use ($connection, $outage): void { + $connection->table('es_test_tally')->insert(['position' => $event->globalPosition]); + if ($outage->down && $event->globalPosition === 3) { + throw new RuntimeException('the tally is down'); + } + }); + $checkpoints = new DatabaseCheckpointStore($connection); + $runner = new ProjectionRunner($store, $checkpoints, new ProjectionRegistry([$projection]), batchSize: 2); + + $failed = $runner->catchUp('tally'); + $tallyAfterFailure = projectionRunnerTally($connection); + $checkpointAfterFailure = $checkpoints->get('tally'); + $outage->down = false; + $retried = $runner->catchUp('tally'); + + expect([$failed->outcome, $failed->toPosition, $failed->handled, $failed->failed()])->toBe([ProjectionRunResult::FAILED, 2, 2, true]) + ->and($failed->error)->toContain('the tally is down') + ->and($tallyAfterFailure)->toBe([1, 2]) + ->and([$checkpointAfterFailure->position, $checkpointAfterFailure->failed()])->toBe([2, true]) + ->and($checkpointAfterFailure->lastError)->toContain('RuntimeException: the tally is down') + ->and([$retried->outcome, $retried->handled])->toBe([ProjectionRunResult::CAUGHT_UP, 2]) + ->and(projectionRunnerTally($connection))->toBe([1, 2, 3, 4]) + ->and($checkpoints->get('tally')->failed())->toBeFalse(); +}); + +it('gives its batch up when another runner moved the checkpoint first (fencing)', function (): void { + // A rival whose lease outlived ours advanced the checkpoint between our read and our commit. + $rivalled = new class(new MemoryCheckpointStore) implements CheckpointStore + { + public function __construct(private readonly MemoryCheckpointStore $inner) {} + + public function get(string $projection): Checkpoint + { + return $this->inner->get($projection); + } + + public function all(): array + { + return $this->inner->all(); + } + + public function advance(string $projection, int $from, int $to): bool + { + $this->inner->advance($projection, $from, $from + 1); + + return $this->inner->advance($projection, $from, $to); + } + + public function reset(string $projection, int $position = 0): void + { + $this->inner->reset($projection, $position); + } + + public function recordFailure(string $projection, string $error, DateTimeImmutable $at): void + { + $this->inner->recordFailure($projection, $error, $at); + } + + public function transactional(Closure $work): mixed + { + return $work(); + } + + public function isHealthy(): bool + { + return true; + } + }; + $runner = new ProjectionRunner(projectionRunnerStore(3), $rivalled, new ProjectionRegistry([new FunctionProjection('p', static function (StoredEvent $event): void {})])); + + $result = $runner->catchUp('p'); + + expect([$result->outcome, $result->handled, $result->toPosition])->toBe([ProjectionRunResult::FENCED, 0, 0]) + ->and($rivalled->get('p')->position)->toBe(1); +}); + +it('rebuilds from position 0 after a reset, and is the only way to run an inline projection', function (): void { + $inline = new RecordingInlineProjection; + $runner = new ProjectionRunner(projectionRunnerStore(3), new MemoryCheckpointStore, new ProjectionRegistry([$inline])); + + expect(fn () => $runner->catchUp('recorded.inline'))->toThrow(EventSourcingException::class, 'is inline') + ->and($runner->catchUpAll())->toBe([]); + + $inline->positions = [99]; + $rebuilt = $runner->rebuild('recorded.inline'); + + expect([$rebuilt->outcome, $rebuilt->handled, $inline->resets, $inline->positions])->toBe([ProjectionRunResult::CAUGHT_UP, 3, 1, [1, 2, 3]]) + ->and($runner->statuses()[0]->state())->toBe('inline') + ->and($runner->lag('recorded.inline'))->toBe(0); +}); + +it('follows the stream in live mode, sleeping the poll interval only when idle, until told to stop', function (): void { + $store = projectionRunnerStore(2); + $seen = []; + $sleeps = []; + $projection = new FunctionProjection('all', static function (StoredEvent $event) use (&$seen): void { + $seen[] = $event->globalPosition; + }); + $runner = new ProjectionRunner($store, new MemoryCheckpointStore, new ProjectionRegistry([$projection]), pollInterval: 0.25, sleep: static function (float $seconds) use (&$sleeps): void { + $sleeps[] = $seconds; + }); + $checks = 0; + + $cycles = $runner->runLive(shouldStop: static function () use (&$checks, $store): bool { + $checks++; + if ($checks === 3) { + $store->append('o-late', 'order', [new NewEvent('order.placed', ['n' => 3])], 0); + } + + return $checks > 4; + }); + + expect($cycles)->toBe(4) + ->and($seen)->toBe([1, 2, 3]) + ->and($sleeps)->toBe([0.25, 0.25]) + ->and($runner->runLive(maxRuntime: 0.0))->toBe(0); +}); + +it('reports each projection\'s position, the head and its lag', function (): void { + $registry = new ProjectionRegistry([ + new FunctionProjection('b', static function (StoredEvent $event): void {}), + new FunctionProjection('a', static function (StoredEvent $event): void {}), + ]); + $runner = new ProjectionRunner(projectionRunnerStore(4), new MemoryCheckpointStore, $registry, batchSize: 3); + $runner->catchUp('a'); + + expect(array_map(static fn (ProjectionStatus $s): array => [$s->name, $s->position, $s->head, $s->lag, $s->state()], $runner->statuses())) + ->toBe([['a', 4, 4, 0, 'ok'], ['b', 0, 4, 4, 'ok']]) + ->and($runner->lag('b'))->toBe(4) + ->and(array_map(static fn (ProjectionRunResult $r): string => $r->projection.':'.$r->outcome, $runner->catchUpAll()))->toBe(['a:caught-up', 'b:caught-up']); +}); diff --git a/packages/eventsourcing/tests/Support/projection-racer.php b/packages/eventsourcing/tests/Support/projection-racer.php new file mode 100644 index 00000000..3aec45af --- /dev/null +++ b/packages/eventsourcing/tests/Support/projection-racer.php @@ -0,0 +1,55 @@ + $argv[4]] : []); +$tally = new FunctionProjection('race.tally', static function (StoredEvent $event) use ($connection, $table): void { + $connection->table($table)->insert(['global_position' => $event->globalPosition, 'runner' => (string) getmypid()]); +}); +$runner = new ProjectionRunner(new DatabaseEventStore($connection), new DatabaseCheckpointStore($connection), new ProjectionRegistry([$tally]), batchSize: 7); + +fwrite(STDOUT, "ready\n"); +$deadline = microtime(true) + 60.0; +while (! is_file($go) && microtime(true) < $deadline) { + usleep(500); +} + +try { + $handled = 0; + $fenced = 0; + $failed = 0; + $atHead = 0; + $giveUp = microtime(true) + 90.0; + while ($atHead < 2 && microtime(true) < $giveUp) { + $result = $runner->catchUp('race.tally'); + $handled += $result->handled; + $fenced += $result->outcome === ProjectionRunResult::FENCED ? 1 : 0; + $failed += $result->failed() ? 1 : 0; + $atHead = $result->outcome === ProjectionRunResult::CAUGHT_UP && $runner->lag('race.tally') === 0 ? $atHead + 1 : 0; + } + fwrite(STDOUT, "done {$handled} {$fenced} {$failed}\n"); +} catch (Throwable $failure) { + fwrite(STDOUT, 'error '.$failure::class.': '.$failure->getMessage()."\n"); +} From d26f38342766ba60ef11b989f87fd40431207948 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:35:49 -0700 Subject: [PATCH 24/33] feat(eventsourcing): auto-configure the projection runner, report projection lag in an eventsourcing health component and add the projection commands --- .../firefly-eventsourcing-components.php | 60 +++++++++++++ .../cache/firefly-eventsourcing-context.php | 33 +++++++ .../Actuator/EventSourcingHealthIndicator.php | 85 ++++++++++++++++++ .../src/Console/ListProjectionsCommand.php | 39 +++++++++ .../src/Console/RebuildProjectionCommand.php | 43 ++++++++++ .../src/Console/RunProjectionsCommand.php | 86 +++++++++++++++++++ .../src/EventSourcingAutoConfiguration.php | 55 ++++++++++++ .../src/EventSourcingWiringProvider.php | 13 ++- .../tests/Capstone/CapstoneHealthTest.php | 50 +++++++++++ .../tests/Capstone/CapstoneProjectionTest.php | 70 +++++++++++++++ .../OrderTotalsProjection.php | 48 +++++++++++ .../eventsourcing/tests/PackageBootsTest.php | 12 +++ .../ActuatorEventSourcingCapstoneTestCase.php | 32 +++++++ 13 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 packages/eventsourcing/src/Actuator/EventSourcingHealthIndicator.php create mode 100644 packages/eventsourcing/src/Console/ListProjectionsCommand.php create mode 100644 packages/eventsourcing/src/Console/RebuildProjectionCommand.php create mode 100644 packages/eventsourcing/src/Console/RunProjectionsCommand.php create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneHealthTest.php create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneProjectionTest.php create mode 100644 packages/eventsourcing/tests/CapstoneFixtures/OrderTotalsProjection.php create mode 100644 packages/eventsourcing/tests/Support/ActuatorEventSourcingCapstoneTestCase.php diff --git a/packages/eventsourcing/cache/firefly-eventsourcing-components.php b/packages/eventsourcing/cache/firefly-eventsourcing-components.php index 99a62c99..7e04e408 100644 --- a/packages/eventsourcing/cache/firefly-eventsourcing-components.php +++ b/packages/eventsourcing/cache/firefly-eventsourcing-components.php @@ -6,6 +6,25 @@ return [ 0 => [ + 'class' => 'Firefly\\EventSourcing\\Actuator\\EventSourcingHealthIndicator', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Health\\ConditionalHealthIndicator', + 1 => 'Firefly\\Actuator\\Health\\HealthIndicator', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + ], + ], + 1 => [ 'class' => 'Firefly\\EventSourcing\\EventSourcingAutoConfiguration', 'stereotype' => 'configuration', 'name' => null, @@ -161,6 +180,47 @@ 5 => 'Illuminate\\Container\\Container', ], ], + 11 => [ + 'method' => 'checkpointStore', + 'returns' => 'Firefly\\EventSourcing\\Projection\\CheckpointStore', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 1 => 'Illuminate\\Container\\Container', + ], + ], + 12 => [ + 'method' => 'projectionRegistry', + 'returns' => 'Firefly\\EventSourcing\\Projection\\ProjectionRegistry', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + ], + ], + 13 => [ + 'method' => 'projectionRunner', + 'returns' => 'Firefly\\EventSourcing\\Projection\\ProjectionRunner', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\Store\\EventStore', + 1 => 'Firefly\\EventSourcing\\Projection\\CheckpointStore', + 2 => 'Firefly\\EventSourcing\\Projection\\ProjectionRegistry', + 3 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 4 => 'Illuminate\\Container\\Container', + ], + ], ], 'lazy' => false, 'dependencies' => [ diff --git a/packages/eventsourcing/cache/firefly-eventsourcing-context.php b/packages/eventsourcing/cache/firefly-eventsourcing-context.php index 8c0394fd..af7ec182 100644 --- a/packages/eventsourcing/cache/firefly-eventsourcing-context.php +++ b/packages/eventsourcing/cache/firefly-eventsourcing-context.php @@ -137,6 +137,39 @@ ], ], ], + 11 => [ + 'method' => 'checkpointStore', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Projection\\CheckpointStore', + ], + ], + ], + ], + 12 => [ + 'method' => 'projectionRegistry', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Projection\\ProjectionRegistry', + ], + ], + ], + ], + 13 => [ + 'method' => 'projectionRunner', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Projection\\ProjectionRunner', + ], + ], + ], + ], ], ], ]; diff --git a/packages/eventsourcing/src/Actuator/EventSourcingHealthIndicator.php b/packages/eventsourcing/src/Actuator/EventSourcingHealthIndicator.php new file mode 100644 index 00000000..6b21bee1 --- /dev/null +++ b/packages/eventsourcing/src/Actuator/EventSourcingHealthIndicator.php @@ -0,0 +1,85 @@ +beans->has(EventSourcingSettings::class)) { + return false; + } + + $settings = $this->beans->get(EventSourcingSettings::class); + + return $settings instanceof EventSourcingSettings && $settings->healthEnabled; + } + + public function health(): Health + { + /** @var EventSourcingSettings $settings */ + $settings = $this->beans->get(EventSourcingSettings::class); + /** @var EventStore $store */ + $store = $this->beans->get(EventStore::class); + + if (! $store->isHealthy()) { + return Health::down(['store' => $settings->storeProvider, 'error' => 'the event store does not answer']); + } + + try { + /** @var ProjectionRunner $runner */ + $runner = $this->beans->get(ProjectionRunner::class); + $statuses = $runner->statuses(); + $projections = []; + $lagging = []; + foreach ($statuses as $status) { + $detail = ['position' => $status->position, 'lag' => $status->lag, 'status' => $status->state()]; + if ($status->lastError !== null) { + $detail['error'] = $status->lastError; + } + $projections[$status->name] = $detail; + if (! $status->inline && $settings->maxLag > 0 && $status->lag > $settings->maxLag) { + $lagging[] = "{$status->name} ({$status->lag})"; + } + } + + $details = [ + 'store' => $settings->storeProvider, + 'position' => $statuses === [] ? $store->currentPosition() : $statuses[0]->head, + 'projections' => $projections, + ]; + + if ($lagging !== []) { + return Health::down($details + [ + 'reason' => "projections lag more than firefly.eventsourcing.projection.max-lag ({$settings->maxLag} events): ".implode(', ', $lagging), + ]); + } + + return Health::up($details); + } catch (Throwable $error) { + return Health::down(['store' => $settings->storeProvider, 'error' => $error->getMessage()]); + } + } +} diff --git a/packages/eventsourcing/src/Console/ListProjectionsCommand.php b/packages/eventsourcing/src/Console/ListProjectionsCommand.php new file mode 100644 index 00000000..d383a305 --- /dev/null +++ b/packages/eventsourcing/src/Console/ListProjectionsCommand.php @@ -0,0 +1,39 @@ +statuses(); + if ($statuses === []) { + $this->components->info('No projections are registered.'); + + return self::SUCCESS; + } + + $this->table(['Projection', 'Mode', 'Position', 'Head', 'Lag', 'Status', 'Last error'], array_map(static fn (ProjectionStatus $status): array => [ + $status->name, + $status->inline ? 'inline' : 'catch-up', + (string) $status->position, + (string) $status->head, + (string) $status->lag, + $status->lastError === null ? 'ok' : 'failed', + $status->lastError ?? '', + ], $statuses)); + + return self::SUCCESS; + } +} diff --git a/packages/eventsourcing/src/Console/RebuildProjectionCommand.php b/packages/eventsourcing/src/Console/RebuildProjectionCommand.php new file mode 100644 index 00000000..2c51d626 --- /dev/null +++ b/packages/eventsourcing/src/Console/RebuildProjectionCommand.php @@ -0,0 +1,43 @@ +`: under the projection's lease, resets the + * projection and its checkpoint in one transaction, then replays the whole global stream into it. This is also how an + * inline projection (the event-sourced orchestration read model) is repaired or built the first time. + */ +final class RebuildProjectionCommand extends Command +{ + protected $signature = 'firefly:eventsourcing:projections:rebuild {projection : the projection to reset and replay from position 0}'; + + protected $description = 'Reset a projection and replay the global event stream into it from position 0.'; + + public function handle(ProjectionRunner $runner): int + { + try { + $result = $runner->rebuild($this->argument('projection')); + } catch (EventSourcingException $refused) { + $this->components->error($refused->getMessage()); + + return self::FAILURE; + } + + if ($result->outcome !== ProjectionRunResult::CAUGHT_UP) { + $this->components->error(sprintf('%s was not rebuilt: %s%s', $result->projection, $result->outcome, $result->error === null ? '' : ' ('.$result->error.')')); + + return self::FAILURE; + } + + $this->components->info(sprintf('%s rebuilt: %d event(s) handled, at position %d', $result->projection, $result->handled, $result->toPosition)); + + return self::SUCCESS; + } +} diff --git a/packages/eventsourcing/src/Console/RunProjectionsCommand.php b/packages/eventsourcing/src/Console/RunProjectionsCommand.php new file mode 100644 index 00000000..27ffd5d5 --- /dev/null +++ b/packages/eventsourcing/src/Console/RunProjectionsCommand.php @@ -0,0 +1,86 @@ +argument('projection'); + $name = is_string($name) && $name !== '' ? $name : null; + + if ($this->option('live') === true) { + return $this->live($runner, $name); + } + + try { + $results = $name === null ? $runner->catchUpAll() : [$runner->catchUp($name)]; + } catch (EventSourcingException $refused) { + $this->components->error($refused->getMessage()); + + return self::FAILURE; + } + + if ($results === []) { + $this->components->info('No catch-up projections are registered.'); + } + $failed = false; + foreach ($results as $result) { + $line = sprintf('%s: %s, %d event(s) handled, at position %d', $result->projection, $result->outcome, $result->handled, $result->toPosition); + if ($result->failed()) { + $failed = true; + $this->components->error($line.' ('.(string) $result->error.')'); + } else { + $this->components->info($line); + } + } + + return $failed ? self::FAILURE : self::SUCCESS; + } + + private function live(ProjectionRunner $runner, ?string $name): int + { + if ($name !== null) { + $this->components->error('--live follows every catch-up projection; run it without a projection name.'); + + return self::FAILURE; + } + + $maxRuntime = $this->option('max-runtime'); + if ($maxRuntime !== null && ! is_numeric($maxRuntime)) { + $this->components->error('--max-runtime must be a number of seconds.'); + + return self::FAILURE; + } + + if (extension_loaded('pcntl')) { + $this->trap([SIGINT, SIGTERM], static function () use ($runner): void { + $runner->stop(); + }); + } + + $cycles = $runner->runLive(is_numeric($maxRuntime) ? (float) $maxRuntime : null); + $this->components->info("Stopped after {$cycles} cycle(s)."); + + return self::SUCCESS; + } +} diff --git a/packages/eventsourcing/src/EventSourcingAutoConfiguration.php b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php index fbdc7c67..0caccde8 100644 --- a/packages/eventsourcing/src/EventSourcingAutoConfiguration.php +++ b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php @@ -24,6 +24,12 @@ use Firefly\EventSourcing\Metadata\CorrelationIdMetadataContributor; use Firefly\EventSourcing\Metadata\EventMetadataContributor; use Firefly\EventSourcing\Metadata\MetadataResolver; +use Firefly\EventSourcing\Projection\CheckpointStore; +use Firefly\EventSourcing\Projection\DatabaseCheckpointStore; +use Firefly\EventSourcing\Projection\MemoryCheckpointStore; +use Firefly\EventSourcing\Projection\Projection; +use Firefly\EventSourcing\Projection\ProjectionRegistry; +use Firefly\EventSourcing\Projection\ProjectionRunner; use Firefly\EventSourcing\Publishing\AfterCommitEventStorePublisher; use Firefly\EventSourcing\Publishing\EventStorePublisher; use Firefly\EventSourcing\Publishing\NoOpEventStorePublisher; @@ -41,6 +47,8 @@ use Firefly\EventSourcing\Upcasting\EventUpcaster; use Firefly\EventSourcing\Upcasting\UpcasterChain; use Firefly\Kernel\Exception\Framework\ConfigurationException; +use Firefly\Scheduling\Lock\DistributedLock; +use Firefly\Scheduling\Lock\NoneLock; use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Factory as AuthFactory; use Illuminate\Database\Connection; @@ -58,6 +66,8 @@ * * Collections come from scanned components through the Firefly container's getAll(): EventUpcaster beans make up the * UpcasterChain, and EventMetadataContributor beans join the framework's correlation-id and user contributors. + * Projection components make up the ProjectionRegistry, and the ProjectionRunner leases each projection on whatever + * DistributedLock firefly.scheduling.lock.provider binds (NoneLock when firefly/scheduling is not booted). */ #[Configuration] #[Order(1000)] @@ -191,6 +201,51 @@ public function eventSourcingSupport(EventStore $store, EventMapper $mapper, Sna return new EventSourcingSupport($store, $mapper, $snapshots, SnapshotPolicy::fromSettings($settings), $metadata, $logger instanceof LoggerInterface ? $logger : new NullLogger); } + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(CheckpointStore::class)] + public function checkpointStore(EventSourcingSettings $settings, Container $app): CheckpointStore + { + return $settings->checkpointStore === 'database' + ? new DatabaseCheckpointStore(self::connection($app, $settings->connection, 'firefly.eventsourcing.projection.checkpoint-store')) + : new MemoryCheckpointStore; + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(ProjectionRegistry::class)] + public function projectionRegistry(FireflyContainer $beans): ProjectionRegistry + { + $registry = new ProjectionRegistry; + foreach ($beans->getAll(Projection::class) as $projection) { + if ($projection instanceof Projection) { + $registry->register($projection); + } + } + + return $registry; + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(ProjectionRunner::class)] + public function projectionRunner(EventStore $store, CheckpointStore $checkpoints, ProjectionRegistry $projections, EventSourcingSettings $settings, Container $app): ProjectionRunner + { + $lock = $app->bound(DistributedLock::class) ? $app->make(DistributedLock::class) : null; + $logger = $app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null; + + return new ProjectionRunner( + $store, + $checkpoints, + $projections, + $lock instanceof DistributedLock ? $lock : new NoneLock, + $settings->projectionBatchSize, + $settings->leaseTtl, + $settings->pollInterval, + $logger instanceof LoggerInterface ? $logger : new NullLogger, + ); + } + /** The concrete connection $name names (null = the default), refused with the key that asked for it. */ public static function connection(Container $app, ?string $name, string $key): Connection { diff --git a/packages/eventsourcing/src/EventSourcingWiringProvider.php b/packages/eventsourcing/src/EventSourcingWiringProvider.php index 71431f16..9ac06694 100644 --- a/packages/eventsourcing/src/EventSourcingWiringProvider.php +++ b/packages/eventsourcing/src/EventSourcingWiringProvider.php @@ -7,6 +7,9 @@ use Firefly\Config\Config; use Firefly\Context\Boot\FireflyServiceProvider; use Firefly\Context\Scan\AppScan; +use Firefly\EventSourcing\Console\ListProjectionsCommand; +use Firefly\EventSourcing\Console\RebuildProjectionCommand; +use Firefly\EventSourcing\Console\RunProjectionsCommand; use Firefly\EventSourcing\Manifest\EventSourcingManifest; use Firefly\EventSourcing\Scanner\EventSourcingScanner; use Illuminate\Contracts\Container\Container; @@ -15,9 +18,9 @@ * The boot half of firefly/eventsourcing, a plain FireflyServiceProvider because an AutoConfiguration records * candidacy only (the OrchestrationWiringProvider shape). It binds the EventSourcingManifest behind a bound() guard: * the compiled `eventsourcing.php` first, then an in-process scan of `firefly.scan.paths`, then an empty manifest - * (the AppScan convention), lazily, so a cached application pays nothing. It publishes the migrations - * (`--tag=firefly-eventsourcing-migrations`) and loads them while any eventsourcing store is `database`. Later tasks - * add the commands. + * (the AppScan convention), lazily, so a cached application pays nothing. It registers the + * firefly:eventsourcing:projections* commands in a console, publishes the migrations + * (`--tag=firefly-eventsourcing-migrations`) and loads them while any eventsourcing store is `database`. */ final class EventSourcingWiringProvider extends FireflyServiceProvider { @@ -42,6 +45,10 @@ public function register(): void public function boot(): void { + if ($this->app->runningInConsole()) { + $this->commands([ListProjectionsCommand::class, RunProjectionsCommand::class, RebuildProjectionCommand::class]); + } + $this->publishes([self::MIGRATIONS => $this->app->databasePath('migrations')], 'firefly-eventsourcing-migrations'); if (EventSourcingSettings::fromConfig(new Config($this->app->make('config')))->usesDatabase()) { diff --git a/packages/eventsourcing/tests/Capstone/CapstoneHealthTest.php b/packages/eventsourcing/tests/Capstone/CapstoneHealthTest.php new file mode 100644 index 00000000..ef9d075c --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneHealthTest.php @@ -0,0 +1,50 @@ +app()->make(OrderRepository::class)->save(Order::place('h-1', 1.0)); + $this->command('firefly:eventsourcing:projections:run'); + + $health = $this->getJson('/actuator/health'); + + expect($health->json('components.eventsourcing.status'))->toBe('UP') + ->and($health->json('components.eventsourcing.details'))->toBe([ + 'store' => 'database', + 'position' => 1, + 'projections' => ['order.totals' => ['position' => 1, 'lag' => 0, 'status' => 'ok']], + ]); +}); + +it('turns DOWN when a catch-up projection lags more than max-lag', function (): void { + /** @var ActuatorEventSourcingCapstoneTestCase $this */ + $orders = $this->app()->make(OrderRepository::class); + foreach (['h-2', 'h-3', 'h-4'] as $id) { + $orders->save(Order::place($id, 1.0)); + } + + $health = $this->getJson('/actuator/health'); + + expect($health->json('components.eventsourcing.status'))->toBe('DOWN') + ->and($health->json('components.eventsourcing.details.projections'))->toBe(['order.totals' => ['position' => 0, 'lag' => 3, 'status' => 'ok']]) + ->and($health->json('components.eventsourcing.details.reason'))->toContain('order.totals (3)'); +}); + +it('turns DOWN when the event store does not answer', function (): void { + /** @var ActuatorEventSourcingCapstoneTestCase $this */ + Schema::drop(EventSourcingSchema::EVENTS); + + $health = $this->getJson('/actuator/health'); + + expect($health->json('components.eventsourcing.status'))->toBe('DOWN') + ->and($health->json('components.eventsourcing.details'))->toBe(['store' => 'database', 'error' => 'the event store does not answer']); +}); diff --git a/packages/eventsourcing/tests/Capstone/CapstoneProjectionTest.php b/packages/eventsourcing/tests/Capstone/CapstoneProjectionTest.php new file mode 100644 index 00000000..8ce8bbed --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneProjectionTest.php @@ -0,0 +1,70 @@ +app()->make(OrderRepository::class); + $order = Order::place('proj-1', 12.5); + $order->pay(250); + $orders->save($order); + $orders->save(Order::place('proj-2', 3.0)); + + $exit = $this->command('firefly:eventsourcing:projections:run'); + + expect($exit)->toBe(0) + ->and($this->commandOutput())->toContain('order.totals: caught-up, 3 event(s) handled, at position 3') + ->and($this->app()->make(ProjectionRegistry::class)->names())->toBe(['order.totals']) + ->and($this->app()->make(OrderTotalsProjection::class)->orders)->toBe(['proj-1' => ['total' => 12.5, 'paidCents' => 250], 'proj-2' => ['total' => 3.0, 'paidCents' => 0]]) + ->and($this->app()->make(CheckpointStore::class))->toBeInstanceOf(DatabaseCheckpointStore::class) + ->and(DB::table(EventSourcingSchema::CHECKPOINTS)->where('projection', 'order.totals')->value('position'))->toEqual(3); +}); + +it('lists every projection with its position, the head and its lag', function (): void { + /** @var EventSourcingCapstoneTestCase $this */ + $this->app()->make(OrderRepository::class)->save(Order::place('proj-3', 1.0)); + + $this->command('firefly:eventsourcing:projections'); + $before = $this->commandOutput(); + $this->command('firefly:eventsourcing:projections:run', ['projection' => 'order.totals']); + $this->command('firefly:eventsourcing:projections'); + $after = $this->commandOutput(); + + expect($before)->toMatch('/order\.totals\s*\|\s*catch-up\s*\|\s*0\s*\|\s*1\s*\|\s*1\s*\|\s*ok/') + ->and($after)->toMatch('/order\.totals\s*\|\s*catch-up\s*\|\s*1\s*\|\s*1\s*\|\s*0\s*\|\s*ok/'); +}); + +it('rebuilds a projection from position 0 with firefly:eventsourcing:projections:rebuild', function (): void { + /** @var EventSourcingCapstoneTestCase $this */ + $this->app()->make(OrderRepository::class)->save(Order::place('proj-4', 9.0)); + $this->command('firefly:eventsourcing:projections:run'); + $projection = $this->app()->make(OrderTotalsProjection::class); + $projection->orders['ghost'] = ['total' => 1.0, 'paidCents' => 0]; + + $exit = $this->command('firefly:eventsourcing:projections:rebuild', ['projection' => 'order.totals']); + + expect($exit)->toBe(0) + ->and($this->commandOutput())->toContain('order.totals rebuilt: 1 event(s) handled, at position 1') + ->and($projection->resets)->toBe(1) + ->and($projection->orders)->toBe(['proj-4' => ['total' => 9.0, 'paidCents' => 0]]); +}); + +it('refuses a projection it does not know, and runs live until --max-runtime', function (): void { + /** @var EventSourcingCapstoneTestCase $this */ + expect($this->command('firefly:eventsourcing:projections:rebuild', ['projection' => 'nope']))->toBe(1) + ->and($this->commandOutput())->toContain('No projection is named [nope]') + ->and($this->command('firefly:eventsourcing:projections:run', ['--live' => true, '--max-runtime' => '0.2']))->toBe(0) + ->and($this->commandOutput())->toContain('Stopped after'); +}); diff --git a/packages/eventsourcing/tests/CapstoneFixtures/OrderTotalsProjection.php b/packages/eventsourcing/tests/CapstoneFixtures/OrderTotalsProjection.php new file mode 100644 index 00000000..ef15c1de --- /dev/null +++ b/packages/eventsourcing/tests/CapstoneFixtures/OrderTotalsProjection.php @@ -0,0 +1,48 @@ + */ + public array $orders = []; + + public int $resets = 0; + + public function name(): string + { + return 'order.totals'; + } + + public function handles(): array + { + return ['order.placed', 'order.paid']; + } + + public function handle(StoredEvent $event): void + { + $order = $this->orders[$event->aggregateId] ?? ['total' => 0.0, 'paidCents' => 0]; + if ($event->eventType === 'order.placed') { + $total = $event->payload['total'] ?? 0; + $order['total'] = is_int($total) || is_float($total) ? (float) $total : 0.0; + } else { + $amount = $event->payload['amount'] ?? 0; + $order['paidCents'] += is_int($amount) ? $amount : 0; + } + $this->orders[$event->aggregateId] = $order; + } + + public function reset(): void + { + $this->orders = []; + $this->resets++; + } +} diff --git a/packages/eventsourcing/tests/PackageBootsTest.php b/packages/eventsourcing/tests/PackageBootsTest.php index 1b1379f4..b0b3afff 100644 --- a/packages/eventsourcing/tests/PackageBootsTest.php +++ b/packages/eventsourcing/tests/PackageBootsTest.php @@ -6,6 +6,10 @@ use Firefly\EventSourcing\EventSourcingServiceProvider; use Firefly\EventSourcing\EventSourcingWiringProvider; use Firefly\EventSourcing\Manifest\EventSourcingManifest; +use Firefly\EventSourcing\Projection\CheckpointStore; +use Firefly\EventSourcing\Projection\MemoryCheckpointStore; +use Firefly\EventSourcing\Projection\ProjectionRegistry; +use Firefly\EventSourcing\Projection\ProjectionRunner; use Firefly\EventSourcing\Snapshot\MemorySnapshotStore; use Firefly\EventSourcing\Snapshot\SnapshotStore; use Firefly\EventSourcing\Store\EventStore; @@ -71,3 +75,11 @@ public function path(string $path): void ->and($database->paths)->toBe([EventSourcingWiringProvider::MIGRATIONS]) ->and(ServiceProvider::pathsToPublish(EventSourcingWiringProvider::class, 'firefly-eventsourcing-migrations'))->toHaveKey(EventSourcingWiringProvider::MIGRATIONS); }); + +it('binds memory checkpoints, an empty projection registry and a runner by default', function (): void { + $app = fireflyApplication([], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]); + + expect($app->make(CheckpointStore::class))->toBeInstanceOf(MemoryCheckpointStore::class) + ->and($app->make(ProjectionRegistry::class)->all())->toBe([]) + ->and($app->make(ProjectionRunner::class)->statuses())->toBe([]); +}); diff --git a/packages/eventsourcing/tests/Support/ActuatorEventSourcingCapstoneTestCase.php b/packages/eventsourcing/tests/Support/ActuatorEventSourcingCapstoneTestCase.php new file mode 100644 index 00000000..bd8bdeed --- /dev/null +++ b/packages/eventsourcing/tests/Support/ActuatorEventSourcingCapstoneTestCase.php @@ -0,0 +1,32 @@ + */ + protected function fireflyProviders(): array + { + return [ValidationServiceProvider::class, WebServiceProvider::class, ActuatorServiceProvider::class, ActuatorWiringProvider::class, ...parent::fireflyProviders()]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.management.enabled' => true, + 'firefly.management.endpoints.web.exposure.include' => 'health', + 'firefly.management.endpoint.health.show-details' => 'always', + 'firefly.eventsourcing.projection.max-lag' => 2, + ]; + } +} From e796468e8eee22de0a7471d45392cd8ceb1ec1c5 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:39:53 -0700 Subject: [PATCH 25/33] feat(eventsourcing): model an orchestration execution as an event-sourced aggregate that replays its checkpoints exactly --- .../Event/ExecutionCheckpointed.php | 35 +++ .../Orchestration/Event/ExecutionDeleted.php | 30 +++ .../Orchestration/Event/ExecutionStarted.php | 35 +++ .../Event/ExecutionStatusChanged.php | 48 ++++ .../Event/SearchAttributesChanged.php | 32 +++ .../Event/StepResultRecorded.php | 33 +++ .../Orchestration/Event/StepStatusChanged.php | 33 +++ .../src/Orchestration/ExecutionAggregate.php | 245 ++++++++++++++++++ .../Orchestration/OrchestrationEventTypes.php | 59 +++++ .../Orchestration/ExecutionAggregateTest.php | 124 +++++++++ 10 files changed, 674 insertions(+) create mode 100644 packages/eventsourcing/src/Orchestration/Event/ExecutionCheckpointed.php create mode 100644 packages/eventsourcing/src/Orchestration/Event/ExecutionDeleted.php create mode 100644 packages/eventsourcing/src/Orchestration/Event/ExecutionStarted.php create mode 100644 packages/eventsourcing/src/Orchestration/Event/ExecutionStatusChanged.php create mode 100644 packages/eventsourcing/src/Orchestration/Event/SearchAttributesChanged.php create mode 100644 packages/eventsourcing/src/Orchestration/Event/StepResultRecorded.php create mode 100644 packages/eventsourcing/src/Orchestration/Event/StepStatusChanged.php create mode 100644 packages/eventsourcing/src/Orchestration/ExecutionAggregate.php create mode 100644 packages/eventsourcing/src/Orchestration/OrchestrationEventTypes.php create mode 100644 packages/eventsourcing/tests/Orchestration/ExecutionAggregateTest.php diff --git a/packages/eventsourcing/src/Orchestration/Event/ExecutionCheckpointed.php b/packages/eventsourcing/src/Orchestration/Event/ExecutionCheckpointed.php new file mode 100644 index 00000000..33b94a6a --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/Event/ExecutionCheckpointed.php @@ -0,0 +1,35 @@ + value>)`. + */ +final readonly class ExecutionCheckpointed extends DomainEvent implements SerializableEvent +{ + public function __construct(public string $changes, ?string $eventId = null, ?DateTimeImmutable $occurredAt = null) + { + parent::__construct($eventId, $occurredAt); + } + + /** @return array{changes: string} */ + public function toPayload(): array + { + return ['changes' => $this->changes]; + } + + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent + { + $changes = $payload['changes'] ?? ''; + + return new self(is_string($changes) ? $changes : '', $event->eventId, $event->occurredAt); + } +} diff --git a/packages/eventsourcing/src/Orchestration/Event/ExecutionDeleted.php b/packages/eventsourcing/src/Orchestration/Event/ExecutionDeleted.php new file mode 100644 index 00000000..ea2b6e74 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/Event/ExecutionDeleted.php @@ -0,0 +1,30 @@ +eventId, $event->occurredAt); + } +} diff --git a/packages/eventsourcing/src/Orchestration/Event/ExecutionStarted.php b/packages/eventsourcing/src/Orchestration/Event/ExecutionStarted.php new file mode 100644 index 00000000..551e82ad --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/Event/ExecutionStarted.php @@ -0,0 +1,35 @@ + $this->state]; + } + + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent + { + $state = $payload['state'] ?? ''; + + return new self(is_string($state) ? $state : '', $event->eventId, $event->occurredAt); + } +} diff --git a/packages/eventsourcing/src/Orchestration/Event/ExecutionStatusChanged.php b/packages/eventsourcing/src/Orchestration/Event/ExecutionStatusChanged.php new file mode 100644 index 00000000..39b2c830 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/Event/ExecutionStatusChanged.php @@ -0,0 +1,48 @@ + $this->status, 'updatedAt' => $this->updatedAt, 'completedAt' => $this->completedAt, 'wakeAt' => $this->wakeAt]; + } + + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent + { + $status = $payload['status'] ?? ''; + $updatedAt = $payload['updatedAt'] ?? ''; + $completedAt = $payload['completedAt'] ?? null; + $wakeAt = $payload['wakeAt'] ?? null; + + return new self( + is_string($status) ? $status : '', + is_string($updatedAt) ? $updatedAt : '', + is_string($completedAt) ? $completedAt : null, + is_string($wakeAt) ? $wakeAt : null, + $event->eventId, + $event->occurredAt, + ); + } +} diff --git a/packages/eventsourcing/src/Orchestration/Event/SearchAttributesChanged.php b/packages/eventsourcing/src/Orchestration/Event/SearchAttributesChanged.php new file mode 100644 index 00000000..7c76e423 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/Event/SearchAttributesChanged.php @@ -0,0 +1,32 @@ + ])`. */ +final readonly class SearchAttributesChanged extends DomainEvent implements SerializableEvent +{ + public function __construct(public string $attributes, ?string $eventId = null, ?DateTimeImmutable $occurredAt = null) + { + parent::__construct($eventId, $occurredAt); + } + + /** @return array{attributes: string} */ + public function toPayload(): array + { + return ['attributes' => $this->attributes]; + } + + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent + { + $attributes = $payload['attributes'] ?? ''; + + return new self(is_string($attributes) ? $attributes : '', $event->eventId, $event->occurredAt); + } +} diff --git a/packages/eventsourcing/src/Orchestration/Event/StepResultRecorded.php b/packages/eventsourcing/src/Orchestration/Event/StepResultRecorded.php new file mode 100644 index 00000000..58d247be --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/Event/StepResultRecorded.php @@ -0,0 +1,33 @@ + ])`. */ +final readonly class StepResultRecorded extends DomainEvent implements SerializableEvent +{ + public function __construct(public string $stepId, public string $result, ?string $eventId = null, ?DateTimeImmutable $occurredAt = null) + { + parent::__construct($eventId, $occurredAt); + } + + /** @return array{stepId: string, result: string} */ + public function toPayload(): array + { + return ['stepId' => $this->stepId, 'result' => $this->result]; + } + + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent + { + $stepId = $payload['stepId'] ?? ''; + $result = $payload['result'] ?? ''; + + return new self(is_scalar($stepId) ? (string) $stepId : '', is_string($result) ? $result : '', $event->eventId, $event->occurredAt); + } +} diff --git a/packages/eventsourcing/src/Orchestration/Event/StepStatusChanged.php b/packages/eventsourcing/src/Orchestration/Event/StepStatusChanged.php new file mode 100644 index 00000000..88f1df83 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/Event/StepStatusChanged.php @@ -0,0 +1,33 @@ + $this->stepId, 'status' => $this->status]; + } + + public static function fromPayload(array $payload, StoredEvent $event): DomainEvent + { + $stepId = $payload['stepId'] ?? ''; + $status = $payload['status'] ?? ''; + + return new self(is_scalar($stepId) ? (string) $stepId : '', is_string($status) ? $status : '', $event->eventId, $event->occurredAt); + } +} diff --git a/packages/eventsourcing/src/Orchestration/ExecutionAggregate.php b/packages/eventsourcing/src/Orchestration/ExecutionAggregate.php new file mode 100644 index 00000000..7b46b580 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/ExecutionAggregate.php @@ -0,0 +1,245 @@ +|null */ + private ?array $state = null; + + private bool $deleted = false; + + public static function aggregateType(): string + { + return OrchestrationEventTypes::AGGREGATE_TYPE; + } + + public function state(): ?ExecutionState + { + return $this->state === null || $this->deleted ? null : ExecutionState::fromArray($this->state); + } + + public function isDeleted(): bool + { + return $this->deleted; + } + + public function checkpoint(ExecutionState $state): void + { + $new = StateSerializer::decodeArray(StateSerializer::encode($state)); + if ($this->state === null || $this->deleted) { + $this->recordThat(new ExecutionStarted(StateSerializer::encodeArray($new))); + + return; + } + + $old = $this->state; + if ($old === $new) { + return; + } + + $working = $old; + $events = []; + + if (self::differs($old, $new, self::STATUS_FIELDS)) { + $completedAt = $new['completedAt'] ?? null; + $wakeAt = $new['wakeAt'] ?? null; + self::then($working, $events, new ExecutionStatusChanged( + self::text($new['status'] ?? null), + self::text($new['updatedAt'] ?? null), + is_string($completedAt) ? $completedAt : null, + is_string($wakeAt) ? $wakeAt : null, + )); + } + + $oldStatuses = self::map($old['stepStatuses'] ?? null); + foreach (self::map($new['stepStatuses'] ?? null) as $step => $status) { + if (is_string($status) && (! array_key_exists($step, $oldStatuses) || $oldStatuses[$step] !== $status)) { + self::then($working, $events, new StepStatusChanged((string) $step, $status)); + } + } + + $oldResults = self::map($old['stepResults'] ?? null); + foreach (self::map($new['stepResults'] ?? null) as $step => $result) { + if (! array_key_exists($step, $oldResults) || $oldResults[$step] !== $result) { + self::then($working, $events, new StepResultRecorded((string) $step, StateSerializer::encodeArray(['value' => $result]))); + } + } + + if (($old['searchAttributes'] ?? null) !== ($new['searchAttributes'] ?? null)) { + self::then($working, $events, new SearchAttributesChanged(StateSerializer::encodeArray(['value' => $new['searchAttributes'] ?? []]))); + } + + $changes = []; + foreach ($new as $key => $value) { + if (! array_key_exists($key, $working) || $working[$key] !== $value) { + $changes[$key] = $value; + } + } + if ($changes !== []) { + self::then($working, $events, new ExecutionCheckpointed(StateSerializer::encodeArray($changes))); + } + + foreach ($events as $event) { + $this->recordThat($event); + } + } + + public function delete(): void + { + if ($this->state !== null && ! $this->deleted) { + $this->recordThat(new ExecutionDeleted); + } + } + + /** @return array{state: string|null, deleted: bool} */ + public function snapshotState(): array + { + return ['state' => $this->state === null ? null : StateSerializer::encodeArray($this->state), 'deleted' => $this->deleted]; + } + + public function restoreSnapshot(array $state): void + { + $encoded = $state['state'] ?? null; + $this->state = is_string($encoded) ? StateSerializer::decodeArray($encoded) : null; + $this->deleted = ($state['deleted'] ?? false) === true; + } + + public static function snapshotVersion(): int + { + return 1; + } + + /** Every execution event is folded by fold(), so the convention's apply methods are not needed. */ + protected function eventHandlers(): array + { + $handlers = []; + foreach (array_keys(OrchestrationEventTypes::TYPES) as $class) { + $handlers[$class] = 'fold'; + } + + return $handlers; + } + + protected function fold(DomainEvent $event): void + { + if ($event instanceof ExecutionDeleted) { + $this->deleted = true; + + return; + } + if ($event instanceof ExecutionStarted) { + $this->deleted = false; + } + + $this->state = self::applyTo($this->state ?? [], $event); + } + + /** + * @param array $state + * @return array + */ + private static function applyTo(array $state, DomainEvent $event): array + { + if ($event instanceof ExecutionStarted) { + return StateSerializer::decodeArray($event->state); + } + if ($event instanceof ExecutionStatusChanged) { + $state['status'] = $event->status; + $state['updatedAt'] = $event->updatedAt; + $state['completedAt'] = $event->completedAt; + $state['wakeAt'] = $event->wakeAt; + } elseif ($event instanceof StepStatusChanged) { + $statuses = self::map($state['stepStatuses'] ?? null); + $statuses[$event->stepId] = $event->status; + $state['stepStatuses'] = $statuses; + } elseif ($event instanceof StepResultRecorded) { + $results = self::map($state['stepResults'] ?? null); + $results[$event->stepId] = StateSerializer::decodeArray($event->result)['value'] ?? null; + $state['stepResults'] = $results; + } elseif ($event instanceof SearchAttributesChanged) { + $state['searchAttributes'] = StateSerializer::decodeArray($event->attributes)['value'] ?? []; + } elseif ($event instanceof ExecutionCheckpointed) { + foreach (StateSerializer::decodeArray($event->changes) as $key => $value) { + $state[$key] = $value; + } + } + + return $state; + } + + /** + * Queues $event and applies it to the working copy, exactly as fold() will. + * + * @param array $working + * @param list $events + */ + private static function then(array &$working, array &$events, DomainEvent $event): void + { + $events[] = $event; + $working = self::applyTo($working, $event); + } + + /** + * @param array $a + * @param array $b + * @param list $fields + */ + private static function differs(array $a, array $b, array $fields): bool + { + foreach ($fields as $field) { + if (($a[$field] ?? null) !== ($b[$field] ?? null)) { + return true; + } + } + + return false; + } + + /** @return array */ + private static function map(mixed $value): array + { + return is_array($value) ? $value : []; + } + + private static function text(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } +} diff --git a/packages/eventsourcing/src/Orchestration/OrchestrationEventTypes.php b/packages/eventsourcing/src/Orchestration/OrchestrationEventTypes.php new file mode 100644 index 00000000..18798006 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/OrchestrationEventTypes.php @@ -0,0 +1,59 @@ +, string> */ + public const array TYPES = [ + ExecutionStarted::class => 'orchestration.execution.started', + ExecutionStatusChanged::class => 'orchestration.execution.status-changed', + StepStatusChanged::class => 'orchestration.execution.step-status-changed', + StepResultRecorded::class => 'orchestration.execution.step-result-recorded', + SearchAttributesChanged::class => 'orchestration.execution.search-attributes-changed', + ExecutionCheckpointed::class => 'orchestration.execution.checkpointed', + ExecutionDeleted::class => 'orchestration.execution.deleted', + ]; + + public static function register(EventTypeRegistry $registry): void + { + foreach (self::TYPES as $class => $type) { + $registry->register(new EventTypeDescriptor($class, $type, 1, true)); + } + } + + /** @return list */ + public static function types(): array + { + return array_values(self::TYPES); + } + + /** @return EventSourcedRepository */ + public static function repository(EventSourcingSupport $support): EventSourcedRepository + { + self::register($support->mapper->types()); + + return $support->repository(ExecutionAggregate::class); + } +} diff --git a/packages/eventsourcing/tests/Orchestration/ExecutionAggregateTest.php b/packages/eventsourcing/tests/Orchestration/ExecutionAggregateTest.php new file mode 100644 index 00000000..c9b933c4 --- /dev/null +++ b/packages/eventsourcing/tests/Orchestration/ExecutionAggregateTest.php @@ -0,0 +1,124 @@ + $changes */ +function executionAggregateNext(ExecutionState $state, array $changes): ExecutionState +{ + return ExecutionState::fromArray(array_replace($state->toArray(), $changes)); +} + +/** @return list */ +function executionAggregateEvents(ExecutionAggregate $aggregate): array +{ + return array_map(static fn (DomainEvent $event): string => $event::class, $aggregate->uncommittedEvents()); +} + +it('starts with the whole state, then records one semantic event per kind of change', function (): void { + $aggregate = new ExecutionAggregate('c-1'); + $started = ExecutionPersistenceContract::state('c-1'); + $next = executionAggregateNext($started, [ + 'updatedAt' => '2026-10-12T10:01:00.000000+00:00', + 'stepStatuses' => ['reserve' => 'DONE', 'charge' => 'DONE'], + 'stepResults' => ['reserve' => ['hold' => 'h-c-1'], 'charge' => ['amount' => 10.0]], + 'searchAttributes' => ['customer' => 'ada'], + 'variables' => ['attempt' => 2], + ]); + + $aggregate->checkpoint($started); + $aggregate->checkpoint($next); + $aggregate->checkpoint($next); + + expect(executionAggregateEvents($aggregate))->toBe([ + ExecutionStarted::class, + ExecutionStatusChanged::class, + StepStatusChanged::class, + StepResultRecorded::class, + SearchAttributesChanged::class, + ExecutionCheckpointed::class, + ]) + ->and($aggregate->state()?->toArray())->toBe($next->toArray()) + ->and(OrchestrationEventTypes::types())->toHaveCount(7); +}); + +it('falls back to one checkpointed event for a change no semantic event reproduces exactly', function (): void { + $aggregate = new ExecutionAggregate('c-2'); + $first = ExecutionPersistenceContract::state('c-2', overrides: ['stepStatuses' => ['a' => 'DONE', 'b' => 'DONE']]); + $reordered = executionAggregateNext($first, ['stepStatuses' => ['b' => 'DONE', 'a' => 'DONE']]); + + $aggregate->checkpoint($first); + $aggregate->checkpoint($reordered); + + expect(executionAggregateEvents($aggregate))->toBe([ExecutionStarted::class, ExecutionCheckpointed::class]) + ->and($aggregate->state()?->toArray())->toBe($reordered->toArray()); +}); + +it('replays to exactly the state it was given, whole floats included, from events and from a snapshot', function (): void { + $aggregate = new ExecutionAggregate('c-3'); + $first = ExecutionPersistenceContract::state('c-3'); + $last = executionAggregateNext($first, ['status' => 'COMPLETED', 'completedAt' => '2026-10-12T10:05:00.000000+00:00', 'stepResults' => ['reserve' => ['hold' => 'h'], 'charge' => 10.0]]); + $aggregate->checkpoint($first); + $aggregate->checkpoint($last); + + $replayed = ExecutionAggregate::reconstitute('c-3', $aggregate->uncommittedEvents()); + $restored = ExecutionAggregate::reconstitute('c-3', [], $aggregate->version(), $aggregate->snapshotState()); + + expect($replayed->state()?->toArray())->toBe($last->toArray()) + ->and($restored->state()?->toArray())->toBe($last->toArray()) + ->and($replayed->state()?->stepResults['charge'] ?? null)->toBe(10.0); +}); + +it('round-trips through a store and a snapshot with the registered event types', function (): void { + $support = new EventSourcingSupport(new MemoryEventStore, new EventMapper(new EventTypeRegistry), new MemorySnapshotStore, new SnapshotPolicy(true, 2, 2)); + $executions = OrchestrationEventTypes::repository($support); + OrchestrationEventTypes::register($support->mapper->types()); + $state = ExecutionPersistenceContract::state('c-4'); + + $aggregate = new ExecutionAggregate('c-4'); + $aggregate->checkpoint($state); + $executions->save($aggregate); + $next = executionAggregateNext($state, ['status' => 'WAITING', 'wakeAt' => '2026-10-12T11:00:00.000000+00:00', 'timers' => ['t' => '2026-10-12T11:00:00.000000+00:00']]); + $reloaded = $executions->get('c-4'); + $reloaded->checkpoint($next); + $executions->save($reloaded); + + expect($executions->get('c-4')->state()?->toArray())->toBe($next->toArray()) + ->and($support->snapshots->latest('c-4')?->version)->toBe(3) + ->and($support->store->load('c-4')->events[0]->eventType)->toBe('orchestration.execution.started'); +}); + +it('tombstones a deleted execution once, and starts it again on the next checkpoint', function (): void { + $aggregate = new ExecutionAggregate('c-5'); + $state = ExecutionPersistenceContract::state('c-5'); + $aggregate->checkpoint($state); + $aggregate->delete(); + $aggregate->delete(); + + expect([$aggregate->state(), $aggregate->isDeleted()])->toBe([null, true]) + ->and(executionAggregateEvents($aggregate))->toBe([ExecutionStarted::class, ExecutionDeleted::class]); + + $aggregate->checkpoint($state); + + expect($aggregate->isDeleted())->toBeFalse() + ->and($aggregate->state()?->toArray())->toBe($state->toArray()) + ->and(executionAggregateEvents($aggregate))->toBe([ExecutionStarted::class, ExecutionDeleted::class, ExecutionStarted::class]); +}); From f9271646adb2309452c0491f85ed60e8ca2ee6e2 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:42:35 -0700 Subject: [PATCH 26/33] feat(eventsourcing): add the event-sourced orchestration execution store with an inline, rebuildable read model --- ...entSourcedExecutionPersistenceProvider.php | 199 ++++++++++++++++++ .../ExecutionReadModelProjection.php | 74 +++++++ ...ourcedExecutionPersistenceProviderTest.php | 102 +++++++++ .../Support/EventSourcedOrchestration.php | 57 +++++ 4 files changed, 432 insertions(+) create mode 100644 packages/eventsourcing/src/Orchestration/EventSourcedExecutionPersistenceProvider.php create mode 100644 packages/eventsourcing/src/Orchestration/ExecutionReadModelProjection.php create mode 100644 packages/eventsourcing/tests/Orchestration/EventSourcedExecutionPersistenceProviderTest.php create mode 100644 packages/eventsourcing/tests/Support/EventSourcedOrchestration.php diff --git a/packages/eventsourcing/src/Orchestration/EventSourcedExecutionPersistenceProvider.php b/packages/eventsourcing/src/Orchestration/EventSourcedExecutionPersistenceProvider.php new file mode 100644 index 00000000..70f55498 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/EventSourcedExecutionPersistenceProvider.php @@ -0,0 +1,199 @@ + */ + private readonly EventSourcedRepository $executions; + + public function __construct( + private readonly EventSourcingSupport $support, + private readonly ExecutionPersistenceProvider $readModel, + private readonly ?Connection $connection = null, + ) { + $this->executions = OrchestrationEventTypes::repository($support); + } + + /** @return EventSourcedRepository */ + public function executions(): EventSourcedRepository + { + return $this->executions; + } + + public function readModel(): ExecutionPersistenceProvider + { + return $this->readModel; + } + + public function save(ExecutionState $state): void + { + OrchestrationSchema::fit('correlationId', $state->correlationId, OrchestrationSchema::ID_LENGTH); + OrchestrationSchema::fit('parentCorrelationId', $state->parentCorrelationId, OrchestrationSchema::ID_LENGTH); + OrchestrationSchema::fit('executionName', $state->executionName, OrchestrationSchema::NAME_LENGTH); + + $this->transactional(function () use ($state): void { + $aggregate = $this->executions->find($state->correlationId) ?? new ExecutionAggregate($state->correlationId); + $aggregate->checkpoint($state); + $this->executions->save($aggregate); + $this->readModel->save($state); + }); + } + + public function findById(string $correlationId): ?ExecutionState + { + return $this->executions->find($correlationId)?->state(); + } + + public function find(ExecutionCriteria $criteria): array + { + return $this->readModel->find($criteria); + } + + public function findInFlight(int $limit = 100): array + { + return $this->readModel->findInFlight($limit); + } + + public function findStale(DateTimeImmutable $before, int $limit = 100): array + { + return $this->readModel->findStale($before, $limit); + } + + public function findDue(DateTimeImmutable $now, int $limit = 100): array + { + return $this->readModel->findDue($now, $limit); + } + + public function findBySearchAttributes(array $criteria, int $limit = 100): array + { + return $this->readModel->findBySearchAttributes($criteria, $limit); + } + + public function updateStatus(string $correlationId, ExecutionStatus $status, DateTimeImmutable $now): void + { + $state = $this->findById($correlationId); + if ($state !== null) { + $this->save($state->withStatus($status, $now)); + } + } + + public function delete(string $correlationId): bool + { + return $this->transactional(function () use ($correlationId): bool { + $aggregate = $this->executions->find($correlationId); + $this->readModel->delete($correlationId); + if ($aggregate === null || $aggregate->state() === null) { + return false; + } + + $aggregate->delete(); + $this->executions->save($aggregate); + + return true; + }); + } + + public function cleanup(DateTimeImmutable $before): int + { + $deleted = 0; + foreach ($this->cleanupCandidates($before) as $correlationId) { + if ($this->delete($correlationId)) { + $deleted++; + } + } + + return $deleted; + } + + public function countInFlight(): int + { + return $this->readModel->countInFlight(); + } + + public function countByStatus(): array + { + return $this->readModel->countByStatus(); + } + + public function isHealthy(): bool + { + return $this->support->store->isHealthy() && $this->readModel->isHealthy(); + } + + /** + * The terminal executions last updated before $before. The database read model answers with one indexed query + * over W2's executions table; the memory one is listed per terminal status. + * + * @return list + */ + private function cleanupCandidates(DateTimeImmutable $before): array + { + if ($this->connection !== null && $this->readModel instanceof DatabaseExecutionPersistenceProvider) { + return array_values(array_filter($this->connection->table(OrchestrationSchema::EXECUTIONS) + ->whereIn('status', array_map(static fn (ExecutionStatus $status): string => $status->value, ExecutionStatus::terminal())) + ->where('updated_at', '<', OrchestrationSchema::instant($before)) + ->orderBy('correlation_id') + ->pluck('correlation_id') + ->all(), 'is_string')); + } + + $ids = []; + foreach (ExecutionStatus::terminal() as $status) { + foreach ($this->readModel->find(new ExecutionCriteria(status: $status, limit: PHP_INT_MAX)) as $state) { + if ($state->updatedAt < $before) { + $ids[] = $state->correlationId; + } + } + } + sort($ids); + + return $ids; + } + + /** + * @template T + * + * @param Closure(): T $work + * @return T + */ + private function transactional(Closure $work): mixed + { + return $this->connection === null ? $work() : $this->connection->transaction(static fn () => $work(), self::ATTEMPTS); + } +} diff --git a/packages/eventsourcing/src/Orchestration/ExecutionReadModelProjection.php b/packages/eventsourcing/src/Orchestration/ExecutionReadModelProjection.php new file mode 100644 index 00000000..0fcf6104 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/ExecutionReadModelProjection.php @@ -0,0 +1,74 @@ + */ + private readonly EventSourcedRepository $executions; + + public function __construct(EventSourcingSupport $support, private readonly ExecutionPersistenceProvider $readModel) + { + $this->executions = OrchestrationEventTypes::repository($support); + } + + public function readModel(): ExecutionPersistenceProvider + { + return $this->readModel; + } + + public function name(): string + { + return self::NAME; + } + + public function handles(): array + { + return OrchestrationEventTypes::types(); + } + + public function handle(StoredEvent $event): void + { + if ($event->aggregateType !== OrchestrationEventTypes::AGGREGATE_TYPE) { + return; + } + + $state = $this->executions->find($event->aggregateId)?->state(); + if ($state === null) { + $this->readModel->delete($event->aggregateId); + + return; + } + + $this->readModel->save($state); + } + + public function reset(): void + { + do { + $states = $this->readModel->find(new ExecutionCriteria(limit: 500)); + foreach ($states as $state) { + $this->readModel->delete($state->correlationId); + } + } while ($states !== []); + } +} diff --git a/packages/eventsourcing/tests/Orchestration/EventSourcedExecutionPersistenceProviderTest.php b/packages/eventsourcing/tests/Orchestration/EventSourcedExecutionPersistenceProviderTest.php new file mode 100644 index 00000000..5f3ed882 --- /dev/null +++ b/packages/eventsourcing/tests/Orchestration/EventSourcedExecutionPersistenceProviderTest.php @@ -0,0 +1,102 @@ + $assert) { + it("event-sourced persistence on the memory store {$case}", function () use ($assert): void { + $assert(EventSourcedOrchestration::provider()); + }); + + it("event-sourced persistence on SQLite {$case}", function () use ($assert): void { + $assert(EventSourcedOrchestration::provider(EventSourcedOrchestration::fresh(EventSourcingDatabase::sqlite()))); + }); +} + +it('stores a saga\'s checkpoints as events and snapshots, and replays it exactly as the read model holds it', function (): void { + $connection = EventSourcedOrchestration::fresh(EventSourcingDatabase::sqlite()); + $provider = EventSourcedOrchestration::provider($connection, snapshotThreshold: 3); + $engines = TestEngines::create(persistence: $provider); + $saga = SagaBuilder::saga('es') + ->step('a')->handler(static fn (): array => ['n' => 1, 'amount' => 10.0])->add() + ->step('b')->dependsOn('a')->handler(static fn (): string => 'b')->add() + ->build(); + + $result = $engines->sagas->execute($saga, ['orderId' => 7], correlationId: 'es-1'); + $types = $connection->table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'es-1')->orderBy('aggregate_version')->pluck('event_type')->all(); + + expect($result->isSuccess())->toBeTrue() + ->and($types[0] ?? null)->toBe('orchestration.execution.started') + ->and(count($types))->toBeGreaterThanOrEqual(3) + ->and($connection->table(EventSourcingSchema::SNAPSHOTS)->where('aggregate_id', 'es-1')->count())->toBeGreaterThan(0) + ->and($provider->findById('es-1')?->toArray())->toBe($provider->readModel()->findById('es-1')?->toArray()) + ->and($provider->findById('es-1')?->stepResults)->toBe(['a' => ['n' => 1, 'amount' => 10.0], 'b' => 'b']) + ->and($connection->table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', 'es-1')->value('status'))->toBe('COMPLETED'); +}); + +it('rolls the events and the read model back together with the caller\'s transaction', function (): void { + $connection = EventSourcedOrchestration::fresh(EventSourcingDatabase::sqlite()); + $provider = EventSourcedOrchestration::provider($connection); + + try { + $connection->transaction(static function () use ($provider): void { + $provider->save(ExecutionPersistenceContract::state('rb-1')); + + throw new RuntimeException('the step failed after its checkpoint'); + }); + } catch (RuntimeException) { + } + + expect($provider->findById('rb-1'))->toBeNull() + ->and($connection->table(EventSourcingSchema::EVENTS)->count())->toBe(0) + ->and($connection->table(OrchestrationSchema::EXECUTIONS)->count())->toBe(0); +}); + +it('tombstones a deleted execution instead of purging its stream, and lets the id start again', function (): void { + $connection = EventSourcedOrchestration::fresh(EventSourcingDatabase::sqlite()); + $provider = EventSourcedOrchestration::provider($connection); + $provider->save(ExecutionPersistenceContract::state('d-1')); + + expect($provider->delete('d-1'))->toBeTrue() + ->and($provider->findById('d-1'))->toBeNull() + ->and($connection->table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'd-1')->orderByDesc('aggregate_version')->value('event_type'))->toBe('orchestration.execution.deleted'); + + $provider->save(ExecutionPersistenceContract::state('d-1')); + + expect($provider->findById('d-1')?->correlationId)->toBe('d-1') + ->and(ExecutionPersistenceContract::ids($provider->find(new ExecutionCriteria)))->toBe(['d-1']); +}); + +it('rebuilds the read model from the events with its inline projection, and only through a rebuild', function (): void { + $connection = EventSourcedOrchestration::fresh(EventSourcingDatabase::sqlite()); + $support = EventSourcedOrchestration::support($connection); + $provider = EventSourcedOrchestration::from($support, $connection); + $provider->save(ExecutionPersistenceContract::state('r-1', overrides: ['searchAttributes' => ['customer' => 'ada']])); + $provider->save(ExecutionPersistenceContract::state('r-2')); + $provider->delete('r-2'); + $connection->table(OrchestrationSchema::SEARCH_ATTRIBUTES)->delete(); + $connection->table(OrchestrationSchema::EXECUTIONS)->delete(); + $projection = new ExecutionReadModelProjection($support, $provider->readModel()); + $runner = new ProjectionRunner($support->store, new DatabaseCheckpointStore($connection), new ProjectionRegistry([$projection])); + + $result = $runner->rebuild(ExecutionReadModelProjection::NAME); + + expect($result->outcome)->toBe(ProjectionRunResult::CAUGHT_UP) + ->and(ExecutionPersistenceContract::ids($provider->find(new ExecutionCriteria)))->toBe(['r-1']) + ->and(ExecutionPersistenceContract::ids($provider->findBySearchAttributes(['customer' => 'ada'])))->toBe(['r-1']) + ->and(fn () => $runner->catchUp(ExecutionReadModelProjection::NAME))->toThrow(EventSourcingException::class, 'is inline'); +}); diff --git a/packages/eventsourcing/tests/Support/EventSourcedOrchestration.php b/packages/eventsourcing/tests/Support/EventSourcedOrchestration.php new file mode 100644 index 00000000..78bd3a29 --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventSourcedOrchestration.php @@ -0,0 +1,57 @@ +getSchemaBuilder()); + OrchestrationSchema::create($connection->getSchemaBuilder()); + + return $connection; + } +} From ade779efba55685cee7a622079df1cc5290f67cd Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:48:31 -0700 Subject: [PATCH 27/33] feat(eventsourcing): supply firefly.orchestration.persistence.provider=event-sourced, proven by W2's capstones and contract on every server --- .../firefly-eventsourcing-components.php | 46 ++++++++++ .../cache/firefly-eventsourcing-context.php | 43 +++++++++ ...eventsourcing_orchestration_read_model.php | 36 ++++++++ .../src/EventSourcingAutoConfiguration.php | 11 ++- .../src/EventSourcingWiringProvider.php | 24 +++-- ...tSourcedOrchestrationAutoConfiguration.php | 67 ++++++++++++++ .../CapstoneEventSourcedOrchestrationTest.php | 88 +++++++++++++++++++ ...oneMemoryEventSourcedOrchestrationTest.php | 31 +++++++ ...cedExecutionPersistenceIntegrationTest.php | 20 +++++ ...edOrchestrationCapstoneIntegrationTest.php | 29 ++++++ .../eventsourcing/tests/PackageBootsTest.php | 31 ++++++- ...ntSourcedOrchestrationCapstoneTestCase.php | 45 ++++++++++ ...ourcedOrchestrationIntegrationTestCase.php | 13 +++ ...ntSourcedOrchestrationCapstoneTestCase.php | 15 ++++ 14 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 packages/eventsourcing/database/migrations/2026_10_13_000100_create_firefly_eventsourcing_orchestration_read_model.php create mode 100644 packages/eventsourcing/src/Orchestration/EventSourcedOrchestrationAutoConfiguration.php create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneEventSourcedOrchestrationTest.php create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneMemoryEventSourcedOrchestrationTest.php create mode 100644 packages/eventsourcing/tests/Integration/EventSourcedExecutionPersistenceIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Integration/EventSourcedOrchestrationCapstoneIntegrationTest.php create mode 100644 packages/eventsourcing/tests/Support/EventSourcedOrchestrationCapstoneTestCase.php create mode 100644 packages/eventsourcing/tests/Support/EventSourcedOrchestrationIntegrationTestCase.php create mode 100644 packages/eventsourcing/tests/Support/MemoryEventSourcedOrchestrationCapstoneTestCase.php diff --git a/packages/eventsourcing/cache/firefly-eventsourcing-components.php b/packages/eventsourcing/cache/firefly-eventsourcing-components.php index 7e04e408..6d424e1e 100644 --- a/packages/eventsourcing/cache/firefly-eventsourcing-components.php +++ b/packages/eventsourcing/cache/firefly-eventsourcing-components.php @@ -203,6 +203,7 @@ 'lazy' => true, 'dependencies' => [ 0 => 'Firefly\\Container\\Container', + 1 => 'Illuminate\\Container\\Container', ], ], 13 => [ @@ -226,4 +227,49 @@ 'dependencies' => [ ], ], + 2 => [ + 'class' => 'Firefly\\EventSourcing\\Orchestration\\EventSourcedOrchestrationAutoConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 900, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'executionReadModelProjection', + 'returns' => 'Firefly\\EventSourcing\\Orchestration\\ExecutionReadModelProjection', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\Aggregate\\EventSourcingSupport', + 1 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 2 => 'Illuminate\\Container\\Container', + ], + ], + 1 => [ + 'method' => 'eventSourcedExecutionPersistenceProvider', + 'returns' => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => true, + 'dependencies' => [ + 0 => 'Firefly\\EventSourcing\\Aggregate\\EventSourcingSupport', + 1 => 'Firefly\\EventSourcing\\Orchestration\\ExecutionReadModelProjection', + 2 => 'Firefly\\EventSourcing\\EventSourcingSettings', + 3 => 'Illuminate\\Container\\Container', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], ]; diff --git a/packages/eventsourcing/cache/firefly-eventsourcing-context.php b/packages/eventsourcing/cache/firefly-eventsourcing-context.php index af7ec182..562e6445 100644 --- a/packages/eventsourcing/cache/firefly-eventsourcing-context.php +++ b/packages/eventsourcing/cache/firefly-eventsourcing-context.php @@ -172,4 +172,47 @@ ], ], ], + 1 => [ + 'class' => 'Firefly\\EventSourcing\\Orchestration\\EventSourcedOrchestrationAutoConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.orchestration.persistence.provider', + 1 => 'event-sourced', + 2 => false, + ], + ], + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'executionReadModelProjection', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\EventSourcing\\Orchestration\\ExecutionReadModelProjection', + ], + ], + ], + ], + 1 => [ + 'method' => 'eventSourcedExecutionPersistenceProvider', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider', + ], + ], + ], + ], + ], + ], ]; diff --git a/packages/eventsourcing/database/migrations/2026_10_13_000100_create_firefly_eventsourcing_orchestration_read_model.php b/packages/eventsourcing/database/migrations/2026_10_13_000100_create_firefly_eventsourcing_orchestration_read_model.php new file mode 100644 index 00000000..a02e2a36 --- /dev/null +++ b/packages/eventsourcing/database/migrations/2026_10_13_000100_create_firefly_eventsourcing_orchestration_read_model.php @@ -0,0 +1,36 @@ +getConnection())); + } + + public function down(): void + { + Schema::connection($this->getConnection())->dropIfExists(OrchestrationSchema::SEARCH_ATTRIBUTES); + Schema::connection($this->getConnection())->dropIfExists(OrchestrationSchema::EXECUTIONS); + } +}; diff --git a/packages/eventsourcing/src/EventSourcingAutoConfiguration.php b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php index 0caccde8..c021fd46 100644 --- a/packages/eventsourcing/src/EventSourcingAutoConfiguration.php +++ b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php @@ -24,6 +24,7 @@ use Firefly\EventSourcing\Metadata\CorrelationIdMetadataContributor; use Firefly\EventSourcing\Metadata\EventMetadataContributor; use Firefly\EventSourcing\Metadata\MetadataResolver; +use Firefly\EventSourcing\Orchestration\ExecutionReadModelProjection; use Firefly\EventSourcing\Projection\CheckpointStore; use Firefly\EventSourcing\Projection\DatabaseCheckpointStore; use Firefly\EventSourcing\Projection\MemoryCheckpointStore; @@ -214,7 +215,7 @@ public function checkpointStore(EventSourcingSettings $settings, Container $app) #[Bean] #[Lazy] #[ConditionalOnMissingBean(ProjectionRegistry::class)] - public function projectionRegistry(FireflyContainer $beans): ProjectionRegistry + public function projectionRegistry(FireflyContainer $beans, Container $app): ProjectionRegistry { $registry = new ProjectionRegistry; foreach ($beans->getAll(Projection::class) as $projection) { @@ -223,6 +224,14 @@ public function projectionRegistry(FireflyContainer $beans): ProjectionRegistry } } + // The event-sourced orchestration read model is a #[Bean], not a scanned component, so getAll() cannot see it. + if ($app->bound(ExecutionReadModelProjection::class)) { + $readModel = $app->make(ExecutionReadModelProjection::class); + if (! $registry->has($readModel->name())) { + $registry->register($readModel); + } + } + return $registry; } diff --git a/packages/eventsourcing/src/EventSourcingWiringProvider.php b/packages/eventsourcing/src/EventSourcingWiringProvider.php index 9ac06694..b5f4996c 100644 --- a/packages/eventsourcing/src/EventSourcingWiringProvider.php +++ b/packages/eventsourcing/src/EventSourcingWiringProvider.php @@ -19,13 +19,19 @@ * candidacy only (the OrchestrationWiringProvider shape). It binds the EventSourcingManifest behind a bound() guard: * the compiled `eventsourcing.php` first, then an in-process scan of `firefly.scan.paths`, then an empty manifest * (the AppScan convention), lazily, so a cached application pays nothing. It registers the - * firefly:eventsourcing:projections* commands in a console, publishes the migrations - * (`--tag=firefly-eventsourcing-migrations`) and loads them while any eventsourcing store is `database`. + * firefly:eventsourcing:projections* commands in a console, publishes the tables migration + * (`--tag=firefly-eventsourcing-migrations`) and the orchestration read-model migration + * (`--tag=firefly-eventsourcing-orchestration-migrations`), and loads the first while any eventsourcing store is + * `database`, the second while orchestration persistence is `event-sourced` over the database store. */ final class EventSourcingWiringProvider extends FireflyServiceProvider { public const string MIGRATIONS = __DIR__.'/../database/migrations'; + public const string TABLES_MIGRATION = '2026_10_13_000000_create_firefly_eventsourcing_tables.php'; + + public const string ORCHESTRATION_MIGRATION = '2026_10_13_000100_create_firefly_eventsourcing_orchestration_read_model.php'; + public function register(): void { if (! $this->app->bound(EventSourcingManifest::class)) { @@ -49,10 +55,18 @@ public function boot(): void $this->commands([ListProjectionsCommand::class, RunProjectionsCommand::class, RebuildProjectionCommand::class]); } - $this->publishes([self::MIGRATIONS => $this->app->databasePath('migrations')], 'firefly-eventsourcing-migrations'); + $tables = self::MIGRATIONS.'/'.self::TABLES_MIGRATION; + $readModel = self::MIGRATIONS.'/'.self::ORCHESTRATION_MIGRATION; + $this->publishes([$tables => $this->app->databasePath('migrations/'.self::TABLES_MIGRATION)], 'firefly-eventsourcing-migrations'); + $this->publishes([$readModel => $this->app->databasePath('migrations/'.self::ORCHESTRATION_MIGRATION)], 'firefly-eventsourcing-orchestration-migrations'); - if (EventSourcingSettings::fromConfig(new Config($this->app->make('config')))->usesDatabase()) { - $this->loadMigrationsFrom(self::MIGRATIONS); + $config = new Config($this->app->make('config')); + $settings = EventSourcingSettings::fromConfig($config); + if ($settings->usesDatabase()) { + $this->loadMigrationsFrom($tables); + } + if ($settings->storeProvider === 'database' && $config->string('firefly.orchestration.persistence.provider', 'memory') === 'event-sourced') { + $this->loadMigrationsFrom($readModel); } } } diff --git a/packages/eventsourcing/src/Orchestration/EventSourcedOrchestrationAutoConfiguration.php b/packages/eventsourcing/src/Orchestration/EventSourcedOrchestrationAutoConfiguration.php new file mode 100644 index 00000000..4a1722b2 --- /dev/null +++ b/packages/eventsourcing/src/Orchestration/EventSourcedOrchestrationAutoConfiguration.php @@ -0,0 +1,67 @@ +storeProvider === 'database' + ? new DatabaseExecutionPersistenceProvider(EventSourcingAutoConfiguration::connection($app, $settings->connection, 'firefly.orchestration.persistence.provider')) + : new MemoryExecutionPersistenceProvider; + + return new ExecutionReadModelProjection(self::untenanted($support), $readModel); + } + + #[Bean] + #[Lazy] + #[ConditionalOnMissingBean(ExecutionPersistenceProvider::class)] + public function eventSourcedExecutionPersistenceProvider(EventSourcingSupport $support, ExecutionReadModelProjection $readModel, EventSourcingSettings $settings, Container $app): ExecutionPersistenceProvider + { + $connection = $settings->storeProvider === 'database' + ? EventSourcingAutoConfiguration::connection($app, $settings->connection, 'firefly.orchestration.persistence.provider') + : null; + + return new EventSourcedExecutionPersistenceProvider(self::untenanted($support), $readModel->readModel(), $connection); + } + + private static function untenanted(EventSourcingSupport $support): EventSourcingSupport + { + return $support->store instanceof TenantScopedEventStore + ? new EventSourcingSupport($support->store->inner(), $support->mapper, $support->snapshots, $support->snapshotPolicy, $support->metadata, $support->logger) + : $support; + } +} diff --git a/packages/eventsourcing/tests/Capstone/CapstoneEventSourcedOrchestrationTest.php b/packages/eventsourcing/tests/Capstone/CapstoneEventSourcedOrchestrationTest.php new file mode 100644 index 00000000..2e786b80 --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneEventSourcedOrchestrationTest.php @@ -0,0 +1,88 @@ +app()->make(Orchestrator::class)->start(ExecutionPattern::Saga, 'checkout', ['orderId' => 7, 'amount' => 40]); + $stream = $this->app()->make(EventStore::class)->load($outcome->correlationId); + + expect($this->app()->make(ExecutionPersistenceProvider::class))->toBeInstanceOf(EventSourcedExecutionPersistenceProvider::class) + ->and($outcome->success)->toBeTrue() + ->and($this->app()->make(Ledger::class)->lines)->toBe(['hook start checkout', 'reserve 7', 'charge 40 for r-7', 'completed checkout']) + ->and($stream->aggregateType())->toBe('orchestration.execution') + ->and($stream->events[0]->eventType)->toBe('orchestration.execution.started') + ->and($this->app()->make(Orchestrator::class)->find($outcome->correlationId)?->status)->toBe(ExecutionStatus::Completed) + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $outcome->correlationId)->value('status'))->toBe('COMPLETED'); +}); + +it('compensates and dead-letters a failing saga, its checkpoints still in the event store', function (): void { + /** @var EventSourcedOrchestrationCapstoneTestCase $this */ + $outcome = $this->app()->make(Orchestrator::class)->start(ExecutionPattern::Saga, 'checkout', ['orderId' => 8, 'amount' => 500]); + + expect($outcome->success)->toBeFalse() + ->and($this->app()->make(Ledger::class)->lines)->toBe(['hook start checkout', 'reserve 8', 'release r-8', 'failed: amount 500 exceeds the limit']) + ->and(DB::table(OrchestrationSchema::DEAD_LETTERS)->where('correlation_id', $outcome->correlationId)->value('step_id'))->toBe('charge') + ->and($this->app()->make(Orchestrator::class)->find($outcome->correlationId)?->status)->toBe(ExecutionStatus::Failed) + ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $outcome->correlationId)->count())->toBeGreaterThan(2); +}); + +it('parks a workflow on a signal and a timer and finishes it, searching the read model', function (): void { + /** @var EventSourcedOrchestrationCapstoneTestCase $this */ + $workflows = $this->app()->make(WorkflowEngine::class); + + $started = $workflows->start('onboarding', ['email' => 'ada@example.com'], correlationId: 'onb-es'); + $workflows->signal('onb-es', 'email-verified', ['token' => 't-1']); + usleep(5_000); + $workflows->resumeDue(); + + expect($started->status)->toBe(ExecutionStatus::Waiting) + ->and($workflows->find('onb-es')?->status)->toBe(ExecutionStatus::Completed) + ->and($this->app()->make(Ledger::class)->lines)->toBe(['hook start onboarding', 'register ada@example.com', 'verified', 'cooled off']) + ->and(array_map(static fn ($state): string => $state->correlationId, $workflows->search(['email' => 'ada@example.com'])))->toBe(['onb-es']); +}); + +it('rebuilds the execution read model from the event store with the projections command', function (): void { + /** @var EventSourcedOrchestrationCapstoneTestCase $this */ + $this->app()->make(WorkflowEngine::class)->start('onboarding', ['email' => 'grace@example.com'], correlationId: 'onb-rebuild'); + DB::table(OrchestrationSchema::SEARCH_ATTRIBUTES)->delete(); + DB::table(OrchestrationSchema::EXECUTIONS)->delete(); + + $exit = $this->command('firefly:eventsourcing:projections:rebuild', ['projection' => 'orchestration.executions']); + $rebuilt = $this->commandOutput(); + $this->command('firefly:eventsourcing:projections'); + + expect($exit)->toBe(0) + ->and($rebuilt)->toContain('orchestration.executions rebuilt') + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', 'onb-rebuild')->value('status'))->toBe('WAITING') + ->and(array_map(static fn ($state): string => $state->correlationId, $this->app()->make(WorkflowEngine::class)->search(['email' => 'grace@example.com'])))->toBe(['onb-rebuild']) + ->and($this->commandOutput())->toMatch('/orchestration\.executions\s*\|\s*inline/') + ->and($this->app()->make(ProjectionRegistry::class)->has('orchestration.executions'))->toBeTrue(); +}); + +it('loads the eventsourcing tables and the read-model migration on the store connection', function (): void { + /** @var EventSourcedOrchestrationCapstoneTestCase $this */ + $migrator = $this->app()->make('migrator'); + + expect($migrator->paths())->toContain( + EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::TABLES_MIGRATION, + EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::ORCHESTRATION_MIGRATION, + ); +}); diff --git a/packages/eventsourcing/tests/Capstone/CapstoneMemoryEventSourcedOrchestrationTest.php b/packages/eventsourcing/tests/Capstone/CapstoneMemoryEventSourcedOrchestrationTest.php new file mode 100644 index 00000000..d6ea16f1 --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneMemoryEventSourcedOrchestrationTest.php @@ -0,0 +1,31 @@ +app()->make(Orchestrator::class)->start(ExecutionPattern::Saga, 'checkout', ['orderId' => 9, 'amount' => 10]); + $persistence = $this->app()->make(ExecutionPersistenceProvider::class); + // The read model the provider queries is the read-model projection bean's, the one the rebuild command replays into. + $readModel = $this->app()->make(ExecutionReadModelProjection::class)->readModel(); + + expect($this->app()->make(EventStore::class))->toBeInstanceOf(MemoryEventStore::class) + ->and($persistence)->toBeInstanceOf(EventSourcedExecutionPersistenceProvider::class) + ->and($readModel)->toBeInstanceOf(MemoryExecutionPersistenceProvider::class) + ->and($outcome->success)->toBeTrue() + ->and($persistence->findById($outcome->correlationId)?->status)->toBe(ExecutionStatus::Completed) + ->and($persistence->countByStatus())->toBe(['COMPLETED' => 1]); +}); diff --git a/packages/eventsourcing/tests/Integration/EventSourcedExecutionPersistenceIntegrationTest.php b/packages/eventsourcing/tests/Integration/EventSourcedExecutionPersistenceIntegrationTest.php new file mode 100644 index 00000000..e3d151c3 --- /dev/null +++ b/packages/eventsourcing/tests/Integration/EventSourcedExecutionPersistenceIntegrationTest.php @@ -0,0 +1,20 @@ + $assert) { + it("{$lane} event-sourced persistence {$case}", function () use ($assert, $lane): void { + $connection = EventSourcedOrchestration::fresh(EventSourcingDatabase::connection($lane)); + try { + $assert(EventSourcedOrchestration::provider($connection)); + } finally { + $connection->disconnect(); + } + })->group('integration')->skip(EventSourcingDatabase::skipReason($lane) !== null, (string) EventSourcingDatabase::skipReason($lane)); + } +} diff --git a/packages/eventsourcing/tests/Integration/EventSourcedOrchestrationCapstoneIntegrationTest.php b/packages/eventsourcing/tests/Integration/EventSourcedOrchestrationCapstoneIntegrationTest.php new file mode 100644 index 00000000..807cea51 --- /dev/null +++ b/packages/eventsourcing/tests/Integration/EventSourcedOrchestrationCapstoneIntegrationTest.php @@ -0,0 +1,29 @@ +app()->make(Orchestrator::class)->start(ExecutionPattern::Saga, 'checkout', ['orderId' => 7, 'amount' => 40]); + $workflows = $this->app()->make(WorkflowEngine::class); + $workflows->start('onboarding', ['email' => 'ada@example.com'], correlationId: 'onb-es-it'); + + expect(DB::connection()->getDriverName())->toBe(IntegrationDatabase::driver($lane)) + ->and($outcome->success)->toBeTrue() + ->and($workflows->find('onb-es-it')?->status)->toBe(ExecutionStatus::Waiting) + ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_type', 'orchestration.execution')->count())->toBeGreaterThan(4) + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $outcome->correlationId)->value('status'))->toBe('COMPLETED') + ->and(array_map(static fn ($state): string => $state->correlationId, $workflows->search(['email' => 'ada@example.com'])))->toBe(['onb-es-it']); +})->with(IntegrationDatabase::lanes())->group('integration'); diff --git a/packages/eventsourcing/tests/PackageBootsTest.php b/packages/eventsourcing/tests/PackageBootsTest.php index b0b3afff..a1e41d7b 100644 --- a/packages/eventsourcing/tests/PackageBootsTest.php +++ b/packages/eventsourcing/tests/PackageBootsTest.php @@ -72,8 +72,35 @@ public function path(string $path): void $databaseApp->make('migrator'); expect($memory->paths)->toBe([]) - ->and($database->paths)->toBe([EventSourcingWiringProvider::MIGRATIONS]) - ->and(ServiceProvider::pathsToPublish(EventSourcingWiringProvider::class, 'firefly-eventsourcing-migrations'))->toHaveKey(EventSourcingWiringProvider::MIGRATIONS); + ->and($database->paths)->toBe([EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::TABLES_MIGRATION]) + ->and(ServiceProvider::pathsToPublish(EventSourcingWiringProvider::class, 'firefly-eventsourcing-migrations'))->toHaveKey(EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::TABLES_MIGRATION) + ->and(ServiceProvider::pathsToPublish(EventSourcingWiringProvider::class, 'firefly-eventsourcing-orchestration-migrations'))->toHaveKey(EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::ORCHESTRATION_MIGRATION); +}); + +it('loads the orchestration read-model migration only while orchestration persistence is event-sourced over the database store', function (): void { + $record = static function (array $config): array { + $migrator = new class + { + /** @var list */ + public array $paths = []; + + public function path(string $path): void + { + $this->paths[] = $path; + } + }; + $app = fireflyApplication(['firefly' => $config], [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]); + $app->bind('migrator', static fn (): object => $migrator); + $app->make('migrator'); + + return $migrator->paths; + }; + $tables = EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::TABLES_MIGRATION; + $readModel = EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::ORCHESTRATION_MIGRATION; + + expect($record(['orchestration' => ['persistence' => ['provider' => 'event-sourced']]]))->toBe([]) + ->and($record(['eventsourcing' => ['store' => ['provider' => 'database']]]))->toBe([$tables]) + ->and($record(['eventsourcing' => ['store' => ['provider' => 'database']], 'orchestration' => ['persistence' => ['provider' => 'event-sourced']]]))->toBe([$tables, $readModel]); }); it('binds memory checkpoints, an empty projection registry and a runner by default', function (): void { diff --git a/packages/eventsourcing/tests/Support/EventSourcedOrchestrationCapstoneTestCase.php b/packages/eventsourcing/tests/Support/EventSourcedOrchestrationCapstoneTestCase.php new file mode 100644 index 00000000..1a8fa713 --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventSourcedOrchestrationCapstoneTestCase.php @@ -0,0 +1,45 @@ + */ + protected function fireflyProviders(): array + { + return [...parent::fireflyProviders(), EventSourcingServiceProvider::class, EventSourcingWiringProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.orchestration.persistence.provider' => 'event-sourced', + 'firefly.eventsourcing.store.provider' => 'database', + 'firefly.eventsourcing.snapshot.threshold' => 3, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + // Dropped first: on a real server (the integration subclass) the tables of the last run remain. + EventSourcingSchema::drop(DB::connection()->getSchemaBuilder()); + EventSourcingSchema::create(DB::connection()->getSchemaBuilder()); + } +} diff --git a/packages/eventsourcing/tests/Support/EventSourcedOrchestrationIntegrationTestCase.php b/packages/eventsourcing/tests/Support/EventSourcedOrchestrationIntegrationTestCase.php new file mode 100644 index 00000000..4ec288e3 --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventSourcedOrchestrationIntegrationTestCase.php @@ -0,0 +1,13 @@ + */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.eventsourcing.store.provider' => 'memory']; + } +} From 9827e1f952975d4e403a9b19665e21c3da4332b3 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:56:25 -0700 Subject: [PATCH 28/33] test(eventsourcing): prove event-sourced orchestration runs across tenants and with none when tenancy is enabled --- ...eTenantedEventSourcedOrchestrationTest.php | 47 +++++++++++++++++++ .../Support/SwitchableTenantResolver.php | 18 +++++++ ...ntSourcedOrchestrationCapstoneTestCase.php | 32 +++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 packages/eventsourcing/tests/Capstone/CapstoneTenantedEventSourcedOrchestrationTest.php create mode 100644 packages/eventsourcing/tests/Support/SwitchableTenantResolver.php create mode 100644 packages/eventsourcing/tests/Support/TenantedEventSourcedOrchestrationCapstoneTestCase.php diff --git a/packages/eventsourcing/tests/Capstone/CapstoneTenantedEventSourcedOrchestrationTest.php b/packages/eventsourcing/tests/Capstone/CapstoneTenantedEventSourcedOrchestrationTest.php new file mode 100644 index 00000000..a882e1a2 --- /dev/null +++ b/packages/eventsourcing/tests/Capstone/CapstoneTenantedEventSourcedOrchestrationTest.php @@ -0,0 +1,47 @@ +app()->make(WorkflowEngine::class); + /** @var TenantScopedEventStore $store asserted below: the bean stays scoped */ + $store = $this->app()->make(EventStore::class); + + $this->tenants->tenant = 'acme'; + $started = $workflows->start('onboarding', ['email' => 'ada@example.com'], correlationId: 'onb-tenanted'); + + // Another tenant signals it: the execution is not hidden as globex's would be. + $this->tenants->tenant = 'globex'; + $workflows->signal('onb-tenanted', 'email-verified', ['token' => 't-1']); + usleep(5_000); + + // The timer sweep runs outside any request, with no tenant at all. + $this->tenants->tenant = null; + $workflows->resumeDue(); + + $events = $store->inner()->load('onb-tenanted')->events; + + expect($this->app()->make(EventStore::class))->toBeInstanceOf(TenantScopedEventStore::class) + ->and($this->app()->make(ExecutionPersistenceProvider::class))->toBeInstanceOf(EventSourcedExecutionPersistenceProvider::class) + ->and($started->status)->toBe(ExecutionStatus::Waiting) + ->and($workflows->find('onb-tenanted')?->status)->toBe(ExecutionStatus::Completed) + ->and($this->app()->make(Ledger::class)->lines)->toBe(['hook start onboarding', 'register ada@example.com', 'verified', 'cooled off']) + ->and($events)->not->toBeEmpty() + // Executions are the framework's records: never stamped with whichever tenant happened to start them. + ->and(array_values(array_unique(array_map(static fn ($event): ?string => $event->metadata->tenantId, $events))))->toBe([null]) + // The bean itself is still scoped: the unwrap is the orchestration beans' alone. + ->and(fn () => $store->load('onb-tenanted'))->toThrow(TenantRequiredException::class); +}); diff --git a/packages/eventsourcing/tests/Support/SwitchableTenantResolver.php b/packages/eventsourcing/tests/Support/SwitchableTenantResolver.php new file mode 100644 index 00000000..7903cfdf --- /dev/null +++ b/packages/eventsourcing/tests/Support/SwitchableTenantResolver.php @@ -0,0 +1,18 @@ +tenant; + } +} diff --git a/packages/eventsourcing/tests/Support/TenantedEventSourcedOrchestrationCapstoneTestCase.php b/packages/eventsourcing/tests/Support/TenantedEventSourcedOrchestrationCapstoneTestCase.php new file mode 100644 index 00000000..d447f7e2 --- /dev/null +++ b/packages/eventsourcing/tests/Support/TenantedEventSourcedOrchestrationCapstoneTestCase.php @@ -0,0 +1,32 @@ + */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.eventsourcing.tenancy.enabled' => true]; + } + + protected function defineFireflyEnvironment(Application $app): void + { + parent::defineFireflyEnvironment($app); + + $this->tenants = new SwitchableTenantResolver; + $app->instance(TenantResolver::class, $this->tenants); + } +} From 3865e76c9016aa1e8afae3bbe237ed6affaa7a58 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 10:14:58 -0700 Subject: [PATCH 29/33] docs(eventsourcing): document event sourcing, its database support and the event-sourced orchestration provider --- CHANGELOG.md | 16 ++ docs/cli.md | 3 + docs/database-support.md | 7 + docs/modules/eventsourcing.md | 451 ++++++++++++++++++++++++++++++++++ docs/modules/orchestration.md | 4 +- skeleton/config/firefly.php | 8 +- 6 files changed, 485 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a602355f..dfcf766d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y ### Added +- **Event sourcing (`firefly/eventsourcing`).** An `EventStore` port on `memory` or portable `database` tables + (`firefly.eventsourcing.store.provider`): appends under optimistic concurrency on + `UNIQUE(aggregate_id, aggregate_version)` (a 409 `ConcurrencyException` for the losing writer), stream loads by + version range, and one global stream numbered in COMMIT order, so a reader never skips an event that committed + late, proven with racing appenders on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite. Event envelopes carry + correlation, causation, tenant and user metadata; `#[EventType(name, version)]` events are compiled by + `firefly:cache`. Event-sourced aggregates and a snapshot-aware `EventSourcedRepository`; snapshots every + `snapshot.threshold` events with keep-count retention; `EventUpcaster` chains applied on read; projections with + fenced `memory`/`database` checkpoints, a per-batch `DistributedLock` lease, catch-up, live mode and rebuild, + `php artisan firefly:eventsourcing:projections{,:run,:rebuild}` and an `eventsourcing` health component with + projection lag; same-transaction publishing through the eda-database outbox (`publish.enabled`), otherwise after + commit; opt-in tenancy (`tenancy.enabled`). +- **`firefly.orchestration.persistence.provider=event-sourced`.** Executions as event-sourced aggregates with + snapshots and an inline, rebuildable read model on the event store's connection + (`firefly:eventsourcing:projections:rebuild orchestration.executions`), passing the orchestration persistence + contract on every server. - **CQRS query cache (`firefly.cqrs.query.cache.provider=cache`).** `CacheQueryCache` on any Laravel cache store: `#[CachedQuery(ttl:, group:)]` on a query class caches its results under a key derived from the query value object; `#[InvalidatesQuery]` on an event class evicts one entry or a whole group when the event commits in diff --git a/docs/cli.md b/docs/cli.md index 37a80890..fdda8a2f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -341,6 +341,9 @@ command is part of that capability rather than of the console. | `firefly:orchestration:dlq` | `firefly/orchestration` | Lists orchestration dead letters. `--name=`, `--limit=50`. | | `firefly:orchestration:dlq:retry` | `firefly/orchestration` | Retries one orchestration dead letter by id; the execution restarts from scratch. | | `firefly:orchestration:tick` | `firefly/orchestration` | Resumes due workflows (signals, timers) and runs the recovery sweep; `--no-recovery` only resumes. The same work runs as a `#[Scheduled]` task under the `DistributedLock` port. | +| `firefly:eventsourcing:projections` | `firefly/eventsourcing` | Lists every projection: its mode (`catch-up` or `inline`), its checkpoint, the global head, its lag and its last error. See [Event Sourcing](modules/eventsourcing.md#operating-projections). | +| `firefly:eventsourcing:projections:run` | `firefly/eventsourcing` | Catches one projection (`{projection}`) or every catch-up projection up with the global event stream and exits; `--live` keeps following it until SIGINT/SIGTERM or `--max-runtime=` seconds. Exit 1 when a projection failed. Each batch runs under the projection's `DistributedLock` lease. | +| `firefly:eventsourcing:projections:rebuild` | `firefly/eventsourcing` | Resets a projection and its checkpoint under its lease and replays the whole stream into it; also rebuilds the inline `orchestration.executions` read model. | | `firefly:flags` | `firefly/feature-flags` | Runs the `flags` management operations in-process: `list`, `show {key}`, `evaluate {key}` (`--context=`, `--targeting-key=`), and the writes `enable`, `disable`, `default-variant {key} {variant}`, `put {key} --file=`, `delete {key}` (each with `--expected-version=`). `--json` prints the management JSON, including `{error,message}` on a refusal. Writes need `firefly.feature-flags.management.writes` and a flag store; the actor is the principal, else `cli:`. An accepted write with pending local refresh exits successfully and asks the operator to check visibility again. | | `firefly:webhooks:prune` | `firefly/webhooks` | Deletes expired idempotency receipts from the database receipt store. Errors out when `firefly.webhooks.enabled` is false. See [Webhooks](modules/webhooks.md#idempotency). | | `firefly:callbacks:list` | `firefly/callbacks` | Lists callback subscriptions, or with `--executions` the most recent deliveries (`--status=pending\|delivered\|failed`, `--subscription=`, `--limit=20`). See [Callbacks](modules/callbacks.md#the-execution-log-and-the-commands). | diff --git a/docs/database-support.md b/docs/database-support.md index a3c79638..31cee2fa 100644 --- a/docs/database-support.md +++ b/docs/database-support.md @@ -21,6 +21,13 @@ on a temporary file. | `#[Lock(PESSIMISTIC_WRITE / READ)]` | ✓ `FOR UPDATE` / `FOR SHARE` | ✓ `FOR UPDATE` / `LOCK IN SHARE MODE` | ✓ | ✓ `UPDLOCK` / `HOLDLOCK` hints | — (compiles to a plain read) | | Optimistic locking (`HasOptimisticLock`) | ✓ | ✓ | ✓ | ✓ | ✓ | | Orchestration checkpoints, search attributes, recovery and dead letters (`firefly.orchestration.persistence.provider=database`) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Event store appends, optimistic concurrency on `UNIQUE(aggregate_id, aggregate_version)` (`firefly.eventsourcing.store.provider=database`) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Racing appenders: one aggregate (exactly one wins), many aggregates (all win, in version order) | ✓ proven (four processes) | ✓ proven | ✓ proven | ✓ proven (CI lane) | ✓ proven (one writer) | +| Commit-ordered global stream (a late committer is never skipped) | ✓ | ✓ READ COMMITTED numbering | ✓ READ COMMITTED numbering | ✓ READPAST numbering | ✓ | +| Snapshots and projection checkpoints (`firefly.eventsourcing.snapshot.store`, `projection.checkpoint-store`) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Racing projection runners, every event projected once (fenced checkpoint) | ✓ proven (two processes) | ✓ proven | ✓ proven | ✓ proven (CI lane) | ✓ proven | +| Same-transaction event publishing through the outbox (`firefly.eventsourcing.publish.enabled`) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Event-sourced orchestration persistence (`firefly.orchestration.persistence.provider=event-sourced`) | ✓ | ✓ | ✓ | ✓ | ✓ | | ASYNC workflows on the `database` queue | ✓ | ✓ | ✓ | ✓ | ✓ | How the outbox claim is chosen, and why every strategy is safe with any number of workers, is explained in diff --git a/docs/modules/eventsourcing.md b/docs/modules/eventsourcing.md index 20cf5959..6f9e0573 100644 --- a/docs/modules/eventsourcing.md +++ b/docs/modules/eventsourcing.md @@ -10,3 +10,454 @@ appended under optimistic concurrency and read back two ways: Snapshots shorten replays, upcasters move old event versions forward on read, and appended events can be published to `firefly/eda` in the same transaction through the database outbox. The package is shaped after the Java `fireflyframework-eventsourcing` library and PyFly's `pyfly.eventsourcing`. + +## Choosing a store + +`memory` (the default) keeps events in the process: right for tests and for trying the model out. `database` +keeps them in four portable tables on any Laravel connection, proven on PostgreSQL 16, MySQL 8.4, MariaDB 11, +SQL Server 2022 and SQLite (see the [database support matrix](../database-support.md)): + + +```php + 'store' => [ + 'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'), + 'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'), + 'batch-size' => 500, + ], +``` + +The snapshot store and the checkpoint store follow `store.provider` until you name them, so one variable moves +the whole package onto the database: + + +```dotenv +FIREFLY_EVENTSOURCING_STORE=database +``` + +The tables are `firefly_eventsourcing_events`, `firefly_eventsourcing_head`, `firefly_eventsourcing_snapshots` +and `firefly_eventsourcing_checkpoints`. The package loads its migration while any of its stores is `database`; +publish it to keep it with your own: + + +```bash +php artisan vendor:publish --tag=firefly-eventsourcing-migrations +php artisan migrate +``` + +## Events and the type registry + +An event is a `firefly/domain` `DomainEvent` with a stable type name and a version, given by `#[EventType]`. +`php artisan firefly:cache` compiles every `#[EventType]` class under `firefly.scan.paths` into the +`eventsourcing.php` manifest, so production maps events without reflection. An event class that is not in the +registry is refused at append: nothing is ever stored that cannot be read back. + +The convention mapper stores an event's public properties and rebuilds it with named constructor arguments, +coercing each value to the parameter's type (ints, floats, strings, bools, arrays, dates and backed enums). +Forward `eventId` and `occurredAt` to the parent so a reloaded event keeps its identity: + + +```php +#[EventType(name: 'order.placed', version: 2)] +final readonly class OrderPlaced extends DomainEvent +{ + /** + * @param list $skus + */ + public function __construct( + public string $orderId, + public float $total, + public OrderChannel $channel = OrderChannel::Web, + public ?DateTimeImmutable $promisedBy = null, + public array $skus = [], + ?string $eventId = null, + ?DateTimeImmutable $occurredAt = null, + ) { + parent::__construct($eventId, $occurredAt); + } +} +``` + +An event whose stored payload should use names of its own, or hold a value object the convention cannot build, +implements `SerializableEvent` (`toPayload()` and a static `fromPayload()`): + + +```php + /** @return array{order: string, amount: int} */ + public function toPayload(): array + { + return ['order' => $this->orderId, 'amount' => $this->cents]; + } +``` + +Every event is stored as a `firefly/eda` `EventEnvelope`, serialized by the bound eda `Serializer`, with its +metadata: the correlation id of the request (the `firefly.correlation_id` Laravel `Context` key the web +package's correlation filter writes), the causation id, the tenant, the authenticated user's id +(`metadata.capture-user`) and the time it occurred. Add headers of your own with an `EventMetadataContributor` +component. + +## Aggregates + +Extend `EventSourcedAggregateRoot`. A command method validates against the current state and calls +`recordThat()`; the event is applied at once and buffered until the repository saves it. Applying calls a +protected `apply()` method by convention (override `eventHandlers()` for other names): + + +```php + public static function place(string $id, float $total): self + { + $order = new self($id); + $order->recordThat(new OrderPlaced($id, $total)); + + return $order; + } + + public function pay(int $cents): void + { + if ($this->isPaid()) { + throw new LogicException('The order is already paid.'); + } + $this->recordThat(new OrderPaid($this->id(), $cents)); + } + // … + protected function applyOrderPlaced(OrderPlaced $event): void + { + $this->total = $event->total; + } + + protected function applyOrderPaid(OrderPaid $event): void + { + $this->paidCents += $event->cents; + } +``` + +Override `aggregateType()` with a stable name: it is stored with every event, and an id belongs to one +aggregate type only. + +## The repository + +A typed repository is a one-line constructor over the `EventSourcingSupport` bean: + + +```php +#[Component] +final class OrderRepository extends EventSourcedRepository +{ + public function __construct(EventSourcingSupport $support) + { + parent::__construct($support, Order::class); + } +} +``` + +`find()` returns null for an id with no events, `get()` throws a 404 `AggregateNotFoundException`, and +`save()` appends the buffered events under the version the aggregate was loaded at. A writer that loaded the +same version earlier has already moved the stream, so the second save gets a 409 `ConcurrencyException`: + + +```php +orders->get($orderId); + $order->pay($cents); + + try { + $this->orders->save($order); + } catch (ConcurrencyException $stale) { + // Another writer saved first: reload and decide again, or let the 409 reach the client. + throw $stale; + } + } +} +``` + +A save inside your own transaction commits or rolls back with it. The repository drains the aggregate's +buffer, so these events are never dispatched a second time by `firefly/data`'s after-commit wiring. + +## Snapshots + +An aggregate that implements `SnapshotAware` (`snapshotState()`, `restoreSnapshot()`, `snapshotVersion()`) is +snapshotted each time a save moves its version across a multiple of `snapshot.threshold` (50 by default; 0 +turns snapshots off), and the newest `snapshot.keep-count` (3) are kept. Loading starts from the newest snapshot +whose `snapshotVersion()` matches the class, so changing the snapshot shape is a version bump, never a migration. +A snapshot that cannot be written is logged and the save stands: snapshots are an optimization, the events are +the truth. + +## The global stream and its visibility guarantee + +Every committed event gets a global position, and `readAll($afterPosition)` returns events in position order. +The hard part is the order. A database sequence or identity hands out its value when a row is inserted, not +when its transaction commits, so a transaction holding position 10 can commit after the one holding 11. A +reader that read 11, checkpointed it, and only then saw 10 appear would skip 10 forever, and that happens on +every server LaraFly speaks. + +The store therefore numbers events in COMMIT order. An event is inserted without a position; it is on its +aggregate's stream at once, but not yet on the global stream. Before a global read, the reader numbers the +committed, unnumbered events in a short transaction of its own, serialized on the `global` row of +`firefly_eventsourcing_head`: it locks that row, gives the next positions to committed rows in insert order +(READ COMMITTED on MySQL and MariaDB, `READPAST` on SQL Server, so an open append is neither seen nor waited +for), and moves the head. A position is only ever given above every position given before, so **a reader +never sees an event appear below a position it has already read**. An aggregate's events keep their version +order on the global stream, and appends never wait on each other except on the same aggregate and version. + +Two consequences: + +- positions are contiguous today, but read them as gap-tolerant; +- a `readAll()` inside an open transaction does not number (the head lock is never held for your unit of work), + so it returns only what was numbered before. + +## Projections + +A projection is a read model kept from the global stream: `name()`, `handles()` (the event types it wants, +`[]` for all), `handle()` and `reset()`. Make it a component and the runner finds it: + + +```php + public function name(): string + { + return 'order.totals'; + } + + public function handles(): array + { + return ['order.placed', 'order.paid']; + } +``` + +Or register a closure-based `FunctionProjection` on the `ProjectionRegistry` bean: + + +```php +app->afterResolving(ProjectionRegistry::class, static function (ProjectionRegistry $projections): void { + $projections->register(new FunctionProjection( + 'orders.placed-count', + static function (StoredEvent $event): void { + Cache::increment('orders.placed'); + }, + ['order.placed'], + static function (): void { + Cache::forget('orders.placed'); + }, + )); + }); + } +} +``` + +The `ProjectionRunner` advances a projection one batch (`projection.batch-size` events) at a time: + +1. It takes the projection's lease on the `firefly/scheduling` `DistributedLock` + (`firefly.scheduling.lock.provider`), for `projection.lease-ttl`. Another node holding it means this node + leaves the projection alone. +2. It reads the checkpoint and the events after it. +3. In one transaction on the checkpoint store's connection it hands the projection the events it handles and + advances the checkpoint from the position it read. That advance is fenced: when a runner whose lease had + expired moved first, it fails and the whole batch rolls back. +4. It releases the lease. The lease is taken per batch, so keep a batch well under `lease-ttl`. + +A projection that writes on the checkpoint connection (the `store.connection`) is therefore exactly-once: its +rows and its checkpoint commit together. Anything else it touches is at-least-once, so make those handlers +idempotent. A projection that throws rolls its batch back; the error is recorded on its checkpoint and the next +cycle retries it. + + +```php + 'projection' => [ + 'checkpoint-store' => env('FIREFLY_EVENTSOURCING_CHECKPOINT_STORE'), + 'batch-size' => 100, + 'lease-ttl' => '30s', + 'poll-interval' => '1s', + 'max-lag' => 0, + ], +``` + +An `InlineProjection` is one its writer keeps current in the same transaction as the events (the orchestration +read model below is one); the runner only ever replays it in a rebuild. + +## Operating projections + + +```bash +php artisan firefly:eventsourcing:projections +php artisan firefly:eventsourcing:projections:run +php artisan firefly:eventsourcing:projections:run --live +php artisan firefly:eventsourcing:projections:rebuild order.totals +``` + +- `firefly:eventsourcing:projections` lists every projection with its mode (`catch-up` or `inline`), its + checkpoint, the global head, its lag and its last error. +- `firefly:eventsourcing:projections:run` catches one projection (or every catch-up projection) up and exits; + `--live` keeps following the stream, sleeping `poll-interval` when idle, until SIGINT/SIGTERM or + `--max-runtime` seconds. Run it under a process supervisor on as many nodes as you like: the lease lets one + node advance each projection at a time. +- `firefly:eventsourcing:projections:rebuild ` resets the projection and its checkpoint under its lease + and replays the whole stream into it. + +The `eventsourcing` health component (`health.enabled`) is DOWN when the event store does not answer, or when a +catch-up projection lags the head by more than `projection.max-lag` events (0 never trips it). Its details hold +the head and every projection's position, lag and state; a failed projection shows its error there, and its +growing lag is what trips the threshold. + +## Upcasting + +Events are immutable, so a changed event shape is a new version plus an `EventUpcaster` that moves the old +version forward ON READ, in the store, so repositories and projections agree. Upcasters chain by type and +version (`v1 → v2 → v3`); among several for the same type and version the highest `priority()` wins. Make one a +component: + + +```php + public function upcast(StoredEvent $event): StoredEvent + { + $payload = $event->payload; + $payload['total'] = $payload['amount'] ?? 0; + unset($payload['amount']); + + return $event->upcasted('order.placed', 2, $payload); + } +``` + +An event whose version, after upcasting, is still not the registered one is refused with the upcaster it is +missing. `CallbackEventUpcaster` builds one from a closure. + +## Publishing to firefly/eda + + +```php + 'publish' => [ + 'enabled' => env('FIREFLY_EVENTSOURCING_PUBLISH_ENABLED', false), + 'destination' => env('FIREFLY_EVENTSOURCING_PUBLISH_DESTINATION', 'eventsourcing.events'), + 'exclude-aggregate-types' => ['orchestration.execution'], + ], +``` + +With `publish.enabled`, every appended event is published on `publish.destination` with its metadata and its +`x-event-id`, `x-event-version`, `x-aggregate-id`, `x-aggregate-type`, `x-aggregate-version` and +`x-occurred-at` headers: + +- under `firefly.eda.provider=database` or `postgres` the outbox row is written IN the append's transaction, so + the event and its publication commit or roll back together. The event store and the outbox must share a + connection, or the boot is refused naming both keys; +- under any other provider the events are published after the append commits, and a rolled-back append + publishes nothing. + +Either way an event is published exactly once by this package. Aggregate types in +`publish.exclude-aggregate-types` (the orchestration executions by default) are never published. + +## Tenancy + +With `tenancy.enabled`, every appended event is stamped with the tenant your `TenantResolver` returns, an +append or load without a current tenant is refused, and loading another tenant's stream is refused as if it +did not exist. Global reads take a tenant filter; projections read every tenant by design. Aggregate ids must +be unique across tenants (use UUIDs). Bind your own resolver: + + +```php +header('X-Tenant-Id'); + + return is_string($tenant) && $tenant !== '' ? $tenant : null; + } +} +``` + +## Event-sourced orchestration persistence + +`firefly.orchestration.persistence.provider=event-sourced` stores every saga, TCC and workflow execution as an +event-sourced aggregate (`orchestration.execution`, its id the correlation id) in this package's event store, +snapshotted every `snapshot.threshold` events: + + +```dotenv +FIREFLY_EVENTSOURCING_STORE=database +FIREFLY_ORCHESTRATION_PERSISTENCE=event-sourced +``` + +Each checkpoint becomes events: `started` with the whole state first, then `status-changed`, +`step-status-changed`, `step-result-recorded` and `search-attributes-changed` for what a step changed, and one +`checkpointed` for anything else, so a replay gives back exactly the state that was saved. `delete()` and the +retention cleanup append a `deleted` tombstone; events are never purged. + +The queries the engines run (in-flight, stale, due and search-attribute lookups, counts) read a read model: +`firefly/orchestration`'s own tables on the event store's connection, written in the same transaction as the +events (the memory store keeps an in-memory read model). Orchestration's `persistence.connection` is not read. +The read model is the inline projection `orchestration.executions`; rebuild it from the events with +`php artisan firefly:eventsourcing:projections:rebuild orchestration.executions`. Its migration is loaded while +the provider is `event-sourced` over the database store; publish it with +`--tag=firefly-eventsourcing-orchestration-migrations`. The provider passes the same contract as `memory`, +`cache` and `database` on every server, and runs the orchestration capstones end to end. + +## Configuration + +| Key (`firefly.eventsourcing.`) | Default | What it does | +|---|---|---| +| `store.provider` | `memory` | `memory` or `database` (`FIREFLY_EVENTSOURCING_STORE`) | +| `store.connection` | `null` | The connection for every eventsourcing table (`FIREFLY_EVENTSOURCING_DB_CONNECTION`) | +| `store.batch-size` | `500` | Events numbered per round, and the default page of a global read | +| `snapshot.enabled` | `true` | Snapshots for `SnapshotAware` aggregates | +| `snapshot.store` | `null` | `memory` or `database`; null follows `store.provider` | +| `snapshot.threshold` | `50` | A snapshot each time the version crosses a multiple; `0` never | +| `snapshot.keep-count` | `3` | Snapshots kept per aggregate | +| `projection.checkpoint-store` | `null` | `memory` or `database`; null follows `store.provider` | +| `projection.batch-size` | `100` | Events per projection batch (one transaction) | +| `projection.lease-ttl` | `30s` | The per-batch lease on the `DistributedLock` | +| `projection.poll-interval` | `1s` | The idle sleep in live mode | +| `projection.max-lag` | `0` | The lag that turns the health component DOWN; `0` never | +| `publish.enabled` | `false` | Publish appended events to `firefly/eda` | +| `publish.destination` | `eventsourcing.events` | The eda destination | +| `publish.exclude-aggregate-types` | `['orchestration.execution']` | Aggregate types never published | +| `tenancy.enabled` | `false` | Stamp and check the `TenantResolver`'s tenant | +| `metadata.capture-user` | `true` | Stamp the authenticated user's id | +| `health.enabled` | `true` | The `eventsourcing` health component | + +A value the package cannot honour (an unknown provider, a negative number, an unparseable duration, a lease +under one second, the event store and the outbox on two connections) refuses the boot, naming its key. + +## Limits + +- Events are immutable and never purged; a deleted aggregate is a tombstone in its stream. +- An aggregate id belongs to one aggregate type, and with tenancy on, to one tenant. +- SQLite has one writer per database file: correct under concurrency, but appends from many processes queue. +- The lease is per batch: the `DistributedLock` port has no extend, so `lease-ttl` must exceed one batch. + Fencing on the checkpoint covers a lease that expired anyway. +- There is no Mongo or Kafka event store. diff --git a/docs/modules/orchestration.md b/docs/modules/orchestration.md index 75d9c20e..e5e279ea 100644 --- a/docs/modules/orchestration.md +++ b/docs/modules/orchestration.md @@ -336,9 +336,11 @@ file, one CI integration lane each. | `memory` (default) | Tests and single-request flows. Nothing survives the process. | | `cache` | Any Laravel cache store (`persistence.cache-store`), keys under `persistence.key-prefix`. | | `database` | The `firefly_orchestration_*` tables on `persistence.connection`. Proven on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite by the CI integration lanes (see the [database support matrix](../database-support.md)). | +| `event-sourced` | Supplied by `firefly/eventsourcing`: every execution is an event-sourced aggregate in the `firefly.eventsourcing.store.*` event store, snapshotted every `firefly.eventsourcing.snapshot.threshold` events, with the tables above as its read model on the event store's connection (`persistence.connection` is not read). See [Event Sourcing](eventsourcing.md#event-sourced-orchestration-persistence). | Publish the migration with `php artisan vendor:publish --tag=firefly-orchestration-migrations`; the package also -loads it while `persistence.provider` or `dlq.store` is `database`. +loads it while `persistence.provider` or `dlq.store` is `database`. Under `event-sourced`, the read-model migration +is `--tag=firefly-eventsourcing-orchestration-migrations`. Writers serialize per execution through the `firefly/scheduling` `DistributedLock` (`firefly.orchestration.lock.*`). With `firefly.orchestration.recovery.enabled`, a scheduled sweep finds executions not updated for diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 81003ba9..ca1fc5f6 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -2963,10 +2963,12 @@ /* | Where execution checkpoints live: `memory` (this process only — the default), `cache` (the Laravel - | cache store `cache-store` names, null = the default store; `key-prefix`, optional `key-ttl`) or + | cache store `cache-store` names, null = the default store; `key-prefix`, optional `key-ttl`), | `database` (three portable tables on `connection`, null = the default; run the migration, - | --tag=firefly-orchestration-migrations to publish it). Terminal executions older than - | `retention-period` are deleted by the recovery sweep, which runs every `cleanup-interval`. + | --tag=firefly-orchestration-migrations to publish it) or `event-sourced` (firefly/eventsourcing: each + | execution an event-sourced aggregate in the firefly.eventsourcing.store event store, these tables its + | read model on that store's connection). Terminal executions older than `retention-period` are + | deleted by the recovery sweep, which runs every `cleanup-interval`. | Defaults: memory, orchestration:, forever, P7D, 1h. */ 'persistence' => [ From 5ef593ca07abbe108934e54ad9915137883dd8e5 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 10:21:58 -0700 Subject: [PATCH 30/33] docs(eventsourcing): state the at-most-once guarantee of after-commit publishing --- CHANGELOG.md | 2 +- docs/modules/eventsourcing.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcf766d..8163c988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y fenced `memory`/`database` checkpoints, a per-batch `DistributedLock` lease, catch-up, live mode and rebuild, `php artisan firefly:eventsourcing:projections{,:run,:rebuild}` and an `eventsourcing` health component with projection lag; same-transaction publishing through the eda-database outbox (`publish.enabled`), otherwise after - commit; opt-in tenancy (`tenancy.enabled`). + commit, at most once; opt-in tenancy (`tenancy.enabled`). - **`firefly.orchestration.persistence.provider=event-sourced`.** Executions as event-sourced aggregates with snapshots and an inline, rebuildable read model on the event store's connection (`firefly:eventsourcing:projections:rebuild orchestration.executions`), passing the orchestration persistence diff --git a/docs/modules/eventsourcing.md b/docs/modules/eventsourcing.md index 6f9e0573..592578e9 100644 --- a/docs/modules/eventsourcing.md +++ b/docs/modules/eventsourcing.md @@ -368,7 +368,13 @@ With `publish.enabled`, every appended event is published on `publish.destinatio - under any other provider the events are published after the append commits, and a rolled-back append publishes nothing. -Either way an event is published exactly once by this package. Aggregate types in +The two paths give different guarantees. Under the outbox providers the event and its outbox row commit +together, so the outbox's own delivery guarantee applies: at-least-once across a crash, so make listeners +idempotent (see [eda-database](eda-database.md)). Under any other provider publishing is at-most-once: the +package never publishes an event twice, but nothing retries a publish, so a crash between the commit and the +publish, or a broker failure partway through publishing an append's events, loses the events not yet +published while the append itself stays committed (the caller sees the broker's exception for an append that +did commit). When every event must reach the broker, use the outbox. Aggregate types in `publish.exclude-aggregate-types` (the orchestration executions by default) are never published. ## Tenancy From a5d9e76f67bc27598c6f03ad0898a33a2a70d95d Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 10:44:10 -0700 Subject: [PATCH 31/33] docs(eventsourcing): stop calling event sourcing future work in the README roadmap --- CHANGELOG.md | 2 ++ README.md | 8 +++--- docs/modules/cqrs.md | 6 ++--- tests/DocsProseIsRealTest.php | 49 +++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8163c988..338d08da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,8 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y - **`RabbitMqRoundTripTest` failed against a real broker when `FIREFLY_RABBITMQ_DSN` was a URL**, which the old parser read as one key and silently replaced with `127.0.0.1:5672`. - **The lumen sample's Postgres round trip called `firefly:eda:consume` without registering it.** +- **README's roadmap still called event sourcing future work.** The bullet and `docs/modules/cqrs.md`'s + known-latent list now point at `firefly/eventsourcing`; a docs test refuses the claim while the package ships. - **The CQRS/EDA bridge figure still credited only the Postgres adapter with same-transaction atomicity.** Its caption now names the database outbox (`provider=database` or `postgres`), and its adapter row draws the `database` provider; a test derives both providers from their outbox auto-configurations. diff --git a/README.md b/README.md index 40094bec..cf525116 100644 --- a/README.md +++ b/README.md @@ -1167,9 +1167,11 @@ accurately: credentials, refresh rotation, introspection, revocation, userinfo and RP-initiated logout). IdP adapters beyond those presets (Cognito, internal-db) and MFA are deferred to their own future packages, matching the Java Firefly Framework's module topology. -- **Read models / projections as a first-class concept.** The sample's `LedgerProjector` shows the pattern - today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event - sourcing/snapshots/projections is future work, as it is in PyFly. +- **Event sourcing in the tutorial and the book.** Event stores, aggregates, snapshots, upcasters and + checkpointed, rebuildable projections ship in [`firefly/eventsourcing`](docs/modules/eventsourcing.md), + shaped after the Java library and PyFly's `pyfly.eventsourcing`. What remains is the walkthrough: the + sample's `LedgerProjector` still builds its read model from a plain `#[EventListener]`, and neither the + tutorial nor the book has a chapter on the package. - **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *larafly by example* [book](book/README.md) (16 chapters + appendices, EN + ES, PDF + EPUB), and a [docs table of contents](docs/README.md) all shipped with the documentation-parity milestone. diff --git a/docs/modules/cqrs.md b/docs/modules/cqrs.md index e6364c80..ecd0b77d 100644 --- a/docs/modules/cqrs.md +++ b/docs/modules/cqrs.md @@ -203,9 +203,9 @@ The original keys are snake_case and stay so; the query-cache keys are kebab-cas ## Known-latent -Read-model / projection scaffolding (→ `firefly/eventsourcing`), real authorization (→ M11), CQRS metrics + health -(→ M12), attribute-driven command validation, the fluent builder / distributed-tracing ergonomics are deferred and -documented honestly. The `HandlerManifest` needs no hand-wiring: like every other +Read-model / projection scaffolding ships in [`firefly/eventsourcing`](eventsourcing.md#projections). Real +authorization (→ M11), CQRS metrics + health (→ M12), attribute-driven command validation, the fluent builder / +distributed-tracing ergonomics are deferred and documented honestly. The `HandlerManifest` needs no hand-wiring: like every other compiled manifest it resolves to the `firefly:cache` artifact if present, otherwise an in-process scan of `firefly.scan.paths`, otherwise empty — so an uncached app registers the same handlers a cached one does. A handler outside `firefly.scan.paths` still never registers. Under Octane, `CorrelationContext` is diff --git a/tests/DocsProseIsRealTest.php b/tests/DocsProseIsRealTest.php index d9985fb7..73f0b1dc 100644 --- a/tests/DocsProseIsRealTest.php +++ b/tests/DocsProseIsRealTest.php @@ -3674,3 +3674,52 @@ function fireflyCurlTranscriptProse(string $page, array $blocks): array ->and(array_values(array_diff($prose, $listing)))->toBe([], 'this file walks a page the listing guard does not audit, which makes the guide\'s account of the two surfaces wrong in the other direction') ->and($unreached)->not->toBe([], 'the prose guard now walks everything the listing guard does, so the paragraph should say that instead of naming exceptions'); }); + +it('refuses any page that still calls event sourcing future work while firefly/eventsourcing ships', function () { + // The twenty-ninth. W3 shipped `packages/eventsourcing` — event store, aggregates, snapshots, upcasters, + // projections and outbox publishing — and README.md's module table learned to list it, while the same + // README's roadmap, under "What's still ahead, accurately", went on saying that a dedicated + // `firefly/eventsourcing`-style package for event sourcing/snapshots/projections "is future work". One page, + // two answers to the same question, and the wrong one sitting under a heading that promises accuracy. + // + // DERIVED FROM THE TREE. The rule holds only while `packages/eventsourcing/composer.json` exists, so the day + // the package moves or is withdrawn the sentence that says it is ahead becomes allowed again by itself. It + // reads sentences one at a time — a roadmap is one Markdown paragraph of bullets, and docs/modules/cqrs.md's + // "Known-latent" paragraph listed projection scaffolding (→ `firefly/eventsourcing`) in the same sentence as + // the things that really are deferred, so the "future work" of a neighbour must not condemn a sentence that + // only points at the shipped package — and judges any sentence that names event sourcing or the package and + // also calls it future, deferred or not yet shipped, in either language the project publishes in. + $root = dirname(__DIR__); + $ships = is_file($root.'/packages/eventsourcing/composer.json'); + + $subject = '/firefly\/eventsourcing|event[\s-]+sourc|event[\s-]+store|abastecimiento de eventos/i'; + $future = '/future work|is deferred|are deferred|not yet (?:shipped|available)|still ahead|trabajo futuro|queda pendiente|todav[ií]a no/i'; + + $judged = 0; + $failures = []; + + foreach (fireflyProsePages() as $page => $paragraphs) { + foreach ($paragraphs as $paragraph) { + $flatParagraph = (string) preg_replace('/\s+/', ' ', trim($paragraph)); + // A sentence ends at terminal punctuation followed by the start of the next one, and a list item + // ends where the next bullet begins (the paragraph is flattened, so that is " - " or " * "). + $sentences = preg_split('/(?<=[.!?])\s+(?=[A-Z`*\[(¿¡])|\s(?=[-*] \*\*)/u', $flatParagraph); + + foreach ($sentences === false ? [] : $sentences as $flat) { + + if (preg_match($subject, $flat) !== 1) { + continue; + } + + $judged++; + + if ($ships && preg_match($future, $flat) === 1) { + $failures[] = $page.': '.mb_substr($flat, 0, 160); + } + } + } + } + + expect($failures)->toBe([]) + ->and($judged)->toBeGreaterThan(0, 'no page names event sourcing any more, so this canary holds nothing'); +}); From 43ee4fd6a67eac649787a7ae61946494f3657027 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 10:45:45 -0700 Subject: [PATCH 32/33] docs(cqrs): link the shipped event-sourcing projections instead of a future package --- CHANGELOG.md | 2 ++ docs/modules/cqrs.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 338d08da..72a43fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,8 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y `provider=postgres` reads the `firefly.eda.database.*` keys, with every `firefly.eda.postgres.*` key kept as an alias. The outbox table gains `claimed_by` / `claimed_until` (run `php artisan migrate`). - **Transaction timeouts on SQL Server** set the session's `LOCK_TIMEOUT` and restore it afterwards. +- **The CQRS module doc points at the shipped projections.** `docs/modules/cqrs.md` no longer calls read models + a future package; it links the `firefly/eventsourcing` projections section. ### Fixed diff --git a/docs/modules/cqrs.md b/docs/modules/cqrs.md index ecd0b77d..e3ba7b4b 100644 --- a/docs/modules/cqrs.md +++ b/docs/modules/cqrs.md @@ -2,8 +2,9 @@ `firefly/cqrs` adds the command/query **dispatch** surface — a synchronous, in-process mediator — on top of the shipped seams. It writes no interception code: a `#[Transactional]` command handler gets -transaction semantics for free from the firefly/data proxy. Read models / projections are a future -`firefly/eventsourcing` package. +transaction semantics for free from the firefly/data proxy. Read models / projections ship in +`firefly/eventsourcing`: see [Projections](eventsourcing.md#projections) for `FunctionProjection`, the +`ProjectionRunner` and the `firefly:eventsourcing:projections` commands. ![CQRS / EDA bridge](../assets/diagrams/cqrs-eda-bridge.svg) From 8266f988e3e78b1dc9aaacd47523a54c34a7dc33 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 10:47:20 -0700 Subject: [PATCH 33/33] docs(cqrs): point the package README at the shipped eventsourcing projections --- CHANGELOG.md | 3 +++ packages/cqrs/README.md | 3 ++- tests/DocsProseIsRealTest.php | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a43fd4..8c946cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,9 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y - **Transaction timeouts on SQL Server** set the session's `LOCK_TIMEOUT` and restore it afterwards. - **The CQRS module doc points at the shipped projections.** `docs/modules/cqrs.md` no longer calls read models a future package; it links the `firefly/eventsourcing` projections section. +- **The cqrs package README points at the shipped projections.** `packages/cqrs/README.md` no longer calls read + models a future package; it links the `firefly/eventsourcing` projections section, and the event-sourcing prose + guard now reads package READMEs too. ### Fixed diff --git a/packages/cqrs/README.md b/packages/cqrs/README.md index 36e5ce61..357041bf 100644 --- a/packages/cqrs/README.md +++ b/packages/cqrs/README.md @@ -9,6 +9,7 @@ query cache), and the domain->integration-event bridge that re-emits every commi transaction semantics for free from the firefly/data proxy — cqrs writes no interception code. With `firefly.cqrs.query.cache.provider=cache` the query cache is real — `#[CachedQuery]` results on the Laravel cache, evicted by `#[InvalidatesQuery]` events committed in process or arriving over the bus. -Read models / projections are a future `firefly/eventsourcing` package. +Read models and projections ship in `firefly/eventsourcing`: see +[Event Sourcing — Projections](../../docs/modules/eventsourcing.md#projections). Apache-2.0 © Firefly Software Solutions Inc. diff --git a/tests/DocsProseIsRealTest.php b/tests/DocsProseIsRealTest.php index 73f0b1dc..2c43eb64 100644 --- a/tests/DocsProseIsRealTest.php +++ b/tests/DocsProseIsRealTest.php @@ -3689,16 +3689,26 @@ function fireflyCurlTranscriptProse(string $page, array $blocks): array // the things that really are deferred, so the "future work" of a neighbour must not condemn a sentence that // only points at the shipped package — and judges any sentence that names event sourcing or the package and // also calls it future, deferred or not yet shipped, in either language the project publishes in. + // + // The package READMEs are judged too: packages/cqrs/README.md closed its summary with "Read models / + // projections are a future `firefly/eventsourcing` package." — the same false claim on the page a reader + // of the cqrs package opens first, phrased as "a future ... package" rather than "future work". $root = dirname(__DIR__); $ships = is_file($root.'/packages/eventsourcing/composer.json'); $subject = '/firefly\/eventsourcing|event[\s-]+sourc|event[\s-]+store|abastecimiento de eventos/i'; - $future = '/future work|is deferred|are deferred|not yet (?:shipped|available)|still ahead|trabajo futuro|queda pendiente|todav[ií]a no/i'; + $future = '/future work|\bfuture\b[^.]{0,40}\bpackage\b|is deferred|are deferred|not yet (?:shipped|available)|still ahead|trabajo futuro|queda pendiente|todav[ií]a no/i'; + + $pages = fireflyProsePages(); + foreach (glob($root.'/packages/*/README.md') ?: [] as $readme) { + $paragraphs = preg_split('/\n\s*\n/', (string) file_get_contents($readme)); + $pages[substr($readme, strlen($root) + 1)] = $paragraphs === false ? [] : $paragraphs; + } $judged = 0; $failures = []; - foreach (fireflyProsePages() as $page => $paragraphs) { + foreach ($pages as $page => $paragraphs) { foreach ($paragraphs as $paragraph) { $flatParagraph = (string) preg_replace('/\s+/', ' ', trim($paragraph)); // A sentence ends at terminal punctuation followed by the start of the next one, and a list item