From 7c246a4a51280fe6890c4bbf8d2aa8a92d3a6a1e Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 00:35:02 -0400 Subject: [PATCH 01/24] wip --- src/Contracts/StoresEvents.php | 2 ++ src/Lifecycle/EventStore.php | 40 ++++++++++++++++++++++++++++ src/Lifecycle/StateReconstructor.php | 23 ++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 src/Lifecycle/StateReconstructor.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index aa145e18..945d6b9c 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -19,4 +19,6 @@ public function read( /** @param Event[] $events */ public function write(array $events): bool; + + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 5a24c5f1..f865fff3 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; +use InvalidArgumentException; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; @@ -47,6 +48,45 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array + { + if ($state_id === null && $type === null) { + throw new InvalidArgumentException('You must specify a state ID or type.'); + } + + $known_state_ids = Collection::make([$state_id])->filter(); + $known_event_ids = new Collection(); + + do { + $discovered_event_ids = VerbStateEvent::query() + ->select('event_id') + ->distinct() + ->whereNotIn('event_id', $known_event_ids) + ->when( + value: $state_id === null, + callback: fn (Builder $query) => $query->where('state_type', $type), + default: fn (Builder $query) => $query->where('state_id', $state_id), + ) + ->toBase() + ->pluck('event_id'); + + $discovered_state_ids = VerbStateEvent::query() + ->select('state_id') + ->distinct() + ->whereIn('event_id', $known_event_ids) + ->whereNotIn('state_id', $known_state_ids) + ->toBase() + ->distinct() + ->pluck('state_id'); + + $known_event_ids = $known_event_ids->merge($discovered_event_ids); + $known_state_ids = $known_state_ids->merge($discovered_state_ids); + + } while ($discovered_state_ids->isNotEmpty()); + + return [$known_state_ids, $known_event_ids]; + } + protected function readEvents( ?State $state, Bits|UuidInterface|AbstractUid|int|string|null $after_id, diff --git a/src/Lifecycle/StateReconstructor.php b/src/Lifecycle/StateReconstructor.php new file mode 100644 index 00000000..933dd53d --- /dev/null +++ b/src/Lifecycle/StateReconstructor.php @@ -0,0 +1,23 @@ +events->allRelatedIds($id, $type); + + // TODO + } +} From 79e69d495e5e80cf128033a8c18e5ccf7432c298 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Wed, 21 Aug 2024 04:35:25 +0000 Subject: [PATCH 02/24] Fix styling --- src/Lifecycle/EventStore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index f865fff3..5b59deb3 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -55,7 +55,7 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st } $known_state_ids = Collection::make([$state_id])->filter(); - $known_event_ids = new Collection(); + $known_event_ids = new Collection; do { $discovered_event_ids = VerbStateEvent::query() From 5c43a99833e44ce3e750b2c39b516bbf1859fb41 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 12:57:05 -0400 Subject: [PATCH 03/24] wip --- src/Contracts/StoresEvents.php | 5 +- src/Lifecycle/EventStore.php | 23 ++++-- src/Lifecycle/StateManager.php | 7 +- src/Lifecycle/StateReconstructor.php | 10 +-- src/State.php | 6 +- src/Support/StateInstanceCache.php | 9 ++- src/Testing/EventStoreFake.php | 10 +++ tests/Unit/StateReconstitutionTest.php | 103 +++++++++++++++++++++++++ 8 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/StateReconstitutionTest.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index 945d6b9c..b848c81e 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -3,6 +3,7 @@ namespace Thunk\Verbs\Contracts; use Glhd\Bits\Bits; +use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; @@ -17,8 +18,10 @@ public function read( bool $singleton = false ): LazyCollection; + public function get(iterable $ids): LazyCollection; + /** @param Event[] $events */ public function write(array $events): bool; - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array; + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index f865fff3..66d4cd75 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -36,6 +36,15 @@ public function read( ->map(fn (VerbEvent $model) => $model->event()); } + public function get(iterable $ids): LazyCollection + { + return VerbEvent::query() + ->whereIn('id', collect($ids)) + ->lazyById() + ->each(fn (VerbEvent $model) => $this->metadata->set($model->event(), $model->metadata())) + ->map(fn (VerbEvent $model) => $model->event()); + } + public function write(array $events): bool { if (empty($events)) { @@ -48,7 +57,7 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): array + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection { if ($state_id === null && $type === null) { throw new InvalidArgumentException('You must specify a state ID or type.'); @@ -62,14 +71,13 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st ->select('event_id') ->distinct() ->whereNotIn('event_id', $known_event_ids) - ->when( - value: $state_id === null, - callback: fn (Builder $query) => $query->where('state_type', $type), - default: fn (Builder $query) => $query->where('state_id', $state_id), - ) + ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) + ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) ->toBase() ->pluck('event_id'); + $known_event_ids = $known_event_ids->merge($discovered_event_ids); + $discovered_state_ids = VerbStateEvent::query() ->select('state_id') ->distinct() @@ -79,12 +87,11 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st ->distinct() ->pluck('state_id'); - $known_event_ids = $known_event_ids->merge($discovered_event_ids); $known_state_ids = $known_state_ids->merge($discovered_state_ids); } while ($discovered_state_ids->isNotEmpty()); - return [$known_state_ids, $known_event_ids]; + return $known_event_ids; } protected function readEvents( diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index d36d85eb..c69249a3 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -4,6 +4,7 @@ use Glhd\Bits\Bits; use Ramsey\Uuid\UuidInterface; +use ReflectionClass; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Contracts\StoresSnapshots; @@ -51,8 +52,12 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type throw new UnexpectedValueException(sprintf('Expected State <%d> to be of type "%s" but got "%s"', $id, class_basename($type), class_basename($state))); } } else { - $state = $type::make(); + // State::__construct() auto-registers the state with the StateManager, so we need to + // skip the constructor until we've already set the ID. + $reflect = new ReflectionClass($type); + $state = $reflect->newInstanceWithoutConstructor(); $state->id = $id; + $state->__construct(); } $this->remember($state); diff --git a/src/Lifecycle/StateReconstructor.php b/src/Lifecycle/StateReconstructor.php index 933dd53d..171107a1 100644 --- a/src/Lifecycle/StateReconstructor.php +++ b/src/Lifecycle/StateReconstructor.php @@ -6,18 +6,18 @@ use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; -use Thunk\Verbs\Support\StateInstanceCache; +use Thunk\Verbs\Event; class StateReconstructor { public function __construct( protected StoresEvents $events, + protected Dispatcher $dispatcher, ) {} - public function reconstruct(string $type, Bits|UuidInterface|AbstractUid|int|string|null $id): StateInstanceCache + public function reconstruct(string $type, Bits|UuidInterface|AbstractUid|int|string|null $id) { - [$state_ids, $event_ids] = $this->events->allRelatedIds($id, $type); - - // TODO + $this->events->get($this->events->allRelatedIds($id, $type)) + ->each(fn (Event $event) => $this->dispatcher->apply($event)); } } diff --git a/src/State.php b/src/State.php index 0ddfac5c..f06f5b8c 100644 --- a/src/State.php +++ b/src/State.php @@ -30,11 +30,7 @@ public static function make(...$args): static $args = $args[0]; } - $state = app(Serializer::class)->deserialize(static::class, $args, call_constructor: true); - - app(StateManager::class)->register($state); - - return $state; + return app(Serializer::class)->deserialize(static::class, $args, call_constructor: true); } /** @return StateFactory */ diff --git a/src/Support/StateInstanceCache.php b/src/Support/StateInstanceCache.php index 4f233b5e..e91e1559 100644 --- a/src/Support/StateInstanceCache.php +++ b/src/Support/StateInstanceCache.php @@ -52,10 +52,6 @@ public function has(string|int $key): bool public function forget(string|int $key): static { - if ($this->discard_callback) { - call_user_func($this->discard_callback, $this->cache[$key]); - } - unset($this->cache[$key]); return $this; @@ -80,6 +76,11 @@ public function reset(): static return $this; } + public function all(): array + { + return $this->cache; + } + public function onDiscard(Closure $callback): static { $this->discard_callback = $callback; diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 508ac711..5ea640b8 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -57,6 +57,16 @@ public function write(array $events): bool return true; } + public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection + { + // FIXME + } + + public function get(iterable $ids): LazyCollection + { + // FIXME + } + /** @return Collection */ public function committed(string $class_name, ?Closure $filter = null): Collection { diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php new file mode 100644 index 00000000..4409cccd --- /dev/null +++ b/tests/Unit/StateReconstitutionTest.php @@ -0,0 +1,103 @@ +instance(StoresSnapshots::class, new SnapshotStoreFake); + app()->instance(StoresEvents::class, new EventStoreFake(app(MetadataManager::class))); +}); + +/* + * The Problem(s) + * + * FIRST PROBLEM: + * - We try to load state1, but we don't have an up-to-date snapshot + * - StateManager::load tries to reconstitute state from events + * - One of those Event::apply methods load state2 + * - Best case scenario: we reconstitute state2 before continuing + * - Worst case scenario: reconstituting state2 tries to reconstitute state1, and we're in an infinite loop + * - (if no loop) state1 continues to reconstitute, but it's acting with state2 FULLY up-to-date, not + * just up-to-date with where state1 happens to be + * + * TO TEST FIRST PROBLEM: + * - Event1 adds State1::counter to State2::counter and increments State2::counter + * - Event2 increments State2::counter + * + * SECOND PROBLEM: + * - We try to load state1, but we don't have an up-to-date snapshot + * - StateManager::load tries to reconstitute state from events + * - One of those Event::apply methods requires state1 and state2, so we need to load state2 + * - Reconstituting state2 re-runs the same apply method on state2 before also running it on state1 + * - Double-apply happens + */ + +test('scenario 1', function () { + $state1_id = snowflake_id(); + $state2_id = snowflake_id(); + + StateReconstitutionTestEvent1::fire(state1_id: $state1_id, state2_id: $state2_id); + + $state1 = StateReconstitutionTestState1::load($state1_id); + $state2 = StateReconstitutionTestState2::load($state2_id); + + expect($state1->counter)->toBe(0) + ->and($state2->counter)->toBe(1); + + StateReconstitutionTestEvent2::fire(state2_id: $state2_id); + + expect($state1->counter)->toBe(0) + ->and($state2->counter)->toBe(2); + + Verbs::commit(); + app(StateManager::class)->reset(include_storage: true); + + $state1 = StateReconstitutionTestState1::load($state1_id); + $state2 = StateReconstitutionTestState2::load($state2_id); + + expect($state1->counter)->toBe(0) + ->and($state2->counter)->toBe(2); +}); + +class StateReconstitutionTestState1 extends State +{ + public int $counter = 0; +} + +class StateReconstitutionTestState2 extends State +{ + public int $counter = 0; +} + +class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event +{ + #[StateId(StateReconstitutionTestState1::class)] + public int $state1_id; + + #[StateId(StateReconstitutionTestState2::class)] + public int $state2_id; + + public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void + { + $state1->counter = $state1->counter + $state2->counter; + $state2->counter++; + } +} + +class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event +{ + #[StateId(StateReconstitutionTestState2::class)] + public int $state2_id; + + public function apply(StateReconstitutionTestState2 $state2): void + { + $state2->counter++; + } +} From 93582b521543c9781fb1da4ac73042f5159f5991 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 13:58:11 -0400 Subject: [PATCH 04/24] Allow EventStateRegistry to reset --- src/Event.php | 7 +------ src/Lifecycle/StateManager.php | 4 ++++ src/Support/EventStateRegistry.php | 19 +++++++++++++++++-- src/VerbsServiceProvider.php | 1 + 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/Event.php b/src/Event.php index 23951c41..5f69d8b6 100644 --- a/src/Event.php +++ b/src/Event.php @@ -13,7 +13,6 @@ use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\PendingEvent; use Thunk\Verbs\Support\StateCollection; -use WeakMap; /** * @method static static fire(...$args) @@ -42,11 +41,7 @@ public function metadata(?string $key = null, mixed $default = null): mixed public function states(): StateCollection { - // TODO: This is a bit hacky, but is probably OK right now - - static $map = new WeakMap; - - return $map[$this] ??= app(EventStateRegistry::class)->getStates($this); + return app(EventStateRegistry::class)->getStates($this); } /** diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index c69249a3..971c636d 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -12,6 +12,7 @@ use Thunk\Verbs\Exceptions\StateCacheSizeTooLow; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\State; +use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\StateInstanceCache; use UnexpectedValueException; @@ -24,6 +25,7 @@ public function __construct( protected StoresSnapshots $snapshots, protected StoresEvents $events, protected StateInstanceCache $states, + protected EventStateRegistry $event_states, ) { $this->states->onDiscard(fn () => throw_unless($this->is_replaying, StateCacheSizeTooLow::class)); } @@ -102,6 +104,8 @@ public function setReplaying(bool $replaying): static public function reset(bool $include_storage = false): static { $this->states->reset(); + $this->event_states->reset(); + $this->is_replaying = false; if ($include_storage) { diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 3bbeefea..969114cd 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -12,16 +12,31 @@ use Thunk\Verbs\Event; use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; +use WeakMap; class EventStateRegistry { protected array $discovered_attributes = []; + protected WeakMap $discovered_states; + public function __construct( protected StateManager $manager - ) {} + ) { + $this->discovered_states = new WeakMap(); + } + + public function reset() + { + $this->discovered_states = new WeakMap(); + } public function getStates(Event $event): StateCollection + { + return $this->discovered_states[$event] ??= $this->discoverStates($event); + } + + protected function discoverStates(Event $event): StateCollection { $discovered = new StateCollection; $deferred = new StateCollection; @@ -40,7 +55,7 @@ public function getStates(Event $event): StateCollection // Once we've loaded everything else, try to discover any deferred attributes $deferred->each(fn (StateDiscoveryAttribute $attr) => $this->discoverAndPushState($attr, $event, $discovered)); - return $discovered; + return $this->discovered_states[$event] = $discovered; } /** @return Collection */ diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index 61d0869b..a8856139 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -75,6 +75,7 @@ public function packageRegistered() states: new StateInstanceCache( capacity: $app->make(Repository::class)->get('verbs.state_cache_size', 100) ), + event_states: $app->make(EventStateRegistry::class), ); }); From ec6082f7714b2003708c513a049a81a286cf9ffa Mon Sep 17 00:00:00 2001 From: inxilpro Date: Wed, 21 Aug 2024 17:58:35 +0000 Subject: [PATCH 05/24] Fix styling --- src/Support/EventStateRegistry.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 969114cd..20e411f7 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -23,12 +23,12 @@ class EventStateRegistry public function __construct( protected StateManager $manager ) { - $this->discovered_states = new WeakMap(); + $this->discovered_states = new WeakMap; } public function reset() { - $this->discovered_states = new WeakMap(); + $this->discovered_states = new WeakMap; } public function getStates(Event $event): StateCollection From 25dbc6cc163990c29b133b3ac8c68e40317b99cb Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 15:41:14 -0400 Subject: [PATCH 06/24] Undo infinite loop --- src/Lifecycle/StateManager.php | 3 +-- src/VerbsServiceProvider.php | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 971c636d..39598bb4 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -25,7 +25,6 @@ public function __construct( protected StoresSnapshots $snapshots, protected StoresEvents $events, protected StateInstanceCache $states, - protected EventStateRegistry $event_states, ) { $this->states->onDiscard(fn () => throw_unless($this->is_replaying, StateCacheSizeTooLow::class)); } @@ -104,7 +103,7 @@ public function setReplaying(bool $replaying): static public function reset(bool $include_storage = false): static { $this->states->reset(); - $this->event_states->reset(); + app(EventStateRegistry::class)->reset(); $this->is_replaying = false; diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index a8856139..61d0869b 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -75,7 +75,6 @@ public function packageRegistered() states: new StateInstanceCache( capacity: $app->make(Repository::class)->get('verbs.state_cache_size', 100) ), - event_states: $app->make(EventStateRegistry::class), ); }); From 1989b9cdceaf73a33eeb10ac0bb2948ee6588654 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:26:35 -0400 Subject: [PATCH 07/24] GREEN Co-Authored-By: Skyler Katz --- src/Lifecycle/EventStore.php | 33 +++++++++++++++----------- src/Lifecycle/StateManager.php | 24 ++++++++++--------- tests/Unit/StateReconstitutionTest.php | 14 ++++------- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 4090dcd1..c9709a23 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -64,23 +64,18 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st } $known_state_ids = Collection::make([$state_id])->filter(); - $known_event_ids = new Collection; + $known_event_ids = VerbStateEvent::query() + ->distinct() + ->select('event_id') + ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) + ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) + ->toBase() + ->pluck('event_id'); do { - $discovered_event_ids = VerbStateEvent::query() - ->select('event_id') - ->distinct() - ->whereNotIn('event_id', $known_event_ids) - ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) - ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) - ->toBase() - ->pluck('event_id'); - - $known_event_ids = $known_event_ids->merge($discovered_event_ids); - $discovered_state_ids = VerbStateEvent::query() - ->select('state_id') ->distinct() + ->select('state_id') ->whereIn('event_id', $known_event_ids) ->whereNotIn('state_id', $known_state_ids) ->toBase() @@ -89,7 +84,17 @@ public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $st $known_state_ids = $known_state_ids->merge($discovered_state_ids); - } while ($discovered_state_ids->isNotEmpty()); + $discovered_event_ids = VerbStateEvent::query() + ->distinct() + ->select('event_id') + ->whereNotIn('event_id', $known_event_ids) + ->whereIn('state_id', $known_state_ids) + ->toBase() + ->pluck('event_id'); + + $known_event_ids = $known_event_ids->merge($discovered_event_ids); + + } while ($discovered_event_ids->isNotEmpty()); return $known_event_ids; } diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 39598bb4..3d25e629 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -18,6 +18,8 @@ class StateManager { + protected bool $is_reconstituting = false; + protected bool $is_replaying = false; public function __construct( @@ -62,7 +64,12 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type } $this->remember($state); - $this->reconstitute($state); + + if (! $this->is_reconstituting) { + $this->is_reconstituting = true; + $this->reconstitute($state); + $this->is_reconstituting = false; + } return $state; } @@ -126,16 +133,11 @@ protected function reconstitute(State $state, bool $singleton = false): static // When we're replaying, the Broker is in charge of applying the correct events // to the State, so we only need to do it *outside* of replays. if (! $this->is_replaying) { - $this->events - ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) - ->each(fn (Event $event) => $this->dispatcher->apply($event)); - - // It's possible for an event to mutate state out of order when reconstituting, - // so as a precaution, we'll clear all other states from the store and reload - // them from snapshots as needed in the rest of the request. - // FIXME: We still need to figure this out - // $this->states->reset(); - //$this->remember($state); + // $this->events + // ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) + // ->each(fn (Event $event) => $this->dispatcher->apply($event)); + (new StateReconstructor($this->events, $this->dispatcher)) + ->reconstruct($state::class, $singleton ? null : $state->id); } return $this; diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index 4409cccd..a4dd5f9e 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -1,19 +1,9 @@ instance(StoresSnapshots::class, new SnapshotStoreFake); - app()->instance(StoresEvents::class, new EventStoreFake(app(MetadataManager::class))); -}); /* * The Problem(s) @@ -39,6 +29,8 @@ * - Double-apply happens */ +// FIXME: We need to account for partially up-to-date snapshots that only need *some* events applied but not all + test('scenario 1', function () { $state1_id = snowflake_id(); $state2_id = snowflake_id(); @@ -86,6 +78,7 @@ class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void { + dump("Applying event {$this->id}"); $state1->counter = $state1->counter + $state2->counter; $state2->counter++; } @@ -98,6 +91,7 @@ class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState2 $state2): void { + dump("Applying event {$this->id}"); $state2->counter++; } } From 712463f046e73bcb30c597b5a555ceff2d9b1787 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:44:44 -0400 Subject: [PATCH 08/24] refactoring Co-Authored-By: Skyler Katz --- src/Contracts/StoresEvents.php | 2 +- src/Lifecycle/EventStore.php | 13 ++++--------- src/Lifecycle/StateManager.php | 7 ++----- src/Lifecycle/StateReconstructor.php | 23 ----------------------- src/Testing/EventStoreFake.php | 2 +- 5 files changed, 8 insertions(+), 39 deletions(-) delete mode 100644 src/Lifecycle/StateReconstructor.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index b848c81e..2e80acf7 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -23,5 +23,5 @@ public function get(iterable $ids): LazyCollection; /** @param Event[] $events */ public function write(array $events): bool; - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection; + public function allRelatedIds(State $state, bool $singleton = false): Collection; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index c9709a23..f88eea7f 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -8,7 +8,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; -use InvalidArgumentException; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; @@ -57,18 +56,14 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection + public function allRelatedIds(State $state, bool $singleton = false): Collection { - if ($state_id === null && $type === null) { - throw new InvalidArgumentException('You must specify a state ID or type.'); - } - - $known_state_ids = Collection::make([$state_id])->filter(); + $known_state_ids = $singleton ? new Collection() : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() ->distinct() ->select('event_id') - ->unless($type === null, fn (Builder $query) => $query->where('state_type', $type)) - ->unless($state_id === null, fn (Builder $query) => $query->where('state_id', $state_id)) + ->where('state_type', $state::class) + ->unless($singleton, fn (Builder $query) => $query->where('state_id', $state->id)) ->toBase() ->pluck('event_id'); diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 3d25e629..9e4a110a 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -133,11 +133,8 @@ protected function reconstitute(State $state, bool $singleton = false): static // When we're replaying, the Broker is in charge of applying the correct events // to the State, so we only need to do it *outside* of replays. if (! $this->is_replaying) { - // $this->events - // ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) - // ->each(fn (Event $event) => $this->dispatcher->apply($event)); - (new StateReconstructor($this->events, $this->dispatcher)) - ->reconstruct($state::class, $singleton ? null : $state->id); + $this->events->get($this->events->allRelatedIds($state, $singleton)) + ->each($this->dispatcher->apply(...)); } return $this; diff --git a/src/Lifecycle/StateReconstructor.php b/src/Lifecycle/StateReconstructor.php deleted file mode 100644 index 171107a1..00000000 --- a/src/Lifecycle/StateReconstructor.php +++ /dev/null @@ -1,23 +0,0 @@ -events->get($this->events->allRelatedIds($id, $type)) - ->each(fn (Event $event) => $this->dispatcher->apply($event)); - } -} diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 5ea640b8..f024d80c 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -57,7 +57,7 @@ public function write(array $events): bool return true; } - public function allRelatedIds(Bits|UuidInterface|AbstractUid|int|string|null $state_id, ?string $type): Collection + public function allRelatedIds(State $state, bool $singleton = false): Collection { // FIXME } From 72188a555d956422aec979b5a888706947678582 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:50:44 -0400 Subject: [PATCH 09/24] One more failing test Co-Authored-By: Skyler Katz --- tests/Unit/StateReconstitutionTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index a4dd5f9e..4fc3bdad 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -3,6 +3,7 @@ use Thunk\Verbs\Attributes\Autodiscovery\StateId; use Thunk\Verbs\Facades\Verbs; use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\Models\VerbSnapshot; use Thunk\Verbs\State; /* @@ -58,6 +59,26 @@ ->and($state2->counter)->toBe(2); }); +test('partially up-to-date snapshots', function () { + StateReconstitutionTestEvent2::fire(state2_id: 1); + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 1); + StateReconstitutionTestEvent2::fire(state2_id: 1); + + Verbs::commit(); + + $snapshot = VerbSnapshot::query()->where('state_id', 1)->sole(); + $snapshot->update([ + 'data' => '{"counter":2}', + 'last_event_id' => $event2->id, + ]); + + app(StateManager::class)->reset(); + + $state = StateReconstitutionTestState2::load(1); + + expect($state->counter)->toBe(3); +}); + class StateReconstitutionTestState1 extends State { public int $counter = 0; From 129b0316981344bdcd6fa5f43bb4cc741c7b73aa Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 16:58:16 -0400 Subject: [PATCH 10/24] Make test moar bad Co-Authored-By: Skyler Katz --- tests/Unit/StateReconstitutionTest.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index 4fc3bdad..c1885266 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -60,23 +60,33 @@ }); test('partially up-to-date snapshots', function () { - StateReconstitutionTestEvent2::fire(state2_id: 1); - $event2 = StateReconstitutionTestEvent2::fire(state2_id: 1); - StateReconstitutionTestEvent2::fire(state2_id: 1); + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 Verbs::commit(); - $snapshot = VerbSnapshot::query()->where('state_id', 1)->sole(); - $snapshot->update([ + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); + $snapshot1->update([ + 'data' => '{"counter":2}', + 'last_event_id' => $event3->id, + ]); + + $snapshot2 = VerbSnapshot::query()->where('state_id', 2)->sole(); + $snapshot2->update([ 'data' => '{"counter":2}', 'last_event_id' => $event2->id, ]); app(StateManager::class)->reset(); - $state = StateReconstitutionTestState2::load(1); + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); - expect($state->counter)->toBe(3); + expect($state1->counter)->toBe(6); + expect($state2->counter)->toBe(5); }); class StateReconstitutionTestState1 extends State From 064fb3c326a2733e0ed5392c3d3981cb9afc94b2 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Wed, 21 Aug 2024 21:00:46 +0000 Subject: [PATCH 11/24] Fix styling --- src/Lifecycle/EventStore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index f88eea7f..7576428b 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -58,7 +58,7 @@ public function write(array $events): bool public function allRelatedIds(State $state, bool $singleton = false): Collection { - $known_state_ids = $singleton ? new Collection() : Collection::make([$state->id]); + $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() ->distinct() ->select('event_id') From f87eb5637afe8079dfa16e6dab332e619138b58f Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 17:03:10 -0400 Subject: [PATCH 12/24] wip Co-Authored-By: Skyler Katz --- src/Lifecycle/StateManager.php | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 9e4a110a..825e0b67 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -64,12 +64,7 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type } $this->remember($state); - - if (! $this->is_reconstituting) { - $this->is_reconstituting = true; - $this->reconstitute($state); - $this->is_reconstituting = false; - } + $this->reconstitute($state); return $state; } @@ -131,10 +126,18 @@ public function prune(): static protected function reconstitute(State $state, bool $singleton = false): static { // When we're replaying, the Broker is in charge of applying the correct events - // to the State, so we only need to do it *outside* of replays. - if (! $this->is_replaying) { - $this->events->get($this->events->allRelatedIds($state, $singleton)) - ->each($this->dispatcher->apply(...)); + // to the State, so we need to skip during replays. Similarly, if we're already + // reconstituting in a recursive call, the root call is responsible for applying + // events, so we should also skip in that case. + + if (! $this->is_replaying && ! $this->is_reconstituting) { + try { + $this->is_reconstituting = true; + $this->events->get($this->events->allRelatedIds($state, $singleton)) + ->each($this->dispatcher->apply(...)); + } finally { + $this->is_reconstituting = false; + } } return $this; From 4edd58528bdd23489f8e4f4ea6db6a92343316f4 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 21 Aug 2024 17:53:19 -0400 Subject: [PATCH 13/24] wip --- src/Lifecycle/EventStore.php | 2 +- src/Lifecycle/StateManager.php | 22 +++++++++++ tests/Unit/StateReconstitutionTest.php | 53 ++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 7576428b..4b763184 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -91,7 +91,7 @@ public function allRelatedIds(State $state, bool $singleton = false): Collection } while ($discovered_event_ids->isNotEmpty()); - return $known_event_ids; + return $known_event_ids->sort(); } protected function readEvents( diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 825e0b67..2588f05f 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -5,6 +5,7 @@ use Glhd\Bits\Bits; use Ramsey\Uuid\UuidInterface; use ReflectionClass; +use RuntimeException; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Contracts\StoresSnapshots; @@ -134,6 +135,27 @@ protected function reconstitute(State $state, bool $singleton = false): static try { $this->is_reconstituting = true; $this->events->get($this->events->allRelatedIds($state, $singleton)) + ->filter(function (Event $event) { + $last_event_ids = $event->states() + ->map(fn (State $state) => $state->last_event_id) + ->filter(); + + $min = $last_event_ids->min() ?? PHP_INT_MIN; + $max = $last_event_ids->max() ?? PHP_INT_MIN; + + // If all states have had this or future events applied, just ignore them + if ($min >= $event->id && $max >= $event->id) { + return false; + } + + // We should never be in a situation where some events are ahead and + // others are behind, so if that's the case we'll throw an exception + if ($max > $event->id && $min <= $event->id) { + throw new RuntimeException('Trying to apply an event to states that are out of sync.'); + } + + return true; + }) ->each($this->dispatcher->apply(...)); } finally { $this->is_reconstituting = false; diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index c1885266..a1bba15d 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -61,13 +61,19 @@ test('partially up-to-date snapshots', function () { StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 - $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 Verbs::commit(); + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6) + ->and($state2->counter)->toBe(5); + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); $snapshot1->update([ 'data' => '{"counter":2}', @@ -76,18 +82,56 @@ $snapshot2 = VerbSnapshot::query()->where('state_id', 2)->sole(); $snapshot2->update([ + 'data' => '{"counter":3}', + 'last_event_id' => $event3->id, + ]); + + app(StateManager::class)->reset(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6); + expect($state2->counter)->toBe(5); +}); + +test('partially up-to-date, but out of sync snapshots', function () { + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + + Verbs::commit(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6) + ->and($state2->counter)->toBe(5); + + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); + $snapshot1->update([ 'data' => '{"counter":2}', + 'last_event_id' => $event3->id, + ]); + + $snapshot2 = VerbSnapshot::query()->where('state_id', 2)->sole(); + $snapshot2->update([ + 'data' => '{"counter":2}', // FIXME: This maybe can't happen? 'last_event_id' => $event2->id, ]); app(StateManager::class)->reset(); + // dump('---- RESET ----'); + $state1 = StateReconstitutionTestState1::load(1); $state2 = StateReconstitutionTestState2::load(2); expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); -}); +})->skip('This may actually not be possible'); class StateReconstitutionTestState1 extends State { @@ -109,8 +153,9 @@ class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void { - dump("Applying event {$this->id}"); + // dump("[event 1] incrementing \$state1->counter from {$state1->counter} to ({$state1->counter} + {$state2->counter})"); $state1->counter = $state1->counter + $state2->counter; + // dump("[event 1] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } } @@ -122,7 +167,7 @@ class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState2 $state2): void { - dump("Applying event {$this->id}"); + // dump("[event 2] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } } From 9609c9f02f4d4fd89905e0b5b66428d23528a68d Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Thu, 22 Aug 2024 16:31:10 -0400 Subject: [PATCH 14/24] wip --- src/Contracts/StoresEvents.php | 4 +-- src/Lifecycle/AggregateStateSummary.php | 17 ++++++++++++ src/Lifecycle/EventStore.php | 37 ++++++++++++++++++++----- src/Lifecycle/StateManager.php | 6 +++- src/Testing/EventStoreFake.php | 3 +- 5 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 src/Lifecycle/AggregateStateSummary.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index 2e80acf7..e1dc2e9b 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -3,11 +3,11 @@ namespace Thunk\Verbs\Contracts; use Glhd\Bits\Bits; -use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Ramsey\Uuid\UuidInterface; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Event; +use Thunk\Verbs\Lifecycle\AggregateStateSummary; use Thunk\Verbs\State; interface StoresEvents @@ -23,5 +23,5 @@ public function get(iterable $ids): LazyCollection; /** @param Event[] $events */ public function write(array $events): bool; - public function allRelatedIds(State $state, bool $singleton = false): Collection; + public function summarize(State $state, bool $singleton = false): AggregateStateSummary; } diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php new file mode 100644 index 00000000..755f7873 --- /dev/null +++ b/src/Lifecycle/AggregateStateSummary.php @@ -0,0 +1,17 @@ +formatRelationshipsForWrite($events)); } - public function allRelatedIds(State $state, bool $singleton = false): Collection + public function summarize(State $state, bool $singleton = false): AggregateStateSummary { $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() @@ -91,7 +93,22 @@ public function allRelatedIds(State $state, bool $singleton = false): Collection } while ($discovered_event_ids->isNotEmpty()); - return $known_event_ids->sort(); + $aggregates = VerbSnapshot::query() + ->toBase() + ->tap(fn (BaseBuilder $query) => $query->select([ + $this->aggregateExpression($query, 'last_event_id', 'min'), + $this->aggregateExpression($query, 'last_event_id', 'max'), + ])) + ->whereIn('state_id', $known_state_ids) + ->first(); + + return new AggregateStateSummary( + state: $state, + related_event_ids: $discovered_event_ids, + related_state_ids: $discovered_state_ids, + min_applied_event_id: $aggregates->min_last_event_id, + max_applied_event_id: $aggregates->max_last_event_id, + ); } protected function readEvents( @@ -124,11 +141,7 @@ protected function guardAgainstConcurrentWrites(array $events): void $query->select([ 'state_type', 'state_id', - DB::raw(sprintf( - 'max(%s) as %s', - $query->getGrammar()->wrap('event_id'), - $query->getGrammar()->wrapTable('max_event_id') - )), + $this->aggregateExpression($query, 'event_id', 'max'), ]); $query->groupBy('state_type', 'state_id'); @@ -193,4 +206,14 @@ protected function formatRelationshipsForWrite(array $event_objects): array ])) ->all(); } + + protected function aggregateExpression(BaseBuilder $query, string $column, string $function): Expression + { + return DB::raw(sprintf( + '%s(%s) as %s', + $function, + $query->getGrammar()->wrap($column), + $query->getGrammar()->wrapTable("{$function}_{$column}") + )); + } } diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 2588f05f..907f7fd3 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -134,7 +134,10 @@ protected function reconstitute(State $state, bool $singleton = false): static if (! $this->is_replaying && ! $this->is_reconstituting) { try { $this->is_reconstituting = true; - $this->events->get($this->events->allRelatedIds($state, $singleton)) + + $summary = $this->events->summarize($state, $singleton); + + $this->events->get($summary->related_event_ids) ->filter(function (Event $event) { $last_event_ids = $event->states() ->map(fn (State $state) => $state->last_event_id) @@ -157,6 +160,7 @@ protected function reconstitute(State $state, bool $singleton = false): static return true; }) ->each($this->dispatcher->apply(...)); + } finally { $this->is_reconstituting = false; } diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index f024d80c..2eb49f6e 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -13,6 +13,7 @@ use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; +use Thunk\Verbs\Lifecycle\AggregateStateSummary; use Thunk\Verbs\Lifecycle\MetadataManager; use Thunk\Verbs\State; @@ -57,7 +58,7 @@ public function write(array $events): bool return true; } - public function allRelatedIds(State $state, bool $singleton = false): Collection + public function summarize(State $state, bool $singleton = false): AggregateStateSummary { // FIXME } From a04e5525cc8b9c1514a691182991886cb1e79922 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 23 Aug 2024 09:49:37 -0400 Subject: [PATCH 15/24] WIP --- src/Contracts/StoresSnapshots.php | 2 ++ src/Lifecycle/EventStore.php | 2 ++ src/Lifecycle/SnapshotStore.php | 5 ++++ src/Lifecycle/StateManager.php | 49 ++++++++++++++++--------------- src/Testing/SnapshotStoreFake.php | 15 ++++++++++ 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/Contracts/StoresSnapshots.php b/src/Contracts/StoresSnapshots.php index eabffbc7..8ddda1cb 100644 --- a/src/Contracts/StoresSnapshots.php +++ b/src/Contracts/StoresSnapshots.php @@ -16,4 +16,6 @@ public function loadSingleton(string $type): ?State; public function write(array $states): bool; public function reset(): bool; + + public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool; } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 7a4f52d9..51ddb471 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -60,6 +60,8 @@ public function write(array $events): bool public function summarize(State $state, bool $singleton = false): AggregateStateSummary { + // FIXME: We probably either need to know the state types or go by snapshot ID + $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); $known_event_ids = VerbStateEvent::query() ->distinct() diff --git a/src/Lifecycle/SnapshotStore.php b/src/Lifecycle/SnapshotStore.php index d409a097..ba8facc9 100644 --- a/src/Lifecycle/SnapshotStore.php +++ b/src/Lifecycle/SnapshotStore.php @@ -56,6 +56,11 @@ public function write(array $states): bool ); } + public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool + { + return VerbSnapshot::whereIn('state_id', array_map(Id::from(...), $ids))->delete() === true; + } + public function reset(): bool { VerbSnapshot::truncate(); diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 907f7fd3..43a61d42 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -58,8 +58,7 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type } else { // State::__construct() auto-registers the state with the StateManager, so we need to // skip the constructor until we've already set the ID. - $reflect = new ReflectionClass($type); - $state = $reflect->newInstanceWithoutConstructor(); + $state = (new ReflectionClass($type))->newInstanceWithoutConstructor(); $state->id = $id; $state->__construct(); } @@ -137,28 +136,32 @@ protected function reconstitute(State $state, bool $singleton = false): static $summary = $this->events->summarize($state, $singleton); + if ($summary->min_applied_event_id !== $summary->max_applied_event_id) { + $this->snapshots->delete($summary->related_state_ids); + } + $this->events->get($summary->related_event_ids) - ->filter(function (Event $event) { - $last_event_ids = $event->states() - ->map(fn (State $state) => $state->last_event_id) - ->filter(); - - $min = $last_event_ids->min() ?? PHP_INT_MIN; - $max = $last_event_ids->max() ?? PHP_INT_MIN; - - // If all states have had this or future events applied, just ignore them - if ($min >= $event->id && $max >= $event->id) { - return false; - } - - // We should never be in a situation where some events are ahead and - // others are behind, so if that's the case we'll throw an exception - if ($max > $event->id && $min <= $event->id) { - throw new RuntimeException('Trying to apply an event to states that are out of sync.'); - } - - return true; - }) + // ->filter(function (Event $event) { + // $last_event_ids = $event->states() + // ->map(fn (State $state) => $state->last_event_id) + // ->filter(); + // + // $min = $last_event_ids->min() ?? PHP_INT_MIN; + // $max = $last_event_ids->max() ?? PHP_INT_MIN; + // + // // If all states have had this or future events applied, just ignore them + // if ($min >= $event->id && $max >= $event->id) { + // return false; + // } + // + // // We should never be in a situation where some events are ahead and + // // others are behind, so if that's the case we'll throw an exception + // if ($max > $event->id && $min <= $event->id) { + // throw new RuntimeException('Trying to apply an event to states that are out of sync.'); + // } + // + // return true; + // }) ->each($this->dispatcher->apply(...)); } finally { diff --git a/src/Testing/SnapshotStoreFake.php b/src/Testing/SnapshotStoreFake.php index 7667289b..e27b7342 100644 --- a/src/Testing/SnapshotStoreFake.php +++ b/src/Testing/SnapshotStoreFake.php @@ -53,6 +53,21 @@ public function reset(): bool return true; } + public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool + { + $ids = array_map(Id::from(...), $ids); + + foreach ($this->states as $type => $states) { + foreach ($states as $id => $state) { + if (in_array($id, $ids)) { + uniqid($this->states[$type][$id]); + } + } + } + + return true; + } + public function assertWritten(string|Closure $state, Closure|int|null $callback = null): static { if ($state instanceof Closure) { From 21283eb1609f13baaa68197aa9b1caa3dda4d872 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 26 Aug 2024 12:02:16 -0500 Subject: [PATCH 16/24] wip --- src/Lifecycle/AggregateStateSummary.php | 1 + src/Lifecycle/EventStore.php | 7 +++- src/Lifecycle/SnapshotStore.php | 4 +- src/Lifecycle/StateManager.php | 54 ++++++++++++++----------- src/Testing/EventStoreFake.php | 4 +- tests/Unit/StateReconstitutionTest.php | 37 ++++++++++++++--- 6 files changed, 74 insertions(+), 33 deletions(-) diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 755f7873..ff7ba66d 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -13,5 +13,6 @@ public function __construct( public readonly Collection $related_state_ids, public readonly ?int $min_applied_event_id, public readonly ?int $max_applied_event_id, + public readonly bool $out_of_sync, ) {} } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index 51ddb471..af31088c 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -98,6 +98,7 @@ public function summarize(State $state, bool $singleton = false): AggregateState $aggregates = VerbSnapshot::query() ->toBase() ->tap(fn (BaseBuilder $query) => $query->select([ + $this->aggregateExpression($query, 'id', 'count'), $this->aggregateExpression($query, 'last_event_id', 'min'), $this->aggregateExpression($query, 'last_event_id', 'max'), ])) @@ -106,10 +107,12 @@ public function summarize(State $state, bool $singleton = false): AggregateState return new AggregateStateSummary( state: $state, - related_event_ids: $discovered_event_ids, - related_state_ids: $discovered_state_ids, + related_event_ids: $known_event_ids, + related_state_ids: $known_state_ids, min_applied_event_id: $aggregates->min_last_event_id, max_applied_event_id: $aggregates->max_last_event_id, + out_of_sync: ($aggregates->count_id && (int) $aggregates->count_id !== count($known_state_ids)) + || $aggregates->min_last_event_id !== $aggregates->max_last_event_id, ); } diff --git a/src/Lifecycle/SnapshotStore.php b/src/Lifecycle/SnapshotStore.php index ba8facc9..41644443 100644 --- a/src/Lifecycle/SnapshotStore.php +++ b/src/Lifecycle/SnapshotStore.php @@ -58,7 +58,9 @@ public function write(array $states): bool public function delete(Bits|UuidInterface|AbstractUid|int|string ...$ids): bool { - return VerbSnapshot::whereIn('state_id', array_map(Id::from(...), $ids))->delete() === true; + $ids = array_map(Id::from(...), $ids); + + return VerbSnapshot::whereIn('state_id', $ids)->delete() === true; } public function reset(): bool diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 43a61d42..a3bc6987 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -51,6 +51,13 @@ public function load(Bits|UuidInterface|AbstractUid|int|string $id, string $type return $state; } + $summary = $this->events->summarize($state); + + // FIXME: + if ($summary->out_of_sync) { + $this->snapshots->delete(...$summary->related_state_ids); + } + if ($state = $this->snapshots->load($id, $type)) { if (! $state instanceof $type) { throw new UnexpectedValueException(sprintf('Expected State <%d> to be of type "%s" but got "%s"', $id, class_basename($type), class_basename($state))); @@ -136,32 +143,33 @@ protected function reconstitute(State $state, bool $singleton = false): static $summary = $this->events->summarize($state, $singleton); - if ($summary->min_applied_event_id !== $summary->max_applied_event_id) { - $this->snapshots->delete($summary->related_state_ids); + // FIXME: + if ($summary->out_of_sync) { + $this->snapshots->delete(...$summary->related_state_ids); } $this->events->get($summary->related_event_ids) - // ->filter(function (Event $event) { - // $last_event_ids = $event->states() - // ->map(fn (State $state) => $state->last_event_id) - // ->filter(); - // - // $min = $last_event_ids->min() ?? PHP_INT_MIN; - // $max = $last_event_ids->max() ?? PHP_INT_MIN; - // - // // If all states have had this or future events applied, just ignore them - // if ($min >= $event->id && $max >= $event->id) { - // return false; - // } - // - // // We should never be in a situation where some events are ahead and - // // others are behind, so if that's the case we'll throw an exception - // if ($max > $event->id && $min <= $event->id) { - // throw new RuntimeException('Trying to apply an event to states that are out of sync.'); - // } - // - // return true; - // }) + ->filter(function (Event $event) { + $last_event_ids = $event->states() + ->map(fn (State $state) => $state->last_event_id) + ->filter(); + + $min = $last_event_ids->min() ?? PHP_INT_MIN; + $max = $last_event_ids->max() ?? PHP_INT_MIN; + + // If all states have had this or future events applied, just ignore them + if ($min >= $event->id && $max >= $event->id) { + return false; + } + + // We should never be in a situation where some events are ahead and + // others are behind, so if that's the case we'll throw an exception + if ($max > $event->id && $min <= $event->id) { + throw new RuntimeException('Trying to apply an event to states that are out of sync.'); + } + + return true; + }) ->each($this->dispatcher->apply(...)); } finally { diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 2eb49f6e..189ae06f 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -60,12 +60,12 @@ public function write(array $events): bool public function summarize(State $state, bool $singleton = false): AggregateStateSummary { - // FIXME + return new AggregateStateSummary($state, collect(), collect(), null, null); } public function get(iterable $ids): LazyCollection { - // FIXME + return new LazyCollection(); } /** @return Collection */ diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index a1bba15d..9cf852f3 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -45,9 +45,10 @@ ->and($state2->counter)->toBe(1); StateReconstitutionTestEvent2::fire(state2_id: $state2_id); + StateReconstitutionTestEvent1::fire(state1_id: $state1_id, state2_id: $state2_id); - expect($state1->counter)->toBe(0) - ->and($state2->counter)->toBe(2); + expect($state1->counter)->toBe(2) + ->and($state2->counter)->toBe(3); Verbs::commit(); app(StateManager::class)->reset(include_storage: true); @@ -55,8 +56,8 @@ $state1 = StateReconstitutionTestState1::load($state1_id); $state2 = StateReconstitutionTestState2::load($state2_id); - expect($state1->counter)->toBe(0) - ->and($state2->counter)->toBe(2); + expect($state1->counter)->toBe(2) + ->and($state2->counter)->toBe(3); }); test('partially up-to-date snapshots', function () { @@ -95,6 +96,32 @@ expect($state2->counter)->toBe(5); }); +test('partially deleted snapshots', function () { + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 + StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + + Verbs::commit(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6) + ->and($state2->counter)->toBe(5); + + VerbSnapshot::query()->where('state_id', 1)->delete(); + + app(StateManager::class)->reset(); + + $state1 = StateReconstitutionTestState1::load(1); + $state2 = StateReconstitutionTestState2::load(2); + + expect($state1->counter)->toBe(6); + expect($state2->counter)->toBe(5); +}); + test('partially up-to-date, but out of sync snapshots', function () { StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 @@ -131,7 +158,7 @@ expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); -})->skip('This may actually not be possible'); +}); class StateReconstitutionTestState1 extends State { From fe0161179d0b3d9c76115934e28f2f54ca0b924a Mon Sep 17 00:00:00 2001 From: inxilpro Date: Sun, 15 Sep 2024 14:36:54 +0000 Subject: [PATCH 17/24] Fix styling --- src/Testing/EventStoreFake.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 189ae06f..137d126e 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -65,7 +65,7 @@ public function summarize(State $state, bool $singleton = false): AggregateState public function get(iterable $ids): LazyCollection { - return new LazyCollection(); + return new LazyCollection; } /** @return Collection */ From 91f3d870fdb21a44b07b8d3ebc52bb379702aa65 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Thu, 19 Sep 2024 14:20:18 -0400 Subject: [PATCH 18/24] Move singleton status to the state class --- docs/attributes.md | 49 +++++++++--------- docs/state-hydration-snapshots.md | 23 ++++++--- docs/states.md | 14 ++--- .../Counter/src/Events/IncrementCount.php | 8 +-- .../src/Events/IncrementCountTwice.php | 4 +- examples/Counter/src/States/CountState.php | 4 +- .../Counter/tests/InitializeStateTest.php | 2 +- .../src/Events/GlobalReportGenerated.php | 4 +- .../src/Events/SubscriptionCancelled.php | 3 +- .../src/States/GlobalReportState.php | 4 +- .../Autodiscovery/AppliesToSingletonState.php | 27 ---------- .../Autodiscovery/AppliesToState.php | 5 ++ src/Contracts/StoresEvents.php | 1 - src/Events/VerbsStateInitialized.php | 12 +++-- src/Lifecycle/EventStore.php | 7 ++- src/Lifecycle/StateManager.php | 15 ++++-- src/SingletonState.php | 51 +++++++++++++++++++ src/State.php | 5 -- src/StateFactory.php | 10 +--- src/Testing/EventStoreFake.php | 6 +-- tests/Unit/ConcurrencyTest.php | 4 +- tests/Unit/FactoryTest.php | 10 +++- tests/Unit/UseStatesDirectlyInEventsTest.php | 24 ++++++++- 23 files changed, 169 insertions(+), 123 deletions(-) delete mode 100644 src/Attributes/Autodiscovery/AppliesToSingletonState.php create mode 100644 src/SingletonState.php diff --git a/docs/attributes.md b/docs/attributes.md index 7d05c899..e3e9f34a 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -13,11 +13,15 @@ class YourEvent extends Event } ``` -The `StateId` attribute takes a `state_type`, an optional [`alias`](https://verbs.thunk.dev/docs/reference/states#content-aliasstring-alias-state-state) string, and by default can [automatically generate](/docs/technical/ids#content-automatically-generating-ids)(`autofill`) a `snowflake_id` for you. +The `StateId` attribute takes a `state_type`, an optional [ +`alias`](https://verbs.thunk.dev/docs/reference/states#content-aliasstring-alias-state-state) string, and by default +can [automatically generate](/docs/technical/ids#content-automatically-generating-ids)(`autofill`) a `snowflake_id` for +you. ### `#[AppliesToState]` -Another way to link states and events; like [`StateId`](#content-stateid), but using the attributes above the class instead of on each individual id. +Another way to link states and events; like [`StateId`](#content-stateid), but using the attributes above the class +instead of on each individual id. ```php #[AppliesToState(GameState::class)] @@ -34,7 +38,8 @@ class RolledDice extends Event } ``` -`AppliesToState` has the same params as `StateId`, with an additional optional `id` param (after `state_type`) if you want to specify which prop belongs to which state. +`AppliesToState` has the same params as `StateId`, with an additional optional `id` param (after `state_type`) if you +want to specify which prop belongs to which state. ```php #[AppliesToState(state_type: GameState::class, id: foo_id)] @@ -51,24 +56,8 @@ class RolledDice extends Event } ``` -Otherwise, with `AppliesToState`, Verbs will find the `id` for you based on your State's prefix (i.e. `ExampleState` would be `example`, meaning `example_id` or `example_ids` would be associated automatically). - -### `#[AppliesToSingletonState]` - -Use the `AppliesToSingletonState` attribute on an event class to tell Verbs that it should always be applied to a single state (e.g. `CountState`) across the entire application (as opposed to having different counts for different states). - -Because we're using a [singleton state](/docs/reference/states#content-singleton-states), there is no need for the event to have a `$count_id`. - -```php -#[AppliesToSingletonState(CountState::class)] -class IncrementCount extends Event -{ - public function apply(CountState $state) - { - $state->count++; - } -} -``` +Otherwise, with `AppliesToState`, Verbs will find the `id` for you based on your State's prefix (i.e. `ExampleState` +would be `example`, meaning `example_id` or `example_ids` would be associated automatically). In addition to your `state_type` param, you may also set an optional `alias` string. @@ -76,7 +65,8 @@ In addition to your `state_type` param, you may also set an optional `alias` str Use the `AppliesToChildState` attribute on an event class to allow Verbs to access a nested state. -For our example, let's make sure our `ParentState` has a `child_id` property pointing to a `ChildState` by firing a `ChildAddedToParent` event: +For our example, let's make sure our `ParentState` has a `child_id` property pointing to a `ChildState` by firing a +`ChildAddedToParent` event: ```php ChildAddedToParent::fire(parent_id: 1, child_id: 2); @@ -103,6 +93,7 @@ class ParentState extends State public int $child_id; } ``` + ```php class ChildState extends State { @@ -110,7 +101,8 @@ class ChildState extends State } ``` -Now that `ParentState` has a record of our `ChildState`, we can load the child *through* the parent with `AppliesToChildState`. +Now that `ParentState` has a record of our `ChildState`, we can load the child *through* the parent with +`AppliesToChildState`. Let's show this by firing a `NestedStateAccessed` event with our new attribute: @@ -134,9 +126,12 @@ class NestedStateAccessed extends Event } } ``` -`AppliesToChildState` takes a `state_type` (your child state), `parent_type`, `id` (your child state id), and an optional `alias` string. -When you use `AppliesToChildState`, don't forget to also use `StateId` or [`AppliesToState`](/docs/technical/attributes#content-appliestostate) to identify the `parent_id`. +`AppliesToChildState` takes a `state_type` (your child state), `parent_type`, `id` (your child state id), and an +optional `alias` string. + +When you use `AppliesToChildState`, don't forget to also use `StateId` or [ +`AppliesToState`](/docs/technical/attributes#content-appliestostate) to identify the `parent_id`. -Verbs uses the [Symfony Serializer component](https://symfony.com/components/Serializer) to serialize your PHP Event objects to JSON. +Verbs uses the [Symfony Serializer component](https://symfony.com/components/Serializer) to serialize your PHP Event +objects to JSON. -The default normalizers should handle most stock Laravel applications, but you may need to add your own normalizers for certain object types, which you can do in `config/verbs.php`. +The default normalizers should handle most stock Laravel applications, but you may need to add your own normalizers for +certain object types, which you can do in `config/verbs.php`. -You can also use our interface `SerializedByVerbs` in tandem with trait `NormalizeToPropertiesAndClassName` on classes to support custom types. +You can also use our interface `SerializedByVerbs` in tandem with trait `NormalizeToPropertiesAndClassName` on classes +to support custom types. -You can see good implentation of this in one of our [examples](https://github.com/hirethunk/verbs/blob/main/examples/Monopoly/src/Game/Spaces/Space.php), `examples/Monopoly/src/Game/Spaces/Space.php` +You can see good implentation of this in one of +our [examples](https://github.com/hirethunk/verbs/blob/main/examples/Monopoly/src/Game/Spaces/Space.php), +`examples/Monopoly/src/Game/Spaces/Space.php` diff --git a/docs/states.md b/docs/states.md index 7acc6ac9..5e044776 100644 --- a/docs/states.md +++ b/docs/states.md @@ -151,23 +151,15 @@ Route::get('/users/{user_state}', function(UserState $user_state) { You may want a state that only needs one iteration across the entire application--this is called a singleton state. Singleton states require no id, since there is no need to differentiate among state instances. -In our events that apply to a singleton state, we simply need to use the -`AppliesToSingletonState` [attribute](/docs/technical/attributes#content-appliestosingletonstate). +To tell Verbs to treat a State as a singleton, implement the `SingletonState` interface. ```php -#[AppliesToSingletonState(CountState::class)] -class IncrementCount extends Event +class CountState extends State implements SingletonState { - public function apply(CountState $state) - { - $state->count++; - } + // ... } ``` -This event uses `AppliesToSingletonState` to tell Verbs that it should always be applied to a single `CountState` across -the entire application (as opposed to having different counts for different situations). - ### Loading the singleton state Since singleton's require no IDs, simply call the `singleton()` method. diff --git a/examples/Counter/src/Events/IncrementCount.php b/examples/Counter/src/Events/IncrementCount.php index 2f3c8fa5..8c5d1bc1 100644 --- a/examples/Counter/src/Events/IncrementCount.php +++ b/examples/Counter/src/Events/IncrementCount.php @@ -2,21 +2,21 @@ namespace Thunk\Verbs\Examples\Counter\Events; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; +use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Counter\States\CountState; /** * In our most basic example of event sourcing, we just use a single event - * to increment a counter. This event uses `AppliesToSingletonState` to tell - * Verbs that it should always be applied to a single `CountState` across the + * to increment a counter. Because CountState is a SingletonState object, + * Verbs will always apply this event to a single `CountState` across the * entire application (as opposed to having different counts for different * situations). * * Because we're using a singleton state, there is no need for the event to * have a `$count_id`. */ -#[AppliesToSingletonState(CountState::class)] +#[AppliesToState(CountState::class)] class IncrementCount extends Event { public function apply(CountState $state) diff --git a/examples/Counter/src/Events/IncrementCountTwice.php b/examples/Counter/src/Events/IncrementCountTwice.php index 8393dec3..c346eee3 100644 --- a/examples/Counter/src/Events/IncrementCountTwice.php +++ b/examples/Counter/src/Events/IncrementCountTwice.php @@ -2,11 +2,11 @@ namespace Thunk\Verbs\Examples\Counter\Events; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; +use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Counter\States\CountState; -#[AppliesToSingletonState(CountState::class)] +#[AppliesToState(CountState::class)] class IncrementCountTwice extends Event { public function handle() diff --git a/examples/Counter/src/States/CountState.php b/examples/Counter/src/States/CountState.php index a2a96d27..edd87e3c 100644 --- a/examples/Counter/src/States/CountState.php +++ b/examples/Counter/src/States/CountState.php @@ -2,9 +2,9 @@ namespace Thunk\Verbs\Examples\Counter\States; -use Thunk\Verbs\State; +use Thunk\Verbs\SingletonState; -class CountState extends State +class CountState extends SingletonState { public int $count = 0; } diff --git a/examples/Counter/tests/InitializeStateTest.php b/examples/Counter/tests/InitializeStateTest.php index 11843a70..9746d3e9 100644 --- a/examples/Counter/tests/InitializeStateTest.php +++ b/examples/Counter/tests/InitializeStateTest.php @@ -7,7 +7,7 @@ use Thunk\Verbs\Models\VerbEvent; it('State factory initializes a state', function () { - $count_state = CountState::factory()->singleton()->create([ + $count_state = CountState::factory()->create([ 'count' => 1337, ]); diff --git a/examples/Subscriptions/src/Events/GlobalReportGenerated.php b/examples/Subscriptions/src/Events/GlobalReportGenerated.php index 75e1d0f8..7f35d97e 100644 --- a/examples/Subscriptions/src/Events/GlobalReportGenerated.php +++ b/examples/Subscriptions/src/Events/GlobalReportGenerated.php @@ -2,13 +2,13 @@ namespace Thunk\Verbs\Examples\Subscriptions\Events; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; +use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Attributes\Hooks\Once; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Subscriptions\Models\Report; use Thunk\Verbs\Examples\Subscriptions\States\GlobalReportState; -#[AppliesToSingletonState(GlobalReportState::class)] +#[AppliesToState(GlobalReportState::class)] class GlobalReportGenerated extends Event { #[Once] diff --git a/examples/Subscriptions/src/Events/SubscriptionCancelled.php b/examples/Subscriptions/src/Events/SubscriptionCancelled.php index 9c800a19..e278c775 100644 --- a/examples/Subscriptions/src/Events/SubscriptionCancelled.php +++ b/examples/Subscriptions/src/Events/SubscriptionCancelled.php @@ -3,7 +3,6 @@ namespace Thunk\Verbs\Examples\Subscriptions\Events; use Thunk\Verbs\Attributes\Autodiscovery\AppliesToChildState; -use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState; use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Event; use Thunk\Verbs\Examples\Subscriptions\Models\Subscription; @@ -13,7 +12,7 @@ #[AppliesToState(state_type: SubscriptionState::class, id: 'subscription_id', alias: 'subscription')] #[AppliesToChildState(state_type: PlanReportState::class, parent_type: SubscriptionState::class, id: 'plan_id', alias: 'plan')] -#[AppliesToSingletonState(state_type: GlobalReportState::class, alias: 'report')] +#[AppliesToState(state_type: GlobalReportState::class, alias: 'report')] class SubscriptionCancelled extends Event { public int $subscription_id; diff --git a/examples/Subscriptions/src/States/GlobalReportState.php b/examples/Subscriptions/src/States/GlobalReportState.php index 127310f0..a1c3f3a9 100644 --- a/examples/Subscriptions/src/States/GlobalReportState.php +++ b/examples/Subscriptions/src/States/GlobalReportState.php @@ -5,9 +5,9 @@ use Illuminate\Support\Carbon; use Thunk\Verbs\Examples\Subscriptions\Events\SubscriptionCancelled; use Thunk\Verbs\Examples\Subscriptions\Events\SubscriptionStarted; -use Thunk\Verbs\State; +use Thunk\Verbs\SingletonState; -class GlobalReportState extends State +class GlobalReportState extends SingletonState { public int $total_subscriptions = 0; diff --git a/src/Attributes/Autodiscovery/AppliesToSingletonState.php b/src/Attributes/Autodiscovery/AppliesToSingletonState.php deleted file mode 100644 index 3d931074..00000000 --- a/src/Attributes/Autodiscovery/AppliesToSingletonState.php +++ /dev/null @@ -1,27 +0,0 @@ -state_type, State::class, true)) { - throw new InvalidArgumentException('You must pass state class names to the "AppliesToSingletonState" attribute.'); - } - } - - public function discoverState(Event $event, StateManager $manager): State - { - return $manager->singleton($this->state_type); - } -} diff --git a/src/Attributes/Autodiscovery/AppliesToState.php b/src/Attributes/Autodiscovery/AppliesToState.php index 32dd227e..7af9d5f4 100644 --- a/src/Attributes/Autodiscovery/AppliesToState.php +++ b/src/Attributes/Autodiscovery/AppliesToState.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use Thunk\Verbs\Event; use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] @@ -26,6 +27,10 @@ public function __construct( public function discoverState(Event $event, StateManager $manager): State|array { + if (is_subclass_of($this->state_type, SingletonState::class)) { + return $this->state_type::singleton(); + } + $property = $this->getStateIdProperty($event); $id = $event->{$property}; diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index aa145e18..057356d8 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -14,7 +14,6 @@ interface StoresEvents public function read( ?State $state = null, Bits|UuidInterface|AbstractUid|int|string|null $after_id = null, - bool $singleton = false ): LazyCollection; /** @param Event[] $events */ diff --git a/src/Events/VerbsStateInitialized.php b/src/Events/VerbsStateInitialized.php index 920f1974..84cd3cbc 100644 --- a/src/Events/VerbsStateInitialized.php +++ b/src/Events/VerbsStateInitialized.php @@ -4,6 +4,7 @@ use Thunk\Verbs\CommitsImmediately; use Thunk\Verbs\Event; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\Support\StateCollection; /** @template TStateType */ @@ -14,15 +15,18 @@ public function __construct( public int|string $state_id, public string $state_class, public array $state_data, - public bool $singleton = false, ) {} /** @return StateCollection */ public function states(): StateCollection { - return StateCollection::make([ - $this->singleton ? $this->state_class::singleton() : $this->state_class::load($this->state_id), - ]); + $state = is_subclass_of($this->state_class, SingletonState::class) + ? $this->state_class::singleton() + : $this->state_class::load($this->state_id); + + $state->id = $this->state_id; + + return StateCollection::make([$state]); } public function validate() diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index b68e5c1d..d90223ea 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -16,6 +16,7 @@ use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Models\VerbEvent; use Thunk\Verbs\Models\VerbStateEvent; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; use Thunk\Verbs\Support\Serializer; @@ -28,9 +29,8 @@ public function __construct( public function read( ?State $state = null, Bits|UuidInterface|AbstractUid|int|string|null $after_id = null, - bool $singleton = false, ): LazyCollection { - return $this->readEvents($state, $after_id, $singleton) + return $this->readEvents($state, $after_id) ->each(fn (VerbEvent $model) => $this->metadata->set($model->event(), $model->metadata())) ->map(fn (VerbEvent $model) => $model->event()); } @@ -50,12 +50,11 @@ public function write(array $events): bool protected function readEvents( ?State $state, Bits|UuidInterface|AbstractUid|int|string|null $after_id, - bool $singleton, ): LazyCollection { if ($state) { return VerbStateEvent::query() ->with('event') - ->unless($singleton, fn (Builder $query) => $query->where('state_id', $state->id)) + ->unless($state instanceof SingletonState, fn (Builder $query) => $query->where('state_id', $state->id)) ->where('state_type', $state::class) ->when($after_id, fn (Builder $query) => $query->whereRelation('event', 'id', '>', Id::from($after_id))) ->lazyById() diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 344448ab..e3f87a32 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -47,7 +47,12 @@ public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, str : $this->loadOne($id, $type); } - /** @param class-string $type */ + /** + * @template TStateClass of State + * + * @param class-string $type + * @return TStateClass + */ public function singleton(string $type): State { // FIXME: If the state we're loading has a last_event_id that's ahead of the registry's last_event_id, we need to re-build the state @@ -56,14 +61,14 @@ public function singleton(string $type): State return $state; } - $state = $this->snapshots->loadSingleton($type) ?? $type::make(); + $state = $this->snapshots->loadSingleton($type) ?? new $type(); $state->id ??= snowflake_id(); // We'll store a reference to it by the type for future singleton access $this->states->put($type, $state); $this->remember($state); - $this->reconstitute($state, singleton: true); + $this->reconstitute($state); return $state; } @@ -180,13 +185,13 @@ protected function loadMany(iterable $ids, string $type): StateCollection ); } - protected function reconstitute(State $state, bool $singleton = false): static + protected function reconstitute(State $state): static { // When we're replaying, the Broker is in charge of applying the correct events // to the State, so we only need to do it *outside* of replays. if (! $this->is_replaying) { $this->events - ->read(state: $state, after_id: $state->last_event_id, singleton: $singleton) + ->read(state: $state, after_id: $state->last_event_id) ->each(fn (Event $event) => $this->dispatcher->apply($event)); // It's possible for an event to mutate state out of order when reconstituting, diff --git a/src/SingletonState.php b/src/SingletonState.php new file mode 100644 index 00000000..a0167f19 --- /dev/null +++ b/src/SingletonState.php @@ -0,0 +1,51 @@ +singleton(static::class); + } + + public function resolveRouteBinding($value, $field = null) + { + return static::singleton(); + } + + public function resolveChildRouteBinding($childType, $value, $field) + { + throw new RuntimeException('Resolving child state via routing is not supported.'); + } +} diff --git a/src/State.php b/src/State.php index 17b1e57d..8d243e47 100644 --- a/src/State.php +++ b/src/State.php @@ -87,11 +87,6 @@ protected static function normalizeKey(mixed $from) : $from; } - public static function singleton(): static - { - return app(StateManager::class)->singleton(static::class); - } - public function storedEvents() { return app(StoresEvents::class) diff --git a/src/StateFactory.php b/src/StateFactory.php index 6023be7d..e41d5bb5 100644 --- a/src/StateFactory.php +++ b/src/StateFactory.php @@ -42,7 +42,6 @@ public function __construct( protected Collection $transformations = new Collection, protected ?int $count = null, protected int|string|null $id = null, - protected bool $singleton = false, protected ?Generator $faker = null, protected Collection $makeCallbacks = new Collection, protected Collection $createCallbacks = new Collection, @@ -93,11 +92,6 @@ public function id(Bits|UuidInterface|AbstractUid|int|string $id): static return $this->clone(['id' => Id::from($id)]); } - public function singleton(bool $singleton = true): static - { - return $this->clone(['singleton' => $singleton]); - } - /** @return TStateType|StateCollection */ public function create(array $data = [], Bits|UuidInterface|AbstractUid|int|string|null $id = null): State|StateCollection { @@ -121,7 +115,7 @@ public function create(array $data = [], Bits|UuidInterface|AbstractUid|int|stri return StateCollection::make([$this->createState()]); } - if ($this->singleton) { + if (is_subclass_of($this->state_class, SingletonState::class)) { throw new RuntimeException('You cannot create multiple singleton states of the same type.'); } @@ -146,7 +140,6 @@ protected function createState(): State state_id: $this->id ?? Id::make(), state_class: $this->state_class, state_data: $this->getRawData(), - singleton: $this->singleton, ) : $this->initial_event::fire( ...$this->getRawData(), @@ -179,7 +172,6 @@ protected function clone(array $with = []): static transformations: $with['transformations'] ?? $this->transformations, count: $with['count'] ?? $this->count, id: $with['id'] ?? $this->id, - singleton: $with['singleton'] ?? $this->singleton, faker: $with['faker'] ?? $this->faker, makeCallbacks: $with['makeCallbacks'] ?? $this->makeCallbacks, createCallbacks: $with['createCallbacks'] ?? $this->createCallbacks, diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index 508ac711..186b569c 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -14,6 +14,7 @@ use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Lifecycle\MetadataManager; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; class EventStoreFake implements StoresEvents @@ -32,15 +33,14 @@ public function __construct( public function read( ?State $state = null, UuidInterface|string|int|AbstractUid|Bits|null $after_id = null, - bool $singleton = false ): LazyCollection { return LazyCollection::make($this->events) ->flatten() ->when($after_id, function (LazyCollection $events, $after_id) { return $events->filter(fn (Event $event) => $event->id > Id::from($after_id)); }) - ->when($state, function (LazyCollection $events, State $state) use ($singleton) { - return $singleton + ->when($state, function (LazyCollection $events, State $state) { + return $state instanceof SingletonState ? $events->filter(fn (Event $event) => $event->state($state::class) !== null) : $events->filter(fn (Event $event) => $event->state($state::class)?->id === $state->id); }) diff --git a/tests/Unit/ConcurrencyTest.php b/tests/Unit/ConcurrencyTest.php index 00dc7fe9..76b59833 100644 --- a/tests/Unit/ConcurrencyTest.php +++ b/tests/Unit/ConcurrencyTest.php @@ -4,7 +4,7 @@ use Thunk\Verbs\Exceptions\ConcurrencyException; use Thunk\Verbs\Lifecycle\EventStore; use Thunk\Verbs\Models\VerbEvent; -use Thunk\Verbs\State; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\Support\StateCollection; it('does not throw on sequential events', function () { @@ -49,4 +49,4 @@ public function states(): StateCollection } } -class ConcurrencyTestState extends State {} +class ConcurrencyTestState extends SingletonState {} diff --git a/tests/Unit/FactoryTest.php b/tests/Unit/FactoryTest.php index e6897359..c846be11 100644 --- a/tests/Unit/FactoryTest.php +++ b/tests/Unit/FactoryTest.php @@ -2,6 +2,7 @@ use Illuminate\Support\Collection; use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; use Thunk\Verbs\StateFactory; @@ -56,11 +57,11 @@ }); test('it can create a singleton state', function () { - $singleton_state = FactoryTestState::factory()->singleton()->create(); + $singleton_state = FactoryTestSingletonState::factory()->create(); expect($singleton_state->id)->not->toBeNull(); - $retreived_state = app(StateManager::class)->singleton(FactoryTestState::class); + $retreived_state = app(StateManager::class)->singleton(FactoryTestSingletonState::class); expect($retreived_state)->toBe($singleton_state); }); @@ -91,6 +92,11 @@ class FactoryTestState extends State public string $name; } +class FactoryTestSingletonState extends SingletonState +{ + public string $name; +} + class CustomFactoryTestState extends State { public string $name; diff --git a/tests/Unit/UseStatesDirectlyInEventsTest.php b/tests/Unit/UseStatesDirectlyInEventsTest.php index 110c0475..4e743dce 100644 --- a/tests/Unit/UseStatesDirectlyInEventsTest.php +++ b/tests/Unit/UseStatesDirectlyInEventsTest.php @@ -1,6 +1,7 @@ user_request->acknowledged = true; + } +} + class UserRequestsProcessed extends Event { public function __construct( From fe66df141191dd25dda8b0cde6666cc6f7881aa6 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Fri, 20 Sep 2024 14:30:23 -0400 Subject: [PATCH 19/24] A little bit of docs --- docs/states.md | 12 +++++------- docs/testing.md | 42 +++++++++++++++++++++++------------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/states.md b/docs/states.md index 5e044776..1598cadf 100644 --- a/docs/states.md +++ b/docs/states.md @@ -118,9 +118,6 @@ in [event lifecycle](/docs/technical/event-lifecycle). ## Loading a State -All state instances are singletons, scoped to an [id](/docs/technical/ids). i.e. say we had a Card Game app--if we apply -a `CardDiscarded` event, we make sure only the `CardState` state with its globablly unique `card_id` is affected. - To retrieve the State, simply call load: ```php @@ -148,10 +145,10 @@ Route::get('/users/{user_state}', function(UserState $user_state) { ## Singleton States -You may want a state that only needs one iteration across the entire application--this is called a singleton state. -Singleton states require no id, since there is no need to differentiate among state instances. +You may want a state that only needs one iteration across the entire application—this is called a singleton state. +Singleton states require no ID because there is only ever one copy in existence across your entire app. -To tell Verbs to treat a State as a singleton, implement the `SingletonState` interface. +To tell Verbs to treat a State as a singleton, extend the `SingletonState` class, rather than `State`. ```php class CountState extends State implements SingletonState @@ -162,7 +159,8 @@ class CountState extends State implements SingletonState ### Loading the singleton state -Since singleton's require no IDs, simply call the `singleton()` method. +Since singletons require no IDs, simply call the `singleton()` method. Trying to load a singleton state in any +other way will result in a `BadMethodCall` exception. ```php YourState::singleton(); diff --git a/docs/testing.md b/docs/testing.md index 1534ac23..fa757dbe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,7 +4,8 @@ We enjoy improving Verbs by providing easy, readable testing affordances. When testing verbs events, you'll need to call [commit](/docs/reference/events#content-committing) manually. -You may continue manually adding `Verbs::commit()` after each `Event::fire()` method; however, we've created `Verbs::commitImmediately` to issue a blanket commit on all events you fire in tests. +You may continue manually adding `Verbs::commit()` after each `Event::fire()` method; however, we've created +`Verbs::commitImmediately` to issue a blanket commit on all events you fire in tests. ```php beforeEach(function () { @@ -19,7 +20,8 @@ You may also implement the `CommitsImmediately` interface directly on an Event. The following Test `assert()` methods are available to thoroughly check your committing granularly. -Before using these methods, add `Verbs::fake()` to your test so Verbs can set up a fake event store to isolate the testing environment. +Before using these methods, add `Verbs::fake()` to your test so Verbs can set up a fake event store to isolate the +testing environment. ```php Verbs::assertNothingCommitted(); @@ -29,9 +31,11 @@ Verbs::assertNotCommitted(...); ## State Factories -In tests, you may find yourself needing to fire and commit several events in order to bring your State to the point where it actually needs testing. +In tests, you may find yourself needing to fire and commit several events in order to bring your State to the point +where it actually needs testing. -The `State::factory()` method allows you to bypass manually building up the State, functioning similarly to `Model::factory()`. +The `State::factory()` method allows you to bypass manually building up the State, functioning similarly to +`Model::factory()`. This allows you to call: @@ -58,15 +62,20 @@ Or, in the case of a [singleton state](/docs/reference/states#content-singleton- ChurnState::factory()->create(['churn' => 40]); ``` -Next, we'll get into how these factories work, and continue after with some [Verbs factory methods](testing#content-factory-methods) you may already be familiar with from Eloquent factories. +Next, we'll get into how these factories work, and continue after with +some [Verbs factory methods](testing#content-factory-methods) you may already be familiar with from Eloquent factories. ### `VerbsStateInitialized` -Under the hood, these methods will fire (and immediately commit) a new `VerbsStateInitialized` event, which will fire onto the given state, identified by the id argument (if id is null, we assume it is a singleton) and return a copy of that state. +Under the hood, these methods will fire (and immediately commit) a new `VerbsStateInitialized` event, which will fire +onto the given state, identified by the id argument (if id is null, we assume it is a singleton) and return a copy of +that state. -This is primarily designed for booting up states for testing. If you are migrating non-event-sourced codebases to Verbs, when there is a need to initiate a state for legacy data, it's better to create a custom `MigratedFromLegacy` event. +This is primarily designed for booting up states for testing. If you are migrating non-event-sourced codebases to Verbs, +when there is a need to initiate a state for legacy data, it's better to create a custom `MigratedFromLegacy` event. -You may also change the initial event fired from the StateFactory from `VerbsStateInitialized` to an event class of your choosing by setting an `$intial_event` property on your State Factory. +You may also change the initial event fired from the StateFactory from `VerbsStateInitialized` to an event class of your +choosing by setting an `$intial_event` property on your State Factory. ```php class ExampleStateFactory extends StateFactory @@ -75,11 +84,13 @@ class ExampleStateFactory extends StateFactory } ``` -`VerbsStateInitialized` implements the `CommitsImmediately` interface detailed [above](testing#content-verbscommit), so if you change from this initial event makes sure to extend the interface on your replacement event. +`VerbsStateInitialized` implements the `CommitsImmediately` interface detailed [above](testing#content-verbscommit), so +if you change from this initial event makes sure to extend the interface on your replacement event. ### Factory Methods -Some methods accept Verbs [IDs](/docs/technical/ids), which, written longform, could be any of these types: `Bits|UuidInterface|AbstractUid|int|string`. +Some methods accept Verbs [IDs](/docs/technical/ids), which, written longform, could be any of these types: +`Bits|UuidInterface|AbstractUid|int|string`. For brevity, this will be abbreviated in the following applicable methods as `Id`. @@ -99,14 +110,6 @@ Set the state ID explicitly (cannot be used with `count`). UserState::factory()->id(123)->create(); ``` -#### `singleton()` - -Mark that this is a singleton state (cannot be used with `count`). - -```php -UserState::factory()->singleton()->create(); -``` - #### `state(callable|array $data)` Default data (will be overridden by `create`). @@ -168,7 +171,8 @@ If you'd like to chain behavior after your Factory `create()` executes, do so in #### `configure()` -The configure method in your custom factory allows you to set `afterMaking` and `afterCreating` effects (see [laravel docs](https://laravel.com/docs/11.x/eloquent-factories#factory-callbacks)). +The configure method in your custom factory allows you to set `afterMaking` and `afterCreating` effects ( +see [laravel docs](https://laravel.com/docs/11.x/eloquent-factories#factory-callbacks)). ##### `afterMaking()` & `afterCreating()` From 5cd6c4506a80dd9f349ccd02fc6e99434d349f95 Mon Sep 17 00:00:00 2001 From: inxilpro Date: Fri, 20 Sep 2024 18:30:45 +0000 Subject: [PATCH 20/24] Fix styling --- src/Lifecycle/StateManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index e3f87a32..6765dbb6 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -61,7 +61,7 @@ public function singleton(string $type): State return $state; } - $state = $this->snapshots->loadSingleton($type) ?? new $type(); + $state = $this->snapshots->loadSingleton($type) ?? new $type; $state->id ??= snowflake_id(); // We'll store a reference to it by the type for future singleton access From 3ea993a3abc891a78b898f2683a3b7ccbb95e603 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 25 Sep 2024 13:26:47 -0400 Subject: [PATCH 21/24] Start to refactor --- src/Contracts/StoresEvents.php | 2 +- src/Lifecycle/AggregateStateSummary.php | 83 ++++++++++++++-- src/Lifecycle/EventStore.php | 58 +---------- src/Lifecycle/StateManager.php | 7 +- src/Support/StateIdentity.php | 35 +++++++ src/Testing/EventStoreFake.php | 5 +- tests/Unit/AggregateStateSummaryTest.php | 120 +++++++++++++++++++++++ 7 files changed, 241 insertions(+), 69 deletions(-) create mode 100644 src/Support/StateIdentity.php create mode 100644 tests/Unit/AggregateStateSummaryTest.php diff --git a/src/Contracts/StoresEvents.php b/src/Contracts/StoresEvents.php index a6fe94f4..47619319 100644 --- a/src/Contracts/StoresEvents.php +++ b/src/Contracts/StoresEvents.php @@ -22,5 +22,5 @@ public function get(iterable $ids): LazyCollection; /** @param Event[] $events */ public function write(array $events): bool; - public function summarize(State $state, bool $singleton = false): AggregateStateSummary; + public function summarize(State ...$states): AggregateStateSummary; } diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index ff7ba66d..2ff61a09 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -2,17 +2,88 @@ namespace Thunk\Verbs\Lifecycle; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; +use Thunk\Verbs\Support\StateIdentity; class AggregateStateSummary { + public static function summarize(State ...$states): static + { + $summary = new static( + original_states: Collection::make($states), + related_event_ids: new Collection(), + related_states: Collection::make($states)->map(StateIdentity::from(...)), + ); + + return $summary->discover(); + } + + /** + * @param Collection $original_states + * @param Collection $related_event_ids + * @param Collection $related_states + */ public function __construct( - public readonly State $state, - public readonly Collection $related_event_ids, - public readonly Collection $related_state_ids, - public readonly ?int $min_applied_event_id, - public readonly ?int $max_applied_event_id, - public readonly bool $out_of_sync, + public Collection $original_states = new Collection(), + public Collection $related_event_ids = new Collection(), + public Collection $related_states = new Collection(), ) {} + + protected function discover(): static + { + $this->discoverNewEventIds(); + + do { + $continue = $this->discoverNewStates() && $this->discoverNewEventIds(); + } while ($continue); + + return $this; + } + + protected function discoverNewEventIds(): bool + { + $new_event_ids = VerbStateEvent::query() + ->distinct() + ->select('event_id') + ->whereNotIn('event_id', $this->related_event_ids) + ->where(fn (Builder $query) => $this->related_states->each( + fn ($state) => $query->orWhere(fn (Builder $query) => $this->addConstraint($state, $query))) + ) + ->toBase() + ->pluck('event_id'); + + $this->related_event_ids = $this->related_event_ids->merge($new_event_ids); + + return $new_event_ids->isNotEmpty(); + } + + protected function discoverNewStates(): bool + { + $discovered_states = VerbStateEvent::query() + ->distinct() + ->select(['state_id', 'state_type']) + ->whereIn('event_id', $this->related_event_ids) + ->where(fn (Builder $query) => $this->related_states->each( + fn ($state) => $query->whereNot(fn (Builder $query) => $this->addConstraint($state, $query))) + ) + ->toBase() + ->distinct() + ->get() + ->map(StateIdentity::from(...)); + + $this->related_states = $this->related_states->merge($discovered_states); + + return $discovered_states->isNotEmpty(); + } + + protected function addConstraint(StateIdentity $state, Builder $query): Builder + { + $query->where('state_type', '=', $state->state_type); + $query->where('state_id', '=', $state->state_id); + + return $query; + } } diff --git a/src/Lifecycle/EventStore.php b/src/Lifecycle/EventStore.php index e8f31393..eb7e3527 100644 --- a/src/Lifecycle/EventStore.php +++ b/src/Lifecycle/EventStore.php @@ -16,7 +16,6 @@ use Thunk\Verbs\Exceptions\ConcurrencyException; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Models\VerbEvent; -use Thunk\Verbs\Models\VerbSnapshot; use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; @@ -58,62 +57,9 @@ public function write(array $events): bool && VerbStateEvent::insert($this->formatRelationshipsForWrite($events)); } - public function summarize(State $state, bool $singleton = false): AggregateStateSummary + public function summarize(State ...$states): AggregateStateSummary { - // FIXME: We probably either need to know the state types or go by snapshot ID - - $known_state_ids = $singleton ? new Collection : Collection::make([$state->id]); - $known_event_ids = VerbStateEvent::query() - ->distinct() - ->select('event_id') - ->where('state_type', $state::class) - ->unless($singleton, fn (Builder $query) => $query->where('state_id', $state->id)) - ->toBase() - ->pluck('event_id'); - - do { - $discovered_state_ids = VerbStateEvent::query() - ->distinct() - ->select('state_id') - ->whereIn('event_id', $known_event_ids) - ->whereNotIn('state_id', $known_state_ids) - ->toBase() - ->distinct() - ->pluck('state_id'); - - $known_state_ids = $known_state_ids->merge($discovered_state_ids); - - $discovered_event_ids = VerbStateEvent::query() - ->distinct() - ->select('event_id') - ->whereNotIn('event_id', $known_event_ids) - ->whereIn('state_id', $known_state_ids) - ->toBase() - ->pluck('event_id'); - - $known_event_ids = $known_event_ids->merge($discovered_event_ids); - - } while ($discovered_event_ids->isNotEmpty()); - - $aggregates = VerbSnapshot::query() - ->toBase() - ->tap(fn (BaseBuilder $query) => $query->select([ - $this->aggregateExpression($query, 'id', 'count'), - $this->aggregateExpression($query, 'last_event_id', 'min'), - $this->aggregateExpression($query, 'last_event_id', 'max'), - ])) - ->whereIn('state_id', $known_state_ids) - ->first(); - - return new AggregateStateSummary( - state: $state, - related_event_ids: $known_event_ids, - related_state_ids: $known_state_ids, - min_applied_event_id: $aggregates->min_last_event_id, - max_applied_event_id: $aggregates->max_last_event_id, - out_of_sync: ($aggregates->count_id && (int) $aggregates->count_id !== count($known_state_ids)) - || $aggregates->min_last_event_id !== $aggregates->max_last_event_id, - ); + return AggregateStateSummary::summarize(...$states); } protected function readEvents( diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 6f8811a9..f568fd40 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -210,10 +210,9 @@ protected function reconstitute(State $state): static $summary = $this->events->summarize($state); - // FIXME: - if ($summary->out_of_sync) { - $this->snapshots->delete(...$summary->related_state_ids); - } + // FIXME: We probably need to re-write all the snapshots after we're done + // FIXME: Swap out existing state manager, push all related states into new state manager + // FIXME: run all the event on them, swap them out $this->events->get($summary->related_event_ids) ->filter(function (Event $event) { diff --git a/src/Support/StateIdentity.php b/src/Support/StateIdentity.php new file mode 100644 index 00000000..08247ae3 --- /dev/null +++ b/src/Support/StateIdentity.php @@ -0,0 +1,35 @@ + $source, + $source instanceof State => new static(state_type: $source::class, state_id: $source->id), + default => static::fromGenericObject($source), + }; + } + + protected static function fromGenericObject(object $source): static + { + $state_id = data_get($source, 'state_id'); + $state_type = data_get($source, 'state_type'); + + if (is_int($state_id) && is_string($state_type)) { + return new static(state_type: $state_type, state_id: $state_id); + } + + throw new InvalidArgumentException('State identity objects must have a "state_id" and "state_type" value.'); + } + + public function __construct( + public readonly string $state_type, + public readonly int|string $state_id, + ) {} +} diff --git a/src/Testing/EventStoreFake.php b/src/Testing/EventStoreFake.php index c962e34d..d9fb6523 100644 --- a/src/Testing/EventStoreFake.php +++ b/src/Testing/EventStoreFake.php @@ -58,9 +58,10 @@ public function write(array $events): bool return true; } - public function summarize(State $state, bool $singleton = false): AggregateStateSummary + public function summarize(State ...$states): AggregateStateSummary { - return new AggregateStateSummary($state, collect(), collect(), null, null); + // FIXME + return new AggregateStateSummary($states[0], collect(), collect(), null, null); } public function get(iterable $ids): LazyCollection diff --git a/tests/Unit/AggregateStateSummaryTest.php b/tests/Unit/AggregateStateSummaryTest.php new file mode 100644 index 00000000..3969773b --- /dev/null +++ b/tests/Unit/AggregateStateSummaryTest.php @@ -0,0 +1,120 @@ + $matching_state_id) { + foreach ($matching_event_ids as $matching_event_id) { + VerbStateEvent::insert([ + 'id' => snowflake_id(), + 'event_id' => $matching_event_id, + 'state_id' => $matching_state_id, + 'state_type' => $matching_state_types[$state_index % count($matching_state_types)], + ]); + } + } + + $target_state = new AggregateStateSummaryTestState1(); + $target_state->id = 10; + + $summary = AggregateStateSummary::summarize($target_state); + + expect($summary->original_states->all())->toBe([$target_state]) + ->and($summary->related_states)->toHaveCount(5) + ->and($summary->related_event_ids)->toHaveCount(5); + + $related_state_ids = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_id) + ->sort() + ->toArray(); + + $related_state_types = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_type) + ->unique() + ->sort() + ->toArray(); + + expect($related_state_ids)->toBe($matching_state_ids) + ->and($related_state_types)->toBe($matching_state_types); +}); + +test('it finds the correct states and events for multiple states', function () { + $matching_state_types = [ + AggregateStateSummaryTestState1::class, + AggregateStateSummaryTestState2::class, + AggregateStateSummaryTestState3::class, + ]; + $matching_state_ids = [10, 11, 12, 13, 14]; + $matching_event_ids = [100, 101, 102, 103, 105]; + + $other_state_types = [ + AggregateStateSummaryTestState4::class, + AggregateStateSummaryTestState5::class, + AggregateStateSummaryTestState6::class, + ]; + $other_state_ids = [20, 21, 22, 23, 24]; + $other_event_ids = [200, 201, 202, 203, 205]; + + foreach ($matching_state_ids as $state_index => $matching_state_id) { + foreach ($matching_event_ids as $matching_event_id) { + VerbStateEvent::insert([ + 'id' => snowflake_id(), + 'event_id' => $matching_event_id, + 'state_id' => $matching_state_id, + 'state_type' => $matching_state_types[$state_index % count($matching_state_types)], + ]); + } + } + + $target_state1 = new AggregateStateSummaryTestState1(); + $target_state1->id = 10; + + $target_state2 = new AggregateStateSummaryTestState2(); + $target_state2->id = 11; + + $summary = AggregateStateSummary::summarize($target_state1, $target_state2); + + expect($summary->original_states->all())->toBe([$target_state1, $target_state2]) + ->and($summary->related_states)->toHaveCount(5) + ->and($summary->related_event_ids)->toHaveCount(5); + + $related_state_ids = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_id) + ->sort() + ->toArray(); + + $related_state_types = $summary->related_states + ->map(fn (StateIdentity $state) => $state->state_type) + ->unique() + ->sort() + ->toArray(); + + expect($related_state_ids)->toBe($matching_state_ids) + ->and($related_state_types)->toBe($matching_state_types); +}); + +class AggregateStateSummaryTestState1 extends State {} +class AggregateStateSummaryTestState2 extends State {} +class AggregateStateSummaryTestState3 extends State {} +class AggregateStateSummaryTestState4 extends State {} +class AggregateStateSummaryTestState5 extends State {} +class AggregateStateSummaryTestState6 extends State {} From 8569f3e8d82bd605dad8ab8e97996dbe73352f6b Mon Sep 17 00:00:00 2001 From: inxilpro Date: Thu, 19 Dec 2024 18:46:10 +0000 Subject: [PATCH 22/24] Fix styling --- src/Lifecycle/AggregateStateSummary.php | 8 ++++---- tests/Unit/AggregateStateSummaryTest.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 2ff61a09..78b82b5e 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -14,7 +14,7 @@ public static function summarize(State ...$states): static { $summary = new static( original_states: Collection::make($states), - related_event_ids: new Collection(), + related_event_ids: new Collection, related_states: Collection::make($states)->map(StateIdentity::from(...)), ); @@ -27,9 +27,9 @@ public static function summarize(State ...$states): static * @param Collection $related_states */ public function __construct( - public Collection $original_states = new Collection(), - public Collection $related_event_ids = new Collection(), - public Collection $related_states = new Collection(), + public Collection $original_states = new Collection, + public Collection $related_event_ids = new Collection, + public Collection $related_states = new Collection, ) {} protected function discover(): static diff --git a/tests/Unit/AggregateStateSummaryTest.php b/tests/Unit/AggregateStateSummaryTest.php index 3969773b..d12a45b3 100644 --- a/tests/Unit/AggregateStateSummaryTest.php +++ b/tests/Unit/AggregateStateSummaryTest.php @@ -33,7 +33,7 @@ } } - $target_state = new AggregateStateSummaryTestState1(); + $target_state = new AggregateStateSummaryTestState1; $target_state->id = 10; $summary = AggregateStateSummary::summarize($target_state); @@ -85,10 +85,10 @@ } } - $target_state1 = new AggregateStateSummaryTestState1(); + $target_state1 = new AggregateStateSummaryTestState1; $target_state1->id = 10; - $target_state2 = new AggregateStateSummaryTestState2(); + $target_state2 = new AggregateStateSummaryTestState2; $target_state2->id = 11; $summary = AggregateStateSummary::summarize($target_state1, $target_state2); From 399288cbad5090c94626d3010905283b13ac6682 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Thu, 19 Dec 2024 16:38:28 -0500 Subject: [PATCH 23/24] wip --- src/Lifecycle/AggregateStateSummary.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 78b82b5e..4479c5b6 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -70,9 +70,7 @@ protected function discoverNewStates(): bool fn ($state) => $query->whereNot(fn (Builder $query) => $this->addConstraint($state, $query))) ) ->toBase() - ->distinct() - ->get() - ->map(StateIdentity::from(...)); + ->chunkMap(StateIdentity::from(...)); $this->related_states = $this->related_states->merge($discovered_states); From 00d713ffc74d86bfc40fac4442b6c7ea8f297d40 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Mon, 13 Jan 2025 17:03:04 -0500 Subject: [PATCH 24/24] wip Co-Authored-By: Daniel Coulbourne <429010+DanielCoulbourne@users.noreply.github.com> Co-Authored-By: Skyler Katz --- src/Lifecycle/AggregateStateSummary.php | 3 + src/Lifecycle/NullSnapshotStore.php | 38 ++++++++++++ src/Lifecycle/StateManager.php | 80 +++++++++++++++---------- src/Support/EventStateRegistry.php | 1 + tests/Unit/StateReconstitutionTest.php | 31 +++++++--- 5 files changed, 114 insertions(+), 39 deletions(-) create mode 100644 src/Lifecycle/NullSnapshotStore.php diff --git a/src/Lifecycle/AggregateStateSummary.php b/src/Lifecycle/AggregateStateSummary.php index 4479c5b6..24d3d8f0 100644 --- a/src/Lifecycle/AggregateStateSummary.php +++ b/src/Lifecycle/AggregateStateSummary.php @@ -40,6 +40,8 @@ protected function discover(): static $continue = $this->discoverNewStates() && $this->discoverNewEventIds(); } while ($continue); + $this->related_event_ids = $this->related_event_ids->sort(); + return $this; } @@ -63,6 +65,7 @@ protected function discoverNewEventIds(): bool protected function discoverNewStates(): bool { $discovered_states = VerbStateEvent::query() + ->orderBy('id') ->distinct() ->select(['state_id', 'state_type']) ->whereIn('event_id', $this->related_event_ids) diff --git a/src/Lifecycle/NullSnapshotStore.php b/src/Lifecycle/NullSnapshotStore.php new file mode 100644 index 00000000..5d4879c0 --- /dev/null +++ b/src/Lifecycle/NullSnapshotStore.php @@ -0,0 +1,38 @@ +remember($state); $this->reconstitute($state); - return $state; + return $this->states->get($key); // FIXME } /** @param class-string $type */ @@ -193,59 +193,75 @@ protected function loadMany(iterable $ids, string $type): StateCollection // At this point, all the states should be in our cache, so we can just load everything return StateCollection::make( - $ids->map(fn ($id) => $this->states->get($this->key($id, $type))) + $ids->map(fn ($id) => $this->states->get($this->key($id, $type))), ); } protected function reconstitute(State $state): static { - // When we're replaying, the Broker is in charge of applying the correct events - // to the State, so we need to skip during replays. Similarly, if we're already - // reconstituting in a recursive call, the root call is responsible for applying - // events, so we should also skip in that case. + // FIXME: Only run this if the state is out of date + if (! $this->needsReconstituting($state)) { + // dump('skipping: everything in sync'); + return $this; + } if (! $this->is_replaying && ! $this->is_reconstituting) { + $real_registry = app(EventStateRegistry::class); + try { $this->is_reconstituting = true; $summary = $this->events->summarize($state); - // FIXME: We probably need to re-write all the snapshots after we're done - // FIXME: Swap out existing state manager, push all related states into new state manager - // FIXME: run all the event on them, swap them out - - $this->events->get($summary->related_event_ids) - ->filter(function (Event $event) { - $last_event_ids = $event->states() - ->map(fn (State $state) => $state->last_event_id) - ->filter(); - - $min = $last_event_ids->min() ?? PHP_INT_MIN; - $max = $last_event_ids->max() ?? PHP_INT_MIN; + [$temp_manager] = $this->bindNewEmptyStateManager(); - // If all states have had this or future events applied, just ignore them - if ($min >= $event->id && $max >= $event->id) { - return false; - } - - // We should never be in a situation where some events are ahead and - // others are behind, so if that's the case we'll throw an exception - if ($max > $event->id && $min <= $event->id) { - throw new RuntimeException('Trying to apply an event to states that are out of sync.'); - } - - return true; - }) + $this->events + ->get($summary->related_event_ids) ->each($this->dispatcher->apply(...)); + foreach ($temp_manager->states->all() as $key => $state) { + $this->states->put($key, $state); + } + } finally { $this->is_reconstituting = false; + + app()->instance(StateManager::class, $this); + app()->instance(EventStateRegistry::class, $real_registry); } } return $this; } + protected function needsReconstituting(State $state): bool + { + $max_id = VerbStateEvent::query() + ->where('state_id', $state->id) + ->where('state_type', $state::class) + ->max('event_id'); + + return $max_id !== $state->last_event_id; + } + + protected function bindNewEmptyStateManager() + { + $temp_manager = new StateManager( + dispatcher: $this->dispatcher, + snapshots: new NullSnapshotStore, + events: $this->events, + states: new StateInstanceCache, + ); + $temp_manager->is_reconstituting = true; // FIXME + + $temp_registry = new EventStateRegistry($temp_manager); + + app()->instance(StateManager::class, $temp_manager); + app()->instance(EventStateRegistry::class, $temp_registry); + + return [$temp_manager, $temp_registry]; + } + protected function remember(State $state): State { $key = $this->key($state->id, $state::class); diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 533c992f..77382c4c 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -43,6 +43,7 @@ public function getStates(Event $event): StateCollection protected function discoverStates(Event $event): StateCollection { + dump('Discovering state: '.$event::class." ($event->id)"); $discovered = new StateCollection; $deferred = new StateCollection; diff --git a/tests/Unit/StateReconstitutionTest.php b/tests/Unit/StateReconstitutionTest.php index 9cf852f3..53face52 100644 --- a/tests/Unit/StateReconstitutionTest.php +++ b/tests/Unit/StateReconstitutionTest.php @@ -28,6 +28,11 @@ * - One of those Event::apply methods requires state1 and state2, so we need to load state2 * - Reconstituting state2 re-runs the same apply method on state2 before also running it on state1 * - Double-apply happens + * + * ALTERNATE TEST?: + * + * - LeftState and RightState + * - IncrementLeftByRight and IncrementRightByLeft */ // FIXME: We need to account for partially up-to-date snapshots that only need *some* events applied but not all @@ -61,11 +66,16 @@ }); test('partially up-to-date snapshots', function () { - StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=1 - StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=null, 2=2 + // event 2 increments state 2 + // event 1 adds state 2 + state 1, then increments state 2 + + $event1 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=0, 2=1 + $event2 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=0, 2=2 $event3 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=2, 2=3 - StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 - StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + $event4 = StateReconstitutionTestEvent2::fire(state2_id: 2); // 1=2, 2=4 + $event5 = StateReconstitutionTestEvent1::fire(state1_id: 1, state2_id: 2); // 1=6, 2=5 + + dump([$event1->id, $event2->id, $event3->id, $event4->id, $event5->id]); Verbs::commit(); @@ -75,6 +85,8 @@ expect($state1->counter)->toBe(6) ->and($state2->counter)->toBe(5); + // Reset the snapshots to what they looked like at event 3 + $snapshot1 = VerbSnapshot::query()->where('state_id', 1)->sole(); $snapshot1->update([ 'data' => '{"counter":2}', @@ -92,6 +104,9 @@ $state1 = StateReconstitutionTestState1::load(1); $state2 = StateReconstitutionTestState2::load(2); + dump($state1); + dump(VerbSnapshot::all()->toArray()); + expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); }); @@ -156,6 +171,8 @@ $state1 = StateReconstitutionTestState1::load(1); $state2 = StateReconstitutionTestState2::load(2); + dump(app(StateManager::class)); + expect($state1->counter)->toBe(6); expect($state2->counter)->toBe(5); }); @@ -180,9 +197,9 @@ class StateReconstitutionTestEvent1 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState1 $state1, StateReconstitutionTestState2 $state2): void { - // dump("[event 1] incrementing \$state1->counter from {$state1->counter} to ({$state1->counter} + {$state2->counter})"); + dump("[event 1] incrementing \$state1->counter from {$state1->counter} to ({$state1->counter} + {$state2->counter})"); $state1->counter = $state1->counter + $state2->counter; - // dump("[event 1] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); + dump("[event 1] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } } @@ -194,7 +211,7 @@ class StateReconstitutionTestEvent2 extends \Thunk\Verbs\Event public function apply(StateReconstitutionTestState2 $state2): void { - // dump("[event 2] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); + dump("[event 2] incrementing \$state2->counter from {$state2->counter} to \$state2->counter++"); $state2->counter++; } }