From 26b01bb66a9f921f23622d36522aa05c45877834 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:20:08 -0400 Subject: [PATCH] Add change set revert --- .../ChangeSets/ChangeOperation.cs | 13 + src/FluentCMS.Core/ChangeSets/ChangeSet.cs | 40 +- .../ChangeSets/ChangeSetErrorCodes.cs | 1 + .../ChangeSets/IChangeSetDirectory.cs | 7 +- .../ChangeSets/ChangeSetConfiguration.cs | 1 + .../ChangeSets/ChangeSetDirectory.cs | 87 +- .../ChangeSets/ChangeSetRow.cs | 2 + ...0261018231434_ChangeSetReverts.Designer.cs | 851 ++++++++++++++++++ .../20261018231434_ChangeSetReverts.cs | 50 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 10 + ...0261018231425_ChangeSetReverts.Designer.cs | 841 +++++++++++++++++ .../Sqlite/20261018231425_ChangeSetReverts.cs | 50 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 10 + .../ChangeSets/ChangeSetDirectoryTests.cs | 21 +- .../ChangeSets/ChangeSetRevertTests.cs | 226 +++++ .../ChangeSets/ChangeSetScenario.cs | 19 +- .../ChangeSets/ChangeOperationTests.cs | 32 + .../ChangeSets/ChangeSetTests.cs | 59 ++ 18 files changed, 2300 insertions(+), 20 deletions(-) create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.cs create mode 100644 tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetRevertTests.cs diff --git a/src/FluentCMS.Core/ChangeSets/ChangeOperation.cs b/src/FluentCMS.Core/ChangeSets/ChangeOperation.cs index 436f03ddc..c5c08bbd9 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeOperation.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeOperation.cs @@ -87,6 +87,19 @@ public static Result Create(OperationKind kind, ItemKind itemKi : Result.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) { diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index bad695ddb..7b4ff77dd 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -42,6 +42,9 @@ private ChangeSet(Guid id, Guid siteId, string rationale, IEnumerable 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 Open(Guid siteId, string rationale, IEnumerable sources, Initiator initiator, string? sessionId = null) @@ -87,7 +90,8 @@ public static ChangeSet Restore( string? sessionId, IEnumerable operations, ChangeSetState state, - string? rejectionReason) + string? rejectionReason, + Guid? revertsChangeSetId = null) { ArgumentNullException.ThrowIfNull(rationale); ArgumentNullException.ThrowIfNull(sources); @@ -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 Revert(string rationale, Initiator initiator) + { + ArgumentNullException.ThrowIfNull(initiator); + + if (State != ChangeSetState.Published) + { + return Result.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.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 sources) { @@ -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)) { diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs index 8f0e2b928..2c1930c92 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -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"; } diff --git a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs index 8e52a6ec4..6d3267098 100644 --- a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs +++ b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs @@ -23,7 +23,8 @@ Task> 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> 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 @@ -31,5 +32,9 @@ Task> AddToSessionAsync( // base version fails the whole publish. Task> 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> RevertAsync(Guid changeSetId, string rationale, CancellationToken cancellationToken = default); + Task> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs index 634d924c5..475df94d3 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetConfiguration.cs @@ -23,6 +23,7 @@ public void Configure(EntityTypeBuilder row) row.Property(set => set.State).HasConversion().HasMaxLength(32); row.HasOne().WithMany().HasForeignKey(set => set.SiteId).OnDelete(DeleteBehavior.Restrict); + row.HasOne().WithMany().HasForeignKey(set => set.RevertsChangeSetId).OnDelete(DeleteBehavior.Restrict); row.HasIndex(set => set.State); diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 53a136f64..f14645023 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -130,7 +130,7 @@ public async Task> 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( @@ -144,10 +144,15 @@ public async Task> AddToSessionAsync( public Task> 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> PublishAsync(Guid changeSetId, CancellationToken cancellationToken = default) @@ -177,12 +182,26 @@ public async Task> PublishAsync(Guid changeSetId, Cancellation return Result.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.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. @@ -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> 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.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.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> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default) { return MoveAsync(changeSetId, ChangeSetState.Rejected, set => set.Reject(reason), cancellationToken); @@ -257,7 +320,7 @@ private async Task> 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); } @@ -330,6 +393,7 @@ private async Task InsertAsync(ChangeSet set, CancellationToken cancellationToke InitiatorName = set.Initiator.DisplayName, SessionId = set.SessionId, State = set.State, + RevertsChangeSetId = set.RevertsChangeSetId, }); await auditLog.AppendAsync( @@ -337,16 +401,16 @@ await auditLog.AppendAsync( 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, @@ -574,7 +638,8 @@ private async Task MovePublishedVersionAsync(ChangeOperation operation, Ca operation.BaseVersion, operation.ProposedVersion)), row.State, - row.RejectionReason); + row.RejectionReason, + row.RevertsChangeSetId); return new StoredChangeSet(set, row.Revision); } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs index 29c72eeb3..6ef704c70 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetRow.cs @@ -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; } } diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.Designer.cs new file mode 100644 index 000000000..e673cfb33 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.Designer.cs @@ -0,0 +1,851 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261018231434_ChangeSetReverts")] + partial class ChangeSetReverts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.cs new file mode 100644 index 000000000..4d8f912ba --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018231434_ChangeSetReverts.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class ChangeSetReverts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RevertsChangeSetId", + table: "ChangeSets", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ChangeSets_RevertsChangeSetId", + table: "ChangeSets", + column: "RevertsChangeSetId"); + + migrationBuilder.AddForeignKey( + name: "FK_ChangeSets_ChangeSets_RevertsChangeSetId", + table: "ChangeSets", + column: "RevertsChangeSetId", + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ChangeSets_ChangeSets_RevertsChangeSetId", + table: "ChangeSets"); + + migrationBuilder.DropIndex( + name: "IX_ChangeSets_RevertsChangeSetId", + table: "ChangeSets"); + + migrationBuilder.DropColumn( + name: "RevertsChangeSetId", + table: "ChangeSets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 4326add21..55e054f73 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -271,6 +271,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RejectionReason") .HasColumnType("text"); + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + b.Property("Revision") .HasColumnType("integer"); @@ -292,6 +295,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("RevertsChangeSetId"); + b.HasIndex("SiteId"); b.HasIndex("State"); @@ -724,6 +729,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) .WithMany() .HasForeignKey("SiteId") diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.Designer.cs new file mode 100644 index 000000000..3b42f27f1 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.Designer.cs @@ -0,0 +1,841 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261018231425_ChangeSetReverts")] + partial class ChangeSetReverts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.cs new file mode 100644 index 000000000..0a96b1de6 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018231425_ChangeSetReverts.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class ChangeSetReverts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RevertsChangeSetId", + table: "ChangeSets", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ChangeSets_RevertsChangeSetId", + table: "ChangeSets", + column: "RevertsChangeSetId"); + + migrationBuilder.AddForeignKey( + name: "FK_ChangeSets_ChangeSets_RevertsChangeSetId", + table: "ChangeSets", + column: "RevertsChangeSetId", + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ChangeSets_ChangeSets_RevertsChangeSetId", + table: "ChangeSets"); + + migrationBuilder.DropIndex( + name: "IX_ChangeSets_RevertsChangeSetId", + table: "ChangeSets"); + + migrationBuilder.DropColumn( + name: "RevertsChangeSetId", + table: "ChangeSets"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index fc1bd1dc9..71be2ce9f 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -265,6 +265,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RejectionReason") .HasColumnType("TEXT"); + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + b.Property("Revision") .HasColumnType("INTEGER"); @@ -286,6 +289,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("RevertsChangeSetId"); + b.HasIndex("SiteId"); b.HasIndex("State"); @@ -714,6 +719,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) .WithMany() .HasForeignKey("SiteId") diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs index a53c3c692..90167df79 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs @@ -376,12 +376,29 @@ public async Task Of_two_concurrent_first_operations_in_a_session_one_opens_the_ } // Submitting and reopening are the caller's own moves; the others are made as a person, as the checks and the desk will. - private static Task> MoveAsync(ChangeSetScenario scenario, Guid id, ChangeSetState state) + private static async Task> MoveAsync(ChangeSetScenario scenario, Guid id, ChangeSetState state) { + // A set is reverted by publishing its revert, which moves it in the same unit of work; a person makes every step. + if (state == ChangeSetState.Reverted) + { + var caller = scenario.Initiator; + scenario.Initiator = Amir; + try + { + var revert = (await scenario.RunAsync(directory => directory.RevertAsync(id, "The sync used the wrong export."))).Value; + await scenario.PublishAsync(revert.Id); + return await scenario.RunAsync(directory => directory.GetAsync(id)); + } + finally + { + scenario.Initiator = caller; + } + } + Func>> move = directory => state == ChangeSetState.Rejected ? directory.RejectAsync(id, "The price has no source.") : directory.TransitionAsync(id, state); - return state is ChangeSetState.ChecksRunning or ChangeSetState.Open ? scenario.RunAsync(move) : scenario.AsPersonAsync(move); + return state is ChangeSetState.ChecksRunning or ChangeSetState.Open ? await scenario.RunAsync(move) : await scenario.AsPersonAsync(move); } } diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetRevertTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetRevertTests.cs new file mode 100644 index 000000000..4e47afaba --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetRevertTests.cs @@ -0,0 +1,226 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; + +namespace FluentCMS.IntegrationTests.ChangeSets; + +public sealed class ChangeSetRevertTests +{ + private const string WrongExport = "The sync used the wrong export."; + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Publishing_a_revert_restores_every_item_and_moves_the_original_to_reverted(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (category, product, region, original) = await PublishedOriginalAsync(scenario); + var versionRows = await VersionRowsAsync(scenario); + + var opened = await scenario.RunAsync(directory => directory.RevertAsync(original.Id, WrongExport)); + + Assert.True(opened.IsSuccess, string.Join("; ", opened.Errors.Select(error => error.Message))); + var revert = (await scenario.RunAsync(directory => directory.GetAsync(opened.Value.Id))).Value; + Assert.Equal(original.Id, revert.RevertsChangeSetId); + Assert.Equal(WrongExport, revert.Rationale); + Assert.Equal(Amir, revert.Initiator); + Assert.Equal(ChangeSetState.Open, revert.State); + Assert.Equal(original.Operations.Select(operation => operation.Inverse()), revert.Operations); + Assert.Equal( + [Delete(category, 1), ChangeOperation.Restore(OperationKind.Update, ItemKind.Schema, product.Id, 2, 1), ChangeOperation.Restore(OperationKind.Create, ItemKind.Schema, region.Id, null, 1)], + revert.Operations); + + var added = (await scenario.ChangeSetEventsAsync(revert.Id)).Where(row => row.Operation == "change_set.operation_added").ToList(); + Assert.Equal([category.Id, product.Id, region.Id], added.Select(row => row.SubjectId)); + Assert.Equal([versionRows[(category.Id, 1)], versionRows[(product.Id, 2)], null], added.Select(row => row.PreviousVersionId)); + + var diff = (await scenario.RunAsync(directory => directory.DiffAsync(revert.Id))).Value; + var summary = Assert.Single(diff.Items[1].Fields); + Assert.Equal("summary", summary.Name); + Assert.Equal(ValueChange.Removed, summary.Change); + + var before = (await scenario.ChangeSetEventsAsync()).Count; + await scenario.ApproveAsync(revert.Id); + var published = await scenario.RunAsync(directory => directory.PublishAsync(revert.Id)); + + Assert.True(published.IsSuccess, string.Join("; ", published.Errors.Select(error => error.Message))); + Assert.Null(await scenario.PublishedVersionAsync(category.Id)); + Assert.Equal(1, await scenario.PublishedVersionAsync(product.Id)); + Assert.Equal(1, await scenario.PublishedVersionAsync(region.Id)); + Assert.Equal(ChangeSetState.Published, (await scenario.ChangeSetRowAsync(revert.Id)).State); + Assert.Equal(ChangeSetState.Reverted, (await scenario.ChangeSetRowAsync(original.Id)).State); + Assert.Equal(ChangeSetState.Reverted, (await scenario.RunAsync(directory => directory.GetAsync(original.Id))).Value.State); + + var events = (await scenario.ChangeSetEventsAsync()).Skip(before + 2).ToList(); + Assert.Equal( + ["content_type.unpublished", "content_type.published", "content_type.published", "change_set.published", "change_set.reverted"], + events.Select(row => row.Operation)); + Assert.Equal([category.Id, product.Id, region.Id, revert.Id, original.Id], events.Select(row => row.SubjectId)); + Assert.Equal([revert.Id, revert.Id, revert.Id, revert.Id, original.Id], events.Select(row => row.ChangeSetId)); + Assert.Equal([versionRows[(category.Id, 1)], versionRows[(product.Id, 2)], null, null, null], events.Select(row => row.PreviousVersionId)); + Assert.All(events, row => Assert.Equal(Amir.UserId, row.InitiatorId)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Reverting_a_revert_publishes_the_original_versions_again(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (category, product, region, original) = await PublishedOriginalAsync(scenario); + var revert = (await scenario.RunAsync(directory => directory.RevertAsync(original.Id, WrongExport))).Value; + await scenario.PublishAsync(revert.Id); + + var again = (await scenario.RunAsync(directory => directory.RevertAsync(revert.Id, "The export was right after all."))).Value; + await scenario.PublishAsync(again.Id); + + Assert.Equal(original.Operations, again.Operations); + Assert.Equal(1, await scenario.PublishedVersionAsync(category.Id)); + Assert.Equal(2, await scenario.PublishedVersionAsync(product.Id)); + Assert.Null(await scenario.PublishedVersionAsync(region.Id)); + Assert.Equal(ChangeSetState.Reverted, (await scenario.ChangeSetRowAsync(revert.Id)).State); + Assert.Equal(ChangeSetState.Reverted, (await scenario.ChangeSetRowAsync(original.Id)).State); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_that_is_not_published_or_whose_items_moved_on_is_not_reverted(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var product = await scenario.ContentTypeAsync("product", revised: true); + var category = await scenario.ContentTypeAsync("category"); + var open = await scenario.OpenAsync(Create(category)); + var original = await scenario.PublishAsync(Create(product)); + await scenario.PublishAsync(Update(product)); + var latest = await scenario.PublishAsync(Create(category)); + var sets = (await scenario.ChangeSetRowsAsync()).Count; + var events = (await scenario.ChangeSetEventsAsync()).Count; + + var unknown = await scenario.RunAsync(directory => directory.RevertAsync(Guid.NewGuid(), WrongExport)); + var notPublished = await scenario.RunAsync(directory => directory.RevertAsync(open.Id, WrongExport)); + var movedOn = await scenario.RunAsync(directory => directory.RevertAsync(original.Id, WrongExport)); + var withoutRationale = await scenario.RunAsync(directory => directory.RevertAsync(latest.Id, " ")); + var moved = await scenario.RunAsync(directory => directory.TransitionAsync(latest.Id, ChangeSetState.Reverted)); + + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Assert.Single(unknown.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(notPublished.Errors).Code); + Assert.Equal( + "The content type 'product' is published at version 2, but this change set was based on version 1.", + Assert.Single(movedOn.Errors, error => error.Code == ChangeSetErrorCodes.BaseVersionStale).Message); + Assert.Equal(ChangeSetErrorCodes.RationaleMissing, Assert.Single(withoutRationale.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(moved.Errors).Code); + Assert.Equal(ChangeSetState.Published, (await scenario.ChangeSetRowAsync(latest.Id)).State); + Assert.Equal(sets, (await scenario.ChangeSetRowsAsync()).Count); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + + var revert = (await scenario.RunAsync(directory => directory.RevertAsync(latest.Id, WrongExport))).Value; + var extra = await scenario.RunAsync(directory => directory.AddOperationAsync(revert.Id, Create(product))); + Assert.Equal(ChangeSetErrorCodes.RevertFixed, Assert.Single(extra.Errors).Code); + Assert.Single(await scenario.OperationRowsAsync(revert.Id)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_two_reverts_of_one_set_only_the_first_to_publish_goes_through(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (_, product, _, original) = await PublishedOriginalAsync(scenario); + var first = (await scenario.RunAsync(directory => directory.RevertAsync(original.Id, WrongExport))).Value; + var second = (await scenario.RunAsync(directory => directory.RevertAsync(original.Id, WrongExport))).Value; + await scenario.ApproveAsync(second.Id); + await scenario.PublishAsync(first.Id); + var events = (await scenario.ChangeSetEventsAsync()).Count; + + var refused = await scenario.RunAsync(directory => directory.PublishAsync(second.Id)); + + Assert.NotEmpty(refused.Errors); + Assert.Equal(1, await scenario.PublishedVersionAsync(product.Id)); + Assert.Equal(ChangeSetState.Approved, (await scenario.ChangeSetRowAsync(second.Id)).State); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + Assert.Single(await scenario.ChangeSetEventsAsync(original.Id), row => row.Operation == "change_set.reverted"); + } + + // Publishing the revert appends one event per item, the revert's and the original's, each after the first item moved. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_failure_mid_revert_leaves_the_items_the_revert_and_the_original_as_they_were(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var (category, product, region, original) = await PublishedOriginalAsync(scenario); + var revert = (await scenario.RunAsync(directory => directory.RevertAsync(original.Id, WrongExport))).Value; + await scenario.ApproveAsync(revert.Id); + var revisions = (Revert: (await scenario.ChangeSetRowAsync(revert.Id)).Revision, Original: (await scenario.ChangeSetRowAsync(original.Id)).Revision); + var events = (await scenario.ChangeSetEventsAsync()).Count; + + for (var append = 1; append <= 5; append++) + { + scenario.FailOnAppend(append); + await Assert.ThrowsAsync(() => scenario.RunAsync(directory => directory.PublishAsync(revert.Id))); + + Assert.Equal(1, await scenario.PublishedVersionAsync(category.Id)); + Assert.Equal(2, await scenario.PublishedVersionAsync(product.Id)); + Assert.Null(await scenario.PublishedVersionAsync(region.Id)); + var storedRevert = await scenario.ChangeSetRowAsync(revert.Id); + var storedOriginal = await scenario.ChangeSetRowAsync(original.Id); + Assert.Equal((ChangeSetState.Approved, revisions.Revert), (storedRevert.State, storedRevert.Revision)); + Assert.Equal((ChangeSetState.Published, revisions.Original), (storedOriginal.State, storedOriginal.Revision)); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + } + + Assert.True((await scenario.RunAsync(directory => directory.PublishAsync(revert.Id))).IsSuccess); + Assert.Equal(ChangeSetState.Reverted, (await scenario.ChangeSetRowAsync(original.Id)).State); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_cannot_revert_and_the_refusal_is_logged(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var product = await scenario.ContentTypeAsync("product"); + var original = await scenario.PublishAsync(Create(product)); + var sets = (await scenario.ChangeSetRowsAsync()).Count; + + var refused = await scenario.RunAsync(directory => directory.RevertAsync(original.Id, WrongExport)); + + Assert.Equal(AgentPermissionErrorCodes.TransitionNotPermitted, Assert.Single(refused.Errors).Code); + Assert.Equal(sets, (await scenario.ChangeSetRowsAsync()).Count); + Assert.Equal(ChangeSetState.Published, (await scenario.ChangeSetRowAsync(original.Id)).State); + Assert.Equal("agent.transition_refused", (await scenario.ChangeSetEventsAsync(original.Id))[^1].Operation); + } + + // Reverting and publishing are review moves, which only a person makes (MCP-06). + private static async Task ScenarioAsync(string provider) + { + var scenario = await ChangeSetScenario.CreateAsync(provider); + scenario.Initiator = Amir; + return scenario; + } + + // A published original with a create of category, an update of product from 1 to 2 and a delete of region at 1. + private static async Task<(ContentType Category, ContentType Product, ContentType Region, ChangeSet Original)> PublishedOriginalAsync( + ChangeSetScenario scenario) + { + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + var region = await scenario.ContentTypeAsync("region"); + await scenario.PublishAsync(Create(product), Create(region)); + + var original = await scenario.PublishAsync(Create(category), Update(product), Delete(region, 1)); + + return (category, product, region, original); + } + + private static async Task> VersionRowsAsync(ChangeSetScenario scenario) + { + var rows = await scenario.QueryAsync(context => context.ContentTypeVersions.AsNoTracking().ToListAsync()); + + return rows.ToDictionary(row => (row.ContentTypeId, row.Version), row => (Guid?)row.Id); + } +} diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index 666ff8e39..b30c0a215 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -174,17 +174,26 @@ public async Task OpenAsync(params ChangeOperation[] operations) // Opened with the operations, submitted and approved, the way routing approves a set without review. public async Task ApproveAsync(params ChangeOperation[] operations) { - var changeSet = await OpenAsync(operations); - Assert.True((await RunAsync(directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.ChecksRunning))).IsSuccess); + return await ApproveAsync((await OpenAsync(operations)).Id); + } + + public async Task ApproveAsync(Guid changeSetId) + { + Assert.True((await RunAsync(directory => directory.TransitionAsync(changeSetId, ChangeSetState.ChecksRunning))).IsSuccess); - return (await AsPersonAsync(directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.Approved))).Value; + return (await AsPersonAsync(directory => directory.TransitionAsync(changeSetId, ChangeSetState.Approved))).Value; } // Approved and published with the operations, so later sets can be based on the versions they publish. public async Task PublishAsync(params ChangeOperation[] operations) { - var changeSet = await ApproveAsync(operations); - var published = await AsPersonAsync(directory => directory.PublishAsync(changeSet.Id)); + return await PublishAsync((await OpenAsync(operations)).Id); + } + + public async Task PublishAsync(Guid changeSetId) + { + await ApproveAsync(changeSetId); + var published = await AsPersonAsync(directory => directory.PublishAsync(changeSetId)); Assert.True(published.IsSuccess, string.Join("; ", published.Errors.Select(error => error.Message))); return published.Value; diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeOperationTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeOperationTests.cs index 18b1b40c9..64b0d692e 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeOperationTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeOperationTests.cs @@ -103,6 +103,38 @@ public void Every_problem_with_an_operation_is_reported_at_once() result.Errors.Select(error => error.Code)); } + [Theory] + [InlineData(OperationKind.Create, null, 1, OperationKind.Delete, 1, null)] + [InlineData(OperationKind.Update, 2, 5, OperationKind.Update, 5, 2)] + [InlineData(OperationKind.Delete, 3, null, OperationKind.Create, null, 3)] + public void The_inverse_puts_the_item_back_at_the_version_the_operation_was_based_on( + OperationKind kind, + int? baseVersion, + int? proposedVersion, + OperationKind inverseKind, + int? inverseBase, + int? inverseProposed) + { + var operation = ChangeOperation.Create(kind, ItemKind.Schema, Item, baseVersion, proposedVersion).Value; + + var inverse = operation.Inverse(); + + Assert.Equal(inverseKind, inverse.Kind); + Assert.Equal(ItemKind.Schema, inverse.ItemKind); + Assert.Equal(Item, inverse.ItemId); + Assert.Equal(inverseBase, inverse.BaseVersion); + Assert.Equal(inverseProposed, inverse.ProposedVersion); + Assert.Equal(operation, inverse.Inverse()); + } + + [Fact] + public void An_update_back_to_an_earlier_version_exists_only_as_an_inverse() + { + var created = ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, Item, 5, 2); + + Assert.Equal(ChangeSetErrorCodes.ProposedVersionInvalid, Assert.Single(created.Errors).Code); + } + [Fact] public void A_stored_operation_is_restored_without_being_checked_again() { diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index b1d7ac8d4..4e3c49764 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -225,5 +225,64 @@ public void A_stored_set_is_restored_as_it_was() Assert.Equal([operation], changeSet.Operations); Assert.Equal(ChangeSetState.Rejected, changeSet.State); Assert.Equal("The price has no source.", changeSet.RejectionReason); + Assert.Null(changeSet.RevertsChangeSetId); + } + + [Fact] + public void A_revert_holds_the_inverse_of_every_operation_in_order_and_names_the_set_it_undoes() + { + var create = ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, Guid.NewGuid(), null, 1).Value; + var update = ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, Guid.NewGuid(), 1, 2).Value; + var original = ChangeSet.Restore( + Guid.NewGuid(), + Site, + "Sync prices.", + ["exports/catalog.csv"], + TestChangeSets.Agent, + null, + [create, update], + ChangeSetState.Published, + null); + var person = new UserInitiator(Guid.NewGuid(), "Amir"); + + var revert = original.Revert(" The sync used the wrong export. ", person).Value; + + Assert.NotEqual(original.Id, revert.Id); + Assert.Equal(original.Id, revert.RevertsChangeSetId); + Assert.Equal(Site, revert.SiteId); + Assert.Equal("The sync used the wrong export.", revert.Rationale); + Assert.Empty(revert.Sources); + Assert.Same(person, revert.Initiator); + Assert.Equal(ChangeSetState.Open, revert.State); + Assert.Equal([create.Inverse(), update.Inverse()], revert.Operations); + Assert.Equal(ChangeSetState.Published, original.State); + } + + [Theory] + [InlineData(ChangeSetState.Open)] + [InlineData(ChangeSetState.Approved)] + [InlineData(ChangeSetState.Rejected)] + [InlineData(ChangeSetState.Reverted)] + public void Only_a_published_set_can_be_reverted(ChangeSetState state) + { + var changeSet = TestChangeSets.InState(state); + + var revert = changeSet.Revert("Undo it.", TestChangeSets.Agent); + + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(revert.Errors).Code); + } + + [Fact] + public void A_revert_needs_a_rationale_and_takes_no_further_operations() + { + var published = TestChangeSets.InState(ChangeSetState.Published); + + var blank = published.Revert(" ", TestChangeSets.Agent); + var revert = published.Revert("Undo it.", TestChangeSets.Agent).Value; + var added = revert.AddOperation(TestChangeSets.Update(ItemKind.Schema)); + + Assert.Equal(ChangeSetErrorCodes.RationaleMissing, Assert.Single(blank.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.RevertFixed, Assert.Single(added.Errors).Code); + Assert.Single(revert.Operations); } }