diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs index 8d2273559..8f0e2b928 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -22,4 +22,5 @@ public static class ChangeSetErrorCodes public const string ItemKindUnavailable = "item_kind_unavailable"; public const string SessionHasOpenSet = "session_has_open_set"; public const string Conflict = "change_set_conflict"; + public const string BaseVersionStale = "base_version_stale"; } diff --git a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs index ff5fc412a..8e52a6ec4 100644 --- a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs +++ b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs @@ -23,7 +23,13 @@ Task> AddToSessionAsync( ChangeOperation operation, CancellationToken cancellationToken = default); + // Moving to published publishes the set, as PublishAsync does. 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 + // 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> PublishAsync(Guid changeSetId, CancellationToken cancellationToken = default); + Task> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 22db9863a..53a136f64 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -144,7 +144,72 @@ public async Task> AddToSessionAsync( public Task> TransitionAsync(Guid changeSetId, ChangeSetState target, CancellationToken cancellationToken = default) { - return MoveAsync(changeSetId, target, set => set.TransitionTo(target), cancellationToken); + // 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); + } + + public async Task> PublishAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + var stored = await ReadAsync(changeSetId, cancellationToken); + if (stored is null) + { + return NotFound(changeSetId); + } + + var allowed = await permissions.CheckTransitionAsync(stored.ChangeSet, ChangeSetState.Published, cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var set = stored.ChangeSet; + var moved = set.TransitionTo(ChangeSetState.Published); + if (!moved.IsSuccess) + { + return Result.Failure(moved.Errors); + } + + var items = await PublishableItemsAsync(set, cancellationToken); + if (!items.IsSuccess) + { + return Result.Failure(items.Errors); + } + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + if (!await UpdateAsync(stored.Revision, set, cancellationToken)) + { + return Conflict(set.Id); + } + + foreach (var item in items.Value) + { + // The scope rolls back the items moved before this one. + if (!await MovePublishedVersionAsync(item.Operation, cancellationToken)) + { + return Result.Failure( + ChangeSetErrorCodes.BaseVersionStale, + $"Another change set published the content type '{item.Slug}' at the same time, " + + "so it is no longer at the version this change set was based on."); + } + + var subjectType = ItemSubjectType(item.Operation.ItemKind); + await auditLog.AppendAsync( + new NewAuditEvent(item.Operation.Kind == OperationKind.Delete ? $"{subjectType}.unpublished" : $"{subjectType}.published") + { + SubjectType = subjectType, + SubjectId = item.Operation.ItemId, + ChangeSetId = set.Id, + Sources = set.Sources, + PreviousVersionId = item.PreviousVersionId, + }, + cancellationToken); + } + + await AppendTransitionAsync(set, cancellationToken); + + return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken); } public Task> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default) @@ -238,13 +303,18 @@ private async Task> MoveAsync( return Conflict(set.Id); } - await auditLog.AppendAsync( - new NewAuditEvent(TransitionOperation(set.State)) { SubjectType = SubjectType, SubjectId = set.Id, ChangeSetId = set.Id }, - cancellationToken); + await AppendTransitionAsync(set, cancellationToken); return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken); } + private Task AppendTransitionAsync(ChangeSet set, CancellationToken cancellationToken) + { + return auditLog.AppendAsync( + new NewAuditEvent(TransitionOperation(set.State)) { SubjectType = SubjectType, SubjectId = set.Id, ChangeSetId = set.Id }, + cancellationToken); + } + private async Task InsertAsync(ChangeSet set, CancellationToken cancellationToken) { var (kind, initiatorId) = InitiatorKinds.Describe(set.Initiator); @@ -402,6 +472,63 @@ private async Task UpdateAsync(int revision, ChangeSet set, CancellationTo + string.Join("; ", definition.Errors.Select(error => error.Message))); } + // The items of a set with the row of the version each is published at. Every item must still be published at its + // operation's base version, and a create's item not at all, so a publish never replaces a version its set has not seen. + private async Task>> PublishableItemsAsync(ChangeSet set, CancellationToken cancellationToken) + { + if (set.Operations.FirstOrDefault(operation => operation.ItemKind != ItemKind.Schema) is { } other) + { + throw new InvalidOperationException( + $"Change set {set.Id} holds an operation on a {ItemSubjectType(other.ItemKind)} item, which nothing publishes yet."); + } + + var ids = set.Operations.Select(operation => operation.ItemId).ToList(); + var bases = set.Operations.Select(operation => operation.BaseVersion).OfType().Distinct().ToList(); + + var types = await context.ContentTypes + .AsNoTracking() + .Where(type => type.SiteId == SiteId && ids.Contains(type.Id)) + .Select(type => new { type.Id, type.Slug, type.PublishedVersion }) + .ToDictionaryAsync(type => type.Id, cancellationToken); + var versions = await context.ContentTypeVersions + .AsNoTracking() + .Where(version => ids.Contains(version.ContentTypeId) && bases.Contains(version.Version)) + .Select(version => new { version.ContentTypeId, version.Version, version.Id }) + .ToDictionaryAsync(version => (version.ContentTypeId, version.Version), version => version.Id, cancellationToken); + + var items = new List(); + var errors = new List(); + foreach (var operation in set.Operations) + { + // Types and versions are never deleted and an operation is checked against them when it is added. + var type = types.GetValueOrDefault(operation.ItemId) + ?? throw new InvalidOperationException($"Change set {set.Id} points at content type {operation.ItemId}, which is not stored."); + + if (type.PublishedVersion != operation.BaseVersion) + { + var published = type.PublishedVersion is { } current ? $"is published at version {current}" : "is not published"; + var based = operation.BaseVersion is { } number ? $"was based on version {number}" : "creates it"; + errors.Add(new Error(ChangeSetErrorCodes.BaseVersionStale, $"The content type '{type.Slug}' {published}, but this change set {based}.")); + continue; + } + + Guid? previousVersionId = operation.BaseVersion is { } baseVersion ? versions[(operation.ItemId, baseVersion)] : null; + items.Add(new PublishableItem(operation, type.Slug, previousVersionId)); + } + + return errors.Count == 0 ? Result>.Success(items) : Result>.Failure(errors); + } + + // Moves only from the base version, so a publish over the same item that committed first leaves nothing to move. + private async Task MovePublishedVersionAsync(ChangeOperation operation, CancellationToken cancellationToken) + { + var moved = await context.ContentTypes + .Where(type => type.Id == operation.ItemId && type.PublishedVersion == operation.BaseVersion) + .ExecuteUpdateAsync(setters => setters.SetProperty(type => type.PublishedVersion, operation.ProposedVersion), cancellationToken); + + return moved == 1; + } + private Task OpenSetOfSessionAsync(Initiator initiator, string sessionId, CancellationToken cancellationToken) { var (kind, initiatorId) = InitiatorKinds.Describe(initiator); @@ -522,4 +649,6 @@ private static Result Conflict(Guid id) } private sealed record StoredChangeSet(ChangeSet ChangeSet, int Revision); + + private sealed record PublishableItem(ChangeOperation Operation, string Slug, Guid? PreviousVersionId); } diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018225456_ContentTypePublishedVersion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018225456_ContentTypePublishedVersion.Designer.cs new file mode 100644 index 000000000..3254597df --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018225456_ContentTypePublishedVersion.Designer.cs @@ -0,0 +1,841 @@ +// +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("20261018225456_ContentTypePublishedVersion")] + partial class ContentTypePublishedVersion + { + /// + 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("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("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.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/20261018225456_ContentTypePublishedVersion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018225456_ContentTypePublishedVersion.cs new file mode 100644 index 000000000..9f5beac83 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018225456_ContentTypePublishedVersion.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class ContentTypePublishedVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PublishedVersion", + table: "ContentTypes", + type: "integer", + nullable: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_ContentTypes_PublishedVersion", + table: "ContentTypes", + sql: "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_ContentTypes_PublishedVersion", + table: "ContentTypes"); + + migrationBuilder.DropColumn( + name: "PublishedVersion", + table: "ContentTypes"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index b3262d0c7..4326add21 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -526,6 +526,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(128) .HasColumnType("character varying(128)"); + b.Property("PublishedVersion") + .HasColumnType("integer"); + b.Property("SiteId") .HasColumnType("uuid"); @@ -544,6 +547,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ContentTypes", null, t => { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); }); }); diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018225447_ContentTypePublishedVersion.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018225447_ContentTypePublishedVersion.Designer.cs new file mode 100644 index 000000000..2853f804b --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018225447_ContentTypePublishedVersion.Designer.cs @@ -0,0 +1,831 @@ +// +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("20261018225447_ContentTypePublishedVersion")] + partial class ContentTypePublishedVersion + { + /// + 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("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("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.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/20261018225447_ContentTypePublishedVersion.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018225447_ContentTypePublishedVersion.cs new file mode 100644 index 000000000..2c2819bfb --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018225447_ContentTypePublishedVersion.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class ContentTypePublishedVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PublishedVersion", + table: "ContentTypes", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddCheckConstraint( + name: "CK_ContentTypes_PublishedVersion", + table: "ContentTypes", + sql: "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "CK_ContentTypes_PublishedVersion", + table: "ContentTypes"); + + migrationBuilder.DropColumn( + name: "PublishedVersion", + table: "ContentTypes"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 2d5e44bd6..fc1bd1dc9 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -520,6 +520,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(128) .HasColumnType("TEXT"); + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + b.Property("SiteId") .HasColumnType("TEXT"); @@ -538,6 +541,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ContentTypes", null, t => { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); }); }); diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeConfiguration.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeConfiguration.cs index 3b1dfaae8..b34db2f92 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeConfiguration.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeConfiguration.cs @@ -9,7 +9,11 @@ internal sealed class ContentTypeConfiguration : IEntityTypeConfiguration row) { - row.ToTable("ContentTypes", table => table.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1")); + row.ToTable("ContentTypes", table => + { + table.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + table.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + }); row.HasKey(type => type.Id); row.Property(type => type.Id).ValueGeneratedNever(); row.Property(type => type.Name).HasMaxLength(ContentType.MaxNameLength).IsRequired(); diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeRow.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeRow.cs index 0963666b6..371532f3d 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeRow.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeRow.cs @@ -1,6 +1,7 @@ namespace FluentCMS.Infrastructure.Schema; -// A content type and the number of its current version; each version's definition is its own ContentTypeVersionRow. +// A content type, the number of its current version and the number of its published one; each version's definition is its own +// ContentTypeVersionRow. public sealed class ContentTypeRow { public Guid Id { get; set; } @@ -12,4 +13,8 @@ public sealed class ContentTypeRow public string Slug { get; set; } = string.Empty; public int Version { get; set; } + + // Moved only by publishing a change set (CHG-05); null until one publishes the type. Writes still move Version at once + // until schema changes wait for publish (SCH-05). + public int? PublishedVersion { get; set; } } diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs index a525d2754..2b27ce445 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs @@ -31,11 +31,12 @@ public sealed class AgentPermissionTests public async Task An_agent_makes_no_move_but_submit_and_reopen_and_each_refusal_is_logged(string provider) { await using var scenario = await ChangeSetScenario.CreateAsync(provider); - var product = await scenario.ContentTypeAsync("product", revised: true); + var agentMoves = ChangeSetTransitions.All.Where(transition => transition.To is not (ChangeSetState.ChecksRunning or ChangeSetState.Open)); - foreach (var (from, to) in ChangeSetTransitions.All.Where(transition => transition.To is not (ChangeSetState.ChecksRunning or ChangeSetState.Open))) + foreach (var ((from, to), index) in agentMoves.Select((transition, index) => (transition, index))) { - var changeSet = await scenario.OpenAsync(Update(product)); + // A new type each time, so a set that reaches published has no earlier publish of its item to be stale against. + var changeSet = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync($"type-{index}"))); foreach (var state in PathFromOpen[from]) { Assert.True((await MoveAsync(scenario, changeSet.Id, state)).IsSuccess); diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs index d9e0b5a8d..a53c3c692 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs @@ -139,11 +139,11 @@ public async Task An_operation_that_does_not_point_at_stored_versions_fails_with public async Task Every_transition_in_the_table_is_stored_with_its_event(string provider) { await using var scenario = await ChangeSetScenario.CreateAsync(provider); - var product = await scenario.ContentTypeAsync("product", revised: true); - foreach (var (from, to) in ChangeSetTransitions.All) + foreach (var ((from, to), index) in ChangeSetTransitions.All.Select((transition, index) => (transition, index))) { - var changeSet = await scenario.OpenAsync(Update(product)); + // A new type each time, so a set that reaches published has no earlier publish of its item to be stale against. + var changeSet = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync($"type-{index}"))); foreach (var state in PathFromOpen[from]) { Assert.True((await MoveAsync(scenario, changeSet.Id, state)).IsSuccess); @@ -348,8 +348,7 @@ public async Task Of_two_concurrent_transitions_one_wins_and_the_other_gets_a_co await MoveAsync(scenario, changeSet.Id, ChangeSetState.ChecksRunning); scenario.Initiator = Amir; - var lost = await RaceAsync( - scenario, + var lost = await scenario.RaceAsync( directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.AwaitingReview), directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.ChecksFailed)); @@ -366,8 +365,7 @@ public async Task Of_two_concurrent_first_operations_in_a_session_one_opens_the_ await using var scenario = await ChangeSetScenario.CreateAsync(provider); var category = await scenario.ContentTypeAsync("category"); - var lost = await RaceAsync( - scenario, + var lost = await scenario.RaceAsync( directory => directory.AddToSessionAsync("example-session", CatalogSync, Create(category)), directory => directory.AddToSessionAsync("example-session", CatalogSync, Create(category))); @@ -386,22 +384,4 @@ private static Task> MoveAsync(ChangeSetScenario scenario, Gui return state is ChangeSetState.ChecksRunning or ChangeSetState.Open ? scenario.RunAsync(move) : scenario.AsPersonAsync(move); } - - private static async Task> RaceAsync( - ChangeSetScenario scenario, - Func>> winner, - Func>> loser) - { - await using var scope = scenario.Services.CreateAsyncScope(); - var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); - - Assert.True((await winner(scope.ServiceProvider.GetRequiredService())).IsSuccess); - - var second = Task.Run(() => scenario.RunAsync(loser)); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - await work.CommitAsync(); - await work.DisposeAsync(); - - return await second; - } } diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetPublishTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetPublishTests.cs new file mode 100644 index 000000000..48c7e6e56 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetPublishTests.cs @@ -0,0 +1,287 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Persistence; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; + +namespace FluentCMS.IntegrationTests.ChangeSets; + +public sealed class ChangeSetPublishTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Publishing_an_approved_set_moves_every_item_and_records_its_provenance(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + var productVersions = await scenario.QueryAsync(context => context.ContentTypeVersions + .AsNoTracking() + .Where(version => version.ContentTypeId == product.Id) + .OrderBy(version => version.Version) + .Select(version => version.Id) + .ToListAsync()); + + // Moving a set to published publishes it like PublishAsync does. + var first = await scenario.ApproveAsync(Create(product)); + Assert.True((await scenario.RunAsync(directory => directory.TransitionAsync(first.Id, ChangeSetState.Published))).IsSuccess); + Assert.Equal(1, await scenario.PublishedVersionAsync(product.Id)); + + var changeSet = await scenario.ApproveAsync(Create(category), Update(product)); + var before = (await scenario.ChangeSetEventsAsync(changeSet.Id)).Count; + + var published = await scenario.RunAsync(directory => directory.PublishAsync(changeSet.Id)); + + Assert.True(published.IsSuccess, string.Join("; ", published.Errors.Select(error => error.Message))); + Assert.Equal(ChangeSetState.Published, published.Value.State); + Assert.Equal(ChangeSetState.Published, (await scenario.ChangeSetRowAsync(changeSet.Id)).State); + Assert.Equal(1, await scenario.PublishedVersionAsync(category.Id)); + Assert.Equal(2, await scenario.PublishedVersionAsync(product.Id)); + + var events = (await scenario.ChangeSetEventsAsync(changeSet.Id)).Skip(before).ToList(); + Assert.Equal(["content_type.published", "content_type.published", "change_set.published"], events.Select(row => row.Operation)); + Assert.Equal(["content_type", "content_type", "change_set"], events.Select(row => row.SubjectType)); + Assert.Equal([category.Id, product.Id, changeSet.Id], events.Select(row => row.SubjectId)); + Assert.Equal([null, productVersions[0], null], events.Select(row => row.PreviousVersionId)); + Assert.All(events, row => Assert.Equal(changeSet.Id, row.ChangeSetId)); + Assert.All(events, row => Assert.Equal(InitiatorKinds.User, row.InitiatorKind)); + Assert.All(events, row => Assert.Equal(Amir.UserId, row.InitiatorId)); + Assert.All(events.Take(2), row => Assert.Equal(CatalogSync.Sources, row.Sources)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_published_delete_clears_the_published_version(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var product = await scenario.ContentTypeAsync("product"); + await scenario.PublishAsync(Create(product)); + var publishedRow = await scenario.QueryAsync(context => context.ContentTypeVersions + .AsNoTracking() + .Where(version => version.ContentTypeId == product.Id && version.Version == 1) + .Select(version => version.Id) + .SingleAsync()); + + var deleted = await scenario.PublishAsync(Delete(product, 1)); + + Assert.Null(await scenario.PublishedVersionAsync(product.Id)); + var unpublished = (await scenario.ChangeSetEventsAsync(deleted.Id))[^2]; + Assert.Equal("content_type.unpublished", unpublished.Operation); + Assert.Equal(product.Id, unpublished.SubjectId); + Assert.Equal(publishedRow, unpublished.PreviousVersionId); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Only_an_approved_set_publishes(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product"); + var open = await scenario.OpenAsync(Create(product)); + var reviewed = await scenario.OpenAsync(Create(product)); + await scenario.RunAsync(directory => directory.TransitionAsync(reviewed.Id, ChangeSetState.ChecksRunning)); + await scenario.RunAsync(directory => directory.TransitionAsync(reviewed.Id, ChangeSetState.AwaitingReview)); + var published = await scenario.PublishAsync(Create(category)); + var events = (await scenario.ChangeSetEventsAsync()).Count; + + var fromOpen = await scenario.RunAsync(directory => directory.PublishAsync(open.Id)); + var fromReview = await scenario.RunAsync(directory => directory.PublishAsync(reviewed.Id)); + var again = await scenario.RunAsync(directory => directory.PublishAsync(published.Id)); + var unknown = await scenario.RunAsync(directory => directory.PublishAsync(Guid.NewGuid())); + + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(fromOpen.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(fromReview.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(again.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Assert.Single(unknown.Errors).Code); + Assert.Null(await scenario.PublishedVersionAsync(product.Id)); + Assert.Equal(ChangeSetState.Open, (await scenario.ChangeSetRowAsync(open.Id)).State); + Assert.Equal(ChangeSetState.AwaitingReview, (await scenario.ChangeSetRowAsync(reviewed.Id)).State); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_whose_items_moved_past_their_base_versions_is_refused_and_writes_nothing(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var product = await scenario.ContentTypeAsync("product", revised: true); + var category = await scenario.ContentTypeAsync("category", revised: true); + await scenario.PublishAsync(Create(product)); + var stale = await scenario.ApproveAsync(Update(product), Update(category)); + var recreate = await scenario.ApproveAsync(Create(product)); + await scenario.PublishAsync(Update(product)); + var revision = (await scenario.ChangeSetRowAsync(stale.Id)).Revision; + var events = (await scenario.ChangeSetEventsAsync()).Count; + + var refused = await scenario.RunAsync(directory => directory.PublishAsync(stale.Id)); + var recreated = await scenario.RunAsync(directory => directory.PublishAsync(recreate.Id)); + + Assert.All(refused.Errors.Concat(recreated.Errors), error => Assert.Equal(ChangeSetErrorCodes.BaseVersionStale, error.Code)); + Assert.Equal( + [ + "The content type 'product' is published at version 2, but this change set was based on version 1.", + "The content type 'category' is not published, but this change set was based on version 1.", + ], + refused.Errors.Select(error => error.Message)); + Assert.Equal( + "The content type 'product' is published at version 2, but this change set creates it.", + Assert.Single(recreated.Errors).Message); + + Assert.Equal(2, await scenario.PublishedVersionAsync(product.Id)); + Assert.Null(await scenario.PublishedVersionAsync(category.Id)); + var after = await scenario.ChangeSetRowAsync(stale.Id); + Assert.Equal(ChangeSetState.Approved, after.State); + Assert.Equal(revision, after.Revision); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + + // A set based on the version published now goes through. + var next = await scenario.ReviseAsync(product); + await scenario.PublishAsync(Update(next, 2, 3)); + Assert.Equal(3, await scenario.PublishedVersionAsync(product.Id)); + } + + // The publish appends one event per item and then the set's, each after the first item's version moved in the transaction. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_failure_mid_publish_leaves_nothing_published_and_the_set_approved(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + await scenario.PublishAsync(Create(product)); + var changeSet = await scenario.ApproveAsync(Create(category), Update(product)); + var revision = (await scenario.ChangeSetRowAsync(changeSet.Id)).Revision; + var events = (await scenario.ChangeSetEventsAsync()).Count; + + async Task AssertNothingPublishedAsync() + { + Assert.Null(await scenario.PublishedVersionAsync(category.Id)); + Assert.Equal(1, await scenario.PublishedVersionAsync(product.Id)); + var stored = await scenario.ChangeSetRowAsync(changeSet.Id); + Assert.Equal(ChangeSetState.Approved, stored.State); + Assert.Equal(revision, stored.Revision); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + } + + for (var append = 1; append <= 3; append++) + { + scenario.FailOnAppend(append); + await Assert.ThrowsAsync(() => scenario.RunAsync(directory => directory.PublishAsync(changeSet.Id))); + await AssertNothingPublishedAsync(); + } + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + scenario.FailOnAppend(3); + await Assert.ThrowsAsync( + () => scope.ServiceProvider.GetRequiredService().PublishAsync(changeSet.Id)); + + // Inside the still open transaction every version has moved, so the failure came after they reached the database. + Assert.Equal( + [1, 2], + await context.ContentTypes + .AsNoTracking() + .Where(row => row.Id == category.Id || row.Id == product.Id) + .OrderBy(row => row.Slug) + .Select(row => row.PublishedVersion) + .ToListAsync()); + } + + await AssertNothingPublishedAsync(); + Assert.True((await scenario.RunAsync(directory => directory.PublishAsync(changeSet.Id))).IsSuccess); + Assert.Equal(1, await scenario.PublishedVersionAsync(category.Id)); + Assert.Equal(2, await scenario.PublishedVersionAsync(product.Id)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_two_concurrent_publishes_over_one_item_one_wins_and_the_other_is_stale(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var product = await scenario.ContentTypeAsync("product", revised: true); + await scenario.PublishAsync(Create(product)); + var winner = await scenario.ApproveAsync(Update(product)); + var loser = await scenario.ApproveAsync(Update(product)); + var loserEvents = (await scenario.ChangeSetEventsAsync(loser.Id)).Count; + + var lost = await scenario.RaceAsync( + directory => directory.PublishAsync(winner.Id), + directory => directory.PublishAsync(loser.Id)); + + Assert.Equal(ChangeSetErrorCodes.BaseVersionStale, Assert.Single(lost.Errors).Code); + Assert.Equal(2, await scenario.PublishedVersionAsync(product.Id)); + Assert.Equal(ChangeSetState.Published, (await scenario.ChangeSetRowAsync(winner.Id)).State); + Assert.Equal(ChangeSetState.Approved, (await scenario.ChangeSetRowAsync(loser.Id)).State); + Assert.Equal(loserEvents, (await scenario.ChangeSetEventsAsync(loser.Id)).Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_two_concurrent_publishes_of_one_set_one_wins_and_the_other_gets_a_conflict(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var product = await scenario.ContentTypeAsync("product"); + var changeSet = await scenario.ApproveAsync(Create(product)); + + var lost = await scenario.RaceAsync( + directory => directory.PublishAsync(changeSet.Id), + directory => directory.PublishAsync(changeSet.Id)); + + Assert.Equal(ChangeSetErrorCodes.Conflict, Assert.Single(lost.Errors).Code); + Assert.Equal(1, await scenario.PublishedVersionAsync(product.Id)); + var operations = (await scenario.ChangeSetEventsAsync(changeSet.Id)).Select(row => row.Operation).ToList(); + Assert.Single(operations, operation => operation == "content_type.published"); + Assert.Single(operations, operation => operation == "change_set.published"); + } + + // Expected failures are found before the directory opens its own unit of work, so they never doom the caller's. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_refused_publish_leaves_the_callers_unit_of_work_able_to_commit(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var product = await scenario.ContentTypeAsync("product", revised: true); + var stale = await scenario.ApproveAsync(Update(product)); + Guid id; + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var directory = scope.ServiceProvider.GetRequiredService(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + id = (await directory.OpenAsync(CatalogSync)).Value.Id; + var refusedStale = await directory.PublishAsync(stale.Id); + var refusedOpen = await directory.PublishAsync(id); + + Assert.Equal(ChangeSetErrorCodes.BaseVersionStale, Assert.Single(refusedStale.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.TransitionNotAllowed, Assert.Single(refusedOpen.Errors).Code); + await work.CommitAsync(); + } + + Assert.Equal(ChangeSetState.Open, (await scenario.ChangeSetRowAsync(id)).State); + Assert.Equal(ChangeSetState.Approved, (await scenario.ChangeSetRowAsync(stale.Id)).State); + Assert.Null(await scenario.PublishedVersionAsync(product.Id)); + } + + // Approving 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; + } +} \ No newline at end of file diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index ef4c1dc93..666ff8e39 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -2,6 +2,8 @@ using FluentCMS.Core.Agents; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Agents; @@ -25,13 +27,20 @@ internal sealed class ChangeSetScenario : IAsyncDisposable private readonly TemporaryDatabase _database; private readonly SwitchableInitiator _initiator; private readonly TickingClock _clock; - - private ChangeSetScenario(TemporaryDatabase database, ServiceProvider services, SwitchableInitiator initiator, TickingClock clock) + private readonly AuditFailure _auditFailure; + + private ChangeSetScenario( + TemporaryDatabase database, + ServiceProvider services, + SwitchableInitiator initiator, + TickingClock clock, + AuditFailure auditFailure) { _database = database; Services = services; _initiator = initiator; _clock = clock; + _auditFailure = auditFailure; } public ServiceProvider Services { get; } @@ -47,14 +56,16 @@ public static async Task CreateAsync(string provider) var database = await TemporaryDatabase.CreateAsync(provider); var initiator = new SwitchableInitiator { Current = Agent }; var clock = new TickingClock(); + var auditFailure = new AuditFailure(); var services = TestServices.Build( database.Configuration, collection => collection .AddSingleton(initiator) - .AddSingleton(clock)); + .AddSingleton(clock), + collection => FailingAuditLog.Wrap(collection, auditFailure)); await TestServices.MigrateAsync(services); - var scenario = new ChangeSetScenario(database, services, initiator, clock); + var scenario = new ChangeSetScenario(database, services, initiator, clock, auditFailure); await scenario.QueryAsync(async context => { context.AgentCredentials.Add(AgentCredential()); @@ -94,6 +105,12 @@ public Task UpdateAgentAsync(Action change) }); } + // The given audit append from now on throws, counting from one. + public void FailOnAppend(int append) + { + _auditFailure.FailOnAppend(append); + } + // Each call gets its own scope and DbContext, as separate requests would. public async Task RunAsync(Func> action) { @@ -101,6 +118,24 @@ public async Task RunAsync(Func> action) return await action(scope.ServiceProvider.GetRequiredService()); } + // The loser reads while the winner has not committed, so its own checks pass and the database has the last word. + public async Task> RaceAsync( + Func>> winner, + Func>> loser) + { + await using var scope = Services.CreateAsyncScope(); + var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + Assert.True((await winner(scope.ServiceProvider.GetRequiredService())).IsSuccess); + + var second = Task.Run(() => RunAsync(loser)); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + await work.CommitAsync(); + await work.DisposeAsync(); + + return await second; + } + // A content type at version 1, or at version 2 when revised, for operations to point at. public async Task ContentTypeAsync(string slug, bool revised = false) { @@ -115,6 +150,16 @@ public async Task ContentTypeAsync(string slug, bool revised = fals : type; } + // The next version of a type, with one more field. + public async Task ReviseAsync(ContentType type) + { + await using var scope = Services.CreateAsyncScope(); + var directory = scope.ServiceProvider.GetRequiredService(); + var definition = new ContentTypeDefinition([.. type.Definition.Fields, new FieldDefinition($"note{type.Version + 1}", "text")]); + + return (await directory.ReviseAsync(type.Slug, type.Version, definition)).Value; + } + public async Task OpenAsync(params ChangeOperation[] operations) { var changeSet = (await RunAsync(directory => directory.OpenAsync(CatalogSync))).Value; @@ -126,6 +171,34 @@ public async Task OpenAsync(params ChangeOperation[] operations) return changeSet; } + // 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 AsPersonAsync(directory => directory.TransitionAsync(changeSet.Id, 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)); + Assert.True(published.IsSuccess, string.Join("; ", published.Errors.Select(error => error.Message))); + + return published.Value; + } + + public Task PublishedVersionAsync(Guid contentTypeId) + { + return QueryAsync(context => context.ContentTypes + .AsNoTracking() + .Where(row => row.Id == contentTypeId) + .Select(row => row.PublishedVersion) + .SingleAsync()); + } + public Task ChangeSetRowAsync(Guid id) { return QueryAsync(context => context.ChangeSets.AsNoTracking().SingleAsync(row => row.Id == id)); @@ -145,7 +218,7 @@ public Task> OperationRowsAsync(Guid changeSetId) .ToListAsync()); } - // Change set events only, in the order they were appended; content type events are left out. + // Events that carry a change set id, in the order they were appended; the content type directory's own events are left out. public async Task> ChangeSetEventsAsync(Guid? changeSetId = null) { var rows = await QueryAsync(context => context.AuditEvents @@ -178,6 +251,16 @@ public static ChangeOperation Create(ContentType type) return ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value; } + public static ChangeOperation Update(ContentType type, int baseVersion, int proposedVersion) + { + return ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, type.Id, baseVersion, proposedVersion).Value; + } + + public static ChangeOperation Delete(ContentType type, int baseVersion) + { + return ChangeOperation.Create(OperationKind.Delete, ItemKind.Schema, type.Id, baseVersion, null).Value; + } + // The stored credential behind Agent, scoped to the site with room to spare, so only a test that lowers a cap meets one. private static AgentCredentialRow AgentCredential() { diff --git a/tests/FluentCMS.IntegrationTests/FailingAuditLog.cs b/tests/FluentCMS.IntegrationTests/FailingAuditLog.cs new file mode 100644 index 000000000..ec9ba1ac1 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/FailingAuditLog.cs @@ -0,0 +1,41 @@ +using FluentCMS.Core.Audit; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests; + +// Wraps the registered audit log and throws on an armed append, to stand for a failure partway through a unit of work. +internal sealed class FailingAuditLog(IAuditLog inner, AuditFailure failure) : IAuditLog +{ + public Task AppendAsync(NewAuditEvent entry, CancellationToken cancellationToken = default) + { + return failure.Trips() + ? throw new InvalidOperationException($"Injected failure while appending {entry.Operation}.") + : inner.AppendAsync(entry, cancellationToken); + } + + public static void Wrap(IServiceCollection services, AuditFailure failure) + { + var registered = services.Single(descriptor => descriptor.ServiceType == typeof(IAuditLog)); + services.Remove(registered); + services.AddScoped(provider => new FailingAuditLog( + (IAuditLog)ActivatorUtilities.CreateInstance(provider, registered.ImplementationType!), + failure)); + } +} + +internal sealed class AuditFailure +{ + private int _remaining; + + // The given append from now on throws, counting from one; every other append goes through. + public void FailOnAppend(int append) + { + ArgumentOutOfRangeException.ThrowIfLessThan(append, 1); + _remaining = append; + } + + public bool Trips() + { + return _remaining > 0 && --_remaining == 0; + } +} diff --git a/tests/FluentCMS.IntegrationTests/TestServices.cs b/tests/FluentCMS.IntegrationTests/TestServices.cs index 394f5ceb9..cac99f2f8 100644 --- a/tests/FluentCMS.IntegrationTests/TestServices.cs +++ b/tests/FluentCMS.IntegrationTests/TestServices.cs @@ -9,7 +9,10 @@ namespace FluentCMS.IntegrationTests; internal static class TestServices { - public static ServiceProvider Build(IEnumerable> settings, Action? configure = null) + public static ServiceProvider Build( + IEnumerable> settings, + Action? configure = null, + Action? replace = null) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); var services = new ServiceCollection(); @@ -20,6 +23,9 @@ public static ServiceProvider Build(IEnumerable> s services.AddFluentCmsCore(); services.AddFluentCmsInfrastructure(configuration); + // Runs last, to wrap or swap what Infrastructure registered. + replace?.Invoke(services); + return services.BuildServiceProvider(); }