Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeOperation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using FluentCMS.Core.Results;

namespace FluentCMS.Core.ChangeSets;

// One create, update or delete in a change set. It names the item and the version the change was based on, so publish can
// tell whether the item moved on in the meantime and revert knows what to restore (CHG-05, CHG-06).
public sealed record ChangeOperation
{
private ChangeOperation(OperationKind kind, ItemKind itemKind, Guid itemId, int? baseVersion)
{
Kind = kind;
ItemKind = itemKind;
ItemId = itemId;
BaseVersion = baseVersion;
}

public OperationKind Kind { get; }

public ItemKind ItemKind { get; }

public Guid ItemId { get; }

// Null for a create, which has no earlier version.
public int? BaseVersion { get; }

public static Result<ChangeOperation> Create(OperationKind kind, ItemKind itemKind, Guid itemId, int? baseVersion)
{
var errors = new List<Error>();

if (!Enum.IsDefined(kind))
{
errors.Add(new Error(ChangeSetErrorCodes.OperationKindInvalid, $"The operation '{kind}' is not one of create, update or delete."));
}

if (!Enum.IsDefined(itemKind))
{
errors.Add(new Error(
ChangeSetErrorCodes.ItemKindInvalid,
$"The item kind '{itemKind}' is not one of entry, entity, media, schema or rule."));
}

if (itemId == Guid.Empty)
{
errors.Add(new Error(ChangeSetErrorCodes.ItemMissing, "An operation names its item, and the item id is empty."));
}

if (kind == OperationKind.Create && baseVersion is not null)
{
errors.Add(new Error(
ChangeSetErrorCodes.BaseVersionInvalid,
$"A create has no base version because the item does not exist yet, but version {baseVersion} was given."));
}

if (kind is OperationKind.Update or OperationKind.Delete && baseVersion is not >= 1)
{
errors.Add(new Error(
ChangeSetErrorCodes.BaseVersionInvalid,
$"An {kind.ToString().ToLowerInvariant()} names the version it was based on, which is 1 or later."));
}

return errors.Count == 0
? Result<ChangeOperation>.Success(new ChangeOperation(kind, itemKind, itemId, baseVersion))
: Result<ChangeOperation>.Failure(errors);
}
}
175 changes: 175 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using FluentCMS.Core.Identity;
using FluentCMS.Core.Results;

namespace FluentCMS.Core.ChangeSets;

// The unit of every write and every review: operations grouped under one rationale, with the sources they came from and
// who started them (CHG-01, D15). It moves only along ChangeSetTransitions, and a refused call leaves it as it was.
public sealed class ChangeSet
{
private readonly List<ChangeOperation> _operations = [];

private ChangeSet(Guid id, Guid siteId, string rationale, IReadOnlyList<string> sources, Initiator initiator)
{
Id = id;
SiteId = siteId;
Rationale = rationale;
Sources = sources;
Initiator = initiator;
}

public Guid Id { get; }

public Guid SiteId { get; }

public string Rationale { get; }

public IReadOnlyList<string> Sources { get; }

public Initiator Initiator { get; }

public IReadOnlyList<ChangeOperation> Operations => _operations.AsReadOnly();

public ChangeSetState State { get; private set; } = ChangeSetState.Open;

// Kept so the desk can offer it as rule feedback once the rule book exists (CHG-08, RUL-08).
public string? RejectionReason { get; private set; }

// Operations come later, while the set is open, so an agent can open a set before its first write (CHG-02). An empty
// list of sources is allowed here: requiring one is a check (CHK-01), not a rule of the model.
public static Result<ChangeSet> Open(Guid siteId, string rationale, IEnumerable<string> sources, Initiator initiator)
{
ArgumentNullException.ThrowIfNull(sources);
ArgumentNullException.ThrowIfNull(initiator);

var errors = new List<Error>();
var trimmedRationale = rationale?.Trim() ?? string.Empty;
var trimmedSources = sources.Select(source => source?.Trim() ?? string.Empty).ToList();

if (siteId == Guid.Empty)
{
errors.Add(new Error(ChangeSetErrorCodes.SiteMissing, "A change set belongs to a site, and the site id is empty."));
}

if (trimmedRationale.Length == 0)
{
errors.Add(new Error(ChangeSetErrorCodes.RationaleMissing, "A change set needs a rationale that says why the changes are made."));
}

for (var index = 0; index < trimmedSources.Count; index++)
{
if (trimmedSources[index].Length == 0)
{
errors.Add(new Error(
ChangeSetErrorCodes.SourceBlank,
$"Source {index + 1} is blank. A source names where a change came from, such as a URL, a file, an export or a person."));
}
}

return errors.Count == 0
? Result<ChangeSet>.Success(new ChangeSet(Guid.CreateVersion7(), siteId, trimmedRationale, trimmedSources, initiator))
: Result<ChangeSet>.Failure(errors);
}

public Result AddOperation(ChangeOperation operation)
{
ArgumentNullException.ThrowIfNull(operation);

if (State != ChangeSetState.Open)
{
return Result.Failure(
ChangeSetErrorCodes.NotOpen,
$"Operations are added while a change set is open, and this one is {Describe(State)}.");
}

// One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06).
if (_operations.Any(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId))
{
return Result.Failure(
ChangeSetErrorCodes.ItemDuplicate,
$"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set.");
}

_operations.Add(operation);
return Result.Success();
}

public Result TransitionTo(ChangeSetState target)
{
var allowed = CanMoveTo(target);
if (!allowed.IsSuccess)
{
return allowed;
}

if (target == ChangeSetState.Rejected)
{
return Result.Failure(
ChangeSetErrorCodes.RejectionReasonMissing,
"A rejection needs a reason, which the change set stores; reject it with one.");
}

State = target;
return Result.Success();
}

public Result Reject(string reason)
{
var allowed = CanMoveTo(ChangeSetState.Rejected);
if (!allowed.IsSuccess)
{
return allowed;
}

var trimmedReason = reason?.Trim() ?? string.Empty;
if (trimmedReason.Length == 0)
{
return Result.Failure(
ChangeSetErrorCodes.RejectionReasonMissing,
"A rejection needs a reason that says what is wrong, and the reason is blank.");
}

State = ChangeSetState.Rejected;
RejectionReason = trimmedReason;
return Result.Success();
}

private Result CanMoveTo(ChangeSetState target)
{
if (!ChangeSetTransitions.IsAllowed(State, target))
{
var next = ChangeSetTransitions.From(State);
var options = next.Count == 0
? $"{Describe(State)} is final"
: "it can move to " + string.Join(" or ", next.Select(Describe));

return Result.Failure(
ChangeSetErrorCodes.TransitionNotAllowed,
$"A change set cannot move from {Describe(State)} to {Describe(target)}; {options}.");
}

// A change set contains one or more operations (CHG-01); while open it may still be empty.
if (State == ChangeSetState.Open && _operations.Count == 0)
{
return Result.Failure(ChangeSetErrorCodes.OperationsMissing, "A change set needs at least one operation before it leaves open.");
}

return Result.Success();
}

private static string Describe(ChangeSetState state)
{
return state switch
{
ChangeSetState.ChecksRunning => "checks running",
ChangeSetState.ChecksFailed => "checks failed",
ChangeSetState.AwaitingReview => "awaiting review",
_ => state.ToString().ToLowerInvariant(),
};
}

private static string Describe(ItemKind kind)
{
return kind.ToString().ToLowerInvariant();
}
}
17 changes: 17 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace FluentCMS.Core.ChangeSets;

public static class ChangeSetErrorCodes
{
public const string OperationKindInvalid = "operation_kind_invalid";
public const string ItemKindInvalid = "item_kind_invalid";
public const string ItemMissing = "item_missing";
public const string BaseVersionInvalid = "base_version_invalid";
public const string ItemDuplicate = "item_duplicate";
public const string SiteMissing = "site_missing";
public const string RationaleMissing = "rationale_missing";
public const string SourceBlank = "source_blank";
public const string NotOpen = "change_set_not_open";
public const string OperationsMissing = "operations_missing";
public const string TransitionNotAllowed = "transition_not_allowed";
public const string RejectionReasonMissing = "rejection_reason_missing";
}
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace FluentCMS.Core.ChangeSets;

// The CHG-04 states; ChangeSetTransitions says which moves between them exist.
public enum ChangeSetState
{
Open,
ChecksRunning,
ChecksFailed,
AwaitingReview,
Approved,
Published,
Rejected,
Reverted,
}
32 changes: 32 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetTransitions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace FluentCMS.Core.ChangeSets;

// The CHG-04 state machine. A change set moves only along these pairs and every other move is refused, so a new transition
// is a change to this table and nowhere else.
public static class ChangeSetTransitions
{
public static IReadOnlyList<(ChangeSetState From, ChangeSetState To)> All { get; } =
[
(ChangeSetState.Open, ChangeSetState.ChecksRunning),
(ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed),
(ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview),

// Routing auto-publishes: nobody approves, but publish keeps a single way in (RTE-01, MCP-06).
(ChangeSetState.ChecksRunning, ChangeSetState.Approved),
(ChangeSetState.ChecksFailed, ChangeSetState.Open),
(ChangeSetState.ChecksFailed, ChangeSetState.Rejected),
(ChangeSetState.AwaitingReview, ChangeSetState.Approved),
(ChangeSetState.AwaitingReview, ChangeSetState.Rejected),
(ChangeSetState.Approved, ChangeSetState.Published),
(ChangeSetState.Published, ChangeSetState.Reverted),
];

public static bool IsAllowed(ChangeSetState from, ChangeSetState to)
{
return All.Contains((from, to));
}

public static IReadOnlyList<ChangeSetState> From(ChangeSetState state)
{
return All.Where(transition => transition.From == state).Select(transition => transition.To).ToList();
}
}
11 changes: 11 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ItemKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace FluentCMS.Core.ChangeSets;

// What a change set operation can touch (CHG-01). Schema means a content type; the other kinds arrive with their epics.
public enum ItemKind
{
Entry,
Entity,
Media,
Schema,
Rule,
}
8 changes: 8 additions & 0 deletions src/FluentCMS.Core/ChangeSets/OperationKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace FluentCMS.Core.ChangeSets;

public enum OperationKind
{
Create,
Update,
Delete,
}
Loading
Loading