diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php
index 8a0f6de9359..19a66fd8c0d 100644
--- a/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php
+++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/ContentGraphReadModelAdapter.php
@@ -23,6 +23,7 @@
use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
+use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsDeactivated;
use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream;
use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace;
@@ -66,6 +67,9 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraph
if ($row === false) {
throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName);
}
+ if ($row['currentContentStreamId'] === null) {
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspaceName);
+ }
$currentContentStreamId = ContentStreamId::fromString($row['currentContentStreamId']);
return new ContentGraph($this->dbal, $this->nodeFactory, $this->contentRepositoryId, $this->nodeTypeManager, $this->tableNames, $workspaceName, $currentContentStreamId);
}
@@ -144,7 +148,7 @@ private function getBasicWorkspaceQuery(): QueryBuilder
return $queryBuilder
->select('ws.name, ws.baseWorkspaceName, ws.currentContentStreamId, cs.hasChanges, cs.sourceContentStreamVersion = scs.version as upToDateWithBase')
->from($this->tableNames->workspace(), 'ws')
- ->join('ws', $this->tableNames->contentStream(), 'cs', 'cs.id = ws.currentcontentstreamid')
+ ->leftJoin('ws', $this->tableNames->contentStream(), 'cs', 'cs.id = ws.currentcontentstreamid')
->leftJoin('cs', $this->tableNames->contentStream(), 'scs', 'scs.id = cs.sourceContentStreamId');
}
@@ -153,9 +157,13 @@ private function getBasicWorkspaceQuery(): QueryBuilder
*/
private static function workspaceFromDatabaseRow(array $row): Workspace
{
+ $workspaceName = WorkspaceName::fromString($row['name']);
$baseWorkspaceName = $row['baseWorkspaceName'] !== null ? WorkspaceName::fromString($row['baseWorkspaceName']) : null;
- if ($baseWorkspaceName === null) {
+ if ($row['currentContentStreamId'] === null) {
+ // no currentContentStreamId, workspace is deactivated
+ $status = WorkspaceStatus::DEACTIVATED;
+ } elseif ($baseWorkspaceName === null) {
// no base workspace, a root is always up-to-date
$status = WorkspaceStatus::UP_TO_DATE;
} elseif ($row['upToDateWithBase'] === 1) {
@@ -167,9 +175,9 @@ private static function workspaceFromDatabaseRow(array $row): Workspace
}
return Workspace::create(
- WorkspaceName::fromString($row['name']),
+ $workspaceName,
$baseWorkspaceName,
- ContentStreamId::fromString($row['currentContentStreamId']),
+ $row['currentContentStreamId'] ? ContentStreamId::fromString($row['currentContentStreamId']) : null,
$status,
$baseWorkspaceName === null
? false
diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php
index fc713cc4c0b..d835bc46191 100644
--- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php
+++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjection.php
@@ -51,6 +51,8 @@
use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasTagged;
use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasUntagged;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasActivated;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasDeactivated;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\WorkspaceWasCreated;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceBaseWorkspaceWasChanged;
@@ -167,9 +169,11 @@ public function apply(EventInterface $event, EventEnvelope $eventEnvelope): void
WorkspaceBaseWorkspaceWasChanged::class => $this->whenWorkspaceBaseWorkspaceWasChanged($event),
WorkspaceRebaseFailed::class => $this->whenWorkspaceRebaseFailed($event),
WorkspaceWasCreated::class => $this->whenWorkspaceWasCreated($event),
+ WorkspaceWasActivated::class => $this->whenWorkspaceWasActivated($event),
WorkspaceWasDiscarded::class => $this->whenWorkspaceWasDiscarded($event),
WorkspaceWasPublished::class => $this->whenWorkspaceWasPublished($event),
WorkspaceWasRebased::class => $this->whenWorkspaceWasRebased($event),
+ WorkspaceWasDeactivated::class => $this->whenWorkspaceWasDeactivated($event),
WorkspaceWasRemoved::class => $this->whenWorkspaceWasRemoved($event),
default => null,
};
@@ -683,6 +687,16 @@ private function whenWorkspaceBaseWorkspaceWasChanged(WorkspaceBaseWorkspaceWasC
$this->updateBaseWorkspace($event->workspaceName, $event->baseWorkspaceName, $event->newContentStreamId);
}
+ private function whenWorkspaceWasActivated(WorkspaceWasActivated $event): void
+ {
+ $this->updateWorkspaceContentStreamId($event->workspaceName, $event->newContentStreamId);
+ }
+
+ private function whenWorkspaceWasDeactivated(WorkspaceWasDeactivated $event): void
+ {
+ $this->updateWorkspaceContentStreamId($event->workspaceName, null);
+ }
+
private function whenWorkspaceRebaseFailed(WorkspaceRebaseFailed $event): void
{
// legacy handling:
diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php
index 0aaa056c601..2e5baf0b2c6 100644
--- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php
+++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphSchemaBuilder.php
@@ -109,7 +109,7 @@ private function createWorkspaceTable(AbstractPlatform $platform): Table
$workspaceTable = self::createTable($this->tableNames->workspace(), [
DbalSchemaFactory::columnForWorkspaceName('name', $platform)->setNotnull(true),
DbalSchemaFactory::columnForWorkspaceName('baseWorkspaceName', $platform)->setNotnull(false),
- DbalSchemaFactory::columnForContentStreamId('currentContentStreamId', $platform)->setNotNull(true),
+ DbalSchemaFactory::columnForContentStreamId('currentContentStreamId', $platform)->setNotNull(false),
]);
$workspaceTable->addUniqueIndex(['currentContentStreamId']);
diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php
index 882e4469f96..19b556a1c9d 100644
--- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php
+++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Projection/Feature/Workspace.php
@@ -45,10 +45,10 @@ private function updateBaseWorkspace(WorkspaceName $workspaceName, WorkspaceName
private function updateWorkspaceContentStreamId(
WorkspaceName $workspaceName,
- ContentStreamId $contentStreamId,
+ ?ContentStreamId $contentStreamId,
): void {
$this->dbal->update($this->tableNames->workspace(), [
- 'currentContentStreamId' => $contentStreamId->value,
+ 'currentContentStreamId' => $contentStreamId?->value,
], [
'name' => $workspaceName->value
]);
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/01-ConstraintChecks.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/01-ConstraintChecks.feature
new file mode 100644
index 00000000000..812592b6dd0
--- /dev/null
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/01-ConstraintChecks.feature
@@ -0,0 +1,73 @@
+@contentrepository @adapters=DoctrineDBAL
+Feature: Deactivate/Activate workspace constraints
+
+ Background:
+ Given using no content dimensions
+ And using the following node types:
+ """yaml
+ 'Neos.ContentRepository.Testing:Content':
+ properties:
+ text:
+ type: string
+ 'Neos.ContentRepository.Testing:Document':
+ childNodes:
+ child1:
+ type: 'Neos.ContentRepository.Testing:Content'
+ child2:
+ type: 'Neos.ContentRepository.Testing:Content'
+ """
+ And using identifier "default", I define a content repository
+ And I am in content repository "default"
+ And the command CreateRootWorkspace is executed with payload:
+ | Key | Value |
+ | workspaceName | "live" |
+ | newContentStreamId | "cs-identifier" |
+ And I am in workspace "live"
+ And the command CreateRootNodeAggregateWithNode is executed with payload:
+ | Key | Value |
+ | workspaceName | "live" |
+ | nodeAggregateId | "lady-eleonode-rootford" |
+ | nodeTypeName | "Neos.ContentRepository:Root" |
+
+ And the command CreateWorkspace is executed with payload:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ | baseWorkspaceName | "live" |
+ | newContentStreamId | "user-cs-identifier" |
+
+ Scenario: Deactivating the workspace is not allowed for workspaces with other workspaces depending on it
+ When the command DeactivateWorkspace is executed with payload and exceptions are caught:
+ | Key | Value |
+ | workspaceName | "live" |
+
+ Then the last command should have thrown an exception of type "WorkspaceHasWorkspacesDependingOnIt"
+
+ Scenario: Activating the workspace is not allowed for active workspaces
+ When the command ActivateWorkspace is executed with payload and exceptions are caught:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ | newContentStreamId | "new-user-cs-identifier" |
+
+ Then the last command should have thrown an exception of type "WorkspaceIsActivated" with code 1766069245 and message:
+ """
+ The workspace "user-test" is activated
+ """
+
+ Scenario: Deactivating the workspace is not allowed if there are pending changes
+ When the command CreateNodeAggregateWithNode is executed with payload:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ | nodeAggregateId | "holy-nody" |
+ | nodeTypeName | "Neos.ContentRepository.Testing:Content" |
+ | originDimensionSpacePoint | {} |
+ | parentNodeAggregateId | "lady-eleonode-rootford" |
+ | initialPropertyValues | {"text": "New node in shared"} |
+
+ Given I am in workspace "user-test" and dimension space point {}
+ Then I expect node aggregate identifier "holy-nody" to lead to node user-cs-identifier;holy-nody;{}
+
+ When the command DeactivateWorkspace is executed with payload and exceptions are caught:
+ | Key | Value |
+ | workspaceName | "user-test" |
+
+ Then the last command should have thrown an exception of type "WorkspaceContainsPublishableChanges"
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/02-BasicFeatures.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/02-BasicFeatures.feature
new file mode 100644
index 00000000000..653bc2def1c
--- /dev/null
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/W12-DeactivateWorkspace/02-BasicFeatures.feature
@@ -0,0 +1,76 @@
+@contentrepository @adapters=DoctrineDBAL
+Feature: Deactivate and Activate a Workspace
+
+ Background:
+ Given using no content dimensions
+ And using the following node types:
+ """yaml
+ 'Neos.ContentRepository.Testing:Content':
+ properties:
+ text:
+ type: string
+ 'Neos.ContentRepository.Testing:Document':
+ childNodes:
+ child1:
+ type: 'Neos.ContentRepository.Testing:Content'
+ child2:
+ type: 'Neos.ContentRepository.Testing:Content'
+ """
+ And using identifier "default", I define a content repository
+ And I am in content repository "default"
+ And the command CreateRootWorkspace is executed with payload:
+ | Key | Value |
+ | workspaceName | "live" |
+ | newContentStreamId | "cs-identifier" |
+ And I am in workspace "live"
+ And the command CreateRootNodeAggregateWithNode is executed with payload:
+ | Key | Value |
+ | workspaceName | "live" |
+ | nodeAggregateId | "lady-eleonode-rootford" |
+ | nodeTypeName | "Neos.ContentRepository:Root" |
+ When the command CreateNodeAggregateWithNode is executed with payload:
+ | Key | Value |
+ | workspaceName | "live" |
+ | nodeAggregateId | "nody-mc-nodeface" |
+ | nodeTypeName | "Neos.ContentRepository.Testing:Content" |
+ | originDimensionSpacePoint | {} |
+ | parentNodeAggregateId | "lady-eleonode-rootford" |
+ | initialPropertyValues | {"text": "Original text"} |
+
+ And the command CreateWorkspace is executed with payload:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ | baseWorkspaceName | "live" |
+ | newContentStreamId | "user-cs-identifier" |
+
+ Scenario: Deactivating a workspace with no pending changes
+ When the command DeactivateWorkspace is executed with payload:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ Then workspace user-test has status DEACTIVATED
+
+ Scenario: Deactivating an already deactivated Workspace fails
+ And the command DeactivateWorkspace is executed with payload and exceptions are caught:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ When the command DeactivateWorkspace is executed with payload and exceptions are caught:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ Then the last command should have thrown an exception of type "WorkspaceIsDeactivated" with code 1765977861 and message:
+ """
+ The workspace "user-test" is deactivated
+ """
+
+ Scenario: Activating a deactivated workspace
+ When the command DeactivateWorkspace is executed with payload:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ And the command ActivateWorkspace is executed with payload:
+ | Key | Value |
+ | workspaceName | "user-test" |
+ | newContentStreamId | "user-new-cs-identifier" |
+ Then workspace user-test has status UP_TO_DATE
+
+ Given I am in workspace "user-test" and dimension space point {}
+ Then I expect node aggregate identifier "nody-mc-nodeface" to lead to node user-new-cs-identifier;nody-mc-nodeface;{}
+
diff --git a/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php b/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php
index 028700f972f..6e079993a13 100644
--- a/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php
+++ b/Neos.ContentRepository.Core/Classes/EventStore/EventNormalizer.php
@@ -27,6 +27,8 @@
use Neos\ContentRepository\Core\Feature\RootNodeCreation\Event\RootNodeAggregateWithNodeWasCreated;
use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasTagged;
use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasUntagged;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasActivated;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasDeactivated;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\WorkspaceWasCreated;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Event\WorkspaceBaseWorkspaceWasChanged;
@@ -107,6 +109,8 @@ public static function create(): self
WorkspaceWasRemoved::class,
WorkspaceOwnerWasChanged::class,
WorkspaceBaseWorkspaceWasChanged::class,
+ WorkspaceWasActivated::class,
+ WorkspaceWasDeactivated::class,
];
$fullClassNameToShortEventType = [];
diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php b/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php
index 571b24f7432..bf04f05c125 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Common/WorkspaceConstraintChecks.php
@@ -14,14 +14,19 @@
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceContainsPublishableChanges;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName;
+use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasWorkspacesDependingOnIt;
+use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsDeactivated;
+use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
use Neos\ContentRepository\Core\SharedModel\Workspace\Workspaces;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceStatus;
trait WorkspaceConstraintChecks
{
/**
* @throws WorkspaceDoesNotExist
+ * @phpstan-return Workspace
*/
private function requireWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): Workspace
{
@@ -33,9 +38,31 @@ private function requireWorkspace(WorkspaceName $workspaceName, CommandHandlingD
return $workspace;
}
+ /**
+ * @throws WorkspaceDoesNotExist|WorkspaceIsDeactivated
+ * @phpstan-return object{ currentContentStreamId: ContentStreamId, status: WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED } & Workspace
+ */
+ private function requireActiveWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): Workspace
+ {
+ $workspace = $this->requireWorkspace($workspaceName, $commandHandlingDependencies);
+ if (!$workspace->isActive()) {
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspaceName);
+ }
+
+ // DANGEROUS error of type return.type ignored
+ // This is done due to phpstan being unable to deduce that the returned workspace actually fulfills all
+ // required conditions. This is guaranteed by `$workspace->isActive()` returning true:
+ // isActive() is annotated with
+ // @ phpstan-assert-if-true ContentStreamId $this->currentContentStreamId
+ // @ phpstan-assert-if-true WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED $this->status
+ // which are the same guarantees this method makes, but in a syntactical different way.
+ return $workspace; // @phpstan-ignore return.type
+ }
+
/**
* @throws WorkspaceHasNoBaseWorkspaceName
* @throws BaseWorkspaceDoesNotExist
+ * @phpstan-return object{ currentContentStreamId: ContentStreamId, status: WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED } & Workspace
*/
private function requireBaseWorkspace(Workspace $workspace, CommandHandlingDependencies $commandHandlingDependencies): Workspace
{
@@ -45,13 +72,19 @@ private function requireBaseWorkspace(Workspace $workspace, CommandHandlingDepen
$baseWorkspace = $commandHandlingDependencies->findWorkspaceByName($workspace->baseWorkspaceName);
if (is_null($baseWorkspace)) {
throw BaseWorkspaceDoesNotExist::butWasSupposedTo($workspace->workspaceName);
+ } elseif (!$baseWorkspace->isActive()) {
+ // should never happen!
+ // TODO: if this happens, something is seriously wrong in the database, handle differently?
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspace->baseWorkspaceName);
}
- return $baseWorkspace;
+
+ // @phpstan-ignore-next-line
+ return $baseWorkspace; // @phpstan-ignore return.type
}
private function requireWorkspaceToBeRootOrRootBasedForDimensionAdjustment(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): void
{
- $workspace = $this->requireWorkspace($workspaceName, $commandHandlingDependencies);
+ $workspace = $this->requireActiveWorkspace($workspaceName, $commandHandlingDependencies);
if (!$workspace->isRootWorkspace()) {
$baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies);
if (!$baseWorkspace->isRootWorkspace()) {
@@ -60,6 +93,23 @@ private function requireWorkspaceToBeRootOrRootBasedForDimensionAdjustment(Works
}
}
+ private function requireWorkspaceToNotBeBaseForAnyOtherWorkspace(WorkspaceName $workspaceName, CommandHandlingDependencies $commandHandlingDependencies): void
+ {
+ $workspaces = $commandHandlingDependencies->findAllWorkspaces();
+ $conflictingWorkspaceNames = [];
+ foreach ($workspaces as $workspace) {
+ if ($workspace->baseWorkspaceName === $workspaceName) {
+ $conflictingWorkspaceNames[] = $workspace->workspaceName;
+ }
+ }
+ if ($conflictingWorkspaceNames !== []) {
+ throw WorkspaceHasWorkspacesDependingOnIt::butWasNotSupposedTo(
+ $workspaceName,
+ ...$conflictingWorkspaceNames
+ );
+ }
+ }
+
private static function requireNoWorkspaceToHaveChanges(Workspaces $workspaces): void
{
$conflictingWorkspaceNames = [];
diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Command/ActivateWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Command/ActivateWorkspace.php
new file mode 100644
index 00000000000..db6f973cbe6
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Command/ActivateWorkspace.php
@@ -0,0 +1,50 @@
+workspaceName;
+ }
+
+ public static function fromArray(array $values): self
+ {
+ return new self(
+ WorkspaceName::fromString($values['workspaceName']),
+ ContentStreamId::fromString($values['newContentStreamId']),
+ );
+ }
+
+ public function jsonSerialize(): array
+ {
+ return [
+ 'workspaceName' => $this->workspaceName,
+ 'newContentStreamId' => $this->newContentStreamId
+ ];
+ }
+}
diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Event/WorkspaceWasDeactivated.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Event/WorkspaceWasDeactivated.php
new file mode 100644
index 00000000000..dff0bb55f45
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceActivation/Event/WorkspaceWasDeactivated.php
@@ -0,0 +1,44 @@
+workspaceName;
+ }
+
+ public static function fromArray(array $values): self
+ {
+ return new self(WorkspaceName::fromString($values['workspaceName']));
+ }
+
+ public function jsonSerialize(): array
+ {
+ return ['workspaceName' => $this->workspaceName];
+ }
+}
diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php
index 56e6048adc9..a139c0b5f4b 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceCommandHandler.php
@@ -30,6 +30,10 @@
use Neos\ContentRepository\Core\Feature\ContentStreamClosing\Event\ContentStreamWasReopened;
use Neos\ContentRepository\Core\Feature\ContentStreamCreation\Event\ContentStreamWasCreated;
use Neos\ContentRepository\Core\Feature\ContentStreamRemoval\Event\ContentStreamWasRemoved;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasActivated;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Event\WorkspaceWasDeactivated;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Event\RootWorkspaceWasCreated;
@@ -60,6 +64,8 @@
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName;
use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceContainsPublishableChanges;
+use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsActivated;
+use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceIsDeactivated;
use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace;
use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
@@ -99,12 +105,14 @@ public function handle(CommandInterface|RebasableToOtherWorkspaceInterface $comm
/** @phpstan-ignore-next-line */
return match ($command::class) {
CreateWorkspace::class => $this->handleCreateWorkspace($command, $commandHandlingDependencies),
+ ActivateWorkspace::class => $this->handleActivateWorkspace($command, $commandHandlingDependencies),
CreateRootWorkspace::class => $this->handleCreateRootWorkspace($command, $commandHandlingDependencies),
PublishWorkspace::class => $this->handlePublishWorkspace($command, $commandHandlingDependencies),
RebaseWorkspace::class => $this->handleRebaseWorkspace($command, $commandHandlingDependencies),
PublishIndividualNodesFromWorkspace::class => $this->handlePublishIndividualNodesFromWorkspace($command, $commandHandlingDependencies),
DiscardIndividualNodesFromWorkspace::class => $this->handleDiscardIndividualNodesFromWorkspace($command, $commandHandlingDependencies),
DiscardWorkspace::class => $this->handleDiscardWorkspace($command, $commandHandlingDependencies),
+ DeactivateWorkspace::class => $this->handleDeactivateWorkspace($command, $commandHandlingDependencies),
DeleteWorkspace::class => $this->handleDeleteWorkspace($command, $commandHandlingDependencies),
ChangeBaseWorkspace::class => $this->handleChangeBaseWorkspace($command, $commandHandlingDependencies),
};
@@ -129,6 +137,9 @@ private function handleCreateWorkspace(
$command->workspaceName->value
), 1513890708);
}
+ if (!$baseWorkspace->isActive()) {
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($command->baseWorkspaceName);
+ }
$sourceContentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId);
$this->requireContentStreamToNotBeClosed($baseWorkspace->currentContentStreamId, $commandHandlingDependencies);
$this->requireContentStreamToNotExistYet($command->newContentStreamId, $commandHandlingDependencies);
@@ -192,7 +203,7 @@ private function handlePublishWorkspace(
PublishWorkspace $command,
CommandHandlingDependencies $commandHandlingDependencies,
): \Generator {
- $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies);
+ $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies);
$baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies);
if (!$workspace->hasPublishableChanges()) {
throw WorkspaceCommandSkipped::becauseWorkspaceToPublishIsEmpty($command->workspaceName);
@@ -284,6 +295,13 @@ private function rebaseWorkspaceWithoutChanges(
Version $baseWorkspaceContentStreamVersion,
ContentStreamId $newContentStreamId
): \Generator {
+ if (!$baseWorkspace->isActive()) {
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($baseWorkspace->workspaceName);
+ }
+ if (!$workspace->isActive()) {
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspace->workspaceName);
+ }
+
yield $this->forkContentStream(
$newContentStreamId,
$baseWorkspace->currentContentStreamId,
@@ -340,7 +358,7 @@ private function handleRebaseWorkspace(
RebaseWorkspace $command,
CommandHandlingDependencies $commandHandlingDependencies,
): \Generator {
- $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies);
+ $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies);
$baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies);
$workspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($workspace, $commandHandlingDependencies);
@@ -443,7 +461,7 @@ private function handlePublishIndividualNodesFromWorkspace(
PublishIndividualNodesFromWorkspace $command,
CommandHandlingDependencies $commandHandlingDependencies,
): \Generator {
- $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies);
+ $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies);
$baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies);
if (!$workspace->hasPublishableChanges()) {
@@ -567,7 +585,7 @@ private function handleDiscardIndividualNodesFromWorkspace(
DiscardIndividualNodesFromWorkspace $command,
CommandHandlingDependencies $commandHandlingDependencies,
): \Generator {
- $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies);
+ $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies);
$baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies);
if (!$workspace->hasPublishableChanges()) {
@@ -669,7 +687,7 @@ private function handleDiscardWorkspace(
DiscardWorkspace $command,
CommandHandlingDependencies $commandHandlingDependencies,
): \Generator {
- $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies);
+ $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies);
$baseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies);
if (!$workspace->hasPublishableChanges()) {
@@ -696,6 +714,13 @@ private function discardWorkspace(
Version $baseWorkspaceContentStreamVersion,
ContentStreamId $newContentStream
): \Generator {
+ if (!$baseWorkspace->isActive()) {
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($baseWorkspace->workspaceName);
+ }
+ if (!$workspace->isActive()) {
+ throw WorkspaceIsDeactivated::butWasSupposedToBeActivated($workspace->workspaceName);
+ }
+
yield $this->forkContentStream(
$newContentStream,
$baseWorkspace->currentContentStreamId,
@@ -731,7 +756,7 @@ private function handleChangeBaseWorkspace(
ChangeBaseWorkspace $command,
CommandHandlingDependencies $commandHandlingDependencies,
): \Generator {
- $workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies);
+ $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies);
$currentBaseWorkspace = $this->requireBaseWorkspace($workspace, $commandHandlingDependencies);
$this->requireContentStreamToNotBeClosed($workspace->currentContentStreamId, $commandHandlingDependencies);
@@ -744,7 +769,7 @@ private function handleChangeBaseWorkspace(
throw WorkspaceContainsPublishableChanges::butWasNotSupposedToForBaseWorkspaceChange($workspace->workspaceName);
}
- $newBaseWorkspace = $this->requireWorkspace($command->baseWorkspaceName, $commandHandlingDependencies);
+ $newBaseWorkspace = $this->requireActiveWorkspace($command->baseWorkspaceName, $commandHandlingDependencies);
$this->requireNonCircularRelationBetweenWorkspaces($workspace, $newBaseWorkspace, $commandHandlingDependencies);
$newBaseWorkspaceContentStreamVersion = $this->requireOpenContentStreamAndVersion($newBaseWorkspace, $commandHandlingDependencies);
@@ -779,29 +804,109 @@ private function handleDeleteWorkspace(
CommandHandlingDependencies $commandHandlingDependencies,
): \Generator {
$workspace = $this->requireWorkspace($command->workspaceName, $commandHandlingDependencies);
- $contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId);
+
+ if ($workspace->isActive()) {
+ $contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId);
+
+ yield new EventsToPublish(
+ ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(),
+ Events::with(
+ new ContentStreamWasRemoved(
+ $workspace->currentContentStreamId,
+ ),
+ ),
+ ExpectedVersion::fromVersion($contentStreamVersion)
+ );
+ }
yield new EventsToPublish(
- ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(),
+ WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(),
Events::with(
- new ContentStreamWasRemoved(
- $workspace->currentContentStreamId,
- ),
+ new WorkspaceWasRemoved(
+ $command->workspaceName,
+ )
),
- ExpectedVersion::fromVersion($contentStreamVersion)
+ ExpectedVersion::ANY()
+ );
+ }
+
+ /**
+ * @throws WorkspaceDoesNotExist
+ * @throws ContentStreamAlreadyExists
+ */
+ private function handleActivateWorkspace(
+ ActivateWorkspace $command,
+ CommandHandlingDependencies $commandHandlingDependencies,
+ ): \Generator {
+ $workspace = $commandHandlingDependencies->findWorkspaceByName($command->workspaceName);
+ if (is_null($workspace)) {
+ throw WorkspaceDoesNotExist::butWasSupposedTo($command->workspaceName);
+ }
+ if ($workspace->isActive()) {
+ throw WorkspaceIsActivated::butWasSupposedToBeDeactivated($command->workspaceName);
+ }
+ $baseWorkspace = $this->requireActiveWorkspace($workspace->baseWorkspaceName, $commandHandlingDependencies);
+ $contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($baseWorkspace->currentContentStreamId);
+
+ $this->requireContentStreamToNotExistYet($command->newContentStreamId, $commandHandlingDependencies);
+
+ yield $this->forkContentStream(
+ $command->newContentStreamId,
+ $baseWorkspace->currentContentStreamId,
+ $contentStreamVersion,
+ sprintf('Activate workspace %s based on %s', $workspace->workspaceName->value, $baseWorkspace->workspaceName->value)
);
yield new EventsToPublish(
WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(),
Events::with(
- new WorkspaceWasRemoved(
+ new WorkspaceWasActivated(
$command->workspaceName,
+ $command->newContentStreamId,
)
),
ExpectedVersion::ANY()
);
}
+ /**
+ * @throws WorkspaceDoesNotExist
+ * @throws WorkspaceContainsPublishableChanges
+ */
+ private function handleDeactivateWorkspace(
+ DeactivateWorkspace $command,
+ CommandHandlingDependencies $commandHandlingDependencies,
+ ): \Generator {
+ $workspace = $this->requireActiveWorkspace($command->workspaceName, $commandHandlingDependencies);
+ //$contentStreamVersion = $commandHandlingDependencies->getContentStreamVersion($workspace->currentContentStreamId);
+
+ if ($workspace->hasPublishableChanges()) {
+ throw WorkspaceContainsPublishableChanges::butWasNotSupposedToForDeactivating($workspace->workspaceName);
+ }
+ $this->requireWorkspaceToNotBeBaseForAnyOtherWorkspace($command->workspaceName, $commandHandlingDependencies);
+
+ // TODO: Discuss order of deletion/deactivation
+ yield new EventsToPublish(
+ WorkspaceEventStreamName::fromWorkspaceName($command->workspaceName)->getEventStreamName(),
+ Events::with(
+ new WorkspaceWasDeactivated($command->workspaceName)
+ ),
+ ExpectedVersion::ANY()
+ );
+
+ yield new EventsToPublish(
+ ContentStreamEventStreamName::fromContentStreamId($workspace->currentContentStreamId)->getEventStreamName(),
+ Events::with(
+ new ContentStreamWasRemoved(
+ $workspace->currentContentStreamId,
+ ),
+ ),
+ // content stream is unused by now and deletion is idempotent. There is no possible gain from a version
+ // check at this point.
+ ExpectedVersion::ANY()
+ );
+ }
+
private function forkNewContentStreamAndApplyEvents(
ContentStreamId $newContentStreamId,
ContentStreamId $sourceContentStreamId,
@@ -848,6 +953,11 @@ private function requireWorkspaceToNotExist(WorkspaceName $workspaceName, Comman
), 1715341085);
}
+ /**
+ * @param Workspace&object{ currentContentStreamId: ContentStreamId } $workspace
+ * @param CommandHandlingDependencies $commandHandlingDependencies
+ * @return Version
+ */
private function requireOpenContentStreamAndVersion(Workspace $workspace, CommandHandlingDependencies $commandHandlingDependencies): Version
{
if ($commandHandlingDependencies->isContentStreamClosed($workspace->currentContentStreamId)) {
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php
index 0d7df9ec549..b41e7776c9d 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceContainsPublishableChanges.php
@@ -13,6 +13,11 @@
*/
final class WorkspaceContainsPublishableChanges extends \RuntimeException
{
+ public static function butWasNotSupposedToForDeactivating(WorkspaceName $workspaceName): self
+ {
+ throw new self(sprintf('The workspace %s needs to be empty before being deactivated.', $workspaceName->value), 1765979146);
+ }
+
public static function butWasNotSupposedToForBaseWorkspaceChange(WorkspaceName $workspaceName): self
{
throw new self(sprintf('The workspace %s needs to be empty before switching the base workspace.', $workspaceName->value), 1681455989);
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceHasWorkspacesDependingOnIt.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceHasWorkspacesDependingOnIt.php
new file mode 100644
index 00000000000..6dcb677f011
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceHasWorkspacesDependingOnIt.php
@@ -0,0 +1,40 @@
+ $workspaceName->value,
+ $dependingWorkspaceNames
+ )
+ ),
+ $workspaceName,
+ ), 1766070671);
+ }
+}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsActivated.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsActivated.php
new file mode 100644
index 00000000000..d6d3a22bace
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsActivated.php
@@ -0,0 +1,31 @@
+value
+ ), 1766069245);
+ }
+}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsDeactivated.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsDeactivated.php
new file mode 100644
index 00000000000..e724ef87dc5
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceIsDeactivated.php
@@ -0,0 +1,31 @@
+value
+ ), 1765977861);
+ }
+}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php
index 5a9a9985e2e..e128e3d3438 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/Workspace.php
@@ -24,19 +24,26 @@
/**
* @param WorkspaceName $workspaceName Workspace identifier, unique within one Content Repository instance
* @param WorkspaceName|null $baseWorkspaceName Workspace identifier of the base workspace (i.e. the target when publishing changes) – if null this instance is considered a root (aka public) workspace
- * @param ContentStreamId $currentContentStreamId The Content Stream this workspace currently points to – usually it is set to a new, empty content stream after publishing/rebasing the workspace
+ * @param ContentStreamId|null $currentContentStreamId The Content Stream this workspace currently points to if it is active – usually it is set to a new, empty content stream after publishing/rebasing the workspace
* @param WorkspaceStatus $status The current status of this workspace
*/
private function __construct(
public WorkspaceName $workspaceName,
public ?WorkspaceName $baseWorkspaceName,
- public ContentStreamId $currentContentStreamId,
+ // TODO: ist allowing null here a problem? (strict phpstan?)
+ public ?ContentStreamId $currentContentStreamId,
public WorkspaceStatus $status,
private bool $hasPublishableChanges
) {
if ($this->isRootWorkspace() && $this->hasPublishableChanges) {
throw new \InvalidArgumentException('Root workspaces cannot have changes', 1730371566);
}
+ if ($this->currentContentStreamId === null && $this->isActive()) {
+ throw new \InvalidArgumentException('Active workspaces must have a non null content stream ID', 1730371566);
+ }
+ if ($this->isRootWorkspace() && !$this->isActive()) {
+ throw new \InvalidArgumentException('Root Workspaces cannot be deactivated', 1768571925);
+ }
}
/**
@@ -45,7 +52,7 @@ private function __construct(
public static function create(
WorkspaceName $workspaceName,
?WorkspaceName $baseWorkspaceName,
- ContentStreamId $currentContentStreamId,
+ ?ContentStreamId $currentContentStreamId,
WorkspaceStatus $status,
bool $hasPublishableChanges
): self {
@@ -60,6 +67,18 @@ public function hasPublishableChanges(): bool
return $this->hasPublishableChanges;
}
+ /**
+ * Indicates if the workspace is active.
+ *
+ * @phpstan-assert-if-true ContentStreamId $this->currentContentStreamId
+ * @phpstan-assert-if-true WorkspaceStatus::UP_TO_DATE|WorkspaceStatus::OUTDATED $this->status
+ * @phpstan-assert-if-false WorkspaceName $this->baseWorkspaceName
+ */
+ public function isActive(): bool
+ {
+ return $this->status !== WorkspaceStatus::DEACTIVATED;
+ }
+
/**
* @phpstan-assert-if-false WorkspaceName $this->baseWorkspaceName
*/
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php
index 5ef187bb1db..cce28b7c775 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceStatus.php
@@ -47,6 +47,13 @@ enum WorkspaceStatus: string implements \JsonSerializable
*/
case OUTDATED = 'OUTDATED';
+ /**
+ * A non-base workspace can be deactivated on purpose. A deactivated workspace has no current content stream.
+ * Before a deactivated workspace can be used it has to be activated again, which will result in its content stream
+ * to become a new fork of its base workspace content stream.
+ */
+ case DEACTIVATED = 'DEACTIVATED';
+
public function equals(self $other): bool
{
return $this->value === $other->value;
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php
index fad57676f18..939c7b6de31 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php
@@ -44,6 +44,8 @@
use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions;
use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree;
use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
@@ -312,6 +314,24 @@ public function theCommandRebaseWorkspaceIsExecutedWithPayload(TableNode $payloa
$this->handleCommand(RebaseWorkspace::class, $commandArguments);
}
+ /**
+ * @When the command DeactivateWorkspace is executed with payload:
+ */
+ public function theCommandDeactivateWorkspaceIsExecutedWithPayload(TableNode $payloadTable): void
+ {
+ $commandArguments = $this->readPayloadTable($payloadTable);
+ $this->handleCommand(DeactivateWorkspace::class, $commandArguments);
+ }
+
+ /**
+ * @When the command ActivateWorkspace is executed with payload:
+ */
+ public function theCommandActivateWorkspaceIsExecutedWithPayload(TableNode $payloadTable): void
+ {
+ $commandArguments = $this->readPayloadTable($payloadTable);
+ $this->handleCommand(ActivateWorkspace::class, $commandArguments);
+ }
+
/**
* @When the command :shortCommandName is executed with payload and exceptions are caught:
*/
@@ -477,6 +497,8 @@ protected static function resolveShortCommandName(string $shortCommandName): str
CreateRootWorkspace::class,
CreateWorkspace::class,
DeleteWorkspace::class,
+ ActivateWorkspace::class,
+ DeactivateWorkspace::class,
DisableNodeAggregate::class,
DiscardIndividualNodesFromWorkspace::class,
DiscardWorkspace::class,
diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
index c9b6a0eeec5..94efdac7b24 100644
--- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php
+++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
@@ -14,6 +14,11 @@
namespace Neos\Neos\Command;
+use DateInterval;
+use DateInvalidOperationException;
+use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists;
use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy;
use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed;
@@ -26,6 +31,10 @@
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Cli\CommandController;
use Neos\Flow\Cli\Exception\StopCommandException;
+use Neos\Flow\Security\Account;
+use Neos\Flow\Utility\Now;
+use Neos\Neos\Domain\Model\User;
+use Neos\Neos\Domain\Model\UserId;
use Neos\Neos\Domain\Model\WorkspaceClassification;
use Neos\Neos\Domain\Model\WorkspaceDescription;
use Neos\Neos\Domain\Model\WorkspaceRole;
@@ -56,6 +65,9 @@ class WorkspaceCommandController extends CommandController
#[Flow\Inject]
protected WorkspaceService $workspaceService;
+ #[Flow\Inject]
+ protected Now $now;
+
/**
* Publish changes of a workspace
*
@@ -467,7 +479,7 @@ public function listCommand(string $contentRepository = 'default'): void
$workspaceMetadata->title->value,
$workspaceMetadata->description->value,
$workspace->status->value,
- $workspace->currentContentStreamId->value,
+ $workspace->currentContentStreamId?->value,
];
}
$this->output->outputTable($tableRows, $headerRow);
@@ -504,7 +516,7 @@ public function showCommand(string $workspace, string $contentRepository = 'defa
$this->outputFormatted('Title: %s', [$workspaceMetadata->title->value]);
$this->outputFormatted('Description: %s', [$workspaceMetadata->description->value]);
$this->outputFormatted('Status: %s', [$workspacesInstance->status->value]);
- $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId->value]);
+ $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId?->value]);
$workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName);
$this->outputLine();
@@ -524,6 +536,129 @@ public function showCommand(string $workspace, string $contentRepository = 'defa
]);
}
+ /**
+ * Deactivate a single workspace by name.
+ *
+ * A workspace can only be deactivated if it has no pending changes and no other workspace depends on it.
+ *
+ * @param string $workspace Name of the workspace to deactivate.
+ * @param string $contentRepository The name of the content repository. (Default: 'default')
+ * @throws StopCommandException
+ * @throws AccessDenied
+ */
+ public function deactivateCommand(string $workspace, string $contentRepository = 'default'): void
+ {
+ $contentRepositoryId = ContentRepositoryId::fromString($contentRepository);
+ $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId);
+
+ $workspaceName = WorkspaceName::fromString($workspace);
+ $workspaceInstance = $contentRepositoryInstance->findWorkspaceByName($workspaceName);
+ if ($workspaceInstance === null) {
+ $this->outputLine('Workspace "%s" not found.', [$workspaceName->value]);
+ $this->quit();
+ }
+ if ($workspaceInstance->hasPublishableChanges()) {
+ $this->outputLine('Workspace "%s" cannot be deactivated, it has publishable changes.', [$workspaceName->value]);
+ $this->quit();
+ }
+ $dependentWorkspaces = $contentRepositoryInstance->findWorkspaces()
+ ->filter(fn(Workspace $workspace) => $workspace->baseWorkspaceName === $workspaceName)
+ ->map(fn (Workspace $workspace) => $workspace->workspaceName->value);
+ if (count($dependentWorkspaces) > 0) {
+ $this->outputLine('Workspace "%s" cannot be deactivated, the following workspaces depend on it: "%s".', [
+ $workspaceName->value,
+ implode('", "', $dependentWorkspaces)
+ ]);
+ $this->quit();
+ }
+
+ $contentRepositoryInstance->handle(DeactivateWorkspace::create($workspaceName));
+ }
+
+ /**
+ * Deactivate all stale personal workspaces.
+ *
+ * A personal workspace is considered stale if it has no pending changes, no other workspace uses it as a base
+ * workspace and the owner of the workspace did not log in for the time specified.
+ *
+ * @param string $contentRepository The name of the content repository. (Default: 'default')
+ * @param string $dateInterval The time interval a user had to be inactive for its workspaces to be considered stale. (Default: '7 days')
+ * @throws AccessDenied
+ */
+ public function deactivateStaleCommand(string $contentRepository = 'default', string $dateInterval = '7 days'): void
+ {
+ $contentRepositoryId = ContentRepositoryId::fromString($contentRepository);
+ $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId);
+
+ $interval = DateInterval::createFromDateString($dateInterval);
+ if ($interval === false) {
+ $this->outputLine('Invalid date interval "%s".', [$dateInterval]);
+ return;
+ }
+
+ try {
+ $cutoffTime = $this->now->sub($interval);
+ // DateInvalidOperationException is introduced in php 8.3, therefore phpstan can't find it and complains
+ // about the next line in php 8.2
+ } catch (\DateInvalidOperationException) { // @phpstan-ignore class.notFound,catch.neverThrown
+ $this->outputLine('Invalid date interval "%s".', [$interval]);
+ return;
+ }
+
+ $workspaces = $contentRepositoryInstance->findWorkspaces();
+ $baseWorkspaces = $this->splObjectStoreFromIterable($workspaces->map(fn($workspace) => $workspace->baseWorkspaceName));
+
+ $probablyStaleWorkspaceNames = $this->splObjectStoreFromIterable(
+ $workspaces
+ ->filter(fn($workspace) => !$workspace->hasPublishableChanges() &&
+ !$baseWorkspaces->contains($workspace->workspaceName))
+ ->map(fn($workspace) => $workspace->workspaceName)
+ );
+ $inactiveUserIds = $this->splObjectStoreFromIterable($this->userService->findUserIdsNotLoggedInBefore($cutoffTime));
+
+ $personalWorkspaces = $this->workspaceService->getPersonalWorkspaces($contentRepositoryId);
+ $workspacesToDeactivate = [];
+ foreach ($personalWorkspaces as $userId => $personalWorkspace) {
+ /** @var UserId $userId */
+ if (
+ $probablyStaleWorkspaceNames->contains($personalWorkspace) &&
+ $inactiveUserIds->contains($userId)
+ ) {
+ $workspacesToDeactivate[] = $personalWorkspace;
+ }
+ }
+
+ foreach ($workspacesToDeactivate as $workspace) {
+ $contentRepositoryInstance->handle(DeactivateWorkspace::create($workspace));
+ }
+ }
+ /**
+ * Activate a single - previously deactivated - workspace by name.
+ *
+ * @param string $workspace Name of the workspace to deactivate.
+ * @param string $contentRepository The name of the content repository. (Default: 'default')
+ * @throws StopCommandException
+ * @throws AccessDenied
+ */
+ public function activateCommand(string $workspace, string $contentRepository = 'default'): void
+ {
+ $contentRepositoryId = ContentRepositoryId::fromString($contentRepository);
+ $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId);
+
+ $workspaceName = WorkspaceName::fromString($workspace);
+ $workspaceInstance = $contentRepositoryInstance->findWorkspaceByName($workspaceName);
+ if ($workspaceInstance === null) {
+ $this->outputLine('Workspace "%s" not found.', [$workspaceName->value]);
+ $this->quit();
+ }
+ if ($workspaceInstance->isActive()) {
+ $this->outputLine('Workspace "%s" is already active.', [$workspaceName->value]);
+ $this->quit();
+ }
+
+ $contentRepositoryInstance->handle(ActivateWorkspace::create($workspaceName));
+ }
+
// -----------------------
private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType, string $usernameOrRoleIdentifier): WorkspaceRoleSubject
@@ -540,4 +675,21 @@ private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType
}
return $roleSubject;
}
+
+ /**
+ * @template TObject of object
+ * @param iterable $iterable
+ * @return \SplObjectStorage
+ */
+ private function splObjectStoreFromIterable(iterable $iterable): \SplObjectStorage
+ {
+ /** @var \SplObjectStorage $result */
+ $result = new \SplObjectStorage();
+ foreach ($iterable as $value) {
+ if ($value !== null) {
+ $result->attach($value);
+ }
+ }
+ return $result;
+ }
}
diff --git a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php
index df4ead8c822..a0b2d717a36 100644
--- a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php
+++ b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php
@@ -385,6 +385,30 @@ classification = :personalWorkspaceClassification
}
}
+ /**
+ * @return \Traversable
+ */
+ public function findAllPersonalWorkspaceNamesByContentRepositoryId(ContentRepositoryId $contentRepositoryId): \Traversable
+ {
+ $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA;
+ $query = <<dbal->fetchAllAssociative($query, [
+ 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value,
+ 'contentRepositoryId' => $contentRepositoryId->value,
+ ]);
+ foreach ($rows as $row) {
+ yield UserId::fromString($row['owner_user_id']) => WorkspaceName::fromString($row['workspace_name']);
+ }
+ }
+
/**
* @param \Closure(): void $fn
* @return void
diff --git a/Neos.Neos/Classes/Domain/Service/SiteExportService.php b/Neos.Neos/Classes/Domain/Service/SiteExportService.php
index ba305374607..6332b6665a1 100644
--- a/Neos.Neos/Classes/Domain/Service/SiteExportService.php
+++ b/Neos.Neos/Classes/Domain/Service/SiteExportService.php
@@ -59,6 +59,10 @@ public function exportToPath(ContentRepositoryId $contentRepositoryId, string $p
if ($liveWorkspace === null) {
throw new \RuntimeException('Failed to find live workspace', 1716652280);
}
+ if (!$liveWorkspace->isActive()) {
+ //TODO: should be impossible
+ throw new \RuntimeException('Live workspace was deactivated', 1768577266);
+ }
$processors = Processors::fromArray([
'Exporting events' => $this->contentRepositoryRegistry->buildService(
diff --git a/Neos.Neos/Classes/Domain/Service/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php
index 9c6f9e52348..63e1c1d6467 100644
--- a/Neos.Neos/Classes/Domain/Service/UserService.php
+++ b/Neos.Neos/Classes/Domain/Service/UserService.php
@@ -14,6 +14,7 @@
namespace Neos\Neos\Domain\Service;
+use DateTimeInterface;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Persistence\Exception\IllegalObjectTypeException;
use Neos\Flow\Persistence\PersistenceManagerInterface;
@@ -775,6 +776,30 @@ public function getAllRoles(User $user): array
return $roles;
}
+ /**
+ * @param DateTimeInterface $dateTime
+ * @return \Traversable
+ */
+ public function findUserIdsNotLoggedInBefore(DateTimeInterface $dateTime): \Traversable
+ {
+ /** @var User $user */
+ foreach ($this->getUsers() as $user) {
+ $accounts = $user->getAccounts();
+ $loggedIn = false;
+ foreach ($accounts as $account) {
+ $lastSuccessfulAuthenticationDate = $account->getLastSuccessfulAuthenticationDate();
+ if ($lastSuccessfulAuthenticationDate != null && $lastSuccessfulAuthenticationDate > $dateTime) {
+ $loggedIn = true;
+ break;
+ }
+ }
+
+ if (!$loggedIn) {
+ yield $user->getId();
+ }
+ }
+ }
+
/**
* @param User $user
* @param bool $keepCurrentSession
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php
index eb5280b56ed..0e08d13c617 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php
@@ -371,13 +371,17 @@ private function resolveNodeIdsToPublishOrDiscard(
private function pendingWorkspaceChangesInternal(ContentRepository $contentRepository, WorkspaceName $workspaceName): Changes
{
$crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName);
- return $contentRepository->projectionState(ChangeFinder::class)->findByContentStreamId($crWorkspace->currentContentStreamId);
+ return $crWorkspace->isActive()
+ ? $contentRepository->projectionState(ChangeFinder::class)->findByContentStreamId($crWorkspace->currentContentStreamId)
+ : Changes::fromArray([]);
}
private function countPendingWorkspaceChangesInternal(ContentRepository $contentRepository, WorkspaceName $workspaceName): int
{
$crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName);
- return $contentRepository->projectionState(ChangeFinder::class)->countByContentStreamId($crWorkspace->currentContentStreamId);
+ return $crWorkspace->isActive()
+ ? $contentRepository->projectionState(ChangeFinder::class)->countByContentStreamId($crWorkspace->currentContentStreamId)
+ : 0;
}
private function isChangePublishableWithinAncestorScope(
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index e9f7f92b2f4..28361b5ec47 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -15,6 +15,8 @@
namespace Neos\Neos\Domain\Service;
use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\ActivateWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceActivation\Command\DeactivateWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists;
@@ -217,6 +219,44 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con
);
}
+ public function isWorkspaceActive(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): bool
+ {
+ return $this->requireWorkspace($contentRepositoryId, $workspaceName)->isActive();
+ }
+
+ public function activatePersonalWorkspaceForUserIfDeactivated(ContentRepositoryId $contentRepositoryId, User $user): void
+ {
+ $existingWorkspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $user->getId());
+ if ($existingWorkspaceName === null) {
+ throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $user->getId()->value), 1766049866);
+ }
+
+ $workspaceIsActive = $this->isWorkspaceActive($contentRepositoryId, $existingWorkspaceName);
+ if ($workspaceIsActive) {
+ return;
+ }
+
+ $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
+ $contentRepository->handle(ActivateWorkspace::create($existingWorkspaceName));
+ }
+
+ // TODO: required? If not remove
+ public function deactivatePersonalWorkspaceForUserIfActive(ContentRepositoryId $contentRepositoryId, User $user): void
+ {
+ $existingWorkspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $user->getId());
+ if ($existingWorkspaceName === null) {
+ throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $user->getId()->value), 1766049866);
+ }
+
+ $workspaceIsActive = $this->isWorkspaceActive($contentRepositoryId, $existingWorkspaceName);
+ if (!$workspaceIsActive) {
+ return;
+ }
+
+ $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
+ $contentRepository->handle(DeactivateWorkspace::create($existingWorkspaceName));
+ }
+
/**
* Assign a workspace role to the given user/user group
*
@@ -301,6 +341,14 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId,
throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479);
}
+ /**
+ * @return \Traversable
+ */
+ public function getPersonalWorkspaces(ContentRepositoryId $contentRepository): \Traversable
+ {
+ return $this->metadataAndRoleRepository->findAllPersonalWorkspaceNamesByContentRepositoryId($contentRepository);
+ }
+
// ------------------
/**
diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
index 70fe40b4adf..95860320548 100644
--- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
+++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
@@ -466,10 +466,9 @@ public function deleteAction(WorkspaceName $workspaceName): void
$nodesCount = 0;
try {
- $nodesCount = $contentRepository->projectionState(ChangeFinder::class)
- ->countByContentStreamId(
- $workspace->currentContentStreamId
- );
+ $nodesCount = $workspace->currentContentStreamId === null ? 0 :
+ $contentRepository->projectionState(ChangeFinder::class)
+ ->countByContentStreamId($workspace->currentContentStreamId);
} catch (\Exception $exception) {
$message = $this->getModuleLabel(
'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes',
@@ -996,7 +995,7 @@ protected function renderContentChanges(
ContentRepository $contentRepository,
): ContentChangeItems {
$currentWorkspace = $contentRepository->findWorkspaces()->find(
- fn (Workspace $potentialWorkspace) => $potentialWorkspace->currentContentStreamId->equals($contentStreamIdOfOriginalNode)
+ fn (Workspace $potentialWorkspace) => $potentialWorkspace->currentContentStreamId?->equals($contentStreamIdOfOriginalNode) ?? false
);
$originalNode = null;
if ($currentWorkspace !== null) {
@@ -1327,6 +1326,10 @@ protected function getWorkspaceListItems(
}
protected function getChangesFromWorkspace(Workspace $selectedWorkspace,ContentRepository $contentRepository ): Changes{
+ if (!$selectedWorkspace->isActive()) {
+ // since there is no content stream associated to them, inactive workspaces cannot contain changes
+ return Changes::fromArray([]);
+ }
return $contentRepository->projectionState(ChangeFinder::class)
->findByContentStreamId(
$selectedWorkspace->currentContentStreamId