diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c5b05a88 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Verbs is a Laravel package that provides event sourcing capabilities. It focuses on developer experience, following +Laravel conventions, and minimizing boilerplate. + +## Code Rules + +- Never use `private` or `readonly` keywords +- Never use strict types +- Values are `snake_case` +- Anything callable is `camelCase` (even if it's a variable) +- Paths are `kebab-case` (URLs, files, etc) +- Only apply docblocks where they provide useful IDE/static analysis value + +## Testing + +The project uses Pest PHP for testing. Key testing patterns: + +```php +// Use Verbs::fake() to prevent database writes during tests +Verbs::fake(); + +// Use Verbs::commitImmediately() for integration tests +Verbs::commitImmediately(); + +// Create test states using factories +CustomerState::factory()->id($id)->create(); +``` + +## High-Level Architecture + +### Core Concepts + +1. **Events**: Immutable records of what happened in the system + - Located in `src/Events/` + - Must extend `Verbs\Event` + - Can implement `boot()`, `authorize()`, `validate()`, `apply()`, and `handle()` methods + +2. **States**: Aggregate event data over time + - Located in `src/States/` + - Must extend `Verbs\State` + - Use `#[StateId]` attribute to specify which event property contains the state ID + +3. **Storage**: Three-table structure + - `verb_events`: All events with metadata + - `verb_snapshots`: State snapshots for performance + - `verb_state_events`: Event-to-state mappings + +### Key Directories + +- `src/`: Main package source code + - `Attributes/`: PHP 8 attributes for configuration + - `Commands/`: Artisan commands + - `Contracts/`: Interfaces + - `Events/`: Base event classes and utilities + - `Facades/`: Laravel facades + - `Models/`: Eloquent models for storage + - `States/`: Base state classes + - `Support/`: Utilities and helpers +- `tests/`: Pest tests organized by feature +- `examples/`: Complete example implementations (Bank, Cart, etc.) + +### Important Patterns + +1. **Event Lifecycle**: boot -> authorize → validate → apply → handle +2. **Attribute Usage**: `#[StateId]`, `#[AppliesToState]`, `#[AppliesToSingletonState]` +3. **Serialization**: Custom normalizers in `src/Support/Normalization/` +4. **Replay Safety**: Use `#[Once]` annotations and `Verbs::unlessReplaying()` for side effects + +## Development Guidelines + +- Follow Laravel package conventions +- Use Pest for all new tests +- Run `composer format` before committing +- Ensure compatibility with PHP 8.1+ and Laravel 10.x, 11.x, 12.x +- Test against SQLite, MySQL, and PostgreSQL when modifying storage logic diff --git a/examples/Bank/tests/BankAccountTest.php b/examples/Bank/tests/BankAccountTest.php index 8184f98c..a09e76aa 100644 --- a/examples/Bank/tests/BankAccountTest.php +++ b/examples/Bank/tests/BankAccountTest.php @@ -3,6 +3,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Mail; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Examples\Bank\Events\AccountOpened; use Thunk\Verbs\Examples\Bank\Events\MoneyDeposited; use Thunk\Verbs\Examples\Bank\Events\MoneyWithdrawn; @@ -11,7 +12,6 @@ use Thunk\Verbs\Examples\Bank\Models\User; use Thunk\Verbs\Examples\Bank\States\AccountState; use Thunk\Verbs\Facades\Verbs; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Models\VerbEvent; test('a bank account can be opened and interacted with', function () { @@ -99,7 +99,7 @@ // We'll also confirm that the state is correctly loaded without snapshots - app(StateManager::class)->reset(include_storage: true); + app(TracksState::class)->reset(include_storage: true); $account_state = AccountState::load($account->id); expect($account_state->balance_in_cents)->toBe(100_00); diff --git a/examples/Counter/tests/StateRehydrationTest.php b/examples/Counter/tests/StateRehydrationTest.php index 3d6a64f1..71b73475 100644 --- a/examples/Counter/tests/StateRehydrationTest.php +++ b/examples/Counter/tests/StateRehydrationTest.php @@ -1,8 +1,8 @@ count())->toBe(1); - app(StateManager::class)->reset(include_storage: true); + app(TracksState::class)->reset(include_storage: true); $state = IncrementCount::fire()->state(); diff --git a/src/Attributes/Autodiscovery/AppliesToChildState.php b/src/Attributes/Autodiscovery/AppliesToChildState.php index 7333ec07..353cb9b7 100644 --- a/src/Attributes/Autodiscovery/AppliesToChildState.php +++ b/src/Attributes/Autodiscovery/AppliesToChildState.php @@ -4,8 +4,8 @@ use Attribute; use InvalidArgumentException; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; #[Attribute(Attribute::TARGET_CLASS)] @@ -31,7 +31,7 @@ public function dependencies(): array return [$this->parent_type]; } - public function discoverState(Event $event, StateManager $manager): State + public function discoverState(Event $event, TracksState $manager): State { $parent = $this->discovered->first(fn (State $state) => $state instanceof $this->parent_type); diff --git a/src/Attributes/Autodiscovery/AppliesToState.php b/src/Attributes/Autodiscovery/AppliesToState.php index 7af9d5f4..20dc6dfe 100644 --- a/src/Attributes/Autodiscovery/AppliesToState.php +++ b/src/Attributes/Autodiscovery/AppliesToState.php @@ -6,8 +6,8 @@ use Illuminate\Support\Arr; use Illuminate\Support\Str; use InvalidArgumentException; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\SingletonState; use Thunk\Verbs\State; @@ -25,7 +25,7 @@ public function __construct( } } - public function discoverState(Event $event, StateManager $manager): State|array + public function discoverState(Event $event, TracksState $manager): State|array { if (is_subclass_of($this->state_type, SingletonState::class)) { return $this->state_type::singleton(); diff --git a/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php b/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php index 385d97ba..1e475368 100644 --- a/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php +++ b/src/Attributes/Autodiscovery/StateDiscoveryAttribute.php @@ -5,8 +5,8 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use ReflectionProperty; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; abstract class StateDiscoveryAttribute @@ -18,7 +18,7 @@ abstract class StateDiscoveryAttribute /** @var Collection */ protected Collection $discovered; - abstract public function discoverState(Event $event, StateManager $manager): State|array; + abstract public function discoverState(Event $event, TracksState $manager): State|array; public function setProperty(ReflectionProperty $property): static { diff --git a/src/Attributes/Autodiscovery/StateId.php b/src/Attributes/Autodiscovery/StateId.php index 6366241d..b341763d 100644 --- a/src/Attributes/Autodiscovery/StateId.php +++ b/src/Attributes/Autodiscovery/StateId.php @@ -5,8 +5,8 @@ use Attribute; use Illuminate\Support\Arr; use InvalidArgumentException; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -23,7 +23,7 @@ public function __construct( } } - public function discoverState(Event $event, StateManager $manager): State|array + public function discoverState(Event $event, TracksState $manager): State|array { $id = $this->property->getValue($event); $property_name = $this->property->getName(); diff --git a/src/Contracts/TracksState.php b/src/Contracts/TracksState.php new file mode 100644 index 00000000..1faf2f8c --- /dev/null +++ b/src/Contracts/TracksState.php @@ -0,0 +1,40 @@ + $type + * @return TState|StateCollection + */ + public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, string $type): StateCollection|State; + + /** + * @template TState of State + * + * @param class-string $type + * @return TState + */ + public function make(Bits|UuidInterface|AbstractUid|int|string $id, string $type): State; + + /** + * @template TState instanceof State + * + * @param class-string $type + * @return TState + */ + public function singleton(string $type): State; + + public function prune(): static; +} diff --git a/src/Lifecycle/Broker.php b/src/Lifecycle/Broker.php index 533a1222..538af8b4 100644 --- a/src/Lifecycle/Broker.php +++ b/src/Lifecycle/Broker.php @@ -5,6 +5,7 @@ use Thunk\Verbs\CommitsImmediately; use Thunk\Verbs\Contracts\BrokersEvents; use Thunk\Verbs\Contracts\StoresEvents; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; use Thunk\Verbs\Exceptions\EventNotValid; use Thunk\Verbs\Lifecycle\Queue as EventQueue; @@ -19,7 +20,7 @@ public function __construct( protected Dispatcher $dispatcher, protected MetadataManager $metadata, protected EventQueue $queue, - protected StateManager $states, + protected TracksState $states, ) {} public function fireIfValid(Event $event): ?Event @@ -37,19 +38,19 @@ public function fire(Event $event): ?Event return null; } - // NOTE: Any changes to how the dispatcher is called here - // should also be applied to the `replay` method - - $this->dispatcher->boot($event); - - Guards::for($event)->check(); - - $this->dispatcher->apply($event); + Lifecycle::run( + event: $event, + phases: new Phases( + Phase::Boot, + Phase::Authorize, + Phase::Validate, + Phase::Apply, + Phase::Fired, + ) + ); $this->queue->queue($event); - $this->dispatcher->fired($event); - if ($this->commit_immediately || $event instanceof CommitsImmediately) { $this->commit(); } @@ -94,8 +95,10 @@ public function replay(?callable $beforeEach = null, ?callable $afterEach = null $beforeEach($event); } - $this->dispatcher->apply($event); - $this->dispatcher->replay($event); + Lifecycle::run( + event: $event, + phases: new Phases(Phase::Apply, Phase::Replay) + ); if ($afterEach) { $afterEach($event); diff --git a/src/Lifecycle/Lifecycle.php b/src/Lifecycle/Lifecycle.php new file mode 100644 index 00000000..c4b2d6f4 --- /dev/null +++ b/src/Lifecycle/Lifecycle.php @@ -0,0 +1,54 @@ +make(Dispatcher::class); + + return (new static($dispatcher, $event, $phases))->handle(); + } + + public function __construct( + public Dispatcher $dispatcher, + public Event $event, + public Phases $phases, + ) {} + + public function handle(): Event + { + if ($this->phases->has(Phase::Boot)) { + $this->dispatcher->boot($this->event); + } + + $guards = null; + if ($this->phases->has(Phase::Authorize)) { + $guards ??= Guards::for($this->event); + $guards->authorize(); + } + + if ($this->phases->has(Phase::Validate)) { + $guards ??= Guards::for($this->event); + $guards->validate(); + } + + if ($this->phases->has(Phase::Apply)) { + $this->dispatcher->apply($this->event); + } + + if ($this->phases->has(Phase::Handle)) { + $this->dispatcher->handle($this->event); + } + + if ($this->phases->has(Phase::Fired)) { + $this->dispatcher->fired($this->event); + } + + return $this->event; + } +} diff --git a/src/Lifecycle/Phases.php b/src/Lifecycle/Phases.php new file mode 100644 index 00000000..f01a0e1f --- /dev/null +++ b/src/Lifecycle/Phases.php @@ -0,0 +1,24 @@ +phases = $phases; + } + + public function has(Phase $phase): bool + { + return in_array($phase, $this->phases); + } +} diff --git a/src/SingletonState.php b/src/SingletonState.php index a0167f19..3a2acebc 100644 --- a/src/SingletonState.php +++ b/src/SingletonState.php @@ -4,7 +4,7 @@ use BadMethodCallException; use RuntimeException; -use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Support\StateCollection; abstract class SingletonState extends State @@ -36,7 +36,7 @@ public static function loadByKey($from): static|StateCollection public static function singleton(): static { - return app(StateManager::class)->singleton(static::class); + return app(TracksState::class)->singleton(static::class); } public function resolveRouteBinding($value, $field = null) diff --git a/src/State.php b/src/State.php index 8d243e47..23e6453c 100644 --- a/src/State.php +++ b/src/State.php @@ -9,8 +9,8 @@ use RuntimeException; use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Exceptions\StateNotFoundException; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Support\Serializer; use Thunk\Verbs\Support\StateCollection; @@ -22,7 +22,7 @@ abstract class State implements UrlRoutable public function __construct() { - app(StateManager::class)->register($this); + app(TracksState::class)->register($this); } public static function make(...$args): static @@ -77,7 +77,7 @@ public static function load($from): static|StateCollection public static function loadByKey($from): static|StateCollection { - return app(StateManager::class)->load($from, static::class); + return app(TracksState::class)->load($from, static::class); } protected static function normalizeKey(mixed $from) @@ -96,7 +96,7 @@ public function storedEvents() public function fresh(): static { - return app(StateManager::class)->load($this->id, static::class); + return app(TracksState::class)->load($this->id, static::class); } public function getRouteKey() diff --git a/src/State/LooksUpStateByKey.php b/src/State/LooksUpStateByKey.php new file mode 100644 index 00000000..cbb7cdb5 --- /dev/null +++ b/src/State/LooksUpStateByKey.php @@ -0,0 +1,23 @@ + $state + */ + protected function key(State|string $state, string|int|null $id = null): string + { + if ($state instanceof State) { + $id = $state->id; + $state = $state::class; + } + + return $id ? "{$state}:{$id}" : $state; + } +} diff --git a/src/State/ReconstitutionQuery.php b/src/State/ReconstitutionQuery.php new file mode 100644 index 00000000..89e9e671 --- /dev/null +++ b/src/State/ReconstitutionQuery.php @@ -0,0 +1,113 @@ +data()->min('last_event_id') ?? 0; + } + + /** @return Collection */ + public function states(): Collection + { + return $this->data()->map(function ($data) { + $state = app(Serializer::class)->deserialize($data->state_type, $data->data ?? []); + $state->id = $data->state_id; + $state->last_event_id = $data->last_event_id; + + // TODO: app(MetadataManager::class)->setEphemeral($state, 'snapshot_id', $this->id); + return $state; + }); + } + + protected function data(): Collection + { + return $this->data ??= $this->load(); + } + + protected function load(): Collection + { + $state_type = $this->state_type; + $state_id = (string) Id::from($this->state_id); + + $sql = <<<'SQL' + with events_to_process as ( + select distinct state_events.event_id + from `verb_state_events` state_events + where state_events.state_id = ? + and state_events.state_type = ? + and state_events.event_id > ( + select coalesce( + ( + select snapshots.last_event_id + from `verb_snapshots` snapshots + where snapshots.state_id = ? + and snapshots.type = ? + ), + 0 /* 0 or null UUID */ + ) + ) + ) + select distinct + cast(state_events.state_id as char /* char or text */) as state_id, + state_events.state_type, + snapshots.data, + coalesce(snapshots.last_event_id, 0 /* 0 or null UUID */) as last_event_id + from `verb_state_events` as state_events + left join `verb_snapshots` as snapshots + on snapshots.state_id = state_events.state_id + and snapshots.type = state_events.state_type + join events_to_process on events_to_process.event_id = state_events.event_id + SQL; + + $grammar = DB::getQueryGrammar(); + $snapshots = $grammar->wrapTable(config('verbs.tables.snapshots')); + $state_events = $grammar->wrapTable(config('verbs.tables.state_events')); + + $sql = str_replace([ + '`verb_snapshots`', + '`verb_state_events`', + ' as char /* char or text */)', + '0 /* 0 or null UUID */', + ], [ + $snapshots, + $state_events, + match ($grammar::class) { + MySqlGrammar::class => ' as char)', + default => ' as text)', + }, + '0', // TODO: Support UUIDs + ], $sql); + + $bindings = [ + $state_id, + $state_type, + $state_id, + $state_type, + ]; + + // fwrite(STDOUT, "\n{$sql}\n"); + + return Collection::make(DB::select($sql, $bindings))->sortBy('state_id')->values(); + } +} diff --git a/src/State/StateIdentity.php b/src/State/StateIdentity.php new file mode 100644 index 00000000..05289777 --- /dev/null +++ b/src/State/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/Lifecycle/StateManager.php b/src/State/StateManager.php similarity index 88% rename from src/Lifecycle/StateManager.php rename to src/State/StateManager.php index 9ce4553f..31fe70fa 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/State/StateManager.php @@ -1,6 +1,6 @@ remember($state); } - /** - * @template S instanceof State - * - * @param class-string $type - * @return S|StateCollection - */ public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, string $type): StateCollection|State { return is_iterable($id) @@ -47,12 +45,6 @@ public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, str : $this->loadOne($id, $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 @@ -73,16 +65,10 @@ public function singleton(string $type): State return $state; } - /** - * @template TState of State - * - * @param class-string $type - * @return TState - */ public function make(Bits|UuidInterface|AbstractUid|int|string $id, string $type): State { // If we've already instantiated this state, we'll load it - if ($existing = $this->states->get($this->key($id, $type))) { + if ($existing = $this->states->get($this->key($type, $id))) { return $existing; } @@ -135,7 +121,7 @@ public function prune(): static protected function loadOne(Bits|UuidInterface|AbstractUid|int|string $id, string $type): State { $id = Id::from($id); - $key = $this->key($id, $type); + $key = $this->key($type, $id); // 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 @@ -162,7 +148,7 @@ protected function loadMany(iterable $ids, string $type): StateCollection { $ids = collect($ids)->map(Id::from(...)); - $missing = $ids->reject(fn ($id) => $this->states->has($this->key($id, $type))); + $missing = $ids->reject(fn ($id) => $this->states->has($this->key($type, $id))); // Load all available snapshots for missing states $this->snapshots->load($missing, $type)->each(function (State $state) { @@ -172,7 +158,7 @@ protected function loadMany(iterable $ids, string $type): StateCollection // Then make any states that don't exist yet $missing - ->reject(fn ($id) => $this->states->has($this->key($id, $type))) + ->reject(fn ($id) => $this->states->has($this->key($type, $id))) ->each(function (string|int $id) use ($type) { $state = $this->make($id, $type); $this->remember($state); @@ -181,7 +167,7 @@ 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($type, $id))) ); } @@ -207,7 +193,7 @@ protected function reconstitute(State $state): static protected function remember(State $state): State { - $key = $this->key($state->id, $state::class); + $key = $this->key($state); if ($this->states->get($key) === $state) { return $state; @@ -221,9 +207,4 @@ protected function remember(State $state): State return $state; } - - protected function key(string|int $id, string $type): string - { - return "{$type}:{$id}"; - } } diff --git a/src/State/TemporaryStateManager.php b/src/State/TemporaryStateManager.php new file mode 100644 index 00000000..c944289c --- /dev/null +++ b/src/State/TemporaryStateManager.php @@ -0,0 +1,98 @@ +states->put($this->key($state), $state); + + return $state; + } + + public function load(iterable|UuidInterface|string|int|AbstractUid|Bits $id, string $type): StateCollection|State + { + return is_iterable($id) + ? $this->loadMany($id, $type) + : $this->loadOne(Id::from($id), $type); + } + + public function make(UuidInterface|string|int|AbstractUid|Bits $id, string $type): State + { + // If we've already instantiated this state, we'll load it + if ($existing = $this->states->get($this->key($type, $id))) { + return $existing; + } + + // State::__construct() auto-registers the state with the StateManager, + // so we need to skip the constructor until we've already set the ID. + $state = (new ReflectionClass($type))->newInstanceWithoutConstructor(); + $state->id = Id::from($id); + $state->__construct(); + + $this->states->put($this->key($state), $state); + + return $state; + } + + public function singleton(string $type): State + { + $key = $this->key($type); + + if ($this->states->has($key)) { + return $this->states->get($key); + } + + $state = $this->make(snowflake_id(), $type); + $this->states->put($key, $state); + + return $state; + } + + public function prune(): static + { + $this->states = new Collection; + + return $this; + } + + /** @param class-string $type */ + protected function loadOne(int|string $id, string $type): State + { + $key = $this->key($type, $id); + + if ($state = $this->states->get($key)) { + return $state; + } + + $state = $this->make($id, $type); + + $this->states->put($key, $state); + + return $state; + } + + /** @param class-string $type */ + protected function loadMany(iterable $ids, string $type): StateCollection + { + return StateCollection::make($ids) + ->map(fn ($id) => $this->loadOne(Id::from($id), $type)); + } +} diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 1a956589..3dc01233 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -12,8 +12,8 @@ use ReflectionProperty; use ReflectionUnionType; use Thunk\Verbs\Attributes\Autodiscovery\StateDiscoveryAttribute; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; class EventStateRegistry @@ -23,7 +23,7 @@ class EventStateRegistry protected array $discovered_properties = []; public function __construct( - protected StateManager $manager, + protected TracksState $manager, ) {} public function getStates(Event $event): StateCollection diff --git a/src/Support/Normalization/StateNormalizer.php b/src/Support/Normalization/StateNormalizer.php index 6156c70c..246d62e3 100644 --- a/src/Support/Normalization/StateNormalizer.php +++ b/src/Support/Normalization/StateNormalizer.php @@ -5,7 +5,7 @@ use InvalidArgumentException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\State; use Thunk\Verbs\Support\Serializer; @@ -23,7 +23,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return $data; } - return app(StateManager::class)->load($data, $type); + return app(TracksState::class)->load($data, $type); } public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool diff --git a/src/Support/Replay.php b/src/Support/Replay.php new file mode 100644 index 00000000..17ebcd19 --- /dev/null +++ b/src/Support/Replay.php @@ -0,0 +1,40 @@ +instance(TracksState::class, $this->states); + + foreach ($this->events as $event) { + Lifecycle::run($event, $this->phases); + } + + // FIXME: This will throw an exception right now + // foreach ($this->states as $state) { + // $original_states->register($state); + // } + } finally { + app()->instance(TracksState::class, $original_states); + } + + return $this; + } +} diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index d3f537eb..1a66b9a6 100644 --- a/src/VerbsServiceProvider.php +++ b/src/VerbsServiceProvider.php @@ -25,6 +25,7 @@ use Thunk\Verbs\Contracts\BrokersEvents; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Contracts\StoresSnapshots; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Lifecycle\AutoCommitManager; use Thunk\Verbs\Lifecycle\Broker; use Thunk\Verbs\Lifecycle\Dispatcher; @@ -32,8 +33,8 @@ use Thunk\Verbs\Lifecycle\MetadataManager; use Thunk\Verbs\Lifecycle\Queue as EventQueue; use Thunk\Verbs\Lifecycle\SnapshotStore; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Livewire\SupportVerbs; +use Thunk\Verbs\State\StateManager; use Thunk\Verbs\Support\EventStateRegistry; use Thunk\Verbs\Support\IdManager; use Thunk\Verbs\Support\Serializer; @@ -69,7 +70,7 @@ public function packageRegistered() $this->app->scoped(EventStateRegistry::class); $this->app->singleton(MetadataManager::class); - $this->app->scoped(StateManager::class, function (Container $app) { + $this->app->scoped(TracksState::class, function (Container $app) { return new StateManager( dispatcher: $app->make(Dispatcher::class), snapshots: $app->make(StoresSnapshots::class), diff --git a/tests/Feature/ReconstitutionQueryTest.php b/tests/Feature/ReconstitutionQueryTest.php new file mode 100644 index 00000000..a75c5001 --- /dev/null +++ b/tests/Feature/ReconstitutionQueryTest.php @@ -0,0 +1,162 @@ + 1, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 2, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 3, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 4, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 5, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 6, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + + // Attach events to different states + VerbStateEvent::truncate(); + VerbStateEvent::insert(['id' => 1, 'event_id' => 1, 'state_id' => 1, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 2, 'event_id' => 2, 'state_id' => 1, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 3, 'event_id' => 2, 'state_id' => 2, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 4, 'event_id' => 3, 'state_id' => 2, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 5, 'event_id' => 4, 'state_id' => 1, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 6, 'event_id' => 5, 'state_id' => 1, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 7, 'event_id' => 5, 'state_id' => 2, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 8, 'event_id' => 6, 'state_id' => 2, 'state_type' => ReconstitutionQueryTestState::class]); + + // Red herring events + VerbEvent::insert(['id' => 7, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 8, 'type' => ReconstitutionQueryTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbStateEvent::insert(['id' => 9, 'event_id' => 7, 'state_id' => 3, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 10, 'event_id' => 8, 'state_id' => 3, 'state_type' => ReconstitutionQueryTestState::class]); + VerbStateEvent::insert(['id' => 11, 'event_id' => 8, 'state_id' => 4, 'state_type' => ReconstitutionQueryTestState::class]); + + // CASE: All events have snapshots (same event ID) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 2]); + + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); + + expect($results->states())->toHaveCount(2); + + expect($results->earliestEventId())->toBe(2); + + expect($results->states()->first()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(2); + + expect($results->states()->last()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(2); + + // CASE: All events have snapshots (different event IDs, queried state is earlier) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 4]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 2]); + + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); + + expect($results->states())->toHaveCount(2); + + expect($results->earliestEventId())->toBe(2); + + expect($results->states()->first()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(4); + + expect($results->states()->last()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(2); + + // CASE: All events have snapshots (different event IDs, queried state is later) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 1]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 2]); + + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); + + expect($results->states())->toHaveCount(2); + + expect($results->earliestEventId())->toBe(1); + + expect($results->states()->first()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(1); + + expect($results->states()->last()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(2); + + // CASE: One event has a snapshot, the other doesn't (queried state has snapshot) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 3]); + + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); + + expect($results->states())->toHaveCount(2); + + expect($results->earliestEventId())->toBe(0); + + expect($results->states()->first()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(0); + + expect($results->states()->last()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(3); + + // CASE: One event has a snapshot, the other doesn't (non-queried state has snapshot) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 2]); + + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); + + expect($results->states())->toHaveCount(2); + + expect($results->earliestEventId())->toBe(0); + + expect($results->states()->first()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(2); + + expect($results->states()->last()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(0); + + // CASE: Neither event has a snapshot + VerbSnapshot::truncate(); + + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); + + expect($results->states())->toHaveCount(2); + + expect($results->earliestEventId())->toBe(0); + + expect($results->states()->first()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(0); + + expect($results->states()->last()) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(0); +}); + +class ReconstitutionQueryTestState extends State {} +class ReconstitutionQueryTestEvent extends Event {} diff --git a/tests/Feature/ReplayCommandTest.php b/tests/Feature/ReplayCommandTest.php index feaf3b27..3c1af774 100644 --- a/tests/Feature/ReplayCommandTest.php +++ b/tests/Feature/ReplayCommandTest.php @@ -4,10 +4,10 @@ use Illuminate\Support\Carbon; use Thunk\Verbs\Attributes\Autodiscovery\StateId; use Thunk\Verbs\Commands\ReplayCommand; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\Facades\Verbs; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\Models\VerbSnapshot; use Thunk\Verbs\State; @@ -37,11 +37,11 @@ Verbs::commit(); - expect(app(StateManager::class)->load($state1_id, ReplayCommandTestState::class)->count) + expect(app(TracksState::class)->load($state1_id, ReplayCommandTestState::class)->count) ->toBe(2) ->and($GLOBALS['replay_test_counts'][$state1_id]) ->toBe(2) - ->and(app(StateManager::class)->load($state2_id, ReplayCommandTestState::class)->count) + ->and(app(TracksState::class)->load($state2_id, ReplayCommandTestState::class)->count) ->toBe(4) ->and($GLOBALS['replay_test_counts'][$state2_id]) ->toBe(4) @@ -54,11 +54,11 @@ config(['app.env' => 'testing']); $this->artisan(ReplayCommand::class); - expect(app(StateManager::class)->load($state1_id, ReplayCommandTestState::class)->count) + expect(app(TracksState::class)->load($state1_id, ReplayCommandTestState::class)->count) ->toBe(2) ->and($GLOBALS['replay_test_counts'][$state1_id]) ->toBe(2) - ->and(app(StateManager::class)->load($state2_id, ReplayCommandTestState::class)->count) + ->and(app(TracksState::class)->load($state2_id, ReplayCommandTestState::class)->count) ->toBe(4) ->and($GLOBALS['replay_test_counts'][$state2_id]) ->toBe(4) @@ -72,7 +72,7 @@ Verbs::commit(); - expect(app(StateManager::class)->load($state_id, ReplayCommandTestWormholeState::class)->time->unix()) + expect(app(TracksState::class)->load($state_id, ReplayCommandTestWormholeState::class)->time->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()) ->and($GLOBALS['time'][$state_id]->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()); @@ -83,7 +83,7 @@ config(['app.env' => 'testing']); $this->artisan(ReplayCommand::class); - expect(app(StateManager::class)->load($state_id, ReplayCommandTestWormholeState::class)->time->unix()) + expect(app(TracksState::class)->load($state_id, ReplayCommandTestWormholeState::class)->time->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()) ->and($GLOBALS['time'][$state_id]->unix()) ->toBe(CarbonImmutable::parse('2024-04-01 12:00:00')->unix()); diff --git a/tests/Unit/CollectionNormalizerTest.php b/tests/Unit/CollectionNormalizerTest.php index 4b769faa..f52ff63d 100644 --- a/tests/Unit/CollectionNormalizerTest.php +++ b/tests/Unit/CollectionNormalizerTest.php @@ -7,7 +7,7 @@ use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; use Symfony\Component\Serializer\Serializer as SymfonySerializer; -use Thunk\Verbs\Lifecycle\StateManager; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\SerializedByVerbs; use Thunk\Verbs\State; use Thunk\Verbs\Support\Normalization\CarbonNormalizer; @@ -130,7 +130,7 @@ }); it('can normalize a collection all of states', function () { - $manager = app(StateManager::class); + $manager = app(TracksState::class); $serializer = new SymfonySerializer( normalizers: [ diff --git a/tests/Unit/EventStoreFakeTest.php b/tests/Unit/EventStoreFakeTest.php index b1e89331..ff659ee0 100644 --- a/tests/Unit/EventStoreFakeTest.php +++ b/tests/Unit/EventStoreFakeTest.php @@ -2,9 +2,9 @@ use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState; use Thunk\Verbs\Contracts\StoresEvents; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; use Thunk\Verbs\Lifecycle\MetadataManager; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; use Thunk\Verbs\Testing\EventStoreFake; @@ -75,12 +75,12 @@ it('reads and writes stateful events normally', function () { app()->instance(StoresEvents::class, $store = new EventStoreFake(app(MetadataManager::class))); - $state1 = app(StateManager::class)->load( + $state1 = app(TracksState::class)->load( 1001, type: EventStoreFakeTestState::class, ); - $state2 = app(StateManager::class)->load( + $state2 = app(TracksState::class)->load( 1002, type: EventStoreFakeTestState::class, ); diff --git a/tests/Unit/FactoryTest.php b/tests/Unit/FactoryTest.php index c846be11..99fcc3ba 100644 --- a/tests/Unit/FactoryTest.php +++ b/tests/Unit/FactoryTest.php @@ -1,7 +1,7 @@ id)->not->toBeNull(); - $retreived_state = app(StateManager::class)->singleton(FactoryTestSingletonState::class); + $retreived_state = app(TracksState::class)->singleton(FactoryTestSingletonState::class); expect($retreived_state)->toBe($singleton_state); }); diff --git a/tests/Unit/SupportUuidsTest.php b/tests/Unit/SupportUuidsTest.php index fbe25631..e26db621 100644 --- a/tests/Unit/SupportUuidsTest.php +++ b/tests/Unit/SupportUuidsTest.php @@ -2,8 +2,8 @@ use Illuminate\Support\Facades\Facade; use Illuminate\Support\Str; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; -use Thunk\Verbs\Lifecycle\StateManager; use Thunk\Verbs\State; use Thunk\Verbs\Support\IdManager; @@ -49,7 +49,7 @@ state: $state, ); - app(StateManager::class)->reset(include_storage: true); + app(TracksState::class)->reset(include_storage: true); $state = UuidState::load($uuid);