From 833bf7e77800749f583a513e6b45a8dde65eabe3 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 09:04:29 -0400 Subject: [PATCH 1/3] Answer a schema write with the change set it joined, read by id --- .../Builtin/Tools/ChangeSetTarget.cs | 6 - .../Builtin/Tools/ProposedWrite.cs | 25 ++- .../Builtin/Tools/SchemaTools.cs | 4 +- .../Schema/IContentTypeDirectory.cs | 13 ++ .../Schema/ContentTypeDirectory.cs | 66 ++++++- .../Mcp/SchemaWriteAnswerTests.cs | 173 ++++++++++++++++++ 6 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs index 141f184ad..9b9519094 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs @@ -117,10 +117,4 @@ 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> ReadAsync(IChangeSetDirectory directory, CancellationToken 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 586d0bc4e..2b99ac47e 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -15,32 +15,39 @@ internal static class ProposedWrite public static async Task RunAsync( ToolInvocation call, ChangeSetTarget target, - Func>> write, + Func>> write, CancellationToken cancellationToken) { var services = call.Services; - var type = await write(services.GetRequiredService(), target.ToDirectoryTarget()); - if (!type.IsSuccess) + var written = await write(services.GetRequiredService(), target.ToDirectoryTarget()); + if (!written.IsSuccess) { - return ToolResult.Failure(type.Errors, Hint(type.Errors)); + return ToolResult.Failure(written.Errors, Hint(written.Errors)); } - var set = await target.ReadAsync(services.GetRequiredService(), cancellationToken); + // The set the write joined, read by its id: one submitted since still answers, in its new state, and a session's next set + // opened since is not mistaken for it. + var type = written.Value.ContentType; + var set = await services.GetRequiredService().GetAsync(written.Value.ChangeSetId, cancellationToken); if (!set.IsSuccess) { 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); + var recorded = set.Value.Operations.Single(operation => operation.ItemKind == ItemKind.Schema && operation.ItemId == type.Id); + var open = set.Value.State == ChangeSetState.Open; return ToolResult.Success(new JsonObject { - ["contentType"] = ToolJson.ContentType(type.Value), + ["contentType"] = ToolJson.ContentType(type), ["changeSet"] = ToolJson.ChangeSet(set.Value), - ["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.", + ["next"] = open + ? $"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." + : $"The {ToolJson.Name(recorded.Kind)} is in change set {set.Value.Id}, which was submitted since; read it with " + + "get_change_set, and write further changes into a new set.", }); } diff --git a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs index cfc41f12e..3df65ac0d 100644 --- a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs @@ -146,7 +146,7 @@ private static async Task CreateAsync(ToolInvocation call, Cancellat return await ProposedWrite.RunAsync( call, target!, - (directory, changeSet) => directory.CreateAsync(new NewContentType(name!, slug!, parsed!), changeSet, cancellationToken), + (directory, changeSet) => directory.CreateInChangeSetAsync(new NewContentType(name!, slug!, parsed!), changeSet, cancellationToken), cancellationToken); } @@ -166,7 +166,7 @@ private static async Task ReviseAsync(ToolInvocation call, Cancellat return await ProposedWrite.RunAsync( call, target!, - (directory, changeSet) => directory.ReviseAsync(slug!, baseVersion!.Value, parsed!, changeSet, cancellationToken), + (directory, changeSet) => directory.ReviseInChangeSetAsync(slug!, baseVersion!.Value, parsed!, changeSet, cancellationToken), cancellationToken); } diff --git a/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs b/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs index 4917443df..d9d228a2c 100644 --- a/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs +++ b/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs @@ -30,4 +30,17 @@ Task> ReviseAsync( ContentTypeDefinition definition, ChangeSetTarget changeSet, CancellationToken cancellationToken = default); + + // As CreateAsync and ReviseAsync, with the change set the write joined, so a caller such as a tool answers from that set instead of + // looking it up again by session, which a submit or the session's next set could change in between. + Task> CreateInChangeSetAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default); + + Task> ReviseInChangeSetAsync( + string slug, + int baseVersion, + ContentTypeDefinition definition, + ChangeSetTarget changeSet, + CancellationToken cancellationToken = default); } + +public sealed record ContentTypeWrite(ContentType ContentType, Guid ChangeSetId); diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index 97c78715c..5b818005d 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; using FluentCMS.Core.ChangeSets; @@ -54,7 +55,51 @@ public Task> GetVersionAsync(string slug, int version, Cance return ReadAsync(slug, _ => version, cancellationToken); } - public async Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) + public Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) + { + return CreateAsync(contentType, changeSet, joined: null, cancellationToken); + } + + public async Task> CreateInChangeSetAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) + { + var joined = new StrongBox(); + return Written(await CreateAsync(contentType, changeSet, joined, cancellationToken), joined); + } + + public Task> ReviseAsync( + string slug, + int baseVersion, + ContentTypeDefinition definition, + ChangeSetTarget changeSet, + CancellationToken cancellationToken = default) + { + return ReviseAsync(slug, baseVersion, definition, changeSet, joined: null, cancellationToken); + } + + public async Task> ReviseInChangeSetAsync( + string slug, + int baseVersion, + ContentTypeDefinition definition, + ChangeSetTarget changeSet, + CancellationToken cancellationToken = default) + { + var joined = new StrongBox(); + return Written(await ReviseAsync(slug, baseVersion, definition, changeSet, joined, cancellationToken), joined); + } + + private static Result Written(Result written, StrongBox joined) + { + return written.IsSuccess + ? Result.Success(new ContentTypeWrite(written.Value, joined.Value)) + : Result.Failure(written.Errors); + } + + // The set the write joined is put in joined once it has committed. + private async Task> CreateAsync( + NewContentType contentType, + ChangeSetTarget changeSet, + StrongBox? joined, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(contentType); ArgumentNullException.ThrowIfNull(changeSet); @@ -113,15 +158,16 @@ await auditLog.AppendAsync( cancellationToken); await changeSets.RecordAsync(pending.Value, null, cancellationToken); - return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, SlugTaken(type.Slug)), cancellationToken); + return Joined(await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, SlugTaken(type.Slug)), cancellationToken), pending.Value, joined); } - public async Task> ReviseAsync( + private async Task> ReviseAsync( string slug, int baseVersion, ContentTypeDefinition definition, ChangeSetTarget changeSet, - CancellationToken cancellationToken = default) + StrongBox? joined, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(slug); ArgumentNullException.ThrowIfNull(definition); @@ -241,7 +287,17 @@ await auditLog.AppendAsync( await changeSets.RecordAsync(pending.Value, based.Id, cancellationToken); - return await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, RevisedMeanwhile(row)), cancellationToken); + return Joined(await CommitAsync(scope, type, CommitConflict(changeSet, pending.Value, RevisedMeanwhile(row)), cancellationToken), pending.Value, joined); + } + + private static Result Joined(Result committed, ChangeSetDirectory.PendingOperation pending, StrongBox? joined) + { + if (committed.IsSuccess && joined is not null) + { + joined.Value = pending.ChangeSet.Id; + } + + return committed; } private async Task> ReadAsync(string slug, Func version, CancellationToken cancellationToken) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs new file mode 100644 index 000000000..aeeddef8b --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Builtin.Tools; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Mcp; + +// A schema tool answers with the set its write joined, read by id, so what happens to the session between the write and the answer, +// such as a submit or the session's next set, cannot change or break the answer. +public sealed class SchemaWriteAnswerTests +{ + private const string Session = "catalog-model"; + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_write_whose_set_is_submitted_before_the_answer_answers_with_that_set_in_its_new_state(string provider) + { + await using var scenario = await Scenario.CreateAsync(provider, async (services, joined) => + Assert.True((await services.GetRequiredService().TransitionAsync(joined, ChangeSetState.ChecksRunning)).IsSuccess)); + + var answer = await scenario.CreateAsync("product"); + + var joined = Assert.Single(scenario.Joined); + Assert.Equal(joined.ToString(), (string?)answer["changeSet"]!["id"]); + Assert.Equal("checks_running", (string?)answer["changeSet"]!["state"]); + Assert.Equal(1, (int?)answer["contentType"]!["version"]); + Assert.Contains("submitted since", (string?)answer["next"], StringComparison.Ordinal); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_write_whose_session_opened_its_next_set_meanwhile_answers_with_the_set_it_joined(string provider) + { + await using var scenario = await Scenario.CreateAsync(provider, async (services, joined) => + { + var changeSets = services.GetRequiredService(); + Assert.True((await changeSets.TransitionAsync(joined, ChangeSetState.ChecksRunning)).IsSuccess); + Assert.True((await services.GetRequiredService().CreateAsync( + new NewContentType("Category", "category", new ContentTypeDefinition([new FieldDefinition("name", "text")])), + ChangeSetTarget.Session(Session, new NewChangeSet("The next model change.", [])))).IsSuccess); + }); + + var answer = await scenario.CreateAsync("product"); + + var joined = scenario.Joined[0]; + Assert.Equal(joined.ToString(), (string?)answer["changeSet"]!["id"]); + Assert.Equal("product", (string?)answer["contentType"]!["slug"]); + Assert.NotEqual(joined, (await scenario.SessionSetAsync()).Id); + } + + private sealed class Scenario : IAsyncDisposable + { + private readonly TemporaryDatabase _database; + private readonly ServiceProvider _services; + private readonly RacingContentTypes.Shared _shared; + + private Scenario(TemporaryDatabase database, ServiceProvider services, RacingContentTypes.Shared shared) + { + _database = database; + _services = services; + _shared = shared; + } + + public IReadOnlyList Joined => _shared.Joined; + + // What another call does right after the tool's write commits and before the tool answers, in a scope of its own. + public static async Task CreateAsync(string provider, Func meanwhile) + { + var database = await TemporaryDatabase.CreateAsync(provider); + var shared = new RacingContentTypes.Shared(meanwhile); + var services = TestServices.Build( + database.Configuration, + collection => collection.AddSingleton(new TestInitiator(new UserInitiator(Guid.NewGuid(), "Amir"))), + collection => + { + var original = collection.Single(descriptor => descriptor.ServiceType == typeof(IContentTypeDirectory)); + collection.Remove(original); + collection.AddScoped(provider => new RacingContentTypes( + (IContentTypeDirectory)ActivatorUtilities.CreateInstance(provider, original.ImplementationType!), + provider.GetRequiredService(), + shared)); + }); + await TestServices.MigrateAsync(services); + return new Scenario(database, services, shared); + } + + public async Task CreateAsync(string slug) + { + await using var scope = _services.CreateAsyncScope(); + var tool = new SchemaTools().Tools.Single(candidate => candidate.Name == "create_content_type"); + var arguments = JsonSerializer.SerializeToElement(new Dictionary + { + ["name"] = slug, + ["slug"] = slug, + ["definition"] = new { fields = new[] { new { name = "title", type = "text" } } }, + ["session_id"] = Session, + ["rationale"] = "Model the catalog.", + }); + + var result = await tool.InvokeAsync(new ToolInvocation(arguments, scope.ServiceProvider)); + + Assert.True(result.IsSuccess, string.Join("; ", result.Errors.Select(error => error.Message))); + return result.Value!.AsObject(); + } + + public async Task SessionSetAsync() + { + await using var scope = _services.CreateAsyncScope(); + return (await scope.ServiceProvider.GetRequiredService().GetSessionSetAsync(Session)).Value; + } + + public async ValueTask DisposeAsync() + { + await _services.DisposeAsync(); + await _database.DisposeAsync(); + } + } + + // The directory as the product registers it, with another call let in after the first write of a test commits. + private sealed class RacingContentTypes(IContentTypeDirectory inner, IServiceScopeFactory scopes, RacingContentTypes.Shared shared) : IContentTypeDirectory + { + public Task> ListAsync(CancellationToken cancellationToken = default) => inner.ListAsync(cancellationToken); + + public Task> GetAsync(string slug, CancellationToken cancellationToken = default) => inner.GetAsync(slug, cancellationToken); + + public Task> GetPublishedAsync(string slug, CancellationToken cancellationToken = default) => inner.GetPublishedAsync(slug, cancellationToken); + + public Task> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default) => + inner.GetVersionAsync(slug, version, cancellationToken); + + public Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => + inner.CreateAsync(contentType, changeSet, cancellationToken); + + public Task> ReviseAsync(string slug, int baseVersion, ContentTypeDefinition definition, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => + inner.ReviseAsync(slug, baseVersion, definition, changeSet, cancellationToken); + + public async Task> CreateInChangeSetAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) + { + var written = await inner.CreateInChangeSetAsync(contentType, changeSet, cancellationToken); + if (written.IsSuccess && shared.Joined.Count == 0) + { + shared.Joined.Add(written.Value.ChangeSetId); + await using var scope = scopes.CreateAsyncScope(); + await shared.Meanwhile(scope.ServiceProvider, written.Value.ChangeSetId); + } + + return written; + } + + public Task> ReviseInChangeSetAsync( + string slug, + int baseVersion, + ContentTypeDefinition definition, + ChangeSetTarget changeSet, + CancellationToken cancellationToken = default) => + inner.ReviseInChangeSetAsync(slug, baseVersion, definition, changeSet, cancellationToken); + + public sealed class Shared(Func meanwhile) + { + public Func Meanwhile { get; } = meanwhile; + + public List Joined { get; } = []; + } + } +} From 26d48a1851f8c240e1ded9b1ded4f7428931ad2f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 25 Oct 2026 21:00:45 -0400 Subject: [PATCH 2/3] Give the admin test fakes the change set writes --- tests/FluentCMS.Tests/Admin/MediaViewsTests.cs | 4 ++++ tests/FluentCMS.Tests/Admin/PickerFieldTests.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs index f4a8e16dd..4be6422d7 100644 --- a/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs +++ b/tests/FluentCMS.Tests/Admin/MediaViewsTests.cs @@ -246,6 +246,10 @@ public Task> GetAsync(string slug, CancellationToken cancell public Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> ReviseAsync(string slug, int baseVersion, ContentTypeDefinition definition, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CreateInChangeSetAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ReviseInChangeSetAsync(string slug, int baseVersion, ContentTypeDefinition definition, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FakeUsage(FakeLibrary library) : IWhereUsed diff --git a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs index 897067187..0380ee367 100644 --- a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs +++ b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs @@ -191,6 +191,10 @@ public Task> GetAsync(string slug, CancellationToken cancell public Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> ReviseAsync(string slug, int baseVersion, ContentTypeDefinition definition, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CreateInChangeSetAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ReviseInChangeSetAsync(string slug, int baseVersion, ContentTypeDefinition definition, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FakeEntries : IEntryDirectory From 9103830e4b85e8f96a3c0981877eaee9f5d262f5 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 26 Oct 2026 08:46:48 -0400 Subject: [PATCH 3/3] Race a revision against a submit as well --- .../Mcp/SchemaWriteAnswerTests.cs | 84 +++++++++++++++---- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs index aeeddef8b..d3fd7849e 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/SchemaWriteAnswerTests.cs @@ -56,6 +56,27 @@ public async Task A_write_whose_session_opened_its_next_set_meanwhile_answers_wi Assert.NotEqual(joined, (await scenario.SessionSetAsync()).Id); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_whose_set_is_submitted_before_the_answer_answers_with_that_set_in_its_new_state(string provider) + { + await using var scenario = await Scenario.CreateAsync(provider, async (services, joined) => + Assert.True((await services.GetRequiredService().TransitionAsync(joined, ChangeSetState.ChecksRunning)).IsSuccess)); + scenario.Racing = false; + var created = await scenario.CreateAsync("product"); + scenario.Racing = true; + + var answer = await scenario.ReviseAsync("product", 1); + + var joined = Assert.Single(scenario.Joined); + Assert.Equal((string?)created["changeSet"]!["id"], joined.ToString()); + Assert.Equal(joined.ToString(), (string?)answer["changeSet"]!["id"]); + Assert.Equal("checks_running", (string?)answer["changeSet"]!["state"]); + Assert.Equal(2, (int?)answer["contentType"]!["version"]); + Assert.Contains("submitted since", (string?)answer["next"], StringComparison.Ordinal); + } + private sealed class Scenario : IAsyncDisposable { private readonly TemporaryDatabase _database; @@ -71,6 +92,13 @@ private Scenario(TemporaryDatabase database, ServiceProvider services, RacingCon public IReadOnlyList Joined => _shared.Joined; + // Whether the next write lets the other call in. + public bool Racing + { + get => _shared.Racing; + set => _shared.Racing = value; + } + // What another call does right after the tool's write commits and before the tool answers, in a scope of its own. public static async Task CreateAsync(string provider, Func meanwhile) { @@ -92,11 +120,9 @@ public static async Task CreateAsync(string provider, Func CreateAsync(string slug) + public Task CreateAsync(string slug) { - await using var scope = _services.CreateAsyncScope(); - var tool = new SchemaTools().Tools.Single(candidate => candidate.Name == "create_content_type"); - var arguments = JsonSerializer.SerializeToElement(new Dictionary + return CallAsync("create_content_type", new Dictionary { ["name"] = slug, ["slug"] = slug, @@ -104,8 +130,26 @@ public async Task CreateAsync(string slug) ["session_id"] = Session, ["rationale"] = "Model the catalog.", }); + } - var result = await tool.InvokeAsync(new ToolInvocation(arguments, scope.ServiceProvider)); + public Task ReviseAsync(string slug, int baseVersion) + { + return CallAsync("revise_content_type", new Dictionary + { + ["slug"] = slug, + ["base_version"] = baseVersion, + ["definition"] = new { fields = new[] { new { name = "title", type = "text" }, new { name = "sku", type = "text" } } }, + ["session_id"] = Session, + ["rationale"] = "Give products a SKU.", + }); + } + + private async Task CallAsync(string name, Dictionary arguments) + { + await using var scope = _services.CreateAsyncScope(); + var tool = new SchemaTools().Tools.Single(candidate => candidate.Name == name); + + var result = await tool.InvokeAsync(new ToolInvocation(JsonSerializer.SerializeToElement(arguments), scope.ServiceProvider)); Assert.True(result.IsSuccess, string.Join("; ", result.Errors.Select(error => error.Message))); return result.Value!.AsObject(); @@ -124,7 +168,7 @@ public async ValueTask DisposeAsync() } } - // The directory as the product registers it, with another call let in after the first write of a test commits. + // The directory as the product registers it, with another call let in after the first racing write of a test commits. private sealed class RacingContentTypes(IContentTypeDirectory inner, IServiceScopeFactory scopes, RacingContentTypes.Shared shared) : IContentTypeDirectory { public Task> ListAsync(CancellationToken cancellationToken = default) => inner.ListAsync(cancellationToken); @@ -144,8 +188,22 @@ public Task> ReviseAsync(string slug, int baseVersion, Conte public async Task> CreateInChangeSetAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) { - var written = await inner.CreateInChangeSetAsync(contentType, changeSet, cancellationToken); - if (written.IsSuccess && shared.Joined.Count == 0) + return await RaceAsync(await inner.CreateInChangeSetAsync(contentType, changeSet, cancellationToken)); + } + + public async Task> ReviseInChangeSetAsync( + string slug, + int baseVersion, + ContentTypeDefinition definition, + ChangeSetTarget changeSet, + CancellationToken cancellationToken = default) + { + return await RaceAsync(await inner.ReviseInChangeSetAsync(slug, baseVersion, definition, changeSet, cancellationToken)); + } + + private async Task> RaceAsync(Result written) + { + if (written.IsSuccess && shared.Racing && shared.Joined.Count == 0) { shared.Joined.Add(written.Value.ChangeSetId); await using var scope = scopes.CreateAsyncScope(); @@ -155,18 +213,12 @@ public async Task> CreateInChangeSetAsync(NewContentTyp return written; } - public Task> ReviseInChangeSetAsync( - string slug, - int baseVersion, - ContentTypeDefinition definition, - ChangeSetTarget changeSet, - CancellationToken cancellationToken = default) => - inner.ReviseInChangeSetAsync(slug, baseVersion, definition, changeSet, cancellationToken); - public sealed class Shared(Func meanwhile) { public Func Meanwhile { get; } = meanwhile; + public bool Racing { get; set; } = true; + public List Joined { get; } = []; } }