gdbots/ncr-php

View on GitHub
src/Aggregate.php

Summary

Maintainability
F
4 days
Test Coverage
<?php
declare(strict_types=1);

namespace Gdbots\Ncr;

use Gdbots\Ncr\Exception\InvalidArgumentException;
use Gdbots\Ncr\Exception\LogicException;
use Gdbots\Ncr\Exception\NodeAlreadyLocked;
use Gdbots\Pbj\Message;
use Gdbots\Pbj\MessageResolver;
use Gdbots\Pbj\Util\ClassUtil;
use Gdbots\Pbj\Util\SlugUtil;
use Gdbots\Pbj\WellKnown\MessageRef;
use Gdbots\Pbj\WellKnown\Microtime;
use Gdbots\Pbj\WellKnown\NodeRef;
use Gdbots\Pbjx\Pbjx;
use Gdbots\Schemas\Ncr\Enum\NodeStatus;
use Gdbots\Schemas\Ncr\Event\NodeCreatedV1;
use Gdbots\Schemas\Ncr\Event\NodeDeletedV1;
use Gdbots\Schemas\Ncr\Event\NodeExpiredV1;
use Gdbots\Schemas\Ncr\Event\NodeLabelsUpdatedV1;
use Gdbots\Schemas\Ncr\Event\NodeLockedV1;
use Gdbots\Schemas\Ncr\Event\NodeMarkedAsDraftV1;
use Gdbots\Schemas\Ncr\Event\NodeMarkedAsPendingV1;
use Gdbots\Schemas\Ncr\Event\NodePublishedV1;
use Gdbots\Schemas\Ncr\Event\NodeRenamedV1;
use Gdbots\Schemas\Ncr\Event\NodeScheduledV1;
use Gdbots\Schemas\Ncr\Event\NodeTagsUpdatedV1;
use Gdbots\Schemas\Ncr\Event\NodeUnlockedV1;
use Gdbots\Schemas\Ncr\Event\NodeUnpublishedV1;
use Gdbots\Schemas\Ncr\Event\NodeUpdatedV1;
use Gdbots\Schemas\Pbjx\StreamId;

class Aggregate
{
    protected Message $node;
    protected NodeRef $nodeRef;
    protected Pbjx $pbjx;

    /** @var Message[] */
    protected array $events = [];

    /**
     * When the aggregate is first created from a node/snapshot
     * or from a NodeRef we need to inform the sync process that
     * it should read the entire stream or use the last updated
     * at value from the node itself to determine where to start.
     *
     * @var bool
     */
    protected bool $syncAllEvents;

    /**
     * Creates a new instance of the aggregate with the initial
     * state being the provided node (aka snapshot).
     *
     * @param Message $node
     * @param Pbjx    $pbjx
     *
     * @return static
     */
    public static function fromNode(Message $node, Pbjx $pbjx): self
    {
        return new static($node, $pbjx, false);
    }

    /**
     * Creates a new instance of the aggregate without an initial
     * state (node/snapshot). The aggregate will create its own default state.
     *
     * @param NodeRef $nodeRef
     * @param Pbjx    $pbjx
     *
     * @return static
     */
    public static function fromNodeRef(NodeRef $nodeRef, Pbjx $pbjx): self
    {
        $node = MessageResolver::resolveQName($nodeRef->getQName())::fromArray([
            '_id' => $nodeRef->getId(),
        ]);

        return new static($node, $pbjx, true);
    }

    public static function generateEtag(Message $node): string
    {
        return $node->generateEtag([
            'etag',
            'updated_at',
            'updater_ref',
            'last_event_ref',
        ]);
    }

    protected function __construct(Message $node, Pbjx $pbjx, bool $syncAllEvents = false)
    {
        $this->nodeRef = $node->generateNodeRef();
        $this->node = $node->isFrozen() ? clone $node : $node;
        $this->pbjx = $pbjx;
        $this->syncAllEvents = $syncAllEvents;
    }

    public function useSoftDelete(): bool
    {
        return true;
    }

    public function hasUncommittedEvents(): bool
    {
        return !empty($this->events);
    }

    /**
     * Returns any events that have resulted from processing commands
     * that have yet to be committed.
     *
     * @return Message[]
     */
    public function getUncommittedEvents(): array
    {
        return $this->events;
    }

    /**
     * Clears all uncommitted events. Note that this does NOT restore
     * the state of the aggregate. To restore just create a new instance
     * with the original node (snapshot).
     */
    public function clearUncommittedEvents(): void
    {
        $this->events = [];
    }

    /**
     * Persists all of the uncommitted events to the EventStore.
     *
     * @param array $context Data that helps the EventStore decide where to read/write data from.
     */
    public function commit(array $context = []): void
    {
        if (!$this->hasUncommittedEvents()) {
            return;
        }

        $streamId = $this->getStreamId();
        $this->pbjx->getEventStore()->putEvents($streamId, $this->events, null, $context);
        $this->events = [];
        $this->syncAllEvents = false;
    }

    /**
     * Reads events from the EventStore for this aggregate's
     * stream and applies them to the state.
     *
     * @param array $context Data that helps the EventStore decide where to read/write data from.
     */
    public function sync(array $context = []): void
    {
        if ($this->hasUncommittedEvents()) {
            throw new LogicException(sprintf('The [%s] has uncommitted events.', $this->nodeRef->toString()));
        }

        $eventStore = $this->pbjx->getEventStore();
        $streamId = $this->getStreamId();
        $since = $this->syncAllEvents ? null : $this->getLastUpdatedAt();

        foreach ($eventStore->pipeEvents($streamId, $since, null, $context) as $event) {
            $this->applyEvent($event);
        }

        $this->syncAllEvents = false;
    }

    /**
     * Returns the current state of the aggregate as a node
     * which can be stored, serialized, etc. as a snapshot.
     *
     * @return Message
     */
    public function getNode(): Message
    {
        return clone $this->node;
    }

    public function getNodeRef(): NodeRef
    {
        return $this->nodeRef;
    }

    public function getStreamId(): StreamId
    {
        return StreamId::fromNodeRef($this->nodeRef);
    }

    public function getEtag(): ?string
    {
        return $this->node->get('etag');
    }

    public function getLastEventRef(): ?MessageRef
    {
        return $this->node->get('last_event_ref');
    }

    public function getLastUpdatedAt(): Microtime
    {
        return $this->node->get('updated_at') ?: $this->node->get('created_at');
    }

    public function createNode(Message $command): void
    {
        /** @var Message $node */
        $node = clone $command->get('node');
        $this->assertNodeRefMatches($node->generateNodeRef());

        $event = $this->createNodeCreatedEvent($command);
        $this->copyContext($command, $event);
        $event->set('node', $node);

        $node
            ->clear('updated_at')
            ->clear('updater_ref')
            ->set('created_at', $event->get('occurred_at'))
            ->set('creator_ref', $event->get('ctx_user_ref'))
            ->set('last_event_ref', $event->generateMessageRef());

        if ($node::schema()->hasMixin('gdbots:ncr:mixin:publishable')) {
            $node->set('status', NodeStatus::DRAFT);
        } else {
            $node->set('status', NodeStatus::PUBLISHED);
        }

        $this->recordEvent($event);
    }

    public function deleteNode(Message $command): void
    {
        if ($this->node->get('status') === NodeStatus::DELETED) {
            // node already deleted, ignore
            return;
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeDeletedEvent($command);
        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug')) {
            $event->set('slug', $this->node->get('slug'));
        }

        $this->recordEvent($event);
    }

    public function expireNode(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:expirable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:expirable]."
            );
        }

        /** @var NodeStatus $currStatus */
        $currStatus = $this->node->get('status');
        if ($currStatus === NodeStatus::DELETED || $currStatus === NodeStatus::EXPIRED) {
            // already expired or soft-deleted nodes can be ignored
            return;
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeExpiredEvent($command);
        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug')) {
            $event->set('slug', $this->node->get('slug'));
        }

        $this->recordEvent($event);
    }

    public function lockNode(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:lockable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:lockable]."
            );
        }

        if ($this->node->get('is_locked')) {
            if ($command->has('ctx_user_ref')) {
                $userNodeRef = NodeRef::fromMessageRef($command->get('ctx_user_ref'));
                if ((string)$this->node->get('locked_by_ref') === (string)$userNodeRef) {
                    // if it's the same user we can ignore it because they already own the lock
                    return;
                }
            }

            throw new NodeAlreadyLocked();
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeLockedEvent($command);
        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug') && $event::schema()->hasField('slug')) {
            $event->set('slug', $this->node->get('slug'));
        }

        $this->recordEvent($event);
    }

    public function markNodeAsDraft(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:publishable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:publishable]."
            );
        }

        if ($this->node->get('status') === NodeStatus::DRAFT) {
            // node already draft, ignore
            return;
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeMarkedAsDraftEvent($command);
        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug')) {
            $event->set('slug', $this->node->get('slug'));
        }

        $this->recordEvent($event);
    }

    public function markNodeAsPending(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:publishable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:publishable]."
            );
        }

        if ($this->node->get('status') === NodeStatus::PENDING) {
            // node already pending, ignore
            return;
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeMarkedAsPendingEvent($command);
        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug')) {
            $event->set('slug', $this->node->get('slug'));
        }

        $this->recordEvent($event);
    }

    public function publishNode(Message $command, ?\DateTimeZone $localTimeZone = null): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:publishable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:publishable]."
            );
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        /** @var \DateTimeInterface $publishAt */
        $publishAt = $command->get('publish_at') ?: $command->get('occurred_at')->toDateTime();
        /*
         * If the node will publish within 15 seconds then we'll
         * just publish it now rather than schedule it.
         */
        $now = time() + 15;

        /** @var NodeStatus $currStatus */
        $currStatus = $this->node->get('status');
        $currPublishedAt = $this->node->has('published_at') ? $this->node->get('published_at')->getTimestamp() : null;

        if ($now >= $publishAt->getTimestamp()) {
            if ($currStatus === NodeStatus::PUBLISHED && $currPublishedAt === $publishAt->getTimestamp()) {
                return;
            }
            $event = $this->createNodePublishedEvent($command);
            $event->set('published_at', $publishAt);
        } else {
            if ($currStatus === NodeStatus::SCHEDULED && $currPublishedAt === $publishAt->getTimestamp()) {
                return;
            }
            $event = $this->createNodeScheduledEvent($command);
            $event->set('publish_at', $publishAt);
        }

        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug')) {
            $slug = $this->node->get('slug');
            if (null !== $localTimeZone && SlugUtil::containsDate($slug)) {
                $date = $publishAt instanceof \DateTimeImmutable
                    ? \DateTime::createFromImmutable($publishAt)
                    : clone $publishAt;
                $date->setTimezone($localTimeZone);
                $slug = SlugUtil::addDate($slug, $date);
            }
            $event->set('slug', $slug);
        }

        $this->recordEvent($event);
    }

    public function renameNode(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:sluggable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:sluggable]."
            );
        }

        if ($this->node->get('slug') === $command->get('new_slug')) {
            // ignore a pointless rename
            return;
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeRenamedEvent($command);
        $this->copyContext($command, $event);
        $event
            ->set('node_ref', $nodeRef)
            ->set('new_slug', $command->get('new_slug'))
            ->set('old_slug', $this->node->get('slug'))
            ->set('node_status', $this->node->get('status'));

        $this->recordEvent($event);
    }

    public function unlockNode(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:lockable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:lockable]."
            );
        }

        if (!$this->node->get('is_locked')) {
            // node already unlocked, ignore
            return;
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeUnlockedEvent($command);
        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug') && $event::schema()->hasField('slug')) {
            $event->set('slug', $this->node->get('slug'));
        }

        $this->recordEvent($event);
    }

    public function unpublishNode(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:ncr:mixin:publishable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:ncr:mixin:publishable]."
            );
        }

        if ($this->node->get('status') !== NodeStatus::PUBLISHED) {
            // node already not published, ignore
            return;
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $event = $this->createNodeUnpublishedEvent($command);
        $this->copyContext($command, $event);
        $event->set('node_ref', $this->nodeRef);

        if ($this->node->has('slug')) {
            $event->set('slug', $this->node->get('slug'));
        }

        $this->recordEvent($event);
    }

    public function updateNode(Message $command): void
    {
        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        /** @var Message $newNode */
        $newNode = clone $command->get('new_node');
        $this->assertNodeRefMatches($newNode->generateNodeRef());

        $oldNode = (clone $this->node)->freeze();
        $event = $this->createNodeUpdatedEvent($command);
        $this->copyContext($command, $event);
        $event
            ->set('node_ref', $this->nodeRef)
            ->set('old_node', $oldNode)
            ->set('new_node', $newNode);

        $schema = $newNode::schema();

        if ($command->has('paths')) {
            $paths = $command->get('paths');
            $event->addToSet('paths', $paths);
            $paths = array_flip($paths);
            foreach ($schema->getFields() as $field) {
                $fieldName = $field->getName();
                if (isset($paths[$fieldName])) {
                    // this means we intended to set this value
                    // so leave it as is.
                    continue;
                }

                $newNode->setWithoutValidation($fieldName, $oldNode->fget($fieldName));
            }
        }

        $newNode
            ->set('updated_at', $event->get('occurred_at'))
            ->set('updater_ref', $event->get('ctx_user_ref'))
            ->set('last_event_ref', $event->generateMessageRef())
            // status SHOULD NOT change during an update, use the appropriate
            // command to change a status (delete, publish, etc.)
            ->set('status', $oldNode->get('status'))
            // created_at and creator_ref MUST NOT change
            ->set('created_at', $oldNode->get('created_at'))
            ->set('creator_ref', $oldNode->get('creator_ref'));

        // labels SHOULD NOT change during an update, use "update-node-labels"
        if ($schema->hasMixin('gdbots:common:mixin:labelable')) {
            $newNode->setWithoutValidation('labels', $oldNode->fget('labels'));
        }

        // published_at SHOULD NOT change during an update, use "[un]publish-node"
        if ($schema->hasMixin('gdbots:ncr:mixin:publishable')) {
            $newNode->set('published_at', $oldNode->get('published_at'));
        }

        // slug SHOULD NOT change during an update, use "rename-node"
        if ($schema->hasMixin('gdbots:ncr:mixin:sluggable')) {
            $newNode->set('slug', $oldNode->get('slug'));
        }

        // is_locked and locked_by_ref SHOULD NOT change during an update, use "[un]lock-node"
        if ($schema->hasMixin('gdbots:ncr:mixin:lockable')) {
            $newNode
                ->set('is_locked', $oldNode->get('is_locked'))
                ->set('locked_by_ref', $oldNode->get('locked_by_ref'));
        }

        // if a node is being updated and it's deleted, restore the default status
        if (NodeStatus::DELETED === $newNode->get('status')) {
            $newNode->clear('status');
        }

        $this->recordEvent($event);
    }

    public function updateNodeLabels(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:common:mixin:labelable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:common:mixin:labelable]."
            );
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $added = array_values(array_filter(
            $command->get('add_labels', []),
            fn(string $label) => !$this->node->isInSet('labels', $label)
        ));

        $removed = array_values(array_filter(
            $command->get('remove_labels', []),
            fn(string $label) => $this->node->isInSet('labels', $label)
        ));

        if (empty($added) && empty($removed)) {
            return;
        }

        $event = NodeLabelsUpdatedV1::create();
        $this->copyContext($command, $event);
        $event
            ->set('node_ref', $this->nodeRef)
            ->addToSet('labels_added', $added)
            ->addToSet('labels_removed', $removed);

        $this->recordEvent($event);
    }

    public function updateNodeTags(Message $command): void
    {
        if (!$this->node::schema()->hasMixin('gdbots:common:mixin:taggable')) {
            throw new InvalidArgumentException(
                "Node [{$this->nodeRef}] must have [gdbots:common:mixin:taggable]."
            );
        }

        /** @var NodeRef $nodeRef */
        $nodeRef = $command->get('node_ref');
        $this->assertNodeRefMatches($nodeRef);

        $removed = array_values(array_filter(
            $command->get('remove_tags', []),
            fn(string $tag) => $this->node->isInMap('tags', $tag)
        ));

        if (!$command->has('add_tags') && empty($removed)) {
            return;
        }

        $event = NodeTagsUpdatedV1::create();
        $this->copyContext($command, $event);
        $event
            ->set('node_ref', $this->nodeRef)
            ->addToSet('tags_removed', $removed);

        foreach ($command->get('add_tags', []) as $k => $v) {
            $event->addToMap('tags_added', $k, $v);
        }

        $this->recordEvent($event);
    }

    protected function applyNodeCreated(Message $event): void
    {
        $this->node = clone $event->get('node');
    }

    protected function applyNodeDeleted(Message $event): void
    {
        $this->node->set('status', NodeStatus::DELETED);
    }

    protected function applyNodeExpired(Message $event): void
    {
        $this->node->set('status', NodeStatus::EXPIRED);
    }

    protected function applyNodeLabelsUpdated(Message $event): void
    {
        $this->node
            ->removeFromSet('labels', $event->get('labels_removed', []))
            ->addToSet('labels', $event->get('labels_added', []));
    }

    protected function applyNodeLocked(Message $event): void
    {
        if ($event->has('ctx_user_ref')) {
            $lockedByRef = NodeRef::fromMessageRef($event->get('ctx_user_ref'));
        } else {
            /*
             * todo: make "bots" a first class citizen in iam services
             * this is not likely to ever occur (being locked without a user ref)
             * but if it did we'll fake our future bot strategy for now.  the
             * eventual solution is that bots will be like users but will perform
             * operations through pbjx endpoints only, not via the web clients.
             */
            $lockedByRef = NodeRef::fromString("{$this->nodeRef->getVendor()}:user:e3949dc0-4261-4731-beb0-d32e723de939");
        }

        $this->node
            ->set('is_locked', true)
            ->set('locked_by_ref', $lockedByRef);
    }

    protected function applyNodeMarkedAsDraft(Message $event): void
    {
        $this->node
            ->set('status', NodeStatus::DRAFT)
            ->clear('published_at');
    }

    protected function applyNodeMarkedAsPending(Message $event): void
    {
        $this->node
            ->set('status', NodeStatus::PENDING)
            ->clear('published_at');
    }

    protected function applyNodePublished(Message $event): void
    {
        $this->node
            ->set('status', NodeStatus::PUBLISHED)
            ->set('published_at', $event->get('published_at'));

        if ($event->has('slug')) {
            $this->node->set('slug', $event->get('slug'));
        }
    }

    protected function applyNodeRenamed(Message $event): void
    {
        $this->node->set('slug', $event->get('new_slug'));
    }

    protected function applyNodeScheduled(Message $event): void
    {
        $this->node
            ->set('status', NodeStatus::SCHEDULED)
            ->set('published_at', $event->get('publish_at'));

        if ($event->has('slug')) {
            $this->node->set('slug', $event->get('slug'));
        }
    }

    protected function applyNodeTagsUpdated(Message $event): void
    {
        foreach ($event->get('tags_removed', []) as $tag) {
            $this->node->removeFromMap('tags', $tag);
        }

        foreach ($event->get('tags_added', []) as $k => $v) {
            $this->node->addToMap('tags', $k, $v);
        }
    }

    protected function applyNodeUnlocked(Message $event): void
    {
        $this->node
            ->set('is_locked', false)
            ->clear('locked_by_ref');
    }

    protected function applyNodeUnpublished(Message $event): void
    {
        $this->node
            ->set('status', NodeStatus::DRAFT)
            ->clear('published_at');
    }

    protected function applyNodeUpdated(Message $event): void
    {
        $this->node = clone $event->get('new_node');
    }

    protected function enrichNodeCreated(Message $event): void
    {
        /** @var Message $node */
        $node = $event->get('node');
        $node->set('etag', static::generateEtag($node));
    }

    protected function enrichNodeUpdated(Message $event): void
    {
        /** @var Message $node */
        $node = $event->get('new_node');
        $node->set('etag', static::generateEtag($node));
        $event
            ->set('old_etag', $this->node->get('etag'))
            ->set('new_etag', $node->get('etag'));
    }

    protected function assertNodeRefMatches(NodeRef $nodeRef): void
    {
        if ($this->nodeRef->equals($nodeRef)) {
            return;
        }

        throw new InvalidArgumentException(sprintf(
            '%s Provided NodeRef [%s] does not match [%s].',
            ClassUtil::getShortName(static::class),
            $this->nodeRef->toString(),
            $nodeRef->toString()
        ));
    }

    protected function applyEvent(Message $event): void
    {
        $method = $event::schema()->getHandlerMethodName(false, 'apply');
        $this->$method($event);
        $this->syncAllEvents = false;
        $eventRef = $event->generateMessageRef();

        if ($this->node->has('last_event_ref') && $eventRef->equals($this->node->get('last_event_ref'))) {
            // the apply* method already performed the updates
            // to updated and etag fields
            return;
        }

        $this->node
            ->set('updated_at', $event->get('occurred_at'))
            ->set('updater_ref', $event->get('ctx_user_ref'))
            ->set('last_event_ref', $eventRef)
            ->set('etag', static::generateEtag($this->node));
    }

    protected function copyContext(Message $command, Message $event): void
    {
        $this->pbjx->copyContext($command, $event);
        if ($event::schema()->hasMixin('gdbots:common:mixin:taggable')) {
            foreach ($command->get('tags', []) as $k => $v) {
                $event->addToMap('tags', $k, $v);
            }
        }
    }

    protected function shouldRecordEvent(Message $event): bool
    {
        if (!$event->has('new_etag')) {
            return true;
        }

        $oldEtag = $event->get('old_etag');
        $newEtag = $event->get('new_etag');
        return $oldEtag !== $newEtag;
    }

    protected function recordEvent(Message $event): void
    {
        $this->pbjx->triggerLifecycle($event);
        $method = $event::schema()->getHandlerMethodName(false, 'enrich');
        if (is_callable([$this, $method])) {
            $this->$method($event);
        }

        if (!$this->shouldRecordEvent($event)) {
            return;
        }

        $this->events[] = $event->freeze();
        $this->applyEvent($event);
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeCreatedEvent(Message $command): Message
    {
        return NodeCreatedV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeDeletedEvent(Message $command): Message
    {
        return NodeDeletedV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeExpiredEvent(Message $command): Message
    {
        return NodeExpiredV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeLockedEvent(Message $command): Message
    {
        return NodeLockedV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeMarkedAsDraftEvent(Message $command): Message
    {
        return NodeMarkedAsDraftV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeMarkedAsPendingEvent(Message $command): Message
    {
        return NodeMarkedAsPendingV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodePublishedEvent(Message $command): Message
    {
        return NodePublishedV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeRenamedEvent(Message $command): Message
    {
        return NodeRenamedV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeScheduledEvent(Message $command): Message
    {
        return NodeScheduledV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeUnlockedEvent(Message $command): Message
    {
        return NodeUnlockedV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeUnpublishedEvent(Message $command): Message
    {
        return NodeUnpublishedV1::create();
    }

    /**
     * This is for legacy uses of command/event mixins for common
     * ncr operations. It will be removed in 4.x.
     *
     * @param Message $command
     *
     * @return Message
     *
     * @deprecated Will be removed in 4.x.
     */
    protected function createNodeUpdatedEvent(Message $command): Message
    {
        return NodeUpdatedV1::create();
    }
}