Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b5ee173
Record content type writes as change set operations
pournasserian Oct 18, 2026
01efe9d
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 18, 2026
1c905eb
Pass the change set target through the schema write tools
pournasserian Oct 18, 2026
1b82ea7
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 18, 2026
96f0f8c
Pass the change set target in the agent scope tests
pournasserian Oct 18, 2026
460031c
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 18, 2026
c3af4f7
Pass the change set target in the entry change set tests
pournasserian Oct 18, 2026
b69ddd3
Drop the unused adding count from the change set size check
pournasserian Oct 19, 2026
861fa68
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
dbf7c06
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
9135738
Pass the change set target in the entity relations test
pournasserian Oct 19, 2026
a545178
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
4519401
Pass the change set target in the entity tests
pournasserian Oct 19, 2026
1d6e597
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
6bed7b5
Pass the change set target in the check tool tests
pournasserian Oct 19, 2026
fb0456f
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
c45d652
Publish revisions of unpublished types as creates and guard draft ref…
pournasserian Oct 19, 2026
8b7a0cd
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
f749251
Pass the change set target in the desk screen tests
pournasserian Oct 19, 2026
d10ca90
Refuse revisions of dead drafts and check the create scope
pournasserian Oct 19, 2026
9180cb0
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
a67a0cb
Pass the change set target in the desk set view tests
pournasserian Oct 19, 2026
835bac5
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
14960f8
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
dd92475
Guard open drafts and check references against live definitions
pournasserian Oct 19, 2026
a83602b
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
1854b2c
Pass the change set target in the browser tests
pournasserian Oct 19, 2026
e729ccd
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
bf73410
Pass the change set target in the security event tests
pournasserian Oct 19, 2026
3d93c33
Check references against what each type is heading to
pournasserian Oct 19, 2026
2064dd3
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
e76711d
Pass the change set target in the search and media tests
pournasserian Oct 19, 2026
e979151
Revise a set's own draft after another set drafts the type
pournasserian Oct 19, 2026
261ea4f
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
23986ee
Pass the change set target in the review action and form tests
pournasserian Oct 19, 2026
df554c7
Count the form test's sets by session
pournasserian Oct 19, 2026
326789f
Merge remote-tracking branch 'origin/v2' into 2513-schema-change-sets
pournasserian Oct 19, 2026
cb6ca0a
Pass the change set target in the entity form and search tests
pournasserian Oct 19, 2026
e6b5425
Refuse a revision into a closed set as not open
pournasserian Oct 19, 2026
f0b1627
Merge remote-tracking branch 'origin/v2' into check-2520
pournasserian Oct 19, 2026
6041204
Move the form picker tests to the change set target
pournasserian Oct 19, 2026
12f2ac0
Merge remote-tracking branch 'origin/v2' into check-2520
pournasserian Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using FluentCMS.Core.Results;
using FluentCMS.Core.Tools;
using Microsoft.Extensions.DependencyInjection;
using DirectoryTarget = FluentCMS.Core.ChangeSets.ChangeSetTarget;

namespace FluentCMS.Core.Builtin.Tools;

Expand Down Expand Up @@ -82,9 +83,10 @@ 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.
// An item the set already holds takes no more room, since its operation moves on to the newer version.
// For a write whose directory does not join the change set itself, such as an entry's: 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. An item the set already holds takes no more
// room, since its operation moves on to the newer version.
public async Task<Result> CheckAsync(IServiceProvider services, Guid? itemId, CancellationToken cancellationToken)
{
var directory = services.GetRequiredService<IChangeSetDirectory>();
Expand All @@ -109,4 +111,16 @@ public Task<Result<ChangeSet>> 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()
{
return _changeSetId is { } id ? DirectoryTarget.Existing(id) : DirectoryTarget.Session(_sessionId!, _opening!);
}

// The set the write went into, read after it to answer the call.
public Task<Result<ChangeSet>> ReadAsync(IChangeSetDirectory directory, CancellationToken cancellationToken)
{
return _changeSetId is { } id ? directory.GetAsync(id, cancellationToken) : directory.GetSessionSetAsync(_sessionId!, cancellationToken);
}
}
29 changes: 10 additions & 19 deletions src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs
Original file line number Diff line number Diff line change
@@ -1,54 +1,45 @@
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<ToolResult> RunAsync(
ToolInvocation call,
ChangeSetTarget target,
Func<IContentTypeDirectory, Task<Result<ContentType>>> write,
Func<ContentType, ChangeOperation> operation,
Func<IContentTypeDirectory, DirectoryTarget, Task<Result<ContentType>>> write,
CancellationToken cancellationToken)
{
var services = call.Services;
var ready = await target.CheckAsync(services, null, 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<IUnitOfWork>().BeginAsync(cancellationToken);

var type = await write(services.GetRequiredService<IContentTypeDirectory>());
var type = await write(services.GetRequiredService<IContentTypeDirectory>(), 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<IChangeSetDirectory>(), proposed, cancellationToken);
var set = await target.ReadAsync(services.GetRequiredService<IChangeSetDirectory>(), cancellationToken);
if (!set.IsSuccess)
{
return ToolResult.Failure(set.Errors);
}

await scope.CommitAsync(cancellationToken);
// 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(proposed.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.",
});
}
Expand All @@ -57,7 +48,7 @@ public static async Task<ToolResult> 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 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,
};
Expand Down
6 changes: 2 additions & 4 deletions src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,7 @@ private static async Task<ToolResult> 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),
cancellationToken);
}

Expand All @@ -167,8 +166,7 @@ private static async Task<ToolResult> 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),
cancellationToken);
}

Expand Down
70 changes: 59 additions & 11 deletions src/FluentCMS.Core/ChangeSets/ChangeSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,18 +170,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). A newer version of an
Expand All @@ -203,6 +195,43 @@ 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. 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(itemPublished || operation.Kind != OperationKind.Update
? operation
: ChangeOperation.Create(OperationKind.Create, operation.ItemKind, operation.ItemId, null, operation.ProposedVersion).Value);
}

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);
Expand Down Expand Up @@ -243,6 +272,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))
Expand Down
33 changes: 33 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetTarget.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 2 additions & 1 deletion src/FluentCMS.Core/Schema/ContentTypeSummary.cs
Original file line number Diff line number Diff line change
@@ -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);
16 changes: 13 additions & 3 deletions src/FluentCMS.Core/Schema/IContentTypeDirectory.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<ContentTypeSummary>> ListAsync(CancellationToken cancellationToken = default);

// The latest version, which authoring builds on: entries are checked against it and the schema document describes it.
Task<Result<ContentType>> GetAsync(string slug, CancellationToken cancellationToken = default);

// The live version, which delivery reads.
Task<Result<ContentType>> GetPublishedAsync(string slug, CancellationToken cancellationToken = default);

// Earlier versions stay readable after a revision.
Task<Result<ContentType>> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default);

Task<Result<ContentType>> CreateAsync(NewContentType contentType, CancellationToken cancellationToken = default);
Task<Result<ContentType>> 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<Result<ContentType>> ReviseAsync(
string slug,
int baseVersion,
ContentTypeDefinition definition,
ChangeSetTarget changeSet,
CancellationToken cancellationToken = default);
}
1 change: 1 addition & 0 deletions src/FluentCMS.Core/Schema/SchemaErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Loading
Loading