diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc06a0d..e11f1fc0 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, 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 + 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 @@ -109,6 +125,11 @@ 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. +- **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 @@ -122,6 +143,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 28d8948e..4fed842d 100644 --- a/README.md +++ b/README.md @@ -1080,7 +1080,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) | @@ -1108,6 +1108,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` | @@ -1174,9 +1175,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, through a database outbox and a saga), the *larafly by example* [book](book/README.md) (18 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/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 69dbd678..c9243032 100644 --- a/book/src-es/07-cqrs.md +++ b/book/src-es/07-cqrs.md @@ -555,11 +555,11 @@ Los grupos se expulsan por **generaciones**, no por etiquetas de caché, así qu `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. @@ -579,7 +579,7 @@ Equivócate en esto en una aplicación — añade un manejador, olvida volver a | `#[CachedQuery]` / `#[InvalidatesQuery]` | Guarda la respuesta de una consulta por valor en un almacén de caché de Laravel (`firefly.cqrs.query.cache.provider=cache`); el evento que la deja obsoleta expulsa una entrada (`with:`) o un grupo tras confirmarse | | `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 40ab5a02..99ac5890 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 69078dca..27a95005 100644 --- a/book/src/07-cqrs.md +++ b/book/src/07-cqrs.md @@ -555,11 +555,11 @@ Groups are evicted by **generations**, not cache tags, so eviction works on ever `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. @@ -579,7 +579,7 @@ Get this wrong in an application — add a handler, forget to re-run `firefly:ca | `#[CachedQuery]` / `#[InvalidatesQuery]` | Caches a query's answer by value on a Laravel store (`firefly.cqrs.query.cache.provider=cache`); the event that makes it stale evicts one entry (`with:`) or a group after it commits | | `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 1d98c938..7050408a 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/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..c3b83647 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 @@ -659,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/ @@ -674,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 @@ -695,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/README.md b/docs/README.md index 56862136..9b70010f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,11 +28,11 @@ ## 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 -thirty-ninth guide turns this page red rather than quietly making it wrong. +fortieth guide turns this page red rather than quietly making it wrong. ### Foundation @@ -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,8 +129,8 @@ 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 | -| [CLI Reference](cli.md) | Every `firefly:*` command and `make:firefly-*` generator, including the seventeen contributed by capability packages | +| [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 twenty 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 | | [Versioning](versioning.md) | CalVer (`YY.MM.Patch`), no `version` field, how Packagist derives releases from tags | diff --git a/docs/book.md b/docs/book.md index be38284a..500670b5 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/cli.md b/docs/cli.md index 182fb8f3..62cf304e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,11 +8,14 @@ framework stereotype (`make:firefly-*`), and thinly delegates to Laravel's own ` commands (`firefly:serve`/`firefly:schedule`/`firefly:db`). Its own Deptrac `Cli` layer — depends on the rest of the framework, depended on by nothing. -Seven more `firefly:*` commands ship with the capability they belong to rather than with the console: +Twenty more `firefly:*` commands ship with the capability they belong to rather than with the console: `firefly:management:serve` (`firefly/actuator`), `firefly:openapi` (`firefly/openapi`), `firefly:eda:consume` and `firefly:eda:dead-letters:replay` (`firefly/eda`), `firefly:outbox:relay` (`firefly/eda-database`), -`firefly:messaging:dead-letters:replay` (`firefly/messaging`) and `firefly:flags` (`firefly/feature-flags`). They -are all listed at the end of this page. +`firefly:messaging:consume` and `firefly:messaging:dead-letters:replay` (`firefly/messaging`), six +`firefly:orchestration:*` commands (`firefly/orchestration`), three `firefly:eventsourcing:projections*` commands +(`firefly/eventsourcing`), `firefly:flags` (`firefly/feature-flags`), `firefly:webhooks:prune` (`firefly/webhooks`) +and `firefly:callbacks:list` and `firefly:callbacks:replay` (`firefly/callbacks`). They are all listed at the end of +this page. ## `firefly:cache` @@ -21,10 +24,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 +42,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 +116,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 ``` @@ -340,6 +344,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/contributing.md b/docs/contributing.md index a49dc5e1..81dfbbd9 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -245,7 +245,7 @@ required to land on a document that exists; `navigation.indexes` is required to section really has an `index.md`/`README.md` for it to bind, because the feature is inert otherwise and the first explanation of the tab row was wrong in precisely that way; the package and guide counts quoted in prose — on `README.md`, `docs/README.md`, `docs/index.md` and `docs/modules.md` — are compared against -`glob()` over `packages/*` and `docs/modules/*.md` rather than proof-read, so a thirty-ninth guide fails the +`glob()` over `packages/*` and `docs/modules/*.md` rather than proof-read, so a fortieth guide fails the build instead of turning four sentences into lies no diff touched; and the two wiring idioms `docs/modules.md` names are held to the split the packages really have, exemplar files included. 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/index.md b/docs/index.md index 0794f033..4d55dc53 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. @@ -106,7 +106,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. @@ -116,7 +116,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..1926ffe3 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -1,12 +1,12 @@ # 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. **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 12** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards @@ -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/cqrs.md b/docs/modules/cqrs.md index e6364c80..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) @@ -203,9 +204,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/docs/modules/eventsourcing.md b/docs/modules/eventsourcing.md new file mode 100644 index 00000000..592578e9 --- /dev/null +++ b/docs/modules/eventsourcing.md @@ -0,0 +1,469 @@ +# 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`. + +## 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. + +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 + +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/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/docs/tutorial.es.md b/docs/tutorial.es.md index 1582809b..3efb0c01 100644 --- a/docs/tutorial.es.md +++ b/docs/tutorial.es.md @@ -1097,10 +1097,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 @@ -1108,7 +1108,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 2497f2d4..6edc3fa4 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1077,17 +1077,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/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/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/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/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/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/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..6d424e1e --- /dev/null +++ b/packages/eventsourcing/cache/firefly-eventsourcing-components.php @@ -0,0 +1,275 @@ + [ + '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, + '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', + ], + ], + 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', + 1 => 'Illuminate\\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' => [ + ], + ], + 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 new file mode 100644 index 00000000..562e6445 --- /dev/null +++ b/packages/eventsourcing/cache/firefly-eventsourcing-context.php @@ -0,0 +1,218 @@ + [ + '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', + ], + ], + ], + ], + 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', + ], + ], + ], + ], + ], + ], + 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/composer.json b/packages/eventsourcing/composer.json new file mode 100644 index 00000000..949cd6de --- /dev/null +++ b/packages/eventsourcing/composer.json @@ -0,0 +1,73 @@ +{ + "name": "firefly/eventsourcing", + "description": "LaraFly event sourcing: an EventStore port (memory, database) with optimistic concurrency on UNIQUE(aggregate_id, aggregate_version) and a commit-ordered global stream safe under concurrent appenders on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite; event-sourced aggregates and a snapshot-aware repository; snapshot stores; upcasters; projections with checkpoints and a DistributedLock lease; same-transaction publishing through the eda-database outbox; and an event-sourced orchestration execution store.", + "type": "library", + "license": "Apache-2.0", + "homepage": "https://github.com/fireflyframework/fireflyframework-php", + "authors": [ + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "event-sourcing", + "cqrs", + "projections", + "ddd" + ], + "support": { + "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", + "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/eventsourcing" + }, + "require": { + "php": "^8.3", + "firefly/actuator": "*@dev", + "firefly/autoconfigure": "*@dev", + "firefly/config": "*@dev", + "firefly/container": "*@dev", + "firefly/context": "*@dev", + "firefly/data": "*@dev", + "firefly/domain": "*@dev", + "firefly/eda": "*@dev", + "firefly/eda-database": "*@dev", + "firefly/kernel": "*@dev", + "firefly/orchestration": "*@dev", + "firefly/resilience": "*@dev", + "firefly/scheduling": "*@dev", + "illuminate/console": "^13.0", + "illuminate/container": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", + "illuminate/log": "^13.0", + "illuminate/support": "^13.0", + "psr/log": "^3.0" + }, + "extra": { + "laravel": { + "providers": [ + "Firefly\\EventSourcing\\EventSourcingServiceProvider", + "Firefly\\EventSourcing\\EventSourcingWiringProvider" + ] + }, + "branch-alias": { + "dev-main": "26.x-dev" + } + }, + "autoload": { + "psr-4": { + "Firefly\\EventSourcing\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Firefly\\EventSourcing\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "config": { + "sort-packages": true + } +} 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/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/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/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/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/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/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/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 @@ +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/EventMapper.php b/packages/eventsourcing/src/Event/EventMapper.php new file mode 100644 index 00000000..f700231f --- /dev/null +++ b/packages/eventsourcing/src/Event/EventMapper.php @@ -0,0 +1,168 @@ + 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 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 +{ + 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); + } + } + + /** + * Whether coerce() can rebuild a constructor parameter of this compiled type from its stored JSON value: no + * 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 + { + 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; + 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_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."), + }; + } + + /** @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."); + } + + /** + * 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/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/EventParameter.php b/packages/eventsourcing/src/Event/EventParameter.php new file mode 100644 index 00000000..9a777435 --- /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/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/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/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/EventSourcingAutoConfiguration.php b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php new file mode 100644 index 00000000..c021fd46 --- /dev/null +++ b/packages/eventsourcing/src/EventSourcingAutoConfiguration.php @@ -0,0 +1,270 @@ +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); + } + + #[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, Container $app): ProjectionRegistry + { + $registry = new ProjectionRegistry; + foreach ($beans->getAll(Projection::class) as $projection) { + if ($projection instanceof Projection) { + $registry->register($projection); + } + } + + // 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; + } + + #[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 + { + $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/EventSourcingServiceProvider.php b/packages/eventsourcing/src/EventSourcingServiceProvider.php new file mode 100644 index 00000000..94a57c28 --- /dev/null +++ b/packages/eventsourcing/src/EventSourcingServiceProvider.php @@ -0,0 +1,26 @@ + */ + 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/src/EventSourcingWiringProvider.php b/packages/eventsourcing/src/EventSourcingWiringProvider.php new file mode 100644 index 00000000..b5f4996c --- /dev/null +++ b/packages/eventsourcing/src/EventSourcingWiringProvider.php @@ -0,0 +1,72 @@ +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(); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ListProjectionsCommand::class, RunProjectionsCommand::class, RebuildProjectionCommand::class]); + } + + $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'); + + $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/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 + */ + 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/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/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/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/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/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/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/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/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..63f77de7 --- /dev/null +++ b/packages/eventsourcing/src/Projection/CheckpointStore.php @@ -0,0 +1,52 @@ + 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`. + * + * @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; + + /** 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..442cb5e2 --- /dev/null +++ b/packages/eventsourcing/src/Projection/DatabaseCheckpointStore.php @@ -0,0 +1,184 @@ +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]; + + 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; + } catch (Throwable $failure) { + if ($this->changedSinceRead($failure)) { + throw CheckpointConflictException::of($projection, $from, $failure); + } + + throw $failure; + } + } + + 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) { + if ($this->changedSinceRead($failure)) { + throw $failure; + } + $translated = $this->translator->translate($failure, (string) $this->connection->getDriverName()); + if ($translated instanceof DuplicateKeyException) { + return false; + } + + throw $translated; + } + } + + /** + * 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 + { + $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/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/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/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 @@ + $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/src/Scanner/EventSourcingScanner.php b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php new file mode 100644 index 00000000..adb24ad3 --- /dev/null +++ b/packages/eventsourcing/src/Scanner/EventSourcingScanner.php @@ -0,0 +1,199 @@ + $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) { + $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, $composite); + } + + /** @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); + } + + /** + * 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.'); + } + + /** + * 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, array $composite): void + { + $unbuildable = $composite; + 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 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; + } + + /** + * @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/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 @@ +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..9f338304 --- /dev/null +++ b/packages/eventsourcing/src/Store/DatabaseEventStore.php @@ -0,0 +1,306 @@ + */ + 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 { + $this->lockSqliteForWrite(); + $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; + } + } + + /** + * 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 + { + $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/EventFilter.php b/packages/eventsourcing/src/Store/EventFilter.php new file mode 100644 index 00000000..fa8e8855 --- /dev/null +++ b/packages/eventsourcing/src/Store/EventFilter.php @@ -0,0 +1,54 @@ + $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/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/src/Store/EventStore.php b/packages/eventsourcing/src/Store/EventStore.php new file mode 100644 index 00000000..2018c014 --- /dev/null +++ b/packages/eventsourcing/src/Store/EventStore.php @@ -0,0 +1,50 @@ + $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; + + /** + * 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). */ + 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 @@ +> */ + 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->upcasters->apply($this->decode($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->decode($this->streams[$aggregateId][$index]); + if ($filter === null || $filter->matches($event)) { + $events[] = $this->upcasters->apply($event); + } + } + + return $events; + } + + public function currentPosition(): int + { + return count($this->global); + } + + public function isHealthy(): bool + { + return true; + } + + /** + * 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 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/CompiledManifestFreshnessTest.php b/packages/eventsourcing/tests/CompiledManifestFreshnessTest.php new file mode 100644 index 00000000..0e67af85 --- /dev/null +++ b/packages/eventsourcing/tests/CompiledManifestFreshnessTest.php @@ -0,0 +1,30 @@ + 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/Event/EventEnvelopeCodecTest.php b/packages/eventsourcing/tests/Event/EventEnvelopeCodecTest.php new file mode 100644 index 00000000..9c20426a --- /dev/null +++ b/packages/eventsourcing/tests/Event/EventEnvelopeCodecTest.php @@ -0,0 +1,23 @@ + '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/EventMapperTest.php b/packages/eventsourcing/tests/Event/EventMapperTest.php new file mode 100644 index 00000000..805ab4b0 --- /dev/null +++ b/packages/eventsourcing/tests/Event/EventMapperTest.php @@ -0,0 +1,65 @@ +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'); +}); + +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/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/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/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/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/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 @@ + $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 @@ +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; + } +} 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/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/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/DatabaseCheckpointStoreIntegrationTest.php b/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php new file mode 100644 index 00000000..e2e2add6 --- /dev/null +++ b/packages/eventsourcing/tests/Integration/DatabaseCheckpointStoreIntegrationTest.php @@ -0,0 +1,49 @@ + $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)); + } + + 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 { + $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/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/Integration/DatabaseSnapshotStoreIntegrationTest.php b/packages/eventsourcing/tests/Integration/DatabaseSnapshotStoreIntegrationTest.php new file mode 100644 index 00000000..b6d97f77 --- /dev/null +++ b/packages/eventsourcing/tests/Integration/DatabaseSnapshotStoreIntegrationTest.php @@ -0,0 +1,20 @@ + $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/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/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/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/InvalidFixtures/DuplicateType/First.php b/packages/eventsourcing/tests/InvalidFixtures/DuplicateType/First.php new file mode 100644 index 00000000..b57ee026 --- /dev/null +++ b/packages/eventsourcing/tests/InvalidFixtures/DuplicateType/First.php @@ -0,0 +1,11 @@ + $tags */ + public function __construct(public Countable&Traversable $tags) + { + parent::__construct(); + } +} diff --git a/packages/eventsourcing/tests/InvalidFixtures/NotADomainEvent/Stray.php b/packages/eventsourcing/tests/InvalidFixtures/NotADomainEvent/Stray.php new file mode 100644 index 00000000..325b9d13 --- /dev/null +++ b/packages/eventsourcing/tests/InvalidFixtures/NotADomainEvent/Stray.php @@ -0,0 +1,10 @@ + $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 @@ +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 new file mode 100644 index 00000000..63f6fff7 --- /dev/null +++ b/packages/eventsourcing/tests/Manifest/EventSourcingManifestTest.php @@ -0,0 +1,103 @@ + */ +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'], +]); + +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'], +]); + +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'); +}); + +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']]); +}); 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/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/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]); +}); diff --git a/packages/eventsourcing/tests/PackageBootsTest.php b/packages/eventsourcing/tests/PackageBootsTest.php new file mode 100644 index 00000000..a1e41d7b --- /dev/null +++ b/packages/eventsourcing/tests/PackageBootsTest.php @@ -0,0 +1,112 @@ +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(); +}); + +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.'/'.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 { + $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/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/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/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']); +}); diff --git a/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php b/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php new file mode 100644 index 00000000..54e65fee --- /dev/null +++ b/packages/eventsourcing/tests/ReflectionFreeEventSourcingTest.php @@ -0,0 +1,31 @@ + 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). The scanner is that file. + */ +it('confines reflection in firefly/eventsourcing to the scanner', function (): void { + expect(eventSourcingReflectionHits(__DIR__.'/../src'))->toBe(['EventSourcingScanner.php']); +}); 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/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..dbf6947a --- /dev/null +++ b/packages/eventsourcing/tests/Store/DatabaseEventStoreTest.php @@ -0,0 +1,109 @@ + $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(); +}); + +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); + } +}); 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(); +}); 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/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'); +}); 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, + ]; + } +} 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/CheckpointStoreContract.php b/packages/eventsourcing/tests/Support/CheckpointStoreContract.php new file mode 100644 index 00000000..749fb8be --- /dev/null +++ b/packages/eventsourcing/tests/Support/CheckpointStoreContract.php @@ -0,0 +1,125 @@ + */ + 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(); + }, + ]; + } + + /** + * 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'; + } + } +} 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; + } +} 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 @@ + */ + 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/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/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; + } +} diff --git a/packages/eventsourcing/tests/Support/EventStoreContract.php b/packages/eventsourcing/tests/Support/EventStoreContract.php new file mode 100644 index 00000000..fd94365d --- /dev/null +++ b/packages/eventsourcing/tests/Support/EventStoreContract.php @@ -0,0 +1,203 @@ + $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']); + }, + '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); + + $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']]); + }, + '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); + + expect($store->append('order-1', 'order', [], 0))->toBe([]) + ->and($store->exists('order-1'))->toBeFalse() + ->and($store->currentPosition())->toBe(0) + ->and($store->isHealthy())->toBeTrue(); + }, + ]; + } +} diff --git a/packages/eventsourcing/tests/Support/MemoryEventSourcedOrchestrationCapstoneTestCase.php b/packages/eventsourcing/tests/Support/MemoryEventSourcedOrchestrationCapstoneTestCase.php new file mode 100644 index 00000000..f17d4907 --- /dev/null +++ b/packages/eventsourcing/tests/Support/MemoryEventSourcedOrchestrationCapstoneTestCase.php @@ -0,0 +1,15 @@ + */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.eventsourcing.store.provider' => 'memory']; + } +} 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)); + } +} 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(); + }, + ]; + } +} 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); + } +} 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"); +} 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"); +} 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'); +}); 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), diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 61fade6e..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' => [ @@ -3058,4 +3060,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], + ], + ]; diff --git a/tests/DocsProseIsRealTest.php b/tests/DocsProseIsRealTest.php index 24011a20..2c43eb64 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; @@ -3674,3 +3674,62 @@ 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. + // + // 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|\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 ($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 + // 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'); +});