From 282856c56a8242c5e13e387c70e639ce876d6b90 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Tue, 8 Jul 2025 12:05:25 -0400 Subject: [PATCH 1/9] wip --- CLAUDE.md | 80 +++++++++++++++++++++++++++++++++++++ src/Lifecycle/Lifecycle.php | 47 ++++++++++++++++++++++ src/Lifecycle/Phases.php | 36 +++++++++++++++++ src/State/StateIdentity.php | 35 ++++++++++++++++ src/Support/Replay.php | 34 ++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 CLAUDE.md create mode 100644 src/Lifecycle/Lifecycle.php create mode 100644 src/Lifecycle/Phases.php create mode 100644 src/State/StateIdentity.php create mode 100644 src/Support/Replay.php 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/src/Lifecycle/Lifecycle.php b/src/Lifecycle/Lifecycle.php new file mode 100644 index 00000000..9349fc8c --- /dev/null +++ b/src/Lifecycle/Lifecycle.php @@ -0,0 +1,47 @@ +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); + } + + // FIXME: This is actually two phases + if ($this->phases->has(Phase::Authorize)) { + Guards::for($this->event)->check(); + } + + if ($this->phases->has(Phase::Apply)) { + $this->dispatcher->apply($this->event); + } + + if ($this->phases->has(Phase::Handle)) { + // FIXME + // $this->queue->queue($this->event); + $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..17de1665 --- /dev/null +++ b/src/Lifecycle/Phases.php @@ -0,0 +1,36 @@ +phases = $phases; + } + + public function has(Phase $phase): bool + { + return in_array($phase, $this->phases); + } +} 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/Support/Replay.php b/src/Support/Replay.php new file mode 100644 index 00000000..9f861d33 --- /dev/null +++ b/src/Support/Replay.php @@ -0,0 +1,34 @@ +instance(StateManager::class, $this->states); + + foreach ($this->events as $event) { + Lifecycle::run($event, $this->phases); + } + } finally { + app()->instance(StateManager::class, $global_registry); + } + + return $this; + } +} From 6c434a87a851982cd6b4157299940418d8f52ba1 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Tue, 8 Jul 2025 12:17:38 -0400 Subject: [PATCH 2/9] Add `TracksState` interface --- examples/Bank/tests/BankAccountTest.php | 4 +-- .../Counter/tests/StateRehydrationTest.php | 4 +-- .../Autodiscovery/AppliesToChildState.php | 4 +-- .../Autodiscovery/AppliesToState.php | 4 +-- .../Autodiscovery/StateDiscoveryAttribute.php | 4 +-- src/Attributes/Autodiscovery/StateId.php | 4 +-- src/Contracts/TracksState.php | 5 ++++ src/Lifecycle/Broker.php | 29 ++++++++++--------- src/Lifecycle/Lifecycle.php | 17 +++++++---- src/Lifecycle/Phases.php | 12 -------- src/Lifecycle/StateManager.php | 3 +- src/SingletonState.php | 4 +-- src/State.php | 8 ++--- src/Support/EventStateRegistry.php | 4 +-- src/Support/Normalization/StateNormalizer.php | 4 +-- src/Support/Replay.php | 10 +++---- src/VerbsServiceProvider.php | 3 +- tests/Feature/ReplayCommandTest.php | 14 ++++----- tests/Unit/CollectionNormalizerTest.php | 4 +-- tests/Unit/EventStoreFakeTest.php | 6 ++-- tests/Unit/FactoryTest.php | 4 +-- tests/Unit/SupportUuidsTest.php | 4 +-- 22 files changed, 80 insertions(+), 75 deletions(-) create mode 100644 src/Contracts/TracksState.php 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..ef54efa5 --- /dev/null +++ b/src/Contracts/TracksState.php @@ -0,0 +1,5 @@ +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 index 9349fc8c..c4b2d6f4 100644 --- a/src/Lifecycle/Lifecycle.php +++ b/src/Lifecycle/Lifecycle.php @@ -2,13 +2,16 @@ namespace Thunk\Verbs\Lifecycle; +use Illuminate\Container\Container; use Thunk\Verbs\Event; class Lifecycle { public static function run(Event $event, Phases $phases): Event { - return (new static(app(Dispatcher::class), $event, $phases))->handle(); + $dispatcher = Container::getInstance()->make(Dispatcher::class); + + return (new static($dispatcher, $event, $phases))->handle(); } public function __construct( @@ -23,9 +26,15 @@ public function handle(): Event $this->dispatcher->boot($this->event); } - // FIXME: This is actually two phases + $guards = null; if ($this->phases->has(Phase::Authorize)) { - Guards::for($this->event)->check(); + $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)) { @@ -33,8 +42,6 @@ public function handle(): Event } if ($this->phases->has(Phase::Handle)) { - // FIXME - // $this->queue->queue($this->event); $this->dispatcher->handle($this->event); } diff --git a/src/Lifecycle/Phases.php b/src/Lifecycle/Phases.php index 17de1665..f01a0e1f 100644 --- a/src/Lifecycle/Phases.php +++ b/src/Lifecycle/Phases.php @@ -12,18 +12,6 @@ public static function all(): static return new static(...Phase::cases()); } - public static function fire(): static - { - return new static( - Phase::Boot, - Phase::Authorize, - Phase::Validate, - Phase::Apply, - // FIXME: Something else here, maybe more than one - Phase::Fired, - ); - } - public function __construct(Phase ...$phases) { $this->phases = $phases; diff --git a/src/Lifecycle/StateManager.php b/src/Lifecycle/StateManager.php index 9ce4553f..4e7d20bb 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -9,6 +9,7 @@ use Symfony\Component\Uid\AbstractUid; use Thunk\Verbs\Contracts\StoresEvents; use Thunk\Verbs\Contracts\StoresSnapshots; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Event; use Thunk\Verbs\Facades\Id; use Thunk\Verbs\State; @@ -16,7 +17,7 @@ use Thunk\Verbs\Support\StateInstanceCache; use UnexpectedValueException; -class StateManager +class StateManager implements TracksState { protected bool $is_replaying = false; 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/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 index 9f861d33..d591d432 100644 --- a/src/Support/Replay.php +++ b/src/Support/Replay.php @@ -3,30 +3,30 @@ namespace Thunk\Verbs\Support; use Illuminate\Support\Enumerable; +use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Lifecycle\Lifecycle; use Thunk\Verbs\Lifecycle\Phases; -use Thunk\Verbs\State\StateManager; class Replay { public function __construct( - public StateManager $states, + public TracksState $states, public Enumerable $events, public Phases $phases, ) {} public function handle(): static { - $global_registry = app(StateManager::class); + $original_states = app(TracksState::class); try { - app()->instance(StateManager::class, $this->states); + app()->instance(TracksState::class, $this->states); foreach ($this->events as $event) { Lifecycle::run($event, $this->phases); } } finally { - app()->instance(StateManager::class, $global_registry); + app()->instance(TracksState::class, $original_states); } return $this; diff --git a/src/VerbsServiceProvider.php b/src/VerbsServiceProvider.php index d3f537eb..267fb4a1 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; @@ -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/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); From 1c1cca421f3835184549f22d4d6453117d22c47a Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Tue, 8 Jul 2025 12:22:45 -0400 Subject: [PATCH 3/9] Pull methods into interface --- src/Contracts/TracksState.php | 37 +++++++++++++++++++++++++++++++++- src/Lifecycle/StateManager.php | 18 ----------------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/Contracts/TracksState.php b/src/Contracts/TracksState.php index ef54efa5..1faf2f8c 100644 --- a/src/Contracts/TracksState.php +++ b/src/Contracts/TracksState.php @@ -2,4 +2,39 @@ namespace Thunk\Verbs\Contracts; -interface TracksState {} +use Glhd\Bits\Bits; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\Uid\AbstractUid; +use Thunk\Verbs\State; +use Thunk\Verbs\Support\StateCollection; + +interface TracksState +{ + public function register(State $state): State; + + /** + * @template TState instanceof State + * + * @param class-string $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/StateManager.php b/src/Lifecycle/StateManager.php index 4e7d20bb..41f0b58e 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/Lifecycle/StateManager.php @@ -35,12 +35,6 @@ public function register(State $state): State return $this->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) @@ -48,12 +42,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 @@ -74,12 +62,6 @@ 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 From 98abcd9f2035201e67c94d30b9d88b8dfc2e4ca4 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Tue, 8 Jul 2025 12:23:26 -0400 Subject: [PATCH 4/9] Move StateManager --- src/{Lifecycle => State}/StateManager.php | 3 ++- src/VerbsServiceProvider.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename src/{Lifecycle => State}/StateManager.php (98%) diff --git a/src/Lifecycle/StateManager.php b/src/State/StateManager.php similarity index 98% rename from src/Lifecycle/StateManager.php rename to src/State/StateManager.php index 41f0b58e..6a564d7a 100644 --- a/src/Lifecycle/StateManager.php +++ b/src/State/StateManager.php @@ -1,6 +1,6 @@ Date: Wed, 9 Jul 2025 14:04:17 -0400 Subject: [PATCH 5/9] wip --- src/State/LooksUpStateByKey.php | 23 +++++++ src/State/Magic.php | 77 +++++++++++++++++++++++ src/State/StateManager.php | 19 +++--- src/State/TemporaryStateManager.php | 98 +++++++++++++++++++++++++++++ src/Support/Replay.php | 8 ++- tests/Feature/MagicTest.php | 92 +++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 src/State/LooksUpStateByKey.php create mode 100644 src/State/Magic.php create mode 100644 src/State/TemporaryStateManager.php create mode 100644 tests/Feature/MagicTest.php 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/Magic.php b/src/State/Magic.php new file mode 100644 index 00000000..4de5d970 --- /dev/null +++ b/src/State/Magic.php @@ -0,0 +1,77 @@ + (select last_event_id from target_last_event) + ) + select distinct + merged_state_events.state_id, + merged_state_events.state_type, + merged_state_events.data, + merged_state_events.last_event_id + from ( + select state_events.state_id, state_events.state_type, snapshots.data, snapshots.last_event_id + from {$state_events} as state_events + join events_to_process on state_events.event_id = events_to_process.event_id + left join {$snapshots} as snapshots + on snapshots.state_id = state_events.state_id + and snapshots.type = state_events.state_type + union all select + ? as state_id, + ? as state_type, + null as data, + (select last_event_id from target_last_event) as last_event_id + where not exists ( + select 1 + from {$snapshots} snapshots + where snapshots.state_id = ? + and snapshots.type = ? + ) + ) merged_state_events + SQL; + + $bindings = [ + $state_id, + $state_type, + $state_id, + $state_type, + $state_id, + $state_type, + $state_id, + $state_type, + ]; + + // fwrite(STDOUT, "\n{$sql}\n"); + + return new Collection(DB::select($sql, $bindings)); + } +} diff --git a/src/State/StateManager.php b/src/State/StateManager.php index 6a564d7a..31fe70fa 100644 --- a/src/State/StateManager.php +++ b/src/State/StateManager.php @@ -20,6 +20,8 @@ class StateManager implements TracksState { + use LooksUpStateByKey; + protected bool $is_replaying = false; public function __construct( @@ -66,7 +68,7 @@ public function singleton(string $type): State 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; } @@ -119,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 @@ -146,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) { @@ -156,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); @@ -165,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))) ); } @@ -191,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; @@ -205,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/Replay.php b/src/Support/Replay.php index d591d432..17ebcd19 100644 --- a/src/Support/Replay.php +++ b/src/Support/Replay.php @@ -6,13 +6,14 @@ use Thunk\Verbs\Contracts\TracksState; use Thunk\Verbs\Lifecycle\Lifecycle; use Thunk\Verbs\Lifecycle\Phases; +use Thunk\Verbs\State\TemporaryStateManager; class Replay { public function __construct( - public TracksState $states, public Enumerable $events, public Phases $phases, + public TracksState $states = new TemporaryStateManager, ) {} public function handle(): static @@ -25,6 +26,11 @@ public function handle(): static 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); } diff --git a/tests/Feature/MagicTest.php b/tests/Feature/MagicTest.php new file mode 100644 index 00000000..ce633010 --- /dev/null +++ b/tests/Feature/MagicTest.php @@ -0,0 +1,92 @@ + 1, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 2, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 3, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 4, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 5, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 6, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); + + // Attach events to different states + VerbStateEvent::truncate(); + VerbStateEvent::insert(['id' => 1, 'event_id' => 1, 'state_id' => 1, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 2, 'event_id' => 2, 'state_id' => 1, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 3, 'event_id' => 2, 'state_id' => 2, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 4, 'event_id' => 3, 'state_id' => 2, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 5, 'event_id' => 4, 'state_id' => 1, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 6, 'event_id' => 5, 'state_id' => 1, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 7, 'event_id' => 5, 'state_id' => 2, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 8, 'event_id' => 6, 'state_id' => 2, 'state_type' => 'S1']); + + // CASE: All events have snapshots (same event ID) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + + $results = Magic::query('S1', 2)->sortBy('state_id')->values(); + + expect($results)->toHaveCount(2); + + expect($results[0]) + ->toHaveProperty('state_id', '1') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '2'); + + expect($results[1]) + ->toHaveProperty('state_id', '2') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '2'); + + // CASE: All events have snapshots (different event IDs) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 5]); + + $results = Magic::query('S1', 2)->sortBy('state_id')->values(); + + expect($results)->toHaveCount(2); + + expect($results[0]) + ->toHaveProperty('state_id', '1') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '2'); + + expect($results[1]) + ->toHaveProperty('state_id', '2') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '5'); + + // CASE: One event has a snapshot, the other doesn't + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 3]); + + $results = Magic::query('S1', 2) + ->sortBy('state_id') + ->values(); + + expect($results)->toHaveCount(2); + + expect($results[0]) + ->toHaveProperty('state_id', '1') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', null) + ->toHaveProperty('last_event_id', null); + + expect($results[1]) + ->toHaveProperty('state_id', '2') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '3'); +}); From 058a54600a0c286528963eee9871e1a28fe385a7 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 9 Jul 2025 17:03:46 -0400 Subject: [PATCH 6/9] Way simpler --- src/State/Magic.php | 81 ++++++++++++++++--------------------- tests/Feature/MagicTest.php | 76 +++++++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 53 deletions(-) diff --git a/src/State/Magic.php b/src/State/Magic.php index 4de5d970..eb13a683 100644 --- a/src/State/Magic.php +++ b/src/State/Magic.php @@ -2,6 +2,7 @@ namespace Thunk\Verbs\State; +use Illuminate\Database\Query\Grammars\MySqlGrammar; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -9,65 +10,51 @@ class Magic { public static function query(string $state_type, string $state_id) { - // $grammar = DB::getQueryGrammar(); - $snapshots = config('verbs.tables.snapshots'); - $events = config('verbs.tables.events'); - $state_events = config('verbs.tables.state_events'); - - $sql = << ( select coalesce( ( select snapshots.last_event_id - from {$snapshots} snapshots + from `verb_snapshots` snapshots where snapshots.state_id = ? and snapshots.type = ? ), - 0 - ) as last_event_id - ), - events_to_process as ( - select distinct state_events.event_id - from {$state_events} state_events - where state_events.state_id = ? - and state_events.state_type = ? - and state_events.event_id > (select last_event_id from target_last_event) - ) - select distinct - merged_state_events.state_id, - merged_state_events.state_type, - merged_state_events.data, - merged_state_events.last_event_id - from ( - select state_events.state_id, state_events.state_type, snapshots.data, snapshots.last_event_id - from {$state_events} as state_events - join events_to_process on state_events.event_id = events_to_process.event_id - left join {$snapshots} as snapshots - on snapshots.state_id = state_events.state_id - and snapshots.type = state_events.state_type - union all select - ? as state_id, - ? as state_type, - null as data, - (select last_event_id from target_last_event) as last_event_id - where not exists ( - select 1 - from {$snapshots} snapshots - where snapshots.state_id = ? - and snapshots.type = ? + 0 /* 0 or null UUID */ ) - ) merged_state_events + ) 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, - $state_id, - $state_type, - $state_id, - $state_type, ]; // fwrite(STDOUT, "\n{$sql}\n"); diff --git a/tests/Feature/MagicTest.php b/tests/Feature/MagicTest.php index ce633010..4871348d 100644 --- a/tests/Feature/MagicTest.php +++ b/tests/Feature/MagicTest.php @@ -47,10 +47,10 @@ ->toHaveProperty('data', '{}') ->toHaveProperty('last_event_id', '2'); - // CASE: All events have snapshots (different event IDs) + // CASE: All events have snapshots (different event IDs, queried state is earlier) VerbSnapshot::truncate(); - VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); - VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 5]); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 4]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); $results = Magic::query('S1', 2)->sortBy('state_id')->values(); @@ -60,15 +60,36 @@ ->toHaveProperty('state_id', '1') ->toHaveProperty('state_type', 'S1') ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '4'); + + expect($results[1]) + ->toHaveProperty('state_id', '2') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') ->toHaveProperty('last_event_id', '2'); + // CASE: All events have snapshots (different event IDs, queried state is later) + VerbSnapshot::truncate(); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 1]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + + $results = Magic::query('S1', 2)->sortBy('state_id')->values(); + + expect($results)->toHaveCount(2); + + expect($results[0]) + ->toHaveProperty('state_id', '1') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '1'); + expect($results[1]) ->toHaveProperty('state_id', '2') ->toHaveProperty('state_type', 'S1') ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '5'); + ->toHaveProperty('last_event_id', '2'); - // CASE: One event has a snapshot, the other doesn't + // CASE: One event has a snapshot, the other doesn't (queried state has snapshot) VerbSnapshot::truncate(); VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 3]); @@ -82,11 +103,54 @@ ->toHaveProperty('state_id', '1') ->toHaveProperty('state_type', 'S1') ->toHaveProperty('data', null) - ->toHaveProperty('last_event_id', null); + ->toHaveProperty('last_event_id', '0'); expect($results[1]) ->toHaveProperty('state_id', '2') ->toHaveProperty('state_type', 'S1') ->toHaveProperty('data', '{}') ->toHaveProperty('last_event_id', '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' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + + $results = Magic::query('S1', 2) + ->sortBy('state_id') + ->values(); + + expect($results)->toHaveCount(2); + + expect($results[0]) + ->toHaveProperty('state_id', '1') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', '{}') + ->toHaveProperty('last_event_id', '2'); + + expect($results[1]) + ->toHaveProperty('state_id', '2') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', null) + ->toHaveProperty('last_event_id', '0'); + + // CASE: Neither event has a snapshot + VerbSnapshot::truncate(); + + $results = Magic::query('S1', 2) + ->sortBy('state_id') + ->values(); + + expect($results)->toHaveCount(2); + + expect($results[0]) + ->toHaveProperty('state_id', '1') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', null) + ->toHaveProperty('last_event_id', '0'); + + expect($results[1]) + ->toHaveProperty('state_id', '2') + ->toHaveProperty('state_type', 'S1') + ->toHaveProperty('data', null) + ->toHaveProperty('last_event_id', '0'); }); From c50463a57af86991aa0923f74ee7186a4fd0e39f Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 9 Jul 2025 17:52:04 -0400 Subject: [PATCH 7/9] Better data --- src/State/Magic.php | 44 +++++++- tests/Feature/MagicTest.php | 199 ++++++++++++++++++------------------ 2 files changed, 141 insertions(+), 102 deletions(-) diff --git a/src/State/Magic.php b/src/State/Magic.php index eb13a683..972ab565 100644 --- a/src/State/Magic.php +++ b/src/State/Magic.php @@ -2,14 +2,54 @@ namespace Thunk\Verbs\State; +use Glhd\Bits\Bits; use Illuminate\Database\Query\Grammars\MySqlGrammar; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\Uid\AbstractUid; +use Thunk\Verbs\Facades\Id; +use Thunk\Verbs\Lifecycle\MetadataManager; +use Thunk\Verbs\State; +use Thunk\Verbs\Support\Serializer; class Magic { - public static function query(string $state_type, string $state_id) + protected Collection $data; + + public function __construct( + protected string $state_type, + protected Bits|UuidInterface|AbstractUid|int|string $state_id, + ) {} + + public function earliestEventId(): int|string { + return $this->data()->min('last_event_id') ?? 0; + } + + /** @return Collection */ + public function states() + { + 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; + }); + } + + public 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' select distinct cast(state_events.state_id as char /* char or text */) as state_id, @@ -59,6 +99,6 @@ public static function query(string $state_type, string $state_id) // fwrite(STDOUT, "\n{$sql}\n"); - return new Collection(DB::select($sql, $bindings)); + return Collection::make(DB::select($sql, $bindings))->sortBy('state_id')->values(); } } diff --git a/tests/Feature/MagicTest.php b/tests/Feature/MagicTest.php index 4871348d..5c8bb3e8 100644 --- a/tests/Feature/MagicTest.php +++ b/tests/Feature/MagicTest.php @@ -1,156 +1,155 @@ 1, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 2, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 3, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 4, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 5, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 6, 'type' => 'E1', 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 1, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 2, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 3, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 4, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 5, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 6, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); // Attach events to different states VerbStateEvent::truncate(); - VerbStateEvent::insert(['id' => 1, 'event_id' => 1, 'state_id' => 1, 'state_type' => 'S1']); - VerbStateEvent::insert(['id' => 2, 'event_id' => 2, 'state_id' => 1, 'state_type' => 'S1']); - VerbStateEvent::insert(['id' => 3, 'event_id' => 2, 'state_id' => 2, 'state_type' => 'S1']); - VerbStateEvent::insert(['id' => 4, 'event_id' => 3, 'state_id' => 2, 'state_type' => 'S1']); - VerbStateEvent::insert(['id' => 5, 'event_id' => 4, 'state_id' => 1, 'state_type' => 'S1']); - VerbStateEvent::insert(['id' => 6, 'event_id' => 5, 'state_id' => 1, 'state_type' => 'S1']); - VerbStateEvent::insert(['id' => 7, 'event_id' => 5, 'state_id' => 2, 'state_type' => 'S1']); - VerbStateEvent::insert(['id' => 8, 'event_id' => 6, 'state_id' => 2, 'state_type' => 'S1']); + VerbStateEvent::insert(['id' => 1, 'event_id' => 1, 'state_id' => 1, 'state_type' => MagicTestState::class]); + VerbStateEvent::insert(['id' => 2, 'event_id' => 2, 'state_id' => 1, 'state_type' => MagicTestState::class]); + VerbStateEvent::insert(['id' => 3, 'event_id' => 2, 'state_id' => 2, 'state_type' => MagicTestState::class]); + VerbStateEvent::insert(['id' => 4, 'event_id' => 3, 'state_id' => 2, 'state_type' => MagicTestState::class]); + VerbStateEvent::insert(['id' => 5, 'event_id' => 4, 'state_id' => 1, 'state_type' => MagicTestState::class]); + VerbStateEvent::insert(['id' => 6, 'event_id' => 5, 'state_id' => 1, 'state_type' => MagicTestState::class]); + VerbStateEvent::insert(['id' => 7, 'event_id' => 5, 'state_id' => 2, 'state_type' => MagicTestState::class]); + VerbStateEvent::insert(['id' => 8, 'event_id' => 6, 'state_id' => 2, 'state_type' => MagicTestState::class]); // CASE: All events have snapshots (same event ID) VerbSnapshot::truncate(); - VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); - VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); - $results = Magic::query('S1', 2)->sortBy('state_id')->values(); + $results = (new Magic(MagicTestState::class, 2)); - expect($results)->toHaveCount(2); + expect($results->states())->toHaveCount(2); - expect($results[0]) - ->toHaveProperty('state_id', '1') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '2'); + expect($results->earliestEventId())->toBe(2); - expect($results[1]) - ->toHaveProperty('state_id', '2') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '2'); + expect($results->states()->first()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(2); + + expect($results->states()->last()) + ->toBeInstanceOf(MagicTestState::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' => 'S1', 'data' => '{}', 'last_event_id' => 4]); - VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 4]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); + + $results = (new Magic(MagicTestState::class, 2)); - $results = Magic::query('S1', 2)->sortBy('state_id')->values(); + expect($results->states())->toHaveCount(2); - expect($results)->toHaveCount(2); + expect($results->earliestEventId())->toBe(2); - expect($results[0]) - ->toHaveProperty('state_id', '1') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '4'); + expect($results->states()->first()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(4); - expect($results[1]) - ->toHaveProperty('state_id', '2') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '2'); + expect($results->states()->last()) + ->toBeInstanceOf(MagicTestState::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' => 'S1', 'data' => '{}', 'last_event_id' => 1]); - VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 1]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); - $results = Magic::query('S1', 2)->sortBy('state_id')->values(); + $results = (new Magic(MagicTestState::class, 2)); - expect($results)->toHaveCount(2); + expect($results->states())->toHaveCount(2); - expect($results[0]) - ->toHaveProperty('state_id', '1') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '1'); + expect($results->earliestEventId())->toBe(1); - expect($results[1]) - ->toHaveProperty('state_id', '2') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '2'); + expect($results->states()->first()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(1); + + expect($results->states()->last()) + ->toBeInstanceOf(MagicTestState::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' => 'S1', 'data' => '{}', 'last_event_id' => 3]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 3]); + + $results = (new Magic(MagicTestState::class, 2)); - $results = Magic::query('S1', 2) - ->sortBy('state_id') - ->values(); + expect($results->states())->toHaveCount(2); - expect($results)->toHaveCount(2); + expect($results->earliestEventId())->toBe(0); - expect($results[0]) - ->toHaveProperty('state_id', '1') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', null) - ->toHaveProperty('last_event_id', '0'); + expect($results->states()->first()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(0); - expect($results[1]) - ->toHaveProperty('state_id', '2') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '3'); + expect($results->states()->last()) + ->toBeInstanceOf(MagicTestState::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' => 'S1', 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); - $results = Magic::query('S1', 2) - ->sortBy('state_id') - ->values(); + $results = (new Magic(MagicTestState::class, 2)); - expect($results)->toHaveCount(2); + expect($results->states())->toHaveCount(2); - expect($results[0]) - ->toHaveProperty('state_id', '1') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', '{}') - ->toHaveProperty('last_event_id', '2'); + expect($results->earliestEventId())->toBe(0); - expect($results[1]) - ->toHaveProperty('state_id', '2') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', null) - ->toHaveProperty('last_event_id', '0'); + expect($results->states()->first()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(2); + + expect($results->states()->last()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(0); // CASE: Neither event has a snapshot VerbSnapshot::truncate(); - $results = Magic::query('S1', 2) - ->sortBy('state_id') - ->values(); + $results = (new Magic(MagicTestState::class, 2)); + + expect($results->states())->toHaveCount(2); - expect($results)->toHaveCount(2); + expect($results->earliestEventId())->toBe(0); - expect($results[0]) - ->toHaveProperty('state_id', '1') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', null) - ->toHaveProperty('last_event_id', '0'); + expect($results->states()->first()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('1') + ->last_event_id->toBe(0); - expect($results[1]) - ->toHaveProperty('state_id', '2') - ->toHaveProperty('state_type', 'S1') - ->toHaveProperty('data', null) - ->toHaveProperty('last_event_id', '0'); + expect($results->states()->last()) + ->toBeInstanceOf(MagicTestState::class) + ->id->toBe('2') + ->last_event_id->toBe(0); }); + +class MagicTestState extends State {} +class MagicTestEvent extends Event {} From 21aa73f91d23b16f924799131a04b70c7dd27ffa Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 9 Jul 2025 18:44:57 -0400 Subject: [PATCH 8/9] Rename --- .../{Magic.php => ReconstitutionQuery.php} | 6 +- ...icTest.php => ReconstitutionQueryTest.php} | 88 +++++++++---------- 2 files changed, 47 insertions(+), 47 deletions(-) rename src/State/{Magic.php => ReconstitutionQuery.php} (96%) rename tests/Feature/{MagicTest.php => ReconstitutionQueryTest.php} (53%) diff --git a/src/State/Magic.php b/src/State/ReconstitutionQuery.php similarity index 96% rename from src/State/Magic.php rename to src/State/ReconstitutionQuery.php index 972ab565..f7663636 100644 --- a/src/State/Magic.php +++ b/src/State/ReconstitutionQuery.php @@ -13,7 +13,7 @@ use Thunk\Verbs\State; use Thunk\Verbs\Support\Serializer; -class Magic +class ReconstitutionQuery { protected Collection $data; @@ -28,7 +28,7 @@ public function earliestEventId(): int|string } /** @return Collection */ - public function states() + public function states(): Collection { return $this->data()->map(function ($data) { $state = app(Serializer::class)->deserialize($data->state_type, $data->data ?? []); @@ -40,7 +40,7 @@ public function states() }); } - public function data(): Collection + protected function data(): Collection { return $this->data ??= $this->load(); } diff --git a/tests/Feature/MagicTest.php b/tests/Feature/ReconstitutionQueryTest.php similarity index 53% rename from tests/Feature/MagicTest.php rename to tests/Feature/ReconstitutionQueryTest.php index 5c8bb3e8..de4198a9 100644 --- a/tests/Feature/MagicTest.php +++ b/tests/Feature/ReconstitutionQueryTest.php @@ -5,151 +5,151 @@ use Thunk\Verbs\Models\VerbSnapshot; use Thunk\Verbs\Models\VerbStateEvent; use Thunk\Verbs\State; -use Thunk\Verbs\State\Magic; +use Thunk\Verbs\State\ReconstitutionQuery; -it('is magic', function () { +it('returns data as expected', function () { // Six fake events VerbEvent::truncate(); - VerbEvent::insert(['id' => 1, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 2, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 3, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 4, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 5, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); - VerbEvent::insert(['id' => 6, 'type' => MagicTestEvent::class, 'data' => '{}', 'metadata' => '{}']); + VerbEvent::insert(['id' => 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' => MagicTestState::class]); - VerbStateEvent::insert(['id' => 2, 'event_id' => 2, 'state_id' => 1, 'state_type' => MagicTestState::class]); - VerbStateEvent::insert(['id' => 3, 'event_id' => 2, 'state_id' => 2, 'state_type' => MagicTestState::class]); - VerbStateEvent::insert(['id' => 4, 'event_id' => 3, 'state_id' => 2, 'state_type' => MagicTestState::class]); - VerbStateEvent::insert(['id' => 5, 'event_id' => 4, 'state_id' => 1, 'state_type' => MagicTestState::class]); - VerbStateEvent::insert(['id' => 6, 'event_id' => 5, 'state_id' => 1, 'state_type' => MagicTestState::class]); - VerbStateEvent::insert(['id' => 7, 'event_id' => 5, 'state_id' => 2, 'state_type' => MagicTestState::class]); - VerbStateEvent::insert(['id' => 8, 'event_id' => 6, 'state_id' => 2, 'state_type' => MagicTestState::class]); + 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]); // CASE: All events have snapshots (same event ID) VerbSnapshot::truncate(); - VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); - VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); + 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 Magic(MagicTestState::class, 2)); + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); expect($results->states())->toHaveCount(2); expect($results->earliestEventId())->toBe(2); expect($results->states()->first()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('1') ->last_event_id->toBe(2); expect($results->states()->last()) - ->toBeInstanceOf(MagicTestState::class) + ->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' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 4]); - VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); + 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 Magic(MagicTestState::class, 2)); + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); expect($results->states())->toHaveCount(2); expect($results->earliestEventId())->toBe(2); expect($results->states()->first()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('1') ->last_event_id->toBe(4); expect($results->states()->last()) - ->toBeInstanceOf(MagicTestState::class) + ->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' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 1]); - VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); + 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 Magic(MagicTestState::class, 2)); + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); expect($results->states())->toHaveCount(2); expect($results->earliestEventId())->toBe(1); expect($results->states()->first()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('1') ->last_event_id->toBe(1); expect($results->states()->last()) - ->toBeInstanceOf(MagicTestState::class) + ->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' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 3]); + VerbSnapshot::insert(['id' => 2, 'state_id' => 2, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 3]); - $results = (new Magic(MagicTestState::class, 2)); + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); expect($results->states())->toHaveCount(2); expect($results->earliestEventId())->toBe(0); expect($results->states()->first()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('1') ->last_event_id->toBe(0); expect($results->states()->last()) - ->toBeInstanceOf(MagicTestState::class) + ->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' => MagicTestState::class, 'data' => '{}', 'last_event_id' => 2]); + VerbSnapshot::insert(['id' => 1, 'state_id' => 1, 'type' => ReconstitutionQueryTestState::class, 'data' => '{}', 'last_event_id' => 2]); - $results = (new Magic(MagicTestState::class, 2)); + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); expect($results->states())->toHaveCount(2); expect($results->earliestEventId())->toBe(0); expect($results->states()->first()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('1') ->last_event_id->toBe(2); expect($results->states()->last()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('2') ->last_event_id->toBe(0); // CASE: Neither event has a snapshot VerbSnapshot::truncate(); - $results = (new Magic(MagicTestState::class, 2)); + $results = (new ReconstitutionQuery(ReconstitutionQueryTestState::class, 2)); expect($results->states())->toHaveCount(2); expect($results->earliestEventId())->toBe(0); expect($results->states()->first()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('1') ->last_event_id->toBe(0); expect($results->states()->last()) - ->toBeInstanceOf(MagicTestState::class) + ->toBeInstanceOf(ReconstitutionQueryTestState::class) ->id->toBe('2') ->last_event_id->toBe(0); }); -class MagicTestState extends State {} -class MagicTestEvent extends Event {} +class ReconstitutionQueryTestState extends State {} +class ReconstitutionQueryTestEvent extends Event {} From 4c543c168553e4e37407387630b01b16e545c811 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Thu, 10 Jul 2025 12:02:47 -0400 Subject: [PATCH 9/9] Fix --- src/State/ReconstitutionQuery.php | 31 +++++++++++++++-------- tests/Feature/ReconstitutionQueryTest.php | 7 +++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/State/ReconstitutionQuery.php b/src/State/ReconstitutionQuery.php index f7663636..89e9e671 100644 --- a/src/State/ReconstitutionQuery.php +++ b/src/State/ReconstitutionQuery.php @@ -51,6 +51,23 @@ protected function load(): Collection $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, @@ -60,17 +77,7 @@ protected function load(): Collection left join `verb_snapshots` as snapshots on snapshots.state_id = state_events.state_id and snapshots.type = state_events.state_type - where 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 */ - ) - ) + join events_to_process on events_to_process.event_id = state_events.event_id SQL; $grammar = DB::getQueryGrammar(); @@ -95,6 +102,8 @@ protected function load(): Collection $bindings = [ $state_id, $state_type, + $state_id, + $state_type, ]; // fwrite(STDOUT, "\n{$sql}\n"); diff --git a/tests/Feature/ReconstitutionQueryTest.php b/tests/Feature/ReconstitutionQueryTest.php index de4198a9..a75c5001 100644 --- a/tests/Feature/ReconstitutionQueryTest.php +++ b/tests/Feature/ReconstitutionQueryTest.php @@ -28,6 +28,13 @@ 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]);