diff --git a/config/verbs.php b/config/verbs.php index faa55516..21582acb 100644 --- a/config/verbs.php +++ b/config/verbs.php @@ -29,6 +29,22 @@ */ 'id_type' => env('VERBS_ID_TYPE', 'snowflake'), + /* + |-------------------------------------------------------------------------- + | Serialize Event ID + |-------------------------------------------------------------------------- + | + | By default, the Event's `id` is excluded from the serialized JSON stored + | in verb_events.data (it's redundant with the row's primary key). Some + | event types (e.g. SerializedByVerbs) require `id` for deserialization. + | VerbEvent injects the row id when loading in that case. + | + | Set to true to include `id` in stored data so events deserialize without + | injection. Useful for new installations or after replaying events. + | + */ + 'serialize_event_id' => env('VERBS_SERIALIZE_EVENT_ID', false), + /* |-------------------------------------------------------------------------- | Normalizers @@ -118,7 +134,7 @@ | | By default, Verbs will auto-commit events to the event store for you: | - | - at the end of every request (before returning a response) + | - at the end of every request (before returning the response) | - at the end of every console command | - at the end of every queued job | diff --git a/src/Models/VerbEvent.php b/src/Models/VerbEvent.php index 63e20e6d..dc97facc 100644 --- a/src/Models/VerbEvent.php +++ b/src/Models/VerbEvent.php @@ -40,8 +40,14 @@ public function getTable() public function event(): Event { - $this->event ??= app(Serializer::class)->deserialize($this->type, $this->data); - $this->event->id = $this->id; + if ($this->event !== null) { + return $this->event; + } + + // When serialize_event_id is false (default), stored data lacks id but some event types + // require it for deserialization. Always use row id as source of truth. + $data = array_merge($this->data ?? [], ['id' => $this->id]); + $this->event = app(Serializer::class)->deserialize($this->type, $data); app(MetadataManager::class)->setEphemeral($this->event, 'created_at', $this->created_at); diff --git a/src/Support/PendingEvent.php b/src/Support/PendingEvent.php index 15bab288..d7ee5550 100644 --- a/src/Support/PendingEvent.php +++ b/src/Support/PendingEvent.php @@ -92,6 +92,14 @@ public function shouldFire(): static public function hydrate(array $data): static { + // When firing with args, we deserialize from the passed data. Events require an id, + // but we don't have one yet when creating fresh. Inject it before deserializing. + $target = $this->event; + $targetClass = is_string($target) ? $target : $target::class; + if (is_subclass_of($targetClass, Event::class) && ! array_key_exists('id', $data)) { + $data = array_merge($data, ['id' => snowflake_id()]); + } + $this->event = app(Serializer::class)->deserialize($this->event, $data, call_constructor: true); app(MetadataManager::class)->initialize($this->event); diff --git a/src/Support/Serializer.php b/src/Support/Serializer.php index d8fa8709..661712ef 100644 --- a/src/Support/Serializer.php +++ b/src/Support/Serializer.php @@ -79,7 +79,8 @@ protected function serializationContext(object $target): array $context = [...$this->context]; if ($target instanceof Event) { - $context[AbstractNormalizer::IGNORED_ATTRIBUTES] = ['id']; + $ignored = config('verbs.serialize_event_id', false) ? [] : ['id']; + $context[AbstractNormalizer::IGNORED_ATTRIBUTES] = $ignored; } if ($target instanceof State) { diff --git a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php new file mode 100644 index 00000000..7f54e670 --- /dev/null +++ b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php @@ -0,0 +1,74 @@ + $rowId, + 'type' => $eventType, + 'data' => json_encode([]), // Simulates serialized payload without id (default) + 'metadata' => '{}', + 'created_at' => now(), + ]); + + $verbEvent = VerbEvent::find($rowId); + expect($verbEvent)->not->toBeNull(); + + $event = $verbEvent->event(); + + expect($event) + ->toBeInstanceOf(Event::class) + ->and($event->id)->toBe($rowId); +}); + +it('overwrites id in data with row id so row remains source of truth', function () { + $rowId = snowflake_id(); + $staleIdInData = snowflake_id() + 1; + $eventType = VerbEventDeserializesWithoutIdInDataTestEvent::class; + + VerbEvent::insert([ + 'id' => $rowId, + 'type' => $eventType, + 'data' => json_encode(['id' => $staleIdInData]), + 'metadata' => '{}', + 'created_at' => now(), + ]); + + $verbEvent = VerbEvent::find($rowId); + $event = $verbEvent->event(); + + expect($event->id)->toBe($rowId); +}); + +it('excludes event id from serialization when serialize_event_id is false', function () { + config(['verbs.serialize_event_id' => false]); + + $event = new VerbEventDeserializesWithoutIdInDataTestEvent; + $event->id = snowflake_id(); + + $serialized = app(Serializer::class)->serialize($event); + + expect($serialized)->not->toContain((string) $event->id); +}); + +it('includes event id in serialization when serialize_event_id is true', function () { + config(['verbs.serialize_event_id' => true]); + + $event = new VerbEventDeserializesWithoutIdInDataTestEvent; + $event->id = $id = snowflake_id(); + + $serialized = app(Serializer::class)->serialize($event); + + expect($serialized)->toContain((string) $id); +}); + +class VerbEventDeserializesWithoutIdInDataTestEvent extends Event {}