From b5ee1730341e8b48a30ea00b05a9e390e424ee17 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:42:17 -0400 Subject: [PATCH 01/23] Record content type writes as change set operations --- src/FluentCMS.Core/ChangeSets/ChangeSet.cs | 66 ++++- .../ChangeSets/ChangeSetTarget.cs | 33 +++ .../Schema/ContentTypeSummary.cs | 3 +- .../Schema/IContentTypeDirectory.cs | 16 +- src/FluentCMS.Core/Schema/SchemaErrorCodes.cs | 1 + .../ChangeSets/ChangeSetDirectory.cs | 129 ++++++++ ...frastructureServiceCollectionExtensions.cs | 4 +- .../Schema/ContentTypeDirectory.cs | 116 ++++++-- .../ChangeSets/ChangeSetScenario.cs | 58 +++- .../ChangeSets/SchemaChangeSetTests.cs | 275 ++++++++++++++++++ .../Mcp/BuiltInToolsTests.cs | 6 +- .../Schema/ContentTypeDirectoryTests.cs | 33 ++- .../Schema/SchemaScenario.cs | 21 +- .../TestChangeSetTargets.cs | 13 + .../ChangeSets/ChangeSetTests.cs | 53 ++++ 15 files changed, 752 insertions(+), 75 deletions(-) create mode 100644 src/FluentCMS.Core/ChangeSets/ChangeSetTarget.cs create mode 100644 tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/TestChangeSetTargets.cs diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index 7b4ff77dd..c1f794780 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -162,18 +162,10 @@ public Result AddOperation(ChangeOperation operation) { ArgumentNullException.ThrowIfNull(operation); - if (State != ChangeSetState.Open) - { - return Result.Failure( - ChangeSetErrorCodes.NotOpen, - $"Operations are added while a change set is open, and this one is {Describe(State)}."); - } - - if (RevertsChangeSetId is { } reverted) + var writable = CanTakeOperations(); + if (!writable.IsSuccess) { - return Result.Failure( - ChangeSetErrorCodes.RevertFixed, - $"This change set reverts change set {reverted} and holds exactly what undoes it, so no operation can be added."); + return writable; } // One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06). @@ -188,6 +180,39 @@ public Result AddOperation(ChangeOperation operation) return Result.Success(); } + // A write of an item adds its operation, or extends the one the set already holds for the item, so the set still has one + // operation per item (SCH-05): the write is based on the version the set proposes, and the operation now proposes the write's + // version. A create stays a create and an update keeps its base. + public Result Record(ChangeOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + var index = _operations.FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId); + if (index < 0) + { + return AddOperation(operation); + } + + var writable = CanTakeOperations(); + if (!writable.IsSuccess) + { + return writable; + } + + var current = _operations[index]; + if (operation.Kind != OperationKind.Update || current.ProposedVersion is not { } proposed || operation.BaseVersion != proposed) + { + var proposes = current.ProposedVersion is { } version ? $"version {version}" : "its deletion"; + return Result.Failure( + ChangeSetErrorCodes.ItemDuplicate, + $"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set, which proposes {proposes}; " + + "a later write of it in this set is an update based on that version."); + } + + _operations[index] = ChangeOperation.Create(current.Kind, current.ItemKind, current.ItemId, current.BaseVersion, operation.ProposedVersion).Value; + return Result.Success(); + } + public Result TransitionTo(ChangeSetState target) { var allowed = CanMoveTo(target); @@ -228,6 +253,25 @@ public Result Reject(string reason) return Result.Success(); } + private Result CanTakeOperations() + { + if (State != ChangeSetState.Open) + { + return Result.Failure( + ChangeSetErrorCodes.NotOpen, + $"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."); + } + + return Result.Success(); + } + private Result CanMoveTo(ChangeSetState target) { if (!ChangeSetTransitions.IsAllowed(State, target)) diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetTarget.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetTarget.cs new file mode 100644 index 000000000..5ec76e648 --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetTarget.cs @@ -0,0 +1,33 @@ +namespace FluentCMS.Core.ChangeSets; + +// The change set a write of an item joins (SCH-05): one the caller opened, or the open set of the caller's session, which the +// first write opens with the given rationale and sources (CHG-02). +public sealed record ChangeSetTarget +{ + private ChangeSetTarget(Guid? changeSetId, string? sessionId, NewChangeSet? newChangeSet) + { + ChangeSetId = changeSetId; + SessionId = sessionId; + NewChangeSet = newChangeSet; + } + + public Guid? ChangeSetId { get; } + + public string? SessionId { get; } + + // The rationale and sources of the set a session's first write opens; later writes add any new sources to it. + public NewChangeSet? NewChangeSet { get; } + + public static ChangeSetTarget Existing(Guid changeSetId) + { + return new ChangeSetTarget(changeSetId, null, null); + } + + public static ChangeSetTarget Session(string sessionId, NewChangeSet changeSet) + { + ArgumentNullException.ThrowIfNull(sessionId); + ArgumentNullException.ThrowIfNull(changeSet); + + return new ChangeSetTarget(null, sessionId, changeSet); + } +} diff --git a/src/FluentCMS.Core/Schema/ContentTypeSummary.cs b/src/FluentCMS.Core/Schema/ContentTypeSummary.cs index fdd31765a..d5b0e1f10 100644 --- a/src/FluentCMS.Core/Schema/ContentTypeSummary.cs +++ b/src/FluentCMS.Core/Schema/ContentTypeSummary.cs @@ -1,3 +1,4 @@ namespace FluentCMS.Core.Schema; -public sealed record ContentTypeSummary(Guid Id, string Name, string Slug, int Version); +// Version is the latest version written; PublishedVersion is the live one, null until a change set publishes the type (SCH-05). +public sealed record ContentTypeSummary(Guid Id, string Name, string Slug, int Version, int? PublishedVersion); diff --git a/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs b/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs index daba41a07..4917443df 100644 --- a/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs +++ b/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs @@ -1,23 +1,33 @@ +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Results; namespace FluentCMS.Core.Schema; -// The stored content types of the site (SCH-01, SCH-07). Every write appends its audit event in the same unit of work (AUD-N1). +// The stored content types of the site (SCH-01, SCH-07). Every write stores a new version and records it as an operation of +// the change set it names, together with their audit events in one unit of work (SCH-05, AUD-N1). A write never changes what +// is live: only publishing its change set moves the published version. public interface IContentTypeDirectory { + // The latest version of every type, with the version that is live. Task> ListAsync(CancellationToken cancellationToken = default); + // The latest version, which authoring builds on: entries are checked against it and the schema document describes it. Task> GetAsync(string slug, CancellationToken cancellationToken = default); + // The live version, which delivery reads. + Task> GetPublishedAsync(string slug, CancellationToken cancellationToken = default); + // Earlier versions stay readable after a revision. Task> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default); - Task> CreateAsync(NewContentType contentType, CancellationToken cancellationToken = default); + Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default); - // The caller names the version it read, so a revision never silently replaces one it has not seen. + // The caller names the version it read, which is the latest one or the published one, so a revision never silently replaces + // a version it has not seen and can leave an unpublished draft behind. Task> ReviseAsync( string slug, int baseVersion, ContentTypeDefinition definition, + ChangeSetTarget changeSet, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs index 342ce8800..fe1665b72 100644 --- a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs +++ b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs @@ -23,4 +23,5 @@ public static class SchemaErrorCodes public const string FieldReferenced = "field_referenced"; public const string DescriptionInvalid = "description_invalid"; public const string ValueInvalid = "value_invalid"; + public const string ContentTypeNotPublished = "content_type_not_published"; } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index f14645023..b465c55b9 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -280,6 +280,109 @@ public Task> RejectAsync(Guid changeSetId, string reason, Canc return MoveAsync(changeSetId, ChangeSetState.Rejected, set => set.Reject(reason), cancellationToken); } + // A write of an item in another directory that is also an operation of a change set, such as a content type revision + // (SCH-05), joins its set in two steps, so the item's rows and the operation commit together. PrepareAsync finds every + // expected failure before the caller's unit of work begins; WriteAsync runs inside it, with the row of the version the write + // was based on. + internal async Task> PrepareAsync(ChangeSetTarget target, ChangeOperation operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(operation); + + if (target.ChangeSetId is { } id) + { + var stored = await ReadAsync(id, cancellationToken); + return stored is null + ? Result.Failure(NotFound(id).Errors) + : await PrepareJoinAsync(stored, operation, [], cancellationToken); + } + + var sessionId = target.SessionId!; + var initiator = Initiator(); + var openId = await OpenSetOfSessionAsync(initiator, sessionId, cancellationToken); + if (openId is not null && await ReadAsync(openId.Value, cancellationToken) is { } open) + { + return await PrepareJoinAsync(open, operation, target.NewChangeSet!.Sources, cancellationToken); + } + + var opened = ChangeSet.Open(SiteId, target.NewChangeSet!.Rationale, target.NewChangeSet.Sources, initiator, sessionId); + if (!opened.IsSuccess) + { + return Result.Failure(opened.Errors); + } + + var set = opened.Value; + var recorded = set.Record(operation); + if (!recorded.IsSuccess) + { + return Result.Failure(recorded.Errors); + } + + var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken); + return allowed.IsSuccess + ? Result.Success(new PendingOperation(set, null, 0, Extends: false)) + : Result.Failure(allowed.Errors); + } + + // False when another call changed the set since PrepareAsync read it; the caller rolls back and answers with a conflict. + internal async Task WriteAsync(PendingOperation pending, Guid? previousVersionId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(pending); + + var set = pending.ChangeSet; + if (pending.Revision is not { } revision) + { + await InsertAsync(set, cancellationToken); + } + else if (!await UpdateAsync(revision, set, cancellationToken)) + { + return false; + } + + if (pending.Extends) + { + await ExtendOperationAsync(set, pending.Position, previousVersionId, cancellationToken); + } + else + { + await InsertOperationAsync(set, pending.Position, previousVersionId, cancellationToken); + } + + return true; + } + + private async Task> PrepareJoinAsync( + StoredChangeSet stored, + ChangeOperation operation, + IReadOnlyList sources, + CancellationToken cancellationToken) + { + var set = stored.ChangeSet; + var count = set.Operations.Count; + + var recorded = set.Record(operation); + if (!recorded.IsSuccess) + { + return Result.Failure(recorded.Errors); + } + + var sourcesAdded = set.AddSources(sources); + if (!sourcesAdded.IsSuccess) + { + return Result.Failure(sourcesAdded.Errors); + } + + // Checked against the read whose revision guards the write, as for AddOperationAsync. + var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var position = set.Operations.ToList().FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId); + return Result.Success(new PendingOperation(set, stored.Revision, position, Extends: set.Operations.Count == count)); + } + private async Task> AddAsync( StoredChangeSet stored, ChangeOperation operation, @@ -430,6 +533,28 @@ await auditLog.AppendAsync( cancellationToken); } + // A later write of an item already in the set moves its operation to the write's version, with the version the write was + // based on as the event's previous version (AUD-01). + private async Task ExtendOperationAsync(ChangeSet set, int position, Guid? previousVersionId, CancellationToken cancellationToken) + { + var operation = set.Operations[position]; + + await context.ChangeSetOperations + .Where(row => row.ChangeSetId == set.Id && row.Position == position) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.ProposedVersion, operation.ProposedVersion), cancellationToken); + + await auditLog.AppendAsync( + new NewAuditEvent("change_set.operation_revised") + { + SubjectType = ItemSubjectType(operation.ItemKind), + SubjectId = operation.ItemId, + ChangeSetId = set.Id, + Sources = set.Sources, + PreviousVersionId = previousVersionId, + }, + cancellationToken); + } + // Writes only over the revision this call read, so a concurrent writer that committed first leaves nothing to update. private async Task UpdateAsync(int revision, ChangeSet set, CancellationToken cancellationToken) { @@ -715,5 +840,9 @@ private static Result Conflict(Guid id) private sealed record StoredChangeSet(ChangeSet ChangeSet, int Revision); + // Revision is null for a set the write opens. Position is the operation's place in the set, and Extends says whether the write + // extends an operation the set already holds. + internal sealed record PendingOperation(ChangeSet ChangeSet, int? Revision, int Position, bool Extends); + private sealed record PublishableItem(ChangeOperation Operation, string Slug, Guid? PreviousVersionId); } diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 01500f36d..4dc5f5458 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -72,7 +72,9 @@ private static void AddSchema(IServiceCollection services) private static void AddChangeSets(IServiceCollection services) { - services.AddScoped(); + // The content type directory writes its operations through the same instance (SCH-05). + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); } private static void AddAgents(IServiceCollection services) diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index 60f1f399f..9a6e5eb11 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -1,8 +1,10 @@ using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Persistence; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.ChangeSets; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -13,7 +15,8 @@ internal sealed class ContentTypeDirectory( FluentCmsDbContext context, IUnitOfWork unitOfWork, IAuditLog auditLog, - FieldTypeRegistry fieldTypes) : IContentTypeDirectory + FieldTypeRegistry fieldTypes, + ChangeSetDirectory changeSets) : IContentTypeDirectory { private const string SubjectType = "content_type"; @@ -26,23 +29,29 @@ public async Task> ListAsync(CancellationToken .AsNoTracking() .Where(type => type.SiteId == SiteId) .OrderBy(type => type.Slug) - .Select(type => new ContentTypeSummary(type.Id, type.Name, type.Slug, type.Version)) + .Select(type => new ContentTypeSummary(type.Id, type.Name, type.Slug, type.Version, type.PublishedVersion)) .ToListAsync(cancellationToken); } public Task> GetAsync(string slug, CancellationToken cancellationToken = default) { - return ReadAsync(slug, version: null, cancellationToken); + return ReadAsync(slug, row => row.Version, cancellationToken); + } + + public Task> GetPublishedAsync(string slug, CancellationToken cancellationToken = default) + { + return ReadAsync(slug, row => row.PublishedVersion, cancellationToken); } public Task> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default) { - return ReadAsync(slug, version, cancellationToken); + return ReadAsync(slug, _ => version, cancellationToken); } - public async Task> CreateAsync(NewContentType contentType, CancellationToken cancellationToken = default) + public async Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(contentType); + ArgumentNullException.ThrowIfNull(changeSet); var created = ContentType.Create(SiteId, contentType.Name, contentType.Slug, contentType.Definition, fieldTypes); if (!created.IsSuccess) @@ -65,6 +74,12 @@ public async Task> CreateAsync(NewContentType contentType, C return Result.Failure(errors); } + var pending = await changeSets.PrepareAsync(changeSet, Operation(OperationKind.Create, type.Id, null, type.Version), cancellationToken); + if (!pending.IsSuccess) + { + return Result.Failure(pending.Errors); + } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); context.ContentTypes.Add(new ContentTypeRow @@ -76,19 +91,28 @@ public async Task> CreateAsync(NewContentType contentType, C Version = type.Version, }); context.ContentTypeVersions.Add(VersionRow(type)); - await auditLog.AppendAsync(new NewAuditEvent("content_type.created") { SubjectType = SubjectType, SubjectId = type.Id }, cancellationToken); + await auditLog.AppendAsync( + new NewAuditEvent("content_type.created") { SubjectType = SubjectType, SubjectId = type.Id, ChangeSetId = pending.Value.ChangeSet.Id }, + cancellationToken); - return await CommitAsync(scope, type, SlugTaken(type.Slug), cancellationToken); + if (!await changeSets.WriteAsync(pending.Value, null, cancellationToken)) + { + return Result.Failure([SetConflict(pending.Value)]); + } + + return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, SlugTaken(type.Slug)), cancellationToken); } public async Task> ReviseAsync( string slug, int baseVersion, ContentTypeDefinition definition, + ChangeSetTarget changeSet, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(slug); ArgumentNullException.ThrowIfNull(definition); + ArgumentNullException.ThrowIfNull(changeSet); var row = await FindAsync(slug, cancellationToken); if (row is null) @@ -96,16 +120,18 @@ public async Task> ReviseAsync( return NotFound(slug); } - if (row.Version != baseVersion) + // The published version is a base too, so a revision can leave an unpublished draft behind, such as a rejected one. + if (baseVersion != row.Version && baseVersion != row.PublishedVersion) { - return Result.Failure([VersionConflict(slug, baseVersion)]); + return Result.Failure([VersionConflict(row, baseVersion)]); } - var current = await context.ContentTypeVersions + var based = await context.ContentTypeVersions .AsNoTracking() - .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == row.Version, cancellationToken); + .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == baseVersion, cancellationToken); - var previous = ToContentType(row, current); + // Numbered after the latest version, whichever version the revision changes. + var previous = ContentType.Restore(row.Id, row.SiteId, row.Name, row.Slug, row.Version, ReadDefinition(row.Slug, based.Version, based.Definition)); var revised = previous.Revise(definition, fieldTypes); if (!revised.IsSuccess) { @@ -120,26 +146,43 @@ public async Task> ReviseAsync( return Result.Failure(errors); } + var pending = await changeSets.PrepareAsync(changeSet, Operation(OperationKind.Update, type.Id, baseVersion, type.Version), cancellationToken); + if (!pending.IsSuccess) + { + return Result.Failure(pending.Errors); + } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); - // Moves the pointer only from the version the caller read; a concurrent revision that committed first leaves nothing to move. + // Moves the pointer only from the latest version this call read; a concurrent revision that committed first leaves nothing to move. var moved = await context.ContentTypes - .Where(stored => stored.Id == row.Id && stored.Version == baseVersion) + .Where(stored => stored.Id == row.Id && stored.Version == row.Version) .ExecuteUpdateAsync(setters => setters.SetProperty(stored => stored.Version, type.Version), cancellationToken); if (moved == 0) { - return Result.Failure([VersionConflict(slug, baseVersion)]); + return Result.Failure([VersionConflict(row, baseVersion)]); } context.ContentTypeVersions.Add(VersionRow(type)); await auditLog.AppendAsync( - new NewAuditEvent("content_type.revised") { SubjectType = SubjectType, SubjectId = type.Id, PreviousVersionId = current.Id }, + new NewAuditEvent("content_type.revised") + { + SubjectType = SubjectType, + SubjectId = type.Id, + ChangeSetId = pending.Value.ChangeSet.Id, + PreviousVersionId = based.Id, + }, cancellationToken); - return await CommitAsync(scope, type, VersionConflict(slug, baseVersion), cancellationToken); + if (!await changeSets.WriteAsync(pending.Value, based.Id, cancellationToken)) + { + return Result.Failure([SetConflict(pending.Value)]); + } + + return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, VersionConflict(row, baseVersion)), cancellationToken); } - private async Task> ReadAsync(string slug, int? version, CancellationToken cancellationToken) + private async Task> ReadAsync(string slug, Func version, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(slug); @@ -149,7 +192,13 @@ private async Task> ReadAsync(string slug, int? version, Can return NotFound(slug); } - var number = version ?? row.Version; + if (version(row) is not { } number) + { + return Result.Failure( + SchemaErrorCodes.ContentTypeNotPublished, + $"No change set has published the content type '{slug}' yet; its latest version is {row.Version}."); + } + var stored = await context.ContentTypeVersions .AsNoTracking() .SingleOrDefaultAsync(candidate => candidate.ContentTypeId == row.Id && candidate.Version == number, cancellationToken); @@ -311,10 +360,35 @@ private static Error SlugTaken(string slug) return new Error(SchemaErrorCodes.SlugTaken, $"The slug '{slug}' is already used by another content type in this site."); } - private static Error VersionConflict(string slug, int baseVersion) + private static Error VersionConflict(ContentTypeRow row, int baseVersion) { + var published = row.PublishedVersion is { } version ? $" or the published version {version}" : string.Empty; + return new Error( SchemaErrorCodes.VersionConflict, - $"Version {baseVersion} is not the current version of the content type '{slug}'; read it again and revise the current version."); + $"Version {baseVersion} is not the latest version of the content type '{row.Slug}'{published}; read it again and revise one of those."); + } + + private static ChangeOperation Operation(OperationKind kind, Guid contentTypeId, int? baseVersion, int proposedVersion) + { + return ChangeOperation.Create(kind, ItemKind.Schema, contentTypeId, baseVersion, proposedVersion).Value; + } + + private static Error SetConflict(ChangeSetDirectory.PendingOperation pending) + { + return new Error( + ChangeSetErrorCodes.Conflict, + $"Change set {pending.ChangeSet.Id} was changed by another call at the same time; read it again and retry."); + } + + // A unique index at commit is this write's own conflict or, when the write opened its session's set, possibly a concurrent + // first write of the same session. + private static Error CommitConflict(ChangeSetTarget target, ChangeSetDirectory.PendingOperation pending, Error own) + { + return pending.Revision is null && target.SessionId is { } session + ? new Error( + ChangeSetErrorCodes.Conflict, + $"Another call wrote this content type or opened a change set for session '{session}' at the same time; read it again and retry.") + : own; } } diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index b30c0a215..0c959095c 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -136,28 +136,50 @@ public async Task> RaceAsync( 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) + // A content type at version 1, or at version 2 when revised, for operations to point at. A person writes it in the content + // model's own set, which no test publishes and the change set reads below leave out. + public Task ContentTypeAsync(string slug, bool revised = false) { - await using var scope = Services.CreateAsyncScope(); - var directory = scope.ServiceProvider.GetRequiredService(); - - var title = new FieldDefinition("title", "text"); - var type = (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([title])))).Value; + return AsSchemaAuthorAsync(async directory => + { + var title = new FieldDefinition("title", "text"); + var type = (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([title])), TestChangeSetTargets.ContentModel)).Value; - return revised - ? (await directory.ReviseAsync(slug, 1, new ContentTypeDefinition([title, new FieldDefinition("summary", "text")]))).Value - : type; + return revised + ? (await directory.ReviseAsync(slug, 1, new ContentTypeDefinition([title, new FieldDefinition("summary", "text")]), TestChangeSetTargets.ContentModel)).Value + : type; + }); } // The next version of a type, with one more field. - public async Task ReviseAsync(ContentType type) + public 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; + return AsSchemaAuthorAsync(async directory => + (await directory.ReviseAsync(type.Slug, type.Version, definition, TestChangeSetTargets.ContentModel)).Value); + } + + // The content type directory as whoever Initiator names, in a scope of its own. + public async Task RunSchemaAsync(Func> action) + { + await using var scope = Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + private async Task AsSchemaAuthorAsync(Func> action) + { + var initiator = Initiator; + Initiator = Amir; + try + { + await using var scope = Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + finally + { + Initiator = initiator; + } } public async Task OpenAsync(params ChangeOperation[] operations) @@ -215,7 +237,10 @@ public Task ChangeSetRowAsync(Guid id) public Task> ChangeSetRowsAsync() { - return QueryAsync(context => context.ChangeSets.AsNoTracking().ToListAsync()); + return QueryAsync(context => context.ChangeSets + .AsNoTracking() + .Where(row => row.SessionId != TestChangeSetTargets.ContentModelSession) + .ToListAsync()); } public Task> OperationRowsAsync(Guid changeSetId) @@ -227,12 +252,13 @@ public Task> OperationRowsAsync(Guid changeSetId) .ToListAsync()); } - // Events that carry a change set id, in the order they were appended; the content type directory's own events are left out. + // Events that carry a change set id, in the order they were appended; those of the content model's set are left out. public async Task> ChangeSetEventsAsync(Guid? changeSetId = null) { var rows = await QueryAsync(context => context.AuditEvents .AsNoTracking() .Where(row => row.ChangeSetId != null && (changeSetId == null || row.ChangeSetId == changeSetId)) + .Where(row => !context.ChangeSets.Any(set => set.Id == row.ChangeSetId && set.SessionId == TestChangeSetTargets.ContentModelSession)) .ToListAsync()); return [.. rows.OrderBy(row => row.Timestamp)]; diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs new file mode 100644 index 000000000..47821f6ed --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs @@ -0,0 +1,275 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; + +namespace FluentCMS.IntegrationTests.ChangeSets; + +// Content type writes as change set operations (SCH-05): each write and its operation commit together, and only publishing the +// set changes what is live. +public sealed class SchemaChangeSetTests +{ + private static readonly FieldDefinition Title = new("title", "text"); + + private static readonly NewContentType Product = new("Product", "product", new ContentTypeDefinition([Title])); + + private static readonly ContentTypeDefinition WithSummary = new([Title, new FieldDefinition("summary", "text")]); + + private static readonly ContentTypeDefinition WithPrice = new([Title, new FieldDefinition("price", "number")]); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_create_is_an_operation_of_its_set_and_is_live_only_once_the_set_publishes(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var changeSet = await scenario.OpenAsync(); + + var created = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Existing(changeSet.Id)))).Value; + + Assert.Equal([Create(created)], (await scenario.RunAsync(directory => directory.GetAsync(changeSet.Id))).Value.Operations); + var events = await scenario.ChangeSetEventsAsync(changeSet.Id); + Assert.Equal(["change_set.opened", "content_type.created", "change_set.operation_added"], events.Select(row => row.Operation)); + Assert.All(events.Skip(1), row => Assert.Equal(created.Id, row.SubjectId)); + + var summary = Assert.Single(await scenario.RunSchemaAsync(directory => directory.ListAsync())); + Assert.Equal((1, (int?)null), (summary.Version, summary.PublishedVersion)); + var live = await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product")); + Assert.Equal(SchemaErrorCodes.ContentTypeNotPublished, Assert.Single(live.Errors).Code); + + await scenario.PublishAsync(changeSet.Id); + + Assert.Equal(1, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value.Version); + Assert.Equal(1, Assert.Single(await scenario.RunSchemaAsync(directory => directory.ListAsync())).PublishedVersion); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_in_the_same_set_extends_the_items_operation(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var changeSet = await scenario.OpenAsync(); + var target = ChangeSetTarget.Existing(changeSet.Id); + var created = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, target))).Value; + + var revised = (await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, target))).Value; + + Assert.Equal(2, revised.Version); + var operation = ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, created.Id, null, 2).Value; + Assert.Equal([operation], (await scenario.RunAsync(directory => directory.GetAsync(changeSet.Id))).Value.Operations); + Assert.Equal(2, Assert.Single(await scenario.OperationRowsAsync(changeSet.Id)).ProposedVersion); + + var events = await scenario.ChangeSetEventsAsync(changeSet.Id); + Assert.Equal( + ["change_set.opened", "content_type.created", "change_set.operation_added", "content_type.revised", "change_set.operation_revised"], + events.Select(row => row.Operation)); + var firstVersion = await VersionRowAsync(scenario, created.Id, 1); + Assert.Equal([firstVersion, firstVersion], events.Skip(3).Select(row => row.PreviousVersionId)); + + var item = Assert.Single((await scenario.RunAsync(directory => directory.DiffAsync(changeSet.Id))).Value.Items); + Assert.Equal(["title", "summary"], item.Fields.Select(field => field.Name)); + Assert.All(item.Fields, field => Assert.Equal(ValueChange.Added, field.Change)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_of_a_published_type_is_live_only_once_its_set_publishes(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var created = await PublishedProductAsync(scenario); + var changeSet = await scenario.OpenAsync(); + + var revised = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, ChangeSetTarget.Existing(changeSet.Id))); + + Assert.Equal(2, revised.Value.Version); + Assert.Equal([Update(created, 1, 2)], (await scenario.RunAsync(directory => directory.GetAsync(changeSet.Id))).Value.Operations); + Assert.Equal(2, (await scenario.RunSchemaAsync(directory => directory.GetAsync("product"))).Value.Version); + Assert.Equal(1, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value.Version); + + await scenario.PublishAsync(changeSet.Id); + + var live = (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value; + Assert.Equal(2, live.Version); + Assert.True(live.Definition.HasField("summary")); + var previous = (await scenario.RunSchemaAsync(directory => directory.GetVersionAsync("product", 1))).Value; + Assert.False(previous.Definition.HasField("summary")); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_based_on_the_published_version_leaves_an_unpublished_draft_behind(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var created = await PublishedProductAsync(scenario); + var rejected = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, ChangeSetTarget.Existing(rejected.Id))); + await scenario.RunAsync(directory => directory.TransitionAsync(rejected.Id, ChangeSetState.ChecksRunning)); + await scenario.RunAsync(directory => directory.TransitionAsync(rejected.Id, ChangeSetState.AwaitingReview)); + await scenario.RunAsync(directory => directory.RejectAsync(rejected.Id, "The summary belongs on the page, not the product.")); + var changeSet = await scenario.OpenAsync(); + var target = ChangeSetTarget.Existing(changeSet.Id); + + var revised = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithPrice, target)); + var unknownBase = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 2, WithPrice, target)); + + Assert.Equal(3, revised.Value.Version); + Assert.Equal([Update(created, 1, 3)], (await scenario.RunAsync(directory => directory.GetAsync(changeSet.Id))).Value.Operations); + Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(unknownBase.Errors).Code); + + await scenario.PublishAsync(changeSet.Id); + + var live = (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value; + Assert.Equal(3, live.Version); + Assert.True(live.Definition.HasField("price")); + Assert.False(live.Definition.HasField("summary")); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Writes_through_a_session_share_the_set_their_first_write_opens(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var target = ChangeSetTarget.Session("schema-session", CatalogSync); + + var product = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, target))).Value; + var category = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Category", "category", new ContentTypeDefinition([Title])), target))).Value; + + var stored = Assert.Single(await scenario.ChangeSetRowsAsync(), row => row.SessionId == "schema-session"); + Assert.Equal(CatalogSync.Rationale, stored.Rationale); + Assert.Equal(Amir.UserId, stored.InitiatorId); + Assert.Equal([Create(product), Create(category)], (await scenario.RunAsync(directory => directory.GetAsync(stored.Id))).Value.Operations); + } + + // Expected failures are found before either directory opens its unit of work, so they never doom the caller's. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_refused_write_stores_nothing_and_leaves_the_callers_unit_of_work_able_to_commit(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var submitted = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.CreateAsync( + new NewContentType("Category", "category", new ContentTypeDefinition([Title])), + ChangeSetTarget.Existing(submitted.Id))); + await scenario.RunAsync(directory => directory.TransitionAsync(submitted.Id, ChangeSetState.ChecksRunning)); + var events = (await scenario.ChangeSetEventsAsync()).Count; + Guid opened; + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var directory = scope.ServiceProvider.GetRequiredService(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + opened = (await scope.ServiceProvider.GetRequiredService().OpenAsync(CatalogSync)).Value.Id; + var unknown = await directory.CreateAsync(Product, ChangeSetTarget.Existing(Guid.NewGuid())); + var notOpen = await directory.CreateAsync(Product, ChangeSetTarget.Existing(submitted.Id)); + var blank = await directory.CreateAsync(Product, ChangeSetTarget.Session("new-session", new NewChangeSet(" ", []))); + + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Assert.Single(unknown.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.NotOpen, Assert.Single(notOpen.Errors).Code); + Assert.Equal(ChangeSetErrorCodes.RationaleMissing, Assert.Single(blank.Errors).Code); + await work.CommitAsync(); + } + + Assert.Equal(ChangeSetState.Open, (await scenario.ChangeSetRowAsync(opened)).State); + Assert.DoesNotContain(await scenario.RunSchemaAsync(directory => directory.ListAsync()), type => type.Slug == "product"); + Assert.Single(await scenario.OperationRowsAsync(submitted.Id)); + Assert.Equal(events + 1, (await scenario.ChangeSetEventsAsync()).Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_writes_only_into_its_own_set_and_within_its_cap(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var persons = await scenario.AsPersonAsync(directory => directory.OpenAsync(CatalogSync)); + var own = await scenario.OpenAsync(); + await scenario.UpdateAgentAsync(row => row.ItemsPerChangeSet = 1); + + var intoPersons = await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Existing(persons.Value.Id))); + var first = await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Existing(own.Id))); + var pastCap = await scenario.RunSchemaAsync(directory => directory.CreateAsync( + new NewContentType("Category", "category", new ContentTypeDefinition([Title])), + ChangeSetTarget.Existing(own.Id))); + + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Assert.Single(intoPersons.Errors).Code); + Assert.True(first.IsSuccess); + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, Assert.Single(pastCap.Errors).Code); + Assert.Equal(["product"], (await scenario.RunSchemaAsync(directory => directory.ListAsync())).Select(type => type.Slug)); + Assert.Empty(await scenario.OperationRowsAsync(persons.Value.Id)); + Assert.Single(await scenario.OperationRowsAsync(own.Id)); + } + + // A revision moves the latest version and the set's revision before its events are appended, so each failure point comes + // after something reached the database. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_failure_mid_write_leaves_neither_the_version_nor_the_operation(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var changeSet = await scenario.OpenAsync(); + var target = ChangeSetTarget.Existing(changeSet.Id); + var created = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, target))).Value; + var revision = (await scenario.ChangeSetRowAsync(changeSet.Id)).Revision; + var events = (await scenario.ChangeSetEventsAsync()).Count; + + for (var append = 1; append <= 2; append++) + { + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + scenario.FailOnAppend(append); + await Assert.ThrowsAsync( + () => scope.ServiceProvider.GetRequiredService().ReviseAsync("product", 1, WithSummary, target)); + + Assert.Equal(2, await context.ContentTypes.AsNoTracking().Where(row => row.Id == created.Id).Select(row => row.Version).SingleAsync()); + } + + Assert.Equal(1, (await scenario.RunSchemaAsync(directory => directory.GetAsync("product"))).Value.Version); + Assert.Single(await scenario.QueryAsync(context => context.ContentTypeVersions.AsNoTracking().Where(row => row.ContentTypeId == created.Id).ToListAsync())); + Assert.Equal(1, Assert.Single(await scenario.OperationRowsAsync(changeSet.Id)).ProposedVersion); + Assert.Equal(revision, (await scenario.ChangeSetRowAsync(changeSet.Id)).Revision); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + } + + Assert.True((await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, target))).IsSuccess); + } + + // Content type writes here are a person's, who also makes the review moves (MCP-06). + private static async Task ScenarioAsync(string provider) + { + var scenario = await ChangeSetScenario.CreateAsync(provider); + scenario.Initiator = Amir; + return scenario; + } + + private static async Task PublishedProductAsync(ChangeSetScenario scenario) + { + var changeSet = await scenario.OpenAsync(); + var created = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Existing(changeSet.Id)))).Value; + await scenario.PublishAsync(changeSet.Id); + + return created; + } + + private static Task VersionRowAsync(ChangeSetScenario scenario, Guid contentTypeId, int version) + { + return scenario.QueryAsync(context => context.ContentTypeVersions + .AsNoTracking() + .Where(row => row.ContentTypeId == contentTypeId && row.Version == version) + .Select(row => (Guid?)row.Id) + .SingleAsync()); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs index 88efd5ec6..4a2a242d2 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs @@ -188,9 +188,9 @@ private static async Task CreateTypesAsync(AdminHost host, Guid adm return await host.AsUserAsync(administrator, async directory => { var title = new FieldDefinition("title", "text", required: true); - await directory.CreateAsync(new NewContentType("Category", "category", new ContentTypeDefinition([title]))); - await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([title]))); - return (await directory.ReviseAsync("product", 1, new ContentTypeDefinition([title, new FieldDefinition("price", "number")]))).Value; + await directory.CreateAsync(new NewContentType("Category", "category", new ContentTypeDefinition([title])), TestChangeSetTargets.ContentModel); + await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([title])), TestChangeSetTargets.ContentModel); + return (await directory.ReviseAsync("product", 1, new ContentTypeDefinition([title, new FieldDefinition("price", "number")]), TestChangeSetTargets.ContentModel)).Value; }); } diff --git a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs index 3101f6946..e770396cf 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs @@ -39,7 +39,7 @@ public async Task A_created_type_belongs_to_the_one_site_starts_at_version_one_a AssertSameDefinition(created.Definition, read.Definition); Assert.Equal( - [new ContentTypeSummary(created.Id, "Product", "product", 1)], + [new ContentTypeSummary(created.Id, "Product", "product", 1, null)], await scenario.RunAsync(directory => directory.ListAsync())); Assert.Equal(Site.Default.Id, Assert.Single(await scenario.ContentTypeRowsAsync()).SiteId); Assert.Equal( @@ -62,7 +62,7 @@ public async Task A_revision_stores_the_next_version_and_the_earlier_one_stays_r var original = (await scenario.CreateAsync("product", ProductFields)).Value; var revisedDefinition = new ContentTypeDefinition([.. ProductFields, Field("releasedOn", "dateTime", """{"dateOnly": true}""")]); - var revised = (await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, revisedDefinition))).Value; + var revised = (await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, revisedDefinition, TestChangeSetTargets.ContentModel))).Value; Assert.Equal(2, revised.Version); Assert.Equal(original.Id, revised.Id); @@ -123,7 +123,7 @@ public async Task A_reference_targets_a_stored_type_of_the_site_or_the_type_itse var revision = await scenario.RunAsync(directory => directory.ReviseAsync( "category", 1, - new ContentTypeDefinition([Field("name", "text"), References("regions", "region")]))); + new ContentTypeDefinition([Field("name", "text"), References("regions", "region")]), TestChangeSetTargets.ContentModel)); Assert.Equal(SchemaErrorCodes.ReferenceTargetUnknown, Assert.Single(revision.Errors).Code); await AssertStoredAsync(scenario, types: 3, versions: 3, events: 3); @@ -138,9 +138,9 @@ public async Task A_revision_starts_from_the_current_version(string provider) await scenario.CreateAsync("product", ProductFields); var definition = new ContentTypeDefinition([.. ProductFields, Field("summary", "text")]); - var first = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, definition)); - var stale = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, definition)); - var ahead = await scenario.RunAsync(directory => directory.ReviseAsync("product", 5, definition)); + var first = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, definition, TestChangeSetTargets.ContentModel)); + var stale = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, definition, TestChangeSetTargets.ContentModel)); + var ahead = await scenario.RunAsync(directory => directory.ReviseAsync("product", 5, definition, TestChangeSetTargets.ContentModel)); Assert.Equal(2, first.Value.Version); Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(stale.Errors).Code); @@ -157,7 +157,7 @@ public async Task Invalid_definitions_and_unknown_types_fail_without_writing(str var invalid = await scenario.CreateAsync("product", Field("price", "money"), Field("price", "number")); var unknownRead = await scenario.RunAsync(directory => directory.GetAsync("product")); - var unknownRevision = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([]))); + var unknownRevision = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([]), TestChangeSetTargets.ContentModel)); Assert.Equal([SchemaErrorCodes.FieldTypeUnknown, SchemaErrorCodes.FieldNameDuplicate], invalid.Errors.Select(error => error.Code)); Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Assert.Single(unknownRead.Errors).Code); @@ -165,7 +165,7 @@ public async Task Invalid_definitions_and_unknown_types_fail_without_writing(str Assert.Empty(await scenario.RunAsync(directory => directory.ListAsync())); await scenario.CreateAsync("product", ProductFields); - var invalidRevision = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Field("Name", "text")]))); + var invalidRevision = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Field("Name", "text")]), TestChangeSetTargets.ContentModel)); Assert.Equal(SchemaErrorCodes.FieldNameInvalid, Assert.Single(invalidRevision.Errors).Code); await AssertStoredAsync(scenario, types: 1, versions: 1, events: 1); @@ -183,7 +183,7 @@ public async Task A_create_in_an_abandoned_unit_of_work_leaves_neither_the_type_ await using (await scope.ServiceProvider.GetRequiredService().BeginAsync()) { var created = await scope.ServiceProvider.GetRequiredService() - .CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields))); + .CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields)), TestChangeSetTargets.ContentModel); Assert.True(created.IsSuccess); } } @@ -204,9 +204,9 @@ public async Task A_failed_create_leaves_the_callers_unit_of_work_able_to_commit var directory = scope.ServiceProvider.GetRequiredService(); await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); - Assert.True((await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields)))).IsSuccess); - var duplicate = await directory.CreateAsync(new NewContentType("Product again", "product", new ContentTypeDefinition(ProductFields))); - var dangling = await directory.CreateAsync(new NewContentType("Page", "page", new ContentTypeDefinition([Reference("about", "nothing")]))); + Assert.True((await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields)), TestChangeSetTargets.ContentModel)).IsSuccess); + var duplicate = await directory.CreateAsync(new NewContentType("Product again", "product", new ContentTypeDefinition(ProductFields)), TestChangeSetTargets.ContentModel); + var dangling = await directory.CreateAsync(new NewContentType("Page", "page", new ContentTypeDefinition([Reference("about", "nothing")])), TestChangeSetTargets.ContentModel); Assert.Equal(SchemaErrorCodes.SlugTaken, Assert.Single(duplicate.Errors).Code); Assert.Equal(SchemaErrorCodes.ReferenceTargetUnknown, Assert.Single(dangling.Errors).Code); @@ -225,7 +225,10 @@ public async Task Of_two_concurrent_creates_of_one_slug_one_wins_and_the_other_g await using var scenario = await SchemaScenario.CreateAsync(provider); var product = new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields)); - var lost = await RaceAsync(scenario, directory => directory.CreateAsync(product), directory => directory.CreateAsync(product)); + // Each writer has its own set, so the two meet only on the slug. + var first = await scenario.OpenChangeSetAsync(); + var second = await scenario.OpenChangeSetAsync(); + var lost = await RaceAsync(scenario, directory => directory.CreateAsync(product, first), directory => directory.CreateAsync(product, second)); Assert.Equal(SchemaErrorCodes.SlugTaken, Assert.Single(lost.Errors).Code); await AssertStoredAsync(scenario, types: 1, versions: 1, events: 1); @@ -242,8 +245,8 @@ public async Task Of_two_concurrent_revisions_of_one_version_one_wins_and_the_ot var lost = await RaceAsync( scenario, - directory => directory.ReviseAsync("product", 1, definition), - directory => directory.ReviseAsync("product", 1, definition)); + directory => directory.ReviseAsync("product", 1, definition, TestChangeSetTargets.ContentModel), + directory => directory.ReviseAsync("product", 1, definition, TestChangeSetTargets.ContentModel)); Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(lost.Errors).Code); Assert.Equal(2, Assert.Single(await scenario.ContentTypeRowsAsync()).Version); diff --git a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs index eb2ff0183..8a90f7732 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Identity; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -47,19 +48,27 @@ public async Task RunAsync(Func> action) return await action(scope.ServiceProvider.GetRequiredService()); } + // A change set of its own, for a test whose writes must not meet in the content model's session set. + public async Task OpenChangeSetAsync() + { + await using var scope = Services.CreateAsyncScope(); + var opened = await scope.ServiceProvider.GetRequiredService().OpenAsync(new NewChangeSet("Define the content model.", [])); + + return ChangeSetTarget.Existing(opened.Value.Id); + } public Task> CreateAsync(string slug, params FieldDefinition[] fields) { - return RunAsync(directory => directory.CreateAsync(new NewContentType(Title(slug), slug, new ContentTypeDefinition(fields)))); + return RunAsync(directory => directory.CreateAsync(new NewContentType(Title(slug), slug, new ContentTypeDefinition(fields)), TestChangeSetTargets.ContentModel)); } public Task> CreateEntityAsync(string slug, params FieldDefinition[] fields) { - return RunAsync(directory => directory.CreateAsync(new NewContentType(Title(slug), slug, new ContentTypeDefinition(fields, isEntity: true)))); + return RunAsync(directory => directory.CreateAsync(new NewContentType(Title(slug), slug, new ContentTypeDefinition(fields, isEntity: true)), TestChangeSetTargets.ContentModel)); } public Task> ReviseAsync(string slug, int baseVersion, ContentTypeDefinition definition) { - return RunAsync(directory => directory.ReviseAsync(slug, baseVersion, definition)); + return RunAsync(directory => directory.ReviseAsync(slug, baseVersion, definition, TestChangeSetTargets.ContentModel)); } public async Task> ContentTypeRowsAsync() @@ -72,9 +81,13 @@ public async Task> VersionRowsAsync() return await QueryAsync(context => context.ContentTypeVersions.AsNoTracking().OrderBy(version => version.Version).ToListAsync()); } + // The content type directory's own events; the change set events each write also appends are left out. public async Task> AuditRowsAsync() { - return await QueryAsync(context => context.AuditEvents.AsNoTracking().ToListAsync()); + return await QueryAsync(context => context.AuditEvents + .AsNoTracking() + .Where(row => row.Operation.StartsWith("content_type.")) + .ToListAsync()); } public async Task QueryAsync(Func> query) diff --git a/tests/FluentCMS.IntegrationTests/TestChangeSetTargets.cs b/tests/FluentCMS.IntegrationTests/TestChangeSetTargets.cs new file mode 100644 index 000000000..71a863464 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/TestChangeSetTargets.cs @@ -0,0 +1,13 @@ +using FluentCMS.Core.ChangeSets; + +namespace FluentCMS.IntegrationTests; + +internal static class TestChangeSetTargets +{ + public const string ContentModelSession = "content-model"; + + // The session a test's content model is written in when the test is not about the change set itself (SCH-05). + public static readonly ChangeSetTarget ContentModel = ChangeSetTarget.Session( + ContentModelSession, + new NewChangeSet("Define the content model.", [])); +} diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index 4e3c49764..3d39a2f5c 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -199,6 +199,54 @@ public void Sources_are_added_only_while_the_set_is_open() Assert.Single(changeSet.Sources); } + [Fact] + public void A_later_write_of_an_item_extends_its_operation_and_keeps_its_kind_and_base() + { + var created = Guid.NewGuid(); + var updated = Guid.NewGuid(); + var changeSet = TestChangeSets.Open(); + + Assert.True(changeSet.Record(Operation(OperationKind.Create, created, null, 1)).IsSuccess); + Assert.True(changeSet.Record(Operation(OperationKind.Update, updated, 2, 3)).IsSuccess); + Assert.True(changeSet.Record(Operation(OperationKind.Update, created, 1, 2)).IsSuccess); + Assert.True(changeSet.Record(Operation(OperationKind.Update, updated, 3, 5)).IsSuccess); + + Assert.Equal([Operation(OperationKind.Create, created, null, 2), Operation(OperationKind.Update, updated, 2, 5)], changeSet.Operations); + } + + [Fact] + public void A_later_write_that_is_not_based_on_the_proposed_version_is_refused() + { + var item = Guid.NewGuid(); + var deleted = Guid.NewGuid(); + var changeSet = TestChangeSets.Open(); + Assert.True(changeSet.Record(Operation(OperationKind.Update, item, 1, 2)).IsSuccess); + Assert.True(changeSet.Record(Operation(OperationKind.Delete, deleted, 1, null)).IsSuccess); + + var errors = new[] + { + changeSet.Record(Operation(OperationKind.Update, item, 1, 3)), + changeSet.Record(Operation(OperationKind.Delete, item, 2, null)), + changeSet.Record(Operation(OperationKind.Update, deleted, 1, 2)), + }; + + Assert.All(errors, result => Assert.Equal(ChangeSetErrorCodes.ItemDuplicate, Assert.Single(result.Errors).Code)); + Assert.Equal([Operation(OperationKind.Update, item, 1, 2), Operation(OperationKind.Delete, deleted, 1, null)], changeSet.Operations); + } + + [Theory] + [InlineData(ChangeSetState.ChecksRunning)] + [InlineData(ChangeSetState.Published)] + public void A_write_is_recorded_only_while_the_set_is_open(ChangeSetState state) + { + var changeSet = TestChangeSets.InState(state); + var item = changeSet.Operations[0]; + + var extended = changeSet.Record(ChangeOperation.Create(OperationKind.Update, item.ItemKind, item.ItemId, 2, 3).Value); + + Assert.Equal(ChangeSetErrorCodes.NotOpen, Assert.Single(extended.Errors).Code); + } + [Fact] public void A_stored_set_is_restored_as_it_was() { @@ -285,4 +333,9 @@ public void A_revert_needs_a_rationale_and_takes_no_further_operations() Assert.Equal(ChangeSetErrorCodes.RevertFixed, Assert.Single(added.Errors).Code); Assert.Single(revert.Operations); } + + private static ChangeOperation Operation(OperationKind kind, Guid item, int? baseVersion, int? proposedVersion) + { + return ChangeOperation.Create(kind, ItemKind.Schema, item, baseVersion, proposedVersion).Value; + } } From 1c905eb9aca252b748de00dcc28d59806484006b Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:48:28 -0400 Subject: [PATCH 02/23] Pass the change set target through the schema write tools --- .../Builtin/Tools/ChangeSetTarget.cs | 29 +++++-------------- .../Builtin/Tools/ProposedWrite.cs | 28 +++++------------- .../Builtin/Tools/SchemaTools.cs | 8 ++--- .../ChangeSets/ChangeSetDirectory.cs | 2 +- .../Mcp/WriteToolsTests.cs | 4 +-- 5 files changed, 22 insertions(+), 49 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs index 94adf41e1..729760d08 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs @@ -1,8 +1,7 @@ -using FluentCMS.Core.Agents; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Results; using FluentCMS.Core.Tools; -using Microsoft.Extensions.DependencyInjection; +using DirectoryTarget = FluentCMS.Core.ChangeSets.ChangeSetTarget; namespace FluentCMS.Core.Builtin.Tools; @@ -82,29 +81,15 @@ changeSetId is not null return null; } - // Refuses ahead of the write what the change set would refuse after it, so no version is stored for nothing and a refused - // cap keeps its security event (SEC-08), which a rolled-back write would take with it. The set checks again on the add. - public async Task CheckAsync(IServiceProvider services, CancellationToken cancellationToken) + // The directories take the same target; they find every refusal, a cap included, before they write anything (SCH-05). + public DirectoryTarget ToDirectoryTarget() { - var directory = services.GetRequiredService(); - var permissions = services.GetRequiredService(); - - var set = _changeSetId is { } id - ? await directory.GetAsync(id, cancellationToken) - : await directory.GetSessionSetAsync(_sessionId!, cancellationToken); - - if (set.IsSuccess) - { - return await permissions.CheckChangeSetSizeAsync(set.Value, adding: 1, cancellationToken); - } - - return _changeSetId is null ? await permissions.CheckNewChangeSetAsync(cancellationToken) : Result.Failure(set.Errors); + return _changeSetId is { } id ? DirectoryTarget.Existing(id) : DirectoryTarget.Session(_sessionId!, _opening!); } - public Task> AddAsync(IChangeSetDirectory directory, ChangeOperation operation, CancellationToken cancellationToken) + // The set the write went into, read after it to answer the call. + public Task> ReadAsync(IChangeSetDirectory directory, CancellationToken cancellationToken) { - return _changeSetId is { } id - ? directory.AddOperationAsync(id, operation, cancellationToken) - : directory.AddToSessionAsync(_sessionId!, _opening!, operation, cancellationToken); + return _changeSetId is { } id ? directory.GetAsync(id, cancellationToken) : directory.GetSessionSetAsync(_sessionId!, cancellationToken); } } diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs index ee086c0e3..01b43a891 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -1,58 +1,46 @@ using System.Text.Json.Nodes; using FluentCMS.Core.ChangeSets; -using FluentCMS.Core.Persistence; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; using FluentCMS.Core.Tools; using Microsoft.Extensions.DependencyInjection; +using DirectoryTarget = FluentCMS.Core.ChangeSets.ChangeSetTarget; namespace FluentCMS.Core.Builtin.Tools; -// A schema write an agent proposes: the new version and its operation in the change set commit together or not at all, so no -// version an agent writes exists outside a change set a person reviews (AUD-N1, CHG-01). +// A schema write an agent proposes. The directory stores the new version and records its operation in the change set in one +// unit of work, so no version an agent writes exists outside a change set a person reviews (SCH-05, AUD-N1, CHG-01). internal static class ProposedWrite { public static async Task RunAsync( ToolInvocation call, ChangeSetTarget target, - Func>> write, - Func operation, + Func>> write, + OperationKind kind, CancellationToken cancellationToken) { var services = call.Services; - var ready = await target.CheckAsync(services, cancellationToken); - if (!ready.IsSuccess) - { - return ToolResult.Failure(ready.Errors); - } - - // Each directory finds its expected failures before its own unit of work, so leaving this one uncommitted undoes both. - await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); - var type = await write(services.GetRequiredService()); + var type = await write(services.GetRequiredService(), target.ToDirectoryTarget()); if (!type.IsSuccess) { return ToolResult.Failure(type.Errors, Hint(type.Errors)); } - var proposed = operation(type.Value); - var set = await target.AddAsync(services.GetRequiredService(), proposed, cancellationToken); + var set = await target.ReadAsync(services.GetRequiredService(), cancellationToken); if (!set.IsSuccess) { return ToolResult.Failure(set.Errors); } - await scope.CommitAsync(cancellationToken); - return ToolResult.Success(new JsonObject { ["contentType"] = ToolJson.ContentType(type.Value), ["changeSet"] = ToolJson.ChangeSet(set.Value), - ["next"] = $"The {ToolJson.Name(proposed.Kind)} is in change set {set.Value.Id}, which stays open for more " + ["next"] = $"The {ToolJson.Name(kind)} is in change set {set.Value.Id}, which stays open for more " + "operations. Call submit_change_set when it is complete; checks run and a person reviews it before it is published.", }); } - private static string? Hint(IReadOnlyList errors) { return errors.FirstOrDefault()?.Code switch diff --git a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs index 3db536dec..afaa6283c 100644 --- a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs @@ -146,8 +146,8 @@ private static async Task CreateAsync(ToolInvocation call, Cancellat return await ProposedWrite.RunAsync( call, target!, - directory => directory.CreateAsync(new NewContentType(name!, slug!, parsed!), cancellationToken), - type => ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, type.Version).Value, + (directory, changeSet) => directory.CreateAsync(new NewContentType(name!, slug!, parsed!), changeSet, cancellationToken), + OperationKind.Create, cancellationToken); } @@ -167,8 +167,8 @@ private static async Task ReviseAsync(ToolInvocation call, Cancellat return await ProposedWrite.RunAsync( call, target!, - directory => directory.ReviseAsync(slug!, baseVersion!.Value, parsed!, cancellationToken), - type => ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, type.Id, baseVersion!.Value, type.Version).Value, + (directory, changeSet) => directory.ReviseAsync(slug!, baseVersion!.Value, parsed!, changeSet, cancellationToken), + OperationKind.Update, cancellationToken); } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index fe6fad560..4f987a3ba 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -383,7 +383,7 @@ private async Task> PrepareJoinAsync( } // Checked against the read whose revision guards the write, as for AddOperationAsync. - var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken); + var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs index 162b52b30..d70b694fa 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs @@ -144,7 +144,7 @@ public async Task A_revision_from_a_stale_version_is_refused_with_the_way_to_the await agent.AsPersonAsync(directory => directory.ReviseAsync( "product", 1, - new ContentTypeDefinition([new FieldDefinition("title", "text"), new FieldDefinition("summary", "text")]))); + new ContentTypeDefinition([new FieldDefinition("title", "text"), new FieldDefinition("summary", "text")]), TestChangeSetTargets.ContentModel)); var stale = await agent.FailAsync("revise_content_type", new() { @@ -221,7 +221,7 @@ public async Task AsPersonAsync(Func CreateAsPersonAsync(string slug) { return AsPersonAsync(directory => - directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))); + directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)); } public async Task QueryAsync(Func> query) From 96f0f8c96cff3cbe16d77adf2aedac0b4659fe2f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:52:34 -0400 Subject: [PATCH 03/23] Pass the change set target in the agent scope tests --- .../Agents/AgentScopeTests.cs | 10 +++++----- .../FluentCMS.IntegrationTests/Agents/ScopeScenario.cs | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Agents/AgentScopeTests.cs b/tests/FluentCMS.IntegrationTests/Agents/AgentScopeTests.cs index afbccc6eb..64afe201c 100644 --- a/tests/FluentCMS.IntegrationTests/Agents/AgentScopeTests.cs +++ b/tests/FluentCMS.IntegrationTests/Agents/AgentScopeTests.cs @@ -80,9 +80,9 @@ public async Task Each_operation_needs_its_own_grant(string provider) ("delete", await scenario.RunAsync(directory => directory.DeleteAsync(category, 1))), ("delete", Plain(await scenario.RunAsync>(directory => directory.RestoreAsync(category)))), ("create", Plain(await scenario.RunAsync>(directory => directory.CreateAsync( - new NewContentType("Region", "region", new ContentTypeDefinition([new FieldDefinition("name", "text")])))))), + new NewContentType("Region", "region", new ContentTypeDefinition([new FieldDefinition("name", "text")])), TestChangeSetTargets.ContentModel)))), ("update", Plain(await scenario.RunAsync>(directory => directory.ReviseAsync( - "category", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("slug", "text")]))))), + "category", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("slug", "text")]), TestChangeSetTargets.ContentModel)))), ]; Assert.All(refused, refusal => @@ -107,7 +107,7 @@ public async Task Each_operation_needs_its_own_grant(string provider) Assert.True((await scenario.RunAsync(directory => directory.DeleteAsync(category, 2))).IsSuccess); Assert.True((await scenario.RunAsync>(directory => directory.RestoreAsync(category))).IsSuccess); Assert.True((await scenario.RunAsync>(directory => directory.ReviseAsync( - "category", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("slug", "text")])))).IsSuccess); + "category", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("slug", "text")]), TestChangeSetTargets.ContentModel))).IsSuccess); } [Theory] @@ -125,14 +125,14 @@ public async Task A_field_list_narrows_writes_of_its_type_to_the_listed_fields(s Assert.True((await scenario.RunAsync>(directory => directory.CreateAsync(new NewEntry("product", Json("""{"price":5}"""))))).IsSuccess); Assert.True((await scenario.RunAsync>(directory => directory.UpdateAsync(scenario.Category.Id, 1, Json("""{"name":"Home"}""")))).IsSuccess); Assert.True((await scenario.RunAsync>(directory => directory.ReviseAsync( - "category", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("slug", "text")])))).IsSuccess); + "category", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("slug", "text")]), TestChangeSetTargets.ContentModel))).IsSuccess); var counts = await scenario.CountsAsync(); var renamed = await scenario.RunAsync>(directory => directory.UpdateAsync(product, 2, Json("""{"name":"Teapot","price":25}"""))); var dropped = await scenario.RunAsync>(directory => directory.UpdateAsync(product, 2, Json("""{"price":25}"""))); var named = await scenario.RunAsync>(directory => directory.CreateAsync(new NewEntry("product", Json("""{"name":"Mug","price":5}""")))); var definition = await scenario.RunAsync>(directory => directory.ReviseAsync( - "product", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("price", "number", required: true)]))); + "product", 1, new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("price", "number", required: true)]), TestChangeSetTargets.ContentModel)); var proposed = await scenario.RunAsync>(async directory => { var changeSet = (await directory.OpenAsync(new NewChangeSet("Make the price required.", []))).Value; diff --git a/tests/FluentCMS.IntegrationTests/Agents/ScopeScenario.cs b/tests/FluentCMS.IntegrationTests/Agents/ScopeScenario.cs index d873d2c6d..7cb241e03 100644 --- a/tests/FluentCMS.IntegrationTests/Agents/ScopeScenario.cs +++ b/tests/FluentCMS.IntegrationTests/Agents/ScopeScenario.cs @@ -55,11 +55,11 @@ public static async Task CreateAsync(string provider) await scenario.RunAsync(async directory => (await directory.CreateAsync(new NewContentType( "Product", "product", - new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("price", "number")])))).Value); + new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("price", "number")])), TestChangeSetTargets.ContentModel)).Value); await scenario.RunAsync(async directory => (await directory.CreateAsync(new NewContentType( "Category", "category", - new ContentTypeDefinition([new FieldDefinition("name", "text")])))).Value); + new ContentTypeDefinition([new FieldDefinition("name", "text")])), TestChangeSetTargets.ContentModel)).Value); scenario.Product = await scenario.RunAsync(async directory => (await directory.CreateAsync(new NewEntry("product", Json("""{"name":"Kettle","price":20}""")))).Value); From c3af4f7a18d6177158ea305fb4796f6f58522167 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:56:54 -0400 Subject: [PATCH 04/23] Pass the change set target in the entry change set tests --- .../ChangeSets/EntryChangeSetTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/EntryChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/EntryChangeSetTests.cs index 60c103cb4..5249d1713 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/EntryChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/EntryChangeSetTests.cs @@ -262,8 +262,8 @@ private static async Task ScenarioAsync(string provider) new FieldDefinition("price", "reference", settings: Json("""{"target": "product", "field": "price"}""")), ]); - Assert.True((await types.CreateAsync(new NewContentType("Product", "product", product))).IsSuccess); - Assert.True((await types.CreateAsync(new NewContentType("Page", "page", page))).IsSuccess); + Assert.True((await types.CreateAsync(new NewContentType("Product", "product", product), TestChangeSetTargets.ContentModel)).IsSuccess); + Assert.True((await types.CreateAsync(new NewContentType("Page", "page", page), TestChangeSetTargets.ContentModel)).IsSuccess); } var productId = (await EntriesAsync(scenario, entries => entries.CreateAsync(new NewEntry("product", Json(ProductFields("19.99")))))).Value.Id; From b69ddd396314ffdeee95e639bd2a820a430dda06 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 20:01:13 -0400 Subject: [PATCH 05/23] Drop the unused adding count from the change set size check --- src/FluentCMS.Core/Agents/AgentPermissions.cs | 4 ++-- src/FluentCMS.Core/Agents/IAgentPermissions.cs | 5 ++--- .../ChangeSets/ChangeSetDirectory.cs | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs index 435c75ac6..9fd7bddd2 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -82,7 +82,7 @@ await RecordAsync( + $"(change sets per day). Add the operation to a change set that is still open, or wait until fewer than {cap} were opened in the last 24 hours."); } - public async Task CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default) + public async Task CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(changeSet); @@ -104,7 +104,7 @@ public async Task CheckChangeSetSizeAsync(ChangeSet changeSet, int addin } var cap = credential.Caps.ItemsPerChangeSet; - var count = changeSet.Operations.Count + adding; + var count = changeSet.Operations.Count; if (count <= cap) { return Result.Success(); diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs index 14b891cfa..ca23c99c2 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -14,9 +14,8 @@ public interface IAgentPermissions Task CheckNewChangeSetAsync(CancellationToken cancellationToken = default); - // Called with the agent's own set as read for the write and the new operation added in memory, before anything is written, - // or ahead of a write with the number of operations it will add. - Task CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default); + // Called with the agent's own set as read for the write and the new operation added in memory, before anything is written. + Task CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default); // The operation on items of the content type (MCP-05). A write names the fields it changes, which a field list in the scope can // narrow; a read names none, so an agent reads whole entries of a type in its scope. diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 66be39202..250174367 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -387,7 +387,7 @@ private async Task> PrepareJoinAsync( } // Checked against the read whose revision guards the write, as for AddOperationAsync. - var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken: cancellationToken); + var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); @@ -425,7 +425,7 @@ private async Task> AddAsync( } // Checked against the read whose revision guards the write, so two concurrent operations cannot both pass the cap. - var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken: cancellationToken); + var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); From 9135738981d9ecbaba6e03d2bd3a89e456f9fa37 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 20:43:12 -0400 Subject: [PATCH 06/23] Pass the change set target in the entity relations test --- .../FluentCMS.IntegrationTests/Entities/EntityRelationsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Entities/EntityRelationsTests.cs b/tests/FluentCMS.IntegrationTests/Entities/EntityRelationsTests.cs index 8c4a258c8..db551f376 100644 --- a/tests/FluentCMS.IntegrationTests/Entities/EntityRelationsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Entities/EntityRelationsTests.cs @@ -108,7 +108,7 @@ public async Task An_agent_reads_relations_only_within_its_scope(string provider var types = scope.ServiceProvider.GetRequiredService(); foreach (var (slug, isEntity, fields) in SchemaScenario.ReferenceModel) { - Assert.True((await types.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)))).IsSuccess); + Assert.True((await types.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)), TestChangeSetTargets.ContentModel)).IsSuccess); } } From 451940119ce72a36129698f96efff5aae86919f8 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:34:53 -0400 Subject: [PATCH 07/23] Pass the change set target in the entity tests --- tests/FluentCMS.IntegrationTests/Entities/BlastRadiusTests.cs | 2 +- tests/FluentCMS.IntegrationTests/Entities/EntityFactsTests.cs | 2 +- tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Entities/BlastRadiusTests.cs b/tests/FluentCMS.IntegrationTests/Entities/BlastRadiusTests.cs index 858eb58c4..d4b9da61c 100644 --- a/tests/FluentCMS.IntegrationTests/Entities/BlastRadiusTests.cs +++ b/tests/FluentCMS.IntegrationTests/Entities/BlastRadiusTests.cs @@ -133,7 +133,7 @@ public async Task An_agent_sees_only_affected_pages_of_types_in_its_scope(string var entries = scope.ServiceProvider.GetRequiredService(); foreach (var (slug, isEntity, fields) in SchemaScenario.ReferenceModel) { - Assert.True((await types.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)))).IsSuccess); + Assert.True((await types.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)), TestChangeSetTargets.ContentModel)).IsSuccess); } var drill = (await entries.CreateAsync(new NewEntry("product", Json(ProductFields("99", Catalog))))).Value.Id; diff --git a/tests/FluentCMS.IntegrationTests/Entities/EntityFactsTests.cs b/tests/FluentCMS.IntegrationTests/Entities/EntityFactsTests.cs index 1ef1b46cf..f05cb9333 100644 --- a/tests/FluentCMS.IntegrationTests/Entities/EntityFactsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Entities/EntityFactsTests.cs @@ -138,7 +138,7 @@ public async Task An_agent_sees_facts_only_within_its_scope(string provider) var entries = scope.ServiceProvider.GetRequiredService(); foreach (var (slug, isEntity, fields) in SchemaScenario.ReferenceModel) { - Assert.True((await types.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)))).IsSuccess); + Assert.True((await types.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)), TestChangeSetTargets.ContentModel)).IsSuccess); } Assert.True((await entries.CreateAsync(new NewEntry("category", Json($$"""{"name": {{Fact("\"Tools\"")}}}""")))).IsSuccess); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs index 9c938b358..cb56ee7cb 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs @@ -131,7 +131,7 @@ public async Task Refused_fact_and_relation_writes_say_why_and_store_nothing(str Assert.Contains("get_content_type", (string?)notRelation["hint"]); Assert.Equal(ContentErrorCodes.ReferenceTypeMismatch, Code(wrongType)); Assert.Equal(versions, await agent.QueryAsync(context => context.EntryVersions.CountAsync())); - Assert.Equal(0, await agent.QueryAsync(context => context.ChangeSets.CountAsync())); + Assert.Equal(0, await agent.QueryAsync(context => context.ChangeSets.CountAsync(set => set.SessionId != TestChangeSetTargets.ContentModelSession))); } private static async Task CatalogAsync(ConnectedAgent agent) @@ -139,7 +139,7 @@ private static async Task CatalogAsync(ConnectedAgent agent) async Task TypeAsync(string slug, bool isEntity, params FieldDefinition[] fields) { await agent.AsPersonAsync(directory => - directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)))); + directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)), TestChangeSetTargets.ContentModel)); } await TypeAsync("category", true, new FieldDefinition("name", "text")); From 6bed7b5c5a62bcb38b5dd47033b3198e05b27296 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 23:01:22 -0400 Subject: [PATCH 08/23] Pass the change set target in the check tool tests --- tests/FluentCMS.IntegrationTests/Mcp/CheckToolsTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/CheckToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/CheckToolsTests.cs index b358d238d..31daf6fee 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/CheckToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/CheckToolsTests.cs @@ -55,7 +55,7 @@ public async Task An_agent_checks_a_draft_fixes_what_three_checks_find_and_the_d Assert.All(fixedDraft["results"]!.AsArray(), result => Assert.Equal("pass", (string?)result!["status"])); // A check stores nothing and opens no set. - Assert.Equal(0, await agent.QueryAsync(context => Task.FromResult(context.ChangeSets.Count()))); + Assert.Equal(0, await agent.QueryAsync(context => Task.FromResult(context.ChangeSets.Count(set => set.SessionId != TestChangeSetTargets.ContentModelSession)))); Assert.Equal(0, await agent.QueryAsync(context => Task.FromResult(context.Entries.Count()))); } @@ -226,7 +226,7 @@ await agent.AsPersonAsync(directory => direc new FieldDefinition("headline", "text", required: true), new FieldDefinition("body", "richText"), new FieldDefinition("price", "number"), - ])))); + ])), TestChangeSetTargets.ContentModel)); var checks = new Dictionary { From c45d652abd2d7aca014d352487d37951c036f0ef Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:08:41 -0400 Subject: [PATCH 09/23] Publish revisions of unpublished types as creates and guard draft references --- src/FluentCMS.Core/ChangeSets/ChangeSet.cs | 10 +- .../ChangeSets/ChangeSetDirectory.cs | 16 ++- .../Schema/ContentTypeDirectory.cs | 24 +++- .../ChangeSets/ChangeSetScenario.cs | 13 +- .../ChangeSets/SchemaChangeSetTests.cs | 115 ++++++++++++++++-- .../Mcp/WriteToolsTests.cs | 3 +- .../ChangeSets/ChangeSetTests.cs | 14 +++ 7 files changed, 166 insertions(+), 29 deletions(-) diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index 42284f139..cc081d3c3 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -190,15 +190,19 @@ public Result AddOperation(ChangeOperation operation) // A write of an item adds its operation, or extends the one the set already holds for the item, so the set still has one // operation per item (SCH-05): the write is based on the version the set proposes, and the operation now proposes the write's - // version. A create stays a create and an update keeps its base. - public Result Record(ChangeOperation operation) + // version. A create stays a create and an update keeps its base. An update of an item no set has published, or that a published + // delete took offline (a rejected create, a reverted one), is recorded as a create, since publishing it makes the item live again + // from nothing. + public Result Record(ChangeOperation operation, bool itemPublished = true) { ArgumentNullException.ThrowIfNull(operation); var index = _operations.FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId); if (index < 0) { - return AddOperation(operation); + return AddOperation(itemPublished || operation.Kind != OperationKind.Update + ? operation + : ChangeOperation.Create(OperationKind.Create, operation.ItemKind, operation.ItemId, null, operation.ProposedVersion).Value); } var writable = CanTakeOperations(); diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 0dc30c4dc..31646950a 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -299,7 +299,12 @@ public Task> RejectAsync(Guid changeSetId, string reason, Canc // (SCH-05), joins its set in two steps, so the item's rows and the operation commit together. PrepareAsync finds every // expected failure before the caller's unit of work begins; WriteAsync runs inside it, with the row of the version the write // was based on. - internal async Task> PrepareAsync(ChangeSetTarget target, ChangeOperation operation, CancellationToken cancellationToken) + // ItemPublished says whether a change set has published the item, so an update of one that is not live records a create. + internal async Task> PrepareAsync( + ChangeSetTarget target, + ChangeOperation operation, + bool itemPublished, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(operation); @@ -309,7 +314,7 @@ internal async Task> PrepareAsync(ChangeSetTarget targe var stored = await ReadAsync(id, cancellationToken); return stored is null ? Result.Failure(NotFound(id).Errors) - : await PrepareJoinAsync(stored, operation, [], cancellationToken); + : await PrepareJoinAsync(stored, operation, itemPublished, [], cancellationToken); } var sessionId = target.SessionId!; @@ -317,7 +322,7 @@ internal async Task> PrepareAsync(ChangeSetTarget targe var openId = await OpenSetOfSessionAsync(initiator, sessionId, cancellationToken); if (openId is not null && await ReadAsync(openId.Value, cancellationToken) is { } open) { - return await PrepareJoinAsync(open, operation, target.NewChangeSet!.Sources, cancellationToken); + return await PrepareJoinAsync(open, operation, itemPublished, target.NewChangeSet!.Sources, cancellationToken); } var opened = ChangeSet.Open(SiteId, target.NewChangeSet!.Rationale, target.NewChangeSet.Sources, initiator, sessionId); @@ -327,7 +332,7 @@ internal async Task> PrepareAsync(ChangeSetTarget targe } var set = opened.Value; - var recorded = set.Record(operation); + var recorded = set.Record(operation, itemPublished); if (!recorded.IsSuccess) { return Result.Failure(recorded.Errors); @@ -369,13 +374,14 @@ internal async Task WriteAsync(PendingOperation pending, Guid? previousVer private async Task> PrepareJoinAsync( StoredChangeSet stored, ChangeOperation operation, + bool itemPublished, IReadOnlyList sources, CancellationToken cancellationToken) { var set = stored.ChangeSet; var count = set.Operations.Count; - var recorded = set.Record(operation); + var recorded = set.Record(operation, itemPublished); if (!recorded.IsSuccess) { return Result.Failure(recorded.Errors); diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index 36fc52453..cbaa215be 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -86,7 +86,7 @@ public async Task> CreateAsync(NewContentType contentType, C return Result.Failure(errors); } - var pending = await changeSets.PrepareAsync(changeSet, Operation(OperationKind.Create, type.Id, null, type.Version), cancellationToken); + var pending = await changeSets.PrepareAsync(changeSet, Operation(OperationKind.Create, type.Id, null, type.Version), itemPublished: false, cancellationToken); if (!pending.IsSuccess) { return Result.Failure(pending.Errors); @@ -156,15 +156,29 @@ public async Task> ReviseAsync( return revised; } + // A revision from the published version drops the latest draft's fields too, so other types must not reference those either. + var dropped = new List { previous.Definition }; + if (baseVersion != row.Version) + { + var latest = await context.ContentTypeVersions + .AsNoTracking() + .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == row.Version, cancellationToken); + dropped.Add(ReadDefinition(row.Slug, latest.Version, latest.Definition)); + } + var type = revised.Value; var errors = await ReferenceErrorsAsync(type, cancellationToken); - errors.AddRange(await RemovedFieldsInUseAsync(type, previous.Definition, cancellationToken)); + errors.AddRange(await RemovedFieldsInUseAsync(type, dropped, cancellationToken)); if (errors.Count > 0) { return Result.Failure(errors); } - var pending = await changeSets.PrepareAsync(changeSet, Operation(OperationKind.Update, type.Id, baseVersion, type.Version), cancellationToken); + var pending = await changeSets.PrepareAsync( + changeSet, + Operation(OperationKind.Update, type.Id, baseVersion, type.Version), + itemPublished: row.PublishedVersion is not null, + cancellationToken); if (!pending.IsSuccess) { return Result.Failure(pending.Errors); @@ -286,9 +300,9 @@ private async Task> ReferenceErrorsAsync(ContentType type, Cancellat } // A field another type points at cannot go, or that reference would dangle (SCH-04). - private async Task> RemovedFieldsInUseAsync(ContentType type, ContentTypeDefinition previous, CancellationToken cancellationToken) + private async Task> RemovedFieldsInUseAsync(ContentType type, IEnumerable previous, CancellationToken cancellationToken) { - var removed = previous.Fields.Select(field => field.Name).Where(name => !type.Definition.HasField(name)).ToHashSet(); + var removed = previous.SelectMany(definition => definition.Fields).Select(field => field.Name).Where(name => !type.Definition.HasField(name)).ToHashSet(); if (removed.Count == 0) { return []; diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index 0c959095c..acba2021c 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -22,6 +22,9 @@ internal sealed class ChangeSetScenario : IAsyncDisposable public static readonly AgentInitiator Agent = new(Guid.NewGuid(), "example-agent"); + // A second agent with its own stored credential, for what one agent may not do to another's sets. + public static readonly AgentInitiator OtherAgent = new(Guid.NewGuid(), "other-agent"); + public static readonly NewChangeSet CatalogSync = new("Sync prices from the catalog export.", ["exports/catalog-2026-10-18.csv"]); private readonly TemporaryDatabase _database; @@ -68,7 +71,7 @@ public static async Task CreateAsync(string provider) var scenario = new ChangeSetScenario(database, services, initiator, clock, auditFailure); await scenario.QueryAsync(async context => { - context.AgentCredentials.Add(AgentCredential()); + context.AgentCredentials.AddRange(AgentCredential(Agent), AgentCredential(OtherAgent)); return await context.SaveChangesAsync(); }); @@ -296,13 +299,13 @@ 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() + // The stored credential behind an agent, scoped to the site with room to spare, so only a test that lowers a cap meets one. + private static AgentCredentialRow AgentCredential(AgentInitiator agent) { return new AgentCredentialRow { - Id = Agent.CredentialId, - DisplayName = Agent.DisplayName, + Id = agent.CredentialId, + DisplayName = agent.DisplayName, KeyHash = RandomNumberGenerator.GetBytes(32), IssuedAt = new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), ExpiresAt = new DateTimeOffset(2027, 10, 1, 0, 0, 0, TimeSpan.Zero), diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs index 47821f6ed..2dec94bf6 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Persistence; @@ -185,30 +186,108 @@ await scenario.RunSchemaAsync(directory => directory.CreateAsync( Assert.Equal(events + 1, (await scenario.ChangeSetEventsAsync()).Count); } + // A refusal is found before the directories write, so the caller's unit of work still commits; a refused cap keeps its + // security event (SEC-08), and nothing else of a refused write is stored. [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] public async Task An_agent_writes_only_into_its_own_set_and_within_its_cap(string provider) { await using var scenario = await ChangeSetScenario.CreateAsync(provider); - var persons = await scenario.AsPersonAsync(directory => directory.OpenAsync(CatalogSync)); + scenario.Initiator = OtherAgent; + var others = await scenario.OpenAsync(); + scenario.Initiator = Agent; var own = await scenario.OpenAsync(); await scenario.UpdateAgentAsync(row => row.ItemsPerChangeSet = 1); + var writes = await WriteEventsAsync(scenario); - var intoPersons = await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Existing(persons.Value.Id))); - var first = await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Existing(own.Id))); - var pastCap = await scenario.RunSchemaAsync(directory => directory.CreateAsync( - new NewContentType("Category", "category", new ContentTypeDefinition([Title])), - ChangeSetTarget.Existing(own.Id))); + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var directory = scope.ServiceProvider.GetRequiredService(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + var first = await directory.CreateAsync(Product, ChangeSetTarget.Existing(own.Id)); + var intoOthers = await directory.CreateAsync(new NewContentType("Region", "region", new ContentTypeDefinition([Title])), ChangeSetTarget.Existing(others.Id)); + var pastCap = await directory.CreateAsync(new NewContentType("Category", "category", new ContentTypeDefinition([Title])), ChangeSetTarget.Existing(own.Id)); + + Assert.True(first.IsSuccess); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Assert.Single(intoOthers.Errors).Code); + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, Assert.Single(pastCap.Errors).Code); + await work.CommitAsync(); + } - Assert.Equal(AgentPermissionErrorCodes.NotOwner, Assert.Single(intoPersons.Errors).Code); - Assert.True(first.IsSuccess); - Assert.Equal(AgentPermissionErrorCodes.CapExceeded, Assert.Single(pastCap.Errors).Code); Assert.Equal(["product"], (await scenario.RunSchemaAsync(directory => directory.ListAsync())).Select(type => type.Slug)); - Assert.Empty(await scenario.OperationRowsAsync(persons.Value.Id)); + Assert.Empty(await scenario.OperationRowsAsync(others.Id)); Assert.Single(await scenario.OperationRowsAsync(own.Id)); + Assert.Equal(["content_type.created", "change_set.operation_added"], (await WriteEventsAsync(scenario)).Skip(writes.Count)); + Assert.Single( + await scenario.QueryAsync(context => context.AuditEvents.AsNoTracking().Where(row => row.Operation == "agent.items_per_change_set_exceeded").ToListAsync())); } + // A rejected create leaves a type no set has published, and a reverted one takes it offline; either way the next revision + // records a create, so the type can be published again. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_type_whose_create_was_rejected_or_reverted_is_published_again_through_a_create(string provider) + { + await using var scenario = await ScenarioAsync(provider); + + var rejected = await scenario.OpenAsync(); + var product = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Existing(rejected.Id)))).Value; + await scenario.RunAsync(directory => directory.TransitionAsync(rejected.Id, ChangeSetState.ChecksRunning)); + await scenario.RunAsync(directory => directory.TransitionAsync(rejected.Id, ChangeSetState.AwaitingReview)); + await scenario.RunAsync(directory => directory.RejectAsync(rejected.Id, "Not this quarter.")); + + var retry = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, ChangeSetTarget.Existing(retry.Id))); + Assert.Equal([ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, product.Id, null, 2).Value], (await scenario.RunAsync(directory => directory.GetAsync(retry.Id))).Value.Operations); + await scenario.PublishAsync(retry.Id); + Assert.Equal(2, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value.Version); + + var published = await scenario.OpenAsync(); + var category = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Category", "category", new ContentTypeDefinition([Title])), ChangeSetTarget.Existing(published.Id)))).Value; + await scenario.PublishAsync(published.Id); + var revert = (await scenario.RunAsync(directory => directory.RevertAsync(published.Id, "Categories come later."))).Value; + await scenario.PublishAsync(revert.Id); + Assert.Equal(SchemaErrorCodes.ContentTypeNotPublished, Assert.Single((await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("category"))).Errors).Code); + + var again = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("category", 1, new ContentTypeDefinition([Title, new FieldDefinition("parent", "text")]), ChangeSetTarget.Existing(again.Id))); + Assert.Equal([ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, category.Id, null, 2).Value], (await scenario.RunAsync(directory => directory.GetAsync(again.Id))).Value.Operations); + await scenario.PublishAsync(again.Id); + Assert.Equal(2, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("category"))).Value.Version); + } + + // The published version is behind the latest draft, whose fields other drafts may already reference (SCH-04). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_from_the_published_version_cannot_drop_a_field_a_draft_references(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var name = new FieldDefinition("name", "text"); + var first = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Brand", "brand", new ContentTypeDefinition([name], isEntity: true)), ChangeSetTarget.Existing(first.Id))); + await scenario.PublishAsync(first.Id); + + var draft = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("brand", 1, new ContentTypeDefinition([name, new FieldDefinition("country", "text")], isEntity: true), draft)); + var country = new FieldDefinition("brandCountry", "reference", settings: Json("""{"target": "brand", "field": "country"}""")); + Assert.True((await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([Title, country])), draft))).IsSuccess); + + var other = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + var revision = await scenario.RunSchemaAsync(directory => directory.ReviseAsync( + "brand", + 1, + new ContentTypeDefinition([name, new FieldDefinition("logo", "text")], isEntity: true), + other)); + + var error = Assert.Single(revision.Errors); + Assert.Equal(SchemaErrorCodes.FieldReferenced, error.Code); + Assert.Contains("'country'", error.Message); + Assert.Equal(2, (await scenario.RunSchemaAsync(directory => directory.GetAsync("brand"))).Value.Version); + } // A revision moves the latest version and the set's revision before its events are appended, so each failure point comes // after something reached the database. [Theory] @@ -247,6 +326,22 @@ await Assert.ThrowsAsync( Assert.True((await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, target))).IsSuccess); } + // The content type and change set events of writes, in the order they were appended; security events are left out. + private static async Task> WriteEventsAsync(ChangeSetScenario scenario) + { + var rows = await scenario.QueryAsync(context => context.AuditEvents + .AsNoTracking() + .Where(row => row.Operation.StartsWith("content_type.") || row.Operation.StartsWith("change_set.")) + .ToListAsync()); + + return [.. rows.OrderBy(row => row.Timestamp).Select(row => row.Operation)]; + } + + private static JsonElement Json(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } // Content type writes here are a person's, who also makes the review moves (MCP-06). private static async Task ScenarioAsync(string provider) { diff --git a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs index ffb081628..806a4532e 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs @@ -52,10 +52,11 @@ public async Task An_agent_proposes_a_new_type_and_a_revision_in_one_set_and_sub var category = await agent.AsPersonAsync(directory => directory.GetAsync("category")); Assert.Equal(ChangeSetState.ChecksRunning, stored.State); Assert.Equal(agent.Credential.Initiator, stored.Initiator); + // No set has published the person's product yet, so the agent's revision of it is recorded as its create. Assert.Equal( [ ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, category.Id, null, 1).Value, - ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, product.Id, 1, 2).Value, + ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, product.Id, null, 2).Value, ], stored.Operations); diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index 3d39a2f5c..f89514c45 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -214,6 +214,20 @@ public void A_later_write_of_an_item_extends_its_operation_and_keeps_its_kind_an Assert.Equal([Operation(OperationKind.Create, created, null, 2), Operation(OperationKind.Update, updated, 2, 5)], changeSet.Operations); } + [Fact] + public void An_update_of_an_item_that_is_not_published_is_recorded_as_a_create() + { + var rejected = Guid.NewGuid(); + var created = Guid.NewGuid(); + var changeSet = TestChangeSets.Open(); + + Assert.True(changeSet.Record(Operation(OperationKind.Update, rejected, 1, 2), itemPublished: false).IsSuccess); + Assert.True(changeSet.Record(Operation(OperationKind.Create, created, null, 1), itemPublished: false).IsSuccess); + Assert.True(changeSet.Record(Operation(OperationKind.Update, rejected, 2, 3), itemPublished: false).IsSuccess); + + Assert.Equal([Operation(OperationKind.Create, rejected, null, 3), Operation(OperationKind.Create, created, null, 1)], changeSet.Operations); + } + [Fact] public void A_later_write_that_is_not_based_on_the_proposed_version_is_refused() { From f749251c664fd6805a611e90a41905e211f09c40 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:22:03 -0400 Subject: [PATCH 10/23] Pass the change set target in the desk screen tests --- tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs b/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs index 6e2bc4f3f..fce3e73ca 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/DeskScreenTests.cs @@ -65,7 +65,7 @@ public async Task The_desk_renders_right_to_left() private static async Task AwaitingReviewAsync(AdminHost host, Guid userId, string slug) { var type = await host.AsUserAsync(userId, async directory => - (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value); + (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)).Value); return await host.AsUserAsync(userId, async directory => { From d10ca90b5b444c25082bd14504e80a546b075421 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:03:22 -0400 Subject: [PATCH 11/23] Refuse revisions of dead drafts and check the create scope --- .../Builtin/Tools/ProposedWrite.cs | 6 +- .../Builtin/Tools/SchemaTools.cs | 2 - .../ChangeSets/ChangeSetDirectory.cs | 17 +++++ .../Schema/ContentTypeDirectory.cs | 22 ++++++ .../ChangeSets/SchemaChangeSetTests.cs | 76 +++++++++++++++++++ 5 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs index 01b43a891..738f878cf 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -16,7 +16,6 @@ public static async Task RunAsync( ToolInvocation call, ChangeSetTarget target, Func>> write, - OperationKind kind, CancellationToken cancellationToken) { var services = call.Services; @@ -33,11 +32,14 @@ public static async Task RunAsync( return ToolResult.Failure(set.Errors); } + // The set says what the write became: a revision of a type with nothing live is recorded as its create. + var recorded = set.Value.Operations.Single(operation => operation.ItemKind == ItemKind.Schema && operation.ItemId == type.Value.Id); + return ToolResult.Success(new JsonObject { ["contentType"] = ToolJson.ContentType(type.Value), ["changeSet"] = ToolJson.ChangeSet(set.Value), - ["next"] = $"The {ToolJson.Name(kind)} is in change set {set.Value.Id}, which stays open for more " + ["next"] = $"The {ToolJson.Name(recorded.Kind)} is in change set {set.Value.Id}, which stays open for more " + "operations. Call submit_change_set when it is complete; checks run and a person reviews it before it is published.", }); } diff --git a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs index afaa6283c..cfc41f12e 100644 --- a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs @@ -147,7 +147,6 @@ private static async Task CreateAsync(ToolInvocation call, Cancellat call, target!, (directory, changeSet) => directory.CreateAsync(new NewContentType(name!, slug!, parsed!), changeSet, cancellationToken), - OperationKind.Create, cancellationToken); } @@ -168,7 +167,6 @@ private static async Task ReviseAsync(ToolInvocation call, Cancellat call, target!, (directory, changeSet) => directory.ReviseAsync(slug!, baseVersion!.Value, parsed!, changeSet, cancellationToken), - OperationKind.Update, cancellationToken); } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 31646950a..b75c259a7 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -344,6 +344,23 @@ internal async Task> PrepareAsync( : Result.Failure(allowed.Errors); } + // Whether a set that can still publish, one not yet published, rejected or reverted, proposes this version of the item. + internal Task ProposesAsync(ItemKind itemKind, Guid itemId, int version, CancellationToken cancellationToken) + { + return ( + from operation in context.ChangeSetOperations.AsNoTracking() + join set in context.ChangeSets.AsNoTracking() on operation.ChangeSetId equals set.Id + where set.SiteId == SiteId + && operation.ItemKind == itemKind + && operation.ItemId == itemId + && operation.ProposedVersion == version + && set.State != ChangeSetState.Published + && set.State != ChangeSetState.Rejected + && set.State != ChangeSetState.Reverted + select operation.Id) + .AnyAsync(cancellationToken); + } + // False when another call changed the set since PrepareAsync read it; the caller rolls back and answers with a conflict. internal async Task WriteAsync(PendingOperation pending, Guid? previousVersionId, CancellationToken cancellationToken) { diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index cbaa215be..35213aa80 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -144,6 +144,28 @@ public async Task> ReviseAsync( return Result.Failure([VersionConflict(row, baseVersion)]); } + // A draft is only worth building on while a set that can still publish proposes it; a revision of a rejected or reverted + // draft could never publish over the version that is live. + if (row.PublishedVersion is { } published + && baseVersion != published + && !await changeSets.ProposesAsync(ItemKind.Schema, row.Id, baseVersion, cancellationToken)) + { + return Result.Failure( + SchemaErrorCodes.VersionConflict, + $"Version {baseVersion} of the content type '{slug}' is a draft that no open change set proposes any more, so it will never " + + $"be published; revise the published version {published} instead."); + } + + // A type with nothing live is made live again by a create, so the revision needs the scope to create it (MCP-05). + if (row.PublishedVersion is null) + { + var creatable = await permissions.CheckDefinitionAsync(slug, AgentOperation.Create, cancellationToken); + if (!creatable.IsSuccess) + { + return Result.Failure(creatable.Errors); + } + } + var based = await context.ContentTypeVersions .AsNoTracking() .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == baseVersion, cancellationToken); diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs index 2dec94bf6..01b08be53 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs @@ -117,9 +117,14 @@ public async Task A_revision_based_on_the_published_version_leaves_an_unpublishe var changeSet = await scenario.OpenAsync(); var target = ChangeSetTarget.Existing(changeSet.Id); + // The rejected draft is still the latest version, but no set that can publish proposes it, so it is no base. + var deadDraft = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 2, WithPrice, target)); var revised = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithPrice, target)); var unknownBase = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 2, WithPrice, target)); + var refusal = Assert.Single(deadDraft.Errors); + Assert.Equal(SchemaErrorCodes.VersionConflict, refusal.Code); + Assert.Contains("revise the published version 1", refusal.Message); Assert.Equal(3, revised.Value.Version); Assert.Equal([Update(created, 1, 3)], (await scenario.RunAsync(directory => directory.GetAsync(changeSet.Id))).Value.Operations); Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(unknownBase.Errors).Code); @@ -224,6 +229,77 @@ public async Task An_agent_writes_only_into_its_own_set_and_within_its_cap(strin await scenario.QueryAsync(context => context.AuditEvents.AsNoTracking().Where(row => row.Operation == "agent.items_per_change_set_exceeded").ToListAsync())); } + // A draft an open set proposes is a base for the next draft, which publishes after it. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_may_build_on_a_draft_an_open_set_still_proposes(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var created = await PublishedProductAsync(scenario); + var first = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, ChangeSetTarget.Existing(first.Id))); + var second = await scenario.OpenAsync(); + + var chained = await scenario.RunSchemaAsync(directory => directory.ReviseAsync( + "product", + 2, + new ContentTypeDefinition([Title, new FieldDefinition("summary", "text"), new FieldDefinition("price", "number")]), + ChangeSetTarget.Existing(second.Id))); + + Assert.Equal(3, chained.Value.Version); + Assert.Equal([Update(created, 2, 3)], (await scenario.RunAsync(directory => directory.GetAsync(second.Id))).Value.Operations); + await scenario.PublishAsync(first.Id); + await scenario.PublishAsync(second.Id); + Assert.Equal(3, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value.Version); + } + + // A revision of a type with nothing live is recorded as its create, so an agent needs the create scope for it (MCP-05). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_without_the_create_scope_cannot_revise_a_type_with_nothing_live(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + await scenario.ContentTypeAsync("product"); + var own = await scenario.OpenAsync(); + await scenario.UpdateAgentAsync(row => row.Operations = [AgentOperation.Read, AgentOperation.Update]); + + var revised = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, ChangeSetTarget.Existing(own.Id))); + + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Assert.Single(revised.Errors).Code); + Assert.Empty(await scenario.OperationRowsAsync(own.Id)); + Assert.Equal(1, (await scenario.RunSchemaAsync(directory => directory.GetAsync("product"))).Value.Version); + } + + // Inside a caller's unit of work the revision's rows are saved, so rolling that unit back is what undoes them. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_rollback_after_the_rows_reached_the_database_leaves_neither_the_version_nor_the_operation(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var changeSet = await scenario.OpenAsync(); + var target = ChangeSetTarget.Existing(changeSet.Id); + var created = (await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, target))).Value; + var events = (await scenario.ChangeSetEventsAsync()).Count; + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + Assert.True((await scope.ServiceProvider.GetRequiredService().ReviseAsync("product", 1, WithSummary, target)).IsSuccess); + + Assert.True(await context.ContentTypeVersions.AsNoTracking().AnyAsync(row => row.ContentTypeId == created.Id && row.Version == 2)); + Assert.Equal(2, await context.ChangeSetOperations.AsNoTracking().Where(row => row.ChangeSetId == changeSet.Id).Select(row => row.ProposedVersion).SingleAsync()); + } + + Assert.Equal(1, (await scenario.RunSchemaAsync(directory => directory.GetAsync("product"))).Value.Version); + Assert.Single(await scenario.QueryAsync(context => context.ContentTypeVersions.AsNoTracking().Where(row => row.ContentTypeId == created.Id).ToListAsync())); + Assert.Equal(1, Assert.Single(await scenario.OperationRowsAsync(changeSet.Id)).ProposedVersion); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + } // A rejected create leaves a type no set has published, and a reverted one takes it offline; either way the next revision // records a create, so the type can be published again. [Theory] From a67a0cb083686e9594f77dea38b37f633eec575b Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:18:04 -0400 Subject: [PATCH 12/23] Pass the change set target in the desk set view tests --- tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs | 2 +- tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs b/tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs index 6ef5c8755..de821142b 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs @@ -114,7 +114,7 @@ public async Task A_rejected_revert_set_shows_its_reason_and_links_to_the_set_it private static Task CreateTypeAsync(AdminHost host, Guid userId, string slug) { return host.AsUserAsync(userId, async directory => - (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value); + (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)).Value); } private static ChangeOperation Create(ContentType type) diff --git a/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs b/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs index 6296029e0..ca288c37d 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs @@ -171,7 +171,7 @@ private static Task AwaitingReviewAsync(AdminHost host, Guid userId, private static async Task OpenAsync(AdminHost host, Guid userId, string slug, params ChangeSetState[] moves) { var type = await host.AsUserAsync(userId, async directory => - (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value); + (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)).Value); return await host.AsUserAsync(userId, async directory => { From dd92475b722f8ed41d138670cd7706a8fc7a1424 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:08:04 -0400 Subject: [PATCH 13/23] Guard open drafts and check references against live definitions --- .../Builtin/Tools/ProposedWrite.cs | 2 +- .../ChangeSets/ChangeSetDirectory.cs | 88 ++++++++----- .../Schema/ContentTypeDirectory.cs | 119 +++++++++++------- .../ChangeSets/SchemaChangeSetTests.cs | 61 ++++++++- .../Schema/ContentTypeDirectoryTests.cs | 7 +- 5 files changed, 195 insertions(+), 82 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs index 738f878cf..f9eb9aeb8 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -47,7 +47,7 @@ public static async Task RunAsync( { return errors.FirstOrDefault()?.Code switch { - SchemaErrorCodes.VersionConflict => "Call get_content_type for the current version and its fields, then revise from that version.", + SchemaErrorCodes.VersionConflict => "The message names the version to revise; call get_content_type with that version for its fields, then revise from it.", SchemaErrorCodes.ContentTypeNotFound => "Call list_content_types for the slugs of the site's content types.", _ => null, }; diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index aa43cc8eb..5a5873170 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -310,8 +310,8 @@ public Task> RejectAsync(Guid changeSetId, string reason, Canc // A write of an item in another directory that is also an operation of a change set, such as a content type revision // (SCH-05), joins its set in two steps, so the item's rows and the operation commit together. PrepareAsync finds every - // expected failure before the caller's unit of work begins; WriteAsync runs inside it, with the row of the version the write - // was based on. + // expected failure before the caller's unit of work begins; BeginWriteAsync and RecordAsync run inside it, around the item's own + // rows and event. // ItemPublished says whether a change set has published the item, so an update of one that is not live records a create. internal async Task> PrepareAsync( ChangeSetTarget target, @@ -351,56 +351,76 @@ internal async Task> PrepareAsync( return Result.Failure(recorded.Errors); } - var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken); - return allowed.IsSuccess - ? Result.Success(new PendingOperation(set, null, 0, Extends: false)) - : Result.Failure(allowed.Errors); + // The daily cap is counted when the set is written, inside the caller's unit of work, as for OpenAsync. + return Result.Success(new PendingOperation(set, null, 0, Extends: false)); } // Whether a set that can still publish, one not yet published, rejected or reverted, proposes this version of the item. internal Task ProposesAsync(ItemKind itemKind, Guid itemId, int version, CancellationToken cancellationToken) { - return ( - from operation in context.ChangeSetOperations.AsNoTracking() - join set in context.ChangeSets.AsNoTracking() on operation.ChangeSetId equals set.Id - where set.SiteId == SiteId - && operation.ItemKind == itemKind - && operation.ItemId == itemId - && operation.ProposedVersion == version - && set.State != ChangeSetState.Published - && set.State != ChangeSetState.Rejected - && set.State != ChangeSetState.Reverted - select operation.Id) - .AnyAsync(cancellationToken); + return LiveOperations(itemKind) + .AnyAsync(operation => operation.ItemId == itemId && operation.ProposedVersion == version, cancellationToken); } - // False when another call changed the set since PrepareAsync read it; the caller rolls back and answers with a conflict. - internal async Task WriteAsync(PendingOperation pending, Guid? previousVersionId, CancellationToken cancellationToken) + // The versions of these items that sets which can still publish propose, as (item, version). + internal async Task> LiveProposalsAsync( + ItemKind itemKind, + IReadOnlyCollection itemIds, + CancellationToken cancellationToken) + { + var rows = await LiveOperations(itemKind) + .Where(operation => itemIds.Contains(operation.ItemId) && operation.ProposedVersion != null) + .Select(operation => new { operation.ItemId, Version = operation.ProposedVersion!.Value }) + .ToListAsync(cancellationToken); + + return rows.Select(row => (row.ItemId, row.Version)).ToHashSet(); + } + + private IQueryable LiveOperations(ItemKind itemKind) + { + return + from operation in context.ChangeSetOperations.AsNoTracking() + join set in context.ChangeSets.AsNoTracking() on operation.ChangeSetId equals set.Id + where set.SiteId == SiteId + && operation.ItemKind == itemKind + && set.State != ChangeSetState.Published + && set.State != ChangeSetState.Rejected + && set.State != ChangeSetState.Reverted + select operation; + } + + // The first step inside the caller's unit of work, before the item's own rows and event: it opens the set or moves its revision. + // It fails when an agent's daily cap refuses the set the write opens, counted here with the agent's credential held so concurrent + // opens take turns, or when another call changed the set since PrepareAsync read it. Either way the caller rolls back. + internal async Task BeginWriteAsync(PendingOperation pending, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(pending); var set = pending.ChangeSet; - if (pending.Revision is not { } revision) + if (pending.Revision is { } revision) { - await InsertAsync(set, cancellationToken); - } - else if (!await UpdateAsync(revision, set, cancellationToken)) - { - return false; + return await UpdateAsync(revision, set, cancellationToken) ? Result.Success() : Result.Failure(Conflict(set.Id).Errors); } - if (pending.Extends) - { - await ExtendOperationAsync(set, pending.Position, previousVersionId, cancellationToken); - } - else + var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken); + if (!allowed.IsSuccess) { - await InsertOperationAsync(set, pending.Position, previousVersionId, cancellationToken); + return allowed; } - return true; + await InsertAsync(set, cancellationToken); + return Result.Success(); } + // The last step, after the item's own rows and event: the operation, with the row of the version the write was based on. + internal Task RecordAsync(PendingOperation pending, Guid? previousVersionId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(pending); + + return pending.Extends + ? ExtendOperationAsync(pending.ChangeSet, pending.Position, previousVersionId, cancellationToken) + : InsertOperationAsync(pending.ChangeSet, pending.Position, previousVersionId, cancellationToken); + } private async Task> PrepareJoinAsync( StoredChangeSet stored, ChangeOperation operation, @@ -595,7 +615,7 @@ await context.ChangeSetOperations .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.ProposedVersion, operation.ProposedVersion), cancellationToken); await auditLog.AppendAsync( - new NewAuditEvent("change_set.operation_revised") + new NewAuditEvent("change_set.operation_updated") { SubjectType = ItemSubjectType(operation.ItemKind), SubjectId = operation.ItemId, diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index 35213aa80..3df81baba 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -93,6 +93,11 @@ public async Task> CreateAsync(NewContentType contentType, C } await using var scope = await unitOfWork.BeginAsync(cancellationToken); + var begun = await changeSets.BeginWriteAsync(pending.Value, cancellationToken); + if (!begun.IsSuccess) + { + return Result.Failure(begun.Errors); + } context.ContentTypes.Add(new ContentTypeRow { @@ -106,11 +111,7 @@ public async Task> CreateAsync(NewContentType contentType, C await auditLog.AppendAsync( new NewAuditEvent("content_type.created") { SubjectType = SubjectType, SubjectId = type.Id, ChangeSetId = pending.Value.ChangeSet.Id }, cancellationToken); - - if (!await changeSets.WriteAsync(pending.Value, null, cancellationToken)) - { - return Result.Failure([SetConflict(pending.Value)]); - } + await changeSets.RecordAsync(pending.Value, null, cancellationToken); return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, SlugTaken(type.Slug)), cancellationToken); } @@ -178,18 +179,29 @@ public async Task> ReviseAsync( return revised; } - // A revision from the published version drops the latest draft's fields too, so other types must not reference those either. + // A revision from the published version sets aside the latest draft, which an open set may still publish and other drafts may + // build on, so it must not take away what that draft gives them either: its fields and its entity flag (SCH-03, SCH-04). var dropped = new List { previous.Definition }; - if (baseVersion != row.Version) + var errors = new List(); + if (baseVersion != row.Version && await changeSets.ProposesAsync(ItemKind.Schema, row.Id, row.Version, cancellationToken)) { var latest = await context.ContentTypeVersions .AsNoTracking() .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == row.Version, cancellationToken); - dropped.Add(ReadDefinition(row.Slug, latest.Version, latest.Definition)); + var draft = ReadDefinition(row.Slug, latest.Version, latest.Definition); + dropped.Add(draft); + + if (draft.IsEntity && !definition.IsEntity) + { + errors.Add(new Error( + SchemaErrorCodes.EntityFlagRemoved, + $"The latest draft of the content type '{slug}', version {row.Version}, makes it an entity type, and an open change set " + + "may still publish it, so a revision must keep \"entity\": true.")); + } } var type = revised.Value; - var errors = await ReferenceErrorsAsync(type, cancellationToken); + errors.AddRange(await ReferenceErrorsAsync(type, cancellationToken)); errors.AddRange(await RemovedFieldsInUseAsync(type, dropped, cancellationToken)); if (errors.Count > 0) { @@ -207,6 +219,11 @@ public async Task> ReviseAsync( } await using var scope = await unitOfWork.BeginAsync(cancellationToken); + var begun = await changeSets.BeginWriteAsync(pending.Value, cancellationToken); + if (!begun.IsSuccess) + { + return Result.Failure(begun.Errors); + } // Moves the pointer only from the latest version this call read; a concurrent revision that committed first leaves nothing to move. var moved = await context.ContentTypes @@ -214,7 +231,7 @@ public async Task> ReviseAsync( .ExecuteUpdateAsync(setters => setters.SetProperty(stored => stored.Version, type.Version), cancellationToken); if (moved == 0) { - return Result.Failure([VersionConflict(row, baseVersion)]); + return Result.Failure([RevisedMeanwhile(row)]); } context.ContentTypeVersions.Add(VersionRow(type)); @@ -228,12 +245,10 @@ await auditLog.AppendAsync( }, cancellationToken); - if (!await changeSets.WriteAsync(pending.Value, based.Id, cancellationToken)) - { - return Result.Failure([SetConflict(pending.Value)]); - } + await changeSets.RecordAsync(pending.Value, based.Id, cancellationToken); - return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, VersionConflict(row, baseVersion)), cancellationToken); + + return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, RevisedMeanwhile(row)), cancellationToken); } private async Task> ReadAsync(string slug, Func version, CancellationToken cancellationToken) @@ -278,6 +293,8 @@ private async Task> ReadAsync(string slug, Func> ReferenceErrorsAsync(ContentType type, CancellationToken cancellationToken) { var references = type.Definition.References(fieldTypes); @@ -287,13 +304,13 @@ private async Task> ReferenceErrorsAsync(ContentType type, Cancellat } var others = references.Select(reference => reference.Target.Type).Where(slug => slug != type.Slug).Distinct().ToList(); - var targets = others.Count == 0 ? [] : await CurrentDefinitionsAsync(others, cancellationToken); - targets[type.Slug] = type.Definition; + var targets = others.Count == 0 ? [] : await LiveDefinitionsAsync(others, cancellationToken); + targets[type.Slug] = [type.Definition]; var errors = new List(); foreach (var (field, target) in references) { - if (!targets.TryGetValue(target.Type, out var definition)) + if (!targets.TryGetValue(target.Type, out var definitions) || definitions.Count == 0) { errors.Add(new Error( SchemaErrorCodes.ReferenceTargetUnknown, @@ -303,14 +320,14 @@ private async Task> ReferenceErrorsAsync(ContentType type, Cancellat { continue; } - else if (!definition.IsEntity) + else if (!definitions.Any(definition => definition.IsEntity)) { // Only facts are referenced field by field (SCH-04); anything else is referenced as a whole entry. errors.Add(new Error( SchemaErrorCodes.ReferenceTargetNotEntity, $"Field '{field}': '{target.Type}' is not an entity type, so its fields cannot be referenced; reference its entries instead.")); } - else if (!definition.HasField(target.Field)) + else if (!definitions.Any(definition => definition.IsEntity && definition.HasField(target.Field))) { errors.Add(new Error( SchemaErrorCodes.ReferenceFieldUnknown, @@ -321,7 +338,8 @@ private async Task> ReferenceErrorsAsync(ContentType type, Cancellat return errors; } - // A field another type points at cannot go, or that reference would dangle (SCH-04). + // A field another type points at cannot go, or that reference would dangle (SCH-04), whether it points from that type's live + // version or from a draft an open set may still publish. private async Task> RemovedFieldsInUseAsync(ContentType type, IEnumerable previous, CancellationToken cancellationToken) { var removed = previous.SelectMany(definition => definition.Fields).Select(field => field.Name).Where(name => !type.Definition.HasField(name)).ToHashSet(); @@ -330,12 +348,15 @@ private async Task> RemovedFieldsInUseAsync(ContentType type, IEnume return []; } - var definitions = await CurrentDefinitionsAsync(slugs: null, cancellationToken); + var definitions = await LiveDefinitionsAsync(slugs: null, cancellationToken); return definitions .Where(other => other.Key != type.Slug) .OrderBy(other => other.Key, StringComparer.Ordinal) - .SelectMany(other => other.Value.References(fieldTypes).Select(reference => (Type: other.Key, Reference: reference))) + .SelectMany(other => other.Value + .SelectMany(definition => definition.References(fieldTypes)) + .DistinctBy(reference => (reference.Field, reference.Target.Type, reference.Target.Field)) + .Select(reference => (Type: other.Key, Reference: reference))) .Where(use => use.Reference.Target.Type == type.Slug && use.Reference.Target.Field is { } field && removed.Contains(field)) .Select(use => new Error( SchemaErrorCodes.FieldReferenced, @@ -343,27 +364,37 @@ private async Task> RemovedFieldsInUseAsync(ContentType type, IEnume .ToList(); } - // The current definition of every type in the site, or of the given slugs only. - private async Task> CurrentDefinitionsAsync( + // What each type is live as or may become: its published definition, and its latest one while a set that can still publish + // proposes it. Every type in the site, or the given slugs only; a type with neither, such as one whose create was rejected, has none. + private async Task>> LiveDefinitionsAsync( IReadOnlyCollection? slugs, CancellationToken cancellationToken) { - var query = - from type in context.ContentTypes.AsNoTracking() - join version in context.ContentTypeVersions.AsNoTracking() - on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } - where type.SiteId == SiteId - select new { type.Slug, version.Version, version.Definition }; - + var types = context.ContentTypes.AsNoTracking().Where(type => type.SiteId == SiteId); if (slugs is not null) { - query = query.Where(row => slugs.Contains(row.Slug)); + types = types.Where(type => slugs.Contains(type.Slug)); } - var rows = await query.ToListAsync(cancellationToken); - return rows.ToDictionary(row => row.Slug, row => ReadDefinition(row.Slug, row.Version, row.Definition)); - } + var rows = await ( + from type in types + join version in context.ContentTypeVersions.AsNoTracking() on type.Id equals version.ContentTypeId + where version.Version == type.Version || version.Version == type.PublishedVersion + select new { type.Id, type.Slug, type.PublishedVersion, version.Version, version.Definition }) + .ToListAsync(cancellationToken); + var proposed = await changeSets.LiveProposalsAsync(ItemKind.Schema, rows.Select(row => row.Id).Distinct().ToList(), cancellationToken); + + return rows + .GroupBy(row => row.Slug, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group + .Where(row => row.Version == row.PublishedVersion || proposed.Contains((row.Id, row.Version))) + .Select(row => ReadDefinition(row.Slug, row.Version, row.Definition)) + .ToList(), + StringComparer.Ordinal); + } private static async Task> CommitAsync( IUnitOfWorkScope scope, ContentType type, @@ -422,24 +453,24 @@ private static Error SlugTaken(string slug) private static Error VersionConflict(ContentTypeRow row, int baseVersion) { - var published = row.PublishedVersion is { } version ? $" or the published version {version}" : string.Empty; + var published = row.PublishedVersion is { } version && version != row.Version ? $" nor the published version {version}" : string.Empty; return new Error( SchemaErrorCodes.VersionConflict, - $"Version {baseVersion} is not the latest version of the content type '{row.Slug}'{published}; read it again and revise one of those."); + $"Version {baseVersion} is neither the latest version {row.Version} of the content type '{row.Slug}'{published}; revise one of those."); } + private static Error RevisedMeanwhile(ContentTypeRow row) + { + return new Error( + SchemaErrorCodes.VersionConflict, + $"Another call revised the content type '{row.Slug}' at the same time; read its latest version again and revise that one."); + } private static ChangeOperation Operation(OperationKind kind, Guid contentTypeId, int? baseVersion, int proposedVersion) { return ChangeOperation.Create(kind, ItemKind.Schema, contentTypeId, baseVersion, proposedVersion).Value; } - private static Error SetConflict(ChangeSetDirectory.PendingOperation pending) - { - return new Error( - ChangeSetErrorCodes.Conflict, - $"Change set {pending.ChangeSet.Id} was changed by another call at the same time; read it again and retry."); - } // A unique index at commit is this write's own conflict or, when the write opened its session's set, possibly a concurrent // first write of the same session. diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs index 01b08be53..43c2eaf14 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs @@ -67,7 +67,7 @@ public async Task A_revision_in_the_same_set_extends_the_items_operation(string var events = await scenario.ChangeSetEventsAsync(changeSet.Id); Assert.Equal( - ["change_set.opened", "content_type.created", "change_set.operation_added", "content_type.revised", "change_set.operation_revised"], + ["change_set.opened", "content_type.created", "change_set.operation_added", "content_type.revised", "change_set.operation_updated"], events.Select(row => row.Operation)); var firstVersion = await VersionRowAsync(scenario, created.Id, 1); Assert.Equal([firstVersion, firstVersion], events.Skip(3).Select(row => row.PreviousVersionId)); @@ -150,6 +150,9 @@ public async Task Writes_through_a_session_share_the_set_their_first_write_opens var stored = Assert.Single(await scenario.ChangeSetRowsAsync(), row => row.SessionId == "schema-session"); Assert.Equal(CatalogSync.Rationale, stored.Rationale); + Assert.Equal( + ["change_set.opened", "content_type.created", "change_set.operation_added", "content_type.created", "change_set.operation_added"], + (await scenario.ChangeSetEventsAsync(stored.Id)).Select(row => row.Operation)); Assert.Equal(Amir.UserId, stored.InitiatorId); Assert.Equal([Create(product), Create(category)], (await scenario.RunAsync(directory => directory.GetAsync(stored.Id))).Value.Operations); } @@ -300,6 +303,62 @@ public async Task A_rollback_after_the_rows_reached_the_database_leaves_neither_ Assert.Equal(1, Assert.Single(await scenario.OperationRowsAsync(changeSet.Id)).ProposedVersion); Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); } + // An open set may still publish the latest draft, so a revision from the published version keeps what the draft adds; once the + // draft's set is rejected, the draft no longer counts. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_from_the_published_version_keeps_the_entity_flag_of_a_draft_an_open_set_proposes(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var name = new FieldDefinition("name", "text"); + await PublishedProductAsync(scenario); + var draft = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Title, name], isEntity: true), ChangeSetTarget.Existing(draft.Id))); + var other = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + + var plain = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Title, name]), other)); + + Assert.Equal(SchemaErrorCodes.EntityFlagRemoved, Assert.Single(plain.Errors).Code); + + await scenario.RunAsync(directory => directory.TransitionAsync(draft.Id, ChangeSetState.ChecksRunning)); + await scenario.RunAsync(directory => directory.TransitionAsync(draft.Id, ChangeSetState.AwaitingReview)); + await scenario.RunAsync(directory => directory.RejectAsync(draft.Id, "Products are not facts.")); + Assert.True((await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Title, name]), other))).IsSuccess); + } + + // References are checked against what is live or an open set may still publish: the live version keeps its references after a + // rejected draft drops them, and a field only a rejected draft has is no target (SCH-04). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task References_follow_the_live_and_open_versions_and_not_rejected_drafts(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var name = new FieldDefinition("name", "text"); + var country = new FieldDefinition("country", "text"); + var brandCountry = new FieldDefinition("brandCountry", "reference", settings: Json("""{"target": "brand", "field": "country"}""")); + var brandLogo = new FieldDefinition("brandLogo", "reference", settings: Json("""{"target": "brand", "field": "logo"}""")); + var live = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Brand", "brand", new ContentTypeDefinition([name, country], isEntity: true)), ChangeSetTarget.Existing(live.Id))); + await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Page", "page", new ContentTypeDefinition([Title, brandCountry])), ChangeSetTarget.Existing(live.Id))); + await scenario.PublishAsync(live.Id); + + var rejected = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("page", 1, new ContentTypeDefinition([Title]), ChangeSetTarget.Existing(rejected.Id))); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("brand", 1, new ContentTypeDefinition([name, country, new FieldDefinition("logo", "text")], isEntity: true), ChangeSetTarget.Existing(rejected.Id))); + await scenario.RunAsync(directory => directory.TransitionAsync(rejected.Id, ChangeSetState.ChecksRunning)); + await scenario.RunAsync(directory => directory.TransitionAsync(rejected.Id, ChangeSetState.AwaitingReview)); + await scenario.RunAsync(directory => directory.RejectAsync(rejected.Id, "Not now.")); + var next = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + + var dropCountry = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("brand", 1, new ContentTypeDefinition([name], isEntity: true), next)); + var toRejectedField = await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Ad", "ad", new ContentTypeDefinition([Title, brandLogo])), next)); + + Assert.Equal(SchemaErrorCodes.FieldReferenced, Assert.Single(dropCountry.Errors).Code); + Assert.Contains("'brandCountry' of 'page'", dropCountry.Errors[0].Message); + Assert.Equal(SchemaErrorCodes.ReferenceFieldUnknown, Assert.Single(toRejectedField.Errors).Code); + } // A rejected create leaves a type no set has published, and a reverted one takes it offline; either way the next revision // records a create, so the type can be published again. [Theory] diff --git a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs index e770396cf..74b9e126d 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs @@ -243,10 +243,13 @@ public async Task Of_two_concurrent_revisions_of_one_version_one_wins_and_the_ot await scenario.CreateAsync("product", ProductFields); var definition = new ContentTypeDefinition([.. ProductFields, Field("summary", "text")]); + // Each writer has its own set, so the two meet only on the version. + var first = await scenario.OpenChangeSetAsync(); + var second = await scenario.OpenChangeSetAsync(); var lost = await RaceAsync( scenario, - directory => directory.ReviseAsync("product", 1, definition, TestChangeSetTargets.ContentModel), - directory => directory.ReviseAsync("product", 1, definition, TestChangeSetTargets.ContentModel)); + directory => directory.ReviseAsync("product", 1, definition, first), + directory => directory.ReviseAsync("product", 1, definition, second)); Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(lost.Errors).Code); Assert.Equal(2, Assert.Single(await scenario.ContentTypeRowsAsync()).Version); From 1854b2c6d5ad49e7bcd2999b3a2013b5fc78ed2e Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:19:28 -0400 Subject: [PATCH 14/23] Pass the change set target in the browser tests --- tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs | 2 +- tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs index 81a6f98bf..f5d5323f5 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs @@ -47,7 +47,7 @@ public async Task The_showcase_desk_and_set_view_have_no_wcag_21_aa_violations() private static async Task AwaitingReviewAsync(BrowserSession session) { var type = await session.Host.AsUserAsync(session.OwnerId, async directory => - (await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value); + (await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)).Value); return await session.Host.AsUserAsync(session.OwnerId, async directory => { diff --git a/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs b/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs index aa0bbe633..f6d1f0db4 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs @@ -120,7 +120,7 @@ private static async Task AwaitingReviewAsync(BrowserSession session, { var slug = $"{name}-{index}"; types.Add(await session.Host.AsUserAsync(session.OwnerId, async directory => - (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value)); + (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)).Value)); } return await session.Host.AsUserAsync(session.OwnerId, async directory => From bf73410d76d19456628333a5a8b19eeecdbeec0e Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:24:34 -0400 Subject: [PATCH 15/23] Pass the change set target in the security event tests --- tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs b/tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs index f96e50f9f..f7dfc0266 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/SecurityEventTests.cs @@ -116,7 +116,7 @@ public async Task An_anonymous_request_for_the_security_view_is_sent_to_sign_in( private static async Task AwaitingReviewAsync(AdminHost host, Guid ownerId) { var type = await host.AsUserAsync(ownerId, async directory => - (await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value); + (await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)).Value); await host.AsUserAsync(ownerId, async directory => { var set = (await directory.OpenAsync(new NewChangeSet("Add the product type.", []))).Value; From 3d93c3371d9d22e3b5fc120f783adbae8fbbd946 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 03:21:34 -0400 Subject: [PATCH 16/23] Check references against what each type is heading to --- .../Builtin/Tools/ChangeSetTarget.cs | 1 + .../Builtin/Tools/ProposedWrite.cs | 3 +- .../ChangeSets/ChangeSetDirectory.cs | 18 +-- .../Schema/ContentTypeDirectory.cs | 119 ++++++++++-------- .../ChangeSets/SchemaChangeSetTests.cs | 115 +++++++++++++++++ 5 files changed, 188 insertions(+), 68 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs index 555790b30..94bf38955 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs @@ -109,6 +109,7 @@ public Task> AddAsync(IChangeSetDirectory directory, ChangeOpe ? directory.AddOperationAsync(id, operation, cancellationToken) : directory.AddToSessionAsync(_sessionId!, _opening!, operation, cancellationToken); } + // The directories take the same target; they find every refusal, a cap included, before they write anything (SCH-05). public DirectoryTarget ToDirectoryTarget() { diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs index f9eb9aeb8..586d0bc4e 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -43,11 +43,12 @@ public static async Task RunAsync( + "operations. Call submit_change_set when it is complete; checks run and a person reviews it before it is published.", }); } + private static string? Hint(IReadOnlyList errors) { return errors.FirstOrDefault()?.Code switch { - SchemaErrorCodes.VersionConflict => "The message names the version to revise; call get_content_type with that version for its fields, then revise from it.", + SchemaErrorCodes.VersionConflict => "The message says which version to revise; read it with get_content_type, then revise from it.", SchemaErrorCodes.ContentTypeNotFound => "Call list_content_types for the slugs of the site's content types.", _ => null, }; diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 5a5873170..a5dacffce 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -362,21 +362,8 @@ internal Task ProposesAsync(ItemKind itemKind, Guid itemId, int version, C .AnyAsync(operation => operation.ItemId == itemId && operation.ProposedVersion == version, cancellationToken); } - // The versions of these items that sets which can still publish propose, as (item, version). - internal async Task> LiveProposalsAsync( - ItemKind itemKind, - IReadOnlyCollection itemIds, - CancellationToken cancellationToken) - { - var rows = await LiveOperations(itemKind) - .Where(operation => itemIds.Contains(operation.ItemId) && operation.ProposedVersion != null) - .Select(operation => new { operation.ItemId, Version = operation.ProposedVersion!.Value }) - .ToListAsync(cancellationToken); - - return rows.Select(row => (row.ItemId, row.Version)).ToHashSet(); - } - - private IQueryable LiveOperations(ItemKind itemKind) + // The operations of sets that can still publish, for a caller's query to join. + internal IQueryable LiveOperations(ItemKind itemKind) { return from operation in context.ChangeSetOperations.AsNoTracking() @@ -421,6 +408,7 @@ internal Task RecordAsync(PendingOperation pending, Guid? previousVersionId, Can ? ExtendOperationAsync(pending.ChangeSet, pending.Position, previousVersionId, cancellationToken) : InsertOperationAsync(pending.ChangeSet, pending.Position, previousVersionId, cancellationToken); } + private async Task> PrepareJoinAsync( StoredChangeSet stored, ChangeOperation operation, diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index 3df81baba..e84a0fcb7 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -179,30 +179,22 @@ public async Task> ReviseAsync( return revised; } - // A revision from the published version sets aside the latest draft, which an open set may still publish and other drafts may - // build on, so it must not take away what that draft gives them either: its fields and its entity flag (SCH-03, SCH-04). - var dropped = new List { previous.Definition }; + // A revision may set drafts aside, such as the latest one when it is based on the published version. An open set may still + // publish them and other drafts may build on them, so it must not take away what they give: their fields and their entity + // flag (SCH-03, SCH-04). + var live = (await LiveDefinitionsAsync([slug], cancellationToken)).GetValueOrDefault(slug) ?? []; var errors = new List(); - if (baseVersion != row.Version && await changeSets.ProposesAsync(ItemKind.Schema, row.Id, row.Version, cancellationToken)) + if (!definition.IsEntity && live.FirstOrDefault(candidate => !candidate.Published && candidate.Definition.IsEntity) is { } entity) { - var latest = await context.ContentTypeVersions - .AsNoTracking() - .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == row.Version, cancellationToken); - var draft = ReadDefinition(row.Slug, latest.Version, latest.Definition); - dropped.Add(draft); - - if (draft.IsEntity && !definition.IsEntity) - { - errors.Add(new Error( - SchemaErrorCodes.EntityFlagRemoved, - $"The latest draft of the content type '{slug}', version {row.Version}, makes it an entity type, and an open change set " - + "may still publish it, so a revision must keep \"entity\": true.")); - } + errors.Add(new Error( + SchemaErrorCodes.EntityFlagRemoved, + $"Version {entity.Version} of the content type '{slug}', which an open change set may still publish, makes it an entity " + + "type, so a revision must keep \"entity\": true.")); } var type = revised.Value; errors.AddRange(await ReferenceErrorsAsync(type, cancellationToken)); - errors.AddRange(await RemovedFieldsInUseAsync(type, dropped, cancellationToken)); + errors.AddRange(await RemovedFieldsInUseAsync(type, [previous.Definition, .. live.Select(candidate => candidate.Definition)], cancellationToken)); if (errors.Count > 0) { return Result.Failure(errors); @@ -247,7 +239,6 @@ await auditLog.AppendAsync( await changeSets.RecordAsync(pending.Value, based.Id, cancellationToken); - return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, RevisedMeanwhile(row)), cancellationToken); } @@ -293,8 +284,8 @@ private async Task> ReadAsync(string slug, Func> ReferenceErrorsAsync(ContentType type, CancellationToken cancellationToken) { var references = type.Definition.References(fieldTypes); @@ -305,39 +296,58 @@ private async Task> ReferenceErrorsAsync(ContentType type, Cancellat var others = references.Select(reference => reference.Target.Type).Where(slug => slug != type.Slug).Distinct().ToList(); var targets = others.Count == 0 ? [] : await LiveDefinitionsAsync(others, cancellationToken); - targets[type.Slug] = [type.Definition]; var errors = new List(); foreach (var (field, target) in references) { - if (!targets.TryGetValue(target.Type, out var definitions) || definitions.Count == 0) + if (target.Type == type.Slug) { - errors.Add(new Error( - SchemaErrorCodes.ReferenceTargetUnknown, - $"Field '{field}': there is no content type '{target.Type}' in this site to reference.")); + errors.AddRange(FieldTargetErrors(field, target, type.Definition)); } - else if (target.Field is null) + else if (!targets.TryGetValue(target.Type, out var definitions)) { - continue; - } - else if (!definitions.Any(definition => definition.IsEntity)) - { - // Only facts are referenced field by field (SCH-04); anything else is referenced as a whole entry. errors.Add(new Error( - SchemaErrorCodes.ReferenceTargetNotEntity, - $"Field '{field}': '{target.Type}' is not an entity type, so its fields cannot be referenced; reference its entries instead.")); + SchemaErrorCodes.ReferenceTargetUnknown, + $"Field '{field}': there is no content type '{target.Type}' in this site to reference.")); } - else if (!definitions.Any(definition => definition.IsEntity && definition.HasField(target.Field))) + else { - errors.Add(new Error( - SchemaErrorCodes.ReferenceFieldUnknown, - $"Field '{field}': the entity type '{target.Type}' has no field '{target.Field}'.")); + // A draft is named by its version when the published version may read differently. + var drafts = definitions.Where(candidate => !candidate.Published).ToList(); + var published = drafts.Count < definitions.Count; + errors.AddRange((drafts.Count > 0 ? drafts : definitions) + .Select(candidate => FieldTargetErrors(field, target, candidate.Definition, published && !candidate.Published ? candidate.Version : null)) + .FirstOrDefault(found => found.Count > 0) ?? []); } } return errors; } + // Only facts are referenced field by field (SCH-04); anything else is referenced as a whole entry. + private static List FieldTargetErrors(string field, ReferenceTarget target, ContentTypeDefinition definition, int? draft = null) + { + if (target.Field is null) + { + return []; + } + + string Name(string kind) => draft is { } version + ? $"version {version} of {kind}'{target.Type}', which an open change set may still publish," + : $"{kind}'{target.Type}'"; + + if (!definition.IsEntity) + { + return [new Error( + SchemaErrorCodes.ReferenceTargetNotEntity, + $"Field '{field}': {Name(string.Empty)} is not an entity type, so its fields cannot be referenced; reference its entries instead.")]; + } + + return definition.HasField(target.Field) + ? [] + : [new Error(SchemaErrorCodes.ReferenceFieldUnknown, $"Field '{field}': {Name("the entity type ")} has no field '{target.Field}'.")]; + } + // A field another type points at cannot go, or that reference would dangle (SCH-04), whether it points from that type's live // version or from a draft an open set may still publish. private async Task> RemovedFieldsInUseAsync(ContentType type, IEnumerable previous, CancellationToken cancellationToken) @@ -354,7 +364,7 @@ private async Task> RemovedFieldsInUseAsync(ContentType type, IEnume .Where(other => other.Key != type.Slug) .OrderBy(other => other.Key, StringComparer.Ordinal) .SelectMany(other => other.Value - .SelectMany(definition => definition.References(fieldTypes)) + .SelectMany(candidate => candidate.Definition.References(fieldTypes)) .DistinctBy(reference => (reference.Field, reference.Target.Type, reference.Target.Field)) .Select(reference => (Type: other.Key, Reference: reference))) .Where(use => use.Reference.Target.Type == type.Slug && use.Reference.Target.Field is { } field && removed.Contains(field)) @@ -364,9 +374,10 @@ private async Task> RemovedFieldsInUseAsync(ContentType type, IEnume .ToList(); } - // What each type is live as or may become: its published definition, and its latest one while a set that can still publish - // proposes it. Every type in the site, or the given slugs only; a type with neither, such as one whose create was rejected, has none. - private async Task>> LiveDefinitionsAsync( + // What each type is live as or may become: its published definition, and every draft a set that can still publish proposes, in + // version order. Every type in the site, or the given slugs only; a type with neither, such as one whose create was rejected, is + // left out. + private async Task>> LiveDefinitionsAsync( IReadOnlyCollection? slugs, CancellationToken cancellationToken) { @@ -376,25 +387,26 @@ private async Task>> LiveDefiniti types = types.Where(type => slugs.Contains(type.Slug)); } + var proposed = changeSets.LiveOperations(ItemKind.Schema); var rows = await ( from type in types join version in context.ContentTypeVersions.AsNoTracking() on type.Id equals version.ContentTypeId - where version.Version == type.Version || version.Version == type.PublishedVersion - select new { type.Id, type.Slug, type.PublishedVersion, version.Version, version.Definition }) + where version.Version == type.PublishedVersion + || proposed.Any(operation => operation.ItemId == type.Id && operation.ProposedVersion == version.Version) + select new { type.Slug, type.PublishedVersion, version.Version, version.Definition }) .ToListAsync(cancellationToken); - var proposed = await changeSets.LiveProposalsAsync(ItemKind.Schema, rows.Select(row => row.Id).Distinct().ToList(), cancellationToken); - return rows .GroupBy(row => row.Slug, StringComparer.Ordinal) .ToDictionary( group => group.Key, group => group - .Where(row => row.Version == row.PublishedVersion || proposed.Contains((row.Id, row.Version))) - .Select(row => ReadDefinition(row.Slug, row.Version, row.Definition)) + .OrderBy(row => row.Version) + .Select(row => new LiveDefinition(row.Version, row.Version == row.PublishedVersion, ReadDefinition(row.Slug, row.Version, row.Definition))) .ToList(), StringComparer.Ordinal); } + private static async Task> CommitAsync( IUnitOfWorkScope scope, ContentType type, @@ -453,11 +465,12 @@ private static Error SlugTaken(string slug) private static Error VersionConflict(ContentTypeRow row, int baseVersion) { - var published = row.PublishedVersion is { } version && version != row.Version ? $" nor the published version {version}" : string.Empty; - return new Error( SchemaErrorCodes.VersionConflict, - $"Version {baseVersion} is neither the latest version {row.Version} of the content type '{row.Slug}'{published}; revise one of those."); + row.PublishedVersion is { } published && published != row.Version + ? $"Version {baseVersion} is neither the latest version {row.Version} of the content type '{row.Slug}' nor the published " + + $"version {published}; revise one of those." + : $"Version {baseVersion} is not the latest version {row.Version} of the content type '{row.Slug}'; revise that one."); } private static Error RevisedMeanwhile(ContentTypeRow row) @@ -466,12 +479,12 @@ private static Error RevisedMeanwhile(ContentTypeRow row) SchemaErrorCodes.VersionConflict, $"Another call revised the content type '{row.Slug}' at the same time; read its latest version again and revise that one."); } + private static ChangeOperation Operation(OperationKind kind, Guid contentTypeId, int? baseVersion, int proposedVersion) { return ChangeOperation.Create(kind, ItemKind.Schema, contentTypeId, baseVersion, proposedVersion).Value; } - // A unique index at commit is this write's own conflict or, when the write opened its session's set, possibly a concurrent // first write of the same session. private static Error CommitConflict(ChangeSetTarget target, ChangeSetDirectory.PendingOperation pending, Error own) @@ -482,4 +495,6 @@ private static Error CommitConflict(ChangeSetTarget target, ChangeSetDirectory.P $"Another call wrote this content type or opened a change set for session '{session}' at the same time; read it again and retry.") : own; } + + private sealed record LiveDefinition(int Version, bool Published, ContentTypeDefinition Definition); } diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs index 43c2eaf14..e8cce84ce 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs @@ -22,6 +22,12 @@ public sealed class SchemaChangeSetTests private static readonly ContentTypeDefinition WithPrice = new([Title, new FieldDefinition("price", "number")]); + private static readonly FieldDefinition Name = new("name", "text"); + + private static readonly FieldDefinition Logo = new("logo", "text"); + + private static readonly FieldDefinition BrandLogo = new("brandLogo", "reference", settings: Json("""{"target": "brand", "field": "logo"}""")); + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] @@ -303,6 +309,7 @@ public async Task A_rollback_after_the_rows_reached_the_database_leaves_neither_ Assert.Equal(1, Assert.Single(await scenario.OperationRowsAsync(changeSet.Id)).ProposedVersion); Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); } + // An open set may still publish the latest draft, so a revision from the published version keeps what the draft adds; once the // draft's set is rejected, the draft no longer counts. [Theory] @@ -359,6 +366,7 @@ public async Task References_follow_the_live_and_open_versions_and_not_rejected_ Assert.Contains("'brandCountry' of 'page'", dropCountry.Errors[0].Message); Assert.Equal(SchemaErrorCodes.ReferenceFieldUnknown, Assert.Single(toRejectedField.Errors).Code); } + // A rejected create leaves a type no set has published, and a reverted one takes it offline; either way the next revision // records a create, so the type can be published again. [Theory] @@ -423,6 +431,95 @@ public async Task A_revision_from_the_published_version_cannot_drop_a_field_a_dr Assert.Contains("'country'", error.Message); Assert.Equal(2, (await scenario.RunSchemaAsync(directory => directory.GetAsync("brand"))).Value.Version); } + + // A draft that removes a field is what its type is heading to, so the field is no target while an open set may still publish + // that draft, whichever set the reference is written into (SCH-04). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_field_a_pending_draft_removes_cannot_be_referenced(string provider) + { + await using var scenario = await ScenarioAsync(provider); + await PublishedBrandAndPageAsync(scenario); + var draft = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("brand", 1, new ContentTypeDefinition([Name], isEntity: true), draft)); + var other = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + + var sameSet = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("page", 1, new ContentTypeDefinition([Title, BrandLogo]), draft)); + var otherSet = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("page", 1, new ContentTypeDefinition([Title, BrandLogo]), other)); + + foreach (var refused in new[] { sameSet, otherSet }) + { + var error = Assert.Single(refused.Errors); + Assert.Equal(SchemaErrorCodes.ReferenceFieldUnknown, error.Code); + Assert.Contains("version 2 of the entity type 'brand'", error.Message); + } + } + + // Every draft an open set proposes may still publish, not only the latest one, so a field any of them references stays. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_field_an_older_open_draft_references_cannot_be_removed(string provider) + { + await using var scenario = await ScenarioAsync(provider); + await PublishedBrandAndPageAsync(scenario); + var referencing = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("page", 1, new ContentTypeDefinition([Title, BrandLogo]), referencing)); + var latest = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("page", 1, WithSummary, latest)); + var next = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + + var dropLogo = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("brand", 1, new ContentTypeDefinition([Name], isEntity: true), next)); + + var error = Assert.Single(dropLogo.Errors); + Assert.Equal(SchemaErrorCodes.FieldReferenced, error.Code); + Assert.Contains("'brandLogo' of 'page'", error.Message); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_keeps_the_entity_flag_of_an_older_open_draft(string provider) + { + await using var scenario = await ScenarioAsync(provider); + await PublishedProductAsync(scenario); + var open = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Title], isEntity: true), open)); + var rejected = await scenario.OpenAsync(); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Title, Name], isEntity: true), ChangeSetTarget.Existing(rejected.Id))); + await RejectAsync(scenario, rejected.Id); + var next = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + + var plain = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithPrice, next)); + + var error = Assert.Single(plain.Errors); + Assert.Equal(SchemaErrorCodes.EntityFlagRemoved, error.Code); + Assert.StartsWith("Version 2 of the content type 'product'", error.Message); + } + + // The daily cap is counted inside the write's unit of work, so concurrent opens take turns; a refusal stores nothing but its + // security event (SEC-08). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_write_that_would_open_a_set_past_the_agents_daily_cap_is_refused(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + await scenario.UpdateAgentAsync(row => row.ChangeSetsPerDay = 1); + await scenario.OpenAsync(); + + var refused = await scenario.RunSchemaAsync(directory => directory.CreateAsync(Product, ChangeSetTarget.Session("schema-session", CatalogSync))); + + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, Assert.Single(refused.Errors).Code); + Assert.Single(await scenario.ChangeSetRowsAsync()); + Assert.False(await scenario.QueryAsync(context => context.ContentTypes.AnyAsync(row => row.Slug == "product"))); + Assert.Single(await scenario.QueryAsync(context => context.AuditEvents + .AsNoTracking() + .Where(row => row.Operation == "agent.change_sets_per_day_exceeded") + .ToListAsync())); + } + // A revision moves the latest version and the set's revision before its events are appended, so each failure point comes // after something reached the database. [Theory] @@ -477,6 +574,7 @@ private static JsonElement Json(string json) using var document = JsonDocument.Parse(json); return document.RootElement.Clone(); } + // Content type writes here are a person's, who also makes the review moves (MCP-06). private static async Task ScenarioAsync(string provider) { @@ -494,6 +592,23 @@ private static async Task PublishedProductAsync(ChangeSetScenario s return created; } + // An entity brand with a logo, and a page that references nothing yet, both live. + private static async Task PublishedBrandAndPageAsync(ChangeSetScenario scenario) + { + var changeSet = await scenario.OpenAsync(); + var target = ChangeSetTarget.Existing(changeSet.Id); + await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Brand", "brand", new ContentTypeDefinition([Name, Logo], isEntity: true)), target)); + await scenario.RunSchemaAsync(directory => directory.CreateAsync(new NewContentType("Page", "page", new ContentTypeDefinition([Title])), target)); + await scenario.PublishAsync(changeSet.Id); + } + + private static async Task RejectAsync(ChangeSetScenario scenario, Guid changeSetId) + { + await scenario.RunAsync(directory => directory.TransitionAsync(changeSetId, ChangeSetState.ChecksRunning)); + await scenario.RunAsync(directory => directory.TransitionAsync(changeSetId, ChangeSetState.AwaitingReview)); + await scenario.RunAsync(directory => directory.RejectAsync(changeSetId, "Not now.")); + } + private static Task VersionRowAsync(ChangeSetScenario scenario, Guid contentTypeId, int version) { return scenario.QueryAsync(context => context.ContentTypeVersions From e76711de2b42a138b604c8dd086ee2fdfd1779aa Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 03:24:56 -0400 Subject: [PATCH 17/23] Pass the change set target in the search and media tests --- tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs | 4 ++-- tests/FluentCMS.IntegrationTests/Media/MediaScreenTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs index acd8d5f0c..ae5d0ad4a 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs @@ -67,10 +67,10 @@ public async Task A_page_is_not_found_through_a_match_the_agent_may_not_read(str { await using var agent = await ConnectedAgent.StartAsync(provider, types: ["page"]); await agent.AsPersonAsync(directory => - directory.CreateAsync(new NewContentType("product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])))); + directory.CreateAsync(new NewContentType("product", "product", new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)); await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType("page", "page", new ContentTypeDefinition( - [new FieldDefinition("title", "text"), new FieldDefinition("product", "reference", settings: JsonSerializer.SerializeToElement(new { target = "product" }))])))); + [new FieldDefinition("title", "text"), new FieldDefinition("product", "reference", settings: JsonSerializer.SerializeToElement(new { target = "product" }))])), TestChangeSetTargets.ContentModel)); var product = await CreateAsync(agent, "product", "Part 4411"); var page = (await agent.AsPersonAsync(entries => entries.CreateAsync(new NewEntry("page", JsonSerializer.SerializeToElement(new { title = "Spring sale", product = product.ToString() }))))).Id; diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaScreenTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaScreenTests.cs index c4a44b6ad..d510c4e12 100644 --- a/tests/FluentCMS.IntegrationTests/Media/MediaScreenTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/MediaScreenTests.cs @@ -132,7 +132,7 @@ public async Task The_asset_page_lists_where_it_is_used() await host.AsUserAsync(ownerId, async types => (await types.CreateAsync(new NewContentType( "Product page", "product-page", - new ContentTypeDefinition([new FieldDefinition("title", "text"), new FieldDefinition("hero", "media")])))).Value); + new ContentTypeDefinition([new FieldDefinition("title", "text"), new FieldDefinition("hero", "media")])), TestChangeSetTargets.ContentModel)).Value); var page = await host.AsUserAsync(ownerId, async entries => (await entries.CreateAsync(new NewEntry( "product-page", JsonSerializer.Deserialize($$"""{"title": "Drills", "hero": "{{hero}}"}""")))).Value); From e979151674aefe80c418861152aae991418c8001 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 04:43:54 -0400 Subject: [PATCH 18/23] Revise a set's own draft after another set drafts the type --- .../ChangeSets/ChangeSetDirectory.cs | 19 +++++++++++++ .../Schema/ContentTypeDirectory.cs | 21 +++++++++------ .../ChangeSets/SchemaChangeSetTests.cs | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 4a23873e8..3885b2f0b 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -355,6 +355,25 @@ internal async Task> PrepareAsync( return Result.Success(new PendingOperation(set, null, 0, Extends: false)); } + // The version the set a write joins already proposes for the item, which a later write in that set builds on (SCH-05). Null when + // that set holds no operation proposing a version of the item, or the write would open the set. + internal async Task ProposedVersionAsync(ChangeSetTarget target, ItemKind itemKind, Guid itemId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + + var setId = target.ChangeSetId ?? await OpenSetOfSessionAsync(Initiator(), target.SessionId!, cancellationToken); + if (setId is null) + { + return null; + } + + return await context.ChangeSetOperations + .AsNoTracking() + .Where(operation => operation.ChangeSetId == setId && operation.ItemKind == itemKind && operation.ItemId == itemId) + .Select(operation => operation.ProposedVersion) + .SingleOrDefaultAsync(cancellationToken); + } + // Whether a set that can still publish, one not yet published, rejected or reverted, proposes this version of the item. internal Task ProposesAsync(ItemKind itemKind, Guid itemId, int version, CancellationToken cancellationToken) { diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index e84a0fcb7..97c78715c 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -139,10 +139,12 @@ public async Task> ReviseAsync( return NotFound(slug); } - // The published version is a base too, so a revision can leave an unpublished draft behind, such as a rejected one. - if (baseVersion != row.Version && baseVersion != row.PublishedVersion) + // The published version is a base too, so a revision can leave an unpublished draft behind, such as a rejected one. A set that + // already holds the type is revised from the version it proposes, however many drafts other sets have written since. + var own = await changeSets.ProposedVersionAsync(changeSet, ItemKind.Schema, row.Id, cancellationToken); + if (own is { } proposed ? baseVersion != proposed : baseVersion != row.Version && baseVersion != row.PublishedVersion) { - return Result.Failure([VersionConflict(row, baseVersion)]); + return Result.Failure([VersionConflict(row, baseVersion, own)]); } // A draft is only worth building on while a set that can still publish proposes it; a revision of a rejected or reverted @@ -463,14 +465,17 @@ private static Error SlugTaken(string slug) return new Error(SchemaErrorCodes.SlugTaken, $"The slug '{slug}' is already used by another content type in this site."); } - private static Error VersionConflict(ContentTypeRow row, int baseVersion) + private static Error VersionConflict(ContentTypeRow row, int baseVersion, int? own) { return new Error( SchemaErrorCodes.VersionConflict, - row.PublishedVersion is { } published && published != row.Version - ? $"Version {baseVersion} is neither the latest version {row.Version} of the content type '{row.Slug}' nor the published " - + $"version {published}; revise one of those." - : $"Version {baseVersion} is not the latest version {row.Version} of the content type '{row.Slug}'; revise that one."); + own is { } proposed + ? $"The change set already proposes version {proposed} of the content type '{row.Slug}', so a later write of it in that set " + + $"revises version {proposed}, not {baseVersion}." + : row.PublishedVersion is { } published && published != row.Version + ? $"Version {baseVersion} is neither the latest version {row.Version} of the content type '{row.Slug}' nor the published " + + $"version {published}; revise one of those." + : $"Version {baseVersion} is not the latest version {row.Version} of the content type '{row.Slug}'; revise that one."); } private static Error RevisedMeanwhile(ContentTypeRow row) diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs index e8cce84ce..3426d698f 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs @@ -263,6 +263,33 @@ public async Task A_revision_may_build_on_a_draft_an_open_set_still_proposes(str Assert.Equal(3, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value.Version); } + // A set that already holds a type builds on its own draft, whatever other sets have drafted since. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_revises_its_own_draft_after_another_set_drafted_the_type(string provider) + { + await using var scenario = await ScenarioAsync(provider); + var created = await PublishedProductAsync(scenario); + var first = await scenario.OpenAsync(); + var target = ChangeSetTarget.Existing(first.Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, target)); + var other = ChangeSetTarget.Existing((await scenario.OpenAsync()).Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithPrice, other)); + var both = new ContentTypeDefinition([Title, new FieldDefinition("summary", "text"), new FieldDefinition("price", "number")]); + + var fromLatest = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 3, both, target)); + var fromOwn = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 2, both, target)); + + var refusal = Assert.Single(fromLatest.Errors); + Assert.Equal(SchemaErrorCodes.VersionConflict, refusal.Code); + Assert.Contains("already proposes version 2", refusal.Message); + Assert.Equal(4, fromOwn.Value.Version); + Assert.Equal([Update(created, 1, 4)], (await scenario.RunAsync(directory => directory.GetAsync(first.Id))).Value.Operations); + await scenario.PublishAsync(first.Id); + Assert.Equal(4, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value.Version); + } + // A revision of a type with nothing live is recorded as its create, so an agent needs the create scope for it (MCP-05). [Theory] [InlineData(DatabaseProviders.Sqlite)] From 23986ee2830a089d6d45802882de054f8907fd2f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 04:48:42 -0400 Subject: [PATCH 19/23] Pass the change set target in the review action and form tests --- tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs | 2 +- .../FluentCMS.IntegrationTests/Forms/EntryFormScreenTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs b/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs index f9eeb0df4..34cc65bdf 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs @@ -64,7 +64,7 @@ public async Task Submitting_an_open_set_runs_its_checks_and_the_view_shows_each await using var host = await AdminHost.StartAsync(install); var ownerId = await host.CreateFirstAdministratorAsync(); await host.AsUserAsync(ownerId, async types => (await types.CreateAsync(new NewContentType( - "Product page", "product-page", new ContentTypeDefinition([new FieldDefinition("headline", "text", required: true)])))).Value); + "Product page", "product-page", new ContentTypeDefinition([new FieldDefinition("headline", "text", required: true)])), TestChangeSetTargets.ContentModel)).Value); await host.AsUserAsync(ownerId, async checks => { var definition = new FluentCMS.Core.Checks.CheckDefinition( diff --git a/tests/FluentCMS.IntegrationTests/Forms/EntryFormScreenTests.cs b/tests/FluentCMS.IntegrationTests/Forms/EntryFormScreenTests.cs index 1768adb8a..600a012ae 100644 --- a/tests/FluentCMS.IntegrationTests/Forms/EntryFormScreenTests.cs +++ b/tests/FluentCMS.IntegrationTests/Forms/EntryFormScreenTests.cs @@ -157,7 +157,7 @@ public async Task The_content_pages_list_types_and_entries_and_need_a_signed_in_ private static async Task<(Guid Category, Guid Asset)> KitchenSinkAsync(AdminHost host, Guid ownerId) { await host.AsUserAsync(ownerId, async types => (await types.CreateAsync(new NewContentType( - "Category", "category", new ContentTypeDefinition([new FieldDefinition("name", "text", required: true)])))).Value); + "Category", "category", new ContentTypeDefinition([new FieldDefinition("name", "text", required: true)])), TestChangeSetTargets.ContentModel)).Value); await host.AsUserAsync(ownerId, async types => (await types.CreateAsync(new NewContentType( "Kitchen sink", "kitchen-sink", @@ -174,7 +174,7 @@ await host.AsUserAsync(ownerId, async types new FieldDefinition("hero", "media"), new FieldDefinition("tags", "list", settings: Json("""{"itemType": "text"}""")), new FieldDefinition("extra", "json"), - ])))).Value); + ])), TestChangeSetTargets.ContentModel)).Value); var category = await host.AsUserAsync(ownerId, async entries => (await entries.CreateAsync(new NewEntry("category", Json("""{"name": "Tools"}""")))).Value); var asset = await host.AsUserAsync(ownerId, async library => (await library.UploadAsync(new NewAsset( From df554c7a5c946d3ed8d24669447921c24e9b8c33 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 04:56:39 -0400 Subject: [PATCH 20/23] Count the form test's sets by session --- tests/FluentCMS.IntegrationTests/Forms/EntryEditsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Forms/EntryEditsTests.cs b/tests/FluentCMS.IntegrationTests/Forms/EntryEditsTests.cs index a40238c7e..381d90ae0 100644 --- a/tests/FluentCMS.IntegrationTests/Forms/EntryEditsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Forms/EntryEditsTests.cs @@ -58,7 +58,7 @@ public async Task A_refused_save_stores_neither_the_version_nor_a_set(string pro Assert.Equal(ChangeSetErrorCodes.RationaleMissing, Assert.Single(noReason.Errors).Code); Assert.NotEmpty(invalid.Errors); Assert.Empty(await scenario.EntryRowsAsync()); - Assert.Equal(0, await scenario.Schema.QueryAsync(context => context.ChangeSets.CountAsync(row => row.Rationale != "content-model"))); + Assert.Equal(0, await scenario.Schema.QueryAsync(context => context.ChangeSets.CountAsync(row => row.SessionId != TestChangeSetTargets.ContentModelSession))); } private static async Task CreateAsync(string provider) From cb6ca0a81a84444347d2ecfd8a4c5c8e4997701f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 05:17:56 -0400 Subject: [PATCH 21/23] Pass the change set target in the entity form and search tests --- .../Forms/EntityFormScreenTests.cs | 2 +- tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs b/tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs index 0edeb35d6..99ed5c60f 100644 --- a/tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs +++ b/tests/FluentCMS.IntegrationTests/Forms/EntityFormScreenTests.cs @@ -90,6 +90,6 @@ private static Task ProductTypeAsync(AdminHost host, Guid ownerId) return host.AsUserAsync(ownerId, async types => (await types.CreateAsync(new NewContentType( "Product", "product", - new ContentTypeDefinition([new FieldDefinition("name", "text", required: true), new FieldDefinition("price", "number")], isEntity: true)))).Value); + new ContentTypeDefinition([new FieldDefinition("name", "text", required: true), new FieldDefinition("price", "number")], isEntity: true)), TestChangeSetTargets.ContentModel)).Value); } } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs index a17629e0b..a77af392d 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/SearchToolsTests.cs @@ -92,10 +92,10 @@ public async Task An_agent_filters_by_field_values_and_references_with_or_withou await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType("product", "product", new ContentTypeDefinition( [new FieldDefinition("title", "text"), new FieldDefinition("price", "number")], - isEntity: true)))); + isEntity: true)), TestChangeSetTargets.ContentModel)); await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType("page", "page", new ContentTypeDefinition( - [new FieldDefinition("title", "text"), new FieldDefinition("product", "reference", settings: JsonSerializer.SerializeToElement(new { target = "product" }))])))); + [new FieldDefinition("title", "text"), new FieldDefinition("product", "reference", settings: JsonSerializer.SerializeToElement(new { target = "product" }))])), TestChangeSetTargets.ContentModel)); var cheap = await CreateAsync(agent, "product", new { title = Fact("Part 4411"), price = Fact(19.99) }); var dear = await CreateAsync(agent, "product", new { title = Fact("Part 4412"), price = Fact(25) }); var page = await CreateAsync(agent, "page", new { title = "Spring sale", product = cheap.ToString() }); @@ -136,7 +136,7 @@ public async Task Field_filters_that_do_not_fit_one_type_are_tool_errors_with_th await using var agent = await ConnectedAgent.StartAsync(provider); await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType("product", "product", new ContentTypeDefinition( - [new FieldDefinition("title", "text"), new FieldDefinition("price", "number")])))); + [new FieldDefinition("title", "text"), new FieldDefinition("price", "number")])), TestChangeSetTargets.ContentModel)); await agent.CreateAsPersonAsync("page"); async Task CodeAsync(Dictionary arguments) From e6b54255f86588df7976b464b9489e3550dbca23 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 05:17:58 -0400 Subject: [PATCH 22/23] Refuse a revision into a closed set as not open --- .../ChangeSets/ChangeSetDirectory.cs | 15 ++++++++++----- .../ChangeSets/SchemaChangeSetTests.cs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 3885b2f0b..fda8df643 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -356,7 +356,8 @@ internal async Task> PrepareAsync( } // The version the set a write joins already proposes for the item, which a later write in that set builds on (SCH-05). Null when - // that set holds no operation proposing a version of the item, or the write would open the set. + // that set holds no operation proposing a version of the item, the write would open the set, or the set is not open, which the + // join then refuses. internal async Task ProposedVersionAsync(ChangeSetTarget target, ItemKind itemKind, Guid itemId, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(target); @@ -367,10 +368,14 @@ internal async Task> PrepareAsync( return null; } - return await context.ChangeSetOperations - .AsNoTracking() - .Where(operation => operation.ChangeSetId == setId && operation.ItemKind == itemKind && operation.ItemId == itemId) - .Select(operation => operation.ProposedVersion) + return await ( + from operation in context.ChangeSetOperations.AsNoTracking() + join set in context.ChangeSets.AsNoTracking() on operation.ChangeSetId equals set.Id + where set.Id == setId + && set.State == ChangeSetState.Open + && operation.ItemKind == itemKind + && operation.ItemId == itemId + select operation.ProposedVersion) .SingleOrDefaultAsync(cancellationToken); } diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs index 3426d698f..13f4bcec8 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/SchemaChangeSetTests.cs @@ -290,6 +290,24 @@ public async Task A_set_revises_its_own_draft_after_another_set_drafted_the_type Assert.Equal(4, (await scenario.RunSchemaAsync(directory => directory.GetPublishedAsync("product"))).Value.Version); } + // A closed set takes no more writes, whatever it proposes, so the refusal says so rather than naming a version to revise. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_into_a_rejected_set_that_holds_the_type_is_refused_as_not_open(string provider) + { + await using var scenario = await ScenarioAsync(provider); + await PublishedProductAsync(scenario); + var rejected = await scenario.OpenAsync(); + var target = ChangeSetTarget.Existing(rejected.Id); + await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithSummary, target)); + await RejectAsync(scenario, rejected.Id); + + var revised = await scenario.RunSchemaAsync(directory => directory.ReviseAsync("product", 1, WithPrice, target)); + + Assert.Equal(ChangeSetErrorCodes.NotOpen, Assert.Single(revised.Errors).Code); + } + // A revision of a type with nothing live is recorded as its create, so an agent needs the create scope for it (MCP-05). [Theory] [InlineData(DatabaseProviders.Sqlite)] From 60412041693bde5e53d33b236625293b5feb8716 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 05:52:00 -0400 Subject: [PATCH 23/23] Move the form picker tests to the change set target --- tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs index fd7443337..d5e6a2b75 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs @@ -49,7 +49,7 @@ public async Task A_person_picks_a_referenced_entry_by_search_with_the_keyboard_ private static async Task ModelAsync(BrowserSession session) { await session.Host.AsUserAsync(session.OwnerId, async types => (await types.CreateAsync(new NewContentType( - "Product", "product", new ContentTypeDefinition([new FieldDefinition("name", "text", required: true)])))).Value); + "Product", "product", new ContentTypeDefinition([new FieldDefinition("name", "text", required: true)])), TestChangeSetTargets.ContentModel)).Value); await session.Host.AsUserAsync(session.OwnerId, async types => (await types.CreateAsync(new NewContentType( "Product page", "product-page", @@ -57,7 +57,7 @@ await session.Host.AsUserAsync(session.Owner [ new FieldDefinition("title", "text", required: true), new FieldDefinition("product", "reference", settings: JsonDocument.Parse("""{"target": "product"}""").RootElement), - ])))).Value); + ])), TestChangeSetTargets.ContentModel)).Value); await session.Host.AsUserAsync(session.OwnerId, async entries => (await entries.CreateAsync(new NewEntry("product", JsonDocument.Parse("""{"name": "Hammer"}""").RootElement))).Value); var drill = await session.Host.AsUserAsync(session.OwnerId, async entries =>