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
13 changes: 13 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeOperation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ public static Result<ChangeOperation> Create(OperationKind kind, ItemKind itemKi
: Result<ChangeOperation>.Failure(errors);
}

// The operation that puts the item back at the version this one was based on (CHG-06). An update back to an earlier version
// exists only this way; Create still asks an update for a later version.
public ChangeOperation Inverse()
{
return Kind switch
{
OperationKind.Create => new ChangeOperation(OperationKind.Delete, ItemKind, ItemId, ProposedVersion, null),
OperationKind.Update => new ChangeOperation(OperationKind.Update, ItemKind, ItemId, ProposedVersion, BaseVersion),
OperationKind.Delete => new ChangeOperation(OperationKind.Create, ItemKind, ItemId, null, BaseVersion),
_ => throw new InvalidOperationException($"The operation '{Kind}' has no inverse."),
};
}

// Rebuilds a stored operation without checking it again, so a set stays readable if the rules above change.
public static ChangeOperation Restore(OperationKind kind, ItemKind itemKind, Guid itemId, int? baseVersion, int? proposedVersion)
{
Expand Down
40 changes: 39 additions & 1 deletion src/FluentCMS.Core/ChangeSets/ChangeSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ private ChangeSet(Guid id, Guid siteId, string rationale, IEnumerable<string> so
// 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; }

// The published set this one undoes (CHG-06); null for every other set.
public Guid? RevertsChangeSetId { 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, string? sessionId = null)
Expand Down Expand Up @@ -87,7 +90,8 @@ public static ChangeSet Restore(
string? sessionId,
IEnumerable<ChangeOperation> operations,
ChangeSetState state,
string? rejectionReason)
string? rejectionReason,
Guid? revertsChangeSetId = null)
{
ArgumentNullException.ThrowIfNull(rationale);
ArgumentNullException.ThrowIfNull(sources);
Expand All @@ -98,12 +102,39 @@ public static ChangeSet Restore(
{
State = state,
RejectionReason = rejectionReason,
RevertsChangeSetId = revertsChangeSetId,
};
changeSet._operations.AddRange(operations);

return changeSet;
}

// Opens the set that undoes this published one (CHG-06): the inverse of every operation, in the same order. It names this set
// instead of citing sources, and holds nothing else, so publishing it restores exactly what this set changed.
public Result<ChangeSet> Revert(string rationale, Initiator initiator)
{
ArgumentNullException.ThrowIfNull(initiator);

if (State != ChangeSetState.Published)
{
return Result<ChangeSet>.Failure(
ChangeSetErrorCodes.TransitionNotAllowed,
$"Only a published change set can be reverted, and this one is {Describe(State)}.");
}

var opened = Open(SiteId, rationale, [], initiator);
if (!opened.IsSuccess)
{
return opened;
}

var revert = opened.Value;
revert.RevertsChangeSetId = Id;
revert._operations.AddRange(_operations.Select(operation => operation.Inverse()));

return Result<ChangeSet>.Success(revert);
}

// Later operations in a session may cite sources the first one did not; a source already on the set is not added twice.
public Result AddSources(IEnumerable<string> sources)
{
Expand Down Expand Up @@ -138,6 +169,13 @@ public Result AddOperation(ChangeOperation operation)
$"Operations are added while a change set is open, and this one is {Describe(State)}.");
}

if (RevertsChangeSetId is { } reverted)
{
return Result.Failure(
ChangeSetErrorCodes.RevertFixed,
$"This change set reverts change set {reverted} and holds exactly what undoes it, so no operation can be added.");
}

// 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))
{
Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ public static class ChangeSetErrorCodes
public const string SessionHasOpenSet = "session_has_open_set";
public const string Conflict = "change_set_conflict";
public const string BaseVersionStale = "base_version_stale";
public const string RevertFixed = "revert_set_fixed";
}
7 changes: 6 additions & 1 deletion src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ Task<Result<ChangeSet>> AddToSessionAsync(
ChangeOperation operation,
CancellationToken cancellationToken = default);

// Moving to published publishes the set, as PublishAsync does.
// Moving to published publishes the set, as PublishAsync does. Moving to reverted is refused: a set is reverted by publishing
// the set RevertAsync opens.
Task<Result<ChangeSet>> TransitionAsync(Guid changeSetId, ChangeSetState target, CancellationToken cancellationToken = default);

// Moves every item of an approved set from the version its operation was based on to the proposed one, together with the set
// and the provenance of each item, or nothing at all (CHG-05, AUD-N1, REL-01). An item that is no longer published at its
// base version fails the whole publish.
Task<Result<ChangeSet>> PublishAsync(Guid changeSetId, CancellationToken cancellationToken = default);

// Opens the set that undoes a published one and returns it (CHG-06). It goes through review like any set, and publishing it
// puts every item back at the version it had before the original and moves the original to reverted, together.
Task<Result<ChangeSet>> RevertAsync(Guid changeSetId, string rationale, CancellationToken cancellationToken = default);

Task<Result<ChangeSet>> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public void Configure(EntityTypeBuilder<ChangeSetRow> row)
row.Property(set => set.State).HasConversion<string>().HasMaxLength(32);

row.HasOne<SiteRow>().WithMany().HasForeignKey(set => set.SiteId).OnDelete(DeleteBehavior.Restrict);
row.HasOne<ChangeSetRow>().WithMany().HasForeignKey(set => set.RevertsChangeSetId).OnDelete(DeleteBehavior.Restrict);

row.HasIndex(set => set.State);

Expand Down
87 changes: 76 additions & 11 deletions src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public async Task<Result<ChangeSet>> AddToSessionAsync(

await using var scope = await unitOfWork.BeginAsync(cancellationToken);
await InsertAsync(set, cancellationToken);
await InsertOperationAsync(set, previousVersion.Value, cancellationToken);
await InsertOperationAsync(set, set.Operations.Count - 1, previousVersion.Value, cancellationToken);

// A concurrent first operation of the same session that committed first trips the one-open-set index.
return await CommitAsync(
Expand All @@ -144,10 +144,15 @@ public async Task<Result<ChangeSet>> AddToSessionAsync(

public Task<Result<ChangeSet>> TransitionAsync(Guid changeSetId, ChangeSetState target, CancellationToken cancellationToken = default)
{
// No call moves a set to published without its items.
return target == ChangeSetState.Published
? PublishAsync(changeSetId, cancellationToken)
: MoveAsync(changeSetId, target, set => set.TransitionTo(target), cancellationToken);
// No call moves a set to published or reverted without its items.
return target switch
{
ChangeSetState.Published => PublishAsync(changeSetId, cancellationToken),
ChangeSetState.Reverted => MoveAsync(changeSetId, target, _ => Result.Failure(
ChangeSetErrorCodes.TransitionNotAllowed,
"A change set is reverted by publishing the change set that reverting it opens; revert it instead."), cancellationToken),
_ => MoveAsync(changeSetId, target, set => set.TransitionTo(target), cancellationToken),
};
}

public async Task<Result<ChangeSet>> PublishAsync(Guid changeSetId, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -177,12 +182,26 @@ public async Task<Result<ChangeSet>> PublishAsync(Guid changeSetId, Cancellation
return Result<ChangeSet>.Failure(items.Errors);
}

// A revert moves the set it undoes to reverted in the same unit of work (CHG-06).
var reverted = set.RevertsChangeSetId is { } revertedId ? await ReadAsync(revertedId, cancellationToken) : null;
if (reverted is not null && !reverted.ChangeSet.TransitionTo(ChangeSetState.Reverted).IsSuccess)
{
return Result<ChangeSet>.Failure(
ChangeSetErrorCodes.TransitionNotAllowed,
$"This change set reverts change set {reverted.ChangeSet.Id}, which is no longer published, so there is nothing left to revert.");
}

await using var scope = await unitOfWork.BeginAsync(cancellationToken);
if (!await UpdateAsync(stored.Revision, set, cancellationToken))
{
return Conflict(set.Id);
}

if (reverted is not null && !await UpdateAsync(reverted.Revision, reverted.ChangeSet, cancellationToken))
{
return Conflict(reverted.ChangeSet.Id);
}

foreach (var item in items.Value)
{
// The scope rolls back the items moved before this one.
Expand All @@ -208,10 +227,54 @@ await auditLog.AppendAsync(
}

await AppendTransitionAsync(set, cancellationToken);
if (reverted is not null)
{
await AppendTransitionAsync(reverted.ChangeSet, cancellationToken);
}

return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken);
}

public async Task<Result<ChangeSet>> RevertAsync(Guid changeSetId, string rationale, CancellationToken cancellationToken = default)
{
var stored = await ReadAsync(changeSetId, cancellationToken);
if (stored is null)
{
return NotFound(changeSetId);
}

// Reverting changes what is live, as publishing does, so only a person reverts, and people have no caps (MCP-06).
var allowed = await permissions.CheckTransitionAsync(stored.ChangeSet, ChangeSetState.Reverted, cancellationToken);
if (!allowed.IsSuccess)
{
return Result<ChangeSet>.Failure(allowed.Errors);
}

var opened = stored.ChangeSet.Revert(rationale, Initiator());
if (!opened.IsSuccess)
{
return opened;
}

// The revert publishes over the versions the original published, so an item that moved on since fails it here already.
var revert = opened.Value;
var items = await PublishableItemsAsync(revert, cancellationToken);
if (!items.IsSuccess)
{
return Result<ChangeSet>.Failure(items.Errors);
}


await using var scope = await unitOfWork.BeginAsync(cancellationToken);
await InsertAsync(revert, cancellationToken);
for (var position = 0; position < revert.Operations.Count; position++)
{
await InsertOperationAsync(revert, position, items.Value[position].PreviousVersionId, cancellationToken);
}

return await CommitAsync(scope, revert, Conflict(revert.Id), cancellationToken);
}

public Task<Result<ChangeSet>> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default)
{
return MoveAsync(changeSetId, ChangeSetState.Rejected, set => set.Reject(reason), cancellationToken);
Expand Down Expand Up @@ -257,7 +320,7 @@ private async Task<Result<ChangeSet>> AddAsync(
return Conflict(set.Id);
}

await InsertOperationAsync(set, previousVersion.Value, cancellationToken);
await InsertOperationAsync(set, set.Operations.Count - 1, previousVersion.Value, cancellationToken);

return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken);
}
Expand Down Expand Up @@ -330,23 +393,24 @@ private async Task InsertAsync(ChangeSet set, CancellationToken cancellationToke
InitiatorName = set.Initiator.DisplayName,
SessionId = set.SessionId,
State = set.State,
RevertsChangeSetId = set.RevertsChangeSetId,
});

await auditLog.AppendAsync(
new NewAuditEvent(OpenedOperation) { SubjectType = SubjectType, SubjectId = set.Id, ChangeSetId = set.Id, Sources = set.Sources },
cancellationToken);
}

// The newest operation of the set, with the stored version it was based on as the event's previous version (AUD-01).
private async Task InsertOperationAsync(ChangeSet set, Guid? previousVersionId, CancellationToken cancellationToken)
// An operation of the set, with the stored version it was based on as the event's previous version (AUD-01).
private async Task InsertOperationAsync(ChangeSet set, int position, Guid? previousVersionId, CancellationToken cancellationToken)
{
var operation = set.Operations[^1];
var operation = set.Operations[position];

context.ChangeSetOperations.Add(new ChangeSetOperationRow
{
Id = Guid.CreateVersion7(),
ChangeSetId = set.Id,
Position = set.Operations.Count - 1,
Position = position,
Kind = operation.Kind,
ItemKind = operation.ItemKind,
ItemId = operation.ItemId,
Expand Down Expand Up @@ -574,7 +638,8 @@ private async Task<bool> MovePublishedVersionAsync(ChangeOperation operation, Ca
operation.BaseVersion,
operation.ProposedVersion)),
row.State,
row.RejectionReason);
row.RejectionReason,
row.RevertsChangeSetId);

return new StoredChangeSet(set, row.Revision);
}
Expand Down
2 changes: 2 additions & 0 deletions src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public sealed class ChangeSetRow

public string? RejectionReason { get; set; }

public Guid? RevertsChangeSetId { get; set; }

// Every write moves it on and only applies to the revision it read, so of two concurrent writers the second changes nothing.
public int Revision { get; set; }
}
Loading
Loading